Crud listener for building JSON API Servers with almost no code.
Comes with advanced features like:
Install the JsonApi Listener by running the following command inside your project folder:
composer require friendsofcake/crud-json-api
It is highly recommended that you install the Search
plugin as well:
composer require friendsofcake/search
Only run the following command if your application does not yet use Crud:
bin/cake plugin load Crud
Before you can start producing JSON:API you will have to set up your application by following the steps in this section.
CakePHP needs to be told that JSON:API requests should be parsed as JSON.
To do this, the BodyParserMiddleware
must be added to your application
middleware queue, and a parser for the application/vnd.api+json
mime-type
must be added.
In your Application
class’ middleware
method, add the following.
$bodies = new BodyParserMiddleware();
$bodies->addParser(['application/vnd.api+json'], function ($body) {
return json_decode($body, true);
});
$middlewareQueue->add($bodies);
Assuming you are using the default App Skeleton’s middleware queue, change it to.
public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
{
$bodies = new BodyParserMiddleware();
$bodies->addParser(['application/vnd.api+json'], function ($body) {
return json_decode($body, true);
});
$middlewareQueue
// Catch any exceptions in the lower layers,
// and make an error page/response
->add(new ErrorHandlerMiddleware(Configure::read('Error')))
// Handle plugin/theme assets like CakePHP normally does.
->add(new AssetMiddleware([
'cacheTime' => Configure::read('Asset.cacheTime'),
]))
// Add routing middleware.
// If you have a large number of routes connected, turning on routes
// caching in production could improve performance. For that when
// creating the middleware instance specify the cache config name by
// using it's second constructor argument:
// `new RoutingMiddleware($this, '_cake_routes_')`
->add(new RoutingMiddleware($this))
// Parse various types of encoded request bodies so that they are
// available as array through $request->getData()
// https://book.cakephp.org/4/en/controllers/middleware.html#body-parser-middleware
->add($bodies);
return $middlewareQueue;
}
Attach the listener using the components array if you want to attach
it to all controllers, application wide, and make sure RequestHandler
is loaded before Crud
.
class AppController extends Controller
{
public function initialize()
{
$this->loadComponent('RequestHandler');
$this->loadComponent('Crud.Crud', [
'actions' => [
'Crud.Index',
'Crud.View',
],
'listeners' => ['CrudJsonApi.JsonApi'],
]);
}
}
Alternatively, attach the listener to your controllers beforeFilter
if you prefer attaching the listener to only specific controllers on the fly.
class SamplesController extends AppController
{
public function beforeFilter(\Cake\Event\Event $event) {
parent::beforeFilter();
$this->Crud->addListener('CrudJsonApi.JsonApi');
}
}
The JsonApi listener overrides the Exception.renderer
for jsonapi
requests,
so in case of an error, a standardized error will be returned,
according to the JSON API specification.
Create a custom exception renderer by extending the Crud’s JsonApiExceptionRenderer
class and enabling it with the exceptionRenderer
configuration option.
class AppController extends Controller
{
public function initialize()
{
parent::initialize();
$this->Crud->config(['listeners.jsonApi.exceptionRenderer' => 'App\Error\JsonApiExceptionRenderer']);
}
}
Note
The listener setting above is ignored when using CakePHP’s PSR7 middleware feature.
If you want to use CakePHP’s ErrorHandlerMiddleware
:
Error.exceptionRenderer
option in config/app.php
to 'CrudJsonApi\Error\JsonApiExceptionRenderer'
like shown below:'Error' => [
'errorLevel' => E_ALL,
'exceptionRenderer' => 'CrudJsonApi\Error\JsonApiExceptionRenderer',
'skipLog' => [],
'log' => true,
'trace' => true,
],
Only controllers explicitly mapped can be exposed as API resources so make sure
to configure your global routing scope in config/routes.php
similar to:
const API_RESOURCES = [
'Countries',
'Currencies',
];
Router::scope('/', function ($routes) {
foreach (API_RESOURCES as $apiResource) {
$routes->resources($apiResource, [
'inflect' => 'dasherize',
]);
}
});
The JsonApi Listener adds the jsonapi
request detector
to your Request
object which checks if the request
contains a HTTP Accept
header set to application/vnd.api+json
and can be used like this inside your application:
if ($this->request->is('jsonapi')) {
return 'cool, using JSON API';
}
Note
To make sure the listener won’t get in your way it will
return null
for all requests unless is('jsonapi')
is true.
The output produced by the listener is highly configurable using the Crud configuration options described in this section.
Either configure the options on the fly per action or enable them for all
actions in your controller by adding them to your contoller’s initialize()
event
like this:
public function initialize()
{
parent::initialize();
$this->Crud->config('listeners.jsonApi.withJsonApiVersion', true);
}
Pass this mixed option a boolean with value true (default: false) to
make the listener add the top-level jsonapi
node with member node
version
to each response like shown below.
{
"jsonapi": {
"version": "1.0"
}
}
Passing an array or hash will achieve the same result but will also generate the additional meta child node.
{
"jsonapi": {
"version": "1.0",
"meta": {
"cool": "stuff"
}
}
}
Pass this array option (default: empty) an array or hash will make the listener
add the the top-level jsonapi
node with member node meta
to each response
like shown below.
{
"jsonapi": {
"meta": {
"copyright": {
"name": "FriendsOfCake"
}
}
}
}
Setting this boolean option to true (default: false) will make the listener generate absolute links for the JSON API responses.
Setting this boolean option to false (default: true) will make the listener render non-pretty json in debug mode.
Pass this array option (default: empty) an array with PHP Predefined JSON Constants to manipulate the generated json response. For example:
public function initialize()
{
parent::initialize();
$this->Crud->config('listeners.jsonApi.jsonOptions', [
JSON_HEX_QUOT,
JSON_UNESCAPED_UNICODE,
]);
}
Pass this array option (default: empty) an array with associated entity
names to limit the data added to the json included
node.
Please note that entity names:
$this->Crud->config('listeners.jsonApi.include', [
'currency', // belongsTo relationship and thus singular
'cultures', // hasMany relationship and thus plural
]);
Note
The value of the include
configuration will be overwritten if the
the client uses the ?include
query parameter.
Pass this array option (default: empty) a hash with field names to limit the attributes/fields shown in the generated json. For example:
$this->Crud->config('listeners.jsonApi.fieldSets', [
'countries' => [ // main record
'name',
],
'currencies' => [ // associated data
'code',
],
]);
Note
Please note that there is no need to hide id
fields as this
is handled by the listener automatically as per the
JSON API specification.
Setting this boolean option to true (default: false) will make the listener
add an about
link pointing to an explanation for all validation errors caused
by posting request data in a format that does not comply with the JSON API document
structure.
This option is mainly intended to help developers understand what’s wrong with their
posted data structure. An example of an about link for a validation error caused
by a missing type
node in the posted data would be:
{
"errors": [
{
"links": {
"about": "http://jsonapi.org/format/#crud-creating"
},
"title": "_required",
"detail": "Primary data does not contain member 'type'",
"source": {
"pointer": "/data"
}
}
]
}
This array option allows you to specify query parameters to parse in your application.
Currently this listener supports the official include
parameter. You can easily add your own
by specifying a callable.
$this->Crud->listener('jsonApi')->config('queryParameter.parent', [
'callable' => function ($queryData, $subject) {
$subject->query->where('parent' => $queryData);
},
]);
This listener fully supports the Crud API Query Log
listener and will,
once enabled as described here
, add a top-level query
node to every response when debug mode is enabled.
{
"query": {
"default": [
{
"query": "SHOW FULL COLUMNS FROM `countries`",
"took": 0,
"params": [],
"numRows": 10,
"error": null
}
]
}
}
This listener comes with an additional Pagination listener that, once enabled,
wil add the meta
and links
nodes as per the JSON API specification.
Attach the listener using the components array if you want to attach it to all controllers, application wide.
class AppController extends Controller
{
public function initialize()
{
$this->loadComponent('RequestHandler');
$this->loadComponent('Crud.Crud', [
'actions' => [
'Crud.Index',
'Crud.View',
],
'listeners' => [
'CrudJsonApi.JsonApi',
'CrudJsonApi.Pagination',
],
]);
}
}
Alternatively, attach the listener to your controllers beforeFilter
if you prefer attaching the listener to only specific controllers on the fly.
class SamplesController extends AppController
{
public function beforeFilter(\Cake\Event\EventInterface $event)
{
parent::beforeFilter($event);
$this->Crud->addListener('CrudJsonApi.Pagination');
}
}
All GET
requests to the index action will now add
JSON API pagination information to the response as shown below.
{
"meta": {
"record_count": 15,
"page_count": 2,
"page_limit": null
},
"links": {
"self": "/countries?page=2",
"first": "/countries?page=1",
"last": "/countries?page=2",
"prev": "/countries?page=1",
"next": null
}
}
This listener makes use of NeoMerx schemas to handle the heavy lifting that is required for converting CakePHP entities to JSON API format.
By default all entities in the _entities
viewVar will be passed to the
Listener’s DynamicEntitySchema
for conversion. This dynamic schema extends
Neomerx\JsonApi\Schema\SchemaProvider
and is, amongst other things, used to
override NeoMerx methods so we can generate CakePHP specific output (like links).
Even though the dynamic entity schema provided by Crud should cater to the needs of most users, creating your own custom schemas is also supported. When using custom schemas please note that the listener will use the first matching schema, following this order:
Use a custom entity schema in situations where you need to alter the generated JSON API but only for a specific controller/entity.
An example would be overriding the NeoMerx getSelfSubUrl
method used
to prefix all self
links in the generated json for a Countries
controller. This would require creating a src/Schema/JsonApi/CountrySchema.php
file looking similar to:
namespace App\Schema\JsonApi;
use CrudJsonApi\Schema\JsonApi\DynamicEntitySchema;
class CountrySchema extends DynamicEntitySchema
{
public function getSelfSubUrl($entity = null)
{
return 'https://countryies.example.com/controller/self-links/';
}
}
Use a custom dynamic schema if you need to alter the generated JSON API for all controllers, application wide.
An example of a custom dynamic schema would require creating
a src/Schema/JsonApi/DynamicEntitySchema.php
file looking similar to:
namespace App\Schema\JsonApi;
use CrudJsonApi\Schema\JsonApi\DynamicEntitySchema as CrudDynamicEntitySchema;
class DynamicEntitySchema extends CrudDynamicEntitySchema
{
public function getSelfSubUrl($entity = null)
{
return 'https://api.example.com/controller/self-links/';
}
}
Fetching JSON API Resource Collections is done by calling the index
action of your API with:
HTTP GET
request typeAccept
header set to application/vnd.api+json
A successful request will respond with HTTP response code 200
and response body similar to this output produced by
http://example.com/countries
:
{
"data": [
{
"type": "countries",
"id": "1",
"attributes": {
"code": "NL",
"name": "The Netherlands"
},
"links": {
"self": "/countries/1"
}
},
{
"type": "countries",
"id": "2",
"attributes": {
"code": "BE",
"name": "Belgium"
},
"links": {
"self": "/countries/2"
}
}
]
}
Fetch a single JSON API Resource by calling the view
action of your API with:
HTTP GET
request typeAccept
header set to application/vnd.api+json
A successful request will respond with HTTP response code 200
and response body similar to this output produced by
http://example.com/countries/1
:
{
"data": {
"type": "countries",
"id": "1",
"attributes": {
"code": "NL",
"name": "The Netherlands",
"dummy-counter": 11111
},
"relationships": {
"currency": {
"data": {
"type": "currencies",
"id": "1"
},
"links": {
"self": "/currencies/1"
}
},
"national-capital": {
"data": {
"type": "national-capitals",
"id": "1"
},
"links": {
"self": "/national-capitals/1"
}
}
},
"links": {
"self": "/countries/1"
}
}
}
Note
When retrieving a single Resource, crud-json-api will automatically generate relationships
links for
all belongsTo
attributes in your model UNLESS you pass the include
request parameter OR define
a contain statement inside your Controller.
Creating a new JSON API Resource is done by calling the add
action of your API with:
HTTP POST
request typeAccept
header set to application/vnd.api+json
Content-Type
header set to application/vnd.api+json
A successful request will respond with HTTP response code 201
and a JSON API response body presenting the newly created Resource
along with id
, attributes
and belongsTo
relationships.
All data posted to the listener is transformed from JSON API format to standard CakePHP format so it can be processed “as usual” once the data is accepted.
To make sure posted data complies with the JSON API specification it is first validated by the listener’s DocumentValidator which will throw a (422) ValidationException if it does not comply along with a pointer to the cause.
A valid JSON API request body for creating a new Country would look similar to:
{
"data": {
"type": "countries",
"attributes": {
"code": "NL",
"name": "The Netherlands"
}
}
}
The same rules apply when you create a new Resource and want to set its belongsTo
relationships.
For example, the JSON API request body for creating a new Country with currency_id=1
would like:
{
"data": {
"type": "countries",
"attributes": {
"code": "NL",
"name": "The Netherlands"
},
"relationships": {
"currency": {
"data": {
"type": "currencies",
"id": "1"
}
}
}
}
}
Note
See this link for more examples of valid JsonApiRequestBodies.
Side-posting is an often requested feature which would allow creating multiple Resources (and/or relationships) using a single POST request.
However, this functionality is NOT supported by version 1.0 of the JSON API specification and is therefore NOT supported by crud-json-api.
In practice this means:
belongsTo
relationships pointing to EXISTING foreign keysBadRequestException
when it detects attempts to side-post hasMany
relationshipsNote
Side-posting might land in version 1.1 of the JSON API specification, more information available in this Pull Request.
Updating an existing JSON API Resource is done by calling the edit
action of your API with:
HTTP PATCH
request typeAccept
header set to application/vnd.api+json
Content-Type
header set to application/vnd.api+json
id
of the resource to updateA successful request will respond with HTTP response code 200
and response body similar to the one produced by the view
action.
A valid JSON API document structure for updating the name
field
for a Country with id
10 would look similar to the following output
produced by http://example.com/countries/1
:
{
"data": {
"type": "countries",
"id": "10",
"attributes": {
"name": "My new name"
}
}
}
When updating a primary JSON API Resource, you can use the same PATCH request to set one or multiple To-One
(or belongsTo
) relationships but only as long as the following conditions are met:
id
of the related resource MUST correspond with an EXISTING foreign keyFor example, a valid JSON API document structure that would set a single related
national-capital
for a given country
would look like:
{
"data": {
"type": "countries",
"id": "2",
"relationships": {
"national-capital": {
"data": {
"type": "national-capitals",
"id": "4"
}
}
}
}
}
Note
Please note that JSON API does not support updating attributes for the related resource(s) and thus will simply ignore them if detected in the request body.
When updating a primary JSON API Resource, you can use the same PATCH request to set one or multiple To-Many
(or hasMany
) relationships but only as long as the following conditions are met:
id
of the related resource MUST correspond with an EXISTING foreign keyFor example, a valid JSON API document structure that would set multiple related cultures
for a given country
would look like:
{
"data": {
"type": "countries",
"id": "2",
"relationships": {
"cultures": {
"data": [
{
"type": "cultures",
"id": "2"
},
{
"type": "cultures",
"id": "3"
}
]
}
}
}
}
Note
Please note that JSON API does not support updating attributes for the related resource(s) and thus will simply ignore them if detected in the request body.
Deleting an existing JSON API Resource is done by calling the delete
action of your API with:
HTTP DELETE
request typeAccept
header set to application/vnd.api+json
Content-Type
header set to application/vnd.api+json
id
of the resource to deleteA successful request will return HTTP response code 204
(No Content)
and empty response body. Failed requests will return HTTP response
code 400
with empty response body.
An valid JSON API document structure for deleting a Country
with id
10 would look similar to:
{
"data": {
"type": "countries",
"id": "10"
}
}
}
The listener will produce error responses in the following JSON API format for all standard errors and all non-validation exceptions:
{
"errors": [
{
"code": "501",
"title": "Not Implemented"
}
],
"debug": {
"class": "Cake\\Network\\Exception\\NotImplementedException",
"trace": []
}
}
Note
Please note that the debug
node with the stack trace will only be included if debug
is true.
The listener will produce validation error (422) responses in the following JSON API format for all validation errors:
{
"errors": [
{
"title": "_required",
"detail": "Primary data does not contain member 'type'",
"source": {
"pointer": "/data"
}
}
]
}
Please be aware that the listener will also respond with (422) validation errors if request data is posted in a structure that does not comply with the JSON API specification.
The listener will detect associated data as produced by
contain
and will automatically render those associations
into the JSON API response as specified by the specification.
Let’s take the following example code for the view
action of
a Country model with a belongsTo
association to Currencies
and a hasMany
relationship with Cultures:
public function view()
{
$this->Crud->on('beforeFind', function (Event $event) {
$event->getSubject()->query->contain([
'Currencies',
'Cultures',
]);
});
return $this->Crud->execute();
}
Assuming a successful find the listener would produce the following JSON API response including all associated data:
{
"data": {
"type": "countries",
"id": "2",
"attributes": {
"code": "BE",
"name": "Belgium"
},
"relationships": {
"currency": {
"data": {
"type": "currencies",
"id": "1"
},
"links": {
"self": "/currencies/1"
}
},
"cultures": {
"data": [
{
"type": "cultures",
"id": "2"
},
{
"type": "cultures",
"id": "3"
}
],
"links": {
"self": "/cultures?country_id=2"
}
}
},
"links": {
"self": "/countries/2"
}
},
"included": [
{
"type": "currencies",
"id": "1",
"attributes": {
"code": "EUR",
"name": "Euro"
},
"links": {
"self": "/currencies/1"
}
},
{
"type": "cultures",
"id": "2",
"attributes": {
"code": "nl-BE",
"name": "Dutch (Belgium)"
},
"links": {
"self": "/cultures/2"
}
},
{
"type": "cultures",
"id": "3",
"attributes": {
"code": "fr-BE",
"name": "French (Belgium)"
},
"links": {
"self": "/cultures/3"
}
}
]
}
Crud-json-api fully supports the JSON API include
request parameter which allows a client
to specify which related/associated resources should be returned.
As an example, a client could produce the exact same JSON API response as shown above by using
/countries/2?include=cultures,currencies
.
Note
If the include
parameter is provided, then only the requested relationships will be included
in the included
schema.
It is possible to denyList, or allowList what the client is allowed to include. This is done using the listener configuration:
public function view()
{
$this->Crud
->listener('jsonApi')
->config('queryParameters.include.allowList', ['cultures', 'cities']);
return $this->Crud->execute();
}
allowListing will prevent all non-allowListed associations from being
contained. denyListing will prevent associations from being included.
denyListing takes precedence over allowListing (i.e denyListing and
allowListing the same association will prevent it from being included).
If you wish to prevent any associations, set the denyList``config
option to ``true
:
public function view()
{
$this->Crud
->listener('jsonApi')
->config('queryParameters.include.denyList', true);
return $this->Crud->execute();
}
JSON API Sparse Fieldsets
allow you to limit the fields returned by your API by passing the fields
parameter
in your request.
To select all countries but only retrieve their code field:
/countries?fields[countries]=code
To select a single country and only retrieve its name field:
/countries/1?fields[countries]=name
It is also possible to limit the fields of associated data. The following example will
return all fields for countries
(the primary data) but will limit the fields returned
for currencies
(the associated data) to id
and name
.
/countries?include=currencies&fields[currencies]=id,name
Please note that you MUST include the associated data in the fields args, eg:
/countries?fields[countries]=name&include=currencies&fields[currencies]=id,code
will NOT work/countries?fields[countries]=name,currency&include=currencies&fields[currencies]=id,code
does WORKYou may also use any combination of the above. In this case we are limiting the fields for both the primary resource and the associated data.
/countries/1?fields[countries]=name,currency&include=currencies&fields[currencies]=id,name
JSON API Sorting
allows you to sort the results produced by your API according to one
by passing one or more criteria to your request using the sort
parameter.
Before continuing please note that the default sort order for each field is ascending
UNLESS the field is prefixed with a hyphen (-
) in which case the sort order will
be descending.
To sort by a single field using ascending order:
/currencies?sort=code
To sort descending:
/currencies?sort=-code
To sort by multiple fields simply pass comma-separated sort fields in the order you want them applied:
/currencies?sort=code,name
/currencies?sort=-code,name
/currencies?sort=-code,-name
/currencies?sort=name,code
CrudJsonApi supports any combination of the above sorts. E.g.
/currencies?include=countries&sort=name,countries.code
/currencies?include=countries&sort=name,-countries.code
JSON API Filtering allow searching your API and requires:
Crud SearchListener
as described hereNow create search aliases named filter
in your tables like shown below:
// src/Model/Table/CountriesTable.php
public function searchManager()
{
$searchManager = $this->behaviors()->Search->searchManager();
$searchManager->like('filter', [
'before' => true,
'after' => true,
'field' => [$this->aliasField('name')],
]);
return $searchManager;
}
Once that is done you will be able to search your API using URLs similar to:
/countries?filter=netherlands
/countries?filter=nether
We realize the JSON API document structure can be complex and hard to memorize which is exactly why we have decided to use pure JSON API documents as fixtures for our integration tests. This not only assures crud-json-api will behave exactly as we expect, it also provides you with a reference directory you can use to lookup fully-functional examples of:
Note
Please submit a PR if you are missing a use case.
Crud-json-api does not require you to create templates so if you see the following error you are
most likely not sending the correct application/vnd.api+json
Accept Header with your requests:
Error: Missing Template
Crud-json-api depends on CakePHP Routing to generate the correct links for all resources in your JSON API response.
If you encounter errors like the one shown below, make sure that both your primary resource and all related
resources are added to the API_RESOURCES
constant found in your config/routes.php
file.
A route matching '' could not be found.
If you see the following error make sure that valid Table
and Entity
classes are
present for both the primary resource and all related resources.
Schema is not registered for a resource at path ''.
If you are just getting back a standard page response, rather than a JSON response (and you have confirmed that you are sending the correct JSON API Request Headers) it is most likely because you already have a controller action defined for the resource you are trying to request. For CRUD to handle it, you must remove any existing controller actions that conflict with the routes you are trying to configure.
By default crud-json-api will return timestamps in the following format:
"created-at": "2018-06-10T13:41:05+00:00"
If you prefer a different format, either specify it in your bootstrap.php
file or right before a
specific action. E.g.
\Cake\I18n\FrozenTime::setJsonEncodeFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
\Cake\I18n\FrozenDate::setJsonEncodeFormat('yyyy-MM-dd');
There are many ways you can help improving this plugin:
Note
We welcome and appreciate contributions on all levels.