Initial commit

This commit is contained in:
Toby Zerner 2018-12-14 11:57:28 +10:30
commit e8bf26eaae
40 changed files with 3234 additions and 0 deletions

6
.gitattributes vendored Normal file
View File

@ -0,0 +1,6 @@
.gitattributes export-ignore
.gitignore export-ignore
.travis.yml export-ignore
phpunit.xml export-ignore
tests export-ignore

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
composer.lock
vendor

11
.travis.yml Normal file
View File

@ -0,0 +1,11 @@
language: php
php:
- 7.1
- 7.2
install:
- composer install --no-interaction --prefer-source
script:
- vendor/bin/phpunit

373
README.md Normal file
View File

@ -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.

33
composer.json Normal file
View File

@ -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"
}
}

28
phpunit.xml Normal file
View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
beStrictAboutTestsThatDoNotTestAnything="true"
beStrictAboutOutputDuringTests="true"
bootstrap="vendor/autoload.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
failOnRisky="true"
failOnWarning="true"
processIsolation="false"
stopOnError="false"
stopOnFailure="false"
verbose="true"
>
<testsuites>
<testsuite name="JSON:API Server Test Suite">
<directory suffix="Test.php">./tests</directory>
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">./src</directory>
</whitelist>
</filter>
</phpunit>

View File

@ -0,0 +1,50 @@
<?php
namespace Tobscure\JsonApiServer\Adapter;
use Tobscure\JsonApiServer\Schema\Attribute;
use Tobscure\JsonApiServer\Schema\HasMany;
use Tobscure\JsonApiServer\Schema\HasOne;
interface AdapterInterface
{
public function create();
public function query();
public function find($query, $id);
public function get($query): array;
public function getId($model): string;
public function getAttribute($model, Attribute $attribute);
public function getHasOne($model, HasOne $relationship);
public function getHasMany($model, HasMany $relationship): array;
public function applyAttribute($model, Attribute $attribute, $value);
public function applyHasOne($model, HasOne $relationship, $related);
public function save($model);
public function saveHasMany($model, HasMany $relationship, array $related);
public function delete($model);
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 include($query, array $relationships);
public function load($model, array $relationships);
}

View File

@ -0,0 +1,142 @@
<?php
namespace Tobscure\JsonApiServer\Adapter;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Tobscure\JsonApiServer\Schema\Attribute;
use Tobscure\JsonApiServer\Schema\HasMany;
use Tobscure\JsonApiServer\Schema\HasOne;
class EloquentAdapter implements AdapterInterface
{
/**
* @var Model
*/
protected $model;
public function __construct($model)
{
$this->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));
}
}

122
src/Api.php Normal file
View File

@ -0,0 +1,122 @@
<?php
namespace Tobscure\JsonApiServer;
use Closure;
use JsonApiPhp\JsonApi;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface;
use Tobscure\JsonApiServer\Exception\MethodNotAllowedException;
use Tobscure\JsonApiServer\Exception\ResourceNotFoundException;
use Tobscure\JsonApiServer\Handler\Concerns\FindsResources;
class Api implements RequestHandlerInterface
{
use FindsResources;
protected $resources = [];
protected $baseUrl;
public function __construct(string $baseUrl)
{
$this->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;
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace Tobscure\JsonApiServer\Exception;
class BadRequestException extends \DomainException
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace Tobscure\JsonApiServer\Exception;
class ForbiddenException extends \DomainException
{
}

View File

@ -0,0 +1,13 @@
<?php
namespace Tobscure\JsonApiServer\Exception;
use Exception;
class MethodNotAllowedException extends \DomainException
{
public function __construct($message = null, $code = 405, Exception $previous = null)
{
parent::__construct($message, $code, $previous);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace Tobscure\JsonApiServer\Exception;
use RuntimeException;
class ResourceNotFoundException extends RuntimeException
{
protected $type;
public function __construct(string $type, $id = null)
{
parent::__construct("Resource [$type] not found.");
$this->type = $type;
}
public function getStatusCode()
{
return 404;
}
}

View File

@ -0,0 +1,9 @@
<?php
namespace Tobscure\JsonApiServer\Exception;
use Exception;
class UnprocessableEntityException extends \DomainException
{
}

View File

@ -0,0 +1,29 @@
<?php
namespace Tobscure\JsonApiServer\Handler\Concerns;
use Psr\Http\Message\ServerRequestInterface as Request;
use Tobscure\JsonApiServer\Exception\ResourceNotFoundException;
use Tobscure\JsonApiServer\ResourceType;
trait FindsResources
{
private function findResource(Request $request, ResourceType $resource, $id)
{
$adapter = $resource->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;
}
}

View File

@ -0,0 +1,108 @@
<?php
namespace Tobscure\JsonApiServer\Handler\Concerns;
use Psr\Http\Message\ServerRequestInterface as Request;
use Tobscure\JsonApiServer\Exception\BadRequestException;
use Tobscure\JsonApiServer\ResourceType;
use Tobscure\JsonApiServer\Schema\HasMany;
use Tobscure\JsonApiServer\Schema\Relationship;
trait IncludesData
{
private function getInclude(Request $request): array
{
$queryParams = $request->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;
}
}

View File

@ -0,0 +1,211 @@
<?php
namespace Tobscure\JsonApiServer\Handler\Concerns;
use Psr\Http\Message\ServerRequestInterface as Request;
use Tobscure\JsonApiServer\Exception\BadRequestException;
use Tobscure\JsonApiServer\Exception\UnprocessableEntityException;
use Tobscure\JsonApiServer\ResourceType;
use Tobscure\JsonApiServer\Schema;
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);
$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);
}
}
}
}

39
src/Handler/Create.php Normal file
View File

@ -0,0 +1,39 @@
<?php
namespace Tobscure\JsonApiServer\Handler;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface;
use Tobscure\JsonApiServer\Api;
use Tobscure\JsonApiServer\Exception\ForbiddenException;
use Tobscure\JsonApiServer\ResourceType;
class Create implements RequestHandlerInterface
{
use Concerns\SavesData;
private $api;
private $resource;
public function __construct(Api $api, ResourceType $resource)
{
$this->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);
}
}

33
src/Handler/Delete.php Normal file
View File

@ -0,0 +1,33 @@
<?php
namespace Tobscure\JsonApiServer\Handler;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface;
use Tobscure\JsonApiServer\Exception\ForbiddenException;
use Tobscure\JsonApiServer\ResourceType;
use Zend\Diactoros\Response\EmptyResponse;
class Delete implements RequestHandlerInterface
{
private $resource;
private $model;
public function __construct(ResourceType $resource, $model)
{
$this->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;
}
}

179
src/Handler/Index.php Normal file
View File

@ -0,0 +1,179 @@
<?php
namespace Tobscure\JsonApiServer\Handler;
use JsonApiPhp\JsonApi;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface;
use Tobscure\JsonApiServer\Api;
use Tobscure\JsonApiServer\Exception\BadRequestException;
use Tobscure\JsonApiServer\JsonApiResponse;
use Tobscure\JsonApiServer\ResourceType;
use Tobscure\JsonApiServer\Schema;
use Tobscure\JsonApiServer\Serializer;
class Index implements RequestHandlerInterface
{
use Concerns\IncludesData;
private $api;
private $resource;
public function __construct(Api $api, ResourceType $resource)
{
$this->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);
}
}
}

57
src/Handler/Show.php Normal file
View File

@ -0,0 +1,57 @@
<?php
namespace Tobscure\JsonApiServer\Handler;
use JsonApiPhp\JsonApi;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface;
use Tobscure\JsonApiServer\Api;
use Tobscure\JsonApiServer\JsonApiResponse;
use Tobscure\JsonApiServer\ResourceType;
use Tobscure\JsonApiServer\Serializer;
class Show implements RequestHandlerInterface
{
use Concerns\IncludesData;
private $api;
private $resource;
private $model;
public function __construct(Api $api, ResourceType $resource, $model)
{
$this->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);
}
}
}

34
src/Handler/Update.php Normal file
View File

@ -0,0 +1,34 @@
<?php
namespace Tobscure\JsonApiServer\Handler;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface;
use Tobscure\JsonApiServer\Api;
use Tobscure\JsonApiServer\ResourceType;
class Update implements RequestHandlerInterface
{
use Concerns\SavesData;
private $api;
private $resource;
private $model;
public function __construct(Api $api, ResourceType $resource, $model)
{
$this->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);
}
}

19
src/JsonApiResponse.php Normal file
View File

@ -0,0 +1,19 @@
<?php
namespace Tobscure\JsonApiServer;
use Zend\Diactoros\Response\JsonResponse;
class JsonApiResponse extends JsonResponse
{
public function __construct(
$data,
$status = 200,
array $headers = [],
$encodingOptions = self::DEFAULT_JSON_FLAGS
) {
$headers['content-type'] = 'application/vnd.api+json';
parent::__construct($data, $status, $headers, $encodingOptions);
}
}

20
src/Models/Discussion.php Normal file
View File

@ -0,0 +1,20 @@
<?php
namespace Tobscure\JsonApiServer\Models;
use Illuminate\Database\Eloquent\Model;
class Discussion extends Model
{
protected $dates = ['created_at'];
public function user()
{
return $this->belongsTo(User::class);
}
public function posts()
{
return $this->hasMany(Post::class);
}
}

15
src/Models/Group.php Normal file
View File

@ -0,0 +1,15 @@
<?php
namespace Tobscure\JsonApiServer\Models;
use Illuminate\Database\Eloquent\Model;
class Group extends Model
{
public $timestamps = false;
public function users()
{
return $this->belongsToMany(User::class);
}
}

27
src/Models/Post.php Normal file
View File

@ -0,0 +1,27 @@
<?php
namespace Tobscure\JsonApiServer\Models;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
protected $dates = ['created_at', 'edited_at', 'hidden_at'];
public $timestamps = false;
public function user()
{
return $this->belongsTo(User::class);
}
public function discussion()
{
return $this->belongsTo(Discussion::class);
}
public function editedUser()
{
return $this->belongsTo(User::class, 'edited_user_id');
}
}

22
src/Models/User.php Normal file
View File

@ -0,0 +1,22 @@
<?php
namespace Tobscure\JsonApiServer\Models;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
protected $dates = ['joined_at'];
public $timestamps = false;
public function posts()
{
return $this->hasMany(Post::class);
}
public function groups()
{
return $this->belongsToMany(Group::class);
}
}

45
src/ResourceType.php Normal file
View File

@ -0,0 +1,45 @@
<?php
namespace Tobscure\JsonApiServer;
use Closure;
use Tobscure\JsonApiServer\Adapter\AdapterInterface;
use Tobscure\JsonApiServer\Schema\Builder;
class ResourceType
{
protected $type;
protected $adapter;
protected $buildSchema;
protected $schema;
public function __construct(string $type, AdapterInterface $adapter, Closure $buildSchema = null)
{
$this->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;
}
}

30
src/Schema/Attribute.php Normal file
View File

@ -0,0 +1,30 @@
<?php
namespace Tobscure\JsonApiServer\Schema;
use Closure;
use Illuminate\Support\Traits\Macroable;
class Attribute extends Field
{
use Macroable;
public $location = 'attributes';
public $sortable = false;
public $sorter;
public function __construct(string $name)
{
parent::__construct($name);
$this->property = snake_case($name);
}
public function sortable(Closure $callback = null)
{
$this->sortable = true;
$this->sorter = $callback;
return $this;
}
}

127
src/Schema/Builder.php Normal file
View File

@ -0,0 +1,127 @@
<?php
namespace Tobscure\JsonApiServer\Schema;
use Closure;
class Builder
{
public $fields = [];
public $paginate = 20;
public $scopes = [];
public $isVisible;
public $isCreatable;
public $isDeletable;
public function __construct()
{
$this->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];
}
}

145
src/Schema/Field.php Normal file
View File

@ -0,0 +1,145 @@
<?php
namespace Tobscure\JsonApiServer\Schema;
use Closure;
abstract class Field
{
public $name;
public $property;
public $isVisible;
public $isWritable;
public $getter;
public $setter;
public $saver;
public $default;
public $validators = [];
public $filterable = false;
public $filter;
public function __construct(string $name)
{
$this->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;
}
}

29
src/Schema/HasMany.php Normal file
View File

@ -0,0 +1,29 @@
<?php
namespace Tobscure\JsonApiServer\Schema;
class HasMany extends Relationship
{
public $includable = false;
public function __construct(string $name)
{
parent::__construct($name);
$this->resource = $name;
}
public function includable()
{
$this->includable = true;
return $this;
}
public function included()
{
$this->includable();
return parent::included();
}
}

13
src/Schema/HasOne.php Normal file
View File

@ -0,0 +1,13 @@
<?php
namespace Tobscure\JsonApiServer\Schema;
class HasOne extends Relationship
{
public function __construct(string $name)
{
parent::__construct($name);
$this->resource = str_plural($name);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace Tobscure\JsonApiServer\Schema;
use Closure;
use Illuminate\Support\Traits\Macroable;
abstract class Relationship extends Field
{
use Macroable;
public $location = 'relationships';
public $included = false;
public $resource;
public function resource($resource)
{
$this->resource = $resource;
return $this;
}
public function included()
{
$this->included = true;
return $this;
}
}

176
src/Serializer.php Normal file
View File

@ -0,0 +1,176 @@
<?php
namespace Tobscure\JsonApiServer;
use DateTime;
use DateTimeInterface;
use JsonApiPhp\JsonApi;
use Psr\Http\Message\ServerRequestInterface as Request;
use Tobscure\JsonApiServer\Adapter\AdapterInterface;
class Serializer
{
protected $api;
protected $request;
protected $map = [];
protected $primary = [];
public function __construct(Api $api, Request $request)
{
$this->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']
);
}
}

View File

@ -0,0 +1,35 @@
<?php
/*
* This file is part of JSON-API.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace 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));
}
}

202
tests/CreateTest.php Normal file
View File

@ -0,0 +1,202 @@
<?php
/*
* This file is part of JSON-API.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace 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']
);
}
}

60
tests/DeleteTest.php Normal file
View File

@ -0,0 +1,60 @@
<?php
/*
* This file is part of JSON-API.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace 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);
}
}

123
tests/MockAdapter.php Normal file
View File

@ -0,0 +1,123 @@
<?php
namespace Tobscure\Tests\JsonApiServer;
use Tobscure\JsonApiServer\Adapter\AdapterInterface;
use Tobscure\JsonApiServer\Schema\Attribute;
use Tobscure\JsonApiServer\Schema\HasMany;
use Tobscure\JsonApiServer\Schema\HasOne;
class MockAdapter implements AdapterInterface
{
public $models = [];
public $createdModel;
public function __construct(array $models = [])
{
$this->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;
}
}

602
tests/ShowTest.php Normal file
View File

@ -0,0 +1,602 @@
<?php
/*
* This file is part of JSON-API.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace 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
);
}
}