Great APIs
with Symfony and
Open Source Software
Asmir Mustafic - @goetas
SymfonyLive 2019 Berlin
Me
Asmir Mustafic
Me
@goetas
- Twitter: @goetas_asmir
- Github: @goetas
- LinkedIn: @goetas
- WWW: goetas.com
Berlin
My first index.html in 1999
Community
- jms/serializer (maintainer)
- masterminds/html5 (maintainer)
- hautelook/templated-uri-bundle (maintainer)
- goetas-webservices/xsd2php (author)
- goetas-webservices/xsd-reader (author)
- goetas-webservices/soap-client (author)
- goetas/twital (author)
- PHP-FIG secretary
REST API
Poor man's
Social Network
Practical example
API
Requirements
Business Requirements
do the job and/or make money!
RESTFul
http://stackoverflow.com/questions/20335967/how-useful-important-is-rest-hateoas-maturity-level-3
HATEOAS
GET /user/17827
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: ...
{
"id": "17827",
"name": "Tom",
"_links": {
"self": {
"href": "http://example.com/user/17827"
},
"avatar": {
"href": "http://example.com/avatars/17827.jpg"
}
}
}
Why
HATEOAS
?
document.write(
"<img src='http://avatars.com/" + userId + ".jpg' />"
)
var host = 'http://avatars.com'; // have this per env
document.write(
"<img src='" + host + "/" + userId + ".jpg' />"
)
document.write(
"<img src='" + user._links.avatar.href + "' />"
)
$.ajax("http://example.com/user/"+ id + "/settings");
$.ajax(user._links.settings.href);
Validated
Proper error messages
(for humans and machines)
Secure
Authentication/Authorization
Debuggable
Logs and Traces
Stable (no changes)
Versioning
Documented
documentation and code must grow together
otherwise they will go out of sync
Easy to change
Quick to develop
Easy to use
Customizable
And more...
It looks pretty hard,
right?
How to reach the goal?
code code code code
First Law of Software Quality
https://twitter.com/mmrichards/status/602949000690466816
Use other people's code
use
Open Source
use
Symfony
composer create-project symfony/skeleton api-demo
Symfony Flex
<?php
// config/bundles.php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
FOS\RestBundle\FOSRestBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true],
// ....
];
# .env files
APP_ENV=dev
APP_DEBUG=1
APP_SECRET=XXXXXXX
RABBITMQ_URL=amqp://guest:guest@queue
MYSQL_DB_HOST=db
MYSQL_DB_NAME=name
MYSQL_DB_USER=db-usr
MYSQL_DB_PASS=secret
config/
├── bundles.php
├── packages
│ ├── prod
│ │ └── monolog.yaml
│ ├── dev
│ │ ├── monolog.yaml
│ │ ├── routing.yaml
│ │ └── web_profiler.yaml
│ ├── framework.yaml
│ ├── monolog.yaml
│ ├── routing.yaml
│ ├── security.yaml
│ ├── translation.yaml
│ └── twig.yaml
├── routes
│ ├── annotations.yaml
│ └── dev
│ └── twig.yaml
├── routes.yaml
├── parameters.yaml
├── services_prod.yaml
└── services.yaml
composer require anotations
composer require orm
Symfony Flex
composer require annotations
- A View layer for format agnostic Controllers
- Format negotiation
- Decoding of HTTP requests
- Exception handling
- much more!
composer require
friendsofsymfonny/rest-bundle
Entities
Controllers
Services
Bundles
Entities
class User
{
private $name;
private $email;
public function __construct($name)
{
$this->name = $name;
}
// more code ...
}
$user = new User('Tom');
Doctrine
Entities
$user = new User('Tom');
$manager->persist($user);
$manager->flush();
composer require
doctrine/doctrine-bundle
// src/Entity/User.php
/** @ORM\Entity */
class User implements UserInterface
{
/** @ORM\Column(type="uuid") @ORM\Id */
private $id;
/** @ORM\Column(length=255, unique=true) */
private $email;
/**
* @ORM\OneToMany(targetEntity="AppBundle\Entity\Friendship", mappedBy="from")
*/
private $connReceived;
/**
* @ORM\OneToMany(targetEntity="AppBundle\Entity\Friendship", mappedBy="to")
*/
private $connSent;
// more more fields
public function __construct(UuidInterface $id = null)
{
$this->id = $id ?: Uuid::uuid4();
$this->createdAt = new \DateTime();
$this->connReceived = new ArrayCollection();
$this->connSent = new ArrayCollection();
}
}
// src/Entity/Friendship.php
/** @ORM\Entity */
class Friendship
{
/** @ORM\Column(type="uuid") @ORM\Id */
private $id;
/**
* @ORM\ManyToOne(targetEntity="AppBundle\Entity\User", inversedBy="connReceived")
* @ORM\JoinColumn(onDelete="CASCADE")
*/
private $from;
/**
* @ORM\ManyToOne(targetEntity="AppBundle\Entity\User", inversedBy="connSent")
* @ORM\JoinColumn(onDelete="CASCADE")
*/
private $to;
/** @ORM\Column(type="string") */
private $status = 'requested';
public function __construct(
User $from,
User $to
)
// more methods
}
// src/Entity/Friendship.php
/** @ORM\Entity @ORM\Table(name="messages") */
class Message
{
/** @ORM\Column(type="uuid") @ORM\Id */
private $id;
/**
* @ORM\ManyToOne(targetEntity="AppBundle\Entity\User")
* @ORM\JoinColumn(onDelete="NULL")
*/
private $from;
/**
* @ORM\ManyToOne(targetEntity="AppBundle\Entity\User")
* @ORM\JoinColumn(onDelete="NULL")
*/
private $to;
/** @ORM\Column(type="text") */
private $text;
public function __construct(
User $from,
User $to,
string $text
)
}
Doctrine tip
Use UUID
ramsey/uuid-doctrine
$user = new User();
echo $user->getId();
$em->persist($user);
echo $user->getId();
$em->flush();
echo $user->getId();
query count | echo |
---|---|
0 | null |
0 | null |
1 | 5 |
query count | echo |
---|---|
0 | null |
1 | 5 |
2 | 5 |
query count | echo |
---|---|
0 | 57d9dbcd... |
0 | 57d9dbcd... |
1 | 57d9dbcd... |
autoincrement
sequence
uuid
Controllers
User Controller
[GET|DELETE|PATCH|PUT] /user/{id}
// src/Controller/UserController.php
class UserController
{
/** @var EntityManagerInterface */
private $em;
/** @var FormFactoryInterface */
private $formFactory;
public function __construct(
EntityManagerInterface $em,
FormFactoryInterface $formFactory
) {
$this->em = $em;
$this->formFactory = $formFactory;
}
// actions
}
/**
* @Route("/user/{id}", name="user_get", method={"GET"})
*/
public function read(User $user): View
{
return new View($user, Response::HTTP_OK);
}
GET
/**
* @Route("/user/{nickname}", name="user_get", method={"GET"})
*
* @Entity("user", expr="repository.findOneByNickname(nickname)")
*/
public function read(User $user): View
{
return new View($user, Response::HTTP_OK);
}
GET by nickname
/**
* @Route("/user/{id}", name="user", method={"DELETE"})
*/
public function delete(User $user): View
{
$this->em->remove($user);
$this->em->flush();
return new View(null, Response::HTTP_NO_CONTENT);
}
DELETE
/**
* @Route("/user/{id}", name="user_put", method={"PUT"})
*/
public function put(string $id, Request $request): View
{
$user = new User($id);
$form = $this->formFactory->createNamedBuilder('', UserType::class, $user)
->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->em->persist($user);
$this->em->flush();
return new View($user, Response::HTTP_CREATED);
}
return new View($form, Response::HTTP_BAD_REQUEST);
}
PUT
/**
* @Route("/user/{id}", name="user_patch", method={"PATCH"})
*/
public function post(User $user, Request $request): View
{
$form = $this->formFactory->createNamedBuilder('', UserType::class, $user)
->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->em->persist($user);
$this->em->flush();
return new View($user, Response::HTTP_OK);
}
return new View($form, Response::HTTP_BAD_REQUEST);
}
PATCH
/**
* @Route("/user/{id}", name="user_get", method={"GET"})
*/
public function read(User $user): View
{
return new View($user, Response::HTTP_OK);
}
JSON output
{
"email":"me@example.com",
"name":"Tom"
}
class User
{
private $name;
private $email;
// more code ...
}
???
composer require
jms/serializer-bundle
// in addition to existing annotation
class User implements UserInterface
{
/**
* @Serializer\Type("string")
*/
private $id;
/**
* @Serializer\Type("string")
* @Serializer\Expose(
* if="service('security.authorization_checker').isGranted('FULL_USER_VIEW', object)"
* )
*/
private $email;
/**
* @Serializer\Type("DateTime<'Y-m-d H:i:s'>")
*/
private $lastActive;
/**
* @Serializer\Type("DateTime<'Y-m-d H:i:s'>")
*/
private $registered;
// more fields here
}
{
"id":"f357c49c-2abc-40ce-81ab-93766dda8cf1",
"email":"me@example.com",
"last_active":"2017-01-23 16:30:04",
"registered":"2017-01-23 16:30:04"
}
{
"id":"f357c49c-2abc-40ce-81ab-93766dda8cf1",
"last_active":"2017-01-23 16:30:04",
"registered":"2017-01-23 16:30:04"
}
HATEOAS
composer require
willdurand/hateoas-bundle
// in addition to existing annotations
/**
* @Hateoas\Relation("avatar",
* href = @Hateoas\Route(
* "user_avatar",
* parameters = {"id" = "expr(object.getId())"}
* )
* )
* @Hateoas\Relation("friends",
* href = @Hateoas\Route(
* "user_friends_get",
* parameters = {
* "id" = "expr(object.getId())"
* }
* )
* )
*/
class User implements UserInterface
{
"id":"f357c49c-2abc-40ce-81ab-93766dda8cf1",
"name":"Splash",
"email":"me@example.com",
"_links":{
"avatar":{
"href":"http://example.com/user/f357c49c...jpg"
},
"friends":{
"href":"http://example.com/user/f357c49c.../friends/"
}
}
}
{
"id":"f357c49c-2abc-40ce-81ab-93766dda8cf1",
"name":"Splash",
"email":"me@example.com",
"_links":{
"avatar":{
"href":"http://example.com/user/f357c49c...jpg"
},
"friends":{
"href":"http://example.com/user/f357c49c.../friends/"
}
}
}
What about sorting and filtering?
Templated URIs
RFC-6570
"friends":{
"href":".../friends/?{&active,&only_best,&sort*}"
}
composer require
hautelook/templated-uri-bundle
// in addition to existing annotation
/**
* @Hateoas\Relation("friends",
* href = @Hateoas\Route(
* "user_friends_get",
* parameters = {
* "active" = "{active}"
* "best" = "{best}"
* },
* generator="templated_uri"
* )
* )
*/
class User
"friends":{
"href":".../friends/?{&active,&only_best,&sort*}"
}
HTTP Input mapping
PUT /user/....
{
"email":"me@example.com",
"name":"Tom"
}
class User
{
private $name;
private $email;
// more code ...
}
???
composer require
symfony/form
class UserType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('name');
$builder->add('email');
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefault('data_class', User::class);
}
}
PUT /user/f357c49c-2abc-40ce-81ab-93766dda8cf1
Content-Type: application/json
{
"email":"me@example.com",
"name":"Tom"
}
PUT /user/f357c49c-2abc-40ce-81ab-93766dda8cf1
Content-Type: application/json
{
"email":"me@example.com",
"name":"Tom"
}
/**
* @Route("/user/{id}", name="user", method={"PUT"})
*/
public function put(Request $request): View
{
$user = new User();
$form = $this->formFactory->createNamedBuilder('', UserType::class, $user)
->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->em->persist($user);
$this->em->flush();
return new View($user, Response::HTTP_CREATED);
}
return new View($form, Response::HTTP_BAD_REQUEST);
}
PUT
/**
* @Route("/user/{id}", name="user", method={"PUT"})
*/
public function put(Request $request): View
{
$user = new User();
$form = $this->formFactory->createNamedBuilder('', UserType::class, $user)
->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->em->persist($user);
$this->em->flush();
return new View($user, Response::HTTP_CREATED);
}
return new View($form, Response::HTTP_BAD_REQUEST);
}
PUT
Validation
composer require
symfony/validator
/**
* @Route("/user/{id}", name="user", method={"PUT"})
*/
public function put(Request $request): View
{
$user = new User();
$form = $this->formFactory->createNamedBuilder('', UserType::class, $user)
->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->em->persist($user);
$this->em->flush();
return new View($user, Response::HTTP_CREATED);
}
return new View($form, Response::HTTP_BAD_REQUEST);
}
PUT
// in addition to existing annotation
/**
* @UniqueEntity("email", message="This email is already registered")
*/
class User implements UserInterface
{
// more fields here
/**
* @Assert\Email(checkHost=true)
* @Assert\NotBlank
*/
private $email;
/**
* @Assert\NotBlank
* @Assert\Length(min="3")
*/
private $name;
// more fields here
}
User
/**
* @Route("/user/{id}", name="user", method={"PUT"})
*/
public function put(Request $request): View
{
$user = new User();
$form = $this->formFactory->createNamedBuilder('', UserType::class, $user)
->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->em->persist($user);
$this->em->flush();
return new View($user, Response::HTTP_CREATED);
}
return new View($form, Response::HTTP_BAD_REQUEST);
}
PUT
{
"code":400,
"message":"Validation Failed",
"errors":{
"children":{
"name":{
"errors":["This value should not be blank."]
},
"email":{
"errors":["This email is already registered."]
}
}
}
}
User - errors
{
"code":400,
"message":"Validation Failed",
"errors":{
"children":{
"name":{
"errors":["This value should not be blank."],
"error_codes":["not_blank"]
},
"email":{
"errors":["This email is already registered."],
"error_codes":["email_registered"]
}
}
}
}
User - error codes
Versioning
PUT /user/....
Accept: application/json;
{
"email":"me@example.com",
"name":"Tom"
}
public function add(...)
{
// ..
}
PUT /user/....
Accept: application/json; v=2.0
{
"personal": {
"name": "Tom",
"age": 36,
}
}
public function addV2(...)
{
// ..
}
/**
* @Route(
* "/user/{id}",
* name="user_put",
* method={"PUT"}
* )
*/
public function add(): View
{
// ..
}
/**
* @Route(
* "/user/{id}",
* name="user_put_v2",
* method={"PUT"},
* condition="request.attributes.get('version') == '2.0'"
* )
*/
public function addV2(): View
{
// ..
}
// in addition to existing annotations
class User implements UserInterface
{
/**
* @Serializer\Unil("3.0.0")
*/
private $lastActive;
/**
* @Serializer\Since("2.2.0")
*/
private $registered;
}
Versioning - jms/serializer-bundle
Authorization
composer require
symfony/security-bundle
/**
* @Route("/message/{id}", name="message_delete", method={"DELETE"})
*/
public function delete(Message $message): View
{
$this->em->remove($message);
$this->em->flush();
return new View(null, Response::HTTP_NO_CONTENT);
}
DELETE
/**
* @Route("/message/{id}", name="message_delete", method={"DELETE"})
*
* @IsGranted("MESSAGE_DELETE", subject=message)
*/
public function delete(Message $message): View
{
$this->em->remove($message);
$this->em->flush();
return new View(null, Response::HTTP_NO_CONTENT);
}
DELETE
/**
* @Route("/message/{id}", name="message_get", method={"GET"})
*
* @IsGranted("MESSAGE_VIEW", subject=message)
*/
public function read(Message $message): View
{
return new View($message, Response::HTTP_NO_CONTENT);
}
GET
/**
*
* @Security("is_granted('MESSAGE_VIEW', message)")
*
*/
/**
*
* @IsGranted("USER_SEND_MESSAGE", subject=user)
* @IsGranted("MESSAGE_VIEW", subject=message)
* @IsGranted("ASK_FRIENDSHIP", subject=firend)
*
*/
/**
*
* @Security("is_granted('SEND', message) || isAdmin()")
*
*/
Security Voters
/**
*
* @Security("is_granted('SEND_MESSAGE', user)")
*
*/
// src/Voter/MessageVoter.php
class MessageVoter extends Voter
{
protected function supports($attribute, $subject)
{
return $attribute === 'SEND_MESSAGE';
}
protected function voteOnAttribute($attribute, $receiver, TokenInterface $token)
{
$loggedUser = $token->getUser();
if (!($loggedUser instanceof User)) {
// the user must be logged in; if not, deny access
return false;
}
switch ($attribute) {
case 'SEND_MESSAGE':
return $receiver->isFriend($loggedUser);
}
throw new \LogicException();
}
}
Rate Limiting
composer require
noxlogic/ratelimit-bundle
/**
* @RateLimit(limit=5000, period=3600)
*/
public function read(User $user): View
{
// ...
}
Rate Limit Info
...
X-RateLimit-Limit: 5000
X-RateLimit-Remaining: 506
X-RateLimit-Reset: 60
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 5000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 60
Documentation
composer require
nelmio/api-doc-bundle
/**
* Edit an user.
*
* @SWG\Parameter(
* name="Authorization",
* in="header",
* type="string",
* description="API Key",
* required=true
* )
* @SWG\Parameter(
* name="body",
* in="body",
* type="json",
* description="User data",
* required=true,
* @Model(type=UserType::class)
* )
* @SWG\Response(
* response=200,
* description="User data",
* @Model(type=User::class, groups={"full"})
* )
*
* @SWG\Response(
* response=400,
* description="Errors",
* @Model(type=JMSErrors::class)
* )
*
*/
public function patch(User $user, Request $request): View
lunetics/locale-bundle snc/redis-bundle nelmio/cors-bundle symfony/monolog-bundle
chrisguitarguy/request-id-bundle
....
More projects
What to take home?
use small libraries
that do (well) few things
and can be glued together
symfony + friendsofsymfony/rest-bundle doctrine/doctrine-bundle jms/serializer-bundle willdurand/hateoas-bundle hautelook/templated-uri-bundle symfony/form symfony/validation symfony/security-bundle nelmio/api-doc-bundle noxlogic/ratelimit-bundle
(and may other packages...)
=
Great API
Open source software is awesome!
Thank you!
- Twitter: @goetas_asmir
- Github: @goetas
- LinkedIn: @goetas
- WWW: goetas.com
Please rate this talk on joind.in
Great APIs with Symfony and Open Source Software - SymfonyLive Berlin 2019
By Asmir Mustafic
Great APIs with Symfony and Open Source Software - SymfonyLive Berlin 2019
This is a walk-trough on how to build a rich, RESTful (Level 3 including HATEOAS) API for web applications. The result will be a standard, documented, validated and easy to maintain API that users will enjoy and developers love to build.
- 1,534