This commit is contained in:
Toby Zerner 2019-08-16 23:31:58 +02:00
parent 40776bc6ab
commit 46d5ebd2a9
53 changed files with 3020 additions and 1016 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
composer.lock composer.lock
vendor vendor
.phpunit.result.cache

View File

@ -12,27 +12,27 @@ composer require tobyz/json-api-server
``` ```
```php ```php
use Tobyz\JsonApiServer\Api;
use Tobyz\JsonApiServer\Adapter\EloquentAdapter; use Tobyz\JsonApiServer\Adapter\EloquentAdapter;
use Tobyz\JsonApiServer\Schema\Builder; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Schema\Type;
$api = new Api('http://example.com/api'); $api = new JsonApi('http://example.com/api');
$api->resource('articles', new EloquentAdapter(new Article), function (Builder $schema) { $api->resource('articles', new EloquentAdapter(Article::class), function (Type $type) {
$schema->attribute('title'); $type->attribute('title');
$schema->hasOne('author', 'people'); $type->hasOne('author')->type('people');
$schema->hasMany('comments'); $type->hasMany('comments');
}); });
$api->resource('people', new EloquentAdapter(new User), function (Builder $schema) { $api->resource('people', new EloquentAdapter(User::class), function (Type $type) {
$schema->attribute('firstName'); $type->attribute('firstName');
$schema->attribute('lastName'); $type->attribute('lastName');
$schema->attribute('twitter'); $type->attribute('twitter');
}); });
$api->resource('comments', new EloquentAdapter(new Comment), function (Builder $schema) { $api->resource('comments', new EloquentAdapter(Comment::class), function (Type $type) {
$schema->attribute('body'); $type->attribute('body');
$schema->hasOne('author', 'people'); $type->hasOne('author')->type('people');
}); });
/** @var Psr\Http\Message\ServerRequestInterface $request */ /** @var Psr\Http\Message\ServerRequestInterface $request */
@ -60,9 +60,9 @@ The schema definition is extremely powerful and lets you easily apply [permissio
### Handling Requests ### Handling Requests
```php ```php
use Tobyz\JsonApiServer\Api; use Tobyz\JsonApiServer\JsonApi;
$api = new Api('http://example.com/api'); $api = new JsonApi('http://example.com/api');
try { try {
$response = $api->handle($request); $response = $api->handle($request);
@ -71,26 +71,26 @@ try {
} }
``` ```
`Tobyz\JsonApiServer\Api` is a [PSR-15 Request Handler](https://www.php-fig.org/psr/psr-15/). Instantiate it with your API's base URL. Convert your framework's request object into a [PSR-7 Request](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface) implementation, then let the `Api` handler take it from there. Catch any exceptions and give them back to `Api` if you want a JSON:API error response. `Tobyz\JsonApiServer\JsonApi` is a [PSR-15 Request Handler](https://www.php-fig.org/psr/psr-15/). Instantiate it with your API's base URL. Convert your framework's request object into a [PSR-7 Request](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface) implementation, then let the `JsonApi` handler take it from there. Catch any exceptions and give them back to `JsonApi` if you want a JSON:API error response.
### Defining Resources ### Defining Resources
Define your API's resources using the `resource` method. The first argument is the [resource type](https://jsonapi.org/format/#document-resource-object-identification). The second is an instance of `Tobyz\JsonApiServer\Adapter\AdapterInterface` which will allow the handler to interact with your models. The third is a closure in which you'll build the schema for your resource. Define your API's resources using the `resource` method. The first argument is the [resource type](https://jsonapi.org/format/#document-resource-object-identification). The second is an instance of `Tobyz\JsonApiServer\Adapter\AdapterInterface` which will allow the handler to interact with your models. The third is a closure in which you'll build the schema for your resource.
```php ```php
use Tobyz\JsonApiServer\Schema\Builder; use Tobyz\JsonApiServer\Schema\Type;
$api->resource('comments', $adapter, function (Builder $schema) { $api->resource('comments', $adapter, function (Schema $schema) {
// define your schema // define your schema
}); });
``` ```
We provide an `EloquentAdapter` to hook your resources up with Laravel [Eloquent](https://laravel.com/docs/5.8/eloquent) models. Set it up with an instance of the model that your resource represents. You can [implement your own adapter](https://github.com/tobyz/json-api-server/blob/master/src/Adapter/AdapterInterface.php) if you use a different ORM. We provide an `EloquentAdapter` to hook your resources up with Laravel [Eloquent](https://laravel.com/docs/5.8/eloquent) models. Set it up with the name of the model that your resource represents. You can [implement your own adapter](https://github.com/tobyz/json-api-server/blob/master/src/Adapter/AdapterInterface.php) if you use a different ORM.
```php ```php
use Tobyz\JsonApiServer\Adapter\EloquentAdapter; use Tobyz\JsonApiServer\Adapter\EloquentAdapter;
$adapter = new EloquentAdapter(new User); $adapter = new EloquentAdapter(User::class);
``` ```
### Attributes ### Attributes
@ -116,10 +116,10 @@ $schema->hasOne('user');
$schema->hasMany('comments'); $schema->hasMany('comments');
``` ```
By default the [resource type](https://jsonapi.org/format/#document-resource-object-identification) that the relationship corresponds to will be derived from the relationship name. In the example above, the `user` relationship would correspond to the `users` resource type, while `comments` would correspond to `comments`. If you'd like to use a different resource type, provide it as a second argument: By default the [resource type](https://jsonapi.org/format/#document-resource-object-identification) that the relationship corresponds to will be derived from the relationship name. In the example above, the `user` relationship would correspond to the `users` resource type, while `comments` would correspond to `comments`. If you'd like to use a different resource type, call the `type` method:
```php ```php
$schema->hasOne('author', 'people'); $schema->hasOne('author')->type('people');
``` ```
Like attributes, the relationship will automatically read and write to the relation on your model with the same name. If you'd like it to correspond to a different relation, provide it as a third argument. Like attributes, the relationship will automatically read and write to the relation on your model with the same name. If you'd like it to correspond to a different relation, provide it as a third argument.
@ -173,14 +173,14 @@ $schema->hasOne('user')
#### Polymorphic Relationships #### Polymorphic Relationships
Define polymorphic relationships on your resource using the `morphOne` and `morphMany` methods: Define a relationship as polymorphic using the `polymorphic` method:
```php ```php
$schema->morphOne('commentable'); $schema->hasOne('commentable')->polymorphic();
$schema->morphMany('taggable'); $schema->hasMany('taggable')->polymorphic();
``` ```
Polymorphic relationships do not accept a second argument for the resource type, because it will be automatically derived from each related resource. Nested includes cannot be requested on these relationships. This will mean that the resource type associated with the relationship will be derived from the model of each related resource. Consequently, nested includes cannot be requested on these relationships.
### Getters ### Getters
@ -257,7 +257,6 @@ $schema->attribute('email')
You can provide a default value for a field to be used when creating a new resource if there is no value provided by the consumer. Pass a value or a closure to the `default` method: You can provide a default value for a field to be used when creating a new resource if there is no value provided by the consumer. Pass a value or a closure to the `default` method:
```php ```php
$schema->attribute('joinedAt') $schema->attribute('joinedAt')
->default(new DateTime); ->default(new DateTime);
@ -299,7 +298,7 @@ $schema->hasMany('groups')
You can easily use Laravel's [Validation](https://laravel.com/docs/5.8/validation) component for field validation with the `rules` function: You can easily use Laravel's [Validation](https://laravel.com/docs/5.8/validation) component for field validation with the `rules` function:
```php ```php
use Tobyz\JsonApi\Server\Laravel\rules; use Tobyz\JsonApiServer\Laravel\rules;
$schema->attribute('username') $schema->attribute('username')
->validate(rules('required', 'min:3', 'max:30')); ->validate(rules('required', 'min:3', 'max:30'));
@ -312,7 +311,7 @@ Use the `set` method to define custom mutation logic for your field, instead of
```php ```php
$schema->attribute('firstName') $schema->attribute('firstName')
->set(function ($model, $value, $request) { ->set(function ($model, $value, $request) {
return $model->first_name = strtolower($value); $model->first_name = strtolower($value);
}); });
``` ```
@ -396,6 +395,14 @@ You can set a default sort string to be used when the consumer has not supplied
$schema->defaultSort('-updatedAt,-createdAt'); $schema->defaultSort('-updatedAt,-createdAt');
``` ```
To define sortable criteria that does not correspond to an attribute, use the `sort` method:
```php
$schema->sort('relevance', function ($query, $direction, $request) {
$query->orderBy('relevance', $direction);
});
```
### Pagination ### Pagination
By default, resource listings are automatically [paginated](https://jsonapi.org/format/#fetching-pagination) with 20 records per page. You can change this limit using the `paginate` method on the schema builder, or you can remove it by passing `null`: By default, resource listings are automatically [paginated](https://jsonapi.org/format/#fetching-pagination) with 20 records per page. You can change this limit using the `paginate` method on the schema builder, or you can remove it by passing `null`:
@ -434,9 +441,11 @@ $schema->meta('requestTime', function ($request) {
### Creating Resources ### Creating Resources
By default, resources are not [creatable](https://jsonapi.org/format/#crud-creating) (i.e. `POST` requests will return `403 Forbidden`). You can allow them to be created using the `creatable` and `notCreatable` methods on the schema builder: By default, resources are not [creatable](https://jsonapi.org/format/#crud-creating) (i.e. `POST` requests will return `403 Forbidden`). You can allow them to be created using the `creatable` and `notCreatable` methods on the schema builder. Pass a closure that returns `true` if the resource should be creatable, or no value to have it always creatable.
```php ```php
$schema->creatable();
$schema->creatable(function ($request) { $schema->creatable(function ($request) {
return $request->getAttribute('isAdmin'); return $request->getAttribute('isAdmin');
}); });
@ -444,7 +453,7 @@ $schema->creatable(function ($request) {
#### Customizing the Model #### Customizing the Model
When creating a resource, an empty model is supplied by the adapter. You may wish to provide a custom model in special circumstances. You can do so using the `create` 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 `create` method:
```php ```php
$schema->create(function ($request) { $schema->create(function ($request) {
@ -457,6 +466,8 @@ $schema->create(function ($request) {
By default, resources are not [updatable](https://jsonapi.org/format/#crud-updating) (i.e. `PATCH` requests will return `403 Forbidden`). You can allow them to be updated using the `updatable` and `notUpdatable` methods on the schema builder: By default, resources are not [updatable](https://jsonapi.org/format/#crud-updating) (i.e. `PATCH` requests will return `403 Forbidden`). You can allow them to be updated using the `updatable` and `notUpdatable` methods on the schema builder:
```php ```php
$schema->updatable();
$schema->updatable(function ($request) { $schema->updatable(function ($request) {
return $request->getAttribute('isAdmin'); return $request->getAttribute('isAdmin');
}); });
@ -467,6 +478,8 @@ $schema->updatable(function ($request) {
By default, resources are not [deletable](https://jsonapi.org/format/#crud-deleting) (i.e. `DELETE` requests will return `403 Forbidden`). You can allow them to be deleted using the `deletable` and `notDeletable` methods on the schema builder: By default, resources are not [deletable](https://jsonapi.org/format/#crud-deleting) (i.e. `DELETE` requests will return `403 Forbidden`). You can allow them to be deleted using the `deletable` and `notDeletable` methods on the schema builder:
```php ```php
$schema->deletable();
$schema->deletable(function ($request) { $schema->deletable(function ($request) {
return $request->getAttribute('isAdmin'); return $request->getAttribute('isAdmin');
}); });

View File

@ -18,7 +18,8 @@
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Tobyz\\JsonApiServer\\": "src/" "Tobyz\\JsonApiServer\\": "src/"
} },
"files": ["src/functions.php"]
}, },
"autoload-dev": { "autoload-dev": {
"psr-4": { "psr-4": {
@ -26,7 +27,9 @@
} }
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^7.4" "dms/phpunit-arraysubset-asserts": "^0.1.0",
"helmich/phpunit-json-assert": "^3.0",
"phpunit/phpunit": "^8.0"
}, },
"config": { "config": {
"sort-packages": true "sort-packages": true

View File

@ -9,47 +9,231 @@ use Tobyz\JsonApiServer\Schema\Relationship;
interface AdapterInterface interface AdapterInterface
{ {
public function handles($model); /**
* Create a new query builder instance.
public function create(); *
* This is used as a basis for building the queries which show a resource
* or list a resource index. It will be passed around through the relevant
* scopes, filters, and sorting methods before finally being passed into
* the `find` or `get` methods.
*
* @return mixed
*/
public function query(); public function query();
public function find($query, $id); /**
* 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;
/**
* Manipulate the query to only include resources with a certain attribute
* value.
*
* @param $query
* @param Attribute $attribute
* @param $value
* @return mixed
*/
public function filterByAttribute($query, Attribute $attribute, $value): void;
/**
* Manipulate the query to only include resources with any one of the given
* resource IDs in a has-one relationship.
*
* @param $query
* @param HasOne $relationship
* @param array $ids
* @return mixed
*/
public function filterByHasOne($query, HasOne $relationship, array $ids): void;
/**
* Manipulate the query to only include resources with any one of the given
* resource IDs in a has-many relationship.
*
* @param $query
* @param HasMany $relationship
* @param array $ids
* @return mixed
*/
public function filterByHasMany($query, HasMany $relationship, array $ids): void;
/**
* 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;
/**
* Manipulate the query to only include a certain number of results,
* starting from the given offset.
*
* @param $query
* @param int $limit
* @param int $offset
* @return mixed
*/
public function paginate($query, int $limit, int $offset): void;
/**
* Find a single resource by ID from the query.
*
* @param $query
* @param string $id
* @return mixed
*/
public function find($query, string $id);
/**
* 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.
*
* @param $query
* @return 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 create();
/**
* 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.
*
* @param $model
* @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.
*
* @param $model
* @param HasOne $relationship
* @return mixed|null
*/
public function getHasOne($model, HasOne $relationship); public function getHasOne($model, HasOne $relationship);
/**
* Get the ID of the related resource for a has-one relationship.
*
* @param $model
* @param HasOne $relationship
* @return mixed|null
*/
public function getHasOneId($model, HasOne $relationship): ?string;
/**
* Get a list of models for a has-many relationship for the model.
*
* @param $model
* @param HasMany $relationship
* @return array
*/
public function getHasMany($model, HasMany $relationship): array; public function getHasMany($model, HasMany $relationship): array;
public function applyAttribute($model, Attribute $attribute, $value); /**
* 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 applyHasOne($model, HasOne $relationship, $related); /**
* 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 save($model); /**
* Save the model.
*
* @param $model
* @return mixed
*/
public function save($model): void;
public function saveHasMany($model, HasMany $relationship, array $related); /**
* 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 delete($model); /**
* Delete the model.
*
* @param $model
* @return mixed
*/
public function delete($model): void;
public function filterByIds($query, array $ids); /**
* Load information about related resources onto a collection of models.
*
* @param array $models
* @param array $relationships
* @return mixed
*/
public function load(array $models, array $relationships): void;
public function filterByAttribute($query, Attribute $attribute, $value); /**
* Load information about the IDs of related resources onto a collection
public function filterByHasOne($query, HasOne $relationship, array $ids); * of models.
*
public function filterByHasMany($query, HasMany $relationship, array $ids); * @param array $models
* @param Relationship $relationship
public function sortByAttribute($query, Attribute $attribute, string $direction); * @return mixed
*/
public function paginate($query, int $limit, int $offset); public function loadIds(array $models, Relationship $relationship): void;
public function load(array $models, array $relationships);
public function loadIds(array $models, Relationship $relationship);
} }

View File

@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use InvalidArgumentException;
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;
@ -24,11 +25,11 @@ class EloquentAdapter implements AdapterInterface
$this->model = is_string($model) ? new $model : $model; $this->model = is_string($model) ? new $model : $model;
if (! $this->model instanceof Model) { if (! $this->model instanceof Model) {
throw new \InvalidArgumentException('Model must be an instance of '.Model::class); throw new InvalidArgumentException('Model must be an instance of '.Model::class);
} }
} }
public function handles($model) public function represents($model): bool
{ {
return $model instanceof $this->model; return $model instanceof $this->model;
} }
@ -43,7 +44,7 @@ class EloquentAdapter implements AdapterInterface
return $this->model->query(); return $this->model->query();
} }
public function find($query, $id) public function find($query, string $id)
{ {
return $query->find($id); return $query->find($id);
} }
@ -63,64 +64,63 @@ class EloquentAdapter implements AdapterInterface
return $model->getKey(); return $model->getKey();
} }
public function getAttribute($model, Attribute $field) public function getAttribute($model, Attribute $attribute)
{ {
return $model->{$this->getAttributeProperty($field)}; return $model->{$this->getAttributeProperty($attribute)};
} }
public function getHasOneId($model, HasOne $field) public function getHasOneId($model, HasOne $relationship): ?string
{ {
$relation = $model->{$this->getRelationshipProperty($field)}(); $relation = $this->getRelation($model, $relationship);
// If this is a belongs-to relation, we can simply return the value of
// the foreign key on the model.
if ($relation instanceof BelongsTo) { if ($relation instanceof BelongsTo) {
$related = $relation->getRelated(); return $model->{$relation->getForeignKeyName()};
$key = $model->{$relation->getForeignKeyName()};
if ($key) {
return $related->forceFill([$related->getKeyName() => $key]);
}
return null;
} }
return $model->{$this->getRelationshipProperty($field)}; $related = $this->getRelationValue($model, $relationship);
return $related ? $related->getKey() : null;
} }
public function getHasOne($model, HasOne $field) public function getHasOne($model, HasOne $relationship)
{ {
return $model->{$this->getRelationshipProperty($field)}; return $this->getRelationValue($model, $relationship);
} }
public function getHasMany($model, HasMany $field): array public function getHasMany($model, HasMany $relationship): array
{ {
$collection = $model->{$this->getRelationshipProperty($field)}; $collection = $this->getRelationValue($model, $relationship);
return $collection ? $collection->all() : []; return $collection ? $collection->all() : [];
} }
public function applyAttribute($model, Attribute $field, $value) public function setAttribute($model, Attribute $attribute, $value): void
{ {
$model->{$this->getAttributeProperty($field)} = $value; $model->{$this->getAttributeProperty($attribute)} = $value;
} }
public function applyHasOne($model, HasOne $field, $related) public function setHasOne($model, HasOne $relationship, $related): void
{ {
$model->{$this->getRelationshipProperty($field)}()->associate($related); $this->getRelation($model, $relationship)->associate($related);
} }
public function save($model) public function save($model): void
{ {
$model->save(); $model->save();
} }
public function saveHasMany($model, HasMany $field, array $related) public function saveHasMany($model, HasMany $relationship, array $related): void
{ {
$model->{$this->getRelationshipProperty($field)}()->sync(Collection::make($related)); $this->getRelation($model, $relationship)->sync(new Collection($related));
} }
public function delete($model) public function delete($model): void
{ {
// For models that use the SoftDeletes trait, deleting the resource from
// the API implies permanent deletion. Non-permanent deletion should be
// achieved by manipulating a resource attribute.
if (method_exists($model, 'forceDelete')) { if (method_exists($model, 'forceDelete')) {
$model->forceDelete(); $model->forceDelete();
} else { } else {
@ -128,17 +128,18 @@ class EloquentAdapter implements AdapterInterface
} }
} }
public function filterByIds($query, array $ids) public function filterByIds($query, array $ids): void
{ {
$key = $query->getModel()->getQualifiedKeyName(); $key = $query->getModel()->getQualifiedKeyName();
$query->whereIn($key, $ids); $query->whereIn($key, $ids);
} }
public function filterByAttribute($query, Attribute $field, $value) public function filterByAttribute($query, Attribute $attribute, $value): void
{ {
$property = $this->getAttributeProperty($field); $property = $this->getAttributeProperty($attribute);
// TODO: extract this into non-adapter territory
if (preg_match('/(.+)\.\.(.+)/', $value, $matches)) { if (preg_match('/(.+)\.\.(.+)/', $value, $matches)) {
if ($matches[1] !== '*') { if ($matches[1] !== '*') {
$query->where($property, '>=', $matches[1]); $query->where($property, '>=', $matches[1]);
@ -161,19 +162,17 @@ class EloquentAdapter implements AdapterInterface
$query->where($property, $value); $query->where($property, $value);
} }
public function filterByHasOne($query, HasOne $field, array $ids) public function filterByHasOne($query, HasOne $relationship, array $ids): void
{ {
$relation = $query->getModel()->{$this->getRelationshipProperty($field)}(); $relation = $this->getRelation($query->getModel(), $relationship);
$foreignKey = $relation->getQualifiedForeignKeyName(); $query->whereIn($relation->getQualifiedForeignKeyName(), $ids);
$query->whereIn($foreignKey, $ids);
} }
public function filterByHasMany($query, HasMany $field, array $ids) public function filterByHasMany($query, HasMany $relationship, array $ids): void
{ {
$property = $this->getRelationshipProperty($field); $property = $this->getRelationshipProperty($relationship);
$relation = $query->getModel()->{$property}(); $relation = $this->getRelation($query->getModel(), $relationship);
$relatedKey = $relation->getRelated()->getQualifiedKeyName(); $relatedKey = $relation->getRelated()->getQualifiedKeyName();
$query->whereHas($property, function ($query) use ($relatedKey, $ids) { $query->whereHas($property, function ($query) use ($relatedKey, $ids) {
@ -181,22 +180,22 @@ class EloquentAdapter implements AdapterInterface
}); });
} }
public function sortByAttribute($query, Attribute $field, string $direction) public function sortByAttribute($query, Attribute $field, string $direction): void
{ {
$query->orderBy($this->getAttributeProperty($field), $direction); $query->orderBy($this->getAttributeProperty($field), $direction);
} }
public function paginate($query, int $limit, int $offset) public function paginate($query, int $limit, int $offset): void
{ {
$query->take($limit)->skip($offset); $query->take($limit)->skip($offset);
} }
public function load(array $models, array $trail) public function load(array $models, array $relationships): void
{ {
(new Collection($models))->loadMissing($this->relationshipTrailToPath($trail)); (new Collection($models))->loadMissing($this->getRelationshipPath($relationships));
} }
public function loadIds(array $models, Relationship $relationship) public function loadIds(array $models, Relationship $relationship): void
{ {
if (empty($models)) { if (empty($models)) {
return; return;
@ -219,20 +218,28 @@ class EloquentAdapter implements AdapterInterface
]); ]);
} }
private function getAttributeProperty(Attribute $field) private function getAttributeProperty(Attribute $attribute): string
{ {
return $field->property ?: strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $field->name)); return $attribute->getProperty() ?: strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $attribute->getName()));
} }
private function getRelationshipProperty(Relationship $field) private function getRelationshipProperty(Relationship $relationship): string
{ {
return $field->property ?: $field->name; return $relationship->getProperty() ?: $relationship->getName();
} }
private function relationshipTrailToPath(array $trail) private function getRelationshipPath(array $trail): string
{ {
return implode('.', array_map(function ($relationship) { return implode('.', array_map([$this, 'getRelationshipProperty'], $trail));
return $this->getRelationshipProperty($relationship); }
}, $trail));
private function getRelation($model, Relationship $relationship)
{
return $model->{$this->getRelationshipProperty($relationship)}();
}
private function getRelationValue($model, Relationship $relationship)
{
return $model->{$this->getRelationshipProperty($relationship)};
} }
} }

View File

@ -1,144 +0,0 @@
<?php
namespace Tobyz\JsonApiServer;
use Closure;
use JsonApiPhp\JsonApi;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface;
use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Exception\MethodNotAllowedException;
use Tobyz\JsonApiServer\Exception\NotImplementedException;
use Tobyz\JsonApiServer\Exception\ResourceNotFoundException;
use Tobyz\JsonApiServer\Handler\Concerns\FindsResources;
class Api implements RequestHandlerInterface
{
use FindsResources;
protected $resources = [];
protected $baseUrl;
public function __construct(string $baseUrl)
{
$this->baseUrl = $baseUrl;
}
public function resource(string $type, $adapter, Closure $buildSchema = null): void
{
$this->resources[$type] = new ResourceType($type, $adapter, $buildSchema);
}
public function getResources(): array
{
return $this->resources;
}
public function getResource(string $type): ResourceType
{
if (! isset($this->resources[$type])) {
throw new ResourceNotFoundException($type);
}
return $this->resources[$type];
}
public function handle(Request $request): Response
{
$path = $this->stripBasePath(
$request->getUri()->getPath()
);
$segments = explode('/', trim($path, '/'));
$count = count($segments);
$resource = $this->getResource($segments[0]);
if ($count === 1) {
switch ($request->getMethod()) {
case 'GET':
return $this->handleWithHandler($request, new Handler\Index($this, $resource));
case 'POST':
return $this->handleWithHandler($request, new Handler\Create($this, $resource));
default:
throw new MethodNotAllowedException;
}
}
$model = $this->findResource($request, $resource, $segments[1]);
if ($count === 2) {
switch ($request->getMethod()) {
case 'PATCH':
return $this->handleWithHandler($request, new Handler\Update($this, $resource, $model));
case 'GET':
return $this->handleWithHandler($request, new Handler\Show($this, $resource, $model));
case 'DELETE':
return $this->handleWithHandler($request, new Handler\Delete($resource, $model));
default:
throw new MethodNotAllowedException;
}
}
if ($count === 3) {
throw new NotImplementedException;
// return $this->handleRelated($request, $resource, $model, $segments[2]);
}
if ($count === 4 && $segments[2] === 'relationships') {
throw new NotImplementedException;
// return $this->handleRelationship($request, $resource, $model, $segments[3]);
}
throw new BadRequestException;
}
private function stripBasePath(string $path): string
{
$basePath = parse_url($this->baseUrl, PHP_URL_PATH);
$len = strlen($basePath);
if (substr($path, 0, $len) === $basePath) {
$path = substr($path, $len + 1);
}
return $path;
}
private function handleWithHandler(Request $request, RequestHandlerInterface $handler)
{
$request = $request->withAttribute('jsonApiHandler', $handler);
return $handler->handle($request);
}
public function error($e)
{
if (! $e instanceof ErrorProviderInterface) {
$e = new Exception\InternalServerErrorException;
}
$errors = $e->getJsonApiErrors();
$status = $e->getJsonApiStatus();
$data = new JsonApi\ErrorDocument(
...$errors
);
return new JsonApiResponse($data, $status);
}
public function getBaseUrl(): string
{
return $this->baseUrl;
}
}

View File

@ -2,10 +2,11 @@
namespace Tobyz\JsonApiServer\Exception; namespace Tobyz\JsonApiServer\Exception;
use DomainException;
use JsonApiPhp\JsonApi\Error; use JsonApiPhp\JsonApi\Error;
use Tobyz\JsonApiServer\ErrorProviderInterface; use Tobyz\JsonApiServer\ErrorProviderInterface;
class BadRequestException extends \DomainException implements ErrorProviderInterface class BadRequestException extends DomainException implements ErrorProviderInterface
{ {
/** /**
* @var string * @var string

View File

@ -2,10 +2,11 @@
namespace Tobyz\JsonApiServer\Exception; namespace Tobyz\JsonApiServer\Exception;
use DomainException;
use JsonApiPhp\JsonApi\Error; use JsonApiPhp\JsonApi\Error;
use Tobyz\JsonApiServer\ErrorProviderInterface; use Tobyz\JsonApiServer\ErrorProviderInterface;
class ForbiddenException extends \DomainException implements ErrorProviderInterface class ForbiddenException extends DomainException implements ErrorProviderInterface
{ {
public function getJsonApiErrors(): array public function getJsonApiErrors(): array
{ {

View File

@ -3,16 +3,17 @@
namespace Tobyz\JsonApiServer\Exception; namespace Tobyz\JsonApiServer\Exception;
use JsonApiPhp\JsonApi\Error; use JsonApiPhp\JsonApi\Error;
use RuntimeException;
use Tobyz\JsonApiServer\ErrorProviderInterface; use Tobyz\JsonApiServer\ErrorProviderInterface;
class InternalServerErrorException extends \RuntimeException implements ErrorProviderInterface class InternalServerErrorException extends RuntimeException implements ErrorProviderInterface
{ {
public function getJsonApiErrors(): array public function getJsonApiErrors(): array
{ {
return [ return [
new Error( new Error(
new Error\Title('Internal Server Error'), new Error\Title('Internal Server Error'),
new Error\Status('500') new Error\Status($this->getJsonApiStatus())
) )
]; ];
} }

View File

@ -2,18 +2,24 @@
namespace Tobyz\JsonApiServer\Exception; namespace Tobyz\JsonApiServer\Exception;
use DomainException as DomainExceptionAlias;
use JsonApiPhp\JsonApi\Error; use JsonApiPhp\JsonApi\Error;
use Tobyz\JsonApiServer\ErrorProviderInterface; use Tobyz\JsonApiServer\ErrorProviderInterface;
class MethodNotAllowedException extends \DomainException implements ErrorProviderInterface class MethodNotAllowedException extends DomainExceptionAlias implements ErrorProviderInterface
{ {
public function getJsonApiErrors(): array public function getJsonApiErrors(): array
{ {
return [ return [
new Error( new Error(
new Error\Title('Method Not Allowed'), new Error\Title('Method Not Allowed'),
new Error\Status('405') new Error\Status($this->getJsonApiStatus())
) )
]; ];
} }
public function getJsonApiStatus(): string
{
return '405';
}
} }

View File

@ -0,0 +1,25 @@
<?php
namespace Tobyz\JsonApiServer\Exception;
use JsonApiPhp\JsonApi\Error;
use RuntimeException;
use Tobyz\JsonApiServer\ErrorProviderInterface;
class NotAcceptableException extends RuntimeException implements ErrorProviderInterface
{
public function getJsonApiErrors(): array
{
return [
new Error(
new Error\Title('Not Acceptable'),
new Error\Status($this->getJsonApiStatus())
)
];
}
public function getJsonApiStatus(): string
{
return '406';
}
}

View File

@ -2,18 +2,24 @@
namespace Tobyz\JsonApiServer\Exception; namespace Tobyz\JsonApiServer\Exception;
use DomainException;
use JsonApiPhp\JsonApi\Error; use JsonApiPhp\JsonApi\Error;
use Tobyz\JsonApiServer\ErrorProviderInterface; use Tobyz\JsonApiServer\ErrorProviderInterface;
class NotImplementedException extends \DomainException implements ErrorProviderInterface class NotImplementedException extends DomainException implements ErrorProviderInterface
{ {
public function getJsonApiErrors(): array public function getJsonApiErrors(): array
{ {
return [ return [
new Error( new Error(
new Error\Title('Not Implemented'), new Error\Title('Not Implemented'),
new Error\Status('501') new Error\Status($this->getJsonApiStatus())
) )
]; ];
} }
public function getJsonApiStatus(): string
{
return '501';
}
} }

View File

@ -3,9 +3,10 @@
namespace Tobyz\JsonApiServer\Exception; namespace Tobyz\JsonApiServer\Exception;
use JsonApiPhp\JsonApi\Error; use JsonApiPhp\JsonApi\Error;
use RuntimeException;
use Tobyz\JsonApiServer\ErrorProviderInterface; use Tobyz\JsonApiServer\ErrorProviderInterface;
class ResourceNotFoundException extends \RuntimeException implements ErrorProviderInterface class ResourceNotFoundException extends RuntimeException implements ErrorProviderInterface
{ {
protected $type; protected $type;
protected $id; protected $id;

View File

@ -2,10 +2,11 @@
namespace Tobyz\JsonApiServer\Exception; namespace Tobyz\JsonApiServer\Exception;
use DomainException;
use JsonApiPhp\JsonApi\Error; use JsonApiPhp\JsonApi\Error;
use Tobyz\JsonApiServer\ErrorProviderInterface; use Tobyz\JsonApiServer\ErrorProviderInterface;
class UnauthorizedException extends \DomainException implements ErrorProviderInterface class UnauthorizedException extends DomainException implements ErrorProviderInterface
{ {
public function getJsonApiErrors(): array public function getJsonApiErrors(): array
{ {

View File

@ -2,10 +2,11 @@
namespace Tobyz\JsonApiServer\Exception; namespace Tobyz\JsonApiServer\Exception;
use DomainException;
use JsonApiPhp\JsonApi\Error; use JsonApiPhp\JsonApi\Error;
use Tobyz\JsonApiServer\ErrorProviderInterface; use Tobyz\JsonApiServer\ErrorProviderInterface;
class UnprocessableEntityException extends \DomainException implements ErrorProviderInterface class UnprocessableEntityException extends DomainException implements ErrorProviderInterface
{ {
private $failures; private $failures;
@ -19,11 +20,19 @@ class UnprocessableEntityException extends \DomainException implements ErrorProv
public function getJsonApiErrors(): array public function getJsonApiErrors(): array
{ {
return array_map(function ($failure) { return array_map(function ($failure) {
return new Error( $members = [
new Error\Status($this->getJsonApiStatus()), new Error\Status($this->getJsonApiStatus()),
new Error\SourcePointer('/data/'.$failure['field']->location.'/'.$failure['field']->name), ];
new Error\Detail($failure['message'])
); if ($field = $failure['field']) {
$members[] = new Error\SourcePointer('/data/'.$field->getLocation().'/'.$field->getName());
}
if ($failure['message']) {
$members[] = new Error\Detail($failure['message']);
}
return new Error(...$members);
}, $this->failures); }, $this->failures);
} }

View File

@ -0,0 +1,25 @@
<?php
namespace Tobyz\JsonApiServer\Exception;
use JsonApiPhp\JsonApi\Error;
use RuntimeException;
use Tobyz\JsonApiServer\ErrorProviderInterface;
class UnsupportedMediaTypeException extends RuntimeException implements ErrorProviderInterface
{
public function getJsonApiErrors(): array
{
return [
new Error(
new Error\Title('Unsupported Media Type'),
new Error\Status($this->getJsonApiStatus())
)
];
}
public function getJsonApiStatus(): string
{
return '415';
}
}

View File

@ -5,18 +5,17 @@ namespace Tobyz\JsonApiServer\Handler\Concerns;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Tobyz\JsonApiServer\Exception\ResourceNotFoundException; use Tobyz\JsonApiServer\Exception\ResourceNotFoundException;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use function Tobyz\JsonApiServer\run_callbacks;
trait FindsResources trait FindsResources
{ {
private function findResource(Request $request, ResourceType $resource, $id) private function findResource(Request $request, ResourceType $resource, string $id)
{ {
$adapter = $resource->getAdapter(); $adapter = $resource->getAdapter();
$query = $adapter->query(); $query = $adapter->query();
foreach ($resource->getSchema()->scopes as $scope) { run_callbacks($resource->getSchema()->getScopes(), [$query, $request, $id]);
$scope($request, $query, $id);
}
$model = $adapter->find($query, $id); $model = $adapter->find($query, $id);

View File

@ -2,7 +2,9 @@
namespace Tobyz\JsonApiServer\Handler\Concerns; namespace Tobyz\JsonApiServer\Handler\Concerns;
use Closure;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use function Tobyz\JsonApiServer\evaluate;
use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Schema\HasMany; use Tobyz\JsonApiServer\Schema\HasMany;
@ -47,18 +49,18 @@ trait IncludesData
private function validateInclude(ResourceType $resource, array $include, string $path = '') private function validateInclude(ResourceType $resource, array $include, string $path = '')
{ {
$schema = $resource->getSchema(); $fields = $resource->getSchema()->getFields();
foreach ($include as $name => $nested) { foreach ($include as $name => $nested) {
if (! isset($schema->fields[$name]) if (! isset($fields[$name])
|| ! $schema->fields[$name] instanceof Relationship || ! $fields[$name] instanceof Relationship
|| ($schema->fields[$name] instanceof HasMany && ! $schema->fields[$name]->includable) || ! $fields[$name]->isIncludable()
) { ) {
throw new BadRequestException("Invalid include [{$path}{$name}]", 'include'); throw new BadRequestException("Invalid include [{$path}{$name}]", 'include');
} }
if ($schema->fields[$name]->resource) { if ($type = $fields[$name]->getType()) {
$relatedResource = $this->api->getResource($schema->fields[$name]->resource); $relatedResource = $this->api->getResource($type);
$this->validateInclude($relatedResource, $nested, $name.'.'); $this->validateInclude($relatedResource, $nested, $name.'.');
} elseif ($nested) { } elseif ($nested) {
@ -69,18 +71,18 @@ trait IncludesData
private function buildRelationshipTrails(ResourceType $resource, array $include): array private function buildRelationshipTrails(ResourceType $resource, array $include): array
{ {
$schema = $resource->getSchema(); $fields = $resource->getSchema()->getFields();
$trails = []; $trails = [];
foreach ($include as $name => $nested) { foreach ($include as $name => $nested) {
$relationship = $schema->fields[$name]; $relationship = $fields[$name];
if ($relationship->loadable) { if ($relationship->getLoadable()) {
$trails[] = [$relationship]; $trails[] = [$relationship];
} }
if ($schema->fields[$name]->resource) { if ($type = $fields[$name]->getType()) {
$relatedResource = $this->api->getResource($relationship->resource); $relatedResource = $this->api->getResource($type);
$trails = array_merge( $trails = array_merge(
$trails, $trails,
@ -100,15 +102,15 @@ trait IncludesData
private function loadRelationships(array $models, array $include, Request $request) private function loadRelationships(array $models, array $include, Request $request)
{ {
$adapter = $this->resource->getAdapter(); $adapter = $this->resource->getAdapter();
$schema = $this->resource->getSchema(); $fields = $this->resource->getSchema()->getFields();
foreach ($schema->fields as $name => $field) { foreach ($fields as $name => $field) {
if (! $field instanceof Relationship || ! ($field->linkage)($request) || ! $field->loadable) { if (! $field instanceof Relationship || ! evaluate($field->getLinkage(), [$request]) || ! $field->getLoadable()) {
continue; continue;
} }
if ($field->loader) { if (($load = $field->getLoadable()) instanceof Closure) {
($field->loader)($models, true); $load($models, true);
} else { } else {
$adapter->loadIds($models, $field); $adapter->loadIds($models, $field);
} }
@ -117,9 +119,9 @@ trait IncludesData
$trails = $this->buildRelationshipTrails($this->resource, $include); $trails = $this->buildRelationshipTrails($this->resource, $include);
foreach ($trails as $relationships) { foreach ($trails as $relationships) {
if ($loader = end($relationships)->loader) { if (($load = end($relationships)->getLoadable()) instanceof Closure) {
// TODO: probably need to loop through relationships here // TODO: probably need to loop through relationships here
($loader)($models, false); $load($models, false);
} else { } else {
$adapter->load($models, $relationships); $adapter->load($models, $relationships);
} }

View File

@ -3,42 +3,21 @@
namespace Tobyz\JsonApiServer\Handler\Concerns; namespace Tobyz\JsonApiServer\Handler\Concerns;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use function Tobyz\JsonApiServer\evaluate;
use function Tobyz\JsonApiServer\get_value;
use function Tobyz\JsonApiServer\has_value;
use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Exception\UnprocessableEntityException; use Tobyz\JsonApiServer\Exception\UnprocessableEntityException;
use Tobyz\JsonApiServer\ResourceType; use function Tobyz\JsonApiServer\run_callbacks;
use Tobyz\JsonApiServer\Schema; use Tobyz\JsonApiServer\Schema\Attribute;
use Tobyz\JsonApiServer\Schema\HasMany;
use Tobyz\JsonApiServer\Schema\HasOne;
use Tobyz\JsonApiServer\Schema\Relationship;
trait SavesData trait SavesData
{ {
use FindsResources; use FindsResources;
private function save($model, Request $request, bool $creating = false): void
{
$data = $this->parseData($request->getParsedBody());
$adapter = $this->resource->getAdapter();
$this->assertFieldsExist($data);
$this->assertFieldsWritable($data, $model, $request);
if ($creating) {
$this->fillDefaultValues($data, $request);
}
$this->loadRelatedResources($data, $request);
$this->assertDataValid($data, $model, $request, $creating);
$this->applyValues($data, $model, $request);
$this->saveModel($model, $request);
$this->saveFields($data, $model, $request);
$this->runSavedCallbacks($data, $model, $request);
}
private function parseData($body): array private function parseData($body): array
{ {
if (! is_array($body) && ! is_object($body)) { if (! is_array($body) && ! is_object($body)) {
@ -80,15 +59,19 @@ trait SavesData
return $this->findResource($request, $resource, $identifier['id']); return $this->findResource($request, $resource, $identifier['id']);
} }
private function validateFields(array $data, $model, Request $request)
{
$this->assertFieldsExist($data);
$this->assertFieldsWritable($data, $model, $request);
}
private function assertFieldsExist(array $data) private function assertFieldsExist(array $data)
{ {
$schema = $this->resource->getSchema(); $fields = $this->resource->getSchema()->getFields();
foreach (['attributes', 'relationships'] as $location) { foreach (['attributes', 'relationships'] as $location) {
foreach ($data[$location] as $name => $value) { foreach ($data[$location] as $name => $value) {
if (! isset($schema->fields[$name]) if (! isset($fields[$name]) || $location !== $fields[$name]->getLocation()) {
|| $location !== $schema->fields[$name]->location
) {
throw new BadRequestException("Unknown field [$name]"); throw new BadRequestException("Unknown field [$name]");
} }
} }
@ -97,52 +80,29 @@ trait SavesData
private function assertFieldsWritable(array $data, $model, Request $request) private function assertFieldsWritable(array $data, $model, Request $request)
{ {
$schema = $this->resource->getSchema(); foreach ($this->resource->getSchema()->getFields() as $field) {
if (has_value($data, $field) && ! evaluate($field->getWritable(), [$model, $request])) {
foreach ($schema->fields as $name => $field) { throw new BadRequestException("Field [{$field->getName()}] is not writable");
$valueProvided = isset($data[$field->location][$name]);
if ($valueProvided && ! ($field->isWritable)($request, $model)) {
throw new BadRequestException("Field [$name] is not writable");
}
}
}
private function fillDefaultValues(array &$data, Request $request)
{
$schema = $this->resource->getSchema();
foreach ($schema->fields as $name => $field) {
$valueProvided = isset($data[$field->location][$name]);
if (! $valueProvided && $field->default) {
$data[$field->location][$name] = ($field->default)($request);
} }
} }
} }
private function loadRelatedResources(array &$data, Request $request) private function loadRelatedResources(array &$data, Request $request)
{ {
$schema = $this->resource->getSchema(); foreach ($this->resource->getSchema()->getFields() as $field) {
if (! $field instanceof Relationship || ! has_value($data, $field)) {
foreach ($schema->fields as $name => $field) {
if (! isset($data[$field->location][$name])) {
continue; continue;
} }
$value = &$data[$field->location][$name]; $value = &get_value($data, $field);
if ($field instanceof Schema\Relationship) { if (isset($value['data'])) {
$value = $value['data']; if ($field instanceof HasOne) {
$value = $this->getModelForIdentifier($request, $value['data']);
if ($value) { } elseif ($field instanceof HasMany) {
if ($field instanceof Schema\HasOne) { $value = array_map(function ($identifier) use ($request) {
$value = $this->getModelForIdentifier($request, $value); return $this->getModelForIdentifier($request, $identifier);
} elseif ($field instanceof Schema\HasMany) { }, $value['data']);
$value = array_map(function ($identifier) use ($request) {
return $this->getModelForIdentifier($request, $identifier);
}, $value);
}
} }
} }
} }
@ -150,22 +110,21 @@ trait SavesData
private function assertDataValid(array $data, $model, Request $request, bool $all): void private function assertDataValid(array $data, $model, Request $request, bool $all): void
{ {
$schema = $this->resource->getSchema();
$failures = []; $failures = [];
foreach ($schema->fields as $name => $field) { foreach ($this->resource->getSchema()->getFields() as $field) {
if (! $all && ! isset($data[$field->location][$name])) { if (! $all && ! has_value($data, $field)) {
continue; continue;
} }
$fail = function ($message) use (&$failures, $field) { $fail = function ($message = null) use (&$failures, $field) {
$failures[] = compact('field', 'message'); $failures[] = compact('field', 'message');
}; };
foreach ($field->validators as $validator) { run_callbacks(
$validator($fail, $data[$field->location][$name] ?? null, $model, $request, $field); $field->getListeners('validate'),
} [$fail, get_value($data, $field), $model, $request, $field]
);
} }
if (count($failures)) { if (count($failures)) {
@ -173,78 +132,81 @@ trait SavesData
} }
} }
private function applyValues(array $data, $model, Request $request) private function setValues(array $data, $model, Request $request)
{ {
$schema = $this->resource->getSchema();
$adapter = $this->resource->getAdapter(); $adapter = $this->resource->getAdapter();
foreach ($schema->fields as $name => $field) { foreach ($this->resource->getSchema()->getFields() as $field) {
if (! isset($data[$field->location][$name])) { if (! has_value($data, $field)) {
continue; continue;
} }
$value = $data[$field->location][$name]; $value = get_value($data, $field);
if ($field->setter || $field->saver) {
if ($field->setter) {
($field->setter)($request, $model, $value);
}
if ($setter = $field->getSetter()) {
$setter($model, $value, $request);
continue; continue;
} }
if ($field instanceof Schema\Attribute) { if ($field->getSaver()) {
$adapter->applyAttribute($model, $field, $value); continue;
} elseif ($field instanceof Schema\HasOne) { }
$adapter->applyHasOne($model, $field, $value);
if ($field instanceof Attribute) {
$adapter->setAttribute($model, $field, $value);
} elseif ($field instanceof HasOne) {
$adapter->setHasOne($model, $field, $value);
} }
} }
} }
private function save(array $data, $model, Request $request)
{
$this->saveModel($model, $request);
$this->saveFields($data, $model, $request);
}
private function saveModel($model, Request $request) private function saveModel($model, Request $request)
{ {
$adapter = $this->resource->getAdapter(); if ($saver = $this->resource->getSchema()->getSaver()) {
$schema = $this->resource->getSchema(); $saver($model, $request);
if ($schema->saver) {
($schema->saver)($request, $model);
} else { } else {
$adapter->save($model); $this->resource->getAdapter()->save($model);
} }
} }
private function saveFields(array $data, $model, Request $request) private function saveFields(array $data, $model, Request $request)
{ {
$schema = $this->resource->getSchema();
$adapter = $this->resource->getAdapter(); $adapter = $this->resource->getAdapter();
foreach ($schema->fields as $name => $field) { foreach ($this->resource->getSchema()->getFields() as $field) {
if (! isset($data[$field->location][$name])) { if (! has_value($data, $field)) {
continue; continue;
} }
$value = $data[$field->location][$name]; $value = get_value($data, $field);
if ($field->saver) { if ($saver = $field->getSaver()) {
($field->saver)($request, $model, $value); $saver($model, $value, $request);
} elseif ($field instanceof Schema\HasMany) { } elseif ($field instanceof HasMany) {
$adapter->saveHasMany($model, $field, $value); $adapter->saveHasMany($model, $field, $value);
} }
} }
$this->runSavedCallbacks($data, $model, $request);
} }
private function runSavedCallbacks(array $data, $model, Request $request) private function runSavedCallbacks(array $data, $model, Request $request)
{ {
$schema = $this->resource->getSchema(); foreach ($this->resource->getSchema()->getFields() as $field) {
if (! has_value($data, $field)) {
foreach ($schema->fields as $name => $field) {
if (! isset($data[$field->location][$name])) {
continue; continue;
} }
foreach ($field->savedCallbacks as $callback) { run_callbacks(
$callback($request, $model, $data[$field->location][$name]); $field->getListeners('saved'),
} [$model, get_value($data, $field), $request]
);
} }
} }
} }

View File

@ -5,9 +5,13 @@ namespace Tobyz\JsonApiServer\Handler;
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\Api; use function Tobyz\JsonApiServer\evaluate;
use function Tobyz\JsonApiServer\has_value;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Exception\ForbiddenException;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use function Tobyz\JsonApiServer\run_callbacks;
use function Tobyz\JsonApiServer\set_value;
class Create implements RequestHandlerInterface class Create implements RequestHandlerInterface
{ {
@ -16,7 +20,7 @@ class Create implements RequestHandlerInterface
private $api; private $api;
private $resource; private $resource;
public function __construct(Api $api, ResourceType $resource) public function __construct(JsonApi $api, ResourceType $resource)
{ {
$this->api = $api; $this->api = $api;
$this->resource = $resource; $this->resource = $resource;
@ -26,44 +30,43 @@ class Create implements RequestHandlerInterface
{ {
$schema = $this->resource->getSchema(); $schema = $this->resource->getSchema();
if (! ($schema->isCreatable)($request)) { if (! evaluate($schema->getCreatable(), [$request])) {
throw new ForbiddenException('You cannot create this resource'); throw new ForbiddenException;
} }
$model = $schema->createModel ? ($schema->createModel)($request) : $this->resource->getAdapter()->create(); $model = $this->createModel($request);
$data = $this->parseData($request->getParsedBody()); $data = $this->parseData($request->getParsedBody());
$adapter = $this->resource->getAdapter(); $this->validateFields($data, $model, $request);
$this->assertFieldsExist($data);
$this->assertFieldsWritable($data, $model, $request);
$this->fillDefaultValues($data, $request); $this->fillDefaultValues($data, $request);
$this->loadRelatedResources($data, $request); $this->loadRelatedResources($data, $request);
$this->assertDataValid($data, $model, $request, true); $this->assertDataValid($data, $model, $request, true);
$this->setValues($data, $model, $request);
$this->applyValues($data, $model, $request); run_callbacks($schema->getListeners('creating'), [$request, $model]);
foreach ($schema->creatingCallbacks as $callback) { $this->save($data, $model, $request);
$callback($request, $model);
}
$this->saveModel($model, $request); run_callbacks($schema->getListeners('created'), [$request, $model]);
$this->saveFields($data, $model, $request);
$this->runSavedCallbacks($data, $model, $request);
foreach ($schema->createdCallbacks as $callback) {
$callback($request, $model);
}
return (new Show($this->api, $this->resource, $model)) return (new Show($this->api, $this->resource, $model))
->handle($request) ->handle($request)
->withStatus(201); ->withStatus(201);
} }
private function createModel(Request $request)
{
$creator = $this->resource->getSchema()->getCreator();
return $creator ? $creator($request) : $this->resource->getAdapter()->create();
}
private function fillDefaultValues(array &$data, Request $request)
{
foreach ($this->resource->getSchema()->getFields() as $field) {
if (! has_value($data, $field) && ($default = $field->getDefault())) {
set_value($data, $field, $default($request));
}
}
}
} }

View File

@ -5,8 +5,10 @@ namespace Tobyz\JsonApiServer\Handler;
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 function Tobyz\JsonApiServer\evaluate;
use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Exception\ForbiddenException;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use function Tobyz\JsonApiServer\run_callbacks;
use Zend\Diactoros\Response\EmptyResponse; use Zend\Diactoros\Response\EmptyResponse;
class Delete implements RequestHandlerInterface class Delete implements RequestHandlerInterface
@ -24,19 +26,15 @@ class Delete implements RequestHandlerInterface
{ {
$schema = $this->resource->getSchema(); $schema = $this->resource->getSchema();
if (! ($schema->isDeletable)($request, $this->model)) { if (! evaluate($schema->getDeletable(), [$request, $this->model])) {
throw new ForbiddenException('You cannot delete this resource'); throw new ForbiddenException;
} }
foreach ($schema->deletingCallbacks as $callback) { run_callbacks($schema->getListeners('deleting'), [$request, $this->model]);
$callback($request, $this->model);
}
$this->resource->getAdapter()->delete($this->model); $this->resource->getAdapter()->delete($this->model);
foreach ($schema->deletedCallbacks as $callback) { run_callbacks($schema->getListeners('deleted'), [$request, $this->model]);
$callback($request, $this->model);
}
return new EmptyResponse; return new EmptyResponse;
} }

View File

@ -2,18 +2,24 @@
namespace Tobyz\JsonApiServer\Handler; namespace Tobyz\JsonApiServer\Handler;
use JsonApiPhp\JsonApi; use Closure;
use JsonApiPhp\JsonApi\Link; use JsonApiPhp\JsonApi as Structure;
use JsonApiPhp\JsonApi\Link\LastLink;
use JsonApiPhp\JsonApi\Link\NextLink;
use JsonApiPhp\JsonApi\Link\PrevLink;
use JsonApiPhp\JsonApi\Meta; use JsonApiPhp\JsonApi\Meta;
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\Api; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Exception\ForbiddenException;
use Tobyz\JsonApiServer\JsonApiResponse; use Tobyz\JsonApiServer\JsonApiResponse;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Schema; use Tobyz\JsonApiServer\Schema\Attribute;
use Tobyz\JsonApiServer\Schema\HasMany;
use Tobyz\JsonApiServer\Schema\HasOne;
use Tobyz\JsonApiServer\Schema\Type;
use Tobyz\JsonApiServer\Serializer; use Tobyz\JsonApiServer\Serializer;
class Index implements RequestHandlerInterface class Index implements RequestHandlerInterface
@ -23,7 +29,7 @@ class Index implements RequestHandlerInterface
private $api; private $api;
private $resource; private $resource;
public function __construct(Api $api, ResourceType $resource) public function __construct(JsonApi $api, ResourceType $resource)
{ {
$this->api = $api; $this->api = $api;
$this->resource = $resource; $this->resource = $resource;
@ -38,13 +44,9 @@ class Index implements RequestHandlerInterface
$adapter = $this->resource->getAdapter(); $adapter = $this->resource->getAdapter();
$schema = $this->resource->getSchema(); $schema = $this->resource->getSchema();
if (! ($schema->isVisible)($request)) {
throw new ForbiddenException('You cannot view this resource');
}
$query = $adapter->query(); $query = $adapter->query();
foreach ($schema->scopes as $scope) { foreach ($schema->getScopes() as $scope) {
$request = $scope($request, $query) ?: $request; $request = $scope($request, $query) ?: $request;
} }
@ -58,13 +60,13 @@ class Index implements RequestHandlerInterface
$paginationLinks = []; $paginationLinks = [];
$members = [ $members = [
new Link\SelfLink($this->buildUrl($request)), new Structure\Link\SelfLink($this->buildUrl($request)),
new Meta('offset', $offset), new Structure\Meta('offset', $offset),
new Meta('limit', $limit), new Structure\Meta('limit', $limit),
]; ];
if ($offset > 0) { if ($offset > 0) {
$paginationLinks[] = new Link\FirstLink($this->buildUrl($request, ['page' => ['offset' => 0]])); $paginationLinks[] = new Structure\Link\FirstLink($this->buildUrl($request, ['page' => ['offset' => 0]]));
$prevOffset = $offset - $limit; $prevOffset = $offset - $limit;
@ -74,16 +76,16 @@ class Index implements RequestHandlerInterface
$params = ['page' => ['offset' => max(0, $prevOffset)]]; $params = ['page' => ['offset' => max(0, $prevOffset)]];
} }
$paginationLinks[] = new Link\PrevLink($this->buildUrl($request, $params)); $paginationLinks[] = new PrevLink($this->buildUrl($request, $params));
} }
if ($schema->countable && $schema->paginate) { if ($schema->isCountable() && $schema->getPaginate()) {
$total = $adapter->count($query); $total = $adapter->count($query);
$members[] = new Meta('total', $total); $members[] = new Meta('total', $total);
if ($offset + $limit < $total) { if ($offset + $limit < $total) {
$paginationLinks[] = new Link\LastLink($this->buildUrl($request, ['page' => ['offset' => floor(($total - 1) / $limit) * $limit]])); $paginationLinks[] = new LastLink($this->buildUrl($request, ['page' => ['offset' => floor(($total - 1) / $limit) * $limit]]));
} }
} }
@ -96,7 +98,7 @@ class Index implements RequestHandlerInterface
$models = $adapter->get($query); $models = $adapter->get($query);
if ((count($models) && $total === null) || $offset + $limit < $total) { if ((count($models) && $total === null) || $offset + $limit < $total) {
$paginationLinks[] = new Link\NextLink($this->buildUrl($request, ['page' => ['offset' => $offset + $limit]])); $paginationLinks[] = new NextLink($this->buildUrl($request, ['page' => ['offset' => $offset + $limit]]));
} }
$this->loadRelationships($models, $include, $request); $this->loadRelationships($models, $include, $request);
@ -108,12 +110,12 @@ class Index implements RequestHandlerInterface
} }
return new JsonApiResponse( return new JsonApiResponse(
new JsonApi\CompoundDocument( new Structure\CompoundDocument(
new JsonApi\PaginatedCollection( new Structure\PaginatedCollection(
new JsonApi\Pagination(...$paginationLinks), new Structure\Pagination(...$paginationLinks),
new JsonApi\ResourceCollection(...$serializer->primary()) new Structure\ResourceCollection(...$serializer->primary())
), ),
new JsonApi\Included(...$serializer->included()), new Structure\Included(...$serializer->included()),
...$members ...$members
) )
); );
@ -149,7 +151,7 @@ class Index implements RequestHandlerInterface
$queryParams = $request->getQueryParams(); $queryParams = $request->getQueryParams();
$limit = $this->resource->getSchema()->paginate; $limit = $this->resource->getSchema()->getPaginate();
if (isset($queryParams['page']['limit'])) { if (isset($queryParams['page']['limit'])) {
$limit = $queryParams['page']['limit']; $limit = $queryParams['page']['limit'];
@ -158,7 +160,7 @@ class Index implements RequestHandlerInterface
throw new BadRequestException('page[limit] must be a positive integer', 'page[limit]'); throw new BadRequestException('page[limit] must be a positive integer', 'page[limit]');
} }
$limit = min($this->resource->getSchema()->limit, $limit); $limit = min($this->resource->getSchema()->getLimit(), $limit);
} }
$offset = 0; $offset = 0;
@ -175,15 +177,16 @@ class Index implements RequestHandlerInterface
->withAttribute('jsonApiLimit', $limit) ->withAttribute('jsonApiLimit', $limit)
->withAttribute('jsonApiOffset', $offset); ->withAttribute('jsonApiOffset', $offset);
$sort = $queryParams['sort'] ?? $this->resource->getSchema()->defaultSort; $sort = $queryParams['sort'] ?? $this->resource->getSchema()->getDefaultSort();
if ($sort) { if ($sort) {
$sort = $this->parseSort($sort); $sort = $this->parseSort($sort);
$fields = $schema->getFields();
foreach ($sort as $name => $direction) { foreach ($sort as $name => $direction) {
if (! isset($schema->fields[$name]) if (! isset($fields[$name])
|| ! $schema->fields[$name] instanceof Schema\Attribute || ! $fields[$name] instanceof Attribute
|| ! $schema->fields[$name]->sortable || ! $fields[$name]->getSortable()
) { ) {
throw new BadRequestException("Invalid sort field [$name]", 'sort'); throw new BadRequestException("Invalid sort field [$name]", 'sort');
} }
@ -199,8 +202,10 @@ class Index implements RequestHandlerInterface
throw new BadRequestException('filter must be an array', 'filter'); throw new BadRequestException('filter must be an array', 'filter');
} }
$fields = $schema->getFields();
foreach ($filter as $name => $value) { foreach ($filter as $name => $value) {
if ($name !== 'id' && (! isset($schema->fields[$name]) || ! $schema->fields[$name]->filterable)) { if ($name !== 'id' && (! isset($fields[$name]) || ! $fields[$name]->getFilterable())) {
throw new BadRequestException("Invalid filter [$name]", "filter[$name]"); throw new BadRequestException("Invalid filter [$name]", "filter[$name]");
} }
} }
@ -217,10 +222,10 @@ class Index implements RequestHandlerInterface
$adapter = $this->resource->getAdapter(); $adapter = $this->resource->getAdapter();
foreach ($sort as $name => $direction) { foreach ($sort as $name => $direction) {
$attribute = $schema->fields[$name]; $attribute = $schema->getFields()[$name];
if ($attribute->sorter) { if (($sorter = $attribute->getSortable()) instanceof Closure) {
($attribute->sorter)($request, $query, $direction); $sorter($query, $direction, $request);
} else { } else {
$adapter->sortByAttribute($query, $attribute, $direction); $adapter->sortByAttribute($query, $attribute, $direction);
} }
@ -267,16 +272,16 @@ class Index implements RequestHandlerInterface
continue; continue;
} }
$field = $schema->fields[$name]; $field = $schema->getFields()[$name];
if ($field->filter) { if (($filter = $field->getFilterable()) instanceof Closure) {
($field->filter)($query, $value, $request); $filter($query, $value, $request);
} elseif ($field instanceof Schema\Attribute) { } elseif ($field instanceof Attribute) {
$adapter->filterByAttribute($query, $field, $value); $adapter->filterByAttribute($query, $field, $value);
} elseif ($field instanceof Schema\HasOne) { } elseif ($field instanceof HasOne) {
$value = explode(',', $value); $value = explode(',', $value);
$adapter->filterByHasOne($query, $field, $value); $adapter->filterByHasOne($query, $field, $value);
} elseif ($field instanceof Schema\HasMany) { } elseif ($field instanceof HasMany) {
$value = explode(',', $value); $value = explode(',', $value);
$adapter->filterByHasMany($query, $field, $value); $adapter->filterByHasMany($query, $field, $value);
} }

View File

@ -2,12 +2,12 @@
namespace Tobyz\JsonApiServer\Handler; namespace Tobyz\JsonApiServer\Handler;
use JsonApiPhp\JsonApi; use JsonApiPhp\JsonApi\CompoundDocument;
use JsonApiPhp\JsonApi\Included;
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\Api; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Exception\ForbiddenException;
use Tobyz\JsonApiServer\JsonApiResponse; use Tobyz\JsonApiServer\JsonApiResponse;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Serializer; use Tobyz\JsonApiServer\Serializer;
@ -20,7 +20,7 @@ class Show implements RequestHandlerInterface
private $resource; private $resource;
private $model; private $model;
public function __construct(Api $api, ResourceType $resource, $model) public function __construct(JsonApi $api, ResourceType $resource, $model)
{ {
$this->api = $api; $this->api = $api;
$this->resource = $resource; $this->resource = $resource;
@ -29,12 +29,6 @@ class Show implements RequestHandlerInterface
public function handle(Request $request): Response public function handle(Request $request): Response
{ {
$schema = $this->resource->getSchema();
if (! ($schema->isVisible)($request)) {
throw new ForbiddenException('You cannot view this resource');
}
$include = $this->getInclude($request); $include = $this->getInclude($request);
$this->loadRelationships([$this->model], $include, $request); $this->loadRelationships([$this->model], $include, $request);
@ -44,9 +38,9 @@ class Show implements RequestHandlerInterface
$serializer->add($this->resource, $this->model, $include); $serializer->add($this->resource, $this->model, $include);
return new JsonApiResponse( return new JsonApiResponse(
new JsonApi\CompoundDocument( new CompoundDocument(
$serializer->primary()[0], $serializer->primary()[0],
new JsonApi\Included(...$serializer->included()) new Included(...$serializer->included())
) )
); );
} }

View File

@ -5,9 +5,11 @@ namespace Tobyz\JsonApiServer\Handler;
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\Api; use function Tobyz\JsonApiServer\evaluate;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Exception\ForbiddenException;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use function Tobyz\JsonApiServer\run_callbacks;
class Update implements RequestHandlerInterface class Update implements RequestHandlerInterface
{ {
@ -17,7 +19,7 @@ class Update implements RequestHandlerInterface
private $resource; private $resource;
private $model; private $model;
public function __construct(Api $api, ResourceType $resource, $model) public function __construct(JsonApi $api, ResourceType $resource, $model)
{ {
$this->api = $api; $this->api = $api;
$this->resource = $resource; $this->resource = $resource;
@ -28,19 +30,22 @@ class Update implements RequestHandlerInterface
{ {
$schema = $this->resource->getSchema(); $schema = $this->resource->getSchema();
if (! ($schema->isUpdatable)($request, $this->model)) { if (! evaluate($schema->getUpdatable(), [$request, $this->model])) {
throw new ForbiddenException('You cannot update this resource'); throw new ForbiddenException;
} }
foreach ($schema->updatingCallbacks as $callback) { $data = $this->parseData($request->getParsedBody());
$callback($request, $this->model);
}
$this->save($this->model, $request); $this->validateFields($data, $this->model, $request);
$this->loadRelatedResources($data, $request);
$this->assertDataValid($data, $this->model, $request, false);
$this->setValues($data, $this->model, $request);
foreach ($schema->updatedCallbacks as $callback) { run_callbacks($schema->getListeners('updating'), [$request, $this->model]);
$callback($request, $this->model);
} $this->save($data, $this->model, $request);
run_callbacks($schema->getListeners('updated'), [$request, $this->model]);
return (new Show($this->api, $this->resource, $this->model))->handle($request); return (new Show($this->api, $this->resource, $this->model))->handle($request);
} }

56
src/Http/MediaTypes.php Normal file
View File

@ -0,0 +1,56 @@
<?php
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;
}
}

202
src/JsonApi.php Normal file
View File

@ -0,0 +1,202 @@
<?php
namespace Tobyz\JsonApiServer;
use Closure;
use JsonApiPhp\JsonApi\ErrorDocument;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface;
use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Exception\InternalServerErrorException;
use Tobyz\JsonApiServer\Exception\MethodNotAllowedException;
use Tobyz\JsonApiServer\Exception\NotAcceptableException;
use Tobyz\JsonApiServer\Exception\NotImplementedException;
use Tobyz\JsonApiServer\Exception\ResourceNotFoundException;
use Tobyz\JsonApiServer\Exception\UnsupportedMediaTypeException;
use Tobyz\JsonApiServer\Handler\Concerns\FindsResources;
use Tobyz\JsonApiServer\Http\MediaTypes;
final class JsonApi implements RequestHandlerInterface
{
const CONTENT_TYPE = 'application/vnd.api+json';
use FindsResources;
private $resources = [];
private $baseUrl;
public function __construct(string $baseUrl)
{
$this->baseUrl = $baseUrl;
}
public function resource(string $type, $adapter, Closure $buildSchema = null): void
{
$this->resources[$type] = new ResourceType($type, $adapter, $buildSchema);
}
public function getResources(): array
{
return $this->resources;
}
public function getResource(string $type): ResourceType
{
if (! isset($this->resources[$type])) {
throw new ResourceNotFoundException($type);
}
return $this->resources[$type];
}
public function handle(Request $request): Response
{
$this->validateRequest($request);
$path = $this->stripBasePath(
$request->getUri()->getPath()
);
$segments = explode('/', trim($path, '/'));
switch (count($segments)) {
case 1:
return $this->handleWithHandler(
$request,
$this->getCollectionHandler($request, $segments)
);
case 2:
return $this->handleWithHandler(
$request,
$this->getMemberHandler($request, $segments)
);
case 3:
// return $this->handleRelated($request, $resource, $model, $segments[2]);
throw new NotImplementedException;
case 4:
if ($segments[2] === 'relationships') {
// return $this->handleRelationship($request, $resource, $model, $segments[3]);
throw new NotImplementedException;
}
}
throw new BadRequestException;
}
private function validateRequest(Request $request): void
{
$this->validateRequestContentType($request);
$this->validateRequestAccepts($request);
}
private function validateRequestContentType(Request $request): void
{
$header = $request->getHeaderLine('Content-Type');
if (empty($header)) {
return;
}
if ((new MediaTypes($header))->containsExactly(self::CONTENT_TYPE)) {
return;
}
throw new UnsupportedMediaTypeException;
}
private function validateRequestAccepts(Request $request): void
{
$header = $request->getHeaderLine('Accept');
if (empty($header)) {
return;
}
if ((new MediaTypes($header))->containsExactly(self::CONTENT_TYPE)) {
return;
}
throw new NotAcceptableException;
}
private function stripBasePath(string $path): string
{
$basePath = parse_url($this->baseUrl, PHP_URL_PATH);
$len = strlen($basePath);
if (substr($path, 0, $len) === $basePath) {
$path = substr($path, $len + 1);
}
return $path;
}
private function getCollectionHandler(Request $request, array $segments): RequestHandlerInterface
{
$resource = $this->getResource($segments[0]);
switch ($request->getMethod()) {
case 'GET':
return new Handler\Index($this, $resource);
case 'POST':
return new Handler\Create($this, $resource);
default:
throw new MethodNotAllowedException;
}
}
private function getMemberHandler(Request $request, array $segments): RequestHandlerInterface
{
$resource = $this->getResource($segments[0]);
$model = $this->findResource($request, $resource, $segments[1]);
switch ($request->getMethod()) {
case 'PATCH':
return new Handler\Update($this, $resource, $model);
case 'GET':
return new Handler\Show($this, $resource, $model);
case 'DELETE':
return new Handler\Delete($resource, $model);
default:
throw new MethodNotAllowedException;
}
}
private function handleWithHandler(Request $request, RequestHandlerInterface $handler)
{
$request = $request->withAttribute('jsonApiHandler', $handler);
return $handler->handle($request);
}
public function error($e)
{
if (! $e instanceof ErrorProviderInterface) {
$e = new InternalServerErrorException;
}
$errors = $e->getJsonApiErrors();
$status = $e->getJsonApiStatus();
$data = new ErrorDocument(
...$errors
);
return new JsonApiResponse($data, $status);
}
public function getBaseUrl(): string
{
return $this->baseUrl;
}
}

View File

@ -12,7 +12,7 @@ class JsonApiResponse extends JsonResponse
array $headers = [], array $headers = [],
$encodingOptions = self::DEFAULT_JSON_FLAGS $encodingOptions = self::DEFAULT_JSON_FLAGS
) { ) {
$headers['content-type'] = 'application/vnd.api+json'; $headers['content-type'] = JsonApi::CONTENT_TYPE;
parent::__construct($data, $status, $headers, $encodingOptions); parent::__construct($data, $status, $headers, $encodingOptions);
} }

View File

@ -4,14 +4,14 @@ namespace Tobyz\JsonApiServer;
use Closure; use Closure;
use Tobyz\JsonApiServer\Adapter\AdapterInterface; use Tobyz\JsonApiServer\Adapter\AdapterInterface;
use Tobyz\JsonApiServer\Schema\Builder; use Tobyz\JsonApiServer\Schema\Type;
class ResourceType final class ResourceType
{ {
protected $type; private $type;
protected $adapter; private $adapter;
protected $buildSchema; private $buildSchema;
protected $schema; private $schema;
public function __construct(string $type, AdapterInterface $adapter, Closure $buildSchema = null) public function __construct(string $type, AdapterInterface $adapter, Closure $buildSchema = null)
{ {
@ -30,10 +30,10 @@ class ResourceType
return $this->adapter; return $this->adapter;
} }
public function getSchema(): Builder public function getSchema(): Type
{ {
if (! $this->schema) { if (! $this->schema) {
$this->schema = new Builder; $this->schema = new Type;
if ($this->buildSchema) { if ($this->buildSchema) {
($this->buildSchema)($this->schema); ($this->buildSchema)($this->schema);

View File

@ -4,17 +4,31 @@ namespace Tobyz\JsonApiServer\Schema;
use Closure; use Closure;
class Attribute extends Field final class Attribute extends Field
{ {
public $location = 'attributes'; private $sortable = false;
public $sortable = false;
public $sorter;
public function sortable(Closure $callback = null) public function sortable(Closure $callback = null)
{ {
$this->sortable = true; $this->sortable = $callback ?: true;
$this->sorter = $callback;
return $this; return $this;
} }
public function notSortable()
{
$this->sortable = false;
return $this;
}
public function getSortable()
{
return $this->sortable;
}
public function getLocation(): string
{
return 'attributes';
}
} }

View File

@ -1,241 +0,0 @@
<?php
namespace Tobyz\JsonApiServer\Schema;
use Closure;
class Builder
{
public $fields = [];
public $meta = [];
public $paginate = 20;
public $limit = 50;
public $countable = true;
public $scopes = [];
public $isCreatable;
public $creatingCallbacks = [];
public $createdCallbacks = [];
public $isUpdatable;
public $updatingCallbacks = [];
public $updatedCallbacks = [];
public $isDeletable;
public $deletingCallbacks = [];
public $deletedCallbacks = [];
public $defaultSort;
public $createModel;
public $saver;
public function __construct()
{
$this->visible();
$this->notCreatable();
$this->notUpdatable();
$this->notDeletable();
}
public function attribute(string $name, string $property = null): Attribute
{
return $this->field(Attribute::class, $name, $property);
}
public function hasOne(string $name, $resource = null, string $property = null): HasOne
{
$field = $this->field(HasOne::class, $name, $property);
if ($resource) {
$field->resource($resource);
}
return $field;
}
public function morphOne(string $name, string $property = null): HasOne
{
return $this->field(HasOne::class, $name, $property)->resource(null);
}
public function hasMany(string $name, $resource = null, string $property = null): HasMany
{
$field = $this->field(HasMany::class, $name, $property);
if ($resource) {
$field->resource($resource);
}
return $field;
}
public function meta(string $name, $value)
{
return $this->meta[$name] = new Meta($name, $value);
}
public function paginate(?int $perPage)
{
$this->paginate = $perPage;
}
public function limit(?int $limit)
{
$this->limit = $limit;
}
public function countable()
{
$this->countable = true;
}
public function uncountable()
{
$this->countable = false;
}
public function createModel(Closure $callback)
{
$this->createModel = $callback;
}
public function scope(Closure $callback)
{
$this->scopes[] = $callback;
}
public function creatableIf(Closure $condition)
{
$this->isCreatable = $condition;
return $this;
}
public function creatable()
{
return $this->creatableIf(function () {
return true;
});
}
public function notCreatableIf(Closure $condition)
{
return $this->creatableIf(function (...$args) use ($condition) {
return ! $condition(...$args);
});
}
public function notCreatable()
{
return $this->notCreatableIf(function () {
return true;
});
}
public function creating(Closure $callback)
{
$this->creatingCallbacks[] = $callback;
}
public function created(Closure $callback)
{
$this->createdCallbacks[] = $callback;
}
public function updatableIf(Closure $condition)
{
$this->isUpdatable = $condition;
return $this;
}
public function updatable()
{
return $this->updatableIf(function () {
return true;
});
}
public function notUpdatableIf(Closure $condition)
{
return $this->updatableIf(function (...$args) use ($condition) {
return ! $condition(...$args);
});
}
public function notUpdatable()
{
return $this->notUpdatableIf(function () {
return true;
});
}
public function updating(Closure $callback)
{
$this->updatingCallbacks[] = $callback;
}
public function updated(Closure $callback)
{
$this->updatedCallbacks[] = $callback;
}
public function save(Closure $callback)
{
$this->saver = $callback;
return $this;
}
public function deletableIf(Closure $condition)
{
$this->isDeletable = $condition;
return $this;
}
public function deletable()
{
return $this->deletableIf(function () {
return true;
});
}
public function notDeletableIf(Closure $condition)
{
return $this->deletableIf(function (...$args) use ($condition) {
return ! $condition(...$args);
});
}
public function notDeletable()
{
return $this->notDeletableIf(function () {
return true;
});
}
public function deleting(Closure $callback)
{
$this->deletingCallbacks[] = $callback;
}
public function deleted(Closure $callback)
{
$this->deletedCallbacks[] = $callback;
}
public function defaultSort(string $sort)
{
$this->defaultSort = $sort;
}
private function field(string $class, string $name, string $property = null)
{
if (! isset($this->fields[$name]) || ! $this->fields[$name] instanceof $class) {
$this->fields[$name] = new $class($name);
}
if ($property) {
$this->fields[$name]->property($property);
}
return $this->fields[$name];
}
}

View File

@ -0,0 +1,22 @@
<?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\Concerns;
trait HasListeners
{
private $listeners = [];
public function getListeners(string $event)
{
return $this->listeners[$event] ?? [];
}
}

View File

@ -3,30 +3,31 @@
namespace Tobyz\JsonApiServer\Schema; namespace Tobyz\JsonApiServer\Schema;
use Closure; use Closure;
use function Tobyz\JsonApiServer\negate;
use Tobyz\JsonApiServer\Schema\Concerns\HasListeners;
use function Tobyz\JsonApiServer\wrap;
abstract class Field abstract class Field
{ {
public $name; use HasListeners;
public $property;
public $isVisible; private $name;
public $isWritable; private $property;
public $getter; private $visible = true;
public $setter; private $writable = false;
public $saver; private $getter;
public $savedCallbacks = []; private $setter;
public $default; private $saver;
public $validators = []; private $default;
public $filterable = false; private $filterable = false;
public $filter;
public function __construct(string $name) public function __construct(string $name)
{ {
$this->name = $name; $this->name = $name;
$this->visible();
$this->readonly();
} }
abstract public function getLocation(): string;
public function property(string $property) public function property(string $property)
{ {
$this->property = $property; $this->property = $property;
@ -34,65 +35,37 @@ abstract class Field
return $this; return $this;
} }
public function visibleIf(Closure $condition) public function visible(Closure $condition = null)
{ {
$this->isVisible = $condition; $this->visible = $condition ?: true;
return $this; return $this;
} }
public function visible() public function hidden(Closure $condition = null)
{ {
return $this->visibleIf(function () { $this->visible = $condition ? negate($condition) : false;
return true;
});
}
public function hiddenIf(Closure $condition)
{
return $this->visibleIf(function (...$args) use ($condition) {
return ! $condition(...$args);
});
}
public function hidden()
{
return $this->hiddenIf(function () {
return true;
});
}
public function writableIf(Closure $condition)
{
$this->isWritable = $condition;
return $this; return $this;
} }
public function writable() public function writable(Closure $condition = null)
{ {
return $this->writableIf(function () { $this->writable = $condition ?: true;
return true;
}); return $this;
} }
public function readonlyIf(Closure $condition) public function readonly(Closure $condition = null)
{ {
return $this->writableIf(function (...$args) use ($condition) { $this->writable = $condition ? negate($condition) : false;
return ! $condition(...$args);
}); return $this;
} }
public function readonly() public function get($value)
{ {
return $this->readonlyIf(function () { $this->getter = wrap($value);
return true;
});
}
public function get($callback)
{
$this->getter = $this->wrap($callback);
return $this; return $this;
} }
@ -113,41 +86,86 @@ abstract class Field
public function saved(Closure $callback) public function saved(Closure $callback)
{ {
$this->savedCallbacks[] = $callback; $this->listeners['saved'][] = $callback;
return $this; return $this;
} }
public function default($value) public function default($value)
{ {
$this->default = $this->wrap($value); $this->default = wrap($value);
return $this; return $this;
} }
public function validate(Closure $callback) public function validate(Closure $callback)
{ {
$this->validators[] = $callback; $this->listeners['validate'][] = $callback;
return $this; return $this;
} }
public function filterable(Closure $callback = null) public function filterable(Closure $callback = null)
{ {
$this->filterable = true; $this->filterable = $callback ?: true;
$this->filter = $callback;
return $this; return $this;
} }
protected function wrap($value) public function notFilterable()
{ {
if (! $value instanceof Closure) { $this->filterable = false;
$value = function () use ($value) {
return $value;
};
}
return $value; return $this;
} }
public function getName(): string
{
return $this->name;
}
public function getProperty()
{
return $this->property;
}
/**
* @return bool|Closure
*/
public function getVisible()
{
return $this->visible;
}
public function getWritable()
{
return $this->writable;
}
public function getGetter()
{
return $this->getter;
}
public function getSetter()
{
return $this->setter;
}
public function getSaver()
{
return $this->saver;
}
public function getDefault()
{
return $this->default;
}
public function getFilterable()
{
return $this->filterable;
}
} }

View File

@ -2,14 +2,12 @@
namespace Tobyz\JsonApiServer\Schema; namespace Tobyz\JsonApiServer\Schema;
class HasMany extends Relationship final class HasMany extends Relationship
{ {
public $includable = false;
public function __construct(string $name) public function __construct(string $name)
{ {
parent::__construct($name); parent::__construct($name);
$this->resource = $name; $this->type($name);
} }
} }

View File

@ -4,12 +4,12 @@ namespace Tobyz\JsonApiServer\Schema;
use Doctrine\Common\Inflector\Inflector; use Doctrine\Common\Inflector\Inflector;
class HasOne extends Relationship final class HasOne extends Relationship
{ {
public function __construct(string $name) public function __construct(string $name)
{ {
parent::__construct($name); parent::__construct($name);
$this->resource = Inflector::pluralize($name); $this->type(Inflector::pluralize($name));
} }
} }

View File

@ -3,26 +3,26 @@
namespace Tobyz\JsonApiServer\Schema; namespace Tobyz\JsonApiServer\Schema;
use Closure; use Closure;
use function Tobyz\JsonApiServer\wrap;
class Meta final class Meta
{ {
public $name; private $name;
public $value; private $value;
public function __construct(string $name, $value) public function __construct(string $name, $value)
{ {
$this->name = $name; $this->name = $name;
$this->value = $this->wrap($value); $this->value = wrap($value);
} }
private function wrap($value) public function getName(): string
{ {
if (! $value instanceof Closure) { return $this->name;
$value = function () use ($value) { }
return $value;
};
}
return $value; public function getValue(): Closure
{
return $this->value;
} }
} }

View File

@ -3,57 +3,47 @@
namespace Tobyz\JsonApiServer\Schema; namespace Tobyz\JsonApiServer\Schema;
use Closure; use Closure;
use Tobyz\JsonApiServer\Handler\Show; use function Tobyz\JsonApiServer\negate;
abstract class Relationship extends Field abstract class Relationship extends Field
{ {
public $location = 'relationships'; private $type;
public $linkage; private $linkage = false;
public $hasLinks = true; private $links = true;
public $loadable = true; private $loadable = true;
public $loader; private $includable = false;
public $included = false;
public $includable = true;
public $resource;
public function __construct(string $name) public function type($type)
{ {
parent::__construct($name); $this->type = $type;
$this->noLinkage();
}
public function resource($resource)
{
$this->resource = $resource;
return $this; return $this;
} }
public function linkageIf(Closure $condition) public function polymorphic()
{ {
$this->linkage = $condition; $this->type = null;
return $this; return $this;
} }
public function linkage() public function linkage(Closure $condition = null)
{ {
return $this->linkageIf(function () { $this->linkage = $condition ?: true;
return true;
}); return $this;
} }
public function noLinkage() public function noLinkage(Closure $condition = null)
{ {
return $this->linkageIf(function () { $this->linkage = $condition ? negate($condition) : false;
return false;
}); return $this;
} }
public function loadable() public function loadable(Closure $callback = null)
{ {
$this->loadable = true; $this->loadable = $callback ?: true;
return $this; return $this;
} }
@ -65,13 +55,6 @@ abstract class Relationship extends Field
return $this; return $this;
} }
public function load(Closure $callback)
{
$this->loader = $callback;
return $this;
}
public function includable() public function includable()
{ {
$this->includable = true; $this->includable = true;
@ -86,10 +69,47 @@ abstract class Relationship extends Field
return $this; return $this;
} }
public function noLinks() public function getType()
{ {
$this->hasLinks = false; return $this->type;
}
public function links()
{
$this->links = true;
return $this; return $this;
} }
public function noLinks()
{
$this->links = false;
return $this;
}
public function getLinkage()
{
return $this->linkage;
}
public function hasLinks(): bool
{
return $this->links;
}
public function getLoadable()
{
return $this->loadable;
}
public function isIncludable(): bool
{
return $this->includable;
}
public function getLocation(): string
{
return 'relationships';
}
} }

282
src/Schema/Type.php Normal file
View File

@ -0,0 +1,282 @@
<?php
namespace Tobyz\JsonApiServer\Schema;
use Closure;
use function Tobyz\JsonApiServer\negate;
use Tobyz\JsonApiServer\Schema\Concerns\HasListeners;
final class Type
{
use HasListeners;
private $fields = [];
private $meta = [];
private $paginate = 20;
private $limit = 50;
private $countable = true;
private $defaultSort;
private $scopes = [];
private $saver;
private $creatable = false;
private $create;
private $updatable = false;
private $deletable = false;
private $delete;
public function attribute(string $name): Attribute
{
return $this->field(Attribute::class, $name);
}
public function hasOne(string $name): HasOne
{
return $this->field(HasOne::class, $name);
}
public function hasMany(string $name): HasMany
{
return $this->field(HasMany::class, $name);
}
private function field(string $class, string $name)
{
if (! isset($this->fields[$name]) || ! $this->fields[$name] instanceof $class) {
$this->fields[$name] = new $class($name);
}
return $this->fields[$name];
}
public function removeField(string $name)
{
unset($this->fields[$name]);
return $this;
}
/**
* @return Field[]
*/
public function getFields(): array
{
return $this->fields;
}
public function meta(string $name, $value)
{
return $this->meta[$name] = new Meta($name, $value);
}
public function removeMeta(string $name)
{
unset($this->meta[$name]);
return $this;
}
public function getMeta(): array
{
return $this->meta;
}
public function paginate(int $perPage)
{
$this->paginate = $perPage;
}
public function dontPaginate()
{
$this->paginate = null;
}
public function getPaginate(): int
{
return $this->paginate;
}
public function limit(int $limit)
{
$this->limit = $limit;
}
public function noLimit()
{
$this->limit = null;
}
public function getLimit(): int
{
return $this->limit;
}
public function countable()
{
$this->countable = true;
}
public function uncountable()
{
$this->countable = false;
}
public function isCountable(): bool
{
return $this->countable;
}
public function scope(Closure $callback)
{
$this->scopes[] = $callback;
}
public function getScopes(): array
{
return $this->scopes;
}
public function create(?Closure $callback)
{
$this->create = $callback;
}
public function getCreator()
{
return $this->create;
}
public function creatable(Closure $condition = null)
{
$this->creatable = $condition ?: true;
return $this;
}
public function notCreatable(Closure $condition = null)
{
$this->creatable = $condition ? negate($condition) : false;
return $this;
}
public function getCreatable()
{
return $this->creatable;
}
public function creating(Closure $callback)
{
$this->listeners['creating'][] = $callback;
return $this;
}
public function created(Closure $callback)
{
$this->listeners['created'][] = $callback;
return $this;
}
public function updatable(Closure $condition = null)
{
$this->updatable = $condition ?: true;
return $this;
}
public function notUpdatable(Closure $condition = null)
{
$this->updatable = $condition ? negate($condition) : false;
return $this;
}
public function getUpdatable()
{
return $this->updatable;
}
public function updating(Closure $callback)
{
$this->listeners['updating'][] = $callback;
return $this;
}
public function updated(Closure $callback)
{
$this->listeners['updated'][] = $callback;
return $this;
}
public function save(?Closure $callback)
{
$this->saver = $callback;
return $this;
}
public function getSaver()
{
return $this->saver;
}
public function deletable(Closure $condition = null)
{
$this->deletable = $condition ?: true;
return $this;
}
public function notDeletable(Closure $condition = null)
{
$this->deletable = $condition ? negate($condition) : false;
return $this;
}
public function getDeletable()
{
return $this->deletable;
}
public function delete(?Closure $callback)
{
$this->delete = $callback;
return $this;
}
public function getDelete()
{
return $this->delete;
}
public function deleting(Closure $callback)
{
$this->listeners['deleting'][] = $callback;
return $this;
}
public function deleted(Closure $callback)
{
$this->listeners['deleted'][] = $callback;
return $this;
}
public function defaultSort(?string $sort)
{
$this->defaultSort = $sort;
return $this;
}
public function getDefaultSort()
{
return $this->defaultSort;
}
}

View File

@ -4,9 +4,18 @@ namespace Tobyz\JsonApiServer;
use DateTime; use DateTime;
use DateTimeInterface; use DateTimeInterface;
use JsonApiPhp\JsonApi; use JsonApiPhp\JsonApi as Structure;
use JsonApiPhp\JsonApi\EmptyRelationship;
use JsonApiPhp\JsonApi\Link\RelatedLink;
use JsonApiPhp\JsonApi\Link\SelfLink;
use JsonApiPhp\JsonApi\ResourceIdentifier;
use JsonApiPhp\JsonApi\ResourceIdentifierCollection;
use JsonApiPhp\JsonApi\ToMany;
use JsonApiPhp\JsonApi\ToOne;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Tobyz\JsonApiServer\Adapter\AdapterInterface; use Tobyz\JsonApiServer\Adapter\AdapterInterface;
use Tobyz\JsonApiServer\Schema\Attribute;
use Tobyz\JsonApiServer\Schema\Relationship;
class Serializer class Serializer
{ {
@ -15,7 +24,7 @@ class Serializer
protected $map = []; protected $map = [];
protected $primary = []; protected $primary = [];
public function __construct(Api $api, Request $request) public function __construct(JsonApi $api, Request $request)
{ {
$this->api = $api; $this->api = $api;
$this->request = $request; $this->request = $request;
@ -43,7 +52,7 @@ class Serializer
$resourceUrl = $this->api->getBaseUrl().'/'.$data['type'].'/'.$data['id']; $resourceUrl = $this->api->getBaseUrl().'/'.$data['type'].'/'.$data['id'];
$fields = $schema->fields; $fields = $schema->getFields();
$queryParams = $this->request->getQueryParams(); $queryParams = $this->request->getQueryParams();
@ -60,7 +69,7 @@ class Serializer
continue; continue;
} }
if (! ($field->isVisible)($this->request, $model)) { if (! evaluate($field->getVisible(), [$model, $this->request])) {
continue; continue;
} }
@ -68,7 +77,7 @@ class Serializer
$value = $this->attribute($field, $model, $adapter); $value = $this->attribute($field, $model, $adapter);
} elseif ($field instanceof Schema\Relationship) { } elseif ($field instanceof Schema\Relationship) {
$isIncluded = isset($include[$name]); $isIncluded = isset($include[$name]);
$isLinkage = ($field->linkage)($this->request); $isLinkage = evaluate($field->getLinkage(), [$this->request]);
if (! $isIncluded && ! $isLinkage) { if (! $isIncluded && ! $isLinkage) {
$value = $this->emptyRelationship($field, $resourceUrl); $value = $this->emptyRelationship($field, $resourceUrl);
@ -82,12 +91,14 @@ class Serializer
$data['fields'][$name] = $value; $data['fields'][$name] = $value;
} }
$data['links']['self'] = new JsonApi\Link\SelfLink($resourceUrl); $data['links']['self'] = new SelfLink($resourceUrl);
ksort($schema->meta); $metas = $schema->getMeta();
foreach ($schema->meta as $name => $meta) { ksort($metas);
$data['meta'][$name] = new JsonApi\Meta($meta->name, ($meta->value)($this->request, $model));
foreach ($metas as $name => $meta) {
$data['meta'][$name] = new Structure\Meta($meta->name, ($meta->value)($this->request, $model));
} }
$this->merge($data); $this->merge($data);
@ -95,10 +106,10 @@ class Serializer
return $data; return $data;
} }
private function attribute(Schema\Attribute $field, $model, AdapterInterface $adapter): JsonApi\Attribute private function attribute(Attribute $field, $model, AdapterInterface $adapter): Structure\Attribute
{ {
if ($field->getter) { if ($getter = $field->getGetter()) {
$value = ($field->getter)($this->request, $model); $value = $getter($model, $this->request);
} else { } else {
$value = $adapter->getAttribute($model, $field); $value = $adapter->getAttribute($model, $field);
} }
@ -107,18 +118,18 @@ class Serializer
$value = $value->format(DateTime::RFC3339); $value = $value->format(DateTime::RFC3339);
} }
return new JsonApi\Attribute($field->name, $value); return new Structure\Attribute($field->getName(), $value);
} }
private function toOne(Schema\HasOne $field, $model, AdapterInterface $adapter, bool $isIncluded, bool $isLinkage, array $include, string $resourceUrl) private function toOne(Schema\HasOne $field, $model, AdapterInterface $adapter, bool $isIncluded, bool $isLinkage, array $include, string $resourceUrl)
{ {
$links = $this->getRelationshipLinks($field, $resourceUrl); $links = $this->getRelationshipLinks($field, $resourceUrl);
$value = $isIncluded ? ($field->getter ? ($field->getter)($this->request, $model) : $adapter->getHasOne($model, $field)) : ($isLinkage && $field->loadable ? $adapter->getHasOneId($model, $field) : null); $value = $isIncluded ? (($getter = $field->getGetter()) ? $getter($model, $this->request) : $adapter->getHasOne($model, $field)) : ($isLinkage && $field->getLoadable() ? $adapter->getHasOneId($model, $field) : null);
if (! $value) { if (! $value) {
return new JsonApi\ToNull( return new Structure\ToNull(
$field->name, $field->getName(),
...$links ...$links
); );
} }
@ -130,8 +141,8 @@ class Serializer
} }
return new JsonApi\ToOne( return new ToOne(
$field->name, $field->getName(),
$identifier, $identifier,
...$links ...$links
); );
@ -139,8 +150,8 @@ class Serializer
private function toMany(Schema\HasMany $field, $model, AdapterInterface $adapter, bool $isIncluded, bool $isLinkage, array $include, string $resourceUrl) private function toMany(Schema\HasMany $field, $model, AdapterInterface $adapter, bool $isIncluded, bool $isLinkage, array $include, string $resourceUrl)
{ {
if ($field->getter) { if ($getter = $field->getGetter()) {
$value = ($field->getter)($this->request, $model); $value = $getter($model, $this->request);
} else { } else {
$value = ($isLinkage || $isIncluded) ? $adapter->getHasMany($model, $field) : null; $value = ($isLinkage || $isIncluded) ? $adapter->getHasMany($model, $field) : null;
} }
@ -157,36 +168,36 @@ class Serializer
} }
} }
return new JsonApi\ToMany( return new ToMany(
$field->name, $field->getName(),
new JsonApi\ResourceIdentifierCollection(...$identifiers), new ResourceIdentifierCollection(...$identifiers),
...$this->getRelationshipLinks($field, $resourceUrl) ...$this->getRelationshipLinks($field, $resourceUrl)
); );
} }
private function emptyRelationship(Schema\Relationship $field, string $resourceUrl): JsonApi\EmptyRelationship private function emptyRelationship(Relationship $field, string $resourceUrl): EmptyRelationship
{ {
return new JsonApi\EmptyRelationship( return new EmptyRelationship(
$field->name, $field->getName(),
...$this->getRelationshipLinks($field, $resourceUrl) ...$this->getRelationshipLinks($field, $resourceUrl)
); );
} }
private function getRelationshipLinks(Schema\Relationship $field, string $resourceUrl): array private function getRelationshipLinks(Relationship $field, string $resourceUrl): array
{ {
if (! $field->hasLinks) { if (! $field->hasLinks()) {
return []; return [];
} }
return [ return [
new JsonApi\Link\SelfLink($resourceUrl.'/relationships/'.$field->name), new SelfLink($resourceUrl.'/relationships/'.$field->getName()),
new JsonApi\Link\RelatedLink($resourceUrl.'/'.$field->name) new RelatedLink($resourceUrl.'/'.$field->getName())
]; ];
} }
private function addRelated(Schema\Relationship $field, $model, array $include): JsonApi\ResourceIdentifier private function addRelated(Relationship $field, $model, array $include): ResourceIdentifier
{ {
$relatedResource = $field->resource ? $this->api->getResource($field->resource) : $this->resourceForModel($model); $relatedResource = $field->getType() ? $this->api->getResource($field->getType()) : $this->resourceForModel($model);
return $this->resourceIdentifier( return $this->resourceIdentifier(
$this->addToMap($relatedResource, $model, $include) $this->addToMap($relatedResource, $model, $include)
@ -238,9 +249,9 @@ class Serializer
}, $items); }, $items);
} }
private function resourceObject(array $data): JsonApi\ResourceObject private function resourceObject(array $data): Structure\ResourceObject
{ {
return new JsonApi\ResourceObject( return new Structure\ResourceObject(
$data['type'], $data['type'],
$data['id'], $data['id'],
...array_values($data['fields']), ...array_values($data['fields']),
@ -249,9 +260,9 @@ class Serializer
); );
} }
private function resourceIdentifier(array $data): JsonApi\ResourceIdentifier private function resourceIdentifier(array $data): Structure\ResourceIdentifier
{ {
return new JsonApi\ResourceIdentifier( return new Structure\ResourceIdentifier(
$data['type'], $data['type'],
$data['id'] $data['id']
); );

View File

@ -1,8 +0,0 @@
<?php
namespace Tobyz\JsonApiServer;
interface StatusProviderInterface
{
public function getJsonApiStatus(): array;
}

64
src/functions.php Normal file
View File

@ -0,0 +1,64 @@
<?php
namespace Tobyz\JsonApiServer;
use Closure;
use Tobyz\JsonApiServer\Schema\Field;
function negate(Closure $condition)
{
return function (...$args) use ($condition) {
return ! $condition(...$args);
};
}
function wrap($value)
{
if (! $value instanceof Closure) {
$value = function () use ($value) {
return $value;
};
}
return $value;
}
function evaluate($condition, array $params)
{
return $condition === true || ($condition instanceof Closure && $condition(...$params));
}
function run_callbacks(array $callbacks, array $params)
{
foreach ($callbacks as $callback) {
$callback(...$params);
}
}
function has_value(array $data, Field $field)
{
return isset($data[$field->getLocation()][$field->getName()]);
}
function &get_value(array $data, Field $field)
{
return $data[$field->getLocation()][$field->getName()];
}
function set_value(array &$data, Field $field, $value)
{
$data[$field->getLocation()][$field->getName()] = $value;
}
function array_set(array $array, $key, $value)
{
$keys = explode('.', $key);
while (count($keys) > 1) {
$array = &$array[array_shift($keys)];
}
$array[array_shift($keys)] = $value;
return $array;
}

View File

@ -11,19 +11,19 @@
namespace Tobyz\Tests\JsonApiServer; namespace Tobyz\Tests\JsonApiServer;
use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\ServerRequest; use Zend\Diactoros\ServerRequest;
use Zend\Diactoros\Uri; use Zend\Diactoros\Uri;
abstract class AbstractTestCase extends TestCase abstract class AbstractTestCase extends TestCase
{ {
public static function assertEncodesTo(string $expected, $obj, string $message = '') use ArraySubsetAsserts;
protected function assertJsonApiDocumentSubset($subset, string $body, bool $checkForObjectIdentity = false, string $message = ''): void
{ {
self::assertEquals( static::assertArraySubset($subset, json_decode($body, true), $checkForObjectIdentity, $message);
json_decode($expected),
json_decode(json_encode($obj, JSON_UNESCAPED_SLASHES)),
$message
);
} }
protected function buildRequest(string $method, string $uri): ServerRequest protected function buildRequest(string $method, string $uri): ServerRequest

View File

@ -11,11 +11,11 @@
namespace Tobyz\Tests\JsonApiServer; namespace Tobyz\Tests\JsonApiServer;
use Tobyz\JsonApiServer\Api; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Exception\ForbiddenException;
use Tobyz\JsonApiServer\Serializer; use Tobyz\JsonApiServer\Serializer;
use Tobyz\JsonApiServer\Schema\Builder; use Tobyz\JsonApiServer\Schema\Type;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use JsonApiPhp\JsonApi; use JsonApiPhp\JsonApi;
use Zend\Diactoros\ServerRequest; use Zend\Diactoros\ServerRequest;
@ -25,9 +25,9 @@ class CreateTest extends AbstractTestCase
{ {
public function testResourceNotCreatableByDefault() public function testResourceNotCreatableByDefault()
{ {
$api = new Api('http://example.com'); $api = new JsonApi('http://example.com');
$api->resource('users', new MockAdapter(), function (Builder $schema) { $api->resource('users', new MockAdapter(), function (Type $schema) {
// //
}); });
@ -41,9 +41,9 @@ class CreateTest extends AbstractTestCase
public function testCreateResourceValidatesBody() public function testCreateResourceValidatesBody()
{ {
$api = new Api('http://example.com'); $api = new JsonApi('http://example.com');
$api->resource('users', new MockAdapter(), function (Builder $schema) { $api->resource('users', new MockAdapter(), function (Type $schema) {
$schema->creatable(); $schema->creatable();
}); });
@ -56,9 +56,9 @@ class CreateTest extends AbstractTestCase
public function testCreateResource() public function testCreateResource()
{ {
$api = new Api('http://example.com'); $api = new JsonApi('http://example.com');
$api->resource('users', $adapter = new MockAdapter(), function (Builder $schema) { $api->resource('users', $adapter = new MockAdapter(), function (Type $schema) {
$schema->creatable(); $schema->creatable();
$schema->attribute('name')->writable(); $schema->attribute('name')->writable();
@ -110,9 +110,9 @@ class CreateTest extends AbstractTestCase
] ]
]); ]);
$api = new Api('http://example.com'); $api = new JsonApi('http://example.com');
$api->resource('users', $adapter = new MockAdapter(), function (Builder $schema) use ($adapter, $request) { $api->resource('users', $adapter = new MockAdapter(), function (Type $schema) use ($adapter, $request) {
$schema->creatable(); $schema->creatable();
$schema->attribute('writable1')->writable(); $schema->attribute('writable1')->writable();
@ -148,9 +148,9 @@ class CreateTest extends AbstractTestCase
] ]
]); ]);
$api = new Api('http://example.com'); $api = new JsonApi('http://example.com');
$api->resource('users', $adapter = new MockAdapter(), function (Builder $schema) use ($adapter, $request) { $api->resource('users', $adapter = new MockAdapter(), function (Type $schema) use ($adapter, $request) {
$schema->creatable(); $schema->creatable();
$schema->attribute('readonly')->readonly(); $schema->attribute('readonly')->readonly();
@ -175,9 +175,9 @@ class CreateTest extends AbstractTestCase
] ]
]); ]);
$api = new Api('http://example.com'); $api = new JsonApi('http://example.com');
$api->resource('users', $adapter = new MockAdapter(), function (Builder $schema) use ($request) { $api->resource('users', $adapter = new MockAdapter(), function (Type $schema) use ($request) {
$schema->creatable(); $schema->creatable();
$schema->attribute('attribute1')->default('defaultValue'); $schema->attribute('attribute1')->default('defaultValue');

View File

@ -11,11 +11,11 @@
namespace Tobyz\Tests\JsonApiServer; namespace Tobyz\Tests\JsonApiServer;
use Tobyz\JsonApiServer\Api; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Exception\ForbiddenException;
use Tobyz\JsonApiServer\Serializer; use Tobyz\JsonApiServer\Serializer;
use Tobyz\JsonApiServer\Schema\Builder; use Tobyz\JsonApiServer\Schema\Type;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use JsonApiPhp\JsonApi; use JsonApiPhp\JsonApi;
use Zend\Diactoros\ServerRequest; use Zend\Diactoros\ServerRequest;
@ -25,9 +25,9 @@ class DeleteTest extends AbstractTestCase
{ {
public function testResourceNotDeletableByDefault() public function testResourceNotDeletableByDefault()
{ {
$api = new Api('http://example.com'); $api = new JsonApi('http://example.com');
$api->resource('users', new MockAdapter(), function (Builder $schema) { $api->resource('users', new MockAdapter(), function (Type $schema) {
// //
}); });
@ -45,9 +45,9 @@ class DeleteTest extends AbstractTestCase
'1' => $user = (object)['id' => '1'] '1' => $user = (object)['id' => '1']
]); ]);
$api = new Api('http://example.com'); $api = new JsonApi('http://example.com');
$api->resource('users', $usersAdapter, function (Builder $schema) { $api->resource('users', $usersAdapter, function (Type $schema) {
$schema->deletable(); $schema->deletable();
}); });

View File

@ -13,6 +13,7 @@ class MockAdapter implements AdapterInterface
{ {
public $models = []; public $models = [];
public $createdModel; public $createdModel;
public $query;
private $type; private $type;
public function __construct(array $models = [], string $type = null) public function __construct(array $models = [], string $type = null)
@ -28,10 +29,10 @@ class MockAdapter implements AdapterInterface
public function query() public function query()
{ {
return (object) []; return $this->query = (object) [];
} }
public function find($query, $id) public function find($query, string $id)
{ {
return $this->models[$id] ?? (object) ['id' => $id]; return $this->models[$id] ?? (object) ['id' => $id];
} }
@ -61,17 +62,17 @@ class MockAdapter implements AdapterInterface
return $model->{$this->getProperty($relationship)} ?? []; return $model->{$this->getProperty($relationship)} ?? [];
} }
public function applyAttribute($model, Attribute $attribute, $value) public function setAttribute($model, Attribute $attribute, $value): void
{ {
$model->{$this->getProperty($attribute)} = $value; $model->{$this->getProperty($attribute)} = $value;
} }
public function applyHasOne($model, HasOne $relationship, $related) public function setHasOne($model, HasOne $relationship, $related): void
{ {
$model->{$this->getProperty($relationship)} = $related; $model->{$this->getProperty($relationship)} = $related;
} }
public function save($model) public function save($model): void
{ {
$model->saveWasCalled = true; $model->saveWasCalled = true;
@ -80,59 +81,54 @@ class MockAdapter implements AdapterInterface
} }
} }
public function saveHasMany($model, HasMany $relationship, array $related) public function saveHasMany($model, HasMany $relationship, array $related): void
{ {
$model->saveHasManyWasCalled = true; $model->saveHasManyWasCalled = true;
} }
public function delete($model) public function delete($model): void
{ {
$model->deleteWasCalled = true; $model->deleteWasCalled = true;
} }
public function filterByIds($query, array $ids) public function filterByIds($query, array $ids): void
{ {
$query->filters[] = ['ids', $ids]; $query->filter[] = ['ids', $ids];
} }
public function filterByAttribute($query, Attribute $attribute, $value) public function filterByAttribute($query, Attribute $attribute, $value): void
{ {
$query->filters[] = [$attribute, $value]; $query->filter[] = [$attribute, $value];
} }
public function filterByHasOne($query, HasOne $relationship, array $ids) public function filterByHasOne($query, HasOne $relationship, array $ids): void
{ {
$query->filters[] = [$relationship, $ids]; $query->filter[] = [$relationship, $ids];
} }
public function filterByHasMany($query, HasMany $relationship, array $ids) public function filterByHasMany($query, HasMany $relationship, array $ids): void
{ {
$query->filters[] = [$relationship, $ids]; $query->filter[] = [$relationship, $ids];
} }
public function sortByAttribute($query, Attribute $attribute, string $direction) public function sortByAttribute($query, Attribute $attribute, string $direction): void
{ {
$query->sort[] = [$attribute, $direction]; $query->sort[] = [$attribute, $direction];
} }
public function paginate($query, int $limit, int $offset) public function paginate($query, int $limit, int $offset): void
{ {
$query->paginate[] = [$limit, $offset]; $query->paginate[] = [$limit, $offset];
} }
public function include($query, array $relationships) public function load(array $models, array $relationships): void
{
$query->include[] = $relationships;
}
public function load(array $models, array $relationships)
{ {
foreach ($models as $model) { foreach ($models as $model) {
$model->load[] = $relationships; $model->load[] = $relationships;
} }
} }
public function loadIds(array $models, Relationship $relationship) public function loadIds(array $models, Relationship $relationship): void
{ {
foreach ($models as $model) { foreach ($models as $model) {
$model->loadIds[] = $relationship; $model->loadIds[] = $relationship;
@ -141,11 +137,34 @@ class MockAdapter implements AdapterInterface
private function getProperty(Field $field) private function getProperty(Field $field)
{ {
return $field->property ?: $field->name; return $field->getProperty() ?: $field->getName();
} }
public function handles($model) public function represents($model): bool
{ {
return isset($model['type']) && $model['type'] === $this->type; return isset($model['type']) && $model['type'] === $this->type;
} }
/**
* Get the number of results from the query.
*
* @param $query
* @return int
*/
public function count($query): int
{
return count($this->models);
}
/**
* Get the ID of the related resource for a has-one relationship.
*
* @param $model
* @param HasOne $relationship
* @return mixed|null
*/
public function getHasOneId($model, HasOne $relationship): ?string
{
// TODO: Implement getHasOneId() method.
}
} }

View File

@ -11,21 +11,17 @@
namespace Tobyz\Tests\JsonApiServer; namespace Tobyz\Tests\JsonApiServer;
use Tobyz\JsonApiServer\Api; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Serializer; use Tobyz\JsonApiServer\Schema\Type;
use Tobyz\JsonApiServer\Schema\Builder;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use JsonApiPhp\JsonApi;
use Zend\Diactoros\ServerRequest;
use Zend\Diactoros\Uri;
class ShowTest extends AbstractTestCase class ShowTest extends AbstractTestCase
{ {
public function testResourceWithNoFields() public function test_resource_with_no_fields()
{ {
$api = new Api('http://example.com'); $api = new JsonApi('http://example.com');
$api->resource('users', new MockAdapter(), function (Builder $schema) { $api->resource('users', new MockAdapter(), function (Type $schema) {
// no fields // no fields
}); });
@ -35,17 +31,19 @@ class ShowTest extends AbstractTestCase
$this->assertEquals($response->getStatusCode(), 200); $this->assertEquals($response->getStatusCode(), 200);
$this->assertEquals( $this->assertEquals(
[ [
'type' => 'users', 'data' => [
'id' => '1', 'type' => 'users',
'links' => [ 'id' => '1',
'self' => 'http://example.com/users/1' 'links' => [
'self' => 'http://example.com/users/1'
]
] ]
], ],
json_decode($response->getBody(), true)['data'] json_decode($response->getBody(), true)
); );
} }
public function testAttributes() public function test_attributes()
{ {
$adapter = new MockAdapter([ $adapter = new MockAdapter([
'1' => (object) [ '1' => (object) [
@ -58,9 +56,9 @@ class ShowTest extends AbstractTestCase
$request = $this->buildRequest('GET', '/users/1'); $request = $this->buildRequest('GET', '/users/1');
$api = new Api('http://example.com'); $api = new JsonApi('http://example.com');
$api->resource('users', $adapter, function (Builder $schema) { $api->resource('users', $adapter, function (Type $schema) {
$schema->attribute('attribute1'); $schema->attribute('attribute1');
$schema->attribute('attribute2', 'property2'); $schema->attribute('attribute2', 'property2');
$schema->attribute('attribute3')->property('property3'); $schema->attribute('attribute3')->property('property3');
@ -88,9 +86,9 @@ class ShowTest extends AbstractTestCase
$request = $this->buildRequest('GET', '/users/1'); $request = $this->buildRequest('GET', '/users/1');
$api = new Api('http://example.com'); $api = new JsonApi('http://example.com');
$api->resource('users', $adapter, function (Builder $schema) use ($model, $request) { $api->resource('users', $adapter, function (Type $schema) use ($model, $request) {
$schema->attribute('attribute1') $schema->attribute('attribute1')
->get(function ($arg1, $arg2) use ($model, $request) { ->get(function ($arg1, $arg2) use ($model, $request) {
$this->assertInstanceOf(Request::class, $arg1); $this->assertInstanceOf(Request::class, $arg1);
@ -120,8 +118,8 @@ class ShowTest extends AbstractTestCase
$request = $this->buildRequest('GET', '/users/1'); $request = $this->buildRequest('GET', '/users/1');
$api = new Api('http://example.com'); $api = new JsonApi('http://example.com');
$api->resource('users', $adapter, function (Builder $schema) use ($model, $request) { $api->resource('users', $adapter, function (Type $schema) use ($model, $request) {
$schema->attribute('visible1'); $schema->attribute('visible1');
$schema->attribute('visible2')->visible(); $schema->attribute('visible2')->visible();
@ -183,19 +181,19 @@ class ShowTest extends AbstractTestCase
$request = $this->buildRequest('GET', '/users/1') $request = $this->buildRequest('GET', '/users/1')
->withQueryParams(['include' => 'phone,phone2,phone3']); ->withQueryParams(['include' => 'phone,phone2,phone3']);
$api = new Api('http://example.com'); $api = new JsonApi('http://example.com');
$api->resource('users', $usersAdapter, function (Builder $schema) { $api->resource('users', $usersAdapter, function (Type $schema) {
$schema->hasOne('phone'); $schema->hasOne('phone');
$schema->hasOne('phone2', 'phones', 'property2'); $schema->hasOne('phone2', 'phones', 'property2');
$schema->hasOne('phone3') $schema->hasOne('phone3')
->resource('phones') ->type('phones')
->property('property3'); ->property('property3');
}); });
$api->resource('phones', $phonesAdapter, function (Builder $schema) { $api->resource('phones', $phonesAdapter, function (Type $schema) {
$schema->attribute('number'); $schema->attribute('number');
}); });
@ -267,15 +265,15 @@ class ShowTest extends AbstractTestCase
$request = $this->buildRequest('GET', '/users/1') $request = $this->buildRequest('GET', '/users/1')
->withQueryParams(['include' => 'phone2']); ->withQueryParams(['include' => 'phone2']);
$api = new Api('http://example.com'); $api = new JsonApi('http://example.com');
$api->resource('users', $usersAdapter, function (Builder $schema) { $api->resource('users', $usersAdapter, function (Type $schema) {
$schema->hasOne('phone'); $schema->hasOne('phone');
$schema->hasOne('phone2', 'phones', 'property2'); $schema->hasOne('phone2', 'phones', 'property2');
}); });
$api->resource('phones', $phonesAdapter, function (Builder $schema) { $api->resource('phones', $phonesAdapter, function (Type $schema) {
$schema->attribute('number'); $schema->attribute('number');
}); });
@ -338,9 +336,9 @@ class ShowTest extends AbstractTestCase
public function testHasManyRelationshipNotIncludableByDefault() public function testHasManyRelationshipNotIncludableByDefault()
{ {
$api = new Api('http://example.com'); $api = new JsonApi('http://example.com');
$api->resource('users', new MockAdapter(), function (Builder $schema) { $api->resource('users', new MockAdapter(), function (Type $schema) {
$schema->hasMany('groups'); $schema->hasMany('groups');
}); });
@ -365,9 +363,9 @@ class ShowTest extends AbstractTestCase
] ]
]); ]);
$api = new Api('http://example.com'); $api = new JsonApi('http://example.com');
$api->resource('users', $usersAdapter, function (Builder $schema) { $api->resource('users', $usersAdapter, function (Type $schema) {
$schema->hasMany('groups'); $schema->hasMany('groups');
}); });
@ -400,11 +398,11 @@ class ShowTest extends AbstractTestCase
] ]
]); ]);
$api = new Api('http://example.com'); $api = new JsonApi('http://example.com');
$relationships = []; $relationships = [];
$api->resource('users', $usersAdapter, function (Builder $schema) use (&$relationships) { $api->resource('users', $usersAdapter, function (Type $schema) use (&$relationships) {
$relationships[] = $schema->hasMany('groups1', 'groups', 'property1') $relationships[] = $schema->hasMany('groups1', 'groups', 'property1')
->includable(); ->includable();
@ -412,7 +410,7 @@ class ShowTest extends AbstractTestCase
->includable(); ->includable();
}); });
$api->resource('groups', $groupsAdapter, function (Builder $schema) { $api->resource('groups', $groupsAdapter, function (Type $schema) {
$schema->attribute('name'); $schema->attribute('name');
}); });
@ -519,19 +517,19 @@ class ShowTest extends AbstractTestCase
'1' => $post = (object) ['id' => '1', 'user' => $user] '1' => $post = (object) ['id' => '1', 'user' => $user]
]); ]);
$api = new Api('http://example.com'); $api = new JsonApi('http://example.com');
$relationships = []; $relationships = [];
$api->resource('posts', $postsAdapter, function (Builder $schema) use (&$relationships) { $api->resource('posts', $postsAdapter, function (Type $schema) use (&$relationships) {
$relationships[] = $schema->hasOne('user'); $relationships[] = $schema->hasOne('user');
}); });
$api->resource('users', $usersAdapter, function (Builder $schema) use (&$relationships) { $api->resource('users', $usersAdapter, function (Type $schema) use (&$relationships) {
$relationships[] = $schema->hasMany('groups')->includable(); $relationships[] = $schema->hasMany('groups')->includable();
}); });
$api->resource('groups', $groupsAdapter, function (Builder $schema) { $api->resource('groups', $groupsAdapter, function (Type $schema) {
$schema->attribute('name'); $schema->attribute('name');
}); });

View File

@ -0,0 +1,107 @@
<?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\Tests\JsonApiServer\feature;
use Psr\Http\Message\ServerRequestInterface;
use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Schema\Type;
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
use Tobyz\Tests\JsonApiServer\MockAdapter;
class AttributeFilterableTest extends AbstractTestCase
{
/**
* @var JsonApi
*/
private $api;
/**
* @var MockAdapter
*/
private $adapter;
public function setUp(): void
{
$this->api = new JsonApi('http://example.com');
$this->adapter = new MockAdapter();
}
public function test_attributes_are_not_filterable_by_default()
{
$this->api->resource('users', $this->adapter, function (Type $type) {
$type->attribute('field');
});
$this->expectException(BadRequestException::class);
$this->api->handle(
$this->buildRequest('GET', '/users')
->withQueryParams(['filter' => ['field' => 'Toby']])
);
}
public function test_attributes_can_be_filterable()
{
$attribute = null;
$this->api->resource('users', $this->adapter, function (Type $type) use (&$attribute) {
$attribute = $type->attribute('name')->filterable();
});
$this->api->handle(
$this->buildRequest('GET', '/users')
->withQueryParams(['filter' => ['name' => 'Toby']])
);
$this->assertContains([$attribute, 'Toby'], $this->adapter->query->filter);
}
public function test_attributes_can_be_filterable_with_custom_logic()
{
$called = false;
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
$type->attribute('name')
->filterable(function ($query, $value, $request) use (&$called) {
$this->assertSame($this->adapter->query, $query);
$this->assertEquals('Toby', $value);
$this->assertInstanceOf(ServerRequestInterface::class, $request);
$called = true;
});
});
$this->api->handle(
$this->buildRequest('GET', '/users')
->withQueryParams(['filter' => ['name' => 'Toby']])
);
$this->assertTrue($called);
$this->assertTrue(empty($this->adapter->query->filter));
}
public function test_attributes_can_be_explicitly_not_filterable()
{
$this->api->resource('users', $this->adapter, function (Type $type) {
$type->attribute('name')->notFilterable();
});
$this->expectException(BadRequestException::class);
$this->api->handle(
$this->buildRequest('GET', '/users')
->withQueryParams(['filter' => ['name' => 'Toby']])
);
}
}

View File

@ -0,0 +1,107 @@
<?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\Tests\JsonApiServer\feature;
use Psr\Http\Message\ServerRequestInterface;
use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Schema\Type;
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
use Tobyz\Tests\JsonApiServer\MockAdapter;
class AttributeSortableTest extends AbstractTestCase
{
/**
* @var JsonApi
*/
private $api;
/**
* @var MockAdapter
*/
private $adapter;
public function setUp(): void
{
$this->api = new JsonApi('http://example.com');
$this->adapter = new MockAdapter();
}
public function test_attributes_can_be_sortable()
{
$attribute = null;
$this->api->resource('users', $this->adapter, function (Type $type) use (&$attribute) {
$attribute = $type->attribute('name')->sortable();
});
$this->api->handle(
$this->buildRequest('GET', '/users')
->withQueryParams(['sort' => 'name'])
);
$this->assertContains([$attribute, 'asc'], $this->adapter->query->sort);
}
public function test_attributes_can_be_sortable_with_custom_logic()
{
$called = false;
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
$type->attribute('name')
->sortable(function ($query, $direction, $request) use (&$called) {
$this->assertSame($this->adapter->query, $query);
$this->assertEquals('asc', $direction);
$this->assertInstanceOf(ServerRequestInterface::class, $request);
$called = true;
});
});
$this->api->handle(
$this->buildRequest('GET', '/users')
->withQueryParams(['sort' => 'name'])
);
$this->assertTrue($called);
$this->assertTrue(empty($this->adapter->query->sort));
}
public function test_attributes_are_not_sortable_by_default()
{
$this->api->resource('users', $this->adapter, function (Type $type) {
$type->attribute('name');
});
$this->expectException(BadRequestException::class);
$this->api->handle(
$this->buildRequest('GET', '/users')
->withQueryParams(['sort' => 'name'])
);
}
public function test_attributes_can_be_explicitly_not_sortable()
{
$this->api->resource('users', $this->adapter, function (Type $type) {
$type->attribute('name')->notSortable();
});
$this->expectException(BadRequestException::class);
$this->api->handle(
$this->buildRequest('GET', '/users')
->withQueryParams(['sort' => 'name'])
);
}
}

View File

@ -0,0 +1,393 @@
<?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\Tests\JsonApiServer\feature;
use JsonApiPhp\JsonApi\ErrorDocument;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ServerRequestInterface;
use Tobyz\JsonApiServer\ErrorProviderInterface;
use Tobyz\JsonApiServer\Exception\UnprocessableEntityException;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Schema\Attribute;
use Tobyz\JsonApiServer\Schema\Type;
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
use Tobyz\Tests\JsonApiServer\MockAdapter;
class AttributeTest extends AbstractTestCase
{
/**
* @var JsonApi
*/
private $api;
/**
* @var MockAdapter
*/
private $adapter;
public function setUp(): void
{
$this->api = new JsonApi('http://example.com');
$this->adapter = new MockAdapter([
'1' => (object) ['id' => '1', 'name' => 'Toby', 'color' => 'yellow'],
'2' => (object) ['id' => '2', 'name' => 'Franz', 'color' => 'blue'],
]);
}
public function test_multiple_attributes()
{
$this->api->resource('users', $this->adapter, function (Type $type) {
$type->attribute('name');
$type->attribute('color');
});
$response = $this->api->handle(
$this->buildRequest('GET', '/users/1')
);
$this->assertJsonApiDocumentSubset([
'data' => [
'attributes' => [
'name' => 'Toby',
'color' => 'yellow',
],
]
], $response->getBody());
}
public function test_attributes_can_specify_a_property()
{
$this->api->resource('users', $this->adapter, function (Type $type) {
$type->attribute('name')
->property('color');
});
$response = $this->api->handle(
$this->buildRequest('GET', '/users/1')
);
$this->assertJsonApiDocumentSubset([
'data' => [
'attributes' => [
'name' => 'yellow',
],
]
], $response->getBody());
}
public function test_attributes_can_have_getters()
{
$called = false;
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
$type->attribute('name')
->get('Toby');
$type->attribute('color')
->get(function ($model, $request) use (&$called) {
$called = true;
$this->assertSame($this->adapter->models['1'], $model);
$this->assertInstanceOf(RequestInterface::class, $request);
return 'yellow';
});
});
$response = $this->api->handle(
$this->buildRequest('GET', '/users/1')
);
$this->assertTrue($called);
$this->assertJsonApiDocumentSubset([
'data' => [
'attributes' => [
'name' => 'Toby',
'color' => 'yellow',
],
]
], $response->getBody());
}
public function test_attribute_setter_receives_correct_parameters()
{
$called = false;
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
$type->updatable();
$type->attribute('writable')
->writable()
->set(function ($model, $value, $request) use (&$called) {
$this->assertSame($this->adapter->models['1'], $model);
$this->assertEquals('value', $value);
$this->assertInstanceOf(RequestInterface::class, $request);
$called = true;
});
});
$this->api->handle(
$this->buildRequest('PATCH', '/users/1')
->withParsedBody([
'data' => [
'type' => 'users',
'id' => '1',
'attributes' => [
'writable' => 'value',
]
]
])
);
$this->assertTrue($called);
}
public function test_attribute_setter_precludes_adapter_action()
{
$this->api->resource('users', $this->adapter, function (Type $type) {
$type->updatable();
$type->attribute('writable')
->writable()
->set(function () {});
});
$this->api->handle(
$this->buildRequest('PATCH', '/users/1')
->withParsedBody([
'data' => [
'type' => 'users',
'id' => '1',
'attributes' => [
'writable' => 'value',
]
]
])
);
$this->assertTrue(empty($this->adapter->models['1']->writable));
}
public function test_attribute_saver_receives_correct_parameters()
{
$called = false;
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
$type->updatable();
$type->attribute('writable')
->writable()
->save(function ($model, $value, $request) use (&$called) {
$this->assertSame($this->adapter->models['1'], $model);
$this->assertEquals('value', $value);
$this->assertInstanceOf(RequestInterface::class, $request);
$called = true;
});
});
$this->api->handle(
$this->buildRequest('PATCH', '/users/1')
->withParsedBody([
'data' => [
'type' => 'users',
'id' => '1',
'attributes' => [
'writable' => 'value',
]
]
])
);
$this->assertTrue($called);
}
public function test_attribute_saver_precludes_adapter_action()
{
$this->api->resource('users', $this->adapter, function (Type $type) {
$type->updatable();
$type->attribute('writable')
->writable()
->save(function () {});
});
$this->api->handle(
$this->buildRequest('PATCH', '/users/1')
->withParsedBody([
'data' => [
'type' => 'users',
'id' => '1',
'attributes' => [
'writable' => 'value',
]
]
])
);
$this->assertTrue(empty($this->adapter->models['1']->writable));
}
public function test_attributes_can_run_callback_after_being_saved()
{
$called = false;
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
$type->updatable();
$type->attribute('writable')
->writable()
->saved(function ($model, $value, $request) use (&$called) {
$this->assertTrue($this->adapter->models['1']->saveWasCalled);
$this->assertSame($this->adapter->models['1'], $model);
$this->assertEquals('value', $value);
$this->assertInstanceOf(RequestInterface::class, $request);
$called = true;
});
});
$this->api->handle(
$this->buildRequest('PATCH', '/users/1')
->withParsedBody([
'data' => [
'type' => 'users',
'id' => '1',
'attributes' => [
'writable' => 'value',
]
]
])
);
$this->assertTrue($called);
}
public function test_attributes_can_have_default_values()
{
$this->api->resource('users', $this->adapter, function (Type $type) {
$type->creatable();
$type->attribute('name')
->default('Toby');
$type->attribute('color')
->default(function () {
return 'yellow';
});
});
$this->api->handle(
$this->buildRequest('POST', '/users')
->withParsedBody([
'data' => [
'type' => 'users',
]
])
);
$this->assertEquals('Toby', $this->adapter->createdModel->name);
$this->assertEquals('yellow', $this->adapter->createdModel->color);
}
public function test_attribute_default_callback_receives_correct_parameters()
{
$this->api->resource('users', $this->adapter, function (Type $type) {
$type->creatable();
$type->attribute('attribute')
->default(function ($request) {
$this->assertInstanceOf(RequestInterface::class, $request);
});
});
$this->api->handle(
$this->buildRequest('POST', '/users')
->withParsedBody([
'data' => [
'type' => 'users',
]
])
);
}
public function test_attribute_values_from_request_override_default_values()
{
$this->api->resource('users', $this->adapter, function (Type $type) {
$type->creatable();
$type->attribute('name')
->writable()
->default('Toby');
});
$this->api->handle(
$this->buildRequest('POST', '/users')
->withParsedBody([
'data' => [
'type' => 'users',
'attributes' => [
'name' => 'Franz',
]
]
])
);
$this->assertEquals('Franz', $this->adapter->createdModel->name);
}
public function test_attributes_can_be_validated()
{
$this->api->resource('users', $this->adapter, function (Type $type) {
$type->creatable();
$type->attribute('name')
->writable()
->validate(function ($fail, $value, $model, $request, $field) {
$this->assertEquals('Toby', $value);
$this->assertSame($this->adapter->createdModel, $model);
$this->assertInstanceOf(ServerRequestInterface::class, $request);
$this->assertInstanceOf(Attribute::class, $field);
$fail('detail');
});
});
$this->expectException(UnprocessableEntityException::class);
try {
$this->api->handle(
$this->buildRequest('POST', '/users')
->withParsedBody([
'data' => [
'type' => 'users',
'attributes' => [
'name' => 'Toby',
]
]
])
);
} catch (ErrorProviderInterface $e) {
$document = new ErrorDocument(...$e->getJsonApiErrors());
$this->assertArraySubset([
'errors' => [
[
'status' => '422',
'source' => [
'pointer' => '/data/attributes/name'
],
'detail' => 'detail'
]
]
], json_decode(json_encode($document), true));
throw $e;
}
}
}

View File

@ -0,0 +1,274 @@
<?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\Tests\JsonApiServer\feature;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ServerRequestInterface;
use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Schema\Type;
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
use Tobyz\Tests\JsonApiServer\MockAdapter;
class AttributeWritableTest extends AbstractTestCase
{
/**
* @var JsonApi
*/
private $api;
/**
* @var MockAdapter
*/
private $adapter;
public function setUp(): void
{
$this->api = new JsonApi('http://example.com');
$this->adapter = new MockAdapter([
'1' => (object) ['id' => '1']
]);
}
public function test_attributes_are_readonly_by_default()
{
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
$type->updatable();
$type->attribute('readonly');
});
$this->expectException(BadRequestException::class);
$this->api->handle(
$this->buildRequest('PATCH', '/users/1')
->withParsedBody([
'data' => [
'type' => 'users',
'id' => '1',
'attributes' => [
'readonly' => 'value',
]
]
])
);
}
public function test_attributes_can_be_explicitly_writable()
{
$this->api->resource('users', $this->adapter, function (Type $type) {
$type->updatable();
$type->attribute('writable')->writable();
});
$response = $this->api->handle(
$this->buildRequest('PATCH', '/users/1')
->withParsedBody([
'data' => [
'type' => 'users',
'id' => '1',
'attributes' => [
'writable' => 'value',
]
]
])
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('value', $this->adapter->models['1']->writable);
}
public function test_attributes_can_be_conditionally_writable()
{
$this->api->resource('users', $this->adapter, function (Type $type) {
$type->updatable();
$type->attribute('writable')
->writable(function () { return true; });
});
$response = $this->api->handle(
$this->buildRequest('PATCH', '/users/1')
->withParsedBody([
'data' => [
'type' => 'users',
'id' => '1',
'attributes' => [
'writable' => 'value',
]
]
])
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('value', $this->adapter->models['1']->writable);
}
public function test_attributes_can_be_conditionally_not_writable()
{
$this->api->resource('users', $this->adapter, function (Type $type) {
$type->updatable();
$type->attribute('writable')
->writable(function () { return false; });
});
$this->expectException(BadRequestException::class);
$this->api->handle(
$this->buildRequest('PATCH', '/users/1')
->withParsedBody([
'data' => [
'type' => 'users',
'id' => '1',
'attributes' => [
'writable' => 'value',
]
]
])
);
}
public function test_attribute_writable_callback_receives_correct_parameters()
{
$called = false;
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
$type->updatable();
$type->attribute('writable')
->writable(function ($model, $request) use (&$called) {
$this->assertSame($this->adapter->models['1'], $model);
$this->assertInstanceOf(ServerRequestInterface::class, $request);
return $called = true;
});
});
$this->api->handle(
$this->buildRequest('PATCH', '/users/1')
->withParsedBody([
'data' => [
'type' => 'users',
'id' => '1',
'attributes' => [
'writable' => 'value',
]
]
])
);
$this->assertTrue($called);
}
public function test_attributes_can_be_explicitly_readonly()
{
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
$type->updatable();
$type->attribute('readonly')->readonly();
});
$this->expectException(BadRequestException::class);
$this->api->handle(
$this->buildRequest('PATCH', '/users/1')
->withParsedBody([
'data' => [
'type' => 'users',
'id' => '1',
'attributes' => [
'readonly' => 'value',
]
]
])
);
}
public function test_attributes_can_be_conditionally_readonly()
{
$this->api->resource('users', $this->adapter, function (Type $type) {
$type->updatable();
$type->attribute('readonly')
->readonly(function () { return true; });
});
$this->expectException(BadRequestException::class);
$this->api->handle(
$this->buildRequest('PATCH', '/users/1')
->withParsedBody([
'data' => [
'type' => 'users',
'id' => '1',
'attributes' => [
'readonly' => 'value',
]
]
])
);
}
public function test_attribute_readonly_callback_receives_correct_parameters()
{
$called = false;
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
$type->updatable();
$type->attribute('readonly')
->readonly(function ($model, $request) use (&$called) {
$called = true;
$this->assertSame($this->adapter->models['1'], $model);
$this->assertInstanceOf(RequestInterface::class, $request);
return false;
});
});
$this->api->handle(
$this->buildRequest('PATCH', '/users/1')
->withParsedBody([
'data' => [
'type' => 'users',
'id' => '1',
'attributes' => [
'readonly' => 'value',
]
]
])
);
$this->assertTrue($called);
}
public function test_attributes_can_be_conditionally_not_readonly()
{
$this->api->resource('users', $this->adapter, function (Type $type) {
$type->updatable();
$type->attribute('writable')
->readonly(function () { return false; });
});
$response = $this->api->handle(
$this->buildRequest('PATCH', '/users/1')
->withParsedBody([
'data' => [
'type' => 'users',
'id' => '1',
'attributes' => [
'writable' => 'value',
]
]
])
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('value', $this->adapter->models['1']->writable);
}
}

174
tests/feature/BasicTest.php Normal file
View File

@ -0,0 +1,174 @@
<?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\Tests\JsonApiServer\feature;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Schema\Type;
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
use Tobyz\Tests\JsonApiServer\MockAdapter;
class BasicTest extends AbstractTestCase
{
/**
* @var JsonApi
*/
private $api;
public function setUp(): void
{
$this->api = new JsonApi('http://example.com');
$adapter = new MockAdapter([
'1' => (object) [
'id' => '1',
'name' => 'Toby',
],
'2' => (object) [
'id' => '2',
'name' => 'Franz',
],
]);
$this->api->resource('users', $adapter, function (Type $type) {
$type->attribute('name')->writable();
$type->creatable();
$type->updatable();
$type->deletable();
});
}
public function test_show_resource()
{
$response = $this->api->handle(
$this->buildRequest('GET', '/users/1')
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertJsonApiDocumentSubset([
'data' => [
'type' => 'users',
'id' => '1',
'attributes' => [
'name' => 'Toby'
],
'links' => [
'self' => 'http://example.com/users/1'
]
]
], $response->getBody());
}
public function test_list_resources()
{
$response = $this->api->handle(
$this->buildRequest('GET', '/users')
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertJsonApiDocumentSubset([
'data' => [
[
'type' => 'users',
'id' => '1',
'attributes' => [
'name' => 'Toby'
],
'links' => [
'self' => 'http://example.com/users/1'
]
],
[
'type' => 'users',
'id' => '2',
'attributes' => [
'name' => 'Franz'
],
'links' => [
'self' => 'http://example.com/users/2'
]
]
]
], $response->getBody());
}
public function test_create_resource()
{
$response = $this->api->handle(
$this->buildRequest('POST', '/users')
->withParsedBody([
'data' => [
'type' => 'users',
'attributes' => [
'name' => 'Bob',
],
],
])
);
$this->assertEquals(201, $response->getStatusCode());
$this->assertJsonApiDocumentSubset([
'data' => [
'type' => 'users',
'id' => '1',
'attributes' => [
'name' => 'Bob',
],
'links' => [
'self' => 'http://example.com/users/1',
],
],
], $response->getBody());
}
public function test_update_resource()
{
$response = $this->api->handle(
$this->buildRequest('PATCH', '/users/1')
->withParsedBody([
'data' => [
'type' => 'users',
'id' => '1',
'attributes' => [
'name' => 'Bob',
],
],
])
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertJsonApiDocumentSubset([
'data' => [
'type' => 'users',
'id' => '1',
'attributes' => [
'name' => 'Bob',
],
'links' => [
'self' => 'http://example.com/users/1',
],
],
], $response->getBody());
}
public function test_delete_resource()
{
$response = $this->api->handle(
$this->buildRequest('DELETE', '/users/1')
);
$this->assertEquals(204, $response->getStatusCode());
}
}

View File

@ -0,0 +1,237 @@
<?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\Tests\JsonApiServer\feature;
use Psr\Http\Message\RequestInterface;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Schema\Type;
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
use Tobyz\Tests\JsonApiServer\MockAdapter;
class FieldVisibilityTest extends AbstractTestCase
{
/**
* @var JsonApi
*/
private $api;
/**
* @var MockAdapter
*/
private $adapter;
public function setUp(): void
{
$this->api = new JsonApi('http://example.com');
$this->adapter = new MockAdapter([
'1' => (object) ['id' => '1']
]);
}
public function test_fields_are_visible_by_default()
{
$this->api->resource('users', new MockAdapter, function (Type $type) {
$type->attribute('visibleAttribute');
$type->hasOne('visibleHasOne');
$type->hasMany('visibleHasMany');
});
$response = $this->api->handle(
$this->buildRequest('GET', '/users/1')
);
$document = json_decode($response->getBody(), true);
$attributes = $document['data']['attributes'] ?? [];
$relationships = $document['data']['relationships'] ?? [];
$this->assertArrayHasKey('visibleAttribute', $attributes);
$this->assertArrayHasKey('visibleHasOne', $relationships);
$this->assertArrayHasKey('visibleHasMany', $relationships);
}
public function test_fields_can_be_explicitly_visible()
{
$this->api->resource('users', new MockAdapter, function (Type $type) {
$type->attribute('visibleAttribute')->visible();
$type->hasOne('visibleHasOne')->visible();
$type->hasMany('visibleHasMany')->visible();
});
$response = $this->api->handle(
$this->buildRequest('GET', '/users/1')
);
$document = json_decode($response->getBody(), true);
$attributes = $document['data']['attributes'] ?? [];
$relationships = $document['data']['relationships'] ?? [];
$this->assertArrayHasKey('visibleAttribute', $attributes);
$this->assertArrayHasKey('visibleHasOne', $relationships);
$this->assertArrayHasKey('visibleHasMany', $relationships);
}
public function test_fields_can_be_conditionally_visible()
{
$this->api->resource('users', new MockAdapter, function (Type $type) {
$type->attribute('visibleAttribute')
->visible(function () { return true; });
$type->attribute('hiddenAttribute')
->visible(function () { return false; });
$type->hasOne('visibleHasOne')
->visible(function () { return true; });
$type->hasOne('hiddenHasOne')
->visible(function () { return false; });
$type->hasMany('visibleHasMany')
->visible(function () { return true; });
$type->hasMany('hiddenHasMany')
->visible(function () { return false; });
});
$response = $this->api->handle(
$this->buildRequest('GET', '/users/1')
);
$document = json_decode($response->getBody(), true);
$attributes = $document['data']['attributes'] ?? [];
$relationships = $document['data']['relationships'] ?? [];
$this->assertArrayHasKey('visibleAttribute', $attributes);
$this->assertArrayHasKey('visibleHasOne', $relationships);
$this->assertArrayHasKey('visibleHasMany', $relationships);
$this->assertArrayNotHasKey('hiddenAttribute', $attributes);
$this->assertArrayNotHasKey('hiddenHasOne', $relationships);
$this->assertArrayNotHasKey('hiddenHasMany', $relationships);
}
public function test_visible_callback_receives_correct_parameters()
{
$called = 0;
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
$callback = function ($model, $request) use (&$called) {
$this->assertSame($this->adapter->models['1'], $model);
$this->assertInstanceOf(RequestInterface::class, $request);
$called++;
};
$type->attribute('attribute')
->visible($callback);
$type->hasOne('hasOne')
->visible($callback);
$type->hasMany('hasMany')
->visible($callback);
});
$this->api->handle(
$this->buildRequest('GET', '/users/1')
);
$this->assertEquals(3, $called);
}
public function test_fields_can_be_explicitly_hidden()
{
$this->api->resource('users', new MockAdapter, function (Type $type) {
$type->attribute('hiddenAttribute')->hidden();
$type->hasOne('hiddenHasOne')->hidden();
$type->hasMany('hiddenHasMany')->hidden();
});
$response = $this->api->handle(
$this->buildRequest('GET', '/users/1')
);
$document = json_decode($response->getBody(), true);
$attributes = $document['data']['attributes'] ?? [];
$relationships = $document['data']['relationships'] ?? [];
$this->assertArrayNotHasKey('hiddenAttribute', $attributes);
$this->assertArrayNotHasKey('hiddenHasOne', $relationships);
$this->assertArrayNotHasKey('hiddenHasMany', $relationships);
}
public function test_fields_can_be_conditionally_hidden()
{
$this->api->resource('users', new MockAdapter, function (Type $type) {
$type->attribute('visibleAttribute')
->hidden(function () { return false; });
$type->attribute('hiddenAttribute')
->hidden(function () { return true; });
$type->hasOne('visibleHasOne')
->hidden(function () { return false; });
$type->hasOne('hiddenHasOne')
->hidden(function () { return true; });
$type->hasMany('visibleHasMany')
->hidden(function () { return false; });
$type->hasMany('hiddenHasMany')
->hidden(function () { return true; });
});
$response = $this->api->handle(
$this->buildRequest('GET', '/users/1')
);
$document = json_decode($response->getBody(), true);
$attributes = $document['data']['attributes'] ?? [];
$relationships = $document['data']['relationships'] ?? [];
$this->assertArrayHasKey('visibleAttribute', $attributes);
$this->assertArrayHasKey('visibleHasOne', $relationships);
$this->assertArrayHasKey('visibleHasMany', $relationships);
$this->assertArrayNotHasKey('hiddenAttribute', $attributes);
$this->assertArrayNotHasKey('hiddenHasOne', $relationships);
$this->assertArrayNotHasKey('hiddenHasMany', $relationships);
}
public function test_hidden_callback_receives_correct_parameters()
{
$called = 0;
$this->api->resource('users', $this->adapter, function (Type $type) use (&$called) {
$callback = function ($model, $request) use (&$called) {
$this->assertSame($this->adapter->models['1'], $model);
$this->assertInstanceOf(RequestInterface::class, $request);
$called++;
};
$type->attribute('attribute')
->hidden($callback);
$type->hasOne('hasOne')
->hidden($callback);
$type->hasMany('hasMany')
->hidden($callback);
});
$this->api->handle(
$this->buildRequest('GET', '/users/1')
);
$this->assertEquals(3, $called);
}
}

View File

@ -0,0 +1,77 @@
<?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\Tests\JsonApiServer\specification;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Exception\NotAcceptableException;
use Tobyz\JsonApiServer\Exception\UnsupportedMediaTypeException;
use Tobyz\JsonApiServer\Schema\Type;
use Tobyz\Tests\JsonApiServer\AbstractTestCase;
use Tobyz\Tests\JsonApiServer\MockAdapter;
class ContentNegotiationTest extends AbstractTestCase
{
/**
* @var JsonApi
*/
private $api;
public function setUp(): void
{
$this->api = new JsonApi('http://example.com');
$this->api->resource('users', new MockAdapter(), function (Type $type) {
// no fields
});
}
public function testJsonApiContentTypeIsReturned()
{
$response = $this->api->handle(
$this->buildRequest('GET', '/users/1')
);
$this->assertEquals(
'application/vnd.api+json',
$response->getHeaderLine('Content-Type')
);
}
public function testErrorWhenValidRequestContentTypeHasParameters()
{
$request = $this->buildRequest('PATCH', '/users/1')
->withHeader('Content-Type', 'application/vnd.api+json;profile="http://example.com/last-modified"');
$this->expectException(UnsupportedMediaTypeException::class);
$this->api->handle($request);
}
public function testErrorWhenAllValidAcceptsHaveParameters()
{
$request = $this->buildRequest('GET', '/users/1')
->withHeader('Accept', 'application/vnd.api+json;profile="http://example.com/last-modified", application/vnd.api+json;profile="http://example.com/versioning"');
$this->expectException(NotAcceptableException::class);
$this->api->handle($request);
}
public function testSuccessWhenOnlySomeAcceptsHaveParameters()
{
$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());
}
}

View File

@ -0,0 +1,72 @@
<?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 testContainsOnExactMatch()
{
$header = new MediaTypes('application/json');
$this->assertTrue(
$header->containsExactly('application/json')
);
}
public function testContainsDoesNotMatchWithExtraParameters()
{
$header = new MediaTypes('application/json; profile=foo');
$this->assertFalse(
$header->containsExactly('application/json')
);
}
public function testContainsMatchesWhenOnlyWeightIsProvided()
{
$header = new MediaTypes('application/json; q=0.8');
$this->assertTrue(
$header->containsExactly('application/json')
);
}
public function testContainsDoesNotMatchWithExtraParametersBeforeWeight()
{
$header = new MediaTypes('application/json; profile=foo; q=0.8');
$this->assertFalse(
$header->containsExactly('application/json')
);
}
public function testContainsMatchesWithExtraParametersAfterWeight()
{
$header = new MediaTypes('application/json; q=0.8; profile=foo');
$this->assertTrue(
$header->containsExactly('application/json')
);
}
public function testContainsMatchesWhenOneOfMultipleMediaTypesIsValid()
{
$header = new MediaTypes('application/json; profile=foo, application/json; q=0.6');
$this->assertTrue(
$header->containsExactly('application/json')
);
}
}