diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c4b06d..8a2a53f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/README.md b/README.md index c708540..99f462d 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 ``` @@ -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 diff --git a/lib/spectral.ex b/lib/spectral.ex index f243c9b..de56ed5 100644 --- a/lib/spectral.ex +++ b/lib/spectral.ex @@ -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. @@ -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 diff --git a/mix.exs b/mix.exs index 16ab742..6d75e9c 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..b73e58b 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.1", "2845541bc0bfe1043a104485dd9466f1dbdd533adad0ee2161a8cd5f91108c30", [:rebar3], [], "hexpm", "2b47fb73194e7bd89197b9fe32d10d4a50466df1c314eabe007f2e7dd8473a1a"}, "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..9ee475d --- /dev/null +++ b/test/spectral_nested_doc_test.exs @@ -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 + 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 diff --git a/test/spectral_struct_encode_test.exs b/test/spectral_struct_encode_test.exs new file mode 100644 index 0000000..14e364c --- /dev/null +++ b/test/spectral_struct_encode_test.exs @@ -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 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..ae0941e --- /dev/null +++ b/test/support/nested_doc_module.ex @@ -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