<!--
SPDX-FileCopyrightText: 2025 ash_introspection contributors
SPDX-License-Identifier: MIT
-->
# AshIntrospection

[](https://opensource.org/licenses/MIT)
[](https://hex.pm/packages/ash_introspection)
[](https://hexdocs.pm/ash_introspection)
> **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](https://github.com/ash-project/ash_typescript) and AshKotlinMultiplatform. It enables seamless RPC communication between Elixir/Ash backends and clients in TypeScript, Kotlin, Swift, and other languages.
## 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`:
```elixir
def deps do
[
{:ash_introspection, "~> 0.2"}
]
end
```
## 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
| Module | Description |
|--------|-------------|
| `AshIntrospection.TypeSystem.Introspection` | Core type classification, NewType unwrapping, union extraction |
| `AshIntrospection.TypeSystem.ResourceFields` | Unified field lookup for attributes, calculations, relationships |
### RPC Runtime
| Module | Description |
|--------|-------------|
| `AshIntrospection.Rpc.Request` | Request data structure for the RPC pipeline |
| `AshIntrospection.Rpc.Pipeline` | Language-agnostic 4-stage action execution pipeline |
| `AshIntrospection.Rpc.ValueFormatter` | Bidirectional type-driven value formatting |
| `AshIntrospection.Rpc.ResultProcessor` | Field extraction from action results |
| `AshIntrospection.Rpc.FieldExtractor` | Unified extraction for maps, structs, keywords, tuples |
### Field Processing
| Module | Description |
|--------|-------------|
| `AshIntrospection.Rpc.FieldProcessing.Atomizer` | Convert client field names to atoms |
| `AshIntrospection.Rpc.FieldProcessing.FieldSelector` | Type-driven recursive field selection |
| `AshIntrospection.Rpc.FieldProcessing.Validation` | Duplicate detection and field validation |
### Error Handling
| Module | Description |
|--------|-------------|
| `AshIntrospection.Rpc.Error` | Protocol for extracting exception information |
| `AshIntrospection.Rpc.ErrorBuilder` | Comprehensive error message generation |
| `AshIntrospection.Rpc.Errors` | Central error processing pipeline |
| `AshIntrospection.Rpc.DefaultErrorHandler` | Pass-through error handler |
### Code Generation
| Module | Description |
|--------|-------------|
| `AshIntrospection.Codegen.TypeDiscovery` | Recursive resource and type scanning |
| `AshIntrospection.Codegen.ActionIntrospection` | Action pagination, input, and return type analysis |
| `AshIntrospection.Codegen.ValidationErrorTypes` | Validation error type classification |
### Formatting Utilities
| Module | Description |
|--------|-------------|
| `AshIntrospection.FieldFormatter` | Field name formatting (camelCase, PascalCase, snake_case) |
| `AshIntrospection.Helpers` | Low-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
```elixir
{: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
```elixir
{:ok, filtered} = AshIntrospection.Rpc.Pipeline.process_result(result, request, config)
```
Applies field selection using the pre-computed extraction template.
### Stage 4: Format Output
```elixir
formatted = AshIntrospection.Rpc.Pipeline.format_output_with_request(filtered, request, config)
```
Converts field names and structures for client consumption.
### Pipeline Configuration
```elixir
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:
```elixir
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:
```elixir
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:
```elixir
# 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:
```elixir
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:
```elixir
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:
```elixir
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:
```elixir
# 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.2"},
{:spark, "~> 2.0"}
]
end
end
```
## Step 2: Type Mapping
Create a module to map Ash types to your target language:
```elixir
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:
```elixir
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:
```elixir
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:
```elixir
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:
```elixir
# 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:
```elixir
# 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:
```elixir
# 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:
```elixir
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:
```elixir
# 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"}
```
### 6. Pagination Response Handling
Handle both paginated and non-paginated responses:
```elixir
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:
```elixir
# 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:
```elixir
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
- **Documentation**: [https://hexdocs.pm/ash_introspection](https://hexdocs.pm/ash_introspection)
- **GitHub Issues**: [https://github.com/ash-project/ash_introspection/issues](https://github.com/ash-project/ash_introspection/issues)
- **Discord**: [Ash Framework Discord](https://discord.gg/HTHRaaVPUc)