API with boring code and Symfony

Asmir Mustafic

Symfony User Group - Berlin - August 2017

WHO AM I?

Asmir Mustafic

Bosnia, Italy, Germany

Freelance software developer

Open source

  • jms/serializer  (maintainer)
  • html5-php (maintainer)
  • xsd2php (author)
  • twital (author)
  • doctrine2 (contributor)
  • and many other...

contributed/contributing to:

Personal experience

collabout.com

collabout.com

Marketplace for

sponsors

and

event organizers

collabout.com

collabout.com

symfony 3.2 (api)

+
AngularJS 1.6 (frontend)

 

API

 

requirements

CRUD

  • Users
  • Events
  • Companies
  • Messages
    • mark-as-read
  • ​Friendship
    • ask confirm/ignore

But also ...

  • Authentication​
    • ​password, token, oauth...
  • Authorization
    • ​who can see my data
    • who can contact me
    • what am I allowed to do
  • ​Application behavior
    • ​send notifications
    • save interaction results
    • ...

Rich

Fast

Up

Stable

Easy to change

Quick to develop
Easy to use (Standard and Documented)

more and more...

And more...

Documented

documentation and code must grow together

otherwise they will go
out of sync

RESTFul

http://stackoverflow.com/questions/20335967/how-useful-important-is-rest-hateoas-maturity-level-3

HATEOAS

GET /account/12345


HTTP/1.1 200 OK
Content-Type: application/xml
Content-Length: ...

<?xml version="1.0"?>
<account>
   <account_number>12345</account_number>
   <balance currency="usd">100.00</balance>
   <link rel="deposit" href="https://somebank.org/account/12345/deposit" />
   <link rel="withdraw" href="https://somebank.org/account/12345/withdraw" /> 
   <link rel="transfer" href="https://somebank.org/account/12345/transfer" />
   <link rel="close" href="https://somebank.org/account/12345/close" />
 </account>

It looks pretty hard,
right?

How to reach the goal?

Let's write some code

Boring code

Boring Controllers

  • max 10 lines of code for each action
    • no other methods (protected/private helpers)
  • no conditionals/control structures (almost)
  • single responsibility principle
  • dependency injection as much as possible
    (if not always!)

Boring services

  • same level of abstraction
  • no framework dependencies (only controllers?)
    • framework-deps != libraries-deps
  • allow easy extraction to separate packages/services/microservices

this is another topic!

Pragmatic approach

Implementation

Entities

Controllers
Services

   +
authorization + serialization + input handling + validation

Symfony

(used just as a glue for many other [cool] libraries)

doctrine/orm

/** @ORM\Entity */
class User [implements UserInterface, \Serializable, EquatableInterface]
{
    /** @ORM\Column(type="uuid") @ORM\Id  */
    private $id;

    /** @ORM\Column(length=255, unique=true) */
    private $email;

    /** @ORM\OneToMany(targetEntity="AppBundle\Entity\Event", mappedBy="createdBy") */
    private $events;

    /** @ORM\OneToMany(targetEntity="AppBundle\Entity\Company", mappedBy="createdBy") */
    private $companies;

    // more more fields

    public function __construct(UuidInterface $id = null)
    {
        $this->id = $id ?: Uuid::uuid4();
        $this->createdAt = new \DateTime();
        $this->connections = new ArrayCollection();
        $this->events = new ArrayCollection();
        $this->companies = new ArrayCollection();
    }

    // more methods
}
/** @ORM\Entity */
class Company
{
    /** @ORM\Column(type="uuid") @ORM\Id  */
    private $id;

    /** @ORM\Column(type="string") */
    private $name;

    /**
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\User", inversedBy="companies")
     * @ORM\JoinColumn(onDelete="CASCADE")
     */
    private $createdBy;

    // more more fields

    public function __construct(User $createdBy, UuidInterface $id = null)
    {
        $this->id = $id ?: Uuid::uuid4();
        $this->createdBy = $createdBy;
        $this->createdAt = new \DateTime();
    }

    // more methods
}
/** @ORM\Entity */
class Event
{
    /** @ORM\Column(type="uuid") @ORM\Id  */
    private $id;

    /** @ORM\Column(type="string") */
    private $name;

    /**
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\User", inversedBy="events")
     * @ORM\JoinColumn(onDelete="CASCADE")
     */
    private $createdBy;

    // more more fields

    public function __construct(User $createdBy, UuidInterface $id = null)
    {
        $this->id = $id ?: Uuid::uuid4();
        $this->createdBy = $createdBy;
        $this->createdAt = new \DateTime();
    }

    // more methods
}
/** @ORM\Entity */
class Connection
{
    /** @ORM\Column(type="uuid") @ORM\Id */
    private $id;

    /**
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\User", inversedBy="connections")
     * @ORM\JoinColumn(onDelete="CASCADE")
     */
    private $from;

    /**
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\User",  inversedBy="connected")
     * @ORM\JoinColumn(onDelete="CASCADE")
     */
    private $to;

    /** @ORM\Column(type="string") */
    private $status = 'requested';

    public function __construct(
        User $from,
        User $to
    )
    // more methods
}
/** @ORM\Entity */
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
    )
    // more more fields
    // more methods
}

Doctrine tip

Use UUID



$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

Company Controller
 

[GET|DELETE|POST|PUT] /company/{id}
/** @Route(service="app.controller.company") */
class CompanyController
{
    /** @var ObjectManager */
    private $em;

    /** @var CompanyRepository */
    private $repo;

    /** @var TokenStorageInterface */
    private $tokenStorage;

    /** @var FormFactoryInterface */
    private $formFactory;

    public function __construct(
        ObjectManager $em, 
        CompanyRepository $companyRepository,
        TokenStorageInterface $tokenStorage,
        FormFactoryInterface $formFactory)
    {
        $this->em = $em;
        $this->repo = $companyRepository;
        $this->tokenStorage = $tokenStorage;
        $this->formFactory = $formFactory;
    }

    // ...
}
/**
 * @Route("/company/{id}", name="company_get")
 * @Method({"GET"})
 *
 * @Security("is_granted('COMPANY_VIEW', company)")
 */
public function get(Company $company): View
{
    return new View($company, Response::HTTP_OK);
}

GET

/**
 * @Route("/company/", name="company_list")
 * @Method({"GET"})
 *
 * @QueryParam(name="name", nullable=true)
 * @QueryParam(name="place", map=true)
 */
public function list(ParamFetcher $paramFetcher, Request $request): View
{
    $filters = $paramFetcher->all();
    $count = $this->repo->countByFilters($filters);

    // pagination with HTTP Range header
    list ($start, $limit) = RangeUtils::extract($request, 20, 50);

    $companies = $this->repo->findByFilters($filters, ['date' => 'DESC'], $start, $limit);

    return new View($companies, Response::HTTP_PARTIAL_CONTENT, [
        "Content-Range" => RangeUtils::create($start, count($companies), $count),
        "Vary" => "Accept-Encoding,Accept-Language,Range",
    ]);
}

GET Collection

/**
 * @Route("/company/{id}", name="company_delete")
 * @Method({"DELETE"})
 *
 * @Security("is_granted('COMPANY_DELETE', company)")
 */
public function delete(Company $company): View
{
    $this->em->remove($company);
    $this->em->flush();

    return new View(null, Response::HTTP_NO_CONTENT);
}

DELETE

/**
 * @Route("/company/{id}", name="company_put")
 * @Method({"PUT"})
 *
 * @Security("is_granted('COMPANY_CREATE')")
 */
public function put(Request $request): View
{
    $company = new Company($this->tokenStorage->getToken()->getUser());

    $form = $this->formFactory->createNamedBuilder('', CompanyType::class, $company)->getForm();

    $form->submit($request->request->all());
    if ($form->isValid()) {

        $this->em->persist($company);
        $this->em->flush();

        return new View($company, Response::HTTP_CREATED);
    }

    return new View($form, Response::HTTP_BAD_REQUEST);
}

PUT

/**
 * @Route("/{id}", name="company_post")
 * @Method({"POST"})
 *
 * @Security("is_granted('COMPANY_EDIT', company)")
 */
public function post(Company $company, Request $request): View
{
    $form = $this->formFactory->createNamedBuilder('', CompanyType::class, $company)->getForm();
    $form->submit($request->request->all(), false);

    if ($form->isSubmitted() && $form->isValid()) {

        $this->em->persist($company);
        $this->em->flush();

        return new View($company, Response::HTTP_OK);
    }

    return new View($form, Response::HTTP_BAD_REQUEST);
}

POST

Event Controller
 

[GET|DELETE|POST|PUT] /event/{id}

Event Controller
 

Identical to Company Controller
Ctrl+R "company" -> "event" (preserve case)

User Controller
 

[GET|DELETE|POST|PUT] /user/{id}

User Controller
 

Identical to Company Controller
Ctrl+R "company" -> "user" (preserve case)

class MessagesController
{
  public function markAsReadAction(User $user, User $to): View;

  public function conversationsAction(User $user): View;

  public function createMessage(User $user, User $to, Request $request): View

  public function listMessages(User $user, User $to, Request $request): View
}

other...

Serialization to JSON

jms/serializer

// in addition to existing annotation
class User implements UserInterface, \Serializable, EquatableInterface
{
    /** 
     * @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'>")
     * @Serializer\Expose(
     *   if="service('security.authorization_checker').isGranted('PARTIAL_USER_VIEW', object)"
     * )
     */
    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"
}
/** @ORM\Entity */
class Company
{
    /**
     * @Serializer\Type("string")
     */
    private $id;

    /**
     * @Serializer\Type("string")
     */
    private $name;

    /**
     * @Serializer\Type("string")
     * @Serializer\Expose(
     *   if="service('security.authorization_checker').isGranted('COMPANY_INFO', object)"
     * )
     */
    private $link;

    // more more fields

}
{
   "id":"ab9745f0-2abc-40ce-81ab-93766dda8cf1",
   "name":"Acme",
   "link":"http://www.example.com"
}

And so on...

HATEOAS

willdurand/hateoas

// in addition to existing annotation
/**
 * @Hateoas\Relation("self",
 *     href = @Hateoas\Route(
 *              "user_get", 
 *              parameters = {"id" = "expr(object.getId())"}
 *     )
 * )
 * @Hateoas\Relation("conversations",
 *     href = @Hateoas\Route(
 *                  "message_get_conversations", 
 *                  parameters = {"id" = "expr(object.getId())"}
 *     )
 * )
 * @Hateoas\Relation("add_as_friend",
 *     href = @Hateoas\Route(
 *              "user_connections_put",
 *              generator="templated_uri",
 *              parameters = {
 *                  "friend" = "expr(object.getId())",
 *                  "id" = "{user}"
 *             }
 *     ),
 *     attributes={"templated":true}
 * )
 */
class User
{
   "id":"f357c49c-2abc-40ce-81ab-93766dda8cf1",
   "name":"Splash",
   "email":"me@example.com",
   "_links":{
      "self":{
         "href":"http://www.example.com/api/user/f357c49c..."
      },
      "conversations":{
         "href":"http://www.example.com/api/messages/f357c49c.../conversations"
      },
      "add_as_friend":{
         "href":"http://www.example.com/api/user/{user}/connections/f357c49c...",
         "templated":true
      }
   }
}
/**
 * @Hateoas\Relation("self",
 *     href = @Hateoas\Route("company_get", parameters = {"id" = "expr(object.getId())"})
 * )
 * @Hateoas\Relation("logo",
 *     href = @Hateoas\Route("company_logo_get", parameters = {"id" = "expr(object.getId())"})
 * )
 * @Hateoas\Relation(
 *     name = "created_by",
 *     embedded = @Hateoas\Embedded(
 *         "expr(object.getCreatedBy())"
 *     )
 * )
 */
class Company
{
   "id":"ab9745f0-2abc-40ce-81ab-93766dda8cf1",
   "name":"Acme",
   "link":"http://www.example.com",
   "_links":{
      "self":{
         "href":"http://www.example.com/api/company/ab9745f0..."
      },
      "logo":{
         "href":"http://www.example.com/api/company/ab9745f0.../logo"
      }
   },
    "_embedded":{
      "created_by":{
           "id":"f357c49c-2abc-40ce-81ab-93766dda8cf1",
           "name":"Splash",
           "email":"me@example.com",
           "_links":{
              "self":{
                 "href":"http://www.example.com/api/user/f357c49c..."
              },
              "conversations":{
                 "href":"http://www.example.com/api/messages/f357c49c.../conversations"
              },
              "add_as_friend":{
                 "href":"http://www.example.com/api/user/{user}/connections/f357c49c...",
                 "templated":true
              }
           }
      }
   }
}

And so on...

HTTP Input

symfony/form

class UserType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('name');
        $builder->add('email');
        $builder->add('bio');
        $builder->add('city');
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefault('data_class', User::class);
    }
}

User POST/PUT

POST /user/f357c49c-2abc-40ce-81ab-93766dda8cf1
Content-Type: application/json

{
   "email":"me@example.com"
}

User POST/PUT

And so on...

Validation

symfony/validator

// in addition to existing annotation
/**
 * @UniqueEntity("email, message="This email is already registered")
 */ 
class User implements UserInterface, \Serializable, EquatableInterface
{
    // more fields here

    /**
     * @Assert\Email(checkHost=true)
     * @Assert\NotBlank
     */
    private $email;

    /**
     * @Assert\NotBlank
     * @Assert\Length(min="3")
     */
    private $name;

    // more fields here
}

User

{
   "code":400,
   "message":"Validation Failed",
   "errors":{
      "children":{
         "name":{
            "errors":[
               "This value should not be blank."
            ]
         },
         "email":{
            "errors":[
               "This email is already registered."
            ]
         },
         "description":{

         }
      }
   }
}

User - errors

And so on...

Authorization

symfony/security

security voters

/**
 *
 * @Security("is_granted('SOME_PERMISSION_NAME', subject)")
 *
 */ 
class CompanyVoter extends Voter
{
    protected function supports($attribute, $subject)
    {
        ...
    }

    protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
    {
        $user = $token->getUser();
        if (!($user instanceof User)) {
            // the user must be logged in; if not, deny access
            return false;
        }

        /** @var Company $subject */

        switch ($attribute) {
            case 'COMPANY_CREATE':
                return $this->canCreate($user);
            case 'COMPANY_EDIT':
                return $this->canEdit($subject, $user);
            case 'COMPANY_DELETE':
                return $this->canDelete($subject, $user);
            case 'COMPANY_VIEW':
                return $this->canView($subject, $user);
        }

        throw new \LogicException();
    }
}

or try to use roles...

attention to not end up in a endless ACL

Documentation

nelmio/api-doc-bundle

/**
 * Save a user.
 *
 * @ApiDoc(
 *     resource=true,
 *     section="User",
 *     statusCodes={
 *         200="Returned when successful",
 *         404="Returned when the user is not found",
 *         400="Returned when validation fails"
 *     },
 *     input="AppBundle\Form\Type\UserType",
 *     output="AppBundle\Entity\User"
 * )
 */
public function post(User $user, Request $request)
{
   //...
}

POST /user/{id}

And so on...

CORS

nelmio/cors-bundle

nelmio_cors:
    defaults:
        allow_credentials: true
        allow_origin: ['www.collabout.com']
        allow_methods: ['POST', 'PUT', 'GET', 'DELETE', 'OPTIONS', 'PATCH']
        allow_headers: 
         - 'Authorization'
         - 'Content-Type'
         - 'Origin'
         - 'Accept-Encoding'
         - 'Accept-Language'
         - 'Range'
    paths:
        '^/': ~

What to take home?

symfony/framework
friendsofsymfony/rest-bundle

 

doctrine/orm

jms/serializer

willdurand/hateoas

symfony/form

symfony/validation

symfony/security

nelmio/api-doc-bundle

nelmio/cors-bundle

(and may other packages...)

Open source software is awesome!

use small libraries

that do (well) few things

and can be glued together 

Thank you!

An API with boring code - Symfony User Group Berlin 2017

By Asmir Mustafic

An API with boring code - Symfony User Group Berlin 2017

  • 2,207