From ac4729a4fc880ac4caa04889940c5f0fb66f004f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 15:21:37 +0000 Subject: [PATCH 1/3] Update spectra to 0.14.0: nested doc annotations spectra 0.14.0 fixes doc annotations being dropped when a type is inlined into another schema. title, description, deprecated, examples and examples_function set with the spectral/1 macro now reach 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 schema generation was entered with kept them. No wrapper API change was needed. Added: - Tests covering each inlined position, alias merging (nearest use site wins, other keys kept from both), and example validation at an inlined position. - A readable ArgumentError for {invalid_example, ...} in place of the raw ErlangError. 0.14.0 validates examples at every position a type appears in, not just at entry points, so this error is far more reachable. - README section on annotation propagation, merging, the three positions that do not carry an annotation, and the cost of examples_function being called once per position. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XTMXez4gy2fHp6HgovtfGG --- CHANGELOG.md | 10 +++ README.md | 34 ++++++++- lib/spectral.ex | 11 +++ mix.exs | 4 +- mix.lock | 2 +- test/spectral_nested_doc_test.exs | 74 +++++++++++++++++++ test/support/nested_doc_bad_example_module.ex | 11 +++ test/support/nested_doc_module.ex | 38 ++++++++++ 8 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 test/spectral_nested_doc_test.exs create mode 100644 test/support/nested_doc_bad_example_module.ex create mode 100644 test/support/nested_doc_module.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index f886566..6bd6d29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.14.0] - 2026-09-11 + +### Changed +- Upgraded spectra dependency to `~> 0.14.0`. 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. + ## [0.13.0] - 2026-05-07 ### Added diff --git a/README.md b/README.md index fa6b2ff..a17da94 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -508,6 +508,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 diff --git a/lib/spectral.ex b/lib/spectral.ex index b8498f3..3a0a9dc 100644 --- a/lib/spectral.ex +++ b/lib/spectral.ex @@ -691,9 +691,20 @@ 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 -> # Re-raise the original ErlangError if it's not a known configuration error raise error end 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 diff --git a/mix.exs b/mix.exs index 1cfcab1..c225d84 100644 --- a/mix.exs +++ b/mix.exs @@ -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(), @@ -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 diff --git a/mix.lock b/mix.lock index 85513b5..1e48061 100644 --- a/mix.lock +++ b/mix.lock @@ -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.0", "fc9ae12dc583e86b9980af6498261b1ddd4b0c057512f0262fff9be2cf95cf8e", [:rebar3], [], "hexpm", "6dfb5043bf22d0392ab8b29c94f27802b6208623978a7bce687a1947724994c7"}, "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, } diff --git a/test/spectral_nested_doc_test.exs b/test/spectral_nested_doc_test.exs new file mode 100644 index 0000000..9218750 --- /dev/null +++ b/test/spectral_nested_doc_test.exs @@ -0,0 +1,74 @@ +defmodule SpectralNestedDocTest do + # Doc annotations (title, description, deprecated, examples) survive inlining + # into another schema (spectra 0.14.0). Before 0.14.0 only the type schema + # generation was entered with kept them. + 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 + 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 +end diff --git a/test/support/nested_doc_bad_example_module.ex b/test/support/nested_doc_bad_example_module.ex new file mode 100644 index 0000000..51fbd82 --- /dev/null +++ b/test/support/nested_doc_bad_example_module.ex @@ -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 diff --git a/test/support/nested_doc_module.ex b/test/support/nested_doc_module.ex new file mode 100644 index 0000000..d4403b3 --- /dev/null +++ b/test/support/nested_doc_module.ex @@ -0,0 +1,38 @@ +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()} +end From a8d1828d45ed2fadc3531e233d97c610d85af378 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 06:28:58 +0000 Subject: [PATCH 2/3] Address Copilot review: examples_function and OpenAPI coverage - Cover examples_function at inlined positions. 0.14.0 changes its call count from once per schema to once per inline position, which the tests asserted nothing about. The fixture now counts its own invocations, and a type inlining it twice asserts two calls and the examples on both properties. - Cover the OpenAPI rendering path. The existing tests only exercised JSON Schema, so a regression in spectra_openapi would have passed. The new test builds an endpoint, follows the response body $ref into components.schemas, and asserts the nested titles and descriptions. - Reword the ungrammatical sentence in the test module comment. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XTMXez4gy2fHp6HgovtfGG --- test/spectral_nested_doc_test.exs | 41 +++++++++++++++++++++++++++++-- test/support/nested_doc_module.ex | 14 +++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/test/spectral_nested_doc_test.exs b/test/spectral_nested_doc_test.exs index 9218750..9ee475d 100644 --- a/test/spectral_nested_doc_test.exs +++ b/test/spectral_nested_doc_test.exs @@ -1,7 +1,7 @@ defmodule SpectralNestedDocTest do # Doc annotations (title, description, deprecated, examples) survive inlining - # into another schema (spectra 0.14.0). Before 0.14.0 only the type schema - # generation was entered with kept them. + # 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 @@ -71,4 +71,41 @@ defmodule SpectralNestedDocTest do 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 diff --git a/test/support/nested_doc_module.ex b/test/support/nested_doc_module.ex index d4403b3..ae0941e 100644 --- a/test/support/nested_doc_module.ex +++ b/test/support/nested_doc_module.ex @@ -35,4 +35,18 @@ defmodule NestedDocModule do @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 From 941427e5e7a05756bbfb17970bfd9f798596694a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 10:31:55 +0000 Subject: [PATCH 3/3] Update 0.14.0 release date to today Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XTMXez4gy2fHp6HgovtfGG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc0e46e..8a2a53f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.14.0] - 2026-09-11 +## [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.