diff --git a/documentation/dsls/DSL-AshJsonApi.Domain.md b/documentation/dsls/DSL-AshJsonApi.Domain.md index 27ea8e9..7ed58e5 100644 --- a/documentation/dsls/DSL-AshJsonApi.Domain.md +++ b/documentation/dsls/DSL-AshJsonApi.Domain.md @@ -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 @@ -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 ``` @@ -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. | @@ -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 ``` @@ -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 ``` @@ -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 @@ -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 ``` diff --git a/documentation/dsls/DSL-AshJsonApi.Resource.md b/documentation/dsls/DSL-AshJsonApi.Resource.md index f67ac28..d1b34f2 100644 --- a/documentation/dsls/DSL-AshJsonApi.Resource.md +++ b/documentation/dsls/DSL-AshJsonApi.Resource.md @@ -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 @@ -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 ``` @@ -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 @@ -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 ``` @@ -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 ``` diff --git a/documentation/topics/routing.md b/documentation/topics/routing.md index 710af92..c4b0068 100644 --- a/documentation/topics/routing.md +++ b/documentation/topics/routing.md @@ -1,3 +1,9 @@ + + # 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. diff --git a/lib/ash_json_api/controllers/get_related.ex b/lib/ash_json_api/controllers/get_related.ex index 34b3e5e..7c8f21a 100644 --- a/lib/ash_json_api/controllers/get_related.ex +++ b/lib/ash_json_api/controllers/get_related.ex @@ -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() diff --git a/lib/ash_json_api/controllers/helpers.ex b/lib/ash_json_api/controllers/helpers.ex index 3b2c053..5bb34e8 100644 --- a/lib/ash_json_api/controllers/helpers.ex +++ b/lib/ash_json_api/controllers/helpers.ex @@ -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] - else - [] - end + read_opts = Request.opts(request) destination_query = relationship.destination @@ -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) diff --git a/test/acceptance/get_related_test.exs b/test/acceptance/get_related_test.exs index ed9bccd..cc3821a 100644 --- a/test/acceptance/get_related_test.exs +++ b/test/acceptance/get_related_test.exs @@ -352,4 +352,141 @@ defmodule Test.Acceptance.GetRelatedTest do ] = includes end end + + describe "related endpoint with pagination" do + defmodule PaginatedComment do + use Ash.Resource, + domain: Test.Acceptance.GetRelatedTest.PaginationDomain, + data_layer: Ash.DataLayer.Ets, + extensions: [AshJsonApi.Resource] + + ets do + private?(true) + end + + json_api do + type("paginated_comment") + end + + actions do + default_accept(:*) + defaults([:create, :update, :destroy]) + + read :read do + primary? true + pagination(offset?: true, countable: true, default_limit: 10) + end + end + + attributes do + uuid_primary_key(:id) + attribute(:name, :string, public?: true) + attribute(:content, :string, public?: true) + attribute(:pagination_post_id, :uuid, public?: true) + end + + relationships do + belongs_to(:post, Test.Acceptance.GetRelatedTest.PaginationPost) do + public?(true) + end + end + end + + defmodule PaginationPost do + use Ash.Resource, + domain: Test.Acceptance.GetRelatedTest.PaginationDomain, + data_layer: Ash.DataLayer.Ets, + extensions: [AshJsonApi.Resource] + + ets do + private?(true) + end + + json_api do + type("pagination_post") + end + + actions do + default_accept(:*) + defaults([:create, :read, :update, :destroy]) + end + + attributes do + uuid_primary_key(:id) + attribute(:name, :string, public?: true) + end + + relationships do + has_many(:comments, PaginatedComment) do + public?(true) + end + end + end + + defmodule PaginationDomain do + use Ash.Domain, + otp_app: :ash_json_api, + extensions: [AshJsonApi.Domain] + + json_api do + log_errors?(false) + + routes do + base_route "/pagination_posts", PaginationPost do + get :read + related :comments, :read + end + end + end + + resources do + resource(PaginationPost) + resource(PaginatedComment) + end + end + + defmodule PaginationRouter do + use AshJsonApi.Router, domain: PaginationDomain + end + + setup do + Application.put_env(:ash_json_api, PaginationDomain, + json_api: [test_router: PaginationRouter] + ) + + post = Ash.Changeset.for_create(PaginationPost, :create, %{name: "parent"}) |> Ash.create!() + + Enum.map(1..5, fn i -> + Ash.Changeset.for_create(PaginatedComment, :create, %{ + name: "comment#{i}", + content: "content", + pagination_post_id: post.id + }) + |> Ash.create!() + end) + + %{post: post} + end + + test "returns paginated related resources when page params are given", %{post: post} do + response = + PaginationDomain + |> get("/pagination_posts/#{post.id}/comments?page[limit]=2&page[offset]=0", status: 200) + + data = response.resp_body["data"] + assert length(data) == 2 + + assert Map.has_key?(response.resp_body, "meta") + assert Map.has_key?(response.resp_body["meta"], "page") + end + + test "respects page offset for related resources", %{post: post} do + response = + PaginationDomain + |> get("/pagination_posts/#{post.id}/comments?page[limit]=2&page[offset]=2", status: 200) + + data = response.resp_body["data"] + assert length(data) == 2 + end + end end