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