From 817dd28b3af89c37993617c974b16821eb631b5a Mon Sep 17 00:00:00 2001 From: Fabian Wagner Date: Fri, 15 May 2026 12:16:55 +0200 Subject: [PATCH] R structure miscservice, A response service --- src/Services/MiscService.php | 388 +++++++++++++------------------ src/Services/ResponseService.php | 254 ++++++++++++++++++++ 2 files changed, 416 insertions(+), 226 deletions(-) create mode 100644 src/Services/ResponseService.php diff --git a/src/Services/MiscService.php b/src/Services/MiscService.php index 0f2fdda..998081c 100644 --- a/src/Services/MiscService.php +++ b/src/Services/MiscService.php @@ -5,32 +5,131 @@ namespace Blax\Workkit\Services; use Illuminate\Support\Facades\Http; 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. + */ class MiscService { + /* ────────────────────────────────────────────────────────────────────── + * Response envelope (delegates to ResponseService) + * ────────────────────────────────────────────────────────────────────── */ + /** - * Build a standard response payload envelope. + * Build a raw `{ data, meta }` envelope. See {@see ResponseService::response()}. */ - public static function response( - mixed $data = null, - array $meta = [] - ): array { - return [ - 'data' => $data, - 'meta' => $meta, - ]; + 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()}. + * + * @return array + */ + public static function availableLanguages(): array + { + return ResponseService::availableLanguages(); + } + + /** + * Standard meta block (`url`, `locale`, `languages` + extras). + * See {@see ResponseService::apiMeta()}. + */ + public static function apiMeta(array $extra = []): array + { + return ResponseService::apiMeta($extra); + } + + /** + * Single-item envelope. See {@see ResponseService::apiItem()}. + * + * @param class-string<\Illuminate\Http\Resources\Json\JsonResource>|null $resource_class + */ + public static function apiItem(mixed $item, ?string $resource_class = null, array $extraMeta = []): array + { + 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()}. + * + * @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resource_class + */ + public static function apiCollection(iterable $items, string $resource_class, array $extraMeta = []): array + { + return ResponseService::apiCollection($items, $resource_class, $extraMeta); + } + + /** + * Paginated envelope. See {@see ResponseService::apiPaginated()}. + * + * @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resource_class + */ + public static function apiPaginated(mixed $paginated, string $resource_class, array $extraMeta = []): array + { + 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 + * ────────────────────────────────────────────────────────────────────── */ + /** * Resolve a controller payload to a normalized options array. * * Supported payload shapes: - * - ['options' => [...]] - * - [...] (already flat) + * - `['options' => [...]]` + * - `[...]` (already flat) + * + * @param array $payload + * @param array $defaults + * @return array */ - public static function resolveOptions( - array $payload, - array $defaults = [] - ): array { + public static function resolveOptions(array $payload, array $defaults = []): array + { $options = is_array($payload['options'] ?? null) ? $payload['options'] : $payload; @@ -39,13 +138,12 @@ class MiscService } /** - * Read an option value using camelCase or snake_case fallback. + * Read an option value using exact / snake_case / camelCase fallback. + * + * @param array $options */ - public static function option( - array $options, - string $key, - mixed $default = null - ): mixed { + public static function option(array $options, string $key, mixed $default = null): mixed + { if (array_key_exists($key, $options)) { return $options[$key]; } @@ -64,194 +162,9 @@ class MiscService } /** - * Build pagination metadata in a consistent format. + * Format a byte count as a human-readable string (B, KB, MB, …). */ - public static function paginationMeta( - $paginated, - array $options = [], - array $meta = [] - ): array { - $data = $paginated->toArray(); - - $base = [ - 'from' => @$data['from'], - 'to' => @$data['to'], - 'total' => @$data['total'], - 'last_page' => @$data['last_page'], - 'current_page' => @$data['current_page'], - 'options' => (object) $options, - ]; - - if ($meta) { - $base = array_merge($base, $meta); - } - - return $base; - } - - public static function asPaginated( - $paginated, - $resource_class, - array $meta = [], - ?array $options = null - ) { - $resolvedOptions = $options; - if ($resolvedOptions === null) { - $resolvedOptions = is_array(request('options')) - ? request('options') - : []; - } - - $payload = [ - 'data' => $resource_class::collection($paginated), - 'meta' => self::paginationMeta($paginated, $resolvedOptions), - ]; - - if ($meta) { - $payload['meta'] = array_merge($payload['meta'], $meta); - } - - return $payload; - } - - /** - * Available content languages for the running app. - * - * Tries (in order): - * 1. config('languages.languages') — Blax convention, list of {code, ...} - * 2. config('app.available_locales') — plain array of codes - * 3. fall back to [app()->getLocale()] - * - * @return array - */ - public static function availableLanguages(): array - { - $configured = config('languages.languages'); - if (is_array($configured) && $configured) { - return collect($configured) - ->map(fn($l) => is_array($l) ? ($l['code'] ?? $l['lang'] ?? null) : $l) - ->filter() - ->values() - ->toArray(); - } - - $locales = config('app.available_locales'); - if (is_array($locales) && $locales) { - return array_values($locales); - } - - return [app()->getLocale()]; - } - - /** - * Standard meta block for an API response. - * - * Every api response in the workkit-shaped envelope carries this block - * so consumers always know: - * - which URL produced the payload (`url`) - * - which locale they got back (`locale`) - * - which other locales the same resource is available in (`languages`) - * - * Pagination keys (current_page, total, total_pages, etc.) are merged in - * by `apiPaginated()`; `apiItem()` / `apiCollection()` skip them. - */ - public static function apiMeta(array $extra = []): array - { - return array_merge([ - 'url' => optional(request())->fullUrl(), - 'locale' => app()->getLocale(), - 'languages' => self::availableLanguages(), - ], $extra); - } - - /** - * Paginated API envelope. Use for any list/index endpoint. - * - * Returns: - * { - * "data": [...resource collection...], - * "meta": { - * "url", "locale", "languages", - * "current_page", "per_page", "from", "to", - * "total", "total_pages", "has_more" - * } - * } - */ - public static function apiPaginated( - $paginated, - string $resource_class, - array $extraMeta = [] - ): array { - $arr = method_exists($paginated, 'toArray') ? $paginated->toArray() : []; - - $current = $arr['current_page'] ?? 1; - $last = $arr['last_page'] ?? null; - - $pagination = [ - 'current_page' => $current, - 'per_page' => $arr['per_page'] ?? null, - 'from' => $arr['from'] ?? null, - 'to' => $arr['to'] ?? null, - 'total' => $arr['total'] ?? null, - 'total_pages' => $last, - 'has_more' => ($last !== null) ? ($current < $last) : false, - ]; - - return [ - 'data' => $resource_class::collection($paginated), - 'meta' => self::apiMeta(array_merge($pagination, $extraMeta)), - ]; - } - - /** - * Single-item API envelope. Use for any show endpoint. - */ - public static function apiItem( - $item, - ?string $resource_class = null, - array $extraMeta = [] - ): array { - return [ - 'data' => $resource_class ? $resource_class::make($item) : $item, - 'meta' => self::apiMeta($extraMeta), - ]; - } - - /** - * Non-paginated collection envelope. Use only when pagination is - * impractical (tiny fixed list like an enum or a child collection of a - * parent show response). Most list endpoints should use apiPaginated(). - */ - public static function apiCollection( - $items, - string $resource_class, - array $extraMeta = [] - ): array { - $count = is_countable($items) ? count($items) : null; - - return [ - 'data' => $resource_class::collection($items), - 'meta' => self::apiMeta(array_merge([ - 'total' => $count, - ], $extraMeta)), - ]; - } - - /** - * Plain envelope (data + meta) for arbitrary payloads — login responses, - * action acknowledgements, etc. Always carries the standard apiMeta block. - */ - public static function apiResponse( - mixed $data = null, - array $extraMeta = [] - ): array { - return [ - 'data' => $data, - 'meta' => self::apiMeta($extraMeta), - ]; - } - - public static function bytesToHuman($bytes) + public static function bytesToHuman(int|float $bytes): string { $units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; @@ -262,39 +175,53 @@ class MiscService return round($bytes, 2) . ' ' . $units[$i]; } - public static function deterministicEncrypt($data) + /** + * AES-128-ECB encrypt — deterministic (same input → same output). + * Use only where determinism is required; prefer Laravel's `Crypt` + * facade for general-purpose encryption. + */ + public static function deterministicEncrypt(string $data): string { return base64_encode(openssl_encrypt($data, 'AES-128-ECB', config('app.key'), OPENSSL_RAW_DATA)); } - public static function deterministicDecrypt($encrypted) + /** + * Inverse of {@see deterministicEncrypt()}. + */ + public static function deterministicDecrypt(string $encrypted): string|false { return openssl_decrypt(base64_decode($encrypted), 'AES-128-ECB', config('app.key'), OPENSSL_RAW_DATA); } - public static function logExecutionTime( - string $logtext, - $callable = null - ) { + /** + * Time a callable and log its duration at debug level. Returns the + * callable's return value (or null when no callable is given). + */ + public static function logExecutionTime(string $logtext, ?callable $callable = null): mixed + { $start = microtime(true); - if (!$callable) { - return; + if (! $callable) { + return null; } $result = $callable(); $end = microtime(true); - $executionTime = $end - $start; - Log::debug($logtext, [ - 'execution_time' => $executionTime + 'execution_time' => $end - $start, ]); return $result; } - public static function getIpInformation($ip) + /** + * Look up geolocation/ISP info for an IP via ipapi.co. + * Cached per-request via `once()` and per-IP via flexible cache. + * + * @return array|null + */ + public static function getIpInformation(string $ip): ?array { return once(function () use ($ip) { return cache()->flexible('ipapi-' . $ip, [60 * 60 * 24 * 2, 60 * 60 * 24 * 7], function () use ($ip) { @@ -309,7 +236,10 @@ class MiscService }); } - public static function countryToCode($country_long): ?string + /** + * Map a German/native country name to its ISO 3166-1 alpha-2 code. + */ + public static function countryToCode(string $country_long): ?string { return match (str()->lower($country_long)) { 'deutschland' => 'de', @@ -323,7 +253,11 @@ class MiscService }; } - public static function codeToCountry($country_code, string|null $locale = null) + /** + * Map an ISO 3166-1 alpha-2 code to a localized country name. + * Supports `de`, `es`, `uk` and falls through to English. + */ + public static function codeToCountry(string $country_code, ?string $locale = null): ?string { $country_code = str()->lower($country_code); $locale ??= app()->getLocale(); @@ -379,10 +313,12 @@ class MiscService }; } - public static function parseIncompleteJson( - string $json, - bool $associative = true - ): array|object|null { + /** + * Parse partial/streaming JSON best-effort. Delegates to + * {@see IncompleteJsonService}. + */ + public static function parseIncompleteJson(string $json, bool $associative = true): array|object|null + { return (new IncompleteJsonService())->parse($json, $associative); } } diff --git a/src/Services/ResponseService.php b/src/Services/ResponseService.php new file mode 100644 index 0000000..2b04fb9 --- /dev/null +++ b/src/Services/ResponseService.php @@ -0,0 +1,254 @@ +json(ResponseService::apiPaginated($q->paginate(), BookResource::class)); + * return response()->json(ResponseService::apiItem($book, BookResource::class)); + * return response()->json(ResponseService::apiResponse(['token' => $token]), 201); + */ +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, + ]; + } + + /** + * Available content languages for the running app. + * + * Resolution order: + * 1. `config('languages.languages')` — Blax convention, list of + * `{ code, ... }` records. + * 2. `config('app.available_locales')` — plain array of codes. + * 3. Fallback to `[app()->getLocale()]`. + * + * @return array + */ + public static function availableLanguages(): array + { + $configured = config('languages.languages'); + if (is_array($configured) && $configured) { + return collect($configured) + ->map(fn ($l) => is_array($l) ? ($l['code'] ?? $l['lang'] ?? null) : $l) + ->filter() + ->values() + ->toArray(); + } + + $locales = config('app.available_locales'); + if (is_array($locales) && $locales) { + return array_values($locales); + } + + return [app()->getLocale()]; + } + + /** + * Standard meta block: `url`, `locale`, `languages`, plus any extras. + * + * Pagination keys are merged in by {@see apiPaginated()}; + * {@see apiItem()} and {@see apiCollection()} skip them. + * + * @param array $extra + * @return array + */ + public static function apiMeta(array $extra = []): array + { + return array_merge([ + 'url' => optional(request())->fullUrl(), + 'locale' => app()->getLocale(), + 'languages' => self::availableLanguages(), + ], $extra); + } + + /** + * Single-item envelope. Use for any `show`-style endpoint. + * + * 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 class-string<\Illuminate\Http\Resources\Json\JsonResource>|null $resourceClass + * @param array $extraMeta + * @return array{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), + ); + } + + /** + * 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()}. + * + * @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resourceClass + * @param array $extraMeta + * @return array{data: mixed, meta: array} + */ + public static function apiCollection(iterable $items, string $resourceClass, array $extraMeta = []): array + { + $count = is_countable($items) ? count($items) : null; + + return self::response( + $resourceClass::collection($items), + self::apiMeta(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. + * + * @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} + */ + public static function apiPaginated(mixed $paginated, string $resourceClass, array $extraMeta = []): 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)), + ); + } + + /** + * Legacy paginated meta block: slimmer than {@see apiMeta()}, no + * `url`/`locale`/`languages`, includes a passthrough `options` object + * reflecting the request's filter/sort state. + * + * 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 + */ + public static function paginationMeta(mixed $paginated, array $options = [], array $meta = []): array + { + $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; + } + + /** + * Legacy paginated envelope. Same intent as {@see apiPaginated()} but + * with the older meta shape — reads `options` off the current request + * when none are supplied. + * + * Retained for backward compatibility — new code should use + * {@see apiPaginated()}. + * + * @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} + */ + 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); + } + + return $payload; + } +}