FRACTAL + JSON-LD + Hydra REST API system for Symfony
This a work in progress under active development. This bundle relies heavily on the Serializer of Symfony 2.7 and is not usable in production yet.
Here is the fully-featured REST API you'll get in minutes, I promise:
- CRUD support through the API for Doctrine entities: list,
GET
,POST
,PUT
andDELETE
- Hypermedia implementing JSON-LD
- Machine-readable documentation in Hydra, guessed from PHPDoc, Serializer, Validator and Doctrine ORM metadata
- Pagination (following the Hydra format)
- List filters (following the Hydra format)
- Validation (through the Symfony Validator Component, supporting groups)
- Errors serialization (following the Hydra format)
- Custom serialization (through the Symfony Serializer Component, supporting groups)
- Automatic routes registration
- Automatic entrypoint generation giving access to all resources
\DateTime
serialization and deserialization- FOSUserBundle integration
Everything is fully customizable through a powerful event system and strong OOP.
This bundle is documented and tested with Behat (take a look at the features/
directory).
If you are starting a new project, the easiest way to get this bundle working and well integrated with other useful tools such as PHP Schema, NelmioApiDocBundle, NelmioCorsBundle or Behat is to install Dunglas's API Platform. It's a Symfony edition packaged with the best tools to develop a REST API and with sensitive settings.
Alternatively, you can use Composer to install the standalone bundle in your project:
composer require dunglas/api-bundle
Then, update your app/config/AppKernel.php
file:
public function registerBundles()
{
$bundles = [
// ...
new Nelmio\ApiDocBundle\NelmioApiDocBundle()
new Dunglas\ApiBundle\DunglasApiBundle(),
new Eliberty\ApiBundle\ElibertyApiBundle(),
// ...
];
return $bundles;
}
Register the routes of our API by adding the following lines to app/config/routing.yml
:
api:
resource: "."
type: "api"
prefix: "/api" # Optional
The first step is to name your API. Add the following lines in app/config/config.yml
:
dunglas_api:
title: "Your API name"
description: "The full description of your API"
default: # optional
items_per_page: 30 # Number of items per page in paginated collections (optional)
order: ~ # Default order: null for natural order, ASC or DESC (optional)
# Nelmio API Doc
nelmio_api_doc:
sandbox:
accept_type: "application/json"
body_format:
formats: [ "json" ]
default_format: "json"
request_format:
formats:
json: "application/json"
The name and the description you give will be accessible trough the auto-generated Hydra documentation.
Imagine you have the following Doctrine entity classes:
<?php
# src/AppBundle/Entity/Product.php
namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity
*/
class Product
{
/**
* @ORM\Column(type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
*/
public $id;
/**
* @ORM\Column
* @Assert\NotBlank
*/
public $name;
}
<?php
# src/AppBundle/Entity/Offer.php
namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity
*/
class Offer
{
/**
* @ORM\Column(type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
*/
public $id;
/**
* @ORM\Column(type="text")
*/
public $description;
/**
* @ORM\Column(type="float")
* @Assert\NotBlank
* @Assert\Range(min=0, message="The price must be superior to 0.")
* @Assert\Type(type="float")
*/
public $price;
/**
* @ORM\ManyToOne(targetEntity="Product")
*/
public $product;
}
Register the following services (for example in app/config/services.yml
):
services:
resource.product:
parent: api.resource
arguments:
- AppBundle\Entity\Product
tags:
- { name: api.resource }
resource.offer:
parent: api.resource
arguments:
- AppBundle\Entity\Offer
tags:
- { name: api.resource }
you will automatically!
End point GET PUT CPOST CGET DELETE for the entity offer, product
What is Fractal?!
Fractal provides a presentation and transformation layer for complex data output, the like found in RESTful APIs, and works really well with JSON. Think of this as a view layer for your JSON/YAML/etc.
When building an API it is common for people to just grab stuff from the database and pass it to json_encode(). This might be passable for “trivial” APIs but if they are in use by the public, or used by mobile applications then this will quickly lead to inconsistent output.
Tansformer!
Create transformer for un resource
<?php
namespace AppBundle\Transformer\V2;
use AppBundle\Entity\Offer;
use Eliberty\ApiBundle\Transformer\BaseTransformer;
use Symfony\Component\Validator\Constraints as Assert;
use Dunglas\ApiBundle\Annotation\Iri;
class OfferTransformer extends BaseTransformer
{
/**
* @var string
*/
protected $currentResourceKey = 'offer';
/**
* entity class
* @var string
*/
protected $entityClass = 'AppBundle\Entity\Offer';
/**
* List of resources to automatically include
*
* @var array
*/
protected $defaultIncludes = [];
/**
* List of resources possible to embed via this processor.
*
* @var array
*/
protected $availableIncludes = [];
/**
* Turn this item object into a generic array.
* @param Offer $offer
* @return array
*/
public function transform(Offer $offer = null)
{
if (is_null($offer)) {
return [];
}
$response = [
"id" => $offer->getId(),
"description" => $offer->getDescription(),
];
return $response;
}
}
<?php
namespace AppBundle\Transformer\V2;
use AppBundle\Entity\Product;
use Eliberty\ApiBundle\Transformer\BaseTransformer;
use Symfony\Component\Validator\Constraints as Assert;
use Dunglas\ApiBundle\Annotation\Iri;
class ProductTransformer extends BaseTransformer
{
/**
* @var string
*/
protected $currentResourceKey = 'product';
/**
* entity class
* @var string
*/
protected $entityClass = 'AppBundle\Entity\Product';
/**
* List of resources to automatically include
*
* @var array
*/
protected $defaultIncludes = [];
/**
* List of resources possible to embed via this processor.
*
* @var array
*/
protected $availableIncludes = [];
/**
* Turn this item object into a generic array.
* @param Product $product
* @return array
*/
public function transform(Product $product = null)
{
if (is_null($offer)) {
return [];
}
$response = [
"id" => $product->getId(),
"name" => $product->getDescription(),
];
return $response;
}
}
Register the following services (for example in app/config/services.yml
):
services:
transformer.parent.v2:
abstract: true
arguments:
- @doctrine.orm.entity_manager
calls:
- [ setRequest, [ @request ] ]
transformer.offer.v2:
class: AppBundle\Transformer\V2\OfferTransformer
parent: transformer.parent.v2
tags:
- { name: api_transformer }
scope: request
transformer.product.v2:
class: AppBundle\Transformer\V2\ProductTransformer
parent: transformer.parent.v2
tags:
- { name: api_transformer }
scope: request
You're done!
You now have a fully featured API exposing your Doctrine entities.
Run the Symfony app (app/console server:run
) and browse the API entrypoint at http://localhost:8000/api
.
Interact with it using a REST client such as Postman
and take a look at the usage examples in the features
directory.
Note: NelmioApiDocBundle (dev-master) has built-in support for this bundle. Installing it will give you access to a human-readable documentation and a nice sandbox.
The bundle provides a generic system to apply filters on collections. It ships with built-in Doctrine ORM support and can be extended to fit your specific needs.
By default, all filters are disabled. They must be enabled manually.
If Doctrine ORM support is enabled, adding filters is as easy as adding an entry in your app/config/services.yml
file.
It supports exact and partial matching strategies. If the partial strategy is specified, a SQL query with a LIKE %text to search%
query will be automatically issued.
To allow filtering the list of offers:
services:
resource.offer.filter.id:
parent: "api.doctrine.orm.filter"
arguments: [ "id" ] # Filters on the id property, allow both numeric values and IRIs
resource.offer.filter.price:
parent: "api.doctrine.orm.filter"
arguments: [ "price" ] # Extracts all collection elements with the exact given price
resource.offer.filter.name:
parent: "api.doctrine.orm.filter"
arguments: [ "name", "partial" ] # Elements with given text in their name
resource.offer:
parent: "api.resource"
arguments: [ "AppBundle\Entity\Offer" ]
calls:
- [ "addFilter", [ "@resource.offer.filter.id" ] ]
- [ "addFilter", [ "@resource.offer.filter.price" ] ]
- [ "addFilter", [ "@resource.offer.filter.name" ] ]
tags: [ { name: "api.resource" } ]
http://localhost:8000/api/offers?price=10
will return all offers with a price being exactly 10
.
http://localhost:8000/api/offers?name=shirt
will returns all offer with a description containing the word "shirt".
Filters can be combined together: http://localhost:8000/api/offers?price=10&name=shirt
It also possible to filter by relations:
services:
resource.offer.filter.product:
parent: "api.doctrine.orm.filter"
arguments: [ "product" ]
resource.offer:
parent: "api.resource"
arguments: [ "AppBundle\Entity\Offer"]
calls:
- [ "addFilter", [ "@resource.offer.filter.product" ] ]
tags: [ { name: "api.resource" } ]
With this service definition, it is possible to find all offers for the given product.
Try the following: http://localhost:8000/api/offers?product=/api/products/12
Using a numeric ID will also work: http://localhost:8000/api/offers?product=12
It will return all offers for the product having the JSON-LD identifier (@id
) http://localhost:8000/api/products/12
.
Custom filters can be written by implementing the Dunglas\ApiBundle\Api\Filter\FilterInterface
interface.
Doctrine ORM filters must implement the Dunglas\ApiBundle\Doctrine\Orm\FilterInterface
. They can interact directly
with the Doctrine QueryBuilder
.
Don't forget to register your custom filters with the Dunglas\ApiBundle\Api\Resource::addFilter()
method.
By default, Embedding relations is null , if will be configure for the entity into the .
exemple configuration embed into offerTransformer for include product:
<?php
namespace AppBundle\Transformer\V2;
use AppBundle\Entity\Offer;
use Eliberty\ApiBundle\Transformer\BaseTransformer;
use Symfony\Component\Validator\Constraints as Assert;
use Dunglas\ApiBundle\Annotation\Iri;
class OfferTransformer extends BaseTransformer
{
/**
* @var string
*/
protected $currentResourceKey = 'offer';
/**
* entity class
* @var string
*/
protected $entityClass = 'AppBundle\Entity\Offer';
/**
* List of resources to automatically include
*
* @var array
*/
protected $defaultIncludes = ['product'];
/**
* List of resources possible to embed via this processor.
*
* @var array
*/
protected $availableIncludes = ['product'];
/**
* Turn this item object into a generic array.
* @param Offer $offer
* @return array
*/
public function transform(Offer $offer = null)
{
if (is_null($offer)) {
return [];
}
$response = [
"id" => $offer->getId(),
"description" => $offer->getDescription(),
];
return $response;
}
/**
* Embed product.
*
* @param Orderitem $orderitem
*
* @return \League\Fractal\Resource\Item
*/
public function includeProduct(Offer $offer = null)
{
$this->setEmbed('product');
$product = null !== $offer ? $offer->getProduct() : null;
$productTransformer = new ProductTransformer($this->getEm());
$productTransformer
->setRequest($this->request);
return $this->item($product, $productTransformer);
}
}
#if you have a relation collectionyour declaration looks like
/**
* List of resources to automatically include
*
* @var array
*/
protected $defaultIncludes = ['products'];
/**
* List of resources possible to embed via this processor.
*
* @var array
*/
protected $availableIncludes = ['products'];
/**
* Embed Products.
*
* Embed product.
*
* @return \League\Fractal\Resource\Collection
*/
public function includeProducts(Offer $offer = null)
{
$this->setEmbed('products');
$productTransformer = new ProductTransformer($this->getEm());
$productTransformer
->setRequest($this->request);
if (empty($catalog)) {
return $this->Collection([], $productTransformer);
}
$products = $offer->getProducts();
return $this->paginate($products, $productTransformer);
}
From a performance point of view, it's sometimes necessary to avoid extra HTTP requests. It is possible to embed related objects (or only some of their properties) directly in the parent response:
resource.offet:
parent: api.resource
arguments:
- AppBundle\Entity\Offer
calls:
- [ addEmbedOperation, [ @resource.offer.collection_operation.embeds_get] ]
tags:
- { name: api.resource }
resource.offer.collection_operation.embeds_get:
class: Dunglas\ApiBundle\Api\Operation\Operation
factory_service: api.operation_factory
factory_method: createCollectionOperation
arguments:
- @resource.offer
- GET
- /offers/{id}/{embed}
- ElibertyApiBundle:Resource:cgetEmbed
- offerts_embeds_get
with this declaration all embed defined into the offer transformer in the section includAvailableEmbed is explosed
and if into you api request you will be view all embed you can add into you request header:
#e-embed-available: 1
if you have an embed with a different name of your resource you can use is your alias to match a resource that embed
#exemple
<?php
namespace AppBundle\Transformer\V2;
use AppBundle\Entity\Offer;
use Eliberty\ApiBundle\Transformer\BaseTransformer;
use Symfony\Component\Validator\Constraints as Assert;
use Dunglas\ApiBundle\Annotation\Iri;
class OfferTransformer extends BaseTransformer
{
/**
* @var string
*/
protected $currentResourceKey = 'offer';
/**
* entity class
* @var string
*/
protected $entityClass = 'AppBundle\Entity\Offer';
/**
* List of resources to automatically include
*
* @var array
*/
protected $defaultIncludes = ['productalias'];
/**
* List of resources possible to embed via this processor.
*
* @var array
*/
protected $availableIncludes = ['product'];
/**
* Turn this item object into a generic array.
* @param Offer $offer
* @return array
*/
public function transform(Offer $offer = null)
{
if (is_null($offer)) {
return [];
}
$response = [
"id" => $offer->getId(),
"description" => $offer->getDescription(),
];
return $response;
}
/**
* Embed Productalias.
*
* @param Orderitem $orderitem
*
* @return \League\Fractal\Resource\Item
*/
public function includeProductalias(Offer $offer = null)
{
$this->setEmbed('product');
$product = null !== $offer ? $offer->getProduct() : null;
$productTransformer = new ProductTransformer($this->getEm());
$productTransformer
->setRequest($this->request);
return $this->item($product, $productTransformer);
}
}
resource.product:
parent: api.resource
arguments:
- AppBundle\Entity\Product
- ['Productalias']
tags:
- { name: api.resource }
#this market also for classes if you use a single table inheritance
- single table inheritance (in english)
resource.product:
parent: api.resource
arguments:
- AppBundle\Entity\Product
- ['AppBundle\Entity\ProductChild']
tags:
- { name: api.resource }
The built-in controller is able to leverage Symfony's validation groups.
To take care of them, edit your service declaration and add groups you want to use when the validation occurs:
services:
resource.product:
parent: "api.resource"
arguments: [ "AppBundle\Entity\Product" ]
calls: [ [ "initValidationGroups", [ [ "group1", "group2" ] ] ] ]
tags: [ { name: "api.resource" } ]
With the previous definition, the validations groups group1
and group2
will be used when the validation occurs.
The bundle provides a powerful event system triggered in the object lifecycle. Here is the list:
api.retrieve_list
(Dunglas\ApiBundle\Event::RETRIEVE_LIST
): occurs after the retrieving of an object list during aGET
request on a collection.
api.retrieve
(Dunglas\ApiBundle\Event::RETRIEVE_LIST
): after the retrieving of an object during aGET
request on an item.
api.pre_create_validation
(Dunglas\ApiBundle\Event::PRE_CREATE_VALIDATION
): occurs before the object validation during aPOST
request.api.pre_create
(Dunglas\ApiBundle\Event::PRE_CREATE
): occurs after the object validation and before its persistence during aPOST
requestapi.post_create
(Dunglas\ApiBundle\Event::POST_CREATE
): event occurs after the object persistence duringPOST
request
api.pre_update_validation
(Dunglas\ApiBundle\Event::PRE_UPDATE_VALIDATION
): event occurs before the object validation during aPUT
request.api.pre_update
(Dunglas\ApiBundle\Event::PRE_UPDATE
): occurs after the object validation and before its persistence during aPUT
requestapi.post_update
(Dunglas\ApiBundle\Event::POST_UPDATE
): event occurs after the object persistence during aPUT
request
api.pre_delete
(Dunglas\ApiBundle\Event::PRE_DELETE
): event occurs before the object deletion during aDELETE
requestapi.post_delete
(Dunglas\ApiBundle\Event::POST_DELETE
): occurs after the object deletion during aDELETE
request
Computing metadata used by the bundle is a costly operation. Fortunately, metadata can be computed once then cached. The
bundle provides a built-in cache service using APCu.
To enable it in the prod environment (requires APCu to be installed), add the following lines to app/config/config_prod.yml
:
dunglas_json_ld_api:
cache: api.mapping.cache.apc
DunglasApiBundle leverages Doctrine Cache to abstract the cache backend. If
you want to use a custom cache backend such as Redis, Memcache or MongoDB, register a Doctrine Cache provider as a service
and set the cache
config key to the id of the custom service you created.
A built-in cache warmer will be automatically executed every time you clear or warmup the cache if a cache service is configured.
JSON-LD allows to define classes and properties of your API with open vocabularies such as Schema.org and Good Relations.
DunglasApiBundle provides annotations usable on PHP classes and properties to specify a related external IRI.
<?php
# src/AppBundle/Entity/Product.php
namespace AppBundle\Entity;
use Dunglas\ApiBundle\Annotation\Iri;
// ...
/**
* ...
* @Iri("https://schema.org/Product")
*/
class Product
{
// ...
/**
* ...
* @Iri("https://schema.org/name")
*/
public $name;
}
The generated JSON for products and the related context document will now use external IRIs according to the specified annotations:
GET /products/22
{
"@context": "/contexts/Product",
"@id": "/product/22",
"@type": "https://schema.org/Product",
"name": "My awesome product",
// other properties
}
GET /contexts/Product
{
"@context": {
"@vocab": "http://example.com/vocab#",
"hydra": "http://www.w3.org/ns/hydra/core#",
"name": "https://schema.org/name",
// Other properties
}
}
An extended list of existing open vocabularies is available on the Linked Open Vocabularies (LOV) database.
By default, the following operations are automatically enabled:
Collection
Method | Description |
---|---|
GET |
Retrieve the (paginated) list of elements |
POST |
Create a new element |
Item
Method | Description |
---|---|
GET |
Retrieve element (mandatory operation) |
PUT |
Update an element |
DELETE |
Delete an element |
If you want to disable some operations (e.g. the DELETE
operation), you must register manually applicable operations using
the operation factory class, Dunglas\ApiBundle\Resource::addCollectionOperation()
and Dunglas\ApiBundle\Resource::addCollectionOperation()
methods.
The following Resource
definition exposes a GET
operation for it's collection but not the POST
one:
service
resource.offer:
parent: api.resource
arguments:
- AppBundle\Entity\Offer
calls:
- [ addItemOperation, [ @resource.offer.item_operation.get] ]
- [ addCollectionOperation, [ @resource.offer.collection_operation.get] ]
tags:
- { name: api.resource }
##item GET
resource.offer.item_operation.get:
class: "Dunglas\ApiBundle\Api\Operation\Operation"
public: false
factory: [ "@api.operation_factory", "createItemOperation" ]
arguments: [ "@resource.offer", "GET" ]
##collection CGET
resource.offer.collection_operation.get:
class: "Dunglas\ApiBundle\Api\Operation\Operation"
public: false
factory: [ "@api.operation_factory", "createCollectionOperation" ]
arguments: [ "@resource.offer", "GET" ]
Sometimes, it can be useful to create custom controller actions. DunglasApiBundle allows to register custom operations for both collections and items. It will register them automatically in the Symfony routing system and will expose them in the Hydra vocab (if enabled).
resource.product.item_operation.get:
class: "Dunglas\ApiBundle\Api\Operation\Operation"
public: false
factory: [ "@api.operation_factory", "createItemOperation" ]
arguments: [ "@resource.product", "GET" ]
resource.product.item_operation.put:
class: "Dunglas\ApiBundle\Api\Operation\Operation"
public: false
factory: [ "@api.operation_factory", "createItemOperation" ]
arguments: [ "@resource.product", "PUT" ]
resource.product.item_operation.custom_get:
class: "Dunglas\ApiBundle\Api\Operation\Operation"
public: false
factory: [ "@api.operation_factory", "createItemOperation" ]
arguments:
- "@resource.product" # Resource
- [ "GET", "HEAD" ] # Methods
- "/products/{id}/custom" # Path
- "AppBundle:Custom:custom" # Controller
- "my_custom_route" # Route name
- # Context (will be present in Hydra documentation)
"@type": "hydra:Operation"
"hydra:title": "A custom operation"
"returns": "xmls:string"
resource.product:
parent: "api.resource"
arguments: [ "AppBundle\Entity\Product" ]
calls:
- [ "addItemOperation", [ "@resource.product.item_operation.get" ] ]
- [ "addItemOperation", [ "@resource.product.item_operation.put" ] ]
- [ "addItemOperation", [ "@resource.product.item_operation.custom_get" ] ]
tags: [ { name: "api.resource" } ]
Additionnaly to the default generated GET
and PUT
operations, this definition will expose a new GET
operation for
the /products/{id}/custom
URL. When this URL is opened, the AppBundle:Custom:custom
controller is called.
When the size of your services definition start to grow, it is useful to create custom resources instead of using the default
one. To do so, the Dunglas\ApiBundle\Api\ResourceInterface
interface must be implemented.
<?php
namespace AppBundle\Api;
use Dunglas\ApiBundle\Api\ResourceInterface;
class MyCustomResource implements ResourceInterface
{
public function getEntityClass()
{
return 'AppBundle\Entity\MyCustomOne';
}
public function getItemOperations() {
return [
new MyItemOperation();
];
}
public function getCollectionOperations()
{
return [
new MyCollectionOperation();
];
}
public function getFilters()
{
return [];
}
public function getNormalizationContext()
{
return [];
}
public function getNormalizationGroups()
{
return null;
}
public function getDenormalizationContext()
{
return [];
}
public function getDenormalizationGroups()
{
return null;
}
public function getValidationGroups()
{
return null;
}
public function getShortName()
{
return 'MyCustomOne';
}
}
The service definition can now be simplified:
services:
resource.product:
parent: "api.resource"
class: "AppBundle\Api\MyCustomResource"
tags: [ { name: "api.resource" } ]
A seen in the Operations section, it's possible to use custom controllers.
Your custom controller should extend the ResourceController
provided by this bundle. It provides convenient methods to
retrieve the Resource
class associated with the current request and to serialize entities in JSON-LD.
Example of custom controller:
<?php
namespace AppBundle\Controller;
use Dunglas\ApiBundle\Controller\ResourceController;
use Symfony\Component\HttpFoundation\Request;
class CustomController extends ResourceController
{
# Customize the AppBundle:Custom:custom
public function getAction(Request $request, $id)
{
$this->get('logger')->info('This is my custom controller.');
return parent::getAction($request, $id);
}
}
DunglasApiBundle works fine with AngularJS. The popular Restangular REST client library for Angular can easily be configured to handle the API format.
Here is a working Restangular config:
'use strict';
var app =
angular.module('myAngularjsApp')
.config(['RestangularProvider', function(RestangularProvider) {
// The URL of the API endpoint
RestangularProvider.setBaseUrl('http://localhost:8000');
// JSON-LD @id support
RestangularProvider.setRestangularFields({
id: '@id'
});
RestangularProvider.setSelfLinkAbsoluteUrl(false);
// Hydra collections support
RestangularProvider.addResponseInterceptor(function(data, operation, what, url, response, deferred) {
// Remove trailing slash to make Restangular working
function populateHref(data) {
if (data['@id']) {
data['href'] = data['@id'].substring(1);
}
}
// Populate href property for the collection
populateHref(data);
if ('getList' === operation) {
var collectionResponse = data['hydra:member'];
collectionResponse['metadata'] = {};
// Put metadata in a property of the collection
angular.forEach(data, function(value, key) {
if ('hydra:member' !== key) {
collectionResponse.metadata[key] = value;
}
});
// Populate href property for all elements of the collection
angular.forEach(collectionResponse, function(value, key) {
populateHref(value);
});
return collectionResponse;
}
return data;
});
}])
;
##Versioning
for check the versioning controle beween the different version of the endpoint
you will be insert into you request header:
Accept: application/vnd.eliberty.api.v2+json
will this you call the service transformer.offer.v2
- A la découverte de API Platform (Symfony Paris Live 2015) (in french)
- API-first et Linked Data avec Symfony (sfPot Lille 2015) (in french)
This project has been created by Kévin Dunglas. Sponsored by Les-Tilleuls.coop.