API Gateway: Caching

Oskar Jerzyk
2.0.0

Overview

In this tutorial, we will use Knot.x as the API gateway to show the usage of caching functionality. Knot.x will act as a proxy API to some external API and will cache its responses. The caching functionality can be implemented in a custom handler. We would implement API invocation logic and code caching logic using, for example, Guava cache. However, this approach implies the need to implement both the API call and the cache mechanism. Moreover, what happens if we would have to implement a circuit breaker pattern for proxied API in the future? How will it affect our existing integration logic? In this tutorial, we would make use of Configurable Integrations. Thanks to this we would encapsulate our API invocation with an HTTP Action, then wrap it with In-memory Cache Action.

To fully understand this tutorial, it's highly recommended to get familiar with the previous ones:

Prerequisites

  • JDK 8
  • Docker

What you're going to learn:

  • How to wrap an existing (external) API with a caching proxy
  • How to use behaviours and actions

Download the Latest Knot.x Starter Kit release and unzip it.

Project has the following structure:

├── docker
|   ├── Dockerfile                // Docker file with image definition.
├── functional                    // Keep here your functional tests. Example implementation included
├── gradle                        // Gradle wrapper and common gradle scripts
├── knotx/conf                    // Knotx configuration which will be copied to docker image
├── modules                       // Sub-modules of your project
│   ├── ...                       // example modules implementation

Why do we need caching?

The general purpose of using caching is faster data retrieval - when the user is calling the same endpoint a few times, we don't have to fetch the data from the server for every request - we can cache and reuse the response for incoming requests (until cached values are valid) - it allows quicker response times, which improve the overall user experience. Other benefits include reduced latency and traffic. Requests are satisfied in a shorter time with cached values.

Actions & behaviours

Let's define our custom task that will retrieve data from an external API and cache its JSON response. All future task's invocations should use stored values (until the cache entries are invalidated). As explained in the previous tutorials, a task is a directed graph of actions. In our case, it will be a graph with two nodes. Let's configure the get-product-task task in the knotx/conf/routes/handlers/fragments-handler.conf configuration file:

tasks {
  get-product-task {
    action = fetch-product-with-cache
    onTransitions {
      _success {
        action = product-to-body
      }
    }
  }
}

actions {
  # Target API caching proxy
  fetch-product-with-cache {
    # https://github.com/Knotx/knotx-fragments/blob/2.0.0/handler/core/src/main/java/io/knotx/fragments/handler/action/InMemoryCacheActionFactory.java
    factory = in-memory-cache
    config {
      cache {
        maximumSize = 1000
        ttl = 5000
      }
      cacheKey = "product-{param.id}"
      payloadKey = fetch-product
    }
    doAction = fetch-product
  }

  # Target API
  fetch-product {
    # https://github.com/Knotx/knotx-data-bridge/blob/2.0.0/http/action/src/main/java/io/knotx/databridge/http/action/HttpActionFactory.java
    factory = http
    config {
      endpointOptions {
        path = /product/id
        domain = webapi
        port = 8080
        allowedRequestHeaders = ["Content-Type"]
      }
    }

  }

  product-to-body {
    # https://github.com/Knotx/knotx-fragments/blob/2.0.0/handler/core/src/main/java/io/knotx/fragments/handler/action/PayloadToBodyActionFactory.java
    factory = payload-to-body
    config {
      key = "fetch-product._result"
    }
  }
}

We declared the get-product-task task. It's entry node is the fetch-product-with-cache action. The action declares one success transition (onTransitions) that points to the product-to-body action node upon execution success (_success).

Below the task definition, you can see all necessary action's definitions. The fetch-product action is responsible for the target RESTful HTTP API invocation. In our case, it is the http://webapi:8080/product/id. The action logic is delivered via HTTP Action implementation available out of the box from Knot.x. Now it is time to add the cache. For this purpose, we define a separate fetch-product-with-cache action that:

  • uses In-memory Cache Action implementation
  • decorates/wraps the fetch-product action (see the doAction attribute), it is responsible for caching only
  • declares the key name in the fragment's payload that would contain cached values (it is the decorated action name)
  • configures cache parameters such as maximum size and time to live for stored entries cache key This action is declared as the task root node. Knot.x defines those kinds of actions as behaviours. They wrap a target action and add some functionality. Circuit Breaker Action is also the example of default behaviours implementations. Finally, we define the product-to-body action to rewrite the previously fetched data from the fragment's payload to the response body.

You may be wondering if we can replace this cache with another one, for example with Redis. The answer is yes, you can do it very simply! The only thing needed here is to create appropriate custom action factory which will handle caching in the desired way and then properly link it to an action in the config file (the same way as we did above).

Operations configuration

Now we have to configure knotx/conf/routes/operations.conf and define two operations:

  • healthcheck-operation
  • product-api-caching-proxy-operation

These operations are configured in the openapi.yaml specification which we'll do next. This file specifies which handlers should be invoked
to process the requests. Let's specify the product-api-caching-proxy-operation operation first:

routingOperations = ${routingOperations} [
  {
    operationId = product-api-caching-proxy-operation
    handlers = ${config.server.handlers.common.request} [
      {
        name = singleFragmentSupplier
        config = {
          type = json
          configuration {
            data-knotx-task = get-product-task
          }
        }
      },
      {
        name = fragmentsHandler
        config = { include required(classpath("routes/handlers/fragments-handler.conf")) }
      },

      {
        name = fragmentsAssembler
      }
    ] ${config.server.handlers.common.response}
  }
  {
    operationId = healthcheck-operation
    handlers = [
      {
        name = healthcheck
      }
    ]
  }
]

The operation consists of three handlers:

  • singleFragmentSupplier that converts incoming HTTP request to Fragment and assign a task to it
  • fragmentsHandler handler that reads task configuration and evaluates graph logic
  • fragmentsAssembler handler that rewrite fragment's body into the final HTTP response

OpenAPI

Finally, we need to declare the exposed the new API containing caching functionality, its path and HTTP method. We add it in the ./knotx/conf/openapi.yaml OpenAPI 3.0 specification:

openapi: "3.0.0"
info:
  version: 1.0.0
  title: API gateway caching example
  description: API gateway caching example

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

paths:
  /healthcheck:
    get:
      operationId: healthcheck-operation
      responses:
        default:
          description: example vert.x healthcheck

  /product/id:
    get:
      operationId: product-api-caching-proxy-operation
      responses:
        default:
          description: External API (Product API) caching proxy.

The API is exposed by the product/id path and GET HTTP method. It delegates its processing logic to the product-api-caching-proxy-operation operation.

We have all Knot.x specific files created, now we are going to focus on project setup and deployment.

Environment configuration

The first thing we need to specify the Docker Swarm configuration file. Let's create the./api-cache.yml file containing our environment definition: target API and Knot.x instance. For the target API, we used Wiremock.

Let's copy the following configuration:

version: '3.7'

networks:
  knotnet:

services:

  webapi:
    image: rodolpheche/wiremock
    volumes:
      - "./common-services/webapi:/home/wiremock"
      - "./common-services/webapi/extensions:/var/wiremock/extensions"
    ports:
      - "3000:8080"
    networks:
      - knotnet
    command: ["--global-response-templating", "--extensions", "com.opentable.extension.BodyTransformer"]

  knotx:
    image: knotx-example/api-cache:latest
    command: ["knotx", "run-knotx"]
    ports:
      - "8092:8092"
      - "18092:18092"
    networks:
      - knotnet

First and foremost it specifies Docker virtual network in which our services will be deployed. Then we have some service configs. The first one is webapi

  • image - docker image is provided here,
  • volumes - directories to which Docker will have access, in our case it's directory where all Wiremock configs are stored,
  • ports - port accessible from localhost is specified on the left side, and a port available in the virtual environment is specified on the right
  • networks - specifies in which docker network the service will be deployed.

Then we have knotx service specification. It's very similar to the previous one, so only one property is worth explaining.

  • command - this property is used to specify Knot.x starting commands.

The webapi service is the second piece of our environment. It is a flexible API mocking tool, called Wiremock, that serves preconfigured responses for matched requests (based on path and method). It is our target /product/id API.

One additional change is required for Wiremock to work correctly. We need to include it's dependency used for response templating in container's /var/wiremock/extensions directory. As seen above, we mounted the volume to ./common-services/webapi/extensions directory on the host machine. Luckily Gradle will do the downloading/copying for us. Just make the following changes to build.gradle.kts:

// ...
configurations {
    register("wiremockExtensions")  // create new configuration for our task
}
// ...
dependencies {
    // ...
    "wiremockExtensions"("com.opentable:wiremock-body-transformer:1.1.3") { isTransitive = false }  // download the needed jar (we don't need any transitive dependencies) 
}
// ...

val downloadWireMockExtensions = tasks.register<Copy>("downloadWiremockExtensions") { // copy downloaded JAR to the desired directory
    from(configurations.named("wiremockExtensions"))
    into("./common-services/webapi/extensions")
}

tasks.named("build") {
    dependsOn(downloadWireMockExtensions, "runFunctionalTest")  // add our task as a build dependency
}

Let's define the custom Docker image name for Knot.x instance. We can specify it by the docker.image.name property in the ./gradle.properties file:

version=2.0.0-SNAPSHOT
knotx.version=2.0.0
knotx.conf=knotx
docker.image.name=knotx-example/api-cache

Target API mock

We use Wiremock to mock our target API behaviour. We can easily specify the response body, code and delay. To do it let's create following directories:

  • ./common-services/webapi/__files/
  • ./common-services/webapi/mappings/

In the ./common-services/webapi/__files/ directory please create the product.json file with following body:

{
  "id": 21762532,
  "url": "http://knotx.io",
  "label": "Product"
}

In the ./common-services/webapi/mappings/ directory please create the product.json file with following body:

{
  "request": {
    "method": "GET",
    "url": "/product/id"
  },
  "response": {
    "status": 200,
    "fixedDelayMilliseconds": 100,
    "bodyFileName": "product.json",
    "currentTime": "{{now format='yyyy-MM-dd HH:mm:ss'}}",
    "promoCode": "{{randomValue length=6 type='ALPHANUMERIC'}}"
  }
}

Make sure that the volume property from ./api-cache.yml points to the ./common-services/webapi directory.

Note the response templating usage in product.json. Each response from Wiremock will have some unique content: timestamp and some random string - an excellent way to demonstrate caching functionality!

Build and run

From project level directory execute following commands which will build project and run Docker Swarm:

$ gradlew clean build
$ docker swarm init
$ docker stack deploy -c api-cache.yml api-cache

After a while the stack will be deployed and both wiremock (port 3000) and api-cache (port 8092) containers should be up. Now you can invoke Wiremock's and Knot.x's endpoints:

curl -X GET http://localhost:3000/product/id

{
  "id": 21762532,
  "url": "http://knotx.io",
  "label": "Product",
  "currentTime": "2019-10-29 13:57:51",
  "promoCode": "1tpm41"
}
curl -X GET http://localhost:3000/product/id
{
  "id": 21762532,
  "url": "http://knotx.io",
  "label": "Product",
  "currentTime": "2019-10-29 13:57:57",
  "promoCode": "ymhvkg"
}

curl -X GET http://localhost:8092/product/id

{
  "id" : 21762532,
  "url" : "http://knotx.io",
  "label" : "Product",
  "currentTime" : "2019-10-29 13:58:09",
  "promoCode" : "y8v7u2"
}
curl -X GET http://localhost:8092/product/id
{
  "id" : 21762532,
  "url" : "http://knotx.io",
  "label" : "Product",
  "currentTime" : "2019-10-29 13:58:09",
  "promoCode" : "y8v7u2"
}

You should be able to receive a HTTP response at localhost:3000/product/id. This is the endpoint served by Wiremock. The response is different with each request - it's not cached.

If you invoke Knot.x at localhost:8092/product/id and refresh it a few times, you can see that neither the timestamp nor the random string in the JSON response changes - we have successfully cached external API response!

Summary

In this tutorial, we used Knot.x as a simple API gateway providing very flexible API caching functionality. With Configurable Integrations we achieved it without writing any line of code, using only Knot.x built-in features. With Actions and Behaviours, we've focused on creating it in a declarative way instead of implementing caching functionality on our own. Now you'll understand how quickly you can "decorate" your existing API with cross-cutting functionalities such as caching or circuit breaker.

You can find the complete code of this tutorial in our github repository