Skip to content

Commit 880c0da

Browse files
committed
docs: write a routing guide
1 parent 1781d5d commit 880c0da

2 files changed

Lines changed: 361 additions & 0 deletions

File tree

documentation/topics/routing.md

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
# Routing
2+
3+
AshJsonApi provides a set of route helpers that map HTTP requests to Ash actions. Routes are defined inside the `json_api do routes do ... end end` block on either a resource or a domain.
4+
5+
## Route overview
6+
7+
| Route Helper | HTTP Method | Default Path | Primary Action Type | Also Accepts |
8+
|---|---|---|---|---|
9+
| `get` | GET | `/:id` | `:read` | `:action` |
10+
| `index` | GET | `/` | `:read` | `:action` |
11+
| `post` | POST | `/` | `:create` | `:action`, `:read` |
12+
| `patch` | PATCH | `/:id` | `:update` | `:action` |
13+
| `delete` | DELETE | `/:id` | `:destroy` | `:action` |
14+
| `related` | GET | `/:id/<relationship>` | `:read` ||
15+
| `relationship` | GET | `/:id/relationships/<relationship>` | `:read` ||
16+
| `post_to_relationship` | POST | `/:id/relationships/<relationship>` | `:update` ||
17+
| `patch_relationship` | PATCH | `/:id/relationships/<relationship>` | `:update` ||
18+
| `delete_from_relationship` | DELETE | `/:id/relationships/<relationship>` | `:update` ||
19+
| `route` | *any* | *required* | `:action` ||
20+
21+
## Defining routes
22+
23+
Routes can live on the resource or on the domain. Defining them on the domain is the default recommendation — it keeps resources focused on data and actions while the domain acts as the API surface.
24+
25+
### On the domain
26+
27+
```elixir
28+
defmodule MyApp.Support do
29+
use Ash.Domain, extensions: [AshJsonApi.Domain]
30+
31+
json_api do
32+
routes do
33+
base_route "/tickets", MyApp.Support.Ticket do
34+
get :read
35+
index :read
36+
post :create
37+
patch :update
38+
delete :destroy
39+
end
40+
end
41+
end
42+
end
43+
```
44+
45+
`base_route` scopes all nested routes under the given path prefix for the specified resource.
46+
47+
### On the resource
48+
49+
```elixir
50+
defmodule MyApp.Support.Ticket do
51+
use Ash.Resource, extensions: [AshJsonApi.Resource]
52+
53+
json_api do
54+
type "ticket"
55+
56+
routes do
57+
base "/tickets"
58+
59+
get :read
60+
index :read
61+
post :create
62+
patch :update
63+
delete :destroy
64+
end
65+
end
66+
end
67+
```
68+
69+
`base` sets the path prefix for all routes defined on the resource.
70+
71+
## Standard CRUD routes
72+
73+
### `get` — fetch a single record
74+
75+
```elixir
76+
get :read
77+
```
78+
79+
Issues a GET request to `/:id` (by default). Looks up a single record by primary key and returns a JSON:API resource object.
80+
81+
### `index` — list records
82+
83+
```elixir
84+
index :read
85+
```
86+
87+
Issues a GET request to `/` (by default). Returns a JSON:API array of resource objects. Supports filtering, sorting, pagination, and includes.
88+
89+
Options:
90+
- `paginate?` (default `true`) — whether to apply pagination
91+
92+
### `post` — create a record
93+
94+
```elixir
95+
post :create
96+
```
97+
98+
Issues a POST request to `/` (by default). Accepts a JSON:API resource object in the request body and creates a record.
99+
100+
Options:
101+
- `relationship_arguments` — arguments used to edit relationships inline. See the [relationships guide](/documentation/topics/relationships.md).
102+
- `upsert?` (default `false`) — use `upsert?: true` when calling `Ash.create/2`
103+
- `upsert_identity` — which identity to use for the upsert
104+
105+
### `patch` — update a record
106+
107+
```elixir
108+
patch :update
109+
```
110+
111+
Issues a PATCH request to `/:id` (by default). Looks up the record, then applies the update action.
112+
113+
Options:
114+
- `read_action` — the read action used to look up the record before updating
115+
- `relationship_arguments` — arguments used to edit relationships inline
116+
117+
### `delete` — destroy a record
118+
119+
```elixir
120+
delete :destroy
121+
```
122+
123+
Issues a DELETE request to `/:id` (by default). Looks up the record, then destroys it.
124+
125+
Options:
126+
- `read_action` — the read action used to look up the record before destroying
127+
128+
### Custom paths
129+
130+
Any standard route can override its default path:
131+
132+
```elixir
133+
patch :update_email do
134+
route "/update_email/:id"
135+
end
136+
137+
delete :archive do
138+
route "/archive/:id"
139+
end
140+
```
141+
142+
## Relationship routes
143+
144+
These routes manage relationships following the JSON:API relationship specification. See the [relationships guide](/documentation/topics/relationships.md) for full details.
145+
146+
```elixir
147+
# GET /tickets/:id/comments — returns related comment resources
148+
related :comments, :read
149+
150+
# GET /tickets/:id/relationships/comments — returns resource identifiers
151+
relationship :comments, :read
152+
153+
# POST /tickets/:id/relationships/comments — add to relationship
154+
post_to_relationship :comments
155+
156+
# PATCH /tickets/:id/relationships/comments — replace relationship
157+
patch_relationship :comments
158+
159+
# DELETE /tickets/:id/relationships/comments — remove from relationship
160+
delete_from_relationship :comments
161+
```
162+
163+
## Generic actions with `route`
164+
165+
The `route` helper exposes generic actions (Ash actions with `type: :action`) over any HTTP method. It is the most flexible routing option.
166+
167+
```elixir
168+
route :get, "/say_hello/:name", :say_hello
169+
route :post, "/trigger_job", :trigger_job
170+
route :delete, "/cancel_job/:id", :cancel_job
171+
```
172+
173+
There are no restrictions on the return type when using `route`. The action can return a string, map, struct, list, or nothing.
174+
175+
### Returning simple values
176+
177+
```elixir
178+
action :say_hello, :string do
179+
argument :name, :string, allow_nil?: false
180+
181+
run fn input, _ ->
182+
{:ok, "Hello, #{input.arguments.name}!"}
183+
end
184+
end
185+
```
186+
187+
The response body is the raw value: `"Hello, fred!"`
188+
189+
### Returning nothing
190+
191+
Actions with no return type respond with `{"success": true}` and status `201` for POST or `200` for other methods.
192+
193+
```elixir
194+
action :trigger_job do
195+
run fn _input, _ ->
196+
:ok
197+
end
198+
end
199+
```
200+
201+
### `wrap_in_result?`
202+
203+
Wraps the result in a `{"result": <value>}` object:
204+
205+
```elixir
206+
route :get, "/count", :count_things, wrap_in_result?: true
207+
# Response: {"result": 42}
208+
```
209+
210+
### Path parameters and query parameters
211+
212+
Arguments can be supplied via path parameters, query parameters, or the request body.
213+
214+
**Path parameters** — embed `:arg_name` segments in the route:
215+
216+
```elixir
217+
route :get, "/say_hello/:name", :say_hello
218+
# GET /say_hello/fred → name = "fred"
219+
```
220+
221+
**Query parameters** — use the `query_params` option:
222+
223+
```elixir
224+
route :get, "/say_hello", :say_hello, query_params: [:name]
225+
# GET /say_hello?name=fred → name = "fred"
226+
```
227+
228+
For GET requests using `route`, all action arguments are automatically accepted as query parameters even without specifying `query_params`.
229+
230+
**Request body** — for POST/PATCH/DELETE, remaining arguments are read from the JSON body under `data`:
231+
232+
```elixir
233+
route :post, "/greet/:name", :greet
234+
# POST /greet/fred with body {"data": {"greeting": "Hi"}}
235+
# → name = "fred", greeting = "Hi"
236+
```
237+
238+
> ### Conflicting parameters {: .warning}
239+
>
240+
> If the same argument appears in both the path and query string, the request returns a `400` error with an `invalid_query` error code.
241+
242+
## Using generic actions with standard route helpers
243+
244+
The standard route helpers (`get`, `index`, `post`, `patch`, `delete`) also accept generic actions, but they impose **return type constraints** so the response conforms to JSON:API format.
245+
246+
### Return type requirements
247+
248+
| Route Helper | Return Type Constraint |
249+
|---|---|
250+
| `route` | **None** — any return type |
251+
| `get` | `:struct` with `instance_of: __MODULE__` |
252+
| `index` | `{:array, :struct}` with `items: [instance_of: __MODULE__]` |
253+
| `post` | `:struct` with `instance_of: __MODULE__` |
254+
| `patch` | `:struct` with `instance_of: __MODULE__` + path param arguments |
255+
| `delete` | `:struct` with `instance_of: __MODULE__` + path param arguments |
256+
257+
When a generic action is used with `patch` or `delete`, every path parameter (e.g. `:id`) must have a corresponding action argument — since there's no read action to look up the record, the action itself is responsible for finding it.
258+
259+
### Example: `get` with a generic action
260+
261+
```elixir
262+
get :my_custom_get
263+
```
264+
265+
```elixir
266+
action :my_custom_get, :struct do
267+
constraints instance_of: __MODULE__
268+
argument :id, :uuid, allow_nil?: false
269+
270+
run fn input, _ ->
271+
Ash.get(__MODULE__, input.arguments.id)
272+
end
273+
end
274+
```
275+
276+
The response is serialized as a standard JSON:API resource object with `type`, `id`, `attributes`, and `relationships`.
277+
278+
### Example: `index` with a generic action
279+
280+
```elixir
281+
index :search
282+
```
283+
284+
```elixir
285+
action :search, {:array, :struct} do
286+
constraints items: [instance_of: __MODULE__]
287+
argument :query, :string, allow_nil?: false
288+
289+
run fn input, _ ->
290+
# custom search logic
291+
{:ok, results}
292+
end
293+
end
294+
```
295+
296+
### Example: `patch` with a generic action
297+
298+
```elixir
299+
patch :fake_update do
300+
route "/fake_update/:id"
301+
end
302+
```
303+
304+
```elixir
305+
action :fake_update, :struct do
306+
constraints instance_of: __MODULE__
307+
argument :id, :uuid, allow_nil?: false
308+
309+
run fn %{arguments: %{id: id}}, _ ->
310+
record = Ash.get!(__MODULE__, id)
311+
{:ok, %{record | name: record.name <> "_updated"}}
312+
end
313+
end
314+
```
315+
316+
### Example: `delete` with a generic action
317+
318+
```elixir
319+
delete :fake_delete do
320+
route "/delete_fake/:id"
321+
end
322+
```
323+
324+
```elixir
325+
action :fake_delete, :struct do
326+
constraints instance_of: __MODULE__
327+
argument :id, :uuid
328+
329+
run fn input, _ ->
330+
Ash.get(__MODULE__, input.arguments.id)
331+
end
332+
end
333+
```
334+
335+
### When to use `route` vs standard helpers
336+
337+
Use the **standard helpers** when your generic action returns resource instances and you want JSON:API response formatting with `type`, `id`, `attributes`, and `relationships`.
338+
339+
Use **`route`** when:
340+
341+
- Your action returns a non-resource value (string, map, integer, etc.)
342+
- Your action returns nothing (side-effect only)
343+
- You want full control over the HTTP method and path
344+
- You don't need JSON:API resource object formatting in the response
345+
346+
## Common route options
347+
348+
These options are available on all route types:
349+
350+
- `route` — the path for the route (can override the default)
351+
- `action` — the action to call
352+
- `default_fields` — a list of fields to include in the response attributes
353+
- `primary?` (default `false`) — whether this is the default route for link generation
354+
- `metadata` — a function `fn subject, result, request -> map end` for top-level response metadata
355+
- `modify_conn` — a function to modify the Plug conn before responding. See the [modify_conn guide](/documentation/topics/modify-conn.md).
356+
- `query_params` — action arguments to accept as query parameters
357+
- `name` — a globally unique name for this route, used in docs and OpenAPI
358+
- `description` — a human-friendly description for generated documentation (overrides the action description)
359+
- `derive_sort?` (default `true`) — derive a sort parameter from sortable fields
360+
- `derive_filter?` (default `true`) — derive a filter parameter from filterable fields

mix.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ defmodule AshJsonApi.MixProject do
7171
"documentation/topics/errors.md",
7272
"documentation/topics/field-names.md",
7373
"documentation/topics/paginated-relationships.md",
74+
"documentation/topics/routing.md",
7475
{"documentation/dsls/DSL-AshJsonApi.Resource.md",
7576
search_data: Spark.Docs.search_data_for(AshJsonApi.Resource)},
7677
{"documentation/dsls/DSL-AshJsonApi.Domain.md",

0 commit comments

Comments
 (0)