From e5f9a6212a66a08e8b16b87f415907dbe2886acf Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 18 Nov 2019 13:46:45 +1030 Subject: [PATCH] wip --- README.md | 203 +++++++++--------- composer.json | 9 +- src/Handler/Concerns/SavesData.php | 6 +- src/Handler/Create.php | 4 +- src/Handler/Show.php | 2 +- src/JsonApi.php | 17 ++ src/Schema/Concerns/HasMeta.php | 45 ++++ src/Schema/Relationship.php | 4 + src/Schema/Type.php | 30 +-- src/Serializer.php | 333 ++++++++++++++--------------- src/functions_laravel.php | 44 ++++ tests/MockAdapter.php | 31 +-- tests/feature/CreateTest.php | 6 +- tests/feature/DeleteTest.php | 4 +- tests/feature/MetaTest.php | 15 -- tests/feature/SortingTest.php | 25 --- 16 files changed, 393 insertions(+), 385 deletions(-) create mode 100644 src/Schema/Concerns/HasMeta.php create mode 100644 src/functions_laravel.php diff --git a/README.md b/README.md index 93ec273..68d8cf3 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ try { } ``` -`Tobyz\JsonApiServer\JsonApi` is a [PSR-15 Request Handler](https://www.php-fig.org/psr/psr-15/). Instantiate it with your API's base URL. Convert your framework's request object into a [PSR-7 Request](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface) implementation, then let the `JsonApi` handler take it from there. Catch any exceptions and give them back to `JsonApi` if you want a JSON:API error response. +`Tobyz\JsonApiServer\JsonApi` is a [PSR-15 Request Handler](https://www.php-fig.org/psr/psr-15/). Instantiate it with your API's base URL. Convert your framework's request object into a [PSR-7 Request](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface) implementation, then let the `JsonApi` handler take it from there. Catch any exceptions and give them back to `JsonApi` to generate a JSON:API error response. ### Defining Resources @@ -80,7 +80,7 @@ Define your API's resources using the `resource` method. The first argument is t ```php use Tobyz\JsonApiServer\Schema\Type; -$api->resource('comments', $adapter, function (Schema $schema) { +$api->resource('comments', $adapter, function (Type $type) { // define your schema }); ``` @@ -98,13 +98,14 @@ $adapter = new EloquentAdapter(User::class); Define an [attribute field](https://jsonapi.org/format/#document-resource-object-attributes) on your resource using the `attribute` method: ```php -$schema->attribute('firstName'); +$type->attribute('firstName'); ``` By default the attribute will correspond to the property on your model with the same name. (`EloquentAdapter` will `snake_case` it automatically for you.) If you'd like it to correspond to a different property, use the `property` method: ```php -$schema->attribute('firstName')->property('fname'); +$type->attribute('firstName') + ->property('fname'); ``` ### Relationships @@ -112,14 +113,15 @@ $schema->attribute('firstName')->property('fname'); Define [relationship fields](https://jsonapi.org/format/#document-resource-object-relationships) on your resource using the `hasOne` and `hasMany` methods: ```php -$schema->hasOne('user'); -$schema->hasMany('comments'); +$type->hasOne('user'); +$type->hasMany('comments'); ``` By default the [resource type](https://jsonapi.org/format/#document-resource-object-identification) that the relationship corresponds to will be derived from the relationship name. In the example above, the `user` relationship would correspond to the `users` resource type, while `comments` would correspond to `comments`. If you'd like to use a different resource type, call the `type` method: ```php -$schema->hasOne('author')->type('people'); +$type->hasOne('author') + ->type('people'); ``` Like attributes, the relationship will automatically read and write to the relation on your model with the same name. If you'd like it to correspond to a different relation, use the `property` method. @@ -129,7 +131,7 @@ Like attributes, the relationship will automatically read and write to the relat Relationships include [`self`](https://jsonapi.org/format/#fetching-relationships) and [`related`](https://jsonapi.org/format/#document-resource-object-related-resource-links) links automatically. For some relationships it may not make sense to have them accessible via their own URL; you may disable these links by calling the `noLinks` method: ```php -$schema->hasOne('mostRelevantPost') +$type->hasOne('mostRelevantPost') ->noLinks(); ``` @@ -137,10 +139,10 @@ $schema->hasOne('mostRelevantPost') #### Relationship Linkage -By default relationships include no [resource linkage](https://jsonapi.org/format/#document-resource-object-linkage). You can toggle this (without forcing the related resources to be included) by calling the `linkage` or `noLinkage` methods. +By default relationships include no [resource linkage](https://jsonapi.org/format/#document-resource-object-linkage). You can toggle this by calling the `linkage` or `noLinkage` methods. ```php -$schema->hasOne('user') +$type->hasOne('user') ->linkage(); ``` @@ -151,22 +153,22 @@ $schema->hasOne('user') To make a relationship available for [inclusion](https://jsonapi.org/format/#fetching-includes) via the `include` query parameter, call the `includable` method. ```php -$schema->hasOne('user') +$type->hasOne('user') ->includable(); ``` > **Warning:** Be careful when making to-many relationships includable as pagination is not supported. -Relationships included via the `include` query parameter are automatically eager-loaded. However, you may wish to define your own eager-loading logic, or prevent a relationship from being eager-loaded. You can do so using the `loadable` and `notLoadable` methods: +Relationships included via the `include` query parameter are automatically [eager-loaded](https://laravel.com/docs/5.8/eloquent-relationships#eager-loading) by the adapter. However, you may wish to define your own eager-loading logic, or prevent a relationship from being eager-loaded. You can do so using the `loadable` and `notLoadable` methods: ```php -$schema->hasOne('user') +$type->hasOne('user') ->includable() - ->loadable(function ($models, $request) { + ->loadable(function ($models, ServerRequestInterface $request) { collect($models)->load(['user' => function () { /* constraints */ }]); }); -$schema->hasOne('user') +$type->hasOne('user') ->includable() ->notLoadable(); ``` @@ -176,19 +178,22 @@ $schema->hasOne('user') Define a relationship as polymorphic using the `polymorphic` method: ```php -$schema->hasOne('commentable')->polymorphic(); -$schema->hasMany('taggable')->polymorphic(); +$type->hasOne('commentable') + ->polymorphic(); + +$type->hasMany('taggable') + ->polymorphic(); ``` This will mean that the resource type associated with the relationship will be derived from the model of each related resource. Consequently, nested includes cannot be requested on these relationships. ### Getters -Use the `get` method to define custom retrieval logic for your field, instead of just reading the value straight from the model property. (If you're using Eloquent, you could also define [casts](https://laravel.com/docs/5.8/eloquent-mutators#attribute-casting) or [accessors](https://laravel.com/docs/5.8/eloquent-mutators#defining-an-accessor) on your model to achieve a similar thing.) +Use the `get` method to define custom retrieval logic for your field, instead of just reading the value straight from the model property. (If you're using Eloquent, you could also define attribute [casts](https://laravel.com/docs/5.8/eloquent-mutators#attribute-casting) or [accessors](https://laravel.com/docs/5.8/eloquent-mutators#defining-an-accessor) on your model to achieve a similar thing.) ```php -$schema->attribute('firstName') - ->get(function ($model, $request) { +$type->attribute('firstName') + ->get(function ($model, ServerRequestInterface $request) { return ucfirst($model->first_name); }); ``` @@ -200,24 +205,30 @@ $schema->attribute('firstName') You can restrict the visibility of the whole resource using the `scope` method. This will allow you to modify the query builder object provided by your adapter: ```php -$schema->scope(function ($query, $request, $id = null) { +$type->scope(function ($query, ServerRequestInterface $request, string $id = null) { $query->where('user_id', $request->getAttribute('userId')); }); ``` -The third argument to this callback (`$id`) is only populated if the request is to access a single resource. If the request is to a resource index, it will be `null`. +The third argument to this callback (`$id`) is only populated if the request is to access a single resource. If the request is to a resource listing, it will be `null`. + +If you want to prevent listing the resource altogether (ie. return `403 Forbidden` from `GET /articles`), you can use the `notListable` method: + +```php +$type->notListable(); +``` #### Field Visibility You can specify logic to restrict the visibility of a field using the `visible` and `hidden` methods: ```php -$schema->attribute('email') +$type->attribute('email') // Make a field always visible (default) ->visible() // Make a field visible only if certain logic is met - ->visible(function ($model, $request) { + ->visible(function ($model, ServerRequestInterface $request) { return $model->id == $request->getAttribute('userId'); }) @@ -225,22 +236,31 @@ $schema->attribute('email') ->hidden() // Hide a field only if certain logic is met - ->hidden(function ($model, $request) { + ->hidden(function ($model, ServerRequestInterface $request) { return $request->getAttribute('userIsSuspended'); }); ``` +#### Expensive Fields + +If a field is particularly expensive to calculate (for example, if you define a custom getter which runs a query), you can opt to only show the field when a single resource has been requested (ie. the field will not be included on resource listings). Use the `single` method to do this: + +```php +$type->attribute('expensive') + ->single(); +``` + ### Writability By default, fields are read-only. You can allow a field to be written to via `PATCH` and `POST` requests using the `writable` and `readonly` methods: ```php -$schema->attribute('email') +$type->attribute('email') // Make an attribute writable ->writable() // Make an attribute writable only if certain logic is met - ->writable(function ($model, $request) { + ->writable(function ($model, ServerRequestInterface $request) { return $model->id == $request->getAttribute('userId'); }) @@ -248,7 +268,7 @@ $schema->attribute('email') ->readonly() // Make an attribute writable *unless* certain logic is met - ->readonly(function ($model, $request) { + ->readonly(function ($model, ServerRequestInterface $request) { return $request->getAttribute('userIsSuspended'); }); ``` @@ -258,24 +278,24 @@ $schema->attribute('email') You can provide a default value for a field to be used when creating a new resource if there is no value provided by the consumer. Pass a value or a closure to the `default` method: ```php -$schema->attribute('joinedAt') +$type->attribute('joinedAt') ->default(new DateTime); -$schema->attribute('ipAddress') - ->default(function ($request) { +$type->attribute('ipAddress') + ->default(function (ServerRequestInterface $request) { return $request->getServerParams()['REMOTE_ADDR'] ?? null; }); ``` -If you're using Eloquent, you could also define [default attribute values](https://laravel.com/docs/5.8/eloquent#default-attribute-values) to achieve a similar thing, although you wouldn't have access to the request object. +If you're using Eloquent, you could also define [default attribute values](https://laravel.com/docs/5.8/eloquent#default-attribute-values) to achieve a similar thing (although you wouldn't have access to the request object). ### Validation You can ensure that data provided for a field is valid before it is saved. Provide a closure to the `validate` method, and call the first argument if validation fails: ```php -$schema->attribute('email') - ->validate(function ($fail, $email, $model, $request, $field) { +$type->attribute('email') + ->validate(function (callable $fail, $email, $model, ServerRequestInterface $request) { if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { $fail('Invalid email'); } @@ -285,8 +305,8 @@ $schema->attribute('email') This works for relationships too – the related models will be retrieved via your adapter and passed into your validation function. ```php -$schema->hasMany('groups') - ->validate(function ($fail, $groups, $model, $request, $field) { +$type->hasMany('groups') + ->validate(function (callable $fail, array $groups, $model, ServerRequestInterface $request) { foreach ($groups as $group) { if ($group->id === 1) { $fail('You cannot assign this group'); @@ -300,17 +320,17 @@ You can easily use Laravel's [Validation](https://laravel.com/docs/5.8/validatio ```php use Tobyz\JsonApiServer\Laravel\rules; -$schema->attribute('username') +$type->attribute('username') ->validate(rules('required', 'min:3', 'max:30')); ``` ### Setters & Savers -Use the `set` method to define custom mutation logic for your field, instead of just setting the value straight on the model property. (Of course, if you're using Eloquent, you could also define [casts](https://laravel.com/docs/5.8/eloquent-mutators#attribute-casting) or [mutators](https://laravel.com/docs/5.8/eloquent-mutators#defining-a-mutator) on your model to achieve a similar thing.) +Use the `set` method to define custom mutation logic for your field, instead of just setting the value straight on the model property. (If you're using Eloquent, you could also define attribute [casts](https://laravel.com/docs/5.8/eloquent-mutators#attribute-casting) or [mutators](https://laravel.com/docs/5.8/eloquent-mutators#defining-a-mutator) on your model to achieve a similar thing.) ```php -$schema->attribute('firstName') - ->set(function ($model, $value, $request) { +$type->attribute('firstName') + ->set(function ($model, $value, ServerRequestInterface $request) { $model->first_name = strtolower($value); }); ``` @@ -318,11 +338,11 @@ $schema->attribute('firstName') If your field corresponds to some other form of data storage rather than a simple property on your model, you can use the `save` method to provide a closure to be run _after_ your model is saved: ```php -$schema->attribute('locale') - ->save(function ($model, $value, $request) { +$type->attribute('locale') + ->save(function ($model, $value, ServerRequestInterface $request) { $model->preferences() - ->update(['value' => $value]) - ->where('key', 'locale'); + ->where('key', 'locale') + ->update(['value' => $value]); }); ``` @@ -331,13 +351,13 @@ $schema->attribute('locale') You can define a field as `filterable` to allow the resource index to be [filtered](https://jsonapi.org/recommendations/#filtering) by the field's value. This works for both attributes and relationships: ```php -$schema->attribute('firstName') +$type->attribute('firstName') ->filterable(); -$schema->hasMany('groups') +$type->hasMany('groups') ->filterable(); -// e.g. GET /api/users?filter[firstName]=Toby&filter[groups]=1,2,3 +// eg. GET /api/users?filter[firstName]=Toby&filter[groups]=1,2,3 ``` The `EloquentAdapter` automatically parses and applies `>`, `>=`, `<`, `<=`, and `..` operators on attribute filter values, so you can do: @@ -347,20 +367,10 @@ GET /api/users?filter[postCount]=>=10 GET /api/users?filter[postCount]=5..15 ``` -You can also pass a closure to customize how the filter is applied to the query builder object: +To define filters with custom logic, or ones that do not correspond to an attribute, use the `filter` method: ```php -$schema->attribute('name') - ->filterable(function ($query, $value, $request) { - $query->where('first_name', $value) - ->orWhere('last_name', $value); - }); -``` - -To define filters that do not correspond to an attribute, use the `filter` method: - -```php -$schema->filter('minPosts', function ($query, $value, $request) { +$type->filter('minPosts', function ($query, $value, ServerRequestInterface $request) { $query->where('postCount', '>=', $value); }); ``` @@ -370,93 +380,82 @@ $schema->filter('minPosts', function ($query, $value, $request) { You can define an attribute as `sortable` to allow the resource index to be [sorted](https://jsonapi.org/format/#fetching-sorting) by the attribute's value: ```php -$schema->attribute('firstName') +$type->attribute('firstName') ->sortable(); -$schema->attribute('lastName') +$type->attribute('lastName') ->sortable(); // e.g. GET /api/users?sort=lastName,firstName ``` -You can pass a closure to customize how the sort is applied to the query builder object: - -```php -$schema->attribute('name') - ->sortable(function ($query, $direction, $request) { - $query->orderBy('last_name', $direction) - ->orderBy('first_name', $direction); - }); -``` - You can set a default sort string to be used when the consumer has not supplied one using the `defaultSort` method on the schema builder: ```php -$schema->defaultSort('-updatedAt,-createdAt'); +$type->defaultSort('-updatedAt,-createdAt'); ``` -To define sortable criteria that does not correspond to an attribute, use the `sort` method: +To define sort fields with custom logic, or ones that do not correspond to an attribute, use the `sort` method: ```php -$schema->sort('relevance', function ($query, $direction, $request) { +$type->sort('relevance', function ($query, $direction, ServerRequestInterface $request) { $query->orderBy('relevance', $direction); }); ``` ### Pagination -By default, resource listings are automatically [paginated](https://jsonapi.org/format/#fetching-pagination) with 20 records per page. You can change this limit using the `paginate` method on the schema builder, or you can remove it by passing `null`: +By default, resource listings are automatically [paginated](https://jsonapi.org/format/#fetching-pagination) with 20 records per page. You can change this amount using the `paginate` method on the schema builder, or you can remove it by calling the `dontPaginate` method: ```php -$schema->paginate(50); // default to listing 50 resources per page -$schema->paginate(null); // default to listing all resources +$type->paginate(50); // default to listing 50 resources per page +$type->dontPaginate(); // default to listing all resources ``` -Consumers may request a different limit using the `page[limit]` query parameter. By default the maximum possible limit is capped at 50; you can change this cap using the `limit` method, or you can remove it by passing `null`: +Consumers may request a different limit using the `page[limit]` query parameter. By default the maximum possible limit is capped at 50; you can change this cap using the `limit` method, or you can remove it by calling the `noLimit` method: ```php -$schema->limit(100); // set the maximum limit for resources per page to 100 -$schema->limit(null); // remove the maximum limit for resources per page +$type->limit(100); // set the maximum limit for resources per page to 100 +$type->noLimit(); // remove the maximum limit for resources per page ``` #### Countability -By default a query will be performed to count the total number of resources in a collection. This will be used to populate a `count` attribute in the document's `meta` object, as well as the `last` pagination link. For some types of resources, or when a query is resource-intensive (especially when certain filters or sorting is applied), it may be undesirable to have this happen. So it can be toggled using the `countable` and `uncountable` methods: +By default a query will be performed to count the total number of resources in a collection. This will be used to populate a `total` attribute in the document's `meta` object, as well as the `last` pagination link. For some types of resources, or when a query is resource-intensive (especially when certain filters or sorting is applied), it may be undesirable to have this happen. So it can be toggled using the `countable` and `uncountable` methods: ```php -$schema->countable(); -$schema->uncountable(); +$type->countable(); +$type->uncountable(); ``` ### Meta Information -You can add meta information to the document or any relationship field using the `meta` method. Pass a value or a closure: +You can add meta information to any resource or relationship field using the `meta` method: ```php -$schema->meta('author', 'Toby Zerner'); -$schema->meta('requestTime', function ($request) { +$type->meta('requestTime', function (ServerRequestInterface $request) { return new DateTime; }); ``` ### Creating Resources -By default, resources are not [creatable](https://jsonapi.org/format/#crud-creating) (i.e. `POST` requests will return `403 Forbidden`). You can allow them to be created using the `creatable` and `notCreatable` methods on the schema builder. Pass a closure that returns `true` if the resource should be creatable, or no value to have it always creatable. +By default, resources are not [creatable](https://jsonapi.org/format/#crud-creating) (ie. `POST` requests will return `403 Forbidden`). You can allow them to be created using the `creatable` and `notCreatable` methods on the schema builder. Pass a closure that returns `true` if the resource should be creatable, or no value to have it always creatable. ```php -$schema->creatable(); +$type->creatable(); -$schema->creatable(function ($request) { +$type->creatable(function (ServerRequestInterface $request) { return $request->getAttribute('isAdmin'); }); ``` #### Customizing the Model -When creating a resource, an empty model is supplied by the adapter. You may wish to override this and provide a custom model in special circumstances. You can do so using the `create` method: +When creating a resource, an empty model is supplied by the adapter. You may wish to override this and provide a custom model in special circumstances. You can do so using the `createModel` method: ```php -$schema->create(function ($request) { +$type->createModel(function (ServerRequestInterface $request) { return new CustomModel; }); ``` @@ -466,9 +465,9 @@ $schema->create(function ($request) { By default, resources are not [updatable](https://jsonapi.org/format/#crud-updating) (i.e. `PATCH` requests will return `403 Forbidden`). You can allow them to be updated using the `updatable` and `notUpdatable` methods on the schema builder: ```php -$schema->updatable(); +$type->updatable(); -$schema->updatable(function ($request) { +$type->updatable(function (ServerRequestInterface $request) { return $request->getAttribute('isAdmin'); }); ``` @@ -478,21 +477,21 @@ $schema->updatable(function ($request) { By default, resources are not [deletable](https://jsonapi.org/format/#crud-deleting) (i.e. `DELETE` requests will return `403 Forbidden`). You can allow them to be deleted using the `deletable` and `notDeletable` methods on the schema builder: ```php -$schema->deletable(); +$type->deletable(); -$schema->deletable(function ($request) { +$type->deletable(function (ServerRequestInterface $request) { return $request->getAttribute('isAdmin'); }); ``` ### Events -The server will fire several events, allowing you to hook into the following points in a resource's lifecycle: `creating`, `created`, `updating`, `updated`, `saving`, `saved`, `deleting`, `deleted`. (Of course, if you're using Eloquent, you could also use [model events](https://laravel.com/docs/5.8/eloquent#events) to achieve a similar thing, although you wouldn't have access to the request object.) +The server will fire several events, allowing you to hook into the following points in a resource's lifecycle: `listing`, `listed`, `showing`, `shown`, `creating`, `created`, `updating`, `updated`, `deleting`, `deleted`. (If you're using Eloquent, you could also use [model events](https://laravel.com/docs/5.8/eloquent#events) to achieve a similar thing, although you wouldn't have access to the request object.) -To listen for an event, simply call the matching method name on the schema builder and pass a closure to be executed, which will receive the model and the request: +To listen for an event, simply call the matching method name on the schema and pass a closure to be executed, which will receive the model and the request: ```php -$schema->creating(function ($model, $request) { +$type->onCreating(function ($model, ServerRequestInterface $request) { // do something before a new model is saved }); ``` @@ -509,13 +508,7 @@ $api->authenticated(); ## Contributing -Feel free to send pull requests or create issues if you come across problems or have great ideas. See the [Contributing Guide](https://github.com/tobyz/json-api-server/blob/master/CONTRIBUTING.md) for more information. - -### Running Tests - -```bash -$ vendor/bin/phpunit -``` +Feel free to send pull requests or create issues if you come across problems or have great ideas. ## License diff --git a/composer.json b/composer.json index fc727f6..7a17686 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,6 @@ { "name": "tobyz/json-api-server", + "description": "A fully automated framework-agnostic JSON:API server implementation in PHP.", "require": { "php": "^7.2", "doctrine/inflector": "^1.3", @@ -19,7 +20,10 @@ "psr-4": { "Tobyz\\JsonApiServer\\": "src/" }, - "files": ["src/functions.php"] + "files": [ + "src/functions.php", + "src/functions_laravel.php" + ] }, "autoload-dev": { "psr-4": { @@ -31,6 +35,9 @@ "helmich/phpunit-json-assert": "^3.0", "phpunit/phpunit": "^8.0" }, + "scripts": { + "test": "phpunit" + }, "config": { "sort-packages": true } diff --git a/src/Handler/Concerns/SavesData.php b/src/Handler/Concerns/SavesData.php index 1c17de0..d89ab9b 100644 --- a/src/Handler/Concerns/SavesData.php +++ b/src/Handler/Concerns/SavesData.php @@ -208,12 +208,12 @@ trait SavesData $value = get_value($data, $field); - if ($setter = $field->getSetter()) { - $setter($model, $value, $request); + if ($setCallback = $field->getSetCallback()) { + $setCallback($model, $value, $request); continue; } - if ($field->getSaver()) { + if ($field->getSaveCallback()) { continue; } diff --git a/src/Handler/Create.php b/src/Handler/Create.php index bd5ff90..5cb8041 100644 --- a/src/Handler/Create.php +++ b/src/Handler/Create.php @@ -78,8 +78,8 @@ class Create implements RequestHandlerInterface private function fillDefaultValues(array &$data, Request $request) { foreach ($this->resource->getSchema()->getFields() as $field) { - if (! has_value($data, $field) && ($default = $field->getDefault())) { - set_value($data, $field, $default($request)); + if (! has_value($data, $field) && ($defaultCallback = $field->getDefaultCallback())) { + set_value($data, $field, $defaultCallback($request)); } } } diff --git a/src/Handler/Show.php b/src/Handler/Show.php index 0e29ed7..bac5a16 100644 --- a/src/Handler/Show.php +++ b/src/Handler/Show.php @@ -49,7 +49,7 @@ class Show implements RequestHandlerInterface run_callbacks($this->resource->getSchema()->getListeners('show'), [$this->model, $request]); $serializer = new Serializer($this->api, $request); - $serializer->addSingle($this->resource, $this->model, $include); + $serializer->add($this->resource, $this->model, $include, true); return new JsonApiResponse( new CompoundDocument( diff --git a/src/JsonApi.php b/src/JsonApi.php index d5d5f36..595d4ce 100644 --- a/src/JsonApi.php +++ b/src/JsonApi.php @@ -17,11 +17,13 @@ use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface; use Tobyz\JsonApiServer\Adapter\AdapterInterface; use Tobyz\JsonApiServer\Exception\BadRequestException; +use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Exception\InternalServerErrorException; use Tobyz\JsonApiServer\Exception\MethodNotAllowedException; use Tobyz\JsonApiServer\Exception\NotAcceptableException; use Tobyz\JsonApiServer\Exception\NotImplementedException; use Tobyz\JsonApiServer\Exception\ResourceNotFoundException; +use Tobyz\JsonApiServer\Exception\UnauthorizedException; use Tobyz\JsonApiServer\Exception\UnsupportedMediaTypeException; use Tobyz\JsonApiServer\Handler\Concerns\FindsResources; use Tobyz\JsonApiServer\Http\MediaTypes; @@ -34,6 +36,7 @@ final class JsonApi implements RequestHandlerInterface private $resources = []; private $baseUrl; + private $authenticated = false; public function __construct(string $baseUrl) { @@ -50,6 +53,8 @@ final class JsonApi implements RequestHandlerInterface /** * Get defined resource types. + * + * @return ResourceType[] */ public function getResources(): array { @@ -209,6 +214,10 @@ final class JsonApi implements RequestHandlerInterface $e = new InternalServerErrorException; } + if (! $this->authenticated && $e instanceof ForbiddenException) { + $e = new UnauthorizedException; + } + $errors = $e->getJsonApiErrors(); $status = $e->getJsonApiStatus(); @@ -226,4 +235,12 @@ final class JsonApi implements RequestHandlerInterface { return $this->baseUrl; } + + /** + * Indicate that the consumer is authenticated. + */ + public function authenticated(): void + { + $this->authenticated = true; + } } diff --git a/src/Schema/Concerns/HasMeta.php b/src/Schema/Concerns/HasMeta.php new file mode 100644 index 0000000..bf2a7c8 --- /dev/null +++ b/src/Schema/Concerns/HasMeta.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobyz\JsonApiServer\Schema\Concerns; + +use Tobyz\JsonApiServer\Schema\Meta; + +trait HasMeta +{ + private $meta = []; + + /** + * Add a meta attribute. + */ + public function meta(string $name, callable $value): Meta + { + return $this->meta[$name] = new Meta($name, $value); + } + + /** + * Remove a meta attribute. + */ + public function removeMeta(string $name): void + { + unset($this->meta[$name]); + } + + /** + * Get the meta attributes. + * + * @return Meta[] + */ + public function getMeta(): array + { + return $this->meta; + } +} diff --git a/src/Schema/Relationship.php b/src/Schema/Relationship.php index f4bd54c..8192bb6 100644 --- a/src/Schema/Relationship.php +++ b/src/Schema/Relationship.php @@ -11,8 +11,12 @@ namespace Tobyz\JsonApiServer\Schema; +use Tobyz\JsonApiServer\Schema\Concerns\HasMeta; + abstract class Relationship extends Field { + use HasMeta; + private $type; private $linkage = false; private $links = true; diff --git a/src/Schema/Type.php b/src/Schema/Type.php index c31080b..9bf674c 100644 --- a/src/Schema/Type.php +++ b/src/Schema/Type.php @@ -12,14 +12,14 @@ namespace Tobyz\JsonApiServer\Schema; use Tobyz\JsonApiServer\Schema\Concerns\HasListeners; +use Tobyz\JsonApiServer\Schema\Concerns\HasMeta; use function Tobyz\JsonApiServer\negate; final class Type { - use HasListeners; + use HasListeners, HasMeta; private $fields = []; - private $meta = []; private $filters = []; private $sortFields = []; private $perPage = 20; @@ -95,32 +95,6 @@ final class Type return $this->fields; } - /** - * Add a meta attribute to the resource type. - */ - public function meta(string $name, callable $value): Meta - { - return $this->meta[$name] = new Meta($name, $value); - } - - /** - * Remove a meta attribute from the resource type. - */ - public function removeMeta(string $name): void - { - unset($this->meta[$name]); - } - - /** - * Get the resource type's meta attributes. - * - * @return Meta[] - */ - public function getMeta(): array - { - return $this->meta; - } - /** * Add a filter to the resource type. */ diff --git a/src/Serializer.php b/src/Serializer.php index 1800e9d..6c8776a 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -14,24 +14,15 @@ namespace Tobyz\JsonApiServer; use DateTime; use DateTimeInterface; use JsonApiPhp\JsonApi as Structure; -use JsonApiPhp\JsonApi\EmptyRelationship; -use JsonApiPhp\JsonApi\Link\RelatedLink; -use JsonApiPhp\JsonApi\Link\SelfLink; -use JsonApiPhp\JsonApi\ResourceIdentifier; -use JsonApiPhp\JsonApi\ResourceIdentifierCollection; -use JsonApiPhp\JsonApi\ToMany; -use JsonApiPhp\JsonApi\ToOne; use Psr\Http\Message\ServerRequestInterface as Request; -use Tobyz\JsonApiServer\Adapter\AdapterInterface; -use Tobyz\JsonApiServer\Schema\Attribute; -use Tobyz\JsonApiServer\Schema\Relationship; +use RuntimeException; final class Serializer { - protected $api; - protected $request; - protected $map = []; - protected $primary = []; + private $api; + private $request; + private $map = []; + private $primary = []; public function __construct(JsonApi $api, Request $request) { @@ -39,45 +30,60 @@ final class Serializer $this->request = $request; } + /** + * Add a primary resource to the document. + */ public function add(ResourceType $resource, $model, array $include, bool $single = false): void { $data = $this->addToMap($resource, $model, $include, $single); - $this->primary[] = $data['type'].':'.$data['id']; + $this->primary[] = $this->key($data); } - public function addSingle(ResourceType $resource, $model, array $include): void + /** + * Get the serialized primary resources. + */ + public function primary(): array { - $this->add($resource, $model, $include, true); + $primary = array_map(function ($key) { + return $this->map[$key]; + }, $this->primary); + + return $this->resourceObjects($primary); } - private function addToMap(ResourceType $resource, $model, array $include, bool $single = false) + /** + * 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, bool $single = false): array { $adapter = $resource->getAdapter(); $schema = $resource->getSchema(); $data = [ 'type' => $type = $resource->getType(), - 'id' => $adapter->getId($model), + 'id' => $id = $adapter->getId($model), 'fields' => [], 'links' => [], 'meta' => [] ]; - $resourceUrl = $this->api->getBaseUrl().'/'.$data['type'].'/'.$data['id']; - + $key = $this->key($data); + $url = $this->api->getBaseUrl()."/$type/$id"; $fields = $schema->getFields(); - $queryParams = $this->request->getQueryParams(); if (isset($queryParams['fields'][$type])) { $fields = array_intersect_key($fields, array_flip(explode(',', $queryParams['fields'][$type]))); } - ksort($fields); - - $key = $data['type'].':'.$data['id']; - foreach ($fields as $name => $field) { if (isset($this->map[$key]['fields'][$name])) { continue; @@ -92,158 +98,39 @@ final class Serializer } if ($field instanceof Schema\Attribute) { - $value = $this->attribute($field, $model, $adapter); + $value = $this->attribute($field, $resource, $model); } elseif ($field instanceof Schema\Relationship) { $isIncluded = isset($include[$name]); - $isLinkage = evaluate($field->isLinkage(), [$this->request]); + $relationshipInclude = $isIncluded ? ($relationshipInclude[$name] ?? []) : null; + $links = $this->relationshipLinks($field, $url); + $meta = $this->meta($field->getMeta(), $model); + $members = array_merge($links, $meta); - if (! $isIncluded && ! $isLinkage) { - $value = $this->emptyRelationship($field, $resourceUrl); + if (! $isIncluded && ! $field->isLinkage()) { + $value = $this->emptyRelationship($field, $members); } elseif ($field instanceof Schema\HasOne) { - $value = $this->toOne($field, $model, $adapter, $isIncluded, $isLinkage, $include[$name] ?? [], $resourceUrl, $single); + $value = $this->toOne($field, $members, $resource, $model, $relationshipInclude, $single); } elseif ($field instanceof Schema\HasMany) { - $value = $this->toMany($field, $model, $adapter, $isIncluded, $isLinkage, $include[$name] ?? [], $resourceUrl); + $value = $this->toMany($field, $members, $resource, $model, $relationshipInclude); } } - if (!empty($value)) { + if (! empty($value)) { $data['fields'][$name] = $value; } } - $data['links']['self'] = new SelfLink($resourceUrl); - - $metas = $schema->getMeta(); - - ksort($metas); - - foreach ($metas as $name => $meta) { - $data['meta'][$name] = new Structure\Meta($meta->getName(), ($meta->getValue())($model, $this->request)); - } + $data['links']['self'] = new Structure\Link\SelfLink($url); + $data['meta'] = $this->meta($schema->getMeta(), $model); $this->merge($data); return $data; } - private function attribute(Attribute $field, $model, AdapterInterface $adapter): Structure\Attribute - { - if ($getter = $field->getGetCallback()) { - $value = $getter($model, $this->request); - } else { - $value = $adapter->getAttribute($model, $field); - } - - if ($value instanceof DateTimeInterface) { - $value = $value->format(DateTime::RFC3339); - } - - return new Structure\Attribute($field->getName(), $value); - } - - private function toOne(Schema\HasOne $field, $model, AdapterInterface $adapter, bool $isIncluded, bool $isLinkage, array $include, string $resourceUrl, bool $single = false) - { - $links = $this->getRelationshipLinks($field, $resourceUrl); - - $value = $isIncluded ? (($getter = $field->getGetCallback()) ? $getter($model, $this->request) : $adapter->getHasOne($model, $field, false)) : ($isLinkage ? $adapter->getHasOne($model, $field, true) : null); - - if (! $value) { - return new Structure\ToNull( - $field->getName(), - ...$links - ); - } - - if ($isIncluded) { - $identifier = $this->addRelated($field, $value, $include, $single); - } else { - $identifier = $this->relatedResourceIdentifier($field, $value); - } - - - return new ToOne( - $field->getName(), - $identifier, - ...$links - ); - } - - private function toMany(Schema\HasMany $field, $model, AdapterInterface $adapter, bool $isIncluded, bool $isLinkage, array $include, string $resourceUrl) - { - if ($getter = $field->getGetCallback()) { - $value = $getter($model, $this->request); - } else { - $value = ($isLinkage || $isIncluded) ? $adapter->getHasMany($model, $field, false) : null; - } - - $identifiers = []; - - if ($isIncluded) { - foreach ($value as $relatedModel) { - $identifiers[] = $this->addRelated($field, $relatedModel, $include); - } - } else { - foreach ($value as $relatedModel) { - $identifiers[] = $this->relatedResourceIdentifier($field, $relatedModel); - } - } - - return new ToMany( - $field->getName(), - new ResourceIdentifierCollection(...$identifiers), - ...$this->getRelationshipLinks($field, $resourceUrl) - ); - } - - private function emptyRelationship(Relationship $field, string $resourceUrl): ?EmptyRelationship - { - $links = $this->getRelationshipLinks($field, $resourceUrl); - - if (! $links) { - return null; - } - - return new EmptyRelationship( - $field->getName(), - ...$links - ); - } - - private function getRelationshipLinks(Relationship $field, string $resourceUrl): array - { - if (! $field->isLinks()) { - return []; - } - - return [ - new SelfLink($resourceUrl.'/relationships/'.$field->getName()), - new RelatedLink($resourceUrl.'/'.$field->getName()) - ]; - } - - private function addRelated(Relationship $field, $model, array $include, bool $single = false): ResourceIdentifier - { - $relatedResource = is_string($field->getType()) ? $this->api->getResource($field->getType()) : $this->resourceForModel($model); - - return $this->resourceIdentifier( - $this->addToMap($relatedResource, $model, $include, $single) - ); - } - - private function resourceForModel($model) - { - foreach ($this->api->getResources() as $resource) { - if ($resource->getAdapter()->represents($model)) { - return $resource; - } - } - - throw new \RuntimeException('No resource defined to handle model of type '.get_class($model)); - } - private function merge($data): void { - $key = $data['type'].':'.$data['id']; + $key = $this->key($data); if (isset($this->map[$key])) { $this->map[$key]['fields'] = array_merge($this->map[$key]['fields'], $data['fields']); @@ -254,20 +141,107 @@ final class Serializer } } - public function primary(): array + private function attribute(Schema\Attribute $field, ResourceType $resource, $model): Structure\Attribute { - $primary = array_map(function ($key) { - return $this->map[$key]; - }, $this->primary); + if ($getCallback = $field->getGetCallback()) { + $value = $getCallback($model, $this->request); + } else { + $value = $resource->getAdapter()->getAttribute($model, $field); + } - return $this->resourceObjects($primary); + if ($value instanceof DateTimeInterface) { + $value = $value->format(DateTime::RFC3339); + } + + return new Structure\Attribute($field->getName(), $value); } - public function included(): array + private function toOne(Schema\HasOne $field, array $members, ResourceType $resource, $model, ?array $include, bool $single) { - $included = array_values(array_diff_key($this->map, array_flip($this->primary))); + $included = $include !== null; - return $this->resourceObjects($included); + $model = ($included && $getCallback = $field->getGetCallback()) + ? $getCallback($model, $this->request) + : $resource->getAdapter()->getHasOne($model, $field, ! $included); + + if (! $model) { + return new Structure\ToNull($field->getName(), ...$members); + } + + $identifier = $include !== null + ? $this->addRelated($field, $model, $include, $single) + : $this->relatedResourceIdentifier($field, $model); + + return new Structure\ToOne($field->getName(), $identifier, ...$members); + } + + private function toMany(Schema\HasMany $field, array $members, ResourceType $resource, $model, ?array $include) + { + $included = $include !== null; + + $models = ($included && $getCallback = $field->getGetCallback()) + ? $getCallback($model, $this->request) + : $resource->getAdapter()->getHasMany($model, $field, ! $included); + + $identifiers = []; + + foreach ($models as $relatedModel) { + $identifiers[] = $included + ? $this->addRelated($field, $relatedModel, $include) + : $this->relatedResourceIdentifier($field, $relatedModel); + } + + return new Structure\ToMany( + $field->getName(), + new Structure\ResourceIdentifierCollection(...$identifiers), + ...$members + ); + } + + private function emptyRelationship(Schema\Relationship $field, array $members): ?Structure\EmptyRelationship + { + if (! $members) { + return null; + } + + return new Structure\EmptyRelationship($field->getName(), ...$members); + } + + /** + * @return Structure\Internal\RelationshipMember + */ + private function relationshipLinks(Schema\Relationship $field, string $url): array + { + if (! $field->isLinks()) { + return []; + } + + return [ + new Structure\Link\SelfLink($url.'/relationships/'.$field->getName()), + new Structure\Link\RelatedLink($url.'/'.$field->getName()) + ]; + } + + private function addRelated(Schema\Relationship $field, $model, array $include, bool $single = false): Structure\ResourceIdentifier + { + $relatedResource = is_string($field->getType()) + ? $this->api->getResource($field->getType()) + : $this->resourceForModel($model); + + return $this->resourceIdentifier( + $this->addToMap($relatedResource, $model, $include, $single) + ); + } + + private function resourceForModel($model): ResourceType + { + foreach ($this->api->getResources() as $resource) { + if ($resource->getAdapter()->represents($model)) { + return $resource; + } + } + + throw new RuntimeException('No resource defined to represent model of type '.get_class($model)); } private function resourceObjects(array $items): array @@ -290,21 +264,36 @@ final class Serializer private function resourceIdentifier(array $data): Structure\ResourceIdentifier { - return new Structure\ResourceIdentifier( - $data['type'], - $data['id'] - ); + return new Structure\ResourceIdentifier($data['type'], $data['id']); } private function relatedResourceIdentifier(Schema\Relationship $field, $model) { $type = $field->getType(); - - $relatedResource = is_string($type) ? $this->api->getResource($type) : $this->resourceForModel($model); + $relatedResource = is_string($type) + ? $this->api->getResource($type) + : $this->resourceForModel($model); return $this->resourceIdentifier([ 'type' => $relatedResource->getType(), 'id' => $relatedResource->getAdapter()->getId($model) ]); } + + /** + * @return Structure\Internal\RelationshipMember + */ + private function meta(array $items, $model): array + { + ksort($items); + + return array_map(function (Schema\Meta $meta) use ($model) { + return new Structure\Meta($meta->getName(), ($meta->getValue())($model, $this->request)); + }, $items); + } + + private function key(array $data) + { + return $data['type'].':'.$data['id']; + } } diff --git a/src/functions_laravel.php b/src/functions_laravel.php new file mode 100644 index 0000000..fd904eb --- /dev/null +++ b/src/functions_laravel.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobyz\JsonApiServer\Laravel; + +use Illuminate\Support\Facades\Validator; +use Psr\Http\Message\ServerRequestInterface as Request; +use Tobyz\JsonApiServer\Schema\Field; + +function rules($rules, array $messages = [], array $customAttributes = []) +{ + if (is_string($rules)) { + $rules = [$rules]; + } + + return function (callable $fail, $value, $model, Request $request, Field $field) use ($rules, $messages, $customAttributes) { + $key = $field->getName(); + $validationRules = [$key => []]; + + foreach ($rules as $k => $v) { + if (! is_numeric($k)) { + $validationRules[$key.'.'.$k] = $v; + } else { + $validationRules[$key][] = $v; + } + } + + $validation = Validator::make($value !== null ? [$key => $value] : [], $validationRules, $messages, $customAttributes); + + if ($validation->fails()) { + foreach ($validation->errors()->all() as $message) { + $fail($message); + } + } + }; +} diff --git a/tests/MockAdapter.php b/tests/MockAdapter.php index eb094d4..eaeb116 100644 --- a/tests/MockAdapter.php +++ b/tests/MockAdapter.php @@ -53,12 +53,12 @@ class MockAdapter implements AdapterInterface return $model->{$this->getProperty($attribute)} ?? 'default'; } - public function getHasOne($model, HasOne $relationship, array $fields = null) + public function getHasOne($model, HasOne $relationship, bool $linkage) { return $model->{$this->getProperty($relationship)} ?? null; } - public function getHasMany($model, HasMany $relationship, array $fields = null): array + public function getHasMany($model, HasMany $relationship, bool $linkage): array { return $model->{$this->getProperty($relationship)} ?? []; } @@ -122,20 +122,13 @@ class MockAdapter implements AdapterInterface $query->paginate[] = [$limit, $offset]; } - public function load(array $models, array $relationships, Closure $scope): void + public function load(array $models, array $relationships, Closure $scope, bool $linkage): void { foreach ($models as $model) { $model->load[] = $relationships; } } - public function loadIds(array $models, Relationship $relationship): void - { - foreach ($models as $model) { - $model->loadIds[] = $relationship; - } - } - private function getProperty(Field $field) { return $field->getProperty() ?: $field->getName(); @@ -146,26 +139,8 @@ class MockAdapter implements AdapterInterface return isset($model['type']) && $model['type'] === $this->type; } - /** - * Get the number of results from the query. - * - * @param $query - * @return int - */ public function count($query): int { return count($this->models); } - - /** - * Get the ID of the related resource for a has-one relationship. - * - * @param $model - * @param HasOne $relationship - * @return mixed|null - */ - public function getHasOneId($model, HasOne $relationship): ?string - { - // TODO: Implement getHasOneId() method. - } } diff --git a/tests/feature/CreateTest.php b/tests/feature/CreateTest.php index 0c4ff11..a51271c 100644 --- a/tests/feature/CreateTest.php +++ b/tests/feature/CreateTest.php @@ -142,7 +142,7 @@ class CreateTest extends AbstractTestCase $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($createdModel) { $type->creatable(); - $type->create(function ($request) use ($createdModel) { + $type->createModel(function ($request) use ($createdModel) { $this->assertInstanceOf(ServerRequestInterface::class, $request); return $createdModel; }); @@ -185,13 +185,13 @@ class CreateTest extends AbstractTestCase $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($adapter, $createdModel, &$called) { $type->creatable(); - $type->creating(function ($model, $request) use ($adapter, $createdModel, &$called) { + $type->onCreating(function ($model, $request) use ($adapter, $createdModel, &$called) { $this->assertSame($createdModel, $model); $this->assertInstanceOf(ServerRequestInterface::class, $request); $adapter->save($createdModel)->shouldNotHaveBeenCalled(); $called++; }); - $type->created(function ($model, $request) use ($adapter, $createdModel, &$called) { + $type->onCreated(function ($model, $request) use ($adapter, $createdModel, &$called) { $this->assertSame($createdModel, $model); $this->assertInstanceOf(ServerRequestInterface::class, $request); $adapter->save($createdModel)->shouldHaveBeenCalled(); diff --git a/tests/feature/DeleteTest.php b/tests/feature/DeleteTest.php index b35341d..023e376 100644 --- a/tests/feature/DeleteTest.php +++ b/tests/feature/DeleteTest.php @@ -165,13 +165,13 @@ class DeleteTest extends AbstractTestCase $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($adapter, $deletingModel, &$called) { $type->deletable(); - $type->deleting(function ($model, $request) use ($adapter, $deletingModel, &$called) { + $type->onDeleting(function ($model, $request) use ($adapter, $deletingModel, &$called) { $this->assertSame($deletingModel, $model); $this->assertInstanceOf(ServerRequestInterface::class, $request); $adapter->delete($deletingModel)->shouldNotHaveBeenCalled(); $called++; }); - $type->deleted(function ($model, $request) use ($adapter, $deletingModel, &$called) { + $type->onDeleted(function ($model, $request) use ($adapter, $deletingModel, &$called) { $this->assertSame($deletingModel, $model); $this->assertInstanceOf(ServerRequestInterface::class, $request); $adapter->delete($deletingModel)->shouldHaveBeenCalled(); diff --git a/tests/feature/MetaTest.php b/tests/feature/MetaTest.php index 699875c..f51d4a8 100644 --- a/tests/feature/MetaTest.php +++ b/tests/feature/MetaTest.php @@ -29,21 +29,6 @@ class MetaTest extends AbstractTestCase $this->api = new JsonApi('http://example.com'); } - public function test_meta_fields_can_be_added_to_resources_with_a_value() - { - $this->api->resource('users', new MockAdapter(), function (Type $type) { - $type->meta('foo', 'bar'); - }); - - $response = $this->api->handle( - $this->buildRequest('GET', '/users/1') - ); - - $document = json_decode($response->getBody(), true); - - $this->assertEquals(['foo' => 'bar'], $document['data']['meta']); - } - public function test_meta_fields_can_be_added_to_resources_with_a_closure() { $adapter = new MockAdapter(['1' => (object) ['id' => '1']]); diff --git a/tests/feature/SortingTest.php b/tests/feature/SortingTest.php index 914ce7d..2b727d3 100644 --- a/tests/feature/SortingTest.php +++ b/tests/feature/SortingTest.php @@ -11,7 +11,6 @@ namespace Tobyz\Tests\JsonApiServer\feature; -use Psr\Http\Message\ServerRequestInterface; use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\Schema\Type; @@ -67,30 +66,6 @@ class SortingTest extends AbstractTestCase $this->assertContains([$attribute, 'asc'], $this->adapter->query->sort); } - public function test_attributes_can_be_sortable_with_custom_logic() - { - $called = false; - - $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { - $type->attribute('name') - ->sortable(function ($query, $direction, $request) use (&$called) { - $this->assertSame($this->adapter->query, $query); - $this->assertEquals('asc', $direction); - $this->assertInstanceOf(ServerRequestInterface::class, $request); - - $called = true; - }); - }); - - $this->api->handle( - $this->buildRequest('GET', '/users') - ->withQueryParams(['sort' => 'name']) - ); - - $this->assertTrue($called); - $this->assertTrue(empty($this->adapter->query->sort)); - } - public function test_attribute_sortable_callback_receives_correct_parameters() { $this->markTestIncomplete();