SwaggerUI and Advanced OpenAPI usecases

Filip Kowalski
2.0.0

Overview

In this tutorial, we will explain more advanced possibilities of OpenAPI specification that Knot.x uses. We'll also demonstrate how to make use of the API visualization tool, Swagger UI, with Knot.x. It's a beginner-friendly tutorial, but you should go through the Getting Started tutorials first. For purposes of this article, it's assumed you have a basic project set up.

You'll learn how to:

  • define more detailed APIs in OpenAPI standard
  • visualize them with Swagger UI

OpenAPI

OpenAPI is a standard specification for describing REST APIs. It allows defining endpoints, parameters, data types, constraints and metadata. This format is also the integral part of Knot.x, so it's important to know it.

OpenAPI can be written either in JSON or YAML format. Knot.x prefers YAML, though. You have to specify your endpoints in knotx/conf/openapi.yaml file. Let's take a look at the default one (coming with the Starter Kit template). It starts like this:

openapi: "3.0.0"
info:
  version: 1.0.0
  title: Knot.x Starter Kit
  description: Knotx Starter Kit

servers:
  - url: https://{domain}:{port}
    description: The local API server
    variables:
      domain:
        default: localhost
        description: api domain
      port:
        enum:
          - '8092'
        default: '8092'

It defines the OpenAPI specification version used and some metadata about our API (version, title, description). Then it specifies a list of servers our API is available on. In this case, it's just one server: localhost:8092. Additionally, we provide some servers metadata such as descriptions. We can also define some variables. They are not necessary in this case, but let's imagine that your API is available on 3 ports and 2 domains. Instead of listing all 6 possible combinations, you can just use variables. Also, note that all variables here have to be strings and that they don't have to just be port and domain. They can be anything.

Next in the file, there are paths defined. Let's see:

paths:
  /api/v1/example:
    get:
      operationId: example-api-with-handlers-operation
      responses:
        default:
          description: Example API response
  /api/v2/example:
    get:
      operationId: example-api-with-fragments-operation
      responses:
        default:
          description: Example API response
  /healthcheck:
    get:
      operationId: healthcheck-operation
      responses:
        '200':
          description: Everything is fine
        '204':
          description: No procedure are registered.
        '501':
          description: At least one procedure has reported a non-healthy stat
        '500':
          description: One procedure has thrown an error or has not reported a status in time

Endpoints are defined as a dictionary of paths. In this case, there are 3 of them: /api/v1/example, /api/v2/example/ and /healthcheck. The first two are very simple. Both specify only one HTTP method (GET). Those simple method definitions consist of operation and possible responses the endpoint may return. The /healthcheck path is similar but it lists multiple possible responses.

Note that there is nothing Knot.x specific in this file, it's all just the OpenAPI standard. Knot.x parses this file and assigns endpoints to appropriate operations. For example, /api/v2/example/ path will be assigned to the operation with id example-api-with-fragments-operation. Remember that operations are defined in /knotx/conf/routes/operations.conf file which is, indeed, specific to Knot.x.

If you try to call an undefined endpoint or use an undefined HTTP method (e.g. call /healthcheck with POST), Knot.x would automatically return 404 Not Found response. Otherwise, it will delegate the request to the assigned operation.

Implementing advanced OpenAPI concepts

Response body

Let's define some more advanced endpoints. First, we'll create /api/user endpoint that will return information about the logged-in user (note that we'll just mock the data for the sake of this tutorial). Add this to paths section of openapi.yaml:

/api/user:
  get:
    operationId: userinfo-operation
    summary: Returns basic info about a logged-in user
    responses:
      200:
        description: OK
        content:
          application/json:
            schema:
              type: object
              properties:
                id:
                  type: integer
                username:
                  type: string

We'll define operations later. What is new in this piece of code is the content part of the response. It's a dictionary of all possible bodies of the response. In this case, we say that the endpoint will always return a JSON in the form defined by a schema. Such a JSON would look like this:

{
  "id": 0,
  "username": "string"
}

We'll talk more about schemas later.

Reusing schemas

Let's say that we want multiple endpoints to return this kind of data. It would be nice to be able to reuse the same schema. We can do that in the components dictionary. Add it at the bottom of the file like this:

paths:
  //...

components:
  schemas:
    UserId:
      type: integer
      minimum: 1
    BasicUser:
      type: object
      properties:
        id:
          $ref: '#/components/schemas/UserId'
        username:
          type: string
        email:
          type: string
          example: test@test.test
        avatar:
          type: string

We've defined two types of data: UserId and BasicUser. UserId is just an integer. What's new here is the constraint minimum. This way we describe that this data is always greater than or equal to 1. There are many possible constraints we can define. You can read more about it in the OpenAPI documentation.

BasicUser is an object with id, username, email and avatar properties. Two things may need an explanation here. Firstly, in the id we used a $ref property. It allows us to reference other types (UserId in this case). Note that referencing already defined type is not the same as defining it "inline". The difference is basically like defining a class in Java versus using a Map. It'll become more clear later. At this point in the tutorial, you don't have to worry about it but keep it in the back of your head. Secondly, we used an example property for the email. It's just metadata that can be used by visualization tools (more on that later).

Now, that we have our schemas defined in one place, we can reference them in our /api/user endpoint. While we're on it, let's also define a POST method for this endpoint. It should now look like this:

/api/user:
    get:
      operationId: userinfo-operation
      summary: Returns basic info about a logged in user
      responses:
        200:
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BasicUser'
    post:
      operationId: deprecated-userinfo-operation
      deprecated: true
      summary: Returns basic info about a logged in user
      responses:
        200:
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BasicUser'

Thanks to $ref properties, we didn't have to define the user twice. Note that the POST method behaves the same way as the GET method. It is, however, marked as deprecated. It's another piece of metadata that can be used by various tools.

Next, let's quickly define an endpoint that will return an array of users:

  /api/users:
    get:
      operationId: userlist-operation
      summary: Returns a list of users with basic info about each
      responses:
        200:
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/BasicUser'

The thing to note here is that we define a custom array of the existing datatype. As you can see, the $ref property can be really powerful.

Request body

Let's move on to the usage of schemas to define request bodies:

/api/user/avatar:
  put:
    operationId: uploadavatar-operation
    summary: Uploads a new avatar for the logged in user
    requestBody:
      content:
        image/png:
          schema:
            type: string
            format: binary
    responses:
      200:
        description: uploaded
      400:
        description: bad request
  get:
    operationId: getavatar-operation
    summary: Gets a link to the user's avatar
    responses:
      200:
        description: OK
        content:
          application/json:
            schema:
              type: object
              properties:
                avatarUrl:
                  type: string

We define two methods for this path. The GET method is nothing special. We just define one possible response with an in-place defined schema. The PUT method has two possible responses without bodies and a request body of type image/png. It's defined in the same way as response bodies. A schema might even be shared between request and response bodies, if necessary. One thing to note here is the schema type. It has a binary format. It's a special subtype of string commonly used for images. Another possible format would be base64. More on image uploading can be found in the OpenAPI documentation.

Parameters

Now, let's talk about parameters. Four types of parameters can be defined: query, path, header and cookie. Last two aren't covered in this tutorial, because they are analogical to query parameters.

Query parameters

Parameters can be defined like this:

/api/user/isadmin:
  get:
    operationId: admininfo-operation
    summary: Returns detailed information about user's account
    parameters:
      - in: query
        name: datatype
        description: mime-type that client want to be returned
        schema:
          type: string
          enum:
            - json
            - xml
        required: true

We define a list of parameters. In this case, we only have one. We specify its name, type (as query) and metadata (description). It uses a schema in the same way as request and response bodies do. The new thing here is the enum property. It specifies that this type can only have one of the specified values. Also, note the required: true part. It means the client can't omit this parameter. Therefore the only two correct requests are:

  • /api/user/isadmin?datatype=json
  • /api/user/isadmin?datatype=xml

If a client omits the datatype parameter or sets it as something other than json or xml, then Knot.x will automatically respond with 400 Bad Request.

Now, the api/user/isadmin endpoint definition is not correct. We have to define responses:

responses:
  200:
    description: OK
    content:
      application/json:
        schema:
          $ref: '#/components/schemas/IsAdmin'
      application/xml:
        schema:
          $ref: '#/components/schemas/IsAdmin'
  400:
    description: Bad request

In case of a success response, we defined two possible content types to be returned: json and xml. This demonstrates that schemas are abstract from any specific format and therefore, like here, can be reused between multiple content types. For JSON it'll be interpreted like this:

{
  "admin": true
}

And the same schema for XML will be interpreted like this:

<?xml version="1.0" encoding="UTF-8"?>
<IsAdmin>
    <admin>true</admin>
</IsAdmin>

Path parametrs

Path parameters are very similar. Actually, the only difference is that they are defined in the path itself (apart from standard parameters property). Let's add this path:

 /api/users/{id}/send/{message}:
    post:
      operationId: sendmsg-operation
      summary: Send a message to a specific user
      parameters:
        - in: path
          name: id
          description: ID of a user to send the message to
          required: true
          schema:
            $ref: '#/components/schemas/UserId'
        - in: path
          name: message
          description: message to be sent to the user
          required: true
          schema:
            type: string
            pattern: '^[a-zA-Z0-9_]*
      responses:
        200:
          description: OK
        400:
          description: Bad request

In the path, the parameters are defined using {} brackets. They are treated like wildcards, therefore our path will match all requests in the form of /api/users/.../send/.... Next, we define the same parameters in the parameters property, the same way as with query parameters. Note that the name in the parameter list must exactly match the name specified in the brackets. Also, path parameters have to be required.

We've also introduced a new schema constraint - pattern. It's just a regular expression that the parameter must match. If it doesn't, Knot.x will return 400 Bad Request response.

As already mentioned, we won't discuss header and cookie parameters, because they work the same way as query parameters. Just specify in: header or in: cookie.

Undefined operation

In the last path we'll define, let's use a non-existent operation. Knot.x will return 501 Not Implemented for this endpoint:

/api/future-feature:
  get:
    operationId: future-feature-operation # we won't implement this operation
    summary: This will be implemented in the future
    responses:
      501:
        description: Not implemeneted

As far as OpenAPI is concerned, there's nothing wrong or new in this path definition.

Implementing operations with mocked data

The endpoints we defined in the openapi.yaml file reference operations that they will invoke. Let's define the operations now. We'll use three custom handlers:

  • static-json - takes data from its configuration and returns it as JSON
  • static-status - just returns a configured response code
  • admin-info - returns harcoded data either as JSON or XML, depending on query parameter

Configuration

Define operations in the operations.conf as follows:

GET api/user/avatar returns JSON with an avatar link:

{
  operationId = getavatar-operation
  handlers = [
    {
      name = static-json
      config.json = {
        avatarUrl = ${mocked.loggedUser.avatar}
      }
    }
  ]
}

POST api/user/avatar always returns 200 OK:

{
  operationId = uploadavatar-operation
  handlers = [
    {
      name = static-status
      config.code = 200
    }
  ]
}

GET api/users returns JSON with the list of users

{
  operationId = userlist-operation
  handlers = [
    {
      name = static-json
      config.json = ${mocked.userList}
    }
  ]
}

GET api/user and POST api/user return data about one user

{
  operationId = userinfo-operation
  handlers = [
    {
      name = static-json
      config.json = ${mocked.loggedUser}
    }
  ]
}
{
  operationId = deprecated-userinfo-operation
  handlers = [
    {
      name = static-json
      config.json = ${mocked.loggedUser}
    }
  ]
}

GET api/user/isadmin invokes admin-info handler that returns harcoded data as either JSON or XML

{
  operationId = admininfo-operation
  handlers = ${config.server.handlers.common.request} [
    {
      name = admin-info
    }
  ]
}

POST /api/user/{id}/send/{message} always returns 200 OK

{
  operationId = sendmsg-operation
  handlers = [
    {
      name = static-status
      config.code = 200
    }
  ]
}

You've probably noticed our handlers' configurations reference an object called mocked. It's a HOCON syntax and we use it just so we don't need to copy and paste hardcoded user data. Let's add the object to the file:

routingOperations = ${routingOperations} [
  # ...
]

mocked = {
  loggedUser = {
    id = 1
    username = "UserNr1"
    email = "usernr1@test.test"
    avatar = "https://api.adorable.io/avatars/285/1"
  }
  userList = [
    ${mocked.loggedUser}
    {id = 2, username = "Lorem", email = "lorem@test.test", avatar = "https://api.adorable.io/avatars/285/2"}
    {id = 3, username = "Ipsum", email = "ipsum@test.test", avatar = "https://api.adorable.io/avatars/285/3"}
  ]
} 

Java handlers

Now let's define three handlers we used above. They're really simple.

The static-status handler just returns a response with a given response code:

public class StaticStatusHandlerFactory implements RoutingHandlerFactory {

  @Override
  public String getName() {
    return "static-status";
  }

  @Override
  public Handler<RoutingContext> create(Vertx vertx, JsonObject config) {
    return routingContext -> routingContext
        .response()
        .setStatusCode(config.getInteger("code"))
        .end();
  }
}

Nothing to explain here. The static-json handlers just read its json property from the configuration, parses it to a JSON string and returns it:

public class StaticJsonHandlerFactory implements RoutingHandlerFactory {

  @Override
  public String getName() {
    return "static-json";
  }

  @Override
  public Handler<RoutingContext> create(Vertx vertx, JsonObject config) {
    return routingContext -> {
      Object json = config.getMap().get("json");
      routingContext.response().end(encodeJson(json));
    };
  }

  private String encodeJson(Object object) {
    if (object instanceof JsonObject) {
      return ((JsonObject) object).encode();
    } else if (object instanceof JsonArray) {
      return ((JsonArray) object).encode();
    } else {
      throw new IllegalStateException("Can't encode non-json object as JSON");
    }
  }
}

One thing that may look suspicious is the encodeJson method. It just calls the encode method on either JsonObject or JsonArray read from configuration. Its needed because, unfortunately, those classes don't share any interface with the encode method.

Last is the admin-info handler. It reads request query parameters and checks for the value of datatype param. It then returns hardcoded data in the form depending on its value:

public class AdminInfoHandlerFactory implements RoutingHandlerFactory {

  @Override
  public String getName() {
    return "admin-info";
  }

  @Override
  public Handler<RoutingContext> create(Vertx vertx, JsonObject config) {
    return routingContext -> {
      RequestContext requestContext = routingContext.get(RequestContext.KEY);
      ClientRequest clientRequest = requestContext.getRequestEvent().getClientRequest();

      String type = clientRequest.getParams().get("datatype");
      respondByType(routingContext.response(), type);
    };
  }

  private void respondByType(HttpServerResponse response, String type) {
    String contentType = "text/plain";
    String content = "";

    if ("json".equals(type)) {
      contentType = "application/json";
      content = "{\"admin\": false}";
    } else if ("xml".equals(type)) {
      contentType = "application/xml";
      content = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><IsAdmin><admin>false</admin></IsAdmin>";
    }

    response
        .putHeader("Content-Type", contentType)
        .end(content);
  }
}

Note that our if..else statement doesn't have any case for when the parameter is neither json nor xml. That's because this handler won't even be called in such a case. Knot.x will return 400 Bad Request response, because of our schema in openapi.yaml.

While Knot.x validates requests based on OpenAPI schemas and handles incorrect cases, there is no such validation for responses. Handlers are responsible for delivering correct data and not breaking the contract.

Remember about adding our handlers to service loader in META-INF.

Swagger UI

Our application is basically working. If you run it should be able to call our endpoints. For example:

curl -X GET "http://localhost:8092/api/future-feature"

Should return 501 because we didn't define any handlers for it. Or:

curl -X GET "http://localhost:8092/api/user/isadmin?datatype=xml"

Should return an XML that looks like this:

<?xml version="1.0" encoding="UTF-8"?>
<IsAdmin>
  <admin>false</admin>
</IsAdmin>

However, we can use Swagger UI to make even better use of our openapi.yaml file. It's an interactive tool that lists all of our endpoints in a nice to look way and allows us to test them. It's a web application so we have to serve it on some endpoint. First, let's create an HTML file that will display Swagger UI. Note that it's recommended that you use non-standalone version if possible (or even a React component version, if your frontend uses React). It requires frontend build tools, though, so for simplicity, we'll use a standalone version hosted on unpkg.com.

Let's create resources/webroot/swagger/index.html file:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Swagger UI</title>

  <link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@3.24.0/swagger-ui.css">

  <script src="https://unpkg.com/swagger-ui-dist@3.24.0/swagger-ui-standalone-preset.js"></script>
  <script src="https://unpkg.com/swagger-ui-dist@3.24.0/swagger-ui-bundle.js"></script>
</head>
<body>
  <div id="swagger-ui" />
  <script>
    // initialize SwaggerUI on window load
    window.onload = () => {
      window.ui = SwaggerUIBundle({
        url: "/swagger/spec",
        dom_id: '#swagger-ui',
        deepLinking: true,
        presets: [
          SwaggerUIBundle.presets.apis,
          SwaggerUIBundle.SwaggerUIStandalonePreset
        ],
        plugins: [
          SwaggerUIBundle.plugins.DownloadUrl
        ]
      })
    }
  </script>
</body>
</html> 

Most of the code here is a Swagger UI specific configuration needed for it to run. For more explanation check the Swagger UI docs. One important thing is the url: "/swagger/spec" part. It's an URL that Swagger UI will call to retrieve the OpenAPI specification file. We will expose the openapi.yaml file under this path.

Let's add two endpoints to the openapi.yaml file that will serve our index.html file and the openapi.yaml file itself:

/swagger/spec:
  get:
    operationId: openapi-operation
    responses:
      default:
        description: exposes openapi spec file to be consumed by swagger ui
/swagger/ui:
  get:
    operationId: swaggerui-operation
    responses:
      default:
        description: swagger-ui web app

And define the operations in operations.conf:

{
  operationId = openapi-operation
  handlers = [
    {
      name = static
      config.webroot = openapi.yaml
    }
  ]
}
{
  operationId = swaggerui-operation
  handlers = [
    {
      name = static
      config.webroot = webroot/swagger/
    }
  ]
}

The first operation serves the openapi.yaml file from as a static resource and the second operation serves the Swagger UI web app. Now we need to define static handler. It just serves static resources from a classpath:

public class StaticHandlerFactory implements RoutingHandlerFactory {

  @Override
  public String getName() {
    return "static";
  }

  @Override
  public Handler<RoutingContext> create(Vertx vertx, JsonObject config) {
    return StaticHandler.create(config.getString("webroot"));
  }
}

Remember about updating the META-INF.

Using Swagger UI

We're done. After you run the application you can go to http://localhost:8092/swagger/ui in your browser. It'll download the openapi.yaml file from the /swagger/spec path, parse it and display the graphical interface.

Top part should look like this:

Top part of Swagger UI interface

It shows some metadata we specified in the OpenAPI file. It also allows us to choose a server and its variables that we defined. It's used for testing the endpoints.

The next part shows the list of all endpoints and their methods:

Swagger UI endpoints

We can see summary is displayed here as well. We can also clearly see that POST /api/user is deprecated. Let's click on the GET /api/user/isadmin endpoint. You should see all the information about this endpoint. It consumes one required query parameter datatype that can either be json or xml. It can also return one of those two content types. Example responses are also provided. You can click on the Try it out button, specify the query parameter and Execute. Swagger UI will call this endpoint and show you the response. Curl commands are also provided. Swagger UI can handle many things, such as image uploading or even different types of authorization. You can read more about it here.

If you scroll down you'll see the Schemas section:

Swagger UI schemas

All schemas defined in components section can be viewed here. You cannot see schemas defined in an 'inline' way here. This is an important distinction.

Summing up

There are also other tools that can greatly improve your experience with OpenAPI (check out this list). Because Knot.x uses open standards you can easily transfer your knowledge between frameworks and make use of existing tools. In this tutorial we exposed our OpenAPI specification to be consumed by Swagger UI but, you could, for example, share it with your API clients or even get the specification file from someone you're constructing the API for. There exist tools that can generate code based on an OpenAPI file. Such things are not needed with Knot.x, because this standard is already a part of it.