Die Zeiten wo man eine API mit:
echo json_encode($result);
die();
in einem einfachen Controller implementierte und eher ein Beiwerk waren sind schon länger vorbei. Heute baut große Clients mit Vue.js oder React und PHP liefert keinen HTML-Code mehr aus sondern implementiert nur noch eine reine REST-API.
Bei REST-APIs mit klassischen Resources und GET, PUT, POST, PATCH, DELETE kann man alles sehr gut verallgemeinern, so dass ein Framework kaum noch Anpassungen benötigen, um eine lauffähige API bereit stellen zu können. Eines dieser Frameworks ist das in PHP implementierte
API Platform. Zusammen mit Symfony 4 kommt noch schneller zu einer REST-API als mit
Spring-Boot oder
Meecrowave.
Ich skizziere hier einen kleinen Speed run, der aufzeigt wie man sehr schnell eine recht komplexe REST-API implementieren kann.
Nicht alles wird für alle nötig sein, aber es gibt einen guten Überblick für eigene Projekte. Ich geh von einem vorhandenen Symfony 4
Projekt aus, in das API Platform mit integriert wird.
1. Installation:
composer require api
2. Die Resource:
Im Grunde nimmt man eine Doctrine-Entity und erweitert die um 2 Annotationen (ApiResource und ApiProperty für die Id).
namespace hp\examples\api\entity;
use ApiPlatform\Core\Annotation\ApiProperty;
use ApiPlatform\Core\Annotation\ApiResource;
use Doctrine\ORM\Mapping as ORM;
/**
* Example-Resource
*
* @ORM\Entity
* @ORM\Table(name="blubb")
*
* @ApiResource(
* )
*/
class Blubb {
/**
* @var int
*
* @ORM\Column(name="id", type="integer", nullable=false)
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
*
* @ApiProperty(
* identifier=true
* )
*/
private $id;
/**
* @var string
*/
private $value = ''
....
....
....
}
Den Rest der Entity wie einen argumentlosen Constructor und die Getter/Setter wird sicher jeder selbst schnellst erzeugen können.
Eine Migration oder die Table per Doctrine zu erzeugen kann sicher auch jeder selber und braucht hier nicht weiter ausgeführt werden.
3. Pfad zu den Resourcen:
Hier passen wir das Paths Field in
config/packages/api_platform.yaml an:
api_platform:
mapping:
paths: ['%kernel.project_dir%/src/examples/api/entity']
patch_formats:
json: ['application/merge-patch+json']
swagger:
versions: [3]
Nun weiß API Platform, wo es nach den Resources suchen soll.
4. URL-Prefix anpassen:
In der
config/routes/api_platform.yaml kann noch schnell ein Prefix für die API bzw den API-Controller gesetzt werden.
api_platform:
resource: .
type: api_platform
prefix: /api/examples/
5. Starten und Testen:
Nun kann die Anwendung auch schon gestartet werden, wenn sie nicht schon ist. Also in meinem Fall den Docker-Container starten, der einen Apache auf dem Port 8080 öffnet.
Ob die Routen vorhanden sind prüfen wir mit:
php bin/console debug:router
Wenn die Routen angezeigt werden kann
http://localhost:8080/api/examples/docs.json aufgerufen werden und hier sollten alle Pfade zu der Resource aufgelistet sein. Sollte die Route nicht gefunden werden hilft ein
php bin/console cache:clear. Um die Resource wirklich auch testen zu können, findet man eine Swagger-Umgebung unter
http://localhost:8080/api/examples/blubbs.html. Wie man sieht hängt ein 's' am dem Namen der Klasse und der Name ist auch nicht in Camelcase geschrieben. Wenn man die JSON-Ausgabe sehen möchte muss man nur
../blubbs.json anstelle der HTML-Variante aufrufen.
6. DTOs und Delegations
Nicht immer will man die Entity so raus reichen, wie sie ist. Auch will man vielleicht Objekte durch einfache Strings ersetzen. Auch beim Anlegen will man vielleicht Business-Keys und keine echten Ids nutzen, die aber in der Ausgabe dann doch zusätzlich mit angezeigt werden sollen.
Dafür kann man bei API-Platform DataTransformer und Stellvertreter-Objekte definieren. Die DataTransformer muss man nur anlegen und werden durch das autowire von Symfony direkt auch schon verwendet.
Ein einfaches Beispiel:
BlubbIn
namespace hp\examples\api\entity;
class BlubbIn {
public $value;
public $logComment;
}
BlubbOut
namespace hp\examples\api\entity;
class BlubbOut {
public $id;
public $value;
public $md5sum;
}
Nun brauchen wir auch zwei DataTransformer:
Incoming
namespace hp\examples\api\entity\transformer;
use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
class BlubbInTransformer implements DataTransformerInterface {
public function transform($object, string $to, array $context = []) {
$entity = new Blubb();
$entity->setValue($object->value);
\hp\Logger::log($object->logComment);
return $entity;
}
public function supportsTransformation($data, string $to, array $context = []): bool
{
return $to === Blubb::class && null !== ($context['input']['class'] ?? null);
}
}
outgoing
namespace hp\examples\api\entity\transformer;
use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
class BlubbOutTransformer implements DataTransformerInterface {
public function transform($object, string $to, array $context = []) {
$dto = new BlubbOut();
$dto->id = $object->getId();
$dto->value = $object->getValue();
$dto->md5sum = md5($object->getValue() . $object->getId());
return $dto;
}
public function supportsTransformation($data, string $to, array $context = []): bool
{
return $to === BlubbOut::class && $data instanceof Blubb;
}
}
Die beiden DTOs müssen noch an der Resource referenziert werden:
...
/**
* Example-Resource
*
* @ORM\Entity
* @ORM\Table(name="blubb")
*
* @ApiResource(
* input=hp\examples\api\entity\BlubbIn::class,
* output=hp\examples\api\entity\BlubbOut::class
* )
*/
...
Nun nutzt man das BlubbIn-Format um etwas an die API zu senden, es wird als die Entity in de DB gespeichert und als Rückgabe sieht die Daten im Format von BlubbOut. Dieses ist etwas, was ich bei vielen Frameworks immer vermisst habe... Object-Replacements wie beim Serialisieren unter Java mit
writeReplace. Gerade bei REST-APIs ist so etwas extrem praktisch und man kann so auch aus einer Entity verschiedene Domain-abhängige (
Bounded-Context) DTOs für verschiedene API-Endpoints erzeugen.
7. Authorization und Security
Klar sind einfache API-Keys die man im Request mitgibt nicht die beste oder finale Lösung, aber sie sind so schön einfach. In API Platform lässt sich so etwas relativ einfach implementieren. Dafür braucht man
Symfony Voters, die mich etwas an die Realms aus Tomcat erinnern. Man reicht eine Role rein und die Logik liefert einfach true oder false zurück. Das Beispiel ist nicht sehr fein-granular, aber sollte reichen, um die Grundidee zu vermitteln.
Die Resource schützen:
...
/**
* Example-Resource
*
* @ORM\Entity
* @ORM\Table(name="blubb")
*
* @ApiResource(
* attributes={"security"="is_granted('EXAMPLE_API_ACTION')"},
* input=hp\examples\api\entity\BlubbIn::class,
* output=hp\examples\api\entity\BlubbOut::class
* )
*/
...
Eine ausführlichere Doku mit der Verwendung des Object findet man
hier bei API Platform.
Den Voter zu implementieren ist an sich genau so einfach, wie einen DataTransformer zu implementieren und funktioniert genau nach der selben Logik, die ich auf die Art auch immer gerne implementiere.
namespace namespace hp\examples\api\security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ExampleAPIVoter extends Voter {
...
...
...
protected function supports($attribute, $subject) {
return $attribute == 'EXAMPLE_API_ACTION';
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token) {
return $this->apiKey == $this->getUserRequestApiKey();
}
}
Mit autowire muss man auch nicht groß etwas in die services.yaml eintragen, wenn man nichts aus der env-config oder
so braucht.