From 807525338cc79070ee5207244283ba4f30e10718 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Sun, 22 Nov 2020 11:13:50 +1030 Subject: [PATCH] Pass around new Context object; update docs; implement once() --- README.md | 676 +---------------------- docs/attributes.md | 5 +- docs/create.md | 10 +- docs/delete.md | 6 +- docs/filtering.md | 2 +- docs/index.md | 6 +- docs/list.md | 6 +- docs/meta.md | 10 +- docs/relationships.md | 9 +- docs/scopes.md | 4 +- docs/sorting.md | 2 +- docs/update.md | 6 +- docs/visibility.md | 2 +- docs/writing.md | 20 +- src/Context.php | 66 +-- src/Endpoint/Concerns/FindsResources.php | 6 +- src/Endpoint/Concerns/IncludesData.php | 30 +- src/Endpoint/Concerns/SavesData.php | 59 +- src/Endpoint/Create.php | 44 +- src/Endpoint/Delete.php | 17 +- src/Endpoint/Index.php | 50 +- src/Endpoint/Show.php | 20 +- src/Endpoint/Update.php | 31 +- src/JsonApi.php | 30 +- src/Schema/Field.php | 16 + src/Serializer.php | 19 +- tests/feature/CreateTest.php | 21 +- tests/feature/DeleteTest.php | 17 +- tests/feature/FieldFiltersTest.php | 3 +- tests/feature/FieldWritabilityTest.php | 43 +- tests/feature/MetaTest.php | 5 +- tests/feature/ScopesTest.php | 5 +- 32 files changed, 294 insertions(+), 952 deletions(-) diff --git a/README.md b/README.md index 5cd10b6..6711edb 100644 --- a/README.md +++ b/README.md @@ -1,681 +1,15 @@ # 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) -> **A fully automated [JSON:API](http://jsonapi.org) server implementation in PHP.** -> Define your schema, plug in your models, and we'll take care of the rest. 🍻 +json-api-server is a [JSON:API](http://jsonapi.org) server implementation in PHP. - - +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. +## Documentation -- [Installation](#installation) -- [Usage](#usage) - - [Handling Requests](#handling-requests) - - [Defining Resources](#defining-resources) - - [Attributes](#attributes) - - [Relationships](#relationships) - - [Relationship Links](#relationship-links) - - [Relationship Linkage](#relationship-linkage) - - [Relationship Inclusion](#relationship-inclusion) - - [Custom Loading Logic](#custom-loading-logic) - - [Polymorphic Relationships](#polymorphic-relationships) - - [Getters](#getters) - - [Visibility](#visibility) - - [Resource Visibility](#resource-visibility) - - [Field Visibility](#field-visibility) - - [Writability](#writability) - - [Default Values](#default-values) - - [Validation](#validation) - - [Transformers, Setters & Savers](#transformers-setters--savers) - - [Filtering](#filtering) - - [Sorting](#sorting) - - [Context](#context) - - [Pagination](#pagination) - - [Countability](#countability) - - [Meta Information](#meta-information) - - [Creating Resources](#creating-resources) - - [Customizing the Model](#customizing-the-model) - - [Customizing Creation Logic](#customizing-creation-logic) - - [Updating Resources](#updating-resources) - - [Customizing Update Logic](#customizing-update-logic) - - [Deleting Resources](#deleting-resources) - - [Events](#events) - - [Authentication](#authentication) - - [Laravel Helpers](#laravel-helpers) - - [Authorization](#authorization) - - [Validation](#validation-1) - - [Meta Information](#meta-information-1) - - [Document-level](#document-level) - - [Resource-level](#resource-level) - - [Relationship-level](#relationship-level) - - [Modifying Responses](#modifying-responses) -- [Examples](#examples) -- [Contributing](#contributing) -- [License](#license) - - - -## 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 +[Read the documentation](https://tobyzerner.github.io/json-api-server) ## Contributing diff --git a/docs/attributes.md b/docs/attributes.md index 13c17c4..c158d90 100644 --- a/docs/attributes.md +++ b/docs/attributes.md @@ -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. ```php -use Psr\Http\Message\ServerRequestInterface as Request; -use Tobyz\JsonApiServer\Schema\Attribute; +use Tobyz\JsonApiServer\Context; $type->attribute('firstName') - ->get(function ($model, Request $request, Attribute $attribute) { + ->get(function ($model, Context $context) { return ucfirst($model->first_name); }); ``` diff --git a/docs/create.md b/docs/create.md index 06283fb..ec180ad 100644 --- a/docs/create.md +++ b/docs/create.md @@ -7,7 +7,7 @@ Optionally pass a closure that returns a boolean value. ```php $type->creatable(); -$type->creatable(function (Request $request) { +$type->creatable(function (Context $context) { 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: ```php -$type->newModel(function (Request $request) { +$type->newModel(function (Context $context) { return new CustomModel; }); ``` @@ -29,7 +29,7 @@ $type->newModel(function (Request $request) { Run before the model is saved. ```php -$type->onCreating(function ($model, Request $request) { +$type->onCreating(function ($model, Context $context) { // do something }); ``` @@ -39,7 +39,7 @@ $type->onCreating(function ($model, Request $request) { Run after the model is saved. ```php -$type->onCreated(function ($model, Request $request) { - // do something +$type->onCreated(function ($model, Context $context) { + $context->meta('foo', 'bar'); }); ``` diff --git a/docs/delete.md b/docs/delete.md index 48da319..887fade 100644 --- a/docs/delete.md +++ b/docs/delete.md @@ -7,7 +7,7 @@ Optionally pass a closure that returns a boolean value. ```php $type->deletable(); -$type->deletable(function (Request $request) { +$type->deletable(function (Context $context) { return $request->getAttribute('user')->isAdmin(); }); ``` @@ -19,7 +19,7 @@ $type->deletable(function (Request $request) { Run before the model is deleted. ```php -$type->onDeleting(function ($model, Request $request) { +$type->onDeleting(function ($model, Context $context) { // do something }); ``` @@ -29,7 +29,7 @@ $type->onDeleting(function ($model, Request $request) { Run after the model is deleted. ```php -$type->onDeleted(function ($model, Request $request) { +$type->onDeleted(function ($model, Context $context) { // do something }); ``` diff --git a/docs/filtering.md b/docs/filtering.md index 22e4f03..cc76020 100644 --- a/docs/filtering.md +++ b/docs/filtering.md @@ -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: ```php -$type->filter('minPosts', function ($query, $value, Request $request) { +$type->filter('minPosts', function ($query, $value, Context $context) { $query->where('postCount', '>=', $value); }); ``` diff --git a/docs/index.md b/docs/index.md index f1b9697..eebffb3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,8 +1,8 @@ # 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: @@ -19,7 +19,7 @@ The schema definition is extremely powerful and lets you easily apply [permissio ### 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 use App\Models\{Article, Comment, User}; diff --git a/docs/list.md b/docs/list.md index 74e7012..bb2ed58 100644 --- a/docs/list.md +++ b/docs/list.md @@ -7,7 +7,7 @@ If you want to restrict the ability to list a resource type, use the `listable` ```php $type->notListable(); -$type->listable(function (Request $request) { +$type->listable(function (Context $context) { 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. ```php -$type->onListing(function ($query, Request $request) { +$type->onListing(function ($query, Context $context) { // 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. ```php -$type->onListed(function ($models, Request $request) { +$type->onListed(function ($models, Context $context) { // do something }); ``` diff --git a/docs/meta.md b/docs/meta.md index 58c5e8e..61a3e0b 100644 --- a/docs/meta.md +++ b/docs/meta.md @@ -4,11 +4,13 @@ You can add meta information at various levels of the document using the `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 -$api->meta('requestTime', function (Request $request) { - return new DateTime; +$type->onListed(function ($models, Context $context) { + $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. ```php -$type->meta('updatedAt', function ($model, Request $request) { +$type->meta('updatedAt', function ($model, Context $context) { return $model->updated_at; }); ``` diff --git a/docs/relationships.md b/docs/relationships.md index 362a651..0398657 100644 --- a/docs/relationships.md +++ b/docs/relationships.md @@ -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: ```php -use Psr\Http\Message\ServerRequestInterface as Request; -use Tobyz\JsonApiServer\Schema\HasOne; +use Tobyz\JsonApiServer\Context; $type->hasOne('users') ->includable() - ->scope(function ($query, Request $request, HasOne $field) { + ->scope(function ($query, Context $context) { $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) { $type->hasOne('user') // 2 ->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 // will be an array of Category models, and $relationships will be // 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 $type->hasOne('user') - ->meta('updatedAt', function ($model, $user, Request $request) { + ->meta('updatedAt', function ($model, $user, Context $context) { return $user->updated_at; }); ``` diff --git a/docs/scopes.md b/docs/scopes.md index b8cbc4b..1455a47 100644 --- a/docs/scopes.md +++ b/docs/scopes.md @@ -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: ```php -$type->scope(function ($query, ServerRequestInterface $request) { - $query->where('user_id', $request->getAttribute('userId')); +$type->scope(function ($query, Context $context) { + $query->where('user_id', $context->getRequest()->getAttribute('userId')); }); ``` diff --git a/docs/sorting.md b/docs/sorting.md index e9550f7..391b256 100644 --- a/docs/sorting.md +++ b/docs/sorting.md @@ -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: ```php -$type->sort('relevance', function ($query, string $direction, Request $request) { +$type->sort('relevance', function ($query, string $direction, Context $context) { $query->orderBy('relevance', $direction); }); ``` diff --git a/docs/update.md b/docs/update.md index 55a6e94..89fdb9e 100644 --- a/docs/update.md +++ b/docs/update.md @@ -7,7 +7,7 @@ Optionally pass a closure that returns a boolean value. ```php $type->updatable(); -$type->updatable(function (Request $request) { +$type->updatable(function (Context $context) { return $request->getAttribute('user')->isAdmin(); }); ``` @@ -19,7 +19,7 @@ $type->updatable(function (Request $request) { Run before the model is saved. ```php -$type->onUpdating(function ($model, Request $request) { +$type->onUpdating(function ($model, Context $context) { // do something }); ``` @@ -29,7 +29,7 @@ $type->onUpdating(function ($model, Request $request) { Run after the model is saved. ```php -$type->onUpdated(function ($model, Request $request) { +$type->onUpdated(function ($model, Context $context) { // do something }); ``` diff --git a/docs/visibility.md b/docs/visibility.md index 3336c4e..ed8e368 100644 --- a/docs/visibility.md +++ b/docs/visibility.md @@ -8,7 +8,7 @@ For example, the following schema will make an email attribute that only appears ```php $type->attribute('email') - ->visible(function ($model, Request $request, Attribute $field) { + ->visible(function ($model, Context $context) { return $model->id === $request->getAttribute('userId'); }); ``` diff --git a/docs/writing.md b/docs/writing.md index ded3966..f9aacf2 100644 --- a/docs/writing.md +++ b/docs/writing.md @@ -8,8 +8,8 @@ For example, the following schema will make an email attribute that is only writ ```php $type->attribute('email') - ->writable(function ($model, Request $request, Attribute $field) { - return $model->id === $request->getAttribute('userId'); + ->writable(function ($model, Context $context) { + return $model->id === $context->getRequest()->getAttribute('userId'); }); ``` @@ -31,8 +31,8 @@ $type->attribute('joinedAt') ->default(new DateTime); $type->attribute('ipAddress') - ->default(function (Request $request, Attribute $attribute) { - return $request->getServerParams()['REMOTE_ADDR'] ?? null; + ->default(function (Context $context) { + 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 $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)) { $fail('Invalid email'); } @@ -61,7 +61,7 @@ This works for relationships, too. The related models will be retrieved via your ```php $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) { if ($group->id === 1) { $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 $type->attribute('firstName') - ->transform(function ($value, Request $request, Attribute $attribute) { + ->transform(function ($value, Context $context) { return ucfirst($value); }); ``` @@ -91,7 +91,7 @@ Use the `set` method to define custom mutation logic for your field, instead of ```php $type->attribute('firstName') - ->set(function ($value, $model, Request $request, Attribute $attribute) { + ->set(function ($value, $model, Context $context) { $model->first_name = ucfirst($value); if ($model->first_name === 'Toby') { $model->last_name = 'Zerner'; @@ -105,7 +105,7 @@ If your field corresponds to some other form of data storage rather than a simpl ```php $type->attribute('locale') - ->save(function ($value, $model, Request $request, Attribute $attribute) { + ->save(function ($value, $model, Context $context) { $model->preferences() ->where('key', 'locale') ->update(['value' => $value]); @@ -120,7 +120,7 @@ Run after a field has been successfully saved. ```php $type->attribute('email') - ->onSaved(function ($value, $model, Request $request, Attribute $attribute) { + ->onSaved(function ($value, $model, Context $context) { event(new EmailWasChanged($model)); }); ``` diff --git a/src/Context.php b/src/Context.php index d406021..75379bb 100644 --- a/src/Context.php +++ b/src/Context.php @@ -1,4 +1,5 @@ api = $api; - $this->resource = $resource; + $this->request = $request; } - public function getApi(): JsonApi - { - return $this->api; - } - - public function getRequest(): ?ServerRequestInterface + public function getRequest(): ServerRequestInterface { return $this->request; } - public function forRequest(ServerRequestInterface $request) + public function response(callable $callback) { - $new = clone $this; - $new->request = $request; - return $new; - } - - public function getResource(): ?ResourceType - { - return $this->resource; - } - - public function forResource(ResourceType $resource) - { - $new = clone $this; - $new->resource = $resource; - $new->model = null; - return $new; - } - - public function getModel() - { - return $this->model; - } - - public function forModel($model) - { - $new = clone $this; - $new->model = $model; - return $new; - } - - public function getField(): ?Field - { - return $this->field; - } - - public function forField(Field $field) - { - $new = clone $this; - $new->field = $field; - return $new; + $this->listeners['response'][] = $callback; } } diff --git a/src/Endpoint/Concerns/FindsResources.php b/src/Endpoint/Concerns/FindsResources.php index a628518..13e01fc 100644 --- a/src/Endpoint/Concerns/FindsResources.php +++ b/src/Endpoint/Concerns/FindsResources.php @@ -11,9 +11,9 @@ namespace Tobyz\JsonApiServer\Endpoint\Concerns; -use Psr\Http\Message\ServerRequestInterface; use Tobyz\JsonApiServer\Exception\ResourceNotFoundException; use Tobyz\JsonApiServer\ResourceType; +use Tobyz\JsonApiServer\Context; use function Tobyz\JsonApiServer\run_callbacks; trait FindsResources @@ -23,12 +23,12 @@ trait FindsResources * * @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(); $query = $adapter->newQuery(); - run_callbacks($resource->getSchema()->getListeners('scope'), [$query, $request]); + run_callbacks($resource->getSchema()->getListeners('scope'), [$query, $context]); $model = $adapter->find($query, $id); diff --git a/src/Endpoint/Concerns/IncludesData.php b/src/Endpoint/Concerns/IncludesData.php index 24ba0a7..f28d588 100644 --- a/src/Endpoint/Concerns/IncludesData.php +++ b/src/Endpoint/Concerns/IncludesData.php @@ -11,10 +11,10 @@ namespace Tobyz\JsonApiServer\Endpoint\Concerns; -use Psr\Http\Message\ServerRequestInterface; use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\ResourceType; +use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Schema\Relationship; use function Tobyz\JsonApiServer\run_callbacks; @@ -24,9 +24,9 @@ use function Tobyz\JsonApiServer\run_callbacks; */ 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'])) { $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(); $schema = $resource->getSchema(); @@ -107,13 +107,13 @@ trait IncludesData $type = $field->getType(); if (is_callable($load)) { - $load($models, $nextRelationshipPath, $field->hasLinkage(), $request); + $load($models, $nextRelationshipPath, $field->hasLinkage(), $context); } else { if (is_string($type)) { $relatedResource = $this->api->getResource($type); - $scope = function ($query) use ($request, $field, $relatedResource) { - run_callbacks($relatedResource->getSchema()->getListeners('scope'), [$query, $request]); - run_callbacks($field->getListeners('scope'), [$query, $request]); + $scope = function ($query) use ($context, $field, $relatedResource) { + run_callbacks($relatedResource->getSchema()->getListeners('scope'), [$query, $context]); + run_callbacks($field->getListeners('scope'), [$query, $context]); }; } else { $relatedResources = is_array($type) ? array_map(function ($type) { @@ -125,10 +125,10 @@ trait IncludesData return $relatedResource->getType(); }, $relatedResources), - array_map(function ($relatedResource) use ($request, $field) { - return function ($query) use ($request, $field, $relatedResource) { - run_callbacks($relatedResource->getSchema()->getListeners('scope'), [$query, $request]); - run_callbacks($field->getListeners('scope'), [$query, $request]); + array_map(function ($relatedResource) use ($context, $field) { + return function ($query) use ($context, $field, $relatedResource) { + run_callbacks($relatedResource->getSchema()->getListeners('scope'), [$query, $context]); + run_callbacks($field->getListeners('scope'), [$query, $context]); }; }, $relatedResources) ); @@ -140,7 +140,7 @@ trait IncludesData if (isset($include[$name]) && is_string($type)) { $relatedResource = $this->api->getResource($type); - $this->loadRelationshipsAtLevel($models, $nextRelationshipPath, $relatedResource, $include[$name] ?? [], $request); + $this->loadRelationshipsAtLevel($models, $nextRelationshipPath, $relatedResource, $include[$name] ?? [], $context); } } } diff --git a/src/Endpoint/Concerns/SavesData.php b/src/Endpoint/Concerns/SavesData.php index bd38c6f..bf78598 100644 --- a/src/Endpoint/Concerns/SavesData.php +++ b/src/Endpoint/Concerns/SavesData.php @@ -16,6 +16,7 @@ use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\UnprocessableEntityException; use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\Schema\Attribute; +use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Schema\HasMany; use Tobyz\JsonApiServer\Schema\HasOne; use Tobyz\JsonApiServer\Schema\Relationship; @@ -77,7 +78,7 @@ trait SavesData * * @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'])) { throw new BadRequestException('type not specified'); @@ -93,16 +94,16 @@ trait SavesData $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. */ - private function validateFields(array $data, $model, ServerRequestInterface $request) + private function validateFields(array $data, $model, Context $context) { $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. */ - private function assertFieldsWritable(array $data, $model, ServerRequestInterface $request) + private function assertFieldsWritable(array $data, $model, Context $context) { 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"); } } @@ -141,7 +152,7 @@ trait SavesData /** * 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) { if (! $field instanceof Relationship || ! has_value($data, $field)) { @@ -154,10 +165,10 @@ trait SavesData $allowedTypes = (array) $field->getType(); 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) { - set_value($data, $field, array_map(function ($identifier) use ($request, $allowedTypes) { - return $this->getModelForIdentifier($request, $identifier, $allowedTypes); + set_value($data, $field, array_map(function ($identifier) use ($context, $allowedTypes) { + return $this->getModelForIdentifier($context, $identifier, $allowedTypes); }, $value['data'])); } } else { @@ -171,7 +182,7 @@ trait SavesData * * @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 = []; @@ -186,7 +197,7 @@ trait SavesData run_callbacks( $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. */ - private function setValues(array $data, $model, ServerRequestInterface $request) + private function setValues(array $data, $model, Context $context) { $adapter = $this->resource->getAdapter(); @@ -210,7 +221,7 @@ trait SavesData $value = get_value($data, $field); if ($setCallback = $field->getSetCallback()) { - $setCallback($model, $value, $request); + $setCallback($model, $value, $context); continue; } @@ -229,19 +240,19 @@ trait SavesData /** * 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->saveFields($data, $model, $request); + $this->saveModel($model, $context); + $this->saveFields($data, $model, $context); } /** * Save the model. */ - private function saveModel($model, ServerRequestInterface $request) + private function saveModel($model, Context $context) { if ($saveCallback = $this->resource->getSchema()->getSaveCallback()) { - $saveCallback($model, $request); + $saveCallback($model, $context); } else { $this->resource->getAdapter()->save($model); } @@ -250,7 +261,7 @@ trait SavesData /** * 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(); @@ -262,19 +273,19 @@ trait SavesData $value = get_value($data, $field); if ($saveCallback = $field->getSaveCallback()) { - $saveCallback($model, $value, $request); + $saveCallback($model, $value, $context); } elseif ($field instanceof HasMany) { $adapter->saveHasMany($model, $field, $value); } } - $this->runSavedCallbacks($data, $model, $request); + $this->runSavedCallbacks($data, $model, $context); } /** * 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) { @@ -284,7 +295,7 @@ trait SavesData run_callbacks( $field->getListeners('saved'), - [$model, get_value($data, $field), $request] + [$model, get_value($data, $field), $context] ); } } diff --git a/src/Endpoint/Create.php b/src/Endpoint/Create.php index c2c2f9f..dd238dd 100644 --- a/src/Endpoint/Create.php +++ b/src/Endpoint/Create.php @@ -11,10 +11,8 @@ namespace Tobyz\JsonApiServer\Endpoint; -use Psr\Http\Message\ResponseInterface as Response; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\ServerRequestInterface as Request; -use Psr\Http\Server\RequestHandlerInterface; +use Psr\Http\Message\ResponseInterface; +use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\ResourceType; @@ -23,7 +21,7 @@ use function Tobyz\JsonApiServer\has_value; use function Tobyz\JsonApiServer\run_callbacks; use function Tobyz\JsonApiServer\set_value; -class Create implements RequestHandlerInterface +class Create { 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. */ - public function handle(Request $request): Response + public function handle(Context $context): ResponseInterface { $schema = $this->resource->getSchema(); - if (! evaluate($schema->isCreatable(), [$request])) { + if (! evaluate($schema->isCreatable(), [$context])) { throw new ForbiddenException; } - $model = $this->newModel($request); - $data = $this->parseData($request->getParsedBody()); + $model = $this->newModel($context); + $data = $this->parseData($context->getRequest()->getParsedBody()); - $this->validateFields($data, $model, $request); - $this->fillDefaultValues($data, $request); - $this->loadRelatedResources($data, $request); - $this->assertDataValid($data, $model, $request, true); - $this->setValues($data, $model, $request); + $this->validateFields($data, $model, $context); + $this->fillDefaultValues($data, $context); + $this->loadRelatedResources($data, $context); + $this->assertDataValid($data, $model, $context, true); + $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)) - ->handle($request) + ->handle($context) ->withStatus(201); } - private function newModel(ServerRequestInterface $request) + private function newModel(Context $context) { $resource = $this->resource; $newModel = $resource->getSchema()->getNewModelCallback(); return $newModel - ? $newModel($request) + ? $newModel($context) : $resource->getAdapter()->newModel(); } - private function fillDefaultValues(array &$data, ServerRequestInterface $request) + private function fillDefaultValues(array &$data, Context $context) { foreach ($this->resource->getSchema()->getFields() as $field) { if (! has_value($data, $field) && ($defaultCallback = $field->getDefaultCallback())) { - set_value($data, $field, $defaultCallback($request)); + set_value($data, $field, $defaultCallback($context)); } } } diff --git a/src/Endpoint/Delete.php b/src/Endpoint/Delete.php index 0b0f89e..5f5ba7c 100644 --- a/src/Endpoint/Delete.php +++ b/src/Endpoint/Delete.php @@ -13,15 +13,14 @@ namespace Tobyz\JsonApiServer\Endpoint; use Nyholm\Psr7\Response; use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface as Request; -use Psr\Http\Server\RequestHandlerInterface; use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\ResourceType; +use Tobyz\JsonApiServer\Context; use function Tobyz\JsonApiServer\evaluate; use function Tobyz\JsonApiServer\run_callbacks; -class Delete implements RequestHandlerInterface +class Delete { private $api; 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. */ - public function handle(Request $request): ResponseInterface + public function handle(Context $context): ResponseInterface { $schema = $this->resource->getSchema(); - if (! evaluate($schema->isDeletable(), [$this->model, $request])) { + if (! evaluate($schema->isDeletable(), [$this->model, $context])) { throw new ForbiddenException; } - run_callbacks($schema->getListeners('deleting'), [$this->model, $request]); + run_callbacks($schema->getListeners('deleting'), [$this->model, $context]); if ($deleteCallback = $schema->getDeleteCallback()) { - $deleteCallback($this->model, $request); + $deleteCallback($this->model, $context); } else { $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); } diff --git a/src/Endpoint/Index.php b/src/Endpoint/Index.php index 79bec22..2dfe896 100644 --- a/src/Endpoint/Index.php +++ b/src/Endpoint/Index.php @@ -15,14 +15,14 @@ use JsonApiPhp\JsonApi as Structure; use JsonApiPhp\JsonApi\Link\LastLink; use JsonApiPhp\JsonApi\Link\NextLink; 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\Server\RequestHandlerInterface; use Tobyz\JsonApiServer\Adapter\AdapterInterface; use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\Schema\Attribute; +use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Schema\HasMany; use Tobyz\JsonApiServer\Schema\HasOne; use Tobyz\JsonApiServer\Serializer; @@ -30,7 +30,7 @@ use function Tobyz\JsonApiServer\evaluate; use function Tobyz\JsonApiServer\json_api_response; use function Tobyz\JsonApiServer\run_callbacks; -class Index implements RequestHandlerInterface +class Index { use Concerns\IncludesData; @@ -46,30 +46,30 @@ class Index implements RequestHandlerInterface /** * Handle a request to show a resource listing. */ - public function handle(Request $request): Response + public function handle(Context $context): ResponseInterface { $adapter = $this->resource->getAdapter(); $schema = $this->resource->getSchema(); $query = $adapter->newQuery(); - run_callbacks($schema->getListeners('listing'), [$query, $request]); - run_callbacks($schema->getListeners('scope'), [$query, $request]); + run_callbacks($schema->getListeners('listing'), [$query, $context]); + run_callbacks($schema->getListeners('scope'), [$query, $context]); - $include = $this->getInclude($request); + $include = $this->getInclude($context); - [$offset, $limit] = $this->paginate($query, $request); - $this->sort($query, $request); - $this->filter($query, $request); + [$offset, $limit] = $this->paginate($query, $context); + $this->sort($query, $context); + $this->filter($query, $context); $total = $schema->isCountable() ? $adapter->count($query) : null; $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) { $serializer->add($this->resource, $model, $include); @@ -78,11 +78,11 @@ class Index implements RequestHandlerInterface return json_api_response( new Structure\CompoundDocument( 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\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('limit', $limit), ...($total !== null ? [new Structure\Meta('total', $total)] : []) @@ -141,11 +141,11 @@ class Index implements RequestHandlerInterface return $paginationLinks; } - private function sort($query, Request $request) + private function sort($query, Context $context) { $schema = $this->resource->getSchema(); - if (! $sort = $request->getQueryParams()['sort'] ?? $schema->getDefaultSort()) { + if (! $sort = $context->getRequest()->getQueryParams()['sort'] ?? $schema->getDefaultSort()) { return; } @@ -155,14 +155,14 @@ class Index implements RequestHandlerInterface foreach ($this->parseSort($sort) as $name => $direction) { if (isset($sortFields[$name])) { - $sortFields[$name]($query, $direction, $request); + $sortFields[$name]($query, $direction, $context); continue; } if ( isset($fields[$name]) && $fields[$name] instanceof Attribute - && evaluate($fields[$name]->getSortable(), [$request]) + && evaluate($fields[$name]->getSortable(), [$context]) ) { $adapter->sortByAttribute($query, $fields[$name], $direction); continue; @@ -190,10 +190,10 @@ class Index implements RequestHandlerInterface return $sort; } - private function paginate($query, Request $request) + private function paginate($query, Context $context) { $schema = $this->resource->getSchema(); - $queryParams = $request->getQueryParams(); + $queryParams = $context->getRequest()->getQueryParams(); $limit = $schema->getPerPage(); if (isset($queryParams['page']['limit'])) { @@ -223,9 +223,9 @@ class Index implements RequestHandlerInterface 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; } @@ -245,11 +245,11 @@ class Index implements RequestHandlerInterface } if (isset($filters[$name])) { - $filters[$name]->getCallback()($query, $value, $request); + $filters[$name]->getCallback()($query, $value, $context); continue; } - if (isset($fields[$name]) && evaluate($fields[$name]->getFilterable(), [$request])) { + if (isset($fields[$name]) && evaluate($fields[$name]->getFilterable(), [$context])) { if ($fields[$name] instanceof Attribute) { $this->filterByAttribute($adapter, $query, $fields[$name], $value); } elseif ($fields[$name] instanceof HasOne) { diff --git a/src/Endpoint/Show.php b/src/Endpoint/Show.php index 5276734..f65dc39 100644 --- a/src/Endpoint/Show.php +++ b/src/Endpoint/Show.php @@ -13,16 +13,15 @@ namespace Tobyz\JsonApiServer\Endpoint; use JsonApiPhp\JsonApi\CompoundDocument; use JsonApiPhp\JsonApi\Included; -use Psr\Http\Message\ResponseInterface as Response; -use Psr\Http\Message\ServerRequestInterface as Request; -use Psr\Http\Server\RequestHandlerInterface; +use Psr\Http\Message\ResponseInterface; use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\ResourceType; +use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Serializer; use function Tobyz\JsonApiServer\json_api_response; use function Tobyz\JsonApiServer\run_callbacks; -class Show implements RequestHandlerInterface +class Show { use Concerns\IncludesData; @@ -37,18 +36,15 @@ class Show implements RequestHandlerInterface $this->model = $model; } - /** - * Handle a request to show a resource. - */ - public function handle(Request $request): Response + public function handle(Context $context): ResponseInterface { - $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); return json_api_response( diff --git a/src/Endpoint/Update.php b/src/Endpoint/Update.php index a5487ea..674e754 100644 --- a/src/Endpoint/Update.php +++ b/src/Endpoint/Update.php @@ -11,16 +11,15 @@ namespace Tobyz\JsonApiServer\Endpoint; -use Psr\Http\Message\ResponseInterface as Response; -use Psr\Http\Message\ServerRequestInterface as Request; -use Psr\Http\Server\RequestHandlerInterface; +use Psr\Http\Message\ResponseInterface; use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\ResourceType; +use Tobyz\JsonApiServer\Context; use function Tobyz\JsonApiServer\evaluate; use function Tobyz\JsonApiServer\run_callbacks; -class Update implements RequestHandlerInterface +class Update { 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. */ - public function handle(Request $request): Response + public function handle(Context $context): ResponseInterface { $schema = $this->resource->getSchema(); - if (! evaluate($schema->isUpdatable(), [$this->model, $request])) { + if (! evaluate($schema->isUpdatable(), [$this->model, $context])) { 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->loadRelatedResources($data, $request); - $this->assertDataValid($data, $this->model, $request, false); - $this->setValues($data, $this->model, $request); + $this->validateFields($data, $this->model, $context); + $this->loadRelatedResources($data, $context); + $this->assertDataValid($data, $this->model, $context, false); + $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)) - ->handle($request); + ->handle($context); } } diff --git a/src/JsonApi.php b/src/JsonApi.php index 6405af7..462f52c 100644 --- a/src/JsonApi.php +++ b/src/JsonApi.php @@ -26,6 +26,7 @@ use Tobyz\JsonApiServer\Exception\UnsupportedMediaTypeException; use Tobyz\JsonApiServer\Endpoint\Concerns\FindsResources; use Tobyz\JsonApiServer\Http\MediaTypes; use Tobyz\JsonApiServer\Schema\Concerns\HasMeta; +use Tobyz\JsonApiServer\Context; final class JsonApi implements RequestHandlerInterface { @@ -93,13 +94,14 @@ final class JsonApi implements RequestHandlerInterface $segments = explode('/', trim($path, '/')); $resource = $this->getResource($segments[0]); + $context = new Context($request); switch (count($segments)) { case 1: - return $this->handleCollection($request, $resource); + return $this->handleCollection($context, $resource); case 2: - return $this->handleResource($request, $resource, $segments[1]); + return $this->handleResource($context, $resource, $segments[1]); case 3: throw new NotImplementedException; @@ -164,33 +166,33 @@ final class JsonApi implements RequestHandlerInterface 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': - return (new Endpoint\Index($this, $resource))->handle($request); + return (new Endpoint\Index($this, $resource))->handle($context); case 'POST': - return (new Endpoint\Create($this, $resource))->handle($request); + return (new Endpoint\Create($this, $resource))->handle($context); default: 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': - return (new Endpoint\Update($this, $resource, $model))->handle($request); + return (new Endpoint\Update($this, $resource, $model))->handle($context); case 'GET': - return (new Endpoint\Show($this, $resource, $model))->handle($request); + return (new Endpoint\Show($this, $resource, $model))->handle($context); case 'DELETE': - return (new Endpoint\Delete($this, $resource, $model))->handle($request); + return (new Endpoint\Delete($this, $resource, $model))->handle($context); default: throw new MethodNotAllowedException; @@ -212,9 +214,7 @@ final class JsonApi implements RequestHandlerInterface $errors = $e->getJsonApiErrors(); $status = $e->getJsonApiStatus(); - $document = new ErrorDocument( - ...$errors - ); + $document = new ErrorDocument(...$errors); return json_api_response($document, $status); } diff --git a/src/Schema/Field.php b/src/Schema/Field.php index d91bac9..996fff7 100644 --- a/src/Schema/Field.php +++ b/src/Schema/Field.php @@ -26,6 +26,7 @@ abstract class Field private $visible = true; private $single = false; private $writable = false; + private $writableOnce = false; private $getCallback; private $setCallback; private $saveCallback; @@ -93,6 +94,16 @@ abstract class Field 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. * @@ -213,6 +224,11 @@ abstract class Field return $this->writable; } + public function isWritableOnce(): bool + { + return $this->writableOnce; + } + public function getGetCallback() { return $this->getCallback; diff --git a/src/Serializer.php b/src/Serializer.php index b34a342..46d3828 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -14,19 +14,20 @@ namespace Tobyz\JsonApiServer; use DateTime; use DateTimeInterface; use JsonApiPhp\JsonApi as Structure; -use Psr\Http\Message\ServerRequestInterface as Request; use RuntimeException; +use Tobyz\JsonApiServer\Context; final class Serializer { private $api; + private $context; private $map = []; private $primary = []; - public function __construct(JsonApi $api, Request $request) + public function __construct(JsonApi $api, Context $context) { $this->api = $api; - $this->request = $request; + $this->context = $context; } /** @@ -77,7 +78,7 @@ final class Serializer $key = $this->key($data); $url = $this->api->getBasePath()."/$type/$id"; $fields = $schema->getFields(); - $queryParams = $this->request->getQueryParams(); + $queryParams = $this->context->getRequest()->getQueryParams(); if (isset($queryParams['fields'][$type])) { $fields = array_intersect_key($fields, array_flip(explode(',', $queryParams['fields'][$type]))); @@ -88,7 +89,7 @@ final class Serializer continue; } - if (! evaluate($field->getVisible(), [$model, $this->request])) { + if (! evaluate($field->getVisible(), [$model, $this->context])) { continue; } @@ -139,7 +140,7 @@ final class Serializer private function attribute(Schema\Attribute $field, ResourceType $resource, $model): Structure\Attribute { if ($getCallback = $field->getGetCallback()) { - $value = $getCallback($model, $this->request); + $value = $getCallback($model, $this->context); } else { $value = $resource->getAdapter()->getAttribute($model, $field); } @@ -156,7 +157,7 @@ final class Serializer $included = $include !== null; $model = ($getCallback = $field->getGetCallback()) - ? $getCallback($model, $this->request) + ? $getCallback($model, $this->context) : $resource->getAdapter()->getHasOne($model, $field, ! $included); if (! $model) { @@ -175,7 +176,7 @@ final class Serializer $included = $include !== null; $models = ($getCallback = $field->getGetCallback()) - ? $getCallback($model, $this->request) + ? $getCallback($model, $this->context) : $resource->getAdapter()->getHasMany($model, $field, ! $included); $identifiers = []; @@ -283,7 +284,7 @@ final class Serializer ksort($items); 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); } diff --git a/tests/feature/CreateTest.php b/tests/feature/CreateTest.php index e32a5b4..12ce6ef 100644 --- a/tests/feature/CreateTest.php +++ b/tests/feature/CreateTest.php @@ -15,6 +15,7 @@ use Psr\Http\Message\ServerRequestInterface; use Tobyz\JsonApiServer\Adapter\AdapterInterface; use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Schema\Type; use Tobyz\Tests\JsonApiServer\AbstractTestCase; use Tobyz\Tests\JsonApiServer\MockAdapter; @@ -106,8 +107,8 @@ class CreateTest extends AbstractTestCase $called = false; $this->api->resource('users', new MockAdapter(), function (Type $type) use (&$called) { - $type->creatable(function ($request) use (&$called) { - $this->assertInstanceOf(ServerRequestInterface::class, $request); + $type->creatable(function ($context) use (&$called) { + $this->assertInstanceOf(Context::class, $context); return $called = true; }); }); @@ -142,8 +143,8 @@ class CreateTest extends AbstractTestCase $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($createdModel) { $type->creatable(); - $type->newModel(function ($request) use ($createdModel) { - $this->assertInstanceOf(ServerRequestInterface::class, $request); + $type->newModel(function ($context) use ($createdModel) { + $this->assertInstanceOf(Context::class, $context); return $createdModel; }); }); @@ -162,10 +163,10 @@ class CreateTest extends AbstractTestCase $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($createdModel, &$called) { $type->creatable(); - $type->save(function ($model, $request) use ($createdModel, &$called) { + $type->save(function ($model, $context) use ($createdModel, &$called) { $model->id = '1'; $this->assertSame($createdModel, $model); - $this->assertInstanceOf(ServerRequestInterface::class, $request); + $this->assertInstanceOf(Context::class, $context); return $called = true; }); }); @@ -185,15 +186,15 @@ class CreateTest extends AbstractTestCase $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($adapter, $createdModel, &$called) { $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->assertInstanceOf(ServerRequestInterface::class, $request); + $this->assertInstanceOf(Context::class, $context); $adapter->save($createdModel)->shouldNotHaveBeenCalled(); $called++; }); - $type->onCreated(function ($model, $request) use ($adapter, $createdModel, &$called) { + $type->onCreated(function ($model, $context) use ($adapter, $createdModel, &$called) { $this->assertSame($createdModel, $model); - $this->assertInstanceOf(ServerRequestInterface::class, $request); + $this->assertInstanceOf(Context::class, $context); $adapter->save($createdModel)->shouldHaveBeenCalled(); $called++; }); diff --git a/tests/feature/DeleteTest.php b/tests/feature/DeleteTest.php index fbadb69..cef40dd 100644 --- a/tests/feature/DeleteTest.php +++ b/tests/feature/DeleteTest.php @@ -15,6 +15,7 @@ use Psr\Http\Message\ServerRequestInterface; use Tobyz\JsonApiServer\Adapter\AdapterInterface; use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Schema\Type; use Tobyz\Tests\JsonApiServer\AbstractTestCase; use Tobyz\Tests\JsonApiServer\MockAdapter; @@ -105,9 +106,9 @@ class DeleteTest extends AbstractTestCase $adapter->delete($deletingModel); $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->assertInstanceOf(ServerRequestInterface::class, $request); + $this->assertInstanceOf(Context::class, $context); return $called = true; }); }); @@ -142,9 +143,9 @@ class DeleteTest extends AbstractTestCase $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($deletingModel, &$called) { $type->deletable(); - $type->delete(function ($model, $request) use ($deletingModel, &$called) { + $type->delete(function ($model, $context) use ($deletingModel, &$called) { $this->assertSame($deletingModel, $model); - $this->assertInstanceOf(ServerRequestInterface::class, $request); + $this->assertInstanceOf(Context::class, $context); return $called = true; }); }); @@ -165,15 +166,15 @@ class DeleteTest extends AbstractTestCase $this->api->resource('users', $adapter->reveal(), function (Type $type) use ($adapter, $deletingModel, &$called) { $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->assertInstanceOf(ServerRequestInterface::class, $request); + $this->assertInstanceOf(Context::class, $context); $adapter->delete($deletingModel)->shouldNotHaveBeenCalled(); $called++; }); - $type->onDeleted(function ($model, $request) use ($adapter, $deletingModel, &$called) { + $type->onDeleted(function ($model, $context) use ($adapter, $deletingModel, &$called) { $this->assertSame($deletingModel, $model); - $this->assertInstanceOf(ServerRequestInterface::class, $request); + $this->assertInstanceOf(Context::class, $context); $adapter->delete($deletingModel)->shouldHaveBeenCalled(); $called++; }); diff --git a/tests/feature/FieldFiltersTest.php b/tests/feature/FieldFiltersTest.php index 2b463e1..358dc82 100644 --- a/tests/feature/FieldFiltersTest.php +++ b/tests/feature/FieldFiltersTest.php @@ -13,6 +13,7 @@ namespace Tobyz\Tests\JsonApiServer\feature; use Psr\Http\Message\ServerRequestInterface; use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Schema\Type; use Tobyz\Tests\JsonApiServer\AbstractTestCase; use Tobyz\Tests\JsonApiServer\MockAdapter; @@ -75,7 +76,7 @@ class FieldFiltersTest extends AbstractTestCase $type->filter('name', function (...$args) use (&$called) { $this->assertSame($this->adapter->query, $args[0]); $this->assertEquals('value', $args[1]); - $this->assertInstanceOf(ServerRequestInterface::class, $args[2]); + $this->assertInstanceOf(Context::class, $args[2]); $called = true; }); diff --git a/tests/feature/FieldWritabilityTest.php b/tests/feature/FieldWritabilityTest.php index a79711b..725facd 100644 --- a/tests/feature/FieldWritabilityTest.php +++ b/tests/feature/FieldWritabilityTest.php @@ -12,9 +12,9 @@ namespace Tobyz\Tests\JsonApiServer\feature; use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ServerRequestInterface; use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Schema\Type; use Tobyz\Tests\JsonApiServer\AbstractTestCase; use Tobyz\Tests\JsonApiServer\MockAdapter; @@ -119,9 +119,9 @@ class FieldWritabilityTest extends AbstractTestCase $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { $type->updatable(); $type->attribute('writable') - ->writable(function ($model, $request) use (&$called) { + ->writable(function ($model, $context) use (&$called) { $this->assertSame($this->adapter->models['1'], $model); - $this->assertInstanceOf(ServerRequestInterface::class, $request); + $this->assertInstanceOf(Context::class, $context); return $called = true; }); @@ -197,11 +197,11 @@ class FieldWritabilityTest extends AbstractTestCase $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { $type->updatable(); $type->attribute('readonly') - ->readonly(function ($model, $request) use (&$called) { + ->readonly(function ($model, $context) use (&$called) { $called = true; $this->assertSame($this->adapter->models['1'], $model); - $this->assertInstanceOf(RequestInterface::class, $request); + $this->assertInstanceOf(Context::class, $context); return false; }); @@ -223,5 +223,38 @@ class FieldWritabilityTest extends AbstractTestCase $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... } diff --git a/tests/feature/MetaTest.php b/tests/feature/MetaTest.php index f51d4a8..536e2fc 100644 --- a/tests/feature/MetaTest.php +++ b/tests/feature/MetaTest.php @@ -13,6 +13,7 @@ namespace Tobyz\Tests\JsonApiServer\feature; use Psr\Http\Message\ServerRequestInterface; use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Schema\Type; use Tobyz\Tests\JsonApiServer\AbstractTestCase; use Tobyz\Tests\JsonApiServer\MockAdapter; @@ -34,9 +35,9 @@ class MetaTest extends AbstractTestCase $adapter = new MockAdapter(['1' => (object) ['id' => '1']]); $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->assertInstanceOf(ServerRequestInterface::class, $request); + $this->assertInstanceOf(Context::class, $context); return 'bar'; }); }); diff --git a/tests/feature/ScopesTest.php b/tests/feature/ScopesTest.php index 3fa5edf..8940be6 100644 --- a/tests/feature/ScopesTest.php +++ b/tests/feature/ScopesTest.php @@ -13,6 +13,7 @@ namespace Tobyz\Tests\JsonApiServer\feature; use Psr\Http\Message\ServerRequestInterface; use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Schema\Type; use Tobyz\Tests\JsonApiServer\AbstractTestCase; use Tobyz\Tests\JsonApiServer\MockAdapter; @@ -40,7 +41,7 @@ class ScopesTest extends AbstractTestCase $this->api->resource('users', $this->adapter, function (Type $type) { $type->updatable(); $type->deletable(); - $type->scope(function ($query, ServerRequestInterface $request) { + $type->scope(function ($query, Context $context) { $this->scopeWasCalled = true; }); }); @@ -114,7 +115,7 @@ class ScopesTest extends AbstractTestCase $organisationScopeWasCalled = false; $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; }); });