commit e8bf26eaaef46de5220350d229113e122f7cee44 Author: Toby Zerner Date: Fri Dec 14 11:57:28 2018 +1030 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a3b1f36 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore + +phpunit.xml export-ignore +tests export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..987e2a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +composer.lock +vendor diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..f0aa0af --- /dev/null +++ b/.travis.yml @@ -0,0 +1,11 @@ +language: php + +php: + - 7.1 + - 7.2 + +install: + - composer install --no-interaction --prefer-source + +script: + - vendor/bin/phpunit diff --git a/README.md b/README.md new file mode 100644 index 0000000..4801bf8 --- /dev/null +++ b/README.md @@ -0,0 +1,373 @@ +# tobscure/json-api-server + +[![Build Status](https://img.shields.io/travis/tobscure/json-api-server/master.svg?style=flat)](https://travis-ci.org/tobscure/json-api-server) +[![Pre Release](https://img.shields.io/packagist/vpre/tobscure/json-api-server.svg?style=flat)](https://github.com/tobscure/json-api-server/releases) +[![License](https://img.shields.io/packagist/l/tobscure/json-api-server.svg?style=flat)](https://packagist.org/packages/tobscure/json-api-server) + +**A fully automated framework-agnostic [JSON:API](http://jsonapi.org) server implementation in PHP.** +Define your schema, plug in your models, and we'll take care of the rest. 🍻 + +```bash +composer require tobscure/json-api-server +``` + +```php +use Tobscure\JsonApiServer\Api; +use Tobscure\JsonApiServer\Adapter\EloquentAdapter; +use Tobscure\JsonApiServer\Schema\Builder; + +$api = new Api('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('people', new EloquentAdapter(new User), function (Builder $schema) { + $schema->attribute('firstName'); + $schema->attribute('lastName'); + $schema->attribute('twitter'); +}); + +$api->resource('comments', new EloquentAdapter(new Comment), function (Builder $schema) { + $schema->attribute('body'); + $schema->hasOne('author', 'people'); +}); + +/** @var Psr\Http\Message\ServerRequestInterface $request */ +/** @var Psr\Http\Message\Response $response */ +try { + $response = $api->handle($request); +} catch (Exception $e) { + $response = $api->error($e); +} +``` + +Assuming you have a few [Eloquent](https://laravel.com/docs/5.7/eloquent) models set up, the above code will serve a **complete JSON:API that conforms to the [spec](https://jsonapi.org/format/)**, including support for: + +- **Showing** individual resources (`GET /api/articles/1`) +- **Listing** resource collections (`GET /api/articles`) +- **Sorting**, **filtering**, **pagination**, and **sparse fieldsets** +- **Compound documents** with inclusion of related resources +- **Creating** resources (`POST /api/articles`) +- **Updating** resources (`PATCH /api/articles/1`) +- **Deleting** resources (`DELETE /api/articles/1`) +- **Error handling** + +The schema definition is extremely powerful and lets you easily apply [permissions](#visibility), [getters](#getters), [setters](#setters-savers), [validation](#validation), and custom [filtering](#filtering) and [sorting](#sorting) logic to build a fully functional API in minutes. + +### Handling Requests + +```php +use Tobscure\JsonApiServer\Api; + +$api = new Api('http://example.com/api'); + +try { + $response = $api->handle($request); +} catch (Exception $e) { + $response = $api->error($e); +} +``` + +`Tobscure\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. + +### 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 implementation of `Tobscure\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 Tobscure\JsonApiServer\Schema\Builder; + +$api->resource('comments', $adapter, function (Builder $schema) { + // define your schema +}); +``` + +We provide an `EloquentAdapter` to hook your resources up with Laravel [Eloquent](https://laravel.com/docs/5.7/eloquent) models. Set it up with an instance of the model that your resource represents. You can [implement your own adapter](https://github.com/tobscure/json-api-server/blob/master/src/Adapter/AdapterInterface.php) if you use a different ORM. + +```php +use Tobscure\JsonApiServer\Adapter\EloquentAdapter; + +$adapter = new EloquentAdapter(new User); +``` + +### Attributes + +Define an [attribute field](https://jsonapi.org/format/#document-resource-object-attributes) on your resource using the `attribute` method: + +```php +$schema->attribute('firstName'); +``` + +By default the attribute will correspond to the property on your model with the same name. (`EloquentAdapter` will `snake_case` it automatically for you.) If you'd like it to correspond to a different property, provide it as a second argument: + +```php +$schema->attribute('firstName', 'fname'); +``` + +### Relationships + +Define [relationship fields](https://jsonapi.org/format/#document-resource-object-relationships) on your resource using the `hasOne` and `hasMany` methods: + +```php +$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: + +```php +$schema->hasOne('author', '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. + +Has-one relationships are available for [inclusion](https://jsonapi.org/format/#fetching-includes) via the `include` query parameter. You can include them by default, if the `include` query parameter is empty, by calling the `included` method: + +```php +$schema->hasOne('user') + ->included(); +``` + +Has-many relationships must be explicitly made available for inclusion via the `includable` method. This is because pagination of included resources is not supported, so performance may suffer if there are large numbers of related resources. + +```php +$schema->hasMany('comments') + ->includable(); +``` + +### Getters + +Use the `get` method to define custom retrieval logic for your field, instead of just reading the value straight from the model property. (Of course, if you're using Eloquent, you could also define [casts](https://laravel.com/docs/5.7/eloquent-mutators#attribute-casting) or [accessors](https://laravel.com/docs/5.7/eloquent-mutators#defining-an-accessor) on your model to achieve a similar thing.) + +```php +$schema->attribute('firstName') + ->get(function ($model, $request) { + return ucfirst($model->first_name); + }); +``` + +### Visibility + +You can specify logic to restrict the visibility of a field using any one of the `visible`, `visibleIf`, `hidden`, and `hiddenIf` methods: + +```php +$schema->attribute('email') + // Make a field always visible (default) + ->visible() + + // Make a field visible only if certain logic is met + ->visibleIf(function ($model, $request) { + return $model->id == $request->getAttribute('userId'); + }) + + // Always hide a field (useful for write-only fields like password) + ->hidden() + + // Hide a field only if certain logic is met + ->hiddenIf(function ($model, $request) { + return $request->getAttribute('userIsSuspended'); + }); +``` + +You can also restrict the visibility of the whole resource using the `scope` method. This will allow you to modify the query builder object provided by your adapter: + +```php +$schema->scope(function ($query, $request) { + $query->where('user_id', $request->getAttribute('userId')); +}); +``` + +### Making Fields Writable + +By default, fields are read-only. You can allow a field to be written to using any one of the `writable`, `writableIf`, `readonly`, and `readonlyIf` methods: + +```php +$schema->attribute('email') + // Make an attribute writable + ->writable() + + // Make an attribute writable only if certain logic is met + ->writableIf(function ($model, $request) { + return $model->id == $request->getAttribute('userId'); + }) + + // Make an attribute read-only (default) + ->readonly() + + // Make an attribute writable *unless* certain logic is met + ->readonlyIf(function ($model, $request) { + return $request->getAttribute('userIsSuspended'); + }); +``` + +### Default Values + +You can provide a default value for a field to be used when creating a new resource if there is no value provided by the consumer. Pass a value or a closure to the `default` method: + + +```php +$schema->attribute('joinedAt') + ->default(new DateTime); + +$schema->attribute('ipAddress') + ->default(function ($request) { + return $request->getServerParams()['REMOTE_ADDR'] ?? null; + }); +``` + +If you're using Eloquent, you could also define [default attribute values](https://laravel.com/docs/5.7/eloquent#default-attribute-values) to achieve a similar thing, although you wouldn't have access to the request object. + +### Validation + +You can ensure that data provided for a field is valid before it is saved. Provide a closure to the `validate` method, and call the first argument if validation fails: + +```php +$schema->attribute('email') + ->validate(function ($fail, $email) { + if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { + $fail('Invalid email'); + } + }); + +$schema->hasMany('groups') + ->validate(function ($fail, $groups) { + foreach ($groups as $group) { + if ($group->id === 1) { + $fail('You cannot assign this group'); + } + } + }); +``` + +See [Macros](#macros) below to learn how to use Laravel's [Validation](https://laravel.com/docs/5.7/validation) component in your schema. + +### Setters & Savers + +Use the `set` method to define custom mutation logic for your field, instead of just setting the value straight on the model property. (Of course, if you're using Eloquent, you could also define [casts](https://laravel.com/docs/5.7/eloquent-mutators#attribute-casting) or [mutators](https://laravel.com/docs/5.7/eloquent-mutators#defining-a-mutator) on your model to achieve a similar thing.) + +```php +$schema->attribute('firstName') + ->set(function ($model, $value, $request) { + return $model->first_name = strtolower($value); + }); +``` + +If your attribute corresponds to some other form of data storage rather than a simple property on your model, you can use the `save` method to provide a closure to be run _after_ your model is saved: + +```php +$schema->attribute('locale') + ->save(function ($model, $value, $request) { + $model->preferences()->update(['value' => $value])->where('key', 'locale'); + }); +``` + +### Filtering + +You can define a field as `filterable` to allow the resource index to be [filtered](https://jsonapi.org/recommendations/#filtering) by the field's value. This works for both attributes and relationships: + +```php +$schema->attribute('firstName') + ->filterable(); + +$schema->hasMany('groups') + ->filterable(); + +// e.g. GET /api/users?filter[firstName]=Toby&filter[groups]=1,2,3 +``` + +You can optionally pass a closure to customize how the filter is applied to the query builder object provided by your adapter: + +```php +$schema->attribute('minPosts') + ->hidden() + ->filterable(function ($query, $value, $request) { + $query->where('postCount', '>=', $value); + }); +``` + +### Sorting + +You can define an attribute as `sortable` to allow the resource index to be [sorted](https://jsonapi.org/format/#fetching-sorting) by the attribute's value: + +```php +$schema->attribute('firstName') + ->sortable(); + +$schema->attribute('lastName') + ->sortable(); + +// e.g. GET /api/users?sort=lastName,firstName +``` + +### Pagination + +By default, resources 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: + +```php +$schema->paginate(50); +``` + +### 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`, `creatableIf`, `notCreatable`, and `notCreatableIf` methods on the schema builder: + +```php +$schema->creatableIf(function ($request) { + return $request->getAttribute('isAdmin'); +}); +``` + +### Deleting Resources + +By default, resources are not [deletable](https://jsonapi.org/format/#crud-deleting) (i.e. `DELETE` requests will return `403 Forbidden`). You can allow them to be deleted using the `deletable`, `deletableIf`, `notDeletable`, and `notDeletableIf` methods on the schema builder: + +```php +$schema->deletableIf(function ($request) { + return $request->getAttribute('isAdmin'); +}); +``` + +### Macros + +You can define macros on the `Tobscure\JsonApiServer\Schema\Attribute` class to aid construction of your API schema. Below is an example that sets up a `rules` macro which will add a validator to validate the attribute value using Laravel's [Validation](https://laravel.com/docs/5.7/validation) component: + +```php +use Tobscure\JsonApiServer\Schema\Attribute; + +Attribute::macro('rules', function ($rules) use ($validator) { + $this->validate(function ($fail, $value) use ($validator, $rules) { + $key = $this->name; + $validation = Validator::make([$key => $value], [$key => $rules]); + + if ($validation->fails()) { + $fail((string) $validation->messages()); + } + }); +}); +``` + +```php +$schema->attribute('username') + ->rules(['required', 'min:3', 'max:30']); +``` + +## Examples + +- [Flarum](https://github.com/flarum/core/tree/master/src/Api) is forum software that uses tobscure/json-api-server to power its API. + +## Contributing + +Feel free to send pull requests or create issues if you come across problems or have great ideas. See the [Contributing Guide](https://github.com/tobscure/json-api-server/blob/master/CONTRIBUTING.md) for more information. + +### Running Tests + +```bash +$ vendor/bin/phpunit +``` + +## License + +This code is published under the [The MIT License](LICENSE). This means you can do almost anything with it, as long as the copyright notice and the accompanying license file is left intact. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e051870 --- /dev/null +++ b/composer.json @@ -0,0 +1,33 @@ +{ + "name": "tobscure/json-api-server", + "require": { + "php": "^7.1", + "illuminate/database": "5.7.*", + "illuminate/events": "5.7.*", + "illuminate/validation": "5.7.*", + "zendframework/zend-diactoros": "^1.8", + "json-api-php/json-api": "^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-message": "^1.0" + }, + "license": "MIT", + "authors": [ + { + "name": "Toby Zerner", + "email": "toby.zerner@gmail.com" + } + ], + "autoload": { + "psr-4": { + "Tobscure\\JsonApiServer\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tobscure\\Tests\\JsonApiServer\\": "tests/" + } + }, + "require-dev": { + "phpunit/phpunit": "^7.4" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..659ff3e --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,28 @@ + + + + + ./tests + + + + + ./src + + + diff --git a/src/Adapter/AdapterInterface.php b/src/Adapter/AdapterInterface.php new file mode 100644 index 0000000..c040cec --- /dev/null +++ b/src/Adapter/AdapterInterface.php @@ -0,0 +1,50 @@ +model = is_string($model) ? new $model : $model; + + if (! $this->model instanceof Model) { + throw new \InvalidArgumentException('Model must be an instance of '.Model::class); + } + } + + public function create() + { + return $this->model->newInstance(); + } + + public function query() + { + return $this->model->query(); + } + + public function find($query, $id) + { + return $query->find($id); + } + + public function get($query): array + { + return $query->get()->all(); + } + + public function getId($model): string + { + return $model->getKey(); + } + + public function getAttribute($model, Attribute $field) + { + return $model->{$field->property}; + } + + public function getHasOne($model, HasOne $field) + { + return $model->{$field->property}; + } + + public function getHasMany($model, HasMany $field): array + { + return $model->{$field->property}->all(); + } + + public function applyAttribute($model, Attribute $field, $value) + { + $model->{$field->property} = $value; + } + + public function applyHasOne($model, HasOne $field, $related) + { + $model->{$field->property}()->associate($related); + } + + public function save($model) + { + $model->save(); + } + + public function saveHasMany($model, HasMany $field, array $related) + { + $model->{$field->property}()->sync(Collection::make($related)); + } + + public function delete($model) + { + $model->delete(); + } + + public function filterByAttribute($query, Attribute $field, $value) + { + $query->where($field->property, $value); + } + + public function filterByHasOne($query, HasOne $field, array $ids) + { + $property = $field->property; + $foreignKey = $query->getModel()->{$property}()->getQualifiedForeignKey(); + + $query->whereIn($foreignKey, $ids); + } + + public function filterByHasMany($query, HasMany $field, array $ids) + { + $property = $field->property; + $relation = $query->getModel()->{$property}(); + $relatedKey = $relation->getRelated()->getQualifiedKeyName(); + + $query->whereHas($property, function ($query) use ($relatedKey, $ids) { + $query->whereIn($relatedKey, $ids); + }); + } + + public function sortByAttribute($query, Attribute $field, string $direction) + { + $query->orderBy($field->property, $direction); + } + + public function paginate($query, int $limit, int $offset) + { + $query->take($limit)->skip($offset); + } + + public function include($query, array $trail) + { + $query->with($this->relationshipTrailToPath($trail)); + } + + public function load($model, array $trail) + { + $model->load($this->relationshipTrailToPath($trail)); + } + + private function relationshipTrailToPath(array $trail) + { + return implode('.', array_map(function ($relationship) { + return $relationship->property; + }, $trail)); + } +} diff --git a/src/Api.php b/src/Api.php new file mode 100644 index 0000000..b0801b2 --- /dev/null +++ b/src/Api.php @@ -0,0 +1,122 @@ +baseUrl = $baseUrl; + } + + public function resource(string $type, $adapter, Closure $buildSchema = null): void + { + $this->resources[$type] = new ResourceType($type, $adapter, $buildSchema); + } + + 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 (new Handler\Index($this, $resource))->handle($request); + + case 'POST': + return (new Handler\Create($this, $resource))->handle($request); + + default: + throw new MethodNotAllowedException; + } + } + + $model = $this->findResource($request, $resource, $segments[1]); + + if ($count === 2) { + switch ($request->getMethod()) { + case 'PATCH': + return (new Handler\Update($this, $resource, $model))->handle($request); + + case 'GET': + return (new Handler\Show($this, $resource, $model))->handle($request); + + case 'DELETE': + return (new Handler\Delete($resource, $model))->handle($request); + + default: + throw new MethodNotAllowedException; + } + } + + // if ($count === 3) { + // return $this->handleRelated($request, $resource, $model, $segments[2]); + // } + + // if ($count === 4 && $segments[2] === 'relationship') { + // return $this->handleRelationship($request, $resource, $model, $segments[3]); + // } + + throw new \RuntimeException; + } + + 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; + } + + public function error(\Throwable $e) + { + $data = new JsonApi\ErrorDocument( + new JsonApi\Error( + new JsonApi\Error\Title($e->getMessage()), + new JsonApi\Error\Detail((string) $e) + ) + ); + + return new JsonApiResponse($data); + } + + public function getBaseUrl(): string + { + return $this->baseUrl; + } +} diff --git a/src/Exception/BadRequestException.php b/src/Exception/BadRequestException.php new file mode 100644 index 0000000..700eed5 --- /dev/null +++ b/src/Exception/BadRequestException.php @@ -0,0 +1,7 @@ +type = $type; + } + + public function getStatusCode() + { + return 404; + } +} diff --git a/src/Exception/UnprocessableEntityException.php b/src/Exception/UnprocessableEntityException.php new file mode 100644 index 0000000..210d1d9 --- /dev/null +++ b/src/Exception/UnprocessableEntityException.php @@ -0,0 +1,9 @@ +getAdapter(); + + $query = $adapter->query(); + + foreach ($resource->getSchema()->scopes as $scope) { + $scope($query, $request); + } + + $model = $adapter->find($query, $id); + + if (! $model) { + throw new ResourceNotFoundException($resource->getType(), $id); + } + + return $model; + } +} diff --git a/src/Handler/Concerns/IncludesData.php b/src/Handler/Concerns/IncludesData.php new file mode 100644 index 0000000..a563286 --- /dev/null +++ b/src/Handler/Concerns/IncludesData.php @@ -0,0 +1,108 @@ +getQueryParams(); + + if (! empty($queryParams['include'])) { + $include = $this->parseInclude($queryParams['include']); + + $this->validateInclude($this->resource, $include); + + return $include; + } + + return $this->defaultInclude($this->resource); + } + + private function parseInclude(string $include): array + { + $tree = []; + + foreach (explode(',', $include) as $path) { + $keys = explode('.', $path); + $array = &$tree; + + foreach ($keys as $key) { + if (! isset($array[$key])) { + $array[$key] = []; + } + + $array = &$array[$key]; + } + } + + return $tree; + } + + private function validateInclude(ResourceType $resource, array $include, string $path = '') + { + $schema = $resource->getSchema(); + + foreach ($include as $name => $nested) { + if (! isset($schema->fields[$name]) + || ! $schema->fields[$name] instanceof Relationship + || ($schema->fields[$name] instanceof HasMany && ! $schema->fields[$name]->includable) + ) { + throw new BadRequestException("Invalid include [{$path}{$name}]"); + } + + $relatedResource = $this->api->getResource($schema->fields[$name]->resource); + + $this->validateInclude($relatedResource, $nested, $name.'.'); + } + } + + private function defaultInclude(ResourceType $resource): array + { + $include = []; + + foreach ($resource->getSchema()->fields as $name => $field) { + if (! $field instanceof Relationship || ! $field->included) { + continue; + } + + $include[$name] = $this->defaultInclude( + $this->api->getResource($field->resource) + ); + } + + return $include; + } + + private function buildRelationshipTrails(ResourceType $resource, array $include): array + { + $schema = $resource->getSchema(); + $trails = []; + + foreach ($include as $name => $nested) { + $relationship = $schema->fields[$name]; + + $trails[] = [$relationship]; + + $relatedResource = $this->api->getResource($relationship->resource); + + $trails = array_merge( + $trails, + array_map( + function ($trail) use ($relationship) { + return array_merge([$relationship], $trail); + }, + $this->buildRelationshipTrails($relatedResource, $nested) + ) + ); + } + + return $trails; + } +} diff --git a/src/Handler/Concerns/SavesData.php b/src/Handler/Concerns/SavesData.php new file mode 100644 index 0000000..4e3299f --- /dev/null +++ b/src/Handler/Concerns/SavesData.php @@ -0,0 +1,211 @@ +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); + + $adapter->save($model); + + $this->saveFields($data, $model, $request); + } + + private function parseData($body): array + { + if (! is_array($body) && ! is_object($body)) { + throw new BadRequestException; + } + + $body = (array) $body; + + if (! isset($body['data'])) { + throw new BadRequestException; + } + + if (isset($body['data']['attributes']) && ! is_array($body['data']['attributes'])) { + throw new BadRequestException; + } + + if (isset($body['data']['relationships']) && ! is_array($body['data']['relationships'])) { + throw new BadRequestException; + } + + return array_merge( + ['attributes' => [], 'relationships' => []], + $body['data'] + ); + } + + private function getModelForIdentifier(Request $request, $identifier) + { + if (! isset($identifier['type']) || ! isset($identifier['id'])) { + throw new BadRequestException('type/id not specified'); + } + + $resource = $this->api->getResource($identifier['type']); + + return $this->findResource($request, $resource, $identifier['id']); + } + + private function assertFieldsExist(array $data) + { + $schema = $this->resource->getSchema(); + + foreach (['attributes', 'relationships'] as $location) { + foreach ($data[$location] as $name => $value) { + if (! isset($schema->fields[$name]) + || $location !== $schema->fields[$name]->location + ) { + throw new BadRequestException("Unknown field [$name]"); + } + } + } + } + + 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)($model, $request)) { + 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); + } + } + } + + private function loadRelatedResources(array &$data, Request $request) + { + $schema = $this->resource->getSchema(); + + foreach ($schema->fields as $name => $field) { + if (! isset($data[$field->location][$name])) { + continue; + } + + $value = &$data[$field->location][$name]; + + if ($field instanceof Schema\HasOne) { + $value = $this->getModelForIdentifier($request, $value['data']); + } elseif ($field instanceof Schema\HasMany) { + $value = array_map(function ($identifier) use ($request) { + return $this->getModelForIdentifier($request, $identifier); + }, $value['data']); + } + } + } + + 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])) { + continue; + } + + $fail = function ($message) use (&$failures, $field, $name) { + $failures[$field->location][$name][] = $message; + }; + + foreach ($field->validators as $validator) { + $validator($fail, $data[$field->location][$name], $model, $request); + } + } + + if (count($failures)) { + throw new UnprocessableEntityException(print_r($failures, true)); + } + } + + private function applyValues(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])) { + continue; + } + + $value = $data[$field->location][$name]; + + if ($field->setter || $field->saver) { + if ($field->setter) { + ($field->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); + } + } + } + + 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])) { + continue; + } + + $value = $data[$field->location][$name]; + + if ($field->saver) { + ($field->saver)($model, $value, $request); + } elseif ($field instanceof Schema\HasMany) { + $adapter->saveHasMany($model, $field, $value); + } + } + } +} diff --git a/src/Handler/Create.php b/src/Handler/Create.php new file mode 100644 index 0000000..4b33d3a --- /dev/null +++ b/src/Handler/Create.php @@ -0,0 +1,39 @@ +api = $api; + $this->resource = $resource; + } + + public function handle(Request $request): Response + { + if (! ($this->resource->getSchema()->isCreatable)($request)) { + throw new ForbiddenException('You cannot create this resource'); + } + + $model = $this->resource->getAdapter()->create(); + + $this->save($model, $request, true); + + return (new Show($this->api, $this->resource, $model)) + ->handle($request) + ->withStatus(201); + } +} diff --git a/src/Handler/Delete.php b/src/Handler/Delete.php new file mode 100644 index 0000000..92f52a9 --- /dev/null +++ b/src/Handler/Delete.php @@ -0,0 +1,33 @@ +resource = $resource; + $this->model = $model; + } + + public function handle(Request $request): Response + { + if (! ($this->resource->getSchema()->isDeletable)($this->model, $request)) { + throw new ForbiddenException('You cannot delete this resource'); + } + + $this->resource->getAdapter()->delete($this->model); + + return new EmptyResponse; + } +} diff --git a/src/Handler/Index.php b/src/Handler/Index.php new file mode 100644 index 0000000..0908757 --- /dev/null +++ b/src/Handler/Index.php @@ -0,0 +1,179 @@ +api = $api; + $this->resource = $resource; + } + + public function handle(Request $request): Response + { + $include = $this->getInclude($request); + + $models = $this->getModels($include, $request); + + $serializer = new Serializer($this->api, $request); + + foreach ($models as $model) { + $serializer->add($this->resource, $model, $include); + } + + return new JsonApiResponse( + new JsonApi\CompoundDocument( + new JsonApi\ResourceCollection(...$serializer->primary()), + new JsonApi\Included(...$serializer->included()) + ) + ); + } + + private function getModels(array $include, Request $request) + { + $adapter = $this->resource->getAdapter(); + + $query = $adapter->query(); + + foreach ($this->resource->getSchema()->scopes as $scope) { + $scope($query, $request); + } + + $queryParams = $request->getQueryParams(); + + if (isset($queryParams['sort'])) { + $this->sort($query, $queryParams['sort'], $request); + } + + if (isset($queryParams['filter'])) { + $this->filter($query, $queryParams['filter'], $request); + } + + $this->paginate($query, $request); + + $this->include($query, $include); + + return $adapter->get($query); + } + + private function sort($query, string $sort, Request $request) + { + $schema = $this->resource->getSchema(); + $adapter = $this->resource->getAdapter(); + + foreach ($this->parseSort($sort) as $name => $direction) { + if (! isset($schema->fields[$name]) + || ! $schema->fields[$name] instanceof Schema\Attribute + || ! $schema->fields[$name]->sortable + ) { + throw new BadRequestException("Invalid sort field [$name]"); + } + + $attribute = $schema->fields[$name]; + + if ($attribute->sorter) { + ($attribute->sorter)($query, $direction, $request); + } else { + $adapter->sortByAttribute($query, $attribute, $direction); + } + } + } + + private function parseSort(string $string): array + { + $sort = []; + $fields = explode(',', $string); + + foreach ($fields as $field) { + if (substr($field, 0, 1) === '-') { + $field = substr($field, 1); + $direction = 'desc'; + } else { + $direction = 'asc'; + } + + $sort[$field] = $direction; + } + + return $sort; + } + + private function paginate($query, Request $request) + { + $queryParams = $request->getQueryParams(); + + $maxLimit = $this->resource->getSchema()->paginate; + + $limit = isset($queryParams['page']['limit']) ? min($maxLimit, (int) $queryParams['page']['limit']) : $maxLimit; + + $offset = isset($queryParams['page']['offset']) ? (int) $queryParams['page']['offset'] : 0; + + if ($offset < 0) { + throw new BadRequestException('page[offset] must be >=0'); + } + + if ($limit) { + $this->resource->getAdapter()->paginate($query, $limit, $offset); + } + } + + private function filter($query, $filter, Request $request) + { + $schema = $this->resource->getSchema(); + $adapter = $this->resource->getAdapter(); + + if (! is_array($filter)) { + throw new BadRequestException('filter must be an array'); + } + + foreach ($filter as $name => $value) { + if (! isset($schema->fields[$name]) + || ! $schema->fields[$name]->filterable + ) { + throw new BadRequestException("Invalid filter [$name]"); + } + + $field = $schema->fields[$name]; + + if ($field->filter) { + ($field->filter)($query, $value, $request); + } elseif ($field instanceof Schema\Attribute) { + $adapter->filterByAttribute($query, $field, $value); + } elseif ($field instanceof Schema\HasOne) { + $value = explode(',', $value); + $adapter->filterByHasOne($query, $field, $value); + } elseif ($field instanceof Schema\HasMany) { + $value = explode(',', $value); + $adapter->filterByHasMany($query, $field, $value); + } + } + } + + private function include($query, array $include) + { + $adapter = $this->resource->getAdapter(); + + $trails = $this->buildRelationshipTrails($this->resource, $include); + + foreach ($trails as $relationships) { + $adapter->include($query, $relationships); + } + } +} diff --git a/src/Handler/Show.php b/src/Handler/Show.php new file mode 100644 index 0000000..d8c110c --- /dev/null +++ b/src/Handler/Show.php @@ -0,0 +1,57 @@ +api = $api; + $this->resource = $resource; + $this->model = $model; + } + + public function handle(Request $request): Response + { + $include = $this->getInclude($request); + + $this->load($include); + + $serializer = new Serializer($this->api, $request); + + $serializer->add($this->resource, $this->model, $include); + + return new JsonApiResponse( + new JsonApi\CompoundDocument( + $serializer->primary()[0], + new JsonApi\Included(...$serializer->included()) + ) + ); + } + + private function load(array $include) + { + $adapter = $this->resource->getAdapter(); + + $trails = $this->buildRelationshipTrails($this->resource, $include); + + foreach ($trails as $relationships) { + $adapter->load($this->model, $relationships); + } + } +} diff --git a/src/Handler/Update.php b/src/Handler/Update.php new file mode 100644 index 0000000..af238a7 --- /dev/null +++ b/src/Handler/Update.php @@ -0,0 +1,34 @@ +api = $api; + $this->resource = $resource; + $this->model = $model; + } + + public function handle(Request $request): Response + { + $adapter = $this->resource->getAdapter(); + + $this->save($this->model, $request); + + return (new Show($this->api, $this->resource, $this->model))->handle($request); + } +} diff --git a/src/JsonApiResponse.php b/src/JsonApiResponse.php new file mode 100644 index 0000000..eddcada --- /dev/null +++ b/src/JsonApiResponse.php @@ -0,0 +1,19 @@ +belongsTo(User::class); + } + + public function posts() + { + return $this->hasMany(Post::class); + } +} diff --git a/src/Models/Group.php b/src/Models/Group.php new file mode 100644 index 0000000..72bc2a3 --- /dev/null +++ b/src/Models/Group.php @@ -0,0 +1,15 @@ +belongsToMany(User::class); + } +} diff --git a/src/Models/Post.php b/src/Models/Post.php new file mode 100644 index 0000000..4c8ddde --- /dev/null +++ b/src/Models/Post.php @@ -0,0 +1,27 @@ +belongsTo(User::class); + } + + public function discussion() + { + return $this->belongsTo(Discussion::class); + } + + public function editedUser() + { + return $this->belongsTo(User::class, 'edited_user_id'); + } +} diff --git a/src/Models/User.php b/src/Models/User.php new file mode 100644 index 0000000..0448eec --- /dev/null +++ b/src/Models/User.php @@ -0,0 +1,22 @@ +hasMany(Post::class); + } + + public function groups() + { + return $this->belongsToMany(Group::class); + } +} diff --git a/src/ResourceType.php b/src/ResourceType.php new file mode 100644 index 0000000..627063b --- /dev/null +++ b/src/ResourceType.php @@ -0,0 +1,45 @@ +type = $type; + $this->adapter = $adapter; + $this->buildSchema = $buildSchema; + } + + public function getType(): string + { + return $this->type; + } + + public function getAdapter(): AdapterInterface + { + return $this->adapter; + } + + public function getSchema(): Builder + { + if (! $this->schema) { + $this->schema = new Builder; + + if ($this->buildSchema) { + ($this->buildSchema)($this->schema); + } + } + + return $this->schema; + } +} diff --git a/src/Schema/Attribute.php b/src/Schema/Attribute.php new file mode 100644 index 0000000..269199b --- /dev/null +++ b/src/Schema/Attribute.php @@ -0,0 +1,30 @@ +property = snake_case($name); + } + + public function sortable(Closure $callback = null) + { + $this->sortable = true; + $this->sorter = $callback; + + return $this; + } +} diff --git a/src/Schema/Builder.php b/src/Schema/Builder.php new file mode 100644 index 0000000..dda30dd --- /dev/null +++ b/src/Schema/Builder.php @@ -0,0 +1,127 @@ +notCreatable(); + $this->notDeletable(); + } + + public function attribute(string $name, string $property = null): Attribute + { + return $this->field(Attribute::class, $name, $property); + } + + public function hasOne(string $name, string $resource = null, string $property = null): HasOne + { + $field = $this->field(HasOne::class, $name, $property); + + if ($resource) { + $field->resource($resource); + } + + return $field; + } + + public function hasMany(string $name, string $resource = null, string $property = null): HasMany + { + $field = $this->field(HasMany::class, $name, $property); + + if ($resource) { + $field->resource($resource); + } + + return $field; + } + + public function paginate(?int $perPage) + { + $this->paginate = $perPage; + } + + 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 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; + }); + } + + 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/Field.php b/src/Schema/Field.php new file mode 100644 index 0000000..f58f1a8 --- /dev/null +++ b/src/Schema/Field.php @@ -0,0 +1,145 @@ +name = $this->property = $name; + + $this->visible(); + $this->readonly(); + } + + public function property(string $property) + { + $this->property = $property; + + return $this; + } + + public function visibleIf(Closure $condition) + { + $this->isVisible = $condition; + + return $this; + } + + public function visible() + { + 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; + + return $this; + } + + public function writable() + { + return $this->writableIf(function () { + return true; + }); + } + + public function readonlyIf(Closure $condition) + { + return $this->writableIf(function (...$args) use ($condition) { + return ! $condition(...$args); + }); + } + + public function readonly() + { + return $this->readonlyIf(function () { + return true; + }); + } + + public function get($callback) + { + $this->getter = $this->wrap($callback); + + return $this; + } + + public function set(Closure $callback) + { + $this->setter = $callback; + + return $this; + } + + public function save(Closure $callback) + { + $this->saver = $callback; + + return $this; + } + + public function default($value) + { + $this->default = $this->wrap($value); + + return $this; + } + + public function validate(Closure $callback) + { + $this->validators[] = $callback; + + return $this; + } + + public function filterable(Closure $callback = null) + { + $this->filterable = true; + $this->filter = $callback; + + return $this; + } + + private function wrap($value) + { + if (! $value instanceof Closure) { + $value = function () use ($value) { + return $value; + }; + } + + return $value; + } +} diff --git a/src/Schema/HasMany.php b/src/Schema/HasMany.php new file mode 100644 index 0000000..77536bd --- /dev/null +++ b/src/Schema/HasMany.php @@ -0,0 +1,29 @@ +resource = $name; + } + + public function includable() + { + $this->includable = true; + + return $this; + } + + public function included() + { + $this->includable(); + + return parent::included(); + } +} diff --git a/src/Schema/HasOne.php b/src/Schema/HasOne.php new file mode 100644 index 0000000..9fa140c --- /dev/null +++ b/src/Schema/HasOne.php @@ -0,0 +1,13 @@ +resource = str_plural($name); + } +} diff --git a/src/Schema/Relationship.php b/src/Schema/Relationship.php new file mode 100644 index 0000000..af903cf --- /dev/null +++ b/src/Schema/Relationship.php @@ -0,0 +1,29 @@ +resource = $resource; + + return $this; + } + + public function included() + { + $this->included = true; + + return $this; + } +} diff --git a/src/Serializer.php b/src/Serializer.php new file mode 100644 index 0000000..1f9d72c --- /dev/null +++ b/src/Serializer.php @@ -0,0 +1,176 @@ +api = $api; + $this->request = $request; + } + + public function add(ResourceType $resource, $model, array $include) + { + $data = $this->addToMap($resource, $model, $include); + + $this->primary[] = $data['type'].':'.$data['id']; + } + + private function addToMap(ResourceType $resource, $model, array $include) + { + $adapter = $resource->getAdapter(); + $schema = $resource->getSchema(); + + $data = [ + 'type' => $resource->getType(), + 'id' => $adapter->getId($model), + 'fields' => [], + 'links' => [] + ]; + + foreach ($schema->fields as $name => $field) { + if (($field instanceof Schema\Relationship && ! isset($include[$name])) + || ! ($field->isVisible)($model, $this->request) + ) { + continue; + } + + $value = $this->getValue($field, $adapter, $model); + + if ($field instanceof Schema\Attribute) { + $value = $this->attribute($field, $value); + } elseif ($field instanceof Schema\HasOne) { + $value = $this->toOne($field, $value, $include[$name] ?? []); + } elseif ($field instanceof Schema\HasMany) { + $value = $this->toMany($field, $value, $include[$name] ?? []); + } + + $data['fields'][$name] = $value; + } + + $data['links']['self'] = new JsonApi\Link\SelfLink($this->api->getBaseUrl().'/'.$data['type'].'/'.$data['id']); + + $this->merge($data); + + return $data; + } + + private function attribute(Schema\Attribute $field, $value): JsonApi\Attribute + { + if ($value instanceof DateTimeInterface) { + $value = $value->format(DateTime::RFC3339); + } + + return new JsonApi\Attribute($field->name, $value); + } + + private function toOne(Schema\Relationship $field, $value, array $include) + { + if (! $value) { + return new JsonApi\ToNull($field->name); + } + + $identifier = $this->addRelated($field, $value, $include); + + return new JsonApi\ToOne($field->name, $identifier); + } + + private function toMany(Schema\Relationship $field, $value, array $include): JsonApi\ToMany + { + $identifiers = []; + + foreach ($value as $relatedModel) { + $identifiers[] = $this->addRelated($field, $relatedModel, $include); + } + + return new JsonApi\ToMany( + $field->name, + new JsonApi\ResourceIdentifierCollection(...$identifiers) + ); + } + + private function addRelated(Schema\Relationship $field, $model, array $include): JsonApi\ResourceIdentifier + { + $relatedResource = $this->api->getResource($field->resource); + + return $this->resourceIdentifier( + $this->addToMap($relatedResource, $model, $include) + ); + } + + private function getValue(Schema\Field $field, AdapterInterface $adapter, $model) + { + if ($field->getter) { + return ($field->getter)($model, $this->request); + } elseif ($field instanceof Schema\Attribute) { + return $adapter->getAttribute($model, $field); + } elseif ($field instanceof Schema\HasOne) { + return $adapter->getHasOne($model, $field); + } elseif ($field instanceof Schema\HasMany) { + return $adapter->getHasMany($model, $field); + } + } + + private function merge($data): void + { + $key = $data['type'].':'.$data['id']; + + if (isset($this->map[$key])) { + $this->map[$key]['fields'] = array_merge($this->map[$key]['fields'], $data['fields']); + $this->map[$key]['links'] = array_merge($this->map[$key]['links'], $data['links']); + } else { + $this->map[$key] = $data; + } + } + + public function primary(): array + { + $primary = array_values(array_intersect_key($this->map, array_flip($this->primary))); + + return $this->resourceObjects($primary); + } + + public function included(): array + { + $included = array_values(array_diff_key($this->map, array_flip($this->primary))); + + return $this->resourceObjects($included); + } + + private function resourceObjects(array $items): array + { + return array_map(function ($data) { + return $this->resourceObject($data); + }, $items); + } + + private function resourceObject(array $data): JsonApi\ResourceObject + { + return new JsonApi\ResourceObject( + $data['type'], + $data['id'], + ...array_values($data['fields']), + ...array_values($data['links']) + ); + } + + private function resourceIdentifier(array $data): JsonApi\ResourceIdentifier + { + return new JsonApi\ResourceIdentifier( + $data['type'], + $data['id'] + ); + } +} diff --git a/tests/AbstractTestCase.php b/tests/AbstractTestCase.php new file mode 100644 index 0000000..a8c59fd --- /dev/null +++ b/tests/AbstractTestCase.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\Tests\JsonApiServer; + +use PHPUnit\Framework\TestCase; +use Zend\Diactoros\ServerRequest; +use Zend\Diactoros\Uri; + +abstract class AbstractTestCase extends TestCase +{ + public static function assertEncodesTo(string $expected, $obj, string $message = '') + { + self::assertEquals( + json_decode($expected), + json_decode(json_encode($obj, JSON_UNESCAPED_SLASHES)), + $message + ); + } + + protected function buildRequest(string $method, string $uri): ServerRequest + { + return (new ServerRequest()) + ->withMethod($method) + ->withUri(new Uri($uri)); + } +} diff --git a/tests/CreateTest.php b/tests/CreateTest.php new file mode 100644 index 0000000..d35ef1f --- /dev/null +++ b/tests/CreateTest.php @@ -0,0 +1,202 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\Tests\JsonApiServer; + +use Tobscure\JsonApiServer\Api; +use Tobscure\JsonApiServer\Exception\BadRequestException; +use Tobscure\JsonApiServer\Exception\ForbiddenException; +use Tobscure\JsonApiServer\Serializer; +use Tobscure\JsonApiServer\Schema\Builder; +use Psr\Http\Message\ServerRequestInterface as Request; +use JsonApiPhp\JsonApi; +use Zend\Diactoros\ServerRequest; +use Zend\Diactoros\Uri; + +class CreateTest extends AbstractTestCase +{ + public function testResourceNotCreatableByDefault() + { + $api = new Api('http://example.com'); + + $api->resource('users', new MockAdapter(), function (Builder $schema) { + // + }); + + $request = $this->buildRequest('POST', '/users'); + + $this->expectException(ForbiddenException::class); + $this->expectExceptionMessage('You cannot create this resource'); + + $api->handle($request); + } + + public function testCreateResourceValidatesBody() + { + $api = new Api('http://example.com'); + + $api->resource('users', new MockAdapter(), function (Builder $schema) { + $schema->creatable(); + }); + + $request = $this->buildRequest('POST', '/users'); + + $this->expectException(BadRequestException::class); + + $api->handle($request); + } + + public function testCreateResource() + { + $api = new Api('http://example.com'); + + $api->resource('users', $adapter = new MockAdapter(), function (Builder $schema) { + $schema->creatable(); + + $schema->attribute('name')->writable(); + }); + + $request = $this->buildRequest('POST', '/users') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'name' => 'Toby' + ] + ] + ]); + + $response = $api->handle($request); + + $this->assertTrue($adapter->createdModel->saveWasCalled); + + $this->assertEquals(201, $response->getStatusCode()); + $this->assertEquals( + [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'name' => 'Toby' + ], + 'links' => [ + 'self' => 'http://example.com/users/1' + ] + ], + json_decode($response->getBody(), true)['data'] + ); + } + + public function testAttributeWritable() + { + $request = $this->buildRequest('POST', '/users') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'writable1' => 'value', + 'writable2' => 'value', + 'writable3' => 'value', + ] + ] + ]); + + $api = new Api('http://example.com'); + + $api->resource('users', $adapter = new MockAdapter(), function (Builder $schema) use ($adapter, $request) { + $schema->creatable(); + + $schema->attribute('writable1')->writable(); + + $schema->attribute('writable2')->writableIf(function ($arg1, $arg2) use ($adapter, $request) { + $this->assertEquals($adapter->createdModel, $arg1); + $this->assertEquals($request, $arg2); + return true; + }); + + $schema->attribute('writable3')->readonlyIf(function ($arg1, $arg2) use ($adapter, $request) { + $this->assertEquals($adapter->createdModel, $arg1); + $this->assertEquals($request, $arg2); + return false; + }); + }); + + $response = $api->handle($request); + + $this->assertEquals(201, $response->getStatusCode()); + } + + public function testAttributeReadonly() + { + $request = $this->buildRequest('POST', '/users') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'readonly' => 'value', + ] + ] + ]); + + $api = new Api('http://example.com'); + + $api->resource('users', $adapter = new MockAdapter(), function (Builder $schema) use ($adapter, $request) { + $schema->creatable(); + + $schema->attribute('readonly')->readonly(); + }); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Field [readonly] is not writable'); + + $api->handle($request); + } + + public function testAttributeDefault() + { + $request = $this->buildRequest('POST', '/users') + ->withParsedBody([ + 'data' => [ + 'type' => 'users', + 'id' => '1', + 'attributes' => [ + 'attribute3' => 'userValue' + ] + ] + ]); + + $api = new Api('http://example.com'); + + $api->resource('users', $adapter = new MockAdapter(), function (Builder $schema) use ($request) { + $schema->creatable(); + + $schema->attribute('attribute1')->default('defaultValue'); + $schema->attribute('attribute2')->default(function ($arg1) use ($request) { + $this->assertEquals($request, $arg1); + return 'defaultValue'; + }); + $schema->attribute('attribute3')->writable()->default('defaultValue'); + }); + + $response = $api->handle($request); + + $this->assertEquals( + [ + 'attribute1' => 'defaultValue', + 'attribute2' => 'defaultValue', + 'attribute3' => 'userValue' + ], + json_decode($response->getBody(), true)['data']['attributes'] + ); + } +} diff --git a/tests/DeleteTest.php b/tests/DeleteTest.php new file mode 100644 index 0000000..b2e437f --- /dev/null +++ b/tests/DeleteTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\Tests\JsonApiServer; + +use Tobscure\JsonApiServer\Api; +use Tobscure\JsonApiServer\Exception\BadRequestException; +use Tobscure\JsonApiServer\Exception\ForbiddenException; +use Tobscure\JsonApiServer\Serializer; +use Tobscure\JsonApiServer\Schema\Builder; +use Psr\Http\Message\ServerRequestInterface as Request; +use JsonApiPhp\JsonApi; +use Zend\Diactoros\ServerRequest; +use Zend\Diactoros\Uri; + +class DeleteTest extends AbstractTestCase +{ + public function testResourceNotDeletableByDefault() + { + $api = new Api('http://example.com'); + + $api->resource('users', new MockAdapter(), function (Builder $schema) { + // + }); + + $request = $this->buildRequest('DELETE', '/users/1'); + + $this->expectException(ForbiddenException::class); + $this->expectExceptionMessage('You cannot delete this resource'); + + $api->handle($request); + } + + public function testDeleteResource() + { + $usersAdapter = new MockAdapter([ + '1' => $user = (object)['id' => '1'] + ]); + + $api = new Api('http://example.com'); + + $api->resource('users', $usersAdapter, function (Builder $schema) { + $schema->deletable(); + }); + + $request = $this->buildRequest('DELETE', '/users/1'); + $response = $api->handle($request); + + $this->assertEquals(204, $response->getStatusCode()); + $this->assertTrue($user->deleteWasCalled); + } +} diff --git a/tests/MockAdapter.php b/tests/MockAdapter.php new file mode 100644 index 0000000..c9b150e --- /dev/null +++ b/tests/MockAdapter.php @@ -0,0 +1,123 @@ +models = $models; + } + + public function create() + { + return $this->createdModel = (object) []; + } + + public function query() + { + return (object) []; + } + + public function find($query, $id) + { + return $this->models[$id] ?? (object) ['id' => $id]; + } + + public function get($query): array + { + return array_values($this->models); + } + + public function getId($model): string + { + return $model->id; + } + + public function getAttribute($model, Attribute $attribute) + { + return $model->{$attribute->property} ?? 'default'; + } + + public function getHasOne($model, HasOne $relationship) + { + return $model->{$relationship->property} ?? null; + } + + public function getHasMany($model, HasMany $relationship): array + { + return $model->{$relationship->property} ?? []; + } + + public function applyAttribute($model, Attribute $attribute, $value) + { + $model->{$attribute->property} = $value; + } + + public function applyHasOne($model, HasOne $relationship, $related) + { + $model->{$relationship->property} = $related; + } + + public function save($model) + { + $model->saveWasCalled = true; + + if (empty($model->id)) { + $model->id = '1'; + } + } + + public function saveHasMany($model, HasMany $relationship, array $related) + { + $model->saveHasManyWasCalled = true; + } + + public function delete($model) + { + $model->deleteWasCalled = true; + } + + public function filterByAttribute($query, Attribute $attribute, $value) + { + $query->filters[] = [$attribute, $value]; + } + + public function filterByHasOne($query, HasOne $relationship, array $ids) + { + $query->filters[] = [$relationship, $ids]; + } + + public function filterByHasMany($query, HasMany $relationship, array $ids) + { + $query->filters[] = [$relationship, $ids]; + } + + public function sortByAttribute($query, Attribute $attribute, string $direction) + { + $query->sort[] = [$attribute, $direction]; + } + + public function paginate($query, int $limit, int $offset) + { + $query->paginate[] = [$limit, $offset]; + } + + public function include($query, array $relationships) + { + $query->include[] = $relationships; + } + + public function load($model, array $relationships) + { + $model->load[] = $relationships; + } +} diff --git a/tests/ShowTest.php b/tests/ShowTest.php new file mode 100644 index 0000000..a00bd29 --- /dev/null +++ b/tests/ShowTest.php @@ -0,0 +1,602 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\Tests\JsonApiServer; + +use Tobscure\JsonApiServer\Api; +use Tobscure\JsonApiServer\Exception\BadRequestException; +use Tobscure\JsonApiServer\Serializer; +use Tobscure\JsonApiServer\Schema\Builder; +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() + { + $api = new Api('http://example.com'); + $api->resource('users', new MockAdapter(), function (Builder $schema) { + // no fields + }); + + $request = $this->buildRequest('GET', '/users/1'); + $response = $api->handle($request); + + $this->assertEquals($response->getStatusCode(), 200); + $this->assertEquals( + [ + 'type' => 'users', + 'id' => '1', + 'links' => [ + 'self' => 'http://example.com/users/1' + ] + ], + json_decode($response->getBody(), true)['data'] + ); + } + + public function testAttributes() + { + $adapter = new MockAdapter([ + '1' => (object) [ + 'id' => '1', + 'attribute1' => 'value1', + 'property2' => 'value2', + 'property3' => 'value3' + ] + ]); + + $request = $this->buildRequest('GET', '/users/1'); + + $api = new Api('http://example.com'); + + $api->resource('users', $adapter, function (Builder $schema) { + $schema->attribute('attribute1'); + $schema->attribute('attribute2', 'property2'); + $schema->attribute('attribute3')->property('property3'); + }); + + $response = $api->handle($request); + + $this->assertArraySubset( + [ + 'attributes' => [ + 'attribute1' => 'value1', + 'attribute2' => 'value2', + 'attribute3' => 'value3' + ] + ], + json_decode($response->getBody(), true)['data'] + ); + } + + public function testAttributeGetter() + { + $adapter = new MockAdapter([ + '1' => $model = (object) ['id' => '1'] + ]); + + $request = $this->buildRequest('GET', '/users/1'); + + $api = new Api('http://example.com'); + + $api->resource('users', $adapter, function (Builder $schema) use ($model, $request) { + $schema->attribute('attribute1') + ->get(function ($arg1, $arg2) use ($model, $request) { + $this->assertEquals($model, $arg1); + $this->assertEquals($request, $arg2); + return 'value1'; + }); + }); + + $response = $api->handle($request); + + $this->assertEquals($response->getStatusCode(), 200); + $this->assertArraySubset( + [ + 'attributes' => [ + 'attribute1' => 'value1' + ] + ], + json_decode($response->getBody(), true)['data'] + ); + } + + public function testAttributeVisibility() + { + $adapter = new MockAdapter([ + '1' => $model = (object) ['id' => '1'] + ]); + + $request = $this->buildRequest('GET', '/users/1'); + + $api = new Api('http://example.com'); + $api->resource('users', $adapter, function (Builder $schema) use ($model, $request) { + $schema->attribute('visible1'); + + $schema->attribute('visible2')->visible(); + + $schema->attribute('visible3')->visibleIf(function ($arg1, $arg2) use ($model, $request) { + $this->assertEquals($model, $arg1); + $this->assertEquals($request, $arg2); + return true; + }); + + $schema->attribute('visible4')->hiddenIf(function ($arg1, $arg2) use ($model, $request) { + $this->assertEquals($model, $arg1); + $this->assertEquals($request, $arg2); + return false; + }); + + $schema->attribute('hidden1')->hidden(); + + $schema->attribute('hidden2')->visibleIf(function () { + return false; + }); + + $schema->attribute('hidden3')->hiddenIf(function () { + return true; + }); + }); + + $response = $api->handle($request); + + $attributes = json_decode($response->getBody(), true)['data']['attributes']; + + $this->assertArrayHasKey('visible1', $attributes); + $this->assertArrayHasKey('visible2', $attributes); + $this->assertArrayHasKey('visible3', $attributes); + $this->assertArrayHasKey('visible4', $attributes); + + $this->assertArrayNotHasKey('hidden1', $attributes); + $this->assertArrayNotHasKey('hidden2', $attributes); + $this->assertArrayNotHasKey('hidden3', $attributes); + } + + public function testHasOneRelationship() + { + $phonesAdapter = new MockAdapter([ + '1' => $phone1 = (object) ['id' => '1', 'number' => '8881'], + '2' => $phone2 = (object) ['id' => '2', 'number' => '8882'], + '3' => $phone3 = (object) ['id' => '3', 'number' => '8883'] + ]); + + $usersAdapter = new MockAdapter([ + '1' => (object) [ + 'id' => '1', + 'phone' => $phone1, + 'property2' => $phone2, + 'property3' => $phone3, + ] + ]); + + $request = $this->buildRequest('GET', '/users/1'); + + $api = new Api('http://example.com'); + + $api->resource('users', $usersAdapter, function (Builder $schema) { + $schema->hasOne('phone') + ->included(); + + $schema->hasOne('phone2', 'phones', 'property2') + ->included(); + + $schema->hasOne('phone3') + ->resource('phones') + ->property('property3') + ->included(); + }); + + $api->resource('phones', $phonesAdapter, function (Builder $schema) { + $schema->attribute('number'); + }); + + $response = $api->handle($request); + + $this->assertArraySubset( + [ + 'relationships' => [ + 'phone' => [ + 'data' => ['type' => 'phones', 'id' => '1'] + ], + 'phone2' => [ + 'data' => ['type' => 'phones', 'id' => '2'] + ], + 'phone3' => [ + 'data' => ['type' => 'phones', 'id' => '3'] + ] + ] + ], + json_decode($response->getBody(), true)['data'] + ); + + $this->assertEquals( + [ + [ + 'type' => 'phones', + 'id' => '1', + 'attributes' => ['number' => '8881'], + 'links' => [ + 'self' => 'http://example.com/phones/1' + ] + ], + [ + 'type' => 'phones', + 'id' => '2', + 'attributes' => ['number' => '8882'], + 'links' => [ + 'self' => 'http://example.com/phones/2' + ] + ], + [ + 'type' => 'phones', + 'id' => '3', + 'attributes' => ['number' => '8883'], + 'links' => [ + 'self' => 'http://example.com/phones/3' + ] + ] + ], + json_decode($response->getBody(), true)['included'] + ); + } + + public function testHasOneRelationshipInclusion() + { + $phonesAdapter = new MockAdapter([ + '1' => $phone1 = (object) ['id' => '1', 'number' => '8881'], + '2' => $phone2 = (object) ['id' => '2', 'number' => '8882'] + ]); + + $usersAdapter = new MockAdapter([ + '1' => (object) [ + 'id' => '1', + 'phone' => $phone1, + 'property2' => $phone2 + ] + ]); + + $request = $this->buildRequest('GET', '/users/1'); + + $api = new Api('http://example.com'); + + $api->resource('users', $usersAdapter, function (Builder $schema) { + $schema->hasOne('phone'); + + $schema->hasOne('phone2', 'phones', 'property2') + ->included(); + }); + + $api->resource('phones', $phonesAdapter, function (Builder $schema) { + $schema->attribute('number'); + }); + + $response = $api->handle($request); + + $this->assertArraySubset( + [ + 'relationships' => [ + 'phone2' => [ + 'data' => ['type' => 'phones', 'id' => '2'] + ] + ] + ], + json_decode($response->getBody(), true)['data'] + ); + + $this->assertEquals( + [ + [ + 'type' => 'phones', + 'id' => '2', + 'attributes' => ['number' => '8882'], + 'links' => [ + 'self' => 'http://example.com/phones/2' + ] + ] + ], + json_decode($response->getBody(), true)['included'] + ); + + $response = $api->handle( + $request->withQueryParams(['include' => 'phone']) + ); + + $this->assertArraySubset( + [ + 'relationships' => [ + 'phone' => [ + 'data' => ['type' => 'phones', 'id' => '1'] + ] + ] + ], + json_decode($response->getBody(), true)['data'] + ); + + $this->assertEquals( + [ + [ + 'type' => 'phones', + 'id' => '1', + 'attributes' => ['number' => '8881'], + 'links' => [ + 'self' => 'http://example.com/phones/1' + ] + ] + ], + json_decode($response->getBody(), true)['included'] + ); + } + + public function testHasManyRelationshipNotIncludableByDefault() + { + $api = new Api('http://example.com'); + + $api->resource('users', new MockAdapter(), function (Builder $schema) { + $schema->hasMany('groups'); + }); + + $request = $this->buildRequest('GET', '/users/1') + ->withQueryParams(['include' => 'groups']); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Invalid include [groups]'); + + $api->handle($request); + } + + public function testHasManyRelationshipNotIncludedByDefault() + { + $usersAdapter = new MockAdapter([ + '1' => (object) [ + 'id' => '1', + 'groups' => [ + (object) ['id' => '1'], + (object) ['id' => '2'], + ] + ] + ]); + + $api = new Api('http://example.com'); + + $api->resource('users', $usersAdapter, function (Builder $schema) { + $schema->hasMany('groups'); + }); + + $api->resource('groups', new MockAdapter()); + + $request = $this->buildRequest('GET', '/users/1'); + + $response = $api->handle($request); + + $body = json_decode($response->getBody(), true); + + $this->assertArrayNotHasKey('relationships', $body['data']); + $this->assertArrayNotHasKey('included', $body); + } + + public function testHasManyRelationshipInclusion() + { + $groupsAdapter = new MockAdapter([ + '1' => $group1 = (object) ['id' => '1', 'name' => 'Admin'], + '2' => $group2 = (object) ['id' => '2', 'name' => 'Mod'], + '3' => $group3 = (object) ['id' => '3', 'name' => 'Member'], + '4' => $group4 = (object) ['id' => '4', 'name' => 'Guest'] + ]); + + $usersAdapter = new MockAdapter([ + '1' => $user = (object) [ + 'id' => '1', + 'property1' => [$group1, $group2], + 'property2' => [$group3, $group4] + ] + ]); + + $api = new Api('http://example.com'); + + $relationships = []; + + $api->resource('users', $usersAdapter, function (Builder $schema) use (&$relationships) { + $relationships[] = $schema->hasMany('groups1', 'groups', 'property1') + ->included(); + + $relationships[] = $schema->hasMany('groups2', 'groups', 'property2') + ->includable(); + }); + + $api->resource('groups', $groupsAdapter, function (Builder $schema) { + $schema->attribute('name'); + }); + + $request = $this->buildRequest('GET', '/users/1'); + + $response = $api->handle($request); + + $this->assertEquals([[$relationships[0]]], $user->load); + + $this->assertArraySubset( + [ + 'relationships' => [ + 'groups1' => [ + 'data' => [ + ['type' => 'groups', 'id' => '1'], + ['type' => 'groups', 'id' => '2'] + ] + ] + ] + ], + json_decode($response->getBody(), true)['data'] + ); + + $this->assertEquals( + [ + [ + 'type' => 'groups', + 'id' => '1', + 'attributes' => ['name' => 'Admin'], + 'links' => [ + 'self' => 'http://example.com/groups/1' + ] + ], + [ + 'type' => 'groups', + 'id' => '2', + 'attributes' => ['name' => 'Mod'], + 'links' => [ + 'self' => 'http://example.com/groups/2' + ] + ] + ], + json_decode($response->getBody(), true)['included'] + ); + + $user->load = []; + + $response = $api->handle( + $request->withQueryParams(['include' => 'groups2']) + ); + + $this->assertEquals([[$relationships[1]]], $user->load); + + $this->assertArraySubset( + [ + 'relationships' => [ + 'groups2' => [ + 'data' => [ + ['type' => 'groups', 'id' => '3'], + ['type' => 'groups', 'id' => '4'], + ] + ] + ] + ], + json_decode($response->getBody(), true)['data'] + ); + + $this->assertEquals( + [ + [ + 'type' => 'groups', + 'id' => '3', + 'attributes' => ['name' => 'Member'], + 'links' => [ + 'self' => 'http://example.com/groups/3' + ] + ], + [ + 'type' => 'groups', + 'id' => '4', + 'attributes' => ['name' => 'Guest'], + 'links' => [ + 'self' => 'http://example.com/groups/4' + ] + ], + ], + json_decode($response->getBody(), true)['included'] + ); + } + + public function testNestedRelationshipInclusion() + { + $groupsAdapter = new MockAdapter([ + '1' => $group1 = (object) ['id' => '1', 'name' => 'Admin'], + '2' => $group2 = (object) ['id' => '2', 'name' => 'Mod'] + ]); + + $usersAdapter = new MockAdapter([ + '1' => $user = (object) ['id' => '1', 'groups' => [$group1, $group2]] + ]); + + $postsAdapter = new MockAdapter([ + '1' => $post = (object) ['id' => '1', 'user' => $user] + ]); + + $api = new Api('http://example.com'); + + $relationships = []; + + $api->resource('posts', $postsAdapter, function (Builder $schema) use (&$relationships) { + $relationships[] = $schema->hasOne('user')->included(); + }); + + $api->resource('users', $usersAdapter, function (Builder $schema) use (&$relationships) { + $relationships[] = $schema->hasMany('groups')->included(); + }); + + $api->resource('groups', $groupsAdapter, function (Builder $schema) { + $schema->attribute('name'); + }); + + $request = $this->buildRequest('GET', '/posts/1'); + + $response = $api->handle($request); + + $this->assertEquals([$relationships[0]], $post->load[0]); + $this->assertEquals($relationships, $post->load[1]); + + $this->assertArraySubset( + [ + 'relationships' => [ + 'user' => [ + 'data' => ['type' => 'users', 'id' => '1'] + ] + ] + ], + json_decode($response->getBody(), true)['data'] + ); + + $included = json_decode($response->getBody(), true)['included']; + + $this->assertContains( + [ + 'type' => 'users', + 'id' => '1', + 'relationships' => [ + 'groups' => [ + 'data' => [ + ['type' => 'groups', 'id' => '1'], + ['type' => 'groups', 'id' => '2'] + ] + ] + ], + 'links' => [ + 'self' => 'http://example.com/users/1' + ] + ], + $included + ); + + $this->assertContains( + [ + 'type' => 'groups', + 'id' => '1', + 'attributes' => ['name' => 'Admin'], + 'links' => [ + 'self' => 'http://example.com/groups/1' + ] + ], + $included + ); + + $this->assertContains( + [ + 'type' => 'groups', + 'id' => '2', + 'attributes' => ['name' => 'Mod'], + 'links' => [ + 'self' => 'http://example.com/groups/2' + ] + ], + $included + ); + } +}