AshKotlinMultiplatform
Generate type-safe Kotlin Multiplatform clients from your Ash resources.
Alpha Software Notice
This library is in alpha and under active development. The API may change between versions without notice. While functional and tested, it is not yet recommended for production use. Please report issues and feedback on GitHub.
docs/PROJECT.md is the source of truth for what this library is, how it is built, where it is going and what is at risk. Start there rather than here if you are joining the project.
Features
- Automatic Kotlin Multiplatform generation from Elixir Ash resources
- Swift code generation for native iOS apps
- End-to-end type safety between backend (Elixir) and frontend (Kotlin/Swift)
- Type-safe RPC client generation using Ktor (Kotlin) and URLSession (Swift)
- Built-in RPC controller for Phoenix applications
- kotlinx.serialization integration for JSON handling
- Swift Codable integration for native iOS
- Phoenix Channel support for real-time applications
- Kotlin Multiplatform support (JVM, iOS, JS, Native)
- Type-safe filtering with generated filter types
- Pagination support for offset and keyset pagination
- Validation with optional javax.validation annotations
Requirements
- Elixir 1.15+
- Ash 3.7+
- Kotlin 1.9+ (for generated code)
- Ktor 2.0+ (for HTTP client)
- kotlinx.serialization 1.6+ (for JSON)
Installation
Add ash_kotlin_multiplatform to your dependencies in mix.exs:
def deps do
[
{:ash_kotlin_multiplatform, "~> 0.2.0"}
]
end
Code generation reads embedded resource types from a compile-time manifest, and raises when none is configured. The installer declares the manifest module and writes the config line:
mix igniter.install ash_kotlin_multiplatform
Or declare both yourself:
defmodule MyApp.AshKotlinMultiplatformManifest do
use AshKotlinMultiplatform.Manifest, otp_app: :my_app
end
# config/config.exs
config :ash_kotlin_multiplatform, manifest: MyApp.AshKotlinMultiplatformManifest
Quick Start
1. Add the Resource Extension
Configure your Ash resources with the AshKotlinMultiplatform.Resource
extension:
defmodule MyApp.Todo do
use Ash.Resource,
domain: MyApp.Domain,
extensions: [AshKotlinMultiplatform.Resource]
kotlin_multiplatform do
# Optional: customize the Kotlin class name
type_name "Todo"
# Optional: map Elixir field names to valid Kotlin identifiers
field_names %{
is_done: :isDone,
created_at: :createdAt
}
end
attributes do
uuid_primary_key :id
attribute :title, :string, allow_nil?: false
attribute :is_done, :boolean, default: false
attribute :created_at, :utc_datetime_usec
end
actions do
defaults [:read, :create, :update, :destroy]
read :list do
pagination offset?: true, default_limit: 25
end
end
end
2. Add the Domain Extension
Configure your Ash domain with the AshKotlinMultiplatform.Rpc extension to
expose actions via RPC:
defmodule MyApp.Domain do
use Ash.Domain, extensions: [AshKotlinMultiplatform.Rpc]
kotlin_rpc do
resource MyApp.Todo do
# Map RPC endpoint names to Ash actions
rpc_action :list_todos, :list
rpc_action :create_todo, :create
rpc_action :get_todo, :read
rpc_action :update_todo, :update
rpc_action :delete_todo, :destroy
# Optional: define typed queries for common operations
typed_query :active_todos, :list do
filter is_done: false
end
end
end
resources do
resource MyApp.Todo
end
end
3. Add the RPC Controller
Create a simple RPC controller using the built-in controller module:
defmodule MyAppWeb.RpcController do
use MyAppWeb, :controller
use AshKotlinMultiplatform.Phoenix.Controller, otp_app: :my_app
end
Add the route to your router:
scope "/api" do
pipe_through [:api, :authenticate]
post "/rpc/run", RpcController, :run
post "/rpc/validate", RpcController, :validate
end
The controller automatically:
- Discovers RPC actions from your
kotlin_rpcconfiguration - Handles authentication via
conn.assigns[:current_user] - Executes actions with proper field formatting
- Returns type-safe JSON responses
Optional customization:
defmodule MyAppWeb.RpcController do
use MyAppWeb, :controller
use AshKotlinMultiplatform.Phoenix.Controller,
otp_app: :my_app,
actor_key: :current_user,
tenant_key: :tenant,
require_auth: true
# Override actor extraction if needed
def get_actor(conn), do: conn.assigns[:current_user]
end
4. Generate Kotlin Code
Run the code generation mix task:
mix ash_kotlin_multiplatform.codegen
This generates a Kotlin file (default: lib/generated/AshRpc.kt) containing
all your types and RPC functions.
5. Generate Swift Code (Optional)
For native iOS apps, generate Swift code:
mix ash_kotlin_multiplatform.swift_codegen
Options:
mix ash_kotlin_multiplatform.swift_codegen \
--output ios/Generated/AshRpc.swift \
--base-url https://api.example.com
This generates a Swift file with:
- Codable structs for all resources
- Type-safe RPC service class
- Error handling types
Generated Code Examples
Kotlin
The generator produces idiomatic Kotlin code:
package com.myapp.ash
import kotlinx.serialization.*
import kotlinx.datetime.*
import io.ktor.client.*
// Data class with serialization support
@Serializable
data class Todo(
val id: String,
val title: String,
val isDone: Boolean? = false,
val createdAt: Instant? = null
)
// Configuration for actions
data class CreateTodoConfig(
val input: CreateTodoInput,
val fields: List<Any> = emptyList(),
val headers: Map<String, String> = emptyMap()
)
// One result wrapper, typed on what the action returns
@Serializable
data class RpcResult<T>(
val success: Boolean,
val data: T? = null,
val errors: List<AshRpcError>? = null
)
// Functional API. The return type names what this action sends back, so
// `result.data` is already a Todo.
suspend fun createTodo(
client: HttpClient,
config: CreateTodoConfig
): RpcResult<Todo> { ... }
// A read returns a page, which reads both shapes the server sends: a bare
// list when the request carried no `page`, a page object when it did.
suspend fun listTodos(
client: HttpClient,
config: ListTodosConfig
): RpcResult<AshPage<Todo>> { ... }
// Object-oriented API wrapper
object TodoRpc {
suspend fun create(client: HttpClient, config: CreateTodoConfig) =
createTodo(client, config)
suspend fun list(client: HttpClient, config: ListTodosConfig) =
listTodos(client, config)
}
Swift
For native iOS apps, the generator produces Swift code:
import Foundation
// Codable struct with automatic JSON mapping
struct Todo: Codable, Identifiable {
let id: String
let title: String?
let isDone: Bool?
let createdAt: String?
}
// Actor-based RPC service (thread-safe)
actor AshRpcService {
private let baseURL: String
private var authToken: String?
init(baseURL: String = "http://localhost:4000") {
self.baseURL = baseURL
}
func setAuthToken(_ token: String?) {
self.authToken = token
}
func listTodos(fields: [String]? = nil) async throws -> RpcListResult<Todo> {
// Implementation...
}
func createTodo(input: CreateTodoInput, fields: [String]? = nil) async throws -> RpcResult<Todo> {
// Implementation...
}
}
// Usage in SwiftUI
struct TodoListView: View {
let rpcService = AshRpcService()
@State private var todos: [Todo] = []
var body: some View {
List(todos) { todo in
Text(todo.title ?? "Untitled")
}
.task {
await rpcService.setAuthToken(authManager.token)
if let result = try? await rpcService.listTodos(),
result.success,
let data = result.data {
todos = data
}
}
}
}
Configuration
Configure the generator in your config/config.exs:
config :ash_kotlin_multiplatform,
# Output settings
output_file: "lib/generated/AshRpc.kt",
swift_output_file: "ios/Generated/AshRpc.swift",
package_name: "com.myapp.ash",
# Code generation options
generate_phoenix_channel_client: true,
generate_validation_functions: true,
generate_filter_types: false,
# Type handling
datetime_library: :kotlinx_datetime, # or :java_time
nullable_strategy: :explicit, # or :platform
# RPC endpoints
run_endpoint: "/rpc/run",
validate_endpoint: "/rpc/validate",
# Warnings
warn_on_missing_rpc_config: true,
warn_on_non_rpc_references: true
Configuration Options
| Option | Default | Description |
|---|---|---|
output_file |
"lib/generated/AshRpc.kt" |
Path for generated Kotlin file |
swift_output_file |
"ios/Generated/AshRpc.swift" |
Path for generated Swift file |
package_name |
Auto-generated | Kotlin package name |
generate_phoenix_channel_client |
true |
Generate WebSocket client code |
generate_validation_functions |
true |
Generate input validation functions |
generate_filter_types |
false |
Generate type-safe filter types |
datetime_library |
:kotlinx_datetime |
Date/time library (:kotlinx_datetime or :java_time) |
nullable_strategy |
:explicit |
Nullable handling (:explicit for T?, :platform for T!) |
run_endpoint |
"/rpc/run" |
RPC execution endpoint |
validate_endpoint |
"/rpc/validate" |
Validation endpoint |
type_mapping_overrides |
[] |
Custom Ash to Kotlin type mappings |
input_field_formatter |
:camel_case |
Input field name format |
output_field_formatter |
:camel_case |
Output field name format |
DSL Reference
Resource Extension (AshKotlinMultiplatform.Resource)
kotlin_multiplatform do
# Custom Kotlin class name (default: resource module name)
type_name "MyCustomName"
# Map field names to valid Kotlin identifiers
field_names %{
elixir_name: :kotlinName
}
# Map argument names per action
argument_names %{
create: %{from_date: :fromDate}
}
end
Domain Extension (AshKotlinMultiplatform.Rpc)
kotlin_rpc do
resource MyApp.Resource do
# Map RPC actions to Ash actions
rpc_action :rpc_name, :ash_action_name
# Expose metadata fields
rpc_action :list_items, :read do
show_metadata [:total_count]
end
# Return one record instead of a list. `get_by` names the fields the
# client sends to select it, and implies `get?`.
rpc_action :get_item, :read do
get_by [:id]
end
# A miss answers with a null payload instead of a not-found error.
rpc_action :find_item, :read do
get_by [:slug]
not_found_error? false
end
# Address an update or destroy by a named identity rather than the
# primary key. `[]` means the action takes no identity at all.
rpc_action :rename_item, :rename do
identities [:unique_slug]
end
# Drop `filter` and `sort` from the generated config. The server refuses
# them too, so a stale client is told rather than quietly handed the
# unfiltered table.
rpc_action :recent_items, :recent do
enable_filter? false
enable_sort? false
end
# Define typed queries with preset filters
typed_query :active_items, :read do
filter status: :active
end
end
end
These options shape the API surface of an action — typically to keep a parameter off an endpoint that has no use for it. They are not authorization. Ash policies run on every request regardless of what the DSL exposes, and narrowing the surface here neither adds nor removes a check.
Phoenix Channel Support
When generate_phoenix_channel_client: true, the generator creates a full
Phoenix Channel client:
val socket = PhoenixSocket(client, "ws://localhost:4000/socket/websocket")
socket.connect()
val channel = AshRpcChannel(socket, "rpc:lobby")
channel.join()
val result = channel.call(
action = "create_todo",
input = mapOf("title" to "Ship it"),
fields = listOf("id", "title")
)
The client speaks Phoenix's v2 protocol and appends vsn=2.0.0 on connect.
The stock Phoenix socket offers v1 and v2, so nothing needs configuring; a
host that narrowed its serializer: option to v1 only must add v2.
Binary payloads
pushBinary sends raw bytes — an image frame, an audio chunk, a file —
instead of base64 inside a JSON payload, which costs 33% more bytes on the
wire and a second allocation on the server. The JSON path is unchanged.
// Send. The server sees {:binary, data} as the payload of handle_in/3.
val push = channel.pushBinary("frame", jpegBytes)
val (status, reply) = push.await()
// Receive. Binary events bind separately from JSON ones, so `on` keeps its
// JsonElement callback.
channel.onBinary("frame") { bytes -> render(bytes) }
On the server:
def handle_in("frame", {:binary, data}, socket) do
{:reply, {:ok, %{bytes: byte_size(data)}}, socket}
end
A reply is JSON or binary independently of what was pushed. Use await()
for a JSON reply, awaitBinary() for {:reply, {:ok, {:binary, data}}},
and awaitPayload() when the server can send either.
join_ref, ref, topic and event are each length-prefixed with a
single byte in a binary frame, so a topic or event name over 255 bytes
raises rather than truncating.
Advanced Features
Type-Safe Filters
Enable with generate_filter_types: true:
val filter = TodoFilter(
title = StringFilter(contains = "important"),
isDone = BooleanFilter(eq = false)
)
val result = listTodos(client, ListTodosConfig(filter = filter))
Custom Type Mappings
Map custom Ash types to Kotlin:
config :ash_kotlin_multiplatform,
type_mapping_overrides: [
{MyApp.Types.Money, "BigDecimal"},
{MyApp.Types.Email, "String"}
]
Lifecycle Hooks
Add hooks for request processing:
config :ash_kotlin_multiplatform,
rpc_action_before_request_hook: "authInterceptor",
rpc_action_after_request_hook: "responseLogger"
Related Packages
- ash - The Ash Framework
- ash_phoenix - Phoenix integration for Ash
- ash_json_api - JSON:API support for Ash
Contributing
Contributions are welcome! Please open an issue or pull request on GitHub.
License
MIT License - see LICENSE for details.