Great APIs
with Symfony and
Open Source Software

Asmir Mustafic
@goetas

SymfonyCamp UA 2019
Kyiv - Ukraine

Me

Asmir Mustafic

Me

@goetas

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)
  • willdurand/hateoas (maintainer)
  • goetas/twital (author)
  • doctrine/migrations (brace your self for 3.0)

 

  • 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!

First Law of Software Quality

e = mc^2
errors = (more\ code)^2

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\Until("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();
    }
}

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!

Should you trust me?

DDD?
CQRS?
ES?

/**
 * @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);
}
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/87
Content-Type: application/json

{
   "email":"me@example.com",
   "name":"Tom"
}
class User 
{
    private $name;
    private $email;

    public function getName(): string
    {
        return $this->name;
    }
    
    public function setName(string $name): void
    {
        $this->name = $name;
    }
    
    public function getEmail(): string
    {
        return $this->email;
    }
    
    public function setEmail(string $email): void
    {
        $this->email = $email;
    }

    // more code

}

Anemic Domain Model

Rich Domain Model

DDD
CQRS
ES

DDD

Business Domain vs Infrastructure, Bounded contexts, internal consistency...

CQRS

Split Read and Write models

Event Sourcing

Read model is the result of event interpretations

CQRS + ES

Extreme isolation of READs from WRITEs

Read/Write Domain

R                 W

Health Tracking App

Read/Write Domain

R   W

LinkedIn Profile

Read/Write Domain

R                 W

R   W

LinkedIn Profile

Health Tracking App

Read/Write Domain

CQRS/ES

CRUD

pain

time   or   model complexity

Rich model

Anemic model

Choose carefully your pain!

Thank you!

Great APIs with Symfony and Open Source Software - SymfonyCamp UA 2019 - Kyiv

By Asmir Mustafic

Great APIs with Symfony and Open Source Software - SymfonyCamp UA 2019 - Kyiv

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.

  • 2,361