README.md

# Spectral

An Elixir wrapper for the Erlang [spectra](https://github.com/andreashasse/spectra) library. Spectral provides type-safe data serialization and deserialization for all Elixir types that can be converted to those types. Currently the focus is on JSON.

- **Type-safe conversion**: Convert typed Elixir values to/from external formats such as JSON, ensuring data conforms to the type specification
- **Detailed errors**: Get error messages with location information when validation fails
- **Support for complex scenarios**: Handles unions, structs, atoms, nested structures, and more

## Installation

Add `spectral` to your list of dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:spectral, "~> 0.1.0"}
  ]
end
```
### Basic Usage

Here's how to use Spectral for JSON serialization and deserialization:

```elixir
defmodule Person do
  defmodule Address do
    defstruct [:street, :city]

    @type t :: %Address{
            street: String.t(),
            city: String.t()
          }
  end

  defstruct [:name, :age, :address]

  @type t :: %Person{
          name: String.t(),
          age: non_neg_integer() | nil,
          address: Address.t() | nil
        }
end

# Encode a struct to JSON
person = %Person{
  name: "Alice",
  age: 30,
  address: %Person.Address{
    street: "Ystader Straße",
    city: "Berlin"
  }
}

{:ok, json_iodata} = Spectral.encode(:json, Person, :t, person)
json = IO.iodata_to_binary(json_iodata)
# => "{\"address\":{\"city\":\"Berlin\",
#      \"street\":\"Ystader Straße\"},
#      \"age\":30,\"name\":\"Alice\"}"

# Decode JSON to a struct
json_string = ~s(
  {"name":"Alice","age":30,
   "address":{"street":"Ystader Straße",
              "city":"Berlin"}}
)

{:ok, decoded_person} =
  Spectral.decode(:json, Person, :t, json_string)
# => {:ok,
#     %Person{
#       name: "Alice",
#       age: 30,
#       address: %Person.Address{
#         street: "Ystader Straße",
#         city: "Berlin"
#       }
#     }}

# Generate a JSON schema
{:ok, schema_iodata} = Spectral.schema(:json_schema, Person, :t)
schema = IO.iodata_to_binary(schema_iodata)
```

### Nil Value Handling

Spectral automatically omits `nil` values from JSON output for optional struct fields:

```elixir
# Only required fields
person = %Person{name: "Alice"}
{:ok, json_iodata} = Spectral.encode(:json, Person, :t, person)
IO.iodata_to_binary(json_iodata)
# => "{\"name\":\"Alice\"}"  (age and address are omitted)

# When decoding, missing fields become nil in structs and records
{:ok, decoded} =
  Spectral.decode(:json, Person, :t, ~s({"name":"Alice"}))
# => {:ok,
#     %Person{name: "Alice", age: nil, address: nil}}
```

### Data Serialization API

The main functions for JSON serialization and deserialization:

```elixir
Spectral.encode(format, module, type_ref, value) ::
    {:ok, iodata()} | {:error, [error()]}

Spectral.decode(format, module, type_ref, data) ::
    {:ok, value} | {:error, [error()]}
```

**Parameters:**
- `format` - The data format: `:json`, `:binary_string`, or `:string`
- `module` - The module where the type is defined (e.g., `Person`)
- `type_ref` - The type reference, typically an atom like `:t` for the `@type t` definition
- `value` / `data` - The Elixir value to encode or the binary data to decode

### Schema API

Generate schemas from your type definitions:

```elixir
Spectral.schema(format, module, type_ref) ::
    {:ok, iodata()} | {:error, [error()]}
```

**Parameters:**
- `format` - Currently supports `:json_schema`
- `module` - The module where the type is defined
- `type_ref` - The type reference

## OpenAPI Specification

Spectral can generate complete [OpenAPI 3.0](https://spec.openapis.org/oas/v3.0.0) specifications for your REST APIs. This provides interactive documentation, client generation, and API testing tools.

### OpenAPI Builder API

The API uses a fluent builder pattern for constructing endpoints and responses. While experimental and subject to change, it's designed to be used by web framework developers.

#### Building Responses

Responses are constructed using a builder pattern:

```elixir
Code.ensure_loaded!(Person)

# Simple response
user_not_found_response =
  Spectral.OpenAPI.response(404, "User not found")

# Response with body
user_found_response =
  Spectral.OpenAPI.response(200, "User found")
  |> Spectral.OpenAPI.response_with_body(Person, :t)

user_created_response =
  Spectral.OpenAPI.response(201, "User created")
  |> Spectral.OpenAPI.response_with_body(
    Person,
    {:type, :t, 0}
  )

users_found_response =
  Spectral.OpenAPI.response(200, "Users found")
  |> Spectral.OpenAPI.response_with_body(
    Person,
    {:type, :persons, 0}
  )

# Response with response header
response_with_headers =
  Spectral.OpenAPI.response(200, "Success")
  |> Spectral.OpenAPI.response_with_body(Person, :t)
  |> Spectral.OpenAPI.response_with_header(
    "X-Rate-Limit",
    :t,
    %{
      description: "Requests remaining",
      required: false,
      schema: :integer
    }
  )
```

#### Building Endpoints

Endpoints are built by combining the endpoint definition with responses, request bodies, and parameters:
Responses are taken from the previous section.

```elixir
user_get_endpoint =
  Spectral.OpenAPI.endpoint(:get, "/users/{id}")
  |> Spectral.OpenAPI.with_parameter(Person, %{
    name: "id",
    in: :path,
    required: true,
    schema: :string
  })
  |> Spectral.OpenAPI.add_response(user_found_response)
  |> Spectral.OpenAPI.add_response(user_not_found_response)


# Add request body (for POST, PUT, PATCH)
user_create_endpoint =
  Spectral.OpenAPI.endpoint(:post, "/users")
  |> Spectral.OpenAPI.with_request_body(
    Person,
    {:type, :t, 0}
  )
  |> Spectral.OpenAPI.add_response(user_created_response)

# Add parameters
user_search_endpoint =
  Spectral.OpenAPI.endpoint(:get, "/users")
  |> Spectral.OpenAPI.with_parameter(Person, %{
    name: "search",
    in: :query,
    required: false,
    schema: :search
  })
  |> Spectral.OpenAPI.add_response(users_found_response)
```

#### Generating the OpenAPI Specification

Combine all endpoints into a complete OpenAPI spec:

```elixir
metadata = %{
  title: "My API",
  version: "1.0.0"
}


endpoints = [
  #user_get_endpoint,
  user_create_endpoint,
  #user_search_endpoint
]

{:ok, openapi_spec} =
  Spectral.OpenAPI.endpoints_to_openapi(metadata, endpoints)

IO.inspect(openapi_spec, pretty: true)
```

## Requirements

- **Compilation**: Modules must be compiled with `debug_info` for Spectral to extract type information. This is enabled by default in Mix projects.

## Error Handling

Spectral uses Elixir-style error tuples for validation errors:

### Validation Errors

Data validation errors are returned as `{:error, [error]}` tuples. These occur when input data doesn't match the expected type during encoding/decoding.

```elixir
bad_json = ~s({"name":"Alice","age":"not a number"})
{:error, errors} = Spectral.decode(:json, Person, :t, bad_json)
# Returns a list of error structures with location and type information
```

Error structures contain:
- `location` - Path showing where the error occurred
- `type` - Error type: `:type_mismatch`, `:no_match`, `:missing_data`, etc.
- `ctx` - Context information about the error

### Configuration Errors

Configuration and structural errors raise exceptions. These occur when:
- Module not found or not loaded
- Type not found in module
- Unsupported type used (e.g., `pid()`, `port()`, `tuple()`)

These errors indicate a problem with your application's configuration or type definitions, not with the data being processed.

## Special Handling

### `nil` Values

In Elixir structs, `nil` values are handled specially:
- When encoding to JSON, struct fields with `nil` values are omitted from the output if the type includes `nil` as a valid value
- When decoding from JSON, missing fields become `nil` if the type specification allows it

Example:
```elixir
@type t :: %Person{
  name: String.t(),
  age: non_neg_integer() | nil  # nil is allowed
}
```

### `term()` and `any()`

When using types with `term()` or `any()`, Spectral will not reject any data, which means it can return data that may not be valid JSON.

### Unsupported Types

For JSON serialization and schema generation, the following Erlang/Elixir types are not supported:
- `pid()`, `port()`, `reference()` - Cannot be serialized to JSON
- `tuple()` (generic tuples without specific structure)
- Function types - Cannot be serialized

## Related Projects

- **[spectra](https://github.com/andreashasse/spectra)** - The underlying Erlang library that powers Spectral

## Development Status

This library is under active development. APIs may change in future versions.

## Contributing

Contributions are welcome! Please feel free to submit issues and pull requests.

## License

See LICENSE.md for details.