Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.14.0] - 2026-09-14

### Changed
- Upgraded spectra dependency to `~> 0.14.0` (now resolving to `0.14.1`). Doc annotations (`title`, `description`, `deprecated`, `examples`, `examples_function`) set with the `spectral/1` macro now propagate into every schema the type is inlined into — struct and map field values, list and non-empty list elements, union branches, optional map values, and remote types from other modules. Previously only the type that schema generation was entered with kept its annotations, so `deprecated: true` on a type used as a struct field produced nothing in the output. Generated JSON Schema and OpenAPI output changes accordingly for annotated sub-schemas.
- Where a type alias and the type it resolves to set the same key, the annotation nearest the use site wins; keys only one of them sets are kept from both.
- `examples` are now validated at every position the type is inlined into, and `examples_function` is invoked once per position rather than once per schema.

### Fixed
- An `examples` value that does not encode as its own type now raises `ArgumentError` with the offending example and type name instead of a raw `ErlangError`. This error is reachable from many more places now that examples are validated at every inlined position.
- `Spectral.AbstractCode` now handles the Elixir non-empty list shorthand, `[elem_type, ...]` (and bare `[...]`), matching the existing `nonempty_list(elem_type)` support. Previously these types failed to compile with `unsupported type AST`.
- `encode/4-5`, `decode/4-5`, and `schema/3-4` rescue `error in ErlangError` to translate a few known spectra configuration errors into `ArgumentError`, but that rescue clause also binds every other exception Elixir normalizes a raw BEAM error into (e.g. `%BadMapError{}`, `%KeyError{}`), not just literal `%ErlangError{}` structs. Any unrelated crash inside spectra therefore hit `handle_erlang_error/4`'s single `%ErlangError{}` clause and failed with a misleading `FunctionClauseError` pointing at Spectral itself, discarding the original exception and stacktrace. Such crashes now reraise unchanged, with their original stacktrace intact.
- Encoding a struct with data that isn't a map (a string, integer, list, or atom) crashed with a raw `badmap` error instead of returning `{:error, [%Spectral.Error{}]}` (spectra 0.14.1). Only the struct branch of encoding was affected; plain map types, list types, record types, and decoding were unaffected.
- `Spectral.Codec.encode/5`, `Spectral.Codec.decode/5`, and `Spectral.Codec.schema/4` now accept a `{:type, name, arity}` or `{:record, name}` reference, as their `sp_type_or_ref()` specs always claimed. Previously only a resolved `sp_type()` node worked and a reference failed with a `type_mismatch` naming an unexpected type.
- The `schema/5` callback is declared to return `map() | :continue`. Returning `:continue` for types a codec does not handle was already documented and supported, but the callback spec said `map()`.
- The README's custom codec example used the pre-0.12.0 callback signatures (`encode/7`, `decode/7`, `schema/6` with a separate `params` argument). Copying it produced callbacks that did not match the behaviour. The example and the `type_parameters` section now show the current `encode/6`, `decode/6`, and `schema/5`, and read `type_parameters` via `:spectra_type.parameters/1`.
Expand Down
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Add `spectral` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:spectral, "~> 0.13.0"}
{:spectral, "~> 0.14.0"}
]
end
```
Expand Down Expand Up @@ -613,6 +613,38 @@ schema = Spectral.schema(Person, :t) |> IO.iodata_to_binary() |> Jason.decode!()
# %{"title" => "Person", "description" => "A person with name and age", "type" => "object", ...}
```

**Annotations follow the type wherever it is used.** A type annotated with `title`,
`description`, `deprecated`, `examples` or `examples_function` carries that metadata into
every schema it is inlined into — struct and map field values, list and non-empty list
elements, union branches, optional map values, and types referenced from another module:

```elixir
defmodule Payment do
use Spectral

spectral title: "Payer", deprecated: true
@type payer :: String.t()

@type request :: %{payer: payer(), amount: non_neg_integer()}
end

Spectral.schema(Payment, :request) |> IO.iodata_to_binary() |> Jason.decode!()
# properties.payer is %{"type" => "string", "title" => "Payer", "deprecated" => true}
```

When a type alias and the type it resolves to set the same key, the annotation nearest the
use site wins; keys only one of them sets are kept from both.

Three positions do not carry the annotation: a union whose members all resolve to literals
(it collapses into a single `enum` schema), a type whose schema comes from a custom codec,
and a parameterized type. An annotation on the union type itself, or on a plain type that
aliases a codec-handled type, is still kept.

Because an annotation reaches every position its type appears in, `examples` are validated
at each of them and an `examples_function` is called once per position — keep such functions
cheap and free of side effects. An example that does not encode as its own type raises
`ArgumentError` from schema generation.

**Multiple types in one module** — only types with a `spectral` call will have title/description in their schemas:

```elixir
Expand Down
11 changes: 11 additions & 0 deletions lib/spectral.ex
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,10 @@ defmodule Spectral do
raise ArgumentError,
"type not supported: #{inspect(type_info)} (#{operation})"

{:invalid_example, type, example, _errors} ->
raise ArgumentError,
"invalid example #{inspect(example)} for #{describe_type(type)} (#{operation})"

_other ->
# Not a known configuration error — re-raise as-is with the original
# stacktrace instead of losing where it happened.
Expand All @@ -711,4 +715,11 @@ defmodule Spectral do
defp handle_erlang_error(error, stacktrace, _operation, _module, _type_ref) do
reraise error, stacktrace
end

defp describe_type(type) do
case :spectra_type.get_meta(type) do
%{name: {:type, name, arity}} -> "type #{name}/#{arity}"
_meta -> "type #{inspect(type)}"
end
end
end
4 changes: 2 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule Spectral.MixProject do
def project do
[
app: :spectral,
version: "0.13.0",
version: "0.14.0",
elixir: "~> 1.17",
start_permanent: Mix.env() == :prod,
description: description(),
Expand All @@ -31,7 +31,7 @@ defmodule Spectral.MixProject do

defp deps do
[
{:spectra, "~> 0.13.1"},
{:spectra, "~> 0.14.0"},
{:stream_data, "~> 1.1", only: :test},
{:cover_diff, "~> 0.1.0", only: :test, runtime: false},
# Code quality tools
Expand Down
2 changes: 1 addition & 1 deletion mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"spectra": {:hex, :spectra, "0.13.1", "b250f046c0ebb3d41b30b97e7ef47b4838fdf702f319c90f854f84d4995f11e5", [:rebar3], [], "hexpm", "b964883070df0192cf0811f15b49aec21951451a964d386ae6b19dd6cee4488d"},
"spectra": {:hex, :spectra, "0.14.1", "2845541bc0bfe1043a104485dd9466f1dbdd533adad0ee2161a8cd5f91108c30", [:rebar3], [], "hexpm", "2b47fb73194e7bd89197b9fe32d10d4a50466df1c314eabe007f2e7dd8473a1a"},
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
}
111 changes: 111 additions & 0 deletions test/spectral_nested_doc_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
defmodule SpectralNestedDocTest do
# Doc annotations (title, description, deprecated, examples) survive inlining
# into another schema (spectra 0.14.0). Before 0.14.0 the annotations were
# kept only on the type that schema generation was entered with.
use ExUnit.Case, async: true

defp schema(module, type) do
module |> Spectral.schema(type) |> IO.iodata_to_binary() |> Jason.decode!()
end

defp properties(module, type), do: schema(module, type)["properties"]

describe "annotations on inlined types" do
test "a remote annotated type keeps its metadata as a map field value" do
assert %{
"type" => "string",
"title" => "Payer",
"description" => "Account charged",
"deprecated" => true
} = properties(NestedDocModule, :request)["payer"]
end

test "a local annotated type keeps title, description and examples as a map field value" do
assert %{
"type" => "integer",
"title" => "Amount",
"description" => "Amount in cents",
"examples" => [1500]
} = properties(NestedDocModule, :request)["amount"]
end

test "list and non-empty list elements keep their metadata" do
props = properties(NestedDocModule, :request)

assert %{"title" => "Tag", "description" => "A short tag"} = props["tags"]["items"]
assert %{"title" => "Tag", "description" => "A short tag"} = props["more_tags"]["items"]
assert props["more_tags"]["minItems"] == 1
Comment thread
andreashasse marked this conversation as resolved.
end

test "a union branch keeps its metadata" do
assert %{"anyOf" => branches} = properties(NestedDocModule, :request)["note"]

assert %{"type" => "string", "title" => "Tag", "description" => "A short tag"} in branches
assert %{"type" => "integer"} in branches
end

test "an optional map value keeps its metadata" do
assert %{"title" => "Tag", "description" => "A short tag"} =
properties(NestedDocModule, :optional_map)["tag"]
end

test "struct fields keep their metadata" do
props = properties(NestedDocModule, :t)

assert %{"title" => "Amount", "examples" => [1500]} = props["amount"]
assert %{"title" => "Tag"} = props["tag"]
end
end

describe "annotation merging through an alias" do
test "the annotation nearest the use site wins and other keys are kept from both" do
assert %{"title" => "Label", "description" => "A short tag"} =
properties(NestedDocModule, :labelled)["label"]
end
end

describe "example validation at inlined positions" do
test "an example that does not encode as its own type is rejected when inlined" do
assert_raise ArgumentError,
~s{invalid example "not an integer" for type count/0 (schema)},
fn -> Spectral.schema(NestedDocBadExampleModule, :wrapper) end
end
end

describe "examples_function at inlined positions" do
test "the function is called once per position the type is inlined into" do
before = NestedDocModule.counted_examples_calls()
props = properties(NestedDocModule, :two_counted)

assert NestedDocModule.counted_examples_calls() - before == 2
assert %{"title" => "Counted", "examples" => [7]} = props["first"]
assert %{"title" => "Counted", "examples" => [7]} = props["second"]
end
end

describe "annotations in OpenAPI output" do
test "a response body schema carries the annotations of its nested types" do
endpoint =
Spectral.OpenAPI.endpoint(:get, "/payments")
|> Spectral.OpenAPI.add_response(
Spectral.OpenAPI.response(200, "OK")
|> Spectral.OpenAPI.response_with_body(NestedDocModule, {:type, :t, 0})
)

{:ok, json} =
Spectral.OpenAPI.endpoints_to_openapi(%{title: "API", version: "1.0"}, [endpoint])

spec = json |> IO.iodata_to_binary() |> Jason.decode!()

assert %{"$ref" => "#/components/schemas/NestedDocModule"} =
spec["paths"]["/payments"]["get"]["responses"]["200"]["content"][
"application/json"
]["schema"]

props = spec["components"]["schemas"]["NestedDocModule"]["properties"]

assert %{"title" => "Amount", "description" => "Amount in cents"} = props["amount"]
assert %{"title" => "Tag", "description" => "A short tag"} = props["tag"]
end
end
end
22 changes: 22 additions & 0 deletions test/spectral_struct_encode_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
defmodule SpectralStructEncodeTest do
use ExUnit.Case, async: true

@moduledoc """
Encoding a struct type with data that isn't a map (a string, integer, list,
or atom) crashed with a raw `badmap` error instead of returning
`{:error, [%Spectral.Error{}]}`, because the struct branch of spectra's
encoder was missing the `is_map/1` guard the plain map, list, and record
branches already had. Fixed in spectra 0.14.1.
"""

test "encoding non-map data against a struct type returns a type_mismatch error" do
assert {:error, [%Spectral.Error{type: :type_mismatch}]} =
Spectral.encode("not a map", Person, {:type, :t, 0})
end

test "encoding! raises Spectral.Error rather than crashing" do
assert_raise Spectral.Error, fn ->
Spectral.encode!("not a map", Person, {:type, :t, 0})
end
end
end
11 changes: 11 additions & 0 deletions test/support/nested_doc_bad_example_module.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
defmodule NestedDocBadExampleModule do
@moduledoc false
# An example that does not encode as its own type is rejected at every
# position the type is inlined into (spectra 0.14.0)
use Spectral

spectral(title: "Count", examples: ["not an integer"])
@type count :: non_neg_integer()

@type wrapper :: %{count: count()}
end
52 changes: 52 additions & 0 deletions test/support/nested_doc_module.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
defmodule NestedDocModule do
@moduledoc false
# Doc annotations propagate into inlined sub-schemas (spectra 0.14.0)
use Spectral

defmodule Remote do
@moduledoc false
use Spectral

spectral(title: "Payer", description: "Account charged", deprecated: true)
@type payer :: String.t()
end

spectral(title: "Amount", description: "Amount in cents", examples: [1500])
@type amount :: non_neg_integer()

spectral(title: "Tag", description: "A short tag")
@type tag :: String.t()

@type request :: %{
payer: Remote.payer(),
amount: amount(),
tags: [tag()],
more_tags: nonempty_list(tag()),
note: tag() | integer()
}

@type optional_map :: %{optional(:tag) => tag()}

defstruct [:amount, :tag]

@type t :: %NestedDocModule{amount: amount(), tag: tag()}

spectral(title: "Label")
@type label :: tag()

@type labelled :: %{label: label()}

spectral(title: "Counted", examples_function: {__MODULE__, :counted_examples, []})
@type counted :: non_neg_integer()

@type two_counted :: %{first: counted(), second: counted()}

@doc false
def counted_examples do
Process.put(:counted_examples_calls, counted_examples_calls() + 1)
[7]
end

@doc false
def counted_examples_calls, do: Process.get(:counted_examples_calls, 0)
end
Loading