API with boring code and Symfony
Asmir Mustafic
Symfony User Group - Berlin - August 2017
WHO AM I?
Asmir Mustafic
- Twitter: @goetas_asmir
- Github: @goetas
- LinkedIn: @goetas
- WWW: goetas.com
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,355