1.0.0releasedAPI Framework

JSON renderer and API framework for Symphony CMS

Clone URLhttps://github.com/pointybeard/api_framework.git

Add as a submodulegit submodule add https://github.com/pointybeard/api_framework.git extensions/api_framework --recursive

Compatibility

2.x.x2.1.x2.2.x2.3.x2.4.x2.5.x2.6.x2.7.02.7.12.7.22.7.32.7.42.7.52.7.62.7.72.7.82.7.92.7.10
NoNoNoNoNoNo0.1.0NoNoNoNoNoNoNoNoNoNoNo. Soon?

Readme

RESTful API Framework for Symphony CMS

JSON renderer and event driven controller interface for Symphony CMS designed to quickly build a RESTful APIs.

Installation

This is an extension for Symphony CMS. Add it to your /extensions folder in your Symphony CMS installation, then enable it though the interface.

Dependencies

This extension depends on the following Composer libraries:

Run composer install on the extension/api_framework directory to install all of these.

Usage

This extension has two parts: The JSON Renderer, and the Controller event.

JSON Renderer

Any page with a type JSON will trigger the new JSON renderer. This automatically converts page XML output into a JSON document (this includes output from any events).

Working with XML

The JSON renderer expects to get well formed XML data, which it then translates into JSON data. Although JSON is just as well structured as XML, XML does not translate directly to JSON. Here are a few examples of XML and how they translate into JSON.

Arrays

Arrays are formed when the same element is used and contains simple values (numbers or strings). Each element does not need to be directly after one another either, but it is best practise to group them in some way.

XML:

xml <data> <array>1</array> <array>2</array> </data>

JSON:

json { "array": [ "1", "2" ] }

Objects

Typically Symphony will be outputting objects (Entries, Sections etc). E.g.

xml <data> <entries> <entry> <id>2</id> <title>Another Entry</title> <handle>another-entry</handle> <body>Blah Blah</body> <publish-date> <date>2016-04-25</date> <time>19:03</time> </publish-date> </entry> <entry> <id>1</id> <title>An Entry</title> <handle>an-entry</handle> <body>This is a dummy entry</body> <publish-date> <date>2016-04-25</date> <time>16:53</time> </publish-date> </entry> </entries> </data>

Would result in the following JSON

json { "entries": { "entry": [ { "id": "2", "title": "Another Entry", "handle": "another-entry", "body": "Blah Blah", "publish-date": { "date": "2016-04-25", "time": "19:03" } }, { "id": "1", "title": "An Entry", "handle": "an-entry", "body": "This is a dummy entry", "publish-date": { "date": "2016-04-25", "time": "16:53" } } ] } }

Dealing with Attributes

Since JSON does not have a concept of attributes in the same way XML does, all attributes are discarded to ensure a consistent result. Consequently, the field name @attributes is reserved and cannot be used.

Headers

The JSON renderer adds two new headers to the page output:

X-API-Framework-Page-Renderer

This allows you to see which page renderer was invoked. Currently there are only two possiblities: JsonFrontendPage, and CacheableJsonFrontendPage

X-API-Framework-Render-Time

This header displays how long the page took to render, in milliseconds (ms).

X-API-Framework-Cache

Will be hit (if a valid cache entry was located and is being used for rendering) or miss (if there was no valid cache entry located), in which case a cache entry will be created.

X-API-Framework-Expired-Cache-Entries

If cache cleanup hasn't been disabled, this will show the number of expired cache entries that were deleted (see Removing Exired Cache Entries for more details).

Handling PUT, POST, PATCH and DELETE Requests

Use the API Framework: Controller event to listen for PUT, POST, PATCH and DELETE requests. To create your own controller, make a folder called controllers in your /workspace directory.

A controller will respond to the 4 methods (PUT, POST, PATCH and DELETE) via a same named public method. E.g. to respond to a PUT request, create a method called 'put' in your controller like so

php public function put(Request $request, Response $response) { ... }

Modify $response to set return values and status code. E.g.:

php $response->setStatusCode(Response::HTTP_OK);

Lastly, call the render method (which is inherited) to generate the output. E.g.

php return $this->render($response, ['data' => 'some output']);

A controllers class and file name are the same. Each controller must sit in a folder path that matches your page path.

For example, if you had a page called "entry", and you wanted to provide PUT, POST, PATCH and DELETE functionality, name your controller class "ControllerEntry" and the file ControllerEntry.php. It should be placed in /workspace/controllers. If that same page was then a child of a page called "parent", you must create a new folder called parent inside /workspace/controllers. Place ControllerEntry in this new folder. Be sure to adjust its namespace accordingly.

Here is an example of a completed controller:

```php

namespace SymphonyApiFrameworkControllers;

use SymfonyComponentHttpFoundationRequest; use SymfonyComponentHttpFoundationResponse; use SymphonyApiFrameworkLib; use SymphonyApiFrameworkLibAbstractController; use SymphonyApiFrameworkLibTraits;

final class ControllerExample extends AbstractController{

use TraitshasEndpointSchemaTrait;

public function execute(){
  // Optional. Add any code here, such
  // as including extension or autoloaders.
  // This method is automatically invoked
  // anytime this controller is going to be used.
}

public function post(Request $request, Response $response)
{
    $data = $request->request->all();

    try{
        // do some work here
        $response->setStatusCode(Response::HTTP_CREATED);
        $response->headers->set(
            'Location', "some/path/to/resource"
        );
        return $this->render($response, [
            'data' => [
                'id' => $idOfNewResource,
            ],
            'status' => $response->getStatusCode(),
            'message' => "Item was successfully created."
        ]);

    } catch(Exception $ex) {
        // handle errors here.
    }
    return;
}

public function put(Request $request, Response $response)
{
    $someEntryId = (int)LibJsonFrontend::instance()
        ->Page()
        ->Params()['some-id'];

    $data = $request->request->all();
    $output = [];

    try{
        // do some work here with "someEntryId"
        $output = [
            'message' => "Entry successfully updated.",
            'status' => Response::HTTP_OK
        ];

    } catch(ExceptionsModelEntryNotFoundException $ex) {
        $output = [
            'message' => "Entry not found.",
            'status' => Response::HTTP_NOT_FOUND
        ];
    }

    $response->setStatusCode($output['status']);
    return $this->render($response, $output);
}

public function patch(Request $request, Response $response)
{
    // Sometimes it is okay just to use the
    // PUT code to handle PATCH requests also
    return $this->put($request, $response);
}

public function delete(Request $request, Response $response)
{
    $someEntryId = (int)Frontend::instance()
        ->Page()
        ->Params()['some-id'];

    $output = [];

    try{
        // do some work here using "someEntryId"
        $output = [
            'message' => "Entry successfully deleted.",
            'status' => Response::HTTP_OK
        ];

    } catch(ExceptionsModelEntryNotFoundException $ex) {
        $output = [
            'message' => "Entry not found.",
            'status' => Response::HTTP_NOT_FOUND
        ];
    }

    $response->setStatusCode($output['status']);
    return $this->render($response, $output);
}

} ```

Validating with JSON Schema

Incoming request and output response JSON can be validated against a JSON Schema document. This allows validation of input before it gets to your controllers, removing the need for a lot of sanity checking code, and validation of controller output to ensure it is conforming to your API specifications.

Note, JSON schema validation is not currently available for GET requests.

To use JSON Schema validation, implement the JsonSchemaValidationInterface Interface and use the TraitshasEndpointSchemaTrait trait in your controller. E.g.

``` namespace SymphonyApiFrameworkControllers;

use SymphonyApiFramework;

...

final class ControllerSchema extends LibAuthenticatedAbstractController Implements ApiFrameworkLibInterfacesJsonSchemaValidationInterface {

use ApiFrameworkLibTraitshasEndpointSchemaTrait;

```

The Interface JsonSchemaValidationInterface has two abstract methods: schema() and validate(). The Trait hasEndpointSchemaTrait implements these methods. It is also possible to implement both of these methods yourself.

Once the necessary libraries have been included, the Event controller will look for schemas by calling ->schema() prior to rendering. It looks for the directory workspace/schemas and follows the same hierarchy as controllers (sub-folders exist for each level of the API). E.g. A schema for the endpoint /order/item/ would need to reside in workspace/schemas/Order.

Schema files must be named using the format [controller-name].[http-method].[request|response].json. For example, if you have an endpoint controller called "Test", and you want to validate the output of a PATCH request, the name of the schema file would be ControllerTest.patch.response.json.

If validation fails, an exception is thrown which renders a 400 Bad Request response. It will look something like this:

``` HTTP/1.1 400 Bad Request Date: Thu, 27 Sep 2018 03:28:06 GMT Content-Type: application/json

{ "status": 400, "error": [ "[fruit] The property fruit is required" ], "message": "Validation failed. Errors where encountered while validating data against the supplied schema.", "validation": { "schema": "schemas/Test/ControllerTest.post.request.json", "input": { "animal": "lion" } } } ```

Modifying rendered output with Transformers

Prior to converting the XML into JSON, transformers are run. Transformers mutate the JSON result based on a test and action. The following built in transformers are available:

@jsonForceArray

This transformation will look for the attribute jsonForceArray on any XML elements. If it is set to "true", this transformation is applied. It relates to #issue-2. When there are multiple elements of the same name, for example 'entry', the JSON encode process will treat these as an array. E.g.

xml <data> <entries> <entry> <id>2</id> <title>Another Entry</title> </entry> <entry> <id>1</id> <title>An Entry</title> </entry> </entries> </data>

becomes

json { "entries": { "entry": [ { "id": "2", "title": "Another Entry", }, { "id": "1", "title": "An Entry", } ] } }

However, if there is only a single 'entry' element, it is treated as an object. This is because internally it is just an associative array, not an indexed array of 'entry' objects. E.g.

xml <data> <entries> <entry> <id>2</id> <title>Another Entry</title> </entry> </entries> </data>

results in

json { "entries": { "entry": { "id": "1", "title": "An Entry", } } }

Notice that 'entry' is a JSON object. The problem with this is inconsistent data and is a symptom of converting from XML to JSON using PHP's SimpleXML class. The solution is to set jsonForceArray="true" on the 'entry' element to trigger the transformation:

xml <data> <entries> <entry jsonForceArray="true"> <id>2</id> <title>Another Entry</title> </entry> </entries> </data>

Which results in JSON

json { "entries": { "entry": [ { "id": "2", "title": "Another Entry", } ] } }

Note: jsonForceArray="true" should not be set if there is more than one entry otherwise the JSON result will contain unnecessary nesting. Use logic in the XSLT to omit or toggle this attribute when there is more than a single entry.

@convertEmptyElementsToString

This transformation will look for the convertEmptyElementsToString attribute on all XML elements. Should the value within that element be empty, i.e. <someElement></someElement>, then the output will be an empty string rather than an empty array.

For example, XML such as this

XML <data> <entry> <name>My Entry</name> <optionalField></optionalField> </entry> <entry> <name>Another Entry</name> <optionalField>Yes</optionalField> </entry> </data>

gets converted to JSON like this

JSON { "entry": [ { "name": "My Entry", "optionalField": [], }, { "name": "2", "optionalField": "Yes", } ] }

Notice that optionalField in the first entry is an empty array ([]). If we set convertEmptyElementsToString, this will instead become an empty string (""). I.e.

XML <data> <entry convertEmptyElementsToString="true"> <name>My Entry</name> <optionalField></optionalField> </entry> ... </data>

instead becomes this

JSON { "entry": [ { "name": "My Entry", "optionalField": "", }, ... ] }

Writing Custom Transformers

This extension provides the delegate APIFrameworkJSONRendererAppendTransformations on all frontend pages with the JSON type. The context includes an instance of LibTransformer. Use the append() method to add your own transformations. E.g.

```php use SymphonyApiFrameworkLib;

Class extension_example extends Extension { public function getSubscribedDelegates(){ return[[ 'page' => '/frontend/', 'delegate' => 'APIFrameworkJSONRendererAppendTransformations', 'callback' => 'appendTransformations' ]]; }

public function appendTransformations($context) {

$context['transformer']->append(
  new LibTransformation(

    // This is the test. If it returns true, the action will be run
    function(array $input, array $attributes=[]){
      // do some tests in here and return either true or false
      return true;
    },

    // This is the action. If the test passes, this code will be run
    function(array $input, array $attributes=[]){
      // Operate on $input and return the result.
      return $input;
    }
  )
);

} } ```

Caching Page Output

From version 1.0.0, it is possible to cache the output of GET requests. To do this, add a page type of cacheable to any page that requires caching. The length the cache remains valid can be set in System > Preferences.

Cache entries can be viewed by going to System > Page Cache in the Symphony admin menu.

Removing Exired Cache Entries

By default, every time a cacheable page is rendered, the system looks for expired cache entries and removes them. This can add overhead to a busy site with many cached pages so it can be disabled in System > Preferences.

It is possible to manually manage the page cache via System > Page Cache in the Symphony admin or via the terminal with the cache shell command. e.g. symphony -c api_framework/cache -a clean (requires the Symphony Shell Extension to be installed).

Support

If you believe you have found a bug, please report it using the GitHub issue tracker, or better yet, fork the library and submit a pull request.

Contributing

We encourage you to contribute to this project. Please check out the Contributing documentation for guidelines about how to get involved.

License

"RESTful API Framework for Symphony CMS" is released under the MIT License.

Version history

Requires Symphony 2.6.7

Requires Symphony 2.6.7

        #### Added
	        * Added extended FrontendPage class, `JsonFrontendPage` to handle json requests specifically.
	        * Added `JsonRequest` and `RequestJsonInvalidException`. They are used to grab the incoming json data. Also updated controller event to use `JsonRequest`.
	        * Added logic that allows pages without JSON content to still reach the controller (Fixes #20)
	        * JSON Schema validation
	        #### Fixes
	        * Fixed the recursive method `recursiveApplyTransformationToArray()`. It had a logic bug that meant the result from lower levels was not propagated up the chain. Additionally, the `@attributes` array was not being dealt with consistently. The last transformer to run will trigger a cleanup which removes the `@attributes` array. (#15, #16, and #17)
	        * Fixed Exception namespace in `__construct()` function signature (Fixes #19).
	        * Added `JSON_UNESCAPED_SLASHES` flag when generating output from ExceptionHandler (Fixes #22)
	        #### Changed
	        * Updated controller event to pass Request object when calling execute method of a Controller object.
	        * Updated README for fixes #15 #16 and #17.
	        * Updated the json renderer to use `JsonFrontend` instead of the core Frontend class.
	        * Changed abstract method `execute()` to include a Request object, allowing controllers to manipulate the Request.
	        * Changed the way a controller path is discovered by using `current-page` and `parent-path` instead of `current-path`. (#14)
	        * Removed `boilerplate-xsl` feature. This is no longer required as `?debug` now works correctly. (#12)
	        * Using `Lib\JsonFrontend` instead of `Frontend`. `JsonFrontend` no longer extends the `Frontend` class.
	        * Minor improvements to how error handling works. Subverting some behaviour of the `FrontendPage` class by skipping it's constructor. `JsonFrontend` will force enable the `GenericExceptionHandler`
	

Requires Symphony 2.6.7

        - Added `JSON_UNESCAPED_SLASHES` to avoid unnecessary escaping of slashes in output. (#8)
	        - Added new abstract extension `AbstractApiException` which is used by `ControllerNotFoundException` and `MethodNotAllowedException`.
	        - Updated core controller event based on changes to `ControllerNotFoundException` and `MethodNotAllowedException`
	        - Updated `ControllerNotFoundException` and `MethodNotAllowedException` to extend the new `AbstractApiException` class
	        - Updated `ExceptionHandler` to check for overloaded http response code. Calls the method `getHttpStatusCode()` if it is available
	        - Removed the use clause for Symphony as it is redundant and causes a PHP warning
	        - Using API Framework exception and error handlers instead of Symphony built in. (#9)
	

Requires Symphony 2.6.7

        - Transformer and Transformation classes.
	        - Added APIFrameworkJSONRendererAppendTransformations delegate
	        - Added phpunit to composer require-dev
	        - Added unit tests for Transformation code
	        - Controller names are based on full page path (#5)
	        - Using PSR-4 folder structure for controllers. Controllers must have a namespace. (#7)
	        - Checking that controller actually exists before trying to include it (#6)
	        - Symphony PDO is not longer a Composer requirement as it is not used
	

Requires Symphony 2.6.7

        - Added CONTRIBUTING.md and CHANGELOG.md
	        - Improvements to the example controller code in README.md
	        - Extension driver had include class name which meant could not install
	

Requires Symphony 2.6

        - Initial release