Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 37 additions & 37 deletions documentation/dsls/DSL-AshJsonApi.Domain.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ This file was generated by Spark. Do not edit it by hand.
-->
# AshJsonApi.Domain

The entrypoint for adding JSON:API behavior to an Ash domain
The entrypoint for adding JSON:API behavior to an Ash domain


## json_api
Global configuration for JSON:API
Global configuration for JSON:API


### Nested DSLs
Expand Down Expand Up @@ -40,10 +40,10 @@ Global configuration for JSON:API

### Examples
```
json_api do
prefix "/json_api"
log_errors? true
end
json_api do
prefix "/json_api"
log_errors? true
end

```

Expand All @@ -61,7 +61,7 @@ end
| [`authorize?`](#json_api-authorize?){: #json_api-authorize? } | `boolean` | `true` | Whether or not to perform authorization on requests. |
| [`log_errors?`](#json_api-log_errors?){: #json_api-log_errors? } | `boolean` | `true` | Whether or not to log any errors produced |
| [`include_nil_values?`](#json_api-include_nil_values?){: #json_api-include_nil_values? } | `boolean` | `true` | Whether or not to include properties for values that are nil in the JSON output |
| [`error_handler`](#json_api-error_handler){: #json_api-error_handler } | `mfa` | | Set an MFA to intercept/handle any errors that are generated. The function will be called with a `AshJsonApi.Error` struct and a context map, and should return a modified `AshJsonApi.Error` struct. The context map contains `:domain` and `:resource`. For example: ```elixir defmodule MyApp.ErrorHandler do def handle_error(error, _context) do %{error \| detail: "Something went wrong"} end end ``` And in your domain: ```elixir json_api do error_handler {MyApp.ErrorHandler, :handle_error, []} end ``` |
| [`error_handler`](#json_api-error_handler){: #json_api-error_handler } | `mfa` | | Set an MFA to intercept/handle any errors that are generated. The function will be called with a `AshJsonApi.Error` struct and a context map, and should return a modified `AshJsonApi.Error` struct. The context map contains `:domain` and `:resource`. For example: ```elixir defmodule MyApp.ErrorHandler do def handle_error(error, _context) do %{error \| detail: "Something went wrong"} end end ``` And in your domain: ```elixir json_api do error_handler {MyApp.ErrorHandler, :handle_error, []} end ``` |
| [`require_type_on_create?`](#json_api-require_type_on_create?){: #json_api-require_type_on_create? } | `boolean` | `false` | When true, POST create requests MUST include type in data. Default false for backwards compatibility; in a future major version may default to true. |


Expand All @@ -72,13 +72,13 @@ OpenAPI configurations

### Examples
```
json_api do
...
open_api do
tag "Users"
group_by :api
end
end
json_api do
...
open_api do
tag "Users"
group_by :api
end
end

```

Expand Down Expand Up @@ -126,20 +126,20 @@ Configure the routes that will be exposed via the JSON:API

### Examples
```
routes do
base "/posts"

get :read
get :me, route: "/me"
index :read
post :confirm_name, route: "/confirm_name"
patch :update
related :comments, :read
relationship :comments, :read
post_to_relationship :comments
patch_relationship :comments
delete_from_relationship :comments
end
routes do
base "/posts"
get :read
get :me, route: "/me"
index :read
post :confirm_name, route: "/confirm_name"
patch :update
related :comments, :read
relationship :comments, :read
post_to_relationship :comments
patch_relationship :comments
delete_from_relationship :comments
end

```

Expand All @@ -152,7 +152,7 @@ base_route route, resource \\ nil
```


Sets a prefix for a list of contained routes
Sets a prefix for a list of contained routes


### Nested DSLs
Expand All @@ -171,14 +171,14 @@ Sets a prefix for a list of contained routes

### Examples
```
base_route "/posts" do
index :read
get :read
end

base_route "/comments" do
index :read
end
base_route "/posts" do
index :read
get :read
end
base_route "/comments" do
index :read
end

```

Expand Down
92 changes: 46 additions & 46 deletions documentation/dsls/DSL-AshJsonApi.Resource.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ This file was generated by Spark. Do not edit it by hand.
-->
# AshJsonApi.Resource

The entrypoint for adding JSON:API behavior to a resource"
The entrypoint for adding JSON:API behavior to a resource"


## json_api
Expand All @@ -27,30 +27,30 @@ Configure the resource's behavior in the JSON:API

### Examples
```
json_api do
type "post"
includes [
friends: [
:comments
],
comments: []
]

routes do
base "/posts"

get :read
get :me, route: "/me"
index :read
post :confirm_name, route: "/confirm_name"
patch :update
related :comments, :read
relationship :comments, :read
post_to_relationship :comments
patch_relationship :comments
delete_from_relationship :comments
end
end
json_api do
type "post"
includes [
friends: [
:comments
],
comments: []
]
routes do
base "/posts"
get :read
get :me, route: "/me"
index :read
post :confirm_name, route: "/confirm_name"
patch :update
related :comments, :read
relationship :comments, :read
post_to_relationship :comments
patch_relationship :comments
delete_from_relationship :comments
end
end

```

Expand All @@ -69,9 +69,9 @@ end
| [`default_fields`](#json_api-default_fields){: #json_api-default_fields } | `list(atom)` | | The fields to include in the object if the `fields` query parameter does not specify. Defaults to all public |
| [`derive_sort?`](#json_api-derive_sort?){: #json_api-derive_sort? } | `boolean` | `true` | Whether or not to derive a sort parameter based on the sortable fields of the resource |
| [`derive_filter?`](#json_api-derive_filter?){: #json_api-derive_filter? } | `boolean` | `true` | Whether or not to derive a filter parameter based on the sortable fields of the resource |
| [`field_names`](#json_api-field_names){: #json_api-field_names } | `:camelize \| :dasherize \| keyword \| (any -> any)` | | Renames fields (attributes, relationships, calculations, and aggregates) in the JSON:API output and input. Can be one of the atoms `:camelize` or `:dasherize` for automatic conversion, a keyword list of `[ash_name: :json_api_name]` mappings, or a 1-arity function that receives an atom field name and returns the desired JSON:API name (atom or string). ```elixir field_names :camelize # first_name → firstName field_names :dasherize # first_name → first-name ``` Or with a keyword list: ```elixir field_names [ first_name: :firstName, last_name: :lastName ] ``` Or with a function for custom logic: ```elixir field_names fn name -> camelized = name \|> to_string() \|> Macro.camelize() {first, rest} = String.split_at(camelized, 1) String.downcase(first) <> rest end ``` Names are applied consistently across serialization, request parsing, sort/filter parameters, field selection, error source pointers, relationship keys, and schema generation. |
| [`argument_names`](#json_api-argument_names){: #json_api-argument_names } | `:camelize \| :dasherize \| keyword \| (any, any -> any)` | | Renames action arguments in the JSON:API request body and schema. Can be one of the atoms `:camelize` or `:dasherize` for automatic conversion, a nested keyword list of `[action_name: [ash_name: :json_api_name]]` mappings, or a 2-arity function that receives `(action_name, argument_name)` atoms and returns the desired JSON:API name (atom or string). ```elixir argument_names :camelize # publish_at → publishAt argument_names :dasherize # publish_at → publish-at ``` Or with a keyword list: ```elixir argument_names [ create: [my_arg: :myArg], update: [my_arg: :myArg] ] ``` Or with a function: ```elixir argument_names fn _action, name -> camelized = name \|> to_string() \|> Macro.camelize() {first, rest} = String.split_at(camelized, 1) String.downcase(first) <> rest end ``` |
| [`calculation_argument_names`](#json_api-calculation_argument_names){: #json_api-calculation_argument_names } | `:camelize \| :dasherize \| keyword \| (any, any -> any)` | | Renames calculation arguments in the JSON:API request and schema. Works the same way as `argument_names` but applies to calculation arguments instead of action arguments. The 2-arity function receives `(calculation_name, argument_name)`. Can be one of the atoms `:camelize` or `:dasherize` for automatic conversion, a nested keyword list of `[calc_name: [ash_name: :json_api_name]]` mappings, or a 2-arity function that receives `(calculation_name, argument_name)` atoms and returns the desired JSON:API name (atom or string). ```elixir calculation_argument_names :camelize # publish_at → publishAt calculation_argument_names :dasherize # publish_at → publish-at ``` Or with a keyword list: ```elixir calculation_argument_names [ full_name: [separator: :sep] ] ``` Or with a function: ```elixir calculation_argument_names fn _calc, name -> camelized = name \|> to_string() \|> Macro.camelize() {first, rest} = String.split_at(camelized, 1) String.downcase(first) <> rest end ``` |
| [`field_names`](#json_api-field_names){: #json_api-field_names } | `:camelize \| :dasherize \| keyword \| (any -> any)` | | Renames fields (attributes, relationships, calculations, and aggregates) in the JSON:API output and input. Can be one of the atoms `:camelize` or `:dasherize` for automatic conversion, a keyword list of `[ash_name: :json_api_name]` mappings, or a 1-arity function that receives an atom field name and returns the desired JSON:API name (atom or string). ```elixir field_names :camelize # first_name → firstName field_names :dasherize # first_name → first-name ``` Or with a keyword list: ```elixir field_names [ first_name: :firstName, last_name: :lastName ] ``` Or with a function for custom logic: ```elixir field_names fn name -> camelized = name \|> to_string() \|> Macro.camelize() {first, rest} = String.split_at(camelized, 1) String.downcase(first) <> rest end ``` Names are applied consistently across serialization, request parsing, sort/filter parameters, field selection, error source pointers, relationship keys, and schema generation. |
| [`argument_names`](#json_api-argument_names){: #json_api-argument_names } | `:camelize \| :dasherize \| keyword \| (any, any -> any)` | | Renames action arguments in the JSON:API request body and schema. Can be one of the atoms `:camelize` or `:dasherize` for automatic conversion, a nested keyword list of `[action_name: [ash_name: :json_api_name]]` mappings, or a 2-arity function that receives `(action_name, argument_name)` atoms and returns the desired JSON:API name (atom or string). ```elixir argument_names :camelize # publish_at → publishAt argument_names :dasherize # publish_at → publish-at ``` Or with a keyword list: ```elixir argument_names [ create: [my_arg: :myArg], update: [my_arg: :myArg] ] ``` Or with a function: ```elixir argument_names fn _action, name -> camelized = name \|> to_string() \|> Macro.camelize() {first, rest} = String.split_at(camelized, 1) String.downcase(first) <> rest end ``` |
| [`calculation_argument_names`](#json_api-calculation_argument_names){: #json_api-calculation_argument_names } | `:camelize \| :dasherize \| keyword \| (any, any -> any)` | | Renames calculation arguments in the JSON:API request and schema. Works the same way as `argument_names` but applies to calculation arguments instead of action arguments. The 2-arity function receives `(calculation_name, argument_name)`. Can be one of the atoms `:camelize` or `:dasherize` for automatic conversion, a nested keyword list of `[calc_name: [ash_name: :json_api_name]]` mappings, or a 2-arity function that receives `(calculation_name, argument_name)` atoms and returns the desired JSON:API name (atom or string). ```elixir calculation_argument_names :camelize # publish_at → publishAt calculation_argument_names :dasherize # publish_at → publish-at ``` Or with a keyword list: ```elixir calculation_argument_names [ full_name: [separator: :sep] ] ``` Or with a function: ```elixir calculation_argument_names fn _calc, name -> camelized = name \|> to_string() \|> Macro.camelize() {first, rest} = String.split_at(camelized, 1) String.downcase(first) <> rest end ``` |


### json_api.routes
Expand All @@ -93,20 +93,20 @@ Configure the routes that will be exposed via the JSON:API

### Examples
```
routes do
base "/posts"

get :read
get :me, route: "/me"
index :read
post :confirm_name, route: "/confirm_name"
patch :update
related :comments, :read
relationship :comments, :read
post_to_relationship :comments
patch_relationship :comments
delete_from_relationship :comments
end
routes do
base "/posts"
get :read
get :me, route: "/me"
index :read
post :confirm_name, route: "/confirm_name"
patch :update
related :comments, :read
relationship :comments, :read
post_to_relationship :comments
patch_relationship :comments
delete_from_relationship :comments
end

```

Expand Down Expand Up @@ -667,10 +667,10 @@ Encode the id of the JSON API response from selected attributes of a resource

### Examples
```
primary_key do
keys [:first_name, :last_name]
delimiter "~"
end
primary_key do
keys [:first_name, :last_name]
delimiter "~"
end

```

Expand Down
6 changes: 6 additions & 0 deletions documentation/topics/routing.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
<!--
SPDX-FileCopyrightText: 2020 Zach Daniel

SPDX-License-Identifier: MIT
-->

# Routing

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.
Expand Down
1 change: 1 addition & 0 deletions lib/ash_json_api/controllers/get_related.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ defmodule AshJsonApi.Controllers.GetRelated do

conn
|> Request.from(resource, action, domain, all_domains, route, options[:prefix])
|> Helpers.fetch_pagination_parameters()
|> Helpers.fetch_related(options[:resource])
|> Helpers.fetch_includes()
|> Helpers.fetch_metadata()
Expand Down
15 changes: 8 additions & 7 deletions lib/ash_json_api/controllers/helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -868,12 +868,7 @@ defmodule AshJsonApi.Controllers.Helpers do

sort = request.sort || default_sort(request.resource)

load_params =
if Map.get(request.assigns, :page) do
[page: request.assigns.page]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is interesting. Seems like maybe we actually do support pagination already? Can you confirm your tests don't already pass against this original behavior?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll take a look. From what I thought was happening was pagination for related routes wasn't quite working but it was for primary routes. I'll work on it now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok so I reverted get_related.ex and fetch_related in helpers.ex to match the version that still had load_params + put_context(:override_domain_params, load_params) and re-ran:

index_pagination_test.exs → still passed
get_related_test.exs → the two “related endpoint with pagination” cases fail: with
?page[limit]=2&page[offset]=0 the response still returns all 5 related records instead of 2 (same for the offset case).
So that path wasn’t applying pagination to the related-route load; primary/index pagination was already working.

With the changes (fetch_pagination_parameters in GetRelated, for_read + Ash.Query.page on the destination query), those tests pass again.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, so the main thing I want to make sure of is that on these related endpoints that we aren't accidentally changing the behavior in some way when page is not supplied, like on actions where pagination is marked as required etc. As long as its not changing existing behavior than all is well 😄 (aside from fixing the usage of page param)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. When page isn’t in the query, it doesn't call Ash.Query.page/2. Only Request.opts with a real page (from page[...]) triggers that. The existing related-route tests that don’t pass page are still passing, so its not changing that path just to “always page.” The only subtle difference is now for_read runs on the destination query. So for the most part it shouldn't, might for some edge cases. If you want, I can add an acceptance test or something along the sort to guarantee.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Speaking of that, I'm not sure that it should run on each call. |> Ash.Query.for_read(request.action.name, request.arguments, read_opts) Is request.action.name referring to the action on the destination resource? If its a related route the action would come from the relationship, and I IIRC that action name refers to the root read action's name? Would need to double check. But I don' thtink we need to run for_read to make this work do we?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry this is a bit long of a reply, I want to be thorough. After playing around a bit and researching here's what I've come up with:

Is request.action.name referring to the action on the destination resource?

  • request.action.name is the read action’s name from the route (e.g. :read). It is not “the destination resource’s Ash.Action struct” on request—that struct is often the base resource’s read. When you call Ash.Query.for_read on a query for relationship.destination, Ash resolves and runs the destination resource’s read whose name is that atom. So the name lines up with the destination read; the request.action value is still usually the base resource’s action object.

If its a related route the action would come from the relationship, and I IIRC that action name refers to the root read action's name?

  • The read name is whatever the route says (e.g. related :comments, :read → :read). That names the read you intend to use on the related resource; it isn’t a separate “relationship action” object—it's the route DSL tying the related endpoint to a read by name. In the router we resolve request.action with Ash.Resource.Info.action(base_resource, route.action), so the request.action struct is often the root/base resource’s read with that name. The important part for the nested query is that we build the query on relationship.destination and call for_read(..., request.action.name, ...), so Ash runs the destination resource’s read named :read (or whatever the route specified), not the root’s read logic on the child. So: the name matches the route (and usually the child read you care about); the resolved action on request can still be the root read struct because of how the route is looked up.

But I don't think we need to run for_read to make this work do we?

  • We might get limit/offset to apply in a toy example by only building filter / sort / load and then Ash.Query.page/2, but that skips everything the read action normally wires up (arguments, preparations, the read’s pagination definition, etc.) and diverges from how index builds its query. For this bug, the behavior we wanted was “related GET paginates like index,” and the path that actually fixed it in tests was for_read on the destination query plus Ash.Query.page/2 when page is in opts. A lighter query without for_read was what we had before and didn’t paginate the related load reliably. So: not strictly impossible in theory, but we do want for_read here for correctness and parity with index.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

request.action.name is the read action’s name from the route (e.g. :read). It is not “the destination resource’s Ash.Action struct” on request—that struct is often the base resource’s read. When you call Ash.Query.for_read on a query for relationship.destination, Ash resolves and runs the destination resource’s read whose name is that atom. So the name lines up with the destination read; the request.action value is still usually the base resource’s action object.

Right, so this is a problem. You're building a query against the destination resource, using an action name from the source resource. This is only working in the tests because the actions happen to have the same name.

As for for_read, can we just try removing it? I'm pretty sure this will all "just work" without it.

Copy link
Copy Markdown
Contributor Author

@Dgoz101 Dgoz101 Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh I see what you are saying. Yes I think you are right. Let me make that change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed for_read and it still passes everything. I tried running spark.cheat_sheets and spark.formatter again but it still isn't changing anything. Deps are up to date so I'm not sure why it isn't working.

else
[]
end
read_opts = Request.opts(request)

destination_query =
relationship.destination
Expand All @@ -883,7 +878,13 @@ defmodule AshJsonApi.Controllers.Helpers do
|> Ash.Query.load(request.includes_keyword)
|> Ash.Query.load(fields(request, request.resource))
|> Ash.Query.set_context(request.context)
|> Ash.Query.put_context(:override_domain_params, load_params)

destination_query =
if request.action.pagination && Keyword.get(read_opts, :page) do
Ash.Query.page(destination_query, Keyword.get(read_opts, :page))
else
destination_query
end

request = Request.assign(request, :query, destination_query)

Expand Down
Loading
Loading