From 3ddd77ad6d252994712f6074c1d884cca25d0dd0 Mon Sep 17 00:00:00 2001 From: "Hermann D. Schimpf" Date: Tue, 30 May 2023 14:55:24 -0400 Subject: [PATCH 1/4] Initial package struct --- .gitignore | 6 +++++ composer.json | 51 +++++++++++++++++++++++++++++++++++++++ src/helpers.php | 1 + tests/coverage/.gitignore | 2 ++ 4 files changed, 60 insertions(+) create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 src/helpers.php create mode 100644 tests/coverage/.gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3efaa0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/.idea +/.vscode +/vendor + +*.cache +composer.lock diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..7add36c --- /dev/null +++ b/composer.json @@ -0,0 +1,51 @@ +{ + "name": "hds-solutions/laravel-api-helpers", + "description": "A package with helpers to generate APIs on your Laravel project", + "type": "library", + "license": "GPL-3.0", + "authors": [ + { + "name": "Hermann D. Schimpf", + "email": "hschimpf@hds-solutions.net" + } + ], + "require": { + "php": "^8.0|^8.1", + "laravel/framework": "^9.0|^10.0" + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "HDSSolutions\\Laravel\\API\\": "src/" + } + }, + "require-dev": { + "pestphp/pest": "^2.6", + "pestphp/pest-plugin-laravel": "^2.0", + "roave/security-advisories": "dev-latest" + }, + "autoload-dev": { + "psr-4": { + "HDSSolutions\\Laravel\\API\\Tests\\": "tests/" + } + }, + "scripts": { + "test": [ + "test --coverage-html tests/coverage" + ] + }, + "minimum-stability": "stable", + "prefer-stable": true, + "config": { + "optimize-autoloader": true, + "preferred-install": { + "*": "dist" + }, + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..f4165e8 --- /dev/null +++ b/src/helpers.php @@ -0,0 +1 @@ + Date: Wed, 12 Jul 2023 14:36:12 -0400 Subject: [PATCH 2/4] Added features implementation - `ResourceFilters` class with filtering features - `ResourceOrders` class with sorting features - `ResourceRelations` class with relationship loading features - `PaginateResults` class with pagination features --- src/Actions/PaginateResults.php | 22 +++++ src/ResourceFilters.php | 144 ++++++++++++++++++++++++++++++++ src/ResourceOrders.php | 114 +++++++++++++++++++++++++ src/ResourceRelations.php | 124 +++++++++++++++++++++++++++ 4 files changed, 404 insertions(+) create mode 100644 src/Actions/PaginateResults.php create mode 100644 src/ResourceFilters.php create mode 100644 src/ResourceOrders.php create mode 100644 src/ResourceRelations.php diff --git a/src/Actions/PaginateResults.php b/src/Actions/PaginateResults.php new file mode 100644 index 0000000..457a9f0 --- /dev/null +++ b/src/Actions/PaginateResults.php @@ -0,0 +1,22 @@ +request->boolean('all') + ? $query->get() + : $query->paginate()->withQueryString() + ); + } + +} diff --git a/src/ResourceFilters.php b/src/ResourceFilters.php new file mode 100644 index 0000000..468503e --- /dev/null +++ b/src/ResourceFilters.php @@ -0,0 +1,144 @@ + '=', + 'lt' => '<', + 'lte' => '<=', + 'gt' => '>', + 'gte' => '>=', + 'ne' => '!=', + 'has' => 'like', + 'in' => null, + ]; + + /** + * Available field types with their default operators + * + * @var array{ string, string[] } + */ + private const TYPES = [ + 'string' => [ 'eq', 'ne', 'has' ], + 'numeric' => [ 'eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'in' ], + 'boolean' => [ 'eq', 'ne' ], + 'date' => [ 'eq', 'ne', 'lt', 'lte', 'gt', 'gte' ], + ]; + + /** + * List of allowed filtrable columns, with their allowed filter operators + * + * @var array{ string, string | string[] } + */ + protected array $allowed_columns = []; + + /** + * List of column mappings + * + * @var array{ string, string } + */ + protected array $column_mappings = []; + + final public function __construct( + protected Request $request, + ) {} + + final public function handle(Builder $query, Closure $next): void { + foreach ($this->allowed_columns as $column => $operators) { + // ignore filter if not specified in params + if (is_null($param = $this->request->query($column))) { + continue; + } + + if (is_string($param)) { + // force parameter without an operator to behave as equal filter + $param = [ 'eq' => $param ]; + } + + if (is_string($operators)) { + // validate that field type exists + if ( !array_key_exists($operators, ResourceFilters::TYPES)) { + throw new RuntimeException( + message: sprintf('Invalid "%s" field type', $operators), + code: Response::HTTP_INTERNAL_SERVER_ERROR, + ); + } + + // load operators for specified field type + $operators = ResourceFilters::TYPES[ $operators ]; + } + + foreach ($operators as $operator) { + // ignore operator if not specified in filter param + if ( !array_key_exists($operator, $param)) { + continue; + } + + // validate that operator is valid + if ( !array_key_exists($operator, ResourceFilters::OPERATORS)) { + throw new RuntimeException( + message: sprintf('Invalid "%s" operator', $operator), + code: Response::HTTP_BAD_REQUEST, + ); + } + + $this->addQueryFilter($query, $column, $operator, $param[ $operator ]); + } + } + + $next($query); + } + + private function addQueryFilter(Builder $query, string $column, string $operator, $value): void { + // check if a method with the param name exists + if (method_exists($this, $method = lcfirst((string) str($column)->studly()))) { + // redirect filtering to the custom method implementation + $query->where(fn($query) => $this->$method( + $query, + ResourceFilters::OPERATORS[ $operator ], + $this->parseValue($operator, $value), + $value, + )); + + // capture special case for WHERE IN + } elseif ($operator === 'in') { + $query->whereIn( + column: $this->column_mappings[ $column ] ?? $column, + values: $value, + ); + + } else { + $query->where( + column: $this->column_mappings[ $column ] ?? $column, + operator: ResourceFilters::OPERATORS[ $operator ], + value: $this->parseValue($operator, $value), + ); + } + } + + private function parseValue(string $operator, $value) { + if ($operator === 'eq' && in_array($value, [ 'true', 'false' ], true)) { + return $value === 'true'; + } + + if ($operator === 'has') { + return "%$value%"; + } + + return $value; + } + +} diff --git a/src/ResourceOrders.php b/src/ResourceOrders.php new file mode 100644 index 0000000..0b9e9d5 --- /dev/null +++ b/src/ResourceOrders.php @@ -0,0 +1,114 @@ +request->query('order')) { + // add default sorting fields + $this->setDefaultOrder($query); + + $next($query); + + return; + } + + // must follow the syntax order[{index}][{direction}]={field} + if ( !is_array($order)) { + throw new InvalidArgumentException( + message: 'Order parameter must have a numeric index, a direction and a field, example: order[0][asc]=field_name', + code: Response::HTTP_BAD_REQUEST, + ); + } + + $this->clean($order); + + foreach ($order as $value) { + // use only the first order that we found + $direction = array_key_first($value); + + $this->addQueryOrder($query, $value[$direction], $direction); + } + + $next($query); + } + + private function clean(array &$order): void { + sort($order); + + $available_order_fields = [ + ...array_filter(array_keys($this->allowed_columns), 'is_string'), + ...array_filter($this->allowed_columns, 'is_int', ARRAY_FILTER_USE_KEY) + ]; + $cleaned = []; + $already_added = []; + + foreach ($order as $idx => $value) { + // validate that order index is an int, direction is either "asc" or "desc", and has a field name + if ( !is_int($idx) || !in_array($direction = array_key_first($value), [ 'asc', 'desc' ], true) || !is_string($value[$direction])) { + throw new InvalidArgumentException( + message: 'Order parameter must have a numeric index, a direction and a field name, example: order[0][asc]=field_name', + code: Response::HTTP_BAD_REQUEST, + ); + } + + // check if field name is in the allowed list, and wasn't already added to the order list + if ( !in_array($column = $value[$direction], $available_order_fields, true) || in_array($value[$direction], $already_added, true)) { + continue; + } + + $already_added[] = $column; + $cleaned[] = $value; + } + + // store the cleaned orders + $order = $cleaned; + } + + private function addQueryOrder(Builder $query, string $column, string $direction): void { + if (method_exists($this, $method = lcfirst((string) str($column)->studly()))) { + $this->$method($query, $direction); + + } else { + $query->orderBy($this->allowed_columns[ $column] ?? $column, $direction); + } + } + + private function setDefaultOrder(Builder $query): void { + foreach ($this->default_order as $column => $direction) { + // check if key is column name + $column = is_string($column) ? $column : $direction; + // set default order if not specified + $direction = in_array(strtoupper($direction), [ 'ASC', 'DESC' ]) ? strtoupper($direction) : 'ASC'; + // add order by column + $query->orderBy($column, $direction); + } + } + +} diff --git a/src/ResourceRelations.php b/src/ResourceRelations.php new file mode 100644 index 0000000..2188f49 --- /dev/null +++ b/src/ResourceRelations.php @@ -0,0 +1,124 @@ +request->query('with')) { + $next($query); + + return; + } + + // convert to array if it is a coma separated string + if (is_string($with) && str_contains($with, ',')) { + $with = explode(',', $with); + } + + // must be an array + if ( !is_array($with)) { + throw new InvalidArgumentException( + message: 'Parameter "with" must be an array.', + code: Response::HTTP_BAD_REQUEST, + ); + } + + foreach ($this->allowed_relations as $mapping => $relation_name) { + if (is_int($mapping)) { + $mapping = $relation_name; + } + + // ignore relation if not specified in params + if ( !in_array($mapping, $with, true)) { + continue; + } + + // check if a method with the relation name exists + if (method_exists($this, $method = explode('.', $mapping, 2)[0])) { + // redirect relation to the custom method implementation + $this->with[$relation_name] = fn(Relation $relation) => $this->$method($relation); + } else { + $this->with[] = $relation_name; + } + } + + $this->parseWiths(); + $this->parseWithCounts(); + + // append relations to the query + $query->with($this->with); + // append relation counts to the query + $query->withCount($this->with_count); + + $next($query); + } + + private function parseWiths(): void { + $with = []; + foreach ($this->with as $idx => $relation) { + // check if the relation is a custom method implementation or isn't a ... + if ($relation instanceof Closure || !method_exists($this, $method = lcfirst(str($relation)->studly()->toString()))) { + $with[$idx] = $relation; + continue; + } + + // add the relation through the custom method implementation + $with[$relation] = fn($query) => $this->$method($query); + } + + // store the parsed relations + $this->with = $with; + } + + private function parseWithCounts(): void { + $with_count = []; + foreach ($this->with_count as $countable) { + // check ... + if ( !method_exists($this, $method = lcfirst(str("{$countable}_count")->studly()->toString()))) { + $with_count[] = $countable; + continue; + } + + // add the relation count through the custom method implementation + $with_count[$countable] = fn($query) => $this->$method($query); + } + + // store the parsed relationship counts + $this->with_count = $with_count; + } + +} From 84caf742d699c081c20dee92089b0125c7fcf6ce Mon Sep 17 00:00:00 2001 From: "Hermann D. Schimpf" Date: Wed, 12 Jul 2023 14:38:06 -0400 Subject: [PATCH 3/4] Added `ResourceRequest` class - `hash()` returns a unique identifier based on the request params - `authorize()` WIP: implementation for authorization by resource --- src/Contracts/ResourceRequest.php | 20 ++++++++++++ src/ResourceRequest.php | 52 +++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 src/Contracts/ResourceRequest.php create mode 100644 src/ResourceRequest.php diff --git a/src/Contracts/ResourceRequest.php b/src/Contracts/ResourceRequest.php new file mode 100644 index 0000000..0d94cd8 --- /dev/null +++ b/src/Contracts/ResourceRequest.php @@ -0,0 +1,20 @@ +route()?->parameters() ?? []; + + return sprintf('%s %s [%s@%s]', + $this->method(), + str_replace(array_map(static fn($key) => sprintf('{%s}', $key), array_keys($parameters)), $parameters, $this->route()?->uri()), + $this->route()?->getName(), + substr(md5(preg_replace('/&?cache=[^&]*/', '', $this->getQueryString() ?? '')), 0, 10).($append ? ':'.substr(md5($append), 0, 6) : ''), + ); + } + + public function authorize(): bool { + return true; + } + + final public function rules(): array { + return match ($this->method()) { + 'GET' => $this->index(), + 'POST' => $this->store(), + 'PUT', 'PATCH' => $this->update(), + 'DELETE' => $this->destroy(), + + default => throw new RuntimeException(sprintf('Unsupported method %s', $this->method())), + }; + } + + protected function index(): array { + return []; + } + + protected function store(): array { + return []; + } + + protected function update(): array { + return []; + } + + protected function destroy(): array { + return []; + } + +} From 966dc1c81afe79d5f26dbf152857604a0204f6f8 Mon Sep 17 00:00:00 2001 From: "Hermann D. Schimpf" Date: Wed, 12 Jul 2023 14:38:20 -0400 Subject: [PATCH 4/4] Library README.md --- README.md | 686 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 686 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..be9cc63 --- /dev/null +++ b/README.md @@ -0,0 +1,686 @@ +# Laravel API Helpers + +This library simplifies the process of building API controllers by providing convenient classes for managing filtering, ordering, relationship loading, and pagination of resource collections. + +[![Latest Stable Version](http://poser.pugx.org/hds-solutions/laravel-api-helpers/v)](https://packagist.org/packages/hds-solutions/laravel-api-helpers) +[![Total Downloads](http://poser.pugx.org/hds-solutions/laravel-api-helpers/downloads)](https://packagist.org/packages/hds-solutions/laravel-api-helpers) +[![License](http://poser.pugx.org/hds-solutions/laravel-api-helpers/license)](https://packagist.org/packages/hds-solutions/laravel-api-helpers) +[![PHP Version Require](http://poser.pugx.org/hds-solutions/laravel-api-helpers/require/php)](https://packagist.org/packages/hds-solutions/laravel-api-helpers) + +## Features + +- Easy management of request query filters for filtering resource collections based on allowed columns. +- Simplified sorting of resource collections based on allowed columns. +- Convenient loading of extra relationships for resource collections. +- Pagination support for resource collections. + +## Installation + +### Dependencies + +- PHP >= 8.0 +- Laravel Framework >= 9.0 + +### Via composer + +```bash +composer require hds-solutions/laravel-api-helpers +``` + +## Usage + +To make use of the library, you will need to create specific classes that extend the provided abstract classes. +The provided classes contain the implementation of the necessary logic for each feature (filtering, sorting, relationship loading, and pagination). + +### ResourceFilters + +The `ResourceFilters` class manages the query filters for resource collections. +It allows you to define the allowed columns and their corresponding filter operators. + +In the extended class, you can define the list of allowed columns that can be used for filtering, along with their allowed operators. + +The available operators are: + +- `eq`: Translates to a `field_name = "value"` filter. +- `ne`: Translates to a `field_name != "value"` filter. +- `has`: Translates to a `field_name LIKE "%value%"` filter. +- `lt`: Translates to a `field_name < "value"` filter. +- `lte`: Translates to a `field_name <= "value"` filter. +- `gt`: Translates to a `field_name > "value"` filter. +- `gte`: Translates to a `field_name >= "value"` filter. +- `in`: Translates to a `field_name IN ("value1", "value2", ...)` filter. + +Operators are also grouped by field type: + +- `string`: Translates to the operators `eq`, `ne` and `has`. +- `numeric`: Translates to the operators `eq`, `ne`, `lt`, `lte`, `gt`, `gte` and `in`. +- `boolean`: Translates to the operators `eq` and `ne`. +- `date`: Translates to the operators `eq`, `ne`, `lt`, `lte`, `gt`, and `gte`. + +#### Example implementation + +You just need to extend the `ResourceFilters` class and define the allowed filtrable columns. + +```php +namespace App\Http\Filters; + +class CountryFilters extends \HDSSolutions\Laravel\API\ResourceFilters { + + protected array $allowed_columns = [ + 'name' => 'string', + 'code' => 'string', + 'something' => [ 'gt', 'lt' ], + ]; + +} +``` + +You can also override the default filtering implementation of a column by defining a method with the same name as the filtrable column. +The method **must** have the following arguments: + +- `Illuminate\Database\Eloquent\Builder`: The current instance of the query builder. +- `string`: The operator requested for filtering. +- `mixed`: The value of the filter. + +```php +namespace App\Http\Filters; + +use Illuminate\Database\Eloquent\Builder; + +class CountryFilters extends \HDSSolutions\Laravel\API\ResourceFilters { + + protected array $allowed_columns = [ + 'name' => 'string', + 'code' => 'string', + 'something' => [ 'gt', 'lt' ], + 'regions_count' => 'number', + ]; + + protected function regionsCount(Builder $query, string $operator, $value): void { + return $query->whereHas('regions', operator: $operator, count: $value); + } + +} +``` + +#### Example requests + +- Filtering by country name: + + ```http request + GET https://localhost/api/countries?name[has]=aus + Accept: application/json + ``` + Example response: + ```json + { + "data": [ + { + "id": 123, + "name": "Country name", + ... + }, + { ... }, + { ... }, + { ... }, + ... + ], + "links": { + ... + } + "meta": { + ... + } + } + ``` + +- Filtering by countries that have more than N regions: + + ```http request + GET https://localhost/api/countries?regions_count[gte]=15 + Accept: application/json + ``` + Example response: + ```json + { + "data": [ + { + "id": 123, + "name": "Country name", + ... + }, + { ... }, + { ... }, + { ... }, + ... + ], + "links": { + ... + } + "meta": { + ... + } + } + ``` + +### ResourceOrders + +The `ResourceOrders` class manages the sorting of resource collections. +It allows you to define the allowed columns to sort the resource collection and a default sorting fields. + +In the extended class, you can define the list of allowed columns that can be used for sorting the resource collection. + +#### Example implementation + +You just need to extend the `ResourceOrders` class and define the allowed sortable columns. + +```php +namespace App/Http/Orders; + +class CountryOrders extends \HDSSolutions\Laravel\API\ResourceOrders { + + protected array $default_order = [ + 'name', + ]; + + protected array $allowed_columns = [ + 'name', + ]; + +} +``` + +You can also override the default sorting implementation of a column by defining a method with the studly version of the sortable column. +The method **must** have the following arguments: + +- `Illuminate\Database\Eloquent\Builder`: The current instance of the query builder. +- `string`: The direction of the sort. + +```php +namespace App/Http/Orders; + +use Illuminate\Database\Eloquent\Builder; + +class CountryOrders extends \HDSSolutions\Laravel\API\ResourceOrders { + + protected array $default_order = [ + 'name', + ]; + + protected array $allowed_columns = [ + 'name', + 'regions_count', + ]; + + protected function regionsCount(Builder $query, string $direction): void { + $query->orderBy('regions_count', direction: $direction); + } + +} +``` + +#### Example requests +The request sorting parameters must follow the following syntax: `order[{index}][{direction}]={field}` + +- Sorting by country name: + + ```http request + GET https://localhost/api/countries?order[0][asc]=name + Accept: application/json + ``` + Example response: + ```json + { + "data": [ + { + "id": 123, + "name": "Country name", + ... + }, + { ... }, + { ... }, + { ... }, + ... + ], + "links": { + ... + } + "meta": { + ... + } + } + ``` + +- Sorting by country name and regions count in descending order: + + ```http request + GET https://localhost/api/countries?order[0][asc]=name&order[1][desc]=regions_count + Accept: application/json + ``` + Example response: + ```json + { + "data": [ + { + "id": 123, + "name": "Country name", + ... + }, + { ... }, + { ... }, + { ... }, + ... + ], + "links": { + ... + } + "meta": { + ... + } + } + ``` + +### ResourceRelations +The `ResourceRelations` class manages the loading of extra relationships for resource collections. +It allows you to specify the allowed relationships to be loaded and the relationships that should always be loaded. + +In the extended class, you can define the list of allowed relationships that can be added to the resource collection. + +#### Example implementation + +```php +namespace App/Http/Relations; + +class CountryRelations extends \HDSSolutions\Laravel\API\ResourceRelations { + + protected array $with_count = [ + 'regions', + ]; + + protected array $allowed_relations = [ + 'regions', + ]; + +} +``` + +You can also capture the loaded relationship to add filters, sorting, or any action that you need. +The method **must** have the following arguments: + +- `Illuminate\Database\Eloquent\Relations\Relation`: The instance of the relationship being loaded. + +```php +namespace App/Http/Relations; + +class CountryRelations extends \HDSSolutions\Laravel\API\ResourceRelations { + + protected array $with_count = [ + 'regions', + ]; + + protected array $allowed_relations = [ + 'regions', + ]; + + protected function regions(Relation $regions): void { + $regions->where('active', true); + } + +} +``` + +#### Example requests +- Loading countries with their regions relationship collection: + + ```http request + GET https://localhost/api/countries?with[]=regions + Accept: application/json + ``` + Example response: + ```json + { + "data": [ + { + "id": 123, + "name": "Country name", + "regions_count": 5, + "regions": [ + { ... }, + { ... }, + { ... }, + ... + ] + }, + { ... }, + { ... }, + { ... }, + ... + ], + "links": { + ... + } + "meta": { + ... + } + } + ``` + +### PaginateResults +The `PaginateResults` class handles the pagination of resource collections. +It provides support for paginating the results or retrieving all records. + +#### Example requests +- Request all countries: + ```http + GET https://localhost/api/countries?all=true + Accept: application/json + ``` + Example response: + ```json + { + "data": [ + { + "id": 123, + "name": "Country name", + "regions_count": 5 + }, + { ... }, + { ... }, + { ... }, + { ... }, + { ... }, + { ... }, + { ... }, + { ... }, + { ... }, + { ... }, + { ... } + ] + } + ``` + +### Controller implementation +Here is an example of a controller using the `Pipeline` facade to implement all the previous features. + +```php +namespace App/Http/Controllers/Api; + +use App\Models\Country; + +use App\Http\Filters; +use App\Http\Relations; +use App\Http\Orders; + +use HDSSolutions\Laravel\API\Actions\PaginateResults; + +use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Http\Resources\Json\ResourceCollection; +use Illuminate\Routing\Controller; +use Illuminate\Support\Facades\Pipeline; + +class CountryController extends Controller { + + public function index(Request $request): ResourceCollection { + return new ResourceCollection( + Pipeline::send(Country::query()) + ->through([ + Filters\CountryFilters::class, + Relations\CountryRelations::class, + Orders\CountryOrders::class, + PaginateResults::class, + ]) + ->thenReturn() + ); + } + + public function show(Request $request, int $country_id): JsonResource { + return new Resource( + Pipeline::send(Country::where('id', $country_id)) + ->through([ + Relations\CountryRelations::class, + ]) + ->thenReturn() + ->firstOrFail() + ) + ); + } + +} +``` + +## More request examples +```http request +GET https://localhost/api/regions +Accept: application/json +``` +Example response: +```json +{ + "data": [ + { + "id": 5, + "name": "Argentina", + "code": "AR", + "regions_count": 24 + }, + { + "id": 1, + "name": "Canada", + "code": "CA", + "regions_count": 13 + }, + { + "id": 3, + "name": "Germany", + "code": "DE", + "regions_count": 16 + }, + ... + ], + "links": { + "first": "https://localhost/api/regions?page=1", + "last": "https://localhost/api/regions?page=13", + "prev": null, + "next": "https://localhost/api/regions?page=2" + }, + "meta": { + "current_page": 1, + "from": 1, + "last_page": 13, + "links": [ + { + "url": null, + "label": "« Previous", + "active": false + }, + { + "url": "https://localhost/api/regions?page=1", + "label": "1", + "active": true + }, + { + "url": "https://localhost/api/regions?page=2", + "label": "2", + "active": false + }, + { + "url": null, + "label": "...", + "active": false + }, + { + "url": "https://localhost/api/regions?page=12", + "label": "12", + "active": false + }, + { + "url": "https://localhost/api/regions?page=13", + "label": "13", + "active": false + }, + { + "url": "https://localhost/api/regions?page=2", + "label": "Next »", + "active": false + } + ], + "path": "https://localhost/api/regions", + "per_page": 15, + "to": 15, + "total": 195 + } +} +``` + +```http request +GET https://localhost/api/regions?name[has]=aus +Accept: application/json +``` +Example response: +```json +{ + "data": [ + { + "id": 34, + "name": "Australia", + "code": "AU", + "regions_count": 8 + }, + { + "id": 12, + "name": "Austria", + "code": "AT", + "regions_count": 9 + } + ], + "links": { + ... + }, + "meta": { + ... + } +} +``` + +```http request +GET https://localhost/api/regions?regions_count[gt]=15&order[][desc]=name +Accept: application/json +``` +Example response: +```json +{ + "data": [ + ... + { + "id": 3, + "name": "Germany", + "code": "DE", + "regions_count": 16 + }, + { + "id": 5, + "name": "Argentina", + "code": "AR", + "regions_count": 24 + }, + ... + ], + "links": { + ... + }, + "meta": { + ... + } +} +``` + +## Extras +### ResourceRequest +The `ResourceRequest` class has the following features: + +- The `hash()` method gives you a unique identifier based on the query parameters. +- The `authorize()` method is a WIP feature that will handle resource access authorization. + +### Caching requests +You can use the `hash()` method of the `ResourceRequest` class and use it as a cache key. The parameter `cache` is ignored and not used to build the request identifier. + +In the following example, we capture the `cache` request parameter to force the cache to be cleared. + +```php +namespace App/Http/Controllers/Api; + +use App\Models\Country; + +use App\Http\Filters; +use App\Http\Relations; +use App\Http\Orders; + +use HDSSolutions\Laravel\API\Actions\PaginateResults; +use HDSSolutions\Laravel\API\ResourceRequest; + +use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Http\Resources\Json\ResourceCollection; +use Illuminate\Routing\Controller; +use Illuminate\Support\Facades\Pipeline; + +class CountryController extends Controller { + + public function index(ResourceRequest $request): JsonResponse | ResourceCollection { + // forget cached data if is requested + if ($request->boolean('cache', true) === false) { + cache()->forget($request->hash(__METHOD__)); + } + + // remember data for 8 hours, using request unique hash as cache key + return cache()->remember( + key: $request->hash(__METHOD__), + ttl: new DateInterval('PT8H'), + callback: fn() => (new ResourceCollection($request, + Pipeline::send(Country::query()) + ->through([ + Filters\CountryFilters::class, + Relations\CountryRelations::class, + Orders\CountryOrders::class, + PaginateResults::class, + ]) + ->thenReturn() + ) + )->response($request) + ); + } + + public function show(Request $request, int $country_id): JsonResponse | JsonResource { + if ($request->boolean('cache', true) === false) { + cache()->forget($request->hash(__METHOD__)); + } + + return cache()->remember( + key: $request->hash(__METHOD__), + ttl: new DateInterval('PT8H'), + callback: fn() => (new Resource( + Pipeline::send(Model::where('id', $country_id)) + ->through([ + Relations\CountryRelations::class, + ]) + ->thenReturn() + ->firstOrFail() + ) + )->response($request) + ); + } + +} +``` + +# Security Vulnerabilities +If you encounter any security-related issues, please feel free to raise a ticket on the issue tracker. + +# Contributing +Contributions are welcome! If you find any issues or would like to add new features or improvements, please feel free to submit a pull request. + +## Contributors +- [Hermann D. Schimpf](https://hds-solutions.net) + +# Licence +This library is open-source software licensed under the [GPL-3.0 License](LICENSE). +Please see the [License File](LICENSE) for more information.