AshIntrospection

View Source

Elixir CI License: MIT Hex version badge Hexdocs badge

Alpha Software: This library is under active development. APIs may change without notice between versions. Use in production at your own risk.

Shared core library for Ash interoperability with multiple languages

AshIntrospection provides the foundational modules used by language-specific generators like AshTypescript and AshKotlinMultiplatform. It enables seamless RPC communication between Elixir/Ash backends and clients in TypeScript, Kotlin, Swift, and other languages.

Start here

docs/PROJECT.md is the project's source of truth: what this library is, how it relates to ash_typescript and ash_kotlin_multiplatform, what is on the roadmap, which risks are live, and the decisions that still shape the code. Read it before changing anything.

  • Architecture — C4 views and the four-stage RPC pipeline
  • Roadmap — shipped, next, and decided against
  • Risks — what we watch and what we would do
  • Decisions — dated, each with what it cost

Contributors should also read CLAUDE.md for this repo's working rules and its traps.

Features

  • Unified Type Introspection - Consistent type classification and analysis across all Ash types
  • Language-Agnostic RPC Pipeline - Execute Ash actions with field selection, filtering, and pagination
  • Bidirectional Field Name Mapping - Convert between snake_case (Elixir) and camelCase (clients)
  • Type-Driven Value Formatting - Format values based on their Ash types for input/output
  • Comprehensive Error Handling - Standardized error responses with field paths and interpolation
  • Code Generation Utilities - Type discovery, action introspection, and validation error classification

Installation

Add to your mix.exs:

def deps do
  [
    {:ash_introspection, "~> 0.3"}
  ]
end

Upgrading

Breaking releases ship a codemod. After bumping the version, run:

mix igniter.upgrade ash_introspection

It rewrites what it can decide safely and prints the shapes it cannot, so the search left to you is short. CHANGELOG.md says what each release breaks.

Architecture Overview


           Language-Specific Generators                      
        (AshTypescript, AshKotlinMultiplatform)             

                      delegates to
                     

         AshIntrospection (Shared Core Library)             
                                                             
     
    Type System                                           
     Introspection - Type classification & unwrap        
     ResourceFields - Field type lookup                  
     
                                                             
     
    RPC Pipeline (4-Stage)                                
     Stage 1: Parse request (language-specific)          
     Stage 2: Execute Ash action                         
     Stage 3: Process result (extract fields)            
     Stage 4: Format output (convert field names)        
     
                                                             
     
    Code Generation                                       
     TypeDiscovery - Resource & type scanning            
     ActionIntrospection - Action analysis               
     ValidationErrorTypes - Error type classification    
     

                     
                     

                    Ash Framework                            
          (Resources, Types, Queries, Changesets)           

Module Reference

Type System

ModuleDescription
AshIntrospection.TypeSystem.IntrospectionCore type classification, NewType unwrapping, union extraction
AshIntrospection.TypeSystem.ResourceFieldsUnified field lookup for attributes, calculations, relationships

RPC Runtime

ModuleDescription
AshIntrospection.Rpc.RequestRequest data structure for the RPC pipeline
AshIntrospection.Rpc.PipelineLanguage-agnostic 4-stage action execution pipeline
AshIntrospection.Rpc.ValueFormatterBidirectional type-driven value formatting
AshIntrospection.Rpc.ResultProcessorField extraction from action results
AshIntrospection.Rpc.FieldExtractorUnified extraction for maps, structs, keywords, tuples

Field Processing

ModuleDescription
AshIntrospection.Rpc.FieldProcessing.AtomizerConvert client field names to atoms
AshIntrospection.Rpc.FieldProcessing.FieldSelectorType-driven recursive field selection
AshIntrospection.Rpc.FieldProcessing.ValidationDuplicate detection and field validation

Error Handling

ModuleDescription
AshIntrospection.Rpc.ErrorProtocol for extracting exception information
AshIntrospection.Rpc.ErrorBuilderComprehensive error message generation
AshIntrospection.Rpc.ErrorsCentral error processing pipeline
AshIntrospection.Rpc.DefaultErrorHandlerPass-through error handler

Client-facing error messages are deliberately terse: a policy failure returns "forbidden" and an unrecognised exception returns "Something went wrong", so neither the authorization rules nor the crash text reach the caller. To see the policy breakdown while debugging, opt in explicitly:

config :ash_introspection, :policies, show_policy_breakdowns?: true

That setting is separate from Ash's own config :ash, :policies, show_policy_breakdowns?: true on purpose — an app-wide development toggle must not be able to open RPC responses. Never enable it in production.

Code Generation

ModuleDescription
AshIntrospection.Codegen.TypeDiscoveryRecursive resource and type scanning
AshIntrospection.Codegen.ActionIntrospectionAction pagination, input, and return type analysis
AshIntrospection.Codegen.ValidationErrorTypesValidation error type classification

Formatting Utilities

ModuleDescription
AshIntrospection.FieldFormatterField name formatting (camelCase, PascalCase, snake_case)
AshIntrospection.HelpersLow-level case conversion utilities

RPC Pipeline

The RPC pipeline executes Ash actions in four stages:

Stage 1: Parse Request (Language-Specific)

Implemented by each language generator. Parses and validates client input, builds the Request struct.

Stage 2: Execute Ash Action

{:ok, result} = AshIntrospection.Rpc.Pipeline.execute_ash_action(request, config)

Executes read, create, update, destroy, or generic actions with proper authorization.

Stage 3: Process Result

{:ok, filtered} = AshIntrospection.Rpc.Pipeline.process_result(result, request, config)

Applies field selection using the pre-computed extraction template.

Stage 4: Format Output

formatted = AshIntrospection.Rpc.Pipeline.format_output_with_request(filtered, request, config)

Converts field names and structures for client consumption.

Pipeline Configuration

config = %{
  input_field_formatter: :camel_case,      # Parse camelCase from client
  output_field_formatter: :camel_case,     # Output camelCase to client
  field_names_callback: :interop_field_names,
  not_found_error?: true,                  # Return error for missing records
  get_original_field_name: fn resource, client_key ->
    # Custom field name resolution
  end,
  format_field_for_client: fn field_name, resource, formatter ->
    # Custom field name formatting
  end
}

Field Name Mapping

The interop_field_names/0 Callback

Types can define field name mappings for client compatibility:

defmodule MyApp.TaskStats do
  use Ash.Type.NewType,
    subtype_of: :map,
    constraints: [
      fields: [
        is_active?: [type: :boolean],
        task_count: [type: :integer]
      ]
    ]

  # Map invalid identifiers to valid client names
  def interop_field_names do
    [
      is_active?: "isActive",
      task_count: "taskCount"
    ]
  end
end

The interop_type_name/0 Callback

Custom types can specify their representation in generated code:

defmodule MyApp.Money do
  use Ash.Type

  def interop_type_name, do: "Money"

  # ... type implementation
end

Type-Driven Dispatch

Many modules use a unified dispatch pattern based on {type, constraints} tuples:

# ValueFormatter dispatches based on type
ValueFormatter.format(value, Ash.Type.Map, [fields: [...]], :output, config)

# ResultProcessor extracts fields based on type
ResultProcessor.process(result, template, resource, config)

# FieldSelector validates based on type
FieldSelector.process(fields, resource, action, config)

This makes types self-describing and enables consistent handling across all modules.

Code Generation Utilities

Type Discovery

Scan resources to find all referenced types:

alias AshIntrospection.Codegen.TypeDiscovery

# Find all resources referenced by RPC resources
{:ok, resources} = TypeDiscovery.scan_rpc_resources(rpc_resources, domain)

# Find embedded resources
embedded = TypeDiscovery.find_embedded_resources(resources, domain)

# Find types with field constraints
typed_structs = TypeDiscovery.find_field_constrained_types(resources, domain)

Action Introspection

Analyze action characteristics:

alias AshIntrospection.Codegen.ActionIntrospection

# Pagination support
ActionIntrospection.action_supports_pagination?(action)
ActionIntrospection.action_supports_offset_pagination?(action)
ActionIntrospection.action_supports_keyset_pagination?(action)

# Input requirements
ActionIntrospection.action_input_type(resource, action)  # :required | :optional | :none
ActionIntrospection.get_required_inputs(resource, action)
ActionIntrospection.get_optional_inputs(resource, action)

# Return type analysis for generic actions
ActionIntrospection.action_returns_field_selectable_type?(action)
# => {:ok, :resource, MyApp.User}
# => {:ok, :array_of_resource, MyApp.User}
# => {:ok, :typed_map, [field: [type: :string]]}
# => {:error, :not_field_selectable_type}

Validation Error Types

Classify validation error types for code generation:

alias AshIntrospection.Codegen.ValidationErrorTypes

# Classify a type's error structure
{:ok, classification} = ValidationErrorTypes.classify_error_type(type, constraints)
# => {:primitive_errors, nil}
# => {:resource_errors, MyApp.Address}
# => {:typed_container_errors, [{:name, {:primitive_errors, nil}}, ...]}
# => {:array_errors, {:resource_errors, MyApp.Item}}

# Classify action input errors
classifications = ValidationErrorTypes.classify_action_input_errors(resource, action)
# => [{:title, {:primitive_errors, nil}, %Ash.Resource.Attribute{...}}, ...]

Integrating a New Language

This section guides you through creating a new language generator (e.g., AshKotlin, AshSwift, AshGo) using AshIntrospection.

Overview

A language generator typically provides:

  1. Code Generation - Generate types, interfaces, and RPC client functions
  2. RPC Runtime - Execute Ash actions from client requests
  3. DSL Extensions - Configure which actions to expose

Step 1: Project Setup

Create a new Elixir package:

# mix.exs
defmodule AshKotlin.MixProject do
  use Mix.Project

  def project do
    [
      app: :ash_kotlin,
      version: "0.1.0",
      deps: deps()
    ]
  end

  defp deps do
    [
      {:ash, "~> 3.0"},
      {:ash_introspection, "~> 0.3"},
      {:spark, "~> 2.0"}
    ]
  end
end

Step 2: Type Mapping

Create a module to map Ash types to your target language:

defmodule AshKotlin.Codegen.TypeMapper do
  alias AshIntrospection.TypeSystem.Introspection

  @primitive_types %{
    Ash.Type.String => "String",
    Ash.Type.Integer => "Int",
    Ash.Type.Float => "Double",
    Ash.Type.Boolean => "Boolean",
    Ash.Type.UUID => "String",
    Ash.Type.Date => "LocalDate",
    Ash.Type.DateTime => "Instant"
  }

  def map_type(type, constraints \\ []) do
    # Unwrap NewTypes first
    {unwrapped, full_constraints} = Introspection.unwrap_new_type(type, constraints)

    cond do
      # Arrays
      match?({:array, _}, type) ->
        {:array, inner} = type
        inner_type = map_type(inner, Keyword.get(constraints, :items, []))
        "List<#{inner_type}>"

      # Primitives
      Map.has_key?(@primitive_types, unwrapped) ->
        Map.get(@primitive_types, unwrapped)

      # Embedded resources
      Introspection.is_embedded_resource?(unwrapped) ->
        build_type_name(unwrapped)

      # Custom types with interop_type_name
      Introspection.is_custom_interop_type?(unwrapped) ->
        unwrapped.interop_type_name()

      # Typed structs
      Introspection.has_field_constraints?(full_constraints) ->
        instance_of = Keyword.get(full_constraints, :instance_of)
        if instance_of, do: build_type_name(instance_of), else: "Map<String, Any>"

      # Unions
      unwrapped == Ash.Type.Union ->
        "Any"  # Or generate sealed class

      # Fallback
      true ->
        "Any"
    end
  end

  defp build_type_name(module) do
    module |> Module.split() |> List.last()
  end
end

Step 3: Code Generation

Generate types and RPC functions:

defmodule AshKotlin.Codegen.Generator do
  alias AshIntrospection.Codegen.{TypeDiscovery, ActionIntrospection}
  alias AshKotlin.Codegen.TypeMapper

  def generate(domain, rpc_config) do
    # Discover all types
    {:ok, resources} = TypeDiscovery.scan_rpc_resources(rpc_config.resources, domain)
    embedded = TypeDiscovery.find_embedded_resources(resources, domain)

    # Generate data classes
    type_definitions = Enum.map(embedded, &generate_data_class/1)

    # Generate RPC functions
    rpc_functions = Enum.flat_map(rpc_config.resources, fn {resource, actions} ->
      Enum.map(actions, fn action_config ->
        action = Ash.Resource.Info.action(resource, action_config.action)
        generate_rpc_function(resource, action, action_config)
      end)
    end)

    combine_output(type_definitions, rpc_functions)
  end

  defp generate_data_class(resource) do
    attrs = Ash.Resource.Info.public_attributes(resource)
    name = TypeMapper.build_type_name(resource)

    fields = Enum.map(attrs, fn attr ->
      type = TypeMapper.map_type(attr.type, attr.constraints)
      nullable = if attr.allow_nil?, do: "?", else: ""
      "  val #{to_camel_case(attr.name)}: #{type}#{nullable}"
    end)

    """
    data class #{name}(
    #{Enum.join(fields, ",\n")}
    )
    """
  end

  defp generate_rpc_function(resource, action, config) do
    name = config.name
    input_type = ActionIntrospection.action_input_type(resource, action)

    # Generate based on action type and input requirements
    # ...
  end
end

Step 4: RPC Runtime

Create your language-specific RPC pipeline wrapper:

defmodule AshKotlin.Rpc.Pipeline do
  alias AshIntrospection.Rpc.{Pipeline, Request, Errors}
  alias AshIntrospection.Rpc.FieldProcessing.FieldSelector

  @config %{
    input_field_formatter: :camel_case,
    output_field_formatter: :camel_case,
    field_names_callback: :interop_field_names,
    not_found_error?: true
  }

  def execute(params, opts \\ []) do
    with {:ok, request} <- parse_request(params, opts),
         {:ok, result} <- Pipeline.execute_ash_action(request, @config),
         {:ok, processed} <- Pipeline.process_result(result, request, @config) do
      formatted = Pipeline.format_output_with_request(processed, request, @config)
      {:ok, %{success: true, data: formatted}}
    else
      {:error, error} ->
        errors = Errors.to_errors(error, request, @config)
        {:ok, %{success: false, errors: errors}}
    end
  end

  defp parse_request(params, opts) do
    # Stage 1: Parse and validate input
    # This is language-specific!

    with {:ok, {domain, resource, action}} <- discover_action(params),
         {:ok, input} <- parse_input(params, resource, action),
         {:ok, fields} <- parse_fields(params, resource, action) do

      # Process field selection
      {:ok, {select, load, template}} =
        FieldSelector.process(fields, resource, action, @config)

      request = %Request{
        domain: domain,
        resource: resource,
        action: action,
        input: input,
        select: select,
        load: load,
        extraction_template: template,
        actor: opts[:actor],
        tenant: opts[:tenant]
      }

      {:ok, request}
    end
  end
end

Step 5: DSL Extension (Optional)

Add a Spark DSL for configuration:

defmodule AshKotlin.Rpc do
  use Spark.Dsl.Extension

  @sections [
    %Spark.Dsl.Section{
      name: :kotlin_rpc,
      entities: [
        %Spark.Dsl.Entity{
          name: :resource,
          args: [:resource],
          schema: [
            resource: [type: :atom, required: true]
          ],
          entities: [
            %Spark.Dsl.Entity{
              name: :rpc_action,
              args: [:name, :action],
              schema: [
                name: [type: :atom, required: true],
                action: [type: :atom, required: true],
                identities: [type: {:list, :atom}, default: [:_primary_key]]
              ]
            }
          ]
        }
      ]
    }
  ]
end

Common Pitfalls

1. NewType Unwrapping

Always unwrap NewTypes before type classification:

# WRONG - May fail for NewTypes
if Introspection.is_embedded_resource?(type), do: ...

# CORRECT - Unwrap first
{unwrapped, constraints} = Introspection.unwrap_new_type(type, constraints)
if Introspection.is_embedded_resource?(unwrapped), do: ...

2. Field Name Callback Precedence

Check language-specific callbacks before falling back:

# Check typescript_field_names first, then interop_field_names
field_names = cond do
  function_exported?(module, :kotlin_field_names, 0) ->
    module.kotlin_field_names()
  Introspection.has_interop_field_names?(module) ->
    Introspection.get_interop_field_names_map(module)
  true ->
    %{}
end

3. Constraint Preservation

When processing types, preserve constraints through the pipeline:

# Constraints contain important type information
{type, constraints} = Introspection.unwrap_new_type(attr.type, attr.constraints)

# Pass constraints to child processors
inner_constraints = Keyword.get(constraints, :items, [])
process_inner_type(inner_type, inner_constraints)

4. Cycle Detection in Type Discovery

Always track visited types to prevent infinite loops:

defp traverse_types(types, visited \\ MapSet.new()) do
  Enum.flat_map(types, fn type ->
    if MapSet.member?(visited, type) do
      []  # Already visited, skip
    else
      visited = MapSet.put(visited, type)
      [type | traverse_types(get_nested_types(type), visited)]
    end
  end)
end

5. Identity Handling for Updates/Deletes

Support multiple identity types:

# Primary key (simple value)
identity: "uuid-123"

# Primary key (composite)
identity: %{org_id: "org-1", user_id: "user-1"}

# Named identity
identity: %{email: "user@example.com"}

identity selects the record an update or destroy acts on. A read selects one with get_by, and a read carrying identity is rejected with identity_not_supported rather than having the parameter dropped. Identity values are equality operands: a map or list value is rejected as invalid_identity, and so is null.

6. Pagination Response Handling

Handle both paginated and non-paginated responses:

case result do
  %Ash.Page.Offset{results: results, count: count} ->
    %{results: process_results(results), count: count}

  %Ash.Page.Keyset{results: results} ->
    %{results: process_results(results)}

  results when is_list(results) ->
    process_results(results)

  single_result ->
    process_result(single_result)
end

7. Error Field Path Formatting

Convert field paths to client format:

# Ash returns: [:user, :address, :street]
# Client expects: ["user", "address", "street"] with camelCase
path = Enum.map(error.path, fn
  field when is_atom(field) -> to_camel_case(field)
  index when is_integer(index) -> Integer.to_string(index)
end)

8. Generic Action Return Types

Not all generic actions return field-selectable types:

case ActionIntrospection.action_returns_field_selectable_type?(action) do
  {:ok, :resource, module} ->
    # Can select fields, generate typed response
    generate_typed_response(module)

  {:ok, :typed_map, fields} ->
    # Can select fields from inline type
    generate_inline_response(fields)

  {:error, :not_field_selectable_type} ->
    # Returns primitive, no field selection
    generate_primitive_response(action.returns)

  {:error, :not_generic_action} ->
    # Not a generic action, use standard CRUD handling
    handle_crud_action(action)
end

Requirements

  • Elixir 1.15 or later
  • Ash 3.0 or later

Contributing

Contributions are welcome! Please:

  1. Fork the repository
  2. Create a feature branch
  3. Make your changes with tests
  4. Ensure all tests pass (mix test)
  5. Run code formatter (mix format)
  6. Open a Pull Request

License

This project is licensed under the MIT License.

Support