Skip to content

Api bundle

Introduction

The Api bundle is a fundamental bundle for the entire enhavo platform. It is the starting point for every request. In contrast to other common approaches like API-Platform, we wanted to define the routes and their corresponding entry points ourselves from the start. This gives us more control over the available routes. This bundle introduces endpoints, a replacement for controllers, that follow common patterns used throughout enhavo, making them more reusable and extendable. It also provides a description API that exposes an OpenAPI-compliant specification of your endpoints.

Installation

$ composer require enhavo/api-bundle

Endpoints

The Api Bundle introduce the concept of endpoints. An endpoint is a class with a handleRequest function to process the request and create data. Similar to an action of a controller, but it will not expect a response for returning. Instead we apply data and let decide later how a response will look like.

Here we have a simple endpoint, that uses the Route attribute for routing and find a book by the request and add the title to the data.

php
namespace App\Endpoint;

use Enhavo\Bundle\ApiBundle\Data\Data;
use Enhavo\Bundle\ApiBundle\Endpoint\AbstractEndpointType;
use Enhavo\Bundle\ApiBundle\Endpoint\Context;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

#[Route(path: '/book/{slug}', name: 'app_book', defaults: ['_format' => 'html'])]
class BookEndpointType extends AbstractEndpointType
{
    public function handleRequest($options, Request $request, Data $data, Context $context)
    {
        $book = $this->findBook($request);
        $data->set('title', $book->getTitle());
    }
    
    private function findBook(Request $request)
    {
        // ...
    }
}

Note

The data you add, must be a scalar type or an array. If you have an object, you have to normalize it first.

The Api Bundle only supports a json response by default, but could be extended in your application or use the endpoints in the app bundle to use templates for example.

Change status code

You can use the context object to change the response behaviour, e.g. change the status code.

php
public function handleRequest($options, Request $request, Data $data, Context $context)
{
    $book = $this->findBook($request);
    if ($book === null) { 
         $context->setStatusCode(400); 
         return; 
    } 
    $data->set('title', $book->getTitle());
}

Redirect

It is also possible to pass a hole response to the context.

php
public function handleRequest($options, Request $request, Data $data, Context $context)
{
    $book = $this->findBook($request);
    if ($book->titleChanged()) { 
         $response = new RedirectResponse($this->generateUrl('app_book', [ 
            'id' => $book->getId(); 
         ])) 
         $context->setResponse($response); 
         return; 
    } 
    $data->set('title', $book->getTitle());
}

Warning

The response is just a suggestion for the endpoint. In some cases this might be ignored.

Load endpoints

If you use route attributes for endpoints. Make sure you load the Endpoint folder in your routing. It will also check all subfolder for further endpoints.

yaml
endpoint:
    resource: ../src/Endpoint
    type: endpoint

It is also possible to use prefixes if you load a folder.

yaml
endpoint:
    resource: ../src/Endpoint/User
    prefix: /user
    type: endpoint

Warning

If you use prefixes for different folders, you should take care, that no folder will be loaded twice. The last loaded route will always count, but routes can be only overwritten by name. So if you don't use names for the routes it will be added twice.

Routing

Instead of php attributes, you can also define routes over the configuration. That make sense if you wan't to use different options for endpoints.

yaml
app_book:
    path: /book/{slug}
    defaults:
        _endpoint:
            type: App\Endpoint\BookEndpointType

Options

If your endpoint will be reused for different routes, you can make it configurable with options. For the options we use the OptionsResolver component from Symfony

Use the function configureOptions to define the possible options for the endpoint. The resolved options will be passed to several functions of the endpoint e.g. handleRequest

php
use Symfony\Component\OptionsResolver\OptionsResolver; 

class BookEndpointType extends AbstractEndpointType
{
    public function handleRequest($options, Request $request, Data $data, Context $context)
    {
        $book = $this->findBook($request);
        if ($options['description']) { 
            $data->set('description', $book->getDescription()); 
        } 
        $data->set('title', $book->getTitle());
    }
    
    public function configureOptions(OptionsResolver $resolver): void
    { 
        $resolver->setDefaults([ 
            'description' => true, 
        ]); 
    } 
    
    private function findBook(Request $request)
    {
        // ...
    }
}

So if you use the endpoint with the routing configuration, you are now able to change its behaviour over options.

yaml
app_book:
    path: /book/{slug}
    defaults:
        _endpoint:
            type: App\Endpoint\BookEndpointType
            description: true

app_other_book:
    path: /other/book/{slug}
    defaults:
        _endpoint:
            type: App\Endpoint\BookEndpointType
            description: false

Endpoint extensions

The endpoint api uses the type pattern, so it's possible that multiple endpoints create data for a single request.

From the type pattern we know two possible extensions. One is the parent (vertical) and the other one is the extension type (horizontal).

Extend with parent

Extension by parent will only extend one single endpoint. You define the parent by return the FQCN in the getParentType function.

php
namespace App\Endpoint;

use Enhavo\Bundle\ApiBundle\Data\Data;
use Enhavo\Bundle\ApiBundle\Endpoint\AbstractEndpointType;
use Enhavo\Bundle\ApiBundle\Endpoint\Context;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

class SectionEndpointType extends AbstractEndpointType
{
    public function handleRequest($options, Request $request, Data $data, Context $context)
    {
        $sections = $this->findSections($request);
        $data->set('sections', $sections);
    }
    
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'sections' => true,
        ]);
    }
    
    public static function getParentType(): ?string
    {
        return BookEndpointType::class;
    }
}

Extend with extension type

The extension type can extend multiple extension at the same time with getExtendedTypes

php
namespace App\Endpoint\Extension;

use Enhavo\Bundle\ApiBundle\Data\Data;
use Enhavo\Bundle\ApiBundle\Endpoint\AbstractEndpointTypeExtension;
use Enhavo\Bundle\ApiBundle\Endpoint\Context;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\OptionsResolver\OptionsResolver;

class SectionExtensionType extends AbstractEndpointTypeExtension
{
    public function handleRequest($options, Request $request, Data $data, Context $context)
    {
        $sections = $this->findSections($request);
        $data->set('sections', $sections);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'sections' => true,
        ]);
    }

    public static function getExtendedTypes(): array
    {
        return [BookEndpointType::class];
    }
}

Context Data

To communicate between extended endpoints, you can use the Context class. Just set some data and later you can get the data in the extended type.

php
public function handleRequest($options, Request $request, Data $data, Context $context)
{
    $book = $this->findBook($request);
    $context->set('book', $book);
    $data->set('title', $book->getTitle());
}
php
public function handleRequest($options, Request $request, Data $data, Context $context)
{
    $book = $context->get('book');
    $data->set('sections', $book->getSections());
}

Tip

This is often useful if you retrieve an object from e.g. a repository and then normalize it. If you pass it also to the context, then the next extensions don't have to retrieve it again. This may increase performance as well.

Normalization

Data, that passes to an endpoint must be normalized first. Symfony has therefor the serializer component. You can just call the normalize function that comes with the endpoint. We advise to use serialization groups, to have more control to what will be normalized and to prevent circular normalization due bidirectional or circular references.

php
namespace App\Endpoint;

class BookEndpointType extends AbstractEndpointType
{
    public function handleRequest($options, Request $request, Data $data, Context $context)
    {
        $book = $this->findBook($request);
        $data->set('book', $this->normalize($book, [], ['groups' => 'endpoint']));
    }
}

We recommend to use attributes to define which properties should be normalized.

php
namespace App\Entity;

use Symfony\Component\Serializer\Annotation\Groups;

class Book
{
    #[Groups(['endpoint'])]
    public ?string $title = null;
    
    #[Groups(['endpoint'])] // also possible on functions
    public getAuthor(): string
    {
        // ...
    }
}

Read more about normalization in the symfony docs

Data Normalizer

The ApiBundle also provide another option to add data to the normalizer. You can use DataNormalizer classes, that can easily bind to classes or interfaces and add additional data.

php
namespace App\Normalizer;

use App\Entity\Book;
use Enhavo\Bundle\ApiBundle\Data\Data;
use Enhavo\Bundle\ApiBundle\Normalizer\AbstractDataNormalizer;
use Symfony\Component\Routing\RouterInterface;

class BookNormalizer extends AbstractDataNormalizer
{
    public function __construct(
        private RouterInterface $router,
    ) {}

    public function buildData(Data $data, $object, string $format = null, array $context = []): void
    {
        if (!$this->hasSerializationGroup('endpoint', $context)) {
            return;
        }
        
        $data->set('url', $this->router->generate('app_book', $object->getTitle()));
        $data->set('chapter', $this->normalize($book->getChapters(), [], ['groups' => 'chapter']));
    }

    public static function getSupportedTypes(): array
    {
        return [Book::class];
    }
}

The data normalizer is useful if ...

  • you need a service to generate additional data
  • you don't have this property or function in your class to normalize
  • you want to add data to all classes using an interface
  • you want to add data regardless to serialization groups
  • you want to call a different serialization group for a property

Api documentation

With the Api bundle, you can define different sections through configuration and use describers to get an OpenAPI-compliant specification.

By default, the Api bundle comes with a default section that uses a RouteDescriber

yaml
enhavo_api:
    documentation:
        section:
            default:
                Enhavo\Bundle\ApiBundle\Documentation\RouteDescriber: ~

You can add other describers in your application configuration.

yaml
enhavo_api:
    documentation:
        section:
            default:
                Enhavo\Bundle\ApiBundle\Documentation\InfoDescriber:
                    title: 'Application api'
                Enhavo\Bundle\ApiBundle\Documentation\ServersDescriber:
                    servers: 
                        - url: https://my-domain.tld
                          description: Testing api

Create your own describer and implement the Enhavo\Bundle\ApiBundle\Documentation\DescriberInterface interface.

Describe endpoints

Endpoints have a describe method to create an OpenAPI-compliant specification. Use the build in api to describe your endpoint.

php
namespace App\Endpoint;

use Enhavo\Bundle\ApiBundle\Endpoint\AbstractEndpointType;

class MyEndpoint extends AbstractEndpointType 
{
    // ...

    public function describe($options, Path $path): void
    {
        $path->method('get')
            ->parameter('id')
                ->in('path')
                ->description('The id of your entity')
                ->schema()
                    ->string()->end()
                ->end()
            ->end()
            ->response('200')
                ->description('Data')
                    ->content()
                        ->schema()
                            ->object()
                                ->property('id', 'string')
    }
}

To add a route to a section, you need to add a _describe option.

yaml
app_book:
    path: /book/{slug}
    defaults:
        _describe: true # use true for default or a string for other sections
        _endpoint:
            type: App\Endpoint\BookEndpointType

Show specification

You can use the command enhavo:api:create-docs to create a yaml specification.

bash
$ bin/console enhavo:api:create-docs specification.yaml

Or add routes to expose the specification in a swagger like style.

yaml
api_docs:
    path: /docs
    defaults:
        _controller: Enhavo\Bundle\ApiBundle\Controller\DocumentationController::indexAction
        data_route: api_docs_data

api_docs_data:
    path: /docs/data
    defaults:
        _controller: Enhavo\Bundle\ApiBundle\Controller\DocumentationController::dataAction
        section: default