Share
Embed
Building REST API with Symfony: experience, errors and recipes
Ever built a REST API? And did you try to do it with Symfony? I will tell the story about a small outsource team, which did all that within several projects. From handling request to generating proper responses. Through versioning, error handling, access and rights control, testing, documenting and more. With examples of our bad decisions and recipes to good ones. This talk will give you the perspective on how to use powerful tools in both good and bad ways with examples from real projects.
Roma
November 14, 2015
Transcript
75 slides
-
Building REST API with Symfony: experience, errors and recipes Roman
Lapin, Evercode Lab [email protected], @memphys -
whoami Co-founder and CEO at Evercode Lab. Developer transformed into
manager, occasional public speaker and consultant and table- foosball fan. -
What this talk is NOT about Ideology Terminology Safe/Unsafe Idempotency
RFC2616 PUT or PATCH How to use PATCH HTTP Cache Unicorns Religion -
What this talk is NOT about Ideology Terminology Safe/Unsafe Idempotency
RFC2616 PUT or PATCH How to use PATCH HTTP Cache Unicorns Religion -
What this talk IS about… Practical aspects of building RESTful
APIs using Symfony with some examples. -
…and why? Because despite of great components, bundles and documentation
in Symfony world there are too many overcomplications and not enough recipes for RESTful APIs. -
…and why? Because despite of great components, bundles and documentation
in Symfony world there are too many overcomplications and not enough recipes for RESTful APIs. -
…and why? Because despite of great components, bundles and documentation
in Symfony world there are too many overcomplications and not enough recipes for RESTful APIs. And REST itself is not that easy. -
Who are the users/clients of our APIs? Mobile applications (iOS,
Android etc.) Frontend Applications (Angular, Backbone etc.) Most of our APIs aren’t open to everyone. -
Because it knows about HTTP verbs Request/Response structures Validation Security
and all kinds of other stuff -
ʦʒʒ AppBundle ʮʒʒ Admin ʮʒʒ Controller ʔ ʮʒʒ Api ʔ
ʦʒʒ Web ʮʒʒ Dto ʮʒʒ Entity ʔ ʦʒʒ Repository ʮʒʒ Resources ʔ ʦʒʒ config ʮʒʒ Security ʮʒʒ Services ʔ ʦʒʒ Listeners ʮʒʒ Test ʮʒʒ Tests ʔ ʦʒʒ Controller ʔ ʮʒʒ Api ʔ ʦʒʒ Web -
ʦʒʒ AppBundle ʮʒʒ Admin ʮʒʒ Controller ʔ ʮʒʒ Api ʔ
ʦʒʒ Web ʮʒʒ Dto ʮʒʒ Entity ʔ ʦʒʒ Repository ʮʒʒ Resources ʔ ʦʒʒ config ʮʒʒ Security ʮʒʒ Services ʔ ʦʒʒ Listeners ʮʒʒ Test ʮʒʒ Tests ʔ ʦʒʒ Controller ʔ ʮʒʒ Api ʔ ʦʒʒ Web -
ʦʒʒ AppBundle ʮʒʒ Admin ʮʒʒ Controller ʔ ʮʒʒ Api ʔ
ʦʒʒ Web ʮʒʒ Dto ʮʒʒ Entity ʔ ʦʒʒ Repository ʮʒʒ Resources ʔ ʦʒʒ config ʮʒʒ Security ʮʒʒ Services ʔ ʦʒʒ Listeners ʮʒʒ Test ʮʒʒ Tests ʔ ʦʒʒ Controller ʔ ʮʒʒ Api ʔ ʦʒʒ Web One bundle with division for specific classes like controllers, listeners, tests, etc. -
ʦʒʒ AppBundle ʮʒʒ Admin ʮʒʒ Controller ʔ ʮʒʒ Api ʔ
ʦʒʒ Web ʮʒʒ Dto ʮʒʒ Entity ʔ ʦʒʒ Repository ʮʒʒ Resources ʔ ʦʒʒ config ʮʒʒ Security ʮʒʒ Services ʔ ʦʒʒ Listeners ʮʒʒ Test ʮʒʒ Tests ʔ ʦʒʒ Controller ʔ ʮʒʒ Api ʔ ʦʒʒ Web One bundle with division for specific classes like controllers, listeners, tests, etc. -
ʮʒʒ Api ʔ ʮʒʒ Cart ʔ ʔ ʮʒʒ Adapters ʔ
ʔ ʮʒʒ ChoiceLists ʔ ʔ ʦʒʒ Dto ʔ ʮʒʒ Exception ʔ ʮʒʒ Notifications ʔ ʦʒʒ User ʔ ʮʒʒ Adapters ʔ ʮʒʒ Dto ʔ ʮʒʒ Event ʔ ʦʒʒ Exception ʮʒʒ ApiBundle ʔ ʮʒʒ Controller ʔ ʮʒʒ Form ʔ ʔ ʦʒʒ Subscribers ʔ ʮʒʒ Handler ʔ ʮʒʒ Listener ʔ ʮʒʒ Tests ʔ ʦʒʒ Validator ʔ ʦʒʒ Constraints ʮʒʒ App ʔ ʦʒʒ Entity ʔ ʮʒʒ Exception ʔ ʦʒʒ Repository ʦʒʒ AppBundle -
ʮʒʒ Api ʔ ʮʒʒ Cart ʔ ʔ ʮʒʒ Adapters ʔ
ʔ ʮʒʒ ChoiceLists ʔ ʔ ʦʒʒ Dto ʔ ʮʒʒ Exception ʔ ʮʒʒ Notifications ʔ ʦʒʒ User ʔ ʮʒʒ Adapters ʔ ʮʒʒ Dto ʔ ʮʒʒ Event ʔ ʦʒʒ Exception ʮʒʒ ApiBundle ʔ ʮʒʒ Controller ʔ ʮʒʒ Form ʔ ʔ ʦʒʒ Subscribers ʔ ʮʒʒ Handler ʔ ʮʒʒ Listener ʔ ʮʒʒ Tests ʔ ʦʒʒ Validator ʔ ʦʒʒ Constraints ʮʒʒ App ʔ ʦʒʒ Entity ʔ ʮʒʒ Exception ʔ ʦʒʒ Repository ʦʒʒ AppBundle Better isolation, but harder to find things and more complex structure to support. -
Separate endpoint In the browser: /categories /orders In the API:
/api/v1/categories /api/v1/orders -
json or what? Support of multiple formats on input and
output is cool Symfony and FOSRestBundle allow to do it But it always adds complexity Do you really need it? -
json or what? fos_rest: format_listener: false view: view_response_listener: force formats:
xml: false json: true templating_formats: html: false body_listener: decoders: json: fos_rest.decoder.jsontoform -
FOSRestBundle Allows to send payload in json and process it
like forms (Request params, actually) Output data in any set of formats you really need Integrates with JMSSerializerBundle to do it easily -
Forms $form = $this->createForm(new OrderType()); $form->handleRequest($request); if (!$form->isValid()) { return
$this->returnBadRequest(new InputFormatError($this->gatherFormErrors($form))); } -
Forms $form = $this->createForm(new OrderType()); $form->handleRequest($request); if (!$form->isValid()) { return
$this->returnBadRequest(new InputFormatError($this->gatherFormErrors($form))); } -
Forms abstract class AbstractRestApiType extends AbstractType { public function configureOptions(OptionsResolver
$resolver) { $resolver->setDefaults([ 'csrf_protection' => false, 'allow_extra_fields' => true ]); } public function getName() { return ''; } } -
Forms abstract class AbstractRestApiType extends AbstractType { public function configureOptions(OptionsResolver
$resolver) { $resolver->setDefaults([ 'csrf_protection' => false, 'allow_extra_fields' => true ]); } public function getName() { return ''; } } -
Forms abstract class AbstractRestApiType extends AbstractType { public function configureOptions(OptionsResolver
$resolver) { $resolver->setDefaults([ 'csrf_protection' => false, 'allow_extra_fields' => true ]); } public function getName() { return ''; } } -
ParamFetcher $title = $paramFetcher->get(‘title’); $venue = $this->get(‘app.venue.manager') ->create($paramFetcher->all(), $user); //
Force validation and in the service without forms -
Validator component namespace App\Entity; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints
as Assert; class Promocode extends Entity { /** * @ORM\Column(type="string", length=60, nullable=false) * @Assert\NotBlank() * @var string */ private $code; -
Gathering errors protected function gatherFormErrors(Form $form) { $errors = [];
foreach ($form->getErrors(true) as $err) { $errors[$err->getOrigin()->getName()][] = $err->getMessage(); } return $errors; } -
Building blocks JMSSerializerBundle Model is not always directly a representation
Use DTO to control response -
namespace Api\Address\Dto; use JMS\Serializer\Annotation as JMS; use Api\Search\Dto\Location; class Address
{ /** * Id of address * @JMS\Type("integer") * @var int */ public $id; /** * City * @JMS\Type("string") * @var string */ public $city; /** * Location * @JMS\Type("Api\Search\Dto\Location") * @var Location */ public $location; … -
public function getUserAddresses(User $user) { $addressesAsDto = []; foreach ($user->getAddresses()
as $address) { $addressesAsDto[] = $this->adapterFactory ->userAddressAdapter() ->transform($address); } return $addressesAsDto; } -
Why? Less magic, more control Custom serialization rules and virtual
properties Isolate entities from response (safe changes) Not so bloated entities Ease up documentation -
Pretty facts about errors How you handle and output errors
is one of the most important parts of your API. -
Pretty facts about errors How you handle and output errors
is one of the most important parts of your API. And let’s be honest in most cases it is the last thing you pay attention to. -
Silver bullet? HTTP/1.1 403 Forbidden Content-Type: application/problem+json Content-Language: en {
"type": "http://example.com/probs/out-of-credit", "title": "You do not have enough credit.", "detail": "Your current balance is 30, but that costs 50.", "instance": "http://example.net/account/12345/msgs/abc", "balance": 30, "accounts": ["http://example.net/account/12345", "http://example.net/account/67890"] } Thanks, KnpUniversity! -
class ApiProblem { const TYPE_VALIDATION_ERROR = ‘validation_error'; const TYPE_INVALID_REQUEST_BODY_FORMAT =
'invalid_body_format'; … private static $titles = array( … ); private $statusCode; private $type; private $extraData = array(); public function toArray() { return array_merge( $this->extraData, [ 'status' => $this->statusCode, 'type' => $this->type, 'title' => $this->title, ]); } -
class ApiProblemException extends HttpException { private $apiProblem; public function __construct(ApiProblem
$apiProblem, \Exception $previous = null, array $headers = array(), { $this->apiProblem = $apiProblem; $statusCode = $apiProblem->getStatusCode(); $message = $apiProblem->getTitle(); parent::__construct( $statusCode, $message, $previous, $headers, $code ); } } } -
Then what? $apiProblem = new ApiProblem( 400, ApiProblem::TYPE_INVALID_REQUEST_BODY_FORMAT ); throw
new ApiProblemException($apiProblem); -
Then what? The ApiProblem object knows everything about how the
response should look You extend and throw ApiProblemException for all the problems you get You need a common place to catch them and transform into a proper response -
onKernelException public function onKernelException(GetResponseForExceptionEvent $event) { $exception = $event->getException(); if
($exception instanceof BadRequestHttpException) { } if ($exception instanceof ApiProblemException) { $response = $exception->getApiProblem()->toArray(); } if ($exception instanceof AccessDeniedHttpException) { } $event->setResponse($response); } -
onKernelException Limit to api routes only Add debug option to
show more information in dev Catch and process other HttpExceptions you need -
NelmioApiDocBundle /** * @ApiDoc( * section="Catalog", * resource=true, * description="Returns
list of places depending on location and preferred delivery time", * output="Api\Search\Dto\SearchResult", * statusCodes={ * 200="Returned when one or more places found or nothing found", * } * ) */ public function searchCatalogAction(Request $request) -
DTO for output /** * @ApiDoc( * section="Catalog", * resource=true,
* description="Returns list of places depending on location and preferred delivery time", * output="Api\Search\Dto\SearchResult", * statusCodes={ * 200="Returned when one or more places found or nothing found", * } * ) */ public function searchCatalogAction(Request $request) -
Forms for input /** * @ApiDoc( * section="Orders", * resource=true,
* input="ApiBundle\Form\CreateFirstOrderType", * output="Api\Order\CreatedOrder", * description="Creates address and order and returns order number", * statusCodes={ * 200="Returned when order created successfully", * 400="Returned when input parameters are invalid" * }, * ) * @Rest\Post( * "/orders/first" * ) */ public function createFirstOrderAction(Request $request) -
QueryParam for filterinng * @Rest\Get( * "/catalog/search" * ) *
@Rest\QueryParam( * name="latitude", * requirements="-?\d+(\.\d+)?", * strict=true, * description="Destination latitude" * ) * @Rest\QueryParam( * name="longitude", * requirements="-?\d+(\.\d+)?", * strict=true, * description="Destination longitude" * ) -
Security Use any mechanism you like to ensure user access
(token, oAuth, etc.) Check the access based on blacklist/whitelist of routes in the listener Use Voters to check for other complicated access rules -
Just be consistent in Request/Response format Errors structure Naming stuff
(like resources and fields) Status codes -
You should thank them https://twitter.com/couac https://twitter.com/lsmith https://twitter.com/weaverryan All the creators
of and contributors to Symfony, all the libraries and bundles around it