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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeIt'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.
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) }}