Skip to main content

Snapshot queries (Request Server)

Overview

The Request Server is a microservice responsible for providing APIs to request snapshots of an application's data. Developers configure specific data sets as named requests, which can be queried by clients via APIs. Request Servers may sometimes be referred to as a "Request Reply" or "ReqRep" for short.

Genesis Request Servers are defined in the *-reqrep.kts files.

All Request Server queries are available via REST automatically, including Open API conforming spec.

Example configuration

Keeping with the data model examples the following shows example request configurations.

requestReplies {

requestReply(INSTRUMENT)

requestReply(COUNTERPARTY) {
permissioning {
permissionCodes = listOf("CounterpartyView")
}
request {
COUNTERPARTY_ID
COUNTERPARTY_NAME
}
}

requestReply("TRADE", TRADE_VIEW) {
permissioning {
permissionCodes = listOf("TradeView")
auth(mapName = "COUNTERPARTY"){
rowLevelPermissions = true
authKey {
key(data.counterpartyId)
}
}
}
request {
TRADE_ID
}
reply {
TRADE_ID
TRADE_PRICE
DIRECTION
QUANTITY
DATE
COUNTERPARTY_ID
COUNTERPARTY_CODE
COUNTERPARTY_NAME
INSTRUMENT_NAME
NOTIONAL
}

filter {
data.date > DateTime.now().minusDays(30)
}
}
}
INSTRUMENT

The request server name is defaulted as no name is specified.

COUNTERPARTY

Access control permissions are set, only users with CounterpartyView permission are able to access this query and see data. With the INSTRUMENT query above, any user who can authenticate with this app are able to access this query and see data.

TRADE_VIEW

A request server name is specified.

This query is for a view, whereas the previous two were onto tables. This query brings in all the fields defined in the view, which span multiple tables.

We have specified the fields of the view we want to show explicitly, if there are fields in a view/table we don't need we can leave them out of this configuration. Where fields block is not specified all are implicitly included.

The auth block in permissioning utilizes access control for row level permissioning. Only users with access to the COUNTERPARTY_ID will see the row.

filter clause is a server side filter. Subscribing clients may specify criteria to filter data, however this filter applies on the back end and clients may not circumvent.

Summary

A Request Server file consists of a number of queries onto a table or view. All the details of the table or view are inherited from the definition, so you don’t need to supply any further details should you not need filter, apply access control, else limit or extend the set of fields on the view or query.

Configuration options

All requestReply blocks should be added under the parent block requestReplies

requestReplies {

requestReply(...

}

### `requestReply`

`requestReply` defines a new request server query. It takes a `name` (optional) and an `entity` (table or view)

`name` is optional. Left unspecified, it is defaulted `REQ_\{table/view name\}`.

```kotlin
// Name of the request server: REQ_INSTRUMENT
requestReply(INSTRUMENT)

// Name of the request server: REQ_TRADE
requestReply("TRADE", TRADE_VIEW)

request

request lists the fields that a client can request data using.

request is optional. Left unspecified the request fields will be those which make up the primary key of the table (primary key of the root table if the entity is a view)

  requestReply("TRADE", TRADE_VIEW) {
request {
TRADE_ID
}
}

You can also use an index as the request input, this will ensure all the fields which make up the index are available on the request:

  requestReplies {
requestReply("TRADE", TRADE_VIEW) {
request(TRADE.TRADE_BY_COUNTERPARTY_ID_DATE)
}
}

In this example, with reference to data model examples, we would have the following REQUEST inputs available to the client:

  • COUNTERPARTY_ID
  • DATE

Requests would be looked up using that table index.

Picking an index

Request servers will attempt to use the most efficient mechanism to retrieve data from the database. Given the REQUEST fields which are input, and not wild carded, it will cycle through the indices of the table or view used and score accordingly. If it is a full match we give max score to that index, any partial matches are scored and that with the highest score is used.

Audit table recommendations

Audit table request servers are very helpful for requesting all the audit records for a given record, or set of records. Audits can get very large, so having a real-time data server can come with a large overhead due to its caching nature. It is encouraged to instead use request servers for requesting audit rows for of a particular record ID.

All audit tables come with a key *_BY_UNDERLYING_ID which utilizes the primary key of the parent table. It is recommended to use this as the request configuration option for all audit tables. For example:

  requestReply(TRADE_AUDIT) {
request(TRADE_AUDIT.BY_UNDERLYING_ID)
}
withTransformation

Request Server scripts can optionally transform a request parameter’s value using withTransformation. This takes two inputs:

  • the request parameter’s value (which is nullable)
  • the full request message

In the example below, withTransformation is used twice.

  • If the ALTERNATE_TYPE parameter value is null, then the Request Server will use "UNKNOWN" by default.
  • If the ALTERNATE_TYPE parameter has the value "RIC", then the transformation block will use the value of INSTRUMENT_CODE from the request. Otherwise, it will assign it the value "NOT_RIC" before making the database lookup.
requestReplies {
requestReply("INSTRUMENT_DETAILS", INSTRUMENT_DETAILS) {

request {
ALTERNATE_TYPE withTransformation { type, _ ->
type?.toUpperCase() ?: "UNKNOWN"
}
INSTRUMENT_CODE withTransformation { type, set ->
val value = if (set.fields["ALTERNATE_TYPE"].toString().toUpperCase() == "RIC") {
type
} else {
"NOT_RIC"
}
value
} withAlias "ALTERNATE_CODE"
}

reply {
INSTRUMENT_CODE
INSTRUMENT_ID
INSTRUMENT_NAME
ALTERNATE_TYPE
}
}
}

reply

reply lists the fields which will be returned.

reply is optional. Left unspecified all fields which make up the entity will be returned.

  requestReply("TRADE", TRADE_VIEW) {
reply {
TRADE_ID
TRADE_PRICE
DIRECTION
QUANTITY
DATE
}
}

You can override the name of a field using various operators, this is necessary in the case a field name is the same as another table's field name.

  • withAlias <NAME> gives the field an alternative NAME
  • withPrefix adds a prefix to the field name

withFormat <FORMAT_MASK> can also be used to override DATE, DATETIME and numerical fields to be sent as a String in a particular format by using format masks.

derivedField

You can also define derived fields to supplement the fields supplied by the table or view. All fields are available under the data parameter.

In the example below, we add a trade description made up of many fields from the row:

    derivedField("TRADE_DESCRIPTION", STRING) {
data.direction + " " + data.quantity + " " + data.instrumentName + " " + data.tradePrice + " " + data.ctptyName
}
note

Derived fields cannot be used within a filter block.

derivedFieldWithUserName

This is the same as derivedField but also has a context property userName with the username who requested the data.

In the example below, the TRADE_DESCRIPTION will be prefixed with "My Trade " if it was traded by the user querying the data.

    derivedFieldWithUserName("TRADE_DESCRIPTION", STRING) {
val myTrade = if(userName == data.tradedBy) { "My Trade " } else { "" }
myTrade + data.direction + " " + data.quantity + " " + data.instrumentName + " " + data.tradePrice + " " + data.ctptyName
}
note

Derived fields cannot be used within a filter block.

filter

Where specified data is filtered by the query and this supersedes any client criteria specified.

filter requires boolean logic and has access to all fields defined in the query. It has a data property which has access to all fields on the entity

    filter {
data.date > DateTime.now().minusDays(30)
}

Note in this example DateTime.now() will be evaluated for every row, which comes with a small overhead. To stop this, you can define it outside per the example below:

  ...
val today = DateTime.now()
...
...
filter {
data.date > today.minusDays(30)
}
...

filterWithUserName

This is the same as filter but also has a context property userName with the username who requested the data.

In the example below, the entity has a field ASSIGNED_TO which is populated with the user the data is assigned to, in this scenario rows which do not have ASSIGNED_TO set to the user querying the data will be filtered out.

    filter {
data.assignedTo == userName
}

filterWithParameters

This is the same as filter but adding a genesisSet context property that holds the parameters that are passed on the request; the parameters can be accessed by using GenesisSet getters.

In the example below, we will filter out rows where the COUNTERPARTY_NAME is equal to the instrument's name.

  filterWithParameters {                 
genesisSet.getString("COUNTERPARTY_NAME") != data.instrumentName
}

filterWithRequest

Similar to filterWithParameters but the genesisSet context property contains the whole request payload and not just the parameters.

  filterWithParameters {                 
genesisSet.getString("REQUEST.COUNTERPARTY_NAME") != data.instrumentName && data.assignedTo == genesisSet.getString("userName")
}

permissioning

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

tip

With the exception of the inner auth block which relies on inbound data, the permissioning block and its contents can also be placed in the outer block and apply to all query/requestServer/eventHandler blocks named in the file.

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 name has access.

  customPermissions { message ->
entitlementUtils.userIsEntitled(message.userName)
}

The customPermissions block also has access to entityDb 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 { message ->
val userAttributes = entityDb.get(UserAttributes.byUserName(message.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.

auth

auth is used to restrict rows (queries) or events with a particular entity based on a user's entity access, also known as row-level permissions.

permissioning {
auth(mapName = "ENTITY_VISIBILITY") {
authKey {
key(data.counterpartyId)
}
}

Details on restricting query entity access here

Auth definitions can be grouped with “and” or “or” operators.

  • You could have two simple permission maps, for example: one by counterparty and another one for forbidden symbols. If the user wants to see a specific row, they need to have both permissions at once.
  • You could have two permission maps: one for buyer and one for seller. A user would be allowed to see a row if they have a seller or buyer profile, but users without one of the those profiles would be denied access.

This example shows an AND grouping:

permissioning {
auth(mapName = "ENTITY_VISIBILITY") {
authKey {
key(data.counterpartyId)
}
} and auth(mapName = "SYMBOL_RESTRICTED") {
authKey {
key(data.symbol)
}
}
}

This example shows OR grouping:

permissioning {
auth(mapName = "ENTITY_VISIBILITY") {
authKey {
key(data.buyerId)
}
} or auth(mapName = "ENTITY_VISIBILITY") {
authKey {
key(data.sellerId)
}
}
}
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")) { ... }
hideFields

For queries, the auth block also allows a hideFields which can be used to restrict attribute/field/column values being returned to users.

For example, you can hide a column (or set of columns) based on boolean logic, for example if a user does not have a specific Right Code in their profile.

In the example below, the code uses auth and hideFields to check if the user has the Right Code TradeViewFull. If this code is not set for the user, then the column CUSTOMER_NAME will not be returned to the user:

  permissioning {
auth {
hideFields { userName, rowData ->
if (!userHasRight(userName, "TradeViewFull")) listOf(CUSTOMER_NAME)
else emptyList()
}
}
}

rowReturnLimit

You can limit the maximum number of rows returned using the property rowReturnLimit. In this example, we limit it to 10 rows that will be returned.

    requestReply("TRADE", TRADE_VIEW) {
rowReturnLimit = 10
}

timeout

timeout specifies the amount of time, in seconds, that a request server will spend generating a response to a client query before giving up. The setting is useful if a request server is on a large data set and a client has the possibility of setting criteria that takes a long time to process and generate a response for.

In this example, we set a timeout of 15 seconds.

  requestReply("TRADE", TRADE_VIEW) {
timeout = 15
}

If timeout of a request reply uses the following precedence:

  1. the timeout value of the requestReply
  2. the value of the ReqRepTimeout system definition item
  3. else 60 seconds

Where a timeout occurs the server will response with a MSG_NACK with an ERROR[0].CODE value of OPERATION_TIMEOUT

MESSAGE_TYPE = MSG_NACK
SOURCE_REF = "source-ref"
ERROR[0].@type = StandardError
ERROR[0].CODE = OPERATION_TIMEOUT
ERROR[0].TEXT = Server timed out
ERROR[0].STATUS_CODE = 408 Request Timeout

Custom Request Servers

By defining your own Request Servers, you have maximum flexibility.

  • You can specify any class for the input and output, similar to Event Handlers.
  • For the request, optional fields should have a default value in the primary constructor.
  • You cannot use native Kotlin classes. You should wrap these in custom input and output classes.

It is recommended that you locate your classes within the main/kotlin messages folder of your project structure. Ensure that the *-reqrep.kts file you are writing a custom request server within has a dependency on the app-name-app ScriptModule near the top

  @file:ScriptModules("{app-name}-app")

requestReplies {
...
}

The requestReply code block can be as simple or complex as your requirements. They are useful, for example, if you want to request data from a number of different tables and views that are not related. By nesting and joining all the relevant data in your requestReply statement, you create your own metadata for the Request Server, so it can then be used anywhere in the module.

Example syntax:

// the name is optional, if none is provided, then request will be based on the output class, e.g. REQ_OUTPUT_CLASS
requestReply<[InputClass], [OutputClass]> ("{optional name}") {
// permissioning is optional
permissioning {
// multiple auth blocks can be combined with the and operator and the or operator
auth("{map name}") {
// use a single field of output_class
authKey {
key(data.fieldName)
}
// or use multiple fields of output_class
authKey {
key(data.fieldNameA, data.fieldNameB)
}
// or use multiple fields of output_class as well as the username
authKeyWithUserName {
key(data.fieldNameA, data.fieldNameB, userName)
}

// hide fields are supported
hideFields { userName ->
listOf("FIELD_NAME_A")
}

// predicates are supported
filter {
}
}
}

// a reply tag is required; there are three types.
// the reply tag will have a single parameter, the request, which will be of type
// [input class]
// all three have these fields available:
// 1. db - readonly entity database
// 2. userName - the name of the user who made the request
// 3. LOG - logger with name: global.genesis.requestreply.pal.{request name}

// either:
reply { request ->
}

// or:
replySingle { request ->
}

// or:
replyList { request ->
}
}

Types of reply

There are three types of reply that you can use with custom request servers:

  • reply expects a type of Flow<OutputClass> to be returned.
  • replySingle expects a return of the <OutputClass> type that you defined in your requestReply.
  • replyList expects a return of the type List<OutputClass>.

For all of these, you can use the parameter request. In our previous example, the request is of the type InputClass.

Examples

In this example, we define two data classes; Hello and World. We use these to create a Hello World request:

data class Hello(val name: String)
data class World(val message: String)

requestReply<Hello, World>("HELLO_WORLD") {
replySingle { hello: Hello ->
World("Hello ${hello.name}")
}
}

We can also check who made the request by accessing the userName property:

requestReply<Hello, World>("HELLO_WORLD_CHECK") {
replySingle { hello: Hello ->
when (userName) {
hello.name -> World("Hello ${hello.name}")
else -> World("You're not ${hello.name}!")
}
}
}

In this next example, we use generated dao classes to get a single record from the INSTRUMENT_DETAILS table using the ByInstrumentId index. We use the db property to access the entity db.

requestReply<InstrumentDetails.ByInstrumentId, InstrumentDetails> {
replySingle { byId->
db.get(byId)
}
}

Next is a more complex example.

  • The first block checks that the user is authorized to view the instrument.
  • The second block uses the ALT_INSTRUMENT_ID table. The index is used as the input, but we return either a getBulk, a getRange or a get, depending on the input.
requestReply<AltInstrumentId.ByAlternateTypeAlternateCode, AltInstrumentId> {
permissioning {
auth("INSTRUMENT") {
authKey {
key(data.instrumentId)
}
}
}

reply { byAlternateTypeAlternateCode ->
when {
byAlternateTypeAlternateCode.alternateType == "*" ->
db.getBulk(ALT_INSTRUMENT_ID)
byAlternateTypeAlternateCode.alternateCode == "*" ->
db.getRange(byAlternateTypeAlternateCode, 1)
else -> db.get(byAlternateTypeAlternateCode).flow()
}
}
}

In the example below, we have defined a more complicated auth logic:

requestReply<AltInstrumentId.ByAlternateTypeAlternateCode, AltInstrumentId>("FANCY_INSTRUMENT") {
permissioning {
auth("INSTRUMENT") {
authKey {
key(data.instrumentId)
}
filter {
data.alternateType == "FOO"
}
} or auth("ALTERNATE_CODE") {
authKey {
key(data.alternateCode)
}
filter {
data.alternateType == "BAR"
}
}
}
reply { byAlternateTypeAlternateCode ->
db.getRange(byAlternateTypeAlternateCode, 1)
}
}

Helpers assist you to interact with the Kotlin Flow type, which is the return type within the reply block. These helpers are:

  • T.flow() - Converts to the Flow type
  • T.distinct() - Returns a Flow of all distinct values
  • T.distinctBy(selector: (T) -> K?) - Returns a Flow of all distinct values given a selector
  • T.sorted() - Returns a Flow of all sorted values
  • T.sortedBy(selector: (T) -> K?) - Returns a Flow of all sorted values given a selector

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.

Optimistic concurrency

Find details around how request servers work with optimistic concurrency

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.

REQ_<REQUEST_SERVER_NAME>

To query a Request Server, the client sends a MESSAGE_TYPE of REQ_<request server name>.

There are several options that can be optionally specified in the message header:

OptionDefaultDescription
MAX_ROWSrowReturnLimit defined for the target Request ServerMaximum number of rows to be returned as part of the reply message
CRITERIA_MATCHThe front end can send an expression or a Groovy expression to perform filtering on the query server; these remain active for the life of the subscription. For example: Expr.dateIsBefore(TRADE_DATE,'20150518') or QUANTITY > 10000; read more below

Request

{
"MESSAGE_TYPE" : "REQ_TRADE",
"SESSION_AUTH_TOKEN" : "SIlvjfUpkfXtGxfiQk1DDvVRlojKtNIM",
"SOURCE_REF" : "234",
"USER_NAME": "admin"
"REQUEST" : {
"COUNTERPARTY_ID" : 1,
"DATE_FROM" : 1731369600000,
"DATE_TO" : 1730419200000
}
"DETAILS" : {
"MAX_ROWS" : 5,
"CRITERIA_MATCH": "DIRECTION == 'BUY'"
}
}

Response

{
"MESSAGE_TYPE": "REP_TRADE",
"SOURCE_REF": "234",
"REPLY": [
{
"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
},
{
"TRADE_ID" : 2,
"TRADE_PRICE" : 225.12,
"DIRECTION" : "BUY",
"QUANTITY" : 1000,
"DATE" : 1730764800000,
"COUNTERPARTY_ID" : 1,
"COUNTERPARTY_CODE" : "GEN",
"COUNTERPARTY_NAME" : "Genesis",
"INSTRUMENT_NAME" : "AAPL",
"NOTIONAL" : 225120
},
{
"TRADE_ID" : 3,
"TRADE_PRICE" : 583.17,
"DIRECTION" : "BUY",
"QUANTITY" : 500,
"DATE" : 1730851200000,
"COUNTERPARTY_ID" : 1,
"COUNTERPARTY_CODE" : "GEN",
"COUNTERPARTY_NAME" : "Genesis",
"INSTRUMENT_NAME" : "META",
"NOTIONAL" : 291585
},
]
}

In the examples above we request all trades for counterparty with ID 1 with a DATE between 1st October 2024 (1731369600000 milliseconds) and 1st November 2024 (1730419200000 milliseconds). We then filter to only return those which are BUY trades, and will limit the response to 5 trades returned maximum. It returns 3 rows.

CRITERIA_MATCH

The client filtering can also be specified in the CRITERIA_MATCH option. This criteria filtering is performed AFTER data has been fetched from the database.

The filters can be specified using common expressions, Groovy expressions, or even a mix of the two. See more detail in Client capabilities - server communications.

Ranges

A client can specify ranges of data from a Request Server by post-fixing the request parameter names with _FROM and _TO.
The example below shows a client building a GenesisSet request based upon the requestReplies defined from previous example. This example stipulates a price range between 1,000 and 10,000. Ranges can be used with numeric, DATE and DATETIME field types. You must specify both _FROM and _TO to get a result. Specifying only one end will result in an error.

  "DETAILS" : {
"LAST_TRADED_PRICE_FROM": 1_000
"LAST_TRADED_PRICE_TO": 10_000
}
note
  1. Ranges must be based on indexes. Post-fixing the request parameter targeting records without an index, will process the request ignoring the range.
  2. It is not possible to get a range using unix timestamps when targeting a GenesisFlake field. The epoch time (milliseconds) is in the most significant bits of the raw GenesisFlake value, so you need to shift once right to decode into epoch milliseconds (GenesisFlake to Timestamp) and once left to do the same thing for encoding (Timestamp to GenesisFlake). This is described in detail here.

Metrics

info

Ensure you have enabled metrics in your environment to view them.

The Request Server latency metrics show how long it takes for a specific requestReply in the Request Server to process a message.

MetricExplanation
message_processing_latencyThe latency for processing requests

Runtime configuration

To include your *-reqrep.kts file definitions in a runtime process, you will need to ensure the process definition:

  1. Ensure genesis-pal-requestserver is included in module
  2. Ensure global.genesis.requestreply.pal is included in package
  3. Ensure your reqrep.kts file(s) are defined in script
  4. Ensure pal is set in language

If you wish to run a dedicated process for a request server, the following gives an example full process definition:

  <process name="POSITION_REQUEST_SERVER">
<groupId>POSITION</groupId>
<start>true</start>
<options>-Xmx256m -DXSD_VALIDATE=false</options>
<module>genesis-pal-requestserver</module>
<package>global.genesis.requestreply.pal</package>
<script>position-reqrep.kts</script>
<description>Server one-shot requests for details</description>
<language>pal</language>
</process>

See runtime configuration - processes for further details.

Performance

If better database response performance is required, you may also consider added a cache to the process. See runtime configuration - process cache for more details.

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.

Integration testing

This document looks at the basics of testing Request Servers.

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

In this example, we shall test the following Request Server:

data class Hello(
val name: String,
)

data class World(
val message: String,
)

requestReplies {
requestReply<Hello, World>("HELLO_WORLD") {
replySingle { hello: Hello ->
World("Hello ${hello.name}")
}
}
}

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:

@ExtendWith(GenesisJunit::class)
@ScriptFile("hello-world-reqrep.kts")
class RequestServerTest {

// our tests go here ...
}

This test class does three things:

  • It enables GenesisJunit.
  • It identifies the Request Server script that we want to test, using the ScriptFile annotation.

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

Inject references

Here, you need to set up two things:

  • inject a Request Server client so that it can communicate with the Request Server you are testing
  • define your workflow, so that the client knows both the request and the reply type

Use the code below:

@ExtendWith(GenesisJunit::class)
@ScriptFile("hello-world-reqrep.kts")
class RequestServerTest {

@Inject
private lateinit var client: RequestClientSync

private val helloWorldFlow = requestReplyWorkflowBuilder<Hello, World>("HELLO_WORLD")

// our tests go here ...
}

Define the workflow

The syntax for defining the request-reply workflow depends on whether you use Kotlin or Java. However, in both instances you need to provide the input type, the output type and the request name.

In Kotlin, you can build the flow in a method call:

private val helloWorldFlow = requestReplyWorkflowBuilder<Hello, World>("HELLO_WORLD")

In Java, you need to construct an abstract class:

private RequestReplyWorkflow<Hello, World> helloWorldFlow = new AbstractRequestReplyWorkflow<>("HELLO_WORLD") { };

A first test

Here is a very simple first test.

@Test
fun testHelloWorldRequestServer() {
val hello = Hello("John")
val result = client.sendRequest(helloWorldFlow, hello)
assertEquals(1, result.size)
val world = result[0]
assertEquals("Hello, John!", world.message)
}

Providing a user name

As you can see, to send a request, you need to provide both the workflow and the content. You can optionally provide a username as well:

@Test
fun testHelloWorldRequestServerWithUser() {
val hello = Hello("John")
val result = client.sendRequest(helloWorldFlow, hello, "JohnDoe")
assertEquals(1, result.size)
val world = result[0]
assertEquals("Hello, John!", world.message)
}

Dynamic authorization

To test dynamic authorization, you need to amend your Request Server definition:

data class Hello(
val name: String,
)

data class World(
val message: String,
val name: String,
)

requestReplies {
requestReply<Hello, World>("HELLO_WORLD") {
replySingle { hello: Hello ->
World("Hello, ${hello.name}!", hello.name)
}
}

requestReply<Hello, World>("HELLO_WORLD_AUTH") {
permissioning {
auth("NAMES") {
authKey {
key(data.name)
}
}
}

replySingle { hello: Hello ->
World("Hello, ${hello.name}!", hello.name)
}
}
}

You then need to create your new test cases. Note that the authorization for Request Servers is on the outgoing message. The Request Server filters out values to which the requesting user has no access.

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

  • In the first test, if the result is 1 (authorized), then the Hello message is sent (as in the earlier example).
  • In the second test, if the result is 0 (unauthorized), then an empty message is returned.
@ExtendWith(GenesisJunit::class)
@ScriptFile("hello-world-reqrep.kts")
@EnableInMemoryTestAuthCache
class HelloWorldRequestServerTest {
@Inject
private lateinit var client: RequestClientSync

@Inject
private lateinit var authCache: InMemoryTestAuthCache

private val helloWorldFlow = requestReplyWorkflowBuilder<Hello, World>("HELLO_WORLD")

@Test
fun testHelloWorldAuthorised() {
authCache.authorise(
authMap = "NAMES",
entityCode = "John",
userName = "JohnDoe"
)

val hello = Hello("John")
val result = client.sendRequest(helloWorldAuthorisedFlow, hello, "JohnDoe")
assertEquals(1, result.size)
val world = result[0]
assertEquals("Hello, John!", world.message)
}

@Test
fun testHelloWorldNotAuthorised() {
authCache.revoke(
authMap = "NAMES",
entityCode = "John",
userName = "JohnDoe"
)

val hello = Hello("John")
val result = client.sendRequest(helloWorldAuthorisedFlow, hello, "JohnDoe")

assertEquals(0, result.size)
}
}

Different clients

There are three request clients available:

  • RequestClientSync - blocking implementation
  • RequestClientAsync - supports coroutines
  • RequestClientRx - supports rx java

In most cases, the RequestClientSync client will suffice.

Conclusion

At this point we have tested our very simple Request Server. We haven't even had to use the database! However, we have covered the basics of communication between tests and Request Servers.

We have covered:

  • defining request flows
  • sending our request and receiving the response
  • setting the username on the request
  • handling dynamic authorization

Integration testing (legacy)

info

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

It is good practice to test your Request Servers. 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. In this case, we want to ensure that we have a database, seeded with information, and that our Request Server configuration is used to create our Request Server. We also need to add the required packages and genesis home.

class ReqRepTests : AbstractGenesisTestSupport<Reply<*>>(
GenesisTestConfig {
addPackageName("global.genesis.requestreply.pal")
genesisHome = "/GenesisHome/"
scriptFileName = "your-application-reqrep.kts"
initialDataFile = "seed-data.csv"
}
) {
...
}

For more information about AbstractGenesisTestSupport, see the Testing pages.

Once you have set up your configuration, we can start writing tests against our Request Server. Your tests will look a little different, depending on if you are using the standard approach to Request Servers or using custom Request Servers.

Standard Request Servers

Let's run a very simple example. Copy the following into a csv file and save as seed-data.csv in the test Resources folder.

#COUNTERPARTY
COUNTERPARTY_ID,COUNTERPARTY_NAME
testID 1,TestName 1
testID 2,TestName 2

We shall send a message to our Genesis application, pointing at the correct Request Server (making sure to add the REQ_ prefix) and wait for the response. The Genesis platform uses Kotlin coroutines which gives us a degree of non-blocking asynchronous computation. For this reason, we must wrap our tests in a runBlocking coroutine scope as seen below.

class ReqrepTest : AbstractGenesisTestSupport<GenesisSet>(
GenesisTestConfig {
addPackageName("global.genesis.requestreply.pal")
genesisHome = "/GenesisHome/"
scriptFileName = "positions-app-tutorial-reqrep.kts"
initialDataFile = "seed-data.csv"
parser = { it }
}
) {

@Test
fun `can get all counterparty`() = runBlocking {
val request = GenesisSet.genesisSet {
MessageType.MESSAGE_TYPE with "REQ_COUNTERPARTY"
}
val counterparties = sendMessageAsync(request).getArray<GenesisSet>("REPLY")
assertNotNull(counterparties)
assertEquals(2, counterparties.size)
}
}

In the above example, we are asserting that there are two rows within the response from our Request Server. This is based on the two rows of data that we have in seed-data.csv declared earlier.

Custom Request Servers

For custom Request Servers, we must declare a workflow object that matches the custom Request Server that we have declared. This should match the same input and output types for the custom Request Server.

Given a custom Request Server that takes an input of type Hello and returns type World, we pass the same types into the requestReplyWorkflow. We can optionally pass the name of the Request Server to the builder.

object HelloWorldFlow : RequestReplyWorkflow<Hello, World> by requestReplyWorkflowBuilder("HELLO_WORLD")

@Test
fun `can get hello world`() = runBlocking {
val reply = sendRequest(HelloWorldFlow, Hello("Peter")).first()

assert(reply.message == "Hello Peter")
}

If you want to reuse a workflow with the same input and output types, you can use the unary plus overload to change the name of the Request Server being pointed to. In the example below, we reuse HelloWorldFlow and change the Request Server to "HELLO_WORLD_CAPS", a variant of the same Request Server which returns a string in all caps.

@Test
fun `can get hello world`() = runBlocking {
val reply = sendRequest(HelloWorldFlow + "HELLO_WORLD_CAPS", Hello("Peter")).first()

assert(reply.message == "HELLO PETER")
}