FOSRestBundle - neue API Methoden via Bundle hinzufügen

Eine kurze Anleitung wie man sein Symfony Projekt mit Hilfe eines Bundles um neue API Methoden erweitern kann, wenn man in seinem Hauptprojekt auf den folgenden Stack setzt:

  • Symfony >= 4.3
  • FOSRestbundle
  • JMSSerializer
  • NelmioApiDocBundle

Welche Ziele werden verfolgt?

Es geht hier nicht darum ein vollständig generisches Symfony Plugin zu entwickeln, welches in jedem Symfony Projekt lauffähig ist.

Vielmehr will ich heute ein Bundle für ein bestehendes Symfony-Projekt entwickeln, dessen eingesetzte Bundles bekannt sind. Dies ist z.B. für viele OpenSource Projekte interessant, deren Grundlagen ja offen zugänglich sind.

Es geht ebenfalls nicht darum, den Code eines vollständigen Bundles zu teilen, sondern nur die Rumpfmethoden und wichtigesten Erweiterungspunkte aufzuzeigen.

Der Code

Ich verwende ein PrependExtensionInterface um die benötigten YAML Konfigurationen zu laden. Statt der gezeigten Methodik mit YAML, könntet ihr auch genausogut XML verwenden oder die Configs direkt als Array hinterlegen.

JMS Serializer Konfiguration

Unter Resources/config/jms_serializer.yaml speichern wir die Konfiguration des Serializers:

jms_serializer:
    metadata:
        directories:
            Demo:
                namespace_prefix: "Acme\\DemoBundle"
                path: "@DemoBundle/Resources/config/serializer"
        warmup:
            paths:
                included:
                    - "%kernel.root_dir%/../vendor/acme/DemoBundle/Entity/"

Dann wollen wir dem JMSSerializer auch gleich noch mitteilen, welche Felder er wie ausgeben soll. Das machen wir unter Resources/config/serializer/Entity.Demo.yml:

Acme\DemoBundle\Entity\Demo:
    exclusion_policy: All
    custom_accessor_order: [id, page, pageSize]
    properties:
        id:
            include: true
        page:
            include: true
        pageSize:
            exclude: true
    virtual_properties:
        getPageSize:
            serialized_name: pageSize
            exp: "object.getPageSize() === null ? 50 : object.getPageSize()"
            type: int

Nelmio API Doc Konfiguration

Unter Resources/config/nelmio_api_doc.yaml speichern wir die Konfiguration des API Doc Bundles:

nelmio_api_doc:
    models:
        names:
            - { alias: DemoForm,       type: Acme\DemoBundle\Form\DemoForm,   groups: [Default, Entity, SomeGroup] }
            - { alias: DemoEntity,     type: Acme\DemoBundle\Entity\Demo,     groups: [Default, Entity, SomeGroup] }
            - { alias: DemoCollection, type: Acme\DemoBundle\Entity\Demo,     groups: [Default, Collection, SomeGroup] }

Die Bundle Extension Klasse

Ich habe mich für den Weg entschieden, weil das Hauptprojekt ebenfalls auf YAMl setzt.

Unter DependencyInjection/DemoExtension.php finden wir die Extension Klasse des Bundles:

namespace Acme\DependencyInjection;

use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\DependencyInjection\Loader;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\Yaml\Parser;

final class DemoExtension extends Extension implements PrependExtensionInterface
{
    public function load(array $configs, ContainerBuilder $container)
    {
        // ...
    }

    public function prepend(ContainerBuilder $container)
    {
        $yamlParser = new Parser();

        $config = $yamlParser->parse(
            file_get_contents(__DIR__ . '/../Resources/config/jms_serializer.yaml')
        );
        $container->prependExtensionConfig('jms_serializer', $config['jms_serializer']);

        $config = $yamlParser->parse(
            file_get_contents(__DIR__ . '/../Resources/config/nelmio_api_doc.yaml')
        );
        $container->prependExtensionConfig('nelmio_api_doc', $config['nelmio_api_doc']);
    }

Demo Rest Controller

Unter API/DemoController.php wird der neue API Controller erstellt:

namespace Acme\DemoBundle\API;

use Acme\DemoBundle\Demo;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

/**
 * @RouteResource("Demo")

 * @Security("is_granted('ROLE_USER')")
 */
final class DemoController extends AbstractController
{
    /**
     * @var ViewHandlerInterface
     */
    private $viewHandler;

    public function __construct(ViewHandlerInterface $viewHandler)
    {
        $this->viewHandler = $viewHandler;
    }

    /**
     * Returns a collection of demos
     *
     * @SWG\Response(
     *      response=200,
     *      description="Returns a collection of demo entities",
     *      @SWG\Schema(
     *          type="array",
     *          @SWG\Items(ref="#/definitions/DemoCollection")
     *      )
     * )
     * @Rest\QueryParam(name="page", requirements="\d+", strict=true, nullable=true, description="The page to display, renders a 404 if not found (default: 1)")
     * @Rest\QueryParam(name="size", requirements="\d+", strict=true, nullable=true, description="The amount of entries for each page (default: 50)")
     */
    public function cgetAction(ParamFetcherInterface $paramFetcher): Response
    {
        $demo = new Demo();

        if (null !== ($page = $paramFetcher->get('page'))) {
            $demo->setPage($page);
        }

        if (null !== ($size = $paramFetcher->get('size'))) {
            $demo->setPageSize($size);
        }

        $view = new View([$demo], 200);
        $view->getContext()->setGroups(['Default', 'Collection', 'SomeGroup']);

        return $this->viewHandler->handle($view);
    }

    // ...
}

Die Routen

Ich setze hier vorraus, dass Ihr Eure Rest Controller im Sub-Namespace und damit auch Verzeichnis API/ speichert (wie in der Controller Klasse vorgeschlagen), um diese von den “normalen” Controllern zu trennen,

Diese Route muss in Eurem Hauptprojekt aktiviert werden.

demo.api:
    resource: "@DemoBundle/API/"
    type: rest
    prefix: /api

Die restlichen Klassen und Bundle Logik sind ausführlich in der Symfony Dokumentation beschrieben, das werde ich hier nicht wiedergeben … daher sind wir auch schon: FERTIG!

Viel Spaß 😃