Run batches and import JSON or CSV through the API

Each operation is dispatched as a request of its own, so a batch is held to
the same validation and permissions as the calls it contains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 08:58:36 +02:00
parent 6b3517743a
commit 0119004f2c
11 changed files with 750 additions and 0 deletions

View File

@@ -136,6 +136,8 @@ tags:
description: Review, publication and preview links
- name: Health
description: What is wrong with the site, and what is left to translate
- name: Batch
description: Several operations in one, and importing records from JSON or CSV
- name: Users
description: Local user administration
- name: Media
@@ -3638,6 +3640,107 @@ paths:
items: { $ref: "#/components/schemas/PendingTranslation" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/batch:
post:
tags: [Batch]
summary: Run several operations
description: |
Up to 50 admin API calls in one request. Each runs as a request of its
own with the caller's credentials — validated, authorised and answered
exactly as if sent separately — so a batch can do nothing its
operations would be refused. Their answers come back in order.
With `atomic`, it stops at the first failure and undoes everything
before it. What already left the database — a queued job, a
notification — is not undone.
`path` is an admin path such as `/v1/pages/3`; batches do not nest and
do not reach the import or the token exchange.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [operations]
properties:
atomic: { type: boolean, default: false }
operations:
type: array
maxItems: 50
items:
type: object
required: [method, path]
properties:
method: { type: string, enum: [GET, POST, PUT, PATCH, DELETE] }
path: { type: string, example: /v1/technologies }
body: { type: object, nullable: true, additionalProperties: true }
responses:
"200":
description: Each operation's answer
content:
application/json:
schema: { $ref: "#/components/schemas/BatchOutcome" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/import/{resource}:
post:
tags: [Batch]
summary: Import records
description: |
Rows of one kind of record, as JSON (`rows`) or a CSV `file`, turned
into the create and update calls the API already has — up to 50 per
import, each validated and authorised like any other call.
A row with an `id`, or whose `match` field (`slug`, `name` or
`full_name`) names an existing record, updates it; any other row
creates one. In a CSV, a column `title.es` fills the Spanish title, a
list is written `1|2|3`, `true`/`false`/`null` are what they say, and
an empty cell is left out rather than erasing what is there.
All-or-nothing unless `atomic` is false. `dry_run` checks every row and
keeps none.
parameters:
- name: resource
in: path
required: true
schema:
type: string
enum: [pages, categories, projects, members, services, clients, testimonials, talks, technologies, calls]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [rows]
properties:
rows:
type: array
maxItems: 50
items: { type: object, additionalProperties: true, description: The same body the resource's create endpoint takes. }
match: { type: string, enum: [slug, name, full_name], nullable: true }
atomic: { type: boolean, default: true }
dry_run: { type: boolean, default: false }
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: { type: string, format: binary, description: "CSV with a header row, UTF-8." }
match: { type: string, enum: [slug, name, full_name] }
atomic: { type: boolean, default: true }
dry_run: { type: boolean, default: false }
responses:
"200":
description: Each row's answer
content:
application/json:
schema: { $ref: "#/components/schemas/BatchOutcome" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/media:
get:
tags: [Media]
@@ -5418,6 +5521,27 @@ components:
missing: { type: array, items: { type: string }, description: Languages with nothing written. }
stale: { type: object, additionalProperties: { type: string, format: date-time }, description: Language → since when it has been behind. }
BatchOutcome:
type: object
properties:
data:
type: array
items:
type: object
properties:
index: { type: integer }
method: { type: string }
path: { type: string }
status: { type: integer, description: The HTTP status the operation answered with. }
body: { nullable: true, description: What the operation answered. }
meta:
type: object
properties:
committed: { type: boolean, description: False when an atomic run or a dry run undid everything. }
succeeded: { type: integer }
failed: { type: integer }
dry_run: { type: boolean, nullable: true }
MediaFor:
type: string
enum: [pages, technologies, projects, members, clients, testimonials]