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:
12
app/Support/Batch/BatchAborted.php
Normal file
12
app/Support/Batch/BatchAborted.php
Normal 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 {}
|
||||
141
app/Support/Batch/BatchRunner.php
Normal file
141
app/Support/Batch/BatchRunner.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
148
app/Support/Batch/Importer.php
Normal file
148
app/Support/Batch/Importer.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user