diff --git a/.gitignore b/.gitignore index 987e2a2..bfc0cf2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ composer.lock vendor +.phpunit.result.cache \ No newline at end of file diff --git a/README.md b/README.md index dea2461..7b5f388 100644 --- a/README.md +++ b/README.md @@ -12,27 +12,27 @@ composer require tobyz/json-api-server ``` ```php -use Tobyz\JsonApiServer\Api; use Tobyz\JsonApiServer\Adapter\EloquentAdapter; -use Tobyz\JsonApiServer\Schema\Builder; +use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\Schema\Type; -$api = new Api('http://example.com/api'); +$api = new JsonApi('http://example.com/api'); -$api->resource('articles', new EloquentAdapter(new Article), function (Builder $schema) { - $schema->attribute('title'); - $schema->hasOne('author', 'people'); - $schema->hasMany('comments'); +$api->resource('articles', new EloquentAdapter(Article::class), function (Type $type) { + $type->attribute('title'); + $type->hasOne('author')->type('people'); + $type->hasMany('comments'); }); -$api->resource('people', new EloquentAdapter(new User), function (Builder $schema) { - $schema->attribute('firstName'); - $schema->attribute('lastName'); - $schema->attribute('twitter'); +$api->resource('people', new EloquentAdapter(User::class), function (Type $type) { + $type->attribute('firstName'); + $type->attribute('lastName'); + $type->attribute('twitter'); }); -$api->resource('comments', new EloquentAdapter(new Comment), function (Builder $schema) { - $schema->attribute('body'); - $schema->hasOne('author', 'people'); +$api->resource('comments', new EloquentAdapter(Comment::class), function (Type $type) { + $type->attribute('body'); + $type->hasOne('author')->type('people'); }); /** @var Psr\Http\Message\ServerRequestInterface $request */ @@ -60,9 +60,9 @@ The schema definition is extremely powerful and lets you easily apply [permissio ### Handling Requests ```php -use Tobyz\JsonApiServer\Api; +use Tobyz\JsonApiServer\JsonApi; -$api = new Api('http://example.com/api'); +$api = new JsonApi('http://example.com/api'); try { $response = $api->handle($request); @@ -71,26 +71,26 @@ try { } ``` -`Tobyz\JsonApiServer\Api` is a [PSR-15 Request Handler](https://www.php-fig.org/psr/psr-15/). Instantiate it with your API's base URL. Convert your framework's request object into a [PSR-7 Request](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface) implementation, then let the `Api` handler take it from there. Catch any exceptions and give them back to `Api` if you want a JSON:API error response. +`Tobyz\JsonApiServer\JsonApi` is a [PSR-15 Request Handler](https://www.php-fig.org/psr/psr-15/). Instantiate it with your API's base URL. Convert your framework's request object into a [PSR-7 Request](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface) implementation, then let the `JsonApi` handler take it from there. Catch any exceptions and give them back to `JsonApi` if you want a JSON:API error response. ### Defining Resources Define your API's resources using the `resource` method. The first argument is the [resource type](https://jsonapi.org/format/#document-resource-object-identification). The second is an instance of `Tobyz\JsonApiServer\Adapter\AdapterInterface` which will allow the handler to interact with your models. The third is a closure in which you'll build the schema for your resource. ```php -use Tobyz\JsonApiServer\Schema\Builder; +use Tobyz\JsonApiServer\Schema\Type; -$api->resource('comments', $adapter, function (Builder $schema) { +$api->resource('comments', $adapter, function (Schema $schema) { // define your schema }); ``` -We provide an `EloquentAdapter` to hook your resources up with Laravel [Eloquent](https://laravel.com/docs/5.8/eloquent) models. Set it up with an instance of the model that your resource represents. You can [implement your own adapter](https://github.com/tobyz/json-api-server/blob/master/src/Adapter/AdapterInterface.php) if you use a different ORM. +We provide an `EloquentAdapter` to hook your resources up with Laravel [Eloquent](https://laravel.com/docs/5.8/eloquent) models. Set it up with the name of the model that your resource represents. You can [implement your own adapter](https://github.com/tobyz/json-api-server/blob/master/src/Adapter/AdapterInterface.php) if you use a different ORM. ```php use Tobyz\JsonApiServer\Adapter\EloquentAdapter; -$adapter = new EloquentAdapter(new User); +$adapter = new EloquentAdapter(User::class); ``` ### Attributes @@ -116,10 +116,10 @@ $schema->hasOne('user'); $schema->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, provide it as a second argument: +By default the [resource type](https://jsonapi.org/format/#document-resource-object-identification) that the relationship corresponds to will be derived from the relationship name. In the example above, the `user` relationship would correspond to the `users` resource type, while `comments` would correspond to `comments`. If you'd like to use a different resource type, call the `type` method: ```php -$schema->hasOne('author', 'people'); +$schema->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, provide it as a third argument. @@ -173,14 +173,14 @@ $schema->hasOne('user') #### Polymorphic Relationships -Define polymorphic relationships on your resource using the `morphOne` and `morphMany` methods: +Define a relationship as polymorphic using the `polymorphic` method: ```php -$schema->morphOne('commentable'); -$schema->morphMany('taggable'); +$schema->hasOne('commentable')->polymorphic(); +$schema->hasMany('taggable')->polymorphic(); ``` -Polymorphic relationships do not accept a second argument for the resource type, because it will be automatically derived from each related resource. Nested includes cannot be requested on these relationships. +This will mean that the resource type associated with the relationship will be derived from the model of each related resource. Consequently, nested includes cannot be requested on these relationships. ### Getters @@ -257,7 +257,6 @@ $schema->attribute('email') You can provide a default value for a field to be used when creating a new resource if there is no value provided by the consumer. Pass a value or a closure to the `default` method: - ```php $schema->attribute('joinedAt') ->default(new DateTime); @@ -299,7 +298,7 @@ $schema->hasMany('groups') You can easily use Laravel's [Validation](https://laravel.com/docs/5.8/validation) component for field validation with the `rules` function: ```php -use Tobyz\JsonApi\Server\Laravel\rules; +use Tobyz\JsonApiServer\Laravel\rules; $schema->attribute('username') ->validate(rules('required', 'min:3', 'max:30')); @@ -312,7 +311,7 @@ Use the `set` method to define custom mutation logic for your field, instead of ```php $schema->attribute('firstName') ->set(function ($model, $value, $request) { - return $model->first_name = strtolower($value); + $model->first_name = strtolower($value); }); ``` @@ -396,6 +395,14 @@ You can set a default sort string to be used when the consumer has not supplied $schema->defaultSort('-updatedAt,-createdAt'); ``` +To define sortable criteria that does not correspond to an attribute, use the `sort` method: + +```php +$schema->sort('relevance', function ($query, $direction, $request) { + $query->orderBy('relevance', $direction); +}); +``` + ### Pagination By default, resource listings are automatically [paginated](https://jsonapi.org/format/#fetching-pagination) with 20 records per page. You can change this limit using the `paginate` method on the schema builder, or you can remove it by passing `null`: @@ -434,9 +441,11 @@ $schema->meta('requestTime', function ($request) { ### Creating Resources -By default, resources are not [creatable](https://jsonapi.org/format/#crud-creating) (i.e. `POST` requests will return `403 Forbidden`). You can allow them to be created using the `creatable` and `notCreatable` methods on the schema builder: +By default, resources are not [creatable](https://jsonapi.org/format/#crud-creating) (i.e. `POST` requests will return `403 Forbidden`). You can allow them to be created using the `creatable` and `notCreatable` methods on the schema builder. Pass a closure that returns `true` if the resource should be creatable, or no value to have it always creatable. ```php +$schema->creatable(); + $schema->creatable(function ($request) { return $request->getAttribute('isAdmin'); }); @@ -444,7 +453,7 @@ $schema->creatable(function ($request) { #### Customizing the Model -When creating a resource, an empty model is supplied by the adapter. You may wish to provide a custom model in special circumstances. You can do so using the `create` method: +When creating a resource, an empty model is supplied by the adapter. You may wish to override this and provide a custom model in special circumstances. You can do so using the `create` method: ```php $schema->create(function ($request) { @@ -457,6 +466,8 @@ $schema->create(function ($request) { By default, resources are not [updatable](https://jsonapi.org/format/#crud-updating) (i.e. `PATCH` requests will return `403 Forbidden`). You can allow them to be updated using the `updatable` and `notUpdatable` methods on the schema builder: ```php +$schema->updatable(); + $schema->updatable(function ($request) { return $request->getAttribute('isAdmin'); }); @@ -467,6 +478,8 @@ $schema->updatable(function ($request) { By default, resources are not [deletable](https://jsonapi.org/format/#crud-deleting) (i.e. `DELETE` requests will return `403 Forbidden`). You can allow them to be deleted using the `deletable` and `notDeletable` methods on the schema builder: ```php +$schema->deletable(); + $schema->deletable(function ($request) { return $request->getAttribute('isAdmin'); }); diff --git a/composer.json b/composer.json index a66c47a..fc727f6 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,8 @@ "autoload": { "psr-4": { "Tobyz\\JsonApiServer\\": "src/" - } + }, + "files": ["src/functions.php"] }, "autoload-dev": { "psr-4": { @@ -26,7 +27,9 @@ } }, "require-dev": { - "phpunit/phpunit": "^7.4" + "dms/phpunit-arraysubset-asserts": "^0.1.0", + "helmich/phpunit-json-assert": "^3.0", + "phpunit/phpunit": "^8.0" }, "config": { "sort-packages": true diff --git a/src/Adapter/AdapterInterface.php b/src/Adapter/AdapterInterface.php index 97794a8..fcd5d88 100644 --- a/src/Adapter/AdapterInterface.php +++ b/src/Adapter/AdapterInterface.php @@ -9,47 +9,231 @@ use Tobyz\JsonApiServer\Schema\Relationship; interface AdapterInterface { - public function handles($model); - - public function create(); - + /** + * Create a new query builder instance. + * + * This is used as a basis for building the queries which show a resource + * or list a resource index. It will be passed around through the relevant + * scopes, filters, and sorting methods before finally being passed into + * the `find` or `get` methods. + * + * @return mixed + */ public function query(); - public function find($query, $id); + /** + * Manipulate the query to only include resources with the given IDs. + * + * @param $query + * @param array $ids + * @return mixed + */ + public function filterByIds($query, array $ids): void; + /** + * Manipulate the query to only include resources with a certain attribute + * value. + * + * @param $query + * @param Attribute $attribute + * @param $value + * @return mixed + */ + public function filterByAttribute($query, Attribute $attribute, $value): void; + + /** + * Manipulate the query to only include resources with any one of the given + * resource IDs in a has-one relationship. + * + * @param $query + * @param HasOne $relationship + * @param array $ids + * @return mixed + */ + public function filterByHasOne($query, HasOne $relationship, array $ids): void; + + /** + * Manipulate the query to only include resources with any one of the given + * resource IDs in a has-many relationship. + * + * @param $query + * @param HasMany $relationship + * @param array $ids + * @return mixed + */ + public function filterByHasMany($query, HasMany $relationship, array $ids): void; + + /** + * Manipulate the query to sort by the given attribute in the given direction. + * + * @param $query + * @param Attribute $attribute + * @param string $direction + * @return mixed + */ + public function sortByAttribute($query, Attribute $attribute, string $direction): void; + + /** + * Manipulate the query to only include a certain number of results, + * starting from the given offset. + * + * @param $query + * @param int $limit + * @param int $offset + * @return mixed + */ + public function paginate($query, int $limit, int $offset): void; + + /** + * Find a single resource by ID from the query. + * + * @param $query + * @param string $id + * @return mixed + */ + public function find($query, string $id); + + /** + * Get a list of resources from the query. + * + * @param $query + * @return array + */ public function get($query): array; + /** + * Get the number of results from the query. + * + * @param $query + * @return int + */ + public function count($query): int; + + /** + * Determine whether or not this resource type represents the given model. + * + * This is used for polymorphic relationships, where there are one or many + * related models of unknown type. The first resource type with an adapter + * that responds positively from this method will be used. + * + * @param mixed $model + * @return bool + */ + public function represents($model): bool; + + /** + * Create a new model instance. + * + * @return mixed + */ + public function create(); + + /** + * Get the ID from the model. + * + * @param $model + * @return string + */ public function getId($model): string; + /** + * Get the value of an attribute from the model. + * + * @param $model + * @param Attribute $attribute + * @return mixed + */ public function getAttribute($model, Attribute $attribute); + /** + * Get the model for a has-one relationship for the model. + * + * @param $model + * @param HasOne $relationship + * @return mixed|null + */ public function getHasOne($model, HasOne $relationship); + /** + * Get the ID of the related resource for a has-one relationship. + * + * @param $model + * @param HasOne $relationship + * @return mixed|null + */ + public function getHasOneId($model, HasOne $relationship): ?string; + + /** + * Get a list of models for a has-many relationship for the model. + * + * @param $model + * @param HasMany $relationship + * @return array + */ public function getHasMany($model, HasMany $relationship): array; - public function applyAttribute($model, Attribute $attribute, $value); + /** + * Apply an attribute value to the model. + * + * @param $model + * @param Attribute $attribute + * @param $value + * @return mixed + */ + public function setAttribute($model, Attribute $attribute, $value): void; - public function applyHasOne($model, HasOne $relationship, $related); + /** + * Apply a has-one relationship value to the model. + * + * @param $model + * @param HasOne $relationship + * @param $related + * @return mixed + */ + public function setHasOne($model, HasOne $relationship, $related): void; - public function save($model); + /** + * Save the model. + * + * @param $model + * @return mixed + */ + public function save($model): void; - public function saveHasMany($model, HasMany $relationship, array $related); + /** + * Save a has-many relationship for the model. + * + * @param $model + * @param HasMany $relationship + * @param array $related + * @return mixed + */ + public function saveHasMany($model, HasMany $relationship, array $related): void; - public function delete($model); + /** + * Delete the model. + * + * @param $model + * @return mixed + */ + public function delete($model): void; - public function filterByIds($query, array $ids); + /** + * Load information about related resources onto a collection of models. + * + * @param array $models + * @param array $relationships + * @return mixed + */ + public function load(array $models, array $relationships): void; - public function filterByAttribute($query, Attribute $attribute, $value); - - public function filterByHasOne($query, HasOne $relationship, array $ids); - - public function filterByHasMany($query, HasMany $relationship, array $ids); - - public function sortByAttribute($query, Attribute $attribute, string $direction); - - public function paginate($query, int $limit, int $offset); - - public function load(array $models, array $relationships); - - public function loadIds(array $models, Relationship $relationship); + /** + * Load information about the IDs of related resources onto a collection + * of models. + * + * @param array $models + * @param Relationship $relationship + * @return mixed + */ + public function loadIds(array $models, Relationship $relationship): void; } diff --git a/src/Adapter/EloquentAdapter.php b/src/Adapter/EloquentAdapter.php index fc5b7f2..4abe0b4 100644 --- a/src/Adapter/EloquentAdapter.php +++ b/src/Adapter/EloquentAdapter.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\SoftDeletes; +use InvalidArgumentException; use Tobyz\JsonApiServer\Schema\Attribute; use Tobyz\JsonApiServer\Schema\HasMany; use Tobyz\JsonApiServer\Schema\HasOne; @@ -24,11 +25,11 @@ class EloquentAdapter implements AdapterInterface $this->model = is_string($model) ? new $model : $model; if (! $this->model instanceof Model) { - throw new \InvalidArgumentException('Model must be an instance of '.Model::class); + throw new InvalidArgumentException('Model must be an instance of '.Model::class); } } - public function handles($model) + public function represents($model): bool { return $model instanceof $this->model; } @@ -43,7 +44,7 @@ class EloquentAdapter implements AdapterInterface return $this->model->query(); } - public function find($query, $id) + public function find($query, string $id) { return $query->find($id); } @@ -63,64 +64,63 @@ class EloquentAdapter implements AdapterInterface return $model->getKey(); } - public function getAttribute($model, Attribute $field) + public function getAttribute($model, Attribute $attribute) { - return $model->{$this->getAttributeProperty($field)}; + return $model->{$this->getAttributeProperty($attribute)}; } - public function getHasOneId($model, HasOne $field) + public function getHasOneId($model, HasOne $relationship): ?string { - $relation = $model->{$this->getRelationshipProperty($field)}(); + $relation = $this->getRelation($model, $relationship); + // If this is a belongs-to relation, we can simply return the value of + // the foreign key on the model. if ($relation instanceof BelongsTo) { - $related = $relation->getRelated(); - - $key = $model->{$relation->getForeignKeyName()}; - - if ($key) { - return $related->forceFill([$related->getKeyName() => $key]); - } - - return null; + return $model->{$relation->getForeignKeyName()}; } - return $model->{$this->getRelationshipProperty($field)}; + $related = $this->getRelationValue($model, $relationship); + + return $related ? $related->getKey() : null; } - public function getHasOne($model, HasOne $field) + public function getHasOne($model, HasOne $relationship) { - return $model->{$this->getRelationshipProperty($field)}; + return $this->getRelationValue($model, $relationship); } - public function getHasMany($model, HasMany $field): array + public function getHasMany($model, HasMany $relationship): array { - $collection = $model->{$this->getRelationshipProperty($field)}; + $collection = $this->getRelationValue($model, $relationship); return $collection ? $collection->all() : []; } - public function applyAttribute($model, Attribute $field, $value) + public function setAttribute($model, Attribute $attribute, $value): void { - $model->{$this->getAttributeProperty($field)} = $value; + $model->{$this->getAttributeProperty($attribute)} = $value; } - public function applyHasOne($model, HasOne $field, $related) + public function setHasOne($model, HasOne $relationship, $related): void { - $model->{$this->getRelationshipProperty($field)}()->associate($related); + $this->getRelation($model, $relationship)->associate($related); } - public function save($model) + public function save($model): void { $model->save(); } - public function saveHasMany($model, HasMany $field, array $related) + public function saveHasMany($model, HasMany $relationship, array $related): void { - $model->{$this->getRelationshipProperty($field)}()->sync(Collection::make($related)); + $this->getRelation($model, $relationship)->sync(new Collection($related)); } - public function delete($model) + public function delete($model): void { + // For models that use the SoftDeletes trait, deleting the resource from + // the API implies permanent deletion. Non-permanent deletion should be + // achieved by manipulating a resource attribute. if (method_exists($model, 'forceDelete')) { $model->forceDelete(); } else { @@ -128,17 +128,18 @@ class EloquentAdapter implements AdapterInterface } } - public function filterByIds($query, array $ids) + public function filterByIds($query, array $ids): void { $key = $query->getModel()->getQualifiedKeyName(); $query->whereIn($key, $ids); } - public function filterByAttribute($query, Attribute $field, $value) + public function filterByAttribute($query, Attribute $attribute, $value): void { - $property = $this->getAttributeProperty($field); + $property = $this->getAttributeProperty($attribute); + // TODO: extract this into non-adapter territory if (preg_match('/(.+)\.\.(.+)/', $value, $matches)) { if ($matches[1] !== '*') { $query->where($property, '>=', $matches[1]); @@ -161,19 +162,17 @@ class EloquentAdapter implements AdapterInterface $query->where($property, $value); } - public function filterByHasOne($query, HasOne $field, array $ids) + public function filterByHasOne($query, HasOne $relationship, array $ids): void { - $relation = $query->getModel()->{$this->getRelationshipProperty($field)}(); + $relation = $this->getRelation($query->getModel(), $relationship); - $foreignKey = $relation->getQualifiedForeignKeyName(); - - $query->whereIn($foreignKey, $ids); + $query->whereIn($relation->getQualifiedForeignKeyName(), $ids); } - public function filterByHasMany($query, HasMany $field, array $ids) + public function filterByHasMany($query, HasMany $relationship, array $ids): void { - $property = $this->getRelationshipProperty($field); - $relation = $query->getModel()->{$property}(); + $property = $this->getRelationshipProperty($relationship); + $relation = $this->getRelation($query->getModel(), $relationship); $relatedKey = $relation->getRelated()->getQualifiedKeyName(); $query->whereHas($property, function ($query) use ($relatedKey, $ids) { @@ -181,22 +180,22 @@ class EloquentAdapter implements AdapterInterface }); } - public function sortByAttribute($query, Attribute $field, string $direction) + public function sortByAttribute($query, Attribute $field, string $direction): void { $query->orderBy($this->getAttributeProperty($field), $direction); } - public function paginate($query, int $limit, int $offset) + public function paginate($query, int $limit, int $offset): void { $query->take($limit)->skip($offset); } - public function load(array $models, array $trail) + public function load(array $models, array $relationships): void { - (new Collection($models))->loadMissing($this->relationshipTrailToPath($trail)); + (new Collection($models))->loadMissing($this->getRelationshipPath($relationships)); } - public function loadIds(array $models, Relationship $relationship) + public function loadIds(array $models, Relationship $relationship): void { if (empty($models)) { return; @@ -219,20 +218,28 @@ class EloquentAdapter implements AdapterInterface ]); } - private function getAttributeProperty(Attribute $field) + private function getAttributeProperty(Attribute $attribute): string { - return $field->property ?: strtolower(preg_replace('/(?name)); + return $attribute->getProperty() ?: strtolower(preg_replace('/(?getName())); } - private function getRelationshipProperty(Relationship $field) + private function getRelationshipProperty(Relationship $relationship): string { - return $field->property ?: $field->name; + return $relationship->getProperty() ?: $relationship->getName(); } - private function relationshipTrailToPath(array $trail) + private function getRelationshipPath(array $trail): string { - return implode('.', array_map(function ($relationship) { - return $this->getRelationshipProperty($relationship); - }, $trail)); + return implode('.', array_map([$this, 'getRelationshipProperty'], $trail)); + } + + private function getRelation($model, Relationship $relationship) + { + return $model->{$this->getRelationshipProperty($relationship)}(); + } + + private function getRelationValue($model, Relationship $relationship) + { + return $model->{$this->getRelationshipProperty($relationship)}; } } diff --git a/src/Api.php b/src/Api.php deleted file mode 100644 index f67314c..0000000 --- a/src/Api.php +++ /dev/null @@ -1,144 +0,0 @@ -baseUrl = $baseUrl; - } - - public function resource(string $type, $adapter, Closure $buildSchema = null): void - { - $this->resources[$type] = new ResourceType($type, $adapter, $buildSchema); - } - - public function getResources(): array - { - return $this->resources; - } - - public function getResource(string $type): ResourceType - { - if (! isset($this->resources[$type])) { - throw new ResourceNotFoundException($type); - } - - return $this->resources[$type]; - } - - public function handle(Request $request): Response - { - $path = $this->stripBasePath( - $request->getUri()->getPath() - ); - - $segments = explode('/', trim($path, '/')); - $count = count($segments); - - $resource = $this->getResource($segments[0]); - - if ($count === 1) { - switch ($request->getMethod()) { - case 'GET': - return $this->handleWithHandler($request, new Handler\Index($this, $resource)); - - case 'POST': - return $this->handleWithHandler($request, new Handler\Create($this, $resource)); - - default: - throw new MethodNotAllowedException; - } - } - - $model = $this->findResource($request, $resource, $segments[1]); - - if ($count === 2) { - switch ($request->getMethod()) { - case 'PATCH': - return $this->handleWithHandler($request, new Handler\Update($this, $resource, $model)); - - case 'GET': - return $this->handleWithHandler($request, new Handler\Show($this, $resource, $model)); - - case 'DELETE': - return $this->handleWithHandler($request, new Handler\Delete($resource, $model)); - - default: - throw new MethodNotAllowedException; - } - } - - if ($count === 3) { - throw new NotImplementedException; - - // return $this->handleRelated($request, $resource, $model, $segments[2]); - } - - if ($count === 4 && $segments[2] === 'relationships') { - throw new NotImplementedException; - - // return $this->handleRelationship($request, $resource, $model, $segments[3]); - } - - throw new BadRequestException; - } - - private function stripBasePath(string $path): string - { - $basePath = parse_url($this->baseUrl, PHP_URL_PATH); - - $len = strlen($basePath); - - if (substr($path, 0, $len) === $basePath) { - $path = substr($path, $len + 1); - } - - return $path; - } - - private function handleWithHandler(Request $request, RequestHandlerInterface $handler) - { - $request = $request->withAttribute('jsonApiHandler', $handler); - - return $handler->handle($request); - } - - public function error($e) - { - if (! $e instanceof ErrorProviderInterface) { - $e = new Exception\InternalServerErrorException; - } - - $errors = $e->getJsonApiErrors(); - $status = $e->getJsonApiStatus(); - - $data = new JsonApi\ErrorDocument( - ...$errors - ); - - return new JsonApiResponse($data, $status); - } - - public function getBaseUrl(): string - { - return $this->baseUrl; - } -} diff --git a/src/Exception/BadRequestException.php b/src/Exception/BadRequestException.php index f299232..3523e07 100644 --- a/src/Exception/BadRequestException.php +++ b/src/Exception/BadRequestException.php @@ -2,10 +2,11 @@ namespace Tobyz\JsonApiServer\Exception; +use DomainException; use JsonApiPhp\JsonApi\Error; use Tobyz\JsonApiServer\ErrorProviderInterface; -class BadRequestException extends \DomainException implements ErrorProviderInterface +class BadRequestException extends DomainException implements ErrorProviderInterface { /** * @var string diff --git a/src/Exception/ForbiddenException.php b/src/Exception/ForbiddenException.php index 0e071e6..2113190 100644 --- a/src/Exception/ForbiddenException.php +++ b/src/Exception/ForbiddenException.php @@ -2,10 +2,11 @@ namespace Tobyz\JsonApiServer\Exception; +use DomainException; use JsonApiPhp\JsonApi\Error; use Tobyz\JsonApiServer\ErrorProviderInterface; -class ForbiddenException extends \DomainException implements ErrorProviderInterface +class ForbiddenException extends DomainException implements ErrorProviderInterface { public function getJsonApiErrors(): array { diff --git a/src/Exception/InternalServerErrorException.php b/src/Exception/InternalServerErrorException.php index a87e520..3dc5fe9 100644 --- a/src/Exception/InternalServerErrorException.php +++ b/src/Exception/InternalServerErrorException.php @@ -3,16 +3,17 @@ namespace Tobyz\JsonApiServer\Exception; use JsonApiPhp\JsonApi\Error; +use RuntimeException; use Tobyz\JsonApiServer\ErrorProviderInterface; -class InternalServerErrorException extends \RuntimeException implements ErrorProviderInterface +class InternalServerErrorException extends RuntimeException implements ErrorProviderInterface { public function getJsonApiErrors(): array { return [ new Error( new Error\Title('Internal Server Error'), - new Error\Status('500') + new Error\Status($this->getJsonApiStatus()) ) ]; } diff --git a/src/Exception/MethodNotAllowedException.php b/src/Exception/MethodNotAllowedException.php index 3568c45..b6b63db 100644 --- a/src/Exception/MethodNotAllowedException.php +++ b/src/Exception/MethodNotAllowedException.php @@ -2,18 +2,24 @@ namespace Tobyz\JsonApiServer\Exception; +use DomainException as DomainExceptionAlias; use JsonApiPhp\JsonApi\Error; use Tobyz\JsonApiServer\ErrorProviderInterface; -class MethodNotAllowedException extends \DomainException implements ErrorProviderInterface +class MethodNotAllowedException extends DomainExceptionAlias implements ErrorProviderInterface { public function getJsonApiErrors(): array { return [ new Error( new Error\Title('Method Not Allowed'), - new Error\Status('405') + new Error\Status($this->getJsonApiStatus()) ) ]; } + + public function getJsonApiStatus(): string + { + return '405'; + } } diff --git a/src/Exception/NotAcceptableException.php b/src/Exception/NotAcceptableException.php new file mode 100644 index 0000000..0b4b875 --- /dev/null +++ b/src/Exception/NotAcceptableException.php @@ -0,0 +1,25 @@ +getJsonApiStatus()) + ) + ]; + } + + public function getJsonApiStatus(): string + { + return '406'; + } +} diff --git a/src/Exception/NotImplementedException.php b/src/Exception/NotImplementedException.php index 2ac27e6..0af1992 100644 --- a/src/Exception/NotImplementedException.php +++ b/src/Exception/NotImplementedException.php @@ -2,18 +2,24 @@ namespace Tobyz\JsonApiServer\Exception; +use DomainException; use JsonApiPhp\JsonApi\Error; use Tobyz\JsonApiServer\ErrorProviderInterface; -class NotImplementedException extends \DomainException implements ErrorProviderInterface +class NotImplementedException extends DomainException implements ErrorProviderInterface { public function getJsonApiErrors(): array { return [ new Error( new Error\Title('Not Implemented'), - new Error\Status('501') + new Error\Status($this->getJsonApiStatus()) ) ]; } + + public function getJsonApiStatus(): string + { + return '501'; + } } diff --git a/src/Exception/ResourceNotFoundException.php b/src/Exception/ResourceNotFoundException.php index ad82593..eef6d49 100644 --- a/src/Exception/ResourceNotFoundException.php +++ b/src/Exception/ResourceNotFoundException.php @@ -3,9 +3,10 @@ namespace Tobyz\JsonApiServer\Exception; use JsonApiPhp\JsonApi\Error; +use RuntimeException; use Tobyz\JsonApiServer\ErrorProviderInterface; -class ResourceNotFoundException extends \RuntimeException implements ErrorProviderInterface +class ResourceNotFoundException extends RuntimeException implements ErrorProviderInterface { protected $type; protected $id; diff --git a/src/Exception/UnauthorizedException.php b/src/Exception/UnauthorizedException.php index 9f27c31..a11ad89 100644 --- a/src/Exception/UnauthorizedException.php +++ b/src/Exception/UnauthorizedException.php @@ -2,10 +2,11 @@ namespace Tobyz\JsonApiServer\Exception; +use DomainException; use JsonApiPhp\JsonApi\Error; use Tobyz\JsonApiServer\ErrorProviderInterface; -class UnauthorizedException extends \DomainException implements ErrorProviderInterface +class UnauthorizedException extends DomainException implements ErrorProviderInterface { public function getJsonApiErrors(): array { diff --git a/src/Exception/UnprocessableEntityException.php b/src/Exception/UnprocessableEntityException.php index 2d3c115..6a01632 100644 --- a/src/Exception/UnprocessableEntityException.php +++ b/src/Exception/UnprocessableEntityException.php @@ -2,10 +2,11 @@ namespace Tobyz\JsonApiServer\Exception; +use DomainException; use JsonApiPhp\JsonApi\Error; use Tobyz\JsonApiServer\ErrorProviderInterface; -class UnprocessableEntityException extends \DomainException implements ErrorProviderInterface +class UnprocessableEntityException extends DomainException implements ErrorProviderInterface { private $failures; @@ -19,11 +20,19 @@ class UnprocessableEntityException extends \DomainException implements ErrorProv public function getJsonApiErrors(): array { return array_map(function ($failure) { - return new Error( + $members = [ new Error\Status($this->getJsonApiStatus()), - new Error\SourcePointer('/data/'.$failure['field']->location.'/'.$failure['field']->name), - new Error\Detail($failure['message']) - ); + ]; + + if ($field = $failure['field']) { + $members[] = new Error\SourcePointer('/data/'.$field->getLocation().'/'.$field->getName()); + } + + if ($failure['message']) { + $members[] = new Error\Detail($failure['message']); + } + + return new Error(...$members); }, $this->failures); } diff --git a/src/Exception/UnsupportedMediaTypeException.php b/src/Exception/UnsupportedMediaTypeException.php new file mode 100644 index 0000000..46ffdf2 --- /dev/null +++ b/src/Exception/UnsupportedMediaTypeException.php @@ -0,0 +1,25 @@ +getJsonApiStatus()) + ) + ]; + } + + public function getJsonApiStatus(): string + { + return '415'; + } +} diff --git a/src/Handler/Concerns/FindsResources.php b/src/Handler/Concerns/FindsResources.php index 11f1920..a37e32a 100644 --- a/src/Handler/Concerns/FindsResources.php +++ b/src/Handler/Concerns/FindsResources.php @@ -5,18 +5,17 @@ namespace Tobyz\JsonApiServer\Handler\Concerns; use Psr\Http\Message\ServerRequestInterface as Request; use Tobyz\JsonApiServer\Exception\ResourceNotFoundException; use Tobyz\JsonApiServer\ResourceType; +use function Tobyz\JsonApiServer\run_callbacks; trait FindsResources { - private function findResource(Request $request, ResourceType $resource, $id) + private function findResource(Request $request, ResourceType $resource, string $id) { $adapter = $resource->getAdapter(); $query = $adapter->query(); - foreach ($resource->getSchema()->scopes as $scope) { - $scope($request, $query, $id); - } + run_callbacks($resource->getSchema()->getScopes(), [$query, $request, $id]); $model = $adapter->find($query, $id); diff --git a/src/Handler/Concerns/IncludesData.php b/src/Handler/Concerns/IncludesData.php index 6c46b00..13b2bb3 100644 --- a/src/Handler/Concerns/IncludesData.php +++ b/src/Handler/Concerns/IncludesData.php @@ -2,7 +2,9 @@ namespace Tobyz\JsonApiServer\Handler\Concerns; +use Closure; use Psr\Http\Message\ServerRequestInterface as Request; +use function Tobyz\JsonApiServer\evaluate; use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\Schema\HasMany; @@ -47,18 +49,18 @@ trait IncludesData private function validateInclude(ResourceType $resource, array $include, string $path = '') { - $schema = $resource->getSchema(); + $fields = $resource->getSchema()->getFields(); foreach ($include as $name => $nested) { - if (! isset($schema->fields[$name]) - || ! $schema->fields[$name] instanceof Relationship - || ($schema->fields[$name] instanceof HasMany && ! $schema->fields[$name]->includable) + if (! isset($fields[$name]) + || ! $fields[$name] instanceof Relationship + || ! $fields[$name]->isIncludable() ) { throw new BadRequestException("Invalid include [{$path}{$name}]", 'include'); } - if ($schema->fields[$name]->resource) { - $relatedResource = $this->api->getResource($schema->fields[$name]->resource); + if ($type = $fields[$name]->getType()) { + $relatedResource = $this->api->getResource($type); $this->validateInclude($relatedResource, $nested, $name.'.'); } elseif ($nested) { @@ -69,18 +71,18 @@ trait IncludesData private function buildRelationshipTrails(ResourceType $resource, array $include): array { - $schema = $resource->getSchema(); + $fields = $resource->getSchema()->getFields(); $trails = []; foreach ($include as $name => $nested) { - $relationship = $schema->fields[$name]; + $relationship = $fields[$name]; - if ($relationship->loadable) { + if ($relationship->getLoadable()) { $trails[] = [$relationship]; } - if ($schema->fields[$name]->resource) { - $relatedResource = $this->api->getResource($relationship->resource); + if ($type = $fields[$name]->getType()) { + $relatedResource = $this->api->getResource($type); $trails = array_merge( $trails, @@ -100,15 +102,15 @@ trait IncludesData private function loadRelationships(array $models, array $include, Request $request) { $adapter = $this->resource->getAdapter(); - $schema = $this->resource->getSchema(); + $fields = $this->resource->getSchema()->getFields(); - foreach ($schema->fields as $name => $field) { - if (! $field instanceof Relationship || ! ($field->linkage)($request) || ! $field->loadable) { + foreach ($fields as $name => $field) { + if (! $field instanceof Relationship || ! evaluate($field->getLinkage(), [$request]) || ! $field->getLoadable()) { continue; } - if ($field->loader) { - ($field->loader)($models, true); + if (($load = $field->getLoadable()) instanceof Closure) { + $load($models, true); } else { $adapter->loadIds($models, $field); } @@ -117,9 +119,9 @@ trait IncludesData $trails = $this->buildRelationshipTrails($this->resource, $include); foreach ($trails as $relationships) { - if ($loader = end($relationships)->loader) { + if (($load = end($relationships)->getLoadable()) instanceof Closure) { // TODO: probably need to loop through relationships here - ($loader)($models, false); + $load($models, false); } else { $adapter->load($models, $relationships); } diff --git a/src/Handler/Concerns/SavesData.php b/src/Handler/Concerns/SavesData.php index abc5a8f..795081f 100644 --- a/src/Handler/Concerns/SavesData.php +++ b/src/Handler/Concerns/SavesData.php @@ -3,42 +3,21 @@ namespace Tobyz\JsonApiServer\Handler\Concerns; use Psr\Http\Message\ServerRequestInterface as Request; +use function Tobyz\JsonApiServer\evaluate; +use function Tobyz\JsonApiServer\get_value; +use function Tobyz\JsonApiServer\has_value; use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\UnprocessableEntityException; -use Tobyz\JsonApiServer\ResourceType; -use Tobyz\JsonApiServer\Schema; +use function Tobyz\JsonApiServer\run_callbacks; +use Tobyz\JsonApiServer\Schema\Attribute; +use Tobyz\JsonApiServer\Schema\HasMany; +use Tobyz\JsonApiServer\Schema\HasOne; +use Tobyz\JsonApiServer\Schema\Relationship; trait SavesData { use FindsResources; - private function save($model, Request $request, bool $creating = false): void - { - $data = $this->parseData($request->getParsedBody()); - - $adapter = $this->resource->getAdapter(); - - $this->assertFieldsExist($data); - - $this->assertFieldsWritable($data, $model, $request); - - if ($creating) { - $this->fillDefaultValues($data, $request); - } - - $this->loadRelatedResources($data, $request); - - $this->assertDataValid($data, $model, $request, $creating); - - $this->applyValues($data, $model, $request); - - $this->saveModel($model, $request); - - $this->saveFields($data, $model, $request); - - $this->runSavedCallbacks($data, $model, $request); - } - private function parseData($body): array { if (! is_array($body) && ! is_object($body)) { @@ -80,15 +59,19 @@ trait SavesData return $this->findResource($request, $resource, $identifier['id']); } + private function validateFields(array $data, $model, Request $request) + { + $this->assertFieldsExist($data); + $this->assertFieldsWritable($data, $model, $request); + } + private function assertFieldsExist(array $data) { - $schema = $this->resource->getSchema(); + $fields = $this->resource->getSchema()->getFields(); foreach (['attributes', 'relationships'] as $location) { foreach ($data[$location] as $name => $value) { - if (! isset($schema->fields[$name]) - || $location !== $schema->fields[$name]->location - ) { + if (! isset($fields[$name]) || $location !== $fields[$name]->getLocation()) { throw new BadRequestException("Unknown field [$name]"); } } @@ -97,52 +80,29 @@ trait SavesData private function assertFieldsWritable(array $data, $model, Request $request) { - $schema = $this->resource->getSchema(); - - foreach ($schema->fields as $name => $field) { - $valueProvided = isset($data[$field->location][$name]); - - if ($valueProvided && ! ($field->isWritable)($request, $model)) { - throw new BadRequestException("Field [$name] is not writable"); - } - } - } - - private function fillDefaultValues(array &$data, Request $request) - { - $schema = $this->resource->getSchema(); - - foreach ($schema->fields as $name => $field) { - $valueProvided = isset($data[$field->location][$name]); - - if (! $valueProvided && $field->default) { - $data[$field->location][$name] = ($field->default)($request); + foreach ($this->resource->getSchema()->getFields() as $field) { + if (has_value($data, $field) && ! evaluate($field->getWritable(), [$model, $request])) { + throw new BadRequestException("Field [{$field->getName()}] is not writable"); } } } private function loadRelatedResources(array &$data, Request $request) { - $schema = $this->resource->getSchema(); - - foreach ($schema->fields as $name => $field) { - if (! isset($data[$field->location][$name])) { + foreach ($this->resource->getSchema()->getFields() as $field) { + if (! $field instanceof Relationship || ! has_value($data, $field)) { continue; } - $value = &$data[$field->location][$name]; + $value = &get_value($data, $field); - if ($field instanceof Schema\Relationship) { - $value = $value['data']; - - if ($value) { - if ($field instanceof Schema\HasOne) { - $value = $this->getModelForIdentifier($request, $value); - } elseif ($field instanceof Schema\HasMany) { - $value = array_map(function ($identifier) use ($request) { - return $this->getModelForIdentifier($request, $identifier); - }, $value); - } + if (isset($value['data'])) { + if ($field instanceof HasOne) { + $value = $this->getModelForIdentifier($request, $value['data']); + } elseif ($field instanceof HasMany) { + $value = array_map(function ($identifier) use ($request) { + return $this->getModelForIdentifier($request, $identifier); + }, $value['data']); } } } @@ -150,22 +110,21 @@ trait SavesData private function assertDataValid(array $data, $model, Request $request, bool $all): void { - $schema = $this->resource->getSchema(); - $failures = []; - foreach ($schema->fields as $name => $field) { - if (! $all && ! isset($data[$field->location][$name])) { + foreach ($this->resource->getSchema()->getFields() as $field) { + if (! $all && ! has_value($data, $field)) { continue; } - $fail = function ($message) use (&$failures, $field) { + $fail = function ($message = null) use (&$failures, $field) { $failures[] = compact('field', 'message'); }; - foreach ($field->validators as $validator) { - $validator($fail, $data[$field->location][$name] ?? null, $model, $request, $field); - } + run_callbacks( + $field->getListeners('validate'), + [$fail, get_value($data, $field), $model, $request, $field] + ); } if (count($failures)) { @@ -173,78 +132,81 @@ trait SavesData } } - private function applyValues(array $data, $model, Request $request) + private function setValues(array $data, $model, Request $request) { - $schema = $this->resource->getSchema(); $adapter = $this->resource->getAdapter(); - foreach ($schema->fields as $name => $field) { - if (! isset($data[$field->location][$name])) { + foreach ($this->resource->getSchema()->getFields() as $field) { + if (! has_value($data, $field)) { continue; } - $value = $data[$field->location][$name]; - - if ($field->setter || $field->saver) { - if ($field->setter) { - ($field->setter)($request, $model, $value); - } + $value = get_value($data, $field); + if ($setter = $field->getSetter()) { + $setter($model, $value, $request); continue; } - if ($field instanceof Schema\Attribute) { - $adapter->applyAttribute($model, $field, $value); - } elseif ($field instanceof Schema\HasOne) { - $adapter->applyHasOne($model, $field, $value); + if ($field->getSaver()) { + continue; + } + + if ($field instanceof Attribute) { + $adapter->setAttribute($model, $field, $value); + } elseif ($field instanceof HasOne) { + $adapter->setHasOne($model, $field, $value); } } } + private function save(array $data, $model, Request $request) + { + $this->saveModel($model, $request); + $this->saveFields($data, $model, $request); + } + private function saveModel($model, Request $request) { - $adapter = $this->resource->getAdapter(); - $schema = $this->resource->getSchema(); - - if ($schema->saver) { - ($schema->saver)($request, $model); + if ($saver = $this->resource->getSchema()->getSaver()) { + $saver($model, $request); } else { - $adapter->save($model); + $this->resource->getAdapter()->save($model); } } private function saveFields(array $data, $model, Request $request) { - $schema = $this->resource->getSchema(); $adapter = $this->resource->getAdapter(); - foreach ($schema->fields as $name => $field) { - if (! isset($data[$field->location][$name])) { + foreach ($this->resource->getSchema()->getFields() as $field) { + if (! has_value($data, $field)) { continue; } - $value = $data[$field->location][$name]; + $value = get_value($data, $field); - if ($field->saver) { - ($field->saver)($request, $model, $value); - } elseif ($field instanceof Schema\HasMany) { + if ($saver = $field->getSaver()) { + $saver($model, $value, $request); + } elseif ($field instanceof HasMany) { $adapter->saveHasMany($model, $field, $value); } } + + $this->runSavedCallbacks($data, $model, $request); } private function runSavedCallbacks(array $data, $model, Request $request) { - $schema = $this->resource->getSchema(); - - foreach ($schema->fields as $name => $field) { - if (! isset($data[$field->location][$name])) { + foreach ($this->resource->getSchema()->getFields() as $field) { + if (! has_value($data, $field)) { continue; } - foreach ($field->savedCallbacks as $callback) { - $callback($request, $model, $data[$field->location][$name]); - } + run_callbacks( + $field->getListeners('saved'), + [$model, get_value($data, $field), $request] + ); } } } diff --git a/src/Handler/Create.php b/src/Handler/Create.php index 69d9b76..33bb2bb 100644 --- a/src/Handler/Create.php +++ b/src/Handler/Create.php @@ -5,9 +5,13 @@ namespace Tobyz\JsonApiServer\Handler; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface; -use Tobyz\JsonApiServer\Api; +use function Tobyz\JsonApiServer\evaluate; +use function Tobyz\JsonApiServer\has_value; +use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\ResourceType; +use function Tobyz\JsonApiServer\run_callbacks; +use function Tobyz\JsonApiServer\set_value; class Create implements RequestHandlerInterface { @@ -16,7 +20,7 @@ class Create implements RequestHandlerInterface private $api; private $resource; - public function __construct(Api $api, ResourceType $resource) + public function __construct(JsonApi $api, ResourceType $resource) { $this->api = $api; $this->resource = $resource; @@ -26,44 +30,43 @@ class Create implements RequestHandlerInterface { $schema = $this->resource->getSchema(); - if (! ($schema->isCreatable)($request)) { - throw new ForbiddenException('You cannot create this resource'); + if (! evaluate($schema->getCreatable(), [$request])) { + throw new ForbiddenException; } - $model = $schema->createModel ? ($schema->createModel)($request) : $this->resource->getAdapter()->create(); - + $model = $this->createModel($request); $data = $this->parseData($request->getParsedBody()); - $adapter = $this->resource->getAdapter(); - - $this->assertFieldsExist($data); - - $this->assertFieldsWritable($data, $model, $request); - + $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->applyValues($data, $model, $request); + run_callbacks($schema->getListeners('creating'), [$request, $model]); - foreach ($schema->creatingCallbacks as $callback) { - $callback($request, $model); - } + $this->save($data, $model, $request); - $this->saveModel($model, $request); - - $this->saveFields($data, $model, $request); - - $this->runSavedCallbacks($data, $model, $request); - - foreach ($schema->createdCallbacks as $callback) { - $callback($request, $model); - } + run_callbacks($schema->getListeners('created'), [$request, $model]); return (new Show($this->api, $this->resource, $model)) ->handle($request) ->withStatus(201); } + + private function createModel(Request $request) + { + $creator = $this->resource->getSchema()->getCreator(); + + return $creator ? $creator($request) : $this->resource->getAdapter()->create(); + } + + private function fillDefaultValues(array &$data, Request $request) + { + foreach ($this->resource->getSchema()->getFields() as $field) { + if (! has_value($data, $field) && ($default = $field->getDefault())) { + set_value($data, $field, $default($request)); + } + } + } } diff --git a/src/Handler/Delete.php b/src/Handler/Delete.php index bd7aae4..7a782ce 100644 --- a/src/Handler/Delete.php +++ b/src/Handler/Delete.php @@ -5,8 +5,10 @@ namespace Tobyz\JsonApiServer\Handler; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface; +use function Tobyz\JsonApiServer\evaluate; use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\ResourceType; +use function Tobyz\JsonApiServer\run_callbacks; use Zend\Diactoros\Response\EmptyResponse; class Delete implements RequestHandlerInterface @@ -24,19 +26,15 @@ class Delete implements RequestHandlerInterface { $schema = $this->resource->getSchema(); - if (! ($schema->isDeletable)($request, $this->model)) { - throw new ForbiddenException('You cannot delete this resource'); + if (! evaluate($schema->getDeletable(), [$request, $this->model])) { + throw new ForbiddenException; } - foreach ($schema->deletingCallbacks as $callback) { - $callback($request, $this->model); - } + run_callbacks($schema->getListeners('deleting'), [$request, $this->model]); $this->resource->getAdapter()->delete($this->model); - foreach ($schema->deletedCallbacks as $callback) { - $callback($request, $this->model); - } + run_callbacks($schema->getListeners('deleted'), [$request, $this->model]); return new EmptyResponse; } diff --git a/src/Handler/Index.php b/src/Handler/Index.php index 864f0a7..71a5723 100644 --- a/src/Handler/Index.php +++ b/src/Handler/Index.php @@ -2,18 +2,24 @@ namespace Tobyz\JsonApiServer\Handler; -use JsonApiPhp\JsonApi; -use JsonApiPhp\JsonApi\Link; +use Closure; +use JsonApiPhp\JsonApi as Structure; +use JsonApiPhp\JsonApi\Link\LastLink; +use JsonApiPhp\JsonApi\Link\NextLink; +use JsonApiPhp\JsonApi\Link\PrevLink; use JsonApiPhp\JsonApi\Meta; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface; -use Tobyz\JsonApiServer\Api; +use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\JsonApiResponse; use Tobyz\JsonApiServer\ResourceType; -use Tobyz\JsonApiServer\Schema; +use Tobyz\JsonApiServer\Schema\Attribute; +use Tobyz\JsonApiServer\Schema\HasMany; +use Tobyz\JsonApiServer\Schema\HasOne; +use Tobyz\JsonApiServer\Schema\Type; use Tobyz\JsonApiServer\Serializer; class Index implements RequestHandlerInterface @@ -23,7 +29,7 @@ class Index implements RequestHandlerInterface private $api; private $resource; - public function __construct(Api $api, ResourceType $resource) + public function __construct(JsonApi $api, ResourceType $resource) { $this->api = $api; $this->resource = $resource; @@ -38,13 +44,9 @@ class Index implements RequestHandlerInterface $adapter = $this->resource->getAdapter(); $schema = $this->resource->getSchema(); - if (! ($schema->isVisible)($request)) { - throw new ForbiddenException('You cannot view this resource'); - } - $query = $adapter->query(); - foreach ($schema->scopes as $scope) { + foreach ($schema->getScopes() as $scope) { $request = $scope($request, $query) ?: $request; } @@ -58,13 +60,13 @@ class Index implements RequestHandlerInterface $paginationLinks = []; $members = [ - new Link\SelfLink($this->buildUrl($request)), - new Meta('offset', $offset), - new Meta('limit', $limit), + new Structure\Link\SelfLink($this->buildUrl($request)), + new Structure\Meta('offset', $offset), + new Structure\Meta('limit', $limit), ]; if ($offset > 0) { - $paginationLinks[] = new Link\FirstLink($this->buildUrl($request, ['page' => ['offset' => 0]])); + $paginationLinks[] = new Structure\Link\FirstLink($this->buildUrl($request, ['page' => ['offset' => 0]])); $prevOffset = $offset - $limit; @@ -74,16 +76,16 @@ class Index implements RequestHandlerInterface $params = ['page' => ['offset' => max(0, $prevOffset)]]; } - $paginationLinks[] = new Link\PrevLink($this->buildUrl($request, $params)); + $paginationLinks[] = new PrevLink($this->buildUrl($request, $params)); } - if ($schema->countable && $schema->paginate) { + if ($schema->isCountable() && $schema->getPaginate()) { $total = $adapter->count($query); $members[] = new Meta('total', $total); if ($offset + $limit < $total) { - $paginationLinks[] = new Link\LastLink($this->buildUrl($request, ['page' => ['offset' => floor(($total - 1) / $limit) * $limit]])); + $paginationLinks[] = new LastLink($this->buildUrl($request, ['page' => ['offset' => floor(($total - 1) / $limit) * $limit]])); } } @@ -96,7 +98,7 @@ class Index implements RequestHandlerInterface $models = $adapter->get($query); if ((count($models) && $total === null) || $offset + $limit < $total) { - $paginationLinks[] = new Link\NextLink($this->buildUrl($request, ['page' => ['offset' => $offset + $limit]])); + $paginationLinks[] = new NextLink($this->buildUrl($request, ['page' => ['offset' => $offset + $limit]])); } $this->loadRelationships($models, $include, $request); @@ -108,12 +110,12 @@ class Index implements RequestHandlerInterface } return new JsonApiResponse( - new JsonApi\CompoundDocument( - new JsonApi\PaginatedCollection( - new JsonApi\Pagination(...$paginationLinks), - new JsonApi\ResourceCollection(...$serializer->primary()) + new Structure\CompoundDocument( + new Structure\PaginatedCollection( + new Structure\Pagination(...$paginationLinks), + new Structure\ResourceCollection(...$serializer->primary()) ), - new JsonApi\Included(...$serializer->included()), + new Structure\Included(...$serializer->included()), ...$members ) ); @@ -149,7 +151,7 @@ class Index implements RequestHandlerInterface $queryParams = $request->getQueryParams(); - $limit = $this->resource->getSchema()->paginate; + $limit = $this->resource->getSchema()->getPaginate(); if (isset($queryParams['page']['limit'])) { $limit = $queryParams['page']['limit']; @@ -158,7 +160,7 @@ class Index implements RequestHandlerInterface throw new BadRequestException('page[limit] must be a positive integer', 'page[limit]'); } - $limit = min($this->resource->getSchema()->limit, $limit); + $limit = min($this->resource->getSchema()->getLimit(), $limit); } $offset = 0; @@ -175,15 +177,16 @@ class Index implements RequestHandlerInterface ->withAttribute('jsonApiLimit', $limit) ->withAttribute('jsonApiOffset', $offset); - $sort = $queryParams['sort'] ?? $this->resource->getSchema()->defaultSort; + $sort = $queryParams['sort'] ?? $this->resource->getSchema()->getDefaultSort(); if ($sort) { $sort = $this->parseSort($sort); + $fields = $schema->getFields(); foreach ($sort as $name => $direction) { - if (! isset($schema->fields[$name]) - || ! $schema->fields[$name] instanceof Schema\Attribute - || ! $schema->fields[$name]->sortable + if (! isset($fields[$name]) + || ! $fields[$name] instanceof Attribute + || ! $fields[$name]->getSortable() ) { throw new BadRequestException("Invalid sort field [$name]", 'sort'); } @@ -199,8 +202,10 @@ class Index implements RequestHandlerInterface throw new BadRequestException('filter must be an array', 'filter'); } + $fields = $schema->getFields(); + foreach ($filter as $name => $value) { - if ($name !== 'id' && (! isset($schema->fields[$name]) || ! $schema->fields[$name]->filterable)) { + if ($name !== 'id' && (! isset($fields[$name]) || ! $fields[$name]->getFilterable())) { throw new BadRequestException("Invalid filter [$name]", "filter[$name]"); } } @@ -217,10 +222,10 @@ class Index implements RequestHandlerInterface $adapter = $this->resource->getAdapter(); foreach ($sort as $name => $direction) { - $attribute = $schema->fields[$name]; + $attribute = $schema->getFields()[$name]; - if ($attribute->sorter) { - ($attribute->sorter)($request, $query, $direction); + if (($sorter = $attribute->getSortable()) instanceof Closure) { + $sorter($query, $direction, $request); } else { $adapter->sortByAttribute($query, $attribute, $direction); } @@ -267,16 +272,16 @@ class Index implements RequestHandlerInterface continue; } - $field = $schema->fields[$name]; + $field = $schema->getFields()[$name]; - if ($field->filter) { - ($field->filter)($query, $value, $request); - } elseif ($field instanceof Schema\Attribute) { + if (($filter = $field->getFilterable()) instanceof Closure) { + $filter($query, $value, $request); + } elseif ($field instanceof Attribute) { $adapter->filterByAttribute($query, $field, $value); - } elseif ($field instanceof Schema\HasOne) { + } elseif ($field instanceof HasOne) { $value = explode(',', $value); $adapter->filterByHasOne($query, $field, $value); - } elseif ($field instanceof Schema\HasMany) { + } elseif ($field instanceof HasMany) { $value = explode(',', $value); $adapter->filterByHasMany($query, $field, $value); } diff --git a/src/Handler/Show.php b/src/Handler/Show.php index 41d72a6..2dbcebd 100644 --- a/src/Handler/Show.php +++ b/src/Handler/Show.php @@ -2,12 +2,12 @@ namespace Tobyz\JsonApiServer\Handler; -use JsonApiPhp\JsonApi; +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 Tobyz\JsonApiServer\Api; -use Tobyz\JsonApiServer\Exception\ForbiddenException; +use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\JsonApiResponse; use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\Serializer; @@ -20,7 +20,7 @@ class Show implements RequestHandlerInterface private $resource; private $model; - public function __construct(Api $api, ResourceType $resource, $model) + public function __construct(JsonApi $api, ResourceType $resource, $model) { $this->api = $api; $this->resource = $resource; @@ -29,12 +29,6 @@ class Show implements RequestHandlerInterface public function handle(Request $request): Response { - $schema = $this->resource->getSchema(); - - if (! ($schema->isVisible)($request)) { - throw new ForbiddenException('You cannot view this resource'); - } - $include = $this->getInclude($request); $this->loadRelationships([$this->model], $include, $request); @@ -44,9 +38,9 @@ class Show implements RequestHandlerInterface $serializer->add($this->resource, $this->model, $include); return new JsonApiResponse( - new JsonApi\CompoundDocument( + new CompoundDocument( $serializer->primary()[0], - new JsonApi\Included(...$serializer->included()) + new Included(...$serializer->included()) ) ); } diff --git a/src/Handler/Update.php b/src/Handler/Update.php index 7a488c3..373c83e 100644 --- a/src/Handler/Update.php +++ b/src/Handler/Update.php @@ -5,9 +5,11 @@ namespace Tobyz\JsonApiServer\Handler; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface; -use Tobyz\JsonApiServer\Api; +use function Tobyz\JsonApiServer\evaluate; +use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\ResourceType; +use function Tobyz\JsonApiServer\run_callbacks; class Update implements RequestHandlerInterface { @@ -17,7 +19,7 @@ class Update implements RequestHandlerInterface private $resource; private $model; - public function __construct(Api $api, ResourceType $resource, $model) + public function __construct(JsonApi $api, ResourceType $resource, $model) { $this->api = $api; $this->resource = $resource; @@ -28,19 +30,22 @@ class Update implements RequestHandlerInterface { $schema = $this->resource->getSchema(); - if (! ($schema->isUpdatable)($request, $this->model)) { - throw new ForbiddenException('You cannot update this resource'); + if (! evaluate($schema->getUpdatable(), [$request, $this->model])) { + throw new ForbiddenException; } - foreach ($schema->updatingCallbacks as $callback) { - $callback($request, $this->model); - } + $data = $this->parseData($request->getParsedBody()); - $this->save($this->model, $request); + $this->validateFields($data, $this->model, $request); + $this->loadRelatedResources($data, $request); + $this->assertDataValid($data, $this->model, $request, false); + $this->setValues($data, $this->model, $request); - foreach ($schema->updatedCallbacks as $callback) { - $callback($request, $this->model); - } + run_callbacks($schema->getListeners('updating'), [$request, $this->model]); + + $this->save($data, $this->model, $request); + + run_callbacks($schema->getListeners('updated'), [$request, $this->model]); return (new Show($this->api, $this->resource, $this->model))->handle($request); } diff --git a/src/Http/MediaTypes.php b/src/Http/MediaTypes.php new file mode 100644 index 0000000..a9cceef --- /dev/null +++ b/src/Http/MediaTypes.php @@ -0,0 +1,56 @@ +value = $value; + } + + /** + * Determine whether the list contains the given type without modifications + * + * This is meant to ease implementation of JSON:API rules for content + * negotiation, which demand HTTP error responses e.g. when all of the + * JSON:API media types in the "Accept" header are modified with "media type + * parameters". Therefore, this method only returns true when the requested + * media type is contained without additional parameters (except for the + * weight parameter "q" and "Accept extension parameters"). + * + * @param string $mediaType + * @return bool + */ + public function containsExactly(string $mediaType): bool + { + $types = array_map('trim', explode(',', $this->value)); + + // Accept headers can contain multiple media types, so we need to check + // whether any of them matches. + foreach ($types as $type) { + $parts = array_map('trim', explode(';', $type)); + + // The actual media type needs to be an exact match + if (array_shift($parts) !== $mediaType) { + continue; + } + + // The media type can optionally be followed by "media type + // parameters". Parameters after the "q" parameter are considered + // "Accept extension parameters", which we don't care about. Thus, + // we have an exact match if there are no parameters at all or if + // the first one is named "q". + // See https://tools.ietf.org/html/rfc7231#section-5.3.2. + if (empty($parts) || substr($parts[0], 0, 2) === 'q=') { + return true; + } + + continue; + } + + return false; + } +} diff --git a/src/JsonApi.php b/src/JsonApi.php new file mode 100644 index 0000000..81481af --- /dev/null +++ b/src/JsonApi.php @@ -0,0 +1,202 @@ +baseUrl = $baseUrl; + } + + public function resource(string $type, $adapter, Closure $buildSchema = null): void + { + $this->resources[$type] = new ResourceType($type, $adapter, $buildSchema); + } + + public function getResources(): array + { + return $this->resources; + } + + public function getResource(string $type): ResourceType + { + if (! isset($this->resources[$type])) { + throw new ResourceNotFoundException($type); + } + + return $this->resources[$type]; + } + + public function handle(Request $request): Response + { + $this->validateRequest($request); + + $path = $this->stripBasePath( + $request->getUri()->getPath() + ); + + $segments = explode('/', trim($path, '/')); + + switch (count($segments)) { + case 1: + return $this->handleWithHandler( + $request, + $this->getCollectionHandler($request, $segments) + ); + + case 2: + return $this->handleWithHandler( + $request, + $this->getMemberHandler($request, $segments) + ); + + case 3: + // return $this->handleRelated($request, $resource, $model, $segments[2]); + throw new NotImplementedException; + + case 4: + if ($segments[2] === 'relationships') { + // return $this->handleRelationship($request, $resource, $model, $segments[3]); + throw new NotImplementedException; + } + } + + throw new BadRequestException; + } + + private function validateRequest(Request $request): void + { + $this->validateRequestContentType($request); + $this->validateRequestAccepts($request); + } + + private function validateRequestContentType(Request $request): void + { + $header = $request->getHeaderLine('Content-Type'); + + if (empty($header)) { + return; + } + + if ((new MediaTypes($header))->containsExactly(self::CONTENT_TYPE)) { + return; + } + + throw new UnsupportedMediaTypeException; + } + + private function validateRequestAccepts(Request $request): void + { + $header = $request->getHeaderLine('Accept'); + + if (empty($header)) { + return; + } + + if ((new MediaTypes($header))->containsExactly(self::CONTENT_TYPE)) { + return; + } + + throw new NotAcceptableException; + } + + private function stripBasePath(string $path): string + { + $basePath = parse_url($this->baseUrl, PHP_URL_PATH); + + $len = strlen($basePath); + + if (substr($path, 0, $len) === $basePath) { + $path = substr($path, $len + 1); + } + + return $path; + } + + private function getCollectionHandler(Request $request, array $segments): RequestHandlerInterface + { + $resource = $this->getResource($segments[0]); + + switch ($request->getMethod()) { + case 'GET': + return new Handler\Index($this, $resource); + + case 'POST': + return new Handler\Create($this, $resource); + + default: + throw new MethodNotAllowedException; + } + } + + private function getMemberHandler(Request $request, array $segments): RequestHandlerInterface + { + $resource = $this->getResource($segments[0]); + $model = $this->findResource($request, $resource, $segments[1]); + + switch ($request->getMethod()) { + case 'PATCH': + return new Handler\Update($this, $resource, $model); + + case 'GET': + return new Handler\Show($this, $resource, $model); + + case 'DELETE': + return new Handler\Delete($resource, $model); + + default: + throw new MethodNotAllowedException; + } + } + + private function handleWithHandler(Request $request, RequestHandlerInterface $handler) + { + $request = $request->withAttribute('jsonApiHandler', $handler); + + return $handler->handle($request); + } + + public function error($e) + { + if (! $e instanceof ErrorProviderInterface) { + $e = new InternalServerErrorException; + } + + $errors = $e->getJsonApiErrors(); + $status = $e->getJsonApiStatus(); + + $data = new ErrorDocument( + ...$errors + ); + + return new JsonApiResponse($data, $status); + } + + public function getBaseUrl(): string + { + return $this->baseUrl; + } +} diff --git a/src/JsonApiResponse.php b/src/JsonApiResponse.php index 35d1131..62e1814 100644 --- a/src/JsonApiResponse.php +++ b/src/JsonApiResponse.php @@ -12,7 +12,7 @@ class JsonApiResponse extends JsonResponse array $headers = [], $encodingOptions = self::DEFAULT_JSON_FLAGS ) { - $headers['content-type'] = 'application/vnd.api+json'; + $headers['content-type'] = JsonApi::CONTENT_TYPE; parent::__construct($data, $status, $headers, $encodingOptions); } diff --git a/src/ResourceType.php b/src/ResourceType.php index b7b631b..09f403d 100644 --- a/src/ResourceType.php +++ b/src/ResourceType.php @@ -4,14 +4,14 @@ namespace Tobyz\JsonApiServer; use Closure; use Tobyz\JsonApiServer\Adapter\AdapterInterface; -use Tobyz\JsonApiServer\Schema\Builder; +use Tobyz\JsonApiServer\Schema\Type; -class ResourceType +final class ResourceType { - protected $type; - protected $adapter; - protected $buildSchema; - protected $schema; + private $type; + private $adapter; + private $buildSchema; + private $schema; public function __construct(string $type, AdapterInterface $adapter, Closure $buildSchema = null) { @@ -30,10 +30,10 @@ class ResourceType return $this->adapter; } - public function getSchema(): Builder + public function getSchema(): Type { if (! $this->schema) { - $this->schema = new Builder; + $this->schema = new Type; if ($this->buildSchema) { ($this->buildSchema)($this->schema); diff --git a/src/Schema/Attribute.php b/src/Schema/Attribute.php index a5bbab4..c1608d9 100644 --- a/src/Schema/Attribute.php +++ b/src/Schema/Attribute.php @@ -4,17 +4,31 @@ namespace Tobyz\JsonApiServer\Schema; use Closure; -class Attribute extends Field +final class Attribute extends Field { - public $location = 'attributes'; - public $sortable = false; - public $sorter; + private $sortable = false; public function sortable(Closure $callback = null) { - $this->sortable = true; - $this->sorter = $callback; + $this->sortable = $callback ?: true; return $this; } + + public function notSortable() + { + $this->sortable = false; + + return $this; + } + + public function getSortable() + { + return $this->sortable; + } + + public function getLocation(): string + { + return 'attributes'; + } } diff --git a/src/Schema/Builder.php b/src/Schema/Builder.php deleted file mode 100644 index e6273ea..0000000 --- a/src/Schema/Builder.php +++ /dev/null @@ -1,241 +0,0 @@ -visible(); - $this->notCreatable(); - $this->notUpdatable(); - $this->notDeletable(); - } - - public function attribute(string $name, string $property = null): Attribute - { - return $this->field(Attribute::class, $name, $property); - } - - public function hasOne(string $name, $resource = null, string $property = null): HasOne - { - $field = $this->field(HasOne::class, $name, $property); - - if ($resource) { - $field->resource($resource); - } - - return $field; - } - - public function morphOne(string $name, string $property = null): HasOne - { - return $this->field(HasOne::class, $name, $property)->resource(null); - } - - public function hasMany(string $name, $resource = null, string $property = null): HasMany - { - $field = $this->field(HasMany::class, $name, $property); - - if ($resource) { - $field->resource($resource); - } - - return $field; - } - - public function meta(string $name, $value) - { - return $this->meta[$name] = new Meta($name, $value); - } - - public function paginate(?int $perPage) - { - $this->paginate = $perPage; - } - - public function limit(?int $limit) - { - $this->limit = $limit; - } - - public function countable() - { - $this->countable = true; - } - - public function uncountable() - { - $this->countable = false; - } - - public function createModel(Closure $callback) - { - $this->createModel = $callback; - } - - public function scope(Closure $callback) - { - $this->scopes[] = $callback; - } - - public function creatableIf(Closure $condition) - { - $this->isCreatable = $condition; - - return $this; - } - - public function creatable() - { - return $this->creatableIf(function () { - return true; - }); - } - - public function notCreatableIf(Closure $condition) - { - return $this->creatableIf(function (...$args) use ($condition) { - return ! $condition(...$args); - }); - } - - public function notCreatable() - { - return $this->notCreatableIf(function () { - return true; - }); - } - - public function creating(Closure $callback) - { - $this->creatingCallbacks[] = $callback; - } - - public function created(Closure $callback) - { - $this->createdCallbacks[] = $callback; - } - - public function updatableIf(Closure $condition) - { - $this->isUpdatable = $condition; - - return $this; - } - - public function updatable() - { - return $this->updatableIf(function () { - return true; - }); - } - - public function notUpdatableIf(Closure $condition) - { - return $this->updatableIf(function (...$args) use ($condition) { - return ! $condition(...$args); - }); - } - - public function notUpdatable() - { - return $this->notUpdatableIf(function () { - return true; - }); - } - - public function updating(Closure $callback) - { - $this->updatingCallbacks[] = $callback; - } - - public function updated(Closure $callback) - { - $this->updatedCallbacks[] = $callback; - } - - public function save(Closure $callback) - { - $this->saver = $callback; - - return $this; - } - - public function deletableIf(Closure $condition) - { - $this->isDeletable = $condition; - - return $this; - } - - public function deletable() - { - return $this->deletableIf(function () { - return true; - }); - } - - public function notDeletableIf(Closure $condition) - { - return $this->deletableIf(function (...$args) use ($condition) { - return ! $condition(...$args); - }); - } - - public function notDeletable() - { - return $this->notDeletableIf(function () { - return true; - }); - } - - public function deleting(Closure $callback) - { - $this->deletingCallbacks[] = $callback; - } - - public function deleted(Closure $callback) - { - $this->deletedCallbacks[] = $callback; - } - - public function defaultSort(string $sort) - { - $this->defaultSort = $sort; - } - - private function field(string $class, string $name, string $property = null) - { - if (! isset($this->fields[$name]) || ! $this->fields[$name] instanceof $class) { - $this->fields[$name] = new $class($name); - } - - if ($property) { - $this->fields[$name]->property($property); - } - - return $this->fields[$name]; - } -} diff --git a/src/Schema/Concerns/HasListeners.php b/src/Schema/Concerns/HasListeners.php new file mode 100644 index 0000000..82e25b8 --- /dev/null +++ b/src/Schema/Concerns/HasListeners.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobyz\JsonApiServer\Schema\Concerns; + +trait HasListeners +{ + private $listeners = []; + + public function getListeners(string $event) + { + return $this->listeners[$event] ?? []; + } +} diff --git a/src/Schema/Field.php b/src/Schema/Field.php index 90b832d..aacb63c 100644 --- a/src/Schema/Field.php +++ b/src/Schema/Field.php @@ -3,30 +3,31 @@ namespace Tobyz\JsonApiServer\Schema; use Closure; +use function Tobyz\JsonApiServer\negate; +use Tobyz\JsonApiServer\Schema\Concerns\HasListeners; +use function Tobyz\JsonApiServer\wrap; abstract class Field { - public $name; - public $property; - public $isVisible; - public $isWritable; - public $getter; - public $setter; - public $saver; - public $savedCallbacks = []; - public $default; - public $validators = []; - public $filterable = false; - public $filter; + use HasListeners; + + private $name; + private $property; + private $visible = true; + private $writable = false; + private $getter; + private $setter; + private $saver; + private $default; + private $filterable = false; public function __construct(string $name) { $this->name = $name; - - $this->visible(); - $this->readonly(); } + abstract public function getLocation(): string; + public function property(string $property) { $this->property = $property; @@ -34,65 +35,37 @@ abstract class Field return $this; } - public function visibleIf(Closure $condition) + public function visible(Closure $condition = null) { - $this->isVisible = $condition; + $this->visible = $condition ?: true; return $this; } - public function visible() + public function hidden(Closure $condition = null) { - return $this->visibleIf(function () { - return true; - }); - } - - public function hiddenIf(Closure $condition) - { - return $this->visibleIf(function (...$args) use ($condition) { - return ! $condition(...$args); - }); - } - - public function hidden() - { - return $this->hiddenIf(function () { - return true; - }); - } - - public function writableIf(Closure $condition) - { - $this->isWritable = $condition; + $this->visible = $condition ? negate($condition) : false; return $this; } - public function writable() + public function writable(Closure $condition = null) { - return $this->writableIf(function () { - return true; - }); + $this->writable = $condition ?: true; + + return $this; } - public function readonlyIf(Closure $condition) + public function readonly(Closure $condition = null) { - return $this->writableIf(function (...$args) use ($condition) { - return ! $condition(...$args); - }); + $this->writable = $condition ? negate($condition) : false; + + return $this; } - public function readonly() + public function get($value) { - return $this->readonlyIf(function () { - return true; - }); - } - - public function get($callback) - { - $this->getter = $this->wrap($callback); + $this->getter = wrap($value); return $this; } @@ -113,41 +86,86 @@ abstract class Field public function saved(Closure $callback) { - $this->savedCallbacks[] = $callback; + $this->listeners['saved'][] = $callback; return $this; } public function default($value) { - $this->default = $this->wrap($value); + $this->default = wrap($value); return $this; } public function validate(Closure $callback) { - $this->validators[] = $callback; + $this->listeners['validate'][] = $callback; return $this; } public function filterable(Closure $callback = null) { - $this->filterable = true; - $this->filter = $callback; + $this->filterable = $callback ?: true; return $this; } - protected function wrap($value) + public function notFilterable() { - if (! $value instanceof Closure) { - $value = function () use ($value) { - return $value; - }; - } + $this->filterable = false; - return $value; + return $this; } + + public function getName(): string + { + return $this->name; + } + + public function getProperty() + { + return $this->property; + } + + /** + * @return bool|Closure + */ + public function getVisible() + { + return $this->visible; + } + + public function getWritable() + { + return $this->writable; + } + + public function getGetter() + { + return $this->getter; + } + + public function getSetter() + { + return $this->setter; + } + + public function getSaver() + { + return $this->saver; + } + + public function getDefault() + { + return $this->default; + } + + public function getFilterable() + { + return $this->filterable; + } + + } diff --git a/src/Schema/HasMany.php b/src/Schema/HasMany.php index c5d2956..32fb2ff 100644 --- a/src/Schema/HasMany.php +++ b/src/Schema/HasMany.php @@ -2,14 +2,12 @@ namespace Tobyz\JsonApiServer\Schema; -class HasMany extends Relationship +final class HasMany extends Relationship { - public $includable = false; - public function __construct(string $name) { parent::__construct($name); - $this->resource = $name; + $this->type($name); } } diff --git a/src/Schema/HasOne.php b/src/Schema/HasOne.php index 7624c05..b111c95 100644 --- a/src/Schema/HasOne.php +++ b/src/Schema/HasOne.php @@ -4,12 +4,12 @@ namespace Tobyz\JsonApiServer\Schema; use Doctrine\Common\Inflector\Inflector; -class HasOne extends Relationship +final class HasOne extends Relationship { public function __construct(string $name) { parent::__construct($name); - $this->resource = Inflector::pluralize($name); + $this->type(Inflector::pluralize($name)); } } diff --git a/src/Schema/Meta.php b/src/Schema/Meta.php index 271944a..153fa3f 100644 --- a/src/Schema/Meta.php +++ b/src/Schema/Meta.php @@ -3,26 +3,26 @@ namespace Tobyz\JsonApiServer\Schema; use Closure; +use function Tobyz\JsonApiServer\wrap; -class Meta +final class Meta { - public $name; - public $value; + private $name; + private $value; public function __construct(string $name, $value) { $this->name = $name; - $this->value = $this->wrap($value); + $this->value = wrap($value); } - private function wrap($value) + public function getName(): string { - if (! $value instanceof Closure) { - $value = function () use ($value) { - return $value; - }; - } + return $this->name; + } - return $value; + public function getValue(): Closure + { + return $this->value; } } diff --git a/src/Schema/Relationship.php b/src/Schema/Relationship.php index 2491c9b..0fc9625 100644 --- a/src/Schema/Relationship.php +++ b/src/Schema/Relationship.php @@ -3,57 +3,47 @@ namespace Tobyz\JsonApiServer\Schema; use Closure; -use Tobyz\JsonApiServer\Handler\Show; +use function Tobyz\JsonApiServer\negate; abstract class Relationship extends Field { - public $location = 'relationships'; - public $linkage; - public $hasLinks = true; - public $loadable = true; - public $loader; - public $included = false; - public $includable = true; - public $resource; + private $type; + private $linkage = false; + private $links = true; + private $loadable = true; + private $includable = false; - public function __construct(string $name) + public function type($type) { - parent::__construct($name); - - $this->noLinkage(); - } - - public function resource($resource) - { - $this->resource = $resource; + $this->type = $type; return $this; } - public function linkageIf(Closure $condition) + public function polymorphic() { - $this->linkage = $condition; + $this->type = null; return $this; } - public function linkage() + public function linkage(Closure $condition = null) { - return $this->linkageIf(function () { - return true; - }); + $this->linkage = $condition ?: true; + + return $this; } - public function noLinkage() + public function noLinkage(Closure $condition = null) { - return $this->linkageIf(function () { - return false; - }); + $this->linkage = $condition ? negate($condition) : false; + + return $this; } - public function loadable() + public function loadable(Closure $callback = null) { - $this->loadable = true; + $this->loadable = $callback ?: true; return $this; } @@ -65,13 +55,6 @@ abstract class Relationship extends Field return $this; } - public function load(Closure $callback) - { - $this->loader = $callback; - - return $this; - } - public function includable() { $this->includable = true; @@ -86,10 +69,47 @@ abstract class Relationship extends Field return $this; } - public function noLinks() + public function getType() { - $this->hasLinks = false; + return $this->type; + } + + public function links() + { + $this->links = true; return $this; } + + public function noLinks() + { + $this->links = false; + + return $this; + } + + public function getLinkage() + { + return $this->linkage; + } + + public function hasLinks(): bool + { + return $this->links; + } + + public function getLoadable() + { + return $this->loadable; + } + + public function isIncludable(): bool + { + return $this->includable; + } + + public function getLocation(): string + { + return 'relationships'; + } } diff --git a/src/Schema/Type.php b/src/Schema/Type.php new file mode 100644 index 0000000..e12eb0f --- /dev/null +++ b/src/Schema/Type.php @@ -0,0 +1,282 @@ +field(Attribute::class, $name); + } + + public function hasOne(string $name): HasOne + { + return $this->field(HasOne::class, $name); + } + + public function hasMany(string $name): HasMany + { + return $this->field(HasMany::class, $name); + } + + private function field(string $class, string $name) + { + if (! isset($this->fields[$name]) || ! $this->fields[$name] instanceof $class) { + $this->fields[$name] = new $class($name); + } + + return $this->fields[$name]; + } + + public function removeField(string $name) + { + unset($this->fields[$name]); + + return $this; + } + + /** + * @return Field[] + */ + public function getFields(): array + { + return $this->fields; + } + + public function meta(string $name, $value) + { + return $this->meta[$name] = new Meta($name, $value); + } + + public function removeMeta(string $name) + { + unset($this->meta[$name]); + + return $this; + } + + public function getMeta(): array + { + return $this->meta; + } + + public function paginate(int $perPage) + { + $this->paginate = $perPage; + } + + public function dontPaginate() + { + $this->paginate = null; + } + + public function getPaginate(): int + { + return $this->paginate; + } + + public function limit(int $limit) + { + $this->limit = $limit; + } + + public function noLimit() + { + $this->limit = null; + } + + public function getLimit(): int + { + return $this->limit; + } + + public function countable() + { + $this->countable = true; + } + + public function uncountable() + { + $this->countable = false; + } + + public function isCountable(): bool + { + return $this->countable; + } + + public function scope(Closure $callback) + { + $this->scopes[] = $callback; + } + + public function getScopes(): array + { + return $this->scopes; + } + + public function create(?Closure $callback) + { + $this->create = $callback; + } + + public function getCreator() + { + return $this->create; + } + + public function creatable(Closure $condition = null) + { + $this->creatable = $condition ?: true; + + return $this; + } + + public function notCreatable(Closure $condition = null) + { + $this->creatable = $condition ? negate($condition) : false; + + return $this; + } + + public function getCreatable() + { + return $this->creatable; + } + + public function creating(Closure $callback) + { + $this->listeners['creating'][] = $callback; + + return $this; + } + + public function created(Closure $callback) + { + $this->listeners['created'][] = $callback; + + return $this; + } + + public function updatable(Closure $condition = null) + { + $this->updatable = $condition ?: true; + + return $this; + } + + public function notUpdatable(Closure $condition = null) + { + $this->updatable = $condition ? negate($condition) : false; + + return $this; + } + + public function getUpdatable() + { + return $this->updatable; + } + + public function updating(Closure $callback) + { + $this->listeners['updating'][] = $callback; + + return $this; + } + + public function updated(Closure $callback) + { + $this->listeners['updated'][] = $callback; + + return $this; + } + + public function save(?Closure $callback) + { + $this->saver = $callback; + + return $this; + } + + public function getSaver() + { + return $this->saver; + } + + public function deletable(Closure $condition = null) + { + $this->deletable = $condition ?: true; + + return $this; + } + + public function notDeletable(Closure $condition = null) + { + $this->deletable = $condition ? negate($condition) : false; + + return $this; + } + + public function getDeletable() + { + return $this->deletable; + } + + public function delete(?Closure $callback) + { + $this->delete = $callback; + + return $this; + } + + public function getDelete() + { + return $this->delete; + } + + public function deleting(Closure $callback) + { + $this->listeners['deleting'][] = $callback; + + return $this; + } + + public function deleted(Closure $callback) + { + $this->listeners['deleted'][] = $callback; + + return $this; + } + + public function defaultSort(?string $sort) + { + $this->defaultSort = $sort; + + return $this; + } + + public function getDefaultSort() + { + return $this->defaultSort; + } +} diff --git a/src/Serializer.php b/src/Serializer.php index 5e69b18..dd2512d 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -4,9 +4,18 @@ namespace Tobyz\JsonApiServer; use DateTime; use DateTimeInterface; -use JsonApiPhp\JsonApi; +use JsonApiPhp\JsonApi as Structure; +use JsonApiPhp\JsonApi\EmptyRelationship; +use JsonApiPhp\JsonApi\Link\RelatedLink; +use JsonApiPhp\JsonApi\Link\SelfLink; +use JsonApiPhp\JsonApi\ResourceIdentifier; +use JsonApiPhp\JsonApi\ResourceIdentifierCollection; +use JsonApiPhp\JsonApi\ToMany; +use JsonApiPhp\JsonApi\ToOne; use Psr\Http\Message\ServerRequestInterface as Request; use Tobyz\JsonApiServer\Adapter\AdapterInterface; +use Tobyz\JsonApiServer\Schema\Attribute; +use Tobyz\JsonApiServer\Schema\Relationship; class Serializer { @@ -15,7 +24,7 @@ class Serializer protected $map = []; protected $primary = []; - public function __construct(Api $api, Request $request) + public function __construct(JsonApi $api, Request $request) { $this->api = $api; $this->request = $request; @@ -43,7 +52,7 @@ class Serializer $resourceUrl = $this->api->getBaseUrl().'/'.$data['type'].'/'.$data['id']; - $fields = $schema->fields; + $fields = $schema->getFields(); $queryParams = $this->request->getQueryParams(); @@ -60,7 +69,7 @@ class Serializer continue; } - if (! ($field->isVisible)($this->request, $model)) { + if (! evaluate($field->getVisible(), [$model, $this->request])) { continue; } @@ -68,7 +77,7 @@ class Serializer $value = $this->attribute($field, $model, $adapter); } elseif ($field instanceof Schema\Relationship) { $isIncluded = isset($include[$name]); - $isLinkage = ($field->linkage)($this->request); + $isLinkage = evaluate($field->getLinkage(), [$this->request]); if (! $isIncluded && ! $isLinkage) { $value = $this->emptyRelationship($field, $resourceUrl); @@ -82,12 +91,14 @@ class Serializer $data['fields'][$name] = $value; } - $data['links']['self'] = new JsonApi\Link\SelfLink($resourceUrl); + $data['links']['self'] = new SelfLink($resourceUrl); - ksort($schema->meta); + $metas = $schema->getMeta(); - foreach ($schema->meta as $name => $meta) { - $data['meta'][$name] = new JsonApi\Meta($meta->name, ($meta->value)($this->request, $model)); + ksort($metas); + + foreach ($metas as $name => $meta) { + $data['meta'][$name] = new Structure\Meta($meta->name, ($meta->value)($this->request, $model)); } $this->merge($data); @@ -95,10 +106,10 @@ class Serializer return $data; } - private function attribute(Schema\Attribute $field, $model, AdapterInterface $adapter): JsonApi\Attribute + private function attribute(Attribute $field, $model, AdapterInterface $adapter): Structure\Attribute { - if ($field->getter) { - $value = ($field->getter)($this->request, $model); + if ($getter = $field->getGetter()) { + $value = $getter($model, $this->request); } else { $value = $adapter->getAttribute($model, $field); } @@ -107,18 +118,18 @@ class Serializer $value = $value->format(DateTime::RFC3339); } - return new JsonApi\Attribute($field->name, $value); + return new Structure\Attribute($field->getName(), $value); } private function toOne(Schema\HasOne $field, $model, AdapterInterface $adapter, bool $isIncluded, bool $isLinkage, array $include, string $resourceUrl) { $links = $this->getRelationshipLinks($field, $resourceUrl); - $value = $isIncluded ? ($field->getter ? ($field->getter)($this->request, $model) : $adapter->getHasOne($model, $field)) : ($isLinkage && $field->loadable ? $adapter->getHasOneId($model, $field) : null); + $value = $isIncluded ? (($getter = $field->getGetter()) ? $getter($model, $this->request) : $adapter->getHasOne($model, $field)) : ($isLinkage && $field->getLoadable() ? $adapter->getHasOneId($model, $field) : null); if (! $value) { - return new JsonApi\ToNull( - $field->name, + return new Structure\ToNull( + $field->getName(), ...$links ); } @@ -130,8 +141,8 @@ class Serializer } - return new JsonApi\ToOne( - $field->name, + return new ToOne( + $field->getName(), $identifier, ...$links ); @@ -139,8 +150,8 @@ class Serializer private function toMany(Schema\HasMany $field, $model, AdapterInterface $adapter, bool $isIncluded, bool $isLinkage, array $include, string $resourceUrl) { - if ($field->getter) { - $value = ($field->getter)($this->request, $model); + if ($getter = $field->getGetter()) { + $value = $getter($model, $this->request); } else { $value = ($isLinkage || $isIncluded) ? $adapter->getHasMany($model, $field) : null; } @@ -157,36 +168,36 @@ class Serializer } } - return new JsonApi\ToMany( - $field->name, - new JsonApi\ResourceIdentifierCollection(...$identifiers), + return new ToMany( + $field->getName(), + new ResourceIdentifierCollection(...$identifiers), ...$this->getRelationshipLinks($field, $resourceUrl) ); } - private function emptyRelationship(Schema\Relationship $field, string $resourceUrl): JsonApi\EmptyRelationship + private function emptyRelationship(Relationship $field, string $resourceUrl): EmptyRelationship { - return new JsonApi\EmptyRelationship( - $field->name, + return new EmptyRelationship( + $field->getName(), ...$this->getRelationshipLinks($field, $resourceUrl) ); } - private function getRelationshipLinks(Schema\Relationship $field, string $resourceUrl): array + private function getRelationshipLinks(Relationship $field, string $resourceUrl): array { - if (! $field->hasLinks) { + if (! $field->hasLinks()) { return []; } return [ - new JsonApi\Link\SelfLink($resourceUrl.'/relationships/'.$field->name), - new JsonApi\Link\RelatedLink($resourceUrl.'/'.$field->name) + new SelfLink($resourceUrl.'/relationships/'.$field->getName()), + new RelatedLink($resourceUrl.'/'.$field->getName()) ]; } - private function addRelated(Schema\Relationship $field, $model, array $include): JsonApi\ResourceIdentifier + private function addRelated(Relationship $field, $model, array $include): ResourceIdentifier { - $relatedResource = $field->resource ? $this->api->getResource($field->resource) : $this->resourceForModel($model); + $relatedResource = $field->getType() ? $this->api->getResource($field->getType()) : $this->resourceForModel($model); return $this->resourceIdentifier( $this->addToMap($relatedResource, $model, $include) @@ -238,9 +249,9 @@ class Serializer }, $items); } - private function resourceObject(array $data): JsonApi\ResourceObject + private function resourceObject(array $data): Structure\ResourceObject { - return new JsonApi\ResourceObject( + return new Structure\ResourceObject( $data['type'], $data['id'], ...array_values($data['fields']), @@ -249,9 +260,9 @@ class Serializer ); } - private function resourceIdentifier(array $data): JsonApi\ResourceIdentifier + private function resourceIdentifier(array $data): Structure\ResourceIdentifier { - return new JsonApi\ResourceIdentifier( + return new Structure\ResourceIdentifier( $data['type'], $data['id'] ); diff --git a/src/StatusProviderInterface.php b/src/StatusProviderInterface.php deleted file mode 100644 index f38bbe6..0000000 --- a/src/StatusProviderInterface.php +++ /dev/null @@ -1,8 +0,0 @@ -getLocation()][$field->getName()]); +} + +function &get_value(array $data, Field $field) +{ + return $data[$field->getLocation()][$field->getName()]; +} + +function set_value(array &$data, Field $field, $value) +{ + $data[$field->getLocation()][$field->getName()] = $value; +} + +function array_set(array $array, $key, $value) +{ + $keys = explode('.', $key); + + while (count($keys) > 1) { + $array = &$array[array_shift($keys)]; + } + + $array[array_shift($keys)] = $value; + + return $array; +} diff --git a/tests/AbstractTestCase.php b/tests/AbstractTestCase.php index ef13fcc..a4d4054 100644 --- a/tests/AbstractTestCase.php +++ b/tests/AbstractTestCase.php @@ -11,19 +11,19 @@ namespace Tobyz\Tests\JsonApiServer; +use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\ResponseInterface; use Zend\Diactoros\ServerRequest; use Zend\Diactoros\Uri; abstract class AbstractTestCase extends TestCase { - public static function assertEncodesTo(string $expected, $obj, string $message = '') + use ArraySubsetAsserts; + + protected function assertJsonApiDocumentSubset($subset, string $body, bool $checkForObjectIdentity = false, string $message = ''): void { - self::assertEquals( - json_decode($expected), - json_decode(json_encode($obj, JSON_UNESCAPED_SLASHES)), - $message - ); + static::assertArraySubset($subset, json_decode($body, true), $checkForObjectIdentity, $message); } protected function buildRequest(string $method, string $uri): ServerRequest diff --git a/tests/CreateTest.php b/tests/CreateTest.old.php similarity index 88% rename from tests/CreateTest.php rename to tests/CreateTest.old.php index 6fe6fd4..007bbeb 100644 --- a/tests/CreateTest.php +++ b/tests/CreateTest.old.php @@ -11,11 +11,11 @@ namespace Tobyz\Tests\JsonApiServer; -use Tobyz\JsonApiServer\Api; +use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Serializer; -use Tobyz\JsonApiServer\Schema\Builder; +use Tobyz\JsonApiServer\Schema\Type; use Psr\Http\Message\ServerRequestInterface as Request; use JsonApiPhp\JsonApi; use Zend\Diactoros\ServerRequest; @@ -25,9 +25,9 @@ class CreateTest extends AbstractTestCase { public function testResourceNotCreatableByDefault() { - $api = new Api('http://example.com'); + $api = new JsonApi('http://example.com'); - $api->resource('users', new MockAdapter(), function (Builder $schema) { + $api->resource('users', new MockAdapter(), function (Type $schema) { // }); @@ -41,9 +41,9 @@ class CreateTest extends AbstractTestCase public function testCreateResourceValidatesBody() { - $api = new Api('http://example.com'); + $api = new JsonApi('http://example.com'); - $api->resource('users', new MockAdapter(), function (Builder $schema) { + $api->resource('users', new MockAdapter(), function (Type $schema) { $schema->creatable(); }); @@ -56,9 +56,9 @@ class CreateTest extends AbstractTestCase public function testCreateResource() { - $api = new Api('http://example.com'); + $api = new JsonApi('http://example.com'); - $api->resource('users', $adapter = new MockAdapter(), function (Builder $schema) { + $api->resource('users', $adapter = new MockAdapter(), function (Type $schema) { $schema->creatable(); $schema->attribute('name')->writable(); @@ -110,9 +110,9 @@ class CreateTest extends AbstractTestCase ] ]); - $api = new Api('http://example.com'); + $api = new JsonApi('http://example.com'); - $api->resource('users', $adapter = new MockAdapter(), function (Builder $schema) use ($adapter, $request) { + $api->resource('users', $adapter = new MockAdapter(), function (Type $schema) use ($adapter, $request) { $schema->creatable(); $schema->attribute('writable1')->writable(); @@ -148,9 +148,9 @@ class CreateTest extends AbstractTestCase ] ]); - $api = new Api('http://example.com'); + $api = new JsonApi('http://example.com'); - $api->resource('users', $adapter = new MockAdapter(), function (Builder $schema) use ($adapter, $request) { + $api->resource('users', $adapter = new MockAdapter(), function (Type $schema) use ($adapter, $request) { $schema->creatable(); $schema->attribute('readonly')->readonly(); @@ -175,9 +175,9 @@ class CreateTest extends AbstractTestCase ] ]); - $api = new Api('http://example.com'); + $api = new JsonApi('http://example.com'); - $api->resource('users', $adapter = new MockAdapter(), function (Builder $schema) use ($request) { + $api->resource('users', $adapter = new MockAdapter(), function (Type $schema) use ($request) { $schema->creatable(); $schema->attribute('attribute1')->default('defaultValue'); diff --git a/tests/DeleteTest.php b/tests/DeleteTest.old.php similarity index 80% rename from tests/DeleteTest.php rename to tests/DeleteTest.old.php index 8ef4351..3f1574d 100644 --- a/tests/DeleteTest.php +++ b/tests/DeleteTest.old.php @@ -11,11 +11,11 @@ namespace Tobyz\Tests\JsonApiServer; -use Tobyz\JsonApiServer\Api; +use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Serializer; -use Tobyz\JsonApiServer\Schema\Builder; +use Tobyz\JsonApiServer\Schema\Type; use Psr\Http\Message\ServerRequestInterface as Request; use JsonApiPhp\JsonApi; use Zend\Diactoros\ServerRequest; @@ -25,9 +25,9 @@ class DeleteTest extends AbstractTestCase { public function testResourceNotDeletableByDefault() { - $api = new Api('http://example.com'); + $api = new JsonApi('http://example.com'); - $api->resource('users', new MockAdapter(), function (Builder $schema) { + $api->resource('users', new MockAdapter(), function (Type $schema) { // }); @@ -45,9 +45,9 @@ class DeleteTest extends AbstractTestCase '1' => $user = (object)['id' => '1'] ]); - $api = new Api('http://example.com'); + $api = new JsonApi('http://example.com'); - $api->resource('users', $usersAdapter, function (Builder $schema) { + $api->resource('users', $usersAdapter, function (Type $schema) { $schema->deletable(); }); diff --git a/tests/MockAdapter.php b/tests/MockAdapter.php index d687886..4500bfa 100644 --- a/tests/MockAdapter.php +++ b/tests/MockAdapter.php @@ -13,6 +13,7 @@ class MockAdapter implements AdapterInterface { public $models = []; public $createdModel; + public $query; private $type; public function __construct(array $models = [], string $type = null) @@ -28,10 +29,10 @@ class MockAdapter implements AdapterInterface public function query() { - return (object) []; + return $this->query = (object) []; } - public function find($query, $id) + public function find($query, string $id) { return $this->models[$id] ?? (object) ['id' => $id]; } @@ -61,17 +62,17 @@ class MockAdapter implements AdapterInterface return $model->{$this->getProperty($relationship)} ?? []; } - public function applyAttribute($model, Attribute $attribute, $value) + public function setAttribute($model, Attribute $attribute, $value): void { $model->{$this->getProperty($attribute)} = $value; } - public function applyHasOne($model, HasOne $relationship, $related) + public function setHasOne($model, HasOne $relationship, $related): void { $model->{$this->getProperty($relationship)} = $related; } - public function save($model) + public function save($model): void { $model->saveWasCalled = true; @@ -80,59 +81,54 @@ class MockAdapter implements AdapterInterface } } - public function saveHasMany($model, HasMany $relationship, array $related) + public function saveHasMany($model, HasMany $relationship, array $related): void { $model->saveHasManyWasCalled = true; } - public function delete($model) + public function delete($model): void { $model->deleteWasCalled = true; } - public function filterByIds($query, array $ids) + public function filterByIds($query, array $ids): void { - $query->filters[] = ['ids', $ids]; + $query->filter[] = ['ids', $ids]; } - public function filterByAttribute($query, Attribute $attribute, $value) + public function filterByAttribute($query, Attribute $attribute, $value): void { - $query->filters[] = [$attribute, $value]; + $query->filter[] = [$attribute, $value]; } - public function filterByHasOne($query, HasOne $relationship, array $ids) + public function filterByHasOne($query, HasOne $relationship, array $ids): void { - $query->filters[] = [$relationship, $ids]; + $query->filter[] = [$relationship, $ids]; } - public function filterByHasMany($query, HasMany $relationship, array $ids) + public function filterByHasMany($query, HasMany $relationship, array $ids): void { - $query->filters[] = [$relationship, $ids]; + $query->filter[] = [$relationship, $ids]; } - public function sortByAttribute($query, Attribute $attribute, string $direction) + public function sortByAttribute($query, Attribute $attribute, string $direction): void { $query->sort[] = [$attribute, $direction]; } - public function paginate($query, int $limit, int $offset) + public function paginate($query, int $limit, int $offset): void { $query->paginate[] = [$limit, $offset]; } - public function include($query, array $relationships) - { - $query->include[] = $relationships; - } - - public function load(array $models, array $relationships) + public function load(array $models, array $relationships): void { foreach ($models as $model) { $model->load[] = $relationships; } } - public function loadIds(array $models, Relationship $relationship) + public function loadIds(array $models, Relationship $relationship): void { foreach ($models as $model) { $model->loadIds[] = $relationship; @@ -141,11 +137,34 @@ class MockAdapter implements AdapterInterface private function getProperty(Field $field) { - return $field->property ?: $field->name; + return $field->getProperty() ?: $field->getName(); } - public function handles($model) + public function represents($model): bool { return isset($model['type']) && $model['type'] === $this->type; } + + /** + * Get the number of results from the query. + * + * @param $query + * @return int + */ + public function count($query): int + { + return count($this->models); + } + + /** + * Get the ID of the related resource for a has-one relationship. + * + * @param $model + * @param HasOne $relationship + * @return mixed|null + */ + public function getHasOneId($model, HasOne $relationship): ?string + { + // TODO: Implement getHasOneId() method. + } } diff --git a/tests/ShowTest.php b/tests/ShowTest.old.php similarity index 88% rename from tests/ShowTest.php rename to tests/ShowTest.old.php index 32e0b0d..46a4161 100644 --- a/tests/ShowTest.php +++ b/tests/ShowTest.old.php @@ -11,21 +11,17 @@ namespace Tobyz\Tests\JsonApiServer; -use Tobyz\JsonApiServer\Api; +use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\Exception\BadRequestException; -use Tobyz\JsonApiServer\Serializer; -use Tobyz\JsonApiServer\Schema\Builder; +use Tobyz\JsonApiServer\Schema\Type; use Psr\Http\Message\ServerRequestInterface as Request; -use JsonApiPhp\JsonApi; -use Zend\Diactoros\ServerRequest; -use Zend\Diactoros\Uri; class ShowTest extends AbstractTestCase { - public function testResourceWithNoFields() + public function test_resource_with_no_fields() { - $api = new Api('http://example.com'); - $api->resource('users', new MockAdapter(), function (Builder $schema) { + $api = new JsonApi('http://example.com'); + $api->resource('users', new MockAdapter(), function (Type $schema) { // no fields }); @@ -35,17 +31,19 @@ class ShowTest extends AbstractTestCase $this->assertEquals($response->getStatusCode(), 200); $this->assertEquals( [ - 'type' => 'users', - 'id' => '1', - 'links' => [ - 'self' => 'http://example.com/users/1' + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'links' => [ + 'self' => 'http://example.com/users/1' + ] ] ], - json_decode($response->getBody(), true)['data'] + json_decode($response->getBody(), true) ); } - public function testAttributes() + public function test_attributes() { $adapter = new MockAdapter([ '1' => (object) [ @@ -58,9 +56,9 @@ class ShowTest extends AbstractTestCase $request = $this->buildRequest('GET', '/users/1'); - $api = new Api('http://example.com'); + $api = new JsonApi('http://example.com'); - $api->resource('users', $adapter, function (Builder $schema) { + $api->resource('users', $adapter, function (Type $schema) { $schema->attribute('attribute1'); $schema->attribute('attribute2', 'property2'); $schema->attribute('attribute3')->property('property3'); @@ -88,9 +86,9 @@ class ShowTest extends AbstractTestCase $request = $this->buildRequest('GET', '/users/1'); - $api = new Api('http://example.com'); + $api = new JsonApi('http://example.com'); - $api->resource('users', $adapter, function (Builder $schema) use ($model, $request) { + $api->resource('users', $adapter, function (Type $schema) use ($model, $request) { $schema->attribute('attribute1') ->get(function ($arg1, $arg2) use ($model, $request) { $this->assertInstanceOf(Request::class, $arg1); @@ -120,8 +118,8 @@ class ShowTest extends AbstractTestCase $request = $this->buildRequest('GET', '/users/1'); - $api = new Api('http://example.com'); - $api->resource('users', $adapter, function (Builder $schema) use ($model, $request) { + $api = new JsonApi('http://example.com'); + $api->resource('users', $adapter, function (Type $schema) use ($model, $request) { $schema->attribute('visible1'); $schema->attribute('visible2')->visible(); @@ -183,19 +181,19 @@ class ShowTest extends AbstractTestCase $request = $this->buildRequest('GET', '/users/1') ->withQueryParams(['include' => 'phone,phone2,phone3']); - $api = new Api('http://example.com'); + $api = new JsonApi('http://example.com'); - $api->resource('users', $usersAdapter, function (Builder $schema) { + $api->resource('users', $usersAdapter, function (Type $schema) { $schema->hasOne('phone'); $schema->hasOne('phone2', 'phones', 'property2'); $schema->hasOne('phone3') - ->resource('phones') + ->type('phones') ->property('property3'); }); - $api->resource('phones', $phonesAdapter, function (Builder $schema) { + $api->resource('phones', $phonesAdapter, function (Type $schema) { $schema->attribute('number'); }); @@ -267,15 +265,15 @@ class ShowTest extends AbstractTestCase $request = $this->buildRequest('GET', '/users/1') ->withQueryParams(['include' => 'phone2']); - $api = new Api('http://example.com'); + $api = new JsonApi('http://example.com'); - $api->resource('users', $usersAdapter, function (Builder $schema) { + $api->resource('users', $usersAdapter, function (Type $schema) { $schema->hasOne('phone'); $schema->hasOne('phone2', 'phones', 'property2'); }); - $api->resource('phones', $phonesAdapter, function (Builder $schema) { + $api->resource('phones', $phonesAdapter, function (Type $schema) { $schema->attribute('number'); }); @@ -338,9 +336,9 @@ class ShowTest extends AbstractTestCase public function testHasManyRelationshipNotIncludableByDefault() { - $api = new Api('http://example.com'); + $api = new JsonApi('http://example.com'); - $api->resource('users', new MockAdapter(), function (Builder $schema) { + $api->resource('users', new MockAdapter(), function (Type $schema) { $schema->hasMany('groups'); }); @@ -365,9 +363,9 @@ class ShowTest extends AbstractTestCase ] ]); - $api = new Api('http://example.com'); + $api = new JsonApi('http://example.com'); - $api->resource('users', $usersAdapter, function (Builder $schema) { + $api->resource('users', $usersAdapter, function (Type $schema) { $schema->hasMany('groups'); }); @@ -400,11 +398,11 @@ class ShowTest extends AbstractTestCase ] ]); - $api = new Api('http://example.com'); + $api = new JsonApi('http://example.com'); $relationships = []; - $api->resource('users', $usersAdapter, function (Builder $schema) use (&$relationships) { + $api->resource('users', $usersAdapter, function (Type $schema) use (&$relationships) { $relationships[] = $schema->hasMany('groups1', 'groups', 'property1') ->includable(); @@ -412,7 +410,7 @@ class ShowTest extends AbstractTestCase ->includable(); }); - $api->resource('groups', $groupsAdapter, function (Builder $schema) { + $api->resource('groups', $groupsAdapter, function (Type $schema) { $schema->attribute('name'); }); @@ -519,19 +517,19 @@ class ShowTest extends AbstractTestCase '1' => $post = (object) ['id' => '1', 'user' => $user] ]); - $api = new Api('http://example.com'); + $api = new JsonApi('http://example.com'); $relationships = []; - $api->resource('posts', $postsAdapter, function (Builder $schema) use (&$relationships) { + $api->resource('posts', $postsAdapter, function (Type $schema) use (&$relationships) { $relationships[] = $schema->hasOne('user'); }); - $api->resource('users', $usersAdapter, function (Builder $schema) use (&$relationships) { + $api->resource('users', $usersAdapter, function (Type $schema) use (&$relationships) { $relationships[] = $schema->hasMany('groups')->includable(); }); - $api->resource('groups', $groupsAdapter, function (Builder $schema) { + $api->resource('groups', $groupsAdapter, function (Type $schema) { $schema->attribute('name'); }); diff --git a/tests/feature/AttributeFilterableTest.php b/tests/feature/AttributeFilterableTest.php new file mode 100644 index 0000000..0daa2b7 --- /dev/null +++ b/tests/feature/AttributeFilterableTest.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobyz\Tests\JsonApiServer\feature; + +use Psr\Http\Message\ServerRequestInterface; +use Tobyz\JsonApiServer\Exception\BadRequestException; +use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\Schema\Type; +use Tobyz\Tests\JsonApiServer\AbstractTestCase; +use Tobyz\Tests\JsonApiServer\MockAdapter; + +class AttributeFilterableTest extends AbstractTestCase +{ + /** + * @var JsonApi + */ + private $api; + + /** + * @var MockAdapter + */ + private $adapter; + + public function setUp(): void + { + $this->api = new JsonApi('http://example.com'); + + $this->adapter = new MockAdapter(); + } + + public function test_attributes_are_not_filterable_by_default() + { + $this->api->resource('users', $this->adapter, function (Type $type) { + $type->attribute('field'); + }); + + $this->expectException(BadRequestException::class); + + $this->api->handle( + $this->buildRequest('GET', '/users') + ->withQueryParams(['filter' => ['field' => 'Toby']]) + ); + } + + public function test_attributes_can_be_filterable() + { + $attribute = null; + + $this->api->resource('users', $this->adapter, function (Type $type) use (&$attribute) { + $attribute = $type->attribute('name')->filterable(); + }); + + $this->api->handle( + $this->buildRequest('GET', '/users') + ->withQueryParams(['filter' => ['name' => 'Toby']]) + ); + + $this->assertContains([$attribute, 'Toby'], $this->adapter->query->filter); + } + + public function test_attributes_can_be_filterable_with_custom_logic() + { + $called = false; + + $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { + $type->attribute('name') + ->filterable(function ($query, $value, $request) use (&$called) { + $this->assertSame($this->adapter->query, $query); + $this->assertEquals('Toby', $value); + $this->assertInstanceOf(ServerRequestInterface::class, $request); + + $called = true; + }); + }); + + $this->api->handle( + $this->buildRequest('GET', '/users') + ->withQueryParams(['filter' => ['name' => 'Toby']]) + ); + + $this->assertTrue($called); + $this->assertTrue(empty($this->adapter->query->filter)); + } + + public function test_attributes_can_be_explicitly_not_filterable() + { + $this->api->resource('users', $this->adapter, function (Type $type) { + $type->attribute('name')->notFilterable(); + }); + + $this->expectException(BadRequestException::class); + + $this->api->handle( + $this->buildRequest('GET', '/users') + ->withQueryParams(['filter' => ['name' => 'Toby']]) + ); + } +} diff --git a/tests/feature/AttributeSortableTest.php b/tests/feature/AttributeSortableTest.php new file mode 100644 index 0000000..3bd290a --- /dev/null +++ b/tests/feature/AttributeSortableTest.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobyz\Tests\JsonApiServer\feature; + +use Psr\Http\Message\ServerRequestInterface; +use Tobyz\JsonApiServer\Exception\BadRequestException; +use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\Schema\Type; +use Tobyz\Tests\JsonApiServer\AbstractTestCase; +use Tobyz\Tests\JsonApiServer\MockAdapter; + +class AttributeSortableTest extends AbstractTestCase +{ + /** + * @var JsonApi + */ + private $api; + + /** + * @var MockAdapter + */ + private $adapter; + + public function setUp(): void + { + $this->api = new JsonApi('http://example.com'); + + $this->adapter = new MockAdapter(); + } + + public function test_attributes_can_be_sortable() + { + $attribute = null; + + $this->api->resource('users', $this->adapter, function (Type $type) use (&$attribute) { + $attribute = $type->attribute('name')->sortable(); + }); + + $this->api->handle( + $this->buildRequest('GET', '/users') + ->withQueryParams(['sort' => 'name']) + ); + + $this->assertContains([$attribute, 'asc'], $this->adapter->query->sort); + } + + public function test_attributes_can_be_sortable_with_custom_logic() + { + $called = false; + + $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { + $type->attribute('name') + ->sortable(function ($query, $direction, $request) use (&$called) { + $this->assertSame($this->adapter->query, $query); + $this->assertEquals('asc', $direction); + $this->assertInstanceOf(ServerRequestInterface::class, $request); + + $called = true; + }); + }); + + $this->api->handle( + $this->buildRequest('GET', '/users') + ->withQueryParams(['sort' => 'name']) + ); + + $this->assertTrue($called); + $this->assertTrue(empty($this->adapter->query->sort)); + } + + public function test_attributes_are_not_sortable_by_default() + { + $this->api->resource('users', $this->adapter, function (Type $type) { + $type->attribute('name'); + }); + + $this->expectException(BadRequestException::class); + + $this->api->handle( + $this->buildRequest('GET', '/users') + ->withQueryParams(['sort' => 'name']) + ); + } + + public function test_attributes_can_be_explicitly_not_sortable() + { + $this->api->resource('users', $this->adapter, function (Type $type) { + $type->attribute('name')->notSortable(); + }); + + $this->expectException(BadRequestException::class); + + $this->api->handle( + $this->buildRequest('GET', '/users') + ->withQueryParams(['sort' => 'name']) + ); + } +} diff --git a/tests/feature/AttributeTest.php b/tests/feature/AttributeTest.php new file mode 100644 index 0000000..a3b21bc --- /dev/null +++ b/tests/feature/AttributeTest.php @@ -0,0 +1,393 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobyz\Tests\JsonApiServer\feature; + +use JsonApiPhp\JsonApi\ErrorDocument; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ServerRequestInterface; +use Tobyz\JsonApiServer\ErrorProviderInterface; +use Tobyz\JsonApiServer\Exception\UnprocessableEntityException; +use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\Schema\Attribute; +use Tobyz\JsonApiServer\Schema\Type; +use Tobyz\Tests\JsonApiServer\AbstractTestCase; +use Tobyz\Tests\JsonApiServer\MockAdapter; + +class AttributeTest extends AbstractTestCase +{ + /** + * @var JsonApi + */ + private $api; + + /** + * @var MockAdapter + */ + private $adapter; + + public function setUp(): void + { + $this->api = new JsonApi('http://example.com'); + + $this->adapter = new MockAdapter([ + '1' => (object) ['id' => '1', 'name' => 'Toby', 'color' => 'yellow'], + '2' => (object) ['id' => '2', 'name' => 'Franz', 'color' => 'blue'], + ]); + } + + public function test_multiple_attributes() + { + $this->api->resource('users', $this->adapter, function (Type $type) { + $type->attribute('name'); + $type->attribute('color'); + }); + + $response = $this->api->handle( + $this->buildRequest('GET', '/users/1') + ); + + $this->assertJsonApiDocumentSubset([ + 'data' => [ + 'attributes' => [ + 'name' => 'Toby', + 'color' => 'yellow', + ], + ] + ], $response->getBody()); + } + + public function test_attributes_can_specify_a_property() + { + $this->api->resource('users', $this->adapter, function (Type $type) { + $type->attribute('name') + ->property('color'); + }); + + $response = $this->api->handle( + $this->buildRequest('GET', '/users/1') + ); + + $this->assertJsonApiDocumentSubset([ + 'data' => [ + 'attributes' => [ + 'name' => 'yellow', + ], + ] + ], $response->getBody()); + } + + public function test_attributes_can_have_getters() + { + $called = false; + + $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { + $type->attribute('name') + ->get('Toby'); + + $type->attribute('color') + ->get(function ($model, $request) use (&$called) { + $called = true; + + $this->assertSame($this->adapter->models['1'], $model); + $this->assertInstanceOf(RequestInterface::class, $request); + + return 'yellow'; + }); + }); + + $response = $this->api->handle( + $this->buildRequest('GET', '/users/1') + ); + + $this->assertTrue($called); + + $this->assertJsonApiDocumentSubset([ + 'data' => [ + 'attributes' => [ + 'name' => 'Toby', + 'color' => 'yellow', + ], + ] + ], $response->getBody()); + } + + public function test_attribute_setter_receives_correct_parameters() + { + $called = false; + + $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { + $type->updatable(); + $type->attribute('writable') + ->writable() + ->set(function ($model, $value, $request) use (&$called) { + $this->assertSame($this->adapter->models['1'], $model); + $this->assertEquals('value', $value); + $this->assertInstanceOf(RequestInterface::class, $request); + + $called = true; + }); + }); + + $this->api->handle( + $this->buildRequest('PATCH', '/users/1') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'writable' => 'value', + ] + ] + ]) + ); + + $this->assertTrue($called); + } + + public function test_attribute_setter_precludes_adapter_action() + { + $this->api->resource('users', $this->adapter, function (Type $type) { + $type->updatable(); + $type->attribute('writable') + ->writable() + ->set(function () {}); + }); + + $this->api->handle( + $this->buildRequest('PATCH', '/users/1') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'writable' => 'value', + ] + ] + ]) + ); + + $this->assertTrue(empty($this->adapter->models['1']->writable)); + } + + public function test_attribute_saver_receives_correct_parameters() + { + $called = false; + + $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { + $type->updatable(); + $type->attribute('writable') + ->writable() + ->save(function ($model, $value, $request) use (&$called) { + $this->assertSame($this->adapter->models['1'], $model); + $this->assertEquals('value', $value); + $this->assertInstanceOf(RequestInterface::class, $request); + + $called = true; + }); + }); + + $this->api->handle( + $this->buildRequest('PATCH', '/users/1') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'writable' => 'value', + ] + ] + ]) + ); + + $this->assertTrue($called); + } + + public function test_attribute_saver_precludes_adapter_action() + { + $this->api->resource('users', $this->adapter, function (Type $type) { + $type->updatable(); + $type->attribute('writable') + ->writable() + ->save(function () {}); + }); + + $this->api->handle( + $this->buildRequest('PATCH', '/users/1') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'writable' => 'value', + ] + ] + ]) + ); + + $this->assertTrue(empty($this->adapter->models['1']->writable)); + } + + public function test_attributes_can_run_callback_after_being_saved() + { + $called = false; + + $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { + $type->updatable(); + $type->attribute('writable') + ->writable() + ->saved(function ($model, $value, $request) use (&$called) { + $this->assertTrue($this->adapter->models['1']->saveWasCalled); + $this->assertSame($this->adapter->models['1'], $model); + $this->assertEquals('value', $value); + $this->assertInstanceOf(RequestInterface::class, $request); + + $called = true; + }); + }); + + $this->api->handle( + $this->buildRequest('PATCH', '/users/1') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'writable' => 'value', + ] + ] + ]) + ); + + $this->assertTrue($called); + } + + public function test_attributes_can_have_default_values() + { + $this->api->resource('users', $this->adapter, function (Type $type) { + $type->creatable(); + + $type->attribute('name') + ->default('Toby'); + + $type->attribute('color') + ->default(function () { + return 'yellow'; + }); + }); + + $this->api->handle( + $this->buildRequest('POST', '/users') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + ] + ]) + ); + + $this->assertEquals('Toby', $this->adapter->createdModel->name); + $this->assertEquals('yellow', $this->adapter->createdModel->color); + } + + public function test_attribute_default_callback_receives_correct_parameters() + { + $this->api->resource('users', $this->adapter, function (Type $type) { + $type->creatable(); + $type->attribute('attribute') + ->default(function ($request) { + $this->assertInstanceOf(RequestInterface::class, $request); + }); + }); + + $this->api->handle( + $this->buildRequest('POST', '/users') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + ] + ]) + ); + } + + public function test_attribute_values_from_request_override_default_values() + { + $this->api->resource('users', $this->adapter, function (Type $type) { + $type->creatable(); + $type->attribute('name') + ->writable() + ->default('Toby'); + }); + + $this->api->handle( + $this->buildRequest('POST', '/users') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'attributes' => [ + 'name' => 'Franz', + ] + ] + ]) + ); + + $this->assertEquals('Franz', $this->adapter->createdModel->name); + } + + public function test_attributes_can_be_validated() + { + $this->api->resource('users', $this->adapter, function (Type $type) { + $type->creatable(); + + $type->attribute('name') + ->writable() + ->validate(function ($fail, $value, $model, $request, $field) { + $this->assertEquals('Toby', $value); + $this->assertSame($this->adapter->createdModel, $model); + $this->assertInstanceOf(ServerRequestInterface::class, $request); + $this->assertInstanceOf(Attribute::class, $field); + + $fail('detail'); + }); + }); + + $this->expectException(UnprocessableEntityException::class); + + try { + $this->api->handle( + $this->buildRequest('POST', '/users') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'attributes' => [ + 'name' => 'Toby', + ] + ] + ]) + ); + } catch (ErrorProviderInterface $e) { + $document = new ErrorDocument(...$e->getJsonApiErrors()); + + $this->assertArraySubset([ + 'errors' => [ + [ + 'status' => '422', + 'source' => [ + 'pointer' => '/data/attributes/name' + ], + 'detail' => 'detail' + ] + ] + ], json_decode(json_encode($document), true)); + + throw $e; + } + } +} diff --git a/tests/feature/AttributeWritableTest.php b/tests/feature/AttributeWritableTest.php new file mode 100644 index 0000000..371ebf7 --- /dev/null +++ b/tests/feature/AttributeWritableTest.php @@ -0,0 +1,274 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +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\Schema\Type; +use Tobyz\Tests\JsonApiServer\AbstractTestCase; +use Tobyz\Tests\JsonApiServer\MockAdapter; + +class AttributeWritableTest extends AbstractTestCase +{ + /** + * @var JsonApi + */ + private $api; + + /** + * @var MockAdapter + */ + private $adapter; + + public function setUp(): void + { + $this->api = new JsonApi('http://example.com'); + + $this->adapter = new MockAdapter([ + '1' => (object) ['id' => '1'] + ]); + } + + public function test_attributes_are_readonly_by_default() + { + $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { + $type->updatable(); + $type->attribute('readonly'); + }); + + $this->expectException(BadRequestException::class); + + $this->api->handle( + $this->buildRequest('PATCH', '/users/1') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'readonly' => 'value', + ] + ] + ]) + ); + } + + public function test_attributes_can_be_explicitly_writable() + { + $this->api->resource('users', $this->adapter, function (Type $type) { + $type->updatable(); + $type->attribute('writable')->writable(); + }); + + $response = $this->api->handle( + $this->buildRequest('PATCH', '/users/1') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'writable' => 'value', + ] + ] + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('value', $this->adapter->models['1']->writable); + } + + public function test_attributes_can_be_conditionally_writable() + { + $this->api->resource('users', $this->adapter, function (Type $type) { + $type->updatable(); + $type->attribute('writable') + ->writable(function () { return true; }); + }); + + $response = $this->api->handle( + $this->buildRequest('PATCH', '/users/1') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'writable' => 'value', + ] + ] + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('value', $this->adapter->models['1']->writable); + } + + public function test_attributes_can_be_conditionally_not_writable() + { + $this->api->resource('users', $this->adapter, function (Type $type) { + $type->updatable(); + $type->attribute('writable') + ->writable(function () { return false; }); + }); + + $this->expectException(BadRequestException::class); + + $this->api->handle( + $this->buildRequest('PATCH', '/users/1') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'writable' => 'value', + ] + ] + ]) + ); + } + + public function test_attribute_writable_callback_receives_correct_parameters() + { + $called = false; + + $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { + $type->updatable(); + $type->attribute('writable') + ->writable(function ($model, $request) use (&$called) { + $this->assertSame($this->adapter->models['1'], $model); + $this->assertInstanceOf(ServerRequestInterface::class, $request); + + return $called = true; + }); + }); + + $this->api->handle( + $this->buildRequest('PATCH', '/users/1') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'writable' => 'value', + ] + ] + ]) + ); + + $this->assertTrue($called); + } + + public function test_attributes_can_be_explicitly_readonly() + { + $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { + $type->updatable(); + $type->attribute('readonly')->readonly(); + }); + + $this->expectException(BadRequestException::class); + + $this->api->handle( + $this->buildRequest('PATCH', '/users/1') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'readonly' => 'value', + ] + ] + ]) + ); + } + + public function test_attributes_can_be_conditionally_readonly() + { + $this->api->resource('users', $this->adapter, function (Type $type) { + $type->updatable(); + $type->attribute('readonly') + ->readonly(function () { return true; }); + }); + + $this->expectException(BadRequestException::class); + + $this->api->handle( + $this->buildRequest('PATCH', '/users/1') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'readonly' => 'value', + ] + ] + ]) + ); + } + + public function test_attribute_readonly_callback_receives_correct_parameters() + { + $called = false; + + $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { + $type->updatable(); + $type->attribute('readonly') + ->readonly(function ($model, $request) use (&$called) { + $called = true; + + $this->assertSame($this->adapter->models['1'], $model); + $this->assertInstanceOf(RequestInterface::class, $request); + + return false; + }); + }); + + $this->api->handle( + $this->buildRequest('PATCH', '/users/1') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'readonly' => 'value', + ] + ] + ]) + ); + + $this->assertTrue($called); + } + + public function test_attributes_can_be_conditionally_not_readonly() + { + $this->api->resource('users', $this->adapter, function (Type $type) { + $type->updatable(); + $type->attribute('writable') + ->readonly(function () { return false; }); + }); + + $response = $this->api->handle( + $this->buildRequest('PATCH', '/users/1') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'writable' => 'value', + ] + ] + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('value', $this->adapter->models['1']->writable); + } +} diff --git a/tests/feature/BasicTest.php b/tests/feature/BasicTest.php new file mode 100644 index 0000000..c0bebeb --- /dev/null +++ b/tests/feature/BasicTest.php @@ -0,0 +1,174 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobyz\Tests\JsonApiServer\feature; + +use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\Schema\Type; +use Tobyz\Tests\JsonApiServer\AbstractTestCase; +use Tobyz\Tests\JsonApiServer\MockAdapter; + +class BasicTest extends AbstractTestCase +{ + /** + * @var JsonApi + */ + private $api; + + public function setUp(): void + { + $this->api = new JsonApi('http://example.com'); + + $adapter = new MockAdapter([ + '1' => (object) [ + 'id' => '1', + 'name' => 'Toby', + ], + '2' => (object) [ + 'id' => '2', + 'name' => 'Franz', + ], + ]); + + $this->api->resource('users', $adapter, function (Type $type) { + $type->attribute('name')->writable(); + $type->creatable(); + $type->updatable(); + $type->deletable(); + }); + } + + public function test_show_resource() + { + $response = $this->api->handle( + $this->buildRequest('GET', '/users/1') + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $this->assertJsonApiDocumentSubset([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'name' => 'Toby' + ], + 'links' => [ + 'self' => 'http://example.com/users/1' + ] + ] + ], $response->getBody()); + } + + public function test_list_resources() + { + $response = $this->api->handle( + $this->buildRequest('GET', '/users') + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $this->assertJsonApiDocumentSubset([ + 'data' => [ + [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'name' => 'Toby' + ], + 'links' => [ + 'self' => 'http://example.com/users/1' + ] + ], + [ + 'type' => 'users', + 'id' => '2', + 'attributes' => [ + 'name' => 'Franz' + ], + 'links' => [ + 'self' => 'http://example.com/users/2' + ] + ] + ] + ], $response->getBody()); + } + + public function test_create_resource() + { + $response = $this->api->handle( + $this->buildRequest('POST', '/users') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'attributes' => [ + 'name' => 'Bob', + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $this->assertJsonApiDocumentSubset([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'name' => 'Bob', + ], + 'links' => [ + 'self' => 'http://example.com/users/1', + ], + ], + ], $response->getBody()); + } + + public function test_update_resource() + { + $response = $this->api->handle( + $this->buildRequest('PATCH', '/users/1') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'name' => 'Bob', + ], + ], + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $this->assertJsonApiDocumentSubset([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'name' => 'Bob', + ], + 'links' => [ + 'self' => 'http://example.com/users/1', + ], + ], + ], $response->getBody()); + } + + public function test_delete_resource() + { + $response = $this->api->handle( + $this->buildRequest('DELETE', '/users/1') + ); + + $this->assertEquals(204, $response->getStatusCode()); + } +} diff --git a/tests/feature/FieldVisibilityTest.php b/tests/feature/FieldVisibilityTest.php new file mode 100644 index 0000000..2247b97 --- /dev/null +++ b/tests/feature/FieldVisibilityTest.php @@ -0,0 +1,237 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobyz\Tests\JsonApiServer\feature; + +use Psr\Http\Message\RequestInterface; +use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\Schema\Type; +use Tobyz\Tests\JsonApiServer\AbstractTestCase; +use Tobyz\Tests\JsonApiServer\MockAdapter; + +class FieldVisibilityTest extends AbstractTestCase +{ + /** + * @var JsonApi + */ + private $api; + + /** + * @var MockAdapter + */ + private $adapter; + + public function setUp(): void + { + $this->api = new JsonApi('http://example.com'); + + $this->adapter = new MockAdapter([ + '1' => (object) ['id' => '1'] + ]); + } + + public function test_fields_are_visible_by_default() + { + $this->api->resource('users', new MockAdapter, function (Type $type) { + $type->attribute('visibleAttribute'); + $type->hasOne('visibleHasOne'); + $type->hasMany('visibleHasMany'); + }); + + $response = $this->api->handle( + $this->buildRequest('GET', '/users/1') + ); + + $document = json_decode($response->getBody(), true); + $attributes = $document['data']['attributes'] ?? []; + $relationships = $document['data']['relationships'] ?? []; + + $this->assertArrayHasKey('visibleAttribute', $attributes); + $this->assertArrayHasKey('visibleHasOne', $relationships); + $this->assertArrayHasKey('visibleHasMany', $relationships); + } + + public function test_fields_can_be_explicitly_visible() + { + $this->api->resource('users', new MockAdapter, function (Type $type) { + $type->attribute('visibleAttribute')->visible(); + $type->hasOne('visibleHasOne')->visible(); + $type->hasMany('visibleHasMany')->visible(); + }); + + $response = $this->api->handle( + $this->buildRequest('GET', '/users/1') + ); + + $document = json_decode($response->getBody(), true); + $attributes = $document['data']['attributes'] ?? []; + $relationships = $document['data']['relationships'] ?? []; + + $this->assertArrayHasKey('visibleAttribute', $attributes); + $this->assertArrayHasKey('visibleHasOne', $relationships); + $this->assertArrayHasKey('visibleHasMany', $relationships); + } + + public function test_fields_can_be_conditionally_visible() + { + $this->api->resource('users', new MockAdapter, function (Type $type) { + $type->attribute('visibleAttribute') + ->visible(function () { return true; }); + + $type->attribute('hiddenAttribute') + ->visible(function () { return false; }); + + $type->hasOne('visibleHasOne') + ->visible(function () { return true; }); + + $type->hasOne('hiddenHasOne') + ->visible(function () { return false; }); + + $type->hasMany('visibleHasMany') + ->visible(function () { return true; }); + + $type->hasMany('hiddenHasMany') + ->visible(function () { return false; }); + }); + + $response = $this->api->handle( + $this->buildRequest('GET', '/users/1') + ); + + $document = json_decode($response->getBody(), true); + $attributes = $document['data']['attributes'] ?? []; + $relationships = $document['data']['relationships'] ?? []; + + $this->assertArrayHasKey('visibleAttribute', $attributes); + $this->assertArrayHasKey('visibleHasOne', $relationships); + $this->assertArrayHasKey('visibleHasMany', $relationships); + + $this->assertArrayNotHasKey('hiddenAttribute', $attributes); + $this->assertArrayNotHasKey('hiddenHasOne', $relationships); + $this->assertArrayNotHasKey('hiddenHasMany', $relationships); + } + + public function test_visible_callback_receives_correct_parameters() + { + $called = 0; + + $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { + $callback = function ($model, $request) use (&$called) { + $this->assertSame($this->adapter->models['1'], $model); + $this->assertInstanceOf(RequestInterface::class, $request); + $called++; + }; + + $type->attribute('attribute') + ->visible($callback); + + $type->hasOne('hasOne') + ->visible($callback); + + $type->hasMany('hasMany') + ->visible($callback); + }); + + $this->api->handle( + $this->buildRequest('GET', '/users/1') + ); + + $this->assertEquals(3, $called); + } + + public function test_fields_can_be_explicitly_hidden() + { + $this->api->resource('users', new MockAdapter, function (Type $type) { + $type->attribute('hiddenAttribute')->hidden(); + $type->hasOne('hiddenHasOne')->hidden(); + $type->hasMany('hiddenHasMany')->hidden(); + }); + + $response = $this->api->handle( + $this->buildRequest('GET', '/users/1') + ); + + $document = json_decode($response->getBody(), true); + $attributes = $document['data']['attributes'] ?? []; + $relationships = $document['data']['relationships'] ?? []; + + $this->assertArrayNotHasKey('hiddenAttribute', $attributes); + $this->assertArrayNotHasKey('hiddenHasOne', $relationships); + $this->assertArrayNotHasKey('hiddenHasMany', $relationships); + } + + public function test_fields_can_be_conditionally_hidden() + { + $this->api->resource('users', new MockAdapter, function (Type $type) { + $type->attribute('visibleAttribute') + ->hidden(function () { return false; }); + + $type->attribute('hiddenAttribute') + ->hidden(function () { return true; }); + + $type->hasOne('visibleHasOne') + ->hidden(function () { return false; }); + + $type->hasOne('hiddenHasOne') + ->hidden(function () { return true; }); + + $type->hasMany('visibleHasMany') + ->hidden(function () { return false; }); + + $type->hasMany('hiddenHasMany') + ->hidden(function () { return true; }); + }); + + $response = $this->api->handle( + $this->buildRequest('GET', '/users/1') + ); + + $document = json_decode($response->getBody(), true); + $attributes = $document['data']['attributes'] ?? []; + $relationships = $document['data']['relationships'] ?? []; + + $this->assertArrayHasKey('visibleAttribute', $attributes); + $this->assertArrayHasKey('visibleHasOne', $relationships); + $this->assertArrayHasKey('visibleHasMany', $relationships); + + $this->assertArrayNotHasKey('hiddenAttribute', $attributes); + $this->assertArrayNotHasKey('hiddenHasOne', $relationships); + $this->assertArrayNotHasKey('hiddenHasMany', $relationships); + } + + public function test_hidden_callback_receives_correct_parameters() + { + $called = 0; + + $this->api->resource('users', $this->adapter, function (Type $type) use (&$called) { + $callback = function ($model, $request) use (&$called) { + $this->assertSame($this->adapter->models['1'], $model); + $this->assertInstanceOf(RequestInterface::class, $request); + $called++; + }; + + $type->attribute('attribute') + ->hidden($callback); + + $type->hasOne('hasOne') + ->hidden($callback); + + $type->hasMany('hasMany') + ->hidden($callback); + }); + + $this->api->handle( + $this->buildRequest('GET', '/users/1') + ); + + $this->assertEquals(3, $called); + } +} diff --git a/tests/specification/ContentNegotiationTest.php b/tests/specification/ContentNegotiationTest.php new file mode 100644 index 0000000..b24d51e --- /dev/null +++ b/tests/specification/ContentNegotiationTest.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobyz\Tests\JsonApiServer\specification; + +use Tobyz\JsonApiServer\JsonApi; +use Tobyz\JsonApiServer\Exception\NotAcceptableException; +use Tobyz\JsonApiServer\Exception\UnsupportedMediaTypeException; +use Tobyz\JsonApiServer\Schema\Type; +use Tobyz\Tests\JsonApiServer\AbstractTestCase; +use Tobyz\Tests\JsonApiServer\MockAdapter; + +class ContentNegotiationTest extends AbstractTestCase +{ + /** + * @var JsonApi + */ + private $api; + + public function setUp(): void + { + $this->api = new JsonApi('http://example.com'); + $this->api->resource('users', new MockAdapter(), function (Type $type) { + // no fields + }); + } + + public function testJsonApiContentTypeIsReturned() + { + $response = $this->api->handle( + $this->buildRequest('GET', '/users/1') + ); + + $this->assertEquals( + 'application/vnd.api+json', + $response->getHeaderLine('Content-Type') + ); + } + + public function testErrorWhenValidRequestContentTypeHasParameters() + { + $request = $this->buildRequest('PATCH', '/users/1') + ->withHeader('Content-Type', 'application/vnd.api+json;profile="http://example.com/last-modified"'); + + $this->expectException(UnsupportedMediaTypeException::class); + + $this->api->handle($request); + } + + public function testErrorWhenAllValidAcceptsHaveParameters() + { + $request = $this->buildRequest('GET', '/users/1') + ->withHeader('Accept', 'application/vnd.api+json;profile="http://example.com/last-modified", application/vnd.api+json;profile="http://example.com/versioning"'); + + $this->expectException(NotAcceptableException::class); + + $this->api->handle($request); + } + + public function testSuccessWhenOnlySomeAcceptsHaveParameters() + { + $response = $this->api->handle( + $this->buildRequest('GET', '/users/1') + ->withHeader('Accept', 'application/vnd.api+json;profile="http://example.com/last-modified", application/vnd.api+json') + ); + + $this->assertEquals(200, $response->getStatusCode()); + } +} diff --git a/tests/unit/MediaTypesTest.php b/tests/unit/MediaTypesTest.php new file mode 100644 index 0000000..8200ba4 --- /dev/null +++ b/tests/unit/MediaTypesTest.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobyz\Tests\JsonApiServer\unit\Http; + +use PHPUnit\Framework\TestCase; +use Tobyz\JsonApiServer\Http\MediaTypes; + +class MediaTypesTest extends TestCase +{ + public function testContainsOnExactMatch() + { + $header = new MediaTypes('application/json'); + + $this->assertTrue( + $header->containsExactly('application/json') + ); + } + + public function testContainsDoesNotMatchWithExtraParameters() + { + $header = new MediaTypes('application/json; profile=foo'); + + $this->assertFalse( + $header->containsExactly('application/json') + ); + } + + public function testContainsMatchesWhenOnlyWeightIsProvided() + { + $header = new MediaTypes('application/json; q=0.8'); + + $this->assertTrue( + $header->containsExactly('application/json') + ); + } + + public function testContainsDoesNotMatchWithExtraParametersBeforeWeight() + { + $header = new MediaTypes('application/json; profile=foo; q=0.8'); + + $this->assertFalse( + $header->containsExactly('application/json') + ); + } + + public function testContainsMatchesWithExtraParametersAfterWeight() + { + $header = new MediaTypes('application/json; q=0.8; profile=foo'); + + $this->assertTrue( + $header->containsExactly('application/json') + ); + } + + public function testContainsMatchesWhenOneOfMultipleMediaTypesIsValid() + { + $header = new MediaTypes('application/json; profile=foo, application/json; q=0.6'); + + $this->assertTrue( + $header->containsExactly('application/json') + ); + } +}