Current section
Files
Jump to
Current section
Files
lib/ex_open_api_utils.ex
defmodule ExOpenApiUtils do
@moduledoc """
OpenAPI schema generation from Ecto schemas with OpenAPI 3.2 support.
## Migration Guide
### From v0.9.x to v0.10.x (OpenAPI 3.2)
#### 1. Update OpenAPI Version
In your ApiSpec module, update the version:
# Before (v0.9.x)
%OpenApi{
openapi: "3.0.0",
...
}
# After (v0.10.x)
%OpenApi{
openapi: ExOpenApiUtils.openapi_version(), # Returns "3.2.0"
...
}
#### 2. Migrate Tags (Optional - for tag hierarchy)
If using flat tags, no changes needed. For hierarchical tags:
# Before (v0.9.x) - flat tags
%OpenApi{
tags: [
%OpenApiSpex.Tag{name: "Users"},
%OpenApiSpex.Tag{name: "Profile"},
%OpenApiSpex.Tag{name: "Admin"}
]
}
# After (v0.10.x) - hierarchical tags with 3.2 fields
alias ExOpenApiUtils.Tag
%OpenApi{
tags: [
Tag.new("Users", summary: "User Management"),
Tag.nested("Profile", "Users", summary: "User Profiles"),
Tag.navigation("Admin", summary: "Admin Panel")
] |> Tag.to_open_api_spex_list()
}
#### 3. Remove Deprecated Extensions (if using Redoc-specific)
Replace non-standard extensions with OpenAPI 3.2 native fields:
| Old (Redoc) | New (3.2 native) |
|-------------------|----------------------|
| `x-tagGroups` | Use `Tag.nested/3` |
| `x-displayName` | Use `summary` field |
### Extensions Retained
These extensions are kept for TypeScript/NestJS codegen compatibility:
- `x-enum-varnames` - TypeScript enum member names
- `x-order` - Property ordering in generated code
## Basic Usage
Define schemas with `use ExOpenApiUtils`:
defmodule MyApp.User do
use ExOpenApiUtils
open_api_property(
key: :name,
schema: %Schema{type: :string, description: "User name"}
)
@primary_key {:id, :binary_id, autogenerate: true}
schema "users" do
field :name, :string
end
open_api_schema(
title: "User",
description: "Application user",
required: [:name],
properties: [:name],
tags: ["Users"]
)
end
## Polymorphic embeds
`ex_open_api_utils` bridges `polymorphic_embed`'s Ecto side to OpenApiSpex's
`oneOf + discriminator` composition. The bridge is two parts: write two
`open_api_property` calls for the polymorphic field (one `writeOnly` with
`*Request` variant submodules, one `readOnly` with `*Response` variant
submodules), then declare the bridge with `polymorphic_embed_discriminator/1`.
See `polymorphic_embed_discriminator/1` for the full shape.
"""
alias ExOpenApiUtils.Property
alias ExOpenApiUtils.SchemaDefinition
alias ExOpenApiUtils.Tag
require Protocol
@doc """
Returns the OpenAPI version string for 3.2 compliance.
"""
@spec openapi_version() :: String.t()
def openapi_version, do: "3.2.0"
@doc """
Alias for Tag module for convenience.
"""
defdelegate tag(name, opts \\ []), to: Tag, as: :new
defdelegate nested_tag(name, parent, opts \\ []), to: Tag, as: :nested
defdelegate navigation_tag(name, opts \\ []), to: Tag, as: :navigation
defmacro __using__(_opts) do
quote do
use Ecto.Schema
require ExOpenApiUtils
import ExOpenApiUtils,
only: [
open_api_schema: 1,
open_api_property: 1,
polymorphic_embed_discriminator: 1
]
alias ExOpenApiUtils.Helpers
alias ExOpenApiUtils.Tag
alias OpenApiSpex.Schema
import Ecto.Changeset, except: [cast: 4, cast: 3]
import ExOpenApiUtils.Changeset, only: [cast: 4, cast: 3]
Module.register_attribute(__MODULE__, :open_api_properties, accumulate: true)
Module.register_attribute(__MODULE__, :open_api_schemas, accumulate: true)
Module.register_attribute(__MODULE__, :polymorphic_embed_declarations, accumulate: true)
@before_compile ExOpenApiUtils
end
end
defmacro open_api_schema(opts) do
title = Keyword.fetch!(opts, :title)
required = Keyword.get(opts, :required, [])
description = Keyword.fetch!(opts, :description)
tags = Keyword.get(opts, :tags, [])
properties = Keyword.get(opts, :properties)
type = Keyword.get(opts, :type, :object)
nullable = Keyword.get(opts, :nullable, nil)
quote do
all_properties = @open_api_properties |> Enum.map(& &1.key)
alias ExOpenApiUtils.SchemaDefinition
properties = unquote(properties) || all_properties
schema_definition = %SchemaDefinition{
tags: unquote(tags),
properties: properties,
title: unquote(title),
required: unquote(required),
description: unquote(description),
type: unquote(type),
nullable: unquote(nullable)
}
Module.put_attribute(__MODULE__, :open_api_schemas, schema_definition)
end
end
defmacro open_api_property(opts) do
schema = Keyword.fetch!(opts, :schema)
key = Keyword.fetch!(opts, :key)
source = Keyword.get(opts, :source, key)
quote do
alias ExOpenApiUtils.Property
property = %Property{schema: unquote(schema), key: unquote(key), source: unquote(source)}
Module.put_attribute(__MODULE__, :open_api_properties, property)
end
end
@doc """
Declares the bridge between a `polymorphic_embeds_one` field and its
corresponding `oneOf + discriminator` `open_api_property` pair.
Use this macro on the parent schema, next to (or near) the matching
`polymorphic_embeds_one` declaration. The library cross-checks at compile
time that the Ecto side and the OpenAPI side agree on variants, wire values,
and the discriminator property name.
## Options
* `:key` (required) — atom. The field name on the parent that is both a
`polymorphic_embeds_one` and the key of the two `open_api_property`
entries (one `writeOnly`, one `readOnly`).
* `:type_field_name` (required) — atom. Must match the `type_field_name:`
option passed to `polymorphic_embeds_one`. This is the atom key that
`cast_polymorphic_embed/3` reads from the flattened params map. It may
differ from the OpenAPI discriminator `propertyName:` (which lives on
the wire as a string); the library bridges the two names.
## Example
open_api_property(
key: :channel,
schema: %Schema{
type: :object,
writeOnly: true,
oneOf: [EmailChannelRequest, SmsChannelRequest, WebhookChannelRequest],
discriminator: %Discriminator{
propertyName: "object_type",
mapping: %{
"email" => EmailChannelRequest,
"sms" => SmsChannelRequest,
"webhook" => WebhookChannelRequest
}
}
}
)
open_api_property(
key: :channel,
schema: %Schema{
type: :object,
readOnly: true,
oneOf: [EmailChannelResponse, SmsChannelResponse, WebhookChannelResponse],
discriminator: %Discriminator{
propertyName: "object_type",
mapping: %{
"email" => EmailChannelResponse,
"sms" => SmsChannelResponse,
"webhook" => WebhookChannelResponse
}
}
}
)
polymorphic_embed_discriminator(
key: :channel,
type_field_name: :__type__
)
schema "notifications" do
field :subject, :string
polymorphic_embeds_one :channel,
types: [
email: EmailChannel,
sms: SmsChannel,
webhook: WebhookChannel
],
type_field_name: :__type__,
on_type_not_found: :raise,
on_replace: :update
end
"""
defmacro polymorphic_embed_discriminator(opts) do
key = Keyword.fetch!(opts, :key)
type_field_name = Keyword.fetch!(opts, :type_field_name)
unless is_atom(key) do
raise ArgumentError,
"polymorphic_embed_discriminator :key must be an atom, got: #{inspect(key)}"
end
unless is_atom(type_field_name) do
raise ArgumentError,
"polymorphic_embed_discriminator :type_field_name must be an atom, got: " <>
inspect(type_field_name)
end
quote do
Module.put_attribute(
__MODULE__,
:polymorphic_embed_declarations,
%{key: unquote(key), type_field_name: unquote(type_field_name)}
)
end
end
defmacro __before_compile__(%{module: module}) do
quote do
require Protocol
alias ExOpenApiUtils.SchemaDefinition
polymorphic_variants =
ExOpenApiUtils.__build_polymorphic_variants__(
unquote(module),
@polymorphic_embed_declarations,
@open_api_properties,
@ecto_fields
)
for %SchemaDefinition{} = schema_definition <- @open_api_schemas do
{request_module_name, request_body, request_properties} =
ExOpenApiUtils.__submodule_spec__(
unquote(module),
schema_definition,
@open_api_properties,
:request
)
Module.create(
request_module_name,
quote do
require OpenApiSpex
OpenApiSpex.schema(unquote(Macro.escape(request_body)))
end,
Macro.Env.location(__ENV__)
)
Protocol.derive(ExOpenApiUtils.Mapper, request_module_name,
property_attrs: request_properties,
map_direction: :from_open_api,
polymorphic_variants: polymorphic_variants
)
{response_module_name, response_body, response_properties} =
ExOpenApiUtils.__submodule_spec__(
unquote(module),
schema_definition,
@open_api_properties,
:response
)
Module.create(
response_module_name,
quote do
require OpenApiSpex
OpenApiSpex.schema(unquote(Macro.escape(response_body)))
end,
Macro.Env.location(__ENV__)
)
Protocol.derive(ExOpenApiUtils.Mapper, response_module_name,
property_attrs: response_properties,
map_direction: :from_open_api,
polymorphic_variants: polymorphic_variants
)
end
exported_properties =
Enum.filter(@open_api_properties, fn %Property{} = property ->
!ExOpenApiUtils.is_writeOnly?(property.schema)
end)
Protocol.derive(ExOpenApiUtils.Mapper, __MODULE__,
property_attrs: exported_properties,
map_direction: :from_ecto,
polymorphic_variants: polymorphic_variants
)
@__ex_open_api_utils_schemas_index__ ExOpenApiUtils.__schemas_index__(
unquote(module),
@open_api_schemas
)
@doc false
def __ex_open_api_utils_schemas__, do: @__ex_open_api_utils_schemas_index__
end
end
@doc false
# Builds the `{module_name, body_map, filtered_properties}` triple for a
# direction-specific sub-module (Request or Response). Extracted out of the
# `__before_compile__` quote block to keep it small — the Module.create and
# Protocol.derive calls have to stay inside the quote (Protocol.derive is a
# macro), but all the filtering and body-assembly is plain data work.
def __submodule_spec__(parent_module, schema_definition, all_properties, direction) do
[root_module | _] = Module.split(parent_module)
title = schema_definition.title
description = schema_definition.description
{suffix, reject_fn, body_extras} =
case direction do
:request ->
{"Request", &is_readOnly?(&1.schema),
%{
description: description <> " Request",
type: :object,
writeOnly: true
}}
:response ->
{"Response", &is_writeOnly?(&1.schema),
%{
description: description,
type: schema_definition.type,
readOnly: true
}}
end
module_name = Module.concat([root_module, "OpenApiSchema", "#{title}#{suffix}"])
properties =
Enum.filter(all_properties, fn %Property{} = property ->
property.key in schema_definition.properties && !reject_fn.(property)
end)
properties_map =
Enum.reduce(properties, %{}, fn %Property{} = property, acc ->
Map.put(acc, property.key, property.schema)
end)
example =
Enum.reduce(properties, %{}, fn %Property{} = property, acc ->
Map.put(acc, Atom.to_string(property.key), OpenApiSpex.Schema.example(property.schema))
end)
properties_keys = Map.keys(properties_map)
required =
Enum.filter(schema_definition.required, &(&1 in properties_keys))
order =
Enum.filter(schema_definition.properties, &(&1 in properties_keys))
body =
Map.merge(body_extras, %{
title: Inflex.camelize(title <> suffix),
required: required,
properties: properties_map,
tags: schema_definition.tags,
nullable: schema_definition.nullable,
example: example,
extensions: %{"x-order" => order}
})
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
{module_name, body, properties}
end
@doc false
def __schemas_index__(module, schemas) do
[root_module | _] = Module.split(module)
Enum.map(schemas, fn %SchemaDefinition{title: title} ->
%{
title: title,
request_module: Module.concat([root_module, "OpenApiSchema", "#{title}Request"]),
response_module: Module.concat([root_module, "OpenApiSchema", "#{title}Response"])
}
end)
end
@doc false
# Builds the polymorphic_variants map consumed by the Mapper derive calls.
#
# Scans @polymorphic_embed_declarations, and for each entry:
# 1. locates the matching polymorphic_embeds_one field in @ecto_fields
# 2. locates the writeOnly + readOnly open_api_property entries for the key
# 3. cross-checks discriminator propertyName agreement
# 4. cross-checks type_field_name agreement with polymorphic_embed
# 5. cross-checks wire value sets across all three sources
# 6. cross-checks oneOf/mapping agree within each open_api_property
# 7. cross-checks submodule refs resolve back to the Ecto variant modules
# via each variant's __ex_open_api_utils_schemas__/0 reflection helper
# 8. builds the unified variant_map keyed by all three struct flavors
#
# Any failure raises a CompileError whose message names the specific item
# that diverges.
def __build_polymorphic_variants__(parent_module, declarations, properties, ecto_fields) do
declarations
|> Enum.reverse()
|> Enum.reduce(%{}, fn decl, acc ->
entry = build_one_polymorphic_entry!(parent_module, decl, properties, ecto_fields)
Map.put(acc, decl.key, entry)
end)
end
defp build_one_polymorphic_entry!(parent_module, decl, properties, ecto_fields) do
%{key: key, type_field_name: type_field_name} = decl
ecto_opts = fetch_polymorphic_embed_opts!(parent_module, key, ecto_fields)
unless ecto_opts.type_field_name == type_field_name do
raise CompileError,
description:
"polymorphic_embed_discriminator(#{inspect(key)}) declares :type_field_name " <>
"#{inspect(type_field_name)} but polymorphic_embeds_one :#{key} declares " <>
":type_field_name #{inspect(ecto_opts.type_field_name)} — they must match"
end
ecto_variant_map =
Enum.into(ecto_opts.types_metadata, %{}, fn %{type: type, module: mod} ->
{to_string(type), mod}
end)
ecto_wire_values = ecto_variant_map |> Map.keys() |> MapSet.new()
key_properties = Enum.filter(properties, &(&1.key == key))
{write_prop, read_prop} = fetch_directional_properties!(key, key_properties)
discriminator_string =
fetch_matching_discriminator_property_name!(key, write_prop, read_prop)
check_oneof_matches_mapping!(key, :writeOnly, write_prop.schema)
check_oneof_matches_mapping!(key, :readOnly, read_prop.schema)
request_mod_by_wire = map_wire_to_modules!(key, :writeOnly, write_prop.schema)
response_mod_by_wire = map_wire_to_modules!(key, :readOnly, read_prop.schema)
check_wire_values_agree!(
key,
ecto_wire_values,
request_mod_by_wire |> Map.keys() |> MapSet.new(),
response_mod_by_wire |> Map.keys() |> MapSet.new()
)
check_submodules_match_reflection!(
key,
ecto_variant_map,
request_mod_by_wire,
response_mod_by_wire
)
variant_map =
ecto_variant_map
|> Enum.flat_map(fn {wire, ecto_mod} ->
req = Map.fetch!(request_mod_by_wire, wire)
res = Map.fetch!(response_mod_by_wire, wire)
[{ecto_mod, wire}, {req, wire}, {res, wire}]
end)
|> Map.new()
# Store the atom form of the discriminator propertyName in the
# returned map. This map flows through
# `Protocol.derive(..., polymorphic_variants: ...)` which passes it
# to `ExOpenApiUtils.Mapper.__deriving__/3`, where `Macro.escape/1`
# pins the whole map as a literal inside the generated Mapper impl's
# `to_map/1` body. Elixir's compiler stashes that literal in the
# module's LitT (literal pool) chunk, and the BEAM loader
# materialises every atom inside the literal pool at module-load
# time — so `:object_type` (or whatever the user declared) is
# present in the runtime atom table the moment the compiled `.beam`
# file is loaded, even in a freshly-started BEAM that has never run
# the library's compile-time `__before_compile__` hook.
# See GH-27 for the bug this fixes.
%{
variant_map: variant_map,
discriminator_string: discriminator_string,
discriminator_atom: String.to_atom(discriminator_string),
type_field_atom: type_field_name
}
end
defp fetch_polymorphic_embed_opts!(parent_module, key, ecto_fields) do
entry =
Enum.find(ecto_fields, fn
{^key, {{:parameterized, {PolymorphicEmbed, _opts}}, _writable}} -> true
_ -> false
end)
case entry do
{^key, {{:parameterized, {PolymorphicEmbed, opts}}, _writable}} ->
opts
nil ->
raise CompileError,
description:
"polymorphic_embed_discriminator(#{inspect(key)}) declared in " <>
"#{inspect(parent_module)} but no matching polymorphic_embeds_one :#{key} " <>
"found in the Ecto schema"
end
end
defp fetch_directional_properties!(key, key_properties) do
write = Enum.find(key_properties, &is_writeOnly?(&1.schema))
read = Enum.find(key_properties, &is_readOnly?(&1.schema))
cond do
is_nil(write) ->
raise CompileError,
description:
"polymorphic_embed_discriminator(#{inspect(key)}) requires a writeOnly " <>
"open_api_property with the same :key — none found"
is_nil(read) ->
raise CompileError,
description:
"polymorphic_embed_discriminator(#{inspect(key)}) requires a readOnly " <>
"open_api_property with the same :key — none found"
true ->
check_is_polymorphic_schema!(key, :writeOnly, write.schema)
check_is_polymorphic_schema!(key, :readOnly, read.schema)
{write, read}
end
end
defp check_is_polymorphic_schema!(key, direction, %OpenApiSpex.Schema{} = schema) do
cond do
is_nil(schema.oneOf) ->
raise CompileError,
description:
"polymorphic_embed_discriminator(#{inspect(key)}) #{direction} schema " <>
"must have :oneOf set"
is_nil(schema.discriminator) ->
raise CompileError,
description:
"polymorphic_embed_discriminator(#{inspect(key)}) #{direction} schema " <>
"must have :discriminator set"
schema.type != :object ->
raise CompileError,
description:
"polymorphic_embed_discriminator(#{inspect(key)}) #{direction} schema " <>
"must have type: :object (this is the OpenApiSpex dispatch gate)"
true ->
:ok
end
end
defp check_is_polymorphic_schema!(key, direction, other) do
raise CompileError,
description:
"polymorphic_embed_discriminator(#{inspect(key)}) #{direction} property " <>
"schema must be an %OpenApiSpex.Schema{}, got: #{inspect(other)}"
end
defp fetch_matching_discriminator_property_name!(key, write_prop, read_prop) do
w_name = write_prop.schema.discriminator.propertyName
r_name = read_prop.schema.discriminator.propertyName
cond do
is_nil(w_name) or w_name == "" ->
raise CompileError,
description:
"polymorphic_embed_discriminator(#{inspect(key)}) writeOnly schema " <>
"discriminator.propertyName must be a non-empty string"
is_nil(r_name) or r_name == "" ->
raise CompileError,
description:
"polymorphic_embed_discriminator(#{inspect(key)}) readOnly schema " <>
"discriminator.propertyName must be a non-empty string"
w_name != r_name ->
raise CompileError,
description:
"polymorphic_embed_discriminator(#{inspect(key)}) writeOnly and readOnly " <>
"schemas must share the same discriminator.propertyName — got " <>
"#{inspect(w_name)} vs #{inspect(r_name)}"
true ->
w_name
end
end
defp check_oneof_matches_mapping!(key, direction, %OpenApiSpex.Schema{
oneOf: one_of,
discriminator: %OpenApiSpex.Discriminator{mapping: mapping}
}) do
one_of_set = MapSet.new(one_of)
mapping_set = mapping |> Map.values() |> MapSet.new()
unless one_of_set == mapping_set do
raise CompileError,
description:
"polymorphic_embed_discriminator(#{inspect(key)}) #{direction} schema " <>
"oneOf entries and discriminator.mapping values must match 1:1 — got " <>
"oneOf=#{inspect(MapSet.to_list(one_of_set))} vs mapping values " <>
"#{inspect(MapSet.to_list(mapping_set))}"
end
:ok
end
defp map_wire_to_modules!(key, direction, %OpenApiSpex.Schema{
discriminator: %OpenApiSpex.Discriminator{mapping: mapping}
}) do
Enum.reduce(mapping, %{}, fn {wire, mod}, acc ->
unless is_atom(mod) do
raise CompileError,
description:
"polymorphic_embed_discriminator(#{inspect(key)}) #{direction} schema " <>
"discriminator.mapping values must be module atoms, got: #{inspect(mod)} " <>
"for wire value #{inspect(wire)}"
end
Map.put(acc, wire, mod)
end)
end
defp check_wire_values_agree!(key, ecto_set, req_set, res_set) do
if ecto_set != req_set or ecto_set != res_set do
raise CompileError,
description:
"polymorphic_embed_discriminator(#{inspect(key)}) wire value sets must " <>
"match across all three sources — polymorphic_embeds_one: " <>
"#{inspect(MapSet.to_list(ecto_set))}, writeOnly mapping: " <>
"#{inspect(MapSet.to_list(req_set))}, readOnly mapping: " <>
"#{inspect(MapSet.to_list(res_set))}"
end
:ok
end
defp check_submodules_match_reflection!(
key,
ecto_variant_map,
request_mod_by_wire,
response_mod_by_wire
) do
Enum.each(ecto_variant_map, fn {wire, ecto_mod} ->
Code.ensure_compiled!(ecto_mod)
unless function_exported?(ecto_mod, :__ex_open_api_utils_schemas__, 0) do
raise CompileError,
description:
"polymorphic_embed_discriminator(#{inspect(key)}) variant module " <>
"#{inspect(ecto_mod)} must `use ExOpenApiUtils` — it does not export " <>
"__ex_open_api_utils_schemas__/0"
end
variant_schemas = ecto_mod.__ex_open_api_utils_schemas__()
{expected_request, expected_response} =
case variant_schemas do
[%{request_module: req, response_module: res} | _] ->
{req, res}
_ ->
raise CompileError,
description:
"polymorphic_embed_discriminator(#{inspect(key)}) variant module " <>
"#{inspect(ecto_mod)} has no open_api_schema defined"
end
actual_request = Map.fetch!(request_mod_by_wire, wire)
actual_response = Map.fetch!(response_mod_by_wire, wire)
if actual_request != expected_request do
raise CompileError,
description:
"polymorphic_embed_discriminator(#{inspect(key)}) wire value " <>
"#{inspect(wire)} writeOnly mapping references " <>
"#{inspect(actual_request)} but variant #{inspect(ecto_mod)} expects " <>
"#{inspect(expected_request)}"
end
if actual_response != expected_response do
raise CompileError,
description:
"polymorphic_embed_discriminator(#{inspect(key)}) wire value " <>
"#{inspect(wire)} readOnly mapping references " <>
"#{inspect(actual_response)} but variant #{inspect(ecto_mod)} expects " <>
"#{inspect(expected_response)}"
end
end)
:ok
end
def is_readOnly?(%OpenApiSpex.Schema{readOnly: readOnly}) do
!!readOnly
end
def is_readOnly?(%OpenApiSpex.Reference{"$ref": ref}) do
String.ends_with?(ref, "Response")
end
def is_readOnly?(module) do
apply(module, :schema, [])
|> is_readOnly?()
end
def is_writeOnly?(%OpenApiSpex.Schema{writeOnly: writeOnly}) do
!!writeOnly
end
def is_writeOnly?(%OpenApiSpex.Reference{"$ref": ref}) do
String.ends_with?(ref, "Request")
end
def is_writeOnly?(module) do
apply(module, :schema, [])
|> is_writeOnly?()
end
end