Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

API Setup & AJAX with fetch()

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

Ok people: it's time to make our React app legit, by loading and saving data to the server via AJAX. Our Symfony app already has a functional set of API endpoints to load, delete and save rep logs. We're not going to spend a lot of time talking about the API side of things: we'll save that for a future tutorial. That's when we'll also talk about other great tools - like ApiPlatform - that you won't see used here.

Anyways, I want to at least take you through the basics of my simple, but very functional, setup.

The API Setup

Open src/Controller/RepLogController.php. As you can see, we already have API endpoints for returning all of the rep logs for a user, a single rep log, deleting a rep log and adding a new rep log. Go back to the browser to check this out: /reps. Boom! A JSON list of the rep logs.

This endpoint is powered by getRepLogsAction(). The findAllUsersRepLogModels() method lives in the parent class - BaseController, which lives in this same directory. Hold Command or Ctrl and click to jump into it.

The really important part is this: I have two rep log classes. First, the RepLog entity stores all the data in the database. Second, in the Api directory, I have another class called RepLogApiModel. This is the class that's transformed into JSON and used for the API: you can see that it has the same fields as the JSON response.

The findAllUsersRepLogModels method first queries for the RepLog entity objects. Then, it loops over each and transforms it into a RepLogApiModel object by calling another method, which lives right above this. The code is super boring and not fancy at all: it simply takes the RepLog entity object and, piece by piece, converts it into a RepLogApiModel.

Finally, back in getRepLogsAction(), we return $this->createApiResponse() and pass it that array of RepLogApiModel objects. This method also lives inside BaseController and it's dead-simple: it uses Symfony's serializer to turn the objects to JSON, then puts that into a Response.

That's it! The most interesting part is that I'm using two classes: the entity and a separate class for the serializer. Having 2 classes means that you need to do some extra work. However... it makes it really easy to make your API look exactly how you want! But, in a lot of cases, serializing your entity object directly works great.

Using fetch()

So here's our first goal: make an API request to /reps and use that to populate our initial repLogs state so that they render in the table.

In the assets/js directory, create a new folder called api and then a new file called rep_log_api.js. This new file will contain all of the logic we need for making requests related to the rep log API endpoints. As our app grows, we might create other files to talk to other resources, like "users" or "products".

You probably also noticed that the filename is lowercase. That's a minor detail. This is because, instead of exporting a class, this module will export some functions... so that's just a naming convention. Inside, export function getRepLogs().

... lines 1 - 5
export function getRepLogs() {
... lines 7 - 12

The question now is... how do we make AJAX calls? There are several great libraries that can help with this. But... actually... we don't need them! All modern browsers have a built-in function that makes AJAX calls super easy. It's called fetch()!

Try this: return fetch('/reps'). fetch() returns a Promise object, which is a super important, but kinda-confusing object we talked a lot about in our ES6 tutorial. To decode the JSON from our API into a JavaScript object, we can add a success handler: .then(), passing it an arrow function with a response argument. Inside, return response.json().

... lines 1 - 5
export function getRepLogs() {
return fetch('/reps')
.then(response => {
return response.json();
});
}

With this code, our getRepLogs() function will still return a Promise. But the "data" for that should now be the decoded JSON. Don't worry, we'll show this in action.

By the way, I mentioned that fetch is available in all modern browsers. So yes, we do need to worry about what happens in older browsers. We'll do that later.

Go back to RepLogApp. Ok, as soon as the page loads, we want to make an AJAX call to /reps and use that to populate the state. The constructor seems like a good place for that code. Oh, but first, bring it in: import { getRepLogs } from ../api/rep_log_api. For the first time, we're not exporting a default value: we're exporting a named function. We'll export more named functions later, for inserting and deleting rep logs.

... lines 1 - 4
import { getRepLogs } from '../api/rep_log_api';
... lines 6 - 83

Oh, and, did you see how PhpStorm auto-completed that for me? That was awesome! And it wasn't video magic: PhpStorm was cool enough to guess that correct import path.

Down below, add getRepLogs() and chain .then(). Because we decoded the JSON already, this should receive that decoded data. Just log it for now.

... lines 1 - 7
constructor(props) {
... lines 9 - 10
getRepLogs()
.then((data) => {
console.log(data);
});
... lines 15 - 29
}
... lines 31 - 83

Ok... let's try it! Move over and, refresh! Oof! An error:

Unexpected token in JSON at position zero

Hmm. It seems like our AJAX call might be working... but it's having a problem decoding the JSON. It turns out, the problem is authentication. Let's learn how to debug this and how to authenticate our React API requests next.

Leave a comment!

5
Login or Register to join the conversation
xmontero Avatar
xmontero Avatar xmontero | posted 1 month ago

Hi, friends, I'd need some help.

Instead of RepLogs I serve Agents, but no matter. All the same following the tutorial.

# I've a problem with the routing in the fetch() call

My "list of agents" API lives here /api/agents thanks to this route:

api_agent_list:
    path: /api/agents
    controller: App\Controller\Api\Agent\GetAgentsController
    methods: GET

The controller that paints the twig where the root component <AgentApp /> is mounted is served here: /private/admin/react-test in a routing like this:

app_admin_react_test_index:
    path: /private/admin/react-test
    controller: App\Controller\Private\Admin\ReactTest\IndexController
    methods: GET

Okey, when I need routes in the twig, I easily use path( 'api_agent_list' ) so the URL is represented correctly.

But in this chapter the rep_log_api.js (in my case agent_api.js) does a fetch directly to the URL, in the example i the video to /reps.

Thing is...

I do not serve my project from the root, but from a directory, and the project is not aware of the directory it's served from.

In fact, my controller in private/admin/react-test is served specifically from here http://127.0.0.1:22080/repos/hello-trip/telethon-parsec_agent-panel/public/private/admin/react-test

This yields in a routing problem:

  • If I fetch( '/api/agents' ) with / then the API is loading from http://127.0.0.1:22080/api/agents which does not exist.
  • If I fetch( 'api/agents' ) without / then the API is loading relative to the base of the controller path, soi it's trying to fetch from here: http://127.0.0.1:22080/repos/hello-trip/telethon-parsec_agent-panel/public/private/admin/api/agents which is also incorrect.

In twig we play with the routing name, in this case api_agent_list, so the symfony framework "builds" the proper route. Worth mentioning that when I configure webpack I need to change this .setPublicPath( '/build' ) into this .setPublicPath( 'build' ) so subdirectory access is allowed.

It works... in twig.

# Question

What do I have to put inside the fetch() in the javascript so it loads the proper path given in the route and supporting subdirectory deploying?

Reply

Hey Xmontero,

If you want to use the correct URLs in your JS files - the best way is to generate the code with path() or url() Twig functions, i.e. generate URLs in Twig templates and pass them into the JS code. This way you will be sure that URLs are generated correctly. You can pass pre-geenrated URLs in a few ways, e.g. setting a data attribute on a tag an then read it from the JS code, or setting a global JS variable in script HTML tag in a Twig template, etc.

I hope this helps!

Cheers!

Reply
xmontero Avatar

Tested, it works!

Just in case it helps anyone, what I did is:

  1. Add a route with the base of the API path, with no methods available, so I can render it but it's not callable.
  2. Add a <meta> tag in my header with the API base path.
  3. In my javascript, read from the data attribute.

My javascript looked like this initially (hardcoded path, bad code):

// agent_api.js
export function getAgents() {
    return fetch( 'http://127.0.0.1:22080/repos/hello-trip/telethon-parsec_agent-panel/public/api/agents' )
        .then( response => response.json() );
}

To overcome this I've done those editions:

In the routing I added this non-callable route:

# api_agent_routes.yaml
api_root:
    path: /api/
    methods: []

[...]

In the base HTML template I added a meta tag before the javascripts, in the header:

    [...]
    <meta id="api-route" data-api-path="{{ path( 'api_root' ) }}">
    {% block javascripts %}
        {{ encore_entry_script_tags('app') }}
    {% endblock %}
</head>
[...]

In the javascript I get the data attribute as explained here: https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes

export function getAgents() {
    const apiRouteElement = document.querySelector( '#api-route' );
    return fetch(  apiRouteElement.dataset.apiPath + 'agents' )
        .then( response => response.json() );
}

I hope this code helps someone serving from a subdir! Thanks @victor for the tip.

1 Reply

Hey Xmontero,

Awesome! Thanks for confirming it worked for you, and thanks for the details examples that could be useful to others!

Cheers!

Reply
xmontero Avatar

I see! Very good idea! I'll try it!

Reply
Cat in space

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

This course uses Symfony 4, but as this is a JavaScript course, all the concepts apply fine to Symfony 5. Have fun!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.2.0",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/doctrine-bundle": "^1.6", // 1.9.1
        "doctrine/doctrine-cache-bundle": "^1.2", // 1.3.3
        "doctrine/doctrine-fixtures-bundle": "~3.0", // 3.0.2
        "doctrine/doctrine-migrations-bundle": "^1.2", // v1.3.1
        "doctrine/orm": "^2.5", // v2.7.2
        "friendsofsymfony/jsrouting-bundle": "^2.2", // 2.2.0
        "friendsofsymfony/user-bundle": "dev-master#4125505ba6eba82ddf944378a3d636081c06da0c", // dev-master
        "sensio/framework-extra-bundle": "^5.1", // v5.2.0
        "symfony/asset": "^4.0", // v4.1.4
        "symfony/console": "^4.0", // v4.1.4
        "symfony/flex": "^1.0", // v1.17.6
        "symfony/form": "^4.0", // v4.1.4
        "symfony/framework-bundle": "^4.0", // v4.1.4
        "symfony/lts": "^4@dev", // dev-master
        "symfony/monolog-bundle": "^3.1", // v3.3.0
        "symfony/polyfill-apcu": "^1.0", // v1.9.0
        "symfony/serializer-pack": "^1.0", // v1.0.1
        "symfony/swiftmailer-bundle": "^3.1", // v3.2.3
        "symfony/twig-bundle": "^4.0", // v4.1.4
        "symfony/validator": "^4.0", // v4.1.4
        "symfony/yaml": "^4.0", // v4.1.4
        "twig/twig": "2.10.*" // v2.10.0
    },
    "require-dev": {
        "symfony/debug-pack": "^1.0", // v1.0.6
        "symfony/dotenv": "^4.0", // v4.1.4
        "symfony/maker-bundle": "^1.5", // v1.5.0
        "symfony/phpunit-bridge": "^4.0", // v4.1.4
        "symfony/web-server-bundle": "^4.0" // v4.1.4
    }
}

What JavaScript libraries does this tutorial use?

// package.json
{
    "dependencies": {
        "@babel/plugin-proposal-object-rest-spread": "^7.12.1" // 7.12.1
    },
    "devDependencies": {
        "@babel/preset-react": "^7.0.0", // 7.12.5
        "@symfony/webpack-encore": "^0.26.0", // 0.26.0
        "babel-plugin-transform-object-rest-spread": "^6.26.0", // 6.26.0
        "babel-plugin-transform-react-remove-prop-types": "^0.4.13", // 0.4.13
        "bootstrap": "3", // 3.3.7
        "copy-webpack-plugin": "^4.4.1", // 4.5.1
        "core-js": "2", // 1.2.7
        "eslint": "^4.19.1", // 4.19.1
        "eslint-plugin-react": "^7.8.2", // 7.8.2
        "font-awesome": "4", // 4.7.0
        "jquery": "^3.3.1", // 3.3.1
        "promise-polyfill": "^8.0.0", // 8.0.0
        "prop-types": "^15.6.1", // 15.6.1
        "react": "^16.3.2", // 16.4.0
        "react-dom": "^16.3.2", // 16.4.0
        "sass": "^1.29.0", // 1.29.0
        "sass-loader": "^7.0.0", // 7.3.1
        "sweetalert2": "^7.11.0", // 7.22.0
        "uuid": "^3.2.1", // 3.4.0
        "webpack-notifier": "^1.5.1", // 1.6.0
        "whatwg-fetch": "^2.0.4" // 2.0.4
    }
}