Skip to main content

Custom endpoints

Overview

The resources you create via the Request Server, Data Server and Event Handler are exposed to the front end as a series of REST endpoints. You can also create custom endpoints.

Example uses for these custom endpoints include:

  • file upload and download
  • integration with external systems
info

Custom endpoints are exposed as HTTP endpoints and are not available with Websockets.

Custom endpoints are defined in the *-web-handler.kts files.

Once a web-handler.kts is available within your product/script directory, it is immediately available as an endpoint in the router.

Example configuration

The following shows example web endpoint configurations.

import global.genesis.trade.CreateTradeResponse
import global.genesis.trade.InstrumentClient
import global.genesis.trade.TradeInformation
import global.genesis.trade.TradeInformationClient
import java.nio.file.Files

webHandlers {
val instrumentClient = injector<InstrumentClient>()
val tradeInformationClient = injector<TradeInformationClient>()

endpoint(GET, "instruments") {
handleRequest {
val instruments = instrumentClient.getInstruments()
LOG.debug("Retrieved instruments: {}", instruments)
instruments
}
}

endpoint<Unit, TradeInformation>(GET, "trades") {
permissioning {
responseAuth("INSTRUMENTS") {
authKey { data.instrument }
}
}

val tradeId: String by queryParameter("trade-id")
handleRequest {
val tradeInformation = tradeInformationClient.getTradeInformation(tradeId)
LOG.debug("Retrieved trade information: {}", tradeInformation)
tradeInformation
}
}

endpoint<Trade, CreateTradeResponse>(POST, "trades") {
permissioning {
requestAuth("INSTRUMENTS") {
authKey { data.instrumentId }
}
}

handleRequest {
val trade: Trade = body
val tradeInformation = TradeInformation(trade)
val createTradeResponse = tradeInformationClient.createTrade(tradeInformation)
LOG.debug("Create trade response: {}", createTradeResponse)
createTradeResponse
}
}

val tmp = Files.createTempDirectory("test")
multipartEndpoint("test") {
handleRequest {
body.fileUploads.forEach {
it.copyTo(tmp.resolve(it.fileName))
}
}
}
}
GET instruments

GET endpoint with path instruments.

Using InstrumentClient class which encapsulates the integration with an external service.

On handling a request, uses InstrumentClient to get some data which is returned on the endpoint's response.

GET trades

GET endpoint with path trades and query parameter trade-id.

Using TradeInformationClient class which encapsulates the integration with an external service.

Includes authorisation so only users with access to the instrument (instrument field on TradeInformation) will see the data.

On handling a request, uses TradeInformationClient to get data about a specific trade using the trade id from the query parameter. The trade is then returned on the endpoint's response.

POST trades

POST endpoint with path trades and request body of Trade.

Using TradeInformationClient class which encapsulates the integration with an external service.

Includes authorisation so only users with access to the instrument (instrumentId field on Trade) can create a trade associated with that instrument.

On handling a request, converts the Trade object to a TradeInformation object and uses the TradeInformationClient to send the data to the external service. The external service's response is returned directly on the endpoint's response also.

Upload file

Creates a temporary directory to write the uploaded file to.

On handling a request, reads all file uploads from the request body and writes them to the temporary directory.

Configuration options

webHandlers

The endpoint by default will take its root from the file name. For example, if the file is called trade-web-handler.kts, all endpoints are prefixed with trade.

Overriding the base path

You can specify a basePath in the webHandlers block:

webHandlers("my-base-path") {
endpoint(GET, "all-trades") {
handleRequest {
tradeClient.getAllTrades()
}
}
}

In the example above, the path would be:

  • my-base-path/all-trades

grouping

You can add extra path segments using the grouping function in this way:

webHandlers("BASE-PATH") {
grouping("trade") {
endpoint(GET, "all-trades") {
handleRequest {
tradeClient.getAllTrades()
}
}
endpoint(GET, "big-trades") {
handleRequest {
tradeClient.getAllTrades()
.filter { it.quantity > 1_000 }
}
}
}
}

In the example above, the paths would be:

  • tables/trade/all-trades
  • tables/trade/big-trades

endpoint

Creating an individual endpoint.

This takes 2 parameters, firstly the type of HTTP request, secondly the unique name of the endpoint.

Valid HTTP request types are:

  • GET
  • POST
  • PUT
  • DELETE
  endpoint(GET, "test") {
Request parameters

In addition to the body, endpoints can also take request parameters. These are defined in the endpoint block, and are available in the handleRequest block.

The framework supports the following parameter types:

  • query parameter
  • path parameter
  • header parameter

These can be optional or required. If a required parameter is missing, it will not be matched. If no matching endpoint is found, a 404 Not Found will be returned.

Use the by syntax to define parameters. Note that these variables are only available within the handleRequest block. If they are accessed outside this block, an exception will be thrown.

Query parameters

Here is a simple example of how to define a query parameter:

endpoint(GET, "test") {
val name by queryParameter("name")

handleRequest {
"Hello $name"
}
}

Here is an example of how to define an optional query parameter. Optional parameters are always nullable.

endpoint(GET, "test") {
val name by optionalQueryParameter("name")

handleRequest {
"Hello ${name ?: "Anonymous"}"
}
}
Path parameters

Path parameters are always required. Here is an example of how to define one:

endpoint(GET, "test/{name}") {
val name by pathParameter("name")

handleRequest {
"Hello $name"
}
}
Header parameters

Here is an example of how to define a header parameter:

endpoint(GET, "test") {
val name by header("name")

handleRequest {
"Hello $name"
}
}

Here is an example of how to define an optional header parameter. Optional parameters are always nullable.

endpoint(GET, "test") {
val name by optionalHeader("name")

handleRequest {
"Hello $name"
}
}
Required values

Headers can also have a set of required values. If the header is present, but the value does not match, then the endpoint will not be matched. Note that the required values are published as part of the OpenAPI specification, unless they are declared a secret.

The below endpoint will only match if the Test-Header header is present with its value set to test:

endpoint(GET, "test") {
header("Test-Header", "test")

handleRequest {
"Hello World"
}
}
Secret values

Here is an example of how to define a secret header:

endpoint(GET, "test") {
headerSecret("secret-header", "secret-value")

handleRequest {
"OK"
}
}
Override status code

By default, all endpoints return a 200 OK status code. You can override the default response status by setting the status explicitly:

webHandlers {
endpoint<Trade, Trade>(POST, "insert-trade", status = HttpStatusCode.Created) {
handleRequest {
tradeClient.createTrade(body)
}
}
}

multipartEndpoint

To support file uploads, use the multipartEndpoint function. This function parses the request body as a multipart request and makes the files available in the fileUploads property of the handleRequest block.

val tmp = Files.createTempDirectory("test")
multipartEndpoint("test") {
handleRequest {
body.fileUploads.forEach {
it.copyTo(tmp.resolve(it.fileName))
}
}
}

config

The config function can be used to configure endpoints, which are supported on different levels:

  • webHandlers level
  • grouping level
  • endpoint levels

config calls in nested blocks override those in parent blocks.

Example

This is an example of a config block:

config {
requiresAuth = false
maxRecords = 10_000
logLevel = DEBUG

json {
prettyPrint = true
propertyCase = PropertyCase.CAMEL_CASE
}

multiPart {
maxFileSize = 10_000_000
useDisk = true
baseDir = "runtime/router/fileuploadtemp"
minSize = 100_000
}
}
Available config options

config is available within the webHandler block, the grouping block, the endpoint block, and the multipartEndpoint block.

SyntaxDescription
requiresAuthDefines that the endpoint requires authentication
maxRecordDefines the maximum number of records returned
logLevelDefines the log level
json { ... }Defines the JSON configuration
multiPart { ... }Defines the multipart configuration
register(requestParsers)Registers request parsers
register(responseComposers)Registers response composers
parseRequest<INPUT, TYPE> { ... }Defines a request parser from INPUT to TYPE
parseRequest<INPUT, TYPE>(contentType) { ... }Defines a request parser for a specific content type
composeResponse<TYPE, OUTPUT> { ... }Defines a response composer for TYPE to OUTPUT
composeResponse<TYPE, OUTPUT>(contentType) { ... }Defines a response composer for TYPE to OUTPUT for a specific content type
json
SyntaxDescription
prettyPrintDefines that JSON should be pretty printed
propertyCaseDefines the case of JSON properties, either camel case, or snake case
multiPart
SyntaxDescription
maxFileSizeDefines the maximum file size
useDiskDefines that files should be written to disk
baseDirDefines the base directory for files written to disk
minSizeDefines the minimum size for files written to disk
parseRequest

When parsing a request, the input type tells the endpoint how to handle the initial parsing of the request. For example, if you want to handle the input as a String, you can do this:

endpoint<Trade, Trade>(PUT, "trades") {
config {
parseRequest<String, Trade> {
Trade {
tradeId = input
tradeType = "SWAP"
currencyId = "USD"
tradeDate = DateTime.now()
}
}
}

handleRequest {
body
}
}

Here, the input type is String, and the output type is Trade. The parseRequest block takes a lambda that takes the input type, and returns the output type. The output type is then passed to the handleRequest block as the body.

composeResponse

When composing a response, the output type tells the endpoint how to handle the final part of the response; this can be any type that the endpoint supports. For example, if you want to produce an endpoint to produce a custom xml, you could do this:

endpoint<String, String>(GET, "trades") {
config {
composeResponse<Trade, String>(ContentType.APPLICATION_XML) {
"""
<trade>
<tradeId>${response.tradeId}</tradeId>
<tradeType>${response.tradeType}</tradeType>
<currencyId>${response.currencyId}</currencyId>
<tradeDate>${response.tradeDate}</tradeDate>
</trade>
""".trimIndent()
}
}

produces(ContentType.APPLICATION_XML)

handleRequest {
tradeClient.getTrade(body)
}
}

produces

By default, the handleRequest function infers the output of the endpoint from the return value of the block. So, if you are only producing output and not receiving any input, you do not need type parameters. The output value is returned as JSON, using the standard serialisation mechanism.

This is sufficient for most cases, but you can customise the output.

Content type

If you want to override the default behaviour and specify an output type, use the call produces with a content type. The following content types are supported out of the box:

Content typeName in codeData
application/jsonContentType.APPLICATION_JSONJSON
application/octet-streamContentType.APPLICATION_OCTET_STREAMBinary
text/csvContentType.TEXT_CSVCSV
text/yamlContentType.TEXT_YAMLYAML
text/xmlContentType.TEXT_XMLXML

You can set multiple content types in the produces call; the client can specify which one is returned by setting the Accept header. If no Accept header is specified, then the first content type will be returned.

webHandlers {
endpoint(GET, "all-trades") {
produces(ContentType.TEXT_CSV)
handleRequest {
tradeClient.getAllTrades()
}
}

endpoint(GET, "all-trades-multi") {
produces(ContentType.TEXT_CSV, ContentType.APPLICATION_JSON)
handleRequest {
tradeClient.getAllTrades()
}
}
}
Return types

By default, the returned value is serialised using the default serialiser. However, this is overruled if the return type specified is in the table below.

If you specify a return type, the value returned and the behaviour will be as per the table below, regardless of the Accept header.

However, if the produces function is used, then the Accept header will always be respected.

Return typeBehaviourDefault Content-Type
UnitNo response is returnedn/a
StringThe string is returned as the responsen/a
ByteArrayThe byte array is returned as the responsen/a
File, PathThe file is streamed as the responseapplication/octet-stream
InputStreamThe input stream is streamed as the responseapplication/octet-stream

accepts

Endpoints can also receive input. For this, the http request must include a body. The body can be parsed and will be available in the body property of the handleRequest block. When endpoints receive input, you must provide type parameters for both the request body and the response type:

webHandlers {
endpoint<Trade, Trade>(POST, "insert-trade") {
handleRequest {
tradeClient.createTrade(body)
}
}
}
Content type

As with producing output, you can specify the content type of the request body using the accepts function. An endpoint is able to accept multiple content types. If no content type is specified, then the endpoint defaults to accept application/json.

These content types are supported out of the box:

Content typeName in codeData
application/jsonContentType.APPLICATION_JSONJSON
text/csvContentType.TEXT_CSVCSV
text/yamlContentType.TEXT_YAMLYAML
text/xmlContentType.TEXT_XMLXML
webHandlers {
endpoint<Trade, Trade>(POST, "insert-trade") {
accepts(ContentType.APPLICATION_JSON, ContentType.TEXT_XML)
handleRequest {
tradeClient.createTrade(body)
}
}
}

handleRequest

Within the context of handleRequest, the following properties are in scope:

PropertyDescriptionAvailable
dbThe database instanceAlways
bodyThe body of the requestAlways
userNameThe user name of the user making the requestWhen logged in
requestThe request objectAlways

Any write call to the db will create audit entries for auditable tables, and will be executed in a transaction, if supported by the database layer.

Additionally, the triggerEvent function is available to trigger events from the endpoint:

endpoint<Trade, Trade>(POST, "insert-trade") {
handleRequest {
val trade = tradeClient.createTrade(body).data
triggerEvent("NOTIFY_TRADE_CREATED", trade)
trade
}
}

exceptionHandler

Another way to handle different status codes is to handle exceptions. The exceptionHandler block enables you to catch specific exceptions and provide a response, including a specific status code.

In this example, we return status code 406 Not Acceptable for IllegalArgumentException:

webHandlers {
endpoint<Trade, Trade>(POST, "insert-trade", status = HttpStatusCode.Created) {
handleRequest {
require(body.date >= DateTime.now()) {
"Trade date cannot be in the past"
}
tradeClient.createTrade(body)
}
exceptionHandler {
exceptionHandler<IllegalArgumentException>(HttpStatusCode.NotAcceptable) {
exception.message ?: "Error handling trade"
}
}
}
}

permissioning

The permissioning block is used to implement access control measures on the data being returned to a user.

permissionCodes

permissionCodes takes a list of permission codes. The client user accessing the query must have access to at least one of the permission codes in the list to be able to access any query data.

permissioning {
permissionCodes = listOf("TradeView", "TradeUpdate")
}

customPermissions

The customPermissions block takes boolean logic. If this function returns true, the user will be able to access the resource; otherwise, the request will be rejected.
It is useful, for example, in the case you have an external API to check against. In this example an entitlementUtils object wraps an external API we can call to check the user's username has access.

customPermissions {
entitlementUtils.userIsEntitled(userName)
}

The customPermissions block also has access to db so that you can query any data in the database. You can add complex logic in here as needed, and have access to the full inbound message.

customPermissions {
val userAttributes = db.get(UserAttributes.byUserName(userName))
userAttributes?.accessType == AccessType.ALL
}

Where utilizing customPermissions you should also consider configuring a customLoginAck so that the front end of the application can also permission its components in a similar way.

requestAuth

requestAuth provides entity-level authorisation on the request input similar to Event Handlers. In the below example, the user will only be able to use the endpoint if they have an auth entry for the instrumentId of the Trade in the request body.

permissioning {
requestAuth(mapName = "ENTITY_VISIBILITY") {
authKey { data.instrumentId }
}
}

Full example:

endpoint<Trade, Trade>(POST, "trades") {
permissioning {
requestAuth(mapName = "ENTITY_VISIBILITY") {
authKey { data.instrument }
}
}

handleRequest {
tradeClient.createTrade(body)
}
}

responseAuth

responseAuth provides entity-level filtering on the response similar to Request Servers. In the below example, the user will only see Trades where the user has an auth entry for the instrumentId of the Trade.

permissioning {
requestAuth(mapName = "ENTITY_VISIBILITY") {
authKey { data.instrumentId }
}
}

Full example:

endpoint(GET, "trades") {
permissioning {
responseAuth(mapName = "ENTITY_VISIBILITY", flow<Trade>()) {
authKey { data.instrumentId }
}
}

handleRequest {
tradeClient.getAllTrades()
}
}

userHasRight

userHasRight is a helpful function which can be called within the auth block, or anywhere else in client facing server components which have a user accessing them, to determine if a user has a given right.

if (!userHasRight(userName, "TradeViewFull")) { ... }

Http status codes

By default, all endpoints return a 200 OK status code.

endpoint override

You can override the http status code for an endpoint.

HttpResponseCode annotation

Use the HttpResponseCode annotation to set the status code for a specific class. This can be especially useful with Kotlin sealed classes, where different subclasses return different status codes.

In the example below, if our endpoint returns SealedResponse, we will return a 200 OK status code for AllGood, and a 404 Not Found status code for Missing:

sealed class SealedResponse {
@HttpResponseCode(HttpStatusCode.Ok)
data class AllGood(val motivation: String) : SealedResponse()

@HttpResponseCode(HttpStatusCode.NotFound)
data class Missing(val sadMessage: String) : SealedResponse()
}

Logging

Genesis provides a class called LOG that can be used to insert custom log messages. This class provides you with 5 methods to be used accordingly: info, warn, error,trade,debug. To use these methods, you need to provide, as an input, a string that will be inserted into the Logs.

Here is an example where you can insert an info message into the log file:

LOG.info("This is an info message")

The LOG instance is a Logger from SLF4J.

note

In order to see these messages, you must set the logging level accordingly. You can do this using the logLevel command.

Client API

tip

To use the API, clients must first authenticate to obtain a SESSION_AUTH_TOKEN by calling EVENT_LOGIN_AUTH.

tip

This API is also accessible via Open API.

REST API

The example below shows an HTTP request to a custom endpoint and the returned HTTP response underneath.

Request

GET /trade-service/trades?trade-id=1 HTTP/1.1
Host: localhost:9064
Content-Type: application/json
SESSION_AUTH_TOKEN: 83eLYBnlqjIWt1tqtJhKwTXJj2IL2WA0

Response

HTTP/1.1 200 OK
content-type: application/json
content-length: 556
connection: keep-alive
{
"TRADE_ID" : 1
"TRADE_PRICE" : 224.34
"DIRECTION" : "BUY"
"QUANTITY" : 1000
"DATE" : 1730678400000
"COUNTERPARTY_ID" : 1
"COUNTERPARTY_CODE" : "GEN"
"COUNTERPARTY_NAME" : "Genesis"
"INSTRUMENT_NAME" : "AAPL"
"NOTIONAL" : 224340
}

OpenAPI

By default, the framework generates a basic OpenAPI specification for all endpoints. This includes the path, and the schemas of the request and response type (if supported). To enable this, use the openapi block to provide additional information, such as descriptions and examples.

We can provide the following information:

  • description
  • summary
  • response
  • requestBody
  • parameters

Description and summary

We can provide a description and summary for our endpoint:

endpoint(GET, "test") {
openapi {
description = "A test endpoint"
summary = "This endpoint is available for testing..."
}
// removed for brevity
}

Response

A schema for the response type used in the endpoint is generated automatically. This schema includes support for sealed types. However, the schema can be customised if needed. You can also provide:

  • examples
  • descriptions
  • additional responses

Here is an example:

endpoint<TestData>(GET, "test") {
openapi {
response {
description = "A test response"
example(TestData("Hello World", 1))
}
response(HttpStatusCode.NotAcceptable) {
noBody()
}
response(HttpStatusCode.NotFound) {
example("Something went missing")
}
}
// removed for brevity
}

The example above defines the standard response of type TestData. There is also an example and a description. We also define that if the status code is 406 Not Acceptable, then there will be no body. Further, if the status code is 404 Not Found, then the body will be a string.

Request body

As with responses, the request body schema is generated automatically. However, this can also be customised.

Similarly, you can provide a description and example. Providing an example for a request body is very useful for testing the endpoint in the OpenAPI UI. The request is ready to go with the example data, which the user can modify to suit their needs.

endpoint<TestData, String>(GET, "test") {
openapi {
requestBody {
description = "A test request"
example(TestData("test", 1))
}
}
// removed for brevity
}

The example above takes TestData as the request body and returns a String. It also provides a description and example for the request body.

Parameters

By default, the framework describes parameters in the OpenAPI spec. However, you can provide additional information, for example:

endpoint<String>(GET, "users") {
openapi {
parameters {
query("userGroup") {
description = "The user group to filter by"
}
}
}
// removed for brevity
}

Runtime configuration

GPAL custom endpoints require no configuration beyond the web-handler.kts file. No modifications to the processes.xml file are required. Files will be picked up automatically by the Genesis Router from the /script folders.

Script modules

If your custom endpoint requires additional dependencies, then these can use the ScriptModules annotation.

For example, to add a dependency on my-module, add this to the top of your file:

@file:ScriptModules("my-module")

This code tries to find your my-module module and add it to the classpath of the script, including all its dependencies. This has the same effect as adding a <module> tag to the processes.xml file, but it works on a script level.

Configure Genesis Router

If you are going to use custom endpoints, it is essential that you configure the Genesis Router.

Here is an example configuration:

router {
webPort = 9064
socketPort = 9065

// rest of file cut for brevity
}

Integration testing

info

GenesisJunit is only available from version 8 of the Genesis Server Framework (GSF).

If you are testing against a previous version of the framework, go to the legacy section.

This document looks at the basics of testing Web Endpoints.

We shall use a simple example and work through the communication between our tests and the Web Endpoint we are testing. This example relies on GenesisJunit, which is designed to make testing easy.

In this example, we shall test the following Web Endpoint. Note, the web handler scripts must exist in your test Genesis Home folder (e.g. src/test/resources/genesisHome/trade-app/src/main/scripts/trade-web-handler.kts).

import global.genesis.trade.TradeInformation
import global.genesis.trade.TradeInformationClient

webHandlers {
val tradeInformationClient = injector<TradeInformationClient>()

endpoint<Unit, TradeInformation>(GET, "trades") {
val tradeId: String by queryParameter("trade-id")

handleRequest {
val tradeInformation = tradeInformationClient.getTradeInformation(tradeId)
LOG.debug("Retrieved trade information: {}", tradeInformation)
tradeInformation
}
}
}

With the below as our test client which represents an external service.

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

class TradeInformationClient {

private val trades = mapOf(
"TR1" to TradeInformation(
tradeId = "TR1",
instrument = "VOD",
price = 20.4,
quantity = 100,
side = "BUY",
),
"TR2" to TradeInformation(
tradeId = "TR2",
instrument = "AAPL",
price = 36.0,
quantity = 200,
side = "BUY",
),
)

fun getTradeInformation(tradeId: String) = trades[tradeId]
}

@Serializable
data class TradeInformation(
@SerialName("TRADE_ID")
val tradeId: String,
@SerialName("INSTRUMENT")
val instrument: String,
@SerialName("PRICE")
val price: Double,
@SerialName("QUANTITY")
val quantity: Int,
@SerialName("SIDE")
val side: String,
)

Preparation

There are some simple steps to follow in order to set up your test.

Create the test class

Create the test class using the code provided below:

import global.genesis.db.rx.entity.multi.AsyncEntityDb
import global.genesis.gen.dao.UserSession
import global.genesis.testsupport.jupiter.GenesisJunit
import global.genesis.testsupport.jupiter.PackageNameScan
import global.genesis.testsupport.jupiter.TestScriptFile
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import javax.inject.Inject

@ExtendWith(GenesisJunit::class)
@TestScriptFile("genesis-router.kts")
@PackageNameScan("global.genesis.router")
class WebEndpointTest {

@Inject
private lateinit var db: AsyncEntityDb

// our tests go here ...
}

This test class does four things:

  • Adds necessary imports.
  • It enables GenesisJunit.
  • It will read the Router script as identified with the TestScriptFile annotation, causing all web handler scripts to be read.
  • Scans the global.genesis.router package with the PackageNameScan annotation to load all relevant Router classes.

There is more information about GenesisJunit and the various annotations in the section on Integration testing.

A first test

Here is a simple first test. It does the following:

  1. Inserts a new user session to simulate a logged-in user as our endpoint is authenticated by default.
  2. Creates an http client to send a request to our endpoint.
  3. Sends a request to our endpoint with the http client.
  4. Verifies the response contains the correct trade information.

Note, by default the endpoint includes the product name in the path. In this example our product is named "trade-service".

@Test
fun testTradeEndpoint() = runBlocking {
db.insert(
UserSession(
userName = "JohnDoe",
sessionId = "1",
sessionAuthToken = "123",
refreshToken = "456",
)
)
val httpClient = HttpClient.newHttpClient()
val request = HttpRequest.newBuilder(URI("http://localhost:9064/trade-service/trades?trade-id=TR1"))
.version(HttpClient.Version.HTTP_1_1)
.header("USER_NAME", "JohnDoe")
.header("SESSION_AUTH_TOKEN", "123")
.GET()
.build()

val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())

assertEquals(
"{\"TRADE_ID\":\"TR1\",\"INSTRUMENT\":\"VOD\",\"PRICE\":20.4,\"QUANTITY\":100,\"SIDE\":\"BUY\"}",
response.body()
)
}

Dynamic authorization

To test dynamic authorization, you need to amend your Web Endpoint definition. Below we are adding authorisation on the endpoint's response.

webHandlers {
val tradeInformationClient = injector<TradeInformationClient>()

endpoint<Unit, TradeInformation>(GET, "trades") {
permissioning {
responseAuth("INSTRUMENTS") {
authKey { data.instrument }
}
}

val tradeId: String by queryParameter("trade-id")
handleRequest {
val tradeInformation = tradeInformationClient.getTradeInformation(tradeId)
LOG.debug("Retrieved trade information: {}", tradeInformation)
tradeInformation
}
}
}

You then need to create your new test cases.

In the code below, there are two tests, which both check the result of the authorization process:

  • In the first test, an auth entry for JohnDoe is added so a trade in the response is expected.
  • In the second test, there is no auth entry for JohnDoe so an empty response is expected.
@ExtendWith(GenesisJunit::class)
@TestScriptFile("genesis-router.kts")
@PackageNameScan("global.genesis.router")
@EnableInMemoryTestAuthCache
class WebEndpointTest {

@Inject
private lateinit var authCache: InMemoryTestAuthCache

@Inject
private lateinit var db: AsyncEntityDb

@Test
fun testTradeEndpoint() = runBlocking {
authCache.authorise(
authMap = "INSTRUMENTS",
entityCode = "VOD",
userName = "JohnDoe"
)
db.insert(
UserSession(
userName = "JohnDoe",
sessionId = "1",
sessionAuthToken = "123",
refreshToken = "456",
)
)
val httpClient = HttpClient.newHttpClient()
val request = HttpRequest.newBuilder(URI("http://localhost:9064/trade-service/trades?trade-id=TR1"))
.version(HttpClient.Version.HTTP_1_1)
.header("USER_NAME", "JohnDoe")
.header("SESSION_AUTH_TOKEN", "123")
.GET()
.build()

val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())

assertEquals(
"{\"TRADE_ID\":\"TR1\",\"INSTRUMENT\":\"VOD\",\"PRICE\":20.4,\"QUANTITY\":100,\"SIDE\":\"BUY\"}",
response.body()
)
}

@Test
fun testTradeEndpointInvalidAuth() = runBlocking {
db.insert(
UserSession(
userName = "JohnDoe",
sessionId = "1",
sessionAuthToken = "123",
refreshToken = "456",
)
)
val httpClient = HttpClient.newHttpClient()
val request = HttpRequest.newBuilder(URI("http://localhost:9064/trade-service/trades?trade-id=TR1"))
.version(HttpClient.Version.HTTP_1_1)
.header("USER_NAME", "JohnDoe")
.header("SESSION_AUTH_TOKEN", "123")
.GET()
.build()

val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())

assertEquals("", response.body())
}
}

Integration testing (legacy)

info

This section covers testing your Web Endpoint if you are using any version of the Genesis Server Framework before GSF v8.

It is good practice to test your Web Endpoints. This is the best way to prevent any unexpected side effects of changes to your application over time.

The Genesis platform provides the AbstractGenesisTestSupport abstract class that enables end-to-end testing of specific areas of your application. First, we will setup our test class as below.

import global.genesis.commons.model.GenesisSet
import global.genesis.gen.dao.UserSession
import global.genesis.testsupport.AbstractGenesisTestSupport
import global.genesis.testsupport.GenesisTestConfig
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse

class WebEndpointTest : AbstractGenesisTestSupport<GenesisSet>(
GenesisTestConfig {
packageNames = mutableListOf("global.genesis.router")
genesisHome = "/genesisHome"
scriptFileName = "genesis-router.kts"
parser = { it }
addAuthCacheOverride("INSTRUMENTS")
}
) {

// our tests go here ...
}

For more information about AbstractGenesisTestSupport, see the Testing pages.

Once you have set up your configuration, we can start writing tests against our Web Endpoint.

Tests with dynamic authorization

  • In the first test, an auth entry for JohnDoe is added so a trade in the response is expected.
  • In the second test, there is no auth entry for JohnDoe so an empty response is expected.
class WebEndpointTest : AbstractGenesisTestSupport<GenesisSet>(
GenesisTestConfig {
packageNames = mutableListOf("global.genesis.router")
genesisHome = "/genesisHome"
scriptFileName = "genesis-router.kts"
parser = { it }
addAuthCacheOverride("INSTRUMENTS")
}
) {

@Test
fun testTradeEndpoint() = runBlocking {
inMemoryTestAuthCache.authorise("INSTRUMENTS", "VOD", "JohnDoe")
entityDb.insert(
UserSession(
userName = "JohnDoe",
sessionId = "1",
sessionAuthToken = "123",
refreshToken = "456",
)
)
val httpClient = HttpClient.newHttpClient()
val request = HttpRequest.newBuilder(URI("http://localhost:9064/trade-service/trades?trade-id=TR1"))
.version(HttpClient.Version.HTTP_1_1)
.header("USER_NAME", "JohnDoe")
.header("SESSION_AUTH_TOKEN", "123")
.GET()
.build()

val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())

assertEquals(
"{\"TRADE_ID\":\"TR1\",\"INSTRUMENT\":\"VOD\",\"PRICE\":20.4,\"QUANTITY\":100,\"SIDE\":\"BUY\"}",
response.body()
)
}

@Test
fun testTradeEndpointNoAuth() = runBlocking {
entityDb.insert(
UserSession(
userName = "JohnDoe",
sessionId = "1",
sessionAuthToken = "123",
refreshToken = "456",
)
)
val httpClient = HttpClient.newHttpClient()
val request = HttpRequest.newBuilder(URI("http://localhost:9064/trade-service/trades?trade-id=TR1"))
.version(HttpClient.Version.HTTP_1_1)
.header("USER_NAME", "JohnDoe")
.header("SESSION_AUTH_TOKEN", "123")
.GET()
.build()

val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())

assertEquals("", response.body())
}
}