├── resources └── lang │ └── en │ ├── success.php │ └── errors.php ├── LICENSE.md ├── src ├── LaravelApiResponseServiceProvider.php ├── Concerns │ ├── Translatable.php │ ├── RendersApiResponse.php │ └── ConvertsExceptionToApiResponse.php └── ApiResponse.php ├── CONTRIBUTING.md ├── composer.json ├── config └── api-response.php └── README.md /resources/lang/en/success.php: -------------------------------------------------------------------------------- 1 | 'An example response message', 12 | 13 | /* 14 | |-------------------------------------------------------------------------- 15 | | Short code message translations 16 | |-------------------------------------------------------------------------- 17 | */ 18 | 19 | 'example_code' => 'Example success message, :status', 20 | 21 | ]; 22 | -------------------------------------------------------------------------------- /resources/lang/en/errors.php: -------------------------------------------------------------------------------- 1 | 'Please wait a few minutes and try again.', 12 | 13 | /* 14 | |-------------------------------------------------------------------------- 15 | | Short code translations which do not specify error codes in HTTP responses 16 | |-------------------------------------------------------------------------- 17 | */ 18 | 19 | 'example_code' => 'An example error message', 20 | 'validation_failed' => 'Validation Failed.', 21 | 22 | /* 23 | |-------------------------------------------------------------------------- 24 | | Short code translations which specify error codes in HTTP responses 25 | |-------------------------------------------------------------------------- 26 | */ 27 | 'error_code' => [ 28 | 29 | 'error_code_name' => 'Example error message with :attribute' 30 | ], 31 | ]; 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Kennedy Osaze 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/LaravelApiResponseServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(__DIR__.'/../config/api-response.php', 'api-response'); 17 | } 18 | 19 | /** 20 | * Bootstrap any application services. 21 | * 22 | * @return void 23 | */ 24 | public function boot() 25 | { 26 | $this->loadTranslationsFrom(__DIR__.'/../resources/lang', 'api-response'); 27 | 28 | $this->configurePublishing(); 29 | } 30 | 31 | /** 32 | * Configure publishing for the package. 33 | * 34 | * @return void 35 | */ 36 | private function configurePublishing() 37 | { 38 | if (! $this->app->runningInConsole()) { 39 | return; 40 | } 41 | 42 | $this->publishes([ 43 | __DIR__.'/../config/api-response.php' => config_path('api-response.php'), 44 | ], 'api-response-config'); 45 | 46 | $this->publishes([ 47 | __DIR__.'/../resources/lang' => lang_path('vendor/api-response'), 48 | ], 'api-response-translations'); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome**! 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/kennedy-osaze/laravel-api-response/pulls). 6 | 7 | ## Requirements 8 | 9 | If the project maintainer has any additional requirements, you will find them listed here. 10 | 11 | - **[PSR-12 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-12-extended-coding-style-guide.md)** which has been **[modified](https://github.com/kennedy-osaze/php-cs-fixer-config/blob/main/src/rules.php)** - The easiest way to apply the conventions is run command: 12 | 13 | ```bash 14 | ./vendor/bin/php-cs-fixer fix 15 | ``` 16 | 17 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 18 | 19 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 20 | 21 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. 22 | 23 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 24 | 25 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 26 | 27 | **Happy coding**! 28 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kennedy-osaze/laravel-api-response", 3 | "description": "Renders consistent HTTP JSON responses for API-based projects", 4 | "keywords": [ 5 | "laravel", 6 | "api", 7 | "json", 8 | "response" 9 | ], 10 | "homepage": "https://github.com/kennedy-osaze/laravel-api-response", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Kennedy Osaze", 15 | "email": "me.osaze@gmail.com", 16 | "role": "Maintainer" 17 | } 18 | ], 19 | "require": { 20 | "php": "^8.1", 21 | "illuminate/contracts": "^10.0", 22 | "illuminate/translation": "^10.0" 23 | }, 24 | "require-dev": { 25 | "kennedy-osaze/php-cs-fixer-config": "^2.0.1", 26 | "nunomaduro/collision": "^7.0", 27 | "orchestra/testbench": "^8.0", 28 | "phpunit/phpunit": "^10.0" 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "KennedyOsaze\\LaravelApiResponse\\": "src/" 33 | } 34 | }, 35 | "autoload-dev": { 36 | "psr-4": { 37 | "KennedyOsaze\\LaravelApiResponse\\Tests\\": "tests/" 38 | } 39 | }, 40 | "scripts": { 41 | "test": "./vendor/bin/testbench package:test" 42 | }, 43 | "config": { 44 | "sort-packages": true 45 | }, 46 | "extra": { 47 | "laravel": { 48 | "providers": [ 49 | "KennedyOsaze\\LaravelApiResponse\\LaravelApiResponseServiceProvider" 50 | ] 51 | } 52 | }, 53 | "minimum-stability": "dev", 54 | "prefer-stable": true 55 | } 56 | -------------------------------------------------------------------------------- /config/api-response.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'code' => 422, 20 | 'message' => 'validation_failed' 21 | ], 22 | 23 | /* 24 | |-------------------------------------------------------------------------- 25 | | HTML Rendering with Exception 26 | |-------------------------------------------------------------------------- 27 | | 28 | | By default, exceptions are handled and rendered as a JSON response. 29 | | This options give the developer the ability to change this default 30 | | and provide a HTML (ignition) page with details of the exception 31 | | whenever an exception occurs. 32 | | 33 | */ 34 | 35 | 'render_html_on_exception' => false, 36 | 37 | /* 38 | |-------------------------------------------------------------------------- 39 | | No-Content HTTP Statuses 40 | |-------------------------------------------------------------------------- 41 | | 42 | | This allows the developer provide a list of HTTP statuses that 43 | | requires no content to be sent in the JSON response. 44 | | 45 | */ 46 | 47 | 'http_statuses_with_no_content' => [ 48 | JsonResponse::HTTP_NO_CONTENT, 49 | ], 50 | 51 | /* 52 | |-------------------------------------------------------------------------- 53 | | Message Translation Path 54 | |-------------------------------------------------------------------------- 55 | | 56 | | This option determines where the translation for all successful 57 | | and error messages can be found. 58 | | 59 | */ 60 | 61 | 'translation' => [ 62 | 'success' => 'success', 63 | 'errors' => 'errors', 64 | ], 65 | 66 | /* 67 | |-------------------------------------------------------------------------- 68 | | HTTP Status Data Wrappers 69 | |-------------------------------------------------------------------------- 70 | | 71 | | This provides a list of "data" wrapper that should be applied 72 | | to contain successful and error payload/data. 73 | | 74 | */ 75 | 76 | 'data_wrappers' => [ 77 | '2xx' => 'data', 78 | '422' => 'errors', 79 | '4xx' => 'error', 80 | '5xx' => 'error', 81 | ], 82 | 83 | ]; 84 | -------------------------------------------------------------------------------- /src/Concerns/Translatable.php: -------------------------------------------------------------------------------- 1 | value1, key2 => value2]) 17 | * to retrieve corresponding translation field 18 | * 19 | * @param string $string 20 | * 21 | * @return array 22 | */ 23 | public function parseStringToTranslationParameters(string $string): array 24 | { 25 | $stringParts = explode(':', Str::after($string, '::')); 26 | 27 | $prefix = Str::contains($string, '::') ? Str::before($string, '::').'::' : ''; 28 | $name = $prefix.array_shift($stringParts); 29 | 30 | $attributes = []; 31 | 32 | if (! empty($stringParts)) { 33 | foreach (explode('|', $stringParts[0]) as $keyValue) { 34 | if ($keyValue === '') { 35 | continue; 36 | } 37 | 38 | $parts = explode('=', $keyValue); 39 | $attributes[$parts[0]] = $parts[1] ?? ''; 40 | } 41 | } 42 | 43 | return compact('name', 'attributes'); 44 | } 45 | 46 | /** 47 | * Transforms the parameters into a string parsable for translation. 48 | * 49 | * Used like this: $this->transformToTranslatableString('name', ['key1' => 'value1', 'key2' => 'value2']) 50 | * 51 | * This outputs as string of the form 'name:key1=value1|key2=value2' 52 | * 53 | * @param string $name 54 | * @param array $attributes 55 | * @param string $string 56 | * 57 | * @return string 58 | */ 59 | public function transformToTranslatableString(string $name, array $attributes = []): string 60 | { 61 | if (empty($attributes) || ! Arr::isAssoc($attributes)) { 62 | return $name; 63 | } 64 | 65 | return rtrim($name.':'.http_build_query(Arr::dot($attributes), '', '|'), '=:|'); 66 | } 67 | 68 | /** 69 | * Attempts to translates a message string returning the corresponding key and translated string. 70 | * 71 | * If the message cannot be translated, the returning key is null and message remains the same. 72 | * 73 | * @param string $message 74 | * @param array $attributes 75 | * @param array 76 | * 77 | * @return array 78 | */ 79 | public function getTranslatedStringArray(string $message, array $attributes = [], ?string $prefix = null): array 80 | { 81 | $path = ! empty($prefix) ? "{$prefix}.{$message}" : $message; 82 | 83 | $key = null; 84 | $translatedMessage = __($path, $attributes); 85 | 86 | if (! Str::startsWith($translatedMessage, $path)) { 87 | $message = $translatedMessage; 88 | $key = Str::slug(last(explode('.', $path)), '_'); 89 | } 90 | 91 | return ['key' => $key, 'message' => $message]; 92 | } 93 | 94 | /** 95 | * Determine if a translation exists. 96 | * 97 | * @param string $key 98 | * 99 | * @return bool 100 | */ 101 | public function isTranslationKey(string $key): bool 102 | { 103 | if (Lang::has($key)) { 104 | return true; 105 | } 106 | 107 | if (count($parts = explode('::', $key)) === 1) { 108 | return Lang::has(Str::before($parts[0], ':')); 109 | } 110 | 111 | return Lang::has($parts[0].'::'.Str::before($parts[1], ':')); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Concerns/RendersApiResponse.php: -------------------------------------------------------------------------------- 1 | successResponse($message, $data, headers: $headers); 19 | } 20 | 21 | public function createdResponse(string $message, $data = null, array $headers = []): JsonResponse 22 | { 23 | return $this->successResponse($message, $data, 201, $headers); 24 | } 25 | 26 | public function acceptedResponse(string $message, $data = null, array $headers = []): JsonResponse 27 | { 28 | return $this->successResponse($message, $data, 202, $headers); 29 | } 30 | 31 | public function noContentResponse(): JsonResponse 32 | { 33 | return $this->successResponse('', null, 204); 34 | } 35 | 36 | public function successResponse(string $message, $data = null, int $status = 200, array $headers = []): JsonResponse 37 | { 38 | return ApiResponse::create($status, $message, $data, $headers); 39 | } 40 | 41 | public function resourceResponse(JsonResource $resource, string $message, int $status = 200, array $headers = []): JsonResponse 42 | { 43 | if (! $resource instanceof ResourceCollection && blank($resource->with) && blank($resource->additional)) { 44 | return ApiResponse::create($status, $message, $resource, $headers); 45 | } 46 | 47 | $response = $resource->response()->withHeaders($headers)->setStatusCode($status); 48 | 49 | return ApiResponse::fromJsonResponse($response, $message, true); 50 | } 51 | 52 | public function resourceCollectionResponse( 53 | ResourceCollection $collection, string $message, bool $wrap = true, int $status = 200, array $headers = [] 54 | ): JsonResponse { 55 | $response = $collection->response()->withHeaders($headers)->setStatusCode($status); 56 | 57 | return ApiResponse::fromJsonResponse($response, $message, $wrap); 58 | } 59 | 60 | public function unauthenticatedResponse(string $message): JsonResponse 61 | { 62 | return $this->clientErrorResponse($message, 401); 63 | } 64 | 65 | public function badRequestResponse(string $message, ?array $error = null): JsonResponse 66 | { 67 | return $this->clientErrorResponse($message, 400, $error); 68 | } 69 | 70 | public function forbiddenResponse(string $message, ?array $error = null): JsonResponse 71 | { 72 | return $this->clientErrorResponse($message, 403, $error); 73 | } 74 | 75 | public function notFoundResponse(string $message, ?array $error = null): JsonResponse 76 | { 77 | return $this->clientErrorResponse($message, 404, $error); 78 | } 79 | 80 | public function throwValidationExceptionWhen($condition, array $messages): void 81 | { 82 | if ((bool) $condition) { 83 | throw ValidationException::withMessages($messages); 84 | } 85 | } 86 | 87 | public function validationFailedResponse(Validator $validator, ?Request $request = null, ?string $message = null): JsonResponse 88 | { 89 | return ApiResponse::fromFailedValidation($validator, $request ?? request(), $message); 90 | } 91 | 92 | public function clientErrorResponse(string $message, int $status = 400, ?array $error = null, array $headers = []): JsonResponse 93 | { 94 | return ApiResponse::create($status, $message, $error, $headers); 95 | } 96 | 97 | public function serverErrorResponse(string $message, int $status = 500, ?Throwable $exception = null): JsonResponse 98 | { 99 | if ($exception !== null) { 100 | report($exception); 101 | } 102 | 103 | return ApiResponse::create($status, $message ?: $exception?->getMessage()); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Concerns/ConvertsExceptionToApiResponse.php: -------------------------------------------------------------------------------- 1 | prepareApiException($exception, $request); 26 | 27 | if ($response = $this->getExceptionResponse($exception, $request)) { 28 | return $response; 29 | } 30 | 31 | if ($exception instanceof HttpException) { 32 | return $this->convertHttpExceptionToJsonResponse($exception); 33 | } 34 | 35 | if ($this->shouldRenderHtmlOnException()) { 36 | return parent::render($request, $exception); 37 | } 38 | 39 | return ApiResponse::create(500, 'Server Error', $this->convertExceptionToArray($exception)); 40 | } 41 | 42 | protected function prepareApiException(Throwable $e, Request $request): Throwable 43 | { 44 | return match (true) { 45 | $e instanceof NotFoundHttpException, $e instanceof ModelNotFoundException => with( 46 | $e, function ($e) { 47 | $message = (string) with($e->getMessage(), function ($message) { 48 | return blank($message) || Str::contains($message, 'No query results for model') ? 'Resource not found.' : $message; 49 | }); 50 | 51 | return new NotFoundHttpException($message, $e); 52 | } 53 | ), 54 | $e instanceof AuthenticationException => new HttpException(401, $e->getMessage(), $e), 55 | $e instanceof UnauthorizedException => new HttpException(403, $e->getMessage(), $e), 56 | default => $e, 57 | }; 58 | } 59 | 60 | protected function getExceptionResponse(Throwable $exception, Request $request): ?JsonResponse 61 | { 62 | if ($exception instanceof ValidationException) { 63 | return ApiResponse::fromFailedValidation($exception->validator, $request); 64 | } 65 | 66 | if (! $exception instanceof HttpResponseException) { 67 | return null; 68 | } 69 | 70 | $response = $exception->getResponse(); 71 | 72 | return $response instanceof JsonResponse 73 | ? ApiResponse::fromJsonResponse($response) 74 | : ApiResponse::create($response->getStatusCode(), 'An error occurred', ['content' => $response->getContent()]); 75 | } 76 | 77 | protected function convertHttpExceptionToJsonResponse(HttpExceptionInterface $exception): JsonResponse 78 | { 79 | $statusCode = $exception->getStatusCode(); 80 | $message = $exception->getMessage() ?: JsonResponse::$statusTexts[$statusCode]; 81 | $headers = $exception->getHeaders(); 82 | $data = method_exists($exception, 'getErrorData') ? call_user_func([$exception, 'getErrorData']) : null; 83 | 84 | return ApiResponse::create($statusCode, $message, $data, $headers); 85 | } 86 | 87 | protected function convertExceptionToArray(Throwable $e) 88 | { 89 | return config('app.debug') ? [ 90 | 'message' => $e->getMessage(), 91 | 'code' => $e->getCode(), 92 | 'exception' => get_class($e), 93 | 'file' => $e->getFile(), 94 | 'line' => $e->getLine(), 95 | 'trace' => collect($e->getTrace())->map(fn ($trace) => Arr::except($trace, ['args']))->all(), 96 | ] : []; 97 | } 98 | 99 | protected function shouldRenderHtmlOnException(): bool 100 | { 101 | return (bool) config('api-response.render_html_on_exception'); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/ApiResponse.php: -------------------------------------------------------------------------------- 1 | message = $message; 40 | $this->data = $data; 41 | $this->headers = $headers; 42 | 43 | $this->setStatusCode($statusCode); 44 | } 45 | 46 | public static function create(int $statusCode, string $message = null, $data = null, array $headers = []): JsonResponse 47 | { 48 | return (new static($statusCode, $message, $data, $headers))->make(); 49 | } 50 | 51 | public static function fromJsonResponse(JsonResponse $response, string $message = null, bool $wrap = false): JsonResponse 52 | { 53 | $data = $response->getData(true); 54 | $status = $response->status(); 55 | 56 | $responseData = is_array($data) ? $data : ['message_data' => $data]; 57 | $message = (string) ($message ?: Arr::pull($responseData, 'message', JsonResponse::$statusTexts[$status])); 58 | 59 | $response = new static($status, $message, $responseData, $response->headers->all()); 60 | 61 | return $response->unless($wrap, fn (self $response) => $response->ignoreDataWrapper())->make(); 62 | } 63 | 64 | public static function fromFailedValidation(Validator $validator, ?Request $request = null, ?string $message = null): JsonResponse 65 | { 66 | ['code' => $code, 'message' => $defaultMessage] = config('api-response.validation'); 67 | 68 | $response = new static($code, $message ?? $defaultMessage); 69 | 70 | $errors = $response->getValidationErrors($validator, $request ?? request()); 71 | 72 | return $response->setData($errors)->make(); 73 | } 74 | 75 | public function make(): JsonResponse 76 | { 77 | $statusesWithNoContent = config('api-response.http_statuses_with_no_content'); 78 | 79 | $data = in_array($this->statusCode, $statusesWithNoContent) ? null : $this->prepareResponseData(); 80 | 81 | return new JsonResponse($data, $this->statusCode, $this->headers); 82 | } 83 | 84 | public function getValidationErrors(Validator $validator, Request $request): array 85 | { 86 | if (is_callable(static::$validationErrorFormatter)) { 87 | return call_user_func(static::$validationErrorFormatter, $validator, $request); 88 | } 89 | 90 | $messages = array_unique(Arr::dot($validator->errors()->messages())); 91 | 92 | $result = []; 93 | $has_rejected_data = ! empty($validator->getRules()); 94 | 95 | collect($messages)->each(function ($message, $key) use (&$result, $validator, $request, $has_rejected_data) { 96 | $field = Str::before($key, '.'); 97 | 98 | if (! array_key_exists($field, $result)) { 99 | $result[$field] = ['message' => $message]; 100 | 101 | if ($has_rejected_data) { 102 | $result[$field]['rejected_value'] = $request->input($field) ?? data_get($validator->getData(), $field); 103 | } 104 | } 105 | }); 106 | 107 | return $result; 108 | } 109 | 110 | protected function prepareResponseData(): ?array 111 | { 112 | $successful = $this->statusCode >= 200 && $this->statusCode < 300; 113 | 114 | $normalizedData = $this->normalizeData($this->data); 115 | $data = is_array($normalizedData) ? $normalizedData : []; 116 | 117 | $messageData = $this->getTranslatedMessageMeta($this->message, $data, $successful); 118 | $normalizedData = is_array($normalizedData) ? $data : $normalizedData; 119 | 120 | $responseData = [ 121 | 'success' => $successful, 122 | 'message' => $messageData['message'] ?? $this->message, 123 | ]; 124 | 125 | $responseData += Arr::except($messageData, ['key', 'message']); 126 | 127 | if ($this->shouldWrapResponse && filled($normalizedData)) { 128 | $responseData[$this->getDataWrapper()] = $normalizedData; 129 | } elseif (! is_null($normalizedData)) { 130 | $responseData += $normalizedData; 131 | } 132 | 133 | return $responseData; 134 | } 135 | 136 | protected function getTranslatedMessageMeta(string $message, array &$data, bool $successful): array 137 | { 138 | $fileKey = $successful ? 'success' : 'errors'; 139 | $file = config("api-response.translation.{$fileKey}"); 140 | 141 | if (! is_string($file)) { 142 | return []; 143 | } 144 | 145 | $translationPrefix = $this->isTranslationKey($message) ? null : 'api-response::'.config("api-response.translation.{$fileKey}"); 146 | 147 | $translated = $this->extractTranslationDataFromResponsePayload($data, $message, $translationPrefix); 148 | 149 | if ($successful) { 150 | return $translated; 151 | } 152 | 153 | return array_merge($translated, $this->pullErrorCodeFromData($data, $message, $translated['key'])); 154 | } 155 | 156 | protected function extractTranslationDataFromResponsePayload(array &$data, string $message, ?string $prefix = null) 157 | { 158 | $parameters = $this->parseStringToTranslationParameters($message); 159 | 160 | $attributes = array_merge($parameters['attributes'], Arr::pull($data, '_attributes', [])); 161 | 162 | return $this->getTranslatedStringArray($parameters['name'], $attributes, $prefix); 163 | } 164 | 165 | protected function pullErrorCodeFromData(array &$data, string $message, ?string $translatedKey = null): array 166 | { 167 | if (array_key_exists('error_code', $data)) { 168 | return ['error_code' => (string) Arr::pull($data, 'error_code')]; 169 | } 170 | 171 | if (! is_null($translatedKey) && Str::contains($message, 'error_code.')) { 172 | return ['error_code' => $translatedKey]; 173 | } 174 | 175 | return []; 176 | } 177 | 178 | public function setData($data): static 179 | { 180 | return tap($this, fn (self $response) => $response->data = $data); 181 | } 182 | 183 | public function ignoreDataWrapper(): static 184 | { 185 | return tap($this, fn (self $response) => $response->shouldWrapResponse = false); 186 | } 187 | 188 | protected function setStatusCode(int $statusCode): void 189 | { 190 | if (! array_key_exists($statusCode, JsonResponse::$statusTexts)) { 191 | throw new InvalidArgumentException("Invalid HTTP status code: [{$statusCode}]"); 192 | } 193 | 194 | $this->statusCode = $statusCode; 195 | } 196 | 197 | protected function normalizeData($data) 198 | { 199 | if (is_array($data) || is_null($data)) { 200 | return $data; 201 | } 202 | 203 | return match (true) { 204 | $data instanceof JsonResource => $data->resolve(), 205 | $data instanceof Jsonable => json_decode($data->toJson(), true), 206 | $data instanceof JsonSerializable => $data->jsonSerialize(), 207 | $data instanceof Arrayable => $data->toArray(), 208 | $data instanceof stdClass => (array) $data, 209 | default => $data 210 | }; 211 | } 212 | 213 | public static function registerValidationErrorFormatter(?Closure $formatter) 214 | { 215 | static::$validationErrorFormatter = $formatter; 216 | } 217 | 218 | protected function getDataWrapper(): ?string 219 | { 220 | if (! $this->shouldWrapResponse) { 221 | return null; 222 | } 223 | 224 | return collect(config('api-response.data_wrappers'))->first(fn ($value, $key) => 225 | Str::is(Str::of($key)->replace('x', '*'), $this->statusCode) 226 | ); 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Laravel API Response Logo](https://banners.beyondco.de/Laravel%20API%20Response.png?theme=dark&packageManager=composer+require&packageName=kennedy-osaze%2Flaravel-api-response&pattern=architect&style=style_1&description=Renders+consistent+HTTP+JSON+responses+for+API-based+projects&md=1&showWatermark=0&fontSize=100px&images=https%3A%2F%2Flaravel.com%2Fimg%2Flogomark.min.svg) 2 | 3 | [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/kennedy-osaze/laravel-api-response/tests?label=CI)](https://github.com/kennedy-osaze/laravel-api-response/actions?query=workflow%3ACI+branch%3Amain) 4 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/kennedy-osaze/laravel-api-response.svg?style=flat-square)](https://packagist.org/packages/kennedy-osaze/laravel-api-response) 5 | [![License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE.md) 6 | [![Total Downloads](https://img.shields.io/packagist/dt/kennedy-osaze/laravel-api-response)](https://packagist.org/packages/kennedy-osaze/laravel-api-response) 7 | 8 | 9 | 10 | **** 11 | 12 | Laravel API Response is a package that helps to provide and render a consistent HTTP JSON responses to API calls as well as converting and formatting exceptions to JSON responses. 13 | 14 | ## Version Compatibility 15 | 16 | Laravel | Laravel API Response 17 | :---------|:---------------------- 18 | 9.x | 1.x 19 | 10.x | 2.x 20 | 21 | ## Installation 22 | 23 | You can install the package via composer: 24 | 25 | ```bash 26 | composer require kennedy-osaze/laravel-api-response 27 | ``` 28 | 29 | You can publish the translation files using: 30 | 31 | ```bash 32 | php artisan vendor:publish --tag="api-response-translations" 33 | ``` 34 | 35 | This will create a vendor folder (if it doesn't exists) in the `lang` folder of your project and inside, a `api-response/en` folder that has two files: `errors.php` and `success.php`. Both files are used for the translation of message strings in the JSON response sent out. 36 | 37 | Optionally, you can publish the config file using: 38 | 39 | ```bash 40 | php artisan vendor:publish --tag="api-response-config" 41 | ``` 42 | 43 | ## Usage 44 | 45 | ### Using Package Traits 46 | 47 | This package provides two traits that can be imported into your projects; namely: 48 | 49 | - The `\KennedyOsaze\LaravelApiResponse\Concerns\RendersApiResponse` trait which can be imported into your (base) controller class, middleware class or even your exception handler class 50 | - The `\KennedyOsaze\LaravelApiResponse\Concerns\ConvertsExceptionToApiResponse` trait which should only be imported into your exception handler class. 51 | 52 | So we can have on the base controller class (from which all other controller may extend from): 53 | 54 | ```php 55 | okResponse('This is a random message', $data = null, $headers = []); 92 | return $this->createdResponse('This is a random message', $data = null, $headers = []); 93 | return $this->acceptedResponse($message, $data, $headers); 94 | return $this->noContentResponse(); 95 | return $this->successResponse($message, $data = null, $status = 200, $headers = []); 96 | 97 | // Successful Responses for \Illuminate\Http\Resources\Json\JsonResource 98 | return $this->resourceResponse($jsonResource, $message, $status = 200, $headers = []); 99 | return $this->resourceCollectionResponse($resourceCollection, $message, $wrap = true, $status = 200, $headers = []); 100 | 101 | // Error Responses 102 | return $this->unauthenticatedResponse('Unauthenticated message'); 103 | return $this->badRequestResponse('Bad request error message', $error = null); 104 | return $this->forbiddenResponse($message); 105 | return $this->notFoundResponse($message); 106 | return $this->clientErrorResponse($message, $status = 400, $error = null, $headers = []); 107 | return $this->serverErrorResponse($message); 108 | return $this->validationFailedResponse($validator, $request = null, $message = null); 109 | 110 | $messages = ['name' => 'Name is not valid']; 111 | $this->throwValidationExceptionWhen($condition, $messages); 112 | ``` 113 | 114 | Also to handle exceptions, converting them to API response by using the `\KennedyOsaze\LaravelApiResponse\Concerns\ConvertsExceptionToApiResponse` trait in your exception handler which provides the `renderApiResponse` public method and this can be used as follows: 115 | 116 | ```php 117 | renderApiResponse($e, $request); 133 | } 134 | } 135 | ``` 136 | 137 | You could also use the `renderable` method of the handler class: 138 | 139 | ```php 140 | renderable(function (Throwable $e, $request) { 156 | return $this->renderApiResponse($e, $request); 157 | }); 158 | } 159 | } 160 | ``` 161 | 162 | ### Using Package Classes 163 | 164 | At the core of the above methods, there is an underlying `ApiResponse` class being called that can also be used as follows: 165 | 166 | ```php 167 | use KennedyOsaze\LaravelApiResponse\ApiResponse; 168 | 169 | $response = new ApiResponse($status = 200, $message = 'Hello world', $data = ['age' => 20], $header = []); 170 | 171 | return $response->make(); 172 | 173 | // Result 174 | { 175 | "success": true, 176 | "message": "Hello world", 177 | "data": { 178 | 'age' => 20 179 | } 180 | } 181 | 182 | // OR 183 | return ApiResponse::create(400, 'Error occurred'); 184 | 185 | // Result 186 | { 187 | "success": false, 188 | "message": "Error occurred" 189 | } 190 | 191 | // We could also have 192 | $validator = Validator::make([], ['name' => 'required']); 193 | return ApiResponse::fromFailedValidation($validator); 194 | 195 | // Result 196 | { 197 | "success": true, 198 | "message": "Validation Failed.", 199 | "errors": [ 200 | "name": { 201 | "message": "The name field is required", 202 | "rejected_value": null 203 | } 204 | ] 205 | } 206 | 207 | // Also 208 | 209 | $response = response()->json(['hello' => 'world']); 210 | 211 | return ApiResponse::fromJsonResponse($response, $message = 'Hello'); 212 | 213 | // Result 214 | { 215 | "success": true, 216 | "message": "hello" 217 | "data": { 218 | "hello": "world" 219 | } 220 | } 221 | ``` 222 | 223 | If you would like to change the format for validation errors, you may call the `registerValidationErrorFormatter` static method of the `ApiResponse` class in the boot method of your `App\Providers\AppServiceProvider` class or any other service provider you want. You can do something like this: 224 | 225 | ```php 226 | $validator->errors()->all(), 239 | ]; 240 | }); 241 | } 242 | ``` 243 | 244 | ### Response Data 245 | 246 | The response data `$data` to be rendered for successful response can be any of the following type: 247 | 248 | - array e.g. `['name' => 'Dummy']` 249 | - standard object e.g. `new stdClass` 250 | - integer e.g. `1` 251 | - boolean e.g. `true` 252 | - any Model object, `instance of \Illuminate\Database\Eloquent\Model` 253 | - any Collection object, `instance of \Illuminate\Support\Collection` 254 | - any JsonResource object, `instance of \Illuminate\Http\Resources\Json\JsonResource` 255 | - any Jsonable object, `instance of \Illuminate\Contracts\Support\Jsonable` 256 | - any JsonSerializable object, `instance of \JsonSerializable` 257 | - any Arrayable object, `instance of \Illuminate\Contracts\Support\Arrayable` 258 | 259 | Any of the above can be used stored as `$data` and used thus: 260 | 261 | ```php 262 | use \KennedyOsaze\LaravelApiResponse\ApiResponse; 263 | 264 | ApiResponse::create(200, 'A message', $data) 265 | ``` 266 | 267 | For API Resources [JsonResources](https://laravel.com/docs/9.x/eloquent-resources "JsonResources") , you can create JSON responses by doing the following: 268 | 269 | ```php 270 | 271 | use App\Models\Book; 272 | use App\Http\Resources\BookResource; 273 | use App\Http\Resources\BookCollection; 274 | use KennedyOsaze\LaravelApiResponse\ApiResponse; 275 | 276 | $resource = new BookResource(Book::find(1)); 277 | 278 | return ApiResponse::fromJsonResponse($resource->response(), 'A book'); 279 | 280 | // Also 281 | 282 | $collection = BookResource::collection(Book::all()); 283 | 284 | return ApiResponse:::fromJsonResponse($collection->response(), 'List of books'); 285 | 286 | // Also 287 | 288 | $collection = new BookCollection(Book::paginate()); 289 | 290 | return ApiResponse::fromJsonResponse($collection->response, 'Paginated list of books') 291 | ``` 292 | 293 | ### Response Messages 294 | 295 | This package uses translation files to translate messages defined when creating responses. This packages, as described earlier, comes with two translation files: `success.php` and `errors.php`. The `success.php` contains translations for success response messages while `errors.php` contains that of error response messages. 296 | 297 | Given that you have a `success.php` translation file as thus: 298 | 299 | ```php 300 | 'User account created successfully', 304 | 'invoice_paid' => 'Invoice with number :invoice_number has been paid.', 305 | ]; 306 | 307 | ``` 308 | 309 | The `ApiResponse` class would be able to translate messages as follows: 310 | 311 | ```php 312 | ['invoice_number' => 'INV_12345'] 332 | ]); 333 | 334 | // Result 335 | { 336 | "success": true, 337 | "message": "Invoice with number INV_12345 has been paid." 338 | } 339 | 340 | // Also: 341 | 342 | return ApiResponse::create(200, 'invoice_paid', [ 343 | '_attributes' => ['invoice_number' => 'INV_12345'], 344 | 'name' => 'Invoice for Mr Bean', 345 | 'amount' => 1000, 346 | 'number' => 'INV_12345' 347 | ]); 348 | 349 | // Result 350 | { 351 | "success": true, 352 | "message": "Invoice with number INV_12345 has been paid.", 353 | "data": { 354 | "name": "Invoice for Mr Bean", 355 | "amount": 1000, 356 | "number": "INV_12345" 357 | } 358 | } 359 | ``` 360 | 361 | This is similar to how messages for error responses are translated except with the fact that the error messages are read from the `errors.php` translation file instead (or whatever you specify in the config file). 362 | 363 | Also, for error messages, you can decide that error response should have error codes. You can provide error codes in your responses in a couple of ways: 364 | 365 | ```php 366 | 'request_failed' // The error code here is "request_failed" 372 | ]); 373 | 374 | // Result 375 | { 376 | "success": false, 377 | "message": "Error message comes here.", 378 | "error_code": "request_failed" 379 | } 380 | 381 | ``` 382 | 383 | Also, you can use the `errors.php` translation file to translate error codes. Given the below `errors.php` file: 384 | 385 | ```php 386 | 387 | return [ 388 | 389 | 'error_code' => [ 390 | 'example_code' => 'Just a failed error message', 391 | 392 | 'error_code_name' => 'Example error message with status :status', 393 | ], 394 | ]; 395 | ``` 396 | 397 | We can have a response with error code as follows: 398 | 399 | ```php 400 | ['status' => 'FAILED'] 418 | ]); 419 | 420 | // OR 421 | 422 | return ApiResponse::create(400, 'error_code.error_code_name:status=FAILED'); 423 | 424 | // Result 425 | 426 | { 427 | "success": false, 428 | "message": "Example error message with status FAILED", 429 | "error_code": "error_code_name" 430 | } 431 | 432 | ``` 433 | 434 | ## Testing 435 | 436 | ```bash 437 | composer test 438 | ``` 439 | 440 | ## Changelog 441 | 442 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 443 | 444 | ## Contributing 445 | 446 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 447 | 448 | ## Security Vulnerabilities 449 | 450 | If you discover any security related issues, please email [me.osaze@gmail.com](mailto:me.osaze@gmail.com) instead of using the issue tracker. 451 | 452 | ## Credits 453 | 454 | - [Kennedy Osaze](https://github.com/kennedy-osaze) 455 | - [All Contributors](../../contributors) 456 | 457 | ## License 458 | 459 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 460 | --------------------------------------------------------------------------------