Contents

Introduction

Crud listener for building JSON API Servers with almost no code.

Comes with advanced features like:

  • Compound Documents (Deeply Nested Includes)
  • Sparse Fieldsets
  • Multi-field Search (Filtering)
  • Multi-field Sorting
  • Multi-field Validation
  • Pagination

Installation

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

Loading Crud

Only run the following command if your application does not yet use Crud:

bin/cake plugin load Crud

Setup

Before you can start producing JSON:API you will have to set up your application by following the steps in this section.

Application

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;
}

Controller

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.

<?php
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.

<?php
class SamplesController extends AppController {

  public function beforeFilter(\Cake\Event\Event $event) {
    parent::beforeFilter();
    $this->Crud->addListener('CrudJsonApi.JsonApi');
  }
}

Exception Handler

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.

<?php
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:

  • make sure that you are using CakePHP 3.4+
  • set the 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,
],

Routing

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'
      ]);
  }
});

Request detector

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.

Listener Options

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);
}

withJsonApiVersion

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"
    }
  }
}

meta

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"
      }
    }
  }
}

debugPrettyPrint

Setting this boolean option to false (default: true) will make the listener render non-pretty json in debug mode.

jsonOptions

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,
  ]);
}

include

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:

  • must be lowercased
  • must be singular for entities with a belongsTo relationship
  • must be plural for entities with a hasMany relationship
$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.

fieldSets

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.

queryParameters

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);
  }
]);

Query Logs

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
      }
  }
}

Schemas

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:

  1. Custom entity schema
  2. Custom dynamic schema
  3. Crud’s dynamic schema

Custom entity schema

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:

<?php
namespace App\Schema\JsonApi;

use CrudJsonApi\Schema\JsonApi\DynamicEntitySchema;

class CountrySchema extends DynamicEntitySchema
{
  public function getSelfSubUrl($entity = null)
  {
    return 'http://prefix.only/countries/controller/self-links/';
  }
}

Custom dynamic schema

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:

<?php
namespace App\Schema\JsonApi;

use CrudJsonApi\Schema\JsonApi\DynamicEntitySchema as CrudDynamicEntitySchema;

class DynamicEntitySchema extends CrudDynamicEntitySchema
{
  public function getSelfSubUrl($entity = null)
  {
    return 'http://prefix.all/controller/self-links/';
  }
}

Fetching Collections

Fetching JSON API Resource Collections is done by calling the index action of your API with:

  • the HTTP GET request type
  • an Accept 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"
      }
    }
  ]
}

Fetching Resources

Fetch a single JSON API Resource by calling the view action of your API with:

  • the HTTP GET request type
  • an Accept 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 Resources

Creating a new JSON API Resource is done by calling the add action of your API with:

  • the HTTP POST request type
  • an Accept header set to application/vnd.api+json
  • a Content-Type header set to application/vnd.api+json
  • request data in valid JSON API document format

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.

Request Data

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

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:

  • you will only be able to create Resources with belongsTo relationships pointing to EXISTING foreign keys
  • crud-json-api will throw a BadRequestException when it detects attempts to side-post hasMany relationships

Note

Side-posting might land in version 1.1 of the JSON API specification, more information available in this Pull Request.

Updating Resources

Updating an existing JSON API Resource is done by calling the edit action of your API with:

  • the HTTP PATCH request type
  • an Accept header set to application/vnd.api+json
  • a Content-Type header set to application/vnd.api+json
  • request data in valid JSON API document format
  • request data containing the id of the resource to update

A 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"
    }
  }
}

Updating To-One Relationships

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:

  • the id of the related resource MUST correspond with an EXISTING foreign key
  • the related resource MUST belong to the primary resource being PATCHed

For 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.

Updating To-Many Relationships

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:

  • the id of the related resource MUST correspond with an EXISTING foreign key
  • the related resource MUST belong to the primary resource being PATCHed

For 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 Resources

Deleting an existing JSON API Resource is done by calling the delete action of your API with:

  • the HTTP DELETE request type
  • an Accept header set to application/vnd.api+json
  • a Content-Type header set to application/vnd.api+json
  • request data in valid JSON API document format
  • request data containing the id of the resource to delete

A 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"
    }
  }
}

Errors and Exceptions

Default Errors

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.

Validation Errors

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"
      }
    }
  ]
}

Invalid Request 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.

Inclusion

Associated Data

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"
      }
    }
  ]
}

Include Parameter

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.

allowList/denyList

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. Blacklisting will prevent any denyListed associations from being included. Blacklisting takes precedence of 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();
}

Sparse Fieldsets

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

Limiting Associated Data

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 WORK

Combinations

You 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

Sorting

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.

Single Field Sorting

To sort by a single field using ascending order:

/currencies?sort=code

To sort descending:

/currencies?sort=-code

Multi Field Sorting

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

Combined Sorts

CrudJsonApi supports any combination of the above sorts. E.g.

  • /currencies?include=countries&sort=name,countries.code
  • /currencies?include=countries&sort=name,-countries.code

Filtering

JSON API Filtering allow searching your API and requires:

  1. Composer installing friendsofcake/search
  2. Configuring the Crud SearchListener as described here

Now 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

Please note that the following search requests would also be matched:

  • /countries?filter[id]=1
  • /countries?filter[id][]=1&filter[id][]=2

Examples

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.

Common Issues

Missing template

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

Missing routes

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.

Schema not registered

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 ''.

Normal HTML is returned

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.

Customizing Output

Date Formats

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');

Contributing

There are many ways you can help improving this plugin:

  • submit a Pull Request, code optimizations are more than welcome
  • raise a Github issue if you detect missing functionality or behavior not according to the JSON API specification
  • suggest improvements to this documentation

Note

We welcome and appreciate contributions on all levels.

  v: stable
Versions
latest
stable
Downloads
pdf
html
epub
On Read the Docs
Project Home
Builds
Downloads
On GitHub
View
Edit

Free document hosting provided by Read the Docs.