Asmir Mustafic
@goetas
SymfonyCamp UA 2019
Kyiv - Ukraine
Berlin
My first index.html in 1999
http://stackoverflow.com/questions/20335967/how-useful-important-is-rest-hateoas-maturity-level-3
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"
}
}
}
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);
(for humans and machines)
https://twitter.com/mmrichards/status/602949000690466816
composer create-project symfony/skeleton api-demo
<?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
composer require annotations
composer require
friendsofsymfonny/rest-bundle
Entities
Controllers
Services
Bundles
class User
{
private $name;
private $email;
public function __construct($name)
{
$this->name = $name;
}
// more code ...
}
$user = new User('Tom');
$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
)
}
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
[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);
}
/**
* @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);
}
/**
* @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);
}
/**
* @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);
}
/**
* @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);
}
/**
* @Route("/user/{id}", name="user_get", method={"GET"})
*/
public function read(User $user): View
{
return new View($user, Response::HTTP_OK);
}
{
"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"
}
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?
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*}"
}
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);
}
/**
* @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);
}
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);
}
// 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
}
/**
* @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);
}
{
"code":400,
"message":"Validation Failed",
"errors":{
"children":{
"name":{
"errors":["This value should not be blank."]
},
"email":{
"errors":["This email is already registered."]
}
}
}
}
{
"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"]
}
}
}
}
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;
}
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);
}
/**
* @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);
}
/**
* @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);
}
/**
*
* @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("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();
}
}
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
....
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...)
/**
* @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
}
Business Domain vs Infrastructure, Bounded contexts, internal consistency...
Split Read and Write models
Read model is the result of event interpretations
Health Tracking App
LinkedIn Profile
LinkedIn Profile
Health Tracking App