From 922d1bc1b9debe1f405020e4f71360810973b472 Mon Sep 17 00:00:00 2001 From: Fabian Wagner Date: Sun, 17 May 2026 14:39:14 +0200 Subject: [PATCH] I respones --- src/Middleware/RequireAuthMiddleware.php | 7 +- src/Services/MiscService.php | 54 +--- src/Services/ResponseService.php | 368 +++++++++++++---------- 3 files changed, 228 insertions(+), 201 deletions(-) diff --git a/src/Middleware/RequireAuthMiddleware.php b/src/Middleware/RequireAuthMiddleware.php index 3672c88..9b02051 100644 --- a/src/Middleware/RequireAuthMiddleware.php +++ b/src/Middleware/RequireAuthMiddleware.php @@ -2,7 +2,7 @@ namespace Blax\Workkit\Middleware; -use Blax\Workkit\Services\MiscService; +use Blax\Workkit\Services\ResponseService; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -18,9 +18,10 @@ class RequireAuthMiddleware public function handle(Request $request, Closure $next, string $action = 'continue'): Response { if (! Auth::check()) { - return response()->json( - MiscService::apiResponse(['message' => "You need to be logged in to {$action}."]), + return ResponseService::apiError( + "You need to be logged in to {$action}.", Response::HTTP_UNAUTHORIZED, + type: 'AuthenticationException', ); } diff --git a/src/Services/MiscService.php b/src/Services/MiscService.php index 998081c..761182b 100644 --- a/src/Services/MiscService.php +++ b/src/Services/MiscService.php @@ -8,12 +8,13 @@ use Illuminate\Support\Facades\Log; /** * Grab-bag of small utilities used across Blax host apps. * - * Response-envelope helpers (`response`, `apiResponse`, `apiMeta`, - * `apiItem`, `apiCollection`, `apiPaginated`, `asPaginated`, - * `paginationMeta`, `availableLanguages`) have moved to - * {@see ResponseService} — they remain here as thin shims so existing - * callers continue to work without changes. Prefer calling - * {@see ResponseService} directly in new code. + * Response-envelope helpers live on {@see ResponseService} now and the old + * shims here have been retired alongside the legacy methods + * (`response`, `apiResponse`, `asPaginated`, `paginationMeta`) that were + * dropped from ResponseService itself. The remaining `apiItem`, + * `apiCollection`, `apiPaginated`, `apiMeta`, `availableLanguages` shims + * stay for the moment as a courtesy to existing callers — new code should + * call {@see ResponseService} directly. */ class MiscService { @@ -21,14 +22,6 @@ class MiscService * Response envelope (delegates to ResponseService) * ────────────────────────────────────────────────────────────────────── */ - /** - * Build a raw `{ data, meta }` envelope. See {@see ResponseService::response()}. - */ - public static function response(mixed $data = null, array $meta = []): array - { - return ResponseService::response($data, $meta); - } - /** * Available content languages for the running app. * See {@see ResponseService::availableLanguages()}. @@ -59,15 +52,6 @@ class MiscService return ResponseService::apiItem($item, $resource_class, $extraMeta); } - /** - * Plain envelope with the standard meta block. - * See {@see ResponseService::apiResponse()}. - */ - public static function apiResponse(mixed $data = null, array $extraMeta = []): array - { - return ResponseService::apiResponse($data, $extraMeta); - } - /** * Non-paginated collection envelope. * See {@see ResponseService::apiCollection()}. @@ -89,30 +73,6 @@ class MiscService return ResponseService::apiPaginated($paginated, $resource_class, $extraMeta); } - /** - * Legacy paginated meta block. - * See {@see ResponseService::paginationMeta()}. - */ - public static function paginationMeta(mixed $paginated, array $options = [], array $meta = []): array - { - return ResponseService::paginationMeta($paginated, $options, $meta); - } - - /** - * Legacy paginated envelope. - * See {@see ResponseService::asPaginated()}. - * - * @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resource_class - */ - public static function asPaginated( - mixed $paginated, - string $resource_class, - array $meta = [], - ?array $options = null, - ): array { - return ResponseService::asPaginated($paginated, $resource_class, $meta, $options); - } - /* ────────────────────────────────────────────────────────────────────── * Misc utilities * ────────────────────────────────────────────────────────────────────── */ diff --git a/src/Services/ResponseService.php b/src/Services/ResponseService.php index 2b04fb9..3761d84 100644 --- a/src/Services/ResponseService.php +++ b/src/Services/ResponseService.php @@ -2,45 +2,47 @@ namespace Blax\Workkit\Services; -use Illuminate\Contracts\Pagination\Paginator; +use Illuminate\Http\JsonResponse; +use Symfony\Component\HttpFoundation\Response; +use Throwable; /** * API response envelope builder. * - * Every method here produces the workkit response shape `{ data, meta }`. - * The richer helpers (`apiItem`, `apiCollection`, `apiPaginated`, - * `apiResponse`) auto-fill a standard meta block via {@see apiMeta()}, - * which carries the request `url`, the active `locale`, and the list of - * available `languages`. Pagination metadata is merged on top by - * {@see apiPaginated()}. + * Every helper here — success or error — produces the same wire shape: * - * This service is the canonical home for these helpers. The matching - * methods on {@see MiscService} remain as thin shims for backward - * compatibility — existing callers do not need to change. + * { + * "status": { "code": , "text": }, + * "message": , + * "data": , // success only + * "error": <{ type, ... } | absent>, // error only + * "errors": <{ field: [...] } | absent>, // present for validation errors + * "meta": { url, locale, languages, ...pagination... } + * } * - * Lifecycle in a controller: + * The split is: `data` carries success payloads, `error` carries failure + * details, `errors` is the Laravel-compatible field-map alias so + * `assertJsonValidationErrors([...])` keeps working without re-jiggering tests. + * Every response also reports its HTTP status as both code and reason phrase + * inside the body — useful for clients that can't easily inspect headers. * - * return response()->json(ResponseService::apiPaginated($q->paginate(), BookResource::class)); - * return response()->json(ResponseService::apiItem($book, BookResource::class)); - * return response()->json(ResponseService::apiResponse(['token' => $token]), 201); + * Controller lifecycle under {@see \Blax\Workkit\Middleware\ForceJsonResponse}: + * + * 200 OK → return ResponseService::apiItem(...) (plain array) + * 200 OK list → return ResponseService::apiPaginated($q->paginate(...), ...) + * 201 Created → return ResponseService::apiCreated(...) (JsonResponse) + * 202 Accepted → return ResponseService::apiAccepted(...) (JsonResponse) + * 204 No Content → return ResponseService::apiNoContent() (JsonResponse) + * 4xx/5xx → return ResponseService::apiError(...) (JsonResponse) + * 422 validation → return ResponseService::apiValidationError([...]) (JsonResponse) + * + * Reach for `response()->json(...)` directly only if you genuinely need a + * status code or shape not modeled above — in which case prefer to add a + * helper here so the convention stays uniform. */ class ResponseService { - /** - * Build a raw `{ data, meta }` envelope with whatever meta you pass. - * - * Lowest-level primitive — every other method here ultimately produces - * this same shape. Prefer the higher-level helpers - * (`apiResponse`, `apiItem`, `apiCollection`, `apiPaginated`) which - * auto-fill the standard meta block. - */ - public static function response(mixed $data = null, array $meta = []): array - { - return [ - 'data' => $data, - 'meta' => $meta, - ]; - } + /* ─────────────────────────── building blocks ──────────────────────── */ /** * Available content languages for the running app. @@ -73,10 +75,8 @@ class ResponseService } /** - * Standard meta block: `url`, `locale`, `languages`, plus any extras. - * - * Pagination keys are merged in by {@see apiPaginated()}; - * {@see apiItem()} and {@see apiCollection()} skip them. + * Standard meta block: `url`, `locale`, `languages`, plus any extras + * (the per-helper additions like pagination keys land here). * * @param array $extra * @return array @@ -91,164 +91,230 @@ class ResponseService } /** - * Single-item envelope. Use for any `show`-style endpoint. + * Internal envelope builder used by every success/error helper. Keeping + * a single source of truth here means the wire shape can't drift between + * helpers — a new top-level key only needs to be added in one place. * - * Pass the resource class to wrap the model in a `JsonResource`, or - * omit it to serialize the value directly (this is also the form to - * use for arbitrary payloads — login responses, action acks, etc.). + * @param array $payload Body keys (data/error/errors) + * @param array $extraMeta + * @return array + */ + private static function envelope( + int $statusCode, + ?string $message, + array $payload, + array $extraMeta = [], + ): array { + return array_merge([ + 'status' => [ + 'code' => $statusCode, + 'text' => Response::$statusTexts[$statusCode] ?? 'Unknown', + ], + 'message' => $message, + ], $payload, [ + 'meta' => self::apiMeta($extraMeta), + ]); + } + + /* ─────────────────────────────── success ──────────────────────────── */ + + /** + * Single-item envelope. Use for any `show`-style endpoint or for an + * arbitrary payload (login receipt, ack, etc. — pass `null` resource). * * @param class-string<\Illuminate\Http\Resources\Json\JsonResource>|null $resourceClass * @param array $extraMeta - * @return array{data: mixed, meta: array} + * @return array{status: array{code:int,text:string}, message: ?string, data: mixed, meta: array} */ - public static function apiItem(mixed $item, ?string $resourceClass = null, array $extraMeta = []): array - { - return self::response( - $resourceClass !== null ? $resourceClass::make($item) : $item, - self::apiMeta($extraMeta), - ); + public static function apiItem( + mixed $item, + ?string $resourceClass = null, + array $extraMeta = [], + ?string $message = null, + ): array { + return self::envelope(200, $message, [ + 'data' => $resourceClass !== null ? $resourceClass::make($item) : $item, + ], $extraMeta); } /** - * Plain envelope with the standard meta block. - * - * Alias of `apiItem($data, null, $extraMeta)` kept for callers whose - * intent is "arbitrary payload" rather than "model" — login responses, - * acknowledgements, etc. - * - * @param array $extraMeta - * @return array{data: mixed, meta: array} - */ - public static function apiResponse(mixed $data = null, array $extraMeta = []): array - { - return self::apiItem($data, null, $extraMeta); - } - - /** - * Non-paginated collection envelope. - * - * Reserve this for genuinely tiny fixed lists (an enum, a child - * collection embedded in a parent show response). Most list endpoints - * should use {@see apiPaginated()}. + * Non-paginated collection envelope. Reserve for genuinely tiny fixed + * lists (an enum, a child collection embedded in a parent response). + * Most list endpoints should use {@see apiPaginated()}. * * @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resourceClass * @param array $extraMeta - * @return array{data: mixed, meta: array} + * @return array */ - public static function apiCollection(iterable $items, string $resourceClass, array $extraMeta = []): array - { + public static function apiCollection( + iterable $items, + string $resourceClass, + array $extraMeta = [], + ?string $message = null, + ): array { $count = is_countable($items) ? count($items) : null; - return self::response( - $resourceClass::collection($items), - self::apiMeta(array_merge(['total' => $count], $extraMeta)), - ); + return self::envelope(200, $message, [ + 'data' => $resourceClass::collection($items), + ], array_merge(['total' => $count], $extraMeta)); } /** - * Paginated envelope. Use for any `index`-style endpoint. - * - * Wire shape: - * - * { - * "data": [...resource collection...], - * "meta": { - * "url", "locale", "languages", - * "current_page", "per_page", "from", "to", - * "total", "total_pages", "last_page", "has_more" - * } - * } - * - * `last_page` is exposed alongside `total_pages` as an alias so - * consumers written against Laravel's native paginator key continue - * to work without a migration. + * Paginated envelope. Use for any `index`-style endpoint. The meta block + * picks up the paginator's `current_page`, `per_page`, `from`, `to`, + * `total`, `total_pages` / `last_page` (aliased so legacy clients keep + * working) and a `has_more` boolean. * * @param \Illuminate\Contracts\Pagination\Paginator|mixed $paginated * @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resourceClass * @param array $extraMeta - * @return array{data: mixed, meta: array} + * @return array */ - public static function apiPaginated(mixed $paginated, string $resourceClass, array $extraMeta = []): array - { + public static function apiPaginated( + mixed $paginated, + string $resourceClass, + array $extraMeta = [], + ?string $message = null, + ): array { $arr = method_exists($paginated, 'toArray') ? $paginated->toArray() : []; $current = $arr['current_page'] ?? 1; $last = $arr['last_page'] ?? null; - return self::response( - $resourceClass::collection($paginated), - self::apiMeta(array_merge([ - 'current_page' => $current, - 'per_page' => $arr['per_page'] ?? null, - 'from' => $arr['from'] ?? null, - 'to' => $arr['to'] ?? null, - 'total' => $arr['total'] ?? null, - 'total_pages' => $last, - 'last_page' => $last, - 'has_more' => $last !== null && $current < $last, - ], $extraMeta)), + return self::envelope(200, $message, [ + 'data' => $resourceClass::collection($paginated), + ], array_merge([ + 'current_page' => $current, + 'per_page' => $arr['per_page'] ?? null, + 'from' => $arr['from'] ?? null, + 'to' => $arr['to'] ?? null, + 'total' => $arr['total'] ?? null, + 'total_pages' => $last, + 'last_page' => $last, + 'has_more' => $last !== null && $current < $last, + ], $extraMeta)); + } + + /** + * Single-item envelope wrapped in a 201 Created JsonResponse. Use for + * any `store`-style endpoint. + * + * @param class-string<\Illuminate\Http\Resources\Json\JsonResource>|null $resourceClass + * @param array $extraMeta + */ + public static function apiCreated( + mixed $item, + ?string $resourceClass = null, + array $extraMeta = [], + ?string $message = null, + ): JsonResponse { + return response()->json( + self::envelope(201, $message, [ + 'data' => $resourceClass !== null ? $resourceClass::make($item) : $item, + ], $extraMeta), + Response::HTTP_CREATED, ); } /** - * Legacy paginated meta block: slimmer than {@see apiMeta()}, no - * `url`/`locale`/`languages`, includes a passthrough `options` object - * reflecting the request's filter/sort state. + * 202 Accepted envelope — for endpoints that queue work and return a + * receipt rather than the final resource. * - * Retained for backward compatibility — new code should use - * {@see apiPaginated()}. - * - * @param \Illuminate\Contracts\Pagination\Paginator|mixed $paginated - * @param array $options - * @param array $meta - * @return array + * @param class-string<\Illuminate\Http\Resources\Json\JsonResource>|null $resourceClass + * @param array $extraMeta */ - public static function paginationMeta(mixed $paginated, array $options = [], array $meta = []): array + public static function apiAccepted( + mixed $item = null, + ?string $resourceClass = null, + array $extraMeta = [], + ?string $message = null, + ): JsonResponse { + return response()->json( + self::envelope(202, $message, [ + 'data' => $resourceClass !== null ? $resourceClass::make($item) : $item, + ], $extraMeta), + Response::HTTP_ACCEPTED, + ); + } + + /** + * 204 No Content — carries no body. Useful for `delete`-style endpoints. + */ + public static function apiNoContent(): JsonResponse { - $data = method_exists($paginated, 'toArray') ? $paginated->toArray() : []; - - $base = [ - 'from' => $data['from'] ?? null, - 'to' => $data['to'] ?? null, - 'total' => $data['total'] ?? null, - 'last_page' => $data['last_page'] ?? null, - 'current_page' => $data['current_page'] ?? null, - 'options' => (object) $options, - ]; - - return $meta ? array_merge($base, $meta) : $base; + return response()->json(null, Response::HTTP_NO_CONTENT); } + /* ──────────────────────────────── errors ──────────────────────────── */ + /** - * Legacy paginated envelope. Same intent as {@see apiPaginated()} but - * with the older meta shape — reads `options` off the current request - * when none are supplied. + * Generic error envelope. Accepts either: * - * Retained for backward compatibility — new code should use - * {@see apiPaginated()}. + * - A {@see \Throwable} instance — its class becomes `error.type` and + * its message becomes the envelope `message` (both overridable). + * - A plain string — used as the envelope `message`. `type` defaults + * to `'Error'` unless you pass it explicitly. * - * @param \Illuminate\Contracts\Pagination\Paginator|mixed $paginated - * @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resourceClass - * @param array $meta - * @param array|null $options - * @return array{data: mixed, meta: array} + * return ResponseService::apiError($e, 422, ['book' => ['...']]); + * return ResponseService::apiError('Forbidden', 403); + * return ResponseService::apiError('Rate limited', 429, type: 'TooManyRequests'); + * + * @param array> $errors Field-keyed + * validation-style errors, mirrored into the top-level `errors` + * key for {@see \Illuminate\Testing\TestResponse::assertJsonValidationErrors()}. + * @param array $extraMeta */ - public static function asPaginated( - mixed $paginated, - string $resourceClass, - array $meta = [], - ?array $options = null, - ): array { - $resolvedOptions = $options ?? (is_array(request('options')) ? request('options') : []); - - $payload = self::response( - $resourceClass::collection($paginated), - self::paginationMeta($paginated, $resolvedOptions), - ); - - if ($meta) { - $payload['meta'] = array_merge($payload['meta'], $meta); + public static function apiError( + Throwable|string $errorOrMessage, + int $status = 500, + array $errors = [], + ?string $type = null, + ?string $message = null, + array $extraMeta = [], + ): JsonResponse { + if ($errorOrMessage instanceof Throwable) { + $type ??= class_basename($errorOrMessage); + $message ??= $errorOrMessage->getMessage(); + } else { + $message ??= $errorOrMessage; + $type ??= 'Error'; } - return $payload; + $payload = ['error' => ['type' => $type]]; + if (! empty($errors)) { + $payload['errors'] = $errors; + } + + return response()->json( + self::envelope($status, $message, $payload, $extraMeta), + $status, + ); + } + + /** + * 422 Unprocessable Entity wrapper around {@see apiError()} with the + * field-error map pre-filled. Mirrors the shape Laravel's default + * ValidationException renderer produces, so `assertJsonValidationErrors` + * keeps working — but the envelope also carries the unified `status`, + * `message`, `error` keys for the rest of the body. + * + * return ResponseService::apiValidationError([ + * 'book' => ['No copies of this book are currently available.'], + * ]); + * + * @param array> $errors + * @param array $extraMeta + */ + public static function apiValidationError( + array $errors, + ?string $message = null, + array $extraMeta = [], + ): JsonResponse { + return self::apiError( + errorOrMessage: $message ?? 'The given data was invalid.', + status: Response::HTTP_UNPROCESSABLE_ENTITY, + errors: $errors, + type: 'ValidationException', + extraMeta: $extraMeta, + ); } }