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 ofINSTRUMENT_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 alternativeNAME
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.
-
DATE
/DATETIME
format patterns should adhere to the Joda DateTime format -
numerical format patterns should adhere to the Java DecimalFormat
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
}
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
}
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.
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:
- the
timeout
value of therequestReply
- the value of the
ReqRepTimeout
system definition item - 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 yourrequestReply
. - 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
, agetRange
or aget
, 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.
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
To use the API, clients must first authenticate to obtain a SESSION_AUTH_TOKEN
by calling EVENT_LOGIN_AUTH
.
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:
Option | Default | Description |
---|---|---|
MAX_ROWS | rowReturnLimit defined for the target Request Server | Maximum number of rows to be returned as part of the reply message |
CRITERIA_MATCH | The 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 |
- Websocket API
- REST API
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
},
]
}
Request
Input parameters are encoded into the URL per REST and GET convention
GET /req-trade?REQUEST.COUNTERPARTY_ID=1&REQUEST.DATE_FROM=1731369600000&DATE_TO=1730419200000&DETAILS.MAX_ROWS=5&DETAILS.CRITERIA_MATCH=DIRECTION%20%3D%3D%20%27BUY%27 HTTP/1.1
Host: localhost:9064
Content-Type: application/json
SESSION_AUTH_TOKEN: 83eLYBnlqjIWt1tqtJhKwTXJj2IL2WA0
SOURCE_REF: 123
Response
HTTP/1.1 200 OK
content-type: application/json
content-length: 556
connection: keep-alive
{
"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
}
- Ranges must be based on indexes. Post-fixing the request parameter targeting records without an index, will process the request ignoring the range.
- 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
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.
Metric | Explanation |
---|---|
message_processing_latency | The 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:
- Ensure
genesis-pal-requestserver
is included inmodule
- Ensure
global.genesis.requestreply.pal
is included inpackage
- Ensure your reqrep.kts file(s) are defined in
script
- Ensure
pal
is set inlanguage
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
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:
- Kotlin
- Java
@ExtendWith(GenesisJunit::class)
@ScriptFile("hello-world-reqrep.kts")
class RequestServerTest {
// our tests go here ...
}
@ExtendWith(GenesisJunit.class)
@ScriptFile("hello-world-reqrep.kts")
public 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:
- Kotlin
- Java
@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 ...
}
@ExtendWith(GenesisJunit.class)
@ScriptFile("hello-world-reqrep.kts")
public class RequestServerTest {
@Inject
private RequestClientSync client = null;
private RequestReplyWorkflow<Hello, World> helloWorldFlow = new AbstractRequestReplyWorkflow<>("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.
- Kotlin
- Java
@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)
}
@Test
public void testHelloWorldRequestServer() {
var hello = new Hello("John");
var result = client.sendRequest(helloWorldFlow, hello);
assertEquals(1, result.size());
var world = result.get(0);
assertEquals("Hello, John!", world.getMessage());
}
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:
- Kotlin
- Java
@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)
}
@Test
public void testHelloWorldRequestServerWithUser() {
var hello = new Hello("John");
var result = client.sendRequest(helloWorldFlow, hello, "JohnDoe");
assertEquals(1, result.size());
var world = result.get(0);
assertEquals("Hello, John!", world.getMessage());
}
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.
- Kotlin
- Java
@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)
}
}
@ExtendWith(GenesisJunit.class)
@ScriptFile("hello-world-reqrep.kts")
@EnableInMemoryTestAuthCache
public class HelloWorldRequestServerJavaTest {
@Inject
private RequestClientSync client = null;
@Inject
private InMemoryTestAuthCache authCache = null;
private RequestReplyWorkflow<Hello, World> helloWorldAuthorisedFlow = new AbstractRequestReplyWorkflow<>("HELLO_WORLD_AUTH") { };
@Test
public void testHelloWorldAuthorised() {
authCache.builder()
.withAuthMap("NAMES")
.withEntityCode("John")
.withUserName("JohnDoe")
.authorise();
var hello = new Hello("John");
var result = client.sendRequest(helloWorldFlow, hello);
assertEquals(1, result.size());
var world = result.get(0);
assertEquals("Hello, John!", world.getMessage());
}
@Test
public void testHelloWorldNotAuthorised() {
authCache.builder()
.withAuthMap("NAMES")
.withEntityCode("John")
.withUserName("JohnDoe")
.revoke();
var hello = new Hello("John");
var result = client.sendRequest(helloWorldFlow, 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)
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")
}