diff --git a/.formatter.exs b/.formatter.exs index ab365e14..a3aebf28 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -11,6 +11,10 @@ spark_locals_without_parens = [ base_route: 1, base_route: 2, base_route: 3, + batch_size: 1, + bulk_update: 1, + bulk_update: 2, + bulk_update: 3, calculation_argument_names: 1, default_fields: 1, delete: 1, @@ -41,6 +45,7 @@ spark_locals_without_parens = [ name: 1, paginate?: 1, paginated_includes: 1, + partial_success_status: 1, patch: 1, patch: 2, patch: 3, @@ -76,7 +81,9 @@ spark_locals_without_parens = [ router: 1, serve_schema?: 1, show_raised_errors?: 1, + success_status: 1, tag: 1, + transaction: 1, type: 1, upsert?: 1, upsert_identity: 1, diff --git a/documentation/dsls/DSL-AshJsonApi.Domain.md b/documentation/dsls/DSL-AshJsonApi.Domain.md index 33739d9b..de5d57ba 100644 --- a/documentation/dsls/DSL-AshJsonApi.Domain.md +++ b/documentation/dsls/DSL-AshJsonApi.Domain.md @@ -18,6 +18,7 @@ Global configuration for JSON:API * index * post * patch + * bulk_update * delete * related * relationship @@ -29,6 +30,7 @@ Global configuration for JSON:API * index * post * patch + * bulk_update * delete * related * relationship @@ -104,6 +106,7 @@ Configure the routes that will be exposed via the JSON:API * index * post * patch + * bulk_update * delete * related * relationship @@ -115,6 +118,7 @@ Configure the routes that will be exposed via the JSON:API * [index](#json_api-routes-index) * [post](#json_api-routes-post) * [patch](#json_api-routes-patch) + * [bulk_update](#json_api-routes-bulk_update) * [delete](#json_api-routes-delete) * [related](#json_api-routes-related) * [relationship](#json_api-routes-relationship) @@ -160,6 +164,7 @@ Sets a prefix for a list of contained routes * [index](#json_api-routes-base_route-index) * [post](#json_api-routes-base_route-post) * [patch](#json_api-routes-base_route-patch) + * [bulk_update](#json_api-routes-base_route-bulk_update) * [delete](#json_api-routes-base_route-delete) * [related](#json_api-routes-base_route-related) * [relationship](#json_api-routes-base_route-relationship) @@ -393,6 +398,66 @@ patch :update, path_param_is_composite_key: :id +### Introspection + +Target: `AshJsonApi.Resource.Route` + +### json_api.routes.base_route.bulk_update +```elixir +bulk_update resource \\ nil, action +``` + + +A route to bulk-update records from an array request body. Supports per-record (non-atomic, partial success) and transactional batch (all-or-nothing) updates. + + + +### Examples +``` +bulk_update :update +``` + +``` +bulk_update :update, transaction: :all +``` + +``` +bulk_update :update, route: "/bulk" +``` + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`resource`](#json_api-routes-base_route-bulk_update-resource){: #json_api-routes-base_route-bulk_update-resource } | `module` | | The resource that the route's action is defined on | +| [`action`](#json_api-routes-base_route-bulk_update-action){: #json_api-routes-base_route-bulk_update-action .spark-required} | `atom` | | The action to call when this route is hit | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`route`](#json_api-routes-base_route-bulk_update-route){: #json_api-routes-base_route-bulk_update-route } | `String.t` | `"/bulk"` | The path of the route | +| [`default_fields`](#json_api-routes-base_route-bulk_update-default_fields){: #json_api-routes-base_route-bulk_update-default_fields } | `list(atom)` | | A list of fields to be shown in the attributes of the called route | +| [`primary?`](#json_api-routes-base_route-bulk_update-primary?){: #json_api-routes-base_route-bulk_update-primary? } | `boolean` | `false` | Whether or not this is the route that should be linked to by default when rendering links to this type of route | +| [`metadata`](#json_api-routes-base_route-bulk_update-metadata){: #json_api-routes-base_route-bulk_update-metadata } | `(any, any, any -> any)` | | A function to generate arbitrary top-level metadata for the JSON:API response | +| [`modify_conn`](#json_api-routes-base_route-bulk_update-modify_conn){: #json_api-routes-base_route-bulk_update-modify_conn } | `(any, any, any, any -> any)` | | A function to modify the conn before responding. Used for things like setting headers based on the response. Takes `conn, subject, result, request`. See the modify_conn guide for more details and examples. | +| [`action_names_in_schema`](#json_api-routes-base_route-bulk_update-action_names_in_schema){: #json_api-routes-base_route-bulk_update-action_names_in_schema } | `keyword` | | A mapping of action names to how they should appear in the OpenAPI schema. You only need to set this if you see a warning during schema generation. | +| [`name`](#json_api-routes-base_route-bulk_update-name){: #json_api-routes-base_route-bulk_update-name } | `String.t` | | A globally unique name for this route, to be used when generating docs and open api specifications | +| [`description`](#json_api-routes-base_route-bulk_update-description){: #json_api-routes-base_route-bulk_update-description } | `String.t` | | A human-friendly description of this specific route to use in generated documentation and OpenAPI. If provided, this overrides the action description. | +| [`derive_sort?`](#json_api-routes-base_route-bulk_update-derive_sort?){: #json_api-routes-base_route-bulk_update-derive_sort? } | `boolean` | `true` | Whether or not to derive a sort parameter based on the sortable fields of the resource | +| [`derive_filter?`](#json_api-routes-base_route-bulk_update-derive_filter?){: #json_api-routes-base_route-bulk_update-derive_filter? } | `boolean` | `true` | Whether or not to derive a filter parameter based on the sortable fields of the resource | +| [`path_param_is_composite_key`](#json_api-routes-base_route-bulk_update-path_param_is_composite_key){: #json_api-routes-base_route-bulk_update-path_param_is_composite_key } | `atom` | | The path parameter that should be parsed as a composite primary key. When specified (e.g., :id), the parameter will be split using the resource's primary key delimiter and mapped to individual primary key fields. This is required for resources with composite primary keys to work correctly with GET, PATCH, and DELETE operations. See the composite primary keys documentation for more details. | +| [`transaction`](#json_api-routes-base_route-bulk_update-transaction){: #json_api-routes-base_route-bulk_update-transaction } | `:per_record \| :batch \| :all \| false` | `:per_record` | How to wrap the bulk update. `:per_record`/`false` are non-atomic — each record commits independently so successes persist when other records fail (partial success → multi-status). `:batch`/`:all` are atomic — any failure rolls the whole request back (all-or-nothing). | +| [`batch_size`](#json_api-routes-base_route-bulk_update-batch_size){: #json_api-routes-base_route-bulk_update-batch_size } | `pos_integer` | | The number of records to update per batch, passed through to `Ash.update_all/3`. | +| [`success_status`](#json_api-routes-base_route-bulk_update-success_status){: #json_api-routes-base_route-bulk_update-success_status } | `integer` | `200` | The HTTP status code to use when every record succeeds. | +| [`partial_success_status`](#json_api-routes-base_route-bulk_update-partial_success_status){: #json_api-routes-base_route-bulk_update-partial_success_status } | `integer` | `207` | The HTTP status code to use when some records succeed and some fail (per-record mode only). | +| [`read_action`](#json_api-routes-base_route-bulk_update-read_action){: #json_api-routes-base_route-bulk_update-read_action } | `atom` | | The read action to use to look the records up before updating | + + + + + ### Introspection Target: `AshJsonApi.Resource.Route` @@ -959,6 +1024,66 @@ patch :update, path_param_is_composite_key: :id +### Introspection + +Target: `AshJsonApi.Resource.Route` + +### json_api.routes.bulk_update +```elixir +bulk_update resource, action +``` + + +A route to bulk-update records from an array request body. Supports per-record (non-atomic, partial success) and transactional batch (all-or-nothing) updates. + + + +### Examples +``` +bulk_update :update +``` + +``` +bulk_update :update, transaction: :all +``` + +``` +bulk_update :update, route: "/bulk" +``` + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`resource`](#json_api-routes-bulk_update-resource){: #json_api-routes-bulk_update-resource } | `module` | | The resource that the route's action is defined on | +| [`action`](#json_api-routes-bulk_update-action){: #json_api-routes-bulk_update-action .spark-required} | `atom` | | The action to call when this route is hit | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`route`](#json_api-routes-bulk_update-route){: #json_api-routes-bulk_update-route } | `String.t` | `"/bulk"` | The path of the route | +| [`default_fields`](#json_api-routes-bulk_update-default_fields){: #json_api-routes-bulk_update-default_fields } | `list(atom)` | | A list of fields to be shown in the attributes of the called route | +| [`primary?`](#json_api-routes-bulk_update-primary?){: #json_api-routes-bulk_update-primary? } | `boolean` | `false` | Whether or not this is the route that should be linked to by default when rendering links to this type of route | +| [`metadata`](#json_api-routes-bulk_update-metadata){: #json_api-routes-bulk_update-metadata } | `(any, any, any -> any)` | | A function to generate arbitrary top-level metadata for the JSON:API response | +| [`modify_conn`](#json_api-routes-bulk_update-modify_conn){: #json_api-routes-bulk_update-modify_conn } | `(any, any, any, any -> any)` | | A function to modify the conn before responding. Used for things like setting headers based on the response. Takes `conn, subject, result, request`. See the modify_conn guide for more details and examples. | +| [`action_names_in_schema`](#json_api-routes-bulk_update-action_names_in_schema){: #json_api-routes-bulk_update-action_names_in_schema } | `keyword` | | A mapping of action names to how they should appear in the OpenAPI schema. You only need to set this if you see a warning during schema generation. | +| [`name`](#json_api-routes-bulk_update-name){: #json_api-routes-bulk_update-name } | `String.t` | | A globally unique name for this route, to be used when generating docs and open api specifications | +| [`description`](#json_api-routes-bulk_update-description){: #json_api-routes-bulk_update-description } | `String.t` | | A human-friendly description of this specific route to use in generated documentation and OpenAPI. If provided, this overrides the action description. | +| [`derive_sort?`](#json_api-routes-bulk_update-derive_sort?){: #json_api-routes-bulk_update-derive_sort? } | `boolean` | `true` | Whether or not to derive a sort parameter based on the sortable fields of the resource | +| [`derive_filter?`](#json_api-routes-bulk_update-derive_filter?){: #json_api-routes-bulk_update-derive_filter? } | `boolean` | `true` | Whether or not to derive a filter parameter based on the sortable fields of the resource | +| [`path_param_is_composite_key`](#json_api-routes-bulk_update-path_param_is_composite_key){: #json_api-routes-bulk_update-path_param_is_composite_key } | `atom` | | The path parameter that should be parsed as a composite primary key. When specified (e.g., :id), the parameter will be split using the resource's primary key delimiter and mapped to individual primary key fields. This is required for resources with composite primary keys to work correctly with GET, PATCH, and DELETE operations. See the composite primary keys documentation for more details. | +| [`transaction`](#json_api-routes-bulk_update-transaction){: #json_api-routes-bulk_update-transaction } | `:per_record \| :batch \| :all \| false` | `:per_record` | How to wrap the bulk update. `:per_record`/`false` are non-atomic — each record commits independently so successes persist when other records fail (partial success → multi-status). `:batch`/`:all` are atomic — any failure rolls the whole request back (all-or-nothing). | +| [`batch_size`](#json_api-routes-bulk_update-batch_size){: #json_api-routes-bulk_update-batch_size } | `pos_integer` | | The number of records to update per batch, passed through to `Ash.update_all/3`. | +| [`success_status`](#json_api-routes-bulk_update-success_status){: #json_api-routes-bulk_update-success_status } | `integer` | `200` | The HTTP status code to use when every record succeeds. | +| [`partial_success_status`](#json_api-routes-bulk_update-partial_success_status){: #json_api-routes-bulk_update-partial_success_status } | `integer` | `207` | The HTTP status code to use when some records succeed and some fail (per-record mode only). | +| [`read_action`](#json_api-routes-bulk_update-read_action){: #json_api-routes-bulk_update-read_action } | `atom` | | The read action to use to look the records up before updating | + + + + + ### Introspection Target: `AshJsonApi.Resource.Route` diff --git a/documentation/dsls/DSL-AshJsonApi.Resource.md b/documentation/dsls/DSL-AshJsonApi.Resource.md index c7bd564b..f5badba0 100644 --- a/documentation/dsls/DSL-AshJsonApi.Resource.md +++ b/documentation/dsls/DSL-AshJsonApi.Resource.md @@ -15,6 +15,7 @@ Configure the resource's behavior in the JSON:API * index * post * patch + * bulk_update * delete * related * relationship @@ -84,6 +85,7 @@ Configure the routes that will be exposed via the JSON:API * [index](#json_api-routes-index) * [post](#json_api-routes-post) * [patch](#json_api-routes-patch) + * [bulk_update](#json_api-routes-bulk_update) * [delete](#json_api-routes-delete) * [related](#json_api-routes-related) * [relationship](#json_api-routes-relationship) @@ -319,6 +321,65 @@ patch :update, path_param_is_composite_key: :id +### Introspection + +Target: `AshJsonApi.Resource.Route` + +### json_api.routes.bulk_update +```elixir +bulk_update action +``` + + +A route to bulk-update records from an array request body. Supports per-record (non-atomic, partial success) and transactional batch (all-or-nothing) updates. + + + +### Examples +``` +bulk_update :update +``` + +``` +bulk_update :update, transaction: :all +``` + +``` +bulk_update :update, route: "/bulk" +``` + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`action`](#json_api-routes-bulk_update-action){: #json_api-routes-bulk_update-action .spark-required} | `atom` | | The action to call when this route is hit | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`route`](#json_api-routes-bulk_update-route){: #json_api-routes-bulk_update-route } | `String.t` | `"/bulk"` | The path of the route | +| [`default_fields`](#json_api-routes-bulk_update-default_fields){: #json_api-routes-bulk_update-default_fields } | `list(atom)` | | A list of fields to be shown in the attributes of the called route | +| [`primary?`](#json_api-routes-bulk_update-primary?){: #json_api-routes-bulk_update-primary? } | `boolean` | `false` | Whether or not this is the route that should be linked to by default when rendering links to this type of route | +| [`metadata`](#json_api-routes-bulk_update-metadata){: #json_api-routes-bulk_update-metadata } | `(any, any, any -> any)` | | A function to generate arbitrary top-level metadata for the JSON:API response | +| [`modify_conn`](#json_api-routes-bulk_update-modify_conn){: #json_api-routes-bulk_update-modify_conn } | `(any, any, any, any -> any)` | | A function to modify the conn before responding. Used for things like setting headers based on the response. Takes `conn, subject, result, request`. See the modify_conn guide for more details and examples. | +| [`action_names_in_schema`](#json_api-routes-bulk_update-action_names_in_schema){: #json_api-routes-bulk_update-action_names_in_schema } | `keyword` | | A mapping of action names to how they should appear in the OpenAPI schema. You only need to set this if you see a warning during schema generation. | +| [`name`](#json_api-routes-bulk_update-name){: #json_api-routes-bulk_update-name } | `String.t` | | A globally unique name for this route, to be used when generating docs and open api specifications | +| [`description`](#json_api-routes-bulk_update-description){: #json_api-routes-bulk_update-description } | `String.t` | | A human-friendly description of this specific route to use in generated documentation and OpenAPI. If provided, this overrides the action description. | +| [`derive_sort?`](#json_api-routes-bulk_update-derive_sort?){: #json_api-routes-bulk_update-derive_sort? } | `boolean` | `true` | Whether or not to derive a sort parameter based on the sortable fields of the resource | +| [`derive_filter?`](#json_api-routes-bulk_update-derive_filter?){: #json_api-routes-bulk_update-derive_filter? } | `boolean` | `true` | Whether or not to derive a filter parameter based on the sortable fields of the resource | +| [`path_param_is_composite_key`](#json_api-routes-bulk_update-path_param_is_composite_key){: #json_api-routes-bulk_update-path_param_is_composite_key } | `atom` | | The path parameter that should be parsed as a composite primary key. When specified (e.g., :id), the parameter will be split using the resource's primary key delimiter and mapped to individual primary key fields. This is required for resources with composite primary keys to work correctly with GET, PATCH, and DELETE operations. See the composite primary keys documentation for more details. | +| [`transaction`](#json_api-routes-bulk_update-transaction){: #json_api-routes-bulk_update-transaction } | `:per_record \| :batch \| :all \| false` | `:per_record` | How to wrap the bulk update. `:per_record`/`false` are non-atomic — each record commits independently so successes persist when other records fail (partial success → multi-status). `:batch`/`:all` are atomic — any failure rolls the whole request back (all-or-nothing). | +| [`batch_size`](#json_api-routes-bulk_update-batch_size){: #json_api-routes-bulk_update-batch_size } | `pos_integer` | | The number of records to update per batch, passed through to `Ash.update_all/3`. | +| [`success_status`](#json_api-routes-bulk_update-success_status){: #json_api-routes-bulk_update-success_status } | `integer` | `200` | The HTTP status code to use when every record succeeds. | +| [`partial_success_status`](#json_api-routes-bulk_update-partial_success_status){: #json_api-routes-bulk_update-partial_success_status } | `integer` | `207` | The HTTP status code to use when some records succeed and some fail (per-record mode only). | +| [`read_action`](#json_api-routes-bulk_update-read_action){: #json_api-routes-bulk_update-read_action } | `atom` | | The read action to use to look the records up before updating | + + + + + ### Introspection Target: `AshJsonApi.Resource.Route` diff --git a/documentation/topics/routing.md b/documentation/topics/routing.md index c4b00684..10696fbe 100644 --- a/documentation/topics/routing.md +++ b/documentation/topics/routing.md @@ -16,6 +16,7 @@ AshJsonApi provides a set of route helpers that map HTTP requests to Ash actions | `index` | GET | `/` | `:read` | `:action` | | `post` | POST | `/` | `:create` | `:action`, `:read` | | `patch` | PATCH | `/:id` | `:update` | `:action` | +| `bulk_update` | PATCH | `/bulk` | `:update` | — | | `delete` | DELETE | `/:id` | `:destroy` | `:action` | | `related` | GET | `/:id/` | `:read` | — | | `relationship` | GET | `/:id/relationships/` | `:read` | — | @@ -120,6 +121,70 @@ Options: - `read_action` — the read action used to look up the record before updating - `relationship_arguments` — arguments used to edit relationships inline +### `bulk_update` — update many records, with per-record results + +```elixir +bulk_update :update +``` + +Issues a PATCH request to `/bulk` (by default) whose body is an **array** of resource +objects, each with its own `id` and `attributes`. It supports two modes via the `transaction` +option: + +- **per-record** (`:per_record` (default) or `false`) — non-atomic. Each input is applied + independently using Ash's record-by-record bulk update (`Ash.update_all/3`), so successes + persist even when other inputs fail. The response is a + [207 Multi-Status](https://www.rfc-editor.org/rfc/rfc4918#section-11.1) document correlating + each input index to either a success record or an error. +- **transactional batch** (`:batch` or `:all`) — atomic, all-or-nothing. The whole request is + wrapped in a transaction; if any input fails the entire request rolls back and no records are + updated. + +A request and its per-record response: + +```json +// PATCH /posts/bulk +{ + "data": [ + { "type": "post", "id": "1", "attributes": { "name": "First" } }, + { "type": "post", "id": "2", "attributes": { "name": "Second" } } + ] +} +``` + +```json +// 207 Multi-Status +{ + "data": [ { "type": "post", "id": "1", "attributes": { "name": "First" } } ], + "errors": [ { "status": "422", "source": { "pointer": "/data/1/attributes/name" } } ], + "meta": { "total_requested": 2, "successful": 1, "failed": 1 } +} +``` + +The status code depends on the outcome: + +- all inputs succeed → **`success_status`** (default **200**), body is a plain collection (`data` only) +- some succeed, some fail (per-record mode only) → **`partial_success_status`** (default **207**), body has `data` + `errors` + `meta` +- every input fails, or any input fails in atomic mode → a standard JSON:API **errors document** with the natural status (e.g. `400`/`404`), `errors` only + +> #### Spec note {: .info} +> +> The partial-success response intentionally returns `data` and `errors` in the same document. +> This is an **extension** beyond the JSON:API base spec (which forbids `data` and `errors` from +> coexisting) to support the [207 Multi-Status](https://www.rfc-editor.org/rfc/rfc4918#section-11.1) +> behavior. All other responses are spec-conformant: full success is a normal collection and every +> failure case is a normal errors document. + +Each error's `source.pointer` is `/data/` (or `/data//attributes/` for +attribute errors), where `` is the position of the failing input in the request array. + +Options: +- `transaction` — how to wrap the bulk update (`:per_record` (default), `:batch`, `:all`, or `false`). `:per_record`/`false` are non-atomic (partial success); `:batch`/`:all` are atomic (all-or-nothing). +- `batch_size` — number of records to update per batch, passed through to `Ash.update_all/3` +- `success_status` — HTTP status when every record succeeds (default `200`) +- `partial_success_status` — HTTP status when some records succeed and some fail (default `207`) +- `read_action` — the read action used to look the records up before updating + ### `delete` — destroy a record ```elixir diff --git a/lib/ash_json_api/controllers/bulk_update.ex b/lib/ash_json_api/controllers/bulk_update.ex new file mode 100644 index 00000000..bbb371b3 --- /dev/null +++ b/lib/ash_json_api/controllers/bulk_update.ex @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: 2019 ash_json_api contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshJsonApi.Controllers.BulkUpdate do + @moduledoc false + alias AshJsonApi.Controllers.{Helpers, Response} + alias AshJsonApi.Request + + def init(options) do + options + end + + def call(conn, options) do + resource = options[:resource] + action = options[:action] + domain = options[:domain] + route = options[:route] + all_domains = options[:all_domains] + + conn + |> Request.from(resource, action, domain, all_domains, route, options[:prefix]) + |> Helpers.bulk_update_records() + |> Helpers.fetch_includes() + |> Helpers.fetch_metadata() + |> Helpers.render_or_render_errors(conn, fn conn, request -> + case request.assigns.bulk_status do + :success -> + Response.render_many( + conn, + request, + route.success_status || 200, + request.assigns.result, + request.assigns.includes + ) + + _ -> + Response.render_bulk_update(conn, request, request.assigns.includes) + end + end) + end +end diff --git a/lib/ash_json_api/controllers/helpers.ex b/lib/ash_json_api/controllers/helpers.ex index 55d8692e..fa502d5a 100644 --- a/lib/ash_json_api/controllers/helpers.ex +++ b/lib/ash_json_api/controllers/helpers.ex @@ -455,6 +455,191 @@ defmodule AshJsonApi.Controllers.Helpers do end) end + @doc false + def bulk_update_records(request) do + chain(request, fn request -> + route = request.route + inputs = request.assigns.bulk_inputs || [] + atomic? = route.transaction in [:batch, :all] + + case lookup_bulk_records(request, inputs) do + {:error, error} -> + Request.add_error(request, error, :read) + + {_found, not_found_errors} when atomic? and not_found_errors != [] -> + finalize_bulk_result(request, [], not_found_errors, length(inputs), atomic?) + + {found, not_found_errors} -> + found_inputs = Enum.map(found, fn {_idx, record, attrs} -> {record, attrs} end) + + index_map = + found + |> Enum.with_index() + |> Map.new(fn {{req_idx, _record, _attrs}, found_idx} -> {found_idx, req_idx} end) + + result = + Ash.update_all( + found_inputs, + request.action.name, + bulk_update_opts(request, route, atomic?) + ) + + records = + (result.records || []) + |> Enum.map(fn record -> + {Map.get(index_map, bulk_update_index(record), 0), record} + end) + |> Enum.sort_by(&elem(&1, 0)) + |> Enum.map(&elem(&1, 1)) + + update_all_errors = + Enum.map(result.errors || [], fn error -> + req_idx = Map.get(index_map, AshJsonApi.Error.bulk_error_index(error), 0) + {req_idx, error} + end) + + finalize_bulk_result( + request, + records, + update_all_errors ++ not_found_errors, + length(inputs), + atomic? + ) + end + end) + end + + defp bulk_update_opts(request, route, atomic?) do + opts = + Request.opts(request, + transaction: route.transaction, + stop_on_error?: atomic?, + authorize_changeset_with: Request.authorize_bulk_with(request.resource), + return_records?: true, + return_errors?: true, + notify?: true, + context: request.context || %{}, + load: fields(request, request.resource) ++ (request.includes_keyword || []) + ) + + if route.batch_size do + Keyword.put(opts, :batch_size, route.batch_size) + else + opts + end + end + + defp finalize_bulk_result(request, records, bulk_errors, total, atomic?) do + {records, bulk_errors} = + if atomic? and bulk_errors != [] do + {[], bulk_errors} + else + {records, bulk_errors} + end + + successful = length(records) + failed = total - successful + + cond do + failed == 0 -> + request + |> Request.assign(:result, records) + |> Request.assign(:bulk_status, :success) + + not atomic? and successful > 0 -> + request + |> Request.assign(:result, records) + |> Request.assign(:bulk_status, :partial_success) + |> Request.assign(:bulk_errors, bulk_errors) + |> Request.assign(:bulk_meta, %{ + total_requested: total, + successful: successful, + failed: failed + }) + + true -> + Enum.reduce(bulk_errors, request, fn {index, error}, request -> + Request.add_error( + request, + AshJsonApi.Error.bulk_to_json_api_errors( + request.domain, + request.resource, + error, + :update, + index + ), + request.route.type + ) + end) + end + end + + defp bulk_update_index(%{__metadata__: %{bulk_update_index: index}}) when is_integer(index), + do: index + + defp bulk_update_index(_), do: 0 + + defp lookup_bulk_records(request, inputs) do + resource = request.resource + pkey = Ash.Resource.Info.primary_key(resource) + + read_action = + if request.route.read_action do + request.route.read_action + else + Ash.Resource.Info.primary_action!(resource, :read).name + end + + pseudo_records = Enum.map(inputs, fn {id, _attrs} -> id_to_pkey_map(pkey, id) end) + + query = + resource + |> Ash.Query.do_filter(Ash.pkey_filter(pseudo_records, pkey)) + |> Ash.Query.set_context(request.context || %{}) + |> Ash.Query.for_read( + read_action, + %{}, + Keyword.put(Request.opts(request), :page, false) + ) + + case Ash.read(query, Request.opts(request)) do + {:ok, records} -> + by_pkey = Map.new(records, &{record_pkey_key(&1, pkey), &1}) + + {found, missing} = + inputs + |> Enum.with_index() + |> Enum.reduce({[], []}, fn {{id, attrs}, req_idx}, {found, missing} -> + case Map.get(by_pkey, input_pkey_key(pkey, id)) do + nil -> + error = + Ash.Error.Query.NotFound.exception(primary_key: id, resource: resource) + + {found, [{req_idx, error} | missing]} + + record -> + {[{req_idx, record, attrs} | found], missing} + end + end) + + {Enum.reverse(found), Enum.reverse(missing)} + + {:error, error} -> + {:error, error} + end + end + + defp id_to_pkey_map(_pkey, id) when is_map(id), do: id + defp id_to_pkey_map([single], id), do: %{single => id} + defp id_to_pkey_map(_pkey, id), do: %{id: id} + + defp record_pkey_key(record, pkey), do: Enum.map(pkey, &to_string(Map.get(record, &1))) + + defp input_pkey_key(pkey, id) do + map = id_to_pkey_map(pkey, id) + Enum.map(pkey, &to_string(Map.get(map, &1))) + end + def add_to_relationship(request, relationship_name) do chain(request, fn %{assigns: %{result: result}} -> action = Ash.Resource.Info.primary_action!(request.resource, :update).name diff --git a/lib/ash_json_api/controllers/response.ex b/lib/ash_json_api/controllers/response.ex index e48fa4dd..a62731ba 100644 --- a/lib/ash_json_api/controllers/response.ex +++ b/lib/ash_json_api/controllers/response.ex @@ -102,6 +102,14 @@ defmodule AshJsonApi.Controllers.Response do send_resp(conn, status, serialized) end + # sobelow_skip ["XSS.SendResp"] + def render_bulk_update(conn, request, includes) do + status = request.route.partial_success_status || 207 + serialized = AshJsonApi.Serializer.serialize_bulk_result(request, includes) + + send_resp(conn, status, serialized) + end + # sobelow_skip ["XSS.SendResp"] def render_one_relationship(conn, request, status, relationship) do serialized = diff --git a/lib/ash_json_api/error/error.ex b/lib/ash_json_api/error/error.ex index 72a4403b..fae220ea 100644 --- a/lib/ash_json_api/error/error.ex +++ b/lib/ash_json_api/error/error.ex @@ -112,6 +112,51 @@ defmodule AshJsonApi.Error do apply_error_handler(errors, domain, resource) end + @doc false + def bulk_to_json_api_errors(domain, resource, error, type, index) do + domain + |> to_json_api_errors(resource, error, type) + |> Enum.map(&with_bulk_source_pointer(&1, index)) + end + + @doc false + def bulk_error_index(%{path: [index | _]}) when is_integer(index), do: index + def bulk_error_index(%{errors: [inner | _]}), do: bulk_error_index(inner) + def bulk_error_index(_), do: 0 + + @doc false + def with_bulk_source_pointer(%__MODULE__{source_pointer: pointer} = error, index) do + %{error | source_pointer: bulk_source_pointer(pointer, index)} + end + + defp bulk_source_pointer(pointer, index) when pointer in [nil, :undefined] do + "/data/#{index}" + end + + defp bulk_source_pointer("/data/attributes/" <> rest, index) do + "/data/#{index}/attributes/#{strip_leading_index(rest)}" + end + + defp bulk_source_pointer("/data/relationships/" <> rest, index) do + "/data/#{index}/relationships/#{strip_leading_index(rest)}" + end + + defp bulk_source_pointer("/data/" <> rest, index) do + "/data/#{index}/#{strip_leading_index(rest)}" + end + + defp bulk_source_pointer(pointer, _index), do: pointer + + defp strip_leading_index(rest) do + case String.split(rest, "/", parts: 2) do + [maybe_index, tail] -> + if maybe_index =~ ~r/^\d+$/, do: tail, else: rest + + _ -> + rest + end + end + defp apply_error_handler(errors, nil, _resource), do: errors defp apply_error_handler(errors, domain, resource) do diff --git a/lib/ash_json_api/json_schema/json_schema.ex b/lib/ash_json_api/json_schema/json_schema.ex index f942037d..cdb59845 100644 --- a/lib/ash_json_api/json_schema/json_schema.ex +++ b/lib/ash_json_api/json_schema/json_schema.ex @@ -924,6 +924,65 @@ defmodule AshJsonApi.JsonSchema do } end + defp route_in_schema( + %{ + type: :bulk_update, + action: action, + relationship_arguments: relationship_arguments + } = route, + _domain, + resource + ) do + action = Ash.Resource.Info.action(resource, action) + + non_relationship_arguments = + Enum.reject(action.arguments, &has_relationship_argument?(relationship_arguments, &1.name)) + + %{ + "type" => "object", + "required" => ["data"], + "additionalProperties" => false, + "properties" => %{ + "data" => %{ + "type" => "array", + "items" => %{ + "type" => "object", + "additionalProperties" => false, + "properties" => + %{ + "type" => %{ + "const" => AshJsonApi.Resource.Info.type(resource) + }, + "attributes" => + %{ + "type" => "object", + "additionalProperties" => false, + "required" => + required_write_attributes( + resource, + non_relationship_arguments, + action, + route + ), + "properties" => + write_attributes(resource, non_relationship_arguments, action, route) + } + |> add_null_for_non_required(), + "relationships" => %{ + "type" => "object", + "additionalProperties" => false, + "required" => + required_relationship_attributes(resource, relationship_arguments, action), + "properties" => write_relationships(resource, relationship_arguments, action) + } + } + |> add_id_field(resource) + } + } + } + } + end + defp route_in_schema( %{type: type, relationship: relationship}, _domain, diff --git a/lib/ash_json_api/json_schema/open_api.ex b/lib/ash_json_api/json_schema/open_api.ex index c60d346c..2d68d703 100644 --- a/lib/ash_json_api/json_schema/open_api.ex +++ b/lib/ash_json_api/json_schema/open_api.ex @@ -1607,17 +1607,27 @@ if Code.ensure_loaded?(OpenApiSpex) do acc_with_request_schemas = %{acc | schemas: Map.merge(acc.schemas, request_schemas)} + responses = + %{ + :default => %Reference{ + "$ref": "#/components/responses/errors" + }, + response_code => response + } + |> then(fn responses -> + if route.type == :bulk_update do + Map.put(responses, 207, bulk_multi_status_response(route, resource)) + else + responses + end + end) + operation = %Operation{ description: action_description(action, route, resource), operationId: route.name, tags: [to_string(AshJsonApi.Resource.Info.type(resource))], parameters: parameters_list, - responses: %{ - :default => %Reference{ - "$ref": "#/components/responses/errors" - }, - response_code => response - }, + responses: responses, requestBody: request_body_result } @@ -2119,6 +2129,7 @@ if Code.ensure_loaded?(OpenApiSpex) do :route, :post, :patch, + :bulk_update, :post_to_relationship, :patch_relationship, :delete_from_relationship @@ -2152,6 +2163,9 @@ if Code.ensure_loaded?(OpenApiSpex) do route.type == :route -> json_body_schema.properties.data.required != [] + route.type == :bulk_update -> + true + true -> json_body_schema.properties.data.properties.attributes.required != [] || json_body_schema.properties.data.properties.relationships.required != [] @@ -2365,6 +2379,82 @@ if Code.ensure_loaded?(OpenApiSpex) do {schema, acc} end + defp request_body_schema( + %{ + type: :bulk_update, + action: action, + relationship_arguments: relationship_arguments + } = route, + resource, + format, + acc + ) do + action = Ash.Resource.Info.action(resource, action) + + non_relationship_arguments = + Enum.reject( + action.arguments, + &has_relationship_argument?(relationship_arguments, &1.name) + ) + + {properties, acc} = + write_attributes( + resource, + non_relationship_arguments, + action, + route, + acc, + format + ) + + schema = %Schema{ + type: :object, + required: [:data], + additionalProperties: false, + properties: %{ + data: %Schema{ + type: :array, + items: %Schema{ + type: :object, + additionalProperties: false, + required: [:id], + properties: %{ + id: %Schema{ + type: :string + }, + type: %Schema{ + enum: [AshJsonApi.Resource.Info.type(resource)] + }, + attributes: + %Schema{ + type: :object, + additionalProperties: false, + properties: properties, + required: + required_write_attributes( + resource, + non_relationship_arguments, + action, + route + ) + } + |> add_null_for_non_required(), + relationships: %Schema{ + type: :object, + additionalProperties: false, + properties: write_relationships(resource, relationship_arguments, action), + required: + required_relationship_attributes(resource, relationship_arguments, action) + } + } + } + } + } + } + + {schema, acc} + end + defp request_body_schema( %{type: type, relationship: relationship}, resource, @@ -2555,6 +2645,44 @@ if Code.ensure_loaded?(OpenApiSpex) do {response, acc} end + defp bulk_multi_status_response(route, resource) do + schema = %Schema{ + type: :object, + additionalProperties: false, + properties: %{ + data: %Schema{ + description: + "An array of successfully updated #{AshJsonApi.Resource.Info.type(resource)} resource objects", + type: :array, + items: item_reference(route, resource), + uniqueItems: true + }, + included: included_resource_schemas(resource), + errors: %Schema{ + description: "Errors for inputs that failed, correlated by index via source.pointer", + type: :array, + items: %Schema{type: :object, additionalProperties: true} + }, + meta: %Schema{ + type: :object, + additionalProperties: true, + properties: %{ + total_requested: %Schema{type: :integer}, + successful: %Schema{type: :integer}, + failed: %Schema{type: :integer} + } + } + } + } + + %Response{ + description: "Multi-Status — some inputs succeeded and some failed", + content: %{ + "application/vnd.api+json" => %MediaType{schema: schema} + } + } + end + @spec response_schema(Route.t(), resource :: module, acc :: acc()) :: {Schema.t(), acc()} defp response_schema(route, resource, acc) do case route.type do @@ -2622,6 +2750,28 @@ if Code.ensure_loaded?(OpenApiSpex) do :delete -> {nil, acc} + :bulk_update -> + schema = %Schema{ + type: :object, + additionalProperties: false, + properties: %{ + data: %Schema{ + description: + "An array of updated #{AshJsonApi.Resource.Info.type(resource)} resource objects", + type: :array, + items: item_reference(route, resource), + uniqueItems: true + }, + included: included_resource_schemas(resource), + meta: %Schema{ + type: :object, + additionalProperties: true + } + } + } + + {schema, acc} + type when type in [:post_to_relationship, :patch_relationship, :delete_from_relationship] -> resource diff --git a/lib/ash_json_api/request.ex b/lib/ash_json_api/request.ex index 9b63cc80..faf7e36b 100644 --- a/lib/ash_json_api/request.ex +++ b/lib/ash_json_api/request.ex @@ -126,6 +126,7 @@ defmodule AshJsonApi.Request do |> parse_action_arguments() |> parse_relationships() |> parse_resource_identifiers() + |> parse_bulk_data() end def load_opts(request) do @@ -1379,6 +1380,145 @@ defmodule AshJsonApi.Request do %{request | resource_identifiers: nil} end + defp parse_bulk_data(%{route: %{type: :bulk_update}, body: %{"data" => data}} = request) + when is_list(data) do + expected_type = AshJsonApi.Resource.Info.type(request.resource) + + {inputs, request} = + data + |> Enum.with_index() + |> Enum.reduce({[], request}, fn {member, index}, {inputs, request} -> + case parse_bulk_member(request, member, index, expected_type) do + {:ok, input} -> + {[input | inputs], request} + + {:error, error} -> + {inputs, add_error(request, error, request.route.type)} + end + end) + + assign(request, :bulk_inputs, Enum.reverse(inputs)) + end + + defp parse_bulk_data(%{route: %{type: :bulk_update}} = request) do + add_error( + request, + bulk_body_error("Expected `data` to be an array of resource objects", "/data"), + request.route.type + ) + end + + defp parse_bulk_data(request), do: request + + defp parse_bulk_member(request, member, index, expected_type) when is_map(member) do + with :ok <- validate_bulk_member_type(member, expected_type, index), + {:ok, id} <- fetch_bulk_member_id(request, member, index) do + attributes = Map.get(member, "attributes", %{}) + {:ok, {id, build_bulk_input(request, attributes)}} + end + end + + defp parse_bulk_member(_request, _member, index, _expected_type) do + {:error, bulk_body_error("Expected a resource object", "/data/#{index}")} + end + + defp validate_bulk_member_type(member, expected_type, index) do + case Map.fetch(member, "type") do + {:ok, ^expected_type} -> + :ok + + :error -> + :ok + + {:ok, _other} -> + {:error, + bulk_body_error( + "Expected member type to be \"#{expected_type}\"", + "/data/#{index}/type" + )} + end + end + + defp fetch_bulk_member_id(request, member, index) do + case Map.fetch(member, "id") do + {:ok, id} when is_binary(id) -> + case normalize_bulk_id(request.resource, id) do + {:ok, normalized} -> + {:ok, normalized} + + :error -> + {:error, bulk_body_error("Invalid composite primary key", "/data/#{index}/id")} + end + + _ -> + {:error, bulk_body_error("Expected member to have an `id`", "/data/#{index}/id")} + end + end + + defp normalize_bulk_id(resource, id) do + case AshJsonApi.Resource.Info.primary_key_fields(resource) do + fields when fields in [nil, []] -> + {:ok, id} + + [_single] -> + {:ok, id} + + fields -> + delimiter = AshJsonApi.Resource.Info.primary_key_delimiter(resource) + values = String.split(id, delimiter) + + if length(values) == length(fields) do + {:ok, fields |> Enum.zip(values) |> Map.new()} + else + :error + end + end + end + + defp build_bulk_input(request, attributes) when is_map(attributes) do + action = request.action + resource = request.resource + arg_names = AshJsonApi.Resource.Info.argument_names(resource) + attr_names = AshJsonApi.Resource.Info.field_names(resource) + + Enum.reduce(attributes, %{}, fn {key, value}, acc -> + matching_argument = + Enum.find(action.arguments, fn argument -> + argument.public? && + AshJsonApi.Resource.Info.apply_argument_name_mapping( + arg_names, + action.name, + argument.name + ) == key + end) + + matching_accept = + action + |> Map.get(:accept, []) + |> Enum.find(fn attr_name -> + AshJsonApi.Resource.Info.apply_field_name_mapping(attr_names, attr_name) == key + end) + + case matching_argument || matching_accept do + %Ash.Resource.Actions.Argument{name: name} -> Map.put(acc, name, value) + name when is_atom(name) and not is_nil(name) -> Map.put(acc, name, value) + _ -> acc + end + end) + end + + defp build_bulk_input(_request, _attributes), do: %{} + + defp bulk_body_error(detail, pointer) do + %AshJsonApi.Error{ + status_code: 400, + code: "invalid_body", + title: "InvalidBody", + detail: detail, + source_pointer: pointer + } + end + defp relationship_change_value(name, value) when is_list(value) do value diff --git a/lib/ash_json_api/resource/resource.ex b/lib/ash_json_api/resource/resource.ex index c31c2d2b..2f540956 100644 --- a/lib/ash_json_api/resource/resource.ex +++ b/lib/ash_json_api/resource/resource.ex @@ -190,6 +190,57 @@ defmodule AshJsonApi.Resource do ] } + @bulk_update %Spark.Dsl.Entity{ + name: :bulk_update, + args: [:action], + describe: + "A route to bulk-update records from an array request body. Supports per-record (non-atomic, partial success) and transactional batch (all-or-nothing) updates.", + examples: [ + "bulk_update :update", + "bulk_update :update, transaction: :all", + "bulk_update :update, route: \"/bulk\"" + ], + schema: + @route_schema + |> Spark.Options.Helpers.set_default!(:route, "/bulk") + |> Keyword.delete(:query_params) + |> Keyword.merge( + transaction: [ + type: {:one_of, [:per_record, :batch, :all, false]}, + default: :per_record, + doc: + "How to wrap the bulk update. `:per_record`/`false` are non-atomic — each record commits independently so successes persist when other records fail (partial success → multi-status). `:batch`/`:all` are atomic — any failure rolls the whole request back (all-or-nothing)." + ], + batch_size: [ + type: :pos_integer, + doc: "The number of records to update per batch, passed through to `Ash.update_all/3`." + ], + success_status: [ + type: :integer, + default: 200, + doc: "The HTTP status code to use when every record succeeds." + ], + partial_success_status: [ + type: :integer, + default: 207, + doc: + "The HTTP status code to use when some records succeed and some fail (per-record mode only)." + ], + read_action: [ + type: :atom, + default: nil, + doc: "The read action to use to look the records up before updating" + ] + ), + target: AshJsonApi.Resource.Route, + auto_set_fields: [ + method: :patch, + controller: AshJsonApi.Controllers.BulkUpdate, + action_type: :update, + type: :bulk_update + ] + } + @delete %Spark.Dsl.Entity{ name: :delete, args: [:action], @@ -425,6 +476,7 @@ defmodule AshJsonApi.Resource do @index, @post, @patch, + @bulk_update, @delete, @related, @relationship, diff --git a/lib/ash_json_api/resource/route.ex b/lib/ash_json_api/resource/route.ex index f6b50618..9aa4175c 100644 --- a/lib/ash_json_api/resource/route.ex +++ b/lib/ash_json_api/resource/route.ex @@ -20,6 +20,10 @@ defmodule AshJsonApi.Resource.Route do :upsert_identity, :read_action, :description, + :transaction, + :batch_size, + :success_status, + :partial_success_status, :__spark_metadata__, query_params: [], modify_conn: nil, diff --git a/lib/ash_json_api/resource/verifiers/verify_actions.ex b/lib/ash_json_api/resource/verifiers/verify_actions.ex index 214201d8..0b8ae9cb 100644 --- a/lib/ash_json_api/resource/verifiers/verify_actions.ex +++ b/lib/ash_json_api/resource/verifiers/verify_actions.ex @@ -11,6 +11,7 @@ defmodule AshJsonApi.Resource.Verifiers.VerifyActions do index: [:read, :action], post: [:create, :action, :read], patch: [:update, :action], + bulk_update: [:update], delete: [:destroy, :action], route: [:action], get_related: [:read], diff --git a/lib/ash_json_api/serializer.ex b/lib/ash_json_api/serializer.ex index 9b971dbc..873ae68e 100644 --- a/lib/ash_json_api/serializer.ex +++ b/lib/ash_json_api/serializer.ex @@ -109,6 +109,39 @@ defmodule AshJsonApi.Serializer do |> Jason.encode!() end + def serialize_bulk_result(request, includes) do + records = request.assigns.result || [] + base_meta = Map.get(request.assigns, :metadata, %{}) + + json_api = %{version: "1.0"} + links = %{self: request.url} + bulk_meta = Map.merge(base_meta || %{}, request.assigns.bulk_meta) + + %{ + data: Enum.map(records, &serialize_one_record(request, &1)), + jsonapi: json_api, + links: links + } + |> add_includes(request, includes) + |> Map.put(:errors, serialize_bulk_errors(request, request.assigns.bulk_errors || [])) + |> Map.put(:meta, bulk_meta) + |> Jason.encode!() + end + + defp serialize_bulk_errors(request, bulk_errors) do + bulk_errors + |> Enum.flat_map(fn {index, error} -> + AshJsonApi.Error.bulk_to_json_api_errors( + request.domain, + request.resource, + error, + :update, + index + ) + end) + |> Enum.map(&serialize_one_error(&1, request)) + end + def serialize_errors(request, error_or_errors, meta \\ nil) do json_api = %{version: "1.0"} diff --git a/test/acceptance/bulk_update_test.exs b/test/acceptance/bulk_update_test.exs new file mode 100644 index 00000000..c3316700 --- /dev/null +++ b/test/acceptance/bulk_update_test.exs @@ -0,0 +1,536 @@ +# SPDX-FileCopyrightText: 2019 ash_json_api contributors +# +# SPDX-License-Identifier: MIT + +defmodule Test.Acceptance.BulkUpdateTest do + use ExUnit.Case, async: true + + defmodule Post do + use Ash.Resource, + domain: Test.Acceptance.BulkUpdateTest.Domain, + data_layer: Ash.DataLayer.Ets, + extensions: [ + AshJsonApi.Resource + ] + + ets do + private?(true) + end + + json_api do + type("post") + + routes do + base("/posts") + get(:read) + index(:read) + post(:create) + patch(:update) + + bulk_update(:update) + + bulk_update :update do + route("/bulk_atomic") + transaction(:all) + end + end + end + + actions do + default_accept([:*]) + defaults([:read, :destroy, create: :*]) + + update :update do + primary?(true) + require_atomic?(false) + accept([:name]) + + validate(fn changeset, _context -> + if Ash.Changeset.get_attribute(changeset, :name) == "FAIL" do + {:error, + Ash.Error.Changes.InvalidAttribute.exception( + field: :name, + message: "is invalid" + )} + else + :ok + end + end) + end + end + + attributes do + uuid_primary_key(:id, writable?: true, public?: true) + attribute(:name, :string, public?: true, allow_nil?: false) + end + end + + defmodule Enrollment do + use Ash.Resource, + domain: Test.Acceptance.BulkUpdateTest.Domain, + data_layer: Ash.DataLayer.Ets, + extensions: [ + AshJsonApi.Resource + ] + + ets do + private?(true) + end + + json_api do + type("enrollment") + + primary_key do + keys([:student_id, :course_id]) + delimiter("-") + end + + routes do + base("/enrollments") + index(:read) + bulk_update(:update) + end + end + + actions do + default_accept([:*]) + defaults([:read, create: :*]) + + update :update do + primary?(true) + require_atomic?(false) + accept([:grade]) + + validate(fn changeset, _context -> + if Ash.Changeset.get_attribute(changeset, :grade) == "FAIL" do + {:error, + Ash.Error.Changes.InvalidAttribute.exception(field: :grade, message: "is invalid")} + else + :ok + end + end) + end + end + + attributes do + attribute(:student_id, :string, primary_key?: true, allow_nil?: false, public?: true) + attribute(:course_id, :string, primary_key?: true, allow_nil?: false, public?: true) + attribute(:grade, :string, public?: true) + end + end + + defmodule Domain do + use Ash.Domain, + otp_app: :ash_json_api, + extensions: [ + AshJsonApi.Domain + ] + + json_api do + log_errors?(false) + end + + resources do + resource(Post) + resource(Enrollment) + end + end + + defmodule Router do + use AshJsonApi.Router, domain: Domain + end + + import AshJsonApi.Test + + setup do + Application.put_env(:ash_json_api, Domain, json_api: [test_router: Router]) + + posts = + Enum.map(1..3, fn i -> + Post + |> Ash.Changeset.for_create(:create, %{id: Ecto.UUID.generate(), name: "Post #{i}"}) + |> Ash.create!() + end) + + %{posts: posts} + end + + describe "partial success" do + test "some ids fail -> 207 with data, errors and meta", %{posts: [p1, p2, p3]} do + response = + Domain + |> patch( + "/posts/bulk", + %{ + data: [ + %{type: "post", id: p1.id, attributes: %{name: "updated 1"}}, + %{type: "post", id: p2.id, attributes: %{name: "FAIL"}}, + %{type: "post", id: p3.id, attributes: %{name: "updated 3"}} + ] + }, + status: 207 + ) + + assert %{"data" => data, "errors" => errors, "meta" => meta} = response.resp_body + + assert meta == %{"total_requested" => 3, "successful" => 2, "failed" => 1} + + assert length(data) == 2 + ids = Enum.map(data, & &1["id"]) + assert p1.id in ids + assert p3.id in ids + refute p2.id in ids + + names = Enum.map(data, &get_in(&1, ["attributes", "name"])) + assert "updated 1" in names + assert "updated 3" in names + + assert [error] = errors + assert error["source"]["pointer"] == "/data/1/attributes/name" + end + end + + describe "all success" do + test "every id succeeds -> 200 with only data", %{posts: [p1, p2, _p3]} do + response = + Domain + |> patch( + "/posts/bulk", + %{ + data: [ + %{type: "post", id: p1.id, attributes: %{name: "updated 1"}}, + %{type: "post", id: p2.id, attributes: %{name: "updated 2"}} + ] + }, + status: 200 + ) + + assert %{"data" => data} = response.resp_body + assert length(data) == 2 + refute Map.has_key?(response.resp_body, "errors") + + names = Enum.map(data, &get_in(&1, ["attributes", "name"])) + assert "updated 1" in names + assert "updated 2" in names + end + end + + describe "transactional batch (atomic)" do + test "all succeed -> 200 with a plain collection", %{posts: [p1, p2, _p3]} do + response = + Domain + |> patch( + "/posts/bulk_atomic", + %{ + data: [ + %{type: "post", id: p1.id, attributes: %{name: "updated 1"}}, + %{type: "post", id: p2.id, attributes: %{name: "updated 2"}} + ] + }, + status: 200 + ) + + assert %{"data" => data} = response.resp_body + assert length(data) == 2 + refute Map.has_key?(response.resp_body, "errors") + end + + test "any failure rolls the whole request back -> standard errors document", %{ + posts: [p1, p2, _p3] + } do + response = + Domain + |> patch( + "/posts/bulk_atomic", + %{ + data: [ + %{type: "post", id: p1.id, attributes: %{name: "FAIL"}}, + %{type: "post", id: p2.id, attributes: %{name: "would-be-ok"}} + ] + }, + status: 400 + ) + + refute Map.has_key?(response.resp_body, "data") + assert %{"errors" => errors} = response.resp_body + assert errors != [] + end + + test "a missing id fails the whole atomic request with a 404", %{posts: [p1, _p2, _p3]} do + missing_id = Ecto.UUID.generate() + + response = + Domain + |> patch( + "/posts/bulk_atomic", + %{ + data: [ + %{type: "post", id: p1.id, attributes: %{name: "updated 1"}}, + %{type: "post", id: missing_id, attributes: %{name: "whatever"}} + ] + }, + status: 404 + ) + + refute Map.has_key?(response.resp_body, "data") + assert %{"errors" => [error]} = response.resp_body + assert error["code"] == "not_found" + end + end + + describe "all failed (per-record)" do + test "every id fails -> a standard errors document (no data, no 207)", %{ + posts: [p1, p2, _p3] + } do + response = + Domain + |> patch( + "/posts/bulk", + %{ + data: [ + %{type: "post", id: p1.id, attributes: %{name: "FAIL"}}, + %{type: "post", id: p2.id, attributes: %{name: "FAIL"}} + ] + }, + status: 400 + ) + + refute Map.has_key?(response.resp_body, "data") + assert %{"errors" => errors} = response.resp_body + assert length(errors) == 2 + + pointers = Enum.map(errors, &get_in(&1, ["source", "pointer"])) + assert "/data/0/attributes/name" in pointers + assert "/data/1/attributes/name" in pointers + end + end + + describe "source pointers" do + test "attribute errors point at /data//attributes/", %{posts: [p1, p2, _p3]} do + response = + Domain + |> patch( + "/posts/bulk", + %{ + data: [ + %{type: "post", id: p1.id, attributes: %{name: "FAIL"}}, + %{type: "post", id: p2.id, attributes: %{name: "ok"}} + ] + }, + status: 207 + ) + + assert [error] = response.resp_body["errors"] + assert error["source"]["pointer"] == "/data/0/attributes/name" + end + end + + describe "missing id" do + test "an unknown id surfaces as NotFound at its index, others still succeed", %{ + posts: [p1, _p2, _p3] + } do + missing_id = Ecto.UUID.generate() + + response = + Domain + |> patch( + "/posts/bulk", + %{ + data: [ + %{type: "post", id: p1.id, attributes: %{name: "updated 1"}}, + %{type: "post", id: missing_id, attributes: %{name: "whatever"}} + ] + }, + status: 207 + ) + + assert %{"data" => [datum], "errors" => [error], "meta" => meta} = response.resp_body + assert datum["id"] == p1.id + assert error["code"] == "not_found" + assert error["source"]["pointer"] == "/data/1" + assert meta == %{"total_requested" => 2, "successful" => 1, "failed" => 1} + end + end + + describe "coexistence with a single patch route" do + test "PATCH /posts/bulk hits the bulk route, PATCH /posts/:id hits the single route", %{ + posts: [p1, _p2, _p3] + } do + # bulk route + Domain + |> patch( + "/posts/bulk", + %{data: [%{type: "post", id: p1.id, attributes: %{name: "bulk name"}}]}, + status: 200 + ) + + # single record route still works at /:id + response = + Domain + |> patch( + "/posts/#{p1.id}", + %{data: %{type: "post", id: p1.id, attributes: %{name: "single name"}}}, + status: 200 + ) + + assert get_in(response.resp_body, ["data", "attributes", "name"]) == "single name" + end + end + + describe "openapi" do + test "advertises an array request body and a 207 response" do + spec = AshJsonApi.OpenApi.spec(domain: [Domain]) + + operation = spec.paths["/posts/bulk"].patch + assert operation + + data_schema = + operation.requestBody.content["application/vnd.api+json"].schema.properties.data + + assert data_schema.type == :array + assert data_schema.items.properties.id + + assert Map.has_key?(operation.responses, 207) + assert Map.has_key?(operation.responses, 200) + + response_props = + operation.responses[207].content["application/vnd.api+json"].schema.properties + + assert response_props.data.type == :array + assert response_props.errors.type == :array + assert response_props.meta.properties.total_requested + end + end + + describe "composite primary keys" do + setup do + enrollments = + for {s, c} <- [{"s1", "c1"}, {"s2", "c2"}] do + Enrollment + |> Ash.Changeset.for_create(:create, %{student_id: s, course_id: c, grade: "A"}) + |> Ash.create!() + end + + %{enrollments: enrollments} + end + + test "bulk updates records addressed by composite id", %{enrollments: [e1, e2]} do + response = + Domain + |> patch( + "/enrollments/bulk", + %{ + data: [ + %{ + type: "enrollment", + id: "#{e1.student_id}-#{e1.course_id}", + attributes: %{grade: "B"} + }, + %{ + type: "enrollment", + id: "#{e2.student_id}-#{e2.course_id}", + attributes: %{grade: "FAIL"} + } + ] + }, + status: 207 + ) + + assert %{"data" => [datum], "errors" => [error], "meta" => meta} = response.resp_body + assert datum["id"] == "#{e1.student_id}-#{e1.course_id}" + assert get_in(datum, ["attributes", "grade"]) == "B" + assert error["source"]["pointer"] == "/data/1/attributes/grade" + assert meta == %{"total_requested" => 2, "successful" => 1, "failed" => 1} + end + + test "an unknown composite id surfaces as NotFound at its index", %{enrollments: [e1, _e2]} do + response = + Domain + |> patch( + "/enrollments/bulk", + %{ + data: [ + %{ + type: "enrollment", + id: "#{e1.student_id}-#{e1.course_id}", + attributes: %{grade: "B"} + }, + %{type: "enrollment", id: "nope-missing", attributes: %{grade: "C"}} + ] + }, + status: 207 + ) + + assert %{"data" => [datum], "errors" => [error]} = response.resp_body + assert datum["id"] == "#{e1.student_id}-#{e1.course_id}" + assert error["code"] == "not_found" + assert error["source"]["pointer"] == "/data/1" + end + end + + describe "edge cases" do + test "an empty data array -> 200 with an empty collection" do + response = + Domain + |> patch("/posts/bulk", %{data: []}, status: 200) + + assert response.resp_body["data"] == [] + refute Map.has_key?(response.resp_body, "errors") + end + end + + describe "malformed body" do + test "a missing id member is reported as an invalid_body error", %{posts: [p1, _p2, _p3]} do + response = + Domain + |> patch( + "/posts/bulk", + %{ + data: [ + %{type: "post", id: p1.id, attributes: %{name: "updated 1"}}, + %{type: "post", attributes: %{name: "no id"}} + ] + }, + status: 400 + ) + + assert Enum.any?(response.resp_body["errors"], fn error -> + error["code"] == "invalid_body" and error["source"]["pointer"] == "/data/1/id" + end) + end + + test "a member with the wrong type is reported at /data//type", %{ + posts: [p1, _p2, _p3] + } do + response = + Domain + |> patch( + "/posts/bulk", + %{ + data: [ + %{type: "post", id: p1.id, attributes: %{name: "ok"}}, + %{type: "not-a-post", id: Ecto.UUID.generate(), attributes: %{name: "x"}} + ] + }, + status: 400 + ) + + assert Enum.any?(response.resp_body["errors"], fn error -> + error["source"]["pointer"] == "/data/1/type" + end) + end + + test "a non-list data is rejected", %{posts: [p1, _p2, _p3]} do + response = + Domain + |> patch( + "/posts/bulk", + %{data: %{type: "post", id: p1.id, attributes: %{name: "x"}}}, + status: 400 + ) + + assert %{"errors" => errors} = response.resp_body + assert errors != [] + end + end +end