Healthcheck - Custom Endpoint [API Platform]


Still in shock over just how quickly we got a fully featured API up and running? I know these feels.

Every time I've created a new project using the API Platform I am blown away at how quickly I can get to working on the stuff that matters - rather than get bogged down for hours in the setup.

Our goal is still the same as when using the Symfony 4. We still need to make the tests pass. And ideally without changing the tests. This last task may be a little more challenging here.

The first test is that of our Healthcheck:

Feature: To ensure the API is responding in a simple manner

  In order to offer a working product
  As a conscientious software developer
  I need to ensure my JSON API is functioning

  Scenario: Basic healthcheck
    Given I request "/ping" using HTTP GET
    Then the response code is 200
    And the response body is:
    """
    "pong"
    """

Now, if we try to run this we hit upon a problem immediately:

vendor/bin/behat features/healthcheck.feature

Feature: To ensure the API is responding in a simple manner

  In order to offer a working product
  As a conscientious software developer
  I need to ensure my JSON API is functioning

  ┌─ @BeforeScenario # FeatureContext::cleanUpDatabase()
  │
  ╳  SQLSTATE[HY000] [2002] Connection refused (PDOException)
  │
  Scenario: Basic healthcheck              # features/healthcheck.feature:8
    Given I request "/ping" using HTTP GET # Imbo\BehatApiExtension\Context\ApiContext::requestPath()
    Then the response code is 200          # Imbo\BehatApiExtension\Context\ApiContext::assertResponseCodeIs()
    And the response body is:              # Imbo\BehatApiExtension\Context\ApiContext::assertResponseBodyIs()
      """
      "pong"
      """

--- Skipped scenarios:

    features/healthcheck.feature:8

1 scenario (1 skipped)
3 steps (3 skipped)
0m0.02s (9.11Mb)

Ok - "SQLSTATE[HY000] [2002] Connection refused (PDOException)" - seems a bit weird, given that our healthcheck / /ping endpoint doesn't need the DB in any way.

This error occurs because we defined a cleanUpDatabase function in our Behat FeatureContext, and tagged it with @BeforeScenario.

In other words, before any scenario / test runs, we try to reset the DB.

We don't need this right now.

I will remove the @BeforeScenario tag from the cleanUpDatabase method. By remove, I simply mean adding a space between @ and BeforeScenario. That's enough to disable the clean up code from running.

Hard To Port

With the DB reset process disabled for the time being, let's see where we now get with our test:

vendor/bin/behat features/healthcheck.feature

Feature: To ensure the API is responding in a simple manner

  In order to offer a working product
  As a conscientious software developer
  I need to ensure my JSON API is functioning

In ApiClientAwareInitializer.php line 45:

  Can't connect to base_uri: "http://api.oursite.com:8000".  

behat [-s|--suite SUITE] [-f|--format FORMAT] [-o|--out OUT] [--format-settings FORMAT-SETTINGS] [--init] [--lang LANG] [--name NAME] [--tags TAGS] [--role ROLE] [--story-syntax] [-d|--definitions DEFINITIONS] [--snippets-for [SNIPPETS-FOR]] [--snippets-type SNIPPETS-TYPE] [--append-snippets] [--no-snippets] [--strict] [--order ORDER] [--rerun] [--stop-on-failure] [--dry-run] [--] [<paths>]

Ok, fair enough.

We control this config in our behat.yml file.

At this point you can do one of two things:

  • Change the docker-compose.yaml file to expose port 8000:80, rather than 8080:80 for the api service
  • Change the base_uri in behat.yml

Do whichever you wish.

However.

Changing the API Platform docker-compose.yaml file will mean you need to find every further instance of port 8080 and update it to 8000.

I mean, I'm not trying to sway you one way or the other, it's just I'm lazy so I'm going with updating behat.yml.

# behat.yml

default:
    suites:
        # ... etc
    extensions:
        Imbo\BehatApiExtension:
            apiClient:
                base_uri: http://api.oursite.com:8080

Ok, do we get further?

vendor/bin/behat features/healthcheck.feature

Feature: To ensure the API is responding in a simple manner

  In order to offer a working product
  As a conscientious software developer
  I need to ensure my JSON API is functioning

  Scenario: Basic healthcheck              # features/healthcheck.feature:8
    Given I request "/ping" using HTTP GET # Imbo\BehatApiExtension\Context\ApiContext::requestPath()
    Then the response code is 200          # Imbo\BehatApiExtension\Context\ApiContext::assertResponseCodeIs()
      Expected response code 200, got 404. (Imbo\BehatApiExtension\Exception\AssertionFailedException)
    And the response body is:              # Imbo\BehatApiExtension\Context\ApiContext::assertResponseBodyIs()
      """
      "pong"
      """

--- Failed scenarios:

    features/healthcheck.feature:8

1 scenario (1 failed)
3 steps (1 passed, 1 failed, 1 skipped)
0m0.07s (9.92Mb)

We sure do. We're now getting a 404, because the /ping route is not yet configured on our API Platform implementation.

Adding a Custom Route to API Platform

An initial glance at the API Platform docs and it isn't immediately obvious as to how to add a new route.

We've seen that we are using Symfony's router - but the ADR pattern seemingly throws most of what we know out of whack.

Things become a bit clearer when we know that the API Platform works with the concept of Operations.

When looking at the router, we saw that various CRUD Operations are preconfigured for any entity added to our system:

docker-compose exec php php bin/console debug:router
 ------------------------------- -------- -------- ------ --------------------------------- 
  Name                            Method   Scheme   Host   Path                             
 ------------------------------- -------- -------- ------ --------------------------------- 
  api_greetings_get_collection    GET      ANY      ANY    /greetings.{_format}             
  api_greetings_post_collection   POST     ANY      ANY    /greetings.{_format}             
  api_greetings_get_item          GET      ANY      ANY    /greetings/{id}.{_format}        
  api_greetings_delete_item       DELETE   ANY      ANY    /greetings/{id}.{_format}        
  api_greetings_put_item          PUT      ANY      ANY    /greetings/{id}.{_format}        
 ------------------------------- -------- -------- ------ --------------------------------- 

An operation is a link between a resource, a route and its related controller -- API Platform Operations Documentation

We don't have an entity / resource for our Healthcheck operation.

All we need is a route, and some way of controlling what happens when that route is requested.

Fortunately, API Platform has great documentation on how we can create a custom operation and its related controller.

We will need to create a custom Controller, and add a @Route annotation to that controller.

Our Custom Healthcheck Controller

Here's the code in full:

<?php

// api/src/Controller/HealthcheckController.php

namespace App\Controller;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;

class HealthcheckController
{
    /**
     * @Route(
     *     name="healthcheck",
     *     path="/ping",
     *     methods={"GET"},
     * )
     */
    public function __invoke(): object
    {
        return new JsonResponse('pong');
    }
}

We can check the router to validate this new endpoint is available:

docker-compose exec php php bin/console debug:router
 ------------------------------- -------- -------- ------ --------------------------------- 
  Name                            Method   Scheme   Host   Path                             
 ------------------------------- -------- -------- ------ --------------------------------- 
  healthcheck                     GET      ANY      ANY    /ping 

And immediately, we have a passing test:

vendor/bin/behat features/healthcheck.feature

Feature: To ensure the API is responding in a simple manner

  In order to offer a working product
  As a conscientious software developer
  I need to ensure my JSON API is functioning

  Scenario: Basic healthcheck              # features/healthcheck.feature:8
    Given I request "/ping" using HTTP GET # Imbo\BehatApiExtension\Context\ApiContext::requestPath()
    Then the response code is 200          # Imbo\BehatApiExtension\Context\ApiContext::assertResponseCodeIs()
    And the response body is:              # Imbo\BehatApiExtension\Context\ApiContext::assertResponseBodyIs()
      """
      "pong"
      """

1 scenario (1 passed)
3 steps (3 passed)
0m0.05s (9.59Mb)

There is a downside to this approach:

We're hardcoded to JSON. The docs recommend simply returning a Response, rather than the more specific JsonResponse, but either way we don't get any of the benefits of serialization like an entity would.

Of course you're free to add in any logic you need here to do, or return, absolutely any Object (as per the __invoke method's return type).

Don't be misled by the __invoke magic method.

By defining an __invoke method we can call this HealthcheckController object as if it were a function. This works on literally any PHP class. This is not specific to API Platform in any way.

Incidentally this does highlight that Symfony can use invokable classes for controller actions. This is lightly documented at present.

To the best of my knowledge, you need not use the __invoke magic method as any method name will work. This is because we're really relying on the @Route annotation.

I believe the intention here is to accurately follow the ADR convention. Symfony itself doesn't care, but will support you all the same.

Episodes

# Title Duration
1 What will our JSON API actually do? 08:46
2 What needs to be in our Database for our Tests to work? 12:32
3 Cleaning up after each Test Run 02:40
4 Docker makes for Easy Databases 09:01
5 Healthcheck [Raw Symfony 4] 07:53
6 Send in JSON data using POST [Raw Symfony 4] 05:33
7 Keep your data nice and tidy using Symfony's Form [Raw Symfony 4] 10:48
8 Validating incoming JSON [Raw Symfony 4] 08:26
9 Nicer error messages [Raw Symfony 4] 06:23
10 GET'ting data from our Symfony 4 API [Raw Symfony 4] 08:11
11 GET'ting a collection of Albums [Raw Symfony 4] 01:50
12 Update existing Albums with PUT [Raw Symfony 4] 05:00
13 Upsetting Purists with PATCH [Raw Symfony 4] 02:39
14 Hitting DELETE [Raw Symfony 4] 02:11
15 How to open your API to the outside world with CORS [Raw Symfony 4] 07:48
16 Getting Setup with Symfony 4 and FOSRESTBundle [FOSRESTBundle] 09:11
17 Healthcheck [FOSRESTBundle] 06:14
18 Handling POST requests [FOSRESTBundle] 08:31
19 Saving POST data to the database [FOSRESTBundle] 09:44
20 Work with XML, or JSON, or Both [FOSRESTBundle] 04:31
21 Going far, then Too Far with the ViewResponseListener [FOSRESTBundle] 03:19
22 GET'ting data from your Symfony 4 API [FOSRESTBundle] 05:58
23 GET'ting a Collection of data from your Symfony 4 API [FOSRESTBundle] 01:27
24 Updating with PUT [FOSRESTBundle] 02:58
25 Partially Updating with PATCH [FOSRESTBundle] 02:15
26 DELETE'ing Albums [FOSRESTBundle] 01:27
27 Handling Errors [FOSRESTBundle] 08:58
28 Introducing the API Platform [API Platform] 08:19
29 The Entry Point [API Platform] 04:30
30 The Context [API Platform] 05:52
31 Healthcheck - Custom Endpoint [API Platform] 05:17
32 Starting with POST [API Platform] 07:08
33 Creating Entities with the Schema Generator [API Platform] 07:38
34 Defining A Custom POST Route [API Platform] 07:31
35 Finishing POST [API Platform] 06:29
36 GET'ting One Resource [API Platform] 02:50