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,
],
]);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Support\Batch;
use RuntimeException;
/**
* Thrown inside the batch's transaction to undo it.
*/
final class BatchAborted extends RuntimeException {}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Support\Batch;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* Runs several admin API calls as one.
*
* Each operation goes through the HTTP kernel as a request of its own, with
* the caller's credentials — so it is validated, authorised, throttled and
* answered exactly as if it had been sent separately. Nothing here knows what
* a page or a project is, which is the point: a batch cannot do anything the
* endpoints themselves would refuse.
*
* Atomic batches run inside one transaction and stop at the first failure,
* undoing everything before it. What left the database meanwhile — a queued
* job, a notification — is not undone.
*/
final class BatchRunner
{
public const MAX_OPERATIONS = 50;
/**
* Endpoints a batch may not call: itself (no nesting), the import that is
* built on it, and the token exchange, which is not an admin operation.
*/
protected const EXCLUDED = ['/v1/batch', '/v1/import/', '/v1/auth/'];
public function __construct(protected Kernel $kernel) {}
public static function allows(string $path): bool
{
// A plain path and an optional query string, with no way of climbing
// out of `/v1/` once the router normalises it.
if (! preg_match('#^/v1/[A-Za-z0-9_\-./]*(\?[A-Za-z0-9_\-.=&%\[\]+,]*)?$#', $path)
|| str_contains($path, '..')
|| str_contains($path, '//')) {
return false;
}
foreach (self::EXCLUDED as $excluded) {
if (str_starts_with($path, $excluded)) {
return false;
}
}
return true;
}
/**
* @param array<int, array{method: string, path: string, body?: array<string, mixed>|null}> $operations
* @param bool $atomic All or nothing.
* @param bool $rollback Undo everything even when all succeeded — a dry run.
* @return array{results: array<int, array<string, mixed>>, committed: bool}
*/
public function run(Request $parent, array $operations, bool $atomic = false, bool $rollback = false): array
{
$results = [];
$execute = function () use ($parent, $operations, $atomic, &$results): void {
foreach ($operations as $index => $operation) {
$result = $this->dispatch($parent, $operation);
$results[] = ['index' => $index, ...$result];
if ($atomic && $result['status'] >= 400) {
throw new BatchAborted;
}
}
};
if (! $atomic && ! $rollback) {
$execute();
return ['results' => $results, 'committed' => true];
}
try {
DB::transaction(function () use ($execute, $rollback): void {
$execute();
if ($rollback) {
throw new BatchAborted;
}
});
} catch (BatchAborted) {
return ['results' => $results, 'committed' => false];
}
return ['results' => $results, 'committed' => true];
}
/**
* @param array{method: string, path: string, body?: array<string, mixed>|null} $operation
* @return array{method: string, path: string, status: int, body: mixed}
*/
protected function dispatch(Request $parent, array $operation): array
{
$method = strtoupper($operation['method']);
$path = $operation['path'];
$request = Request::create(
'/api'.$path,
$method,
server: array_filter([
'HTTP_AUTHORIZATION' => $parent->header('Authorization'),
'HTTP_ACCEPT' => 'application/json',
'HTTP_ACCEPT_LANGUAGE' => $parent->header('Accept-Language'),
'CONTENT_TYPE' => 'application/json',
'REMOTE_ADDR' => $parent->ip(),
]),
content: $method === 'GET' ? null : (string) json_encode((object) ($operation['body'] ?? [])),
);
try {
$response = $this->kernel->handle($request);
} catch (Throwable $e) {
report($e);
return ['method' => $method, 'path' => $path, 'status' => 500, 'body' => null];
} finally {
// The kernel swapped the current request for the operation's;
// whatever runs after this one still belongs to the batch's.
app()->instance('request', $parent);
}
$content = $response->getContent();
return [
'method' => $method,
'path' => $path,
'status' => $response->getStatusCode(),
'body' => $content === '' || $content === false ? null : json_decode($content, true),
];
}
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Support\Batch;
use App\Support\AdminContent;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
use Spatie\Translatable\HasTranslations;
/**
* Turns rows — from a JSON array or a CSV file — into the create and update
* calls the API already has, and hands them to the batch runner.
*
* A row that names an existing record, by `id` or by the field given as
* `match`, updates it; any other row creates one. In a CSV, a column called
* `title.es` fills the Spanish title, a list is written `1|2|3`, `true` and
* `false` are booleans, and an empty cell is left out rather than erasing
* what is there.
*/
final class Importer
{
public const MAX_ROWS = BatchRunner::MAX_OPERATIONS;
/**
* Fields a CSV writes as `a|b|c`.
*/
protected const LISTS = ['technology_ids', 'service_ids', 'project_ids'];
/**
* @return array<int, array<string, mixed>>
*/
public function rowsFromCsv(UploadedFile $file): array
{
$handle = fopen($file->getRealPath(), 'r');
$header = null;
$rows = [];
while (($line = fgetcsv($handle, escape: '\\')) !== false) {
if ($line === [null]) {
continue;
}
if ($header === null) {
// A byte-order mark is what a spreadsheet saving "CSV UTF-8"
// puts before the first column's name.
$header = array_map(fn ($name) => trim((string) preg_replace('/^\xEF\xBB\xBF/', '', (string) $name)), $line);
continue;
}
$row = [];
foreach ($header as $i => $column) {
$value = $line[$i] ?? '';
if ($column === '' || $value === '') {
continue;
}
Arr::set($row, $column, $this->cell($column, $value));
}
$rows[] = $row;
}
fclose($handle);
return $rows;
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<int, array{method: string, path: string, body: array<string, mixed>}>
*/
public function operations(string $resource, array $rows, ?string $match = null): array
{
if (count($rows) > self::MAX_ROWS) {
throw ValidationException::withMessages(['rows' => [__('rules.batch.too_many', ['max' => self::MAX_ROWS])]]);
}
return array_map(function (array $row) use ($resource, $match): array {
$id = $row['id'] ?? $this->existing($resource, $row, $match);
unset($row['id']);
return $id === null
? ['method' => 'POST', 'path' => "/v1/{$resource}", 'body' => $row]
: ['method' => 'PATCH', 'path' => "/v1/{$resource}/{$id}", 'body' => $row];
}, $rows);
}
/**
* The record a row names through its `match` field, if any: a slug is
* looked up in the default language when it is translated.
*
* @param array<string, mixed> $row
*/
protected function existing(string $resource, array $row, ?string $match): mixed
{
if ($match === null || ! array_key_exists($match, $row)) {
return null;
}
$model = AdminContent::model($resource);
$instance = new $model;
$value = $row[$match];
$translated = in_array(HasTranslations::class, class_uses_recursive($model), true)
&& in_array($match, $instance->translatable, true);
if ($translated) {
$locale = (string) config('locales.default');
$value = is_array($value) ? ($value[$locale] ?? null) : $value;
$column = "{$match}->{$locale}";
} else {
$column = $match;
}
if ($value === null || is_array($value)) {
return null;
}
$query = $model::query();
if (method_exists($instance, 'bootSoftDeletes')) {
$query->withTrashed();
}
return $query->where($column, $value)->value($instance->getKeyName());
}
protected function cell(string $column, string $value): mixed
{
$base = explode('.', $column)[0];
return match (true) {
in_array($base, self::LISTS, true) => array_map(
fn ($item) => is_numeric($item) ? (int) $item : $item,
array_values(array_filter(array_map('trim', explode('|', $value)), fn ($item) => $item !== '')),
),
strtolower($value) === 'true' => true,
strtolower($value) === 'false' => false,
strtolower($value) === 'null' => null,
default => $value,
};
}
}