|
| 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 |
0 commit comments