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