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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ 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.

### 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

### Fixed
Expand Down
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Add spectra to your rebar.config dependencies:

```erlang
{deps, [
{spectra, "~> 0.13.4"}
{spectra, "~> 0.14.0"}
]}.
```

Expand Down Expand Up @@ -458,6 +458,44 @@ 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`, `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}).
-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.

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. 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 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`

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.
Expand Down
28 changes: 20 additions & 8 deletions src/spectra_json_schema.erl
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@
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.
%%
%% 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) ->
Expand Down Expand Up @@ -87,7 +99,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
Comment thread
andreashasse marked this conversation as resolved.
end;
Expand All @@ -107,7 +119,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;
Expand Down Expand Up @@ -197,10 +209,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,
Expand All @@ -220,7 +232,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 ->
Expand Down Expand Up @@ -324,7 +336,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
Expand Down Expand Up @@ -390,7 +402,7 @@ process_record_fields(
Required,
Config
) ->
FieldSchema = do_to_schema(TypeInfo, FieldType, Config),
FieldSchema = to_schema_for_sp_type(TypeInfo, FieldType, Config),
Comment thread
andreashasse marked this conversation as resolved.
NewProperties = Properties#{BinaryName => FieldSchema},
NewRequired =
case spectra_type:can_be_missing(TypeInfo, FieldType) of
Expand All @@ -402,7 +414,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) ->
Expand Down
14 changes: 14 additions & 0 deletions test/inline_doc_remote_helper.erl
Original file line number Diff line number Diff line change
@@ -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]).
Loading
Loading