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 thedoAction
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 itfragmentsHandler
handler that reads task configuration and evaluates graph logicfragmentsAssembler
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 fromlocalhost
is specified on the left side, and a port available in the virtual environment is specified on the rightnetworks
- 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