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>
142 lines
4.7 KiB
PHP
142 lines
4.7 KiB
PHP
<?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),
|
|
];
|
|
}
|
|
}
|