From db2b3f4106f013d7755d487f776c69f0f2946968 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 13:40:11 +0000 Subject: [PATCH 1/5] Keep doc annotations when a type is inlined into a schema `-spectra()` doc annotations (title, description, deprecated, examples, examples_function) only reached the generated schema for the type that schema generation was entered with. Every type resolved while inlining lost them silently, so `deprecated => true` on a type used as a map field produced nothing at all in the output. Only to_schema_for_sp_type/3 merges a type's doc into its schema, and the recursive descents in do_to_schema/3 bypassed it. Route them all through to_schema_for_sp_type/3: user type and remote type resolution, map field values (exact and optional), record fields, union branches and anyOf members, and list and non-empty list elements. Docs merge along the resolution chain, so on a conflicting key the annotation written nearest the use site wins. That matches how an alias of a documented record already behaved (record_ref_with_own_doc_test); keys only one of them sets are kept from both. type_parameters were unaffected by the bug and keep applying alongside a doc on the same type. This changes generated JSON Schema and OpenAPI output for annotated sub-schemas, so it belongs in a minor release. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017Nffy1QUKxifokPEBLX3Qc --- CHANGELOG.md | 3 + README.md | 18 ++ src/spectra_json_schema.erl | 22 ++- test/inline_doc_remote_helper.erl | 14 ++ test/spectra_json_schema_inline_doc_test.erl | 196 +++++++++++++++++++ 5 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 test/inline_doc_remote_helper.erl create mode 100644 test/spectra_json_schema_inline_doc_test.erl diff --git a/CHANGELOG.md b/CHANGELOG.md index a1900bf..8de3472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- `-spectra()` doc annotations (`title`, `description`, `deprecated`, `examples`, `examples_function`) are no longer dropped when a type is inlined into another schema. Only the type that schema generation was entered with kept its annotations; every type resolved while inlining — map field values, record fields, union branches, list and non-empty list elements, optional map values, and remote types from other modules — silently lost them, so e.g. `deprecated => true` on a type used as a map field produced nothing in the output. Every recursive descent now merges the resolved type's doc into the schema it generates. `type_parameters` were unaffected and keep working alongside a doc annotation on the same type. Where a type alias and the type it resolves to set the same key, the annotation nearest the use site wins, matching how an alias of a documented record already behaved. This changes generated JSON Schema and OpenAPI output for anyone using annotated sub-schemas: nested properties now carry the titles, descriptions, deprecation flags and examples their types declare. + ## [0.13.4] - 2026-06-14 ### Fixed diff --git a/README.md b/README.md index 7215a50..9162f4f 100644 --- a/README.md +++ b/README.md @@ -458,6 +458,24 @@ person_examples() -> The function specified in `examples_function` must be exported. +Annotations follow the type wherever it is used. A type annotated with `title`, +`description`, `deprecated` or `examples` carries that metadata into every +schema it is inlined into — map field values, record fields, union branches, +list elements and remote types — not only when it is the type schema +generation starts from: + +```erlang +-spectra(#{title => <<"Payer">>, deprecated => true}). +-type payer() :: binary(). + +%% properties.payer in this schema gets both the title and deprecated => true +-type request() :: #{payer := payer()}. +``` + +When a type alias and the type it resolves to annotate the same key, the +annotation written nearest the use site wins; keys only one of them sets are +kept from both. + ## Field Filtering with `only` The `only` key in the `-spectra()` attribute restricts which fields are included when encoding, decoding, and generating schemas for a map type. It works for plain Erlang maps and Elixir structs alike, similarly to Jason's `only` option. diff --git a/src/spectra_json_schema.erl b/src/spectra_json_schema.erl index a53b6ae..b708f56 100644 --- a/src/spectra_json_schema.erl +++ b/src/spectra_json_schema.erl @@ -59,6 +59,12 @@ to_schema(TypeInfo, Type, Config) -> to_schema_for_sp_type(TypeInfo, Type, Config). +%% Every descent into a type — map field values, record fields, union branches, +%% list elements — and every resolution of a reference must go through here, so +%% that a type's `-spectra()` doc annotation travels with it wherever it is +%% inlined. Calling do_to_schema/3 directly silently drops the annotation. +%% Docs merge along the resolution chain: on conflicting keys the annotation +%% written nearest the use site wins. -spec to_schema_for_sp_type(spectra:type_info(), spectra:sp_type(), spectra:sp_config()) -> json_schema_object(). to_schema_for_sp_type(TypeInfo, Type, Config) -> @@ -87,7 +93,7 @@ do_to_schema( TypeWithoutVars = spectra_abstract_code:apply_ref_meta( TypeWithoutVars0, UserTypeRef#sp_user_type_ref.meta ), - do_to_schema(TypeInfo, TypeWithoutVars, Config); + to_schema_for_sp_type(TypeInfo, TypeWithoutVars, Config); Schema -> Schema end; @@ -107,7 +113,7 @@ do_to_schema( TypeResolved = spectra_abstract_code:apply_ref_meta( TypeResolved0, RemoteRef#sp_remote_type.meta ), - do_to_schema(RemoteTypeInfo, TypeResolved, Config); + to_schema_for_sp_type(RemoteTypeInfo, TypeResolved, Config); Schema -> Schema end; @@ -197,10 +203,10 @@ do_to_schema(_TypeInfo, #sp_literal{} = Type, _Config) -> erlang:error({type_not_supported, Type}); %% List types do_to_schema(TypeInfo, #sp_list{type = ItemType}, Config) -> - ItemSchema = do_to_schema(TypeInfo, ItemType, Config), + ItemSchema = to_schema_for_sp_type(TypeInfo, ItemType, Config), #{type => <<"array">>, items => ItemSchema}; do_to_schema(TypeInfo, #sp_nonempty_list{type = ItemType}, Config) -> - ItemSchema = do_to_schema(TypeInfo, ItemType, Config), + ItemSchema = to_schema_for_sp_type(TypeInfo, ItemType, Config), #{ type => <<"array">>, items => ItemSchema, @@ -220,7 +226,7 @@ do_to_schema(TypeInfo, #sp_union{types = Types}, Config) -> ) of {[_MissingLiteral], [SingleType]} -> - do_to_schema(TypeInfo, SingleType, Config); + to_schema_for_sp_type(TypeInfo, SingleType, Config); {[], NonMissingTypes} -> case try_generate_enum_schema(NonMissingTypes, TypeInfo, Config) of not_all_literals -> @@ -324,7 +330,7 @@ process_map_fields( HasAdditional, Config ) -> - FieldSchema = do_to_schema(TypeInfo, FieldType, Config), + FieldSchema = to_schema_for_sp_type(TypeInfo, FieldType, Config), NewProperties = Properties#{BinaryName => FieldSchema}, %% A field is required only when it is exact (`:=`) and cannot be missing. %% Optional (`=>`) fields and exact fields whose type can be missing @@ -390,7 +396,7 @@ process_record_fields( Required, Config ) -> - FieldSchema = do_to_schema(TypeInfo, FieldType, Config), + FieldSchema = to_schema_for_sp_type(TypeInfo, FieldType, Config), NewProperties = Properties#{BinaryName => FieldSchema}, NewRequired = case spectra_type:can_be_missing(TypeInfo, FieldType) of @@ -402,7 +408,7 @@ process_record_fields( process_record_fields(TypeInfo, Rest, NewProperties, NewRequired, Config). generate_anyof_schema(TypeInfo, Types, Config) -> - Schemas = lists:map(fun(T) -> do_to_schema(TypeInfo, T, Config) end, Types), + Schemas = lists:map(fun(T) -> to_schema_for_sp_type(TypeInfo, T, Config) end, Types), #{anyOf => Schemas}. try_generate_enum_schema(Types, TypeInfo, Config) -> diff --git a/test/inline_doc_remote_helper.erl b/test/inline_doc_remote_helper.erl new file mode 100644 index 0000000..0c02ed5 --- /dev/null +++ b/test/inline_doc_remote_helper.erl @@ -0,0 +1,14 @@ +-module(inline_doc_remote_helper). + +%% Helper for spectra_json_schema_inline_doc_test: a documented type that is +%% inlined into a schema generated from another module. + +-spectra(#{ + title => <<"Remote Tag">>, + description => <<"A tag defined in another module">>, + deprecated => true, + examples => [<<"remote">>] +}). +-type tag() :: binary(). + +-export_type([tag/0]). diff --git a/test/spectra_json_schema_inline_doc_test.erl b/test/spectra_json_schema_inline_doc_test.erl new file mode 100644 index 0000000..28ab937 --- /dev/null +++ b/test/spectra_json_schema_inline_doc_test.erl @@ -0,0 +1,196 @@ +-module(spectra_json_schema_inline_doc_test). + +%% Doc annotations (title, description, deprecated, examples) must travel with +%% a type wherever it is inlined — map fields, union branches, list elements, +%% optional map values and remote types — not only when schema generation is +%% entered with that type. + +-include_lib("eunit/include/eunit.hrl"). + +-compile([nowarn_unused_type]). + +-spectra(#{ + title => <<"Payer">>, + description => <<"The party paying for the session">>, + deprecated => true, + examples => [<<"alice">>, <<"bob">>] +}). +-type payer() :: binary(). + +-spectra(#{ + title => <<"Legacy Name">>, + description => <<"Superseded by payer">>, + deprecated => true, + examples => [<<"old-style">>] +}). +-type deprecated_string() :: binary(). + +-type object() :: #{id := integer()}. + +%% A type carrying both a doc annotation and type_parameters. Both have to end +%% up on the same inlined schema. +-spectra(#{ + title => <<"URL">>, + description => <<"An absolute https URL">>, + type_parameters => #{max_length => 2048, pattern => <<"^https://">>} +}). +-type url() :: binary(). + +%% An alias of a documented type, carrying a conflicting title of its own. +-spectra(#{title => <<"Session Payer">>}). +-type session_payer() :: payer(). + +-type request() :: #{payer := payer()}. + +-type object_or_legacy() :: object() | deprecated_string(). + +-type payer_list() :: [payer()]. + +-type nonempty_payer_list() :: [payer(), ...]. + +-type optional_payer() :: #{payer => payer()}. + +-type remote_holder() :: #{tag := inline_doc_remote_helper:tag()}. + +-type url_holder() :: #{success_url := url()}. + +-type nested_payer() :: #{inner := request()}. + +-type aliased_payer_holder() :: #{payer := session_payer()}. + +schema(TypeName) -> + SchemaJson = spectra:schema(json_schema, ?MODULE, {type, TypeName, 0}), + Schema = json:decode(iolist_to_binary(SchemaJson)), + json_schema_validator_helper:validate_or_skip(Schema), + Schema. + +payer_doc() -> + #{ + <<"type">> => <<"string">>, + <<"title">> => <<"Payer">>, + <<"description">> => <<"The party paying for the session">>, + <<"deprecated">> => true, + <<"examples">> => [<<"alice">>, <<"bob">>] + }. + +map_field_keeps_doc_test() -> + ?assertMatch( + #{ + <<"properties">> := #{ + <<"payer">> := #{ + <<"type">> := <<"string">>, + <<"title">> := <<"Payer">>, + <<"description">> := <<"The party paying for the session">>, + <<"deprecated">> := true, + <<"examples">> := [<<"alice">>, <<"bob">>] + } + } + }, + schema(request) + ). + +nested_map_field_keeps_doc_test() -> + #{<<"properties">> := #{<<"inner">> := Inner}} = schema(nested_payer), + ?assertEqual(#{<<"payer">> => payer_doc()}, maps:get(<<"properties">>, Inner)). + +union_branch_keeps_doc_test() -> + ?assertMatch( + #{ + <<"anyOf">> := [ + #{<<"type">> := <<"object">>}, + #{ + <<"type">> := <<"string">>, + <<"title">> := <<"Legacy Name">>, + <<"description">> := <<"Superseded by payer">>, + <<"deprecated">> := true, + <<"examples">> := [<<"old-style">>] + } + ] + }, + schema(object_or_legacy) + ). + +list_element_keeps_doc_test() -> + ?assertMatch( + #{ + <<"type">> := <<"array">>, + <<"items">> := #{ + <<"title">> := <<"Payer">>, + <<"description">> := <<"The party paying for the session">>, + <<"deprecated">> := true, + <<"examples">> := [<<"alice">>, <<"bob">>] + } + }, + schema(payer_list) + ). + +nonempty_list_element_keeps_doc_test() -> + ?assertMatch( + #{ + <<"type">> := <<"array">>, + <<"minItems">> := 1, + <<"items">> := #{ + <<"title">> := <<"Payer">>, + <<"deprecated">> := true + } + }, + schema(nonempty_payer_list) + ). + +optional_map_value_keeps_doc_test() -> + Schema = schema(optional_payer), + ?assertEqual(#{<<"payer">> => payer_doc()}, maps:get(<<"properties">>, Schema)), + %% An optional key is never required, doc or no doc. + ?assertEqual(error, maps:find(<<"required">>, Schema)). + +remote_type_keeps_doc_test() -> + ?assertMatch( + #{ + <<"properties">> := #{ + <<"tag">> := #{ + <<"type">> := <<"string">>, + <<"title">> := <<"Remote Tag">>, + <<"description">> := <<"A tag defined in another module">>, + <<"deprecated">> := true, + <<"examples">> := [<<"remote">>] + } + } + }, + schema(remote_holder) + ). + +%% Regression: type_parameters and doc annotations on the same type must both +%% reach the inlined schema. +type_parameters_and_doc_merge_test() -> + ?assertMatch( + #{ + <<"properties">> := #{ + <<"success_url">> := #{ + <<"type">> := <<"string">>, + <<"title">> := <<"URL">>, + <<"description">> := <<"An absolute https URL">>, + <<"maxLength">> := 2048, + <<"pattern">> := <<"^https://">> + } + } + }, + schema(url_holder) + ). + +%% Precedence: annotations merge along the resolution chain and the one written +%% nearest the use site wins on conflicting keys. This matches how a type alias +%% of a documented record already behaved (see spectra_json_schema_doc_test). +alias_doc_wins_over_inlined_doc_test() -> + ?assertMatch( + #{ + <<"properties">> := #{ + <<"payer">> := #{ + <<"title">> := <<"Session Payer">>, + <<"description">> := <<"The party paying for the session">>, + <<"deprecated">> := true, + <<"examples">> := [<<"alice">>, <<"bob">>] + } + } + }, + schema(aliased_payer_holder) + ). From 60513bb181b12e0685ae18e22e7f07ecb9ebdcdb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 13:42:42 +0000 Subject: [PATCH 2/5] Bind the decoded schema as a map in the inline doc test eqwalizer rejects passing a json:decode_value() straight to json_schema_validator_helper:validate_or_skip/1, which takes a map(). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017Nffy1QUKxifokPEBLX3Qc --- test/spectra_json_schema_inline_doc_test.erl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/spectra_json_schema_inline_doc_test.erl b/test/spectra_json_schema_inline_doc_test.erl index 28ab937..c2216ea 100644 --- a/test/spectra_json_schema_inline_doc_test.erl +++ b/test/spectra_json_schema_inline_doc_test.erl @@ -60,7 +60,7 @@ schema(TypeName) -> SchemaJson = spectra:schema(json_schema, ?MODULE, {type, TypeName, 0}), - Schema = json:decode(iolist_to_binary(SchemaJson)), + #{} = Schema = json:decode(iolist_to_binary(SchemaJson)), json_schema_validator_helper:validate_or_skip(Schema), Schema. From 1771cab00f4c51db94fca47dfb310eaad1e89186 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 14:18:44 +0000 Subject: [PATCH 3/5] Cover record fields and examples_function in the inline doc tests The record field descent is a separate code path from the map field one and had no coverage, and examples_function was claimed to follow an inlined type without a test evaluating the MFA in that position. Add a record holder whose fields use an annotated type and an examples_function type. Name examples_function in the README list, and state the two positions that cannot carry a nested annotation: a union of literals collapsed into one enum schema, and a type whose schema comes from a custom codec. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017Nffy1QUKxifokPEBLX3Qc --- README.md | 18 +++++++--- test/spectra_json_schema_inline_doc_test.erl | 37 ++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9162f4f..b2ecc20 100644 --- a/README.md +++ b/README.md @@ -459,10 +459,10 @@ person_examples() -> The function specified in `examples_function` must be exported. Annotations follow the type wherever it is used. A type annotated with `title`, -`description`, `deprecated` or `examples` carries that metadata into every -schema it is inlined into — map field values, record fields, union branches, -list elements and remote types — not only when it is the type schema -generation starts from: +`description`, `deprecated`, `examples` or `examples_function` carries that +metadata into every schema it is inlined into — map field values, record +fields, union branches, list elements and remote types — not only when it is +the type schema generation starts from: ```erlang -spectra(#{title => <<"Payer">>, deprecated => true}). @@ -476,6 +476,16 @@ When a type alias and the type it resolves to annotate the same key, the annotation written nearest the use site wins; keys only one of them sets are kept from both. +Two positions cannot carry a nested annotation, because there is no sub-schema +to attach it to: + +- A union whose members all resolve to literals collapses into a single `enum` + schema, so an annotation on a member type of such a union is dropped. An + annotation on the union type itself is kept. +- A type handled by a [custom codec](#custom-codecs) gets its schema from the + codec, and the codec's schema is used as-is. This applies wherever the type + appears, including when schema generation starts from it. + ## Field Filtering with `only` The `only` key in the `-spectra()` attribute restricts which fields are included when encoding, decoding, and generating schemas for a map type. It works for plain Erlang maps and Elixir structs alike, similarly to Jason's `only` option. diff --git a/test/spectra_json_schema_inline_doc_test.erl b/test/spectra_json_schema_inline_doc_test.erl index c2216ea..d7a84bc 100644 --- a/test/spectra_json_schema_inline_doc_test.erl +++ b/test/spectra_json_schema_inline_doc_test.erl @@ -9,6 +9,8 @@ -compile([nowarn_unused_type]). +-export([nickname_examples/0]). + -spectra(#{ title => <<"Payer">>, description => <<"The party paying for the session">>, @@ -27,6 +29,18 @@ -type object() :: #{id := integer()}. +%% Examples supplied by an MFA rather than as literal terms. +-spectra(#{ + title => <<"Nickname">>, + examples_function => {?MODULE, nickname_examples, []} +}). +-type nickname() :: binary(). + +-record(account, { + owner :: payer(), + nickname :: nickname() +}). + %% A type carrying both a doc annotation and type_parameters. Both have to end %% up on the same inlined schema. -spectra(#{ @@ -54,10 +68,15 @@ -type url_holder() :: #{success_url := url()}. +-type account_holder() :: #account{}. + -type nested_payer() :: #{inner := request()}. -type aliased_payer_holder() :: #{payer := session_payer()}. +nickname_examples() -> + [<<"ace">>, <<"kit">>]. + schema(TypeName) -> SchemaJson = spectra:schema(json_schema, ?MODULE, {type, TypeName, 0}), #{} = Schema = json:decode(iolist_to_binary(SchemaJson)), @@ -194,3 +213,21 @@ alias_doc_wins_over_inlined_doc_test() -> }, schema(aliased_payer_holder) ). + +%% The record field descent is a separate code path from the map field one. +record_field_keeps_doc_test() -> + #{<<"properties">> := Properties} = schema(account_holder), + ?assertEqual(payer_doc(), maps:get(<<"owner">>, Properties)). + +%% examples_function is evaluated and its values converted for an inlined type, +%% the same as literal examples. +examples_function_keeps_doc_when_inlined_test() -> + #{<<"properties">> := #{<<"nickname">> := Nickname}} = schema(account_holder), + ?assertEqual( + #{ + <<"type">> => <<"string">>, + <<"title">> => <<"Nickname">>, + <<"examples">> => [<<"ace">>, <<"kit">>] + }, + Nickname + ). From e045b82f223c78514cacd55b5c2e13d094e908c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 14:32:32 +0000 Subject: [PATCH 4/5] Tighten the inline doc tests and document the annotation's limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups. Tests: assert whole schemas instead of loose patterns for the union branches and list elements, and add a negative case pinning that an unannotated type stays bare and a sibling's annotation does not leak onto it or the parent. Docs: the CHANGELOG entry now separates the fix from the behaviour changes and names two consequences of merging at every position — examples are validated and converted there, so an invalid example raises where it used to pass unnoticed, and examples_function runs once per position. The README adds the parameterized-type gap to the list of positions that do not carry the annotation, and clarifies that an alias of a codec-handled type does keep its own annotation. Also record in the source comment that only the two reference-resolution sites change output today; the descent sites hold the invariant for callers that build an sp_type() tree with a doc on a structural child. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017Nffy1QUKxifokPEBLX3Qc --- CHANGELOG.md | 11 ++- README.md | 22 +++-- src/spectra_json_schema.erl | 6 ++ test/spectra_json_schema_inline_doc_test.erl | 84 +++++++++++--------- 4 files changed, 77 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8de3472..5c37fc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- `-spectra()` doc annotations (`title`, `description`, `deprecated`, `examples`, `examples_function`) are no longer dropped when a type is inlined into another schema. Only the type that schema generation was entered with kept its annotations; every type resolved while inlining — map field values, record fields, union branches, list and non-empty list elements, optional map values, and remote types from other modules — silently lost them, so e.g. `deprecated => true` on a type used as a map field produced nothing in the output. Every recursive descent now merges the resolved type's doc into the schema it generates. `type_parameters` were unaffected and keep working alongside a doc annotation on the same type. Where a type alias and the type it resolves to set the same key, the annotation nearest the use site wins, matching how an alias of a documented record already behaved. This changes generated JSON Schema and OpenAPI output for anyone using annotated sub-schemas: nested properties now carry the titles, descriptions, deprecation flags and examples their types declare. +- `-spectra()` doc annotations (`title`, `description`, `deprecated`, `examples`, `examples_function`) are no longer dropped when a type is inlined into another schema. Only the type that schema generation was entered with kept its annotations. Every type resolved while inlining lost them silently, so `deprecated => true` on a type used as a map field produced nothing in the output. This covers map field values, record fields, union branches, list and non-empty list elements, optional map values, and remote types from other modules. +- `type_parameters` were unaffected and keep working alongside a doc annotation on the same type. + +### Changed +- Generated JSON Schema and OpenAPI output changes for annotated sub-schemas: nested properties now carry the titles, descriptions, deprecation flags and examples their types declare. +- Where a type alias and the type it resolves to set the same key, the annotation nearest the use site wins. This matches how an alias of a documented record already behaved. +- `examples` on an annotated type are now validated and converted at every position the type is inlined into, not only when it is the entry point. An example that does not encode as its own type raises `{invalid_example, Type, Term, Errors}`, which previously surfaced only if that type was used as an entry point. +- `examples_function` is likewise invoked once per position the type appears in, rather than once per schema. Keep it cheap and free of side effects. + +Three positions still cannot carry a nested annotation, all unchanged by this release: a union whose members all resolve to literals (it collapses to one `enum` schema), a type whose schema comes from a custom codec, and a parameterized type such as `-type box(T) :: ...`. See the README for details. ## [0.13.4] - 2026-06-14 diff --git a/README.md b/README.md index b2ecc20..26e93a9 100644 --- a/README.md +++ b/README.md @@ -476,15 +476,25 @@ When a type alias and the type it resolves to annotate the same key, the annotation written nearest the use site wins; keys only one of them sets are kept from both. -Two positions cannot carry a nested annotation, because there is no sub-schema -to attach it to: +Three positions do not carry the annotation: - A union whose members all resolve to literals collapses into a single `enum` - schema, so an annotation on a member type of such a union is dropped. An - annotation on the union type itself is kept. + schema, so an annotation on a member type of such a union is dropped. There + is no per-member sub-schema to attach it to. An annotation on the union type + itself is kept. - A type handled by a [custom codec](#custom-codecs) gets its schema from the - codec, and the codec's schema is used as-is. This applies wherever the type - appears, including when schema generation starts from it. + codec, and an annotation on that type is dropped. This applies wherever the + type appears, including when schema generation starts from it. An annotation + on a plain type that *aliases* a codec-handled type is kept, and is merged + over the schema the codec produced. +- A parameterized type, such as `-type box(T) :: #{v := T}`, loses its + annotation when it is instantiated. This is a known gap rather than a + deliberate limit. + +Because the annotation now reaches every position a type appears in, `examples` +are validated and converted at each of them, and an `examples_function` is +called once per position. An example that does not encode as its own type +raises `{invalid_example, ...}` from schema generation. ## Field Filtering with `only` diff --git a/src/spectra_json_schema.erl b/src/spectra_json_schema.erl index b708f56..f060d0e 100644 --- a/src/spectra_json_schema.erl +++ b/src/spectra_json_schema.erl @@ -65,6 +65,12 @@ to_schema(TypeInfo, Type, Config) -> %% inlined. Calling do_to_schema/3 directly silently drops the annotation. %% Docs merge along the resolution chain: on conflicting keys the annotation %% written nearest the use site wins. +%% +%% A doc only reaches a type node through spectra_abstract_code, which attaches +%% it to the top node of a stored named type or record, so today only the two +%% reference-resolution sites below observably change the output. The descent +%% sites hold the invariant for callers that build an sp_type() tree by hand +%% with a doc on a structural child, as the Elixir wrappers do. -spec to_schema_for_sp_type(spectra:type_info(), spectra:sp_type(), spectra:sp_config()) -> json_schema_object(). to_schema_for_sp_type(TypeInfo, Type, Config) -> diff --git a/test/spectra_json_schema_inline_doc_test.erl b/test/spectra_json_schema_inline_doc_test.erl index d7a84bc..384a0e0 100644 --- a/test/spectra_json_schema_inline_doc_test.erl +++ b/test/spectra_json_schema_inline_doc_test.erl @@ -1,9 +1,11 @@ -module(spectra_json_schema_inline_doc_test). -%% Doc annotations (title, description, deprecated, examples) must travel with -%% a type wherever it is inlined — map fields, union branches, list elements, -%% optional map values and remote types — not only when schema generation is -%% entered with that type. +%% Doc annotations (title, description, deprecated, examples, +%% examples_function) must travel with a type wherever it is inlined — map +%% fields, record fields, union branches, list elements, optional map values +%% and remote types — not only when schema generation is entered with that +%% type. A type without an annotation must stay bare, and a child's annotation +%% must not leak onto its parent. -include_lib("eunit/include/eunit.hrl"). @@ -70,6 +72,11 @@ -type account_holder() :: #account{}. +-type plain() :: binary(). + +%% Neither the holder nor the bare field type carries an annotation. +-type plain_holder() :: #{plain := plain(), payer := payer()}. + -type nested_payer() :: #{inner := request()}. -type aliased_payer_holder() :: #{payer := session_payer()}. @@ -113,48 +120,36 @@ nested_map_field_keeps_doc_test() -> ?assertEqual(#{<<"payer">> => payer_doc()}, maps:get(<<"properties">>, Inner)). union_branch_keeps_doc_test() -> - ?assertMatch( + #{<<"anyOf">> := [ObjectBranch, LegacyBranch]} = schema(object_or_legacy), + %% The undocumented branch must stay bare — no doc leaking across branches. + ?assertEqual( #{ - <<"anyOf">> := [ - #{<<"type">> := <<"object">>}, - #{ - <<"type">> := <<"string">>, - <<"title">> := <<"Legacy Name">>, - <<"description">> := <<"Superseded by payer">>, - <<"deprecated">> := true, - <<"examples">> := [<<"old-style">>] - } - ] + <<"type">> => <<"object">>, + <<"additionalProperties">> => false, + <<"properties">> => #{<<"id">> => #{<<"type">> => <<"integer">>}}, + <<"required">> => [<<"id">>] }, - schema(object_or_legacy) - ). - -list_element_keeps_doc_test() -> - ?assertMatch( + ObjectBranch + ), + ?assertEqual( #{ - <<"type">> := <<"array">>, - <<"items">> := #{ - <<"title">> := <<"Payer">>, - <<"description">> := <<"The party paying for the session">>, - <<"deprecated">> := true, - <<"examples">> := [<<"alice">>, <<"bob">>] - } + <<"type">> => <<"string">>, + <<"title">> => <<"Legacy Name">>, + <<"description">> => <<"Superseded by payer">>, + <<"deprecated">> => true, + <<"examples">> => [<<"old-style">>] }, - schema(payer_list) + LegacyBranch ). +list_element_keeps_doc_test() -> + #{<<"type">> := <<"array">>, <<"items">> := Items} = schema(payer_list), + ?assertEqual(payer_doc(), Items). + nonempty_list_element_keeps_doc_test() -> - ?assertMatch( - #{ - <<"type">> := <<"array">>, - <<"minItems">> := 1, - <<"items">> := #{ - <<"title">> := <<"Payer">>, - <<"deprecated">> := true - } - }, - schema(nonempty_payer_list) - ). + #{<<"type">> := <<"array">>, <<"minItems">> := 1, <<"items">> := Items} = + schema(nonempty_payer_list), + ?assertEqual(payer_doc(), Items). optional_map_value_keeps_doc_test() -> Schema = schema(optional_payer), @@ -231,3 +226,14 @@ examples_function_keeps_doc_when_inlined_test() -> }, Nickname ). + +%% Negative: a type with no annotation gains no doc keys, and the annotation on +%% a sibling field does not leak onto it or onto the parent schema. +undocumented_type_stays_bare_test() -> + Schema = schema(plain_holder), + #{<<"properties">> := #{<<"plain">> := Plain}} = Schema, + ?assertEqual(#{<<"type">> => <<"string">>}, Plain), + ?assertEqual( + [<<"additionalProperties">>, <<"properties">>, <<"required">>, <<"type">>], + lists:sort(maps:keys(maps:remove(<<"$schema">>, Schema))) + ). From 0495cd31cf456ee8439aa5e896ac7083560e5926 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 14:51:17 +0000 Subject: [PATCH 5/5] Prepare release 0.14.0 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017Nffy1QUKxifokPEBLX3Qc --- CHANGELOG.md | 2 ++ README.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c37fc3..1a101f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.14.0] - 2026-09-11 + ### Fixed - `-spectra()` doc annotations (`title`, `description`, `deprecated`, `examples`, `examples_function`) are no longer dropped when a type is inlined into another schema. Only the type that schema generation was entered with kept its annotations. Every type resolved while inlining lost them silently, so `deprecated => true` on a type used as a map field produced nothing in the output. This covers map field values, record fields, union branches, list and non-empty list elements, optional map values, and remote types from other modules. - `type_parameters` were unaffected and keep working alongside a doc annotation on the same type. diff --git a/README.md b/README.md index 26e93a9..135cf19 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Add spectra to your rebar.config dependencies: ```erlang {deps, [ - {spectra, "~> 0.13.4"} + {spectra, "~> 0.14.0"} ]}. ```