AshIntrospection.Rpc.Pipeline (AshIntrospection v0.4.0)

Copy Markdown View Source

Language-agnostic RPC pipeline for Ash actions.

This module implements stages 2 through 4 of the four-stage pipeline:

  1. execute_ash_action/2 - Execute Ash operations
  2. process_result/3 - Apply field selection
  3. 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:

  1. Build the config map with their specific callbacks
  2. Call the shared pipeline functions with that config
  3. 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

config()

@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

execute_ash_action(request, config \\ %{})

@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.

format_output(filtered_result, config \\ %{})

@spec format_output(term(), config()) :: term()

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.

format_output_with_request(filtered_result, request, config \\ %{})

@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.

format_sort_string(sort_string, formatter)

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

process_result(ash_result, request, config \\ %{})

@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.