Proper JSON API Endpoint Setup

It's time to graduate from this old-school AJAX approach where the server sends us HTML, to one where the server sends us ice cream! I mean, JSON!

First, in LiftController::indexAction(), let's remove the two AJAX if statements from before: we won't use them anymore:

97 lines src/AppBundle/Controller/LiftController.php
... lines 1 - 10
class LiftController extends BaseController
{
... lines 13 - 15
public function indexAction(Request $request)
{
... lines 18 - 22
if ($form->isValid()) {
... lines 24 - 30
// return a blank form after success
if ($request->isXmlHttpRequest()) {
return $this->render('lift/_repRow.html.twig', [
'repLog' => $repLog
]);
}
... lines 37 - 40
}
... lines 42 - 50
// render just the form for AJAX, there is a validation error
if ($request->isXmlHttpRequest()) {
$html = $this->renderView('lift/_form.html.twig', [
'form' => $form->createView()
]);
return new Response($html, 400);
}
... lines 59 - 65
}
... lines 67 - 95
}

In fact, we're not going to use this endpoint at all. So, close this file.

Next, head to your browser, refresh, and view the source. Find the <form> element and copy the entire thing. Then back in your editor, find _form.html.twig and completely replace this file with that:

29 lines app/Resources/views/lift/_form.html.twig
<form class="form-inline js-new-rep-log-form" novalidate>
<div class="form-group">
<label class="sr-only control-label required" for="rep_log_item">
What did you lift?
</label>
<select id="rep_log_item"
name="rep_log[item]"
required="required"
class="form-control">
<option value="" selected="selected">What did you lift?</option>
<option value="cat">Cat</option>
<option value="fat_cat">Big Fat Cat</option>
<option value="laptop">My Laptop</option>
<option value="coffee_cup">Coffee Cup</option>
</select></div>
<div class="form-group">
<label class="sr-only control-label required" for="rep_log_reps">
How many times?
</label>
<input type="number" id="rep_log_reps"
name="rep_log[reps]" required="required"
placeholder="How many times?"
class="form-control"/>
</div>
<button type="submit" class="btn btn-primary">I Lifted it!</button>
</form>

Setting up our HTML Form

In short, we are not going to use the Symfony Form component to render the form. It's not because we can't, but this will give us a bit more transparency on how our form looks. If you like writing HTML forms by hand, then write your code like I just did. If you are using Symfony and like to have it do the work for you, awesome, use Symfony forms.

We need to make two adjustments. First, get rid of the CSRF _token field. Protecting your API against CSRF attacks is a little more complicated, and a topic for another time. Second, when you use the Symfony form component, it creates name attributes that are namespaced. Simplify each name to just item and reps:

29 lines app/Resources/views/lift/_form.html.twig
<form class="form-inline js-new-rep-log-form" novalidate>
<div class="form-group">
... lines 3 - 5
<select id="rep_log_item"
name="item"
required="required"
class="form-control">
... lines 10 - 14
</select></div>
<div class="form-group">
... lines 18 - 20
<input type="number" id="rep_log_reps"
name="reps" required="required"
placeholder="How many times?"
class="form-control"/>
</div>
... lines 26 - 27
</form>

We're just making our life easier.

By the way, if you did want to use Symfony's form component to render the form, be sure to override the getBlockPrefix() method in your form class and return an empty string:

SomeFormClass extends AbstractType
{
    public function getBlockPrefix()
    {
        return '';
    }
}

That will tell the form to render simple names like this.

Checking out the Endpoint

Our goal is to send this data to a true API endpoint, get back JSON in the response, and start handling that.

In src/AppBundle/Controller, open another file: RepLogController. This contains a set of API endpoints for working with RepLogs: one endpoint returns a collection, another returns one RepLog, another deletes a RepLog, and one - newRepLogAction() - can be used to create a new RepLog:

131 lines src/AppBundle/Controller/RepLogController.php
... lines 1 - 13
class RepLogController extends BaseController
{
/**
* @Route("/reps", name="rep_log_list")
* @Method("GET")
*/
public function getRepLogsAction()
{
... lines 22 - 33
}
/**
* @Route("/reps/{id}", name="rep_log_get")
* @Method("GET")
*/
public function getRepLogAction(RepLog $repLog)
{
... lines 42 - 44
}
/**
* @Route("/reps/{id}", name="rep_log_delete")
* @Method("DELETE")
*/
public function deleteRepLogAction(RepLog $repLog)
{
... lines 53 - 58
}
/**
* @Route("/reps", name="rep_log_new")
* @Method("POST")
*/
public function newRepLogAction(Request $request)
{
... lines 67 - 101
}
/**
* Turns a RepLog into a RepLogApiModel for the API.
*
* This could be moved into a service if it needed to be
* re-used elsewhere.
*
* @param RepLog $repLog
* @return RepLogApiModel
*/
private function createRepLogApiModel(RepLog $repLog)
{
... lines 115 - 128
}
}

I want you to notice a few things. First, the server expects us to send it the data as JSON:

131 lines src/AppBundle/Controller/RepLogController.php
... lines 1 - 9
use Symfony\Component\HttpFoundation\Request;
... line 11
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class RepLogController extends BaseController
{
... lines 16 - 64
public function newRepLogAction(Request $request)
{
... line 67
$data = json_decode($request->getContent(), true);
if ($data === null) {
throw new BadRequestHttpException('Invalid JSON');
}
... lines 72 - 101
}
... lines 103 - 129
}

Next, if you are a Symfony user, you'll notice that I'm still handling the data through Symfony's form system like normal:

131 lines src/AppBundle/Controller/RepLogController.php
... lines 1 - 5
use AppBundle\Entity\RepLog;
use AppBundle\Form\Type\RepLogType;
... lines 8 - 9
use Symfony\Component\HttpFoundation\Request;
... lines 11 - 13
class RepLogController extends BaseController
{
... lines 16 - 64
public function newRepLogAction(Request $request)
{
... lines 67 - 72
$form = $this->createForm(RepLogType::class, null, [
'csrf_protection' => false,
]);
$form->submit($data);
if (!$form->isValid()) {
... lines 78 - 82
}
/** @var RepLog $repLog */
$repLog = $form->getData();
... lines 87 - 101
}
... lines 103 - 129
}

If it fails form validation, we're returning a JSON collection of those errors:

131 lines src/AppBundle/Controller/RepLogController.php
... lines 1 - 13
class RepLogController extends BaseController
{
... lines 16 - 64
public function newRepLogAction(Request $request)
{
... lines 67 - 76
if (!$form->isValid()) {
$errors = $this->getErrorsFromForm($form);
return $this->createApiResponse([
'errors' => $errors
], 400);
}
... lines 84 - 101
}
... lines 103 - 129
}

The createApiResponse() method uses Symfony's serializer, which is a fancy way of returning JSON:

57 lines src/AppBundle/Controller/BaseController.php
... lines 1 - 8
class BaseController extends Controller
{
/**
* @param mixed $data Usually an object you want to serialize
* @param int $statusCode
* @return JsonResponse
*/
protected function createApiResponse($data, $statusCode = 200)
{
$json = $this->get('serializer')
->serialize($data, 'json');
return new JsonResponse($json, $statusCode, [], true);
}
... lines 23 - 56
}

On success, it does the same thing: returns JSON containing the new RepLog's data:

131 lines src/AppBundle/Controller/RepLogController.php
... lines 1 - 13
class RepLogController extends BaseController
{
... lines 16 - 64
public function newRepLogAction(Request $request)
{
... lines 67 - 91
$apiModel = $this->createRepLogApiModel($repLog);
$response = $this->createApiResponse($apiModel);
... lines 95 - 101
}
... lines 103 - 129
}

We'll see exactly what it looks like in a second.

Updating the AJAX Call

Ok! Let's update our AJAX call to go to this endpoint. In RepLogApp, down in handleNewFormSubmit, we need to somehow get that URL:

102 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
... lines 4 - 24
$.extend(window.RepLogApp.prototype, {
... lines 26 - 61
handleNewFormSubmit: function(e) {
... lines 63 - 67
$.ajax({
url: $form.attr('action'),
... lines 70 - 79
});
}
});
... lines 83 - 100
})(window, jQuery);

No problem! Find the form and add a fancy new data-url attribute set to path(), then the name of that route: rep_log_new:

29 lines app/Resources/views/lift/_form.html.twig
<form class="form-inline js-new-rep-log-form" novalidate data-url="{{ path('rep_log_new') }}">
... lines 2 - 27
</form>

Bam! Now, back in RepLogApp, before we use that, let's clear out all the code that actually updates our DOM: all the stuff related to updating the form with the form errors or adding the new row. That's all a todo for later:

104 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
... lines 4 - 24
$.extend(window.RepLogApp.prototype, {
... lines 26 - 61
handleNewFormSubmit: function(e) {
e.preventDefault();
var $form = $(e.currentTarget);
... lines 66 - 69
$.ajax({
... lines 71 - 81
});
}
});
... lines 85 - 102
})(window, jQuery);

But, do add a console.log('success') and console.log('error') so we can see if this stuff is working!

104 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
... lines 4 - 24
$.extend(window.RepLogApp.prototype, {
... lines 26 - 61
handleNewFormSubmit: function(e) {
e.preventDefault();
var $form = $(e.currentTarget);
... lines 66 - 69
$.ajax({
... lines 71 - 73
success: function(data) {
// todo
console.log('success!');
},
error: function(jqXHR) {
// todo
console.log('error :(');
}
});
}
});
... lines 85 - 102
})(window, jQuery);

Finally, update the url to $form.data('url'):

104 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
... lines 4 - 24
$.extend(window.RepLogApp.prototype, {
... lines 26 - 61
handleNewFormSubmit: function(e) {
... lines 63 - 69
$.ajax({
url: $form.data('url'),
... lines 72 - 81
});
}
});
... lines 85 - 102
})(window, jQuery);

Next, our data format needs to change - I'll show you exactly how.

Leave a comment!

  • 2017-09-21 Дмитрий Политов

    Thank you! It's clear now)

  • 2017-09-21 Diego Aguiar

    Hey Дмитрий Политов

    You are right, but Symfony has changed now. Check my comment here: https://knpuniversity.com/s...

    Cheers!

  • 2017-09-21 Diego Aguiar

    Hey Dmitry, nice catch!

    This is due to a Symfony version change. The Symfony-REST tutorial was made some time ago and it is using Symfony 2.6
    Do you see that last parameter beign passed to JsonResponse constructor? That's a flag that tells if the data is in Json format or not

    I hope this clarifies things a bit :)

  • 2017-09-21 Дмитрий Политов

    I mean in this tutorial you did not replace JsonResponse to Response and code seems working properly..

  • 2017-09-21 Dmitry

    Hi, Ryan!

    protected function createApiResponse($data, $statusCode = 200)
    {
    $json = $this->get('serializer')
    ->serialize($data, 'json');
    return new JsonResponse($json, $statusCode, [], true);
    }

    Why does this code work?
    In this tuto you have replaced JsonResponse to Responce due to "And we can't use JsonResponse anymore, or it'll encode things twice."
    https://knpuniversity.com/s...