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

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Support\AdminContent;
use App\Support\Batch\BatchRunner;
use App\Support\Batch\Importer;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
/**
* Several calls in one: a batch of any admin operations, and importing rows
* of one kind of record from JSON or CSV.
*/
class BatchController extends Controller
{
public function batch(Request $request, BatchRunner $runner): JsonResponse
{
$data = $request->validate([
'operations' => ['required', 'array', 'min:1', 'max:'.BatchRunner::MAX_OPERATIONS],
'operations.*.method' => ['required', Rule::in(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'get', 'post', 'put', 'patch', 'delete'])],
'operations.*.path' => [
'required',
'string',
'max:500',
function (string $attribute, mixed $value, Closure $fail): void {
if (! is_string($value) || ! BatchRunner::allows($value)) {
$fail(__('rules.batch.path'));
}
},
],
'operations.*.body' => ['sometimes', 'nullable', 'array'],
'atomic' => ['sometimes', 'boolean'],
]);
$outcome = $runner->run($request, $data['operations'], atomic: $request->boolean('atomic'));
return $this->answer($outcome);
}
/**
* Rows as JSON (`rows`) or a CSV file (`file`). `match` names the field
* that finds an existing record to update; `dry_run` validates everything
* and keeps nothing.
*/
public function import(Request $request, string $resource, BatchRunner $runner, Importer $importer): JsonResponse
{
abort_unless(array_key_exists($resource, AdminContent::WITH_HISTORY), 404);
$request->validate([
'rows' => ['required_without:file', 'array'],
'rows.*' => ['array'],
'file' => ['required_without:rows', 'file', 'mimes:csv,txt', 'max:2048'],
'match' => ['sometimes', 'nullable', 'string', Rule::in(['slug', 'name', 'full_name'])],
'atomic' => ['sometimes', 'boolean'],
'dry_run' => ['sometimes', 'boolean'],
]);
$rows = $request->hasFile('file')
? $importer->rowsFromCsv($request->file('file'))
: $request->input('rows');
$operations = $importer->operations($resource, $rows, $request->input('match'));
// A dry run is all-or-nothing by nature: it keeps nothing either way.
$outcome = $runner->run(
$request,
$operations,
atomic: $request->boolean('atomic', true) || $request->boolean('dry_run'),
rollback: $request->boolean('dry_run'),
);
return $this->answer($outcome, ['dry_run' => $request->boolean('dry_run')]);
}
/**
* @param array{results: array<int, array<string, mixed>>, committed: bool} $outcome
* @param array<string, mixed> $meta
*/
protected function answer(array $outcome, array $meta = []): JsonResponse
{
$failed = collect($outcome['results'])->where('status', '>=', 400)->count();
return response()->json([
'data' => $outcome['results'],
'meta' => [
...$meta,
'committed' => $outcome['committed'],
'succeeded' => count($outcome['results']) - $failed,
'failed' => $failed,
],
]);
}
}