Skip to main content
Version: Previous

Custom endpoints - advanced

Request context

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 = db.insert(body).record
triggerEvent("NOTIFY_TRADE_CREATED", trade)
trade
}
}

Endpoint paths

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 {
db.getBulk(TRADE)
}
}
}

In the example above, the path would be:

  • my-base-path/all-trades

Adding additional path levels

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

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

In the example above, the paths would be:

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

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 options

SyntaxDescription
prettyPrintDefines that JSON should be pretty printed
propertyCaseDefines the case of JSON properties, either camel case, or snake case

Multipart options

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

Custom type handling

Use parseRequest and composeResponse to define custom type handling. This is useful if the framework doesn't support the required content type, or if you need custom handling of the request or response. These blocks are available within the config block, at each level.

To use these, you must provide two types: the input type, and the output type. Optionally, you can add a content type.

Request parsing

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>(RequestType.PUT, "test") {
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.

Response composing

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>(RequestType.GET, "test") {
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 {
db.get(Trade.ById(body))
}
}

Http status codes

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

Default status code

You can override the default response status by setting the status explicitly:

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

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()
}

Exception handling

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, InsertResult<Trade>>(POST, "insert-trade", status = HttpStatusCode.Created) {
handleRequest {
require(trade.date >= DateTime.now()) {
"Trade date cannot be in the past"
}
db.insert(body)
}
exceptionHandler {
exceptionHandler<IllegalArgumentException>(HttpStatusCode.NotAcceptable) {
exception.message ?: "Error handling trade"
}
}
}
}

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(RequestType.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(RequestType.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(RequestType.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(RequestType.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(RequestType.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(RequestType.GET, "test") {
header("Test-Header", "test")

handleRequest {
"Hello World"
}
}

Secret values

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

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

handleRequest {
"OK"
}
}

OpenAPI

important

Open API support was introduced in version 7.0 of the Genesis platform.

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(RequestType.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>(RequestType.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>(RequestType.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>(RequestType.GET, "users") {
openapi {
parameters {
query("userGroup") {
description = "The user group to filter by"
}
}
}
// removed for brevity
}