This commit is contained in:
Toby Zerner 2019-11-18 13:46:45 +10:30
parent 5d76c0f45a
commit e5f9a6212a
16 changed files with 393 additions and 385 deletions

203
README.md
View File

@ -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 ### Defining Resources
@ -80,7 +80,7 @@ Define your API's resources using the `resource` method. The first argument is t
```php ```php
use Tobyz\JsonApiServer\Schema\Type; use Tobyz\JsonApiServer\Schema\Type;
$api->resource('comments', $adapter, function (Schema $schema) { $api->resource('comments', $adapter, function (Type $type) {
// define your schema // 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: Define an [attribute field](https://jsonapi.org/format/#document-resource-object-attributes) on your resource using the `attribute` method:
```php ```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: 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 ```php
$schema->attribute('firstName')->property('fname'); $type->attribute('firstName')
->property('fname');
``` ```
### Relationships ### 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: Define [relationship fields](https://jsonapi.org/format/#document-resource-object-relationships) on your resource using the `hasOne` and `hasMany` methods:
```php ```php
$schema->hasOne('user'); $type->hasOne('user');
$schema->hasMany('comments'); $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: 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 ```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. 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: 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 ```php
$schema->hasOne('mostRelevantPost') $type->hasOne('mostRelevantPost')
->noLinks(); ->noLinks();
``` ```
@ -137,10 +139,10 @@ $schema->hasOne('mostRelevantPost')
#### Relationship Linkage #### 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 ```php
$schema->hasOne('user') $type->hasOne('user')
->linkage(); ->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. To make a relationship available for [inclusion](https://jsonapi.org/format/#fetching-includes) via the `include` query parameter, call the `includable` method.
```php ```php
$schema->hasOne('user') $type->hasOne('user')
->includable(); ->includable();
``` ```
> **Warning:** Be careful when making to-many relationships includable as pagination is not supported. > **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 ```php
$schema->hasOne('user') $type->hasOne('user')
->includable() ->includable()
->loadable(function ($models, $request) { ->loadable(function ($models, ServerRequestInterface $request) {
collect($models)->load(['user' => function () { /* constraints */ }]); collect($models)->load(['user' => function () { /* constraints */ }]);
}); });
$schema->hasOne('user') $type->hasOne('user')
->includable() ->includable()
->notLoadable(); ->notLoadable();
``` ```
@ -176,19 +178,22 @@ $schema->hasOne('user')
Define a relationship as polymorphic using the `polymorphic` method: Define a relationship as polymorphic using the `polymorphic` method:
```php ```php
$schema->hasOne('commentable')->polymorphic(); $type->hasOne('commentable')
$schema->hasMany('taggable')->polymorphic(); ->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. 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 ### 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 ```php
$schema->attribute('firstName') $type->attribute('firstName')
->get(function ($model, $request) { ->get(function ($model, ServerRequestInterface $request) {
return ucfirst($model->first_name); 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: 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 ```php
$schema->scope(function ($query, $request, $id = null) { $type->scope(function ($query, ServerRequestInterface $request, string $id = null) {
$query->where('user_id', $request->getAttribute('userId')); $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 #### Field Visibility
You can specify logic to restrict the visibility of a field using the `visible` and `hidden` methods: You can specify logic to restrict the visibility of a field using the `visible` and `hidden` methods:
```php ```php
$schema->attribute('email') $type->attribute('email')
// Make a field always visible (default) // Make a field always visible (default)
->visible() ->visible()
// Make a field visible only if certain logic is met // 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'); return $model->id == $request->getAttribute('userId');
}) })
@ -225,22 +236,31 @@ $schema->attribute('email')
->hidden() ->hidden()
// Hide a field only if certain logic is met // Hide a field only if certain logic is met
->hidden(function ($model, $request) { ->hidden(function ($model, ServerRequestInterface $request) {
return $request->getAttribute('userIsSuspended'); 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 ### 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: 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 ```php
$schema->attribute('email') $type->attribute('email')
// Make an attribute writable // Make an attribute writable
->writable() ->writable()
// Make an attribute writable only if certain logic is met // 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'); return $model->id == $request->getAttribute('userId');
}) })
@ -248,7 +268,7 @@ $schema->attribute('email')
->readonly() ->readonly()
// Make an attribute writable *unless* certain logic is met // Make an attribute writable *unless* certain logic is met
->readonly(function ($model, $request) { ->readonly(function ($model, ServerRequestInterface $request) {
return $request->getAttribute('userIsSuspended'); 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: 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 ```php
$schema->attribute('joinedAt') $type->attribute('joinedAt')
->default(new DateTime); ->default(new DateTime);
$schema->attribute('ipAddress') $type->attribute('ipAddress')
->default(function ($request) { ->default(function (ServerRequestInterface $request) {
return $request->getServerParams()['REMOTE_ADDR'] ?? null; 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 ### 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: 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 ```php
$schema->attribute('email') $type->attribute('email')
->validate(function ($fail, $email, $model, $request, $field) { ->validate(function (callable $fail, $email, $model, ServerRequestInterface $request) {
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$fail('Invalid 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. This works for relationships too the related models will be retrieved via your adapter and passed into your validation function.
```php ```php
$schema->hasMany('groups') $type->hasMany('groups')
->validate(function ($fail, $groups, $model, $request, $field) { ->validate(function (callable $fail, array $groups, $model, ServerRequestInterface $request) {
foreach ($groups as $group) { foreach ($groups as $group) {
if ($group->id === 1) { if ($group->id === 1) {
$fail('You cannot assign this group'); $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 ```php
use Tobyz\JsonApiServer\Laravel\rules; use Tobyz\JsonApiServer\Laravel\rules;
$schema->attribute('username') $type->attribute('username')
->validate(rules('required', 'min:3', 'max:30')); ->validate(rules('required', 'min:3', 'max:30'));
``` ```
### Setters & Savers ### 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 ```php
$schema->attribute('firstName') $type->attribute('firstName')
->set(function ($model, $value, $request) { ->set(function ($model, $value, ServerRequestInterface $request) {
$model->first_name = strtolower($value); $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: 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 ```php
$schema->attribute('locale') $type->attribute('locale')
->save(function ($model, $value, $request) { ->save(function ($model, $value, ServerRequestInterface $request) {
$model->preferences() $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: 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 ```php
$schema->attribute('firstName') $type->attribute('firstName')
->filterable(); ->filterable();
$schema->hasMany('groups') $type->hasMany('groups')
->filterable(); ->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: 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 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 ```php
$schema->attribute('name') $type->filter('minPosts', function ($query, $value, ServerRequestInterface $request) {
->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) {
$query->where('postCount', '>=', $value); $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: 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 ```php
$schema->attribute('firstName') $type->attribute('firstName')
->sortable(); ->sortable();
$schema->attribute('lastName') $type->attribute('lastName')
->sortable(); ->sortable();
// e.g. GET /api/users?sort=lastName,firstName // 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: 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 ```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 ```php
$schema->sort('relevance', function ($query, $direction, $request) { $type->sort('relevance', function ($query, $direction, ServerRequestInterface $request) {
$query->orderBy('relevance', $direction); $query->orderBy('relevance', $direction);
}); });
``` ```
### Pagination ### 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 ```php
$schema->paginate(50); // default to listing 50 resources per page $type->paginate(50); // default to listing 50 resources per page
$schema->paginate(null); // default to listing all resources $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 ```php
$schema->limit(100); // set the maximum limit for resources per page to 100 $type->limit(100); // set the maximum limit for resources per page to 100
$schema->limit(null); // remove the maximum limit for resources per page $type->noLimit(); // remove the maximum limit for resources per page
``` ```
#### Countability #### 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 ```php
$schema->countable(); $type->countable();
$schema->uncountable(); $type->uncountable();
``` ```
### Meta Information ### 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 ```php
$schema->meta('author', 'Toby Zerner'); $type->meta('requestTime', function (ServerRequestInterface $request) {
$schema->meta('requestTime', function ($request) {
return new DateTime; return new DateTime;
}); });
``` ```
### Creating Resources ### 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 ```php
$schema->creatable(); $type->creatable();
$schema->creatable(function ($request) { $type->creatable(function (ServerRequestInterface $request) {
return $request->getAttribute('isAdmin'); return $request->getAttribute('isAdmin');
}); });
``` ```
#### Customizing the Model #### 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 ```php
$schema->create(function ($request) { $type->createModel(function (ServerRequestInterface $request) {
return new CustomModel; 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: 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 ```php
$schema->updatable(); $type->updatable();
$schema->updatable(function ($request) { $type->updatable(function (ServerRequestInterface $request) {
return $request->getAttribute('isAdmin'); 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: 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 ```php
$schema->deletable(); $type->deletable();
$schema->deletable(function ($request) { $type->deletable(function (ServerRequestInterface $request) {
return $request->getAttribute('isAdmin'); return $request->getAttribute('isAdmin');
}); });
``` ```
### Events ### 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 ```php
$schema->creating(function ($model, $request) { $type->onCreating(function ($model, ServerRequestInterface $request) {
// do something before a new model is saved // do something before a new model is saved
}); });
``` ```
@ -509,13 +508,7 @@ $api->authenticated();
## Contributing ## 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. Feel free to send pull requests or create issues if you come across problems or have great ideas.
### Running Tests
```bash
$ vendor/bin/phpunit
```
## License ## License

View File

@ -1,5 +1,6 @@
{ {
"name": "tobyz/json-api-server", "name": "tobyz/json-api-server",
"description": "A fully automated framework-agnostic JSON:API server implementation in PHP.",
"require": { "require": {
"php": "^7.2", "php": "^7.2",
"doctrine/inflector": "^1.3", "doctrine/inflector": "^1.3",
@ -19,7 +20,10 @@
"psr-4": { "psr-4": {
"Tobyz\\JsonApiServer\\": "src/" "Tobyz\\JsonApiServer\\": "src/"
}, },
"files": ["src/functions.php"] "files": [
"src/functions.php",
"src/functions_laravel.php"
]
}, },
"autoload-dev": { "autoload-dev": {
"psr-4": { "psr-4": {
@ -31,6 +35,9 @@
"helmich/phpunit-json-assert": "^3.0", "helmich/phpunit-json-assert": "^3.0",
"phpunit/phpunit": "^8.0" "phpunit/phpunit": "^8.0"
}, },
"scripts": {
"test": "phpunit"
},
"config": { "config": {
"sort-packages": true "sort-packages": true
} }

View File

@ -208,12 +208,12 @@ trait SavesData
$value = get_value($data, $field); $value = get_value($data, $field);
if ($setter = $field->getSetter()) { if ($setCallback = $field->getSetCallback()) {
$setter($model, $value, $request); $setCallback($model, $value, $request);
continue; continue;
} }
if ($field->getSaver()) { if ($field->getSaveCallback()) {
continue; continue;
} }

View File

@ -78,8 +78,8 @@ class Create implements RequestHandlerInterface
private function fillDefaultValues(array &$data, Request $request) private function fillDefaultValues(array &$data, Request $request)
{ {
foreach ($this->resource->getSchema()->getFields() as $field) { foreach ($this->resource->getSchema()->getFields() as $field) {
if (! has_value($data, $field) && ($default = $field->getDefault())) { if (! has_value($data, $field) && ($defaultCallback = $field->getDefaultCallback())) {
set_value($data, $field, $default($request)); set_value($data, $field, $defaultCallback($request));
} }
} }
} }

View File

@ -49,7 +49,7 @@ class Show implements RequestHandlerInterface
run_callbacks($this->resource->getSchema()->getListeners('show'), [$this->model, $request]); run_callbacks($this->resource->getSchema()->getListeners('show'), [$this->model, $request]);
$serializer = new Serializer($this->api, $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( return new JsonApiResponse(
new CompoundDocument( new CompoundDocument(

View File

@ -17,11 +17,13 @@ use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
use Tobyz\JsonApiServer\Adapter\AdapterInterface; use Tobyz\JsonApiServer\Adapter\AdapterInterface;
use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Exception\ForbiddenException;
use Tobyz\JsonApiServer\Exception\InternalServerErrorException; use Tobyz\JsonApiServer\Exception\InternalServerErrorException;
use Tobyz\JsonApiServer\Exception\MethodNotAllowedException; use Tobyz\JsonApiServer\Exception\MethodNotAllowedException;
use Tobyz\JsonApiServer\Exception\NotAcceptableException; use Tobyz\JsonApiServer\Exception\NotAcceptableException;
use Tobyz\JsonApiServer\Exception\NotImplementedException; use Tobyz\JsonApiServer\Exception\NotImplementedException;
use Tobyz\JsonApiServer\Exception\ResourceNotFoundException; use Tobyz\JsonApiServer\Exception\ResourceNotFoundException;
use Tobyz\JsonApiServer\Exception\UnauthorizedException;
use Tobyz\JsonApiServer\Exception\UnsupportedMediaTypeException; use Tobyz\JsonApiServer\Exception\UnsupportedMediaTypeException;
use Tobyz\JsonApiServer\Handler\Concerns\FindsResources; use Tobyz\JsonApiServer\Handler\Concerns\FindsResources;
use Tobyz\JsonApiServer\Http\MediaTypes; use Tobyz\JsonApiServer\Http\MediaTypes;
@ -34,6 +36,7 @@ final class JsonApi implements RequestHandlerInterface
private $resources = []; private $resources = [];
private $baseUrl; private $baseUrl;
private $authenticated = false;
public function __construct(string $baseUrl) public function __construct(string $baseUrl)
{ {
@ -50,6 +53,8 @@ final class JsonApi implements RequestHandlerInterface
/** /**
* Get defined resource types. * Get defined resource types.
*
* @return ResourceType[]
*/ */
public function getResources(): array public function getResources(): array
{ {
@ -209,6 +214,10 @@ final class JsonApi implements RequestHandlerInterface
$e = new InternalServerErrorException; $e = new InternalServerErrorException;
} }
if (! $this->authenticated && $e instanceof ForbiddenException) {
$e = new UnauthorizedException;
}
$errors = $e->getJsonApiErrors(); $errors = $e->getJsonApiErrors();
$status = $e->getJsonApiStatus(); $status = $e->getJsonApiStatus();
@ -226,4 +235,12 @@ final class JsonApi implements RequestHandlerInterface
{ {
return $this->baseUrl; return $this->baseUrl;
} }
/**
* Indicate that the consumer is authenticated.
*/
public function authenticated(): void
{
$this->authenticated = true;
}
} }

View File

@ -0,0 +1,45 @@
<?php
/*
* This file is part of Forust.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;
}
}

View File

@ -11,8 +11,12 @@
namespace Tobyz\JsonApiServer\Schema; namespace Tobyz\JsonApiServer\Schema;
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
abstract class Relationship extends Field abstract class Relationship extends Field
{ {
use HasMeta;
private $type; private $type;
private $linkage = false; private $linkage = false;
private $links = true; private $links = true;

View File

@ -12,14 +12,14 @@
namespace Tobyz\JsonApiServer\Schema; namespace Tobyz\JsonApiServer\Schema;
use Tobyz\JsonApiServer\Schema\Concerns\HasListeners; use Tobyz\JsonApiServer\Schema\Concerns\HasListeners;
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
use function Tobyz\JsonApiServer\negate; use function Tobyz\JsonApiServer\negate;
final class Type final class Type
{ {
use HasListeners; use HasListeners, HasMeta;
private $fields = []; private $fields = [];
private $meta = [];
private $filters = []; private $filters = [];
private $sortFields = []; private $sortFields = [];
private $perPage = 20; private $perPage = 20;
@ -95,32 +95,6 @@ final class Type
return $this->fields; 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. * Add a filter to the resource type.
*/ */

View File

@ -14,24 +14,15 @@ namespace Tobyz\JsonApiServer;
use DateTime; use DateTime;
use DateTimeInterface; use DateTimeInterface;
use JsonApiPhp\JsonApi as Structure; 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 Psr\Http\Message\ServerRequestInterface as Request;
use Tobyz\JsonApiServer\Adapter\AdapterInterface; use RuntimeException;
use Tobyz\JsonApiServer\Schema\Attribute;
use Tobyz\JsonApiServer\Schema\Relationship;
final class Serializer final class Serializer
{ {
protected $api; private $api;
protected $request; private $request;
protected $map = []; private $map = [];
protected $primary = []; private $primary = [];
public function __construct(JsonApi $api, Request $request) public function __construct(JsonApi $api, Request $request)
{ {
@ -39,45 +30,60 @@ final class Serializer
$this->request = $request; $this->request = $request;
} }
/**
* Add a primary resource to the document.
*/
public function add(ResourceType $resource, $model, array $include, bool $single = false): void public function add(ResourceType $resource, $model, array $include, bool $single = false): void
{ {
$data = $this->addToMap($resource, $model, $include, $single); $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(); $adapter = $resource->getAdapter();
$schema = $resource->getSchema(); $schema = $resource->getSchema();
$data = [ $data = [
'type' => $type = $resource->getType(), 'type' => $type = $resource->getType(),
'id' => $adapter->getId($model), 'id' => $id = $adapter->getId($model),
'fields' => [], 'fields' => [],
'links' => [], 'links' => [],
'meta' => [] 'meta' => []
]; ];
$resourceUrl = $this->api->getBaseUrl().'/'.$data['type'].'/'.$data['id']; $key = $this->key($data);
$url = $this->api->getBaseUrl()."/$type/$id";
$fields = $schema->getFields(); $fields = $schema->getFields();
$queryParams = $this->request->getQueryParams(); $queryParams = $this->request->getQueryParams();
if (isset($queryParams['fields'][$type])) { if (isset($queryParams['fields'][$type])) {
$fields = array_intersect_key($fields, array_flip(explode(',', $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) { foreach ($fields as $name => $field) {
if (isset($this->map[$key]['fields'][$name])) { if (isset($this->map[$key]['fields'][$name])) {
continue; continue;
@ -92,158 +98,39 @@ final class Serializer
} }
if ($field instanceof Schema\Attribute) { if ($field instanceof Schema\Attribute) {
$value = $this->attribute($field, $model, $adapter); $value = $this->attribute($field, $resource, $model);
} elseif ($field instanceof Schema\Relationship) { } elseif ($field instanceof Schema\Relationship) {
$isIncluded = isset($include[$name]); $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) { if (! $isIncluded && ! $field->isLinkage()) {
$value = $this->emptyRelationship($field, $resourceUrl); $value = $this->emptyRelationship($field, $members);
} elseif ($field instanceof Schema\HasOne) { } 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) { } 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['fields'][$name] = $value;
} }
} }
$data['links']['self'] = new SelfLink($resourceUrl); $data['links']['self'] = new Structure\Link\SelfLink($url);
$data['meta'] = $this->meta($schema->getMeta(), $model);
$metas = $schema->getMeta();
ksort($metas);
foreach ($metas as $name => $meta) {
$data['meta'][$name] = new Structure\Meta($meta->getName(), ($meta->getValue())($model, $this->request));
}
$this->merge($data); $this->merge($data);
return $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 private function merge($data): void
{ {
$key = $data['type'].':'.$data['id']; $key = $this->key($data);
if (isset($this->map[$key])) { if (isset($this->map[$key])) {
$this->map[$key]['fields'] = array_merge($this->map[$key]['fields'], $data['fields']); $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) { if ($getCallback = $field->getGetCallback()) {
return $this->map[$key]; $value = $getCallback($model, $this->request);
}, $this->primary); } 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 private function resourceObjects(array $items): array
@ -290,21 +264,36 @@ final class Serializer
private function resourceIdentifier(array $data): Structure\ResourceIdentifier private function resourceIdentifier(array $data): Structure\ResourceIdentifier
{ {
return new Structure\ResourceIdentifier( return new Structure\ResourceIdentifier($data['type'], $data['id']);
$data['type'],
$data['id']
);
} }
private function relatedResourceIdentifier(Schema\Relationship $field, $model) private function relatedResourceIdentifier(Schema\Relationship $field, $model)
{ {
$type = $field->getType(); $type = $field->getType();
$relatedResource = is_string($type)
$relatedResource = is_string($type) ? $this->api->getResource($type) : $this->resourceForModel($model); ? $this->api->getResource($type)
: $this->resourceForModel($model);
return $this->resourceIdentifier([ return $this->resourceIdentifier([
'type' => $relatedResource->getType(), 'type' => $relatedResource->getType(),
'id' => $relatedResource->getAdapter()->getId($model) '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'];
}
} }

44
src/functions_laravel.php Normal file
View File

@ -0,0 +1,44 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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);
}
}
};
}

View File

@ -53,12 +53,12 @@ class MockAdapter implements AdapterInterface
return $model->{$this->getProperty($attribute)} ?? 'default'; 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; 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)} ?? []; return $model->{$this->getProperty($relationship)} ?? [];
} }
@ -122,20 +122,13 @@ class MockAdapter implements AdapterInterface
$query->paginate[] = [$limit, $offset]; $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) { foreach ($models as $model) {
$model->load[] = $relationships; $model->load[] = $relationships;
} }
} }
public function loadIds(array $models, Relationship $relationship): void
{
foreach ($models as $model) {
$model->loadIds[] = $relationship;
}
}
private function getProperty(Field $field) private function getProperty(Field $field)
{ {
return $field->getProperty() ?: $field->getName(); return $field->getProperty() ?: $field->getName();
@ -146,26 +139,8 @@ class MockAdapter implements AdapterInterface
return isset($model['type']) && $model['type'] === $this->type; 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 public function count($query): int
{ {
return count($this->models); 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.
}
} }

View File

@ -142,7 +142,7 @@ class CreateTest extends AbstractTestCase
$this->api->resource('users', $adapter->reveal(), function (Type $type) use ($createdModel) { $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($createdModel) {
$type->creatable(); $type->creatable();
$type->create(function ($request) use ($createdModel) { $type->createModel(function ($request) use ($createdModel) {
$this->assertInstanceOf(ServerRequestInterface::class, $request); $this->assertInstanceOf(ServerRequestInterface::class, $request);
return $createdModel; return $createdModel;
}); });
@ -185,13 +185,13 @@ class CreateTest extends AbstractTestCase
$this->api->resource('users', $adapter->reveal(), function (Type $type) use ($adapter, $createdModel, &$called) { $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($adapter, $createdModel, &$called) {
$type->creatable(); $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->assertSame($createdModel, $model);
$this->assertInstanceOf(ServerRequestInterface::class, $request); $this->assertInstanceOf(ServerRequestInterface::class, $request);
$adapter->save($createdModel)->shouldNotHaveBeenCalled(); $adapter->save($createdModel)->shouldNotHaveBeenCalled();
$called++; $called++;
}); });
$type->created(function ($model, $request) use ($adapter, $createdModel, &$called) { $type->onCreated(function ($model, $request) use ($adapter, $createdModel, &$called) {
$this->assertSame($createdModel, $model); $this->assertSame($createdModel, $model);
$this->assertInstanceOf(ServerRequestInterface::class, $request); $this->assertInstanceOf(ServerRequestInterface::class, $request);
$adapter->save($createdModel)->shouldHaveBeenCalled(); $adapter->save($createdModel)->shouldHaveBeenCalled();

View File

@ -165,13 +165,13 @@ class DeleteTest extends AbstractTestCase
$this->api->resource('users', $adapter->reveal(), function (Type $type) use ($adapter, $deletingModel, &$called) { $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($adapter, $deletingModel, &$called) {
$type->deletable(); $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->assertSame($deletingModel, $model);
$this->assertInstanceOf(ServerRequestInterface::class, $request); $this->assertInstanceOf(ServerRequestInterface::class, $request);
$adapter->delete($deletingModel)->shouldNotHaveBeenCalled(); $adapter->delete($deletingModel)->shouldNotHaveBeenCalled();
$called++; $called++;
}); });
$type->deleted(function ($model, $request) use ($adapter, $deletingModel, &$called) { $type->onDeleted(function ($model, $request) use ($adapter, $deletingModel, &$called) {
$this->assertSame($deletingModel, $model); $this->assertSame($deletingModel, $model);
$this->assertInstanceOf(ServerRequestInterface::class, $request); $this->assertInstanceOf(ServerRequestInterface::class, $request);
$adapter->delete($deletingModel)->shouldHaveBeenCalled(); $adapter->delete($deletingModel)->shouldHaveBeenCalled();

View File

@ -29,21 +29,6 @@ class MetaTest extends AbstractTestCase
$this->api = new JsonApi('http://example.com'); $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() public function test_meta_fields_can_be_added_to_resources_with_a_closure()
{ {
$adapter = new MockAdapter(['1' => (object) ['id' => '1']]); $adapter = new MockAdapter(['1' => (object) ['id' => '1']]);

View File

@ -11,7 +11,6 @@
namespace Tobyz\Tests\JsonApiServer\feature; namespace Tobyz\Tests\JsonApiServer\feature;
use Psr\Http\Message\ServerRequestInterface;
use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Schema\Type; use Tobyz\JsonApiServer\Schema\Type;
@ -67,30 +66,6 @@ class SortingTest extends AbstractTestCase
$this->assertContains([$attribute, 'asc'], $this->adapter->query->sort); $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() public function test_attribute_sortable_callback_receives_correct_parameters()
{ {
$this->markTestIncomplete(); $this->markTestIncomplete();