Initial commit
This commit is contained in:
commit
e8bf26eaae
|
|
@ -0,0 +1,6 @@
|
||||||
|
.gitattributes export-ignore
|
||||||
|
.gitignore export-ignore
|
||||||
|
.travis.yml export-ignore
|
||||||
|
|
||||||
|
phpunit.xml export-ignore
|
||||||
|
tests export-ignore
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
composer.lock
|
||||||
|
vendor
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
language: php
|
||||||
|
|
||||||
|
php:
|
||||||
|
- 7.1
|
||||||
|
- 7.2
|
||||||
|
|
||||||
|
install:
|
||||||
|
- composer install --no-interaction --prefer-source
|
||||||
|
|
||||||
|
script:
|
||||||
|
- vendor/bin/phpunit
|
||||||
|
|
@ -0,0 +1,373 @@
|
||||||
|
# tobscure/json-api-server
|
||||||
|
|
||||||
|
[](https://travis-ci.org/tobscure/json-api-server)
|
||||||
|
[](https://github.com/tobscure/json-api-server/releases)
|
||||||
|
[](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.
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tobscure\JsonApiServer\Exception;
|
||||||
|
|
||||||
|
class BadRequestException extends \DomainException
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tobscure\JsonApiServer\Exception;
|
||||||
|
|
||||||
|
class ForbiddenException extends \DomainException
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tobscure\JsonApiServer\Exception;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class UnprocessableEntityException extends \DomainException
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue