Refactor, docs

This commit is contained in:
Toby Zerner 2020-11-21 16:30:16 +10:30
parent 467239c3c1
commit fbecdd96de
57 changed files with 12393 additions and 496 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
composer.lock
vendor
node_modules
.phpunit.result.cache

330
README.md
View File

@ -6,6 +6,57 @@
> **A fully automated [JSON:API](http://jsonapi.org) server implementation in PHP.**
> Define your schema, plug in your models, and we'll take care of the rest. 🍻
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
- [Installation](#installation)
- [Usage](#usage)
- [Handling Requests](#handling-requests)
- [Defining Resources](#defining-resources)
- [Attributes](#attributes)
- [Relationships](#relationships)
- [Relationship Links](#relationship-links)
- [Relationship Linkage](#relationship-linkage)
- [Relationship Inclusion](#relationship-inclusion)
- [Custom Loading Logic](#custom-loading-logic)
- [Polymorphic Relationships](#polymorphic-relationships)
- [Getters](#getters)
- [Visibility](#visibility)
- [Resource Visibility](#resource-visibility)
- [Field Visibility](#field-visibility)
- [Writability](#writability)
- [Default Values](#default-values)
- [Validation](#validation)
- [Transformers, Setters & Savers](#transformers-setters--savers)
- [Filtering](#filtering)
- [Sorting](#sorting)
- [Context](#context)
- [Pagination](#pagination)
- [Countability](#countability)
- [Meta Information](#meta-information)
- [Creating Resources](#creating-resources)
- [Customizing the Model](#customizing-the-model)
- [Customizing Creation Logic](#customizing-creation-logic)
- [Updating Resources](#updating-resources)
- [Customizing Update Logic](#customizing-update-logic)
- [Deleting Resources](#deleting-resources)
- [Events](#events)
- [Authentication](#authentication)
- [Laravel Helpers](#laravel-helpers)
- [Authorization](#authorization)
- [Validation](#validation-1)
- [Meta Information](#meta-information-1)
- [Document-level](#document-level)
- [Resource-level](#resource-level)
- [Relationship-level](#relationship-level)
- [Modifying Responses](#modifying-responses)
- [Examples](#examples)
- [Contributing](#contributing)
- [License](#license)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
## Installation
```bash
@ -15,31 +66,50 @@ composer require tobyz/json-api-server
## Usage
```php
use Tobyz\JsonApiServer\Adapter\EloquentAdapter;
use App\Models\{Article, Comment, User};
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Schema\Type;
use Tobyz\JsonApiServer\Laravel\EloquentAdapter;
use Tobyz\JsonApiServer\Laravel;
$api = new JsonApi('http://example.com/api');
$api->resource('articles', new EloquentAdapter(Article::class), function (Type $type) {
$type->attribute('title');
$type->hasOne('author')->type('people');
$type->hasMany('comments');
});
$type->attribute('title')
->writable()
->required();
$api->resource('people', new EloquentAdapter(User::class), function (Type $type) {
$type->attribute('firstName');
$type->attribute('lastName');
$type->attribute('twitter');
$type->hasOne('author')->type('users')
->includable()
->filterable();
$type->hasMany('comments')
->includable();
});
$api->resource('comments', new EloquentAdapter(Comment::class), function (Type $type) {
$type->attribute('body');
$type->hasOne('author')->type('people');
$type->creatable(Laravel\authenticated());
$type->updatable(Laravel\can('update-comment'));
$type->deletable(Laravel\can('delete-comment'));
$type->attribute('body')
->writable()
->required();
$type->hasOne('article')
->required();
$type->hasOne('author')->type('users')
->required();
});
$api->resource('users', new EloquentAdapter(User::class), function (Type $type) {
$type->attribute('firstName')->sortable();
$type->attribute('lastName')->sortable();
});
/** @var Psr\Http\Message\ServerRequestInterface $request */
/** @var Psr\Http\Message\Response $response */
/** @var Psr\Http\Message\ResponseInterface $response */
try {
$response = $api->handle($request);
} catch (Exception $e) {
@ -47,7 +117,7 @@ try {
}
```
Assuming you have a few [Eloquent](https://laravel.com/docs/5.8/eloquent) models set up, the above code will serve a **complete JSON:API that conforms to the [spec](https://jsonapi.org/format/)**, including support for:
Assuming you have a few [Eloquent](https://laravel.com/docs/8.0/eloquent) models set up, the above code will serve a **complete JSON:API that conforms to the [spec](https://jsonapi.org/format/)**, including support for:
- **Showing** individual resources (`GET /api/articles/1`)
- **Listing** resource collections (`GET /api/articles`)
@ -74,11 +144,11 @@ 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` to generate 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 or path. 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
Define your API's resources using the `resource` method. The first argument is the [resource type](https://jsonapi.org/format/#document-resource-object-identification). The second is an instance of `Tobyz\JsonApiServer\Adapter\AdapterInterface` which will allow the handler to interact with your models. The third is a closure in which you'll build the schema for your resource.
Define your API's resource types using the `resource` method. The first argument is the name of the [resource type](https://jsonapi.org/format/#document-resource-object-identification). The second is an instance of `Tobyz\JsonApiServer\Adapter\AdapterInterface` which will allow the handler to interact with your app's models. The third is a closure in which you'll build the schema for your resource type.
```php
use Tobyz\JsonApiServer\Schema\Type;
@ -88,7 +158,9 @@ $api->resource('comments', $adapter, function (Type $type) {
});
```
We provide an `EloquentAdapter` to hook your resources up with Laravel [Eloquent](https://laravel.com/docs/5.8/eloquent) models. Set it up with the name of the model that your resource represents. You can [implement your own adapter](https://github.com/tobyz/json-api-server/blob/master/src/Adapter/AdapterInterface.php) if you use a different ORM.
#### Adapters
We provide an `EloquentAdapter` to hook your resources up with Laravel [Eloquent](https://laravel.com/docs/8.0/eloquent) models. Set it up with the model class that your resource represents. You can [implement your own adapter](https://github.com/tobyz/json-api-server/blob/master/src/Adapter/AdapterInterface.php) if you use a different ORM.
```php
use Tobyz\JsonApiServer\Adapter\EloquentAdapter;
@ -104,7 +176,7 @@ Define an [attribute field](https://jsonapi.org/format/#document-resource-object
$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
$type->attribute('firstName')
@ -120,7 +192,7 @@ $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:
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
$type->hasOne('author')
@ -131,25 +203,25 @@ Like attributes, the relationship will automatically read and write to the relat
#### Relationship Links
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 `withoutLinks` method:
```php
$type->hasOne('mostRelevantPost')
->noLinks();
->withoutLinks();
```
> **Note:** Accessing these URLs is not yet implemented.
> **Note:** These URLs are not yet implemented.
#### Relationship Linkage
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.
By default, to-one relationships include [resource linkage](https://jsonapi.org/format/#document-resource-object-linkage), but to-many relationships do not. You can toggle this by calling the `withLinkage` or `withoutLinkage` methods.
```php
$type->hasOne('user')
->linkage();
$type->hasMany('users')
->withwithLinkage();
```
> **Warning:** Be careful when enabling linkage on to-many relationships as pagination is not supported.
> **Warning:** Be careful when enabling linkage on to-many relationships as pagination is not supported in relationships.
#### Relationship Inclusion
@ -162,33 +234,57 @@ $type->hasOne('user')
> **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](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:
Relationships included via the `include` query parameter are automatically [eager-loaded](https://laravel.com/docs/8.0/eloquent-relationships#eager-loading) by the adapter, and any type [scopes](#resource-visibility) are applied automatically. You can also apply additional scopes at the relationship level using the `scope` method:
```php
$type->hasOne('users')
->includable()
->scope(function ($query, ServerRequestInterface $request, HasOne $field) {
$query->where('is_listed', true);
});
```
#### Custom Loading Logic
Instead of using the adapter's eager-loading logic, you may wish to define your own for a relationship. You can do so using the `load` method. Beware that this can be complicated as eager-loading always takes place on the set of models at the root level; these are passed as the first parameter. The second parameter is an array of the `Relationship` objects that make up the nested inclusion trail leading to the current relationship. So, for example, if a request was made to `GET /categories?include=latestPost.user`, then the custom loading logic for the `user` relationship might look like this:
```php
$api->resource('categories', new EloquentAdapter(Models\Category::class), function (Type $type) {
$type->hasOne('latestPost')->type('posts')->includable(); // 1
});
$api->resource('posts', new EloquentAdapter(Models\Post::class), function (Type $type) {
$type->hasOne('user') // 2
->includable()
->load(function (array $models, array $relationships, Context $context) {
// Since this request is to the `GET /categories` endpoint, $models
// will be an array of Category models, and $relationships will be
// an array containing the objects [1, 2] above.
});
});
```
To prevent a relationship from being eager-loaded altogether, use the `dontLoad` method:
```php
$type->hasOne('user')
->includable()
->loadable(function ($models, ServerRequestInterface $request) {
collect($models)->load(['user' => function () { /* constraints */ }]);
});
$type->hasOne('user')
->includable()
->notLoadable();
->dontLoad();
```
#### Polymorphic Relationships
Define a relationship as polymorphic using the `polymorphic` method:
Define a polymorphic relationship using the `polymorphic` method. Optionally you may provide an array of allowed resource types:
```php
$type->hasOne('commentable')
->polymorphic();
$type->hasMany('taggable')
->polymorphic();
->polymorphic(['photos', 'videos']);
```
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.
Note that nested includes cannot be requested on polymorphic relationships.
### Getters
@ -196,7 +292,7 @@ Use the `get` method to define custom retrieval logic for your field, instead of
```php
$type->attribute('firstName')
->get(function ($model, ServerRequestInterface $request) {
->get(function ($model, Context $context) {
return ucfirst($model->first_name);
});
```
@ -205,17 +301,17 @@ $type->attribute('firstName')
#### Resource Visibility
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 the adapter:
```php
$type->scope(function ($query, ServerRequestInterface $request, string $id = null) {
$query->where('user_id', $request->getAttribute('userId'));
$type->scope(function ($query, Context $context) {
$query->where('user_id', $context->getRequest()->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 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:
If you want to prevent listing the resource altogether (ie. return `405 Method Not Allowed` from `GET /articles`), you can use the `notListable` method:
```php
$type->notListable();
@ -231,28 +327,19 @@ $type->attribute('email')
->visible()
// Make a field visible only if certain logic is met
->visible(function ($model, ServerRequestInterface $request) {
return $model->id == $request->getAttribute('userId');
->visible(function ($model, Context $context) {
return $model->id == $context->getRequest()->getAttribute('userId');
})
// Always hide a field (useful for write-only fields like password)
->hidden()
// Hide a field only if certain logic is met
->hidden(function ($model, ServerRequestInterface $request) {
return $request->getAttribute('userIsSuspended');
->hidden(function ($model, Context $context) {
return $context->getRequest()->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:
@ -263,16 +350,16 @@ $type->attribute('email')
->writable()
// Make an attribute writable only if certain logic is met
->writable(function ($model, ServerRequestInterface $request) {
return $model->id == $request->getAttribute('userId');
->writable(function ($model, Context $context) {
return $model->id == $context->getRequest()->getAttribute('userId');
})
// Make an attribute read-only (default)
->readonly()
// Make an attribute writable *unless* certain logic is met
->readonly(function ($model, ServerRequestInterface $request) {
return $request->getAttribute('userIsSuspended');
->readonly(function ($model, Context $context) {
return $context->getRequest()->getAttribute('userIsSuspended');
});
```
@ -285,8 +372,8 @@ $type->attribute('joinedAt')
->default(new DateTime);
$type->attribute('ipAddress')
->default(function (ServerRequestInterface $request) {
return $request->getServerParams()['REMOTE_ADDR'] ?? null;
->default(function (Context $context) {
return $context->getRequest()->getServerParams()['REMOTE_ADDR'] ?? null;
});
```
@ -298,8 +385,8 @@ You can ensure that data provided for a field is valid before it is saved. Provi
```php
$type->attribute('email')
->validate(function (callable $fail, $email, $model, ServerRequestInterface $request) {
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
->validate(function (callable $fail, $value, $model, Context $context) {
if (! filter_var($value, FILTER_VALIDATE_EMAIL)) {
$fail('Invalid email');
}
});
@ -309,7 +396,7 @@ This works for relationships too the related models will be retrieved via yo
```php
$type->hasMany('groups')
->validate(function (callable $fail, array $groups, $model, ServerRequestInterface $request) {
->validate(function (callable $fail, array $groups, $model, Context $context) {
foreach ($groups as $group) {
if ($group->id === 1) {
$fail('You cannot assign this group');
@ -318,37 +405,58 @@ $type->hasMany('groups')
});
```
You can easily use Laravel's [Validation](https://laravel.com/docs/5.8/validation) component for field validation with the `rules` function:
You can easily use Laravel's [Validation](https://laravel.com/docs/8.0/validation) component for field validation with the `rules` function:
```php
use Tobyz\JsonApiServer\Laravel\rules;
$type->attribute('username')
->validate(rules('required', 'min:3', 'max:30'));
->validate(rules(['required', 'min:3', 'max:30']));
```
### Setters & Savers
### Transformers, 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. (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.)
Use the `transform` method on an attribute to mutate any incoming value before it is saved to the model. (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
$type->attribute('firstName')
->set(function ($model, $value, ServerRequestInterface $request) {
$model->first_name = strtolower($value);
->transform(function ($value, Context $context) {
return ucfirst($value);
});
```
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:
Use the `set` method to define custom mutation logic for your field, instead of just setting the value straight on the model property.
```php
$type->attribute('firstName')
->set(function ($value, $model, Context $context) {
$model->first_name = ucfirst($value);
if ($model->first_name === 'Toby') {
$model->last_name = 'Zerner';
}
});
```
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 has been successfully saved. If specified, the adapter will NOT be used to set the field on the model.
```php
$type->attribute('locale')
->save(function ($model, $value, ServerRequestInterface $request) {
->save(function ($value, $model, Context $context) {
$model->preferences()
->where('key', 'locale')
->update(['value' => $value]);
});
```
Finally, you can add an event listener to be run after a field has been saved using the `onSaved` method:
```php
$type->attribute('email')
->onSaved(function ($value, $model, Context $context) {
event(new EmailWasChanged($model));
});
```
### Filtering
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:
@ -363,7 +471,7 @@ $type->hasMany('groups')
// 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 `>`, `>=`, `<`, `<=`, and `..` operators on attribute filter values are automatically parsed and applied, supporting queries like:
```
GET /api/users?filter[postCount]=>=10
@ -373,7 +481,7 @@ GET /api/users?filter[postCount]=5..15
To define filters with custom logic, or ones that do not correspond to an attribute, use the `filter` method:
```php
$type->filter('minPosts', function ($query, $value, ServerRequestInterface $request) {
$type->filter('minPosts', function ($query, $value, Context $context) {
$query->where('postCount', '>=', $value);
});
```
@ -401,11 +509,22 @@ $type->defaultSort('-updatedAt,-createdAt');
To define sort fields with custom logic, or ones that do not correspond to an attribute, use the `sort` method:
```php
$type->sort('relevance', function ($query, string $direction, ServerRequestInterface $request) {
$type->sort('relevance', function ($query, string $direction, Context $context) {
$query->orderBy('relevance', $direction);
});
```
### Context
The `Context` object is passed through to all callbacks. This object has a few useful methods:
```php
$context->getApi(); // Get the root API object
$context->getRequest(); // Get the current request being handled
$context->setRequest($request); // Modify the current request
$context->getField(); // In the context of a field callback, get the current field
```
### Pagination
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:
@ -424,7 +543,7 @@ $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 `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:
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
$type->countable();
@ -433,14 +552,23 @@ $type->uncountable();
### Meta Information
You can add meta information to any resource or relationship field using the `meta` method:
You can add meta information to a resource using the `meta` method:
```php
$type->meta('requestTime', function (ServerRequestInterface $request) {
$type->meta('requestTime', function ($model, Context $context) {
return new DateTime;
});
```
or relationship field :
```php
$type->hasOne('user')
->meta('updatedAt', function ($model, $user, Context $context) {
return $user->updated_at;
});
```
### Creating Resources
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.
@ -448,21 +576,29 @@ By default, resources are not [creatable](https://jsonapi.org/format/#crud-creat
```php
$type->creatable();
$type->creatable(function (ServerRequestInterface $request) {
$type->creatable(function (Context $context) {
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 `createModel` 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 `newModel` method:
```php
$type->createModel(function (ServerRequestInterface $request) {
$type->newModel(function (Context $context) {
return new CustomModel;
});
```
#### Customizing Creation Logic
```php
$type->create(function ($model, Context $context) {
// push to a queue
});
```
### Updating Resources
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:
@ -470,8 +606,16 @@ By default, resources are not [updatable](https://jsonapi.org/format/#crud-updat
```php
$type->updatable();
$type->updatable(function (ServerRequestInterface $request) {
return $request->getAttribute('isAdmin');
$type->updatable(function (Context $context) {
return $context->getRequest()->getAttribute('isAdmin');
});
```
#### Customizing Update Logic
```php
$type->update(function ($model, Context $context) {
// push to a queue
});
```
@ -483,7 +627,11 @@ By default, resources are not [deletable](https://jsonapi.org/format/#crud-delet
$type->deletable();
$type->deletable(function (ServerRequestInterface $request) {
return $request->getAttribute('isAdmin');
return $request->getAttr``ibute('isAdmin');
});
$type->delete(function ($model, Context $context) {
$model->delete();
});
```
@ -509,6 +657,22 @@ You should indicate to the server if the consumer is authenticated using the `au
$api->authenticated();
```
### Laravel Helpers
#### Authorization
#### Validation
### Meta Information
#### Document-level
#### Resource-level
#### Relationship-level
### Modifying Responses
## Examples
* TODO

View File

@ -4,10 +4,10 @@
"require": {
"php": "^7.2",
"doctrine/inflector": "^1.3",
"json-api-php/json-api": "^2.0",
"json-api-php/json-api": "^2.2",
"nyholm/psr7": "^1.3",
"psr/http-message": "^1.0",
"psr/http-server-handler": "^1.0",
"zendframework/zend-diactoros": "^2.1"
"psr/http-server-handler": "^1.0"
},
"license": "MIT",
"authors": [

60
docs/.vuepress/config.js Normal file
View File

@ -0,0 +1,60 @@
module.exports = {
base: '/json-api-server/',
title: 'json-api-server',
description: 'A fully automated JSON:API server implementation in PHP.',
evergreen: true,
themeConfig: {
search: false,
nav: [
{ text: 'Guide', link: '/' }
],
sidebar: [
{
title: 'Getting Started',
collapsable: false,
children: [
'/',
'install',
'requests',
]
},
{
title: 'Defining Resources',
collapsable: false,
children: [
'adapters',
'scopes',
'attributes',
'relationships',
'visibility',
'writing',
'filtering',
'sorting',
'pagination',
'meta',
]
},
{
title: 'Endpoints',
collapsable: false,
children: [
'list',
'create',
'update',
'delete',
]
},
{
title: 'Advanced',
collapsable: false,
children: [
'errors',
'laravel',
]
}
],
repo: 'tobyz/json-api-server',
editLinks: true,
docsDir: 'docs'
}
}

View File

@ -0,0 +1,4 @@
.theme-default-content > h1 + p {
font-size: 140%;
line-height: 1.5;
}

37
docs/adapters.md Normal file
View File

@ -0,0 +1,37 @@
# Adapters
Adapters connect your API schema to your application's data persistence layer.
You'll need to supply an adapter for each [resource type](https://jsonapi.org/format/#document-resource-object-identification) you define. You can define resource types using the `resource` method. For example:
```php
use Tobyz\JsonApiServer\Schema\Type;
$api->resource('users', $adapter, function (Type $type) {
// define your schema
});
```
### Eloquent Adapter
An `EloquentAdapter` is provided out of the box to hook your resources up with Laravel [Eloquent](https://laravel.com/docs/8.x/eloquent) models. Instantiate it with the model class that corresponds to your resource.
```php
use App\Models\User;
use Tobyz\JsonApiServer\Adapter\EloquentAdapter;
$adapter = new EloquentAdapter(User::class);
```
When using the Eloquent Adapter, the `$model` passed around in the schema will be an instance of the given model, and the `$query` will be a `Illuminate\Database\Eloquent\Builder` instance querying the model's table:
```php
$type->scope(function (Builder $query) { });
$type->attribute('name')
->get(function (User $user) { });
```
### Custom Adapters
For other ORMs or data persistence layers, you can [implement your own adapter](https://github.com/tobyz/json-api-server/blob/master/src/Adapter/AdapterInterface.php).

32
docs/attributes.md Normal file
View File

@ -0,0 +1,32 @@
# Attributes
Define an [attribute field](https://jsonapi.org/format/#document-resource-object-attributes) on your resource using the `attribute` method.
```php
$type->attribute('firstName');
```
By default, the attribute will read and write to the property on your model with the same name. (The Eloquent adapter will `snake_case` it automatically for you.) If you'd like it to correspond to a different property, use the `property` method:
```php
$type->attribute('firstName')
->property('fname');
```
## Getters
Use the `get` method to define custom retrieval logic for your attribute, instead of just reading the value straight from the model property.
```php
use Psr\Http\Message\ServerRequestInterface as Request;
use Tobyz\JsonApiServer\Schema\Attribute;
$type->attribute('firstName')
->get(function ($model, Request $request, Attribute $attribute) {
return ucfirst($model->first_name);
});
```
::: tip
If you're using Eloquent, you could also define attribute [casts](https://laravel.com/docs/8.x/eloquent-mutators#attribute-casting) or [accessors](https://laravel.com/docs/8.x/eloquent-mutators#defining-an-accessor) on your model to achieve a similar thing. However, the Request instance will not be available in this context.
:::

45
docs/create.md Normal file
View File

@ -0,0 +1,45 @@
# Creating Resources
You can allow resources to be [created](https://jsonapi.org/format/#crud-creating) using the `creatable` and `notCreatable` methods on the schema builder.
Optionally pass a closure that returns a boolean value.
```php
$type->creatable();
$type->creatable(function (Request $request) {
return $request->getAttribute('user')->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 `newModel` method:
```php
$type->newModel(function (Request $request) {
return new CustomModel;
});
```
## Events
### `onCreating`
Run before the model is saved.
```php
$type->onCreating(function ($model, Request $request) {
// do something
});
```
### `onCreated`
Run after the model is saved.
```php
$type->onCreated(function ($model, Request $request) {
// do something
});
```

35
docs/delete.md Normal file
View File

@ -0,0 +1,35 @@
# Deleting Resources
You can allow resources to be [deleted](https://jsonapi.org/format/#crud-deleting) using the `deletable` and `notDeletable` methods on the schema builder.
Optionally pass a closure that returns a boolean value.
```php
$type->deletable();
$type->deletable(function (Request $request) {
return $request->getAttribute('user')->isAdmin();
});
```
## Events
### `onDeleting`
Run before the model is deleted.
```php
$type->onDeleting(function ($model, Request $request) {
// do something
});
```
### `onDeleted`
Run after the model is deleted.
```php
$type->onDeleted(function ($model, Request $request) {
// do something
});
```

47
docs/errors.md Normal file
View File

@ -0,0 +1,47 @@
# Error Handling
The `JsonApi` class can produce [JSON:API error responses](https://jsonapi.org/format/#errors) from exceptions.
This is achieved by passing the caught exception into the `error` method.
```php
try {
$response = $api->handle($request);
} catch (Exception $e) {
$response = $api->error($e);
}
```
## Error Providers
Exceptions can implement the `ErrorProviderInterface` to determine what status code will be used in the response, and any JSON:API error objects to be rendered in the document.
The interface defines two methods:
* `getJsonApiStatus` which must return a string.
* `getJsonApiErrors` which must return an array of JSON-serializable content, such as [json-api-php](https://github.com/json-api-php/json-api) error objects
```php
use JsonApiPhp\JsonApi\Error;
use Tobyz\JsonApiServer\ErrorProviderInterface;
class ImATeapotException implements ErrorProviderInterface
{
public function getJsonApiErrors(): array
{
return [
new Error(
new Error\Title("I'm a teapot"),
new Error\Status($this->getJsonApiStatus())
)
];
}
public function getJsonApiStatus(): string
{
return '418';
}
}
```
Exceptions that do not implement this interface will result in a generic `500 Internal Server Error` response.

30
docs/filtering.md Normal file
View File

@ -0,0 +1,30 @@
# Filtering
You can define a field as `filterable` to allow the resource listing to be [filtered](https://jsonapi.org/recommendations/#filtering) by the field's value.
This works for both attributes and relationships:
```php
$type->attribute('firstName')
->filterable();
// GET /users?filter[firstName]=Toby
$type->hasMany('groups')
->filterable();
// GET /users?filter[groups]=1,2,3
```
The `>`, `>=`, `<`, `<=`, and `..` operators on attribute filter values are automatically parsed and applied, supporting queries like:
```
GET /users?filter[postCount]=>=10
GET /users?filter[postCount]=5..15
```
To define filters with custom logic, or ones that do not correspond to an attribute, use the `filter` method:
```php
$type->filter('minPosts', function ($query, $value, Request $request) {
$query->where('postCount', '>=', $value);
});
```

76
docs/index.md Normal file
View File

@ -0,0 +1,76 @@
# Introduction
**json-api-server** is an automated [JSON:API](http://jsonapi.org) server implementation in PHP.
It allows you to define your API's schema, and then use an [Adapter](adapters.md) to connect it to your application's models and database layer, without having to worry about any of the server boilerplate, routing, query parameters, or JSON:API document formatting.
Based on your schema definition, the package will serve a **complete JSON:API that conforms to the [spec](https://jsonapi.org/format/)**, including support for:
- **Showing** individual resources (`GET /api/articles/1`)
- **Listing** resource collections (`GET /api/articles`)
- **Sorting**, **filtering**, **pagination**, and **sparse fieldsets**
- **Compound documents** with inclusion of related resources
- **Creating** resources (`POST /api/articles`)
- **Updating** resources (`PATCH /api/articles/1`)
- **Deleting** resources (`DELETE /api/articles/1`)
- **Error handling**
The schema definition is extremely powerful and lets you easily apply [permissions](visibility.md), [transformations](writing.md#transformers), [validation](writing.md#validation), and custom [filtering](filtering.md) and [sorting](sorting.md) logic to build a fully functional API in minutes.
### Example
The following example uses Eloquent models in a Laravel application. However, json-api-server can be used with any framework that can deal in PSR-7 Requests and Responses. Custom [Adapters](adapters.md) can be used to support other ORMs and data persistence layers.
```php
use App\Models\{Article, Comment, User};
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Schema\Type;
use Tobyz\JsonApiServer\Laravel\EloquentAdapter;
use Tobyz\JsonApiServer\Laravel;
$api = new JsonApi('http://example.com/api');
$api->resource('articles', new EloquentAdapter(Article::class), function (Type $type) {
$type->attribute('title')
->writable()
->validate(Laravel\rules('required'));
$type->hasOne('author')->type('users')
->includable()
->filterable();
$type->hasMany('comments')
->includable();
});
$api->resource('comments', new EloquentAdapter(Comment::class), function (Type $type) {
$type->creatable(Laravel\authenticated());
$type->updatable(Laravel\can('update-comment'));
$type->deletable(Laravel\can('delete-comment'));
$type->attribute('body')
->writable()
->validate(Laravel\rules('required'));
$type->hasOne('article')
->writable()->once()
->validate(Laravel\rules('required'));
$type->hasOne('author')->type('users')
->writable()->once()
->validate(Laravel\rules('required'));
});
$api->resource('users', new EloquentAdapter(User::class), function (Type $type) {
$type->attribute('firstName')->sortable();
$type->attribute('lastName')->sortable();
});
/** @var Psr\Http\Message\ServerRequestInterface $request */
/** @var Psr\Http\Message\ResponseInterface $response */
try {
$response = $api->handle($request);
} catch (Exception $e) {
$response = $api->error($e);
}
```

7
docs/install.md Normal file
View File

@ -0,0 +1,7 @@
# Installation
To install, simply require the package via Composer.
```bash
composer require tobyz/json-api-server
```

34
docs/laravel.md Normal file
View File

@ -0,0 +1,34 @@
# Laravel Helpers
## Validation
### `rules`
Use Laravel's [Validation component](https://laravel.com/docs/8.x/validation) as a [field validator](writing.md#validation).
```php
use Tobyz\JsonApiServer\Laravel;
$type->attribute('name')
->validate(Laravel\rules('required|min:3|max:20'));
```
Pass a string or array of validation rules to be applied to the value. You can also pass an array of custom messages and custom attribute names as the second and third arguments.
## Authentication
### `authenticated`
A shortcut to call `Auth::check()`.
```php
$type->creatable(Laravel\authenticated());
```
### `can`
Use Laravel's [Gate component](https://laravel.com/docs/8.x/authorization) to check if the given ability is allowed. If this is used in the context of a model (eg. `updatable`, `deletable`, or on a field), then the model will be passed to the gate check as well.
```php
$type->updatable(Laravel\can('update-post'));
```

35
docs/list.md Normal file
View File

@ -0,0 +1,35 @@
# Listing Resources
For each resource type, a `GET /{type}` endpoint is exposed to list resources.
If you want to restrict the ability to list a resource type, use the `listable` and `notListable` methods. You can optionally pass a closure that returns a boolean value.
```php
$type->notListable();
$type->listable(function (Request $request) {
return $request->getAttribute('user')->isAdmin();
});
```
## Events
### `onListing`
Run before [scopes](scopes.md) are applied to the `$query` and results are retrieved.
```php
$type->onListing(function ($query, Request $request) {
// do something
});
```
### `onListed`
Run after models and relationships have been retrieved, but before they are serialized into a JSON:API document.
```php
$type->onListed(function ($models, Request $request) {
// do something
});
```

27
docs/meta.md Normal file
View File

@ -0,0 +1,27 @@
# Meta Information
You can add meta information at various levels of the document using the `meta` method.
## Document Meta
To add meta information at the top-level, call `meta` on the `JsonApi` instance:
```php
$api->meta('requestTime', function (Request $request) {
return new DateTime;
});
```
## Resource Meta
To add meta information at the resource-level, call `meta` on the schema builder.
```php
$type->meta('updatedAt', function ($model, Request $request) {
return $model->updated_at;
});
```
## Relationship Meta
Meta information can also be [added to relationships](relationships.md#meta-information).

28
docs/pagination.md Normal file
View File

@ -0,0 +1,28 @@
# Pagination
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
$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 calling the `noLimit` method:
```php
$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 `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
$type->countable();
$type->uncountable();
```

120
docs/relationships.md Normal file
View File

@ -0,0 +1,120 @@
# Relationships
Define [relationship fields](https://jsonapi.org/format/#document-resource-object-relationships) on your resource using the `hasOne` and `hasMany` methods.
```php
$type->hasOne('user');
$type->hasMany('comments');
```
By default, the resource type that the relationship corresponds to will be the pluralized form of 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
$type->hasOne('author')
->type('people');
```
By default, the relationship will 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:
```php
$type->hasOne('author')
->property('user');
```
## Resource Linkage
By default, to-one relationships will have [resource linkage](https://jsonapi.org/format/#document-resource-object-linkage), but to-many relationships will not. You can toggle this by calling the `withLinkage` or `withoutLinkage` methods.
```php
$type->hasMany('users')
->withLinkage();
```
::: danger
Be careful when enabling linkage on to-many relationships as pagination is not supported.
:::
## Relationship Inclusion
To make a relationship available for [inclusion](https://jsonapi.org/format/#fetching-includes) via the `include` query parameter, call the `includable` method.
```php
$type->hasOne('user')
->includable();
```
::: danger
Be careful when making to-many relationships includable as pagination is not supported.
:::
Relationships included via the `include` query parameter are automatically [eager-loaded](https://laravel.com/docs/8.x/eloquent-relationships#eager-loading) by the adapter, and any type [scopes](scopes) are applied automatically. You can also apply additional scopes at the relationship level using the `scope` method:
```php
use Psr\Http\Message\ServerRequestInterface as Request;
use Tobyz\JsonApiServer\Schema\HasOne;
$type->hasOne('users')
->includable()
->scope(function ($query, Request $request, HasOne $field) {
$query->where('is_listed', true);
});
```
To prevent a relationship from being eager-loaded, use the `dontLoad` method:
```php
$type->hasOne('user')
->includable()
->dontLoad();
```
### Custom Loading Logic
Instead of using the adapter's eager-loading logic, you may wish to define your own for a relationship. You can do so using the `load` method.
Beware that this can be complicated as eager-loading always takes place on the set of models at the root level of the document; these are passed as the first parameter. The second parameter is an array of the `Relationship` objects that make up the nested inclusion trail leading to the current relationship.
So, for example, if a request was made to `GET /categories?include=latestPost.user`, then the custom loading logic for the `user` relationship might look like this:
```php
$api->resource('categories', new EloquentAdapter(Models\Category::class), function (Type $type) {
$type->hasOne('latestPost')->type('posts')->includable(); // 1
});
$api->resource('posts', new EloquentAdapter(Models\Post::class), function (Type $type) {
$type->hasOne('user') // 2
->includable()
->load(function (array $models, array $relationships, Request $request, HasOne $field) {
// Since this request is to the `GET /categories` endpoint, $models
// will be an array of Category models, and $relationships will be
// an array containing the objects [1, 2] above.
});
});
```
## Polymorphic Relationships
Define a polymorphic relationship using the `polymorphic` method. Optionally you may provide an array of allowed resource types:
```php
$type->hasOne('commentable')
->polymorphic();
$type->hasMany('taggable')
->polymorphic(['photos', 'videos']);
```
::: warning
Note that nested includes cannot be requested on polymorphic relationships.
:::
## Meta Information
You can add meta information to a relationship using the `meta` method:
```php
$type->hasOne('user')
->meta('updatedAt', function ($model, $user, Request $request) {
return $user->updated_at;
});
```

33
docs/requests.md Normal file
View File

@ -0,0 +1,33 @@
# Handling Requests
The `JsonApi` class is a [PSR-15 request handler](https://www.php-fig.org/psr/psr-15/).
Instantiate it with your **API's base path**, then pass in a PSR-7 request and you'll get back a PSR-7 response. You should catch any exceptions and pass them back into the `error` method to generate a JSON:API error document.
```php
use Tobyz\JsonApiServer\JsonApi;
$api = new JsonApi('/api');
/** @var Psr\Http\Message\ServerRequestInterface $request */
/** @var Psr\Http\Message\ResponseInterface $response */
try {
$response = $api->handle($request);
} catch (Exception $e) {
$response = $api->error($e);
}
```
::: tip
In Laravel, you'll need to [convert the Laravel request into a PSR-7 request](https://laravel.com/docs/8.x/requests#psr7-requests) before you can pass it into `JsonApi`. You can then return the response directly from the route or controller the framework will automatically convert it back into a Laravel response and display it.
:::
## Authentication
You (or your framework) are responsible for performing authentication.
Often you will need to access information about the authenticated user inside of your schema for example, when [scoping](scopes) which resources a visible within the API. An effective way to pass on this information is by setting an attribute on your Request object before passing it into the request handler.
```php
$request = $request->withAttribute('user', $user);
```

17
docs/scopes.md Normal file
View File

@ -0,0 +1,17 @@
# Scopes
Restrict the visibility of resources, and make other query modifications, using the `scope` method.
This `scope` method allows you to modify the query builder object provided by the adapter. This is the perfect opportunity to apply conditions to the query to restrict which resources are visible in the API.
For example, to make it so the authenticated user can only see their own posts:
```php
$type->scope(function ($query, ServerRequestInterface $request) {
$query->where('user_id', $request->getAttribute('userId'));
});
```
A resource type's scope is global it will also be applied when that resource is being [included](relationships) as a relationship.
You can define multiple scopes per resource type, and they will be applied in order.

27
docs/sorting.md Normal file
View File

@ -0,0 +1,27 @@
# Sorting
You can define an attribute as `sortable` to allow the resource listing to be [sorted](https://jsonapi.org/format/#fetching-sorting) by the attribute's value.
```php
$type->attribute('firstName')
->sortable();
$type->attribute('lastName')
->sortable();
// GET /users?sort=lastName,firstName
```
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
$type->defaultSort('-updatedAt,-createdAt');
```
To define sort fields with custom logic, or ones that do not correspond to an attribute, use the `sort` method:
```php
$type->sort('relevance', function ($query, string $direction, Request $request) {
$query->orderBy('relevance', $direction);
});
```

35
docs/update.md Normal file
View File

@ -0,0 +1,35 @@
# Updating Resources
You can allow resources to be [updated](https://jsonapi.org/format/#crud-updating) using the `updatable` and `notUpdatable` methods on the schema builder.
Optionally pass a closure that returns a boolean value.
```php
$type->updatable();
$type->updatable(function (Request $request) {
return $request->getAttribute('user')->isAdmin();
});
```
## Events
### `onUpdating`
Run before the model is saved.
```php
$type->onUpdating(function ($model, Request $request) {
// do something
});
```
### `onUpdated`
Run after the model is saved.
```php
$type->onUpdated(function ($model, Request $request) {
// do something
});
```

22
docs/visibility.md Normal file
View File

@ -0,0 +1,22 @@
# Field Visibility
Restrict the visibility of a field using the `visible` and `hidden` methods.
You can optionally supply a closure to these methods which will receive the model instance, and should return a boolean value.
For example, the following schema will make an email attribute that only appears when the authenticated user is viewing their own profile:
```php
$type->attribute('email')
->visible(function ($model, Request $request, Attribute $field) {
return $model->id === $request->getAttribute('userId');
});
```
Hiding a field completely is useful when you want it the field to be available for [writing](writing.md) but not reading for example, a password field.
```php
$type->attribute('password')
->hidden()
->writable();
```

126
docs/writing.md Normal file
View File

@ -0,0 +1,126 @@
# Field Writability
By default, fields are read-only. You can allow a field to be written to in `PATCH` and `POST` requests using the `writable` and `readonly` methods.
You can optionally supply a closure to these methods which will receive the model instance, and should return a boolean value.
For example, the following schema will make an email attribute that is only writable by the self:
```php
$type->attribute('email')
->writable(function ($model, Request $request, Attribute $field) {
return $model->id === $request->getAttribute('userId');
});
```
## Writable Once
You may want a field to only be writable when creating a new resource, but not when an existing resource is being updated. This can be achieved by calling the `once` method:
```php
$type->hasOne('author')
->writable()->once();
```
## Default Values
You can provide a default value 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
$type->attribute('joinedAt')
->default(new DateTime);
$type->attribute('ipAddress')
->default(function (Request $request, Attribute $attribute) {
return $request->getServerParams()['REMOTE_ADDR'] ?? null;
});
```
::: tip
If you're using Eloquent, you could also define [default attribute values](https://laravel.com/docs/8.x/eloquent#default-attribute-values) to achieve a similar thing. However, the Request instance will not be available in this context.
:::
## Validation
You can ensure that data provided for a field is valid before the resource is saved. Provide a closure to the `validate` method, and call the first argument if validation fails:
```php
$type->attribute('email')
->validate(function (callable $fail, $value, $model, Request $request, Attribute $attribute) {
if (! filter_var($value, FILTER_VALIDATE_EMAIL)) {
$fail('Invalid email');
}
});
```
::: tip
You can easily use Laravel's [Validation](https://laravel.com/docs/8.x/validation) component for field validation with the [`rules` helper function](laravel.md#validation).
:::
This works for relationships, too. The related models will be retrieved via your adapter and passed into your validation function.
```php
$type->hasMany('groups')
->validate(function (callable $fail, array $groups, $model, Request $request, Attribute $attribute) {
foreach ($groups as $group) {
if ($group->id === 1) {
$fail('You cannot assign this group');
}
}
});
```
## Transformers
Use the `transform` method on an attribute to mutate any incoming value before it is saved to the model.
```php
$type->attribute('firstName')
->transform(function ($value, Request $request, Attribute $attribute) {
return ucfirst($value);
});
```
::: tip
If you're using Eloquent, you could also define attribute [casts](https://laravel.com/docs/8.x/eloquent-mutators#attribute-casting) or [mutators](https://laravel.com/docs/8.x/eloquent-mutators#defining-a-mutator) on your model to achieve a similar thing.
:::
## Setters
Use the `set` method to define custom mutation logic for your field, instead of just setting the value straight on the model property.
```php
$type->attribute('firstName')
->set(function ($value, $model, Request $request, Attribute $attribute) {
$model->first_name = ucfirst($value);
if ($model->first_name === 'Toby') {
$model->last_name = 'Zerner';
}
});
```
## Savers
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 that will be run _after_ your model has been successfully saved. If specified, the adapter will NOT be used to set the field on the model.
```php
$type->attribute('locale')
->save(function ($value, $model, Request $request, Attribute $attribute) {
$model->preferences()
->where('key', 'locale')
->update(['value' => $value]);
});
```
## Events
### `onSaved`
Run after a field has been successfully saved.
```php
$type->attribute('email')
->onSaved(function ($value, $model, Request $request, Attribute $attribute) {
event(new EmailWasChanged($model));
});
```

10771
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

11
package.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "json-api-server",
"private": true,
"scripts": {
"docs:dev": "vuepress dev docs",
"docs:build": "vuepress build docs"
},
"devDependencies": {
"vuepress": "^1.7.1"
}
}

View File

@ -11,11 +11,9 @@
namespace Tobyz\JsonApiServer\Adapter;
use Closure;
use Tobyz\JsonApiServer\Schema\Attribute;
use Tobyz\JsonApiServer\Schema\HasMany;
use Tobyz\JsonApiServer\Schema\HasOne;
use Tobyz\JsonApiServer\Schema\Relationship;
interface AdapterInterface
{
@ -29,7 +27,7 @@ interface AdapterInterface
*
* @return mixed
*/
public function query();
public function newQuery();
/**
* Manipulate the query to only include resources with the given IDs.
@ -47,9 +45,10 @@ interface AdapterInterface
* @param $query
* @param Attribute $attribute
* @param $value
* @param string $operator The operator to use for comparison: = < > <= >=
* @return mixed
*/
public function filterByAttribute($query, Attribute $attribute, $value): void;
public function filterByAttribute($query, Attribute $attribute, $value, string $operator = '='): void;
/**
* Manipulate the query to only include resources with any one of the given
@ -136,7 +135,7 @@ interface AdapterInterface
*
* @return mixed
*/
public function create();
public function newModel();
/**
* Get the ID from the model.
@ -226,21 +225,11 @@ interface AdapterInterface
*
* @param array $models
* @param array $relationships
* @param Closure $scope Should be called to give the deepest relationship
* @param mixed $scope Should be called to give the deepest relationship
* an opportunity to scope the query that will fetch related resources
* @param bool $linkage true if we just need the IDs of the related
* resources and not their full data
* @return mixed
*/
public function load(array $models, array $relationships, Closure $scope, bool $linkage): void;
/**
* Load information about the IDs of related resources onto a collection
* of models.
*
* @param array $models
* @param Relationship $relationship
* @return mixed
*/
// public function loadIds(array $models, Relationship $relationship): void;
public function load(array $models, array $relationships, $scope, bool $linkage): void;
}

View File

@ -15,6 +15,8 @@ use Closure;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
use Illuminate\Database\Eloquent\Relations\MorphOneOrMany;
use InvalidArgumentException;
use Tobyz\JsonApiServer\Schema\Attribute;
use Tobyz\JsonApiServer\Schema\HasMany;
@ -42,12 +44,12 @@ class EloquentAdapter implements AdapterInterface
return $model instanceof $this->model;
}
public function create()
public function newModel()
{
return $this->model->newInstance();
}
public function query()
public function newQuery()
{
return $this->model->query();
}
@ -64,7 +66,7 @@ class EloquentAdapter implements AdapterInterface
public function count($query): int
{
return $query->getQuery()->getCountForPagination();
return $query->toBase()->getCountForPagination();
}
public function getId($model): string
@ -146,38 +148,19 @@ class EloquentAdapter implements AdapterInterface
$query->whereIn($key, $ids);
}
public function filterByAttribute($query, Attribute $attribute, $value): void
public function filterByAttribute($query, Attribute $attribute, $value, string $operator = '='): void
{
$column = $this->getAttributeColumn($attribute);
// TODO: extract this into non-adapter territory
if (preg_match('/(.+)\.\.(.+)/', $value, $matches)) {
if ($matches[1] !== '*') {
$query->where($column, '>=', $matches[1]);
}
if ($matches[2] !== '*') {
$query->where($column, '<=', $matches[2]);
}
return;
}
foreach (['>=', '>', '<=', '<'] as $operator) {
if (strpos($value, $operator) === 0) {
$query->where($column, $operator, substr($value, strlen($operator)));
return;
}
}
$query->where($column, $value);
$query->where($column, $operator, $value);
}
public function filterByHasOne($query, HasOne $relationship, array $ids): void
{
$relation = $this->getEloquentRelation($query->getModel(), $relationship);
$column = $relation instanceof HasOneThrough ? $relation->getQualifiedParentKeyName() : $relation->getQualifiedForeignKeyName();
$query->whereIn($relation->getQualifiedForeignKeyName(), $ids);
$query->whereIn($column, $ids);
}
public function filterByHasMany($query, HasMany $relationship, array $ids): void
@ -186,9 +169,13 @@ class EloquentAdapter implements AdapterInterface
$relation = $this->getEloquentRelation($query->getModel(), $relationship);
$relatedKey = $relation->getRelated()->getQualifiedKeyName();
if (count($ids)) {
$query->whereHas($property, function ($query) use ($relatedKey, $ids) {
$query->whereIn($relatedKey, $ids);
});
} else {
$query->whereDoesntHave($property);
}
}
public function sortByAttribute($query, Attribute $attribute, string $direction): void
@ -201,43 +188,27 @@ class EloquentAdapter implements AdapterInterface
$query->take($limit)->skip($offset);
}
public function load(array $models, array $relationships, Closure $scope, bool $linkage): void
public function load(array $models, array $relationships, $scope, bool $linkage): void
{
// TODO: Find the relation on the model that we're after. If it's a
// belongs-to relation, and we only need linkage, then we won't need
// to load anything as the related ID is store directly on the model.
(new Collection($models))->loadMissing([
$this->getRelationshipPath($relationships) => $scope
$this->getRelationshipPath($relationships) => function ($relation) use ($relationships, $scope) {
$query = $relation->getQuery();
if (is_array($scope)) {
// Eloquent doesn't support polymorphic loading constraints,
// so for now we just won't do anything.
// https://github.com/laravel/framework/pull/35190
} else {
$scope($query);
}
}
]);
}
// public function loadIds(array $models, Relationship $relationship): void
// {
// if (empty($models)) {
// return;
// }
//
// $property = $this->getRelationshipProperty($relationship);
// $relation = $models[0]->$property();
//
// // If it's a belongs-to relationship, then the ID is stored on the model
// // itself, so we don't need to load anything in advance.
// if ($relation instanceof BelongsTo) {
// return;
// }
//
// (new Collection($models))->loadMissing([
// $property => function ($query) use ($relation) {
// $query->select($relation->getRelated()->getKeyName());
//
// if (! $relation instanceof BelongsToMany) {
// $query->addSelect($relation->getForeignKeyName());
// }
// }
// ]);
// }
private function getAttributeProperty(Attribute $attribute): string
{
return $attribute->getProperty() ?: strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $attribute->getName()));

83
src/Context.php Normal file
View File

@ -0,0 +1,83 @@
<?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;
use Psr\Http\Message\ServerRequestInterface;
class Context
{
private $api;
private $request;
private $resource;
private $model;
private $field;
public function __construct(JsonApi $api, ResourceType $resource)
{
$this->api = $api;
$this->resource = $resource;
}
public function getApi(): JsonApi
{
return $this->api;
}
public function getRequest(): ?ServerRequestInterface
{
return $this->request;
}
public function forRequest(ServerRequestInterface $request)
{
$new = clone $this;
$new->request = $request;
return $new;
}
public function getResource(): ?ResourceType
{
return $this->resource;
}
public function forResource(ResourceType $resource)
{
$new = clone $this;
$new->resource = $resource;
$new->model = null;
return $new;
}
public function getModel()
{
return $this->model;
}
public function forModel($model)
{
$new = clone $this;
$new->model = $model;
return $new;
}
public function getField(): ?Field
{
return $this->field;
}
public function forField(Field $field)
{
$new = clone $this;
$new->field = $field;
return $new;
}
}

View File

@ -9,9 +9,9 @@
* file that was distributed with this source code.
*/
namespace Tobyz\JsonApiServer\Handler\Concerns;
namespace Tobyz\JsonApiServer\Endpoint\Concerns;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ServerRequestInterface;
use Tobyz\JsonApiServer\Exception\ResourceNotFoundException;
use Tobyz\JsonApiServer\ResourceType;
use function Tobyz\JsonApiServer\run_callbacks;
@ -23,13 +23,12 @@ trait FindsResources
*
* @throws ResourceNotFoundException if the resource is not found.
*/
private function findResource(Request $request, ResourceType $resource, string $id)
private function findResource(ResourceType $resource, string $id, ServerRequestInterface $request)
{
$adapter = $resource->getAdapter();
$query = $adapter->newQuery();
$query = $adapter->query();
run_callbacks($resource->getSchema()->getListeners('scope'), [$query, $request, $id]);
run_callbacks($resource->getSchema()->getListeners('scope'), [$query, $request]);
$model = $adapter->find($query, $id);

View File

@ -9,14 +9,13 @@
* file that was distributed with this source code.
*/
namespace Tobyz\JsonApiServer\Handler\Concerns;
namespace Tobyz\JsonApiServer\Endpoint\Concerns;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ServerRequestInterface;
use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Schema\Relationship;
use function Tobyz\JsonApiServer\evaluate;
use function Tobyz\JsonApiServer\run_callbacks;
/**
@ -25,7 +24,7 @@ use function Tobyz\JsonApiServer\run_callbacks;
*/
trait IncludesData
{
private function getInclude(Request $request): array
private function getInclude(ServerRequestInterface $request): array
{
$queryParams = $request->getQueryParams();
@ -72,7 +71,7 @@ trait IncludesData
throw new BadRequestException("Invalid include [{$path}{$name}]", 'include');
}
if ($type = $fields[$name]->getType()) {
if (($type = $fields[$name]->getType()) && is_string($type)) {
$relatedResource = $this->api->getResource($type);
$this->validateInclude($relatedResource, $nested, $name.'.');
@ -82,39 +81,63 @@ trait IncludesData
}
}
private function loadRelationships(array $models, array $include, Request $request)
private function loadRelationships(array $models, array $include, ServerRequestInterface $request)
{
$this->loadRelationshipsAtLevel($models, [], $this->resource, $include, $request);
}
private function loadRelationshipsAtLevel(array $models, array $relationshipPath, ResourceType $resource, array $include, Request $request)
private function loadRelationshipsAtLevel(array $models, array $relationshipPath, ResourceType $resource, array $include, ServerRequestInterface $request)
{
$adapter = $resource->getAdapter();
$fields = $resource->getSchema()->getFields();
$schema = $resource->getSchema();
$fields = $schema->getFields();
foreach ($fields as $name => $field) {
if (
! $field instanceof Relationship
|| (! $field->isLinkage() && ! isset($include[$name]))
|| $field->isVisible() === false
|| (! $field->hasLinkage() && ! isset($include[$name]))
|| $field->getVisible() === false
) {
continue;
}
$nextRelationshipPath = array_merge($relationshipPath, [$field]);
if ($load = $field->isLoadable()) {
if ($load = $field->getLoad()) {
$type = $field->getType();
if (is_callable($load)) {
$load($models, $nextRelationshipPath, $field->isLinkage(), $request);
$load($models, $nextRelationshipPath, $field->hasLinkage(), $request);
} else {
$scope = function ($query) use ($request, $field) {
if (is_string($type)) {
$relatedResource = $this->api->getResource($type);
$scope = function ($query) use ($request, $field, $relatedResource) {
run_callbacks($relatedResource->getSchema()->getListeners('scope'), [$query, $request]);
run_callbacks($field->getListeners('scope'), [$query, $request]);
};
} else {
$relatedResources = is_array($type) ? array_map(function ($type) {
return $this->api->getResource($type);
}, $type) : $this->api->getResources();
$adapter->load($models, $nextRelationshipPath, $scope, $field->isLinkage());
$scope = array_combine(
array_map(function ($relatedResource) {
return $relatedResource->getType();
}, $relatedResources),
array_map(function ($relatedResource) use ($request, $field) {
return function ($query) use ($request, $field, $relatedResource) {
run_callbacks($relatedResource->getSchema()->getListeners('scope'), [$query, $request]);
run_callbacks($field->getListeners('scope'), [$query, $request]);
};
}, $relatedResources)
);
}
if (isset($include[$name]) && is_string($type = $field->getType())) {
$adapter->load($models, $nextRelationshipPath, $scope, $field->hasLinkage());
}
if (isset($include[$name]) && is_string($type)) {
$relatedResource = $this->api->getResource($type);
$this->loadRelationshipsAtLevel($models, $nextRelationshipPath, $relatedResource, $include[$name] ?? [], $request);

View File

@ -9,12 +9,11 @@
* file that was distributed with this source code.
*/
namespace Tobyz\JsonApiServer\Handler\Concerns;
namespace Tobyz\JsonApiServer\Endpoint\Concerns;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ServerRequestInterface;
use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Exception\UnprocessableEntityException;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Schema\Attribute;
use Tobyz\JsonApiServer\Schema\HasMany;
@ -53,6 +52,7 @@ trait SavesData
if ($model) {
$id = $this->resource->getAdapter()->getId($model);
if (! isset($body['data']['id']) || $body['data']['id'] !== $id) {
throw new BadRequestException('data.id does not match the resource ID');
}
@ -77,7 +77,7 @@ trait SavesData
*
* @throws BadRequestException if the identifier is invalid.
*/
private function getModelForIdentifier(Request $request, array $identifier, array $validTypes = null)
private function getModelForIdentifier(ServerRequestInterface $request, array $identifier, array $validTypes = null)
{
if (! isset($identifier['type'])) {
throw new BadRequestException('type not specified');
@ -87,7 +87,7 @@ trait SavesData
throw new BadRequestException('id not specified');
}
if ($validTypes !== null && ! in_array($identifier['type'], $validTypes)) {
if ($validTypes !== null && count($validTypes) && ! in_array($identifier['type'], $validTypes)) {
throw new BadRequestException("type [{$identifier['type']}] not allowed");
}
@ -99,7 +99,7 @@ trait SavesData
/**
* Assert that the fields contained within a data object are valid.
*/
private function validateFields(array $data, $model, Request $request)
private function validateFields(array $data, $model, ServerRequestInterface $request)
{
$this->assertFieldsExist($data);
$this->assertFieldsWritable($data, $model, $request);
@ -128,10 +128,11 @@ trait SavesData
*
* @throws BadRequestException if a field is not writable.
*/
private function assertFieldsWritable(array $data, $model, Request $request)
private function assertFieldsWritable(array $data, $model, ServerRequestInterface $request)
{
foreach ($this->resource->getSchema()->getFields() as $field) {
if (has_value($data, $field) && ! evaluate($field->isWritable(), [$model, $request])) {
if (has_value($data, $field) && ! evaluate($field->getWritable(), [$model, $request])) {
throw new BadRequestException("Field [{$field->getName()}] is not writable");
}
}
@ -140,7 +141,7 @@ trait SavesData
/**
* Replace relationship linkage within a data object with models.
*/
private function loadRelatedResources(array &$data, Request $request)
private function loadRelatedResources(array &$data, ServerRequestInterface $request)
{
foreach ($this->resource->getSchema()->getFields() as $field) {
if (! $field instanceof Relationship || ! has_value($data, $field)) {
@ -170,7 +171,7 @@ trait SavesData
*
* @throws UnprocessableEntityException if any fields do not pass validation.
*/
private function assertDataValid(array $data, $model, Request $request, bool $validateAll): void
private function assertDataValid(array $data, $model, ServerRequestInterface $request, bool $validateAll): void
{
$failures = [];
@ -185,7 +186,7 @@ trait SavesData
run_callbacks(
$field->getListeners('validate'),
[$fail, get_value($data, $field), $model, $request, $field, $data]
[$fail, get_value($data, $field), $model, $request]
);
}
@ -197,7 +198,7 @@ trait SavesData
/**
* Set field values from a data object to the model instance.
*/
private function setValues(array $data, $model, Request $request)
private function setValues(array $data, $model, ServerRequestInterface $request)
{
$adapter = $this->resource->getAdapter();
@ -228,7 +229,7 @@ trait SavesData
/**
* Save the model and its fields.
*/
private function save(array $data, $model, Request $request)
private function save(array $data, $model, ServerRequestInterface $request)
{
$this->saveModel($model, $request);
$this->saveFields($data, $model, $request);
@ -237,7 +238,7 @@ trait SavesData
/**
* Save the model.
*/
private function saveModel($model, Request $request)
private function saveModel($model, ServerRequestInterface $request)
{
if ($saveCallback = $this->resource->getSchema()->getSaveCallback()) {
$saveCallback($model, $request);
@ -249,7 +250,7 @@ trait SavesData
/**
* Save any fields that were not saved with the model.
*/
private function saveFields(array $data, $model, Request $request)
private function saveFields(array $data, $model, ServerRequestInterface $request)
{
$adapter = $this->resource->getAdapter();
@ -273,8 +274,9 @@ trait SavesData
/**
* Run field saved listeners.
*/
private function runSavedCallbacks(array $data, $model, Request $request)
private function runSavedCallbacks(array $data, $model, ServerRequestInterface $request)
{
foreach ($this->resource->getSchema()->getFields() as $field) {
if (! has_value($data, $field)) {
continue;

View File

@ -9,9 +9,10 @@
* file that was distributed with this source code.
*/
namespace Tobyz\JsonApiServer\Handler;
namespace Tobyz\JsonApiServer\Endpoint;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface;
use Tobyz\JsonApiServer\Exception\ForbiddenException;
@ -48,7 +49,7 @@ class Create implements RequestHandlerInterface
throw new ForbiddenException;
}
$model = $this->createModel($request);
$model = $this->newModel($request);
$data = $this->parseData($request->getParsedBody());
$this->validateFields($data, $model, $request);
@ -68,14 +69,17 @@ class Create implements RequestHandlerInterface
->withStatus(201);
}
private function createModel(Request $request)
private function newModel(ServerRequestInterface $request)
{
$createModel = $this->resource->getSchema()->getCreateModelCallback();
$resource = $this->resource;
$newModel = $resource->getSchema()->getNewModelCallback();
return $createModel ? $createModel($request) : $this->resource->getAdapter()->create();
return $newModel
? $newModel($request)
: $resource->getAdapter()->newModel();
}
private function fillDefaultValues(array &$data, Request $request)
private function fillDefaultValues(array &$data, ServerRequestInterface $request)
{
foreach ($this->resource->getSchema()->getFields() as $field) {
if (! has_value($data, $field) && ($defaultCallback = $field->getDefaultCallback())) {

View File

@ -9,24 +9,27 @@
* file that was distributed with this source code.
*/
namespace Tobyz\JsonApiServer\Handler;
namespace Tobyz\JsonApiServer\Endpoint;
use Psr\Http\Message\ResponseInterface as Response;
use Nyholm\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface;
use Tobyz\JsonApiServer\Exception\ForbiddenException;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\ResourceType;
use Zend\Diactoros\Response\EmptyResponse;
use function Tobyz\JsonApiServer\evaluate;
use function Tobyz\JsonApiServer\run_callbacks;
class Delete implements RequestHandlerInterface
{
private $api;
private $resource;
private $model;
public function __construct(ResourceType $resource, $model)
public function __construct(JsonApi $api, ResourceType $resource, $model)
{
$this->api = $api;
$this->resource = $resource;
$this->model = $model;
}
@ -36,7 +39,7 @@ class Delete implements RequestHandlerInterface
*
* @throws ForbiddenException if the resource is not deletable.
*/
public function handle(Request $request): Response
public function handle(Request $request): ResponseInterface
{
$schema = $this->resource->getSchema();
@ -54,6 +57,6 @@ class Delete implements RequestHandlerInterface
run_callbacks($schema->getListeners('deleted'), [$this->model, $request]);
return new EmptyResponse;
return new Response(204);
}
}

View File

@ -9,9 +9,8 @@
* file that was distributed with this source code.
*/
namespace Tobyz\JsonApiServer\Handler;
namespace Tobyz\JsonApiServer\Endpoint;
use Illuminate\Support\Arr;
use JsonApiPhp\JsonApi as Structure;
use JsonApiPhp\JsonApi\Link\LastLink;
use JsonApiPhp\JsonApi\Link\NextLink;
@ -19,15 +18,16 @@ use JsonApiPhp\JsonApi\Link\PrevLink;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface;
use Tobyz\JsonApiServer\Adapter\AdapterInterface;
use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\JsonApiResponse;
use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Schema\Attribute;
use Tobyz\JsonApiServer\Schema\HasMany;
use Tobyz\JsonApiServer\Schema\HasOne;
use Tobyz\JsonApiServer\Serializer;
use function Tobyz\JsonApiServer\evaluate;
use function Tobyz\JsonApiServer\json_api_response;
use function Tobyz\JsonApiServer\run_callbacks;
class Index implements RequestHandlerInterface
@ -51,11 +51,10 @@ class Index implements RequestHandlerInterface
$adapter = $this->resource->getAdapter();
$schema = $this->resource->getSchema();
run_callbacks($schema->getListeners('listing'), [&$request]);
$query = $adapter->newQuery();
$query = $adapter->query();
run_callbacks($schema->getListeners('scope'), [$query, $request, null]);
run_callbacks($schema->getListeners('listing'), [$query, $request]);
run_callbacks($schema->getListeners('scope'), [$query, $request]);
$include = $this->getInclude($request);
@ -76,7 +75,7 @@ class Index implements RequestHandlerInterface
$serializer->add($this->resource, $model, $include);
}
return new JsonApiResponse(
return json_api_response(
new Structure\CompoundDocument(
new Structure\PaginatedCollection(
new Structure\Pagination(...$this->buildPaginationLinks($request, $offset, $limit, count($models), $total)),
@ -107,7 +106,7 @@ class Index implements RequestHandlerInterface
}
}
$queryString = Arr::query($queryParams);
$queryString = http_build_query($queryParams, '', '&', PHP_QUERY_RFC3986);
return $selfUrl.($queryString ? '?'.$queryString : '');
}
@ -163,7 +162,7 @@ class Index implements RequestHandlerInterface
if (
isset($fields[$name])
&& $fields[$name] instanceof Attribute
&& evaluate($fields[$name]->isSortable(), [$request])
&& evaluate($fields[$name]->getSortable(), [$request])
) {
$adapter->sortByAttribute($query, $fields[$name], $direction);
continue;
@ -250,14 +249,14 @@ class Index implements RequestHandlerInterface
continue;
}
if (isset($fields[$name]) && evaluate($fields[$name]->isFilterable(), [$request])) {
if (isset($fields[$name]) && evaluate($fields[$name]->getFilterable(), [$request])) {
if ($fields[$name] instanceof Attribute) {
$adapter->filterByAttribute($query, $fields[$name], $value);
$this->filterByAttribute($adapter, $query, $fields[$name], $value);
} elseif ($fields[$name] instanceof HasOne) {
$value = explode(',', $value);
$value = array_filter(explode(',', $value));
$adapter->filterByHasOne($query, $fields[$name], $value);
} elseif ($fields[$name] instanceof HasMany) {
$value = explode(',', $value);
$value = array_filter(explode(',', $value));
$adapter->filterByHasMany($query, $fields[$name], $value);
}
continue;
@ -266,4 +265,28 @@ class Index implements RequestHandlerInterface
throw new BadRequestException("Invalid filter [$name]", "filter[$name]");
}
}
private function filterByAttribute(AdapterInterface $adapter, $query, Attribute $attribute, $value)
{
if (preg_match('/(.+)\.\.(.+)/', $value, $matches)) {
if ($matches[1] !== '*') {
$adapter->filterByAttribute($query, $attribute, $value, '>=');
}
if ($matches[2] !== '*') {
$adapter->filterByAttribute($query, $attribute, $value, '<=');
}
return;
}
foreach (['>=', '>', '<=', '<'] as $operator) {
if (strpos($value, $operator) === 0) {
$adapter->filterByAttribute($query, $attribute, substr($value, strlen($operator)), $operator);
return;
}
}
$adapter->filterByAttribute($query, $attribute, $value);
}
}

View File

@ -9,7 +9,7 @@
* file that was distributed with this source code.
*/
namespace Tobyz\JsonApiServer\Handler;
namespace Tobyz\JsonApiServer\Endpoint;
use JsonApiPhp\JsonApi\CompoundDocument;
use JsonApiPhp\JsonApi\Included;
@ -17,9 +17,9 @@ use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\JsonApiResponse;
use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Serializer;
use function Tobyz\JsonApiServer\json_api_response;
use function Tobyz\JsonApiServer\run_callbacks;
class Show implements RequestHandlerInterface
@ -49,9 +49,9 @@ class Show implements RequestHandlerInterface
run_callbacks($this->resource->getSchema()->getListeners('show'), [$this->model, $request]);
$serializer = new Serializer($this->api, $request);
$serializer->add($this->resource, $this->model, $include, true);
$serializer->add($this->resource, $this->model, $include);
return new JsonApiResponse(
return json_api_response(
new CompoundDocument(
$serializer->primary()[0],
new Included(...$serializer->included())

View File

@ -9,7 +9,7 @@
* file that was distributed with this source code.
*/
namespace Tobyz\JsonApiServer\Handler;
namespace Tobyz\JsonApiServer\Endpoint;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;

View File

@ -1,34 +0,0 @@
<?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\Exception;
use DomainException;
use JsonApiPhp\JsonApi\Error;
use Tobyz\JsonApiServer\ErrorProviderInterface;
class UnauthorizedException extends DomainException implements ErrorProviderInterface
{
public function getJsonApiErrors(): array
{
return [
new Error(
new Error\Title('Unauthorized'),
new Error\Status($this->getJsonApiStatus())
)
];
}
public function getJsonApiStatus(): string
{
return '401';
}
}

View File

@ -33,7 +33,7 @@ class UnprocessableEntityException extends DomainException implements ErrorProvi
new Error\Status($this->getJsonApiStatus()),
];
if ($field = $failure['field']) {
if ($field = $failure['field'] ?? null) {
$members[] = new Error\SourcePointer('/data/'.$field->getLocation().'/'.$field->getName());
}

View File

@ -17,30 +17,29 @@ 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\Endpoint\Concerns\FindsResources;
use Tobyz\JsonApiServer\Http\MediaTypes;
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
final class JsonApi implements RequestHandlerInterface
{
const CONTENT_TYPE = 'application/vnd.api+json';
const MEDIA_TYPE = 'application/vnd.api+json';
use FindsResources;
use HasMeta;
private $resources = [];
private $baseUrl;
private $authenticated = false;
private $basePath;
public function __construct(string $baseUrl)
public function __construct(string $basePath)
{
$this->baseUrl = $baseUrl;
$this->basePath = $basePath;
}
/**
@ -93,21 +92,20 @@ final class JsonApi implements RequestHandlerInterface
);
$segments = explode('/', trim($path, '/'));
$resource = $this->getResource($segments[0]);
switch (count($segments)) {
case 1:
return $this->handleCollection($request, $segments);
return $this->handleCollection($request, $resource);
case 2:
return $this->handleResource($request, $segments);
return $this->handleResource($request, $resource, $segments[1]);
case 3:
// return $this->handleRelated($request, $resource, $model, $segments[2]);
throw new NotImplementedException;
case 4:
if ($segments[2] === 'relationships') {
// return $this->handleRelationship($request, $resource, $model, $segments[3]);
throw new NotImplementedException;
}
}
@ -129,7 +127,7 @@ final class JsonApi implements RequestHandlerInterface
return;
}
if ((new MediaTypes($header))->containsExactly(self::CONTENT_TYPE)) {
if ((new MediaTypes($header))->containsExactly(self::MEDIA_TYPE)) {
return;
}
@ -146,7 +144,7 @@ final class JsonApi implements RequestHandlerInterface
$mediaTypes = new MediaTypes($header);
if ($mediaTypes->containsExactly('*/*') || $mediaTypes->containsExactly(self::CONTENT_TYPE)) {
if ($mediaTypes->containsExactly('*/*') || $mediaTypes->containsExactly(self::MEDIA_TYPE)) {
return;
}
@ -155,7 +153,7 @@ final class JsonApi implements RequestHandlerInterface
private function stripBasePath(string $path): string
{
$basePath = parse_url($this->baseUrl, PHP_URL_PATH);
$basePath = parse_url($this->basePath, PHP_URL_PATH);
$len = strlen($basePath);
@ -166,36 +164,33 @@ final class JsonApi implements RequestHandlerInterface
return $path;
}
private function handleCollection(Request $request, array $segments): Response
private function handleCollection(Request $request, ResourceType $resource): Response
{
$resource = $this->getResource($segments[0]);
switch ($request->getMethod()) {
case 'GET':
return (new Handler\Index($this, $resource))->handle($request);
return (new Endpoint\Index($this, $resource))->handle($request);
case 'POST':
return (new Handler\Create($this, $resource))->handle($request);
return (new Endpoint\Create($this, $resource))->handle($request);
default:
throw new MethodNotAllowedException;
}
}
private function handleResource(Request $request, array $segments): Response
private function handleResource(Request $request, ResourceType $resource, string $id): Response
{
$resource = $this->getResource($segments[0]);
$model = $this->findResource($request, $resource, $segments[1]);
$model = $this->findResource($resource, $id, $request);
switch ($request->getMethod()) {
case 'PATCH':
return (new Handler\Update($this, $resource, $model))->handle($request);
return (new Endpoint\Update($this, $resource, $model))->handle($request);
case 'GET':
return (new Handler\Show($this, $resource, $model))->handle($request);
return (new Endpoint\Show($this, $resource, $model))->handle($request);
case 'DELETE':
return (new Handler\Delete($resource, $model))->handle($request);
return (new Endpoint\Delete($this, $resource, $model))->handle($request);
default:
throw new MethodNotAllowedException;
@ -214,33 +209,21 @@ final class JsonApi implements RequestHandlerInterface
$e = new InternalServerErrorException;
}
if (! $this->authenticated && $e instanceof ForbiddenException) {
$e = new UnauthorizedException;
}
$errors = $e->getJsonApiErrors();
$status = $e->getJsonApiStatus();
$data = new ErrorDocument(
$document = new ErrorDocument(
...$errors
);
return new JsonApiResponse($data, $status);
return json_api_response($document, $status);
}
/**
* Get the base URL for the API.
* Get the base path for the API.
*/
public function getBaseUrl(): string
public function getBasePath(): string
{
return $this->baseUrl;
}
/**
* Indicate that the consumer is authenticated.
*/
public function authenticated(): void
{
$this->authenticated = true;
return $this->basePath;
}
}

View File

@ -1,28 +0,0 @@
<?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;
use Zend\Diactoros\Response\JsonResponse;
class JsonApiResponse extends JsonResponse
{
public function __construct(
$data,
int $status = 200,
array $headers = [],
int $encodingOptions = self::DEFAULT_JSON_FLAGS
) {
$headers['content-type'] = JsonApi::CONTENT_TYPE;
parent::__construct($data, $status, $headers, $encodingOptions);
}
}

View File

@ -42,7 +42,7 @@ final class Attribute extends Field
return $this;
}
public function isSortable()
public function getSortable()
{
return $this->sortable;
}

View File

@ -0,0 +1,32 @@
<?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\Schema\Concerns;
trait HasDescription
{
private $description;
/**
* Set the description of the field for documentation generation.
*/
public function description(string $description)
{
$this->description = $description;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
}

View File

@ -11,6 +11,7 @@
namespace Tobyz\JsonApiServer\Schema;
use Tobyz\JsonApiServer\Schema\Concerns\HasDescription;
use Tobyz\JsonApiServer\Schema\Concerns\HasListeners;
use function Tobyz\JsonApiServer\negate;
use function Tobyz\JsonApiServer\wrap;
@ -18,9 +19,9 @@ use function Tobyz\JsonApiServer\wrap;
abstract class Field
{
use HasListeners;
use HasDescription;
private $name;
private $description;
private $property;
private $visible = true;
private $single = false;
@ -42,16 +43,6 @@ abstract class Field
*/
abstract public function getLocation(): string;
/**
* Set the description of the field for documentation generation.
*/
public function description(string $description)
{
$this->description = $description;
return $this;
}
/**
* Set the model property to which this field corresponds.
*/
@ -82,20 +73,6 @@ abstract class Field
return $this;
}
/**
* Only show this field on single root resources.
*
* This is useful if a field requires an expensive calculation for each
* individual resource (eg. n+1 query problem). In this case it may be
* desirable to only have the field show when viewing a single resource.
*/
public function single()
{
$this->single = true;
return $this;
}
/**
* Allow this field to be written.
*/
@ -130,6 +107,16 @@ abstract class Field
return $this;
}
/**
* Apply a transformation to the value before it is set on the model.
*/
public function transform(callable $callback)
{
$this->listeners['transform'][] = $callback;
return $this;
}
/**
* Set the callback to apply a new value for this field to the model.
*
@ -216,17 +203,12 @@ abstract class Field
return $this->property;
}
public function isVisible()
public function getVisible()
{
return $this->visible;
}
public function isSingle(): bool
{
return $this->single;
}
public function isWritable()
public function getWritable()
{
return $this->writable;
}
@ -251,7 +233,7 @@ abstract class Field
return $this->defaultCallback;
}
public function isFilterable()
public function getFilterable()
{
return $this->filterable;
}

View File

@ -11,11 +11,14 @@
namespace Tobyz\JsonApiServer\Schema;
use Tobyz\JsonApiServer\Schema\Concerns\HasDescription;
final class Filter
{
use HasDescription;
private $name;
private $callback;
private $description;
public function __construct(string $name, callable $callback)
{
@ -32,12 +35,4 @@ final class Filter
{
return $this->callback;
}
/**
* Set the description of the type for documentation generation.
*/
public function description(string $description)
{
$this->description = $description;
}
}

View File

@ -11,7 +11,7 @@
namespace Tobyz\JsonApiServer\Schema;
use Doctrine\Common\Inflector\Inflector;
use Doctrine\Inflector\InflectorFactory;
final class HasOne extends Relationship
{
@ -19,6 +19,7 @@ final class HasOne extends Relationship
{
parent::__construct($name);
$this->type(Inflector::pluralize($name));
$this->type(InflectorFactory::create()->build()->pluralize($name));
$this->withLinkage();
}
}

View File

@ -11,8 +11,12 @@
namespace Tobyz\JsonApiServer\Schema;
use Tobyz\JsonApiServer\Schema\Concerns\HasDescription;
final class Meta
{
use HasDescription;
private $name;
private $value;

View File

@ -19,8 +19,8 @@ abstract class Relationship extends Field
private $type;
private $linkage = false;
private $links = true;
private $loadable = true;
// private $urls = true;
private $load = true;
private $includable = false;
public function getLocation(): string
@ -51,7 +51,7 @@ abstract class Relationship extends Field
/**
* Show resource linkage for the relationship.
*/
public function linkage()
public function withLinkage()
{
$this->linkage = true;
@ -61,7 +61,7 @@ abstract class Relationship extends Field
/**
* Do not show resource linkage for the relationship.
*/
public function noLinkage()
public function withoutLinkage()
{
$this->linkage = false;
@ -74,9 +74,9 @@ abstract class Relationship extends Field
* This is used to prevent the n+1 query problem. If null, the adapter will
* be used to eager-load relationship data into the model collection.
*/
public function loadable(callable $callback = null)
public function load(callable $callback = null)
{
$this->loadable = $callback ?: true;
$this->load = $callback ?: true;
return $this;
}
@ -84,9 +84,9 @@ abstract class Relationship extends Field
/**
* Do not eager-load relationship data into the model collection.
*/
public function notLoadable()
public function dontLoad()
{
$this->loadable = false;
$this->load = false;
return $this;
}
@ -111,25 +111,25 @@ abstract class Relationship extends Field
return $this;
}
/**
* Show links for the relationship.
*/
public function links()
{
$this->links = true;
return $this;
}
/**
* Do not show links for the relationship.
*/
public function noLinks()
{
$this->links = false;
return $this;
}
// /**
// * Make URLs available for the relationship.
// */
// public function withUrls()
// {
// $this->urls = true;
//
// return $this;
// }
//
// /**
// * Do not make URLs avaialble for the relationship.
// */
// public function withoutUrls()
// {
// $this->urls = false;
//
// return $this;
// }
/**
* Apply a scope to the query to eager-load the relationship data.
@ -146,22 +146,22 @@ abstract class Relationship extends Field
return $this->type;
}
public function isLinkage(): bool
public function hasLinkage(): bool
{
return $this->linkage;
}
public function isLinks(): bool
{
return $this->links;
}
// public function hasUrls(): bool
// {
// return $this->urls;
// }
/**
* @return bool|callable
*/
public function isLoadable()
public function getLoad()
{
return $this->loadable;
return $this->load;
}
public function isIncludable(): bool

View File

@ -11,15 +11,17 @@
namespace Tobyz\JsonApiServer\Schema;
use Tobyz\JsonApiServer\Schema\Concerns\HasDescription;
use Tobyz\JsonApiServer\Schema\Concerns\HasListeners;
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
use function Tobyz\JsonApiServer\negate;
final class Type
{
use HasListeners, HasMeta;
use HasListeners;
use HasMeta;
use HasDescription;
private $description;
private $fields = [];
private $filters = [];
private $sortFields = [];
@ -30,20 +32,12 @@ final class Type
private $defaultSort;
private $defaultFilter;
private $saveCallback;
private $createModelCallback;
private $newModelCallback;
private $creatable = false;
private $updatable = false;
private $deletable = false;
private $deleteCallback;
/**
* Set the description of the type for documentation generation.
*/
public function description(string $description)
{
$this->description = $description;
}
/**
* Add an attribute to the resource type.
*
@ -279,17 +273,17 @@ final class Type
*
* If null, the adapter will be used to create new model instances.
*/
public function createModel(?callable $callback): void
public function newModel(?callable $callback): void
{
$this->createModelCallback = $callback;
$this->newModelCallback = $callback;
}
/**
* Get the callback to create a new model instance.
*/
public function getCreateModelCallback(): ?callable
public function getNewModelCallback(): ?callable
{
return $this->createModelCallback;
return $this->newModelCallback;
}
/**
@ -465,22 +459,4 @@ final class Type
{
return $this->defaultSort;
}
/**
* Set the default filter parameter value to be used if none is specified in
* the query string.
*/
public function defaultFilter(?array $filter): void
{
$this->defaultFilter = $filter;
}
/**
* Get the default filter parameter value to be used if none is specified in
* the query string.
*/
public function getDefaultFilter(): ?array
{
return $this->defaultFilter;
}
}

View File

@ -20,7 +20,6 @@ use RuntimeException;
final class Serializer
{
private $api;
private $request;
private $map = [];
private $primary = [];
@ -33,9 +32,9 @@ final class Serializer
/**
* 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): void
{
$data = $this->addToMap($resource, $model, $include, $single);
$data = $this->addToMap($resource, $model, $include);
$this->primary[] = $this->key($data);
}
@ -62,7 +61,7 @@ final class Serializer
return $this->resourceObjects($included);
}
private function addToMap(ResourceType $resource, $model, array $include, bool $single = false): array
private function addToMap(ResourceType $resource, $model, array $include): array
{
$adapter = $resource->getAdapter();
$schema = $resource->getSchema();
@ -76,7 +75,7 @@ final class Serializer
];
$key = $this->key($data);
$url = $this->api->getBaseUrl()."/$type/$id";
$url = $this->api->getBasePath()."/$type/$id";
$fields = $schema->getFields();
$queryParams = $this->request->getQueryParams();
@ -89,11 +88,7 @@ final class Serializer
continue;
}
if ($field->isSingle() && ! $single) {
continue;
}
if (! evaluate($field->isVisible(), [$model, $this->request])) {
if (! evaluate($field->getVisible(), [$model, $this->request])) {
continue;
}
@ -106,10 +101,10 @@ final class Serializer
$meta = $this->meta($field->getMeta(), $model);
$members = array_merge($links, $meta);
if (! $isIncluded && ! $field->isLinkage()) {
if (! $isIncluded && ! $field->hasLinkage()) {
$value = $this->emptyRelationship($field, $members);
} elseif ($field instanceof Schema\HasOne) {
$value = $this->toOne($field, $members, $resource, $model, $relationshipInclude, $single);
$value = $this->toOne($field, $members, $resource, $model, $relationshipInclude);
} elseif ($field instanceof Schema\HasMany) {
$value = $this->toMany($field, $members, $resource, $model, $relationshipInclude);
}
@ -156,7 +151,7 @@ final class Serializer
return new Structure\Attribute($field->getName(), $value);
}
private function toOne(Schema\HasOne $field, array $members, ResourceType $resource, $model, ?array $include, bool $single)
private function toOne(Schema\HasOne $field, array $members, ResourceType $resource, $model, ?array $include)
{
$included = $include !== null;
@ -169,7 +164,7 @@ final class Serializer
}
$identifier = $include !== null
? $this->addRelated($field, $model, $include, $single)
? $this->addRelated($field, $model, $include)
: $this->relatedResourceIdentifier($field, $model);
return new Structure\ToOne($field->getName(), $identifier, ...$members);
@ -212,24 +207,24 @@ final class Serializer
*/
private function relationshipLinks(Schema\Relationship $field, string $url): array
{
if (! $field->isLinks()) {
// if (! $field->hasUrls()) {
return [];
// }
// return [
// new Structure\Link\SelfLink($url.'/relationships/'.$field->getName()),
// new Structure\Link\RelatedLink($url.'/'.$field->getName())
// ];
}
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
private function addRelated(Schema\Relationship $field, $model, array $include): Structure\ResourceIdentifier
{
$relatedResource = is_string($field->getType())
? $this->api->getResource($field->getType())
: $this->resourceForModel($model);
return $this->resourceIdentifier(
$this->addToMap($relatedResource, $model, $include, $single)
$this->addToMap($relatedResource, $model, $include)
);
}

View File

@ -12,8 +12,18 @@
namespace Tobyz\JsonApiServer;
use Closure;
use JsonSerializable;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\Stream;
use Tobyz\JsonApiServer\Schema\Field;
function json_api_response(JsonSerializable $document, int $status = 200)
{
return (new Response($status))
->withHeader('content-type', JsonApi::MEDIA_TYPE)
->withBody(Stream::create(json_encode($document, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES)));
}
function negate(Closure $condition)
{
return function (...$args) use ($condition) {

View File

@ -11,6 +11,9 @@
namespace Tobyz\JsonApiServer\Laravel;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Psr\Http\Message\ServerRequestInterface as Request;
use Tobyz\JsonApiServer\Schema\Field;
@ -42,3 +45,17 @@ function rules($rules, array $messages = [], array $customAttributes = [])
}
};
}
function authenticated()
{
return function () {
return Auth::check();
};
}
function can(string $ability)
{
return function ($arg) use ($ability) {
return Gate::allows($ability, $arg instanceof Model ? $arg : null);
};
}

View File

@ -12,9 +12,8 @@
namespace Tobyz\Tests\JsonApiServer;
use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
use Nyholm\Psr7\ServerRequest;
use PHPUnit\Framework\TestCase;
use Zend\Diactoros\ServerRequest;
use Zend\Diactoros\Uri;
abstract class AbstractTestCase extends TestCase
{
@ -27,8 +26,6 @@ abstract class AbstractTestCase extends TestCase
protected function buildRequest(string $method, string $uri): ServerRequest
{
return (new ServerRequest)
->withMethod($method)
->withUri(new Uri($uri));
return new ServerRequest($method, $uri);
}
}

View File

@ -23,12 +23,12 @@ class MockAdapter implements AdapterInterface
$this->type = $type;
}
public function create()
public function newModel()
{
return $this->createdModel = (object) [];
}
public function query()
public function newQuery()
{
return $this->query = (object) [];
}
@ -97,9 +97,9 @@ class MockAdapter implements AdapterInterface
$query->filter[] = ['ids', $ids];
}
public function filterByAttribute($query, Attribute $attribute, $value): void
public function filterByAttribute($query, Attribute $attribute, $value, string $operator = '='): void
{
$query->filter[] = [$attribute, $value];
$query->filter[] = [$attribute, $operator, $value];
}
public function filterByHasOne($query, HasOne $relationship, array $ids): void
@ -122,8 +122,16 @@ class MockAdapter implements AdapterInterface
$query->paginate[] = [$limit, $offset];
}
public function load(array $models, array $relationships, Closure $scope, bool $linkage): void
public function load(array $models, array $relationships, $scope, bool $linkage): void
{
if (is_array($scope)) {
foreach ($scope as $type => $apply) {
$apply((object) []);
}
} else {
$scope((object) []);
}
foreach ($models as $model) {
$model->load[] = $relationships;
}

View File

@ -120,7 +120,7 @@ class CreateTest extends AbstractTestCase
public function test_new_models_are_supplied_and_saved_by_the_adapter()
{
$adapter = $this->prophesize(AdapterInterface::class);
$adapter->create()->willReturn($createdModel = (object) []);
$adapter->newModel()->willReturn($createdModel = (object) []);
$adapter->save($createdModel)->shouldBeCalled();
$adapter->getId($createdModel)->willReturn('1');
@ -136,13 +136,13 @@ class CreateTest extends AbstractTestCase
$createdModel = (object) [];
$adapter = $this->prophesize(AdapterInterface::class);
$adapter->create()->shouldNotBeCalled();
$adapter->newModel()->shouldNotBeCalled();
$adapter->save($createdModel)->shouldBeCalled();
$adapter->getId($createdModel)->willReturn('1');
$this->api->resource('users', $adapter->reveal(), function (Type $type) use ($createdModel) {
$type->creatable();
$type->createModel(function ($request) use ($createdModel) {
$type->newModel(function ($request) use ($createdModel) {
$this->assertInstanceOf(ServerRequestInterface::class, $request);
return $createdModel;
});
@ -156,7 +156,7 @@ class CreateTest extends AbstractTestCase
$called = false;
$adapter = $this->prophesize(AdapterInterface::class);
$adapter->create()->willReturn($createdModel = (object) []);
$adapter->newModel()->willReturn($createdModel = (object) []);
$adapter->save($createdModel)->shouldNotBeCalled();
$adapter->getId($createdModel)->willReturn('1');
@ -180,7 +180,7 @@ class CreateTest extends AbstractTestCase
$called = 0;
$adapter = $this->prophesize(AdapterInterface::class);
$adapter->create()->willReturn($createdModel = (object) []);
$adapter->newModel()->willReturn($createdModel = (object) []);
$adapter->getId($createdModel)->willReturn('1');
$this->api->resource('users', $adapter->reveal(), function (Type $type) use ($adapter, $createdModel, &$called) {

View File

@ -100,7 +100,7 @@ class DeleteTest extends AbstractTestCase
$called = false;
$adapter = $this->prophesize(AdapterInterface::class);
$adapter->query()->willReturn($query = (object) []);
$adapter->newQuery()->willReturn($query = (object) []);
$adapter->find($query, '1')->willReturn($deletingModel = (object) []);
$adapter->delete($deletingModel);
@ -120,7 +120,7 @@ class DeleteTest extends AbstractTestCase
public function test_deleting_a_resource_calls_the_delete_adapter_method()
{
$adapter = $this->prophesize(AdapterInterface::class);
$adapter->query()->willReturn($query = (object) []);
$adapter->newQuery()->willReturn($query = (object) []);
$adapter->find($query, '1')->willReturn($model = (object) []);
$adapter->delete($model)->shouldBeCalled();
@ -136,7 +136,7 @@ class DeleteTest extends AbstractTestCase
$called = false;
$adapter = $this->prophesize(AdapterInterface::class);
$adapter->query()->willReturn($query = (object) []);
$adapter->newQuery()->willReturn($query = (object) []);
$adapter->find($query, '1')->willReturn($deletingModel = (object) []);
$adapter->delete($deletingModel)->shouldNotBeCalled();
@ -159,7 +159,7 @@ class DeleteTest extends AbstractTestCase
$called = 0;
$adapter = $this->prophesize(AdapterInterface::class);
$adapter->query()->willReturn($query = (object) []);
$adapter->newQuery()->willReturn($query = (object) []);
$adapter->find($query, '1')->willReturn($deletingModel = (object) []);
$adapter->delete($deletingModel)->shouldBeCalled();

View File

@ -40,9 +40,7 @@ class ScopesTest extends AbstractTestCase
$this->api->resource('users', $this->adapter, function (Type $type) {
$type->updatable();
$type->deletable();
$type->scope(function (...$args) {
$this->assertSame($this->adapter->query, $args[0]);
$this->assertInstanceOf(ServerRequestInterface::class, $args[1]);
$type->scope(function ($query, ServerRequestInterface $request) {
$this->scopeWasCalled = true;
});
});
@ -89,4 +87,44 @@ class ScopesTest extends AbstractTestCase
$this->assertTrue($this->scopeWasCalled);
}
public function test_scopes_are_applied_to_related_resources()
{
$this->api->resource('pets', new MockAdapter, function (Type $type) {
$type->hasOne('owner')
->type('users')
->includable();
});
$this->api->handle(
$this->buildRequest('GET', '/pets/1')
->withQueryParams(['include' => 'owner'])
);
$this->assertTrue($this->scopeWasCalled);
}
public function test_scopes_are_applied_to_polymorphic_related_resources()
{
$this->api->resource('pets', new MockAdapter, function (Type $type) {
$type->hasOne('owner')
->polymorphic(['users', 'organisations'])
->includable();
});
$organisationScopeWasCalled = false;
$this->api->resource('organisations', new MockAdapter, function (Type $type) use (&$organisationScopeWasCalled) {
$type->scope(function ($query, ServerRequestInterface $request) use (&$organisationScopeWasCalled) {
$organisationScopeWasCalled = true;
});
});
$this->api->handle(
$this->buildRequest('GET', '/pets/1')
->withQueryParams(['include' => 'owner'])
);
$this->assertTrue($this->scopeWasCalled);
$this->assertTrue($organisationScopeWasCalled);
}
}