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. Declare the bridge with a single `open_api_polymorphic_property/1` call alongside the matching `polymorphic_embeds_one`: defmodule MyApp.Notification do use ExOpenApiUtils open_api_property(key: :subject, schema: %Schema{type: :string}) open_api_polymorphic_property( key: :channel, type_field_name: :__type__, open_api_discriminator_property: "channel_type", variants: [ email: EmailChannel, sms: SmsChannel, webhook: WebhookChannel ] ) 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 open_api_schema(title: "Notification", ...) end The library generates one parent-contextual variant submodule per `(parent, variant, direction)` triple at the parent's `__before_compile__` time via `Module.create` with an `allOf` composition body. The generated siblings (e.g. `NotificationEmailChannelRequest` / `NotificationEmailChannelResponse`) carry the discriminator as a real `defstruct` field, so `Kernel.struct/2` preserves it through the full cast pipeline — closing GH-30, where the pre-fix variant submodule's defstruct was built without the discriminator and silently dropped it at `Cast.Object.to_struct/1`. See `open_api_polymorphic_property/1` for the full option list. """ 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, open_api_polymorphic_property: 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__, :open_api_polymorphic_properties, 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 a polymorphic field whose underlying Ecto schema is a `polymorphic_embeds_one` and whose OpenAPI representation is a `oneOf + discriminator` composition. A single call replaces the previous paired-`open_api_property` + `polymorphic_embed_discriminator` shape. The library generates one parent-contextual variant submodule per `(parent, variant, direction)` triple at the parent's `__before_compile__` time, composing each new sibling via `allOf: [, ]` so the generated `defstruct` includes the discriminator field as a real atom key. The parent's own `oneOf + discriminator.mapping` is synthesised to point at the new siblings, so the round-trip cast preserves the discriminator through `Kernel.struct/2`. ## Options * `:key` (required) — atom. The field name on the parent that is both a `polymorphic_embeds_one` and the name of the synthesised writeOnly/readOnly OpenAPI properties. * `: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 wire discriminator name. * `:open_api_discriminator_property` (required) — non-empty string. The wire discriminator key that will appear in the JSON body and that OpenApiSpex's `Cast.Discriminator` will read to route the variant. * `:variants` (required) — non-empty keyword list. Each entry is `wire_atom: EctoVariantModule`. The wire value written to and read from the JSON body is `Atom.to_string(wire_atom)`, and the Ecto variant module must be one of the modules listed in the matching `polymorphic_embeds_one`'s `:types` option. Each variant module must itself `use ExOpenApiUtils` and have at least one `open_api_schema/1` declaration so the library can reflect on its auto-generated Request/Response submodules. ## Example open_api_polymorphic_property( key: :channel, type_field_name: :__type__, open_api_discriminator_property: "channel_type", variants: [ email: EmailChannel, sms: SmsChannel, webhook: WebhookChannel ] ) 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 open_api_polymorphic_property(opts) do key = Keyword.fetch!(opts, :key) type_field_name = Keyword.fetch!(opts, :type_field_name) discriminator_property = Keyword.fetch!(opts, :open_api_discriminator_property) variants_ast = Keyword.fetch!(opts, :variants) unless is_atom(key) do raise ArgumentError, "open_api_polymorphic_property :key must be an atom, got: #{inspect(key)}" end unless is_atom(type_field_name) do raise ArgumentError, "open_api_polymorphic_property :type_field_name must be an atom, got: " <> inspect(type_field_name) end unless is_binary(discriminator_property) and discriminator_property != "" do raise ArgumentError, "open_api_polymorphic_property :open_api_discriminator_property must be a " <> "non-empty string, got: #{inspect(discriminator_property)}" end unless is_list(variants_ast) and variants_ast != [] do raise ArgumentError, "open_api_polymorphic_property :variants must be a non-empty keyword " <> "list, got: #{inspect(variants_ast)}" end # Resolve each variant module reference through the caller's alias # environment so the type check runs against real module atoms. Module # refs in source code live as {:__aliases__, meta, segments} AST tuples # until this point; Macro.expand/2 is a no-op on already-resolved # atoms, so literal atoms pass through unchanged. variants = Enum.map(variants_ast, fn {wire, mod_ast} when is_atom(wire) -> expanded = Macro.expand(mod_ast, __CALLER__) unless is_atom(expanded) and not is_nil(expanded) do raise ArgumentError, "open_api_polymorphic_property :variants entry #{inspect(wire)} " <> "must reference a module, got: #{Macro.to_string(mod_ast)}" end {wire, expanded} bad -> raise ArgumentError, "open_api_polymorphic_property :variants entry must be a " <> "{atom_wire_value, ModuleRef} pair, got: #{Macro.to_string(bad)}" end) quote do Module.put_attribute( __MODULE__, :open_api_polymorphic_properties, %{ key: unquote(key), type_field_name: unquote(type_field_name), discriminator_property: unquote(discriminator_property), variants: unquote(variants) } ) end end defmacro __before_compile__(%{module: module}) do quote do require Protocol alias ExOpenApiUtils.SchemaDefinition polymorphic_variants = ExOpenApiUtils.__build_polymorphic_variants__( unquote(module), @open_api_polymorphic_properties, @ecto_fields ) # Generate one parent-contextual variant submodule per # (parent, variant, direction) triple. Each new module's schema body # is `allOf: [, ]`, so OpenApiSpex.schema/1's macro walks the allOf via # Schema.properties/1 and builds a defstruct that includes the # discriminator field as a real atom key. Closes GH-30. ExOpenApiUtils.__generate_parent_contextual_variants__( unquote(module), @open_api_polymorphic_properties, polymorphic_variants ) # Derive a Mapper impl for each parent-contextual sibling, matching # the shape the variant's own regular siblings already get. Without # this, Mapper.to_map falls back to the Any fallback on the sibling # and leaks the struct unchanged — OpenApiSpex.Cast can't Access.get # through it on the re-cast path because structs don't implement the # Access behaviour. The property_attrs list is the variant's # reflected attrs plus an inline discriminator %Property{} so the # sibling's Mapper emits the discriminator as a real wire field. # Two distinct discriminator stamps happen on each sibling's Mapper # result map, both derived from the same entry + variant inputs: # # 1. WIRE discriminator (e.g. `:destination_type => "webhook"`) — # `discriminator_prop` is appended to `property_attrs` so # the sibling's walker emits it like any other property. # # 2. ECTO type-field discriminator (e.g. `:__type__ => "webhook"`) # — `self_stamp_atom` / `self_stamp_wire` are read by # `Any.__deriving__/3` (GH-34) and spliced as a final # `Map.put(result, atom, wire)` at the tail of the generated # walker body, so nested polymorphic cases get their Ecto # atom at every level without relying on the outer walker's # `polymorphic_variants` knowing about nested keys. # # GH-47: when a parent declares two `open_api_polymorphic_property`s # with the same variant pool (e.g. two cloud-storage embeds both # backed by [aws: AWS, r2: R2, custom: Custom]), the same target # sibling module would be derived twice and emit a "redefining # module" warning. `__build_dedup_derive_specs__/3` collapses # those duplicates and raises on genuine conflicts. for spec <- ExOpenApiUtils.__build_dedup_derive_specs__( unquote(module), @open_api_polymorphic_properties, polymorphic_variants ) do Protocol.derive( ExOpenApiUtils.Mapper, spec.target_module, property_attrs: spec.property_attrs, map_direction: :from_open_api, polymorphic_variants: polymorphic_variants, self_stamp_atom: spec.self_stamp_atom, self_stamp_wire: spec.self_stamp_wire ) end # Synthesize the two directional %Property{} entries per polymorphic # declaration and append them to @open_api_properties via normal # put_attribute. The attribute is `accumulate: true`, so each put # prepends; the downstream __submodule_spec__/4 loop picks them up # alongside the user's scalar declarations. No attribute rewrite. for decl <- @open_api_polymorphic_properties do {write_prop, read_prop} = ExOpenApiUtils.__synthesize_polymorphic_properties__( unquote(module), decl, polymorphic_variants ) Module.put_attribute(__MODULE__, :open_api_properties, write_prop) Module.put_attribute(__MODULE__, :open_api_properties, read_prop) end 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)), derive?: false) unquote(ExOpenApiUtils.JasonEncoder.build_ast(request_properties)) 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)), derive?: false) unquote(ExOpenApiUtils.JasonEncoder.build_ast(response_properties)) 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, @open_api_properties ) @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 # Builds the reflection index stored under @__ex_open_api_utils_schemas_index__ # and exposed via __ex_open_api_utils_schemas__/0. Each entry carries: # # * title — the user-declared schema title # * request_module / response_module — flat-concat module refs # * request_property_attrs / response_property_attrs — filtered %Property{} # lists that match what __submodule_spec__/4 feeds into each direction's # Module.create/Protocol.derive pair. Carrying them here lets the parent's # __before_compile__ pull them back out by reflection when generating # parent-contextual sibling submodules so the siblings can get a real # Protocol.derive(Mapper, ...) with the same property_attrs shape the # underlying variant's regular siblings get. def __schemas_index__(module, schemas, all_properties) do [root_module | _] = Module.split(module) Enum.map(schemas, fn %SchemaDefinition{title: title} = schema_definition -> %{ title: title, request_module: Module.concat([root_module, "OpenApiSchema", "#{title}Request"]), response_module: Module.concat([root_module, "OpenApiSchema", "#{title}Response"]), request_property_attrs: filter_schema_properties(all_properties, schema_definition, :request), response_property_attrs: filter_schema_properties(all_properties, schema_definition, :response) } end) end defp filter_schema_properties(all_properties, %SchemaDefinition{} = schema_definition, :request) do Enum.filter(all_properties, fn %Property{} = property -> property.key in schema_definition.properties and not is_readOnly?(property.schema) end) end defp filter_schema_properties( all_properties, %SchemaDefinition{} = schema_definition, :response ) do Enum.filter(all_properties, fn %Property{} = property -> property.key in schema_definition.properties and not is_writeOnly?(property.schema) end) end @doc false # Builds the polymorphic_variants map consumed by the Mapper derive calls. # # Reads the parent's `@open_api_polymorphic_properties` declarations # (one per `open_api_polymorphic_property/1` call), and for each: # 1. cross-checks that the declared `:type_field_name` matches the # `type_field_name:` option on the matching `polymorphic_embeds_one`. # 2. cross-checks that the `:variants` keyword list agrees with the # `polymorphic_embeds_one` `:types` option (same atom keys and # same module refs). # 3. reflects on each variant Ecto module via its auto-generated # `__ex_open_api_utils_schemas__/0` helper to pull its title and # its auto-generated `request_module` / `response_module` refs. # 4. pre-computes the flat-concat parent-contextual sibling module # names for both directions. # 5. builds the unified `variant_map` keyed by all three struct # flavors (Ecto struct / parent-contextual request sibling / # parent-contextual response sibling) — consumed by # `Mapper.Polymorphic.inject/5` at runtime. # # Any failure raises a CompileError whose message names the specific # item that diverges. def __build_polymorphic_variants__(parent_module, polymorphic_decls, ecto_fields) do polymorphic_decls |> Enum.reverse() |> Enum.reduce(%{}, fn decl, acc -> entry = build_one_polymorphic_entry!(parent_module, decl, ecto_fields) Map.put(acc, decl.key, entry) end) end defp build_one_polymorphic_entry!(parent_module, decl, ecto_fields) do %{ key: key, type_field_name: type_field_name, discriminator_property: discriminator_property, variants: variants } = 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: "open_api_polymorphic_property(#{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 declared_variant_map = Enum.into(variants, %{}, fn {wire_atom, ecto_mod} -> {to_string(wire_atom), ecto_mod} end) ecto_variant_map = Enum.into(ecto_opts.types_metadata, %{}, fn %{type: type, module: mod} -> {to_string(type), mod} end) check_variants_agree_with_ecto!(key, declared_variant_map, ecto_variant_map) root_module = parent_module |> Module.split() |> hd() parent_title = fetch_parent_title!(parent_module) variant_entries = Enum.map(variants, fn {wire_atom, ecto_mod} -> wire_string = Atom.to_string(wire_atom) reflection = fetch_variant_reflection!(ecto_mod) %{ wire: wire_string, ecto_mod: ecto_mod, variant_title: reflection.title, original_request_submodule: reflection.request_module, original_response_submodule: reflection.response_module, # Property attrs from the variant's own reflection, lifted verbatim # from what __submodule_spec__/4 produced when the variant was # compiled. Used by the parent's __before_compile__ below to derive # a Mapper impl for each parent-contextual sibling, matching the # shape that the variant's regular siblings already get. request_property_attrs: Map.get(reflection, :request_property_attrs, []), response_property_attrs: Map.get(reflection, :response_property_attrs, []), parent_contextual_request_submodule: parent_contextual_sibling_name( root_module, parent_title, reflection.title, "Request" ), parent_contextual_response_submodule: parent_contextual_sibling_name( root_module, parent_title, reflection.title, "Response" ) } end) ecto_mod_by_wire = Map.new(variant_entries, &{&1.wire, &1.ecto_mod}) request_mod_by_wire = Map.new(variant_entries, &{&1.wire, &1.parent_contextual_request_submodule}) response_mod_by_wire = Map.new(variant_entries, &{&1.wire, &1.parent_contextual_response_submodule}) # variant_map keys: all five struct flavors that can represent a given # variant at different points in the pipeline: # * Ecto struct (e.g. %EmailChannel{}) # * original request sibling (e.g. %EmailChannelRequest{}) # * original response sibling (e.g. %EmailChannelResponse{}) # * parent-contextual request sibling (e.g. %NotificationEmailChannelRequest{}) # * parent-contextual response sibling (e.g. %NotificationEmailChannelResponse{}) # All five point at the same wire string. Mapper.Polymorphic.inject/5 # pattern-matches on any of them to look up the wire value to inject # on outbound serialization. Including all five keeps backwards # compatibility for users who construct the regular (non-parent- # contextual) sibling structs manually, while still routing cast # output (which uses the parent-contextual siblings) correctly. variant_map = variant_entries |> Enum.flat_map(fn %{ wire: wire, ecto_mod: em, original_request_submodule: orq, original_response_submodule: ors, parent_contextual_request_submodule: rq, parent_contextual_response_submodule: rs } -> [{em, wire}, {orq, wire}, {ors, wire}, {rq, wire}, {rs, wire}] end) |> Map.new() # Store the atom form of the discriminator propertyName in the # returned map. This flows through `Protocol.derive(..., # polymorphic_variants: ...)` 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 `:channel_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_property, discriminator_atom: String.to_atom(discriminator_property), type_field_atom: type_field_name, ecto_mod_by_wire: ecto_mod_by_wire, request_mod_by_wire: request_mod_by_wire, response_mod_by_wire: response_mod_by_wire, variant_entries: variant_entries } 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: "open_api_polymorphic_property(#{inspect(key)}) declared in " <> "#{inspect(parent_module)} but no matching polymorphic_embeds_one :#{key} " <> "found in the Ecto schema" end end defp check_variants_agree_with_ecto!(key, declared_variant_map, ecto_variant_map) do declared_wires = declared_variant_map |> Map.keys() |> MapSet.new() ecto_wires = ecto_variant_map |> Map.keys() |> MapSet.new() unless declared_wires == ecto_wires do raise CompileError, description: "open_api_polymorphic_property(#{inspect(key)}) :variants wire values " <> "#{inspect(MapSet.to_list(declared_wires))} do not match " <> "polymorphic_embeds_one :#{key} types " <> "#{inspect(MapSet.to_list(ecto_wires))}" end Enum.each(declared_variant_map, fn {wire, declared_mod} -> ecto_mod = Map.fetch!(ecto_variant_map, wire) unless declared_mod == ecto_mod do raise CompileError, description: "open_api_polymorphic_property(#{inspect(key)}) :variants wire value " <> "#{inspect(wire)} references #{inspect(declared_mod)} but " <> "polymorphic_embeds_one :#{key} maps it to #{inspect(ecto_mod)}" end end) :ok end defp fetch_parent_title!(parent_module) do case Module.get_attribute(parent_module, :open_api_schemas) do [%SchemaDefinition{title: title} | _] -> title _ -> raise CompileError, description: "open_api_polymorphic_property/1 on #{inspect(parent_module)} requires at " <> "least one open_api_schema/1 declaration so the library can derive the " <> "parent title used to namespace parent-contextual variant siblings" end end defp fetch_variant_reflection!(ecto_variant_mod) do Code.ensure_compiled!(ecto_variant_mod) unless function_exported?(ecto_variant_mod, :__ex_open_api_utils_schemas__, 0) do raise CompileError, description: "polymorphic variant #{inspect(ecto_variant_mod)} must `use ExOpenApiUtils` — " <> "it does not export __ex_open_api_utils_schemas__/0" end case ecto_variant_mod.__ex_open_api_utils_schemas__() do [entry | _] -> entry _ -> raise CompileError, description: "polymorphic variant #{inspect(ecto_variant_mod)} must have at least one " <> "open_api_schema/1 declaration so the library can read its title and " <> "submodule refs via __ex_open_api_utils_schemas__/0 reflection" end end defp parent_contextual_sibling_name(root_module, parent_title, variant_title, direction_suffix) do Module.concat([ root_module, "OpenApiSchema", parent_title <> variant_title <> direction_suffix ]) end @doc false # Generates one parent-contextual variant submodule per # (parent, variant, direction) triple via Module.create. Each new # module's schema body is `allOf: [, # ]`, and `OpenApiSpex.schema/1`'s macro # walks that allOf via `Schema.properties/1` and builds a defstruct # that includes the discriminator field as a real atom key. No # `@open_api_properties` mutation — that's handled separately by # `__synthesize_polymorphic_properties__/3`. def __generate_parent_contextual_variants__( parent_module, polymorphic_decls, polymorphic_variants ) when is_atom(parent_module) and is_list(polymorphic_decls) and is_map(polymorphic_variants) and map_size(polymorphic_variants) > 0 do # GH-47: dedupe by target module so two decls that share a variant pool # don't call `Module.create/3` twice on the same sibling and emit a # "redefining module" warning. Specs that target the same module but # disagree on body raise a CompileError instead of silently winning. parent_module |> build_sibling_specs(polymorphic_decls, polymorphic_variants) |> dedup_sibling_specs!(parent_module) |> Enum.each(fn spec -> create_parent_contextual_sibling!( spec.original_submodule, spec.target_module, spec.discriminator_atom, spec.wire, spec.property_attrs ) end) :ok end def __generate_parent_contextual_variants__( _parent_module, _polymorphic_decls, _polymorphic_variants ), do: :ok defp build_sibling_specs(_parent_module, polymorphic_decls, polymorphic_variants) do for decl <- polymorphic_decls, entry = Map.fetch!(polymorphic_variants, decl.key), variant <- entry.variant_entries, {target_module, original_submodule, base_attrs} <- [ {variant.parent_contextual_request_submodule, variant.original_request_submodule, variant.request_property_attrs}, {variant.parent_contextual_response_submodule, variant.original_response_submodule, variant.response_property_attrs} ] do discriminator_prop = %ExOpenApiUtils.Property{ key: entry.discriminator_atom, source: entry.discriminator_atom, schema: %OpenApiSpex.Schema{type: :string, enum: [variant.wire]} } %{ decl_key: decl.key, target_module: target_module, original_submodule: original_submodule, discriminator_atom: entry.discriminator_atom, wire: variant.wire, property_attrs: base_attrs ++ [discriminator_prop] } end end defp dedup_sibling_specs!(specs, parent_module) do specs |> Enum.group_by(& &1.target_module) |> Enum.map(fn {target_module, group} -> [first | rest] = group first_body = Map.delete(first, :decl_key) Enum.each(rest, fn other -> if Map.delete(other, :decl_key) != first_body do raise CompileError, description: "open_api_polymorphic_property declarations #{inspect(first.decl_key)} and " <> "#{inspect(other.decl_key)} on #{inspect(parent_module)} both derive " <> "parent-contextual sibling #{inspect(target_module)} but disagree on " <> "discriminator atom, wire value, or property attrs. Either use distinct " <> "variant pools or align the type_field_name/open_api_discriminator_property " <> "across the two declarations." end end) first end) end @doc false # GH-47 partner of `__generate_parent_contextual_variants__/3` for the # Mapper Protocol.derive pass: builds the deduped list of derive specs # called from the parent's `__before_compile__` quote. Same dedup # contract — collapse identical (target_module, body) tuples, raise # on collisions whose bodies disagree. def __build_dedup_derive_specs__(parent_module, polymorphic_decls, polymorphic_variants) when is_atom(parent_module) and is_list(polymorphic_decls) and is_map(polymorphic_variants) do parent_module |> build_sibling_specs(polymorphic_decls, polymorphic_variants) |> dedup_sibling_specs!(parent_module) |> Enum.map(fn spec -> entry = Map.fetch!(polymorphic_variants, spec.decl_key) %{ target_module: spec.target_module, property_attrs: spec.property_attrs, self_stamp_atom: entry.type_field_atom, self_stamp_wire: spec.wire } end) end def __build_dedup_derive_specs__(_parent_module, _polymorphic_decls, _polymorphic_variants), do: [] defp create_parent_contextual_sibling!( original_submodule, new_sibling, discriminator_atom, wire_value, property_attrs ) do body = %{ type: :object, allOf: [ original_submodule, %OpenApiSpex.Schema{ type: :object, properties: %{ discriminator_atom => %OpenApiSpex.Schema{type: :string, enum: [wire_value]} }, required: [discriminator_atom] } ] } Module.create( new_sibling, quote do require OpenApiSpex OpenApiSpex.schema(unquote(Macro.escape(body)), derive?: false) unquote(ExOpenApiUtils.JasonEncoder.build_ast(property_attrs)) end, Macro.Env.location(__ENV__) ) end @doc false # Synthesises the writeOnly and readOnly %Property{} entries for a # single `open_api_polymorphic_property/1` declaration. These are # appended to the parent's `@open_api_properties` via normal # `Module.put_attribute` calls inside `__before_compile__` — no # attribute rewrite. Each property's `schema.oneOf` and # `schema.discriminator.mapping` already point at the parent-contextual # siblings, so the downstream `__submodule_spec__/4` loop picks them # up verbatim. def __synthesize_polymorphic_properties__(_parent_module, decl, polymorphic_variants) do entry = Map.fetch!(polymorphic_variants, decl.key) discriminator_string = entry.discriminator_string request_mapping = Enum.into(entry.variant_entries, %{}, fn variant -> {variant.wire, variant.parent_contextual_request_submodule} end) response_mapping = Enum.into(entry.variant_entries, %{}, fn variant -> {variant.wire, variant.parent_contextual_response_submodule} end) request_one_of = Enum.map(entry.variant_entries, & &1.parent_contextual_request_submodule) response_one_of = Enum.map(entry.variant_entries, & &1.parent_contextual_response_submodule) write_schema = %OpenApiSpex.Schema{ type: :object, writeOnly: true, oneOf: request_one_of, discriminator: %OpenApiSpex.Discriminator{ propertyName: discriminator_string, mapping: request_mapping } } read_schema = %OpenApiSpex.Schema{ type: :object, readOnly: true, oneOf: response_one_of, discriminator: %OpenApiSpex.Discriminator{ propertyName: discriminator_string, mapping: response_mapping } } write_prop = %Property{key: decl.key, source: decl.key, schema: write_schema} read_prop = %Property{key: decl.key, source: decl.key, schema: read_schema} {write_prop, read_prop} 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