AshOaskit.SchemaBuilder (AshOasKit v0.2.0)

View Source

Accumulator-based schema builder for recursive type handling.

This module provides the core infrastructure for building OpenAPI schemas from Ash resources, with proper handling of recursive types, embedded resources, relationships, and cycle detection.

Overview

The SchemaBuilder uses an accumulator pattern (similar to AshJsonApi.OpenApi) to collect schemas during spec generation. This pattern enables:

  • Cycle Detection: Prevents infinite loops when resources reference themselves
  • Schema Deduplication: Each schema is generated once and referenced via $ref
  • Ordered Generation: Ensures all referenced schemas exist in components

Architecture

The builder maintains state through a struct containing:

  • schemas - Map of schema name to schema definition
  • seen_types - MapSet of types already processed (output schemas)
  • seen_input_types - MapSet of input types already processed
  • version - OpenAPI version ("3.0" or "3.1") for nullable handling

Module Organization

The SchemaBuilder delegates to focused submodules:

Usage

# Initialize a new builder
builder = SchemaBuilder.new(version: "3.1")

# Add schemas for a resource
builder = SchemaBuilder.add_resource_schemas(builder, MyResource)

# Extract final components
components = SchemaBuilder.to_components(builder)

Schema Naming Conventions

Schema TypeNaming PatternExample
Output attributes{Resource}AttributesPostAttributes
Output response{Resource}ResponsePostResponse
Create input{Resource}CreateInputPostCreateInput
Update input{Resource}UpdateInputPostUpdateInput
Relationships{Resource}RelationshipsPostRelationships
Relationship linkage{Resource}{Rel}LinkagePostCommentsLinkage
Embedded output{Embedded}Address
Embedded input{Embedded}InputAddressInput

Cycle Detection

When building schemas for types that may reference themselves (directly or indirectly), the builder tracks seen types to prevent infinite recursion:

# Self-referential type (e.g., Category with parent)
defmodule Category do
  relationships do
    belongs_to :parent, __MODULE__
    has_many :children, __MODULE__
  end
end

When a cycle is detected, the builder emits a $ref instead of inlining:

%{"$ref" => "#/components/schemas/Category"}

Integration with Generators

This module is used by both V30 and V31 generators to build schemas:

defmodule AshOaskit.Generators.V31 do
  def generate(domains, opts) do
    builder = SchemaBuilder.new(version: "3.1")
    builder = Enum.reduce(domains, builder, &add_domain_schemas/2)

    %{
      "components" => SchemaBuilder.to_components(builder)
    }
  end
end

Error Handling

The builder gracefully handles edge cases:

  • Missing resources: Returns empty schemas
  • Private attributes: Excluded from schemas
  • Function defaults: Omitted (can't serialize to JSON)
  • Unknown types: Falls back to empty schema {}

Summary

Types

t()

The SchemaBuilder accumulator map.

Functions

Adds all schemas for a resource to the builder.

Adds a schema to the builder.

Gets a schema by name from the builder.

Checks if a schema with the given name exists in the builder.

Checks if a type has been seen for input schema generation.

Marks a type as seen for input schema generation.

Marks a type as seen for output schema generation.

Creates a new SchemaBuilder with the given options.

Generates the schema name for a resource.

Returns the count of schemas in the builder.

Lists all schema names in the builder.

Checks if a type has been seen for output schema generation.

Converts the builder's schemas to an OpenAPI components object.

Gets the OpenAPI version from the builder.

Types

t()

@type t() :: map()

The SchemaBuilder accumulator map.

Fields

  • :schemas - Map of schema name (string) to schema definition (map)
  • :seen_types - MapSet of modules already processed for output schemas
  • :seen_input_types - MapSet of modules already processed for input schemas
  • :version - OpenAPI version string ("3.0" or "3.1")

Functions

add_resource_schemas(builder, resource, opts \\ [])

@spec add_resource_schemas(t(), module(), keyword()) :: t()

Adds all schemas for a resource to the builder.

This includes:

  • Attributes schema ({Resource}Attributes)
  • Response schema ({Resource}Response)
  • Relationships schema if the resource has relationships
  • Action-derived input schemas ({Resource}{Action}Input)

Options

  • :input_actions - List of {action_name, route} tuples to derive input schemas from (route may be nil). Defaults to the resource's primary create and update actions.

Parameters

  • builder - The current SchemaBuilder
  • resource - The Ash resource module
  • opts - Options (see above)

Returns

Updated SchemaBuilder with all resource schemas added.

add_schema(builder, name, schema)

@spec add_schema(t(), String.t(), map()) :: t()

Adds a schema to the builder.

If a schema with the same name already exists, it is not overwritten. This ensures the first definition wins (important for recursive types where we want the full definition, not a placeholder).

Parameters

  • builder - The current SchemaBuilder
  • name - Schema name (string)
  • schema - Schema definition (map)

Returns

Updated SchemaBuilder with the new schema added.

Examples

iex> builder = AshOaskit.SchemaBuilder.new()
...> schema = %{type: :object, properties: %{}}
...> builder = AshOaskit.SchemaBuilder.add_schema(builder, "Post", schema)
...> AshOaskit.SchemaBuilder.has_schema?(builder, "Post")
true

get_schema(map, name)

@spec get_schema(t(), String.t()) :: map() | nil

Gets a schema by name from the builder.

Parameters

  • builder - The current SchemaBuilder
  • name - The schema name to retrieve

Returns

The schema map if found, nil otherwise.

has_schema?(map, name)

@spec has_schema?(t(), String.t()) :: boolean()

Checks if a schema with the given name exists in the builder.

Parameters

  • builder - The current SchemaBuilder
  • name - Schema name to check

Returns

true if the schema exists, false otherwise.

Examples

iex> builder = AshOaskit.SchemaBuilder.new()
...> AshOaskit.SchemaBuilder.has_schema?(builder, "Post")
false

iex> builder = AshOaskit.SchemaBuilder.new()
...> builder = AshOaskit.SchemaBuilder.add_schema(builder, "Post", %{})
...> AshOaskit.SchemaBuilder.has_schema?(builder, "Post")
true

input_seen?(map, type)

@spec input_seen?(t(), module()) :: boolean()

Checks if a type has been seen for input schema generation.

Parameters

  • builder - The current SchemaBuilder
  • type - The type module to check

Returns

true if the input type has been seen, false otherwise.

mark_input_seen(builder, type)

@spec mark_input_seen(t(), module()) :: t()

Marks a type as seen for input schema generation.

Input schemas are tracked separately from output schemas because they may have different structures (e.g., different required fields for create vs update operations).

Parameters

  • builder - The current SchemaBuilder
  • type - The type module to mark as seen

Returns

Updated SchemaBuilder with the input type marked as seen.

mark_seen(builder, type)

@spec mark_seen(t(), module()) :: t()

Marks a type as seen for output schema generation.

Used to detect cycles in recursive type definitions. When a type is marked as seen, subsequent calls to seen?/2 will return true, allowing the builder to emit a $ref instead of recursing infinitely.

Parameters

  • builder - The current SchemaBuilder
  • type - The type module to mark as seen

Returns

Updated SchemaBuilder with the type marked as seen.

Examples

iex> builder = AshOaskit.SchemaBuilder.new()
...> builder = AshOaskit.SchemaBuilder.mark_seen(builder, MyApp.Post)
...> AshOaskit.SchemaBuilder.seen?(builder, MyApp.Post)
true

new(opts \\ [])

@spec new(keyword()) :: t()

Creates a new SchemaBuilder with the given options.

Options

  • :version - OpenAPI version ("3.0" or "3.1"). Defaults to "3.1".

Examples

iex> builder = AshOaskit.SchemaBuilder.new()
...> builder.version
"3.1"

iex> builder = AshOaskit.SchemaBuilder.new(version: "3.0")
...> builder.version
"3.0"

resource_schema_name(resource)

@spec resource_schema_name(module()) :: String.t()

Generates the schema name for a resource.

Extracts the last part of the module name (e.g., MyApp.Blog.Post -> Post).

Parameters

  • resource - The Ash resource module

Returns

The schema name string.

Examples

iex> AshOaskit.SchemaBuilder.resource_schema_name(MyApp.Blog.Post)
"Post"

schema_count(map)

@spec schema_count(t()) :: non_neg_integer()

Returns the count of schemas in the builder.

Parameters

  • builder - The current SchemaBuilder

Returns

The number of schemas.

schema_names(map)

@spec schema_names(t()) :: [String.t()]

Lists all schema names in the builder.

Parameters

  • builder - The current SchemaBuilder

Returns

A list of schema name strings.

seen?(map, type)

@spec seen?(t(), module()) :: boolean()

Checks if a type has been seen for output schema generation.

Parameters

  • builder - The current SchemaBuilder
  • type - The type module to check

Returns

true if the type has been seen, false otherwise.

Examples

iex> builder = AshOaskit.SchemaBuilder.new()
...> AshOaskit.SchemaBuilder.seen?(builder, MyApp.Post)
false

to_components(map)

@spec to_components(t()) :: map()

Converts the builder's schemas to an OpenAPI components object.

Parameters

  • builder - The current SchemaBuilder

Returns

A map suitable for the components section of an OpenAPI spec:

%{
  schemas: %{
    "PostAttributes" => %{...},
    "PostResponse" => %{...},
    ...
  }
}

Examples

iex> builder = AshOaskit.SchemaBuilder.new()
...> builder = AshOaskit.SchemaBuilder.add_schema(builder, "Post", %{type: :object})
...> components = AshOaskit.SchemaBuilder.to_components(builder)
...> Map.has_key?(components.schemas, "Post")
true

version(map)

@spec version(t()) :: String.t()

Gets the OpenAPI version from the builder.

Parameters

  • builder - The current SchemaBuilder

Returns

The OpenAPI version string ("3.0" or "3.1").