Symfony API and Swagger Integration

Integrating Swagger documentation on a Symfony API. How to get it done.

The Challenge

The client’s IT department came to us requesting an easier, automated means of updating their CRM tools with the content from the warranty registration application on their public site.

They had been coming to the CMS and downloading a CSV file, which they would then import into their systems.

Our Suggestion

Recommended building a simple REST API with JWT authentication that would enable them to receive JSON formatted responses containing their warranty registration data.

What tools?

Symfony

The site is built on the Symfony framework so it made sense to me to stick with that to handle the API routing and the Controller methods which handle requests and return the responses. Symfony already has a JSON Response class built in to handle data conversions from Doctrine to JSON.

https://symfony.com

lexik/jwt-authentication bundle

This bundle provides JSON Web Tokens (JWT) for Symfony API projects. This is a bearer token that is added to the request header for authentication and must be used for all API requests.

https://github.com/lexik/LexikJWTAuthenticationBundle

zircote/swagger-php bundle

This bundle provides a means of annotating your API controller and it’s methods to generate a swagger.json file which is used by Swagger UI to provide an interface for documentation and testing.

https://github.com/zircote/swagger-php

Swagger UI

The swagger UI is a downloadable set of JS and CSS files that render documentation and testing views for your API.

https://swagger.io/tools/swagger-ui/download/

Postman

I used Postman for testing the API methods initially before I had the Swagger documentation in place to make sure the API was functional.

https://www.postman.com/

Implementation

Bundle Installation

Install the JWT Authentication bundle and the Zircote Swagger PHP bundle per the author’s instructions. The JWT bundle uses a Flex installation which will generate your private and public keys needed for JWT Authentication at config/jwt as well as build out your config file:

#config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
    secret_key: '%env(resolve:JWT_SECRET_KEY)%'
    public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
    pass_phrase: '%env(JWT_PASSPHRASE)%'

There is no configuration to be done with the Zircote Swagger bundle.

User Mechanism

You’ll need to create an API user. The JWT bundle recommends creating a user class and provides a controller action for adding a user, however I thought it simple to just create an API user in the existing User set up, and that works just fine.

Configure Security

You’ll have to set up access control rules and an additional firewall in your security.yaml config file to handle the authentication.

# config/packages/security.yaml
security:
    encoders:
        App\Entity\User: auto
    providers:
        my_users:
            entity: { class: App\Entity\User, property: username }
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        # API Authentication
        api_login:
            pattern:  ^/login
            stateless: true
            anonymous: true
            json_login:
                check_path: /login_check
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure
        api:
            pattern:  ^/api
            stateless: true
            anonymous: false
            provider: cco_users
            guard:
                authenticators:
                    - lexik_jwt_authentication.jwt_token_authenticator

        # Standard User Authentication
        main:
            anonymous: ~
            guard:
                authenticators:
                    - App\Security\LoginFormAuthenticator
            form_login:
                login_path: /log_in
            logout:
                path: /logout

    access_control:
        # CMS Access Control
        - { path: ^/manage, roles: [ROLE_ADMIN,ROLE_SERVICE] }
        - { path: ^/log_in, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/register, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/forgot-password, roles: IS_AUTHENTICATED_ANONYMOUSLY }

        # API Access Control
        - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/register, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }

/login problems

Upon installing the JWT bundle it became apparent that the inner mechanisms that create the tokens rely on the route /login for authentication, which is inconveniently the same route used by EasyAdmin bundle for CMS authentication. Fortunately it’s fairly easy to change the CMS login route, so I changed it to /log_in.

To do this you’ll need to change the login_path parameter in the main firewall of your security config…

# config/packages/security.ymal
security:
    ...
    firewalls:
        main:
        ...
        form_login:
            login_path: /log_in
            ...

…and update the route in your security controller…

# src/Controller/SecurityController.php
namespace App\Controller;
use ...

class SecurityController extends AbstractController
{
    /**
     * @Route("/log_in", name="login")
     * @param AuthenticationUtils $authenticationUtils
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function loginAction(AuthenticationUtils $authenticationUtils)
    {
        ...
    }

now hitting the CMS while not authenticated will redirect you to the /log_in route for the CMS and the /login route can be used for the API JWT Authentication.

Testing security

Now that security is configured, bundles are installed and you have a user you should be able to authenticate and receive a token. You can use curl to do this.

Replace (user) and (pwd) with what you used when you created your user, and change the local host according to your needs.

> curl -X POST -H "Content-Type: application/json" https://127.0.0.1:8000/login_check -d '{"username":"(user)","password":"(pwd)"}'

Build the API Controller

The API controller consists of 4 public functions:

1. buildYml()

This method uses the Swagger PHP bundle to scan the API controller annotations and return a JSON response at the route /manage/api/swagger.json. This is the JSON that Swagger UI uses to provide tests and documentation.

Notice that the route is within the ‘manage’ path. If you recall from looking at the security config file, that path is under access control for admins. So in order to view the JSON, you must be an authenticated admin. All documentation has been routed within this path to prevent unauthorized users from viewing the documentation.

/**
...
 * @Route("/manage/api/swagger.json", name="api_documentation_yaml")
 * @return Response
 */
public function buildYml()
{
    $response = new Response();
    $openApi = \OpenApi\scan(__DIR__.'/../../src/Controller/ApiController.php');
    $response->headers->set('Content-Type','application/json');
    $response->setContent($openApi->toJson());

    return $response;
}
...

2. document()

This method simply renders the documentation twig view and provides a route for viewing docs.

/**
...
* @Route("/manage/api/documentation", name="api_documentation")
* @param Profiler $profiler
* @return Response
*/
public function document(Profiler $profiler)
{
    return $this->render("api/documentation.html.twig");
}

3. getAuthorization()

This is actually a ‘fake’ controller method and ultimately does nothing but return an empty JSON response. Its only purpose is to hold annotations for the Open API documentation that allow the docs to provide an authentication mechanism for testing. More on this later.

/**
...
* @return Response
*/
public function getAuthorization()
{
    $response = new JsonResponse();

    return $response;
}

4. getRegistrations()

This is the sole API method that was required by the client for returning JSON data to their system. Most likely they’ll always grab every record but I went ahead and added a couple parameters to give it some flexibility including a sort direction and limit on number of results.

/**
* ...
* @param string $dir | sort direction for results
* @param Integer $limit | number of results to return (null = all results)
* @return Response
*
* @Route("/api/registrations/{dir}/{limit}",
*     methods={"GET"},
*     name="get_all_registrations",
*     requirements={"dir":"asc|desc", "limit":"\d+"}),
*     defaults={"dir":"asc"}
* )
* ...
*/
public function getRegistrations(String $dir="asc", $limit=null)
{
    $qb = $this->getDoctrine()
    ->getRepository(WarrantyRegistration::class)
    ->createQueryBuilder('q')
    ->orderBy('q.id', $dir)
    ->setMaxResults($limit);

    $data = $qb->getQuery()
    ->getArrayResult();

    if (empty($data)) {
        throw new NotFoundHttpException();
    }

    $response = new JsonResponse($data);

    return $response;
}

These are the basic methods of the API Controller. Later on in this we’ll Annotate it so that the Swagger PHP bundle can create the documentation from it.

Install the Swagger UI Interface

I tried various approaches to installing this…

  • Swagger UI vs. Swagger Editor
  • NPM modules
  • Swagger UI Dist vs. Swagger UI
  • Webpack

However I ultimately failed at getting a working set of documents using any of the NPM modules and WebPack, which really frustrated me, so I ended up creating a view in Twig utilizing the latest index.html from the Swagger UI build at GitHub and linking up the JS and CSS from unpkg.com. I don’t like this approach and want to eventually figure out how to get this running as a WebPack module, but I was afraid I was going to run out of time to work with it. For now there is a @ToDo reminder in the controller so I can remember to address this later.

The view:

<!-- templates/api/documentation.html.twig -->
<!-- HTML for static distribution bundle build -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Swagger UI</title>
    <link rel="stylesheet" type="text/css"
          href="https://unpkg.com/swagger-ui-dist@3.23.4/swagger-ui.css" />
    <link rel="icon" type="image/png" sizes="32x32"
          href="https://unpkg.com/swagger-ui-dist@3.23.4/favicon-32x32.png" />
    <link rel="icon" type="image/png" sizes="16x16"
          href="https://unpkg.com/swagger-ui-dist@3.23.4/favicon-16x16.png" />
    <style>
      html
      {
        box-sizing: border-box;
        overflow: -moz-scrollbars-vertical;
        overflow-y: scroll;
      }

      *,
      *:before,
      *:after
      {
        box-sizing: inherit;
      }

      body
      {
        margin:0;
        background: #fafafa;
      }
    </style>
</head>

<body>
<div id="swagger-ui"></div>
<script
        src="https://unpkg.com/swagger-ui-dist@3.23.4/swagger-ui-standalone-preset.js">
</script>
<script
        src="https://unpkg.com/swagger-ui-dist@3.23.4/swagger-ui-bundle.js">
</script>
<script>
    window.onload = function() {
        // Begin Swagger UI call region
        console.log(window.location.pathname);
        const ui = SwaggerUIBundle({
            url: "/manage/api/swagger.json", // <- Path to your Swagger JSON route
            dom_id: '#swagger-ui',
            deepLinking: true,
            presets: [
                SwaggerUIBundle.presets.apis,
                SwaggerUIStandalonePreset
            ],
            layout: "StandaloneLayout"
        })
        // End Swagger UI call region
        window.ui = ui
    }
</script>
</body>
</html>

I chose Swagger UI over Swagger Editor because it’s a leaner set of tools and all I really wanted to provide in that interface was documentation and testing. Editing is not necessary.

Annotate your Controller and it’s Methods

The Swagger UI reads your JSON file returned by the buildYml() method of your API controller. It is built within the Open API 3 specification. What I learned is…

  • This is a very tight spec and nothing works if your JSON file isn’t formatted properly.
  • The annotations that you put on your methods have to be done exactly right and there are not a lot of examples around.
  • StackOverflow is still a devs best friend.

Top level controller annotations

The controller itself has a few top level annotations describing security schemes and some basic information about the API.

Method level annotations

Each method of the API is also annotated to provide information and examples in the documentation about its properties and usage.

Notice the Use statement for the OpenAPI class. This was undocumented but needed to be added…

use OpenApi\Annotations as OA;

Using the Documentation

With everything set up you can visit the documentation route at (your local host)/manage/api/documentation

…and begin to test and play around with the API.

There are two methods documented in the documentation. One for authentication and one for getting the registrations.

  1. POST /login_check
  2. GET /api/registrations/{dir}/{limit}

Authorization

Swagger UI comes with a handy Authorization modal that allows you to provide auth credentials for your session so that you can run API methods in the interface that contain the authorization token in the header for requests during that session.

In order to authorize your session:

  1. In the 'authorization' group of the docs open the login_check method and click “Try it Out”
  2. Change my_username and my_password in the Request Body JSON to what you set up when creating your user.
  3. Click execute.
  4. If set up properly the response will provide a JSON string including your bearer token.
  5. Copy that token.
  6. Near the top of the page, click the green Authorize button.
  7. Paste the token, into the “Value” field, and click Authorize, then close the modal.

Your session is now authorized to execute further requests to your API in the Swagger UI.

That’s all folks!