Refactor, docs
This commit is contained in:
parent
467239c3c1
commit
fbecdd96de
|
|
@ -1,3 +1,4 @@
|
|||
composer.lock
|
||||
vendor
|
||||
.phpunit.result.cache
|
||||
node_modules
|
||||
.phpunit.result.cache
|
||||
|
|
|
|||
330
README.md
330
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.theme-default-content > h1 + p {
|
||||
font-size: 140%;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
:::
|
||||
|
|
@ -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
|
||||
});
|
||||
```
|
||||
|
|
@ -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
|
||||
});
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
});
|
||||
```
|
||||
|
|
@ -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);
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# Installation
|
||||
|
||||
To install, simply require the package via Composer.
|
||||
|
||||
```bash
|
||||
composer require tobyz/json-api-server
|
||||
```
|
||||
|
|
@ -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'));
|
||||
```
|
||||
|
|
@ -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
|
||||
});
|
||||
```
|
||||
|
|
@ -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).
|
||||
|
|
@ -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();
|
||||
```
|
||||
|
|
@ -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;
|
||||
});
|
||||
```
|
||||
|
|
@ -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);
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
});
|
||||
```
|
||||
|
|
@ -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
|
||||
});
|
||||
```
|
||||
|
|
@ -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();
|
||||
```
|
||||
|
|
@ -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));
|
||||
});
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
$query->whereHas($property, function ($query) use ($relatedKey, $ids) {
|
||||
$query->whereIn($relatedKey, $ids);
|
||||
});
|
||||
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()));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
@ -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 (is_callable($load)) {
|
||||
$load($models, $nextRelationshipPath, $field->isLinkage(), $request);
|
||||
} else {
|
||||
$scope = function ($query) use ($request, $field) {
|
||||
run_callbacks($field->getListeners('scope'), [$query, $request]);
|
||||
};
|
||||
if ($load = $field->getLoad()) {
|
||||
$type = $field->getType();
|
||||
|
||||
$adapter->load($models, $nextRelationshipPath, $scope, $field->isLinkage());
|
||||
if (is_callable($load)) {
|
||||
$load($models, $nextRelationshipPath, $field->hasLinkage(), $request);
|
||||
} else {
|
||||
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();
|
||||
|
||||
$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)
|
||||
);
|
||||
}
|
||||
|
||||
$adapter->load($models, $nextRelationshipPath, $scope, $field->hasLinkage());
|
||||
}
|
||||
|
||||
if (isset($include[$name]) && is_string($type = $field->getType())) {
|
||||
if (isset($include[$name]) && is_string($type)) {
|
||||
$relatedResource = $this->api->getResource($type);
|
||||
|
||||
$this->loadRelationshipsAtLevel($models, $nextRelationshipPath, $relatedResource, $include[$name] ?? [], $request);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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())) {
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
@ -75,8 +74,8 @@ class Index implements RequestHandlerInterface
|
|||
foreach ($models as $model) {
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ final class Attribute extends Field
|
|||
return $this;
|
||||
}
|
||||
|
||||
public function isSortable()
|
||||
public function getSortable()
|
||||
{
|
||||
return $this->sortable;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,12 @@
|
|||
|
||||
namespace Tobyz\JsonApiServer\Schema;
|
||||
|
||||
use Tobyz\JsonApiServer\Schema\Concerns\HasDescription;
|
||||
|
||||
final class Meta
|
||||
{
|
||||
use HasDescription;
|
||||
|
||||
private $name;
|
||||
private $value;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue