Expose Ash Resource actions over tRPC with a Plug-compatible router/controller, robust error handling, and schema tooling.

AshRpc is a comprehensive bridge between Ash Framework and tRPC, enabling you to expose your Ash resources as type-safe, performant tRPC endpoints. It provides seamless integration with Phoenix applications, automatic TypeScript generation, and advanced features like field selection and batching.

⚠️ EXPERIMENTAL WARNING: This package is still in early development and considered highly experimental. Breaking changes may occur frequently without notice. We strongly advise against using this package in production environments until it reaches a stable release (v1.0.0+). Use at your own risk for development and testing purposes only.

Table of Contents

Features

🚀 Core Features

  • Simple Setup: One-line router configuration with use AshRpc.Router
  • Spark DSL: Declarative exposure of Ash resource actions
  • tRPC Compliance: Full tRPC specification support with proper envelopes
  • Error Handling: Robust, structured error responses with detailed validation messages
  • Type Safety: Automatic TypeScript generation for end-to-end type safety

🔧 Advanced Capabilities

  • Batching: Efficient request batching with ?batch=1 support
  • Field Selection: Dynamic field selection with include/exclude semantics
  • Filtering & Sorting: Rich query capabilities with complex filter expressions
  • Pagination: Offset and keyset pagination with automatic detection
  • Relationships: Nested relationship loading with query options

🛠 Developer Experience

  • Auto-Generation: TypeScript types and Zod schemas from your Ash resources
  • IntelliSense: Full IDE support with generated type definitions

Quick Start

1. Install AshRpc

# If you have Igniter installed (recommended)
mix igniter.install ash_rpc

# Or manually install
mix deps.get
mix ash_rpc.install

This creates your tRPC router and configures your Phoenix router.

2. Configure Your Resources

defmodule MyApp.Accounts.User do
  use Ash.Resource, extensions: [AshRpc]

  ash_rpc do
    expose [:read, :create, :update]

    query :read do
      filterable true
      sortable true
      selectable true
      paginatable true
    end

    mutation :create, :create do
      metadata fn _subject, user, _ctx ->
        %{user_id: user.id}
      end
    end
  end

  # ... rest of your resource
end

3. Update Your Router

defmodule MyAppWeb.TrpcRouter do
  use AshRpc.Router, domains: [MyApp.Accounts, MyApp.Billing]
end

4. Generate Types

mix ash_rpc.codegen --output=./frontend/generated --zod

5. Use in Frontend

import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "./generated/trpc";

const client = createTRPCClient<AppRouter>({
  links: [httpBatchLink({ url: "/trpc" })],
});

// Type-safe API calls
const users = await client.accounts.user.read.query({
  filter: { email: { eq: "user@example.com" } },
  select: ["id", "email", "name"],
  page: { limit: 10, offset: 0 },
});

Installation

Add Dependencies

Add ash_rpc to your mix.exs:

defp deps do
  [
    {:ash_rpc, "~> 0.1"},
    # Recommended for type generation
    # For authentication (optional)
    {:ash_authentication, "~> 3.0"},
  ]
end

Install AshRpc

Run the installer to set up your Phoenix application:

mix deps.get
mix ash_rpc.install

This will:

  • Generate MyAppWeb.TrpcRouter module
  • Add tRPC pipeline to your Phoenix router
  • Configure route forwarding to /trpc

Manual Setup (Alternative)

If you prefer manual setup, create the router manually:

# lib/my_app_web/trpc_router.ex
defmodule MyAppWeb.TrpcRouter do
  use AshRpc.Router, domains: [MyApp.Accounts]
end

# router.ex
scope "/trpc" do
  pipe_through :ash_rpc
  forward "/", MyAppWeb.TrpcRouter
end

Backend Setup

Router Configuration

defmodule MyAppWeb.TrpcRouter do
  use AshRpc.Router,
    domains: [MyApp.Accounts, MyApp.Billing, MyApp.Notifications],
    # Optional: Custom transformer for input/output processing
    transformer: MyApp.TrpcTransformer,
    # Optional: Before hooks
    before: [MyApp.TrpcHooks.Logging],
    # Optional: After hooks
    after: [MyApp.TrpcHooks.Metrics],
    # Optional: Context creation function
    create_context: &MyApp.TrpcContext.create/1
end

Resource Configuration

defmodule MyApp.Accounts.User do
  use Ash.Resource,
    extensions: [AshRpc],
    domain: MyApp.Accounts

  ash_rpc do
    # Expose specific actions
    expose [:read, :create, :update, :destroy]

    # Or expose all actions
    # expose :all

    # Custom resource name (defaults to module name)
    resource_name "user"

    # Configure query procedures
    query :read do
      filterable true      # Allow client-side filtering
      sortable true        # Allow client-side sorting
      selectable true      # Allow client-side field selection
      paginatable true     # Allow client-side pagination

      # Custom relationship loading
      relationships [:posts, :comments]
    end

    query :by_email, :read do
      # Custom procedure name for specific action
      filterable false
      selectable true
    end

    # Configure mutation procedures
    mutation :create, :create do
      metadata fn _subject, user, _ctx ->
        %{user_id: user.id, created_at: user.inserted_at}
      end
    end

    mutation :register, :register_with_password do
      metadata fn _subject, user, _ctx ->
        %{token: user.__metadata__.token}
      end
    end
  end

  # ... resource definition
end

DSL Reference

ash_rpc Block Options

  • expose: List of actions to expose (:all or specific action names)
  • resource_name: Override the default resource segment name
  • methods: Override default method mappings ([read: :query, create: :mutation])

Query Configuration

query :read do
  filterable true        # Enable filtering (default: true)
  sortable true         # Enable sorting (default: true)
  selectable true       # Enable field selection (default: true)
  paginatable true      # Enable pagination (default: true)
  relationships [:posts] # Allow loading specific relationships
end

Mutation Configuration

mutation :create, :create do
  metadata fn subject, result, ctx ->
    # Return custom metadata in response
    %{created_by: subject.id, timestamp: DateTime.utc_now()}
  end
end

Authentication

AshRpc integrates seamlessly with AshAuthentication for secure API access.

Setup Authentication

# In your Phoenix router
pipeline :ash_rpc do
  plug :accepts, ["json"]
  plug :retrieve_from_bearer  # Extract token from Authorization header
  plug :set_actor, :user      # Set current user as actor
end

scope "/trpc" do
  pipe_through :ash_rpc
  forward "/", MyAppWeb.TrpcRouter
end

Client Authentication

// Include token in requests
const client = createTRPCClient<AppRouter>({
  links: [
    httpBatchLink({
      url: "/trpc",
      headers() {
        const token = getAuthToken();
        return token ? { Authorization: `Bearer ${token}` } : {};
      },
    }),
  ],
});

Authorization

AshRpc respects Ash's authorization rules. Configure policies on your resources:

defmodule MyApp.Accounts.User do
  # ... resource setup

  policies do
    policy action_type(:read) do
      authorize_if actor_attribute_equals(:role, :admin)
      authorize_if relates_to_actor_via(:self)
    end
  end
end

Frontend Integration

tRPC Client Setup

// client.ts
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "./generated/trpc";

export function createClient(token?: string) {
  return createTRPCClient<AppRouter>({
    links: [
      httpBatchLink({
        url: "/trpc",
        headers: token ? { Authorization: `Bearer ${token}` } : {},
      }),
    ],
  });
}

React Integration

// App.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "./generated/trpc";

export const trpc = createTRPCReact<AppRouter>();
const queryClient = new QueryClient();

function App() {
  return (
    <trpc.Provider client={createClient()} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <MyComponent />
      </QueryClientProvider>
    </trpc.Provider>
  );
}

Usage Examples

// UserList.tsx
import { trpc } from "./trpc";

function UserList() {
  const { data: users, isLoading } = trpc.accounts.user.read.useQuery({
    filter: { role: { eq: "admin" } },
    select: ["id", "email", "name"],
    sort: { insertedAt: "desc" },
    page: { limit: 20, offset: 0 },
  });

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      {users?.result.map((user) => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  );
}

Mutation Examples

// CreateUser.tsx
import { trpc } from "./trpc";

function CreateUser() {
  const createUser = trpc.accounts.user.create.useMutation();

  const handleSubmit = async (data: FormData) => {
    try {
      const result = await createUser.mutateAsync({
        email: data.email,
        password: data.password,
        name: data.name,
      });

      console.log("Created user:", result.result);
      console.log("Metadata:", result.meta);
    } catch (error) {
      console.error("Failed to create user:", error);
    }
  };

  return <form onSubmit={handleSubmit}>{/* form fields */}</form>;
}

Advanced Features

Batching

AshRpc supports request batching for improved performance:

// Automatic batching with httpBatchLink
const client = createTRPCClient<AppRouter>({
  links: [httpBatchLink({ url: "/trpc" })],
});

// Multiple queries batched automatically
const [users, posts] = await Promise.all([
  client.accounts.user.read.query({ limit: 10 }),
  client.blog.post.read.query({ limit: 10 }),
]);

Field Selection

Dynamically select which fields to return:

// Include specific fields
const users = await client.accounts.user.read.query({
  select: ["id", "email", "name"],
});

// Exclude fields with "-"
const users = await client.accounts.user.read.query({
  select: ["-password", "-insertedAt"],
});

// Nested field selection
const posts = await client.blog.post.read.query({
  select: [
    "id",
    "title",
    { author: ["name", "email"] },
    { comments: ["content", "-insertedAt"] },
  ],
});

Filtering & Sorting

Rich query capabilities:

// Complex filtering
const users = await client.accounts.user.read.query({
  filter: {
    and: [
      { email: { like: "%@company.com" } },
      { or: [{ role: { eq: "admin" } }, { role: { eq: "manager" } }] },
    ],
  },
  sort: { insertedAt: "desc" },
});

Pagination

Support for both offset and keyset pagination:

// Offset pagination
const users = await client.accounts.user.read.query({
  page: {
    type: "offset",
    limit: 20,
    offset: 40,
    count: true, // Include total count
  },
});

// Keyset pagination (recommended for large datasets)
const users = await client.accounts.user.read.query({
  page: {
    type: "keyset",
    limit: 20,
    after: "cursor_value",
    before: "cursor_value",
  },
});

TypeScript Generation

Generate Types

# Generate TypeScript types
mix ash_rpc.codegen --output=./frontend/generated

# Generate with Zod schemas
mix ash_rpc.codegen --output=./frontend/generated --zod

Generated Files

  • trpc.d.ts: TypeScript types for your tRPC router
  • trpc.zod.ts: Zod schemas for client-side validation (optional)

Usage

import type { AppRouter } from "./generated/trpc";
import * as schemas from "./generated/trpc.zod";

// Full type safety
const client = createTRPCClient<AppRouter>();

// Client-side validation
const userSchema = schemas.AccountsUserCreateSchema;
const validated = userSchema.parse(formData);

Error Handling

AshRpc provides comprehensive error handling with detailed messages:

try {
  await client.accounts.user.create.mutate({
    email: "invalid-email", // Missing password
  });
} catch (error: any) {
  // error.shape?.message - High-level message
  // error.data?.details - Array of detailed error objects
  console.log(error.shape?.message); // "Validation failed"

  error.data?.details.forEach((detail) => {
    console.log(detail.message); // "password is required"
    console.log(detail.code); // "field_validation_error"
    console.log(detail.pointer); // "password"
  });
}

API Reference

Router Module

defmodule AshRpc.Router do
  @moduledoc """
  Main router module for exposing Ash resources via tRPC.

  ## Options
  - `domains`: List of Ash domain modules to expose
  - `transformer`: Custom input/output transformer module
  - `before`: List of modules to run before request processing
  - `after`: List of modules to run after request processing
  - `create_context`: Function to create request context
  """
end

DSL Module

defmodule AshRpc do
  @moduledoc """
  Spark DSL extension for configuring tRPC exposure on Ash resources.

  ## DSL Structure
  ash_rpc do
    expose [:action1, :action2]
    resource_name "custom_name"

    query :action do
      filterable true
      sortable true
      selectable true
      paginatable true
      relationships [:rel1, :rel2]
    end

    mutation :action do
      metadata fn subject, result, ctx -> %{key: value} end
    end
  end
  """
end

Examples

Quick Examples

Basic CRUD Operations

# Resource
defmodule MyApp.Blog.Post do
  use Ash.Resource, extensions: [AshRpc]

  ash_rpc do
    expose [:read, :create, :update, :destroy]

    query :read do
      filterable true
      sortable true
      selectable true
      paginatable true
      relationships [:author, :comments]
    end
  end
end

# Frontend usage
const posts = await client.blog.post.read.query({
  filter: { published: { eq: true } },
  sort: { publishedAt: "desc" },
  select: ["id", "title", "content", { author: ["name"] }],
  page: { limit: 10 }
});

Advanced Filtering

// Complex queries with relationships
const posts = await client.blog.post.read.query({
  filter: {
    and: [
      { published: { eq: true } },
      { author: { name: { like: "John%" } } },
      {
        or: [
          { tags: { contains: "elixir" } },
          { tags: { contains: "phoenix" } },
        ],
      },
    ],
  },
  load: [
    { author: { filter: { active: { eq: true } } } },
    { comments: { sort: { insertedAt: "desc" }, limit: 5 } },
  ],
});

Contributing

  1. Fork the repository
  2. Create a feature branch
  3. Make your changes
  4. Add tests for new functionality
  5. Run the test suite: mix test
  6. Submit a pull request

Development Setup

git clone https://github.com/antdragon-os/ash_rpc.git
cd ash_rpc
mix deps.get
mix test

Documentation

Documentation is generated with ExDoc. To build locally:

mix docs
open doc/index.html

License

Apache 2.0 - see LICENSE.

Support


Built with ❤️ using Ash Framework and tRPC