Language-agnostic RPC pipeline for Ash actions.
This module implements stages 2 through 4 of the four-stage pipeline:
- execute_ash_action/2 - Execute Ash operations
- process_result/3 - Apply field selection
- format_output/3 - Format for client consumption
Stage 1, parsing and validating client input, is not implemented here and
there is no parse_request/3 in this module. It is language-specific and is
a consumer's responsibility: build the %Request{} this pipeline expects
(and the load/select statement, via Rpc.FieldProcessing.FieldSelector)
before calling into it. See "Action metadata" below for what that leaves
unenforced when a wrapper skips its own parse stage.
Configuration
The pipeline is configured via a config map that provides all the language-specific behavior through callbacks:
%{
input_field_formatter: :camel_case,
output_field_formatter: :camel_case,
field_names_callback: :interop_field_names,
get_original_field_name: fn resource, client_key -> ... end,
format_field_for_client: fn field_name, resource, formatter -> ... end,
not_found_error?: true
}Usage
Language-specific wrappers (e.g., AshTypescript.Rpc.Pipeline) should:
- Build the config map with their specific callbacks
- Call the shared pipeline functions with that config
- Handle any language-specific pre/post processing
This allows each language generator to customize the behavior while sharing the core pipeline logic.
Action metadata
Request.show_metadata names the metadata fields stage 3 extracts, and this
pipeline extracts exactly what it is given. Deciding which metadata fields
a client may ask for is the caller's job, not this pipeline's. There is no
parse stage here — parse_request/3 lives in the language-specific wrapper
— so nothing in this library filters a client-supplied field list against
the action's declaration. A wrapper that passes client input into
show_metadata unfiltered lets a client read any metadata field the action
declares. ash_kotlin_multiplatform does filter, in
AshKotlinMultiplatform.Rpc.Runner: dsl_metadata_fields/2 reads the
allowlist off the RPC DSL and narrow_metadata_fields/2 intersects the
client's request with it, so a client can only narrow, never widen. Upstream
ash_typescript puts the same check in its own parse stage.
Each extracted value is formatted once, by the type its action declared for
it, and the response envelope then formats only the top-level metadata name.
A metadata field declared as an unconstrained :map is an explicit opt-out
of typing: its keys are the caller's and reach the client verbatim.
That guarantee holds on format_output_with_request/3, which has the request
and therefore the types. format_output/2 has neither, so it falls back to
formatting every key it can reach — including the keys inside an
unconstrained map. A wrapper that wants type-correct output has to call
format_output_with_request/3.
Summary
Functions
Stage 2: Execute Ash action using the parsed request.
Stage 4: Format output for client consumption.
Stage 4: Format output for client consumption with type awareness.
Formats a sort string by converting field names from client format to internal format.
Stage 3: Filter result fields using the extraction template.
Types
@type config() :: %{ optional(:input_field_formatter) => atom(), optional(:output_field_formatter) => atom(), optional(:field_names_callback) => atom(), optional(:get_original_field_name) => (module(), String.t() -> atom() | nil), optional(:format_field_for_client) => (atom(), module() | nil, atom() -> String.t()), optional(:not_found_error?) => boolean(), optional(:manifest) => Ash.Info.Manifest.t() | AshIntrospection.ResourceInfo.Source.t() | nil }
Functions
@spec execute_ash_action(AshIntrospection.Rpc.Request.t(), config()) :: {:ok, term()} | {:error, term()}
Stage 2: Execute Ash action using the parsed request.
Builds the appropriate Ash query/changeset and executes it. Returns the raw Ash result for further processing.
Stage 4: Format output for client consumption.
Applies output field formatting and final response structure.
The error clause covers failures raised before a %Request{} exists — action
discovery, identity resolution, parameter validation. It formats them exactly
as format_output_with_request/3 does, so both entry points hand the client
the same response shape and the same client-resolvable placeholders.
@spec format_output_with_request(term(), AshIntrospection.Rpc.Request.t(), config()) :: term()
Stage 4: Format output for client consumption with type awareness.
Applies type-aware output field formatting and final response structure.
Formats a sort string by converting field names from client format to internal format.
Handles Ash.Query.sort_input format:
- "name" or "+name" (ascending)
- "++name" (ascending with nils first)
- "-name" (descending)
- "--name" (descending with nils last)
- "-name,++title" (multiple fields with different modifiers)
Preserves sort modifiers while converting field names using the input formatter.
Examples
iex> format_sort_string("--startDate,++insertedAt", :camel_case)
"--start_date,++inserted_at"
iex> format_sort_string("-userName", :camel_case)
"-user_name"
iex> format_sort_string(nil, :camel_case)
nil
@spec process_result(term(), AshIntrospection.Rpc.Request.t(), config()) :: {:ok, term()} | {:error, term()}
Stage 3: Filter result fields using the extraction template.
Applies field selection to the Ash result using the pre-computed template. Performance-optimized single-pass filtering. Handles metadata extraction for both read and mutation actions.