diff --git a/composer.json b/composer.json index 4d173b3..6f96d1b 100644 --- a/composer.json +++ b/composer.json @@ -3,6 +3,7 @@ "description": "A fully automated JSON:API server implementation in PHP.", "require": { "php": ">=7.1", + "ext-json": "*", "doctrine/inflector": "^1.3", "json-api-php/json-api": "^2.2", "nyholm/psr7": "^1.3", diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index ca9e6eb..6db451c 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -50,6 +50,7 @@ module.exports = { collapsable: false, children: [ 'errors', + 'extensions', 'laravel', ] } diff --git a/docs/.vuepress/styles/palette.styl b/docs/.vuepress/styles/palette.styl new file mode 100644 index 0000000..b42f4e6 --- /dev/null +++ b/docs/.vuepress/styles/palette.styl @@ -0,0 +1 @@ +$accentColor = #0000ff diff --git a/docs/adapters.md b/docs/adapters.md index f1f2068..6a90873 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -7,7 +7,7 @@ You'll need to supply an adapter for each [resource type](https://jsonapi.org/fo ```php use Tobyz\JsonApiServer\Schema\Type; -$api->resource('users', $adapter, function (Type $type) { +$api->resourceType('users', $adapter, function (Type $type) { // define your schema }); ``` @@ -26,10 +26,10 @@ $adapter = new EloquentAdapter(User::class); When using the Eloquent Adapter, the `$model` passed around in the schema will be an instance of the given model, and the `$query` will be a `Illuminate\Database\Eloquent\Builder` instance querying the model's table: ```php -$type->scope(function (Builder $query) { }); +$type->scope(function (Builder $query) {}); $type->attribute('name') - ->get(function (User $user) { }); + ->get(function (User $user) {}); ``` ### Custom Adapters diff --git a/docs/create.md b/docs/create.md index 4c57266..861ea84 100644 --- a/docs/create.md +++ b/docs/create.md @@ -24,22 +24,22 @@ $type->newModel(function (Context $context) { ## Events -### `onCreating` +### `creating` -Run before the model is saved. +Run after values have been set on the model, but before it is saved. ```php -$type->onCreating(function (&$model, Context $context) { +$type->creating(function (&$model, Context $context) { // do something }); ``` -### `onCreated` +### `created` -Run after the model is saved. +Run after the model is saved, and before it is shown in a JSON:API document. ```php -$type->onCreated(function (&$model, Context $context) { +$type->created(function (&$model, Context $context) { $context->meta('foo', 'bar'); }); ``` diff --git a/docs/delete.md b/docs/delete.md index ad7a61b..8750ee0 100644 --- a/docs/delete.md +++ b/docs/delete.md @@ -14,22 +14,22 @@ $type->deletable(function (Context $context) { ## Events -### `onDeleting` +### `deleting` Run before the model is deleted. ```php -$type->onDeleting(function (&$model, Context $context) { +$type->deleting(function (&$model, Context $context) { // do something }); ``` -### `onDeleted` +### `deleted` Run after the model is deleted. ```php -$type->onDeleted(function (&$model, Context $context) { +$type->deleted(function (&$model, Context $context) { // do something }); ``` diff --git a/docs/extensions.md b/docs/extensions.md new file mode 100644 index 0000000..7f87584 --- /dev/null +++ b/docs/extensions.md @@ -0,0 +1,81 @@ +# Extensions + +[Extensions](https://jsonapi.org/format/1.1/#extensions) allow your API to support additional functionality that is not part of the base specification. + +## Defining Extensions + +Extensions can be defined by extending the `Tobyz\JsonApiServer\Extension\Extension` class and implementing two methods: `uri` and `process`. + +You must return your extension's unique URI from `uri`. + +For every request that includes your extension in the media type, the `handle` method will be called. If your extension is able to handle the request, it should return a PSR-7 response. Otherwise, return null to let the normal handling of the request take place. + +```php +use Tobyz\JsonApiServer\Extension\Extension; +use Psr\Http\Message\ResponseInterface; + +use function Tobyz\JsonApiServer\json_api_response; + +class MyExtension extends Extension +{ + public function uri(): string + { + return 'https://example.org/my-extension'; + } + + public function handle(Context $context): ?ResponseInterface; + { + if ($context->getPath() === '/my-extension') { + return json_api_response([ + 'my-extension:greeting' => 'Hello world!' + ]); + } + + return null; + } +} +``` + +::: warning +The current implementation of extensions has no support for augmentation of standard API responses. This API may change dramatically in the future. Please [create an issue](https://github.com/tobyzerner/json-api-server/issues/new) if you have a specific use-case you want to achieve. +::: + +## Registering Extensions + +Extensions can be registered on your `JsonApi` instance using the `extension` method: + +```php +use Tobyz\JsonApiServer\JsonApi; + +$api = new JsonApi('/api'); + +$api->extension(new MyExtension()); +``` + +The `JsonApi` class will automatically perform appropriate [content negotiation](https://jsonapi.org/format/1.1/#content-negotiation-servers) and activate the specified extensions on each request. + +## Atomic Operations + +An implementation of the [Atomic Operations](https://jsonapi.org/ext/atomic/) extension is available at `Tobyz\JsonApi\Extension\Atomic`. + +When using this extension, you are responsible for wrapping the `$api->handle` call in a transaction to ensure any database (or other) operations performed are actually atomic in nature. For example, in Laravel: + +```php +use Illuminate\Support\Facades\DB; +use Tobyz\JsonApiServer\Extension\Atomic; +use Tobyz\JsonApiServer\JsonApi; + +$api = new JsonApi('/api'); + +$api->extension(new Atomic()); + +/** @var Psr\Http\Message\ServerRequestInterface $request */ +/** @var Psr\Http\Message\ResponseInterface $response */ +try { + return DB::transaction(function () use ($api, $request) { + return $api->handle($request); + }); +} catch (Exception $e) { + $response = $api->error($e); +} +``` diff --git a/docs/filtering.md b/docs/filtering.md index 0d57bf7..c7f53f5 100644 --- a/docs/filtering.md +++ b/docs/filtering.md @@ -23,7 +23,7 @@ GET /users?filter[postCount]=5..15 ## Custom Filters -To define filters with custom logic, or ones that do not correspond to an attribute, use the `filter` method: +To define filters with custom logic, or ones that do not correspond to a field, use the `filter` method: ```php $type->filter('minPosts', function ($query, $value, Context $context) { @@ -34,7 +34,7 @@ $type->filter('minPosts', function ($query, $value, Context $context) { Just like [fields](visibility.md), filters can be made conditionally `visible` or `hidden`: ```php -$type->filter('email', $callback) +$type->filter('minPosts', $callback) ->visible(function (Context $context) { return $context->getRequest()->getAttribute('isAdmin'); }); diff --git a/docs/index.md b/docs/index.md index a52f524..e1508cc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,7 @@ json-api-server is a [JSON:API](http://jsonapi.org) server implementation in PHP. -It allows you to define your API's schema, and then use an [adapter](adapters.md) to connect it to your application's models and database layer, without having to worry about any of the server boilerplate, routing, query parameters, or JSON:API document formatting. +It allows you to define your API's schema, and then use an [adapter](adapters.md) to connect it to your application's database layer. You don't have to worry about any of the server boilerplate, routing, query parameters, or JSON:API document formatting. Based on your schema definition, the package will serve a **complete JSON:API that conforms to the [spec](https://jsonapi.org/format/)**, including support for: @@ -15,7 +15,7 @@ Based on your schema definition, the package will serve a **complete JSON:API th - **Deleting** resources (`DELETE /api/articles/1`) - **Error handling** -The schema definition is extremely powerful and lets you easily apply [permissions](visibility.md), [transformations](writing.md#transformers), [validation](writing.md#validation), and custom [filtering](filtering.md) and [sorting](sorting.md) logic to build a fully functional API in minutes. +The schema definition is extremely powerful and lets you easily apply [permissions](visibility.md), [transformations](writing.md#transformers), [validation](writing.md#validation), and custom [filtering](filtering.md) and [sorting](sorting.md) logic to build a fully functional API with ease. ### Example @@ -25,17 +25,18 @@ The following example uses Eloquent models in a Laravel application. However, js use App\Models\{Article, Comment, User}; use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\Schema\Type; -use Tobyz\JsonApiServer\Laravel\EloquentAdapter; +use Tobyz\JsonApiServer\Adapter\EloquentAdapter; use Tobyz\JsonApiServer\Laravel; $api = new JsonApi('http://example.com/api'); -$api->resource('articles', new EloquentAdapter(Article::class), function (Type $type) { +$api->resourceType('articles', new EloquentAdapter(Article::class), function (Type $type) { $type->attribute('title') ->writable() ->validate(Laravel\rules('required')); - $type->hasOne('author')->type('users') + $type->hasOne('author') + ->type('users') ->includable() ->filterable(); @@ -43,7 +44,7 @@ $api->resource('articles', new EloquentAdapter(Article::class), function (Type $ ->includable(); }); -$api->resource('comments', new EloquentAdapter(Comment::class), function (Type $type) { +$api->resourceType('comments', new EloquentAdapter(Comment::class), function (Type $type) { $type->creatable(Laravel\authenticated()); $type->updatable(Laravel\can('update-comment')); $type->deletable(Laravel\can('delete-comment')); @@ -56,12 +57,13 @@ $api->resource('comments', new EloquentAdapter(Comment::class), function (Type $ ->writable()->once() ->validate(Laravel\rules('required')); - $type->hasOne('author')->type('users') + $type->hasOne('author') + ->type('users') ->writable()->once() ->validate(Laravel\rules('required')); }); -$api->resource('users', new EloquentAdapter(User::class), function (Type $type) { +$api->resourceType('users', new EloquentAdapter(User::class), function (Type $type) { $type->attribute('firstName')->sortable(); $type->attribute('lastName')->sortable(); }); diff --git a/docs/laravel.md b/docs/laravel.md index 5e96349..daaee8a 100644 --- a/docs/laravel.md +++ b/docs/laravel.md @@ -1,5 +1,7 @@ # Laravel Helpers +These helpers improve the ergonomics of your API resource definitions when using the Laravel framework. + ## Validation ### `rules` @@ -10,10 +12,20 @@ Use Laravel's [Validation component](https://laravel.com/docs/8.x/validation) as use Tobyz\JsonApiServer\Laravel; $type->attribute('name') - ->validate(Laravel\rules('required|min:3|max:20')); + ->validate(Laravel\rules(['required', 'min:3', 'max:20'])); ``` -Pass a string or array of validation rules to be applied to the value. You can also pass an array of custom messages and custom attribute names as the second and third arguments. +Pass a string or array of validation rules to be applied to the value. Validating array contents is also supported: + +```php +$type->attribute('jobs') + ->validate(Laravel\rules([ + 'required', 'array', + '*' => ['string', 'min:3', 'max:255'] + ])); +``` + +You can also pass an array of custom messages and custom attribute names as the second and third arguments. ## Authentication diff --git a/docs/list.md b/docs/list.md index 306edb0..87e07de 100644 --- a/docs/list.md +++ b/docs/list.md @@ -14,22 +14,22 @@ $type->listable(function (Context $context) { ## Events -### `onListing` +### `listing` Run before [scopes](scopes.md) are applied to the `$query` and results are retrieved. ```php -$type->onListing(function ($query, Context $context) { +$type->listing(function ($query, Context $context) { // do something }); ``` -### `onListed` +### `listed` Run after models and relationships have been retrieved, but before they are serialized into a JSON:API document. ```php -$type->onListed(function ($models, Context $context) { +$type->listed(function ($models, Context $context) { // do something }); ``` diff --git a/docs/meta.md b/docs/meta.md index 61a3e0b..325a51d 100644 --- a/docs/meta.md +++ b/docs/meta.md @@ -6,10 +6,10 @@ You can add meta information at various levels of the document using the `meta` To add meta information at the top-level of a document, you can call the `meta` method on the `Context` instance which is available inside any of your schema's callbacks. -For example, to add meta information to a resource listing, you might call this inside of an `onListed` listener: +For example, to add meta information to a resource listing, you might call this inside of an `listed` listener: ```php -$type->onListed(function ($models, Context $context) { +$type->listed(function ($models, Context $context) { $context->meta('foo', 'bar'); }); ``` diff --git a/docs/relationships.md b/docs/relationships.md index e6b2ac9..bfb90e3 100644 --- a/docs/relationships.md +++ b/docs/relationships.md @@ -59,14 +59,6 @@ $type->hasOne('users') }); ``` -To prevent a relationship from being eager-loaded, use the `dontLoad` method: - -```php -$type->hasOne('user') - ->includable() - ->dontLoad(); -``` - ## Polymorphic Relationships Define a polymorphic relationship using the `polymorphic` method. Optionally you may provide an array of allowed resource types: @@ -79,10 +71,6 @@ $type->hasMany('taggable') ->polymorphic(['photos', 'videos']); ``` -::: warning -Note that nested includes cannot be requested on polymorphic relationships. -::: - ## Meta Information You can add meta information to a relationship using the `meta` method: diff --git a/docs/requests.md b/docs/requests.md index 55de058..f96a96d 100644 --- a/docs/requests.md +++ b/docs/requests.md @@ -31,3 +31,34 @@ Often you will need to access information about the authenticated user inside of ```php $request = $request->withAttribute('user', $user); ``` + +## Context + +An instance of `Tobyz\JsonApi\Context` is passed into callbacks throughout your API's resource definitions – for example, when defining [scopes](scopes): + +```php +use Tobyz\JsonApiServer\Context; + +$type->scope(function ($query, Context $context) { + $user = $context->getRequest()->getAttribute('user'); + + $query->where('user_id', $user?->id); +}); +``` + +This object contains a number of useful methods: + +* `getApi(): Tobyz\JsonApi\JsonApi` + Get the JsonApi instance. + +* `getRequest(): Psr\Http\Message\ServerRequestInterface` + Get the PSR-7 request instance. + +* `getPath(): string` + Get the request path relative to the API's base path. + +* `fieldRequested(string $type, string $field, bool $default = true): bool` + Determine whether a field has been requested in a [sparse fieldset](https://jsonapi.org/format/1.1/#fetching-sparse-fieldsets). + +* `meta(string $name, $value): Tobyz\JsonApi\Schema\Meta` + Add a meta attribute to the response document. diff --git a/docs/show.md b/docs/show.md index ce30121..a542027 100644 --- a/docs/show.md +++ b/docs/show.md @@ -4,12 +4,12 @@ For each resource type, a `GET /{type}/{id}` endpoint is exposed to show an indi ## Events -### `onShow` +### `show` -Run after models and relationships have been retrieved, but before they are serialized into a JSON:API document. +Run after the model has been retrieved, but before it is serialized into a JSON:API document. ```php -$type->onShow(function (&$model, Context $context) { +$type->show(function (&$model, Context $context) { // do something }); ``` diff --git a/docs/sorting.md b/docs/sorting.md index 391b256..fd84665 100644 --- a/docs/sorting.md +++ b/docs/sorting.md @@ -18,6 +18,8 @@ You can set a default sort string to be used when the consumer has not supplied $type->defaultSort('-updatedAt,-createdAt'); ``` +## Custom Sorts + To define sort fields with custom logic, or ones that do not correspond to an attribute, use the `sort` method: ```php @@ -25,3 +27,12 @@ $type->sort('relevance', function ($query, string $direction, Context $context) $query->orderBy('relevance', $direction); }); ``` + +Just like [fields](visibility.md), sorts can be made conditionally `visible` or `hidden`: + +```php +$type->sort('relevance', $callback) + ->visible(function (Context $context) { + return $context->getRequest()->getAttribute('isAdmin'); + }); +``` diff --git a/docs/update.md b/docs/update.md index cba6c3b..cd26bca 100644 --- a/docs/update.md +++ b/docs/update.md @@ -14,22 +14,22 @@ $type->updatable(function (Context $context) { ## Events -### `onUpdating` +### `updating` -Run before the model is saved. +Run after values have been set on the model, but before it is saved. ```php -$type->onUpdating(function (&$model, Context $context) { +$type->updating(function (&$model, Context $context) { // do something }); ``` -### `onUpdated` +### `updated` -Run after the model is saved. +Run after the model is saved, and before it is shown in a JSON:API document. ```php -$type->onUpdated(function (&$model, Context $context) { +$type->updated(function (&$model, Context $context) { // do something }); ``` diff --git a/docs/writing.md b/docs/writing.md index f9aacf2..9c709b4 100644 --- a/docs/writing.md +++ b/docs/writing.md @@ -114,13 +114,13 @@ $type->attribute('locale') ## Events -### `onSaved` +### `saved` Run after a field has been successfully saved. ```php $type->attribute('email') - ->onSaved(function ($value, $model, Context $context) { + ->saved(function ($value, $model, Context $context) { event(new EmailWasChanged($model)); }); ``` diff --git a/src/Adapter/AdapterInterface.php b/src/Adapter/AdapterInterface.php index decdcde..c82a2c3 100644 --- a/src/Adapter/AdapterInterface.php +++ b/src/Adapter/AdapterInterface.php @@ -13,9 +13,11 @@ namespace Tobyz\JsonApiServer\Adapter; use Closure; use Tobyz\JsonApiServer\Context; +use Tobyz\JsonApiServer\Deferred; use Tobyz\JsonApiServer\Schema\Attribute; use Tobyz\JsonApiServer\Schema\HasMany; use Tobyz\JsonApiServer\Schema\HasOne; +use Tobyz\JsonApiServer\Schema\Relationship; interface AdapterInterface { @@ -26,17 +28,11 @@ interface AdapterInterface * or list a resource index. It will be passed around through the relevant * scopes, filters, and sorting methods before finally being passed into * the `find` or `get` methods. - * - * @return mixed */ - public function newQuery(Context $context); + public function query(); /** * Manipulate the query to only include resources with the given IDs. - * - * @param $query - * @param array $ids - * @return mixed */ public function filterByIds($query, array $ids): void; @@ -44,195 +40,109 @@ interface AdapterInterface * Manipulate the query to only include resources with a certain attribute * value. * - * @param $query - * @param Attribute $attribute - * @param $value * @param string $operator The operator to use for comparison: = < > <= >= - * @return mixed */ public function filterByAttribute($query, Attribute $attribute, $value, string $operator = '='): void; /** - * Manipulate the query to only include resources with a has-one - * relationship within the given scope. - * - * @param $query - * @param HasOne $relationship - * @param Closure $scope - * @return mixed + * Manipulate the query to only include resources with a relationship within + * the given scope. */ - public function filterByHasOne($query, HasOne $relationship, Closure $scope): void; - - /** - * Manipulate the query to only include resources with a has-many - * relationship within the given scope. - * - * @param $query - * @param HasMany $relationship - * @param Closure $scope - * @return mixed - */ - public function filterByHasMany($query, HasMany $relationship, Closure $scope): void; + public function filterByRelationship($query, Relationship $relationship, Closure $scope): void; /** * Manipulate the query to sort by the given attribute in the given direction. - * - * @param $query - * @param Attribute $attribute - * @param string $direction - * @return mixed */ public function sortByAttribute($query, Attribute $attribute, string $direction): void; /** * Manipulate the query to only include a certain number of results, * starting from the given offset. - * - * @param $query - * @param int $limit - * @param int $offset - * @return mixed */ public function paginate($query, int $limit, int $offset): void; /** * Find a single resource by ID from the query. - * - * @param $query - * @param string $id - * @return mixed */ public function find($query, string $id); /** * Get a list of resources from the query. - * - * @param $query - * @return array */ public function get($query): array; /** * Get the number of results from the query. - * - * @param $query - * @return int */ public function count($query): int; - /** - * Determine whether or not this resource type represents the given model. - * - * This is used for polymorphic relationships, where there are one or many - * related models of unknown type. The first resource type with an adapter - * that responds positively from this method will be used. - * - * @param mixed $model - * @return bool - */ - public function represents($model): bool; - - /** - * Create a new model instance. - * - * @return mixed - */ - public function newModel(); - /** * Get the ID from the model. - * - * @param $model - * @return string */ public function getId($model): string; /** * Get the value of an attribute from the model. * - * @param $model - * @param Attribute $attribute - * @return mixed + * @return mixed|Deferred */ public function getAttribute($model, Attribute $attribute); /** * Get the model for a has-one relationship for the model. * - * @param $model - * @param HasOne $relationship - * @param bool $linkage - * @return mixed|null + * @return mixed|null|Deferred */ - public function getHasOne($model, HasOne $relationship, bool $linkage); + public function getHasOne($model, HasOne $relationship, bool $linkage, Context $context); /** * Get a list of models for a has-many relationship for the model. * - * @param $model - * @param HasMany $relationship - * @param bool $linkage - * @return array + * @return array|Deferred */ - public function getHasMany($model, HasMany $relationship, bool $linkage): array; + public function getHasMany($model, HasMany $relationship, bool $linkage, Context $context); + + /** + * Determine whether this resource type represents the given model. + * + * This is used for polymorphic relationships, where there are one or many + * related models of unknown type. The first resource type with an adapter + * that responds positively from this method will be used. + */ + public function represents($model): bool; + + /** + * Create a new model instance. + */ + public function model(); + + /** + * Apply a user-generated ID to the model. + */ + public function setId($model, string $id): void; /** * Apply an attribute value to the model. - * - * @param $model - * @param Attribute $attribute - * @param $value - * @return mixed */ public function setAttribute($model, Attribute $attribute, $value): void; /** * Apply a has-one relationship value to the model. - * - * @param $model - * @param HasOne $relationship - * @param $related - * @return mixed */ public function setHasOne($model, HasOne $relationship, $related): void; /** * Save the model. - * - * @param $model - * @return mixed */ public function save($model): void; /** * Save a has-many relationship for the model. - * - * @param $model - * @param HasMany $relationship - * @param array $related - * @return mixed */ public function saveHasMany($model, HasMany $relationship, array $related): void; /** * Delete the model. - * - * @param $model - * @return mixed */ public function delete($model): void; - - /** - * Load information about related resources onto a collection of models. - * - * @param array $models - * @param array $relationships - * @param array|Closure $scope Should be called to give the deepest - * relationship an opportunity to scope the query that will fetch related - * resources - * @param bool $linkage true if we just need the IDs of the related - * resources and not their full data - * @return mixed - */ - public function load(array $models, array $relationships, $scope, bool $linkage): void; } diff --git a/src/Adapter/EloquentAdapter.php b/src/Adapter/EloquentAdapter.php index 508dbdd..60c1f22 100644 --- a/src/Adapter/EloquentAdapter.php +++ b/src/Adapter/EloquentAdapter.php @@ -20,6 +20,7 @@ use Illuminate\Database\Eloquent\Relations\HasOneThrough; use Illuminate\Database\Eloquent\Relations\MorphOneOrMany; use InvalidArgumentException; use Tobyz\JsonApiServer\Context; +use Tobyz\JsonApiServer\Deferred; use Tobyz\JsonApiServer\Schema\Attribute; use Tobyz\JsonApiServer\Schema\HasMany; use Tobyz\JsonApiServer\Schema\HasOne; @@ -41,21 +42,36 @@ class EloquentAdapter implements AdapterInterface } } - public function represents($model): bool - { - return $model instanceof $this->model; - } - - public function newModel() - { - return $this->model->newInstance(); - } - - public function newQuery(Context $context) + public function query() { return $this->model->query(); } + public function filterByIds($query, array $ids): void + { + $query->whereIn($query->getModel()->getQualifiedKeyName(), $ids); + } + + public function filterByAttribute($query, Attribute $attribute, $value, string $operator = '='): void + { + $query->where($this->getAttributeProperty($attribute), $operator, $value); + } + + public function filterByRelationship($query, Relationship $relationship, Closure $scope): void + { + $query->whereHas($this->getRelationshipProperty($relationship), $scope); + } + + public function sortByAttribute($query, Attribute $attribute, string $direction): void + { + $query->orderBy($this->getAttributeProperty($attribute), $direction); + } + + public function paginate($query, int $limit, int $offset): void + { + $query->take($limit)->skip($offset); + } + public function find($query, string $id) { return $query->find($id); @@ -78,54 +94,82 @@ class EloquentAdapter implements AdapterInterface public function getAttribute($model, Attribute $attribute) { - return $model->{$this->getAttributeProperty($attribute)}; + return $model->getAttribute($this->getAttributeProperty($attribute)); } - public function getHasOne($model, HasOne $relationship, bool $linkage) + public function getHasOne($model, HasOne $relationship, bool $linkage, Context $context) { - // If it's a belongs-to relationship and we only need to get the ID, - // then we don't have to actually load the relation because the ID is - // stored in a column directly on the model. We will mock up a related - // model with the value of the ID filled. + // If this is a belongs-to relationship, and we only need to get the ID + // for linkage, then we don't have to actually load the relation because + // the ID is stored in a column directly on the model. We will mock up a + // related model with the value of the ID filled. if ($linkage) { $relation = $this->getEloquentRelation($model, $relationship); if ($relation instanceof BelongsTo) { - if ($key = $model->{$relation->getForeignKeyName()}) { + if ($key = $model->getAttribute($relation->getForeignKeyName())) { $related = $relation->getRelated(); - return $related->newInstance()->forceFill([$related->getKeyName() => $key]); + return $related->newInstance()->forceFill([ + $related->getKeyName() => $key + ]); } return null; } } - return $this->getRelationValue($model, $relationship); + return $this->getRelationship($model, $relationship, $context); } - public function getHasMany($model, HasMany $relationship, bool $linkage): array + public function getHasMany($model, HasMany $relationship, bool $linkage, Context $context) { - $collection = $this->getRelationValue($model, $relationship); + return $this->getRelationship($model, $relationship, $context); + } - return $collection ? $collection->all() : []; + protected function getRelationship($model, Relationship $relationship, Context $context): Deferred + { + $name = $this->getRelationshipProperty($relationship); + + EloquentBuffer::add($model, $name); + + return new Deferred(function () use ($model, $name, $relationship, $context) { + EloquentBuffer::load($model, $name, $relationship, $context); + + $data = $model->getRelation($name); + + return $data instanceof Collection ? $data->all() : $data; + }); + } + + public function represents($model): bool + { + return $model instanceof $this->model; + } + + public function model() + { + return $this->model->newInstance(); + } + + public function setId($model, string $id): void + { + $model->setAttribute($model->getKeyName(), $id); } public function setAttribute($model, Attribute $attribute, $value): void { - $model->{$this->getAttributeProperty($attribute)} = $value; + $model->setAttribute($this->getAttributeProperty($attribute), $value); } public function setHasOne($model, HasOne $relationship, $related): void { $relation = $this->getEloquentRelation($model, $relationship); + // If this is a belongs-to relationship, then the ID is stored on the + // model itself so we can set it here. if ($relation instanceof BelongsTo) { - if ($related === null) { - $relation->dissociate(); - } else { - $relation->associate($related); - } + $relation->associate($related); } } @@ -151,101 +195,18 @@ class EloquentAdapter implements AdapterInterface $model->forceDelete(); } - public function filterByIds($query, array $ids): void - { - $key = $query->getModel()->getQualifiedKeyName(); - - $query->whereIn($key, $ids); - } - - public function filterByAttribute($query, Attribute $attribute, $value, string $operator = '='): void - { - $column = $this->getAttributeColumn($attribute); - - $query->where($column, $operator, $value); - } - - public function filterByHasOne($query, HasOne $relationship, Closure $scope): void - { - $this->filterByRelationship($query, $relationship, $scope); - } - - public function filterByHasMany($query, HasMany $relationship, Closure $scope): void - { - $this->filterByRelationship($query, $relationship, $scope); - } - - private function filterByRelationship($query, Relationship $relationship, Closure $scope): void - { - $property = $this->getRelationshipProperty($relationship); - - $query->whereHas($property, $scope); - } - - public function sortByAttribute($query, Attribute $attribute, string $direction): void - { - $query->orderBy($this->getAttributeColumn($attribute), $direction); - } - - public function paginate($query, int $limit, int $offset): void - { - $query->take($limit)->skip($offset); - } - - public function load(array $models, array $relationships, $scope, bool $linkage): void - { - // TODO: Find the relation on the model that we're after. If it's a - // belongs-to relation, and we only need linkage, then we won't need - // to load anything as the related ID is store directly on the model. - - (new Collection($models))->loadMissing([ - $this->getRelationshipPath($relationships) => function ($relation) use ($scope) { - $query = $relation->getQuery(); - - if (is_array($scope)) { - foreach ($scope as $v) { - // Requires Laravel 8.15+ - $adapter = $v['resource']->getAdapter(); - if ($adapter instanceof self && method_exists($relation, 'constrain')) { - $relation->constrain([ - get_class($adapter->newModel()) => $v['scope'] - ]); - } - } - } else { - $scope($query); - } - } - ]); - } - private function getAttributeProperty(Attribute $attribute): string { return $attribute->getProperty() ?: strtolower(preg_replace('/(?getName())); } - private function getAttributeColumn(Attribute $attribute): string - { - return $this->getAttributeProperty($attribute); - } - private function getRelationshipProperty(Relationship $relationship): string { return $relationship->getProperty() ?: $relationship->getName(); } - private function getRelationshipPath(array $trail): string - { - return implode('.', array_map([$this, 'getRelationshipProperty'], $trail)); - } - private function getEloquentRelation($model, Relationship $relationship) { return $model->{$this->getRelationshipProperty($relationship)}(); } - - private function getRelationValue($model, Relationship $relationship) - { - return $model->{$this->getRelationshipProperty($relationship)}; - } } diff --git a/src/Adapter/EloquentBuffer.php b/src/Adapter/EloquentBuffer.php new file mode 100644 index 0000000..f4a2101 --- /dev/null +++ b/src/Adapter/EloquentBuffer.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobyz\JsonApiServer\Adapter; + +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\MorphTo; +use Tobyz\JsonApiServer\Context; +use Tobyz\JsonApiServer\Schema\Relationship; + +use function Tobyz\JsonApiServer\run_callbacks; + +abstract class EloquentBuffer +{ + private static $buffer = []; + + public static function add(Model $model, string $relation): void + { + static::$buffer[get_class($model)][$relation][] = $model; + } + + public static function load(Model $model, string $relation, Relationship $relationship, Context $context): void + { + if (! $models = static::$buffer[get_class($model)][$relation] ?? null) { + return; + } + + Collection::make($models)->loadMissing([ + $relation => function ($query) use ($model, $relation, $relationship, $context) { + // As we're loading the relationship, we need to scope the query + // using the scopes defined in the related API resources. We + // start by getting the resource types this relationship + // could contain. + $resourceTypes = $context->getApi()->getResourceTypes(); + + if ($type = $relationship->getType()) { + if (is_string($type)) { + $resourceTypes = [$resourceTypes[$type]]; + } else { + $resourceTypes = array_intersect_key($resourceTypes, array_flip($type)); + } + } + + $constrain = []; + + foreach ($resourceTypes as $resourceType) { + if ($model = $resourceType->getAdapter()->model()) { + $constrain[get_class($model)] = function ($query) use ($resourceType, $context) { + run_callbacks( + $resourceType->getSchema()->getListeners('scope'), + [$query, $context] + ); + }; + } + } + + if ($query instanceof MorphTo) { + $query->constrain($constrain); + } else { + reset($constrain)($query->getQuery()); + } + + // Also apply relationship scopes to the query. + run_callbacks( + $relationship->getListeners('scope'), + [$query->getQuery(), $context] + ); + } + ]); + + static::$buffer[get_class($model)][$relation] = []; + } +} diff --git a/src/Adapter/NullAdapter.php b/src/Adapter/NullAdapter.php new file mode 100644 index 0000000..cc88daf --- /dev/null +++ b/src/Adapter/NullAdapter.php @@ -0,0 +1,101 @@ +api = $api; $this->request = $request; } + public function getApi(): JsonApi + { + return $this->api; + } + public function getRequest(): ServerRequestInterface { return $this->request; } + public function withRequest(ServerRequestInterface $request): Context + { + return new static($this->api, $request); + } + + public function getPath(): string + { + return $this->api->stripBasePath( + $this->request->getUri()->getPath() + ); + } + public function response(callable $callback): void { $this->listeners['response'][] = $callback; diff --git a/src/Deferred.php b/src/Deferred.php new file mode 100644 index 0000000..7b890ae --- /dev/null +++ b/src/Deferred.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobyz\JsonApiServer; + +use Closure; + +class Deferred +{ + private $callback; + + public function __construct(Closure $callback) + { + $this->callback = $callback; + } + + public function resolve() + { + return ($this->callback)(); + } +} diff --git a/src/Endpoint/Concerns/FindsResources.php b/src/Endpoint/Concerns/FindsResources.php index 9200e5f..7921012 100644 --- a/src/Endpoint/Concerns/FindsResources.php +++ b/src/Endpoint/Concerns/FindsResources.php @@ -23,17 +23,17 @@ trait FindsResources * * @throws ResourceNotFoundException if the resource is not found. */ - private function findResource(ResourceType $resource, string $id, Context $context) + private function findResource(ResourceType $resourceType, string $id, Context $context) { - $adapter = $resource->getAdapter(); - $query = $adapter->newQuery($context); + $adapter = $resourceType->getAdapter(); + $query = $adapter->query(); - run_callbacks($resource->getSchema()->getListeners('scope'), [$query, $context]); + run_callbacks($resourceType->getSchema()->getListeners('scope'), [$query, $context]); $model = $adapter->find($query, $id); if (! $model) { - throw new ResourceNotFoundException($resource->getType(), $id); + throw new ResourceNotFoundException($resourceType->getType(), $id); } return $model; diff --git a/src/Endpoint/Concerns/IncludesData.php b/src/Endpoint/Concerns/IncludesData.php index 538c846..8e72f33 100644 --- a/src/Endpoint/Concerns/IncludesData.php +++ b/src/Endpoint/Concerns/IncludesData.php @@ -11,27 +11,21 @@ namespace Tobyz\JsonApiServer\Endpoint\Concerns; -use Tobyz\JsonApiServer\Exception\BadRequestException; -use Tobyz\JsonApiServer\JsonApi; -use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\Context; +use Tobyz\JsonApiServer\Exception\BadRequestException; +use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\Schema\Relationship; -use function Tobyz\JsonApiServer\run_callbacks; -/** - * @property JsonApi $api - * @property ResourceType $resource - */ trait IncludesData { - private function getInclude(Context $context): array + private function getInclude(Context $context, ResourceType $resourceType): array { $queryParams = $context->getRequest()->getQueryParams(); if (! empty($queryParams['include'])) { $include = $this->parseInclude($queryParams['include']); - $this->validateInclude([$this->resource], $include); + $this->validateInclude($context, [$resourceType], $include); return $include; } @@ -39,11 +33,11 @@ trait IncludesData return []; } - private function parseInclude(string $include): array + private function parseInclude($include): array { $tree = []; - foreach (explode(',', $include) as $path) { + foreach (is_array($include) ? $include : explode(',', $include) as $path) { $array = &$tree; foreach (explode('.', $path) as $key) { @@ -58,10 +52,10 @@ trait IncludesData return $tree; } - private function validateInclude(array $resources, array $include, string $path = '') + private function validateInclude(Context $context, array $resourceTypes, array $include, string $path = '') { foreach ($include as $name => $nested) { - foreach ($resources as $resource) { + foreach ($resourceTypes as $resource) { $fields = $resource->getSchema()->getFields(); if ( @@ -75,85 +69,21 @@ trait IncludesData $type = $fields[$name]->getType(); if (is_string($type)) { - $relatedResource = $this->api->getResource($type); + $relatedResource = $context->getApi()->getResourceType($type); - $this->validateInclude([$relatedResource], $nested, $name.'.'); + $this->validateInclude($context, [$relatedResource], $nested, $name.'.'); } else { - $relatedResources = is_array($type) ? array_map(function ($type) { - return $this->api->getResource($type); - }, $type) : array_values($this->api->getResources()); + $relatedResources = is_array($type) ? array_map(function ($type) use ($context) { + return $context->getApi()->getResourceType($type); + }, $type) : array_values($context->getApi()->getResourceTypes()); - $this->validateInclude($relatedResources, $nested, $name.'.'); + $this->validateInclude($context, $relatedResources, $nested, $name.'.'); } continue 2; } - throw new BadRequestException("Invalid include [{$path}{$name}]", 'include'); - } - } - - private function loadRelationships(array $models, array $include, Context $context) - { - $this->loadRelationshipsAtLevel($models, [], $this->resource, $include, $context); - } - - private function loadRelationshipsAtLevel(array $models, array $relationshipPath, ResourceType $resource, array $include, Context $context) - { - $adapter = $resource->getAdapter(); - $schema = $resource->getSchema(); - $fields = $schema->getFields(); - - foreach ($fields as $name => $field) { - if ( - ! $field instanceof Relationship - || (! $field->hasLinkage() && ! isset($include[$name])) - || $field->getVisible() === false - ) { - continue; - } - - $nextRelationshipPath = array_merge($relationshipPath, [$field]); - - if ($field->shouldLoad()) { - $type = $field->getType(); - - if (is_string($type)) { - $relatedResource = $this->api->getResource($type); - $scope = function ($query) use ($context, $field, $relatedResource) { - run_callbacks($relatedResource->getSchema()->getListeners('scope'), [$query, $context]); - run_callbacks($field->getListeners('scope'), [$query, $context]); - }; - } else { - $relatedResources = is_array($type) ? array_map(function ($type) { - return $this->api->getResource($type); - }, $type) : $this->api->getResources(); - - $scope = array_combine( - array_map(function ($relatedResource) { - return $relatedResource->getType(); - }, $relatedResources), - - array_map(function ($relatedResource) use ($context, $field) { - return [ - 'resource' => $relatedResource, - 'scope' => function ($query) use ($context, $field, $relatedResource) { - run_callbacks($relatedResource->getSchema()->getListeners('scope'), [$query, $context]); - run_callbacks($field->getListeners('scope'), [$query, $context]); - } - ]; - }, $relatedResources) - ); - } - - $adapter->load($models, $nextRelationshipPath, $scope, $field->hasLinkage()); - - if (isset($include[$name]) && is_string($type)) { - $relatedResource = $this->api->getResource($type); - - $this->loadRelationshipsAtLevel($models, $nextRelationshipPath, $relatedResource, $include[$name] ?? [], $context); - } - } + throw (new BadRequestException("Invalid include [{$path}{$name}]"))->setSourceParameter('include'); } } } diff --git a/src/Endpoint/Concerns/SavesData.php b/src/Endpoint/Concerns/SavesData.php index 86df7e8..61e3668 100644 --- a/src/Endpoint/Concerns/SavesData.php +++ b/src/Endpoint/Concerns/SavesData.php @@ -11,25 +11,21 @@ namespace Tobyz\JsonApiServer\Endpoint\Concerns; -use Psr\Http\Message\ServerRequestInterface; +use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\UnprocessableEntityException; use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\Schema\Attribute; -use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Schema\HasMany; use Tobyz\JsonApiServer\Schema\HasOne; use Tobyz\JsonApiServer\Schema\Relationship; + use function Tobyz\JsonApiServer\evaluate; use function Tobyz\JsonApiServer\get_value; use function Tobyz\JsonApiServer\has_value; use function Tobyz\JsonApiServer\run_callbacks; use function Tobyz\JsonApiServer\set_value; -/** - * @property JsonApi $api - * @property ResourceType $resource - */ trait SavesData { use FindsResources; @@ -39,7 +35,7 @@ trait SavesData * * @throws BadRequestException if the `data` member is invalid. */ - private function parseData($body, $model = null): array + private function parseData(ResourceType $resourceType, $body, $model = null): array { $body = (array) $body; @@ -47,12 +43,12 @@ trait SavesData throw new BadRequestException('data must be an object'); } - if (! isset($body['data']['type']) || $body['data']['type'] !== $this->resource->getType()) { + if (! isset($body['data']['type']) || $body['data']['type'] !== $resourceType->getType()) { throw new BadRequestException('data.type does not match the resource type'); } if ($model) { - $id = $this->resource->getAdapter()->getId($model); + $id = $resourceType->getAdapter()->getId($model); if (! isset($body['data']['id']) || $body['data']['id'] !== $id) { throw new BadRequestException('data.id does not match the resource ID'); @@ -92,18 +88,18 @@ trait SavesData throw new BadRequestException("type [{$identifier['type']}] not allowed"); } - $resource = $this->api->getResource($identifier['type']); + $resourceType = $context->getApi()->getResourceType($identifier['type']); - return $this->findResource($resource, $identifier['id'], $context); + return $this->findResource($resourceType, $identifier['id'], $context); } /** * Assert that the fields contained within a data object are valid. */ - private function validateFields(array $data, $model, Context $context) + private function validateFields(ResourceType $resourceType, array $data, $model, Context $context) { - $this->assertFieldsExist($data); - $this->assertFieldsWritable($data, $model, $context); + $this->assertFieldsExist($resourceType, $data); + $this->assertFieldsWritable($resourceType, $data, $model, $context); } /** @@ -111,9 +107,9 @@ trait SavesData * * @throws BadRequestException if a field is unknown. */ - private function assertFieldsExist(array $data) + private function assertFieldsExist(ResourceType $resourceType, array $data) { - $fields = $this->resource->getSchema()->getFields(); + $fields = $resourceType->getSchema()->getFields(); foreach (['attributes', 'relationships'] as $location) { foreach ($data[$location] as $name => $value) { @@ -129,9 +125,9 @@ trait SavesData * * @throws BadRequestException if a field is not writable. */ - private function assertFieldsWritable(array $data, $model, Context $context) + private function assertFieldsWritable(ResourceType $resourceType, array $data, $model, Context $context) { - foreach ($this->resource->getSchema()->getFields() as $field) { + foreach ($resourceType->getSchema()->getFields() as $field) { if (! has_value($data, $field)) { continue; } @@ -151,9 +147,9 @@ trait SavesData /** * Replace relationship linkage within a data object with models. */ - private function loadRelatedResources(array &$data, Context $context) + private function loadRelatedResources(ResourceType $resourceType, array &$data, Context $context) { - foreach ($this->resource->getSchema()->getFields() as $field) { + foreach ($resourceType->getSchema()->getFields() as $field) { if (! $field instanceof Relationship || ! has_value($data, $field)) { continue; } @@ -181,11 +177,11 @@ trait SavesData * * @throws UnprocessableEntityException if any fields do not pass validation. */ - private function assertDataValid(array $data, $model, Context $context, bool $validateAll): void + private function assertDataValid(ResourceType $resourceType, array $data, $model, Context $context, bool $validateAll): void { $failures = []; - foreach ($this->resource->getSchema()->getFields() as $field) { + foreach ($resourceType->getSchema()->getFields() as $field) { if (! $validateAll && ! has_value($data, $field)) { continue; } @@ -208,11 +204,11 @@ trait SavesData /** * Set field values from a data object to the model instance. */ - private function setValues(array $data, $model, Context $context) + private function setValues(ResourceType $resourceType, array $data, $model, Context $context) { - $adapter = $this->resource->getAdapter(); + $adapter = $resourceType->getAdapter(); - foreach ($this->resource->getSchema()->getFields() as $field) { + foreach ($resourceType->getSchema()->getFields() as $field) { if (! has_value($data, $field)) { continue; } @@ -239,32 +235,32 @@ trait SavesData /** * Save the model and its fields. */ - private function save(array $data, $model, Context $context) + private function save(ResourceType $resourceType, array $data, $model, Context $context) { - $this->saveModel($model, $context); - $this->saveFields($data, $model, $context); + $this->saveModel($resourceType, $model, $context); + $this->saveFields($resourceType, $data, $model, $context); } /** * Save the model. */ - private function saveModel($model, Context $context) + private function saveModel(ResourceType $resourceType, $model, Context $context) { - if ($saveCallback = $this->resource->getSchema()->getSaveCallback()) { + if ($saveCallback = $resourceType->getSchema()->getSaveCallback()) { $saveCallback($model, $context); } else { - $this->resource->getAdapter()->save($model); + $resourceType->getAdapter()->save($model); } } /** * Save any fields that were not saved with the model. */ - private function saveFields(array $data, $model, Context $context) + private function saveFields(ResourceType $resourceType, array $data, $model, Context $context) { - $adapter = $this->resource->getAdapter(); + $adapter = $resourceType->getAdapter(); - foreach ($this->resource->getSchema()->getFields() as $field) { + foreach ($resourceType->getSchema()->getFields() as $field) { if (! has_value($data, $field)) { continue; } @@ -278,16 +274,16 @@ trait SavesData } } - $this->runSavedCallbacks($data, $model, $context); + $this->runSavedCallbacks($resourceType, $data, $model, $context); } /** * Run field saved listeners. */ - private function runSavedCallbacks(array $data, $model, Context $context) + private function runSavedCallbacks(ResourceType $resourceType, array $data, $model, Context $context) { - foreach ($this->resource->getSchema()->getFields() as $field) { + foreach ($resourceType->getSchema()->getFields() as $field) { if (! has_value($data, $field)) { continue; } diff --git a/src/Endpoint/Create.php b/src/Endpoint/Create.php index 3f7603c..fe8df6e 100644 --- a/src/Endpoint/Create.php +++ b/src/Endpoint/Create.php @@ -14,8 +14,8 @@ namespace Tobyz\JsonApiServer\Endpoint; use Psr\Http\Message\ResponseInterface; use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Exception\ForbiddenException; -use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\ResourceType; + use function Tobyz\JsonApiServer\evaluate; use function Tobyz\JsonApiServer\has_value; use function Tobyz\JsonApiServer\run_callbacks; @@ -25,59 +25,49 @@ class Create { use Concerns\SavesData; - private $api; - private $resource; - - public function __construct(JsonApi $api, ResourceType $resource) - { - $this->api = $api; - $this->resource = $resource; - } - /** * @throws ForbiddenException if the resource is not creatable. */ - public function handle(Context $context): ResponseInterface + public function handle(Context $context, ResourceType $resourceType): ResponseInterface { - $schema = $this->resource->getSchema(); + $schema = $resourceType->getSchema(); if (! evaluate($schema->isCreatable(), [$context])) { - throw new ForbiddenException; + throw new ForbiddenException(); } - $model = $this->newModel($context); - $data = $this->parseData($context->getRequest()->getParsedBody()); + $model = $this->newModel($resourceType, $context); + $data = $this->parseData($resourceType, $context->getRequest()->getParsedBody()); - $this->validateFields($data, $model, $context); - $this->fillDefaultValues($data, $context); - $this->loadRelatedResources($data, $context); - $this->assertDataValid($data, $model, $context, true); - $this->setValues($data, $model, $context); + $this->validateFields($resourceType, $data, $model, $context); + $this->fillDefaultValues($resourceType, $data, $context); + $this->loadRelatedResources($resourceType, $data, $context); + $this->assertDataValid($resourceType, $data, $model, $context, true); + $this->setValues($resourceType, $data, $model, $context); run_callbacks($schema->getListeners('creating'), [&$model, $context]); - $this->save($data, $model, $context); + $this->save($resourceType, $data, $model, $context); run_callbacks($schema->getListeners('created'), [&$model, $context]); - return (new Show($this->api, $this->resource, $model)) - ->handle($context) + return (new Show()) + ->handle($context, $resourceType, $model) ->withStatus(201); } - private function newModel(Context $context) + private function newModel(ResourceType $resourceType, Context $context) { - $resource = $this->resource; - $newModel = $resource->getSchema()->getNewModelCallback(); + $newModel = $resourceType->getSchema()->getNewModelCallback(); return $newModel ? $newModel($context) - : $resource->getAdapter()->newModel(); + : $resourceType->getAdapter()->model(); } - private function fillDefaultValues(array &$data, Context $context) + private function fillDefaultValues(ResourceType $resourceType, array &$data, Context $context) { - foreach ($this->resource->getSchema()->getFields() as $field) { + foreach ($resourceType->getSchema()->getFields() as $field) { if (! has_value($data, $field) && ($defaultCallback = $field->getDefaultCallback())) { set_value($data, $field, $defaultCallback($context)); } diff --git a/src/Endpoint/Delete.php b/src/Endpoint/Delete.php index 3dfdd8a..fe71629 100644 --- a/src/Endpoint/Delete.php +++ b/src/Endpoint/Delete.php @@ -13,46 +13,35 @@ namespace Tobyz\JsonApiServer\Endpoint; use Nyholm\Psr7\Response; use Psr\Http\Message\ResponseInterface; -use Tobyz\JsonApiServer\Exception\ForbiddenException; -use Tobyz\JsonApiServer\JsonApi; -use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\Context; +use Tobyz\JsonApiServer\Exception\ForbiddenException; +use Tobyz\JsonApiServer\ResourceType; + use function Tobyz\JsonApiServer\evaluate; use function Tobyz\JsonApiServer\run_callbacks; class Delete { - private $api; - private $resource; - private $model; - - public function __construct(JsonApi $api, ResourceType $resource, $model) - { - $this->api = $api; - $this->resource = $resource; - $this->model = $model; - } - /** * @throws ForbiddenException if the resource is not deletable. */ - public function handle(Context $context): ResponseInterface + public function handle(Context $context, ResourceType $resourceType, $model): ResponseInterface { - $schema = $this->resource->getSchema(); + $schema = $resourceType->getSchema(); - if (! evaluate($schema->isDeletable(), [$this->model, $context])) { - throw new ForbiddenException; + if (! evaluate($schema->isDeletable(), [$model, $context])) { + throw new ForbiddenException(); } - run_callbacks($schema->getListeners('deleting'), [&$this->model, $context]); + run_callbacks($schema->getListeners('deleting'), [&$model, $context]); if ($deleteCallback = $schema->getDeleteCallback()) { - $deleteCallback($this->model, $context); + $deleteCallback($model, $context); } else { - $this->resource->getAdapter()->delete($this->model); + $resourceType->getAdapter()->delete($model); } - run_callbacks($schema->getListeners('deleted'), [&$this->model, $context]); + run_callbacks($schema->getListeners('deleted'), [&$model, $context]); return new Response(204); } diff --git a/src/Endpoint/Index.php b/src/Endpoint/Index.php index 4e18e87..75d46e2 100644 --- a/src/Endpoint/Index.php +++ b/src/Endpoint/Index.php @@ -17,17 +17,15 @@ use JsonApiPhp\JsonApi\Link\NextLink; use JsonApiPhp\JsonApi\Link\PrevLink; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface as Request; -use ReflectionClass; use Tobyz\JsonApiServer\Adapter\AdapterInterface; use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\ForbiddenException; -use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\Schema\Attribute; use Tobyz\JsonApiServer\Schema\Meta; -use Tobyz\JsonApiServer\Schema\Relationship; use Tobyz\JsonApiServer\Serializer; + use function Tobyz\JsonApiServer\evaluate; use function Tobyz\JsonApiServer\json_api_response; use function Tobyz\JsonApiServer\run_callbacks; @@ -36,38 +34,29 @@ class Index { use Concerns\IncludesData; - private $api; - private $resource; - - public function __construct(JsonApi $api, ResourceType $resource) - { - $this->api = $api; - $this->resource = $resource; - } - /** * Handle a request to show a resource listing. */ - public function handle(Context $context): ResponseInterface + public function handle(Context $context, ResourceType $resourceType): ResponseInterface { - $adapter = $this->resource->getAdapter(); - $schema = $this->resource->getSchema(); + $adapter = $resourceType->getAdapter(); + $schema = $resourceType->getSchema(); if (! evaluate($schema->isListable(), [$context])) { - throw new ForbiddenException; + throw new ForbiddenException(); } - $query = $adapter->newQuery($context); + $query = $adapter->query(); - $this->resource->scope($query, $context); + $resourceType->scope($query, $context); - $include = $this->getInclude($context); + $include = $this->getInclude($context, $resourceType); - [$offset, $limit] = $this->paginate($query, $context); - $this->sort($query, $context); + [$offset, $limit] = $this->paginate($resourceType, $query, $context); + $this->sort($resourceType, $query, $context); if ($filter = $context->getRequest()->getQueryParams()['filter'] ?? null) { - $this->filter($this->resource, $query, $filter, $context); + $resourceType->filter($query, $filter, $context); } run_callbacks($schema->getListeners('listing'), [$query, $context]); @@ -75,16 +64,16 @@ class Index $total = $schema->isCountable() ? $adapter->count($query) : null; $models = $adapter->get($query); - $this->loadRelationships($models, $include, $context); - run_callbacks($schema->getListeners('listed'), [$models, $context]); - $serializer = new Serializer($this->api, $context); + $serializer = new Serializer($context); foreach ($models as $model) { - $serializer->add($this->resource, $model, $include); + $serializer->add($resourceType, $model, $include); } + [$primary, $included] = $serializer->serialize(); + $meta = array_values(array_map(function (Meta $meta) use ($context) { return new Structure\Meta($meta->getName(), $meta->getValue()($context)); }, $context->getMeta())); @@ -92,10 +81,10 @@ class Index return json_api_response( new Structure\CompoundDocument( new Structure\PaginatedCollection( - new Structure\Pagination(...$this->buildPaginationLinks($context->getRequest(), $offset, $limit, count($models), $total)), - new Structure\ResourceCollection(...$serializer->primary()) + new Structure\Pagination(...$this->buildPaginationLinks($resourceType, $context->getRequest(), $offset, $limit, count($models), $total)), + new Structure\ResourceCollection(...$primary) ), - new Structure\Included(...$serializer->included()), + new Structure\Included(...$included), new Structure\Link\SelfLink($this->buildUrl($context->getRequest())), new Structure\Meta('offset', $offset), new Structure\Meta('limit', $limit), @@ -126,10 +115,10 @@ class Index return $selfUrl.($queryString ? '?'.$queryString : ''); } - private function buildPaginationLinks(Request $request, int $offset, ?int $limit, int $count, ?int $total): array + private function buildPaginationLinks(ResourceType $resourceType, Request $request, int $offset, ?int $limit, int $count, ?int $total): array { $paginationLinks = []; - $schema = $this->resource->getSchema(); + $schema = $resourceType->getSchema(); if ($offset > 0) { $paginationLinks[] = new Structure\Link\FirstLink($this->buildUrl($request, ['page' => ['offset' => 0]])); @@ -156,21 +145,21 @@ class Index return $paginationLinks; } - private function sort($query, Context $context): void + private function sort(ResourceType $resourceType, $query, Context $context): void { - $schema = $this->resource->getSchema(); + $schema = $resourceType->getSchema(); if (! $sort = $context->getRequest()->getQueryParams()['sort'] ?? $schema->getDefaultSort()) { return; } - $adapter = $this->resource->getAdapter(); - $sortFields = $schema->getSortFields(); + $adapter = $resourceType->getAdapter(); + $sorts = $schema->getSorts(); $fields = $schema->getFields(); foreach ($this->parseSort($sort) as $name => $direction) { - if (isset($sortFields[$name])) { - $sortFields[$name]($query, $direction, $context); + if (isset($sorts[$name]) && evaluate($sorts[$name]->getVisible(), [$context])) { + $sorts[$name]->getCallback()($query, $direction, $context); continue; } @@ -183,7 +172,7 @@ class Index continue; } - throw new BadRequestException("Invalid sort field [$name]", 'sort'); + throw (new BadRequestException("Invalid sort field [$name]"))->setSourceParameter('sort'); } } @@ -205,9 +194,9 @@ class Index return $sort; } - private function paginate($query, Context $context): array + private function paginate(ResourceType $resourceType, $query, Context $context): array { - $schema = $this->resource->getSchema(); + $schema = $resourceType->getSchema(); $queryParams = $context->getRequest()->getQueryParams(); $limit = $schema->getPerPage(); @@ -215,7 +204,7 @@ class Index $limit = $queryParams['page']['limit']; if (! ctype_digit(strval($limit)) || $limit < 1) { - throw new BadRequestException('page[limit] must be a positive integer', 'page[limit]'); + throw (new BadRequestException('page[limit] must be a positive integer'))->setSourceParameter('page[limit]'); } $limit = min($schema->getLimit(), $limit); @@ -227,82 +216,14 @@ class Index $offset = $queryParams['page']['offset']; if (! ctype_digit(strval($offset)) || $offset < 0) { - throw new BadRequestException('page[offset] must be a non-negative integer', 'page[offset]'); + throw (new BadRequestException('page[offset] must be a non-negative integer'))->setSourceParameter('page[offset]'); } } if ($limit || $offset) { - $this->resource->getAdapter()->paginate($query, $limit, $offset); + $resourceType->getAdapter()->paginate($query, $limit, $offset); } return [$offset, $limit]; } - - private function filter(ResourceType $resource, $query, $filter, Context $context): void - { - if (! is_array($filter)) { - throw new BadRequestException('filter must be an array', 'filter'); - } - - $schema = $resource->getSchema(); - $adapter = $resource->getAdapter(); - $filters = $schema->getFilters(); - $fields = $schema->getFields(); - - foreach ($filter as $name => $value) { - if ($name === 'id') { - $adapter->filterByIds($query, explode(',', $value)); - continue; - } - - if (isset($filters[$name]) && evaluate($filters[$name]->getVisible(), [$context])) { - $filters[$name]->getCallback()($query, $value, $context); - continue; - } - - [$name, $sub] = explode('.', $name, 2) + [null, null]; - - if (isset($fields[$name]) && evaluate($fields[$name]->getFilterable(), [$context])) { - if ($fields[$name] instanceof Attribute && $sub === null) { - $this->filterByAttribute($adapter, $query, $fields[$name], $value); - continue; - } elseif ($fields[$name] instanceof Relationship) { - if (is_string($relatedType = $fields[$name]->getType())) { - $relatedResource = $this->api->getResource($relatedType); - $method = 'filterBy'.(new ReflectionClass($fields[$name]))->getShortName(); - $adapter->$method($query, $fields[$name], function ($query) use ($relatedResource, $sub, $value, $context) { - $this->filter($relatedResource, $query, [($sub ?? 'id') => $value], $context); - }); - } - continue; - } - } - - throw new BadRequestException("Invalid filter [$name]", "filter[$name]"); - } - } - - private function filterByAttribute(AdapterInterface $adapter, $query, Attribute $attribute, $value): void - { - if (preg_match('/(.+)\.\.(.+)/', $value, $matches)) { - if ($matches[1] !== '*') { - $adapter->filterByAttribute($query, $attribute, $value, '>='); - } - if ($matches[2] !== '*') { - $adapter->filterByAttribute($query, $attribute, $value, '<='); - } - - return; - } - - foreach (['>=', '>', '<=', '<'] as $operator) { - if (strpos($value, $operator) === 0) { - $adapter->filterByAttribute($query, $attribute, substr($value, strlen($operator)), $operator); - - return; - } - } - - $adapter->filterByAttribute($query, $attribute, $value); - } } diff --git a/src/Endpoint/Show.php b/src/Endpoint/Show.php index 9f209ca..a4cf9c0 100644 --- a/src/Endpoint/Show.php +++ b/src/Endpoint/Show.php @@ -14,10 +14,10 @@ namespace Tobyz\JsonApiServer\Endpoint; use JsonApiPhp\JsonApi\CompoundDocument; use JsonApiPhp\JsonApi\Included; use Psr\Http\Message\ResponseInterface; -use Tobyz\JsonApiServer\JsonApi; -use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\Context; +use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\Serializer; + use function Tobyz\JsonApiServer\json_api_response; use function Tobyz\JsonApiServer\run_callbacks; @@ -25,34 +25,21 @@ class Show { use Concerns\IncludesData; - private $api; - private $resource; - private $model; - - public function __construct(JsonApi $api, ResourceType $resource, $model) + public function handle(Context $context, ResourceType $resourceType, $model): ResponseInterface { - $this->api = $api; - $this->resource = $resource; - $this->model = $model; - } + run_callbacks($resourceType->getSchema()->getListeners('show'), [&$model, $context]); - public function handle(Context $context): ResponseInterface - { - run_callbacks($this->resource->getSchema()->getListeners('showing'), [&$this->model, $context]); + $include = $this->getInclude($context, $resourceType); - $include = $this->getInclude($context); + $serializer = new Serializer($context); + $serializer->add($resourceType, $model, $include); - $this->loadRelationships([$this->model], $include, $context); - - run_callbacks($this->resource->getSchema()->getListeners('shown'), [&$this->model, $context]); - - $serializer = new Serializer($this->api, $context); - $serializer->add($this->resource, $this->model, $include); + [$primary, $included] = $serializer->serialize(); return json_api_response( new CompoundDocument( - $serializer->primary()[0], - new Included(...$serializer->included()) + $primary[0], + new Included(...$included) ) ); } diff --git a/src/Endpoint/Update.php b/src/Endpoint/Update.php index 7445f49..931444a 100644 --- a/src/Endpoint/Update.php +++ b/src/Endpoint/Update.php @@ -12,10 +12,10 @@ namespace Tobyz\JsonApiServer\Endpoint; use Psr\Http\Message\ResponseInterface; -use Tobyz\JsonApiServer\Exception\ForbiddenException; -use Tobyz\JsonApiServer\JsonApi; -use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\Context; +use Tobyz\JsonApiServer\Exception\ForbiddenException; +use Tobyz\JsonApiServer\ResourceType; + use function Tobyz\JsonApiServer\evaluate; use function Tobyz\JsonApiServer\run_callbacks; @@ -23,45 +23,31 @@ class Update { use Concerns\SavesData; - private $api; - private $resource; - private $model; - - public function __construct(JsonApi $api, ResourceType $resource, $model) - { - $this->api = $api; - $this->resource = $resource; - $this->model = $model; - } - /** * @throws ForbiddenException if the resource is not updatable. */ - public function handle(Context $context): ResponseInterface + public function handle(Context $context, ResourceType $resourceType, $model): ResponseInterface { - $schema = $this->resource->getSchema(); + $schema = $resourceType->getSchema(); - if (! evaluate($schema->isUpdatable(), [$this->model, $context])) { - throw new ForbiddenException; + if (! evaluate($schema->isUpdatable(), [$model, $context])) { + throw new ForbiddenException(); } - $data = $this->parseData($context->getRequest()->getParsedBody(), $this->model); + $data = $this->parseData($resourceType, $context->getRequest()->getParsedBody(), $model); - $this->validateFields($data, $this->model, $context); - $this->loadRelatedResources($data, $context); - $this->assertDataValid($data, $this->model, $context, false); - $this->setValues($data, $this->model, $context); + $this->validateFields($resourceType, $data, $model, $context); + $this->loadRelatedResources($resourceType, $data, $context); + $this->assertDataValid($resourceType, $data, $model, $context, false); + $this->setValues($resourceType, $data, $model, $context); - run_callbacks($schema->getListeners('updating'), [&$this->model, $context]); + run_callbacks($schema->getListeners('updating'), [&$model, $context]); - $this->save($data, $this->model, $context); + $this->save($resourceType, $data, $model, $context); - run_callbacks($schema->getListeners('updated'), [&$this->model, $context]); + run_callbacks($schema->getListeners('updated'), [&$model, $context]); - $adapter = $this->resource->getAdapter(); - $freshModel = $this->findResource($this->resource, $adapter->getId($this->model), $context); - - return (new Show($this->api, $this->resource, $freshModel)) - ->handle($context); + return (new Show()) + ->handle($context, $resourceType, $model); } } diff --git a/src/Exception/BadRequestException.php b/src/Exception/BadRequestException.php index c249cf5..2717acb 100644 --- a/src/Exception/BadRequestException.php +++ b/src/Exception/BadRequestException.php @@ -17,16 +17,23 @@ use Tobyz\JsonApiServer\ErrorProviderInterface; class BadRequestException extends DomainException implements ErrorProviderInterface { - /** - * @var string - */ - private $sourceParameter; + private $sourceType; + private $source; - public function __construct(string $message = '', string $sourceParameter = '') + public function setSourceParameter(string $parameter) { - parent::__construct($message); + $this->sourceType = 'parameter'; + $this->source = $parameter; - $this->sourceParameter = $sourceParameter; + return $this; + } + + public function setSourcePointer(string $pointer) + { + $this->sourceType = 'pointer'; + $this->source = $pointer; + + return $this; } public function getJsonApiErrors(): array @@ -37,8 +44,10 @@ class BadRequestException extends DomainException implements ErrorProviderInterf $members[] = new Error\Detail($this->message); } - if ($this->sourceParameter) { - $members[] = new Error\SourceParameter($this->sourceParameter); + if ($this->sourceType === 'parameter') { + $members[] = new Error\SourceParameter($this->source); + } elseif ($this->sourceType === 'pointer') { + $members[] = new Error\SourcePointer($this->source); } return [ diff --git a/src/Extension/Atomic.php b/src/Extension/Atomic.php new file mode 100644 index 0000000..f6d4549 --- /dev/null +++ b/src/Extension/Atomic.php @@ -0,0 +1,184 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobyz\JsonApiServer\Extension; + +use Nyholm\Psr7\Uri; +use Psr\Http\Message\ResponseInterface as Response; +use Tobyz\JsonApiServer\Context; +use Tobyz\JsonApiServer\Endpoint; +use Tobyz\JsonApiServer\Endpoint\Concerns\FindsResources; +use Tobyz\JsonApiServer\Exception\BadRequestException; +use Tobyz\JsonApiServer\Exception\MethodNotAllowedException; +use Tobyz\JsonApiServer\Exception\NotImplementedException; + +use function Tobyz\JsonApiServer\json_api_response; + +final class Atomic extends Extension +{ + use FindsResources; + + private $path; + + public function __construct(string $path = 'operations') + { + $this->path = $path; + } + + public function uri(): string + { + return 'https://jsonapi.org/ext/atomic'; + } + + public function handle(Context $context): ?Response + { + if ($context->getPath() !== '/operations') { + return null; + } + + $request = $context->getRequest(); + + if ($request->getMethod() !== 'POST') { + throw new MethodNotAllowedException(); + } + + $body = $request->getParsedBody(); + $operations = $body['atomic:operations'] ?? null; + + if (! is_array($operations)) { + throw new BadRequestException('atomic:operations must be an array of operation objects'); + } + + $results = []; + $lids = []; + + foreach ($operations as $i => $operation) { + switch ($operation['op'] ?? null) { + case 'add': + $response = $this->add($context, $operation, $lids); + break; + + case 'update': + $response = $this->update($context, $operation, $lids); + break; + + case 'remove': + $response = $this->remove($context, $operation, $lids); + break; + + default: + throw (new BadRequestException('Invalid operation'))->setSourcePointer("/atomic:operations/$i"); + } + + $results[] = json_decode($response->getBody(), true); + } + + return json_api_response( + ['atomic:results' => $results] + ); + } + + private function add(Context $context, array $operation, array &$lids): Response + { + // TODO: support href and ref + if (isset($operation['href']) || isset($operation['ref'])) { + throw new NotImplementedException('href and ref are not currently supported'); + } + + $type = $operation['data']['type']; + $resourceType = $context->getApi()->getResourceType($type); + + $request = $context->getRequest() + ->withMethod('POST') + ->withUri(new Uri("/$type")) + ->withQueryParams($operation['params'] ?? []) + ->withParsedBody(array_diff_key($this->replaceLids($operation, $lids), ['op', 'href', 'ref', 'params'])); + + $context = $context->withRequest($request); + + $response = (new Endpoint\Create())->handle($context, $resourceType); + + if ($lid = $operation['data']['lid'] ?? null) { + if ($id = json_decode($response->getBody(), true)['data']['id'] ?? null) { + $lids[$lid] = $id; + } + } + + return $response; + } + + private function update(Context $context, array $operation, array $lids): Response + { + // TODO: support href and ref + if (isset($operation['href']) || isset($operation['ref'])) { + throw new NotImplementedException('href and ref are not currently supported'); + } + + $operation = $this->replaceLids($operation, $lids); + $type = $operation['data']['type']; + $id = $operation['data']['id']; + $resourceType = $context->getApi()->getResourceType($type); + + $request = $context->getRequest() + ->withMethod('PATCH') + ->withUri(new Uri("/$type/$id")) + ->withQueryParams($operation['params'] ?? []) + ->withParsedBody(array_diff_key($operation, ['op', 'href', 'ref', 'params'])); + + $context = $context->withRequest($request); + + $model = $this->findResource($resourceType, $id, $context); + + return (new Endpoint\Update())->handle($context, $resourceType, $model); + } + + private function remove(Context $context, array $operation, array $lids): Response + { + // TODO: support href + if (isset($operation['href'])) { + throw new NotImplementedException('href is not currently supported'); + } + + $operation = $this->replaceLids($operation, $lids); + $type = $operation['ref']['type']; + $id = $operation['ref']['id']; + $resourceType = $context->getApi()->getResourceType($type); + + $request = $context->getRequest() + ->withMethod('DELETE') + ->withUri(new Uri("/$type/$id")) + ->withQueryParams($operation['params'] ?? []) + ->withParsedBody(array_diff_key($operation, ['op', 'href', 'ref', 'params'])); + + $context = $context->withRequest($request); + + $model = $this->findResource($resourceType, $id, $context); + + return (new Endpoint\Delete())->handle($context, $resourceType, $model); + } + + private function replaceLids(array &$array, array $lids): array + { + foreach ($array as $k => &$v) { + if ($k === 'lid' && isset($lids[$v])) { + $array['id'] = $lids[$v]; + unset($array['lid']); + continue; + } + + if (is_array($v)) { + $v = $this->replaceLids($v, $lids); + } + } + + return $array; + } +} diff --git a/src/Extension/Extension.php b/src/Extension/Extension.php new file mode 100644 index 0000000..1e887d5 --- /dev/null +++ b/src/Extension/Extension.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobyz\JsonApiServer\Extension; + +use Psr\Http\Message\ResponseInterface as Response; +use Tobyz\JsonApiServer\Context; + +abstract class Extension +{ + /** + * The URI that uniquely identifies this extension. + * + * @see https://jsonapi.org/format/1.1/#media-type-parameter-rules + */ + abstract public function uri(): string; + + /** + * Handle a request. + */ + public function handle(Context $context): ?Response + { + return null; + } +} diff --git a/src/JsonApi.php b/src/JsonApi.php index 78d845f..c04b100 100644 --- a/src/JsonApi.php +++ b/src/JsonApi.php @@ -16,6 +16,7 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface; use Tobyz\JsonApiServer\Adapter\AdapterInterface; +use Tobyz\JsonApiServer\Endpoint\Concerns\FindsResources; use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\InternalServerErrorException; use Tobyz\JsonApiServer\Exception\MethodNotAllowedException; @@ -23,32 +24,50 @@ use Tobyz\JsonApiServer\Exception\NotAcceptableException; use Tobyz\JsonApiServer\Exception\NotImplementedException; use Tobyz\JsonApiServer\Exception\ResourceNotFoundException; use Tobyz\JsonApiServer\Exception\UnsupportedMediaTypeException; -use Tobyz\JsonApiServer\Endpoint\Concerns\FindsResources; -use Tobyz\JsonApiServer\Http\MediaTypes; +use Tobyz\JsonApiServer\Extension\Extension; use Tobyz\JsonApiServer\Schema\Concerns\HasMeta; -use Tobyz\JsonApiServer\Context; final class JsonApi implements RequestHandlerInterface { - const MEDIA_TYPE = 'application/vnd.api+json'; + public const MEDIA_TYPE = 'application/vnd.api+json'; use FindsResources; use HasMeta; - private $resources = []; + /** + * @var string + */ private $basePath; + /** + * @var Extension[] + */ + private $extensions = []; + + /** + * @var ResourceType[] + */ + private $resourceTypes = []; + public function __construct(string $basePath) { $this->basePath = $basePath; } + /** + * Register an extension. + */ + public function extension(Extension $extension) + { + $this->extensions[$extension->uri()] = $extension; + } + /** * Define a new resource type. */ - public function resource(string $type, AdapterInterface $adapter, callable $buildSchema = null): void + public function resourceType(string $type, AdapterInterface $adapter, callable $buildSchema = null): void { - $this->resources[$type] = new ResourceType($type, $adapter, $buildSchema); + $this->resourceTypes[$type] = new ResourceType($type, $adapter, $buildSchema); } /** @@ -56,9 +75,9 @@ final class JsonApi implements RequestHandlerInterface * * @return ResourceType[] */ - public function getResources(): array + public function getResourceTypes(): array { - return $this->resources; + return $this->resourceTypes; } /** @@ -66,13 +85,13 @@ final class JsonApi implements RequestHandlerInterface * * @throws ResourceNotFoundException if the resource type has not been defined. */ - public function getResource(string $type): ResourceType + public function getResourceType(string $type): ResourceType { - if (! isset($this->resources[$type])) { + if (! isset($this->resourceTypes[$type])) { throw new ResourceNotFoundException($type); } - return $this->resources[$type]; + return $this->resourceTypes[$type]; } /** @@ -86,116 +105,130 @@ final class JsonApi implements RequestHandlerInterface */ public function handle(Request $request): Response { - $this->validateRequest($request); + // $this->validateRequest($request); + + $context = new Context($this, $request); + + foreach ($this->extensions as $extension) { + if ($response = $extension->handle($context)) { + return $response; + } + } + + // TODO: apply Vary: Accept header to response $path = $this->stripBasePath( $request->getUri()->getPath() ); $segments = explode('/', trim($path, '/')); - $resource = $this->getResource($segments[0]); - $context = new Context($request); + $resourceType = $this->getResourceType($segments[0]); switch (count($segments)) { case 1: - return $this->handleCollection($context, $resource); + return $this->handleCollection($context, $resourceType); case 2: - return $this->handleResource($context, $resource, $segments[1]); + return $this->handleResource($context, $resourceType, $segments[1]); case 3: - throw new NotImplementedException; + throw new NotImplementedException(); case 4: if ($segments[2] === 'relationships') { - throw new NotImplementedException; + throw new NotImplementedException(); } } - throw new BadRequestException; + throw new BadRequestException(); } private function validateRequest(Request $request): void { - $this->validateRequestContentType($request); - $this->validateRequestAccepts($request); + // TODO + + // split content type + // ensure type is json-api + // ensure no params other than ext/profile + // ensure no ext other than those supported + // return list of ext/profiles to apply + + if ($accept = $request->getHeaderLine('Accept')) { + $types = array_map('trim', explode(',', $accept)); + + foreach ($types as $type) { + $parts = array_map('trim', explode(';', $type)); + } + } + + // if accept present + // split accept, order by qvalue + // for each media type: + // if type is not json-api, continue + // if any params other than ext/profile, continue + // if any ext other than those supported, continue + // return list of ext/profiles to apply + // if none matching, Not Acceptable } - private function validateRequestContentType(Request $request): void - { - $header = $request->getHeaderLine('Content-Type'); + // private function validateRequestContentType(Request $request): void + // { + // $header = $request->getHeaderLine('Content-Type'); + // + // if ((new MediaTypes($header))->containsWithOptionalParameters(self::MEDIA_TYPE, ['ext'])) { + // return; + // } + // + // throw new UnsupportedMediaTypeException; + // } + // + // private function getAcceptedParameters(Request $request): array + // { + // $header = $request->getHeaderLine('Accept'); + // + // if (empty($header)) { + // return []; + // } + // + // $mediaTypes = new MediaTypes($header); + // + // if ($parameters = $mediaTypes->get(self::MEDIA_TYPE, ['ext', 'profile'])) { + // return $parameters; + // } + // + // throw new NotAcceptableException; + // } - if (empty($header)) { - return; - } - - if ((new MediaTypes($header))->containsExactly(self::MEDIA_TYPE)) { - return; - } - - throw new UnsupportedMediaTypeException; - } - - private function validateRequestAccepts(Request $request): void - { - $header = $request->getHeaderLine('Accept'); - - if (empty($header)) { - return; - } - - $mediaTypes = new MediaTypes($header); - - if ($mediaTypes->containsExactly('*/*') || $mediaTypes->containsExactly(self::MEDIA_TYPE)) { - return; - } - - throw new NotAcceptableException; - } - - private function stripBasePath(string $path): string - { - $basePath = parse_url($this->basePath, PHP_URL_PATH); - - $len = strlen($basePath); - - if (substr($path, 0, $len) === $basePath) { - $path = substr($path, $len); - } - - return $path; - } - - private function handleCollection(Context $context, ResourceType $resource): Response + private function handleCollection(Context $context, ResourceType $resourceType): Response { switch ($context->getRequest()->getMethod()) { case 'GET': - return (new Endpoint\Index($this, $resource))->handle($context); + return (new Endpoint\Index())->handle($context, $resourceType); case 'POST': - return (new Endpoint\Create($this, $resource))->handle($context); + return (new Endpoint\Create())->handle($context, $resourceType); default: - throw new MethodNotAllowedException; + throw new MethodNotAllowedException(); } } - private function handleResource(Context $context, ResourceType $resource, string $id): Response + private function handleResource(Context $context, ResourceType $resourceType, string $id): Response { - $model = $this->findResource($resource, $id, $context); + $model = $this->findResource($resourceType, $id, $context); switch ($context->getRequest()->getMethod()) { case 'PATCH': - return (new Endpoint\Update($this, $resource, $model))->handle($context); + return (new Endpoint\Update())->handle($context, $resourceType, $model); case 'GET': - return (new Endpoint\Show($this, $resource, $model))->handle($context); + return (new Endpoint\Show())->handle($context, $resourceType, $model); case 'DELETE': - return (new Endpoint\Delete($this, $resource, $model))->handle($context); + return (new Endpoint\Delete())->handle($context, $resourceType, $model); default: - throw new MethodNotAllowedException; + throw new MethodNotAllowedException(); } } @@ -205,10 +238,10 @@ final class JsonApi implements RequestHandlerInterface * If the exception is not an instance of ErrorProviderInterface, an * Internal Server Error response will be produced. */ - public function error($e) + public function error($e): Response { if (! $e instanceof ErrorProviderInterface) { - $e = new InternalServerErrorException; + $e = new InternalServerErrorException(); } $errors = $e->getJsonApiErrors(); @@ -226,4 +259,17 @@ final class JsonApi implements RequestHandlerInterface { return $this->basePath; } + + public function stripBasePath(string $path): string + { + $basePath = parse_url($this->basePath, PHP_URL_PATH); + + $len = strlen($basePath); + + if (substr($path, 0, $len) === $basePath) { + $path = substr($path, $len); + } + + return $path; + } } diff --git a/src/ResourceType.php b/src/ResourceType.php index 2cb1607..f1df5d5 100644 --- a/src/ResourceType.php +++ b/src/ResourceType.php @@ -11,11 +11,11 @@ namespace Tobyz\JsonApiServer; +use ReflectionClass; use Tobyz\JsonApiServer\Adapter\AdapterInterface; use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Schema\Attribute; -use Tobyz\JsonApiServer\Schema\HasMany; -use Tobyz\JsonApiServer\Schema\HasOne; +use Tobyz\JsonApiServer\Schema\Relationship; use Tobyz\JsonApiServer\Schema\Type; final class ResourceType @@ -45,7 +45,7 @@ final class ResourceType public function getSchema(): Type { if (! $this->schema) { - $this->schema = new Type; + $this->schema = new Type(); if ($this->buildSchema) { ($this->buildSchema)($this->schema); @@ -59,4 +59,71 @@ final class ResourceType { run_callbacks($this->getSchema()->getListeners('scope'), [$query, $context]); } + + public function filter($query, $filter, Context $context): void + { + if (! is_array($filter)) { + throw (new BadRequestException('filter must be an array'))->setSourceParameter('filter'); + } + + $schema = $this->getSchema(); + $adapter = $this->getAdapter(); + $filters = $schema->getFilters(); + $fields = $schema->getFields(); + + foreach ($filter as $name => $value) { + if ($name === 'id') { + $adapter->filterByIds($query, explode(',', $value)); + continue; + } + + if (isset($filters[$name]) && evaluate($filters[$name]->getVisible(), [$context])) { + $filters[$name]->getCallback()($query, $value, $context); + continue; + } + + [$name, $sub] = explode('.', $name, 2) + [null, null]; + + if (isset($fields[$name]) && evaluate($fields[$name]->getFilterable(), [$context])) { + if ($fields[$name] instanceof Attribute && $sub === null) { + $this->filterByAttribute($adapter, $query, $fields[$name], $value); + continue; + } elseif ($fields[$name] instanceof Relationship) { + if (is_string($relatedType = $fields[$name]->getType())) { + $relatedResource = $context->getApi()->getResourceType($relatedType); + $adapter->filterByRelationship($query, $fields[$name], function ($query) use ($relatedResource, $sub, $value, $context) { + $relatedResource->filter($query, [($sub ?? 'id') => $value], $context); + }); + } + continue; + } + } + + throw (new BadRequestException("Invalid filter [$name]"))->setSourceParameter("filter[$name]"); + } + } + + private function filterByAttribute(AdapterInterface $adapter, $query, Attribute $attribute, $value): void + { + if (preg_match('/(.+)\.\.(.+)/', $value, $matches)) { + if ($matches[1] !== '*') { + $adapter->filterByAttribute($query, $attribute, $value, '>='); + } + if ($matches[2] !== '*') { + $adapter->filterByAttribute($query, $attribute, $value, '<='); + } + + return; + } + + foreach (['>=', '>', '<=', '<'] as $operator) { + if (strpos($value, $operator) === 0) { + $adapter->filterByAttribute($query, $attribute, substr($value, strlen($operator)), $operator); + + return; + } + } + + $adapter->filterByAttribute($query, $attribute, $value); + } } diff --git a/src/Schema/Field.php b/src/Schema/Field.php index 90cb853..a3e5c54 100644 --- a/src/Schema/Field.php +++ b/src/Schema/Field.php @@ -135,7 +135,7 @@ abstract class Field /** * Run a callback after this field has been saved. */ - public function onSaved(callable $callback) + public function saved(callable $callback) { $this->listeners['saved'][] = $callback; diff --git a/src/Schema/Relationship.php b/src/Schema/Relationship.php index 4369ade..e5b45b9 100644 --- a/src/Schema/Relationship.php +++ b/src/Schema/Relationship.php @@ -68,26 +68,6 @@ abstract class Relationship extends Field return $this; } - /** - * Allow the relationship data to be eager-loaded into the model collection. - */ - public function load() - { - $this->load = true; - - return $this; - } - - /** - * Do not eager-load relationship data into the model collection. - */ - public function dontLoad() - { - $this->load = false; - - return $this; - } - /** * Allow the relationship data to be included in a compound document. */ @@ -153,14 +133,6 @@ abstract class Relationship extends Field // return $this->urls; // } - /** - * @return bool|callable - */ - public function shouldLoad() - { - return $this->load; - } - public function isIncludable(): bool { return $this->includable; diff --git a/src/Schema/Sort.php b/src/Schema/Sort.php new file mode 100644 index 0000000..9de5e28 --- /dev/null +++ b/src/Schema/Sort.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobyz\JsonApiServer\Schema; + +use Tobyz\JsonApiServer\Schema\Concerns\HasDescription; +use Tobyz\JsonApiServer\Schema\Concerns\HasVisibility; + +final class Sort +{ + use HasDescription; + use HasVisibility; + + private $name; + private $callback; + + public function __construct(string $name, callable $callback) + { + $this->name = $name; + $this->callback = $callback; + } + + public function getName(): string + { + return $this->name; + } + + public function getCallback(): callable + { + return $this->callback; + } +} diff --git a/src/Schema/Type.php b/src/Schema/Type.php index b5cef04..c18a56c 100644 --- a/src/Schema/Type.php +++ b/src/Schema/Type.php @@ -24,7 +24,7 @@ final class Type private $fields = []; private $filters = []; - private $sortFields = []; + private $sorts = []; private $perPage = 20; private $limit = 50; private $countable = true; @@ -118,15 +118,15 @@ final class Type */ public function sort(string $name, callable $callback): void { - $this->sortFields[$name] = $callback; + $this->sorts[$name] = new Sort($name, $callback); } /** * Get the resource type's sort fields. */ - public function getSortFields(): array + public function getSorts(): array { - return $this->sortFields; + return $this->sorts; } /** @@ -214,17 +214,9 @@ final class Type /** * Run a callback before a resource is shown. */ - public function onShowing(callable $callback): void + public function show(callable $callback): void { - $this->listeners['showing'][] = $callback; - } - - /** - * Run a callback after a resource is shown. - */ - public function onShown(callable $callback): void - { - $this->listeners['shown'][] = $callback; + $this->listeners['show'][] = $callback; } /** @@ -254,7 +246,7 @@ final class Type /** * Run a callback before the resource type is listed. */ - public function onListing(callable $callback): void + public function listing(callable $callback): void { $this->listeners['listing'][] = $callback; } @@ -262,7 +254,7 @@ final class Type /** * Run a callback when the resource type is listed. */ - public function onListed(callable $callback): void + public function listed(callable $callback): void { $this->listeners['listed'][] = $callback; } @@ -312,7 +304,7 @@ final class Type /** * Run a callback before a resource is created. */ - public function onCreating(callable $callback): void + public function creating(callable $callback): void { $this->listeners['creating'][] = $callback; } @@ -320,7 +312,7 @@ final class Type /** * Run a callback after a resource has been created. */ - public function onCreated(callable $callback): void + public function created(callable $callback): void { $this->listeners['created'][] = $callback; } @@ -352,7 +344,7 @@ final class Type /** * Run a callback before a resource has been updated. */ - public function onUpdating(callable $callback): void + public function updating(callable $callback): void { $this->listeners['updating'][] = $callback; } @@ -360,7 +352,7 @@ final class Type /** * Run a callback after a resource has been updated. */ - public function onUpdated(callable $callback): void + public function updated(callable $callback): void { $this->listeners['updated'][] = $callback; } @@ -428,7 +420,7 @@ final class Type /** * Run a callback before a resource has been deleted. */ - public function onDeleting(callable $callback): void + public function deleting(callable $callback): void { $this->listeners['deleting'][] = $callback; } @@ -436,7 +428,7 @@ final class Type /** * Run a callback after a resource has been deleted. */ - public function onDeleted(callable $callback): void + public function deleted(callable $callback): void { $this->listeners['deleted'][] = $callback; } diff --git a/src/Serializer.php b/src/Serializer.php index 1df8174..e3947a0 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -15,75 +15,90 @@ use DateTime; use DateTimeInterface; use JsonApiPhp\JsonApi as Structure; use RuntimeException; +use Tobyz\JsonApiServer\Schema\Attribute; +use Tobyz\JsonApiServer\Schema\Field; +use Tobyz\JsonApiServer\Schema\HasMany; +use Tobyz\JsonApiServer\Schema\HasOne; +use Tobyz\JsonApiServer\Schema\Relationship; final class Serializer { - private $api; private $context; private $map = []; private $primary = []; + private $deferred = []; - public function __construct(JsonApi $api, Context $context) + public function __construct(Context $context) { - $this->api = $api; $this->context = $context; } /** * Add a primary resource to the document. */ - public function add(ResourceType $resource, $model, array $include): void + public function add(ResourceType $resourceType, $model, array $include): void { - $data = $this->addToMap($resource, $model, $include); + $data = $this->addToMap($resourceType, $model, $include); - $this->primary[] = $this->key($data); + $this->primary[] = $this->key($data['type'], $data['id']); } /** - * Get the serialized primary resources. + * Serialize the primary and included resources into a JSON:API resource objects. */ - public function primary(): array + public function serialize(): array { - $primary = array_map(function ($key) { - return $this->map[$key]; - }, $this->primary); + $this->resolveDeferred(); - return $this->resourceObjects($primary); - } + $keys = array_flip($this->primary); + $primary = array_values(array_intersect_key($this->map, $keys)); + $included = array_values(array_diff_key($this->map, $keys)); - /** - * Get the serialized included resources. - */ - public function included(): array - { - $included = array_values(array_diff_key($this->map, array_flip($this->primary))); - - return $this->resourceObjects($included); - } - - private function addToMap(ResourceType $resource, $model, array $include): array - { - $adapter = $resource->getAdapter(); - $schema = $resource->getSchema(); - - $data = [ - 'type' => $type = $resource->getType(), - 'id' => $id = $adapter->getId($model), - 'fields' => [], - 'links' => [], - 'meta' => [] + return [ + $this->resourceObjects($primary), + $this->resourceObjects($included), ]; + } - $key = $this->key($data); - $url = $this->api->getBasePath()."/$type/$id"; - $fields = $schema->getFields(); - $queryParams = $this->context->getRequest()->getQueryParams(); + private function resolveDeferred(): void + { + $i = 0; + while (count($this->deferred)) { + foreach ($this->deferred as $k => $resolve) { + $resolve(); + unset($this->deferred[$k]); + } - if (isset($queryParams['fields'][$type])) { - $fields = array_intersect_key($fields, array_flip(explode(',', $queryParams['fields'][$type]))); + if ($i++ > 10) { + throw new RuntimeException('Too many levels of deferred values.'); + } } + } + + private function addToMap(ResourceType $resourceType, $model, array $include): array + { + $adapter = $resourceType->getAdapter(); + $schema = $resourceType->getSchema(); + + $key = $this->key( + $type = $resourceType->getType(), + $id = $adapter->getId($model) + ); + + $this->map[$key] = $this->map[$key] ?? [ + 'type' => $type, + 'id' => $id, + 'fields' => [], + 'links' => [], + 'meta' => [] + ]; + + $url = $this->context->getApi()->getBasePath()."/$type/$id"; + $fields = $this->sparseFields($type, $schema->getFields()); + + foreach ($fields as $field) { + $name = $field->getName(); - foreach ($fields as $name => $field) { if (isset($this->map[$key]['fields'][$name])) { continue; } @@ -93,104 +108,127 @@ final class Serializer } if ($field instanceof Schema\Attribute) { - $value = $this->attribute($field, $resource, $model); + $this->setAttribute($key, $field, $resourceType, $model); } elseif ($field instanceof Schema\Relationship) { - $isIncluded = isset($include[$name]); - $relationshipInclude = $isIncluded ? ($include[$name] ?? []) : null; - $links = $this->relationshipLinks($field, $url); - $meta = $this->meta($field->getMeta(), $model); - $members = array_merge($links, $meta); - - if (! $isIncluded && ! $field->hasLinkage()) { - $value = $this->emptyRelationship($field, $members); - } elseif ($field instanceof Schema\HasOne) { - $value = $this->toOne($field, $members, $resource, $model, $relationshipInclude); - } elseif ($field instanceof Schema\HasMany) { - $value = $this->toMany($field, $members, $resource, $model, $relationshipInclude); - } - } - - if (! empty($value)) { - $data['fields'][$name] = $value; + $this->setRelationship($key, $field, $resourceType, $model, $include, $url); } } - $data['links']['self'] = new Structure\Link\SelfLink($url); - $data['meta'] = $this->meta($schema->getMeta(), $model); + $this->map[$key]['links']['self'] = new Structure\Link\SelfLink($url); + $this->map[$key]['meta'] = $this->meta($schema->getMeta(), $model); - $this->merge($data); - - return $data; + return $this->map[$key]; } - private function merge($data): void + private function setAttribute(string $key, Attribute $field, ResourceType $resourceType, $model): void { - $key = $this->key($data); + $this->defer($this->getAttributeValue($field, $resourceType, $model), function ($value) use ($key, $field) { + if ($value instanceof DateTimeInterface) { + $value = $value->format(DateTime::RFC3339); + } - if (isset($this->map[$key])) { - $this->map[$key]['fields'] = array_merge($this->map[$key]['fields'], $data['fields']); - $this->map[$key]['links'] = array_merge($this->map[$key]['links'], $data['links']); - $this->map[$key]['meta'] = array_merge($this->map[$key]['meta'], $data['meta']); - } else { - $this->map[$key] = $data; + $this->map[$key]['fields'][$name = $field->getName()] = new Structure\Attribute($name, $value); + }); + } + + private function getAttributeValue(Attribute $field, ResourceType $resourceType, $model) + { + return ($getCallback = $field->getGetCallback()) + ? $getCallback($model, $this->context) + : $resourceType->getAdapter()->getAttribute($model, $field); + } + + private function setRelationship(string $key, Relationship $field, ResourceType $resourceType, $model, array $include, string $url): void + { + $name = $field->getName(); + $isIncluded = isset($include[$name]); + $nestedInclude = $include[$name] ?? []; + + $members = array_merge( + $this->relationshipLinks($field, $url), + $this->meta($field->getMeta(), $model) + ); + + if (! $isIncluded && ! $field->hasLinkage()) { + if ($relationship = $this->emptyRelationship($field, $members)) { + $this->map[$key]['fields'][$name] = $relationship; + } + return; + } + + $value = $this->getRelationshipValue($field, $resourceType, $model, ! $isIncluded); + + if ($field instanceof Schema\HasOne) { + $this->defer($value, function ($value) use ($key, $field, $name, $isIncluded, $nestedInclude, $members) { + if (! $value) { + $relationship = new Structure\ToNull($name, ...$members); + } else { + $identifier = $isIncluded + ? $this->addRelated($field, $value, $nestedInclude) + : $this->relatedResourceIdentifier($field, $value); + + $relationship = new Structure\ToOne($name, $identifier, ...$members); + } + + $this->map[$key]['fields'][$name] = $relationship; + }); + } elseif ($field instanceof Schema\HasMany) { + $this->defer($value, function ($value) use ($key, $field, $name, $isIncluded, $nestedInclude, $members) { + $identifiers = array_map(function ($relatedModel) use ($field, $isIncluded, $nestedInclude) { + return $isIncluded + ? $this->addRelated($field, $relatedModel, $nestedInclude) + : $this->relatedResourceIdentifier($field, $relatedModel); + }, $value); + + $this->map[$key]['fields'][$name] = new Structure\ToMany( + $name, + new Structure\ResourceIdentifierCollection(...$identifiers), + ...$members + ); + }); } } - private function attribute(Schema\Attribute $field, ResourceType $resource, $model): Structure\Attribute + private function getRelationshipValue(Relationship $field, ResourceType $resourceType, $model, bool $linkage) { if ($getCallback = $field->getGetCallback()) { - $value = $getCallback($model, $this->context); - } else { - $value = $resource->getAdapter()->getAttribute($model, $field); + return $getCallback($model, $this->context); } - if ($value instanceof DateTimeInterface) { - $value = $value->format(DateTime::RFC3339); + if ($field instanceof HasOne) { + return $resourceType->getAdapter()->getHasOne($model, $field, $linkage, $this->context); } - return new Structure\Attribute($field->getName(), $value); + if ($field instanceof HasMany) { + return $resourceType->getAdapter()->getHasMany($model, $field, $linkage, $this->context); + } + + return null; } - private function toOne(Schema\HasOne $field, array $members, ResourceType $resource, $model, ?array $include) + private function sparseFields(string $type, array $fields): array { - $included = $include !== null; + $queryParams = $this->context->getRequest()->getQueryParams(); - $model = ($getCallback = $field->getGetCallback()) - ? $getCallback($model, $this->context) - : $resource->getAdapter()->getHasOne($model, $field, ! $included); - - if (! $model) { - return new Structure\ToNull($field->getName(), ...$members); + if (isset($queryParams['fields'][$type])) { + $requested = $queryParams['fields'][$type]; + $requested = is_array($requested) ? $requested : explode(',', $requested); + $fields = array_intersect_key($fields, array_flip($requested)); } - $identifier = $include !== null - ? $this->addRelated($field, $model, $include) - : $this->relatedResourceIdentifier($field, $model); - - return new Structure\ToOne($field->getName(), $identifier, ...$members); + return $fields; } - private function toMany(Schema\HasMany $field, array $members, ResourceType $resource, $model, ?array $include) + private function defer($value, $callback): void { - $included = $include !== null; - - $models = ($getCallback = $field->getGetCallback()) - ? $getCallback($model, $this->context) - : $resource->getAdapter()->getHasMany($model, $field, ! $included); - - $identifiers = []; - - foreach ($models as $relatedModel) { - $identifiers[] = $included - ? $this->addRelated($field, $relatedModel, $include) - : $this->relatedResourceIdentifier($field, $relatedModel); + if ($value instanceof Deferred) { + $this->deferred[] = function () use (&$data, $value, $callback) { + $this->defer($value->resolve(), $callback); + }; + return; } - return new Structure\ToMany( - $field->getName(), - new Structure\ResourceIdentifierCollection(...$identifiers), - ...$members - ); + $callback($value); } private function emptyRelationship(Schema\Relationship $field, array $members): ?Structure\EmptyRelationship @@ -203,7 +241,7 @@ final class Serializer } /** - * @return Structure\Internal\RelationshipMember + * @return Structure\Internal\RelationshipMember[] */ private function relationshipLinks(Schema\Relationship $field, string $url): array { @@ -220,7 +258,7 @@ final class Serializer private function addRelated(Schema\Relationship $field, $model, array $include): Structure\ResourceIdentifier { $relatedResource = is_string($field->getType()) - ? $this->api->getResource($field->getType()) + ? $this->context->getApi()->getResourceType($field->getType()) : $this->resourceForModel($model); return $this->resourceIdentifier( @@ -230,7 +268,7 @@ final class Serializer private function resourceForModel($model): ResourceType { - foreach ($this->api->getResources() as $resource) { + foreach ($this->context->getApi()->getResourceTypes() as $resource) { if ($resource->getAdapter()->represents($model)) { return $resource; } @@ -266,7 +304,7 @@ final class Serializer { $type = $field->getType(); $relatedResource = is_string($type) - ? $this->api->getResource($type) + ? $this->context->getApi()->getResourceType($type) : $this->resourceForModel($model); return $this->resourceIdentifier([ @@ -276,7 +314,7 @@ final class Serializer } /** - * @return Structure\Internal\RelationshipMember + * @return Structure\Internal\RelationshipMember[] */ private function meta(array $items, $model): array { @@ -287,8 +325,8 @@ final class Serializer }, $items); } - private function key(array $data): string + private function key(string $type, string $id): string { - return $data['type'].':'.$data['id']; + return $type.':'.$id; } } diff --git a/src/functions.php b/src/functions.php index 691a824..c07a73b 100644 --- a/src/functions.php +++ b/src/functions.php @@ -12,28 +12,27 @@ namespace Tobyz\JsonApiServer; use Closure; -use JsonSerializable; use Nyholm\Psr7\Response; use Nyholm\Psr7\Stream; use Tobyz\JsonApiServer\Schema\Field; -function json_api_response(JsonSerializable $document, int $status = 200): Response +function json_api_response($document, int $status = 200): Response { return (new Response($status)) ->withHeader('content-type', JsonApi::MEDIA_TYPE) ->withBody(Stream::create(json_encode($document, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES))); } -function negate(Closure $condition) +function negate(Closure $condition): Closure { return function (...$args) use ($condition) { return ! $condition(...$args); }; } -function wrap($value) +function wrap($value): Closure { - if (! is_callable($value)) { + if (! $value instanceof Closure) { $value = function () use ($value) { return $value; }; @@ -42,19 +41,19 @@ function wrap($value) return $value; } -function evaluate($condition, array $params) +function evaluate($condition, array $params): bool { return $condition === true || (is_callable($condition) && $condition(...$params)); } -function run_callbacks(array $callbacks, array $params) +function run_callbacks(array $callbacks, array $params): void { foreach ($callbacks as $callback) { $callback(...$params); } } -function has_value(array $data, Field $field) +function has_value(array $data, Field $field): bool { return array_key_exists($location = $field->getLocation(), $data) && array_key_exists($field->getName(), $data[$location]); @@ -65,7 +64,7 @@ function get_value(array $data, Field $field) return $data[$field->getLocation()][$field->getName()] ?? null; } -function set_value(array &$data, Field $field, $value) +function set_value(array &$data, Field $field, $value): void { $data[$field->getLocation()][$field->getName()] = $value; } diff --git a/src/functions_laravel.php b/src/functions_laravel.php index 882c884..70b0e0a 100644 --- a/src/functions_laravel.php +++ b/src/functions_laravel.php @@ -11,6 +11,7 @@ namespace Tobyz\JsonApiServer\Laravel; +use Closure; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Gate; @@ -18,7 +19,7 @@ use Illuminate\Support\Facades\Validator; use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Schema\Field; -function rules($rules, array $messages = [], array $customAttributes = []) +function rules($rules, array $messages = [], array $customAttributes = []): Closure { if (is_string($rules)) { $rules = [$rules]; @@ -55,14 +56,14 @@ function rules($rules, array $messages = [], array $customAttributes = []) }; } -function authenticated() +function authenticated(): Closure { return function () { return Auth::check(); }; } -function can(string $ability) +function can(string $ability): Closure { return function ($arg) use ($ability) { return Gate::allows($ability, $arg instanceof Model ? $arg : null); diff --git a/tests/feature/CountabilityTest.php b/tests/feature/CountabilityTest.php index 85598cd..fd04e9a 100644 --- a/tests/feature/CountabilityTest.php +++ b/tests/feature/CountabilityTest.php @@ -42,7 +42,7 @@ class CountabilityTest extends AbstractTestCase public function test_total_number_of_resources_and_last_pagination_link_is_included_by_default() { - $this->api->resource('users', $this->adapter); + $this->api->resourceType('users', $this->adapter); $response = $this->api->handle( $this->buildRequest('GET', '/users') @@ -56,7 +56,7 @@ class CountabilityTest extends AbstractTestCase public function test_types_can_be_made_uncountable() { - $this->api->resource('users', $this->adapter, function (Type $type) { + $this->api->resourceType('users', $this->adapter, function (Type $type) { $type->uncountable(); }); @@ -72,7 +72,7 @@ class CountabilityTest extends AbstractTestCase public function test_types_can_be_made_countable() { - $this->api->resource('users', $this->adapter, function (Type $type) { + $this->api->resourceType('users', $this->adapter, function (Type $type) { $type->uncountable(); $type->countable(); }); diff --git a/tests/feature/CreateTest.php b/tests/feature/CreateTest.php index db82dc9..4100fd6 100644 --- a/tests/feature/CreateTest.php +++ b/tests/feature/CreateTest.php @@ -49,7 +49,7 @@ class CreateTest extends AbstractTestCase public function test_resources_are_not_creatable_by_default() { - $this->api->resource('users', new MockAdapter()); + $this->api->resourceType('users', new MockAdapter()); $this->expectException(ForbiddenException::class); @@ -58,7 +58,7 @@ class CreateTest extends AbstractTestCase public function test_resource_creation_can_be_explicitly_enabled() { - $this->api->resource('users', new MockAdapter(), function (Type $type) { + $this->api->resourceType('users', new MockAdapter(), function (Type $type) { $type->creatable(); }); @@ -69,7 +69,7 @@ class CreateTest extends AbstractTestCase public function test_resource_creation_can_be_conditionally_enabled() { - $this->api->resource('users', new MockAdapter(), function (Type $type) { + $this->api->resourceType('users', new MockAdapter(), function (Type $type) { $type->creatable(function () { return true; }); @@ -82,7 +82,7 @@ class CreateTest extends AbstractTestCase public function test_resource_creation_can_be_explicitly_disabled() { - $this->api->resource('users', new MockAdapter(), function (Type $type) { + $this->api->resourceType('users', new MockAdapter(), function (Type $type) { $type->notCreatable(); }); @@ -93,7 +93,7 @@ class CreateTest extends AbstractTestCase public function test_resource_creation_can_be_conditionally_disabled() { - $this->api->resource('users', new MockAdapter(), function (Type $type) { + $this->api->resourceType('users', new MockAdapter(), function (Type $type) { $type->creatable(function () { return false; }); @@ -108,7 +108,7 @@ class CreateTest extends AbstractTestCase { $called = false; - $this->api->resource('users', new MockAdapter(), function (Type $type) use (&$called) { + $this->api->resourceType('users', new MockAdapter(), function (Type $type) use (&$called) { $type->creatable(function ($context) use (&$called) { $this->assertInstanceOf(Context::class, $context); return $called = true; @@ -127,7 +127,7 @@ class CreateTest extends AbstractTestCase $adapter->save($createdModel)->shouldBeCalled(); $adapter->getId($createdModel)->willReturn('1'); - $this->api->resource('users', $adapter->reveal(), function (Type $type) { + $this->api->resourceType('users', $adapter->reveal(), function (Type $type) { $type->creatable(); }); @@ -143,7 +143,7 @@ class CreateTest extends AbstractTestCase $adapter->save($createdModel)->shouldBeCalled(); $adapter->getId($createdModel)->willReturn('1'); - $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($createdModel) { + $this->api->resourceType('users', $adapter->reveal(), function (Type $type) use ($createdModel) { $type->creatable(); $type->newModel(function ($context) use ($createdModel) { $this->assertInstanceOf(Context::class, $context); @@ -163,7 +163,7 @@ class CreateTest extends AbstractTestCase $adapter->save($createdModel)->shouldNotBeCalled(); $adapter->getId($createdModel)->willReturn('1'); - $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($createdModel, &$called) { + $this->api->resourceType('users', $adapter->reveal(), function (Type $type) use ($createdModel, &$called) { $type->creatable(); $type->save(function ($model, $context) use ($createdModel, &$called) { $model->id = '1'; @@ -186,15 +186,15 @@ class CreateTest extends AbstractTestCase $adapter->newModel()->willReturn($createdModel = (object) []); $adapter->getId($createdModel)->willReturn('1'); - $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($adapter, $createdModel, &$called) { + $this->api->resourceType('users', $adapter->reveal(), function (Type $type) use ($adapter, $createdModel, &$called) { $type->creatable(); - $type->onCreating(function ($model, $context) use ($adapter, $createdModel, &$called) { + $type->creating(function ($model, $context) use ($adapter, $createdModel, &$called) { $this->assertSame($createdModel, $model); $this->assertInstanceOf(Context::class, $context); $adapter->save($createdModel)->shouldNotHaveBeenCalled(); $called++; }); - $type->onCreated(function ($model, $context) use ($adapter, $createdModel, &$called) { + $type->created(function ($model, $context) use ($adapter, $createdModel, &$called) { $this->assertSame($createdModel, $model); $this->assertInstanceOf(Context::class, $context); $adapter->save($createdModel)->shouldHaveBeenCalled(); diff --git a/tests/feature/DeleteTest.php b/tests/feature/DeleteTest.php index 11bed6c..34e1851 100644 --- a/tests/feature/DeleteTest.php +++ b/tests/feature/DeleteTest.php @@ -43,7 +43,7 @@ class DeleteTest extends AbstractTestCase public function test_resources_are_not_deletable_by_default() { - $this->api->resource('users', new MockAdapter()); + $this->api->resourceType('users', new MockAdapter()); $this->expectException(ForbiddenException::class); @@ -52,7 +52,7 @@ class DeleteTest extends AbstractTestCase public function test_resource_deletion_can_be_explicitly_enabled() { - $this->api->resource('users', new MockAdapter(), function (Type $type) { + $this->api->resourceType('users', new MockAdapter(), function (Type $type) { $type->deletable(); }); @@ -63,7 +63,7 @@ class DeleteTest extends AbstractTestCase public function test_resource_deletion_can_be_conditionally_enabled() { - $this->api->resource('users', new MockAdapter(), function (Type $type) { + $this->api->resourceType('users', new MockAdapter(), function (Type $type) { $type->deletable(function () { return true; }); @@ -76,7 +76,7 @@ class DeleteTest extends AbstractTestCase public function test_resource_deletion_can_be_explicitly_disabled() { - $this->api->resource('users', new MockAdapter(), function (Type $type) { + $this->api->resourceType('users', new MockAdapter(), function (Type $type) { $type->notDeletable(); }); @@ -87,7 +87,7 @@ class DeleteTest extends AbstractTestCase public function test_resource_deletion_can_be_conditionally_disabled() { - $this->api->resource('users', new MockAdapter(), function (Type $type) { + $this->api->resourceType('users', new MockAdapter(), function (Type $type) { $type->deletable(function () { return false; }); @@ -107,7 +107,7 @@ class DeleteTest extends AbstractTestCase $adapter->find($query, '1')->willReturn($deletingModel = (object) []); $adapter->delete($deletingModel); - $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($deletingModel, &$called) { + $this->api->resourceType('users', $adapter->reveal(), function (Type $type) use ($deletingModel, &$called) { $type->deletable(function ($model, $context) use ($deletingModel, &$called) { $this->assertSame($deletingModel, $model); $this->assertInstanceOf(Context::class, $context); @@ -127,7 +127,7 @@ class DeleteTest extends AbstractTestCase $adapter->find($query, '1')->willReturn($model = (object) []); $adapter->delete($model)->shouldBeCalled(); - $this->api->resource('users', $adapter->reveal(), function (Type $type) { + $this->api->resourceType('users', $adapter->reveal(), function (Type $type) { $type->deletable(); }); @@ -143,7 +143,7 @@ class DeleteTest extends AbstractTestCase $adapter->find($query, '1')->willReturn($deletingModel = (object) []); $adapter->delete($deletingModel)->shouldNotBeCalled(); - $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($deletingModel, &$called) { + $this->api->resourceType('users', $adapter->reveal(), function (Type $type) use ($deletingModel, &$called) { $type->deletable(); $type->delete(function ($model, $context) use ($deletingModel, &$called) { $this->assertSame($deletingModel, $model); @@ -166,15 +166,15 @@ class DeleteTest extends AbstractTestCase $adapter->find($query, '1')->willReturn($deletingModel = (object) []); $adapter->delete($deletingModel)->shouldBeCalled(); - $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($adapter, $deletingModel, &$called) { + $this->api->resourceType('users', $adapter->reveal(), function (Type $type) use ($adapter, $deletingModel, &$called) { $type->deletable(); - $type->onDeleting(function ($model, $context) use ($adapter, $deletingModel, &$called) { + $type->deleting(function ($model, $context) use ($adapter, $deletingModel, &$called) { $this->assertSame($deletingModel, $model); $this->assertInstanceOf(Context::class, $context); $adapter->delete($deletingModel)->shouldNotHaveBeenCalled(); $called++; }); - $type->onDeleted(function ($model, $context) use ($adapter, $deletingModel, &$called) { + $type->deleted(function ($model, $context) use ($adapter, $deletingModel, &$called) { $this->assertSame($deletingModel, $model); $this->assertInstanceOf(Context::class, $context); $adapter->delete($deletingModel)->shouldHaveBeenCalled(); diff --git a/tests/feature/FieldGettersTest.php b/tests/feature/FieldGettersTest.php index ad86dfd..0c6d262 100644 --- a/tests/feature/FieldGettersTest.php +++ b/tests/feature/FieldGettersTest.php @@ -48,7 +48,7 @@ class FieldGettersTest extends AbstractTestCase public function test_attribute_values_are_retrieved_via_the_adapter_by_default() { - $this->api->resource('users', $this->adapter, function (Type $type) { + $this->api->resourceType('users', $this->adapter, function (Type $type) { $type->attribute('test'); }); @@ -63,7 +63,7 @@ class FieldGettersTest extends AbstractTestCase public function test_attribute_getters_allow_a_custom_value_to_be_used() { - $this->api->resource('users', $this->adapter, function (Type $type) { + $this->api->resourceType('users', $this->adapter, function (Type $type) { $type->attribute('test') ->get(function ($model, Context $context) { return 'custom'; @@ -81,11 +81,11 @@ class FieldGettersTest extends AbstractTestCase public function test_has_one_values_are_retrieved_via_the_adapter_by_default() { - $this->api->resource('users', $this->adapter, function (Type $type) { + $this->api->resourceType('users', $this->adapter, function (Type $type) { $type->hasOne('animal')->withLinkage(); }); - $this->api->resource('animals', new MockAdapter); + $this->api->resourceType('animals', new MockAdapter); $response = $this->api->handle( $this->buildRequest('GET', '/users/1') @@ -98,14 +98,14 @@ class FieldGettersTest extends AbstractTestCase public function test_has_one_getters_allow_a_custom_value_to_be_used() { - $this->api->resource('users', $this->adapter, function (Type $type) { + $this->api->resourceType('users', $this->adapter, function (Type $type) { $type->hasOne('animal')->withLinkage() ->get(function ($model, Context $context) { return (object) ['id' => '2']; }); }); - $this->api->resource('animals', new MockAdapter); + $this->api->resourceType('animals', new MockAdapter); $response = $this->api->handle( $this->buildRequest('GET', '/users/1') @@ -118,11 +118,11 @@ class FieldGettersTest extends AbstractTestCase public function test_has_many_values_are_retrieved_via_the_adapter_by_default() { - $this->api->resource('users', $this->adapter, function (Type $type) { + $this->api->resourceType('users', $this->adapter, function (Type $type) { $type->hasMany('animals')->withLinkage(); }); - $this->api->resource('animals', new MockAdapter); + $this->api->resourceType('animals', new MockAdapter); $response = $this->api->handle( $this->buildRequest('GET', '/users/1') @@ -136,7 +136,7 @@ class FieldGettersTest extends AbstractTestCase public function test_has_many_getters_allow_a_custom_value_to_be_used() { - $this->api->resource('users', $this->adapter, function (Type $type) { + $this->api->resourceType('users', $this->adapter, function (Type $type) { $type->hasMany('animals')->withLinkage() ->get(function ($model, Context $context) { return [ @@ -146,7 +146,7 @@ class FieldGettersTest extends AbstractTestCase }); }); - $this->api->resource('animals', new MockAdapter); + $this->api->resourceType('animals', new MockAdapter); $response = $this->api->handle( $this->buildRequest('GET', '/users/1') diff --git a/tests/feature/FieldVisibilityTest.php b/tests/feature/FieldVisibilityTest.php index cba84c6..40ec28c 100644 --- a/tests/feature/FieldVisibilityTest.php +++ b/tests/feature/FieldVisibilityTest.php @@ -40,7 +40,7 @@ class FieldVisibilityTest extends AbstractTestCase public function test_fields_are_visible_by_default() { - $this->api->resource('users', new MockAdapter, function (Type $type) { + $this->api->resourceType('users', new MockAdapter, function (Type $type) { $type->attribute('visible'); }); @@ -58,7 +58,7 @@ class FieldVisibilityTest extends AbstractTestCase { $this->markTestIncomplete(); - $this->api->resource('users', new MockAdapter, function (Type $type) { + $this->api->resourceType('users', new MockAdapter, function (Type $type) { $type->attribute('visibleAttribute')->visible(); $type->hasOne('visibleHasOne')->visible(); $type->hasMany('visibleHasMany')->visible(); @@ -81,7 +81,7 @@ class FieldVisibilityTest extends AbstractTestCase { $this->markTestIncomplete(); - $this->api->resource('users', new MockAdapter, function (Type $type) { + $this->api->resourceType('users', new MockAdapter, function (Type $type) { $type->attribute('visibleAttribute') ->visible(function () { return true; }); @@ -124,7 +124,7 @@ class FieldVisibilityTest extends AbstractTestCase $called = 0; - $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { + $this->api->resourceType('users', $this->adapter, function (Type $type) use (&$called) { $callback = function ($model, $request) use (&$called) { $this->assertSame($this->adapter->models['1'], $model); $this->assertInstanceOf(RequestInterface::class, $request); @@ -152,7 +152,7 @@ class FieldVisibilityTest extends AbstractTestCase { $this->markTestIncomplete(); - $this->api->resource('users', new MockAdapter, function (Type $type) { + $this->api->resourceType('users', new MockAdapter, function (Type $type) { $type->attribute('hiddenAttribute')->hidden(); $type->hasOne('hiddenHasOne')->hidden(); $type->hasMany('hiddenHasMany')->hidden(); @@ -175,7 +175,7 @@ class FieldVisibilityTest extends AbstractTestCase { $this->markTestIncomplete(); - $this->api->resource('users', new MockAdapter, function (Type $type) { + $this->api->resourceType('users', new MockAdapter, function (Type $type) { $type->attribute('visibleAttribute') ->hidden(function () { return false; }); @@ -218,7 +218,7 @@ class FieldVisibilityTest extends AbstractTestCase $called = 0; - $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { + $this->api->resourceType('users', $this->adapter, function (Type $type) use (&$called) { $callback = function ($model, $request) use (&$called) { $this->assertSame($this->adapter->models['1'], $model); $this->assertInstanceOf(RequestInterface::class, $request); diff --git a/tests/feature/FieldWritabilityTest.php b/tests/feature/FieldWritabilityTest.php index 77875e0..45e62d1 100644 --- a/tests/feature/FieldWritabilityTest.php +++ b/tests/feature/FieldWritabilityTest.php @@ -42,7 +42,7 @@ class FieldWritabilityTest extends AbstractTestCase public function test_attributes_are_readonly_by_default() { - $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { + $this->api->resourceType('users', $this->adapter, function (Type $type) use (&$called) { $type->updatable(); $type->attribute('readonly'); }); @@ -65,7 +65,7 @@ class FieldWritabilityTest extends AbstractTestCase public function test_attributes_can_be_explicitly_writable() { - $this->api->resource('users', $this->adapter, function (Type $type) { + $this->api->resourceType('users', $this->adapter, function (Type $type) { $type->updatable(); $type->attribute('writable')->writable(); }); @@ -89,7 +89,7 @@ class FieldWritabilityTest extends AbstractTestCase public function test_attributes_can_be_conditionally_writable() { - $this->api->resource('users', $this->adapter, function (Type $type) { + $this->api->resourceType('users', $this->adapter, function (Type $type) { $type->updatable(); $type->attribute('writable') ->writable(function () { return true; }); @@ -116,7 +116,7 @@ class FieldWritabilityTest extends AbstractTestCase { $called = false; - $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { + $this->api->resourceType('users', $this->adapter, function (Type $type) use (&$called) { $type->updatable(); $type->attribute('writable') ->writable(function ($model, $context) use (&$called) { @@ -145,7 +145,7 @@ class FieldWritabilityTest extends AbstractTestCase public function test_attributes_can_be_explicitly_readonly() { - $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { + $this->api->resourceType('users', $this->adapter, function (Type $type) use (&$called) { $type->updatable(); $type->attribute('readonly')->readonly(); }); @@ -168,7 +168,7 @@ class FieldWritabilityTest extends AbstractTestCase public function test_attributes_can_be_conditionally_readonly() { - $this->api->resource('users', $this->adapter, function (Type $type) { + $this->api->resourceType('users', $this->adapter, function (Type $type) { $type->updatable(); $type->attribute('readonly') ->readonly(function () { return true; }); @@ -194,7 +194,7 @@ class FieldWritabilityTest extends AbstractTestCase { $called = false; - $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { + $this->api->resourceType('users', $this->adapter, function (Type $type) use (&$called) { $type->updatable(); $type->attribute('readonly') ->readonly(function ($model, $context) use (&$called) { @@ -225,7 +225,7 @@ class FieldWritabilityTest extends AbstractTestCase public function test_field_is_only_writable_once_on_creation() { - $this->api->resource('users', $this->adapter, function (Type $type) { + $this->api->resourceType('users', $this->adapter, function (Type $type) { $type->creatable(); $type->updatable(); $type->attribute('writableOnce')->writable()->once(); diff --git a/tests/feature/FiltersTest.php b/tests/feature/FiltersTest.php index 1a38fd3..940f5a6 100644 --- a/tests/feature/FiltersTest.php +++ b/tests/feature/FiltersTest.php @@ -44,7 +44,7 @@ class FiltersTest extends AbstractTestCase public function test_resources_can_be_filtered_by_id() { - $this->api->resource('users', $this->adapter); + $this->api->resourceType('users', $this->adapter); $this->api->handle( $this->buildRequest('GET', '/users') @@ -56,7 +56,7 @@ class FiltersTest extends AbstractTestCase public function test_attributes_are_not_filterable_by_default() { - $this->api->resource('users', $this->adapter, function (Type $type) { + $this->api->resourceType('users', $this->adapter, function (Type $type) { $type->attribute('test'); }); @@ -70,7 +70,7 @@ class FiltersTest extends AbstractTestCase public function test_attributes_can_be_explicitly_filterable() { - $this->api->resource('users', $this->adapter, function (Type $type) use (&$attribute) { + $this->api->resourceType('users', $this->adapter, function (Type $type) use (&$attribute) { $attribute = $type->attribute('test')->filterable(); }); @@ -107,7 +107,7 @@ class FiltersTest extends AbstractTestCase { $called = false; - $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { + $this->api->resourceType('users', $this->adapter, function (Type $type) use (&$called) { $type->filter('name', function ($query, $value, Context $context) use (&$called) { $this->assertSame($this->adapter->query, $query); $this->assertEquals('value', $value); diff --git a/tests/feature/MetaTest.php b/tests/feature/MetaTest.php index 536e2fc..b9f0255 100644 --- a/tests/feature/MetaTest.php +++ b/tests/feature/MetaTest.php @@ -34,7 +34,7 @@ class MetaTest extends AbstractTestCase { $adapter = new MockAdapter(['1' => (object) ['id' => '1']]); - $this->api->resource('users', $adapter, function (Type $type) use ($adapter) { + $this->api->resourceType('users', $adapter, function (Type $type) use ($adapter) { $type->meta('foo', function ($model, $context) use ($adapter) { $this->assertSame($adapter->models['1'], $model); $this->assertInstanceOf(Context::class, $context); diff --git a/tests/feature/ScopesTest.php b/tests/feature/ScopesTest.php index 8940be6..603dc35 100644 --- a/tests/feature/ScopesTest.php +++ b/tests/feature/ScopesTest.php @@ -38,7 +38,7 @@ class ScopesTest extends AbstractTestCase $this->scopeWasCalled = false; $this->api = new JsonApi('http://example.com'); - $this->api->resource('users', $this->adapter, function (Type $type) { + $this->api->resourceType('users', $this->adapter, function (Type $type) { $type->updatable(); $type->deletable(); $type->scope(function ($query, Context $context) { @@ -91,7 +91,7 @@ class ScopesTest extends AbstractTestCase public function test_scopes_are_applied_to_related_resources() { - $this->api->resource('pets', new MockAdapter, function (Type $type) { + $this->api->resourceType('pets', new MockAdapter, function (Type $type) { $type->hasOne('owner') ->type('users') ->includable(); @@ -107,14 +107,14 @@ class ScopesTest extends AbstractTestCase public function test_scopes_are_applied_to_polymorphic_related_resources() { - $this->api->resource('pets', new MockAdapter, function (Type $type) { + $this->api->resourceType('pets', new MockAdapter, function (Type $type) { $type->hasOne('owner') ->polymorphic(['users', 'organisations']) ->includable(); }); $organisationScopeWasCalled = false; - $this->api->resource('organisations', new MockAdapter, function (Type $type) use (&$organisationScopeWasCalled) { + $this->api->resourceType('organisations', new MockAdapter, function (Type $type) use (&$organisationScopeWasCalled) { $type->scope(function ($query, Context $context) use (&$organisationScopeWasCalled) { $organisationScopeWasCalled = true; }); diff --git a/tests/feature/SortingTest.php b/tests/feature/SortingTest.php index 2b727d3..eab58d9 100644 --- a/tests/feature/SortingTest.php +++ b/tests/feature/SortingTest.php @@ -38,7 +38,7 @@ class SortingTest extends AbstractTestCase public function test_attributes_are_not_sortable_by_default() { - $this->api->resource('users', $this->adapter, function (Type $type) { + $this->api->resourceType('users', $this->adapter, function (Type $type) { $type->attribute('name'); }); @@ -54,7 +54,7 @@ class SortingTest extends AbstractTestCase { $attribute = null; - $this->api->resource('users', $this->adapter, function (Type $type) use (&$attribute) { + $this->api->resourceType('users', $this->adapter, function (Type $type) use (&$attribute) { $attribute = $type->attribute('name')->sortable(); }); @@ -73,7 +73,7 @@ class SortingTest extends AbstractTestCase public function test_attributes_can_be_explicitly_not_sortable() { - $this->api->resource('users', $this->adapter, function (Type $type) { + $this->api->resourceType('users', $this->adapter, function (Type $type) { $type->attribute('name')->notSortable(); }); diff --git a/tests/specification/ContentNegotiationTest.php b/tests/specification/ContentNegotiationTest.php index 3485f3e..93a77c5 100644 --- a/tests/specification/ContentNegotiationTest.php +++ b/tests/specification/ContentNegotiationTest.php @@ -31,7 +31,7 @@ class ContentNegotiationTest extends AbstractTestCase public function setUp(): void { $this->api = new JsonApi('http://example.com'); - $this->api->resource('users', new MockAdapter(), function (Type $type) { + $this->api->resourceType('users', new MockAdapter(), function (Type $type) { // no fields }); }