Getting Started with Knot.x Stack

Mateusz Hinc
1.3.0 1.4.0 1.5.0 2.0.0

Overview

In this tutorial, we will set up a simple project based on the Knot.x Starter Kit template.

We will implement a custom handler that will print a "Hello world from Knot.x!" message.

Prerequisites

What you’re going to learn:

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

Please note that the support for ZIP distribution was added here. If your version does not contain those changes you can easily apply them with upgrade notes.

Project has the following structure:

├── docker
|   ├── Dockerfile                // Docker file with image definition. It will be not used in this tutorial.
├── 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 distribution ZIP
├── modules                       // Sub-modules of your project
│   ├── ...                       // example modules implementation

Configure API: specify path and method

When it comes to API development, two approaches are available: The “Design First” and the “Code First”. You can read more details here to understand the differences between them. Knot.x uses the Design First approach focusing on developer experience and API design quality. It uses the OpenAPI 3.0 specification to configure HTTP request routing.

The knotx/conf/openapi.yaml file contains project-specific API definitions using the OpenAPI 3.0 specification syntax.

We can see three distinguishable parts of this file:

  1. Information about the project, which is quite self-explanatory:

     ...
    
     openapi: "3.0.0"
     info:
       version: 1.0.0
       title: Knot.x Starter Kit
       description: Knotx Starter Kit
       contact: 
         name: "Knot.x Community"
         url: "https://knotx.io/community/"
    
     ...
  2. Server configuration that defines how the server behaves:

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

    Here we can see, for example, that the server will be registered on port 8092. Notice variable substitution indicated by {brackets}. For more information on what can be configured here, see this documentation.

  3. Paths configuration that explicitly specifies the endpoints, their paths, methods, responses and much more in a clear, easily readable and declarative way:

     ...
    
     paths: 
    
       ...   
    
       /healthcheck:
         get:
           operationId: healthcheck-operation
           # https://vertx.io/docs/vertx-health-check/java/
           responses:
             '200':
               description: Everything is fine
             '204':
               description: No procedure are registered.
             '501':
               description: At least one procedure has reported a non-healthy status
             '500':
               description: One procedure has thrown an error or has not reported a status in time
     ...

In OpenAPI terms, paths are endpoints (resources), such as /users or /reports/summary/, that your API exposes, and operations are the HTTP methods used to manipulate these paths, such as GET, POST or DELETE. (read more here)

The Starter Kit template comes with example paths and methods defined. Let's see the /healthcheck API. It is used to express the current state of the application in very simple terms: UP or DOWN (more on that in the second tutorial).

We can see that it is registered under /healthcheck path, it's method is a GET and it can have various responses depending on the state of the instance.

A configured path and method section declares a unique operation that is used to process matching requests. In the example above, it is healthcheck-operation. Details how to link configured operation with the business logic implementation will be covered later.

Now, it is time to define a custom endpoint /api/hello and operation hello-world-operation.

Simply add new routing configuration under paths:

paths:
  /api/hello:
    get:
      operationId: hello-world-operation
      responses:
        default:
          description: Example API implemented in this tutorial

Configure API: define a new operation

As previously mentioned, a configured path and method indicates operation to perform. Every operation is configured as a Routing Operation entry that specifies Handlers with business logic to execute and failure Handlers when processing fails.

All routing operation entries are configured in the knotx/conf/routes/operations.conf file. It is the first time the Knot.x configuration appears. It uses the HOCON (Human-Optimized Config Object Notation) syntax. More on HOCON can be found in here.

Let's take a look at this file:

routingOperations = ${routingOperations} [

  // routing operations for this project

]

routingOperations is an array of routingOperation objects.

${routingOperations} takes care of merging any previously defined routing operations with the ones you define here. More about HOCON concatenation possibilities can be found here.

Let's take a look at an example routing operation entry we get from Starter Kit:

  {
    operationId = healthcheck-operation
    handlers = [
      {
        name = healthcheck
      }
    ]
  }

The healthcheck-operation operation defines the array of Handlers with a single Handler, named healthcheck. Please note that Handlers can be easily reused so healthcheck is a Handler's factory unique name.

Let's take a look at a more complicated one:

{
    operationId = example-api-with-fragments-operation
    handlers = ${config.server.handlers.common.request} [
      {
        name = singleFragmentSupplier
        config = {
          type = json
          configuration {
            data-knotx-task = api-task
          }
        }
      },
      {
        name = fragmentsHandler
        config = { include required(classpath("routes/handlers/api-with-fragments.conf")) }
      },
      {
        name = fragmentsAssembler
      }
    ] ${config.server.handlers.common.response}
  }

Here we provide not one, but three different handlers. By defining them in an array, they will be called in order they're defined. Some of them even have some additional configurations defined explicitly or in another file. They will be passed as JsonObjects to the handler implementation.

Notice two additional imports: ${config.server.handlers.common.request} and ${config.server.handlers.common.response}. Here we use the handlers defined in Knot.x source code, which simplifies handling of basic requests without repeating the configuration for similar operations.

Now, let's connect hello-world-operation with a handler. The name of the handler factory will be hellohandler (reminder: names must be unique!). We'll also make the message configurable.

Simply add new routing operation to the array:

  {
    operationId = hello-world-operation
    handlers = [
      {
        name = hellohandler
        config = {
          message = "Hello World From Knot.x!"
        }
      }
    ]
  }

API implementation: creating new Handler

Now it is time to code some custom logic printing "Hello World from Knot.x!". We will implement the RoutingHandlerFactory interface. We use Java but you can use any JVM-based language, such as Kotlin. Let's put it in a separate module.

Create a new module under the modules folder. The structure should look like this:

modules
└─ hellomodule
    ├── build.gradle.kts                                                        // gradle build script
    └── src 
        └── main                   
            ├── java/com/project/example/hellohandler
            │     └── HelloWorldHandlerFactory.java                             // the handler factory  
            └── resources
                  └── META-INF/services
                        └── io.knotx.server.api.handler.RoutingHandlerFactory   // META-INF file used by Knot.x to find the handler   

Let's take a look at the three files required for the module to work.

build.gradle.kts

plugins {
    `java-library`
}

dependencies {

    "io.knotx:knotx".let { v ->
        implementation(platform("$v-dependencies:${project.property("knotx.version")}"))
        api("$v-fragments-api:${project.property("knotx.version")}")
        api("$v-fragments-handler-api:${project.property("knotx.version")}")
    }
    "io.vertx:vertx".let { v ->
        implementation("$v-web")
        implementation("$v-web-client")
        implementation("$v-rx-java2")
    }
}

io.knotx.server.api.handler.RoutingHandlerFactory

io.knotx.example.hellohandler.HelloWorldHandlerFactory

In this file we list, line by line, all the RoutingHandlerFactory implementations we want Knot.x to see. It has to be a canonical class name. It uses Java ServiceLoader.

HelloWorldHandlerFactory.java

package io.knotx.example.hellohandler;

import io.knotx.server.api.handler.RoutingHandlerFactory;
import io.vertx.core.Handler;
import io.vertx.core.json.JsonObject;
import io.vertx.reactivex.core.Vertx;
import io.vertx.reactivex.ext.web.RoutingContext;

public class HelloWorldHandlerFactory implements RoutingHandlerFactory {

  @Override
  public String getName() {
    return "hellohandler";    // this has to be exactly the same unique string as in operations.conf file                                                 
  }

  @Override
  public Handler<RoutingContext> create(Vertx vertx, JsonObject config) {
    String message = config.getString("message", "Some default message");
    JsonObject jsonObject = new JsonObject()
        .put("message", message);
    return event -> event.response().end(jsonObject.toString());
  }
}

The most important thing is to register it by its name. In this case, it was "hellohandler".

The handling it performs is simply ending the response with previously configured "Hello World from Knot.x!" JSON.

Finally, let's update the main project's settings.gradle.kts file, so that it will recognize the new module.

Simply add those two lines:

include("hello-module")

and

project(":hello-module").projectDir = file("modules/hello-module")

Build & Run

The process of distributing Knot.x via stack is the following:

$ ./gradlew build

This command is building the distributable zip archive. It can be found under build/distributions directory.

This archive is fully functional Knot.x distribution and can be deployed on any Unix-based machine.

In order to run it, first we need to unzip it.

$ cd build/distributions
$ unzip knotx-stack-<version of your project>.zip

Now let's navigate to unzipped knotx folder and make knotx/bin/knotx file executable.

$ cd knotx
$ chmod +x bin/knotx

The final step is executing the starting script:

$ bin/knotx run-knotx

The application should start.

After a while it should be up and running and the endpoint can be accessed. Let's execute the command:

$ curl -X GET http://localhost:8092/api/hello
{"message":"Hello World From Knot.x!"}

Debugging

For manual on how to debug the application see this documentation.


You can find full example implementation here.