Pass around new Context object; update docs; implement once()

This commit is contained in:
Toby Zerner 2020-11-22 11:13:50 +10:30
parent fbecdd96de
commit 807525338c
32 changed files with 294 additions and 952 deletions

676
README.md
View File

@ -1,681 +1,15 @@
# json-api-server # json-api-server
[![Pre Release](https://img.shields.io/packagist/vpre/tobyz/json-api-server.svg?style=flat)](https://github.com/tobyz/json-api-server/releases) [![Pre Release](https://img.shields.io/packagist/vpre/tobyz/json-api-server.svg?style=flat)](https://github.com/tobyzerner/json-api-server/releases)
[![License](https://img.shields.io/packagist/l/tobyz/json-api-server.svg?style=flat)](https://packagist.org/packages/tobyz/json-api-server) [![License](https://img.shields.io/packagist/l/tobyz/json-api-server.svg?style=flat)](https://packagist.org/packages/tobyz/json-api-server)
> **A fully automated [JSON:API](http://jsonapi.org) server implementation in PHP.** json-api-server is a [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 --> Build an API in minutes by defining your API's schema and connecting it to your application's models. json-api-server takes care of all the boilerplate stuff like routing, query parameters, and building a valid JSON:API document.
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
## Documentation
- [Installation](#installation) [Read the documentation](https://tobyzerner.github.io/json-api-server)
- [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
composer require tobyz/json-api-server
```
## Usage
```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()
->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()
->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\ResponseInterface $response */
try {
$response = $api->handle($request);
} catch (Exception $e) {
$response = $api->error($e);
}
```
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`)
- **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), [getters](#getters), [setters](#setters-savers), [validation](#validation), and custom [filtering](#filtering) and [sorting](#sorting) logic to build a fully functional API in minutes.
### Handling Requests
```php
use Tobyz\JsonApiServer\JsonApi;
$api = new JsonApi('http://example.com/api');
try {
$response = $api->handle($request);
} catch (Exception $e) {
$response = $api->error($e);
}
```
`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 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;
$api->resource('comments', $adapter, function (Type $type) {
// define your schema
});
```
#### 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;
$adapter = new EloquentAdapter(User::class);
```
### 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 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')
->property('fname');
```
### 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](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')
->type('people');
```
Like attributes, the relationship will automatically read and write to the relation on your model with the same name. If you'd like it to correspond to a different relation, use the `property` method.
#### 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 `withoutLinks` method:
```php
$type->hasOne('mostRelevantPost')
->withoutLinks();
```
> **Note:** These URLs are not yet implemented.
#### Relationship Linkage
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->hasMany('users')
->withwithLinkage();
```
> **Warning:** Be careful when enabling linkage on to-many relationships as pagination is not supported in relationships.
#### 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();
```
> **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/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()
->dontLoad();
```
#### 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']);
```
Note that nested includes cannot be requested on polymorphic relationships.
### Getters
Use the `get` method to define custom retrieval logic for your field, instead of just reading the value straight from the model property. (If you're using Eloquent, you could also define attribute [casts](https://laravel.com/docs/5.8/eloquent-mutators#attribute-casting) or [accessors](https://laravel.com/docs/5.8/eloquent-mutators#defining-an-accessor) on your model to achieve a similar thing.)
```php
$type->attribute('firstName')
->get(function ($model, Context $context) {
return ucfirst($model->first_name);
});
```
### Visibility
#### 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 the adapter:
```php
$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 `405 Method Not Allowed` from `GET /articles`), you can use the `notListable` method:
```php
$type->notListable();
```
#### Field Visibility
You can specify logic to restrict the visibility of a field using the `visible` and `hidden` methods:
```php
$type->attribute('email')
// Make a field always visible (default)
->visible()
// Make a field visible only if certain logic is met
->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, Context $context) {
return $context->getRequest()->getAttribute('userIsSuspended');
});
```
### Writability
By default, fields are read-only. You can allow a field to be written to via `PATCH` and `POST` requests using the `writable` and `readonly` methods:
```php
$type->attribute('email')
// Make an attribute writable
->writable()
// Make an attribute writable only if certain logic is met
->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, Context $context) {
return $context->getRequest()->getAttribute('userIsSuspended');
});
```
### Default Values
You can provide a default value for a field to be used when creating a new resource if there is no value provided by the consumer. Pass a value or a closure to the `default` method:
```php
$type->attribute('joinedAt')
->default(new DateTime);
$type->attribute('ipAddress')
->default(function (Context $context) {
return $context->getRequest()->getServerParams()['REMOTE_ADDR'] ?? null;
});
```
If you're using Eloquent, you could also define [default attribute values](https://laravel.com/docs/5.8/eloquent#default-attribute-values) to achieve a similar thing (although you wouldn't have access to the request object).
### Validation
You can ensure that data provided for a field is valid before it is saved. Provide a closure to the `validate` method, and call the first argument if validation fails:
```php
$type->attribute('email')
->validate(function (callable $fail, $value, $model, Context $context) {
if (! filter_var($value, FILTER_VALIDATE_EMAIL)) {
$fail('Invalid email');
}
});
```
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, Context $context) {
foreach ($groups as $group) {
if ($group->id === 1) {
$fail('You cannot assign this group');
}
}
});
```
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']));
```
### Transformers, Setters & Savers
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')
->transform(function ($value, Context $context) {
return ucfirst($value);
});
```
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 ($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:
```php
$type->attribute('firstName')
->filterable();
$type->hasMany('groups')
->filterable();
// eg. GET /api/users?filter[firstName]=Toby&filter[groups]=1,2,3
```
The `>`, `>=`, `<`, `<=`, and `..` operators on attribute filter values are automatically parsed and applied, supporting queries like:
```
GET /api/users?filter[postCount]=>=10
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, Context $context) {
$query->where('postCount', '>=', $value);
});
```
### Sorting
You can define an attribute as `sortable` to allow the resource index to be [sorted](https://jsonapi.org/format/#fetching-sorting) by the attribute's value:
```php
$type->attribute('firstName')
->sortable();
$type->attribute('lastName')
->sortable();
// e.g. GET /api/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, 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:
```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();
```
### Meta Information
You can add meta information to a resource using the `meta` method:
```php
$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.
```php
$type->creatable();
$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 `newModel` method:
```php
$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:
```php
$type->updatable();
$type->updatable(function (Context $context) {
return $context->getRequest()->getAttribute('isAdmin');
});
```
#### Customizing Update Logic
```php
$type->update(function ($model, Context $context) {
// push to a queue
});
```
### Deleting Resources
By default, resources are not [deletable](https://jsonapi.org/format/#crud-deleting) (i.e. `DELETE` requests will return `403 Forbidden`). You can allow them to be deleted using the `deletable` and `notDeletable` methods on the schema builder:
```php
$type->deletable();
$type->deletable(function (ServerRequestInterface $request) {
return $request->getAttr``ibute('isAdmin');
});
$type->delete(function ($model, Context $context) {
$model->delete();
});
```
### Events
The server will fire several events, allowing you to hook into the following points in a resource's lifecycle: `listing`, `listed`, `showing`, `shown`, `creating`, `created`, `updating`, `updated`, `deleting`, `deleted`. (If you're using Eloquent, you could also use [model events](https://laravel.com/docs/5.8/eloquent#events) to achieve a similar thing, although you wouldn't have access to the request object.)
To listen for an event, simply call the matching method name on the schema and pass a closure to be executed, which will receive the model and the request:
```php
$type->onCreating(function ($model, ServerRequestInterface $request) {
// do something before a new model is saved
});
```
### Authentication
You are responsible for performing your own authentication. An effective way to pass information about the authenticated user is by setting attributes on your request object before passing it into the request handler.
You should indicate to the server if the consumer is authenticated using the `authenticated` method. This is important because it will determine whether the response will be `401 Unauthorized` or `403 Forbidden` in the case of an unauthorized request.
```php
$api->authenticated();
```
### Laravel Helpers
#### Authorization
#### Validation
### Meta Information
#### Document-level
#### Resource-level
#### Relationship-level
### Modifying Responses
## Examples
* TODO
## Contributing ## Contributing

View File

@ -18,11 +18,10 @@ $type->attribute('firstName')
Use the `get` method to define custom retrieval logic for your attribute, instead of just reading the value straight from the model property. Use the `get` method to define custom retrieval logic for your attribute, instead of just reading the value straight from the model property.
```php ```php
use Psr\Http\Message\ServerRequestInterface as Request; use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Schema\Attribute;
$type->attribute('firstName') $type->attribute('firstName')
->get(function ($model, Request $request, Attribute $attribute) { ->get(function ($model, Context $context) {
return ucfirst($model->first_name); return ucfirst($model->first_name);
}); });
``` ```

View File

@ -7,7 +7,7 @@ Optionally pass a closure that returns a boolean value.
```php ```php
$type->creatable(); $type->creatable();
$type->creatable(function (Request $request) { $type->creatable(function (Context $context) {
return $request->getAttribute('user')->isAdmin(); return $request->getAttribute('user')->isAdmin();
}); });
``` ```
@ -17,7 +17,7 @@ $type->creatable(function (Request $request) {
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: 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 ```php
$type->newModel(function (Request $request) { $type->newModel(function (Context $context) {
return new CustomModel; return new CustomModel;
}); });
``` ```
@ -29,7 +29,7 @@ $type->newModel(function (Request $request) {
Run before the model is saved. Run before the model is saved.
```php ```php
$type->onCreating(function ($model, Request $request) { $type->onCreating(function ($model, Context $context) {
// do something // do something
}); });
``` ```
@ -39,7 +39,7 @@ $type->onCreating(function ($model, Request $request) {
Run after the model is saved. Run after the model is saved.
```php ```php
$type->onCreated(function ($model, Request $request) { $type->onCreated(function ($model, Context $context) {
// do something $context->meta('foo', 'bar');
}); });
``` ```

View File

@ -7,7 +7,7 @@ Optionally pass a closure that returns a boolean value.
```php ```php
$type->deletable(); $type->deletable();
$type->deletable(function (Request $request) { $type->deletable(function (Context $context) {
return $request->getAttribute('user')->isAdmin(); return $request->getAttribute('user')->isAdmin();
}); });
``` ```
@ -19,7 +19,7 @@ $type->deletable(function (Request $request) {
Run before the model is deleted. Run before the model is deleted.
```php ```php
$type->onDeleting(function ($model, Request $request) { $type->onDeleting(function ($model, Context $context) {
// do something // do something
}); });
``` ```
@ -29,7 +29,7 @@ $type->onDeleting(function ($model, Request $request) {
Run after the model is deleted. Run after the model is deleted.
```php ```php
$type->onDeleted(function ($model, Request $request) { $type->onDeleted(function ($model, Context $context) {
// do something // do something
}); });
``` ```

View File

@ -24,7 +24,7 @@ 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: To define filters with custom logic, or ones that do not correspond to an attribute, use the `filter` method:
```php ```php
$type->filter('minPosts', function ($query, $value, Request $request) { $type->filter('minPosts', function ($query, $value, Context $context) {
$query->where('postCount', '>=', $value); $query->where('postCount', '>=', $value);
}); });
``` ```

View File

@ -1,8 +1,8 @@
# Introduction # Introduction
**json-api-server** is an automated [JSON:API](http://jsonapi.org) server implementation in PHP. json-api-server is a comprehensive [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. 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: 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:
@ -19,7 +19,7 @@ The schema definition is extremely powerful and lets you easily apply [permissio
### Example ### 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. 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 ```php
use App\Models\{Article, Comment, User}; use App\Models\{Article, Comment, User};

View File

@ -7,7 +7,7 @@ If you want to restrict the ability to list a resource type, use the `listable`
```php ```php
$type->notListable(); $type->notListable();
$type->listable(function (Request $request) { $type->listable(function (Context $context) {
return $request->getAttribute('user')->isAdmin(); return $request->getAttribute('user')->isAdmin();
}); });
``` ```
@ -19,7 +19,7 @@ $type->listable(function (Request $request) {
Run before [scopes](scopes.md) are applied to the `$query` and results are retrieved. Run before [scopes](scopes.md) are applied to the `$query` and results are retrieved.
```php ```php
$type->onListing(function ($query, Request $request) { $type->onListing(function ($query, Context $context) {
// do something // do something
}); });
``` ```
@ -29,7 +29,7 @@ $type->onListing(function ($query, Request $request) {
Run after models and relationships have been retrieved, but before they are serialized into a JSON:API document. Run after models and relationships have been retrieved, but before they are serialized into a JSON:API document.
```php ```php
$type->onListed(function ($models, Request $request) { $type->onListed(function ($models, Context $context) {
// do something // do something
}); });
``` ```

View File

@ -4,11 +4,13 @@ You can add meta information at various levels of the document using the `meta`
## Document Meta ## Document Meta
To add meta information at the top-level, call `meta` on the `JsonApi` instance: To add meta information at the top-level of a document, you can call the `meta` method on the `Context` instance which is available inside any of your schema's callbacks.
For example, to add meta information to a resource listing, you might call this inside of an `onListed` listener:
```php ```php
$api->meta('requestTime', function (Request $request) { $type->onListed(function ($models, Context $context) {
return new DateTime; $context->meta('foo', 'bar');
}); });
``` ```
@ -17,7 +19,7 @@ $api->meta('requestTime', function (Request $request) {
To add meta information at the resource-level, call `meta` on the schema builder. To add meta information at the resource-level, call `meta` on the schema builder.
```php ```php
$type->meta('updatedAt', function ($model, Request $request) { $type->meta('updatedAt', function ($model, Context $context) {
return $model->updated_at; return $model->updated_at;
}); });
``` ```

View File

@ -50,12 +50,11 @@ Be careful when making to-many relationships includable as pagination is not sup
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: 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 ```php
use Psr\Http\Message\ServerRequestInterface as Request; use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Schema\HasOne;
$type->hasOne('users') $type->hasOne('users')
->includable() ->includable()
->scope(function ($query, Request $request, HasOne $field) { ->scope(function ($query, Context $context) {
$query->where('is_listed', true); $query->where('is_listed', true);
}); });
``` ```
@ -84,7 +83,7 @@ $api->resource('categories', new EloquentAdapter(Models\Category::class), functi
$api->resource('posts', new EloquentAdapter(Models\Post::class), function (Type $type) { $api->resource('posts', new EloquentAdapter(Models\Post::class), function (Type $type) {
$type->hasOne('user') // 2 $type->hasOne('user') // 2
->includable() ->includable()
->load(function (array $models, array $relationships, Request $request, HasOne $field) { ->load(function (array $models, array $relationships, Context $context) {
// Since this request is to the `GET /categories` endpoint, $models // Since this request is to the `GET /categories` endpoint, $models
// will be an array of Category models, and $relationships will be // will be an array of Category models, and $relationships will be
// an array containing the objects [1, 2] above. // an array containing the objects [1, 2] above.
@ -114,7 +113,7 @@ You can add meta information to a relationship using the `meta` method:
```php ```php
$type->hasOne('user') $type->hasOne('user')
->meta('updatedAt', function ($model, $user, Request $request) { ->meta('updatedAt', function ($model, $user, Context $context) {
return $user->updated_at; return $user->updated_at;
}); });
``` ```

View File

@ -7,8 +7,8 @@ This `scope` method allows you to modify the query builder object provided by th
For example, to make it so the authenticated user can only see their own posts: For example, to make it so the authenticated user can only see their own posts:
```php ```php
$type->scope(function ($query, ServerRequestInterface $request) { $type->scope(function ($query, Context $context) {
$query->where('user_id', $request->getAttribute('userId')); $query->where('user_id', $context->getRequest()->getAttribute('userId'));
}); });
``` ```

View File

@ -21,7 +21,7 @@ $type->defaultSort('-updatedAt,-createdAt');
To define sort fields with custom logic, or ones that do not correspond to an attribute, use the `sort` method: To define sort fields with custom logic, or ones that do not correspond to an attribute, use the `sort` method:
```php ```php
$type->sort('relevance', function ($query, string $direction, Request $request) { $type->sort('relevance', function ($query, string $direction, Context $context) {
$query->orderBy('relevance', $direction); $query->orderBy('relevance', $direction);
}); });
``` ```

View File

@ -7,7 +7,7 @@ Optionally pass a closure that returns a boolean value.
```php ```php
$type->updatable(); $type->updatable();
$type->updatable(function (Request $request) { $type->updatable(function (Context $context) {
return $request->getAttribute('user')->isAdmin(); return $request->getAttribute('user')->isAdmin();
}); });
``` ```
@ -19,7 +19,7 @@ $type->updatable(function (Request $request) {
Run before the model is saved. Run before the model is saved.
```php ```php
$type->onUpdating(function ($model, Request $request) { $type->onUpdating(function ($model, Context $context) {
// do something // do something
}); });
``` ```
@ -29,7 +29,7 @@ $type->onUpdating(function ($model, Request $request) {
Run after the model is saved. Run after the model is saved.
```php ```php
$type->onUpdated(function ($model, Request $request) { $type->onUpdated(function ($model, Context $context) {
// do something // do something
}); });
``` ```

View File

@ -8,7 +8,7 @@ For example, the following schema will make an email attribute that only appears
```php ```php
$type->attribute('email') $type->attribute('email')
->visible(function ($model, Request $request, Attribute $field) { ->visible(function ($model, Context $context) {
return $model->id === $request->getAttribute('userId'); return $model->id === $request->getAttribute('userId');
}); });
``` ```

View File

@ -8,8 +8,8 @@ For example, the following schema will make an email attribute that is only writ
```php ```php
$type->attribute('email') $type->attribute('email')
->writable(function ($model, Request $request, Attribute $field) { ->writable(function ($model, Context $context) {
return $model->id === $request->getAttribute('userId'); return $model->id === $context->getRequest()->getAttribute('userId');
}); });
``` ```
@ -31,8 +31,8 @@ $type->attribute('joinedAt')
->default(new DateTime); ->default(new DateTime);
$type->attribute('ipAddress') $type->attribute('ipAddress')
->default(function (Request $request, Attribute $attribute) { ->default(function (Context $context) {
return $request->getServerParams()['REMOTE_ADDR'] ?? null; return $context->getRequest()->getServerParams()['REMOTE_ADDR'] ?? null;
}); });
``` ```
@ -46,7 +46,7 @@ You can ensure that data provided for a field is valid before the resource is sa
```php ```php
$type->attribute('email') $type->attribute('email')
->validate(function (callable $fail, $value, $model, Request $request, Attribute $attribute) { ->validate(function (callable $fail, $value, $model, Context $context) {
if (! filter_var($value, FILTER_VALIDATE_EMAIL)) { if (! filter_var($value, FILTER_VALIDATE_EMAIL)) {
$fail('Invalid email'); $fail('Invalid email');
} }
@ -61,7 +61,7 @@ This works for relationships, too. The related models will be retrieved via your
```php ```php
$type->hasMany('groups') $type->hasMany('groups')
->validate(function (callable $fail, array $groups, $model, Request $request, Attribute $attribute) { ->validate(function (callable $fail, array $groups, $model, Context $context) {
foreach ($groups as $group) { foreach ($groups as $group) {
if ($group->id === 1) { if ($group->id === 1) {
$fail('You cannot assign this group'); $fail('You cannot assign this group');
@ -76,7 +76,7 @@ Use the `transform` method on an attribute to mutate any incoming value before i
```php ```php
$type->attribute('firstName') $type->attribute('firstName')
->transform(function ($value, Request $request, Attribute $attribute) { ->transform(function ($value, Context $context) {
return ucfirst($value); return ucfirst($value);
}); });
``` ```
@ -91,7 +91,7 @@ Use the `set` method to define custom mutation logic for your field, instead of
```php ```php
$type->attribute('firstName') $type->attribute('firstName')
->set(function ($value, $model, Request $request, Attribute $attribute) { ->set(function ($value, $model, Context $context) {
$model->first_name = ucfirst($value); $model->first_name = ucfirst($value);
if ($model->first_name === 'Toby') { if ($model->first_name === 'Toby') {
$model->last_name = 'Zerner'; $model->last_name = 'Zerner';
@ -105,7 +105,7 @@ If your field corresponds to some other form of data storage rather than a simpl
```php ```php
$type->attribute('locale') $type->attribute('locale')
->save(function ($value, $model, Request $request, Attribute $attribute) { ->save(function ($value, $model, Context $context) {
$model->preferences() $model->preferences()
->where('key', 'locale') ->where('key', 'locale')
->update(['value' => $value]); ->update(['value' => $value]);
@ -120,7 +120,7 @@ Run after a field has been successfully saved.
```php ```php
$type->attribute('email') $type->attribute('email')
->onSaved(function ($value, $model, Request $request, Attribute $attribute) { ->onSaved(function ($value, $model, Context $context) {
event(new EmailWasChanged($model)); event(new EmailWasChanged($model));
}); });
``` ```

View File

@ -1,4 +1,5 @@
<?php <?php
/* /*
* This file is part of tobyz/json-api-server. * This file is part of tobyz/json-api-server.
* *
@ -10,74 +11,29 @@
namespace Tobyz\JsonApiServer; namespace Tobyz\JsonApiServer;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Tobyz\JsonApiServer\Schema\Concerns\HasListeners;
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
class Context class Context
{ {
private $api; use HasMeta;
use HasListeners;
private $request; private $request;
private $resource;
private $model;
private $field;
public function __construct(JsonApi $api, ResourceType $resource) public function __construct(ServerRequestInterface $request)
{ {
$this->api = $api; $this->request = $request;
$this->resource = $resource;
} }
public function getApi(): JsonApi public function getRequest(): ServerRequestInterface
{
return $this->api;
}
public function getRequest(): ?ServerRequestInterface
{ {
return $this->request; return $this->request;
} }
public function forRequest(ServerRequestInterface $request) public function response(callable $callback)
{ {
$new = clone $this; $this->listeners['response'][] = $callback;
$new->request = $request;
return $new;
}
public function getResource(): ?ResourceType
{
return $this->resource;
}
public function forResource(ResourceType $resource)
{
$new = clone $this;
$new->resource = $resource;
$new->model = null;
return $new;
}
public function getModel()
{
return $this->model;
}
public function forModel($model)
{
$new = clone $this;
$new->model = $model;
return $new;
}
public function getField(): ?Field
{
return $this->field;
}
public function forField(Field $field)
{
$new = clone $this;
$new->field = $field;
return $new;
} }
} }

View File

@ -11,9 +11,9 @@
namespace Tobyz\JsonApiServer\Endpoint\Concerns; namespace Tobyz\JsonApiServer\Endpoint\Concerns;
use Psr\Http\Message\ServerRequestInterface;
use Tobyz\JsonApiServer\Exception\ResourceNotFoundException; use Tobyz\JsonApiServer\Exception\ResourceNotFoundException;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Context;
use function Tobyz\JsonApiServer\run_callbacks; use function Tobyz\JsonApiServer\run_callbacks;
trait FindsResources trait FindsResources
@ -23,12 +23,12 @@ trait FindsResources
* *
* @throws ResourceNotFoundException if the resource is not found. * @throws ResourceNotFoundException if the resource is not found.
*/ */
private function findResource(ResourceType $resource, string $id, ServerRequestInterface $request) private function findResource(ResourceType $resource, string $id, Context $context)
{ {
$adapter = $resource->getAdapter(); $adapter = $resource->getAdapter();
$query = $adapter->newQuery(); $query = $adapter->newQuery();
run_callbacks($resource->getSchema()->getListeners('scope'), [$query, $request]); run_callbacks($resource->getSchema()->getListeners('scope'), [$query, $context]);
$model = $adapter->find($query, $id); $model = $adapter->find($query, $id);

View File

@ -11,10 +11,10 @@
namespace Tobyz\JsonApiServer\Endpoint\Concerns; namespace Tobyz\JsonApiServer\Endpoint\Concerns;
use Psr\Http\Message\ServerRequestInterface;
use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Schema\Relationship; use Tobyz\JsonApiServer\Schema\Relationship;
use function Tobyz\JsonApiServer\run_callbacks; use function Tobyz\JsonApiServer\run_callbacks;
@ -24,9 +24,9 @@ use function Tobyz\JsonApiServer\run_callbacks;
*/ */
trait IncludesData trait IncludesData
{ {
private function getInclude(ServerRequestInterface $request): array private function getInclude(Context $context): array
{ {
$queryParams = $request->getQueryParams(); $queryParams = $context->getRequest()->getQueryParams();
if (! empty($queryParams['include'])) { if (! empty($queryParams['include'])) {
$include = $this->parseInclude($queryParams['include']); $include = $this->parseInclude($queryParams['include']);
@ -81,12 +81,12 @@ trait IncludesData
} }
} }
private function loadRelationships(array $models, array $include, ServerRequestInterface $request) private function loadRelationships(array $models, array $include, Context $context)
{ {
$this->loadRelationshipsAtLevel($models, [], $this->resource, $include, $request); $this->loadRelationshipsAtLevel($models, [], $this->resource, $include, $context);
} }
private function loadRelationshipsAtLevel(array $models, array $relationshipPath, ResourceType $resource, array $include, ServerRequestInterface $request) private function loadRelationshipsAtLevel(array $models, array $relationshipPath, ResourceType $resource, array $include, Context $context)
{ {
$adapter = $resource->getAdapter(); $adapter = $resource->getAdapter();
$schema = $resource->getSchema(); $schema = $resource->getSchema();
@ -107,13 +107,13 @@ trait IncludesData
$type = $field->getType(); $type = $field->getType();
if (is_callable($load)) { if (is_callable($load)) {
$load($models, $nextRelationshipPath, $field->hasLinkage(), $request); $load($models, $nextRelationshipPath, $field->hasLinkage(), $context);
} else { } else {
if (is_string($type)) { if (is_string($type)) {
$relatedResource = $this->api->getResource($type); $relatedResource = $this->api->getResource($type);
$scope = function ($query) use ($request, $field, $relatedResource) { $scope = function ($query) use ($context, $field, $relatedResource) {
run_callbacks($relatedResource->getSchema()->getListeners('scope'), [$query, $request]); run_callbacks($relatedResource->getSchema()->getListeners('scope'), [$query, $context]);
run_callbacks($field->getListeners('scope'), [$query, $request]); run_callbacks($field->getListeners('scope'), [$query, $context]);
}; };
} else { } else {
$relatedResources = is_array($type) ? array_map(function ($type) { $relatedResources = is_array($type) ? array_map(function ($type) {
@ -125,10 +125,10 @@ trait IncludesData
return $relatedResource->getType(); return $relatedResource->getType();
}, $relatedResources), }, $relatedResources),
array_map(function ($relatedResource) use ($request, $field) { array_map(function ($relatedResource) use ($context, $field) {
return function ($query) use ($request, $field, $relatedResource) { return function ($query) use ($context, $field, $relatedResource) {
run_callbacks($relatedResource->getSchema()->getListeners('scope'), [$query, $request]); run_callbacks($relatedResource->getSchema()->getListeners('scope'), [$query, $context]);
run_callbacks($field->getListeners('scope'), [$query, $request]); run_callbacks($field->getListeners('scope'), [$query, $context]);
}; };
}, $relatedResources) }, $relatedResources)
); );
@ -140,7 +140,7 @@ trait IncludesData
if (isset($include[$name]) && is_string($type)) { if (isset($include[$name]) && is_string($type)) {
$relatedResource = $this->api->getResource($type); $relatedResource = $this->api->getResource($type);
$this->loadRelationshipsAtLevel($models, $nextRelationshipPath, $relatedResource, $include[$name] ?? [], $request); $this->loadRelationshipsAtLevel($models, $nextRelationshipPath, $relatedResource, $include[$name] ?? [], $context);
} }
} }
} }

View File

@ -16,6 +16,7 @@ use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Exception\UnprocessableEntityException; use Tobyz\JsonApiServer\Exception\UnprocessableEntityException;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Schema\Attribute; use Tobyz\JsonApiServer\Schema\Attribute;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Schema\HasMany; use Tobyz\JsonApiServer\Schema\HasMany;
use Tobyz\JsonApiServer\Schema\HasOne; use Tobyz\JsonApiServer\Schema\HasOne;
use Tobyz\JsonApiServer\Schema\Relationship; use Tobyz\JsonApiServer\Schema\Relationship;
@ -77,7 +78,7 @@ trait SavesData
* *
* @throws BadRequestException if the identifier is invalid. * @throws BadRequestException if the identifier is invalid.
*/ */
private function getModelForIdentifier(ServerRequestInterface $request, array $identifier, array $validTypes = null) private function getModelForIdentifier(Context $context, array $identifier, array $validTypes = null)
{ {
if (! isset($identifier['type'])) { if (! isset($identifier['type'])) {
throw new BadRequestException('type not specified'); throw new BadRequestException('type not specified');
@ -93,16 +94,16 @@ trait SavesData
$resource = $this->api->getResource($identifier['type']); $resource = $this->api->getResource($identifier['type']);
return $this->findResource($request, $resource, $identifier['id']); return $this->findResource($resource, $identifier['id'], $context);
} }
/** /**
* Assert that the fields contained within a data object are valid. * Assert that the fields contained within a data object are valid.
*/ */
private function validateFields(array $data, $model, ServerRequestInterface $request) private function validateFields(array $data, $model, Context $context)
{ {
$this->assertFieldsExist($data); $this->assertFieldsExist($data);
$this->assertFieldsWritable($data, $model, $request); $this->assertFieldsWritable($data, $model, $context);
} }
/** /**
@ -128,11 +129,21 @@ trait SavesData
* *
* @throws BadRequestException if a field is not writable. * @throws BadRequestException if a field is not writable.
*/ */
private function assertFieldsWritable(array $data, $model, ServerRequestInterface $request) private function assertFieldsWritable(array $data, $model, Context $context)
{ {
foreach ($this->resource->getSchema()->getFields() as $field) { foreach ($this->resource->getSchema()->getFields() as $field) {
if (has_value($data, $field) && ! evaluate($field->getWritable(), [$model, $request])) { if (! has_value($data, $field)) {
continue;
}
if (
! evaluate($field->getWritable(), [$model, $context])
|| (
$context->getRequest()->getMethod() !== 'POST'
&& $field->isWritableOnce()
)
) {
throw new BadRequestException("Field [{$field->getName()}] is not writable"); throw new BadRequestException("Field [{$field->getName()}] is not writable");
} }
} }
@ -141,7 +152,7 @@ trait SavesData
/** /**
* Replace relationship linkage within a data object with models. * Replace relationship linkage within a data object with models.
*/ */
private function loadRelatedResources(array &$data, ServerRequestInterface $request) private function loadRelatedResources(array &$data, Context $context)
{ {
foreach ($this->resource->getSchema()->getFields() as $field) { foreach ($this->resource->getSchema()->getFields() as $field) {
if (! $field instanceof Relationship || ! has_value($data, $field)) { if (! $field instanceof Relationship || ! has_value($data, $field)) {
@ -154,10 +165,10 @@ trait SavesData
$allowedTypes = (array) $field->getType(); $allowedTypes = (array) $field->getType();
if ($field instanceof HasOne) { if ($field instanceof HasOne) {
set_value($data, $field, $this->getModelForIdentifier($request, $value['data'], $allowedTypes)); set_value($data, $field, $this->getModelForIdentifier($context, $value['data'], $allowedTypes));
} elseif ($field instanceof HasMany) { } elseif ($field instanceof HasMany) {
set_value($data, $field, array_map(function ($identifier) use ($request, $allowedTypes) { set_value($data, $field, array_map(function ($identifier) use ($context, $allowedTypes) {
return $this->getModelForIdentifier($request, $identifier, $allowedTypes); return $this->getModelForIdentifier($context, $identifier, $allowedTypes);
}, $value['data'])); }, $value['data']));
} }
} else { } else {
@ -171,7 +182,7 @@ trait SavesData
* *
* @throws UnprocessableEntityException if any fields do not pass validation. * @throws UnprocessableEntityException if any fields do not pass validation.
*/ */
private function assertDataValid(array $data, $model, ServerRequestInterface $request, bool $validateAll): void private function assertDataValid(array $data, $model, Context $context, bool $validateAll): void
{ {
$failures = []; $failures = [];
@ -186,7 +197,7 @@ trait SavesData
run_callbacks( run_callbacks(
$field->getListeners('validate'), $field->getListeners('validate'),
[$fail, get_value($data, $field), $model, $request] [$fail, get_value($data, $field), $model, $context]
); );
} }
@ -198,7 +209,7 @@ trait SavesData
/** /**
* Set field values from a data object to the model instance. * Set field values from a data object to the model instance.
*/ */
private function setValues(array $data, $model, ServerRequestInterface $request) private function setValues(array $data, $model, Context $context)
{ {
$adapter = $this->resource->getAdapter(); $adapter = $this->resource->getAdapter();
@ -210,7 +221,7 @@ trait SavesData
$value = get_value($data, $field); $value = get_value($data, $field);
if ($setCallback = $field->getSetCallback()) { if ($setCallback = $field->getSetCallback()) {
$setCallback($model, $value, $request); $setCallback($model, $value, $context);
continue; continue;
} }
@ -229,19 +240,19 @@ trait SavesData
/** /**
* Save the model and its fields. * Save the model and its fields.
*/ */
private function save(array $data, $model, ServerRequestInterface $request) private function save(array $data, $model, Context $context)
{ {
$this->saveModel($model, $request); $this->saveModel($model, $context);
$this->saveFields($data, $model, $request); $this->saveFields($data, $model, $context);
} }
/** /**
* Save the model. * Save the model.
*/ */
private function saveModel($model, ServerRequestInterface $request) private function saveModel($model, Context $context)
{ {
if ($saveCallback = $this->resource->getSchema()->getSaveCallback()) { if ($saveCallback = $this->resource->getSchema()->getSaveCallback()) {
$saveCallback($model, $request); $saveCallback($model, $context);
} else { } else {
$this->resource->getAdapter()->save($model); $this->resource->getAdapter()->save($model);
} }
@ -250,7 +261,7 @@ trait SavesData
/** /**
* Save any fields that were not saved with the model. * Save any fields that were not saved with the model.
*/ */
private function saveFields(array $data, $model, ServerRequestInterface $request) private function saveFields(array $data, $model, Context $context)
{ {
$adapter = $this->resource->getAdapter(); $adapter = $this->resource->getAdapter();
@ -262,19 +273,19 @@ trait SavesData
$value = get_value($data, $field); $value = get_value($data, $field);
if ($saveCallback = $field->getSaveCallback()) { if ($saveCallback = $field->getSaveCallback()) {
$saveCallback($model, $value, $request); $saveCallback($model, $value, $context);
} elseif ($field instanceof HasMany) { } elseif ($field instanceof HasMany) {
$adapter->saveHasMany($model, $field, $value); $adapter->saveHasMany($model, $field, $value);
} }
} }
$this->runSavedCallbacks($data, $model, $request); $this->runSavedCallbacks($data, $model, $context);
} }
/** /**
* Run field saved listeners. * Run field saved listeners.
*/ */
private function runSavedCallbacks(array $data, $model, ServerRequestInterface $request) private function runSavedCallbacks(array $data, $model, Context $context)
{ {
foreach ($this->resource->getSchema()->getFields() as $field) { foreach ($this->resource->getSchema()->getFields() as $field) {
@ -284,7 +295,7 @@ trait SavesData
run_callbacks( run_callbacks(
$field->getListeners('saved'), $field->getListeners('saved'),
[$model, get_value($data, $field), $request] [$model, get_value($data, $field), $context]
); );
} }
} }

View File

@ -11,10 +11,8 @@
namespace Tobyz\JsonApiServer\Endpoint; namespace Tobyz\JsonApiServer\Endpoint;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Tobyz\JsonApiServer\Context;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface;
use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Exception\ForbiddenException;
use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
@ -23,7 +21,7 @@ use function Tobyz\JsonApiServer\has_value;
use function Tobyz\JsonApiServer\run_callbacks; use function Tobyz\JsonApiServer\run_callbacks;
use function Tobyz\JsonApiServer\set_value; use function Tobyz\JsonApiServer\set_value;
class Create implements RequestHandlerInterface class Create
{ {
use Concerns\SavesData; use Concerns\SavesData;
@ -37,53 +35,51 @@ class Create implements RequestHandlerInterface
} }
/** /**
* Handle a request to create a resource.
*
* @throws ForbiddenException if the resource is not creatable. * @throws ForbiddenException if the resource is not creatable.
*/ */
public function handle(Request $request): Response public function handle(Context $context): ResponseInterface
{ {
$schema = $this->resource->getSchema(); $schema = $this->resource->getSchema();
if (! evaluate($schema->isCreatable(), [$request])) { if (! evaluate($schema->isCreatable(), [$context])) {
throw new ForbiddenException; throw new ForbiddenException;
} }
$model = $this->newModel($request); $model = $this->newModel($context);
$data = $this->parseData($request->getParsedBody()); $data = $this->parseData($context->getRequest()->getParsedBody());
$this->validateFields($data, $model, $request); $this->validateFields($data, $model, $context);
$this->fillDefaultValues($data, $request); $this->fillDefaultValues($data, $context);
$this->loadRelatedResources($data, $request); $this->loadRelatedResources($data, $context);
$this->assertDataValid($data, $model, $request, true); $this->assertDataValid($data, $model, $context, true);
$this->setValues($data, $model, $request); $this->setValues($data, $model, $context);
run_callbacks($schema->getListeners('creating'), [$model, $request]); run_callbacks($schema->getListeners('creating'), [$model, $context]);
$this->save($data, $model, $request); $this->save($data, $model, $context);
run_callbacks($schema->getListeners('created'), [$model, $request]); run_callbacks($schema->getListeners('created'), [$model, $context]);
return (new Show($this->api, $this->resource, $model)) return (new Show($this->api, $this->resource, $model))
->handle($request) ->handle($context)
->withStatus(201); ->withStatus(201);
} }
private function newModel(ServerRequestInterface $request) private function newModel(Context $context)
{ {
$resource = $this->resource; $resource = $this->resource;
$newModel = $resource->getSchema()->getNewModelCallback(); $newModel = $resource->getSchema()->getNewModelCallback();
return $newModel return $newModel
? $newModel($request) ? $newModel($context)
: $resource->getAdapter()->newModel(); : $resource->getAdapter()->newModel();
} }
private function fillDefaultValues(array &$data, ServerRequestInterface $request) private function fillDefaultValues(array &$data, Context $context)
{ {
foreach ($this->resource->getSchema()->getFields() as $field) { foreach ($this->resource->getSchema()->getFields() as $field) {
if (! has_value($data, $field) && ($defaultCallback = $field->getDefaultCallback())) { if (! has_value($data, $field) && ($defaultCallback = $field->getDefaultCallback())) {
set_value($data, $field, $defaultCallback($request)); set_value($data, $field, $defaultCallback($context));
} }
} }
} }

View File

@ -13,15 +13,14 @@ namespace Tobyz\JsonApiServer\Endpoint;
use Nyholm\Psr7\Response; use Nyholm\Psr7\Response;
use Psr\Http\Message\ResponseInterface; 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\Exception\ForbiddenException;
use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Context;
use function Tobyz\JsonApiServer\evaluate; use function Tobyz\JsonApiServer\evaluate;
use function Tobyz\JsonApiServer\run_callbacks; use function Tobyz\JsonApiServer\run_callbacks;
class Delete implements RequestHandlerInterface class Delete
{ {
private $api; private $api;
private $resource; private $resource;
@ -35,27 +34,25 @@ class Delete implements RequestHandlerInterface
} }
/** /**
* Handle a request to delete a resource.
*
* @throws ForbiddenException if the resource is not deletable. * @throws ForbiddenException if the resource is not deletable.
*/ */
public function handle(Request $request): ResponseInterface public function handle(Context $context): ResponseInterface
{ {
$schema = $this->resource->getSchema(); $schema = $this->resource->getSchema();
if (! evaluate($schema->isDeletable(), [$this->model, $request])) { if (! evaluate($schema->isDeletable(), [$this->model, $context])) {
throw new ForbiddenException; throw new ForbiddenException;
} }
run_callbacks($schema->getListeners('deleting'), [$this->model, $request]); run_callbacks($schema->getListeners('deleting'), [$this->model, $context]);
if ($deleteCallback = $schema->getDeleteCallback()) { if ($deleteCallback = $schema->getDeleteCallback()) {
$deleteCallback($this->model, $request); $deleteCallback($this->model, $context);
} else { } else {
$this->resource->getAdapter()->delete($this->model); $this->resource->getAdapter()->delete($this->model);
} }
run_callbacks($schema->getListeners('deleted'), [$this->model, $request]); run_callbacks($schema->getListeners('deleted'), [$this->model, $context]);
return new Response(204); return new Response(204);
} }

View File

@ -15,14 +15,14 @@ use JsonApiPhp\JsonApi as Structure;
use JsonApiPhp\JsonApi\Link\LastLink; use JsonApiPhp\JsonApi\Link\LastLink;
use JsonApiPhp\JsonApi\Link\NextLink; use JsonApiPhp\JsonApi\Link\NextLink;
use JsonApiPhp\JsonApi\Link\PrevLink; use JsonApiPhp\JsonApi\Link\PrevLink;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface;
use Tobyz\JsonApiServer\Adapter\AdapterInterface; use Tobyz\JsonApiServer\Adapter\AdapterInterface;
use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Schema\Attribute; use Tobyz\JsonApiServer\Schema\Attribute;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Schema\HasMany; use Tobyz\JsonApiServer\Schema\HasMany;
use Tobyz\JsonApiServer\Schema\HasOne; use Tobyz\JsonApiServer\Schema\HasOne;
use Tobyz\JsonApiServer\Serializer; use Tobyz\JsonApiServer\Serializer;
@ -30,7 +30,7 @@ use function Tobyz\JsonApiServer\evaluate;
use function Tobyz\JsonApiServer\json_api_response; use function Tobyz\JsonApiServer\json_api_response;
use function Tobyz\JsonApiServer\run_callbacks; use function Tobyz\JsonApiServer\run_callbacks;
class Index implements RequestHandlerInterface class Index
{ {
use Concerns\IncludesData; use Concerns\IncludesData;
@ -46,30 +46,30 @@ class Index implements RequestHandlerInterface
/** /**
* Handle a request to show a resource listing. * Handle a request to show a resource listing.
*/ */
public function handle(Request $request): Response public function handle(Context $context): ResponseInterface
{ {
$adapter = $this->resource->getAdapter(); $adapter = $this->resource->getAdapter();
$schema = $this->resource->getSchema(); $schema = $this->resource->getSchema();
$query = $adapter->newQuery(); $query = $adapter->newQuery();
run_callbacks($schema->getListeners('listing'), [$query, $request]); run_callbacks($schema->getListeners('listing'), [$query, $context]);
run_callbacks($schema->getListeners('scope'), [$query, $request]); run_callbacks($schema->getListeners('scope'), [$query, $context]);
$include = $this->getInclude($request); $include = $this->getInclude($context);
[$offset, $limit] = $this->paginate($query, $request); [$offset, $limit] = $this->paginate($query, $context);
$this->sort($query, $request); $this->sort($query, $context);
$this->filter($query, $request); $this->filter($query, $context);
$total = $schema->isCountable() ? $adapter->count($query) : null; $total = $schema->isCountable() ? $adapter->count($query) : null;
$models = $adapter->get($query); $models = $adapter->get($query);
$this->loadRelationships($models, $include, $request); $this->loadRelationships($models, $include, $context);
run_callbacks($schema->getListeners('listed'), [$models, $request]); run_callbacks($schema->getListeners('listed'), [$models, $context]);
$serializer = new Serializer($this->api, $request); $serializer = new Serializer($this->api, $context);
foreach ($models as $model) { foreach ($models as $model) {
$serializer->add($this->resource, $model, $include); $serializer->add($this->resource, $model, $include);
@ -78,11 +78,11 @@ class Index implements RequestHandlerInterface
return json_api_response( return json_api_response(
new Structure\CompoundDocument( new Structure\CompoundDocument(
new Structure\PaginatedCollection( new Structure\PaginatedCollection(
new Structure\Pagination(...$this->buildPaginationLinks($request, $offset, $limit, count($models), $total)), new Structure\Pagination(...$this->buildPaginationLinks($context->getRequest(), $offset, $limit, count($models), $total)),
new Structure\ResourceCollection(...$serializer->primary()) new Structure\ResourceCollection(...$serializer->primary())
), ),
new Structure\Included(...$serializer->included()), new Structure\Included(...$serializer->included()),
new Structure\Link\SelfLink($this->buildUrl($request)), new Structure\Link\SelfLink($this->buildUrl($context->getRequest())),
new Structure\Meta('offset', $offset), new Structure\Meta('offset', $offset),
new Structure\Meta('limit', $limit), new Structure\Meta('limit', $limit),
...($total !== null ? [new Structure\Meta('total', $total)] : []) ...($total !== null ? [new Structure\Meta('total', $total)] : [])
@ -141,11 +141,11 @@ class Index implements RequestHandlerInterface
return $paginationLinks; return $paginationLinks;
} }
private function sort($query, Request $request) private function sort($query, Context $context)
{ {
$schema = $this->resource->getSchema(); $schema = $this->resource->getSchema();
if (! $sort = $request->getQueryParams()['sort'] ?? $schema->getDefaultSort()) { if (! $sort = $context->getRequest()->getQueryParams()['sort'] ?? $schema->getDefaultSort()) {
return; return;
} }
@ -155,14 +155,14 @@ class Index implements RequestHandlerInterface
foreach ($this->parseSort($sort) as $name => $direction) { foreach ($this->parseSort($sort) as $name => $direction) {
if (isset($sortFields[$name])) { if (isset($sortFields[$name])) {
$sortFields[$name]($query, $direction, $request); $sortFields[$name]($query, $direction, $context);
continue; continue;
} }
if ( if (
isset($fields[$name]) isset($fields[$name])
&& $fields[$name] instanceof Attribute && $fields[$name] instanceof Attribute
&& evaluate($fields[$name]->getSortable(), [$request]) && evaluate($fields[$name]->getSortable(), [$context])
) { ) {
$adapter->sortByAttribute($query, $fields[$name], $direction); $adapter->sortByAttribute($query, $fields[$name], $direction);
continue; continue;
@ -190,10 +190,10 @@ class Index implements RequestHandlerInterface
return $sort; return $sort;
} }
private function paginate($query, Request $request) private function paginate($query, Context $context)
{ {
$schema = $this->resource->getSchema(); $schema = $this->resource->getSchema();
$queryParams = $request->getQueryParams(); $queryParams = $context->getRequest()->getQueryParams();
$limit = $schema->getPerPage(); $limit = $schema->getPerPage();
if (isset($queryParams['page']['limit'])) { if (isset($queryParams['page']['limit'])) {
@ -223,9 +223,9 @@ class Index implements RequestHandlerInterface
return [$offset, $limit]; return [$offset, $limit];
} }
private function filter($query, Request $request) private function filter($query, Context $context)
{ {
if (! $filter = $request->getQueryParams()['filter'] ?? null) { if (! $filter = $context->getRequest()->getQueryParams()['filter'] ?? null) {
return; return;
} }
@ -245,11 +245,11 @@ class Index implements RequestHandlerInterface
} }
if (isset($filters[$name])) { if (isset($filters[$name])) {
$filters[$name]->getCallback()($query, $value, $request); $filters[$name]->getCallback()($query, $value, $context);
continue; continue;
} }
if (isset($fields[$name]) && evaluate($fields[$name]->getFilterable(), [$request])) { if (isset($fields[$name]) && evaluate($fields[$name]->getFilterable(), [$context])) {
if ($fields[$name] instanceof Attribute) { if ($fields[$name] instanceof Attribute) {
$this->filterByAttribute($adapter, $query, $fields[$name], $value); $this->filterByAttribute($adapter, $query, $fields[$name], $value);
} elseif ($fields[$name] instanceof HasOne) { } elseif ($fields[$name] instanceof HasOne) {

View File

@ -13,16 +13,15 @@ namespace Tobyz\JsonApiServer\Endpoint;
use JsonApiPhp\JsonApi\CompoundDocument; use JsonApiPhp\JsonApi\CompoundDocument;
use JsonApiPhp\JsonApi\Included; use JsonApiPhp\JsonApi\Included;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface;
use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Serializer; use Tobyz\JsonApiServer\Serializer;
use function Tobyz\JsonApiServer\json_api_response; use function Tobyz\JsonApiServer\json_api_response;
use function Tobyz\JsonApiServer\run_callbacks; use function Tobyz\JsonApiServer\run_callbacks;
class Show implements RequestHandlerInterface class Show
{ {
use Concerns\IncludesData; use Concerns\IncludesData;
@ -37,18 +36,15 @@ class Show implements RequestHandlerInterface
$this->model = $model; $this->model = $model;
} }
/** public function handle(Context $context): ResponseInterface
* Handle a request to show a resource.
*/
public function handle(Request $request): Response
{ {
$include = $this->getInclude($request); $include = $this->getInclude($context);
$this->loadRelationships([$this->model], $include, $request); $this->loadRelationships([$this->model], $include, $context);
run_callbacks($this->resource->getSchema()->getListeners('show'), [$this->model, $request]); run_callbacks($this->resource->getSchema()->getListeners('show'), [$this->model, $context]);
$serializer = new Serializer($this->api, $request); $serializer = new Serializer($this->api, $context);
$serializer->add($this->resource, $this->model, $include); $serializer->add($this->resource, $this->model, $include);
return json_api_response( return json_api_response(

View File

@ -11,16 +11,15 @@
namespace Tobyz\JsonApiServer\Endpoint; namespace Tobyz\JsonApiServer\Endpoint;
use Psr\Http\Message\ResponseInterface as 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\Exception\ForbiddenException;
use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Context;
use function Tobyz\JsonApiServer\evaluate; use function Tobyz\JsonApiServer\evaluate;
use function Tobyz\JsonApiServer\run_callbacks; use function Tobyz\JsonApiServer\run_callbacks;
class Update implements RequestHandlerInterface class Update
{ {
use Concerns\SavesData; use Concerns\SavesData;
@ -36,32 +35,30 @@ class Update implements RequestHandlerInterface
} }
/** /**
* Handle a request to update a resource.
*
* @throws ForbiddenException if the resource is not updatable. * @throws ForbiddenException if the resource is not updatable.
*/ */
public function handle(Request $request): Response public function handle(Context $context): ResponseInterface
{ {
$schema = $this->resource->getSchema(); $schema = $this->resource->getSchema();
if (! evaluate($schema->isUpdatable(), [$this->model, $request])) { if (! evaluate($schema->isUpdatable(), [$this->model, $context])) {
throw new ForbiddenException; throw new ForbiddenException;
} }
$data = $this->parseData($request->getParsedBody(), $this->model); $data = $this->parseData($context->getRequest()->getParsedBody(), $this->model);
$this->validateFields($data, $this->model, $request); $this->validateFields($data, $this->model, $context);
$this->loadRelatedResources($data, $request); $this->loadRelatedResources($data, $context);
$this->assertDataValid($data, $this->model, $request, false); $this->assertDataValid($data, $this->model, $context, false);
$this->setValues($data, $this->model, $request); $this->setValues($data, $this->model, $context);
run_callbacks($schema->getListeners('updating'), [$this->model, $request]); run_callbacks($schema->getListeners('updating'), [$this->model, $context]);
$this->save($data, $this->model, $request); $this->save($data, $this->model, $context);
run_callbacks($schema->getListeners('updated'), [$this->model, $request]); run_callbacks($schema->getListeners('updated'), [$this->model, $context]);
return (new Show($this->api, $this->resource, $this->model)) return (new Show($this->api, $this->resource, $this->model))
->handle($request); ->handle($context);
} }
} }

View File

@ -26,6 +26,7 @@ use Tobyz\JsonApiServer\Exception\UnsupportedMediaTypeException;
use Tobyz\JsonApiServer\Endpoint\Concerns\FindsResources; use Tobyz\JsonApiServer\Endpoint\Concerns\FindsResources;
use Tobyz\JsonApiServer\Http\MediaTypes; use Tobyz\JsonApiServer\Http\MediaTypes;
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta; use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
use Tobyz\JsonApiServer\Context;
final class JsonApi implements RequestHandlerInterface final class JsonApi implements RequestHandlerInterface
{ {
@ -93,13 +94,14 @@ final class JsonApi implements RequestHandlerInterface
$segments = explode('/', trim($path, '/')); $segments = explode('/', trim($path, '/'));
$resource = $this->getResource($segments[0]); $resource = $this->getResource($segments[0]);
$context = new Context($request);
switch (count($segments)) { switch (count($segments)) {
case 1: case 1:
return $this->handleCollection($request, $resource); return $this->handleCollection($context, $resource);
case 2: case 2:
return $this->handleResource($request, $resource, $segments[1]); return $this->handleResource($context, $resource, $segments[1]);
case 3: case 3:
throw new NotImplementedException; throw new NotImplementedException;
@ -164,33 +166,33 @@ final class JsonApi implements RequestHandlerInterface
return $path; return $path;
} }
private function handleCollection(Request $request, ResourceType $resource): Response private function handleCollection(Context $context, ResourceType $resource): Response
{ {
switch ($request->getMethod()) { switch ($context->getRequest()->getMethod()) {
case 'GET': case 'GET':
return (new Endpoint\Index($this, $resource))->handle($request); return (new Endpoint\Index($this, $resource))->handle($context);
case 'POST': case 'POST':
return (new Endpoint\Create($this, $resource))->handle($request); return (new Endpoint\Create($this, $resource))->handle($context);
default: default:
throw new MethodNotAllowedException; throw new MethodNotAllowedException;
} }
} }
private function handleResource(Request $request, ResourceType $resource, string $id): Response private function handleResource(Context $context, ResourceType $resource, string $id): Response
{ {
$model = $this->findResource($resource, $id, $request); $model = $this->findResource($resource, $id, $context);
switch ($request->getMethod()) { switch ($context->getRequest()->getMethod()) {
case 'PATCH': case 'PATCH':
return (new Endpoint\Update($this, $resource, $model))->handle($request); return (new Endpoint\Update($this, $resource, $model))->handle($context);
case 'GET': case 'GET':
return (new Endpoint\Show($this, $resource, $model))->handle($request); return (new Endpoint\Show($this, $resource, $model))->handle($context);
case 'DELETE': case 'DELETE':
return (new Endpoint\Delete($this, $resource, $model))->handle($request); return (new Endpoint\Delete($this, $resource, $model))->handle($context);
default: default:
throw new MethodNotAllowedException; throw new MethodNotAllowedException;
@ -212,9 +214,7 @@ final class JsonApi implements RequestHandlerInterface
$errors = $e->getJsonApiErrors(); $errors = $e->getJsonApiErrors();
$status = $e->getJsonApiStatus(); $status = $e->getJsonApiStatus();
$document = new ErrorDocument( $document = new ErrorDocument(...$errors);
...$errors
);
return json_api_response($document, $status); return json_api_response($document, $status);
} }

View File

@ -26,6 +26,7 @@ abstract class Field
private $visible = true; private $visible = true;
private $single = false; private $single = false;
private $writable = false; private $writable = false;
private $writableOnce = false;
private $getCallback; private $getCallback;
private $setCallback; private $setCallback;
private $saveCallback; private $saveCallback;
@ -93,6 +94,16 @@ abstract class Field
return $this; return $this;
} }
/**
* Only allow this field to be written on creation.
*/
public function once()
{
$this->writableOnce = true;
return $this;
}
/** /**
* Define the value of this field. * Define the value of this field.
* *
@ -213,6 +224,11 @@ abstract class Field
return $this->writable; return $this->writable;
} }
public function isWritableOnce(): bool
{
return $this->writableOnce;
}
public function getGetCallback() public function getGetCallback()
{ {
return $this->getCallback; return $this->getCallback;

View File

@ -14,19 +14,20 @@ namespace Tobyz\JsonApiServer;
use DateTime; use DateTime;
use DateTimeInterface; use DateTimeInterface;
use JsonApiPhp\JsonApi as Structure; use JsonApiPhp\JsonApi as Structure;
use Psr\Http\Message\ServerRequestInterface as Request;
use RuntimeException; use RuntimeException;
use Tobyz\JsonApiServer\Context;
final class Serializer final class Serializer
{ {
private $api; private $api;
private $context;
private $map = []; private $map = [];
private $primary = []; private $primary = [];
public function __construct(JsonApi $api, Request $request) public function __construct(JsonApi $api, Context $context)
{ {
$this->api = $api; $this->api = $api;
$this->request = $request; $this->context = $context;
} }
/** /**
@ -77,7 +78,7 @@ final class Serializer
$key = $this->key($data); $key = $this->key($data);
$url = $this->api->getBasePath()."/$type/$id"; $url = $this->api->getBasePath()."/$type/$id";
$fields = $schema->getFields(); $fields = $schema->getFields();
$queryParams = $this->request->getQueryParams(); $queryParams = $this->context->getRequest()->getQueryParams();
if (isset($queryParams['fields'][$type])) { if (isset($queryParams['fields'][$type])) {
$fields = array_intersect_key($fields, array_flip(explode(',', $queryParams['fields'][$type]))); $fields = array_intersect_key($fields, array_flip(explode(',', $queryParams['fields'][$type])));
@ -88,7 +89,7 @@ final class Serializer
continue; continue;
} }
if (! evaluate($field->getVisible(), [$model, $this->request])) { if (! evaluate($field->getVisible(), [$model, $this->context])) {
continue; continue;
} }
@ -139,7 +140,7 @@ final class Serializer
private function attribute(Schema\Attribute $field, ResourceType $resource, $model): Structure\Attribute private function attribute(Schema\Attribute $field, ResourceType $resource, $model): Structure\Attribute
{ {
if ($getCallback = $field->getGetCallback()) { if ($getCallback = $field->getGetCallback()) {
$value = $getCallback($model, $this->request); $value = $getCallback($model, $this->context);
} else { } else {
$value = $resource->getAdapter()->getAttribute($model, $field); $value = $resource->getAdapter()->getAttribute($model, $field);
} }
@ -156,7 +157,7 @@ final class Serializer
$included = $include !== null; $included = $include !== null;
$model = ($getCallback = $field->getGetCallback()) $model = ($getCallback = $field->getGetCallback())
? $getCallback($model, $this->request) ? $getCallback($model, $this->context)
: $resource->getAdapter()->getHasOne($model, $field, ! $included); : $resource->getAdapter()->getHasOne($model, $field, ! $included);
if (! $model) { if (! $model) {
@ -175,7 +176,7 @@ final class Serializer
$included = $include !== null; $included = $include !== null;
$models = ($getCallback = $field->getGetCallback()) $models = ($getCallback = $field->getGetCallback())
? $getCallback($model, $this->request) ? $getCallback($model, $this->context)
: $resource->getAdapter()->getHasMany($model, $field, ! $included); : $resource->getAdapter()->getHasMany($model, $field, ! $included);
$identifiers = []; $identifiers = [];
@ -283,7 +284,7 @@ final class Serializer
ksort($items); ksort($items);
return array_map(function (Schema\Meta $meta) use ($model) { return array_map(function (Schema\Meta $meta) use ($model) {
return new Structure\Meta($meta->getName(), ($meta->getValue())($model, $this->request)); return new Structure\Meta($meta->getName(), ($meta->getValue())($model, $this->context));
}, $items); }, $items);
} }

View File

@ -15,6 +15,7 @@ use Psr\Http\Message\ServerRequestInterface;
use Tobyz\JsonApiServer\Adapter\AdapterInterface; use Tobyz\JsonApiServer\Adapter\AdapterInterface;
use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Exception\ForbiddenException;
use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Schema\Type; use Tobyz\JsonApiServer\Schema\Type;
use Tobyz\Tests\JsonApiServer\AbstractTestCase; use Tobyz\Tests\JsonApiServer\AbstractTestCase;
use Tobyz\Tests\JsonApiServer\MockAdapter; use Tobyz\Tests\JsonApiServer\MockAdapter;
@ -106,8 +107,8 @@ class CreateTest extends AbstractTestCase
$called = false; $called = false;
$this->api->resource('users', new MockAdapter(), function (Type $type) use (&$called) { $this->api->resource('users', new MockAdapter(), function (Type $type) use (&$called) {
$type->creatable(function ($request) use (&$called) { $type->creatable(function ($context) use (&$called) {
$this->assertInstanceOf(ServerRequestInterface::class, $request); $this->assertInstanceOf(Context::class, $context);
return $called = true; return $called = true;
}); });
}); });
@ -142,8 +143,8 @@ class CreateTest extends AbstractTestCase
$this->api->resource('users', $adapter->reveal(), function (Type $type) use ($createdModel) { $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($createdModel) {
$type->creatable(); $type->creatable();
$type->newModel(function ($request) use ($createdModel) { $type->newModel(function ($context) use ($createdModel) {
$this->assertInstanceOf(ServerRequestInterface::class, $request); $this->assertInstanceOf(Context::class, $context);
return $createdModel; return $createdModel;
}); });
}); });
@ -162,10 +163,10 @@ class CreateTest extends AbstractTestCase
$this->api->resource('users', $adapter->reveal(), function (Type $type) use ($createdModel, &$called) { $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($createdModel, &$called) {
$type->creatable(); $type->creatable();
$type->save(function ($model, $request) use ($createdModel, &$called) { $type->save(function ($model, $context) use ($createdModel, &$called) {
$model->id = '1'; $model->id = '1';
$this->assertSame($createdModel, $model); $this->assertSame($createdModel, $model);
$this->assertInstanceOf(ServerRequestInterface::class, $request); $this->assertInstanceOf(Context::class, $context);
return $called = true; return $called = true;
}); });
}); });
@ -185,15 +186,15 @@ class CreateTest extends AbstractTestCase
$this->api->resource('users', $adapter->reveal(), function (Type $type) use ($adapter, $createdModel, &$called) { $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($adapter, $createdModel, &$called) {
$type->creatable(); $type->creatable();
$type->onCreating(function ($model, $request) use ($adapter, $createdModel, &$called) { $type->onCreating(function ($model, $context) use ($adapter, $createdModel, &$called) {
$this->assertSame($createdModel, $model); $this->assertSame($createdModel, $model);
$this->assertInstanceOf(ServerRequestInterface::class, $request); $this->assertInstanceOf(Context::class, $context);
$adapter->save($createdModel)->shouldNotHaveBeenCalled(); $adapter->save($createdModel)->shouldNotHaveBeenCalled();
$called++; $called++;
}); });
$type->onCreated(function ($model, $request) use ($adapter, $createdModel, &$called) { $type->onCreated(function ($model, $context) use ($adapter, $createdModel, &$called) {
$this->assertSame($createdModel, $model); $this->assertSame($createdModel, $model);
$this->assertInstanceOf(ServerRequestInterface::class, $request); $this->assertInstanceOf(Context::class, $context);
$adapter->save($createdModel)->shouldHaveBeenCalled(); $adapter->save($createdModel)->shouldHaveBeenCalled();
$called++; $called++;
}); });

View File

@ -15,6 +15,7 @@ use Psr\Http\Message\ServerRequestInterface;
use Tobyz\JsonApiServer\Adapter\AdapterInterface; use Tobyz\JsonApiServer\Adapter\AdapterInterface;
use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Exception\ForbiddenException;
use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Schema\Type; use Tobyz\JsonApiServer\Schema\Type;
use Tobyz\Tests\JsonApiServer\AbstractTestCase; use Tobyz\Tests\JsonApiServer\AbstractTestCase;
use Tobyz\Tests\JsonApiServer\MockAdapter; use Tobyz\Tests\JsonApiServer\MockAdapter;
@ -105,9 +106,9 @@ class DeleteTest extends AbstractTestCase
$adapter->delete($deletingModel); $adapter->delete($deletingModel);
$this->api->resource('users', $adapter->reveal(), function (Type $type) use ($deletingModel, &$called) { $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($deletingModel, &$called) {
$type->deletable(function ($model, $request) use ($deletingModel, &$called) { $type->deletable(function ($model, $context) use ($deletingModel, &$called) {
$this->assertSame($deletingModel, $model); $this->assertSame($deletingModel, $model);
$this->assertInstanceOf(ServerRequestInterface::class, $request); $this->assertInstanceOf(Context::class, $context);
return $called = true; return $called = true;
}); });
}); });
@ -142,9 +143,9 @@ class DeleteTest extends AbstractTestCase
$this->api->resource('users', $adapter->reveal(), function (Type $type) use ($deletingModel, &$called) { $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($deletingModel, &$called) {
$type->deletable(); $type->deletable();
$type->delete(function ($model, $request) use ($deletingModel, &$called) { $type->delete(function ($model, $context) use ($deletingModel, &$called) {
$this->assertSame($deletingModel, $model); $this->assertSame($deletingModel, $model);
$this->assertInstanceOf(ServerRequestInterface::class, $request); $this->assertInstanceOf(Context::class, $context);
return $called = true; return $called = true;
}); });
}); });
@ -165,15 +166,15 @@ class DeleteTest extends AbstractTestCase
$this->api->resource('users', $adapter->reveal(), function (Type $type) use ($adapter, $deletingModel, &$called) { $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($adapter, $deletingModel, &$called) {
$type->deletable(); $type->deletable();
$type->onDeleting(function ($model, $request) use ($adapter, $deletingModel, &$called) { $type->onDeleting(function ($model, $context) use ($adapter, $deletingModel, &$called) {
$this->assertSame($deletingModel, $model); $this->assertSame($deletingModel, $model);
$this->assertInstanceOf(ServerRequestInterface::class, $request); $this->assertInstanceOf(Context::class, $context);
$adapter->delete($deletingModel)->shouldNotHaveBeenCalled(); $adapter->delete($deletingModel)->shouldNotHaveBeenCalled();
$called++; $called++;
}); });
$type->onDeleted(function ($model, $request) use ($adapter, $deletingModel, &$called) { $type->onDeleted(function ($model, $context) use ($adapter, $deletingModel, &$called) {
$this->assertSame($deletingModel, $model); $this->assertSame($deletingModel, $model);
$this->assertInstanceOf(ServerRequestInterface::class, $request); $this->assertInstanceOf(Context::class, $context);
$adapter->delete($deletingModel)->shouldHaveBeenCalled(); $adapter->delete($deletingModel)->shouldHaveBeenCalled();
$called++; $called++;
}); });

View File

@ -13,6 +13,7 @@ namespace Tobyz\Tests\JsonApiServer\feature;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Schema\Type; use Tobyz\JsonApiServer\Schema\Type;
use Tobyz\Tests\JsonApiServer\AbstractTestCase; use Tobyz\Tests\JsonApiServer\AbstractTestCase;
use Tobyz\Tests\JsonApiServer\MockAdapter; use Tobyz\Tests\JsonApiServer\MockAdapter;
@ -75,7 +76,7 @@ class FieldFiltersTest extends AbstractTestCase
$type->filter('name', function (...$args) use (&$called) { $type->filter('name', function (...$args) use (&$called) {
$this->assertSame($this->adapter->query, $args[0]); $this->assertSame($this->adapter->query, $args[0]);
$this->assertEquals('value', $args[1]); $this->assertEquals('value', $args[1]);
$this->assertInstanceOf(ServerRequestInterface::class, $args[2]); $this->assertInstanceOf(Context::class, $args[2]);
$called = true; $called = true;
}); });

View File

@ -12,9 +12,9 @@
namespace Tobyz\Tests\JsonApiServer\feature; namespace Tobyz\Tests\JsonApiServer\feature;
use Psr\Http\Message\RequestInterface; use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ServerRequestInterface;
use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Schema\Type; use Tobyz\JsonApiServer\Schema\Type;
use Tobyz\Tests\JsonApiServer\AbstractTestCase; use Tobyz\Tests\JsonApiServer\AbstractTestCase;
use Tobyz\Tests\JsonApiServer\MockAdapter; use Tobyz\Tests\JsonApiServer\MockAdapter;
@ -119,9 +119,9 @@ class FieldWritabilityTest extends AbstractTestCase
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
$type->updatable(); $type->updatable();
$type->attribute('writable') $type->attribute('writable')
->writable(function ($model, $request) use (&$called) { ->writable(function ($model, $context) use (&$called) {
$this->assertSame($this->adapter->models['1'], $model); $this->assertSame($this->adapter->models['1'], $model);
$this->assertInstanceOf(ServerRequestInterface::class, $request); $this->assertInstanceOf(Context::class, $context);
return $called = true; return $called = true;
}); });
@ -197,11 +197,11 @@ class FieldWritabilityTest extends AbstractTestCase
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
$type->updatable(); $type->updatable();
$type->attribute('readonly') $type->attribute('readonly')
->readonly(function ($model, $request) use (&$called) { ->readonly(function ($model, $context) use (&$called) {
$called = true; $called = true;
$this->assertSame($this->adapter->models['1'], $model); $this->assertSame($this->adapter->models['1'], $model);
$this->assertInstanceOf(RequestInterface::class, $request); $this->assertInstanceOf(Context::class, $context);
return false; return false;
}); });
@ -223,5 +223,38 @@ class FieldWritabilityTest extends AbstractTestCase
$this->assertTrue($called); $this->assertTrue($called);
} }
public function test_field_is_only_writable_once_on_creation()
{
$this->api->resource('users', $this->adapter, function (Type $type) {
$type->creatable();
$type->updatable();
$type->attribute('writableOnce')->writable()->once();
});
$payload = [
'data' => [
'type' => 'users',
'attributes' => [
'writableOnce' => 'value',
]
]
];
$response = $this->api->handle(
$this->buildRequest('POST', '/users')
->withParsedBody($payload)
);
$this->assertEquals(201, $response->getStatusCode());
$this->expectException(BadRequestException::class);
$payload['data']['id'] = '1';
$this->api->handle(
$this->buildRequest('PATCH', '/users/1')
->withParsedBody($payload)
);
}
// to_one, to_many... // to_one, to_many...
} }

View File

@ -13,6 +13,7 @@ namespace Tobyz\Tests\JsonApiServer\feature;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Schema\Type; use Tobyz\JsonApiServer\Schema\Type;
use Tobyz\Tests\JsonApiServer\AbstractTestCase; use Tobyz\Tests\JsonApiServer\AbstractTestCase;
use Tobyz\Tests\JsonApiServer\MockAdapter; use Tobyz\Tests\JsonApiServer\MockAdapter;
@ -34,9 +35,9 @@ class MetaTest extends AbstractTestCase
$adapter = new MockAdapter(['1' => (object) ['id' => '1']]); $adapter = new MockAdapter(['1' => (object) ['id' => '1']]);
$this->api->resource('users', $adapter, function (Type $type) use ($adapter) { $this->api->resource('users', $adapter, function (Type $type) use ($adapter) {
$type->meta('foo', function ($model, $request) use ($adapter) { $type->meta('foo', function ($model, $context) use ($adapter) {
$this->assertSame($adapter->models['1'], $model); $this->assertSame($adapter->models['1'], $model);
$this->assertInstanceOf(ServerRequestInterface::class, $request); $this->assertInstanceOf(Context::class, $context);
return 'bar'; return 'bar';
}); });
}); });

View File

@ -13,6 +13,7 @@ namespace Tobyz\Tests\JsonApiServer\feature;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Schema\Type; use Tobyz\JsonApiServer\Schema\Type;
use Tobyz\Tests\JsonApiServer\AbstractTestCase; use Tobyz\Tests\JsonApiServer\AbstractTestCase;
use Tobyz\Tests\JsonApiServer\MockAdapter; use Tobyz\Tests\JsonApiServer\MockAdapter;
@ -40,7 +41,7 @@ class ScopesTest extends AbstractTestCase
$this->api->resource('users', $this->adapter, function (Type $type) { $this->api->resource('users', $this->adapter, function (Type $type) {
$type->updatable(); $type->updatable();
$type->deletable(); $type->deletable();
$type->scope(function ($query, ServerRequestInterface $request) { $type->scope(function ($query, Context $context) {
$this->scopeWasCalled = true; $this->scopeWasCalled = true;
}); });
}); });
@ -114,7 +115,7 @@ class ScopesTest extends AbstractTestCase
$organisationScopeWasCalled = false; $organisationScopeWasCalled = false;
$this->api->resource('organisations', new MockAdapter, function (Type $type) use (&$organisationScopeWasCalled) { $this->api->resource('organisations', new MockAdapter, function (Type $type) use (&$organisationScopeWasCalled) {
$type->scope(function ($query, ServerRequestInterface $request) use (&$organisationScopeWasCalled) { $type->scope(function ($query, Context $context) use (&$organisationScopeWasCalled) {
$organisationScopeWasCalled = true; $organisationScopeWasCalled = true;
}); });
}); });