Asmir Mustafic - @goetas
SymfonyLive 2019 Berlin
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\Unil("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
noxlogic/ratelimit-bundle
/**
* @RateLimit(limit=5000, period=3600)
*/
public function read(User $user): View
{
// ...
}
...
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
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...)
Please rate this talk on joind.in