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