Compare commits
78 Commits
v0.1.0-bet
...
master
| Author | SHA1 | Date |
|---|---|---|
|
|
324d72bd6e | |
|
|
de4dacd1da | |
|
|
5f7c0d2da5 | |
|
|
07e80965a4 | |
|
|
7cab9545b4 | |
|
|
2ca836d7ef | |
|
|
ec12976b0f | |
|
|
4a22088559 | |
|
|
a9a8bb84aa | |
|
|
4f84fede67 | |
|
|
fc137cff85 | |
|
|
e400be8ae4 | |
|
|
2f0a524bde | |
|
|
16c56cccee | |
|
|
e95fed2468 | |
|
|
d6f9e18852 | |
|
|
43ff947d16 | |
|
|
228ea6eacc | |
|
|
7785241e12 | |
|
|
a72bcffe1a | |
|
|
6e2049afd5 | |
|
|
7f73189f74 | |
|
|
3f63e2db31 | |
|
|
6183f012f6 | |
|
|
34f719e963 | |
|
|
9299a7da3e | |
|
|
69866ea247 | |
|
|
1e34ffbb04 | |
|
|
ecb8825d04 | |
|
|
8b37d47616 | |
|
|
c1dc91c558 | |
|
|
d678a2ed9e | |
|
|
540d82b672 | |
|
|
d015070569 | |
|
|
7405e07b93 | |
|
|
d91bc79f49 | |
|
|
a839219711 | |
|
|
7979ef5376 | |
|
|
ac23f7a70a | |
|
|
81e0dc63b7 | |
|
|
589fa47f68 | |
|
|
8584d1de9b | |
|
|
aa2754d458 | |
|
|
416a9c80b0 | |
|
|
7d7dcb3e33 | |
|
|
bc09e000d8 | |
|
|
3c50256bd5 | |
|
|
e51dee99b2 | |
|
|
89858188e6 | |
|
|
5c34ac06bb | |
|
|
1ae0313575 | |
|
|
4fe4efc036 | |
|
|
f2aac7f78e | |
|
|
dc8ec9ee1d | |
|
|
cd9a43de7e | |
|
|
32a9fbe35a | |
|
|
f44f363806 | |
|
|
5753e3a17c | |
|
|
848a8df42d | |
|
|
bbca1e44ed | |
|
|
ba4f4d337c | |
|
|
c563c2879b | |
|
|
7858566e1e | |
|
|
569371b4d2 | |
|
|
f0c7ed513e | |
|
|
71dcdadb3b | |
|
|
cdb910fdda | |
|
|
8215cfb0ff | |
|
|
dbd3ceec9c | |
|
|
cda345d3ad | |
|
|
8029c6fd4a | |
|
|
1e1fc4cdb3 | |
|
|
d06a635ae2 | |
|
|
ae6d8ca9b2 | |
|
|
821f493750 | |
|
|
b25ac10be7 | |
|
|
41a38d4988 | |
|
|
b57b6c352b |
|
|
@ -0,0 +1,88 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.2.0] - 2022-06-21
|
||||||
|
### Fixed
|
||||||
|
- Fix `EloquentAdapter::filterByIds()` getting key name from query model instead of adapter model
|
||||||
|
- Fix deprecation notice on PHP 8.1
|
||||||
|
|
||||||
|
## [0.2.0-beta.6] - 2022-04-22
|
||||||
|
### Changed
|
||||||
|
- Add support for `doctrine/inflector:^2.0`
|
||||||
|
|
||||||
|
## [0.2.0-beta.5] - 2022-01-03
|
||||||
|
### Added
|
||||||
|
- `Context::getBody()` method to retrieve the parsed JSON:API payload from the request
|
||||||
|
- `Context::sortRequested()` method to determine if a sort field has been requested
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `Laravel\rules()`: Fix regression disallowing use of advanced validation rules like callbacks and `Rule` instances. (@SychO9)
|
||||||
|
|
||||||
|
## [0.2.0-beta.4] - 2021-09-05
|
||||||
|
### Added
|
||||||
|
- `Laravel\rules()`: Replace `{id}` placeholder in rules with the model's key.
|
||||||
|
- This is useful for the `unique` rule, for example: `unique:users,email,{id}`
|
||||||
|
- `Laravel\can()`: Pass through additional arguments to Gate check.
|
||||||
|
- This is needed to use policy methods without models, for example: `can('create', Post::class)`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Get a fresh copy of the model to display after create/update to ensure consistency
|
||||||
|
- Respond with `400 Bad Request` when attempting to filter on an attribute of a polymorphic relationship
|
||||||
|
|
||||||
|
## [0.2.0-beta.3] - 2021-09-03
|
||||||
|
### Fixed
|
||||||
|
- Fix dependency on `http-accept` now that a version has been tagged
|
||||||
|
- Change `EloquentAdapter` to load relationships using `load` instead of `loadMissing`, as they may need API-specific scopes applied
|
||||||
|
|
||||||
|
## [0.2.0-beta.2] - 2021-09-01
|
||||||
|
### Added
|
||||||
|
- Content-Type validation and Accept negotiation
|
||||||
|
- Include `jsonapi` object with `version` member in response
|
||||||
|
- Validate implementation-specific query parameters according to specification
|
||||||
|
- Added `Location` header to `201 Created` responses
|
||||||
|
- Improved error responses when creating and updating resources
|
||||||
|
- `Context::filter()` method to get the value of a filter
|
||||||
|
- `ResourceType::applyScope()`, `applyFilter()` and `applySort()` methods
|
||||||
|
- `ResourceType::url()` method to get the URL for a model
|
||||||
|
- `Forbidden` error details for CRUD actions, useful when running Atomic Operations
|
||||||
|
- `JsonApi::getExtensions()` method to get all registered extensions
|
||||||
|
- `ConflictException` class
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Renamed `$linkage` parameter in `AdapterInterface` methods to `$linkageOnly`
|
||||||
|
- Renamed `Type::newModel()` to `model()` to be consistent with Adapter
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Properly respond with meta information added to `Context` instance
|
||||||
|
|
||||||
|
## [0.2.0-beta.1] - 2021-08-27
|
||||||
|
### Added
|
||||||
|
- Preliminary support for Extensions
|
||||||
|
- Support filtering by nested relationships/attributes (eg. `filter[relationship.attribute]=value`)
|
||||||
|
- Add new methods to Context object: `getApi`, `getPath`, `fieldRequested`, `meta`
|
||||||
|
- Eloquent adapter: apply scopes when including polymorphic relationships
|
||||||
|
- Laravel validation helper: support nested validation messages
|
||||||
|
- Allow configuration of sort and filter visibility
|
||||||
|
- Add new `setId` method to `AdapterInterface`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Change paradigm for eager loading relationships; allow fields to return `Deferred` values to be evaluated after all other fields, so that resource loading can be buffered.
|
||||||
|
- Remove `on` prefix from field event methods
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Removed `load` and `dontLoad` field methods
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fix pagination next link appearing when it shouldn't
|
||||||
|
|
||||||
|
[0.2.0]: https://github.com/tobyzerner/json-api-server/compare/v0.2.0...v0.2.0-beta.6
|
||||||
|
[0.2.0-beta.6]: https://github.com/tobyzerner/json-api-server/compare/v0.2.0-beta.6...v0.2.0-beta.5
|
||||||
|
[0.2.0-beta.5]: https://github.com/tobyzerner/json-api-server/compare/v0.2.0-beta.5...v0.2.0-beta.4
|
||||||
|
[0.2.0-beta.4]: https://github.com/tobyzerner/json-api-server/compare/v0.2.0-beta.4...v0.2.0-beta.3
|
||||||
|
[0.2.0-beta.3]: https://github.com/tobyzerner/json-api-server/compare/v0.2.0-beta.3...v0.2.0-beta.2
|
||||||
|
[0.2.0-beta.2]: https://github.com/tobyzerner/json-api-server/compare/v0.2.0-beta.2...v0.2.0-beta.1
|
||||||
|
[0.2.0-beta.1]: https://github.com/tobyzerner/json-api-server/compare/v0.2.0-beta.1...v0.1.0-beta.1
|
||||||
15
README.md
15
README.md
|
|
@ -5,7 +5,20 @@
|
||||||
|
|
||||||
json-api-server is a [JSON:API](http://jsonapi.org) server implementation in PHP.
|
json-api-server is a [JSON:API](http://jsonapi.org) server implementation in PHP.
|
||||||
|
|
||||||
Build an API in minutes by defining your API's schema and connecting it to your application's models. json-api-server takes care of all the boilerplate stuff like routing, query parameters, and building a valid JSON:API document.
|
It allows you to define your API's schema, and then use an [adapter](adapters.md) to connect it to your application's database layer. You don't have to worry about any of the server boilerplate, routing, query parameters, or JSON:API document formatting.
|
||||||
|
|
||||||
|
Based on your schema definition, the package 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.md), [transformations](writing.md#transformers), [validation](writing.md#validation), and custom [filtering](filtering.md) and [sorting](sorting.md) logic to build a fully functional API with ease.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@
|
||||||
"description": "A fully automated JSON:API server implementation in PHP.",
|
"description": "A fully automated JSON:API server implementation in PHP.",
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=7.1",
|
"php": ">=7.1",
|
||||||
"doctrine/inflector": "^1.3",
|
"ext-json": "*",
|
||||||
|
"doctrine/inflector": "^1.4 || ^2.0",
|
||||||
"json-api-php/json-api": "^2.2",
|
"json-api-php/json-api": "^2.2",
|
||||||
"nyholm/psr7": "^1.3",
|
"nyholm/psr7": "^1.3",
|
||||||
"psr/http-message": "^1.0",
|
"psr/http-message": "^1.0",
|
||||||
"psr/http-server-handler": "^1.0"
|
"psr/http-server-handler": "^1.0",
|
||||||
|
"hnet/http-accept": "^0.1"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"authors": [
|
"authors": [
|
||||||
|
|
|
||||||
|
|
@ -50,11 +50,12 @@ module.exports = {
|
||||||
collapsable: false,
|
collapsable: false,
|
||||||
children: [
|
children: [
|
||||||
'errors',
|
'errors',
|
||||||
|
'extensions',
|
||||||
'laravel',
|
'laravel',
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
repo: 'tobyz/json-api-server',
|
repo: 'tobyzerner/json-api-server',
|
||||||
editLinks: true,
|
editLinks: true,
|
||||||
docsDir: 'docs'
|
docsDir: 'docs'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
$accentColor = #0000ff
|
||||||
|
|
@ -7,7 +7,7 @@ You'll need to supply an adapter for each [resource type](https://jsonapi.org/fo
|
||||||
```php
|
```php
|
||||||
use Tobyz\JsonApiServer\Schema\Type;
|
use Tobyz\JsonApiServer\Schema\Type;
|
||||||
|
|
||||||
$api->resource('users', $adapter, function (Type $type) {
|
$api->resourceType('users', $adapter, function (Type $type) {
|
||||||
// define your schema
|
// define your schema
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
@ -26,12 +26,12 @@ $adapter = new EloquentAdapter(User::class);
|
||||||
When using the Eloquent Adapter, the `$model` passed around in the schema will be an instance of the given model, and the `$query` will be a `Illuminate\Database\Eloquent\Builder` instance querying the model's table:
|
When using the Eloquent Adapter, the `$model` passed around in the schema will be an instance of the given model, and the `$query` will be a `Illuminate\Database\Eloquent\Builder` instance querying the model's table:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$type->scope(function (Builder $query) { });
|
$type->scope(function (Builder $query) {});
|
||||||
|
|
||||||
$type->attribute('name')
|
$type->attribute('name')
|
||||||
->get(function (User $user) { });
|
->get(function (User $user) {});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Custom Adapters
|
### Custom Adapters
|
||||||
|
|
||||||
For other ORMs or data persistence layers, you can [implement your own adapter](https://github.com/tobyz/json-api-server/blob/master/src/Adapter/AdapterInterface.php).
|
For other ORMs or data persistence layers, you can [implement your own adapter](https://github.com/tobyzerner/json-api-server/blob/master/src/Adapter/AdapterInterface.php).
|
||||||
|
|
|
||||||
|
|
@ -14,32 +14,32 @@ $type->creatable(function (Context $context) {
|
||||||
|
|
||||||
## Customizing the Model
|
## Customizing the Model
|
||||||
|
|
||||||
When creating a resource, an empty model is supplied by the adapter. You may wish to override this and provide a custom model in special circumstances. You can do so using the `newModel` method:
|
When creating a resource, an empty model is supplied by the adapter. You may wish to override this and provide a custom model in special circumstances. You can do so using the `model` method:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$type->newModel(function (Context $context) {
|
$type->model(function (Context $context) {
|
||||||
return new CustomModel;
|
return new CustomModel;
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
|
|
||||||
### `onCreating`
|
### `creating`
|
||||||
|
|
||||||
Run before the model is saved.
|
Run after values have been set on the model, but before it is saved.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$type->onCreating(function (&$model, Context $context) {
|
$type->creating(function (&$model, Context $context) {
|
||||||
// do something
|
// do something
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### `onCreated`
|
### `created`
|
||||||
|
|
||||||
Run after the model is saved.
|
Run after the model is saved, and before it is shown in a JSON:API document.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$type->onCreated(function (&$model, Context $context) {
|
$type->created(function (&$model, Context $context) {
|
||||||
$context->meta('foo', 'bar');
|
$context->meta('foo', 'bar');
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -14,22 +14,22 @@ $type->deletable(function (Context $context) {
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
|
|
||||||
### `onDeleting`
|
### `deleting`
|
||||||
|
|
||||||
Run before the model is deleted.
|
Run before the model is deleted.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$type->onDeleting(function (&$model, Context $context) {
|
$type->deleting(function (&$model, Context $context) {
|
||||||
// do something
|
// do something
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### `onDeleted`
|
### `deleted`
|
||||||
|
|
||||||
Run after the model is deleted.
|
Run after the model is deleted.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$type->onDeleted(function (&$model, Context $context) {
|
$type->deleted(function (&$model, Context $context) {
|
||||||
// do something
|
// do something
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Extensions
|
||||||
|
|
||||||
|
[Extensions](https://jsonapi.org/format/1.1/#extensions) allow your API to support additional functionality that is not part of the base specification.
|
||||||
|
|
||||||
|
## Defining Extensions
|
||||||
|
|
||||||
|
Extensions can be defined by extending the `Tobyz\JsonApiServer\Extension\Extension` class and implementing two methods: `uri` and `process`.
|
||||||
|
|
||||||
|
You must return your extension's unique URI from `uri`.
|
||||||
|
|
||||||
|
For every request that includes your extension in the media type, the `handle` method will be called. If your extension is able to handle the request, it should return a PSR-7 response. Otherwise, return null to let the normal handling of the request take place.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Tobyz\JsonApiServer\Extension\Extension;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
|
||||||
|
use function Tobyz\JsonApiServer\json_api_response;
|
||||||
|
|
||||||
|
class MyExtension extends Extension
|
||||||
|
{
|
||||||
|
public function uri(): string
|
||||||
|
{
|
||||||
|
return 'https://example.org/my-extension';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(Context $context): ?ResponseInterface;
|
||||||
|
{
|
||||||
|
if ($context->getPath() === '/my-extension') {
|
||||||
|
return json_api_response([
|
||||||
|
'my-extension:greeting' => 'Hello world!'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
::: warning
|
||||||
|
The current implementation of extensions has no support for augmentation of standard API responses. This API may change dramatically in the future. Please [create an issue](https://github.com/tobyzerner/json-api-server/issues/new) if you have a specific use-case you want to achieve.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Registering Extensions
|
||||||
|
|
||||||
|
Extensions can be registered on your `JsonApi` instance using the `extension` method:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Tobyz\JsonApiServer\JsonApi;
|
||||||
|
|
||||||
|
$api = new JsonApi('/api');
|
||||||
|
|
||||||
|
$api->extension(new MyExtension());
|
||||||
|
```
|
||||||
|
|
||||||
|
The `JsonApi` class will automatically perform appropriate [content negotiation](https://jsonapi.org/format/1.1/#content-negotiation-servers) and activate the specified extensions on each request.
|
||||||
|
|
||||||
|
## Atomic Operations
|
||||||
|
|
||||||
|
An implementation of the [Atomic Operations](https://jsonapi.org/ext/atomic/) extension is available at `Tobyz\JsonApi\Extension\Atomic`.
|
||||||
|
|
||||||
|
When using this extension, you are responsible for wrapping the `$api->handle` call in a transaction to ensure any database (or other) operations performed are actually atomic in nature. For example, in Laravel:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Tobyz\JsonApiServer\Extension\Atomic;
|
||||||
|
use Tobyz\JsonApiServer\JsonApi;
|
||||||
|
|
||||||
|
$api = new JsonApi('/api');
|
||||||
|
|
||||||
|
$api->extension(new Atomic());
|
||||||
|
|
||||||
|
/** @var Psr\Http\Message\ServerRequestInterface $request */
|
||||||
|
/** @var Psr\Http\Message\ResponseInterface $response */
|
||||||
|
try {
|
||||||
|
return DB::transaction(fn() => $api->handle($request));
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$response = $api->error($e);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
@ -23,7 +23,7 @@ GET /users?filter[postCount]=5..15
|
||||||
|
|
||||||
## Custom Filters
|
## Custom Filters
|
||||||
|
|
||||||
To define filters with custom logic, or ones that do not correspond to an attribute, use the `filter` method:
|
To define filters with custom logic, or ones that do not correspond to a field, use the `filter` method:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$type->filter('minPosts', function ($query, $value, Context $context) {
|
$type->filter('minPosts', function ($query, $value, Context $context) {
|
||||||
|
|
@ -34,7 +34,7 @@ $type->filter('minPosts', function ($query, $value, Context $context) {
|
||||||
Just like [fields](visibility.md), filters can be made conditionally `visible` or `hidden`:
|
Just like [fields](visibility.md), filters can be made conditionally `visible` or `hidden`:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$type->filter('email', $callback)
|
$type->filter('minPosts', $callback)
|
||||||
->visible(function (Context $context) {
|
->visible(function (Context $context) {
|
||||||
return $context->getRequest()->getAttribute('isAdmin');
|
return $context->getRequest()->getAttribute('isAdmin');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
json-api-server is a [JSON:API](http://jsonapi.org) server implementation in PHP.
|
json-api-server is a [JSON:API](http://jsonapi.org) server implementation in PHP.
|
||||||
|
|
||||||
It allows you to define your API's schema, and then use an [adapter](adapters.md) to connect it to your application's models and database layer, without having to worry about any of the server boilerplate, routing, query parameters, or JSON:API document formatting.
|
It allows you to define your API's schema, and then use an [adapter](adapters.md) to connect it to your application's database layer. You don't have to worry about any of the server boilerplate, routing, query parameters, or JSON:API document formatting.
|
||||||
|
|
||||||
Based on your schema definition, the package will serve a **complete JSON:API that conforms to the [spec](https://jsonapi.org/format/)**, including support for:
|
Based on your schema definition, the package will serve a **complete JSON:API that conforms to the [spec](https://jsonapi.org/format/)**, including support for:
|
||||||
|
|
||||||
|
|
@ -15,7 +15,7 @@ Based on your schema definition, the package will serve a **complete JSON:API th
|
||||||
- **Deleting** resources (`DELETE /api/articles/1`)
|
- **Deleting** resources (`DELETE /api/articles/1`)
|
||||||
- **Error handling**
|
- **Error handling**
|
||||||
|
|
||||||
The schema definition is extremely powerful and lets you easily apply [permissions](visibility.md), [transformations](writing.md#transformers), [validation](writing.md#validation), and custom [filtering](filtering.md) and [sorting](sorting.md) logic to build a fully functional API in minutes.
|
The schema definition is extremely powerful and lets you easily apply [permissions](visibility.md), [transformations](writing.md#transformers), [validation](writing.md#validation), and custom [filtering](filtering.md) and [sorting](sorting.md) logic to build a fully functional API with ease.
|
||||||
|
|
||||||
### Example
|
### Example
|
||||||
|
|
||||||
|
|
@ -25,17 +25,18 @@ The following example uses Eloquent models in a Laravel application. However, js
|
||||||
use App\Models\{Article, Comment, User};
|
use App\Models\{Article, Comment, User};
|
||||||
use Tobyz\JsonApiServer\JsonApi;
|
use Tobyz\JsonApiServer\JsonApi;
|
||||||
use Tobyz\JsonApiServer\Schema\Type;
|
use Tobyz\JsonApiServer\Schema\Type;
|
||||||
use Tobyz\JsonApiServer\Laravel\EloquentAdapter;
|
use Tobyz\JsonApiServer\Adapter\EloquentAdapter;
|
||||||
use Tobyz\JsonApiServer\Laravel;
|
use Tobyz\JsonApiServer\Laravel;
|
||||||
|
|
||||||
$api = new JsonApi('http://example.com/api');
|
$api = new JsonApi('http://example.com/api');
|
||||||
|
|
||||||
$api->resource('articles', new EloquentAdapter(Article::class), function (Type $type) {
|
$api->resourceType('articles', new EloquentAdapter(Article::class), function (Type $type) {
|
||||||
$type->attribute('title')
|
$type->attribute('title')
|
||||||
->writable()
|
->writable()
|
||||||
->validate(Laravel\rules('required'));
|
->validate(Laravel\rules('required'));
|
||||||
|
|
||||||
$type->hasOne('author')->type('users')
|
$type->hasOne('author')
|
||||||
|
->type('users')
|
||||||
->includable()
|
->includable()
|
||||||
->filterable();
|
->filterable();
|
||||||
|
|
||||||
|
|
@ -43,7 +44,7 @@ $api->resource('articles', new EloquentAdapter(Article::class), function (Type $
|
||||||
->includable();
|
->includable();
|
||||||
});
|
});
|
||||||
|
|
||||||
$api->resource('comments', new EloquentAdapter(Comment::class), function (Type $type) {
|
$api->resourceType('comments', new EloquentAdapter(Comment::class), function (Type $type) {
|
||||||
$type->creatable(Laravel\authenticated());
|
$type->creatable(Laravel\authenticated());
|
||||||
$type->updatable(Laravel\can('update-comment'));
|
$type->updatable(Laravel\can('update-comment'));
|
||||||
$type->deletable(Laravel\can('delete-comment'));
|
$type->deletable(Laravel\can('delete-comment'));
|
||||||
|
|
@ -56,12 +57,13 @@ $api->resource('comments', new EloquentAdapter(Comment::class), function (Type $
|
||||||
->writable()->once()
|
->writable()->once()
|
||||||
->validate(Laravel\rules('required'));
|
->validate(Laravel\rules('required'));
|
||||||
|
|
||||||
$type->hasOne('author')->type('users')
|
$type->hasOne('author')
|
||||||
|
->type('users')
|
||||||
->writable()->once()
|
->writable()->once()
|
||||||
->validate(Laravel\rules('required'));
|
->validate(Laravel\rules('required'));
|
||||||
});
|
});
|
||||||
|
|
||||||
$api->resource('users', new EloquentAdapter(User::class), function (Type $type) {
|
$api->resourceType('users', new EloquentAdapter(User::class), function (Type $type) {
|
||||||
$type->attribute('firstName')->sortable();
|
$type->attribute('firstName')->sortable();
|
||||||
$type->attribute('lastName')->sortable();
|
$type->attribute('lastName')->sortable();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
# Laravel Helpers
|
# Laravel Helpers
|
||||||
|
|
||||||
|
These helpers improve the ergonomics of your API resource definitions when using the Laravel framework.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
### `rules`
|
### `rules`
|
||||||
|
|
@ -10,10 +12,20 @@ Use Laravel's [Validation component](https://laravel.com/docs/8.x/validation) as
|
||||||
use Tobyz\JsonApiServer\Laravel;
|
use Tobyz\JsonApiServer\Laravel;
|
||||||
|
|
||||||
$type->attribute('name')
|
$type->attribute('name')
|
||||||
->validate(Laravel\rules('required|min:3|max:20'));
|
->validate(Laravel\rules(['required', 'min:3', 'max:20']));
|
||||||
```
|
```
|
||||||
|
|
||||||
Pass a string or array of validation rules to be applied to the value. You can also pass an array of custom messages and custom attribute names as the second and third arguments.
|
Pass a string or array of validation rules to be applied to the value. Validating array contents is also supported:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$type->attribute('jobs')
|
||||||
|
->validate(Laravel\rules([
|
||||||
|
'required', 'array',
|
||||||
|
'*' => ['string', 'min:3', 'max:255']
|
||||||
|
]));
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also pass an array of custom messages and custom attribute names as the second and third arguments.
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,22 +14,22 @@ $type->listable(function (Context $context) {
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
|
|
||||||
### `onListing`
|
### `listing`
|
||||||
|
|
||||||
Run before [scopes](scopes.md) are applied to the `$query` and results are retrieved.
|
Run before [scopes](scopes.md) are applied to the `$query` and results are retrieved.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$type->onListing(function ($query, Context $context) {
|
$type->listing(function ($query, Context $context) {
|
||||||
// do something
|
// do something
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### `onListed`
|
### `listed`
|
||||||
|
|
||||||
Run after models and relationships have been retrieved, but before they are serialized into a JSON:API document.
|
Run after models and relationships have been retrieved, but before they are serialized into a JSON:API document.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$type->onListed(function ($models, Context $context) {
|
$type->listed(function ($models, Context $context) {
|
||||||
// do something
|
// do something
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@ You can add meta information at various levels of the document using the `meta`
|
||||||
|
|
||||||
To add meta information at the top-level of a document, you can call the `meta` method on the `Context` instance which is available inside any of your schema's callbacks.
|
To add meta information at the top-level of a document, you can call the `meta` method on the `Context` instance which is available inside any of your schema's callbacks.
|
||||||
|
|
||||||
For example, to add meta information to a resource listing, you might call this inside of an `onListed` listener:
|
For example, to add meta information to a resource listing, you might call this inside of an `listed` listener:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$type->onListed(function ($models, Context $context) {
|
$type->listed(function ($models, Context $context) {
|
||||||
$context->meta('foo', 'bar');
|
$context->meta('foo', 'bar');
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -59,14 +59,6 @@ $type->hasOne('users')
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
To prevent a relationship from being eager-loaded, use the `dontLoad` method:
|
|
||||||
|
|
||||||
```php
|
|
||||||
$type->hasOne('user')
|
|
||||||
->includable()
|
|
||||||
->dontLoad();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Polymorphic Relationships
|
## Polymorphic Relationships
|
||||||
|
|
||||||
Define a polymorphic relationship using the `polymorphic` method. Optionally you may provide an array of allowed resource types:
|
Define a polymorphic relationship using the `polymorphic` method. Optionally you may provide an array of allowed resource types:
|
||||||
|
|
@ -79,10 +71,6 @@ $type->hasMany('taggable')
|
||||||
->polymorphic(['photos', 'videos']);
|
->polymorphic(['photos', 'videos']);
|
||||||
```
|
```
|
||||||
|
|
||||||
::: warning
|
|
||||||
Note that nested includes cannot be requested on polymorphic relationships.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Meta Information
|
## Meta Information
|
||||||
|
|
||||||
You can add meta information to a relationship using the `meta` method:
|
You can add meta information to a relationship using the `meta` method:
|
||||||
|
|
|
||||||
|
|
@ -31,3 +31,43 @@ Often you will need to access information about the authenticated user inside of
|
||||||
```php
|
```php
|
||||||
$request = $request->withAttribute('user', $user);
|
$request = $request->withAttribute('user', $user);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
An instance of `Tobyz\JsonApi\Context` is passed into callbacks throughout your API's resource definitions – for example, when defining [scopes](scopes):
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Tobyz\JsonApiServer\Context;
|
||||||
|
|
||||||
|
$type->scope(function ($query, Context $context) {
|
||||||
|
$user = $context->getRequest()->getAttribute('user');
|
||||||
|
|
||||||
|
$query->where('user_id', $user?->id);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
This object contains a number of useful methods:
|
||||||
|
|
||||||
|
* `getApi(): Tobyz\JsonApi\JsonApi`
|
||||||
|
Get the JsonApi instance.
|
||||||
|
|
||||||
|
* `getRequest(): Psr\Http\Message\ServerRequestInterface`
|
||||||
|
Get the PSR-7 request instance.
|
||||||
|
|
||||||
|
* `getPath(): string`
|
||||||
|
Get the request path relative to the API's base path.
|
||||||
|
|
||||||
|
* `getBody(): ?array`
|
||||||
|
Get the parsed JSON:API payload.
|
||||||
|
|
||||||
|
* `fieldRequested(string $type, string $field, bool $default = true): bool`
|
||||||
|
Determine whether a field has been requested in a [sparse fieldset](https://jsonapi.org/format/1.1/#fetching-sparse-fieldsets).
|
||||||
|
|
||||||
|
* `sortRequested(string $field): bool`
|
||||||
|
Determine whether a sort field has been requested.
|
||||||
|
|
||||||
|
* `filter(string $name): ?string`
|
||||||
|
Get the value of a filter.
|
||||||
|
|
||||||
|
* `meta(string $name, $value): Tobyz\JsonApi\Schema\Meta`
|
||||||
|
Add a meta attribute to the response document.
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@ For each resource type, a `GET /{type}/{id}` endpoint is exposed to show an indi
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
|
|
||||||
### `onShow`
|
### `show`
|
||||||
|
|
||||||
Run after models and relationships have been retrieved, but before they are serialized into a JSON:API document.
|
Run after the model has been retrieved, but before it is serialized into a JSON:API document.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$type->onShow(function (&$model, Context $context) {
|
$type->show(function (&$model, Context $context) {
|
||||||
// do something
|
// do something
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ You can set a default sort string to be used when the consumer has not supplied
|
||||||
$type->defaultSort('-updatedAt,-createdAt');
|
$type->defaultSort('-updatedAt,-createdAt');
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Custom Sorts
|
||||||
|
|
||||||
To define sort fields with custom logic, or ones that do not correspond to an attribute, use the `sort` method:
|
To define sort fields with custom logic, or ones that do not correspond to an attribute, use the `sort` method:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
|
@ -25,3 +27,12 @@ $type->sort('relevance', function ($query, string $direction, Context $context)
|
||||||
$query->orderBy('relevance', $direction);
|
$query->orderBy('relevance', $direction);
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Just like [fields](visibility.md), sorts can be made conditionally `visible` or `hidden`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$type->sort('relevance', $callback)
|
||||||
|
->visible(function (Context $context) {
|
||||||
|
return $context->getRequest()->getAttribute('isAdmin');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -14,22 +14,22 @@ $type->updatable(function (Context $context) {
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
|
|
||||||
### `onUpdating`
|
### `updating`
|
||||||
|
|
||||||
Run before the model is saved.
|
Run after values have been set on the model, but before it is saved.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$type->onUpdating(function (&$model, Context $context) {
|
$type->updating(function (&$model, Context $context) {
|
||||||
// do something
|
// do something
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### `onUpdated`
|
### `updated`
|
||||||
|
|
||||||
Run after the model is saved.
|
Run after the model is saved, and before it is shown in a JSON:API document.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$type->onUpdated(function (&$model, Context $context) {
|
$type->updated(function (&$model, Context $context) {
|
||||||
// do something
|
// do something
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -114,13 +114,13 @@ $type->attribute('locale')
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
|
|
||||||
### `onSaved`
|
### `saved`
|
||||||
|
|
||||||
Run after a field has been successfully saved.
|
Run after a field has been successfully saved.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$type->attribute('email')
|
$type->attribute('email')
|
||||||
->onSaved(function ($value, $model, Context $context) {
|
->saved(function ($value, $model, Context $context) {
|
||||||
event(new EmailWasChanged($model));
|
event(new EmailWasChanged($model));
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -11,9 +11,13 @@
|
||||||
|
|
||||||
namespace Tobyz\JsonApiServer\Adapter;
|
namespace Tobyz\JsonApiServer\Adapter;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Tobyz\JsonApiServer\Context;
|
||||||
|
use Tobyz\JsonApiServer\Deferred;
|
||||||
use Tobyz\JsonApiServer\Schema\Attribute;
|
use Tobyz\JsonApiServer\Schema\Attribute;
|
||||||
use Tobyz\JsonApiServer\Schema\HasMany;
|
use Tobyz\JsonApiServer\Schema\HasMany;
|
||||||
use Tobyz\JsonApiServer\Schema\HasOne;
|
use Tobyz\JsonApiServer\Schema\HasOne;
|
||||||
|
use Tobyz\JsonApiServer\Schema\Relationship;
|
||||||
|
|
||||||
interface AdapterInterface
|
interface AdapterInterface
|
||||||
{
|
{
|
||||||
|
|
@ -24,17 +28,11 @@ interface AdapterInterface
|
||||||
* or list a resource index. It will be passed around through the relevant
|
* or list a resource index. It will be passed around through the relevant
|
||||||
* scopes, filters, and sorting methods before finally being passed into
|
* scopes, filters, and sorting methods before finally being passed into
|
||||||
* the `find` or `get` methods.
|
* the `find` or `get` methods.
|
||||||
*
|
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public function newQuery();
|
public function query();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manipulate the query to only include resources with the given IDs.
|
* Manipulate the query to only include resources with the given IDs.
|
||||||
*
|
|
||||||
* @param $query
|
|
||||||
* @param array $ids
|
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public function filterByIds($query, array $ids): void;
|
public function filterByIds($query, array $ids): void;
|
||||||
|
|
||||||
|
|
@ -42,194 +40,123 @@ interface AdapterInterface
|
||||||
* Manipulate the query to only include resources with a certain attribute
|
* Manipulate the query to only include resources with a certain attribute
|
||||||
* value.
|
* value.
|
||||||
*
|
*
|
||||||
* @param $query
|
|
||||||
* @param Attribute $attribute
|
|
||||||
* @param $value
|
|
||||||
* @param string $operator The operator to use for comparison: = < > <= >=
|
* @param string $operator The operator to use for comparison: = < > <= >=
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public function filterByAttribute($query, Attribute $attribute, $value, string $operator = '='): void;
|
public function filterByAttribute($query, Attribute $attribute, $value, string $operator = '='): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manipulate the query to only include resources with any one of the given
|
* Manipulate the query to only include resources with a relationship within
|
||||||
* resource IDs in a has-one relationship.
|
* the given scope.
|
||||||
*
|
|
||||||
* @param $query
|
|
||||||
* @param HasOne $relationship
|
|
||||||
* @param array $ids
|
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public function filterByHasOne($query, HasOne $relationship, array $ids): void;
|
public function filterByRelationship($query, Relationship $relationship, Closure $scope): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manipulate the query to only include resources with any one of the given
|
* Manipulate the query to only include resources appropriate to given filter expression.
|
||||||
* resource IDs in a has-many relationship.
|
|
||||||
*
|
*
|
||||||
* @param $query
|
* @param string $expression The filter expression
|
||||||
* @param HasMany $relationship
|
|
||||||
* @param array $ids
|
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public function filterByHasMany($query, HasMany $relationship, array $ids): void;
|
public function filterByExpression($query, string $expression): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manipulate the query to only include specific fields.
|
||||||
|
*
|
||||||
|
* @param string|array $fields Comma-separated list of field names to include or array of such lists for every resource type
|
||||||
|
*/
|
||||||
|
public function sparseFieldset($query, $fields): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manipulate the query to sort by the given attribute in the given direction.
|
* Manipulate the query to sort by the given attribute in the given direction.
|
||||||
*
|
|
||||||
* @param $query
|
|
||||||
* @param Attribute $attribute
|
|
||||||
* @param string $direction
|
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public function sortByAttribute($query, Attribute $attribute, string $direction): void;
|
public function sortByAttribute($query, Attribute $attribute, string $direction): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manipulate the query to only include a certain number of results,
|
* Manipulate the query to only include a certain number of results,
|
||||||
* starting from the given offset.
|
* starting from the given offset.
|
||||||
*
|
|
||||||
* @param $query
|
|
||||||
* @param int $limit
|
|
||||||
* @param int $offset
|
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public function paginate($query, int $limit, int $offset): void;
|
public function paginate($query, int $limit, int $offset): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a single resource by ID from the query.
|
* Find a single resource by ID from the query.
|
||||||
*
|
|
||||||
* @param $query
|
|
||||||
* @param string $id
|
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public function find($query, string $id);
|
public function find($query, string $id);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a list of resources from the query.
|
* Get a list of resources from the query.
|
||||||
*
|
|
||||||
* @param $query
|
|
||||||
* @return array
|
|
||||||
*/
|
*/
|
||||||
public function get($query): array;
|
public function get($query): array;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the number of results from the query.
|
* Get the number of results from the query.
|
||||||
*
|
|
||||||
* @param $query
|
|
||||||
* @return int
|
|
||||||
*/
|
*/
|
||||||
public function count($query): int;
|
public function count($query): int;
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether or not this resource type represents the given model.
|
|
||||||
*
|
|
||||||
* This is used for polymorphic relationships, where there are one or many
|
|
||||||
* related models of unknown type. The first resource type with an adapter
|
|
||||||
* that responds positively from this method will be used.
|
|
||||||
*
|
|
||||||
* @param mixed $model
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
public function represents($model): bool;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new model instance.
|
|
||||||
*
|
|
||||||
* @return mixed
|
|
||||||
*/
|
|
||||||
public function newModel();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the ID from the model.
|
* Get the ID from the model.
|
||||||
*
|
|
||||||
* @param $model
|
|
||||||
* @return string
|
|
||||||
*/
|
*/
|
||||||
public function getId($model): string;
|
public function getId($model): string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the value of an attribute from the model.
|
* Get the value of an attribute from the model.
|
||||||
*
|
*
|
||||||
* @param $model
|
* @return mixed|Deferred
|
||||||
* @param Attribute $attribute
|
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public function getAttribute($model, Attribute $attribute);
|
public function getAttribute($model, Attribute $attribute);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the model for a has-one relationship for the model.
|
* Get the model for a has-one relationship for the model.
|
||||||
*
|
*
|
||||||
* @param $model
|
* @return mixed|null|Deferred
|
||||||
* @param HasOne $relationship
|
|
||||||
* @param bool $linkage
|
|
||||||
* @return mixed|null
|
|
||||||
*/
|
*/
|
||||||
public function getHasOne($model, HasOne $relationship, bool $linkage);
|
public function getHasOne($model, HasOne $relationship, bool $linkageOnly, Context $context);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a list of models for a has-many relationship for the model.
|
* Get a list of models for a has-many relationship for the model.
|
||||||
*
|
*
|
||||||
* @param $model
|
* @return array|Deferred
|
||||||
* @param HasMany $relationship
|
|
||||||
* @param bool $linkage
|
|
||||||
* @return array
|
|
||||||
*/
|
*/
|
||||||
public function getHasMany($model, HasMany $relationship, bool $linkage): array;
|
public function getHasMany($model, HasMany $relationship, bool $linkageOnly, Context $context);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether this resource type represents the given model.
|
||||||
|
*
|
||||||
|
* This is used for polymorphic relationships, where there are one or many
|
||||||
|
* related models of unknown type. The first resource type with an adapter
|
||||||
|
* that responds positively from this method will be used.
|
||||||
|
*/
|
||||||
|
public function represents($model): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new model instance.
|
||||||
|
*/
|
||||||
|
public function model();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a user-generated ID to the model.
|
||||||
|
*/
|
||||||
|
public function setId($model, string $id): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply an attribute value to the model.
|
* Apply an attribute value to the model.
|
||||||
*
|
|
||||||
* @param $model
|
|
||||||
* @param Attribute $attribute
|
|
||||||
* @param $value
|
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public function setAttribute($model, Attribute $attribute, $value): void;
|
public function setAttribute($model, Attribute $attribute, $value): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a has-one relationship value to the model.
|
* Apply a has-one relationship value to the model.
|
||||||
*
|
|
||||||
* @param $model
|
|
||||||
* @param HasOne $relationship
|
|
||||||
* @param $related
|
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public function setHasOne($model, HasOne $relationship, $related): void;
|
public function setHasOne($model, HasOne $relationship, $related): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save the model.
|
* Save the model.
|
||||||
*
|
|
||||||
* @param $model
|
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public function save($model): void;
|
public function save($model): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save a has-many relationship for the model.
|
* Save a has-many relationship for the model.
|
||||||
*
|
|
||||||
* @param $model
|
|
||||||
* @param HasMany $relationship
|
|
||||||
* @param array $related
|
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public function saveHasMany($model, HasMany $relationship, array $related): void;
|
public function saveHasMany($model, HasMany $relationship, array $related): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete the model.
|
* Delete the model.
|
||||||
*
|
|
||||||
* @param $model
|
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public function delete($model): void;
|
public function delete($model): void;
|
||||||
|
|
||||||
/**
|
|
||||||
* Load information about related resources onto a collection of models.
|
|
||||||
*
|
|
||||||
* @param array $models
|
|
||||||
* @param array $relationships
|
|
||||||
* @param mixed $scope Should be called to give the deepest relationship
|
|
||||||
* an opportunity to scope the query that will fetch related resources
|
|
||||||
* @param bool $linkage true if we just need the IDs of the related
|
|
||||||
* resources and not their full data
|
|
||||||
* @return mixed
|
|
||||||
*/
|
|
||||||
public function load(array $models, array $relationships, $scope, bool $linkage): void;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,14 @@
|
||||||
namespace Tobyz\JsonApiServer\Adapter;
|
namespace Tobyz\JsonApiServer\Adapter;
|
||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphOneOrMany;
|
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
use Tobyz\JsonApiServer\Context;
|
||||||
|
use Tobyz\JsonApiServer\Deferred;
|
||||||
use Tobyz\JsonApiServer\Schema\Attribute;
|
use Tobyz\JsonApiServer\Schema\Attribute;
|
||||||
use Tobyz\JsonApiServer\Schema\HasMany;
|
use Tobyz\JsonApiServer\Schema\HasMany;
|
||||||
use Tobyz\JsonApiServer\Schema\HasOne;
|
use Tobyz\JsonApiServer\Schema\HasOne;
|
||||||
|
|
@ -39,21 +41,44 @@ class EloquentAdapter implements AdapterInterface
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function represents($model): bool
|
public function query(): Builder
|
||||||
{
|
|
||||||
return $model instanceof $this->model;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function newModel()
|
|
||||||
{
|
|
||||||
return $this->model->newInstance();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function newQuery()
|
|
||||||
{
|
{
|
||||||
return $this->model->query();
|
return $this->model->query();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function filterByIds($query, array $ids): void
|
||||||
|
{
|
||||||
|
$query->whereIn($this->model->getQualifiedKeyName(), $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function filterByAttribute($query, Attribute $attribute, $value, string $operator = '='): void
|
||||||
|
{
|
||||||
|
$query->where($this->getAttributeProperty($attribute), $operator, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function filterByRelationship($query, Relationship $relationship, Closure $scope): void
|
||||||
|
{
|
||||||
|
$query->whereHas($this->getRelationshipProperty($relationship), $scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function filterByExpression($query, string $expression): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sparseFieldset($query, $fields): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sortByAttribute($query, Attribute $attribute, string $direction): void
|
||||||
|
{
|
||||||
|
$query->orderBy($this->getAttributeProperty($attribute), $direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function paginate($query, int $limit, int $offset): void
|
||||||
|
{
|
||||||
|
$query->take($limit)->skip($offset);
|
||||||
|
}
|
||||||
|
|
||||||
public function find($query, string $id)
|
public function find($query, string $id)
|
||||||
{
|
{
|
||||||
return $query->find($id);
|
return $query->find($id);
|
||||||
|
|
@ -76,47 +101,83 @@ class EloquentAdapter implements AdapterInterface
|
||||||
|
|
||||||
public function getAttribute($model, Attribute $attribute)
|
public function getAttribute($model, Attribute $attribute)
|
||||||
{
|
{
|
||||||
return $model->{$this->getAttributeProperty($attribute)};
|
return $model->getAttribute($this->getAttributeProperty($attribute));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getHasOne($model, HasOne $relationship, bool $linkage)
|
public function getHasOne($model, HasOne $relationship, bool $linkageOnly, Context $context)
|
||||||
{
|
{
|
||||||
// If it's a belongs-to relationship and we only need to get the ID,
|
// If this is a belongs-to relationship, and we only need to get the ID
|
||||||
// then we don't have to actually load the relation because the ID is
|
// for linkage, then we don't have to actually load the relation because
|
||||||
// stored in a column directly on the model. We will mock up a related
|
// the ID is stored in a column directly on the model. We will mock up a
|
||||||
// model with the value of the ID filled.
|
// related model with the value of the ID filled.
|
||||||
if ($linkage) {
|
if ($linkageOnly) {
|
||||||
$relation = $this->getEloquentRelation($model, $relationship);
|
$relation = $this->getEloquentRelation($model, $relationship);
|
||||||
|
|
||||||
if ($relation instanceof BelongsTo) {
|
if ($relation instanceof BelongsTo) {
|
||||||
if ($key = $model->{$relation->getForeignKeyName()}) {
|
if ($key = $model->getAttribute($relation->getForeignKeyName())) {
|
||||||
$related = $relation->getRelated();
|
$related = $relation->getRelated();
|
||||||
|
|
||||||
return $related->newInstance()->forceFill([$related->getKeyName() => $key]);
|
return $related->newInstance()->forceFill([
|
||||||
|
$related->getKeyName() => $key
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->getRelationValue($model, $relationship);
|
return $this->getRelationship($model, $relationship, $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getHasMany($model, HasMany $relationship, bool $linkage): array
|
public function getHasMany($model, HasMany $relationship, bool $linkageOnly, Context $context)
|
||||||
{
|
{
|
||||||
$collection = $this->getRelationValue($model, $relationship);
|
return $this->getRelationship($model, $relationship, $context);
|
||||||
|
}
|
||||||
|
|
||||||
return $collection ? $collection->all() : [];
|
protected function getRelationship($model, Relationship $relationship, Context $context): Deferred
|
||||||
|
{
|
||||||
|
$name = $this->getRelationshipProperty($relationship);
|
||||||
|
|
||||||
|
EloquentBuffer::add($model, $name);
|
||||||
|
|
||||||
|
return new Deferred(function () use ($model, $name, $relationship, $context) {
|
||||||
|
EloquentBuffer::load($model, $name, $relationship, $context);
|
||||||
|
|
||||||
|
$data = $model->getRelation($name);
|
||||||
|
|
||||||
|
return $data instanceof Collection ? $data->all() : $data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function represents($model): bool
|
||||||
|
{
|
||||||
|
return $model instanceof $this->model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function model(): Model
|
||||||
|
{
|
||||||
|
return $this->model->newInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setId($model, string $id): void
|
||||||
|
{
|
||||||
|
$model->setAttribute($model->getKeyName(), $id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setAttribute($model, Attribute $attribute, $value): void
|
public function setAttribute($model, Attribute $attribute, $value): void
|
||||||
{
|
{
|
||||||
$model->{$this->getAttributeProperty($attribute)} = $value;
|
$model->setAttribute($this->getAttributeProperty($attribute), $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setHasOne($model, HasOne $relationship, $related): void
|
public function setHasOne($model, HasOne $relationship, $related): void
|
||||||
{
|
{
|
||||||
$this->getEloquentRelation($model, $relationship)->associate($related);
|
$relation = $this->getEloquentRelation($model, $relationship);
|
||||||
|
|
||||||
|
// If this is a belongs-to relationship, then the ID is stored on the
|
||||||
|
// model itself so we can set it here.
|
||||||
|
if ($relation instanceof BelongsTo) {
|
||||||
|
$relation->associate($related);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function save($model): void
|
public function save($model): void
|
||||||
|
|
@ -126,7 +187,11 @@ class EloquentAdapter implements AdapterInterface
|
||||||
|
|
||||||
public function saveHasMany($model, HasMany $relationship, array $related): void
|
public function saveHasMany($model, HasMany $relationship, array $related): void
|
||||||
{
|
{
|
||||||
$this->getEloquentRelation($model, $relationship)->sync(new Collection($related));
|
$relation = $this->getEloquentRelation($model, $relationship);
|
||||||
|
|
||||||
|
if ($relation instanceof BelongsToMany) {
|
||||||
|
$relation->sync(new Collection($related));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete($model): void
|
public function delete($model): void
|
||||||
|
|
@ -134,79 +199,7 @@ class EloquentAdapter implements AdapterInterface
|
||||||
// For models that use the SoftDeletes trait, deleting the resource from
|
// For models that use the SoftDeletes trait, deleting the resource from
|
||||||
// the API implies permanent deletion. Non-permanent deletion should be
|
// the API implies permanent deletion. Non-permanent deletion should be
|
||||||
// achieved by manipulating a resource attribute.
|
// achieved by manipulating a resource attribute.
|
||||||
if (method_exists($model, 'forceDelete')) {
|
$model->forceDelete();
|
||||||
$model->forceDelete();
|
|
||||||
} else {
|
|
||||||
$model->delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function filterByIds($query, array $ids): void
|
|
||||||
{
|
|
||||||
$key = $query->getModel()->getQualifiedKeyName();
|
|
||||||
|
|
||||||
$query->whereIn($key, $ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function filterByAttribute($query, Attribute $attribute, $value, string $operator = '='): void
|
|
||||||
{
|
|
||||||
$column = $this->getAttributeColumn($attribute);
|
|
||||||
|
|
||||||
$query->where($column, $operator, $value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function filterByHasOne($query, HasOne $relationship, array $ids): void
|
|
||||||
{
|
|
||||||
$relation = $this->getEloquentRelation($query->getModel(), $relationship);
|
|
||||||
$column = $relation instanceof HasOneThrough ? $relation->getQualifiedParentKeyName() : $relation->getQualifiedForeignKeyName();
|
|
||||||
|
|
||||||
$query->whereIn($column, $ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function filterByHasMany($query, HasMany $relationship, array $ids): void
|
|
||||||
{
|
|
||||||
$property = $this->getRelationshipProperty($relationship);
|
|
||||||
$relation = $this->getEloquentRelation($query->getModel(), $relationship);
|
|
||||||
$relatedKey = $relation->getRelated()->getQualifiedKeyName();
|
|
||||||
|
|
||||||
if (count($ids)) {
|
|
||||||
$query->whereHas($property, function ($query) use ($relatedKey, $ids) {
|
|
||||||
$query->whereIn($relatedKey, $ids);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
$query->whereDoesntHave($property);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function sortByAttribute($query, Attribute $attribute, string $direction): void
|
|
||||||
{
|
|
||||||
$query->orderBy($this->getAttributeColumn($attribute), $direction);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function paginate($query, int $limit, int $offset): void
|
|
||||||
{
|
|
||||||
$query->take($limit)->skip($offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function load(array $models, array $relationships, $scope, bool $linkage): void
|
|
||||||
{
|
|
||||||
// TODO: Find the relation on the model that we're after. If it's a
|
|
||||||
// belongs-to relation, and we only need linkage, then we won't need
|
|
||||||
// to load anything as the related ID is store directly on the model.
|
|
||||||
|
|
||||||
(new Collection($models))->loadMissing([
|
|
||||||
$this->getRelationshipPath($relationships) => function ($relation) use ($relationships, $scope) {
|
|
||||||
$query = $relation->getQuery();
|
|
||||||
|
|
||||||
if (is_array($scope)) {
|
|
||||||
// Eloquent doesn't support polymorphic loading constraints,
|
|
||||||
// so for now we just won't do anything.
|
|
||||||
// https://github.com/laravel/framework/pull/35190
|
|
||||||
} else {
|
|
||||||
$scope($query);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getAttributeProperty(Attribute $attribute): string
|
private function getAttributeProperty(Attribute $attribute): string
|
||||||
|
|
@ -214,28 +207,13 @@ class EloquentAdapter implements AdapterInterface
|
||||||
return $attribute->getProperty() ?: strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $attribute->getName()));
|
return $attribute->getProperty() ?: strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $attribute->getName()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getAttributeColumn(Attribute $attribute): string
|
|
||||||
{
|
|
||||||
return $this->model->getTable().'.'.$this->getAttributeProperty($attribute);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getRelationshipProperty(Relationship $relationship): string
|
private function getRelationshipProperty(Relationship $relationship): string
|
||||||
{
|
{
|
||||||
return $relationship->getProperty() ?: $relationship->getName();
|
return $relationship->getProperty() ?: $relationship->getName();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getRelationshipPath(array $trail): string
|
|
||||||
{
|
|
||||||
return implode('.', array_map([$this, 'getRelationshipProperty'], $trail));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getEloquentRelation($model, Relationship $relationship)
|
private function getEloquentRelation($model, Relationship $relationship)
|
||||||
{
|
{
|
||||||
return $model->{$this->getRelationshipProperty($relationship)}();
|
return $model->{$this->getRelationshipProperty($relationship)}();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getRelationValue($model, Relationship $relationship)
|
|
||||||
{
|
|
||||||
return $model->{$this->getRelationshipProperty($relationship)};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of tobyz/json-api-server.
|
||||||
|
*
|
||||||
|
* (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 Tobyz\JsonApiServer\Adapter;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
use Tobyz\JsonApiServer\Context;
|
||||||
|
use Tobyz\JsonApiServer\Schema\Relationship;
|
||||||
|
|
||||||
|
use function Tobyz\JsonApiServer\run_callbacks;
|
||||||
|
|
||||||
|
abstract class EloquentBuffer
|
||||||
|
{
|
||||||
|
private static $buffer = [];
|
||||||
|
|
||||||
|
public static function add(Model $model, string $relationName): void
|
||||||
|
{
|
||||||
|
static::$buffer[get_class($model)][$relationName][] = $model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function load(Model $model, string $relationName, Relationship $relationship, Context $context): void
|
||||||
|
{
|
||||||
|
if (! $models = static::$buffer[get_class($model)][$relationName] ?? null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Collection::make($models)->load([
|
||||||
|
$relationName => function ($relation) use ($model, $relationName, $relationship, $context) {
|
||||||
|
$query = $relation->getQuery();
|
||||||
|
|
||||||
|
// When loading the relationship, we need to scope the query
|
||||||
|
// using the scopes defined in the related API resource – there
|
||||||
|
// may be multiple if this is a polymorphic relationship. We
|
||||||
|
// start by getting the resource types this relationship
|
||||||
|
// could possibly contain.
|
||||||
|
$resourceTypes = $context->getApi()->getResourceTypes();
|
||||||
|
|
||||||
|
if ($type = $relationship->getType()) {
|
||||||
|
if (is_string($type)) {
|
||||||
|
$resourceTypes = [$resourceTypes[$type]];
|
||||||
|
} else {
|
||||||
|
$resourceTypes = array_intersect_key($resourceTypes, array_flip($type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now, construct a map of model class names -> scoping
|
||||||
|
// functions. This will be provided to the MorphTo::constrain
|
||||||
|
// method in order to apply type-specific scoping.
|
||||||
|
$constrain = [];
|
||||||
|
|
||||||
|
foreach ($resourceTypes as $resourceType) {
|
||||||
|
if ($model = $resourceType->getAdapter()->model()) {
|
||||||
|
$constrain[get_class($model)] = function ($query) use ($resourceType, $context) {
|
||||||
|
$resourceType->applyScopes($query, $context);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($relation instanceof MorphTo) {
|
||||||
|
$relation->constrain($constrain);
|
||||||
|
} else {
|
||||||
|
reset($constrain)($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also apply any local scopes that have been defined on this
|
||||||
|
// relationship.
|
||||||
|
run_callbacks(
|
||||||
|
$relationship->getListeners('scope'),
|
||||||
|
[$query, $context]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
static::$buffer[get_class($model)][$relationName] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tobyz\JsonApiServer\Adapter;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Tobyz\JsonApiServer\Context;
|
||||||
|
use Tobyz\JsonApiServer\Schema\Attribute;
|
||||||
|
use Tobyz\JsonApiServer\Schema\HasMany;
|
||||||
|
use Tobyz\JsonApiServer\Schema\HasOne;
|
||||||
|
use Tobyz\JsonApiServer\Schema\Relationship;
|
||||||
|
|
||||||
|
class NullAdapter implements AdapterInterface
|
||||||
|
{
|
||||||
|
public function query()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function filterByIds($query, array $ids): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function filterByAttribute($query, Attribute $attribute, $value, string $operator = '='): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function filterByRelationship($query, Relationship $relationship, Closure $scope): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function filterByExpression($query, string $expression): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sparseFieldset($query, $fields): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sortByAttribute($query, Attribute $attribute, string $direction): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function paginate($query, int $limit, int $offset): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find($query, string $id)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get($query): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function count($query): int
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId($model): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAttribute($model, Attribute $attribute)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHasOne($model, HasOne $relationship, bool $linkageOnly, Context $context)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHasMany($model, HasMany $relationship, bool $linkageOnly, Context $context)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function represents($model): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function model()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setId($model, string $id): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAttribute($model, Attribute $attribute, $value): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHasOne($model, HasOne $relationship, $related): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save($model): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveHasMany($model, HasMany $relationship, array $related): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete($model): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,20 +20,94 @@ class Context
|
||||||
use HasMeta;
|
use HasMeta;
|
||||||
use HasListeners;
|
use HasListeners;
|
||||||
|
|
||||||
|
private $api;
|
||||||
private $request;
|
private $request;
|
||||||
|
|
||||||
public function __construct(ServerRequestInterface $request)
|
public function __construct(JsonApi $api, ServerRequestInterface $request)
|
||||||
{
|
{
|
||||||
|
$this->api = $api;
|
||||||
$this->request = $request;
|
$this->request = $request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the JsonApi instance.
|
||||||
|
*/
|
||||||
|
public function getApi(): JsonApi
|
||||||
|
{
|
||||||
|
return $this->api;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the PSR-7 request instance.
|
||||||
|
*/
|
||||||
public function getRequest(): ServerRequestInterface
|
public function getRequest(): ServerRequestInterface
|
||||||
{
|
{
|
||||||
return $this->request;
|
return $this->request;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function response(callable $callback)
|
public function withRequest(ServerRequestInterface $request): Context
|
||||||
|
{
|
||||||
|
return new static($this->api, $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the request path relative to the API's base path.
|
||||||
|
*/
|
||||||
|
public function getPath(): string
|
||||||
|
{
|
||||||
|
return $this->api->stripBasePath(
|
||||||
|
$this->request->getUri()->getPath()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the parsed JSON:API payload.
|
||||||
|
*/
|
||||||
|
public function getBody(): ?array
|
||||||
|
{
|
||||||
|
return $this->request->getParsedBody() ?: json_decode($this->request->getBody()->getContents(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function response(callable $callback): void
|
||||||
{
|
{
|
||||||
$this->listeners['response'][] = $callback;
|
$this->listeners['response'][] = $callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether a field has been requested in a sparse fieldset.
|
||||||
|
*/
|
||||||
|
public function fieldRequested(string $type, string $field, bool $default = true): bool
|
||||||
|
{
|
||||||
|
$queryParams = $this->request->getQueryParams();
|
||||||
|
|
||||||
|
if (! isset($queryParams['fields'][$type])) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($field, explode(',', $queryParams['fields'][$type]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether a sort field has been requested.
|
||||||
|
*/
|
||||||
|
public function sortRequested(string $field): bool
|
||||||
|
{
|
||||||
|
if ($sortString = $this->getRequest()->getQueryParams()['sort'] ?? null) {
|
||||||
|
foreach (parse_sort_string($sortString) as [$name, $direction]) {
|
||||||
|
if ($name === $field) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the value of a filter.
|
||||||
|
*/
|
||||||
|
public function filter(string $name): ?string
|
||||||
|
{
|
||||||
|
return $this->request->getQueryParams()['filter'][$name] ?? null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of tobyz/json-api-server.
|
||||||
|
*
|
||||||
|
* (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 Tobyz\JsonApiServer;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
|
||||||
|
class Deferred
|
||||||
|
{
|
||||||
|
private $callback;
|
||||||
|
|
||||||
|
public function __construct(Closure $callback)
|
||||||
|
{
|
||||||
|
$this->callback = $callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolve()
|
||||||
|
{
|
||||||
|
return ($this->callback)();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of tobyz/json-api-server.
|
||||||
|
*
|
||||||
|
* (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 Tobyz\JsonApiServer\Endpoint\Concerns;
|
||||||
|
|
||||||
|
use JsonApiPhp\JsonApi\JsonApi;
|
||||||
|
use JsonApiPhp\JsonApi\Meta;
|
||||||
|
use Tobyz\JsonApiServer\Context;
|
||||||
|
|
||||||
|
trait BuildsMeta
|
||||||
|
{
|
||||||
|
private function buildMeta(Context $context): array
|
||||||
|
{
|
||||||
|
$meta = [];
|
||||||
|
|
||||||
|
foreach ($context->getMeta() as $item) {
|
||||||
|
$meta[] = new Meta($item->getName(), $item->getValue()($context));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildJsonApiObject(Context $context): JsonApi
|
||||||
|
{
|
||||||
|
return new JsonApi('1.1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,17 +23,17 @@ trait FindsResources
|
||||||
*
|
*
|
||||||
* @throws ResourceNotFoundException if the resource is not found.
|
* @throws ResourceNotFoundException if the resource is not found.
|
||||||
*/
|
*/
|
||||||
private function findResource(ResourceType $resource, string $id, Context $context)
|
private function findResource(ResourceType $resourceType, string $id, Context $context)
|
||||||
{
|
{
|
||||||
$adapter = $resource->getAdapter();
|
$adapter = $resourceType->getAdapter();
|
||||||
$query = $adapter->newQuery();
|
$query = $adapter->query();
|
||||||
|
|
||||||
run_callbacks($resource->getSchema()->getListeners('scope'), [$query, $context]);
|
run_callbacks($resourceType->getSchema()->getListeners('scope'), [$query, $context]);
|
||||||
|
|
||||||
$model = $adapter->find($query, $id);
|
$model = $adapter->find($query, $id);
|
||||||
|
|
||||||
if (! $model) {
|
if (! $model) {
|
||||||
throw new ResourceNotFoundException($resource->getType(), $id);
|
throw new ResourceNotFoundException($resourceType->getType(), $id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $model;
|
return $model;
|
||||||
|
|
|
||||||
|
|
@ -11,27 +11,21 @@
|
||||||
|
|
||||||
namespace Tobyz\JsonApiServer\Endpoint\Concerns;
|
namespace Tobyz\JsonApiServer\Endpoint\Concerns;
|
||||||
|
|
||||||
use Tobyz\JsonApiServer\Exception\BadRequestException;
|
|
||||||
use Tobyz\JsonApiServer\JsonApi;
|
|
||||||
use Tobyz\JsonApiServer\ResourceType;
|
|
||||||
use Tobyz\JsonApiServer\Context;
|
use Tobyz\JsonApiServer\Context;
|
||||||
|
use Tobyz\JsonApiServer\Exception\BadRequestException;
|
||||||
|
use Tobyz\JsonApiServer\ResourceType;
|
||||||
use Tobyz\JsonApiServer\Schema\Relationship;
|
use Tobyz\JsonApiServer\Schema\Relationship;
|
||||||
use function Tobyz\JsonApiServer\run_callbacks;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property JsonApi $api
|
|
||||||
* @property ResourceType $resource
|
|
||||||
*/
|
|
||||||
trait IncludesData
|
trait IncludesData
|
||||||
{
|
{
|
||||||
private function getInclude(Context $context): array
|
private function getInclude(Context $context, ResourceType $resourceType): array
|
||||||
{
|
{
|
||||||
$queryParams = $context->getRequest()->getQueryParams();
|
$queryParams = $context->getRequest()->getQueryParams();
|
||||||
|
|
||||||
if (! empty($queryParams['include'])) {
|
if (! empty($queryParams['include'])) {
|
||||||
$include = $this->parseInclude($queryParams['include']);
|
$include = $this->parseInclude($queryParams['include']);
|
||||||
|
|
||||||
$this->validateInclude($this->resource, $include);
|
$this->validateInclude($context, [$resourceType], $include);
|
||||||
|
|
||||||
return $include;
|
return $include;
|
||||||
}
|
}
|
||||||
|
|
@ -39,11 +33,11 @@ trait IncludesData
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function parseInclude(string $include): array
|
private function parseInclude($include): array
|
||||||
{
|
{
|
||||||
$tree = [];
|
$tree = [];
|
||||||
|
|
||||||
foreach (explode(',', $include) as $path) {
|
foreach (is_array($include) ? $include : explode(',', $include) as $path) {
|
||||||
$array = &$tree;
|
$array = &$tree;
|
||||||
|
|
||||||
foreach (explode('.', $path) as $key) {
|
foreach (explode('.', $path) as $key) {
|
||||||
|
|
@ -58,87 +52,38 @@ trait IncludesData
|
||||||
return $tree;
|
return $tree;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validateInclude(ResourceType $resource, array $include, string $path = '')
|
private function validateInclude(Context $context, array $resourceTypes, array $include, string $path = '')
|
||||||
{
|
{
|
||||||
$fields = $resource->getSchema()->getFields();
|
|
||||||
|
|
||||||
foreach ($include as $name => $nested) {
|
foreach ($include as $name => $nested) {
|
||||||
if (
|
foreach ($resourceTypes as $resource) {
|
||||||
! isset($fields[$name])
|
$fields = $resource->getSchema()->getFields();
|
||||||
|| ! $fields[$name] instanceof Relationship
|
|
||||||
|| ! $fields[$name]->isIncludable()
|
|
||||||
) {
|
|
||||||
throw new BadRequestException("Invalid include [{$path}{$name}]", 'include');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (($type = $fields[$name]->getType()) && is_string($type)) {
|
if (
|
||||||
$relatedResource = $this->api->getResource($type);
|
! isset($fields[$name])
|
||||||
|
|| ! $fields[$name] instanceof Relationship
|
||||||
|
|| ! $fields[$name]->isIncludable()
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$this->validateInclude($relatedResource, $nested, $name.'.');
|
$type = $fields[$name]->getType();
|
||||||
} elseif ($nested) {
|
|
||||||
throw new BadRequestException("Invalid include [{$path}{$name}.*]", 'include');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function loadRelationships(array $models, array $include, Context $context)
|
|
||||||
{
|
|
||||||
$this->loadRelationshipsAtLevel($models, [], $this->resource, $include, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function loadRelationshipsAtLevel(array $models, array $relationshipPath, ResourceType $resource, array $include, Context $context)
|
|
||||||
{
|
|
||||||
$adapter = $resource->getAdapter();
|
|
||||||
$schema = $resource->getSchema();
|
|
||||||
$fields = $schema->getFields();
|
|
||||||
|
|
||||||
foreach ($fields as $name => $field) {
|
|
||||||
if (
|
|
||||||
! $field instanceof Relationship
|
|
||||||
|| (! $field->hasLinkage() && ! isset($include[$name]))
|
|
||||||
|| $field->getVisible() === false
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$nextRelationshipPath = array_merge($relationshipPath, [$field]);
|
|
||||||
|
|
||||||
if ($field->shouldLoad()) {
|
|
||||||
$type = $field->getType();
|
|
||||||
|
|
||||||
if (is_string($type)) {
|
if (is_string($type)) {
|
||||||
$relatedResource = $this->api->getResource($type);
|
$relatedResource = $context->getApi()->getResourceType($type);
|
||||||
$scope = function ($query) use ($context, $field, $relatedResource) {
|
|
||||||
run_callbacks($relatedResource->getSchema()->getListeners('scope'), [$query, $context]);
|
$this->validateInclude($context, [$relatedResource], $nested, $name.'.');
|
||||||
run_callbacks($field->getListeners('scope'), [$query, $context]);
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
$relatedResources = is_array($type) ? array_map(function ($type) {
|
$relatedResources = is_array($type) ? array_map(function ($type) use ($context) {
|
||||||
return $this->api->getResource($type);
|
return $context->getApi()->getResourceType($type);
|
||||||
}, $type) : $this->api->getResources();
|
}, $type) : array_values($context->getApi()->getResourceTypes());
|
||||||
|
|
||||||
$scope = array_combine(
|
$this->validateInclude($context, $relatedResources, $nested, $name.'.');
|
||||||
array_map(function ($relatedResource) {
|
|
||||||
return $relatedResource->getType();
|
|
||||||
}, $relatedResources),
|
|
||||||
|
|
||||||
array_map(function ($relatedResource) use ($context, $field) {
|
|
||||||
return function ($query) use ($context, $field, $relatedResource) {
|
|
||||||
run_callbacks($relatedResource->getSchema()->getListeners('scope'), [$query, $context]);
|
|
||||||
run_callbacks($field->getListeners('scope'), [$query, $context]);
|
|
||||||
};
|
|
||||||
}, $relatedResources)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$adapter->load($models, $nextRelationshipPath, $scope, $field->hasLinkage());
|
continue 2;
|
||||||
|
|
||||||
if (isset($include[$name]) && is_string($type)) {
|
|
||||||
$relatedResource = $this->api->getResource($type);
|
|
||||||
|
|
||||||
$this->loadRelationshipsAtLevel($models, $nextRelationshipPath, $relatedResource, $include[$name] ?? [], $context);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw (new BadRequestException("Invalid include [{$path}{$name}]"))->setSourceParameter('include');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,25 +11,23 @@
|
||||||
|
|
||||||
namespace Tobyz\JsonApiServer\Endpoint\Concerns;
|
namespace Tobyz\JsonApiServer\Endpoint\Concerns;
|
||||||
|
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Tobyz\JsonApiServer\Context;
|
||||||
use Tobyz\JsonApiServer\Exception\BadRequestException;
|
use Tobyz\JsonApiServer\Exception\BadRequestException;
|
||||||
|
use Tobyz\JsonApiServer\Exception\ConflictException;
|
||||||
|
use Tobyz\JsonApiServer\Exception\ForbiddenException;
|
||||||
use Tobyz\JsonApiServer\Exception\UnprocessableEntityException;
|
use Tobyz\JsonApiServer\Exception\UnprocessableEntityException;
|
||||||
use Tobyz\JsonApiServer\ResourceType;
|
use Tobyz\JsonApiServer\ResourceType;
|
||||||
use Tobyz\JsonApiServer\Schema\Attribute;
|
use Tobyz\JsonApiServer\Schema\Attribute;
|
||||||
use Tobyz\JsonApiServer\Context;
|
|
||||||
use Tobyz\JsonApiServer\Schema\HasMany;
|
use Tobyz\JsonApiServer\Schema\HasMany;
|
||||||
use Tobyz\JsonApiServer\Schema\HasOne;
|
use Tobyz\JsonApiServer\Schema\HasOne;
|
||||||
use Tobyz\JsonApiServer\Schema\Relationship;
|
use Tobyz\JsonApiServer\Schema\Relationship;
|
||||||
|
|
||||||
use function Tobyz\JsonApiServer\evaluate;
|
use function Tobyz\JsonApiServer\evaluate;
|
||||||
use function Tobyz\JsonApiServer\get_value;
|
use function Tobyz\JsonApiServer\get_value;
|
||||||
use function Tobyz\JsonApiServer\has_value;
|
use function Tobyz\JsonApiServer\has_value;
|
||||||
use function Tobyz\JsonApiServer\run_callbacks;
|
use function Tobyz\JsonApiServer\run_callbacks;
|
||||||
use function Tobyz\JsonApiServer\set_value;
|
use function Tobyz\JsonApiServer\set_value;
|
||||||
|
|
||||||
/**
|
|
||||||
* @property JsonApi $api
|
|
||||||
* @property ResourceType $resource
|
|
||||||
*/
|
|
||||||
trait SavesData
|
trait SavesData
|
||||||
{
|
{
|
||||||
use FindsResources;
|
use FindsResources;
|
||||||
|
|
@ -39,7 +37,7 @@ trait SavesData
|
||||||
*
|
*
|
||||||
* @throws BadRequestException if the `data` member is invalid.
|
* @throws BadRequestException if the `data` member is invalid.
|
||||||
*/
|
*/
|
||||||
private function parseData($body, $model = null): array
|
private function parseData(ResourceType $resourceType, $body, $model = null): array
|
||||||
{
|
{
|
||||||
$body = (array) $body;
|
$body = (array) $body;
|
||||||
|
|
||||||
|
|
@ -47,16 +45,24 @@ trait SavesData
|
||||||
throw new BadRequestException('data must be an object');
|
throw new BadRequestException('data must be an object');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! isset($body['data']['type']) || $body['data']['type'] !== $this->resource->getType()) {
|
if (! isset($body['data']['type'])) {
|
||||||
throw new BadRequestException('data.type does not match the resource type');
|
throw new BadRequestException('data.type must be present');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($model) {
|
if ($model) {
|
||||||
$id = $this->resource->getAdapter()->getId($model);
|
if (! isset($body['data']['id'])) {
|
||||||
|
throw new BadRequestException('data.id must be present');
|
||||||
if (! isset($body['data']['id']) || $body['data']['id'] !== $id) {
|
|
||||||
throw new BadRequestException('data.id does not match the resource ID');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($body['data']['id'] !== $resourceType->getAdapter()->getId($model)) {
|
||||||
|
throw new ConflictException('data.id does not match the resource ID');
|
||||||
|
}
|
||||||
|
} elseif (isset($body['data']['id'])) {
|
||||||
|
throw new ForbiddenException('Client-generated IDs are not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($body['data']['type'] !== $resourceType->getType()) {
|
||||||
|
throw new ConflictException('data.type does not match the resource type');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($body['data']['attributes']) && ! is_array($body['data']['attributes'])) {
|
if (isset($body['data']['attributes']) && ! is_array($body['data']['attributes'])) {
|
||||||
|
|
@ -92,18 +98,18 @@ trait SavesData
|
||||||
throw new BadRequestException("type [{$identifier['type']}] not allowed");
|
throw new BadRequestException("type [{$identifier['type']}] not allowed");
|
||||||
}
|
}
|
||||||
|
|
||||||
$resource = $this->api->getResource($identifier['type']);
|
$resourceType = $context->getApi()->getResourceType($identifier['type']);
|
||||||
|
|
||||||
return $this->findResource($resource, $identifier['id'], $context);
|
return $this->findResource($resourceType, $identifier['id'], $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assert that the fields contained within a data object are valid.
|
* Assert that the fields contained within a data object are valid.
|
||||||
*/
|
*/
|
||||||
private function validateFields(array $data, $model, Context $context)
|
private function validateFields(ResourceType $resourceType, array $data, $model, Context $context)
|
||||||
{
|
{
|
||||||
$this->assertFieldsExist($data);
|
$this->assertFieldsExist($resourceType, $data);
|
||||||
$this->assertFieldsWritable($data, $model, $context);
|
$this->assertFieldsWritable($resourceType, $data, $model, $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -111,9 +117,9 @@ trait SavesData
|
||||||
*
|
*
|
||||||
* @throws BadRequestException if a field is unknown.
|
* @throws BadRequestException if a field is unknown.
|
||||||
*/
|
*/
|
||||||
private function assertFieldsExist(array $data)
|
private function assertFieldsExist(ResourceType $resourceType, array $data)
|
||||||
{
|
{
|
||||||
$fields = $this->resource->getSchema()->getFields();
|
$fields = $resourceType->getSchema()->getFields();
|
||||||
|
|
||||||
foreach (['attributes', 'relationships'] as $location) {
|
foreach (['attributes', 'relationships'] as $location) {
|
||||||
foreach ($data[$location] as $name => $value) {
|
foreach ($data[$location] as $name => $value) {
|
||||||
|
|
@ -129,10 +135,9 @@ trait SavesData
|
||||||
*
|
*
|
||||||
* @throws BadRequestException if a field is not writable.
|
* @throws BadRequestException if a field is not writable.
|
||||||
*/
|
*/
|
||||||
private function assertFieldsWritable(array $data, $model, Context $context)
|
private function assertFieldsWritable(ResourceType $resourceType, array $data, $model, Context $context)
|
||||||
{
|
{
|
||||||
|
foreach ($resourceType->getSchema()->getFields() as $field) {
|
||||||
foreach ($this->resource->getSchema()->getFields() as $field) {
|
|
||||||
if (! has_value($data, $field)) {
|
if (! has_value($data, $field)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -152,16 +157,20 @@ trait SavesData
|
||||||
/**
|
/**
|
||||||
* Replace relationship linkage within a data object with models.
|
* Replace relationship linkage within a data object with models.
|
||||||
*/
|
*/
|
||||||
private function loadRelatedResources(array &$data, Context $context)
|
private function loadRelatedResources(ResourceType $resourceType, array &$data, Context $context)
|
||||||
{
|
{
|
||||||
foreach ($this->resource->getSchema()->getFields() as $field) {
|
foreach ($resourceType->getSchema()->getFields() as $field) {
|
||||||
if (! $field instanceof Relationship || ! has_value($data, $field)) {
|
if (! $field instanceof Relationship || ! has_value($data, $field)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$value = get_value($data, $field);
|
$value = get_value($data, $field);
|
||||||
|
|
||||||
if (isset($value['data'])) {
|
if (! array_key_exists('data', $value)) {
|
||||||
|
throw new BadRequestException('relationship does not include data key');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value['data'] !== null) {
|
||||||
$allowedTypes = (array) $field->getType();
|
$allowedTypes = (array) $field->getType();
|
||||||
|
|
||||||
if ($field instanceof HasOne) {
|
if ($field instanceof HasOne) {
|
||||||
|
|
@ -182,11 +191,11 @@ trait SavesData
|
||||||
*
|
*
|
||||||
* @throws UnprocessableEntityException if any fields do not pass validation.
|
* @throws UnprocessableEntityException if any fields do not pass validation.
|
||||||
*/
|
*/
|
||||||
private function assertDataValid(array $data, $model, Context $context, bool $validateAll): void
|
private function assertDataValid(ResourceType $resourceType, array $data, $model, Context $context, bool $validateAll): void
|
||||||
{
|
{
|
||||||
$failures = [];
|
$failures = [];
|
||||||
|
|
||||||
foreach ($this->resource->getSchema()->getFields() as $field) {
|
foreach ($resourceType->getSchema()->getFields() as $field) {
|
||||||
if (! $validateAll && ! has_value($data, $field)) {
|
if (! $validateAll && ! has_value($data, $field)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -209,11 +218,11 @@ trait SavesData
|
||||||
/**
|
/**
|
||||||
* Set field values from a data object to the model instance.
|
* Set field values from a data object to the model instance.
|
||||||
*/
|
*/
|
||||||
private function setValues(array $data, $model, Context $context)
|
private function setValues(ResourceType $resourceType, array $data, $model, Context $context)
|
||||||
{
|
{
|
||||||
$adapter = $this->resource->getAdapter();
|
$adapter = $resourceType->getAdapter();
|
||||||
|
|
||||||
foreach ($this->resource->getSchema()->getFields() as $field) {
|
foreach ($resourceType->getSchema()->getFields() as $field) {
|
||||||
if (! has_value($data, $field)) {
|
if (! has_value($data, $field)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -240,32 +249,32 @@ trait SavesData
|
||||||
/**
|
/**
|
||||||
* Save the model and its fields.
|
* Save the model and its fields.
|
||||||
*/
|
*/
|
||||||
private function save(array $data, $model, Context $context)
|
private function save(ResourceType $resourceType, array $data, $model, Context $context)
|
||||||
{
|
{
|
||||||
$this->saveModel($model, $context);
|
$this->saveModel($resourceType, $model, $context);
|
||||||
$this->saveFields($data, $model, $context);
|
$this->saveFields($resourceType, $data, $model, $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save the model.
|
* Save the model.
|
||||||
*/
|
*/
|
||||||
private function saveModel($model, Context $context)
|
private function saveModel(ResourceType $resourceType, $model, Context $context)
|
||||||
{
|
{
|
||||||
if ($saveCallback = $this->resource->getSchema()->getSaveCallback()) {
|
if ($saveCallback = $resourceType->getSchema()->getSaveCallback()) {
|
||||||
$saveCallback($model, $context);
|
$saveCallback($model, $context);
|
||||||
} else {
|
} else {
|
||||||
$this->resource->getAdapter()->save($model);
|
$resourceType->getAdapter()->save($model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save any fields that were not saved with the model.
|
* Save any fields that were not saved with the model.
|
||||||
*/
|
*/
|
||||||
private function saveFields(array $data, $model, Context $context)
|
private function saveFields(ResourceType $resourceType, array $data, $model, Context $context)
|
||||||
{
|
{
|
||||||
$adapter = $this->resource->getAdapter();
|
$adapter = $resourceType->getAdapter();
|
||||||
|
|
||||||
foreach ($this->resource->getSchema()->getFields() as $field) {
|
foreach ($resourceType->getSchema()->getFields() as $field) {
|
||||||
if (! has_value($data, $field)) {
|
if (! has_value($data, $field)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -279,16 +288,16 @@ trait SavesData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->runSavedCallbacks($data, $model, $context);
|
$this->runSavedCallbacks($resourceType, $data, $model, $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run field saved listeners.
|
* Run field saved listeners.
|
||||||
*/
|
*/
|
||||||
private function runSavedCallbacks(array $data, $model, Context $context)
|
private function runSavedCallbacks(ResourceType $resourceType, array $data, $model, Context $context)
|
||||||
{
|
{
|
||||||
|
|
||||||
foreach ($this->resource->getSchema()->getFields() as $field) {
|
foreach ($resourceType->getSchema()->getFields() as $field) {
|
||||||
if (! has_value($data, $field)) {
|
if (! has_value($data, $field)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -299,4 +308,14 @@ trait SavesData
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a fresh copy of the model for display.
|
||||||
|
*/
|
||||||
|
private function freshModel(ResourceType $resourceType, $model, Context $context)
|
||||||
|
{
|
||||||
|
$id = $resourceType->getAdapter()->getId($model);
|
||||||
|
|
||||||
|
return $this->findResource($resourceType, $id, $context);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,11 @@ namespace Tobyz\JsonApiServer\Endpoint;
|
||||||
|
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Tobyz\JsonApiServer\Context;
|
use Tobyz\JsonApiServer\Context;
|
||||||
|
use Tobyz\JsonApiServer\Endpoint\Concerns\BuildsMeta;
|
||||||
|
use Tobyz\JsonApiServer\Endpoint\Concerns\SavesData;
|
||||||
use Tobyz\JsonApiServer\Exception\ForbiddenException;
|
use Tobyz\JsonApiServer\Exception\ForbiddenException;
|
||||||
use Tobyz\JsonApiServer\JsonApi;
|
|
||||||
use Tobyz\JsonApiServer\ResourceType;
|
use Tobyz\JsonApiServer\ResourceType;
|
||||||
|
|
||||||
use function Tobyz\JsonApiServer\evaluate;
|
use function Tobyz\JsonApiServer\evaluate;
|
||||||
use function Tobyz\JsonApiServer\has_value;
|
use function Tobyz\JsonApiServer\has_value;
|
||||||
use function Tobyz\JsonApiServer\run_callbacks;
|
use function Tobyz\JsonApiServer\run_callbacks;
|
||||||
|
|
@ -23,61 +25,57 @@ use function Tobyz\JsonApiServer\set_value;
|
||||||
|
|
||||||
class Create
|
class Create
|
||||||
{
|
{
|
||||||
use Concerns\SavesData;
|
use SavesData;
|
||||||
|
|
||||||
private $api;
|
|
||||||
private $resource;
|
|
||||||
|
|
||||||
public function __construct(JsonApi $api, ResourceType $resource)
|
|
||||||
{
|
|
||||||
$this->api = $api;
|
|
||||||
$this->resource = $resource;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws ForbiddenException if the resource is not creatable.
|
* @throws ForbiddenException if the resource is not creatable.
|
||||||
*/
|
*/
|
||||||
public function handle(Context $context): ResponseInterface
|
public function handle(Context $context, ResourceType $resourceType): ResponseInterface
|
||||||
{
|
{
|
||||||
$schema = $this->resource->getSchema();
|
$schema = $resourceType->getSchema();
|
||||||
|
|
||||||
if (! evaluate($schema->isCreatable(), [$context])) {
|
if (! evaluate($schema->isCreatable(), [$context])) {
|
||||||
throw new ForbiddenException;
|
throw new ForbiddenException(sprintf(
|
||||||
|
'Cannot create resource type %s',
|
||||||
|
$resourceType->getType()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
$model = $this->newModel($context);
|
$model = $this->newModel($resourceType, $context);
|
||||||
$data = $this->parseData($context->getRequest()->getParsedBody());
|
$data = $this->parseData($resourceType, $context->getBody());
|
||||||
|
|
||||||
$this->validateFields($data, $model, $context);
|
$this->validateFields($resourceType, $data, $model, $context);
|
||||||
$this->fillDefaultValues($data, $context);
|
$this->fillDefaultValues($resourceType, $data, $context);
|
||||||
$this->loadRelatedResources($data, $context);
|
$this->loadRelatedResources($resourceType, $data, $context);
|
||||||
$this->assertDataValid($data, $model, $context, true);
|
$this->assertDataValid($resourceType, $data, $model, $context, true);
|
||||||
$this->setValues($data, $model, $context);
|
$this->setValues($resourceType, $data, $model, $context);
|
||||||
|
|
||||||
run_callbacks($schema->getListeners('creating'), [&$model, $context]);
|
run_callbacks($schema->getListeners('creating'), [&$model, $context]);
|
||||||
|
|
||||||
$this->save($data, $model, $context);
|
$this->save($resourceType, $data, $model, $context);
|
||||||
|
|
||||||
run_callbacks($schema->getListeners('created'), [&$model, $context]);
|
run_callbacks($schema->getListeners('created'), [&$model, $context]);
|
||||||
|
|
||||||
return (new Show($this->api, $this->resource, $model))
|
$model = $this->freshModel($resourceType, $model, $context);
|
||||||
->handle($context)
|
|
||||||
->withStatus(201);
|
return (new Show())
|
||||||
|
->handle($context, $resourceType, $model)
|
||||||
|
->withStatus(201)
|
||||||
|
->withHeader('Location', $resourceType->url($model, $context));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function newModel(Context $context)
|
private function newModel(ResourceType $resourceType, Context $context)
|
||||||
{
|
{
|
||||||
$resource = $this->resource;
|
$newModel = $resourceType->getSchema()->getModelCallback();
|
||||||
$newModel = $resource->getSchema()->getNewModelCallback();
|
|
||||||
|
|
||||||
return $newModel
|
return $newModel
|
||||||
? $newModel($context)
|
? $newModel($context)
|
||||||
: $resource->getAdapter()->newModel();
|
: $resourceType->getAdapter()->model();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function fillDefaultValues(array &$data, Context $context)
|
private function fillDefaultValues(ResourceType $resourceType, array &$data, Context $context)
|
||||||
{
|
{
|
||||||
foreach ($this->resource->getSchema()->getFields() as $field) {
|
foreach ($resourceType->getSchema()->getFields() as $field) {
|
||||||
if (! has_value($data, $field) && ($defaultCallback = $field->getDefaultCallback())) {
|
if (! has_value($data, $field) && ($defaultCallback = $field->getDefaultCallback())) {
|
||||||
set_value($data, $field, $defaultCallback($context));
|
set_value($data, $field, $defaultCallback($context));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,48 +11,55 @@
|
||||||
|
|
||||||
namespace Tobyz\JsonApiServer\Endpoint;
|
namespace Tobyz\JsonApiServer\Endpoint;
|
||||||
|
|
||||||
|
use JsonApiPhp\JsonApi\Meta;
|
||||||
|
use JsonApiPhp\JsonApi\MetaDocument;
|
||||||
use Nyholm\Psr7\Response;
|
use Nyholm\Psr7\Response;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Tobyz\JsonApiServer\Exception\ForbiddenException;
|
|
||||||
use Tobyz\JsonApiServer\JsonApi;
|
|
||||||
use Tobyz\JsonApiServer\ResourceType;
|
|
||||||
use Tobyz\JsonApiServer\Context;
|
use Tobyz\JsonApiServer\Context;
|
||||||
|
use Tobyz\JsonApiServer\Endpoint\Concerns\BuildsMeta;
|
||||||
|
use Tobyz\JsonApiServer\Exception\ForbiddenException;
|
||||||
|
use Tobyz\JsonApiServer\ResourceType;
|
||||||
|
|
||||||
use function Tobyz\JsonApiServer\evaluate;
|
use function Tobyz\JsonApiServer\evaluate;
|
||||||
|
use function Tobyz\JsonApiServer\json_api_response;
|
||||||
use function Tobyz\JsonApiServer\run_callbacks;
|
use function Tobyz\JsonApiServer\run_callbacks;
|
||||||
|
|
||||||
class Delete
|
class Delete
|
||||||
{
|
{
|
||||||
private $api;
|
use BuildsMeta;
|
||||||
private $resource;
|
|
||||||
private $model;
|
|
||||||
|
|
||||||
public function __construct(JsonApi $api, ResourceType $resource, $model)
|
|
||||||
{
|
|
||||||
$this->api = $api;
|
|
||||||
$this->resource = $resource;
|
|
||||||
$this->model = $model;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws ForbiddenException if the resource is not deletable.
|
* @throws ForbiddenException if the resource is not deletable.
|
||||||
*/
|
*/
|
||||||
public function handle(Context $context): ResponseInterface
|
public function handle(Context $context, ResourceType $resourceType, $model): ResponseInterface
|
||||||
{
|
{
|
||||||
$schema = $this->resource->getSchema();
|
$schema = $resourceType->getSchema();
|
||||||
|
|
||||||
if (! evaluate($schema->isDeletable(), [$this->model, $context])) {
|
if (! evaluate($schema->isDeletable(), [$model, $context])) {
|
||||||
throw new ForbiddenException;
|
throw new ForbiddenException(sprintf(
|
||||||
|
'Cannot delete resource %s:%s',
|
||||||
|
$resourceType->getType(),
|
||||||
|
$resourceType->getAdapter()->getId($model)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
run_callbacks($schema->getListeners('deleting'), [&$this->model, $context]);
|
run_callbacks($schema->getListeners('deleting'), [&$model, $context]);
|
||||||
|
|
||||||
if ($deleteCallback = $schema->getDeleteCallback()) {
|
if ($deleteCallback = $schema->getDeleteCallback()) {
|
||||||
$deleteCallback($this->model, $context);
|
$deleteCallback($model, $context);
|
||||||
} else {
|
} else {
|
||||||
$this->resource->getAdapter()->delete($this->model);
|
$resourceType->getAdapter()->delete($model);
|
||||||
}
|
}
|
||||||
|
|
||||||
run_callbacks($schema->getListeners('deleted'), [&$this->model, $context]);
|
run_callbacks($schema->getListeners('deleted'), [&$model, $context]);
|
||||||
|
|
||||||
|
if (count($meta = $this->buildMeta($context))) {
|
||||||
|
$meta[] = $this->buildJsonApiObject($context);
|
||||||
|
|
||||||
|
return json_api_response(
|
||||||
|
new MetaDocument(...$meta)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(204);
|
return new Response(204);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,80 +17,103 @@ use JsonApiPhp\JsonApi\Link\NextLink;
|
||||||
use JsonApiPhp\JsonApi\Link\PrevLink;
|
use JsonApiPhp\JsonApi\Link\PrevLink;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
use Tobyz\JsonApiServer\Adapter\AdapterInterface;
|
use Tobyz\JsonApiServer\Context;
|
||||||
|
use Tobyz\JsonApiServer\Endpoint\Concerns\BuildsMeta;
|
||||||
|
use Tobyz\JsonApiServer\Endpoint\Concerns\IncludesData;
|
||||||
use Tobyz\JsonApiServer\Exception\BadRequestException;
|
use Tobyz\JsonApiServer\Exception\BadRequestException;
|
||||||
use Tobyz\JsonApiServer\Exception\ForbiddenException;
|
use Tobyz\JsonApiServer\Exception\ForbiddenException;
|
||||||
use Tobyz\JsonApiServer\JsonApi;
|
|
||||||
use Tobyz\JsonApiServer\ResourceType;
|
use Tobyz\JsonApiServer\ResourceType;
|
||||||
use Tobyz\JsonApiServer\Schema\Attribute;
|
|
||||||
use Tobyz\JsonApiServer\Context;
|
|
||||||
use Tobyz\JsonApiServer\Schema\HasMany;
|
|
||||||
use Tobyz\JsonApiServer\Schema\HasOne;
|
|
||||||
use Tobyz\JsonApiServer\Serializer;
|
use Tobyz\JsonApiServer\Serializer;
|
||||||
|
|
||||||
use function Tobyz\JsonApiServer\evaluate;
|
use function Tobyz\JsonApiServer\evaluate;
|
||||||
use function Tobyz\JsonApiServer\json_api_response;
|
use function Tobyz\JsonApiServer\json_api_response;
|
||||||
use function Tobyz\JsonApiServer\run_callbacks;
|
use function Tobyz\JsonApiServer\run_callbacks;
|
||||||
|
|
||||||
class Index
|
class Index
|
||||||
{
|
{
|
||||||
use Concerns\IncludesData;
|
use IncludesData;
|
||||||
|
use BuildsMeta;
|
||||||
private $api;
|
|
||||||
private $resource;
|
|
||||||
|
|
||||||
public function __construct(JsonApi $api, ResourceType $resource)
|
|
||||||
{
|
|
||||||
$this->api = $api;
|
|
||||||
$this->resource = $resource;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a request to show a resource listing.
|
* Handle a request to show a resource listing.
|
||||||
*/
|
*/
|
||||||
public function handle(Context $context): ResponseInterface
|
public function handle(Context $context, ResourceType $resourceType): ResponseInterface
|
||||||
{
|
{
|
||||||
$adapter = $this->resource->getAdapter();
|
$adapter = $resourceType->getAdapter();
|
||||||
$schema = $this->resource->getSchema();
|
$schema = $resourceType->getSchema();
|
||||||
|
|
||||||
if (! evaluate($schema->isListable(), [$context])) {
|
if (! evaluate($schema->isListable(), [$context])) {
|
||||||
throw new ForbiddenException;
|
throw new ForbiddenException(sprintf(
|
||||||
|
'Cannot list resource type %s',
|
||||||
|
$resourceType->getType()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
$query = $adapter->newQuery();
|
$query = $adapter->query();
|
||||||
|
|
||||||
|
$resourceType->applyScopes($query, $context);
|
||||||
|
|
||||||
|
$include = $this->getInclude($context, $resourceType);
|
||||||
|
|
||||||
|
[$offset, $limit] = $this->paginate($resourceType, $query, $context);
|
||||||
|
|
||||||
|
if ($sortString = $context->getRequest()->getQueryParams()['sort'] ?? $schema->getDefaultSort()) {
|
||||||
|
$resourceType->applySort($query, $sortString, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($filter = $context->getRequest()->getQueryParams()['filter'] ?? null) {
|
||||||
|
$resourceType->applyFilters($query, $filter, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($fields = $context->getRequest()->getQueryParams()['fields'] ?? null) {
|
||||||
|
$resourceType->applySparseFieldset($query, $fields, $context);
|
||||||
|
}
|
||||||
|
|
||||||
run_callbacks($schema->getListeners('listing'), [$query, $context]);
|
run_callbacks($schema->getListeners('listing'), [$query, $context]);
|
||||||
run_callbacks($schema->getListeners('scope'), [$query, $context]);
|
|
||||||
|
|
||||||
$include = $this->getInclude($context);
|
|
||||||
|
|
||||||
[$offset, $limit] = $this->paginate($query, $context);
|
|
||||||
$this->sort($query, $context);
|
|
||||||
$this->filter($query, $context);
|
|
||||||
|
|
||||||
$total = $schema->isCountable() ? $adapter->count($query) : null;
|
$total = $schema->isCountable() ? $adapter->count($query) : null;
|
||||||
$models = $adapter->get($query);
|
$models = $adapter->get($query);
|
||||||
|
|
||||||
$this->loadRelationships($models, $include, $context);
|
|
||||||
|
|
||||||
run_callbacks($schema->getListeners('listed'), [$models, $context]);
|
run_callbacks($schema->getListeners('listed'), [$models, $context]);
|
||||||
|
|
||||||
$serializer = new Serializer($this->api, $context);
|
$serializer = new Serializer($context);
|
||||||
|
|
||||||
foreach ($models as $model) {
|
foreach ($models as $model) {
|
||||||
$serializer->add($this->resource, $model, $include);
|
$serializer->add($resourceType, $model, $include);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[$primary, $included] = $serializer->serialize();
|
||||||
|
|
||||||
|
$paginationLinks = $this->buildPaginationLinks(
|
||||||
|
$resourceType,
|
||||||
|
$context->getRequest(),
|
||||||
|
$offset,
|
||||||
|
$limit,
|
||||||
|
count($models),
|
||||||
|
$total
|
||||||
|
);
|
||||||
|
|
||||||
|
$meta = [
|
||||||
|
new Structure\Meta('offset', $offset),
|
||||||
|
new Structure\Meta('limit', $limit),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($total !== null) {
|
||||||
|
$meta[] = new Structure\Meta('total', $total);
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = array_merge($meta, $this->buildMeta($context));
|
||||||
|
|
||||||
return json_api_response(
|
return json_api_response(
|
||||||
new Structure\CompoundDocument(
|
new Structure\CompoundDocument(
|
||||||
new Structure\PaginatedCollection(
|
new Structure\PaginatedCollection(
|
||||||
new Structure\Pagination(...$this->buildPaginationLinks($context->getRequest(), $offset, $limit, count($models), $total)),
|
new Structure\Pagination(...$paginationLinks),
|
||||||
new Structure\ResourceCollection(...$serializer->primary())
|
new Structure\ResourceCollection(...$primary)
|
||||||
),
|
),
|
||||||
new Structure\Included(...$serializer->included()),
|
new Structure\Included(...$included),
|
||||||
new Structure\Link\SelfLink($this->buildUrl($context->getRequest())),
|
new Structure\Link\SelfLink($this->buildUrl($context->getRequest())),
|
||||||
new Structure\Meta('offset', $offset),
|
$this->buildJsonApiObject($context),
|
||||||
new Structure\Meta('limit', $limit),
|
...$meta
|
||||||
...($total !== null ? [new Structure\Meta('total', $total)] : [])
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -111,15 +134,17 @@ class Index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ksort($queryParams);
|
||||||
|
|
||||||
$queryString = http_build_query($queryParams, '', '&', PHP_QUERY_RFC3986);
|
$queryString = http_build_query($queryParams, '', '&', PHP_QUERY_RFC3986);
|
||||||
|
|
||||||
return $selfUrl.($queryString ? '?'.$queryString : '');
|
return $selfUrl.($queryString ? '?'.$queryString : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildPaginationLinks(Request $request, int $offset, ?int $limit, int $count, ?int $total)
|
private function buildPaginationLinks(ResourceType $resourceType, Request $request, int $offset, ?int $limit, int $count, ?int $total): array
|
||||||
{
|
{
|
||||||
$paginationLinks = [];
|
$paginationLinks = [];
|
||||||
$schema = $this->resource->getSchema();
|
$schema = $resourceType->getSchema();
|
||||||
|
|
||||||
if ($offset > 0) {
|
if ($offset > 0) {
|
||||||
$paginationLinks[] = new Structure\Link\FirstLink($this->buildUrl($request, ['page' => ['offset' => 0]]));
|
$paginationLinks[] = new Structure\Link\FirstLink($this->buildUrl($request, ['page' => ['offset' => 0]]));
|
||||||
|
|
@ -135,69 +160,20 @@ class Index
|
||||||
$paginationLinks[] = new PrevLink($this->buildUrl($request, $params));
|
$paginationLinks[] = new PrevLink($this->buildUrl($request, $params));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($schema->isCountable() && $schema->getPerPage() && $offset + $limit < $total) {
|
if ($schema->isCountable() && $schema->getPerPage() && $limit && $offset + $limit < $total) {
|
||||||
$paginationLinks[] = new LastLink($this->buildUrl($request, ['page' => ['offset' => floor(($total - 1) / $limit) * $limit]]));
|
$paginationLinks[] = new LastLink($this->buildUrl($request, ['page' => ['offset' => floor(($total - 1) / $limit) * $limit]]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (($total === null && $count === $limit) || $offset + $limit < $total) {
|
if (($total === null && $count === $limit) || $offset + $count < $total) {
|
||||||
$paginationLinks[] = new NextLink($this->buildUrl($request, ['page' => ['offset' => $offset + $limit]]));
|
$paginationLinks[] = new NextLink($this->buildUrl($request, ['page' => ['offset' => $offset + $limit]]));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $paginationLinks;
|
return $paginationLinks;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sort($query, Context $context)
|
private function paginate(ResourceType $resourceType, $query, Context $context): array
|
||||||
{
|
{
|
||||||
$schema = $this->resource->getSchema();
|
$schema = $resourceType->getSchema();
|
||||||
|
|
||||||
if (! $sort = $context->getRequest()->getQueryParams()['sort'] ?? $schema->getDefaultSort()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$adapter = $this->resource->getAdapter();
|
|
||||||
$sortFields = $schema->getSortFields();
|
|
||||||
$fields = $schema->getFields();
|
|
||||||
|
|
||||||
foreach ($this->parseSort($sort) as $name => $direction) {
|
|
||||||
if (isset($sortFields[$name])) {
|
|
||||||
$sortFields[$name]($query, $direction, $context);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
isset($fields[$name])
|
|
||||||
&& $fields[$name] instanceof Attribute
|
|
||||||
&& evaluate($fields[$name]->getSortable(), [$context])
|
|
||||||
) {
|
|
||||||
$adapter->sortByAttribute($query, $fields[$name], $direction);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new BadRequestException("Invalid sort field [$name]", 'sort');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function parseSort(string $string): array
|
|
||||||
{
|
|
||||||
$sort = [];
|
|
||||||
|
|
||||||
foreach (explode(',', $string) as $field) {
|
|
||||||
if ($field[0] === '-') {
|
|
||||||
$field = substr($field, 1);
|
|
||||||
$direction = 'desc';
|
|
||||||
} else {
|
|
||||||
$direction = 'asc';
|
|
||||||
}
|
|
||||||
|
|
||||||
$sort[$field] = $direction;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $sort;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function paginate($query, Context $context)
|
|
||||||
{
|
|
||||||
$schema = $this->resource->getSchema();
|
|
||||||
$queryParams = $context->getRequest()->getQueryParams();
|
$queryParams = $context->getRequest()->getQueryParams();
|
||||||
$limit = $schema->getPerPage();
|
$limit = $schema->getPerPage();
|
||||||
|
|
||||||
|
|
@ -205,7 +181,7 @@ class Index
|
||||||
$limit = $queryParams['page']['limit'];
|
$limit = $queryParams['page']['limit'];
|
||||||
|
|
||||||
if (! ctype_digit(strval($limit)) || $limit < 1) {
|
if (! ctype_digit(strval($limit)) || $limit < 1) {
|
||||||
throw new BadRequestException('page[limit] must be a positive integer', 'page[limit]');
|
throw (new BadRequestException('page[limit] must be a positive integer'))->setSourceParameter('page[limit]');
|
||||||
}
|
}
|
||||||
|
|
||||||
$limit = min($schema->getLimit(), $limit);
|
$limit = min($schema->getLimit(), $limit);
|
||||||
|
|
@ -217,81 +193,14 @@ class Index
|
||||||
$offset = $queryParams['page']['offset'];
|
$offset = $queryParams['page']['offset'];
|
||||||
|
|
||||||
if (! ctype_digit(strval($offset)) || $offset < 0) {
|
if (! ctype_digit(strval($offset)) || $offset < 0) {
|
||||||
throw new BadRequestException('page[offset] must be a non-negative integer', 'page[offset]');
|
throw (new BadRequestException('page[offset] must be a non-negative integer'))->setSourceParameter('page[offset]');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($limit || $offset) {
|
if ($limit || $offset) {
|
||||||
$this->resource->getAdapter()->paginate($query, $limit, $offset);
|
$resourceType->getAdapter()->paginate($query, $limit, $offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [$offset, $limit];
|
return [$offset, $limit];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function filter($query, Context $context)
|
|
||||||
{
|
|
||||||
if (! $filter = $context->getRequest()->getQueryParams()['filter'] ?? null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_array($filter)) {
|
|
||||||
throw new BadRequestException('filter must be an array', 'filter');
|
|
||||||
}
|
|
||||||
|
|
||||||
$schema = $this->resource->getSchema();
|
|
||||||
$adapter = $this->resource->getAdapter();
|
|
||||||
$filters = $schema->getFilters();
|
|
||||||
$fields = $schema->getFields();
|
|
||||||
|
|
||||||
foreach ($filter as $name => $value) {
|
|
||||||
if ($name === 'id') {
|
|
||||||
$adapter->filterByIds($query, explode(',', $value));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($filters[$name]) && evaluate($filters[$name]->getVisible(), [$context])) {
|
|
||||||
$filters[$name]->getCallback()($query, $value, $context);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($fields[$name]) && evaluate($fields[$name]->getFilterable(), [$context])) {
|
|
||||||
if ($fields[$name] instanceof Attribute) {
|
|
||||||
$this->filterByAttribute($adapter, $query, $fields[$name], $value);
|
|
||||||
} elseif ($fields[$name] instanceof HasOne) {
|
|
||||||
$value = array_filter(explode(',', $value));
|
|
||||||
$adapter->filterByHasOne($query, $fields[$name], $value);
|
|
||||||
} elseif ($fields[$name] instanceof HasMany) {
|
|
||||||
$value = array_filter(explode(',', $value));
|
|
||||||
$adapter->filterByHasMany($query, $fields[$name], $value);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new BadRequestException("Invalid filter [$name]", "filter[$name]");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function filterByAttribute(AdapterInterface $adapter, $query, Attribute $attribute, $value)
|
|
||||||
{
|
|
||||||
if (preg_match('/(.+)\.\.(.+)/', $value, $matches)) {
|
|
||||||
if ($matches[1] !== '*') {
|
|
||||||
$adapter->filterByAttribute($query, $attribute, $value, '>=');
|
|
||||||
}
|
|
||||||
if ($matches[2] !== '*') {
|
|
||||||
$adapter->filterByAttribute($query, $attribute, $value, '<=');
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (['>=', '>', '<=', '<'] as $operator) {
|
|
||||||
if (strpos($value, $operator) === 0) {
|
|
||||||
$adapter->filterByAttribute($query, $attribute, substr($value, strlen($operator)), $operator);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$adapter->filterByAttribute($query, $attribute, $value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,43 +14,37 @@ namespace Tobyz\JsonApiServer\Endpoint;
|
||||||
use JsonApiPhp\JsonApi\CompoundDocument;
|
use JsonApiPhp\JsonApi\CompoundDocument;
|
||||||
use JsonApiPhp\JsonApi\Included;
|
use JsonApiPhp\JsonApi\Included;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Tobyz\JsonApiServer\JsonApi;
|
|
||||||
use Tobyz\JsonApiServer\ResourceType;
|
|
||||||
use Tobyz\JsonApiServer\Context;
|
use Tobyz\JsonApiServer\Context;
|
||||||
|
use Tobyz\JsonApiServer\Endpoint\Concerns\BuildsMeta;
|
||||||
|
use Tobyz\JsonApiServer\Endpoint\Concerns\IncludesData;
|
||||||
|
use Tobyz\JsonApiServer\ResourceType;
|
||||||
use Tobyz\JsonApiServer\Serializer;
|
use Tobyz\JsonApiServer\Serializer;
|
||||||
|
|
||||||
use function Tobyz\JsonApiServer\json_api_response;
|
use function Tobyz\JsonApiServer\json_api_response;
|
||||||
use function Tobyz\JsonApiServer\run_callbacks;
|
use function Tobyz\JsonApiServer\run_callbacks;
|
||||||
|
|
||||||
class Show
|
class Show
|
||||||
{
|
{
|
||||||
use Concerns\IncludesData;
|
use IncludesData;
|
||||||
|
use BuildsMeta;
|
||||||
|
|
||||||
private $api;
|
public function handle(Context $context, ResourceType $resourceType, $model): ResponseInterface
|
||||||
private $resource;
|
|
||||||
private $model;
|
|
||||||
|
|
||||||
public function __construct(JsonApi $api, ResourceType $resource, $model)
|
|
||||||
{
|
{
|
||||||
$this->api = $api;
|
run_callbacks($resourceType->getSchema()->getListeners('show'), [&$model, $context]);
|
||||||
$this->resource = $resource;
|
|
||||||
$this->model = $model;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(Context $context): ResponseInterface
|
$include = $this->getInclude($context, $resourceType);
|
||||||
{
|
|
||||||
$include = $this->getInclude($context);
|
|
||||||
|
|
||||||
$this->loadRelationships([$this->model], $include, $context);
|
$serializer = new Serializer($context);
|
||||||
|
$serializer->add($resourceType, $model, $include);
|
||||||
|
|
||||||
run_callbacks($this->resource->getSchema()->getListeners('show'), [&$this->model, $context]);
|
[$primary, $included] = $serializer->serialize();
|
||||||
|
|
||||||
$serializer = new Serializer($this->api, $context);
|
|
||||||
$serializer->add($this->resource, $this->model, $include);
|
|
||||||
|
|
||||||
return json_api_response(
|
return json_api_response(
|
||||||
new CompoundDocument(
|
new CompoundDocument(
|
||||||
$serializer->primary()[0],
|
$primary[0],
|
||||||
new Included(...$serializer->included())
|
new Included(...$included),
|
||||||
|
$this->buildJsonApiObject($context),
|
||||||
|
...$this->buildMeta($context)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,53 +12,50 @@
|
||||||
namespace Tobyz\JsonApiServer\Endpoint;
|
namespace Tobyz\JsonApiServer\Endpoint;
|
||||||
|
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Tobyz\JsonApiServer\Exception\ForbiddenException;
|
|
||||||
use Tobyz\JsonApiServer\JsonApi;
|
|
||||||
use Tobyz\JsonApiServer\ResourceType;
|
|
||||||
use Tobyz\JsonApiServer\Context;
|
use Tobyz\JsonApiServer\Context;
|
||||||
|
use Tobyz\JsonApiServer\Endpoint\Concerns\BuildsMeta;
|
||||||
|
use Tobyz\JsonApiServer\Endpoint\Concerns\SavesData;
|
||||||
|
use Tobyz\JsonApiServer\Exception\ForbiddenException;
|
||||||
|
use Tobyz\JsonApiServer\ResourceType;
|
||||||
|
|
||||||
use function Tobyz\JsonApiServer\evaluate;
|
use function Tobyz\JsonApiServer\evaluate;
|
||||||
use function Tobyz\JsonApiServer\run_callbacks;
|
use function Tobyz\JsonApiServer\run_callbacks;
|
||||||
|
|
||||||
class Update
|
class Update
|
||||||
{
|
{
|
||||||
use Concerns\SavesData;
|
use SavesData;
|
||||||
|
|
||||||
private $api;
|
|
||||||
private $resource;
|
|
||||||
private $model;
|
|
||||||
|
|
||||||
public function __construct(JsonApi $api, ResourceType $resource, $model)
|
|
||||||
{
|
|
||||||
$this->api = $api;
|
|
||||||
$this->resource = $resource;
|
|
||||||
$this->model = $model;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws ForbiddenException if the resource is not updatable.
|
* @throws ForbiddenException if the resource is not updatable.
|
||||||
*/
|
*/
|
||||||
public function handle(Context $context): ResponseInterface
|
public function handle(Context $context, ResourceType $resourceType, $model): ResponseInterface
|
||||||
{
|
{
|
||||||
$schema = $this->resource->getSchema();
|
$schema = $resourceType->getSchema();
|
||||||
|
|
||||||
if (! evaluate($schema->isUpdatable(), [$this->model, $context])) {
|
if (! evaluate($schema->isUpdatable(), [$model, $context])) {
|
||||||
throw new ForbiddenException;
|
throw new ForbiddenException(sprintf(
|
||||||
|
'Cannot update resource %s:%s',
|
||||||
|
$resourceType->getType(),
|
||||||
|
$resourceType->getAdapter()->getId($model)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = $this->parseData($context->getRequest()->getParsedBody(), $this->model);
|
$data = $this->parseData($resourceType, $context->getBody(), $model);
|
||||||
|
|
||||||
$this->validateFields($data, $this->model, $context);
|
$this->validateFields($resourceType, $data, $model, $context);
|
||||||
$this->loadRelatedResources($data, $context);
|
$this->loadRelatedResources($resourceType, $data, $context);
|
||||||
$this->assertDataValid($data, $this->model, $context, false);
|
$this->assertDataValid($resourceType, $data, $model, $context, false);
|
||||||
$this->setValues($data, $this->model, $context);
|
$this->setValues($resourceType, $data, $model, $context);
|
||||||
|
|
||||||
run_callbacks($schema->getListeners('updating'), [&$this->model, $context]);
|
run_callbacks($schema->getListeners('updating'), [&$model, $context]);
|
||||||
|
|
||||||
$this->save($data, $this->model, $context);
|
$this->save($resourceType, $data, $model, $context);
|
||||||
|
|
||||||
run_callbacks($schema->getListeners('updated'), [&$this->model, $context]);
|
run_callbacks($schema->getListeners('updated'), [&$model, $context]);
|
||||||
|
|
||||||
return (new Show($this->api, $this->resource, $this->model))
|
$model = $this->freshModel($resourceType, $model, $context);
|
||||||
->handle($context);
|
|
||||||
|
return (new Show())
|
||||||
|
->handle($context, $resourceType, $model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,23 @@ use Tobyz\JsonApiServer\ErrorProviderInterface;
|
||||||
|
|
||||||
class BadRequestException extends DomainException implements ErrorProviderInterface
|
class BadRequestException extends DomainException implements ErrorProviderInterface
|
||||||
{
|
{
|
||||||
/**
|
private $sourceType;
|
||||||
* @var string
|
private $source;
|
||||||
*/
|
|
||||||
private $sourceParameter;
|
|
||||||
|
|
||||||
public function __construct(string $message = '', string $sourceParameter = '')
|
public function setSourceParameter(string $parameter)
|
||||||
{
|
{
|
||||||
parent::__construct($message);
|
$this->sourceType = 'parameter';
|
||||||
|
$this->source = $parameter;
|
||||||
|
|
||||||
$this->sourceParameter = $sourceParameter;
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSourcePointer(string $pointer)
|
||||||
|
{
|
||||||
|
$this->sourceType = 'pointer';
|
||||||
|
$this->source = $pointer;
|
||||||
|
|
||||||
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getJsonApiErrors(): array
|
public function getJsonApiErrors(): array
|
||||||
|
|
@ -37,8 +44,10 @@ class BadRequestException extends DomainException implements ErrorProviderInterf
|
||||||
$members[] = new Error\Detail($this->message);
|
$members[] = new Error\Detail($this->message);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->sourceParameter) {
|
if ($this->sourceType === 'parameter') {
|
||||||
$members[] = new Error\SourceParameter($this->sourceParameter);
|
$members[] = new Error\SourceParameter($this->source);
|
||||||
|
} elseif ($this->sourceType === 'pointer') {
|
||||||
|
$members[] = new Error\SourcePointer($this->source);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of tobyz/json-api-server.
|
||||||
|
*
|
||||||
|
* (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 Tobyz\JsonApiServer\Exception;
|
||||||
|
|
||||||
|
use DomainException;
|
||||||
|
use JsonApiPhp\JsonApi\Error;
|
||||||
|
use Tobyz\JsonApiServer\ErrorProviderInterface;
|
||||||
|
|
||||||
|
class ConflictException extends DomainException implements ErrorProviderInterface
|
||||||
|
{
|
||||||
|
public function getJsonApiErrors(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new Error(
|
||||||
|
new Error\Title('Conflict'),
|
||||||
|
new Error\Status($this->getJsonApiStatus()),
|
||||||
|
...($this->message ? [new Error\Detail($this->message)] : [])
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getJsonApiStatus(): string
|
||||||
|
{
|
||||||
|
return '409';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,8 @@ class ForbiddenException extends DomainException implements ErrorProviderInterfa
|
||||||
return [
|
return [
|
||||||
new Error(
|
new Error(
|
||||||
new Error\Title('Forbidden'),
|
new Error\Title('Forbidden'),
|
||||||
new Error\Status($this->getJsonApiStatus())
|
new Error\Status($this->getJsonApiStatus()),
|
||||||
|
...($this->message ? [new Error\Detail($this->message)] : [])
|
||||||
)
|
)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of tobyz/json-api-server.
|
||||||
|
*
|
||||||
|
* (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 Tobyz\JsonApiServer\Extension;
|
||||||
|
|
||||||
|
use Nyholm\Psr7\Uri;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Tobyz\JsonApiServer\Context;
|
||||||
|
use Tobyz\JsonApiServer\Endpoint;
|
||||||
|
use Tobyz\JsonApiServer\Endpoint\Concerns\FindsResources;
|
||||||
|
use Tobyz\JsonApiServer\Exception\BadRequestException;
|
||||||
|
use Tobyz\JsonApiServer\Exception\MethodNotAllowedException;
|
||||||
|
use Tobyz\JsonApiServer\Exception\NotImplementedException;
|
||||||
|
|
||||||
|
use function Tobyz\JsonApiServer\json_api_response;
|
||||||
|
|
||||||
|
final class Atomic extends Extension
|
||||||
|
{
|
||||||
|
use FindsResources;
|
||||||
|
|
||||||
|
private $path;
|
||||||
|
|
||||||
|
public function __construct(string $path = 'operations')
|
||||||
|
{
|
||||||
|
$this->path = $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uri(): string
|
||||||
|
{
|
||||||
|
return 'https://jsonapi.org/ext/atomic';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(Context $context): ?Response
|
||||||
|
{
|
||||||
|
if ($context->getPath() !== '/operations') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $context->getRequest();
|
||||||
|
|
||||||
|
if ($request->getMethod() !== 'POST') {
|
||||||
|
throw new MethodNotAllowedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $context->getBody();
|
||||||
|
$operations = $body['atomic:operations'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($operations)) {
|
||||||
|
throw new BadRequestException('atomic:operations must be an array of operation objects');
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
$lids = [];
|
||||||
|
|
||||||
|
foreach ($operations as $i => $operation) {
|
||||||
|
switch ($operation['op'] ?? null) {
|
||||||
|
case 'add':
|
||||||
|
$response = $this->add($context, $operation, $lids);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'update':
|
||||||
|
$response = $this->update($context, $operation, $lids);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'remove':
|
||||||
|
$response = $this->remove($context, $operation, $lids);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw (new BadRequestException('Invalid operation'))->setSourcePointer("/atomic:operations/$i");
|
||||||
|
}
|
||||||
|
|
||||||
|
$results[] = json_decode($response->getBody(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_api_response(
|
||||||
|
['atomic:results' => $results]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function add(Context $context, array $operation, array &$lids): Response
|
||||||
|
{
|
||||||
|
// TODO: support href and ref
|
||||||
|
if (isset($operation['href']) || isset($operation['ref'])) {
|
||||||
|
throw new NotImplementedException('href and ref are not currently supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = $operation['data']['type'];
|
||||||
|
$resourceType = $context->getApi()->getResourceType($type);
|
||||||
|
|
||||||
|
$request = $context->getRequest()
|
||||||
|
->withMethod('POST')
|
||||||
|
->withUri(new Uri("/$type"))
|
||||||
|
->withQueryParams($operation['params'] ?? [])
|
||||||
|
->withParsedBody(array_diff_key($this->replaceLids($operation, $lids), ['op', 'href', 'ref', 'params']));
|
||||||
|
|
||||||
|
$context = $context->withRequest($request);
|
||||||
|
|
||||||
|
$response = (new Endpoint\Create())->handle($context, $resourceType);
|
||||||
|
|
||||||
|
if ($lid = $operation['data']['lid'] ?? null) {
|
||||||
|
if ($id = json_decode($response->getBody(), true)['data']['id'] ?? null) {
|
||||||
|
$lids[$lid] = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function update(Context $context, array $operation, array $lids): Response
|
||||||
|
{
|
||||||
|
// TODO: support href and ref
|
||||||
|
if (isset($operation['href']) || isset($operation['ref'])) {
|
||||||
|
throw new NotImplementedException('href and ref are not currently supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
$operation = $this->replaceLids($operation, $lids);
|
||||||
|
$type = $operation['data']['type'];
|
||||||
|
$id = $operation['data']['id'];
|
||||||
|
$resourceType = $context->getApi()->getResourceType($type);
|
||||||
|
|
||||||
|
$request = $context->getRequest()
|
||||||
|
->withMethod('PATCH')
|
||||||
|
->withUri(new Uri("/$type/$id"))
|
||||||
|
->withQueryParams($operation['params'] ?? [])
|
||||||
|
->withParsedBody(array_diff_key($operation, ['op', 'href', 'ref', 'params']));
|
||||||
|
|
||||||
|
$context = $context->withRequest($request);
|
||||||
|
|
||||||
|
$model = $this->findResource($resourceType, $id, $context);
|
||||||
|
|
||||||
|
return (new Endpoint\Update())->handle($context, $resourceType, $model);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function remove(Context $context, array $operation, array $lids): Response
|
||||||
|
{
|
||||||
|
// TODO: support href
|
||||||
|
if (isset($operation['href'])) {
|
||||||
|
throw new NotImplementedException('href is not currently supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
$operation = $this->replaceLids($operation, $lids);
|
||||||
|
$type = $operation['ref']['type'];
|
||||||
|
$id = $operation['ref']['id'];
|
||||||
|
$resourceType = $context->getApi()->getResourceType($type);
|
||||||
|
|
||||||
|
$request = $context->getRequest()
|
||||||
|
->withMethod('DELETE')
|
||||||
|
->withUri(new Uri("/$type/$id"))
|
||||||
|
->withQueryParams($operation['params'] ?? [])
|
||||||
|
->withParsedBody(array_diff_key($operation, ['op', 'href', 'ref', 'params']));
|
||||||
|
|
||||||
|
$context = $context->withRequest($request);
|
||||||
|
|
||||||
|
$model = $this->findResource($resourceType, $id, $context);
|
||||||
|
|
||||||
|
return (new Endpoint\Delete())->handle($context, $resourceType, $model);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function replaceLids(array &$array, array $lids): array
|
||||||
|
{
|
||||||
|
foreach ($array as $k => &$v) {
|
||||||
|
if ($k === 'lid' && isset($lids[$v])) {
|
||||||
|
$array['id'] = $lids[$v];
|
||||||
|
unset($array['lid']);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($v)) {
|
||||||
|
$v = $this->replaceLids($v, $lids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $array;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of tobyz/json-api-server.
|
||||||
|
*
|
||||||
|
* (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 Tobyz\JsonApiServer\Extension;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Tobyz\JsonApiServer\Context;
|
||||||
|
|
||||||
|
abstract class Extension
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The URI that uniquely identifies this extension.
|
||||||
|
*
|
||||||
|
* @see https://jsonapi.org/format/1.1/#media-type-parameter-rules
|
||||||
|
*/
|
||||||
|
abstract public function uri(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a request.
|
||||||
|
*/
|
||||||
|
public function handle(Context $context): ?Response
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This file is part of tobyz/json-api-server.
|
|
||||||
*
|
|
||||||
* (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 Tobyz\JsonApiServer\Http;
|
|
||||||
|
|
||||||
class MediaTypes
|
|
||||||
{
|
|
||||||
private $value;
|
|
||||||
|
|
||||||
public function __construct(string $value)
|
|
||||||
{
|
|
||||||
$this->value = $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether the list contains the given type without modifications
|
|
||||||
*
|
|
||||||
* This is meant to ease implementation of JSON:API rules for content
|
|
||||||
* negotiation, which demand HTTP error responses e.g. when all of the
|
|
||||||
* JSON:API media types in the "Accept" header are modified with "media type
|
|
||||||
* parameters". Therefore, this method only returns true when the requested
|
|
||||||
* media type is contained without additional parameters (except for the
|
|
||||||
* weight parameter "q" and "Accept extension parameters").
|
|
||||||
*
|
|
||||||
* @param string $mediaType
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
public function containsExactly(string $mediaType): bool
|
|
||||||
{
|
|
||||||
$types = array_map('trim', explode(',', $this->value));
|
|
||||||
|
|
||||||
// Accept headers can contain multiple media types, so we need to check
|
|
||||||
// whether any of them matches.
|
|
||||||
foreach ($types as $type) {
|
|
||||||
$parts = array_map('trim', explode(';', $type));
|
|
||||||
|
|
||||||
// The actual media type needs to be an exact match
|
|
||||||
if (array_shift($parts) !== $mediaType) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The media type can optionally be followed by "media type
|
|
||||||
// parameters". Parameters after the "q" parameter are considered
|
|
||||||
// "Accept extension parameters", which we don't care about. Thus,
|
|
||||||
// we have an exact match if there are no parameters at all or if
|
|
||||||
// the first one is named "q".
|
|
||||||
// See https://tools.ietf.org/html/rfc7231#section-5.3.2.
|
|
||||||
if (empty($parts) || substr($parts[0], 0, 2) === 'q=') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
265
src/JsonApi.php
265
src/JsonApi.php
|
|
@ -11,11 +11,13 @@
|
||||||
|
|
||||||
namespace Tobyz\JsonApiServer;
|
namespace Tobyz\JsonApiServer;
|
||||||
|
|
||||||
|
use HttpAccept\AcceptParser;
|
||||||
use JsonApiPhp\JsonApi\ErrorDocument;
|
use JsonApiPhp\JsonApi\ErrorDocument;
|
||||||
use Psr\Http\Message\ResponseInterface as Response;
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
use Psr\Http\Server\RequestHandlerInterface;
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
use Tobyz\JsonApiServer\Adapter\AdapterInterface;
|
use Tobyz\JsonApiServer\Adapter\AdapterInterface;
|
||||||
|
use Tobyz\JsonApiServer\Endpoint\Concerns\FindsResources;
|
||||||
use Tobyz\JsonApiServer\Exception\BadRequestException;
|
use Tobyz\JsonApiServer\Exception\BadRequestException;
|
||||||
use Tobyz\JsonApiServer\Exception\InternalServerErrorException;
|
use Tobyz\JsonApiServer\Exception\InternalServerErrorException;
|
||||||
use Tobyz\JsonApiServer\Exception\MethodNotAllowedException;
|
use Tobyz\JsonApiServer\Exception\MethodNotAllowedException;
|
||||||
|
|
@ -23,32 +25,58 @@ use Tobyz\JsonApiServer\Exception\NotAcceptableException;
|
||||||
use Tobyz\JsonApiServer\Exception\NotImplementedException;
|
use Tobyz\JsonApiServer\Exception\NotImplementedException;
|
||||||
use Tobyz\JsonApiServer\Exception\ResourceNotFoundException;
|
use Tobyz\JsonApiServer\Exception\ResourceNotFoundException;
|
||||||
use Tobyz\JsonApiServer\Exception\UnsupportedMediaTypeException;
|
use Tobyz\JsonApiServer\Exception\UnsupportedMediaTypeException;
|
||||||
use Tobyz\JsonApiServer\Endpoint\Concerns\FindsResources;
|
use Tobyz\JsonApiServer\Extension\Extension;
|
||||||
use Tobyz\JsonApiServer\Http\MediaTypes;
|
|
||||||
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
|
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
|
||||||
use Tobyz\JsonApiServer\Context;
|
|
||||||
|
|
||||||
final class JsonApi implements RequestHandlerInterface
|
final class JsonApi implements RequestHandlerInterface
|
||||||
{
|
{
|
||||||
const MEDIA_TYPE = 'application/vnd.api+json';
|
public const MEDIA_TYPE = 'application/vnd.api+json';
|
||||||
|
|
||||||
use FindsResources;
|
use FindsResources;
|
||||||
use HasMeta;
|
use HasMeta;
|
||||||
|
|
||||||
private $resources = [];
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
private $basePath;
|
private $basePath;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Extension[]
|
||||||
|
*/
|
||||||
|
private $extensions = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ResourceType[]
|
||||||
|
*/
|
||||||
|
private $resourceTypes = [];
|
||||||
|
|
||||||
public function __construct(string $basePath)
|
public function __construct(string $basePath)
|
||||||
{
|
{
|
||||||
$this->basePath = $basePath;
|
$this->basePath = $basePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an extension.
|
||||||
|
*/
|
||||||
|
public function extension(Extension $extension)
|
||||||
|
{
|
||||||
|
$this->extensions[$extension->uri()] = $extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered extensions.
|
||||||
|
*/
|
||||||
|
public function getExtensions(): array
|
||||||
|
{
|
||||||
|
return $this->extensions;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a new resource type.
|
* Define a new resource type.
|
||||||
*/
|
*/
|
||||||
public function resource(string $type, AdapterInterface $adapter, callable $buildSchema = null): void
|
public function resourceType(string $type, AdapterInterface $adapter, callable $buildSchema = null): void
|
||||||
{
|
{
|
||||||
$this->resources[$type] = new ResourceType($type, $adapter, $buildSchema);
|
$this->resourceTypes[$type] = new ResourceType($type, $adapter, $buildSchema);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -56,9 +84,9 @@ final class JsonApi implements RequestHandlerInterface
|
||||||
*
|
*
|
||||||
* @return ResourceType[]
|
* @return ResourceType[]
|
||||||
*/
|
*/
|
||||||
public function getResources(): array
|
public function getResourceTypes(): array
|
||||||
{
|
{
|
||||||
return $this->resources;
|
return $this->resourceTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -66,13 +94,13 @@ final class JsonApi implements RequestHandlerInterface
|
||||||
*
|
*
|
||||||
* @throws ResourceNotFoundException if the resource type has not been defined.
|
* @throws ResourceNotFoundException if the resource type has not been defined.
|
||||||
*/
|
*/
|
||||||
public function getResource(string $type): ResourceType
|
public function getResourceType(string $type): ResourceType
|
||||||
{
|
{
|
||||||
if (! isset($this->resources[$type])) {
|
if (! isset($this->resourceTypes[$type])) {
|
||||||
throw new ResourceNotFoundException($type);
|
throw new ResourceNotFoundException($type);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->resources[$type];
|
return $this->resourceTypes[$type];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -86,129 +114,184 @@ final class JsonApi implements RequestHandlerInterface
|
||||||
*/
|
*/
|
||||||
public function handle(Request $request): Response
|
public function handle(Request $request): Response
|
||||||
{
|
{
|
||||||
$this->validateRequest($request);
|
$this->validateQueryParameters($request);
|
||||||
|
|
||||||
$path = $this->stripBasePath(
|
$context = new Context($this, $request);
|
||||||
$request->getUri()->getPath()
|
|
||||||
|
$response = $this->runExtensions($context);
|
||||||
|
|
||||||
|
if (! $response) {
|
||||||
|
$response = $this->route($context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->withAddedHeader('Vary', 'Accept');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runExtensions(Context $context): ?Response
|
||||||
|
{
|
||||||
|
$request = $context->getRequest();
|
||||||
|
|
||||||
|
$contentTypeExtensionUris = $this->getContentTypeExtensionUris($request);
|
||||||
|
$acceptableExtensionUris = $this->getAcceptableExtensionUris($request);
|
||||||
|
|
||||||
|
$activeExtensions = array_intersect_key(
|
||||||
|
$this->extensions,
|
||||||
|
array_flip($contentTypeExtensionUris),
|
||||||
|
array_flip($acceptableExtensionUris)
|
||||||
);
|
);
|
||||||
|
|
||||||
$segments = explode('/', trim($path, '/'));
|
foreach ($activeExtensions as $extension) {
|
||||||
$resource = $this->getResource($segments[0]);
|
if ($response = $extension->handle($context)) {
|
||||||
$context = new Context($request);
|
return $response->withHeader('Content-Type', self::MEDIA_TYPE.'; ext='.$extension->uri());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function route(Context $context): Response
|
||||||
|
{
|
||||||
|
$segments = explode('/', trim($context->getPath(), '/'));
|
||||||
|
$resourceType = $this->getResourceType($segments[0]);
|
||||||
|
|
||||||
switch (count($segments)) {
|
switch (count($segments)) {
|
||||||
case 1:
|
case 1:
|
||||||
return $this->handleCollection($context, $resource);
|
return $this->routeCollection($context, $resourceType);
|
||||||
|
|
||||||
case 2:
|
case 2:
|
||||||
return $this->handleResource($context, $resource, $segments[1]);
|
return $this->routeResource($context, $resourceType, $segments[1]);
|
||||||
|
|
||||||
case 3:
|
case 3:
|
||||||
throw new NotImplementedException;
|
throw new NotImplementedException();
|
||||||
|
|
||||||
case 4:
|
case 4:
|
||||||
if ($segments[2] === 'relationships') {
|
if ($segments[2] === 'relationships') {
|
||||||
throw new NotImplementedException;
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new BadRequestException;
|
throw new BadRequestException();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validateRequest(Request $request): void
|
private function validateQueryParameters(Request $request): void
|
||||||
{
|
{
|
||||||
$this->validateRequestContentType($request);
|
foreach ($request->getQueryParams() as $key => $value) {
|
||||||
$this->validateRequestAccepts($request);
|
if (
|
||||||
|
! preg_match('/[^a-z]/', $key)
|
||||||
|
&& ! in_array($key, ['include', 'fields', 'filter', 'page', 'sort'])
|
||||||
|
) {
|
||||||
|
throw (new BadRequestException('Invalid query parameter: '.$key))->setSourceParameter($key);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validateRequestContentType(Request $request): void
|
private function routeCollection(Context $context, ResourceType $resourceType): Response
|
||||||
{
|
|
||||||
$header = $request->getHeaderLine('Content-Type');
|
|
||||||
|
|
||||||
if (empty($header)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((new MediaTypes($header))->containsExactly(self::MEDIA_TYPE)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new UnsupportedMediaTypeException;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function validateRequestAccepts(Request $request): void
|
|
||||||
{
|
|
||||||
$header = $request->getHeaderLine('Accept');
|
|
||||||
|
|
||||||
if (empty($header)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$mediaTypes = new MediaTypes($header);
|
|
||||||
|
|
||||||
if ($mediaTypes->containsExactly('*/*') || $mediaTypes->containsExactly(self::MEDIA_TYPE)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new NotAcceptableException;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function stripBasePath(string $path): string
|
|
||||||
{
|
|
||||||
$basePath = parse_url($this->basePath, PHP_URL_PATH);
|
|
||||||
|
|
||||||
$len = strlen($basePath);
|
|
||||||
|
|
||||||
if (substr($path, 0, $len) === $basePath) {
|
|
||||||
$path = substr($path, $len);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $path;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function handleCollection(Context $context, ResourceType $resource): Response
|
|
||||||
{
|
{
|
||||||
switch ($context->getRequest()->getMethod()) {
|
switch ($context->getRequest()->getMethod()) {
|
||||||
case 'GET':
|
case 'GET':
|
||||||
return (new Endpoint\Index($this, $resource))->handle($context);
|
return (new Endpoint\Index())->handle($context, $resourceType);
|
||||||
|
|
||||||
case 'POST':
|
case 'POST':
|
||||||
return (new Endpoint\Create($this, $resource))->handle($context);
|
return (new Endpoint\Create())->handle($context, $resourceType);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new MethodNotAllowedException;
|
throw new MethodNotAllowedException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleResource(Context $context, ResourceType $resource, string $id): Response
|
private function routeResource(Context $context, ResourceType $resourceType, string $resourceId): Response
|
||||||
{
|
{
|
||||||
$model = $this->findResource($resource, $id, $context);
|
$model = $this->findResource($resourceType, $resourceId, $context);
|
||||||
|
|
||||||
switch ($context->getRequest()->getMethod()) {
|
switch ($context->getRequest()->getMethod()) {
|
||||||
case 'PATCH':
|
case 'PATCH':
|
||||||
return (new Endpoint\Update($this, $resource, $model))->handle($context);
|
return (new Endpoint\Update())->handle($context, $resourceType, $model);
|
||||||
|
|
||||||
case 'GET':
|
case 'GET':
|
||||||
return (new Endpoint\Show($this, $resource, $model))->handle($context);
|
return (new Endpoint\Show())->handle($context, $resourceType, $model);
|
||||||
|
|
||||||
case 'DELETE':
|
case 'DELETE':
|
||||||
return (new Endpoint\Delete($this, $resource, $model))->handle($context);
|
return (new Endpoint\Delete())->handle($context, $resourceType, $model);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new MethodNotAllowedException;
|
throw new MethodNotAllowedException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getContentTypeExtensionUris(Request $request): array
|
||||||
|
{
|
||||||
|
if (! $contentType = $request->getHeaderLine('Content-Type')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$mediaList = (new AcceptParser())->parse($contentType);
|
||||||
|
|
||||||
|
if ($mediaList->count() > 1) {
|
||||||
|
throw new UnsupportedMediaTypeException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$mediaType = $mediaList->preferredMedia(0);
|
||||||
|
|
||||||
|
if ($mediaType->mimetype() !== JsonApi::MEDIA_TYPE) {
|
||||||
|
throw new UnsupportedMediaTypeException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$parameters = $mediaType->parameter();
|
||||||
|
|
||||||
|
if (! empty(array_diff(array_keys($parameters->all()), ['ext', 'profile']))) {
|
||||||
|
throw new UnsupportedMediaTypeException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$extensionUris = $parameters->has('ext') ? explode(' ', $parameters->get('ext')) : [];
|
||||||
|
|
||||||
|
if (! empty(array_diff($extensionUris, array_keys($this->extensions)))) {
|
||||||
|
throw new UnsupportedMediaTypeException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $extensionUris;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getAcceptableExtensionUris(Request $request): array
|
||||||
|
{
|
||||||
|
if (! $accept = $request->getHeaderLine('Accept')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$mediaList = (new AcceptParser())->parse($accept);
|
||||||
|
|
||||||
|
foreach ($mediaList->all() as $mediaType) {
|
||||||
|
if (! in_array($mediaType->mimetype(), [JsonApi::MEDIA_TYPE, '*/*'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parameters = $mediaType->parameter();
|
||||||
|
|
||||||
|
if (! empty(array_diff(array_keys($parameters->all()), ['ext', 'profile']))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$extensionUris = $parameters->has('ext') ? explode(' ', $parameters->get('ext')) : [];
|
||||||
|
|
||||||
|
if (! empty(array_diff($extensionUris, array_keys($this->extensions)))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $extensionUris;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotAcceptableException();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert an exception into a JSON:API error document response.
|
* Convert an exception into a JSON:API error document response.
|
||||||
*
|
*
|
||||||
* If the exception is not an instance of ErrorProviderInterface, an
|
* If the exception is not an instance of ErrorProviderInterface, an
|
||||||
* Internal Server Error response will be produced.
|
* Internal Server Error response will be produced.
|
||||||
*/
|
*/
|
||||||
public function error($e)
|
public function error($e): Response
|
||||||
{
|
{
|
||||||
if (! $e instanceof ErrorProviderInterface) {
|
if (! $e instanceof ErrorProviderInterface) {
|
||||||
$e = new InternalServerErrorException;
|
$e = new InternalServerErrorException();
|
||||||
}
|
}
|
||||||
|
|
||||||
$errors = $e->getJsonApiErrors();
|
$errors = $e->getJsonApiErrors();
|
||||||
|
|
@ -226,4 +309,20 @@ final class JsonApi implements RequestHandlerInterface
|
||||||
{
|
{
|
||||||
return $this->basePath;
|
return $this->basePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip the API's base path from the start of the given path.
|
||||||
|
*/
|
||||||
|
public function stripBasePath(string $path): string
|
||||||
|
{
|
||||||
|
$basePath = parse_url($this->basePath, PHP_URL_PATH) ?: '';
|
||||||
|
|
||||||
|
$len = strlen($basePath);
|
||||||
|
|
||||||
|
if (substr($path, 0, $len) === $basePath) {
|
||||||
|
$path = substr($path, $len);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@
|
||||||
namespace Tobyz\JsonApiServer;
|
namespace Tobyz\JsonApiServer;
|
||||||
|
|
||||||
use Tobyz\JsonApiServer\Adapter\AdapterInterface;
|
use Tobyz\JsonApiServer\Adapter\AdapterInterface;
|
||||||
|
use Tobyz\JsonApiServer\Exception\BadRequestException;
|
||||||
|
use Tobyz\JsonApiServer\Schema\Attribute;
|
||||||
|
use Tobyz\JsonApiServer\Schema\Relationship;
|
||||||
use Tobyz\JsonApiServer\Schema\Type;
|
use Tobyz\JsonApiServer\Schema\Type;
|
||||||
|
|
||||||
final class ResourceType
|
final class ResourceType
|
||||||
|
|
@ -41,7 +44,7 @@ final class ResourceType
|
||||||
public function getSchema(): Type
|
public function getSchema(): Type
|
||||||
{
|
{
|
||||||
if (! $this->schema) {
|
if (! $this->schema) {
|
||||||
$this->schema = new Type;
|
$this->schema = new Type();
|
||||||
|
|
||||||
if ($this->buildSchema) {
|
if ($this->buildSchema) {
|
||||||
($this->buildSchema)($this->schema);
|
($this->buildSchema)($this->schema);
|
||||||
|
|
@ -50,4 +53,151 @@ final class ResourceType
|
||||||
|
|
||||||
return $this->schema;
|
return $this->schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the URL for a model.
|
||||||
|
*/
|
||||||
|
public function url($model, Context $context): string
|
||||||
|
{
|
||||||
|
$id = $this->adapter->getId($model);
|
||||||
|
|
||||||
|
return $context->getApi()->getBasePath()."/$this->type/$id";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the resource type's scopes to a query.
|
||||||
|
*/
|
||||||
|
public function applyScopes($query, Context $context): void
|
||||||
|
{
|
||||||
|
run_callbacks(
|
||||||
|
$this->getSchema()->getListeners('scope'),
|
||||||
|
[$query, $context]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the resource type's filters to a query.
|
||||||
|
*/
|
||||||
|
public function applySort($query, string $sortString, Context $context): void
|
||||||
|
{
|
||||||
|
$schema = $this->getSchema();
|
||||||
|
$customSorts = $schema->getSorts();
|
||||||
|
$fields = $schema->getFields();
|
||||||
|
|
||||||
|
foreach (parse_sort_string($sortString) as [$name, $direction]) {
|
||||||
|
if (
|
||||||
|
isset($customSorts[$name])
|
||||||
|
&& evaluate($customSorts[$name]->getVisible(), [$context])
|
||||||
|
) {
|
||||||
|
$customSorts[$name]->getCallback()($query, $direction, $context);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$field = $fields[$name] ?? null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
$field instanceof Attribute
|
||||||
|
&& evaluate($field->getSortable(), [$context])
|
||||||
|
) {
|
||||||
|
$this->adapter->sortByAttribute($query, $field, $direction);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw (new BadRequestException("Invalid sort field: $name"))->setSourceParameter('sort');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the resource type's filters to a query.
|
||||||
|
*/
|
||||||
|
public function applyFilters($query, $filters, Context $context): void
|
||||||
|
{
|
||||||
|
$schema = $this->getSchema();
|
||||||
|
$customFilters = $schema->getFilters();
|
||||||
|
$fields = $schema->getFields();
|
||||||
|
|
||||||
|
if (is_string($filters)) {
|
||||||
|
$this->adapter->filterByExpression($query, $filters);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($filters as $name => $value) {
|
||||||
|
if ($name === 'id') {
|
||||||
|
$this->adapter->filterByIds($query, explode(',', $value));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_int($name)) {
|
||||||
|
$this->adapter->filterByExpression($query, $value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isset($customFilters[$name])
|
||||||
|
&& evaluate($customFilters[$name]->getVisible(), [$context])
|
||||||
|
) {
|
||||||
|
$customFilters[$name]->getCallback()($query, $value, $context);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$name, $sub] = explode('.', $name, 2) + [null, null];
|
||||||
|
$field = $fields[$name] ?? null;
|
||||||
|
|
||||||
|
if ($field && evaluate($field->getFilterable(), [$context])) {
|
||||||
|
if ($field instanceof Attribute && $sub === null) {
|
||||||
|
$this->filterByAttribute($query, $field, $value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($field instanceof Relationship) {
|
||||||
|
if (is_string($relatedType = $field->getType())) {
|
||||||
|
$relatedResource = $context->getApi()->getResourceType($relatedType);
|
||||||
|
|
||||||
|
$this->adapter->filterByRelationship($query, $field, function ($query) use ($relatedResource, $sub, $value, $context) {
|
||||||
|
$relatedResource->applyFilters($query, [($sub ?? 'id') => $value], $context);
|
||||||
|
});
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw (new BadRequestException('Cannot filter on attribute of polymorphic relationship: '.$name))
|
||||||
|
->setSourceParameter("filter[$name]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw (new BadRequestException("Invalid filter: $name"))->setSourceParameter("filter[$name]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the resource type's sparse fieldsets to a query.
|
||||||
|
*/
|
||||||
|
public function applySparseFieldset($query, $fields, Context $context): void
|
||||||
|
{
|
||||||
|
$this->adapter->sparseFieldset($query, $fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function filterByAttribute($query, Attribute $attribute, $value): void
|
||||||
|
{
|
||||||
|
if (preg_match('/(.+)\.\.(.+)/', $value, $matches)) {
|
||||||
|
if ($matches[1] !== '*') {
|
||||||
|
$this->adapter->filterByAttribute($query, $attribute, $value, '>=');
|
||||||
|
}
|
||||||
|
if ($matches[2] !== '*') {
|
||||||
|
$this->adapter->filterByAttribute($query, $attribute, $value, '<=');
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['>=', '>', '<=', '<'] as $operator) {
|
||||||
|
if (strpos($value, $operator) === 0) {
|
||||||
|
$this->adapter->filterByAttribute($query, $attribute, substr($value, strlen($operator)), $operator);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->adapter->filterByAttribute($query, $attribute, $value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* This file is part of Forust.
|
* This file is part of tobyz/json-api-server.
|
||||||
*
|
*
|
||||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
* (c) Toby Zerner <toby.zerner@gmail.com>
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ abstract class Field
|
||||||
/**
|
/**
|
||||||
* Run a callback after this field has been saved.
|
* Run a callback after this field has been saved.
|
||||||
*/
|
*/
|
||||||
public function onSaved(callable $callback)
|
public function saved(callable $callback)
|
||||||
{
|
{
|
||||||
$this->listeners['saved'][] = $callback;
|
$this->listeners['saved'][] = $callback;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,26 +68,6 @@ abstract class Relationship extends Field
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Allow the relationship data to be eager-loaded into the model collection.
|
|
||||||
*/
|
|
||||||
public function load()
|
|
||||||
{
|
|
||||||
$this->load = true;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do not eager-load relationship data into the model collection.
|
|
||||||
*/
|
|
||||||
public function dontLoad()
|
|
||||||
{
|
|
||||||
$this->load = false;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allow the relationship data to be included in a compound document.
|
* Allow the relationship data to be included in a compound document.
|
||||||
*/
|
*/
|
||||||
|
|
@ -153,14 +133,6 @@ abstract class Relationship extends Field
|
||||||
// return $this->urls;
|
// return $this->urls;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
/**
|
|
||||||
* @return bool|callable
|
|
||||||
*/
|
|
||||||
public function shouldLoad()
|
|
||||||
{
|
|
||||||
return $this->load;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isIncludable(): bool
|
public function isIncludable(): bool
|
||||||
{
|
{
|
||||||
return $this->includable;
|
return $this->includable;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of tobyz/json-api-server.
|
||||||
|
*
|
||||||
|
* (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 Tobyz\JsonApiServer\Schema;
|
||||||
|
|
||||||
|
use Tobyz\JsonApiServer\Schema\Concerns\HasDescription;
|
||||||
|
use Tobyz\JsonApiServer\Schema\Concerns\HasVisibility;
|
||||||
|
|
||||||
|
final class Sort
|
||||||
|
{
|
||||||
|
use HasDescription;
|
||||||
|
use HasVisibility;
|
||||||
|
|
||||||
|
private $name;
|
||||||
|
private $callback;
|
||||||
|
|
||||||
|
public function __construct(string $name, callable $callback)
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
$this->callback = $callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCallback(): callable
|
||||||
|
{
|
||||||
|
return $this->callback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,14 +24,14 @@ final class Type
|
||||||
|
|
||||||
private $fields = [];
|
private $fields = [];
|
||||||
private $filters = [];
|
private $filters = [];
|
||||||
private $sortFields = [];
|
private $sorts = [];
|
||||||
private $perPage = 20;
|
private $perPage = 20;
|
||||||
private $limit = 50;
|
private $limit = 50;
|
||||||
private $countable = true;
|
private $countable = true;
|
||||||
private $listable = true;
|
private $listable = true;
|
||||||
private $defaultSort;
|
private $defaultSort;
|
||||||
private $saveCallback;
|
private $saveCallback;
|
||||||
private $newModelCallback;
|
private $modelCallback;
|
||||||
private $creatable = false;
|
private $creatable = false;
|
||||||
private $updatable = false;
|
private $updatable = false;
|
||||||
private $deletable = false;
|
private $deletable = false;
|
||||||
|
|
@ -118,15 +118,15 @@ final class Type
|
||||||
*/
|
*/
|
||||||
public function sort(string $name, callable $callback): void
|
public function sort(string $name, callable $callback): void
|
||||||
{
|
{
|
||||||
$this->sortFields[$name] = $callback;
|
$this->sorts[$name] = new Sort($name, $callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the resource type's sort fields.
|
* Get the resource type's sort fields.
|
||||||
*/
|
*/
|
||||||
public function getSortFields(): array
|
public function getSorts(): array
|
||||||
{
|
{
|
||||||
return $this->sortFields;
|
return $this->sorts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -174,7 +174,7 @@ final class Type
|
||||||
* Get the maximum number of records that can be listed, or null if there
|
* Get the maximum number of records that can be listed, or null if there
|
||||||
* is no limit.
|
* is no limit.
|
||||||
*/
|
*/
|
||||||
public function getLimit(): int
|
public function getLimit(): ?int
|
||||||
{
|
{
|
||||||
return $this->limit;
|
return $this->limit;
|
||||||
}
|
}
|
||||||
|
|
@ -214,17 +214,9 @@ final class Type
|
||||||
/**
|
/**
|
||||||
* Run a callback before a resource is shown.
|
* Run a callback before a resource is shown.
|
||||||
*/
|
*/
|
||||||
public function onShowing(callable $callback): void
|
public function show(callable $callback): void
|
||||||
{
|
{
|
||||||
$this->listeners['showing'][] = $callback;
|
$this->listeners['show'][] = $callback;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run a callback when a resource is shown.
|
|
||||||
*/
|
|
||||||
public function onShown(callable $callback): void
|
|
||||||
{
|
|
||||||
$this->listeners['shown'][] = $callback;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -254,7 +246,7 @@ final class Type
|
||||||
/**
|
/**
|
||||||
* Run a callback before the resource type is listed.
|
* Run a callback before the resource type is listed.
|
||||||
*/
|
*/
|
||||||
public function onListing(callable $callback): void
|
public function listing(callable $callback): void
|
||||||
{
|
{
|
||||||
$this->listeners['listing'][] = $callback;
|
$this->listeners['listing'][] = $callback;
|
||||||
}
|
}
|
||||||
|
|
@ -262,7 +254,7 @@ final class Type
|
||||||
/**
|
/**
|
||||||
* Run a callback when the resource type is listed.
|
* Run a callback when the resource type is listed.
|
||||||
*/
|
*/
|
||||||
public function onListed(callable $callback): void
|
public function listed(callable $callback): void
|
||||||
{
|
{
|
||||||
$this->listeners['listed'][] = $callback;
|
$this->listeners['listed'][] = $callback;
|
||||||
}
|
}
|
||||||
|
|
@ -272,17 +264,17 @@ final class Type
|
||||||
*
|
*
|
||||||
* If null, the adapter will be used to create new model instances.
|
* If null, the adapter will be used to create new model instances.
|
||||||
*/
|
*/
|
||||||
public function newModel(?callable $callback): void
|
public function model(?callable $callback): void
|
||||||
{
|
{
|
||||||
$this->newModelCallback = $callback;
|
$this->modelCallback = $callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the callback to create a new model instance.
|
* Get the callback to create a new model instance.
|
||||||
*/
|
*/
|
||||||
public function getNewModelCallback(): ?callable
|
public function getModelCallback(): ?callable
|
||||||
{
|
{
|
||||||
return $this->newModelCallback;
|
return $this->modelCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -312,7 +304,7 @@ final class Type
|
||||||
/**
|
/**
|
||||||
* Run a callback before a resource is created.
|
* Run a callback before a resource is created.
|
||||||
*/
|
*/
|
||||||
public function onCreating(callable $callback): void
|
public function creating(callable $callback): void
|
||||||
{
|
{
|
||||||
$this->listeners['creating'][] = $callback;
|
$this->listeners['creating'][] = $callback;
|
||||||
}
|
}
|
||||||
|
|
@ -320,7 +312,7 @@ final class Type
|
||||||
/**
|
/**
|
||||||
* Run a callback after a resource has been created.
|
* Run a callback after a resource has been created.
|
||||||
*/
|
*/
|
||||||
public function onCreated(callable $callback): void
|
public function created(callable $callback): void
|
||||||
{
|
{
|
||||||
$this->listeners['created'][] = $callback;
|
$this->listeners['created'][] = $callback;
|
||||||
}
|
}
|
||||||
|
|
@ -352,7 +344,7 @@ final class Type
|
||||||
/**
|
/**
|
||||||
* Run a callback before a resource has been updated.
|
* Run a callback before a resource has been updated.
|
||||||
*/
|
*/
|
||||||
public function onUpdating(callable $callback): void
|
public function updating(callable $callback): void
|
||||||
{
|
{
|
||||||
$this->listeners['updating'][] = $callback;
|
$this->listeners['updating'][] = $callback;
|
||||||
}
|
}
|
||||||
|
|
@ -360,7 +352,7 @@ final class Type
|
||||||
/**
|
/**
|
||||||
* Run a callback after a resource has been updated.
|
* Run a callback after a resource has been updated.
|
||||||
*/
|
*/
|
||||||
public function onUpdated(callable $callback): void
|
public function updated(callable $callback): void
|
||||||
{
|
{
|
||||||
$this->listeners['updated'][] = $callback;
|
$this->listeners['updated'][] = $callback;
|
||||||
}
|
}
|
||||||
|
|
@ -428,7 +420,7 @@ final class Type
|
||||||
/**
|
/**
|
||||||
* Run a callback before a resource has been deleted.
|
* Run a callback before a resource has been deleted.
|
||||||
*/
|
*/
|
||||||
public function onDeleting(callable $callback): void
|
public function deleting(callable $callback): void
|
||||||
{
|
{
|
||||||
$this->listeners['deleting'][] = $callback;
|
$this->listeners['deleting'][] = $callback;
|
||||||
}
|
}
|
||||||
|
|
@ -436,7 +428,7 @@ final class Type
|
||||||
/**
|
/**
|
||||||
* Run a callback after a resource has been deleted.
|
* Run a callback after a resource has been deleted.
|
||||||
*/
|
*/
|
||||||
public function onDeleted(callable $callback): void
|
public function deleted(callable $callback): void
|
||||||
{
|
{
|
||||||
$this->listeners['deleted'][] = $callback;
|
$this->listeners['deleted'][] = $callback;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,200 +15,198 @@ use DateTime;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
use JsonApiPhp\JsonApi as Structure;
|
use JsonApiPhp\JsonApi as Structure;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
use Tobyz\JsonApiServer\Schema\Attribute;
|
||||||
|
use Tobyz\JsonApiServer\Schema\Field;
|
||||||
|
use Tobyz\JsonApiServer\Schema\HasMany;
|
||||||
|
use Tobyz\JsonApiServer\Schema\HasOne;
|
||||||
|
use Tobyz\JsonApiServer\Schema\Meta;
|
||||||
|
use Tobyz\JsonApiServer\Schema\Relationship;
|
||||||
|
|
||||||
final class Serializer
|
final class Serializer
|
||||||
{
|
{
|
||||||
private $api;
|
|
||||||
private $context;
|
private $context;
|
||||||
private $map = [];
|
private $map = [];
|
||||||
private $primary = [];
|
private $primary = [];
|
||||||
|
private $deferred = [];
|
||||||
|
|
||||||
public function __construct(JsonApi $api, Context $context)
|
public function __construct(Context $context)
|
||||||
{
|
{
|
||||||
$this->api = $api;
|
|
||||||
$this->context = $context;
|
$this->context = $context;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a primary resource to the document.
|
* Add a primary resource to the document.
|
||||||
*/
|
*/
|
||||||
public function add(ResourceType $resource, $model, array $include): void
|
public function add(ResourceType $resourceType, $model, array $include): void
|
||||||
{
|
{
|
||||||
$data = $this->addToMap($resource, $model, $include);
|
$data = $this->addToMap($resourceType, $model, $include);
|
||||||
|
|
||||||
$this->primary[] = $this->key($data);
|
$this->primary[] = $this->key($data['type'], $data['id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the serialized primary resources.
|
* Serialize the primary and included resources into a JSON:API resource objects.
|
||||||
*/
|
*/
|
||||||
public function primary(): array
|
public function serialize(): array
|
||||||
{
|
{
|
||||||
$primary = array_map(function ($key) {
|
$this->resolveDeferred();
|
||||||
return $this->map[$key];
|
|
||||||
}, $this->primary);
|
|
||||||
|
|
||||||
return $this->resourceObjects($primary);
|
$keys = array_flip($this->primary);
|
||||||
}
|
$primary = array_values(array_intersect_key($this->map, $keys));
|
||||||
|
$included = array_values(array_diff_key($this->map, $keys));
|
||||||
|
|
||||||
/**
|
return [
|
||||||
* Get the serialized included resources.
|
$this->resourceObjects($primary),
|
||||||
*/
|
$this->resourceObjects($included),
|
||||||
public function included(): array
|
|
||||||
{
|
|
||||||
$included = array_values(array_diff_key($this->map, array_flip($this->primary)));
|
|
||||||
|
|
||||||
return $this->resourceObjects($included);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function addToMap(ResourceType $resource, $model, array $include): array
|
|
||||||
{
|
|
||||||
$adapter = $resource->getAdapter();
|
|
||||||
$schema = $resource->getSchema();
|
|
||||||
|
|
||||||
$data = [
|
|
||||||
'type' => $type = $resource->getType(),
|
|
||||||
'id' => $id = $adapter->getId($model),
|
|
||||||
'fields' => [],
|
|
||||||
'links' => [],
|
|
||||||
'meta' => []
|
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$key = $this->key($data);
|
private function addToMap(ResourceType $resourceType, $model, array $include): array
|
||||||
$url = $this->api->getBasePath()."/$type/$id";
|
{
|
||||||
$fields = $schema->getFields();
|
$adapter = $resourceType->getAdapter();
|
||||||
$queryParams = $this->context->getRequest()->getQueryParams();
|
$schema = $resourceType->getSchema();
|
||||||
|
|
||||||
if (isset($queryParams['fields'][$type])) {
|
$key = $this->key(
|
||||||
$fields = array_intersect_key($fields, array_flip(explode(',', $queryParams['fields'][$type])));
|
$type = $resourceType->getType(),
|
||||||
|
$id = $adapter->getId($model)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isset($this->map[$key])) {
|
||||||
|
return $this->map[$key];
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($fields as $name => $field) {
|
$this->map[$key] = [
|
||||||
if (isset($this->map[$key]['fields'][$name])) {
|
'type' => $type,
|
||||||
continue;
|
'id' => $id,
|
||||||
}
|
'fields' => [],
|
||||||
|
'links' => [
|
||||||
|
'self' => new Structure\Link\SelfLink($url = $resourceType->url($model, $this->context)),
|
||||||
|
],
|
||||||
|
'meta' => $this->meta($schema->getMeta(), $model)
|
||||||
|
];
|
||||||
|
|
||||||
|
$fields = $this->sparseFields($type, $schema->getFields());
|
||||||
|
|
||||||
|
foreach ($fields as $field) {
|
||||||
if (! evaluate($field->getVisible(), [$model, $this->context])) {
|
if (! evaluate($field->getVisible(), [$model, $this->context])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($field instanceof Schema\Attribute) {
|
if ($field instanceof Attribute) {
|
||||||
$value = $this->attribute($field, $resource, $model);
|
$this->resolveAttribute($key, $field, $resourceType, $model);
|
||||||
} elseif ($field instanceof Schema\Relationship) {
|
} elseif ($field instanceof Relationship) {
|
||||||
$isIncluded = isset($include[$name]);
|
$this->resolveRelationship($key, $field, $resourceType, $model, $include, $url);
|
||||||
$relationshipInclude = $isIncluded ? ($include[$name] ?? []) : null;
|
|
||||||
$links = $this->relationshipLinks($field, $url);
|
|
||||||
$meta = $this->meta($field->getMeta(), $model);
|
|
||||||
$members = array_merge($links, $meta);
|
|
||||||
|
|
||||||
if (! $isIncluded && ! $field->hasLinkage()) {
|
|
||||||
$value = $this->emptyRelationship($field, $members);
|
|
||||||
} elseif ($field instanceof Schema\HasOne) {
|
|
||||||
$value = $this->toOne($field, $members, $resource, $model, $relationshipInclude);
|
|
||||||
} elseif ($field instanceof Schema\HasMany) {
|
|
||||||
$value = $this->toMany($field, $members, $resource, $model, $relationshipInclude);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! empty($value)) {
|
|
||||||
$data['fields'][$name] = $value;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$data['links']['self'] = new Structure\Link\SelfLink($url);
|
return $this->map[$key];
|
||||||
$data['meta'] = $this->meta($schema->getMeta(), $model);
|
|
||||||
|
|
||||||
$this->merge($data);
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function merge($data): void
|
private function key(string $type, string $id): string
|
||||||
{
|
{
|
||||||
$key = $this->key($data);
|
return $type.':'.$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']);
|
|
||||||
$this->map[$key]['meta'] = array_merge($this->map[$key]['meta'], $data['meta']);
|
|
||||||
} else {
|
|
||||||
$this->map[$key] = $data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function attribute(Schema\Attribute $field, ResourceType $resource, $model): Structure\Attribute
|
|
||||||
{
|
|
||||||
if ($getCallback = $field->getGetCallback()) {
|
|
||||||
$value = $getCallback($model, $this->context);
|
|
||||||
} else {
|
|
||||||
$value = $resource->getAdapter()->getAttribute($model, $field);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($value instanceof DateTimeInterface) {
|
|
||||||
$value = $value->format(DateTime::RFC3339);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Structure\Attribute($field->getName(), $value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function toOne(Schema\HasOne $field, array $members, ResourceType $resource, $model, ?array $include)
|
|
||||||
{
|
|
||||||
$included = $include !== null;
|
|
||||||
|
|
||||||
$model = ($getCallback = $field->getGetCallback())
|
|
||||||
? $getCallback($model, $this->context)
|
|
||||||
: $resource->getAdapter()->getHasOne($model, $field, ! $included);
|
|
||||||
|
|
||||||
if (! $model) {
|
|
||||||
return new Structure\ToNull($field->getName(), ...$members);
|
|
||||||
}
|
|
||||||
|
|
||||||
$identifier = $include !== null
|
|
||||||
? $this->addRelated($field, $model, $include)
|
|
||||||
: $this->relatedResourceIdentifier($field, $model);
|
|
||||||
|
|
||||||
return new Structure\ToOne($field->getName(), $identifier, ...$members);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function toMany(Schema\HasMany $field, array $members, ResourceType $resource, $model, ?array $include)
|
|
||||||
{
|
|
||||||
$included = $include !== null;
|
|
||||||
|
|
||||||
$models = ($getCallback = $field->getGetCallback())
|
|
||||||
? $getCallback($model, $this->context)
|
|
||||||
: $resource->getAdapter()->getHasMany($model, $field, ! $included);
|
|
||||||
|
|
||||||
$identifiers = [];
|
|
||||||
|
|
||||||
foreach ($models as $relatedModel) {
|
|
||||||
$identifiers[] = $included
|
|
||||||
? $this->addRelated($field, $relatedModel, $include)
|
|
||||||
: $this->relatedResourceIdentifier($field, $relatedModel);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Structure\ToMany(
|
|
||||||
$field->getName(),
|
|
||||||
new Structure\ResourceIdentifierCollection(...$identifiers),
|
|
||||||
...$members
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function emptyRelationship(Schema\Relationship $field, array $members): ?Structure\EmptyRelationship
|
|
||||||
{
|
|
||||||
if (! $members) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Structure\EmptyRelationship($field->getName(), ...$members);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Structure\Internal\RelationshipMember
|
* @return Structure\Internal\RelationshipMember[]
|
||||||
*/
|
*/
|
||||||
private function relationshipLinks(Schema\Relationship $field, string $url): array
|
private function meta(array $items, $model): array
|
||||||
{
|
{
|
||||||
|
ksort($items);
|
||||||
|
|
||||||
|
return array_map(function (Meta $meta) use ($model) {
|
||||||
|
return new Structure\Meta($meta->getName(), ($meta->getValue())($model, $this->context));
|
||||||
|
}, $items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sparseFields(string $type, array $fields): array
|
||||||
|
{
|
||||||
|
$queryParams = $this->context->getRequest()->getQueryParams();
|
||||||
|
|
||||||
|
if (isset($queryParams['fields'][$type])) {
|
||||||
|
$requested = $queryParams['fields'][$type];
|
||||||
|
$requested = is_array($requested) ? $requested : explode(',', $requested);
|
||||||
|
$fields = array_intersect_key($fields, array_flip($requested));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveAttribute(string $key, Attribute $field, ResourceType $resourceType, $model): void
|
||||||
|
{
|
||||||
|
$value = $this->getAttributeValue($field, $resourceType, $model);
|
||||||
|
|
||||||
|
$this->whenResolved($value, function ($value) use ($key, $field) {
|
||||||
|
if ($value instanceof DateTimeInterface) {
|
||||||
|
$value = $value->format(DateTime::RFC3339);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->setField($key, $field, new Structure\Attribute($field->getName(), $value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveRelationship(string $key, Relationship $field, ResourceType $resourceType, $model, array $include, string $url): void
|
||||||
|
{
|
||||||
|
$name = $field->getName();
|
||||||
|
$linkageOnly = ! isset($include[$name]);
|
||||||
|
$nestedInclude = $include[$name] ?? null;
|
||||||
|
|
||||||
|
$members = array_merge(
|
||||||
|
$this->relationshipLinks($url, $field),
|
||||||
|
$this->meta($field->getMeta(), $model)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($linkageOnly && ! $field->hasLinkage()) {
|
||||||
|
if ($relationship = $this->emptyRelationship($field, $members)) {
|
||||||
|
$this->setField($key, $field, $relationship);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $this->getRelationshipValue($field, $resourceType, $model, $linkageOnly);
|
||||||
|
|
||||||
|
$this->whenResolved($value, function ($value) use ($key, $field, $nestedInclude, $members) {
|
||||||
|
if ($structure = $this->buildRelationship($field, $value, $nestedInclude, $members)) {
|
||||||
|
$this->setField($key, $field, $structure);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getAttributeValue(Attribute $field, ResourceType $resourceType, $model)
|
||||||
|
{
|
||||||
|
if ($getCallback = $field->getGetCallback()) {
|
||||||
|
return $getCallback($model, $this->context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resourceType->getAdapter()->getAttribute($model, $field);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function whenResolved($value, $callback): void
|
||||||
|
{
|
||||||
|
if ($value instanceof Deferred) {
|
||||||
|
$this->deferred[] = function () use (&$data, $value, $callback) {
|
||||||
|
$this->whenResolved($value->resolve(), $callback);
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$callback($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setField(string $key, Field $field, $value): void
|
||||||
|
{
|
||||||
|
$this->map[$key]['fields'][$field->getName()] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Structure\Internal\RelationshipMember[]
|
||||||
|
*/
|
||||||
|
private function relationshipLinks(string $url, Relationship $field): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
|
||||||
// if (! $field->hasUrls()) {
|
// if (! $field->hasUrls()) {
|
||||||
return [];
|
// return [];
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// return [
|
// return [
|
||||||
|
|
@ -217,33 +215,117 @@ final class Serializer
|
||||||
// ];
|
// ];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function addRelated(Schema\Relationship $field, $model, array $include): Structure\ResourceIdentifier
|
private function emptyRelationship(Relationship $field, array $members): ?Structure\EmptyRelationship
|
||||||
{
|
{
|
||||||
$relatedResource = is_string($field->getType())
|
if (! $members) {
|
||||||
? $this->api->getResource($field->getType())
|
return null;
|
||||||
: $this->resourceForModel($model);
|
}
|
||||||
|
|
||||||
|
return new Structure\EmptyRelationship($field->getName(), ...$members);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getRelationshipValue(Relationship $field, ResourceType $resourceType, $model, bool $linkageOnly)
|
||||||
|
{
|
||||||
|
if ($getCallback = $field->getGetCallback()) {
|
||||||
|
return $getCallback($model, $linkageOnly, $this->context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($field instanceof HasOne) {
|
||||||
|
return $resourceType->getAdapter()->getHasOne($model, $field, $linkageOnly, $this->context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($field instanceof HasMany) {
|
||||||
|
return $resourceType->getAdapter()->getHasMany($model, $field, $linkageOnly, $this->context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildRelationship(Relationship $field, $value, ?array $nestedInclude, array $members): ?Structure\Internal\ResourceField
|
||||||
|
{
|
||||||
|
$name = $field->getName();
|
||||||
|
|
||||||
|
if ($field instanceof HasOne) {
|
||||||
|
if (! $value) {
|
||||||
|
return new Structure\ToNull($name, ...$members);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Structure\ToOne(
|
||||||
|
$name,
|
||||||
|
$this->addRelatedResource($field, $value, $nestedInclude),
|
||||||
|
...$members
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($field instanceof HasMany) {
|
||||||
|
$identifiers = array_map(function ($relatedModel) use ($field, $nestedInclude) {
|
||||||
|
return $this->addRelatedResource($field, $relatedModel, $nestedInclude);
|
||||||
|
}, $value);
|
||||||
|
|
||||||
|
return new Structure\ToMany(
|
||||||
|
$name,
|
||||||
|
new Structure\ResourceIdentifierCollection(...$identifiers),
|
||||||
|
...$members
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addRelatedResource(Relationship $field, $model, ?array $include): Structure\ResourceIdentifier
|
||||||
|
{
|
||||||
|
$relatedResourceType = $this->resourceTypeForModel($field, $model);
|
||||||
|
|
||||||
|
if ($include === null) {
|
||||||
|
return $this->resourceIdentifier([
|
||||||
|
'type' => $relatedResourceType->getType(),
|
||||||
|
'id' => $relatedResourceType->getAdapter()->getId($model)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->resourceIdentifier(
|
return $this->resourceIdentifier(
|
||||||
$this->addToMap($relatedResource, $model, $include)
|
$this->addToMap($relatedResourceType, $model, $include)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resourceForModel($model): ResourceType
|
private function resourceTypeForModel(Relationship $field, $model): ResourceType
|
||||||
{
|
{
|
||||||
foreach ($this->api->getResources() as $resource) {
|
if (is_string($type = $field->getType())) {
|
||||||
if ($resource->getAdapter()->represents($model)) {
|
return $this->context->getApi()->getResourceType($type);
|
||||||
return $resource;
|
}
|
||||||
|
|
||||||
|
foreach ($this->context->getApi()->getResourceTypes() as $resourceType) {
|
||||||
|
if ($resourceType->getAdapter()->represents($model)) {
|
||||||
|
return $resourceType;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new RuntimeException('No resource defined to represent model of type '.get_class($model));
|
throw new RuntimeException('No resource type defined to represent model '.get_class($model));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resourceIdentifier(array $data): Structure\ResourceIdentifier
|
||||||
|
{
|
||||||
|
return new Structure\ResourceIdentifier($data['type'], $data['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveDeferred(): void
|
||||||
|
{
|
||||||
|
$i = 0;
|
||||||
|
while (count($this->deferred)) {
|
||||||
|
foreach ($this->deferred as $k => $resolve) {
|
||||||
|
$resolve();
|
||||||
|
unset($this->deferred[$k]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($i++ > 10) {
|
||||||
|
throw new RuntimeException('Too many levels of deferred values');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resourceObjects(array $items): array
|
private function resourceObjects(array $items): array
|
||||||
{
|
{
|
||||||
return array_map(function ($data) {
|
return array_map([$this, 'resourceObject'], $items);
|
||||||
return $this->resourceObject($data);
|
|
||||||
}, $items);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resourceObject(array $data): Structure\ResourceObject
|
private function resourceObject(array $data): Structure\ResourceObject
|
||||||
|
|
@ -256,39 +338,4 @@ final class Serializer
|
||||||
...array_values($data['meta'])
|
...array_values($data['meta'])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resourceIdentifier(array $data): Structure\ResourceIdentifier
|
|
||||||
{
|
|
||||||
return new Structure\ResourceIdentifier($data['type'], $data['id']);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function relatedResourceIdentifier(Schema\Relationship $field, $model)
|
|
||||||
{
|
|
||||||
$type = $field->getType();
|
|
||||||
$relatedResource = is_string($type)
|
|
||||||
? $this->api->getResource($type)
|
|
||||||
: $this->resourceForModel($model);
|
|
||||||
|
|
||||||
return $this->resourceIdentifier([
|
|
||||||
'type' => $relatedResource->getType(),
|
|
||||||
'id' => $relatedResource->getAdapter()->getId($model)
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Structure\Internal\RelationshipMember
|
|
||||||
*/
|
|
||||||
private function meta(array $items, $model): array
|
|
||||||
{
|
|
||||||
ksort($items);
|
|
||||||
|
|
||||||
return array_map(function (Schema\Meta $meta) use ($model) {
|
|
||||||
return new Structure\Meta($meta->getName(), ($meta->getValue())($model, $this->context));
|
|
||||||
}, $items);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function key(array $data)
|
|
||||||
{
|
|
||||||
return $data['type'].':'.$data['id'];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,26 +12,25 @@
|
||||||
namespace Tobyz\JsonApiServer;
|
namespace Tobyz\JsonApiServer;
|
||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use JsonSerializable;
|
|
||||||
use Nyholm\Psr7\Response;
|
use Nyholm\Psr7\Response;
|
||||||
use Nyholm\Psr7\Stream;
|
use Nyholm\Psr7\Stream;
|
||||||
use Tobyz\JsonApiServer\Schema\Field;
|
use Tobyz\JsonApiServer\Schema\Field;
|
||||||
|
|
||||||
function json_api_response(JsonSerializable $document, int $status = 200): Response
|
function json_api_response($document, int $status = 200): Response
|
||||||
{
|
{
|
||||||
return (new Response($status))
|
return (new Response($status))
|
||||||
->withHeader('content-type', JsonApi::MEDIA_TYPE)
|
->withHeader('Content-Type', JsonApi::MEDIA_TYPE)
|
||||||
->withBody(Stream::create(json_encode($document, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES)));
|
->withBody(Stream::create(json_encode($document, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function negate(Closure $condition)
|
function negate(Closure $condition): Closure
|
||||||
{
|
{
|
||||||
return function (...$args) use ($condition) {
|
return function (...$args) use ($condition) {
|
||||||
return ! $condition(...$args);
|
return ! $condition(...$args);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function wrap($value)
|
function wrap($value): Closure
|
||||||
{
|
{
|
||||||
if (! $value instanceof Closure) {
|
if (! $value instanceof Closure) {
|
||||||
$value = function () use ($value) {
|
$value = function () use ($value) {
|
||||||
|
|
@ -42,19 +41,19 @@ function wrap($value)
|
||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluate($condition, array $params)
|
function evaluate($condition, array $params): bool
|
||||||
{
|
{
|
||||||
return $condition === true || (is_callable($condition) && $condition(...$params));
|
return $condition === true || (is_callable($condition) && $condition(...$params));
|
||||||
}
|
}
|
||||||
|
|
||||||
function run_callbacks(array $callbacks, array $params)
|
function run_callbacks(array $callbacks, array $params): void
|
||||||
{
|
{
|
||||||
foreach ($callbacks as $callback) {
|
foreach ($callbacks as $callback) {
|
||||||
$callback(...$params);
|
$callback(...$params);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function has_value(array $data, Field $field)
|
function has_value(array $data, Field $field): bool
|
||||||
{
|
{
|
||||||
return array_key_exists($location = $field->getLocation(), $data)
|
return array_key_exists($location = $field->getLocation(), $data)
|
||||||
&& array_key_exists($field->getName(), $data[$location]);
|
&& array_key_exists($field->getName(), $data[$location]);
|
||||||
|
|
@ -65,7 +64,18 @@ function get_value(array $data, Field $field)
|
||||||
return $data[$field->getLocation()][$field->getName()] ?? null;
|
return $data[$field->getLocation()][$field->getName()] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function set_value(array &$data, Field $field, $value)
|
function set_value(array &$data, Field $field, $value): void
|
||||||
{
|
{
|
||||||
$data[$field->getLocation()][$field->getName()] = $value;
|
$data[$field->getLocation()][$field->getName()] = $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parse_sort_string(string $string): array
|
||||||
|
{
|
||||||
|
return array_map(function ($field) {
|
||||||
|
if ($field[0] === '-') {
|
||||||
|
return [substr($field, 1), 'desc'];
|
||||||
|
} else {
|
||||||
|
return [$field, 'asc'];
|
||||||
|
}
|
||||||
|
}, explode(',', $string));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
|
|
||||||
namespace Tobyz\JsonApiServer\Laravel;
|
namespace Tobyz\JsonApiServer\Laravel;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
@ -18,25 +19,39 @@ use Illuminate\Support\Facades\Validator;
|
||||||
use Tobyz\JsonApiServer\Context;
|
use Tobyz\JsonApiServer\Context;
|
||||||
use Tobyz\JsonApiServer\Schema\Field;
|
use Tobyz\JsonApiServer\Schema\Field;
|
||||||
|
|
||||||
function rules($rules, array $messages = [], array $customAttributes = [])
|
function rules($rules, array $messages = [], array $customAttributes = []): Closure
|
||||||
{
|
{
|
||||||
if (is_string($rules)) {
|
if (is_string($rules)) {
|
||||||
$rules = [$rules];
|
$rules = [$rules];
|
||||||
}
|
}
|
||||||
|
|
||||||
return function (callable $fail, $value, $model, Context $context, Field $field) use ($rules, $messages, $customAttributes) {
|
return function (callable $fail, $value, Model $model, Context $context, Field $field) use (
|
||||||
|
$rules,
|
||||||
|
$messages,
|
||||||
|
$customAttributes
|
||||||
|
) {
|
||||||
$key = $field->getName();
|
$key = $field->getName();
|
||||||
$validationRules = [$key => []];
|
|
||||||
|
|
||||||
foreach ($rules as $k => $v) {
|
$validatorRules = [$key => []];
|
||||||
|
|
||||||
|
foreach ($rules as $k => $rule) {
|
||||||
|
if (is_string($rule)) {
|
||||||
|
$rule = str_replace('{id}', $model->getKey(), $rule);
|
||||||
|
}
|
||||||
|
|
||||||
if (! is_numeric($k)) {
|
if (! is_numeric($k)) {
|
||||||
$validationRules[$key.'.'.$k] = $v;
|
$validatorRules[$key.'.'.$k] = $rule;
|
||||||
} else {
|
} else {
|
||||||
$validationRules[$key][] = $v;
|
$validatorRules[$key][] = $rule;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$validation = Validator::make($value !== null ? [$key => $value] : [], $validationRules, $messages, $customAttributes);
|
$validation = Validator::make(
|
||||||
|
$value !== null ? [$key => $value] : [],
|
||||||
|
$validatorRules,
|
||||||
|
$messages,
|
||||||
|
$customAttributes
|
||||||
|
);
|
||||||
|
|
||||||
if ($validation->fails()) {
|
if ($validation->fails()) {
|
||||||
foreach ($validation->errors()->all() as $message) {
|
foreach ($validation->errors()->all() as $message) {
|
||||||
|
|
@ -46,16 +61,20 @@ function rules($rules, array $messages = [], array $customAttributes = [])
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function authenticated()
|
function authenticated(): Closure
|
||||||
{
|
{
|
||||||
return function () {
|
return function () {
|
||||||
return Auth::check();
|
return Auth::check();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function can(string $ability)
|
function can(string $ability, ...$args): Closure
|
||||||
{
|
{
|
||||||
return function ($arg) use ($ability) {
|
return function ($arg) use ($ability, $args) {
|
||||||
return Gate::allows($ability, $arg instanceof Model ? $arg : null);
|
if ($arg instanceof Model) {
|
||||||
|
array_unshift($args, $arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::allows($ability, $args);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,14 @@
|
||||||
|
|
||||||
namespace Tobyz\Tests\JsonApiServer;
|
namespace Tobyz\Tests\JsonApiServer;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
use Tobyz\JsonApiServer\Adapter\AdapterInterface;
|
use Tobyz\JsonApiServer\Adapter\AdapterInterface;
|
||||||
|
use Tobyz\JsonApiServer\Context;
|
||||||
use Tobyz\JsonApiServer\Schema\Attribute;
|
use Tobyz\JsonApiServer\Schema\Attribute;
|
||||||
use Tobyz\JsonApiServer\Schema\Field;
|
use Tobyz\JsonApiServer\Schema\Field;
|
||||||
use Tobyz\JsonApiServer\Schema\HasMany;
|
use Tobyz\JsonApiServer\Schema\HasMany;
|
||||||
use Tobyz\JsonApiServer\Schema\HasOne;
|
use Tobyz\JsonApiServer\Schema\HasOne;
|
||||||
|
use Tobyz\JsonApiServer\Schema\Relationship;
|
||||||
|
|
||||||
class MockAdapter implements AdapterInterface
|
class MockAdapter implements AdapterInterface
|
||||||
{
|
{
|
||||||
|
|
@ -21,24 +24,34 @@ class MockAdapter implements AdapterInterface
|
||||||
$this->type = $type;
|
$this->type = $type;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function newModel()
|
public function model()
|
||||||
{
|
{
|
||||||
return $this->createdModel = (object) [];
|
return $this->createdModel = (object) [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function newQuery()
|
public function query()
|
||||||
{
|
{
|
||||||
return $this->query = (object) [];
|
return $this->query = (object) [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function find($query, string $id)
|
public function find($query, string $id)
|
||||||
{
|
{
|
||||||
|
if ($id === '404') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return $this->models[$id] ?? (object) ['id' => $id];
|
return $this->models[$id] ?? (object) ['id' => $id];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function get($query): array
|
public function get($query): array
|
||||||
{
|
{
|
||||||
return array_values($this->models);
|
$results = array_values($this->models);
|
||||||
|
|
||||||
|
if (isset($query->paginate)) {
|
||||||
|
$results = array_slice($results, $query->paginate['offset'], $query->paginate['limit']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId($model): string
|
public function getId($model): string
|
||||||
|
|
@ -51,16 +64,21 @@ class MockAdapter implements AdapterInterface
|
||||||
return $model->{$this->getProperty($attribute)} ?? 'default';
|
return $model->{$this->getProperty($attribute)} ?? 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getHasOne($model, HasOne $relationship, bool $linkage)
|
public function getHasOne($model, HasOne $relationship, bool $linkageOnly, Context $context)
|
||||||
{
|
{
|
||||||
return $model->{$this->getProperty($relationship)} ?? null;
|
return $model->{$this->getProperty($relationship)} ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getHasMany($model, HasMany $relationship, bool $linkage): array
|
public function getHasMany($model, HasMany $relationship, bool $linkageOnly, Context $context): array
|
||||||
{
|
{
|
||||||
return $model->{$this->getProperty($relationship)} ?? [];
|
return $model->{$this->getProperty($relationship)} ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setId($model, string $id): void
|
||||||
|
{
|
||||||
|
$model->id = $id;
|
||||||
|
}
|
||||||
|
|
||||||
public function setAttribute($model, Attribute $attribute, $value): void
|
public function setAttribute($model, Attribute $attribute, $value): void
|
||||||
{
|
{
|
||||||
$model->{$this->getProperty($attribute)} = $value;
|
$model->{$this->getProperty($attribute)} = $value;
|
||||||
|
|
@ -100,14 +118,9 @@ class MockAdapter implements AdapterInterface
|
||||||
$query->filter[] = [$attribute, $operator, $value];
|
$query->filter[] = [$attribute, $operator, $value];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function filterByHasOne($query, HasOne $relationship, array $ids): void
|
public function filterByRelationship($query, Relationship $relationship, Closure $scope): void
|
||||||
{
|
{
|
||||||
$query->filter[] = [$relationship, $ids];
|
$query->filter[] = [$relationship, $scope];
|
||||||
}
|
|
||||||
|
|
||||||
public function filterByHasMany($query, HasMany $relationship, array $ids): void
|
|
||||||
{
|
|
||||||
$query->filter[] = [$relationship, $ids];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sortByAttribute($query, Attribute $attribute, string $direction): void
|
public function sortByAttribute($query, Attribute $attribute, string $direction): void
|
||||||
|
|
@ -117,7 +130,7 @@ class MockAdapter implements AdapterInterface
|
||||||
|
|
||||||
public function paginate($query, int $limit, int $offset): void
|
public function paginate($query, int $limit, int $offset): void
|
||||||
{
|
{
|
||||||
$query->paginate[] = [$limit, $offset];
|
$query->paginate = compact('limit', 'offset');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function load(array $models, array $relationships, $scope, bool $linkage): void
|
public function load(array $models, array $relationships, $scope, bool $linkage): void
|
||||||
|
|
@ -135,7 +148,7 @@ class MockAdapter implements AdapterInterface
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getProperty(Field $field)
|
private function getProperty(Field $field): string
|
||||||
{
|
{
|
||||||
return $field->getProperty() ?: $field->getName();
|
return $field->getProperty() ?: $field->getName();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tobyz\Tests\JsonApiServer;
|
||||||
|
|
||||||
|
use JsonApiPhp\JsonApi\Error;
|
||||||
|
use Tobyz\JsonApiServer\ErrorProviderInterface;
|
||||||
|
|
||||||
|
class MockException implements ErrorProviderInterface
|
||||||
|
{
|
||||||
|
public function getJsonApiErrors(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new Error(
|
||||||
|
new Error\Title('Mock Error'),
|
||||||
|
new Error\Status($this->getJsonApiStatus())
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getJsonApiStatus(): string
|
||||||
|
{
|
||||||
|
return '400';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -42,7 +42,7 @@ class CountabilityTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_total_number_of_resources_and_last_pagination_link_is_included_by_default()
|
public function test_total_number_of_resources_and_last_pagination_link_is_included_by_default()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', $this->adapter);
|
$this->api->resourceType('users', $this->adapter);
|
||||||
|
|
||||||
$response = $this->api->handle(
|
$response = $this->api->handle(
|
||||||
$this->buildRequest('GET', '/users')
|
$this->buildRequest('GET', '/users')
|
||||||
|
|
@ -56,7 +56,7 @@ class CountabilityTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_types_can_be_made_uncountable()
|
public function test_types_can_be_made_uncountable()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) {
|
||||||
$type->uncountable();
|
$type->uncountable();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -72,7 +72,7 @@ class CountabilityTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_types_can_be_made_countable()
|
public function test_types_can_be_made_countable()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) {
|
||||||
$type->uncountable();
|
$type->uncountable();
|
||||||
$type->countable();
|
$type->countable();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
|
|
||||||
namespace Tobyz\Tests\JsonApiServer\feature;
|
namespace Tobyz\Tests\JsonApiServer\feature;
|
||||||
|
|
||||||
|
use Prophecy\Argument;
|
||||||
use Prophecy\PhpUnit\ProphecyTrait;
|
use Prophecy\PhpUnit\ProphecyTrait;
|
||||||
use Tobyz\JsonApiServer\Adapter\AdapterInterface;
|
use Tobyz\JsonApiServer\Adapter\AdapterInterface;
|
||||||
use Tobyz\JsonApiServer\Exception\ForbiddenException;
|
use Tobyz\JsonApiServer\Exception\ForbiddenException;
|
||||||
|
|
@ -41,7 +42,6 @@ class CreateTest extends AbstractTestCase
|
||||||
->withParsedBody([
|
->withParsedBody([
|
||||||
'data' => array_merge([
|
'data' => array_merge([
|
||||||
'type' => 'users',
|
'type' => 'users',
|
||||||
'id' => '1',
|
|
||||||
], $data)
|
], $data)
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
@ -49,7 +49,7 @@ class CreateTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_resources_are_not_creatable_by_default()
|
public function test_resources_are_not_creatable_by_default()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', new MockAdapter());
|
$this->api->resourceType('users', new MockAdapter());
|
||||||
|
|
||||||
$this->expectException(ForbiddenException::class);
|
$this->expectException(ForbiddenException::class);
|
||||||
|
|
||||||
|
|
@ -58,7 +58,7 @@ class CreateTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_resource_creation_can_be_explicitly_enabled()
|
public function test_resource_creation_can_be_explicitly_enabled()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', new MockAdapter(), function (Type $type) {
|
$this->api->resourceType('users', new MockAdapter(), function (Type $type) {
|
||||||
$type->creatable();
|
$type->creatable();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -69,7 +69,7 @@ class CreateTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_resource_creation_can_be_conditionally_enabled()
|
public function test_resource_creation_can_be_conditionally_enabled()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', new MockAdapter(), function (Type $type) {
|
$this->api->resourceType('users', new MockAdapter(), function (Type $type) {
|
||||||
$type->creatable(function () {
|
$type->creatable(function () {
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
@ -82,7 +82,7 @@ class CreateTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_resource_creation_can_be_explicitly_disabled()
|
public function test_resource_creation_can_be_explicitly_disabled()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', new MockAdapter(), function (Type $type) {
|
$this->api->resourceType('users', new MockAdapter(), function (Type $type) {
|
||||||
$type->notCreatable();
|
$type->notCreatable();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -93,7 +93,7 @@ class CreateTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_resource_creation_can_be_conditionally_disabled()
|
public function test_resource_creation_can_be_conditionally_disabled()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', new MockAdapter(), function (Type $type) {
|
$this->api->resourceType('users', new MockAdapter(), function (Type $type) {
|
||||||
$type->creatable(function () {
|
$type->creatable(function () {
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
@ -108,7 +108,7 @@ class CreateTest extends AbstractTestCase
|
||||||
{
|
{
|
||||||
$called = false;
|
$called = false;
|
||||||
|
|
||||||
$this->api->resource('users', new MockAdapter(), function (Type $type) use (&$called) {
|
$this->api->resourceType('users', new MockAdapter(), function (Type $type) use (&$called) {
|
||||||
$type->creatable(function ($context) use (&$called) {
|
$type->creatable(function ($context) use (&$called) {
|
||||||
$this->assertInstanceOf(Context::class, $context);
|
$this->assertInstanceOf(Context::class, $context);
|
||||||
return $called = true;
|
return $called = true;
|
||||||
|
|
@ -123,11 +123,13 @@ class CreateTest extends AbstractTestCase
|
||||||
public function test_new_models_are_supplied_and_saved_by_the_adapter()
|
public function test_new_models_are_supplied_and_saved_by_the_adapter()
|
||||||
{
|
{
|
||||||
$adapter = $this->prophesize(AdapterInterface::class);
|
$adapter = $this->prophesize(AdapterInterface::class);
|
||||||
$adapter->newModel()->willReturn($createdModel = (object) []);
|
$adapter->model()->willReturn($createdModel = (object) []);
|
||||||
$adapter->save($createdModel)->shouldBeCalled();
|
$adapter->save($createdModel)->shouldBeCalled();
|
||||||
$adapter->getId($createdModel)->willReturn('1');
|
$adapter->getId($createdModel)->willReturn('1');
|
||||||
|
$adapter->query()->shouldBeCalled();
|
||||||
|
$adapter->find(Argument::any(), '1')->willReturn($createdModel);
|
||||||
|
|
||||||
$this->api->resource('users', $adapter->reveal(), function (Type $type) {
|
$this->api->resourceType('users', $adapter->reveal(), function (Type $type) {
|
||||||
$type->creatable();
|
$type->creatable();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -139,13 +141,15 @@ class CreateTest extends AbstractTestCase
|
||||||
$createdModel = (object) [];
|
$createdModel = (object) [];
|
||||||
|
|
||||||
$adapter = $this->prophesize(AdapterInterface::class);
|
$adapter = $this->prophesize(AdapterInterface::class);
|
||||||
$adapter->newModel()->shouldNotBeCalled();
|
$adapter->model()->shouldNotBeCalled();
|
||||||
$adapter->save($createdModel)->shouldBeCalled();
|
$adapter->save($createdModel)->shouldBeCalled();
|
||||||
$adapter->getId($createdModel)->willReturn('1');
|
$adapter->getId($createdModel)->willReturn('1');
|
||||||
|
$adapter->query()->shouldBeCalled();
|
||||||
|
$adapter->find(Argument::any(), '1')->willReturn($createdModel);
|
||||||
|
|
||||||
$this->api->resource('users', $adapter->reveal(), function (Type $type) use ($createdModel) {
|
$this->api->resourceType('users', $adapter->reveal(), function (Type $type) use ($createdModel) {
|
||||||
$type->creatable();
|
$type->creatable();
|
||||||
$type->newModel(function ($context) use ($createdModel) {
|
$type->model(function ($context) use ($createdModel) {
|
||||||
$this->assertInstanceOf(Context::class, $context);
|
$this->assertInstanceOf(Context::class, $context);
|
||||||
return $createdModel;
|
return $createdModel;
|
||||||
});
|
});
|
||||||
|
|
@ -159,11 +163,13 @@ class CreateTest extends AbstractTestCase
|
||||||
$called = false;
|
$called = false;
|
||||||
|
|
||||||
$adapter = $this->prophesize(AdapterInterface::class);
|
$adapter = $this->prophesize(AdapterInterface::class);
|
||||||
$adapter->newModel()->willReturn($createdModel = (object) []);
|
$adapter->model()->willReturn($createdModel = (object) []);
|
||||||
$adapter->save($createdModel)->shouldNotBeCalled();
|
$adapter->save($createdModel)->shouldNotBeCalled();
|
||||||
$adapter->getId($createdModel)->willReturn('1');
|
$adapter->getId($createdModel)->willReturn('1');
|
||||||
|
$adapter->query()->shouldBeCalled();
|
||||||
|
$adapter->find(Argument::any(), '1')->willReturn($createdModel);
|
||||||
|
|
||||||
$this->api->resource('users', $adapter->reveal(), function (Type $type) use ($createdModel, &$called) {
|
$this->api->resourceType('users', $adapter->reveal(), function (Type $type) use ($createdModel, &$called) {
|
||||||
$type->creatable();
|
$type->creatable();
|
||||||
$type->save(function ($model, $context) use ($createdModel, &$called) {
|
$type->save(function ($model, $context) use ($createdModel, &$called) {
|
||||||
$model->id = '1';
|
$model->id = '1';
|
||||||
|
|
@ -183,18 +189,20 @@ class CreateTest extends AbstractTestCase
|
||||||
$called = 0;
|
$called = 0;
|
||||||
|
|
||||||
$adapter = $this->prophesize(AdapterInterface::class);
|
$adapter = $this->prophesize(AdapterInterface::class);
|
||||||
$adapter->newModel()->willReturn($createdModel = (object) []);
|
$adapter->model()->willReturn($createdModel = (object) []);
|
||||||
$adapter->getId($createdModel)->willReturn('1');
|
$adapter->getId($createdModel)->willReturn('1');
|
||||||
|
$adapter->query()->shouldBeCalled();
|
||||||
|
$adapter->find(Argument::any(), '1')->willReturn($createdModel);
|
||||||
|
|
||||||
$this->api->resource('users', $adapter->reveal(), function (Type $type) use ($adapter, $createdModel, &$called) {
|
$this->api->resourceType('users', $adapter->reveal(), function (Type $type) use ($adapter, $createdModel, &$called) {
|
||||||
$type->creatable();
|
$type->creatable();
|
||||||
$type->onCreating(function ($model, $context) use ($adapter, $createdModel, &$called) {
|
$type->creating(function ($model, $context) use ($adapter, $createdModel, &$called) {
|
||||||
$this->assertSame($createdModel, $model);
|
$this->assertSame($createdModel, $model);
|
||||||
$this->assertInstanceOf(Context::class, $context);
|
$this->assertInstanceOf(Context::class, $context);
|
||||||
$adapter->save($createdModel)->shouldNotHaveBeenCalled();
|
$adapter->save($createdModel)->shouldNotHaveBeenCalled();
|
||||||
$called++;
|
$called++;
|
||||||
});
|
});
|
||||||
$type->onCreated(function ($model, $context) use ($adapter, $createdModel, &$called) {
|
$type->created(function ($model, $context) use ($adapter, $createdModel, &$called) {
|
||||||
$this->assertSame($createdModel, $model);
|
$this->assertSame($createdModel, $model);
|
||||||
$this->assertInstanceOf(Context::class, $context);
|
$this->assertInstanceOf(Context::class, $context);
|
||||||
$adapter->save($createdModel)->shouldHaveBeenCalled();
|
$adapter->save($createdModel)->shouldHaveBeenCalled();
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ class DeleteTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_resources_are_not_deletable_by_default()
|
public function test_resources_are_not_deletable_by_default()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', new MockAdapter());
|
$this->api->resourceType('users', new MockAdapter());
|
||||||
|
|
||||||
$this->expectException(ForbiddenException::class);
|
$this->expectException(ForbiddenException::class);
|
||||||
|
|
||||||
|
|
@ -52,7 +52,7 @@ class DeleteTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_resource_deletion_can_be_explicitly_enabled()
|
public function test_resource_deletion_can_be_explicitly_enabled()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', new MockAdapter(), function (Type $type) {
|
$this->api->resourceType('users', new MockAdapter(), function (Type $type) {
|
||||||
$type->deletable();
|
$type->deletable();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -63,7 +63,7 @@ class DeleteTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_resource_deletion_can_be_conditionally_enabled()
|
public function test_resource_deletion_can_be_conditionally_enabled()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', new MockAdapter(), function (Type $type) {
|
$this->api->resourceType('users', new MockAdapter(), function (Type $type) {
|
||||||
$type->deletable(function () {
|
$type->deletable(function () {
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
@ -76,7 +76,7 @@ class DeleteTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_resource_deletion_can_be_explicitly_disabled()
|
public function test_resource_deletion_can_be_explicitly_disabled()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', new MockAdapter(), function (Type $type) {
|
$this->api->resourceType('users', new MockAdapter(), function (Type $type) {
|
||||||
$type->notDeletable();
|
$type->notDeletable();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -87,7 +87,7 @@ class DeleteTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_resource_deletion_can_be_conditionally_disabled()
|
public function test_resource_deletion_can_be_conditionally_disabled()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', new MockAdapter(), function (Type $type) {
|
$this->api->resourceType('users', new MockAdapter(), function (Type $type) {
|
||||||
$type->deletable(function () {
|
$type->deletable(function () {
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
@ -103,11 +103,11 @@ class DeleteTest extends AbstractTestCase
|
||||||
$called = false;
|
$called = false;
|
||||||
|
|
||||||
$adapter = $this->prophesize(AdapterInterface::class);
|
$adapter = $this->prophesize(AdapterInterface::class);
|
||||||
$adapter->newQuery()->willReturn($query = (object) []);
|
$adapter->query()->willReturn($query = (object) []);
|
||||||
$adapter->find($query, '1')->willReturn($deletingModel = (object) []);
|
$adapter->find($query, '1')->willReturn($deletingModel = (object) []);
|
||||||
$adapter->delete($deletingModel);
|
$adapter->delete($deletingModel);
|
||||||
|
|
||||||
$this->api->resource('users', $adapter->reveal(), function (Type $type) use ($deletingModel, &$called) {
|
$this->api->resourceType('users', $adapter->reveal(), function (Type $type) use ($deletingModel, &$called) {
|
||||||
$type->deletable(function ($model, $context) use ($deletingModel, &$called) {
|
$type->deletable(function ($model, $context) use ($deletingModel, &$called) {
|
||||||
$this->assertSame($deletingModel, $model);
|
$this->assertSame($deletingModel, $model);
|
||||||
$this->assertInstanceOf(Context::class, $context);
|
$this->assertInstanceOf(Context::class, $context);
|
||||||
|
|
@ -123,11 +123,11 @@ class DeleteTest extends AbstractTestCase
|
||||||
public function test_deleting_a_resource_calls_the_delete_adapter_method()
|
public function test_deleting_a_resource_calls_the_delete_adapter_method()
|
||||||
{
|
{
|
||||||
$adapter = $this->prophesize(AdapterInterface::class);
|
$adapter = $this->prophesize(AdapterInterface::class);
|
||||||
$adapter->newQuery()->willReturn($query = (object) []);
|
$adapter->query()->willReturn($query = (object) []);
|
||||||
$adapter->find($query, '1')->willReturn($model = (object) []);
|
$adapter->find($query, '1')->willReturn($model = (object) []);
|
||||||
$adapter->delete($model)->shouldBeCalled();
|
$adapter->delete($model)->shouldBeCalled();
|
||||||
|
|
||||||
$this->api->resource('users', $adapter->reveal(), function (Type $type) {
|
$this->api->resourceType('users', $adapter->reveal(), function (Type $type) {
|
||||||
$type->deletable();
|
$type->deletable();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -139,11 +139,11 @@ class DeleteTest extends AbstractTestCase
|
||||||
$called = false;
|
$called = false;
|
||||||
|
|
||||||
$adapter = $this->prophesize(AdapterInterface::class);
|
$adapter = $this->prophesize(AdapterInterface::class);
|
||||||
$adapter->newQuery()->willReturn($query = (object) []);
|
$adapter->query()->willReturn($query = (object) []);
|
||||||
$adapter->find($query, '1')->willReturn($deletingModel = (object) []);
|
$adapter->find($query, '1')->willReturn($deletingModel = (object) []);
|
||||||
$adapter->delete($deletingModel)->shouldNotBeCalled();
|
$adapter->delete($deletingModel)->shouldNotBeCalled();
|
||||||
|
|
||||||
$this->api->resource('users', $adapter->reveal(), function (Type $type) use ($deletingModel, &$called) {
|
$this->api->resourceType('users', $adapter->reveal(), function (Type $type) use ($deletingModel, &$called) {
|
||||||
$type->deletable();
|
$type->deletable();
|
||||||
$type->delete(function ($model, $context) use ($deletingModel, &$called) {
|
$type->delete(function ($model, $context) use ($deletingModel, &$called) {
|
||||||
$this->assertSame($deletingModel, $model);
|
$this->assertSame($deletingModel, $model);
|
||||||
|
|
@ -162,19 +162,19 @@ class DeleteTest extends AbstractTestCase
|
||||||
$called = 0;
|
$called = 0;
|
||||||
|
|
||||||
$adapter = $this->prophesize(AdapterInterface::class);
|
$adapter = $this->prophesize(AdapterInterface::class);
|
||||||
$adapter->newQuery()->willReturn($query = (object) []);
|
$adapter->query()->willReturn($query = (object) []);
|
||||||
$adapter->find($query, '1')->willReturn($deletingModel = (object) []);
|
$adapter->find($query, '1')->willReturn($deletingModel = (object) []);
|
||||||
$adapter->delete($deletingModel)->shouldBeCalled();
|
$adapter->delete($deletingModel)->shouldBeCalled();
|
||||||
|
|
||||||
$this->api->resource('users', $adapter->reveal(), function (Type $type) use ($adapter, $deletingModel, &$called) {
|
$this->api->resourceType('users', $adapter->reveal(), function (Type $type) use ($adapter, $deletingModel, &$called) {
|
||||||
$type->deletable();
|
$type->deletable();
|
||||||
$type->onDeleting(function ($model, $context) use ($adapter, $deletingModel, &$called) {
|
$type->deleting(function ($model, $context) use ($adapter, $deletingModel, &$called) {
|
||||||
$this->assertSame($deletingModel, $model);
|
$this->assertSame($deletingModel, $model);
|
||||||
$this->assertInstanceOf(Context::class, $context);
|
$this->assertInstanceOf(Context::class, $context);
|
||||||
$adapter->delete($deletingModel)->shouldNotHaveBeenCalled();
|
$adapter->delete($deletingModel)->shouldNotHaveBeenCalled();
|
||||||
$called++;
|
$called++;
|
||||||
});
|
});
|
||||||
$type->onDeleted(function ($model, $context) use ($adapter, $deletingModel, &$called) {
|
$type->deleted(function ($model, $context) use ($adapter, $deletingModel, &$called) {
|
||||||
$this->assertSame($deletingModel, $model);
|
$this->assertSame($deletingModel, $model);
|
||||||
$this->assertInstanceOf(Context::class, $context);
|
$this->assertInstanceOf(Context::class, $context);
|
||||||
$adapter->delete($deletingModel)->shouldHaveBeenCalled();
|
$adapter->delete($deletingModel)->shouldHaveBeenCalled();
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ class FieldGettersTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_attribute_values_are_retrieved_via_the_adapter_by_default()
|
public function test_attribute_values_are_retrieved_via_the_adapter_by_default()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) {
|
||||||
$type->attribute('test');
|
$type->attribute('test');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -63,7 +63,7 @@ class FieldGettersTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_attribute_getters_allow_a_custom_value_to_be_used()
|
public function test_attribute_getters_allow_a_custom_value_to_be_used()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) {
|
||||||
$type->attribute('test')
|
$type->attribute('test')
|
||||||
->get(function ($model, Context $context) {
|
->get(function ($model, Context $context) {
|
||||||
return 'custom';
|
return 'custom';
|
||||||
|
|
@ -81,11 +81,11 @@ class FieldGettersTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_has_one_values_are_retrieved_via_the_adapter_by_default()
|
public function test_has_one_values_are_retrieved_via_the_adapter_by_default()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) {
|
||||||
$type->hasOne('animal')->withLinkage();
|
$type->hasOne('animal')->withLinkage();
|
||||||
});
|
});
|
||||||
|
|
||||||
$this->api->resource('animals', new MockAdapter);
|
$this->api->resourceType('animals', new MockAdapter());
|
||||||
|
|
||||||
$response = $this->api->handle(
|
$response = $this->api->handle(
|
||||||
$this->buildRequest('GET', '/users/1')
|
$this->buildRequest('GET', '/users/1')
|
||||||
|
|
@ -98,14 +98,14 @@ class FieldGettersTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_has_one_getters_allow_a_custom_value_to_be_used()
|
public function test_has_one_getters_allow_a_custom_value_to_be_used()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) {
|
||||||
$type->hasOne('animal')->withLinkage()
|
$type->hasOne('animal')->withLinkage()
|
||||||
->get(function ($model, Context $context) {
|
->get(function ($model, bool $linkageOnly, Context $context) {
|
||||||
return (object) ['id' => '2'];
|
return (object) ['id' => '2'];
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$this->api->resource('animals', new MockAdapter);
|
$this->api->resourceType('animals', new MockAdapter());
|
||||||
|
|
||||||
$response = $this->api->handle(
|
$response = $this->api->handle(
|
||||||
$this->buildRequest('GET', '/users/1')
|
$this->buildRequest('GET', '/users/1')
|
||||||
|
|
@ -118,11 +118,11 @@ class FieldGettersTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_has_many_values_are_retrieved_via_the_adapter_by_default()
|
public function test_has_many_values_are_retrieved_via_the_adapter_by_default()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) {
|
||||||
$type->hasMany('animals')->withLinkage();
|
$type->hasMany('animals')->withLinkage();
|
||||||
});
|
});
|
||||||
|
|
||||||
$this->api->resource('animals', new MockAdapter);
|
$this->api->resourceType('animals', new MockAdapter());
|
||||||
|
|
||||||
$response = $this->api->handle(
|
$response = $this->api->handle(
|
||||||
$this->buildRequest('GET', '/users/1')
|
$this->buildRequest('GET', '/users/1')
|
||||||
|
|
@ -136,9 +136,9 @@ class FieldGettersTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_has_many_getters_allow_a_custom_value_to_be_used()
|
public function test_has_many_getters_allow_a_custom_value_to_be_used()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) {
|
||||||
$type->hasMany('animals')->withLinkage()
|
$type->hasMany('animals')->withLinkage()
|
||||||
->get(function ($model, Context $context) {
|
->get(function ($model, bool $linkageOnly, Context $context) {
|
||||||
return [
|
return [
|
||||||
(object) ['id' => '2'],
|
(object) ['id' => '2'],
|
||||||
(object) ['id' => '3']
|
(object) ['id' => '3']
|
||||||
|
|
@ -146,7 +146,7 @@ class FieldGettersTest extends AbstractTestCase
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$this->api->resource('animals', new MockAdapter);
|
$this->api->resourceType('animals', new MockAdapter());
|
||||||
|
|
||||||
$response = $this->api->handle(
|
$response = $this->api->handle(
|
||||||
$this->buildRequest('GET', '/users/1')
|
$this->buildRequest('GET', '/users/1')
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ class FieldVisibilityTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_fields_are_visible_by_default()
|
public function test_fields_are_visible_by_default()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', new MockAdapter, function (Type $type) {
|
$this->api->resourceType('users', new MockAdapter(), function (Type $type) {
|
||||||
$type->attribute('visible');
|
$type->attribute('visible');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -58,7 +58,7 @@ class FieldVisibilityTest extends AbstractTestCase
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$this->markTestIncomplete();
|
||||||
|
|
||||||
$this->api->resource('users', new MockAdapter, function (Type $type) {
|
$this->api->resourceType('users', new MockAdapter(), function (Type $type) {
|
||||||
$type->attribute('visibleAttribute')->visible();
|
$type->attribute('visibleAttribute')->visible();
|
||||||
$type->hasOne('visibleHasOne')->visible();
|
$type->hasOne('visibleHasOne')->visible();
|
||||||
$type->hasMany('visibleHasMany')->visible();
|
$type->hasMany('visibleHasMany')->visible();
|
||||||
|
|
@ -81,7 +81,7 @@ class FieldVisibilityTest extends AbstractTestCase
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$this->markTestIncomplete();
|
||||||
|
|
||||||
$this->api->resource('users', new MockAdapter, function (Type $type) {
|
$this->api->resourceType('users', new MockAdapter(), function (Type $type) {
|
||||||
$type->attribute('visibleAttribute')
|
$type->attribute('visibleAttribute')
|
||||||
->visible(function () { return true; });
|
->visible(function () { return true; });
|
||||||
|
|
||||||
|
|
@ -124,7 +124,7 @@ class FieldVisibilityTest extends AbstractTestCase
|
||||||
|
|
||||||
$called = 0;
|
$called = 0;
|
||||||
|
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) use (&$called) {
|
||||||
$callback = function ($model, $request) use (&$called) {
|
$callback = function ($model, $request) use (&$called) {
|
||||||
$this->assertSame($this->adapter->models['1'], $model);
|
$this->assertSame($this->adapter->models['1'], $model);
|
||||||
$this->assertInstanceOf(RequestInterface::class, $request);
|
$this->assertInstanceOf(RequestInterface::class, $request);
|
||||||
|
|
@ -152,7 +152,7 @@ class FieldVisibilityTest extends AbstractTestCase
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$this->markTestIncomplete();
|
||||||
|
|
||||||
$this->api->resource('users', new MockAdapter, function (Type $type) {
|
$this->api->resourceType('users', new MockAdapter(), function (Type $type) {
|
||||||
$type->attribute('hiddenAttribute')->hidden();
|
$type->attribute('hiddenAttribute')->hidden();
|
||||||
$type->hasOne('hiddenHasOne')->hidden();
|
$type->hasOne('hiddenHasOne')->hidden();
|
||||||
$type->hasMany('hiddenHasMany')->hidden();
|
$type->hasMany('hiddenHasMany')->hidden();
|
||||||
|
|
@ -175,7 +175,7 @@ class FieldVisibilityTest extends AbstractTestCase
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$this->markTestIncomplete();
|
||||||
|
|
||||||
$this->api->resource('users', new MockAdapter, function (Type $type) {
|
$this->api->resourceType('users', new MockAdapter(), function (Type $type) {
|
||||||
$type->attribute('visibleAttribute')
|
$type->attribute('visibleAttribute')
|
||||||
->hidden(function () { return false; });
|
->hidden(function () { return false; });
|
||||||
|
|
||||||
|
|
@ -218,7 +218,7 @@ class FieldVisibilityTest extends AbstractTestCase
|
||||||
|
|
||||||
$called = 0;
|
$called = 0;
|
||||||
|
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) use (&$called) {
|
||||||
$callback = function ($model, $request) use (&$called) {
|
$callback = function ($model, $request) use (&$called) {
|
||||||
$this->assertSame($this->adapter->models['1'], $model);
|
$this->assertSame($this->adapter->models['1'], $model);
|
||||||
$this->assertInstanceOf(RequestInterface::class, $request);
|
$this->assertInstanceOf(RequestInterface::class, $request);
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ class FieldWritabilityTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_attributes_are_readonly_by_default()
|
public function test_attributes_are_readonly_by_default()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) use (&$called) {
|
||||||
$type->updatable();
|
$type->updatable();
|
||||||
$type->attribute('readonly');
|
$type->attribute('readonly');
|
||||||
});
|
});
|
||||||
|
|
@ -65,7 +65,7 @@ class FieldWritabilityTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_attributes_can_be_explicitly_writable()
|
public function test_attributes_can_be_explicitly_writable()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) {
|
||||||
$type->updatable();
|
$type->updatable();
|
||||||
$type->attribute('writable')->writable();
|
$type->attribute('writable')->writable();
|
||||||
});
|
});
|
||||||
|
|
@ -89,7 +89,7 @@ class FieldWritabilityTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_attributes_can_be_conditionally_writable()
|
public function test_attributes_can_be_conditionally_writable()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) {
|
||||||
$type->updatable();
|
$type->updatable();
|
||||||
$type->attribute('writable')
|
$type->attribute('writable')
|
||||||
->writable(function () { return true; });
|
->writable(function () { return true; });
|
||||||
|
|
@ -116,7 +116,7 @@ class FieldWritabilityTest extends AbstractTestCase
|
||||||
{
|
{
|
||||||
$called = false;
|
$called = false;
|
||||||
|
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) use (&$called) {
|
||||||
$type->updatable();
|
$type->updatable();
|
||||||
$type->attribute('writable')
|
$type->attribute('writable')
|
||||||
->writable(function ($model, $context) use (&$called) {
|
->writable(function ($model, $context) use (&$called) {
|
||||||
|
|
@ -145,7 +145,7 @@ class FieldWritabilityTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_attributes_can_be_explicitly_readonly()
|
public function test_attributes_can_be_explicitly_readonly()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) use (&$called) {
|
||||||
$type->updatable();
|
$type->updatable();
|
||||||
$type->attribute('readonly')->readonly();
|
$type->attribute('readonly')->readonly();
|
||||||
});
|
});
|
||||||
|
|
@ -168,7 +168,7 @@ class FieldWritabilityTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_attributes_can_be_conditionally_readonly()
|
public function test_attributes_can_be_conditionally_readonly()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) {
|
||||||
$type->updatable();
|
$type->updatable();
|
||||||
$type->attribute('readonly')
|
$type->attribute('readonly')
|
||||||
->readonly(function () { return true; });
|
->readonly(function () { return true; });
|
||||||
|
|
@ -194,7 +194,7 @@ class FieldWritabilityTest extends AbstractTestCase
|
||||||
{
|
{
|
||||||
$called = false;
|
$called = false;
|
||||||
|
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) use (&$called) {
|
||||||
$type->updatable();
|
$type->updatable();
|
||||||
$type->attribute('readonly')
|
$type->attribute('readonly')
|
||||||
->readonly(function ($model, $context) use (&$called) {
|
->readonly(function ($model, $context) use (&$called) {
|
||||||
|
|
@ -225,7 +225,7 @@ class FieldWritabilityTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_field_is_only_writable_once_on_creation()
|
public function test_field_is_only_writable_once_on_creation()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) {
|
||||||
$type->creatable();
|
$type->creatable();
|
||||||
$type->updatable();
|
$type->updatable();
|
||||||
$type->attribute('writableOnce')->writable()->once();
|
$type->attribute('writableOnce')->writable()->once();
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ class FiltersTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_resources_can_be_filtered_by_id()
|
public function test_resources_can_be_filtered_by_id()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', $this->adapter);
|
$this->api->resourceType('users', $this->adapter);
|
||||||
|
|
||||||
$this->api->handle(
|
$this->api->handle(
|
||||||
$this->buildRequest('GET', '/users')
|
$this->buildRequest('GET', '/users')
|
||||||
|
|
@ -56,7 +56,7 @@ class FiltersTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_attributes_are_not_filterable_by_default()
|
public function test_attributes_are_not_filterable_by_default()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) {
|
||||||
$type->attribute('test');
|
$type->attribute('test');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -70,7 +70,7 @@ class FiltersTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_attributes_can_be_explicitly_filterable()
|
public function test_attributes_can_be_explicitly_filterable()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) use (&$attribute) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) use (&$attribute) {
|
||||||
$attribute = $type->attribute('test')->filterable();
|
$attribute = $type->attribute('test')->filterable();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -107,7 +107,7 @@ class FiltersTest extends AbstractTestCase
|
||||||
{
|
{
|
||||||
$called = false;
|
$called = false;
|
||||||
|
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) use (&$called) {
|
||||||
$type->filter('name', function ($query, $value, Context $context) use (&$called) {
|
$type->filter('name', function ($query, $value, Context $context) use (&$called) {
|
||||||
$this->assertSame($this->adapter->query, $query);
|
$this->assertSame($this->adapter->query, $query);
|
||||||
$this->assertEquals('value', $value);
|
$this->assertEquals('value', $value);
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class MetaTest extends AbstractTestCase
|
||||||
{
|
{
|
||||||
$adapter = new MockAdapter(['1' => (object) ['id' => '1']]);
|
$adapter = new MockAdapter(['1' => (object) ['id' => '1']]);
|
||||||
|
|
||||||
$this->api->resource('users', $adapter, function (Type $type) use ($adapter) {
|
$this->api->resourceType('users', $adapter, function (Type $type) use ($adapter) {
|
||||||
$type->meta('foo', function ($model, $context) use ($adapter) {
|
$type->meta('foo', function ($model, $context) use ($adapter) {
|
||||||
$this->assertSame($adapter->models['1'], $model);
|
$this->assertSame($adapter->models['1'], $model);
|
||||||
$this->assertInstanceOf(Context::class, $context);
|
$this->assertInstanceOf(Context::class, $context);
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@
|
||||||
|
|
||||||
namespace Tobyz\Tests\JsonApiServer\feature;
|
namespace Tobyz\Tests\JsonApiServer\feature;
|
||||||
|
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
|
||||||
use Tobyz\JsonApiServer\JsonApi;
|
use Tobyz\JsonApiServer\JsonApi;
|
||||||
use Tobyz\JsonApiServer\Context;
|
use Tobyz\JsonApiServer\Context;
|
||||||
use Tobyz\JsonApiServer\Schema\Type;
|
use Tobyz\JsonApiServer\Schema\Type;
|
||||||
|
|
@ -38,7 +37,7 @@ class ScopesTest extends AbstractTestCase
|
||||||
$this->scopeWasCalled = false;
|
$this->scopeWasCalled = false;
|
||||||
|
|
||||||
$this->api = new JsonApi('http://example.com');
|
$this->api = new JsonApi('http://example.com');
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) {
|
||||||
$type->updatable();
|
$type->updatable();
|
||||||
$type->deletable();
|
$type->deletable();
|
||||||
$type->scope(function ($query, Context $context) {
|
$type->scope(function ($query, Context $context) {
|
||||||
|
|
@ -88,44 +87,4 @@ class ScopesTest extends AbstractTestCase
|
||||||
|
|
||||||
$this->assertTrue($this->scopeWasCalled);
|
$this->assertTrue($this->scopeWasCalled);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_scopes_are_applied_to_related_resources()
|
|
||||||
{
|
|
||||||
$this->api->resource('pets', new MockAdapter, function (Type $type) {
|
|
||||||
$type->hasOne('owner')
|
|
||||||
->type('users')
|
|
||||||
->includable();
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->api->handle(
|
|
||||||
$this->buildRequest('GET', '/pets/1')
|
|
||||||
->withQueryParams(['include' => 'owner'])
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertTrue($this->scopeWasCalled);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_scopes_are_applied_to_polymorphic_related_resources()
|
|
||||||
{
|
|
||||||
$this->api->resource('pets', new MockAdapter, function (Type $type) {
|
|
||||||
$type->hasOne('owner')
|
|
||||||
->polymorphic(['users', 'organisations'])
|
|
||||||
->includable();
|
|
||||||
});
|
|
||||||
|
|
||||||
$organisationScopeWasCalled = false;
|
|
||||||
$this->api->resource('organisations', new MockAdapter, function (Type $type) use (&$organisationScopeWasCalled) {
|
|
||||||
$type->scope(function ($query, Context $context) use (&$organisationScopeWasCalled) {
|
|
||||||
$organisationScopeWasCalled = true;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->api->handle(
|
|
||||||
$this->buildRequest('GET', '/pets/1')
|
|
||||||
->withQueryParams(['include' => 'owner'])
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertTrue($this->scopeWasCalled);
|
|
||||||
$this->assertTrue($organisationScopeWasCalled);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ class SortingTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_attributes_are_not_sortable_by_default()
|
public function test_attributes_are_not_sortable_by_default()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) {
|
||||||
$type->attribute('name');
|
$type->attribute('name');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -54,7 +54,7 @@ class SortingTest extends AbstractTestCase
|
||||||
{
|
{
|
||||||
$attribute = null;
|
$attribute = null;
|
||||||
|
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) use (&$attribute) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) use (&$attribute) {
|
||||||
$attribute = $type->attribute('name')->sortable();
|
$attribute = $type->attribute('name')->sortable();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -73,7 +73,7 @@ class SortingTest extends AbstractTestCase
|
||||||
|
|
||||||
public function test_attributes_can_be_explicitly_not_sortable()
|
public function test_attributes_can_be_explicitly_not_sortable()
|
||||||
{
|
{
|
||||||
$this->api->resource('users', $this->adapter, function (Type $type) {
|
$this->api->resourceType('users', $this->adapter, function (Type $type) {
|
||||||
$type->attribute('name')->notSortable();
|
$type->attribute('name')->notSortable();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,14 @@
|
||||||
|
|
||||||
namespace Tobyz\Tests\JsonApiServer\specification;
|
namespace Tobyz\Tests\JsonApiServer\specification;
|
||||||
|
|
||||||
use Tobyz\JsonApiServer\JsonApi;
|
|
||||||
use Tobyz\JsonApiServer\Exception\NotAcceptableException;
|
use Tobyz\JsonApiServer\Exception\NotAcceptableException;
|
||||||
use Tobyz\JsonApiServer\Exception\UnsupportedMediaTypeException;
|
use Tobyz\JsonApiServer\Exception\UnsupportedMediaTypeException;
|
||||||
use Tobyz\JsonApiServer\Schema\Type;
|
use Tobyz\JsonApiServer\JsonApi;
|
||||||
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
|
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
|
||||||
use Tobyz\Tests\JsonApiServer\MockAdapter;
|
use Tobyz\Tests\JsonApiServer\MockAdapter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see https://jsonapi.org/format/#content-negotiation
|
* @see https://jsonapi.org/format/1.1/#content-negotiation
|
||||||
*/
|
*/
|
||||||
class ContentNegotiationTest extends AbstractTestCase
|
class ContentNegotiationTest extends AbstractTestCase
|
||||||
{
|
{
|
||||||
|
|
@ -31,9 +30,7 @@ class ContentNegotiationTest extends AbstractTestCase
|
||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
$this->api = new JsonApi('http://example.com');
|
$this->api = new JsonApi('http://example.com');
|
||||||
$this->api->resource('users', new MockAdapter(), function (Type $type) {
|
$this->api->resourceType('users', new MockAdapter());
|
||||||
// no fields
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_json_api_content_type_is_returned()
|
public function test_json_api_content_type_is_returned()
|
||||||
|
|
@ -48,36 +45,36 @@ class ContentNegotiationTest extends AbstractTestCase
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_error_when_request_content_type_has_parameters()
|
public function test_success_when_request_content_type_contains_profile()
|
||||||
|
{
|
||||||
|
$response = $this->api->handle(
|
||||||
|
$this->buildRequest('GET', '/users/1')
|
||||||
|
->withHeader('Accept', 'application/vnd.api+json; profile="http://example.com/profile"')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(200, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_error_when_request_content_type_contains_unknown_parameter()
|
||||||
{
|
{
|
||||||
$request = $this->buildRequest('PATCH', '/users/1')
|
$request = $this->buildRequest('PATCH', '/users/1')
|
||||||
->withHeader('Content-Type', 'application/vnd.api+json;profile="http://example.com/last-modified"');
|
->withHeader('Content-Type', 'application/vnd.api+json; unknown="parameter"');
|
||||||
|
|
||||||
$this->expectException(UnsupportedMediaTypeException::class);
|
$this->expectException(UnsupportedMediaTypeException::class);
|
||||||
|
|
||||||
$this->api->handle($request);
|
$this->api->handle($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_error_when_all_accepts_have_parameters()
|
public function test_error_when_request_content_type_contains_unsupported_extension()
|
||||||
{
|
{
|
||||||
$request = $this->buildRequest('GET', '/users/1')
|
$request = $this->buildRequest('PATCH', '/users/1')
|
||||||
->withHeader('Accept', 'application/vnd.api+json;profile="http://example.com/last-modified", application/vnd.api+json;profile="http://example.com/versioning"');
|
->withHeader('Content-Type', 'application/vnd.api+json; ext="http://example.com/extension"');
|
||||||
|
|
||||||
$this->expectException(NotAcceptableException::class);
|
$this->expectException(UnsupportedMediaTypeException::class);
|
||||||
|
|
||||||
$this->api->handle($request);
|
$this->api->handle($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_success_when_only_some_accepts_have_parameters()
|
|
||||||
{
|
|
||||||
$response = $this->api->handle(
|
|
||||||
$this->buildRequest('GET', '/users/1')
|
|
||||||
->withHeader('Accept', 'application/vnd.api+json;profile="http://example.com/last-modified", application/vnd.api+json')
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertEquals(200, $response->getStatusCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_success_when_accepts_wildcard()
|
public function test_success_when_accepts_wildcard()
|
||||||
{
|
{
|
||||||
$response = $this->api->handle(
|
$response = $this->api->handle(
|
||||||
|
|
@ -87,4 +84,33 @@ class ContentNegotiationTest extends AbstractTestCase
|
||||||
|
|
||||||
$this->assertEquals(200, $response->getStatusCode());
|
$this->assertEquals(200, $response->getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_error_when_all_accepts_have_unknown_parameters()
|
||||||
|
{
|
||||||
|
$request = $this->buildRequest('GET', '/users/1')
|
||||||
|
->withHeader('Accept', 'application/vnd.api+json; unknown="parameter", application/vnd.api+json; unknown="parameter2"');
|
||||||
|
|
||||||
|
$this->expectException(NotAcceptableException::class);
|
||||||
|
|
||||||
|
$this->api->handle($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_success_when_only_some_accepts_have_parameters()
|
||||||
|
{
|
||||||
|
$response = $this->api->handle(
|
||||||
|
$this->buildRequest('GET', '/users/1')
|
||||||
|
->withHeader('Accept', 'application/vnd.api+json; unknown="parameter", application/vnd.api+json')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(200, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_responds_with_vary_header()
|
||||||
|
{
|
||||||
|
$response = $this->api->handle(
|
||||||
|
$this->buildRequest('GET', '/users/1')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals('Accept', $response->getHeaderLine('vary'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,17 @@
|
||||||
|
|
||||||
namespace Tobyz\Tests\JsonApiServer\specification;
|
namespace Tobyz\Tests\JsonApiServer\specification;
|
||||||
|
|
||||||
|
use Tobyz\JsonApiServer\Exception\BadRequestException;
|
||||||
|
use Tobyz\JsonApiServer\Exception\ConflictException;
|
||||||
|
use Tobyz\JsonApiServer\Exception\ForbiddenException;
|
||||||
|
use Tobyz\JsonApiServer\Exception\ResourceNotFoundException;
|
||||||
use Tobyz\JsonApiServer\JsonApi;
|
use Tobyz\JsonApiServer\JsonApi;
|
||||||
|
use Tobyz\JsonApiServer\Schema\Type;
|
||||||
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
|
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
|
||||||
use Tobyz\Tests\JsonApiServer\MockAdapter;
|
use Tobyz\Tests\JsonApiServer\MockAdapter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see https://jsonapi.org/format/1.0/#crud-creating
|
* @see https://jsonapi.org/format/1.1/#crud-creating
|
||||||
*/
|
*/
|
||||||
class CreatingResourcesTest extends AbstractTestCase
|
class CreatingResourcesTest extends AbstractTestCase
|
||||||
{
|
{
|
||||||
|
|
@ -25,55 +30,116 @@ class CreatingResourcesTest extends AbstractTestCase
|
||||||
*/
|
*/
|
||||||
private $api;
|
private $api;
|
||||||
|
|
||||||
/**
|
|
||||||
* @var MockAdapter
|
|
||||||
*/
|
|
||||||
private $adapter;
|
|
||||||
|
|
||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
$this->api = new JsonApi('http://example.com');
|
$this->api = new JsonApi('http://example.com');
|
||||||
|
|
||||||
$this->adapter = new MockAdapter();
|
$this->api->resourceType('users', new MockAdapter(), function (Type $type) {
|
||||||
|
$type->creatable();
|
||||||
|
$type->attribute('name')->writable();
|
||||||
|
$type->hasOne('pet')->writable();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_bad_request_error_if_body_does_not_contain_data_type()
|
public function test_bad_request_error_if_body_does_not_contain_data_type()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$this->expectException(BadRequestException::class);
|
||||||
|
|
||||||
|
$this->api->handle(
|
||||||
|
$this->buildRequest('POST', '/users')
|
||||||
|
->withParsedBody([
|
||||||
|
'data' => [],
|
||||||
|
])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_bad_request_error_if_relationship_does_not_contain_data()
|
public function test_bad_request_error_if_relationship_does_not_contain_data()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$this->expectException(BadRequestException::class);
|
||||||
|
|
||||||
|
$this->api->handle(
|
||||||
|
$this->buildRequest('POST', '/users')
|
||||||
|
->withParsedBody([
|
||||||
|
'data' => [
|
||||||
|
'type' => 'users',
|
||||||
|
'relationships' => [
|
||||||
|
'pet' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_forbidden_error_if_client_generated_id_provided()
|
public function test_forbidden_error_if_client_generated_id_provided()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$this->expectException(ForbiddenException::class);
|
||||||
|
|
||||||
|
$this->api->handle(
|
||||||
|
$this->buildRequest('POST', '/users')
|
||||||
|
->withParsedBody([
|
||||||
|
'data' => [
|
||||||
|
'type' => 'users',
|
||||||
|
'id' => '1',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_created_response_if_resource_successfully_created()
|
public function test_created_response_includes_created_data_and_location_header()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$response = $this->api->handle(
|
||||||
}
|
$this->buildRequest('POST', '/users')
|
||||||
|
->withParsedBody([
|
||||||
|
'data' => [
|
||||||
|
'type' => 'users',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
public function test_created_response_includes_created_data()
|
$this->assertEquals(201, $response->getStatusCode());
|
||||||
{
|
$this->assertEquals('http://example.com/users/1', $response->getHeaderLine('location'));
|
||||||
$this->markTestIncomplete();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_created_response_includes_location_header_and_matches_self_link()
|
$this->assertJsonApiDocumentSubset([
|
||||||
{
|
'data' => [
|
||||||
$this->markTestIncomplete();
|
'type' => 'users',
|
||||||
|
'id' => '1',
|
||||||
|
'links' => [
|
||||||
|
'self' => 'http://example.com/users/1',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], $response->getBody());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_not_found_error_if_references_resource_that_does_not_exist()
|
public function test_not_found_error_if_references_resource_that_does_not_exist()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$this->expectException(ResourceNotFoundException::class);
|
||||||
|
|
||||||
|
$this->api->handle(
|
||||||
|
$this->buildRequest('POST', '/users')
|
||||||
|
->withParsedBody([
|
||||||
|
'data' => [
|
||||||
|
'type' => 'users',
|
||||||
|
'relationships' => [
|
||||||
|
'pet' => [
|
||||||
|
'data' => ['type' => 'pets', 'id' => '1'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_conflict_error_if_type_does_not_match_endpoint()
|
public function test_conflict_error_if_type_does_not_match_endpoint()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$this->expectException(ConflictException::class);
|
||||||
|
|
||||||
|
$this->api->handle(
|
||||||
|
$this->buildRequest('POST', '/users')
|
||||||
|
->withParsedBody([
|
||||||
|
'data' => [
|
||||||
|
'type' => 'pets',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,15 @@
|
||||||
|
|
||||||
namespace Tobyz\Tests\JsonApiServer\specification;
|
namespace Tobyz\Tests\JsonApiServer\specification;
|
||||||
|
|
||||||
|
use Tobyz\JsonApiServer\Context;
|
||||||
|
use Tobyz\JsonApiServer\Exception\ResourceNotFoundException;
|
||||||
use Tobyz\JsonApiServer\JsonApi;
|
use Tobyz\JsonApiServer\JsonApi;
|
||||||
|
use Tobyz\JsonApiServer\Schema\Type;
|
||||||
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
|
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
|
||||||
use Tobyz\Tests\JsonApiServer\MockAdapter;
|
use Tobyz\Tests\JsonApiServer\MockAdapter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see https://jsonapi.org/format/1.0/#crud-deleting
|
* @see https://jsonapi.org/format/1.1/#crud-deleting
|
||||||
*/
|
*/
|
||||||
class DeletingResourcesTest extends AbstractTestCase
|
class DeletingResourcesTest extends AbstractTestCase
|
||||||
{
|
{
|
||||||
|
|
@ -25,25 +28,48 @@ class DeletingResourcesTest extends AbstractTestCase
|
||||||
*/
|
*/
|
||||||
private $api;
|
private $api;
|
||||||
|
|
||||||
/**
|
|
||||||
* @var MockAdapter
|
|
||||||
*/
|
|
||||||
private $adapter;
|
|
||||||
|
|
||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
$this->api = new JsonApi('http://example.com');
|
$this->api = new JsonApi('http://example.com');
|
||||||
|
|
||||||
$this->adapter = new MockAdapter();
|
$this->api->resourceType('users', new MockAdapter(), function (Type $type) {
|
||||||
|
$type->deletable();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_no_content_response_if_resource_successfully_deleted()
|
public function test_no_content_response_if_resource_successfully_deleted()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$response = $this->api->handle(
|
||||||
|
$this->buildRequest('DELETE', '/users/1')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(204, $response->getStatusCode());
|
||||||
|
$this->assertEmpty($response->getBody()->getContents());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ok_response_if_meta()
|
||||||
|
{
|
||||||
|
$this->api->resourceType('users', new MockAdapter(), function (Type $type) {
|
||||||
|
$type->deletable();
|
||||||
|
$type->deleting(function ($model, Context $context) {
|
||||||
|
$context->meta('foo', 'bar');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$response = $this->api->handle(
|
||||||
|
$this->buildRequest('DELETE', '/users/1')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(200, $response->getStatusCode());
|
||||||
|
$this->assertJsonApiDocumentSubset(['meta' => ['foo' => 'bar']], $response->getBody());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_not_found_error_if_resource_does_not_exist()
|
public function test_not_found_error_if_resource_does_not_exist()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$this->expectException(ResourceNotFoundException::class);
|
||||||
|
|
||||||
|
$this->api->handle(
|
||||||
|
$this->buildRequest('DELETE', '/users/404')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,13 @@
|
||||||
|
|
||||||
namespace Tobyz\Tests\JsonApiServer\specification;
|
namespace Tobyz\Tests\JsonApiServer\specification;
|
||||||
|
|
||||||
|
use Tobyz\JsonApiServer\Exception\ResourceNotFoundException;
|
||||||
use Tobyz\JsonApiServer\JsonApi;
|
use Tobyz\JsonApiServer\JsonApi;
|
||||||
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
|
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
|
||||||
use Tobyz\Tests\JsonApiServer\MockAdapter;
|
use Tobyz\Tests\JsonApiServer\MockAdapter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see https://jsonapi.org/format/#fetching-resources
|
* @see https://jsonapi.org/format/1.1/#fetching-resources
|
||||||
*/
|
*/
|
||||||
class FetchingResourcesTest extends AbstractTestCase
|
class FetchingResourcesTest extends AbstractTestCase
|
||||||
{
|
{
|
||||||
|
|
@ -25,50 +26,80 @@ class FetchingResourcesTest extends AbstractTestCase
|
||||||
*/
|
*/
|
||||||
private $api;
|
private $api;
|
||||||
|
|
||||||
/**
|
|
||||||
* @var MockAdapter
|
|
||||||
*/
|
|
||||||
private $adapter;
|
|
||||||
|
|
||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
$this->api = new JsonApi('http://example.com');
|
$this->api = new JsonApi('http://example.com');
|
||||||
|
|
||||||
$this->adapter = new MockAdapter();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_data_for_resource_collection_is_array_of_resource_objects()
|
public function test_data_for_resource_collection_is_array_of_resource_objects()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$adapter = new MockAdapter([
|
||||||
|
(object) ['id' => '1'],
|
||||||
|
(object) ['id' => '2'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->api->resourceType('articles', $adapter);
|
||||||
|
|
||||||
|
$response = $this->api->handle(
|
||||||
|
$this->buildRequest('GET', '/articles')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertJsonApiDocumentSubset([
|
||||||
|
'data' => [
|
||||||
|
['type' => 'articles', 'id' => '1'],
|
||||||
|
['type' => 'articles', 'id' => '2'],
|
||||||
|
]
|
||||||
|
], $response->getBody());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_data_for_empty_resource_collection_is_empty_array()
|
public function test_data_for_empty_resource_collection_is_empty_array()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$this->api->resourceType('articles', new MockAdapter());
|
||||||
|
|
||||||
|
$response = $this->api->handle(
|
||||||
|
$this->buildRequest('GET', '/articles')
|
||||||
|
);
|
||||||
|
|
||||||
|
$data = json_decode($response->getBody(), true)['data'] ?? null;
|
||||||
|
|
||||||
|
$this->assertIsArray($data);
|
||||||
|
$this->assertEmpty($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_data_for_individual_resource_is_resource_object()
|
public function test_data_for_individual_resource_is_resource_object()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$adapter = new MockAdapter([
|
||||||
|
(object) ['id' => '1'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->api->resourceType('articles', $adapter);
|
||||||
|
|
||||||
|
$response = $this->api->handle(
|
||||||
|
$this->buildRequest('GET', '/articles/1')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertJsonApiDocumentSubset([
|
||||||
|
'data' => ['type' => 'articles', 'id' => '1'],
|
||||||
|
], $response->getBody());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_not_found_error_if_resource_type_does_not_exist()
|
public function test_not_found_error_if_resource_type_does_not_exist()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$this->expectException(ResourceNotFoundException::class);
|
||||||
|
|
||||||
|
$this->api->handle(
|
||||||
|
$this->buildRequest('GET', '/articles/1')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_not_found_error_if_resource_does_not_exist()
|
public function test_not_found_error_if_resource_does_not_exist()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$this->expectException(ResourceNotFoundException::class);
|
||||||
}
|
|
||||||
|
|
||||||
public function test_resource_collection_document_contains_self_link()
|
$this->api->resourceType('articles', new MockAdapter());
|
||||||
{
|
|
||||||
$this->markTestIncomplete();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_resource_document_contains_self_link()
|
$this->api->handle(
|
||||||
{
|
$this->buildRequest('GET', '/articles/404')
|
||||||
$this->markTestIncomplete();
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,29 +16,32 @@ use Tobyz\Tests\JsonApiServer\AbstractTestCase;
|
||||||
use Tobyz\Tests\JsonApiServer\MockAdapter;
|
use Tobyz\Tests\JsonApiServer\MockAdapter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see https://jsonapi.org/format/#document-jsonapi-object
|
* @see https://jsonapi.org/format/1.1/#document-jsonapi-object
|
||||||
*/
|
*/
|
||||||
class JsonApiTest extends AbstractTestCase
|
class JsonApiObjectTest extends AbstractTestCase
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @var JsonApi
|
* @var JsonApi
|
||||||
*/
|
*/
|
||||||
private $api;
|
private $api;
|
||||||
|
|
||||||
/**
|
|
||||||
* @var MockAdapter
|
|
||||||
*/
|
|
||||||
private $adapter;
|
|
||||||
|
|
||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
$this->api = new JsonApi('http://example.com');
|
$this->api = new JsonApi('http://example.com');
|
||||||
|
|
||||||
$this->adapter = new MockAdapter();
|
$this->api->resourceType('articles', new MockAdapter());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_document_includes_jsonapi_member_with_version_1_0()
|
public function test_document_includes_jsonapi_member_with_version_1_1()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$response = $this->api->handle(
|
||||||
|
$this->buildRequest('GET', '/articles')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertJsonApiDocumentSubset([
|
||||||
|
'jsonapi' => [
|
||||||
|
'version' => '1.1',
|
||||||
|
],
|
||||||
|
], $response->getBody());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -12,12 +12,12 @@
|
||||||
namespace Tobyz\Tests\JsonApiServer\specification;
|
namespace Tobyz\Tests\JsonApiServer\specification;
|
||||||
|
|
||||||
use Tobyz\JsonApiServer\JsonApi;
|
use Tobyz\JsonApiServer\JsonApi;
|
||||||
|
use Tobyz\JsonApiServer\Schema\Type;
|
||||||
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
|
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
|
||||||
use Tobyz\Tests\JsonApiServer\MockAdapter;
|
use Tobyz\Tests\JsonApiServer\MockAdapter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see https://jsonapi.org/format/1.0/#fetching-pagination
|
* @see https://jsonapi.org/format/1.1/#fetching-pagination
|
||||||
* @todo Create a profile for offset pagination strategy
|
|
||||||
*/
|
*/
|
||||||
class OffsetPaginationTest extends AbstractTestCase
|
class OffsetPaginationTest extends AbstractTestCase
|
||||||
{
|
{
|
||||||
|
|
@ -26,60 +26,84 @@ class OffsetPaginationTest extends AbstractTestCase
|
||||||
*/
|
*/
|
||||||
private $api;
|
private $api;
|
||||||
|
|
||||||
/**
|
|
||||||
* @var MockAdapter
|
|
||||||
*/
|
|
||||||
private $adapter;
|
|
||||||
|
|
||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
$this->api = new JsonApi('http://example.com');
|
$this->api = new JsonApi('http://example.com');
|
||||||
|
|
||||||
$this->adapter = new MockAdapter();
|
$adapter = new MockAdapter(
|
||||||
|
array_map(function ($i) {
|
||||||
|
return (object) ['id' => (string) $i];
|
||||||
|
}, range(1, 100))
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->api->resourceType('articles', $adapter, function (Type $type) {
|
||||||
|
$type->paginate(20);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_can_request_limit_on_resource_collection()
|
public function test_can_request_limit_on_resource_collection()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$response = $this->api->handle(
|
||||||
|
$this->buildRequest('GET', '/articles')
|
||||||
|
->withQueryParams(['page' => ['limit' => '10']])
|
||||||
|
);
|
||||||
|
|
||||||
|
$data = json_decode($response->getBody(), true)['data'] ?? null;
|
||||||
|
|
||||||
|
$this->assertCount(10, $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_can_request_offset_on_resource_collection()
|
public function test_can_request_offset_on_resource_collection()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$response = $this->api->handle(
|
||||||
|
$this->buildRequest('GET', '/articles')
|
||||||
|
->withQueryParams(['page' => ['offset' => '5']])
|
||||||
|
);
|
||||||
|
|
||||||
|
$data = json_decode($response->getBody(), true)['data'] ?? null;
|
||||||
|
|
||||||
|
$this->assertEquals('6', $data[0]['id'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_first_pagination_link_is_correct()
|
public function test_pagination_links_are_correct_and_retain_query_parameters()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$response = $this->api->handle(
|
||||||
}
|
$this->buildRequest('GET', '/articles')
|
||||||
|
->withQueryParams([
|
||||||
|
'page' => ['offset' => '40'],
|
||||||
|
'otherParam' => 'value',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
public function test_last_pagination_link_is_correct()
|
$links = json_decode($response->getBody(), true)['links'] ?? null;
|
||||||
{
|
|
||||||
$this->markTestIncomplete();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_next_pagination_link_is_correct()
|
$this->assertEquals('/articles?otherParam=value', $links['first'] ?? null);
|
||||||
{
|
$this->assertEquals('/articles?otherParam=value&page%5Boffset%5D=80', $links['last'] ?? null);
|
||||||
$this->markTestIncomplete();
|
$this->assertEquals('/articles?otherParam=value&page%5Boffset%5D=60', $links['next'] ?? null);
|
||||||
|
$this->assertEquals('/articles?otherParam=value&page%5Boffset%5D=20', $links['prev'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_next_pagination_link_is_not_included_on_last_page()
|
public function test_next_pagination_link_is_not_included_on_last_page()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$response = $this->api->handle(
|
||||||
|
$this->buildRequest('GET', '/articles')
|
||||||
|
->withQueryParams(['page' => ['offset' => '80']])
|
||||||
|
);
|
||||||
|
|
||||||
|
$links = json_decode($response->getBody(), true)['links'] ?? null;
|
||||||
|
|
||||||
|
$this->assertNull($links['next'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_prev_pagination_link_is_correct()
|
public function test_prev_pagination_link_is_not_included_on_first_page()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$response = $this->api->handle(
|
||||||
}
|
$this->buildRequest('GET', '/articles')
|
||||||
|
->withQueryParams(['page' => ['offset' => '0']])
|
||||||
|
);
|
||||||
|
|
||||||
public function test_prev_pagination_link_is_not_included_on_last_page()
|
$links = json_decode($response->getBody(), true)['links'] ?? null;
|
||||||
{
|
|
||||||
$this->markTestIncomplete();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_pagination_links_retain_other_query_parameters()
|
$this->assertNull($links['prev'] ?? null);
|
||||||
{
|
|
||||||
$this->markTestIncomplete();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,13 @@
|
||||||
|
|
||||||
namespace Tobyz\Tests\JsonApiServer\specification;
|
namespace Tobyz\Tests\JsonApiServer\specification;
|
||||||
|
|
||||||
|
use Tobyz\JsonApiServer\Exception\BadRequestException;
|
||||||
use Tobyz\JsonApiServer\JsonApi;
|
use Tobyz\JsonApiServer\JsonApi;
|
||||||
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
|
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
|
||||||
use Tobyz\Tests\JsonApiServer\MockAdapter;
|
use Tobyz\Tests\JsonApiServer\MockAdapter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see https://jsonapi.org/format/#query-parameters
|
* @see https://jsonapi.org/format/1.1/#query-parameters
|
||||||
*/
|
*/
|
||||||
class QueryParametersTest extends AbstractTestCase
|
class QueryParametersTest extends AbstractTestCase
|
||||||
{
|
{
|
||||||
|
|
@ -25,20 +26,29 @@ class QueryParametersTest extends AbstractTestCase
|
||||||
*/
|
*/
|
||||||
private $api;
|
private $api;
|
||||||
|
|
||||||
/**
|
|
||||||
* @var MockAdapter
|
|
||||||
*/
|
|
||||||
private $adapter;
|
|
||||||
|
|
||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
$this->api = new JsonApi('http://example.com');
|
$this->api = new JsonApi('http://example.com');
|
||||||
|
$this->api->resourceType('users', new MockAdapter());
|
||||||
$this->adapter = new MockAdapter();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_bad_request_error_if_unknown_query_parameters()
|
public function test_bad_request_error_if_unknown_query_parameters()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$request = $this->buildRequest('GET', '/users/1')
|
||||||
|
->withQueryParams(['unknown' => 'value']);
|
||||||
|
|
||||||
|
$this->expectException(BadRequestException::class);
|
||||||
|
|
||||||
|
$this->api->handle($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_supports_custom_query_parameters()
|
||||||
|
{
|
||||||
|
$request = $this->buildRequest('GET', '/users/1')
|
||||||
|
->withQueryParams(['camelCase' => 'value']);
|
||||||
|
|
||||||
|
$response = $this->api->handle($request);
|
||||||
|
|
||||||
|
$this->assertEquals(200, $response->getStatusCode());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,12 @@
|
||||||
namespace Tobyz\Tests\JsonApiServer\specification;
|
namespace Tobyz\Tests\JsonApiServer\specification;
|
||||||
|
|
||||||
use Tobyz\JsonApiServer\JsonApi;
|
use Tobyz\JsonApiServer\JsonApi;
|
||||||
|
use Tobyz\JsonApiServer\Schema\Type;
|
||||||
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
|
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
|
||||||
use Tobyz\Tests\JsonApiServer\MockAdapter;
|
use Tobyz\Tests\JsonApiServer\MockAdapter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see https://jsonapi.org/format/1.0/#fetching-sparse-fieldsets
|
* @see https://jsonapi.org/format/1.1/#fetching-sparse-fieldsets
|
||||||
*/
|
*/
|
||||||
class SparseFieldsetsTest extends AbstractTestCase
|
class SparseFieldsetsTest extends AbstractTestCase
|
||||||
{
|
{
|
||||||
|
|
@ -25,40 +26,56 @@ class SparseFieldsetsTest extends AbstractTestCase
|
||||||
*/
|
*/
|
||||||
private $api;
|
private $api;
|
||||||
|
|
||||||
/**
|
|
||||||
* @var MockAdapter
|
|
||||||
*/
|
|
||||||
private $adapter;
|
|
||||||
|
|
||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
$this->api = new JsonApi('http://example.com');
|
$this->api = new JsonApi('http://example.com');
|
||||||
|
|
||||||
$this->adapter = new MockAdapter();
|
$articlesAdapter = new MockAdapter([
|
||||||
|
'1' => (object) [
|
||||||
|
'id' => '1',
|
||||||
|
'title' => 'foo',
|
||||||
|
'body' => 'bar',
|
||||||
|
'user' => (object) [
|
||||||
|
'id' => '1',
|
||||||
|
'firstName' => 'Toby',
|
||||||
|
'lastName' => 'Zerner',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->api->resourceType('articles', $articlesAdapter, function (Type $type) {
|
||||||
|
$type->attribute('title');
|
||||||
|
$type->attribute('body');
|
||||||
|
$type->hasOne('user')->includable();
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->api->resourceType('users', new MockAdapter(), function (Type $type) {
|
||||||
|
$type->attribute('firstName');
|
||||||
|
$type->attribute('lastName');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_can_request_sparse_fieldsets_for_a_type()
|
public function test_can_request_sparse_fieldsets()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$request = $this->api->handle(
|
||||||
}
|
$this->buildRequest('GET', '/articles/1')
|
||||||
|
->withQueryParams([
|
||||||
|
'include' => 'user',
|
||||||
|
'fields' => [
|
||||||
|
'articles' => 'title,user',
|
||||||
|
'users' => 'firstName',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
public function test_can_request_sparse_fieldsets_for_multiple_types()
|
$document = json_decode($request->getBody(), true);
|
||||||
{
|
|
||||||
$this->markTestIncomplete();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_can_request_sparse_fieldsets_on_resource_collections()
|
$article = $document['data']['attributes'] ?? [];
|
||||||
{
|
$user = $document['included'][0]['attributes'] ?? [];
|
||||||
$this->markTestIncomplete();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_can_request_sparse_fieldsets_on_create()
|
$this->assertArrayHasKey('title', $article);
|
||||||
{
|
$this->assertArrayNotHasKey('body', $article);
|
||||||
$this->markTestIncomplete();
|
$this->assertArrayHasKey('firstName', $user);
|
||||||
}
|
$this->assertArrayNotHasKey('lastName', $user);
|
||||||
|
|
||||||
public function test_can_request_sparse_fieldsets_on_update()
|
|
||||||
{
|
|
||||||
$this->markTestIncomplete();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,16 @@
|
||||||
|
|
||||||
namespace Tobyz\Tests\JsonApiServer\specification;
|
namespace Tobyz\Tests\JsonApiServer\specification;
|
||||||
|
|
||||||
|
use Tobyz\JsonApiServer\Exception\BadRequestException;
|
||||||
|
use Tobyz\JsonApiServer\Exception\ConflictException;
|
||||||
|
use Tobyz\JsonApiServer\Exception\ResourceNotFoundException;
|
||||||
use Tobyz\JsonApiServer\JsonApi;
|
use Tobyz\JsonApiServer\JsonApi;
|
||||||
|
use Tobyz\JsonApiServer\Schema\Type;
|
||||||
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
|
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
|
||||||
use Tobyz\Tests\JsonApiServer\MockAdapter;
|
use Tobyz\Tests\JsonApiServer\MockAdapter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see https://jsonapi.org/format/1.0/#crud-updating
|
* @see https://jsonapi.org/format/1.1/#crud-updating
|
||||||
*/
|
*/
|
||||||
class UpdatingResourcesTest extends AbstractTestCase
|
class UpdatingResourcesTest extends AbstractTestCase
|
||||||
{
|
{
|
||||||
|
|
@ -25,60 +29,122 @@ class UpdatingResourcesTest extends AbstractTestCase
|
||||||
*/
|
*/
|
||||||
private $api;
|
private $api;
|
||||||
|
|
||||||
/**
|
|
||||||
* @var MockAdapter
|
|
||||||
*/
|
|
||||||
private $adapter;
|
|
||||||
|
|
||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
$this->api = new JsonApi('http://example.com');
|
$this->api = new JsonApi('http://example.com');
|
||||||
|
|
||||||
$this->adapter = new MockAdapter();
|
$adapter = new MockAdapter([
|
||||||
|
'1' => (object) ['id' => '1', 'name' => 'initial'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->api->resourceType('users', $adapter, function (Type $type) {
|
||||||
|
$type->updatable();
|
||||||
|
$type->attribute('name')->writable();
|
||||||
|
$type->hasOne('pet')->writable();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_bad_request_error_if_body_does_not_contain_data_type_and_id()
|
public function test_bad_request_error_if_body_does_not_contain_data_type_and_id()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$this->expectException(BadRequestException::class);
|
||||||
}
|
|
||||||
|
|
||||||
public function test_only_included_attributes_are_processed()
|
$this->api->handle(
|
||||||
{
|
$this->buildRequest('PATCH', '/users/1')
|
||||||
$this->markTestIncomplete();
|
->withParsedBody([
|
||||||
}
|
'data' => [],
|
||||||
|
])
|
||||||
public function test_only_included_relationships_are_processed()
|
);
|
||||||
{
|
|
||||||
$this->markTestIncomplete();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_bad_request_error_if_relationship_does_not_contain_data()
|
public function test_bad_request_error_if_relationship_does_not_contain_data()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$this->expectException(BadRequestException::class);
|
||||||
|
|
||||||
|
$this->api->handle(
|
||||||
|
$this->buildRequest('PATCH', '/users/1')
|
||||||
|
->withParsedBody([
|
||||||
|
'data' => [
|
||||||
|
'type' => 'users',
|
||||||
|
'id' => '1',
|
||||||
|
'relationships' => [
|
||||||
|
'pet' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_ok_response_if_resource_successfully_updated()
|
public function test_ok_response_with_updated_data_if_resource_successfully_updated()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$response = $this->api->handle(
|
||||||
}
|
$this->buildRequest('PATCH', '/users/1')
|
||||||
|
->withParsedBody([
|
||||||
|
'data' => [
|
||||||
|
'type' => 'users',
|
||||||
|
'id' => '1',
|
||||||
|
'attributes' => [
|
||||||
|
'name' => 'updated'
|
||||||
|
],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
public function test_ok_response_includes_updated_data()
|
$document = json_decode($response->getBody(), true);
|
||||||
{
|
|
||||||
$this->markTestIncomplete();
|
$this->assertEquals(200, $response->getStatusCode());
|
||||||
|
$this->assertEquals('updated', $document['data']['attributes']['name'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_not_found_error_if_resource_does_not_exist()
|
public function test_not_found_error_if_resource_does_not_exist()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$this->expectException(ResourceNotFoundException::class);
|
||||||
|
|
||||||
|
$this->api->handle(
|
||||||
|
$this->buildRequest('PATCH', '/users/404')
|
||||||
|
->withParsedBody([
|
||||||
|
'data' => [
|
||||||
|
'type' => 'users',
|
||||||
|
'id' => '404',
|
||||||
|
'attributes' => [
|
||||||
|
'name' => 'bob',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_not_found_error_if_references_resource_that_does_not_exist()
|
public function test_not_found_error_if_references_resource_that_does_not_exist()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$this->expectException(ResourceNotFoundException::class);
|
||||||
|
|
||||||
|
$this->api->handle(
|
||||||
|
$this->buildRequest('PATCH', '/users/1')
|
||||||
|
->withParsedBody([
|
||||||
|
'data' => [
|
||||||
|
'type' => 'users',
|
||||||
|
'id' => '1',
|
||||||
|
'relationships' => [
|
||||||
|
'pet' => [
|
||||||
|
'data' => ['type' => 'pets', 'id' => '1'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_conflict_error_if_type_and_id_does_not_match_endpoint()
|
public function test_conflict_error_if_type_and_id_does_not_match_endpoint()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$this->expectException(ConflictException::class);
|
||||||
|
|
||||||
|
$this->api->handle(
|
||||||
|
$this->buildRequest('PATCH', '/users/1')
|
||||||
|
->withParsedBody([
|
||||||
|
'data' => [
|
||||||
|
'type' => 'pets',
|
||||||
|
'id' => '1',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
<?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 Tobyz\Tests\JsonApiServer\unit\Http;
|
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use Tobyz\JsonApiServer\Http\MediaTypes;
|
|
||||||
|
|
||||||
class MediaTypesTest extends TestCase
|
|
||||||
{
|
|
||||||
public function test_contains_on_exact_match()
|
|
||||||
{
|
|
||||||
$header = new MediaTypes('application/json');
|
|
||||||
|
|
||||||
$this->assertTrue(
|
|
||||||
$header->containsExactly('application/json')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_contains_does_not_match_with_extra_parameters()
|
|
||||||
{
|
|
||||||
$header = new MediaTypes('application/json; profile=foo');
|
|
||||||
|
|
||||||
$this->assertFalse(
|
|
||||||
$header->containsExactly('application/json')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_contains_matches_when_only_weight_is_provided()
|
|
||||||
{
|
|
||||||
$header = new MediaTypes('application/json; q=0.8');
|
|
||||||
|
|
||||||
$this->assertTrue(
|
|
||||||
$header->containsExactly('application/json')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_contains_does_not_match_with_extra_parameters_before_weight()
|
|
||||||
{
|
|
||||||
$header = new MediaTypes('application/json; profile=foo; q=0.8');
|
|
||||||
|
|
||||||
$this->assertFalse(
|
|
||||||
$header->containsExactly('application/json')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_contains_matches_with_extra_parameters_after_weight()
|
|
||||||
{
|
|
||||||
$header = new MediaTypes('application/json; q=0.8; profile=foo');
|
|
||||||
|
|
||||||
$this->assertTrue(
|
|
||||||
$header->containsExactly('application/json')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_contains_matches_when_one_of_multiple_media_types_is_valid()
|
|
||||||
{
|
|
||||||
$header = new MediaTypes('application/json; profile=foo, application/json; q=0.6');
|
|
||||||
|
|
||||||
$this->assertTrue(
|
|
||||||
$header->containsExactly('application/json')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -9,19 +9,56 @@
|
||||||
* file that was distributed with this source code.
|
* file that was distributed with this source code.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace Tobyz\Tests\JsonApiServer\unit\Http;
|
namespace Tobyz\Tests\JsonApiServer\unit;
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
use Exception;
|
||||||
|
use Tobyz\JsonApiServer\JsonApi;
|
||||||
|
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
|
||||||
|
use Tobyz\Tests\JsonApiServer\MockException;
|
||||||
|
|
||||||
class JsonApiTest extends TestCase
|
class JsonApiTest extends AbstractTestCase
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var JsonApi
|
||||||
|
*/
|
||||||
|
private $api;
|
||||||
|
|
||||||
|
public function setUp(): void
|
||||||
|
{
|
||||||
|
$this->api = new JsonApi('http://example.com');
|
||||||
|
}
|
||||||
|
|
||||||
public function test_error_converts_error_provider_to_json_api_response()
|
public function test_error_converts_error_provider_to_json_api_response()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$response = $this->api->error(
|
||||||
|
new MockException()
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(400, $response->getStatusCode());
|
||||||
|
$this->assertJsonApiDocumentSubset([
|
||||||
|
'errors' => [
|
||||||
|
[
|
||||||
|
'title' => 'Mock Error',
|
||||||
|
'status' => '400',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], $response->getBody());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_error_converts_non_error_provider_to_internal_server_error()
|
public function test_error_converts_non_error_provider_to_internal_server_error()
|
||||||
{
|
{
|
||||||
$this->markTestIncomplete();
|
$response = $this->api->error(
|
||||||
|
new Exception()
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(500, $response->getStatusCode());
|
||||||
|
$this->assertJsonApiDocumentSubset([
|
||||||
|
'errors' => [
|
||||||
|
[
|
||||||
|
'title' => 'Internal Server Error',
|
||||||
|
'status' => '500',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], $response->getBody());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class TypeTest extends TestCase
|
||||||
{
|
{
|
||||||
public function test_returns_an_existing_field_with_the_same_name_of_the_same_type()
|
public function test_returns_an_existing_field_with_the_same_name_of_the_same_type()
|
||||||
{
|
{
|
||||||
$type = new Type;
|
$type = new Type();
|
||||||
|
|
||||||
$attribute = $type->attribute('dogs');
|
$attribute = $type->attribute('dogs');
|
||||||
$attributeAgain = $type->attribute('dogs');
|
$attributeAgain = $type->attribute('dogs');
|
||||||
|
|
@ -30,7 +30,7 @@ class TypeTest extends TestCase
|
||||||
|
|
||||||
public function test_overwrites_an_existing_field_with_the_same_name_of_a_different_type()
|
public function test_overwrites_an_existing_field_with_the_same_name_of_a_different_type()
|
||||||
{
|
{
|
||||||
$type = new Type;
|
$type = new Type();
|
||||||
|
|
||||||
$attribute = $type->attribute('dogs');
|
$attribute = $type->attribute('dogs');
|
||||||
$hasOne = $type->hasOne('dogs');
|
$hasOne = $type->hasOne('dogs');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue