Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Proper JSON API Endpoint Setup

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

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:

... 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:

<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:

<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:

... 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:

... 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:

... 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:

... 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:

... 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:

... 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:

... 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:

<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:

... 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!

... 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'):

... 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!

18
Login or Register to join the conversation

Hi,
I dont know how to put
data-url = {{ path('citizenship_api_new')}}
in
<form method="post" class="js-new-rep-log-form">
if I use symfony form

My code
{{ form_start(form,{'attr': {'class':'js-new-rep-log-form' }}
.....
{{ form_end(form) }}

Reply

Hey sasa1007

That's odd, your code should work. If you override the "attr" on a form field. Does it work?
{{ form_row(form.name, {attr: ...} }}

Also, try removing the single quotes from object's key

Cheers!

Reply

now it is working
{{ form_start(form,{
attr: {
'class':'js-new-rep-log-form',
'data-url': path('citizenship_api_new')
}
})
}}
{{ include('Resources/views/admin/citizenship/_form.html.twig' }}
{{ form_end(form) }}

Thank you

Reply

So it was caused by the single quotes?

Reply
J K. Avatar

Hi

could you help me understand how the repLog gets passed into deleteRepLogAction()? I was expecting the id to come in as a variable with the request and to use findById to get the entity and am missing how an entity comes in as a parameter on an action.

I am just updated to symfony 3.4 and am not using auto-wiring.

/**
* @Route("/reps/{id}", name="rep_log_delete")
* @Method("DELETE")
*/
public function deleteRepLogAction(RepLog $repLog)

{
... lines 53 - 58
}

Reply

Hey @happyuser

That's because of Symfony's Paramconverter, it automatically tries to find a "RepLog" record by id, and if it doesn't find any, it throws a 404 exception
You can read more detailed info here: https://symfony.com/doc/4.0...

But, if you don't like it, you can remove the type-hint and rename the variable to "$id"

Cheers!

Reply
Default user avatar

Thanks this helps a lot

Reply
Default user avatar

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...

Reply

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 :)

Reply
Default user avatar
Default user avatar Дмитрий Политов | MolloKhan | posted 5 years ago

Thank you! It's clear now)

Reply
Default user avatar
Default user avatar Дмитрий Политов | posted 5 years ago

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

Reply

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

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

Cheers!

Reply
GDIBass Avatar
GDIBass Avatar GDIBass | posted 5 years ago

Do you guys have any tutorials covering CSRF protection for API back ends? I don't remember anything in the API tutorials

Reply

Hey Matt,

We do talk about it here: https://knpuniversity.com/s... . In short, we just set csrf_protection to false because we can't use normal CSRF protection in an API.

Cheers!

Reply
GDIBass Avatar

Yeah I remember that from the tutorial. I'm wondering how you offer protections if you're using a pure JS front end combined with an API back end. What are the best practices?

Reply

Hey GDIBass!

This is by far my favorite blog post on the topic: http://www.redotheweb.com/2...

Honestly, it's not something I have a lot of advice on, because it seems to be something that many people simply ignore :/. As that blog post says, your best protection is to use two different types of protections together... which is a lot of work. I think many people ignore this because they can - because the endpoint doesn't do anything "secure enough" (e.g. banking) to require them to think about it.

That's about all I can say on it at this time - it's on my list to find a robust, but "reasonable to implement" system for this. All the info is in that blog post... but the work is still up to you ;).

Cheers!

2 Reply
GDIBass Avatar

Great, thanks Ryan. I'll definitely check that out.

1 Reply
GDIBass Avatar

So... wow. This one seems a bit complex. Easy enough in concept but not super easy to implement. Lots to think about!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial uses an older version of Symfony... but since it's a JavaScript tutorial, the concepts are still ? valid!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.2.0",
        "symfony/symfony": "3.1.*", // v3.1.10
        "twig/twig": "2.10.*", // v2.10.0
        "doctrine/orm": "^2.5", // v2.7.1
        "doctrine/doctrine-bundle": "^1.6", // 1.10.3
        "doctrine/doctrine-cache-bundle": "^1.2", // 1.3.2
        "symfony/swiftmailer-bundle": "^2.3", // v2.4.0
        "symfony/monolog-bundle": "^2.8", // 2.12.0
        "symfony/polyfill-apcu": "^1.0", // v1.2.0
        "sensio/distribution-bundle": "^5.0", // v5.0.22
        "sensio/framework-extra-bundle": "^3.0.2", // v3.0.16
        "incenteev/composer-parameter-handler": "^2.0", // v2.1.2
        "friendsofsymfony/user-bundle": "~2.0@dev", // dev-master
        "doctrine/doctrine-fixtures-bundle": "~2.3", // v2.4.1
        "doctrine/doctrine-migrations-bundle": "^1.2", // v1.2.1
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "friendsofsymfony/jsrouting-bundle": "^1.6" // 1.6.0
    },
    "require-dev": {
        "sensio/generator-bundle": "^3.0", // v3.1.1
        "symfony/phpunit-bridge": "^3.0" // v3.1.6
    }
}
userVoice