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
@ -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

View File

@ -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
}

View File

@ -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;
}

View File

@ -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));
}
}
}

View File

@ -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(

View File

@ -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;
}
}

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;
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
abstract class Relationship extends Field
{
use HasMeta;
private $type;
private $linkage = false;
private $links = true;

View File

@ -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.
*/

View File

@ -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'];
}
}

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';
}
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.
}
}

View File

@ -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();

View File

@ -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();

View File

@ -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']]);

View File

@ -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();