Asmir Mustafic
Symfony User Group - Berlin - August 2017
Bosnia, Italy, Germany
Freelance software developer
contributed/contributing to:
Marketplace for
sponsors
and
event organizers
requirements
Rich
Fast
Up
Stable
Easy to change
Quick to develop
Easy to use (Standard and Documented)
more and more...
http://stackoverflow.com/questions/20335967/how-useful-important-is-rest-hateoas-maturity-level-3
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>
Entities
Controllers
Services
+
authorization + serialization + input handling + validation
(used just as a glue for many other [cool] libraries)
/** @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
}
$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|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);
}
/**
* @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",
]);
}
/**
* @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);
}
/**
* @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);
}
/**
* @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);
}
[GET|DELETE|POST|PUT] /event/{id}
Identical to Company Controller
Ctrl+R "company" -> "event" (preserve case)
[GET|DELETE|POST|PUT] /user/{id}
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
}
// 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...
// 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...
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);
}
}
POST /user/f357c49c-2abc-40ce-81ab-93766dda8cf1
Content-Type: application/json
{
"email":"me@example.com"
}
And so on...
// 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
}
{
"code":400,
"message":"Validation Failed",
"errors":{
"children":{
"name":{
"errors":[
"This value should not be blank."
]
},
"email":{
"errors":[
"This email is already registered."
]
},
"description":{
}
}
}
}
And so on...
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();
}
}
/**
* 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)
{
//...
}
And so on...
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:
'^/': ~
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...)