Java SDK
Java SDK reference to build your own generators.
Define a supplier and a generator
The minimum setup is to have one supplier with one generator. Those can be defined as follows:
@SupplierName("Billing")
public class Supplier2 extends Supplier {
public static void main(String... args) {
new Supplier2()
.withGenerators(
new InvoiceGenerator(),
new LoanGenerator())
.bootstrap();
}
}
@ItemName("Invoice")
public class InvoiceGenerator extends Generator {
public record InvoiceRequest(Language language,
Integer amountToBill,
Boolean extendedTerm,
String vatId) {
}
public enum Language {English, French}
public record Invoice(String invoiceId) {
}
public Invoice generate(InvoiceRequest input) {
var randomId = String.valueOf(System.currentTimeMillis() % 1000);
return new Invoice(randomId);
}
}
It is required that the supplier extends Supplier
and that the generator extends Generator
.
The annotation @SupplierName
is mandatory and uniquely identifies the supplier on a given environment. It is case-sensitive.
The name of the item (in the example above it's Invoice
) uniquely identifies the item in scope of a given supplier on a given environment.
Bootstrap the supplier
To bootstrap the supplier and all declared generators, you can use in plain Java:
public class WhateverClass {
public static void main(String... args) {
new BillingSupplier().withGenerators(new InvoiceGenerator()).bootstrap();
}
}
or in Spring:
@Component
public class WhateverClass {
@PostConstruct
public void init() {
new BillingSupplier().withGenerators(new InvoiceGenerator()).bootstrap();
}
}
Sixpack SDK launches a separate set of threads to operate and handles failures with a back-off strategy. It will cause application restart in following conditions:
- Cannot connect to the Sixpack platform at startup (later losing the connection will only trigger reconnection with a back-off strategy)
- Some other critical error occurs (out of memory or similar)
Detailed description of a generator
Generator inputs and outputs
Any generator input is an object with any number of fields that must be of type String
, Integer
, Long
, Short
, Boolean
, enum
. The inputs define how will look like the specification form on the UI and what inputs will be accepted on the REST interface. Types are mapped as follows:
String
- text input with possible null valueInteger
,Long
,Short
- number input with possible null valueBoolean
- 3-state checkbox (true, false, null)enum
- dropdown with possible null value
Any generator output is a similar object. In more complex cases an output of one generator can be used as input of another generator. That is why the field types must follow the same rules.
The objects used as input or output can be of any type including Java records with following limitations:
- They must be serializable by Jackson (and you may use related annotations).
- They must have a flat structure (no nested objects).
- Only allowed field types, see above.
Method generate
The method generate is mandatory. It performs the required operation to produce a dataset and returns necessary information, usually just a subset of the dataset. It should handle properly null fields in the input by e.g. generating random values as per required business logic. It must:
- Not override
generate
fromGenerator
- Be public
- Have a first argument representing the input
- Return an object representing the output
- Complete within 30s or call
heartbeat()
at least every 30s It may have: - A second argument of type Context see below for more details.
Iterative generation
Instead of directly returning the output object, the method generate can end by calling haltAndIterateAfter(Duration)
or haltAndIterateAfter(duration, message)
. In that case the generate method is halted at that point and will be restarted from the beginning after the specified timeout. Along with the initial input a second parameter of the method generate will hold any arbitrary contextual data that has been stored by the previous iteration. The optional message passed to haltAndIterateAfter
will be displayed on the UI as a progress message.
This allows to implement simple asynchronous scenarios such as polling some system for an expected result before continuing with the test dataset construction. Along with the user specified contextual data, Sixpack provides some built-in information such as the index of the iteration.
Example in which:
- The first iteration does some initial setup
- Next iterations check for some condition every 5 minutes and only then return the output
@ItemName("Loan")
public class WhateverClass {
public Loan generate(LoanRequest loanRequest, Context context) {
if (context.getIteration() == 0) {
// ...
context.set("someKey", myVariable);
haltAndIterateAfter(Duration.ofMinutes(10));
}
var myVariable = context.get("someKey");
// ...
if (dataNotYetReady) {
haltAndIterateAfter(Duration.ofMinutes(5));
}
return new Loan(...);
}
}
Note: In more complex scenarios involving several generators a better option is available with orchestrators which or generators that are able to request and consume data from other generators.
Method configure
It can be used to configure a prebuilt dataset just before it is allocated to the user. In cases when creating a dataset takes time but adding some label e.g. tester's name is a fast operation, that operation can be done in this configure
method only once it is clear which tester will get that dataset.
This method is optional. If not defined, not configuration will be done.
Method cleanup
This method is optionally called after a defined period of time when the dataset was allocated to a tester. The time can be defined in the annotation @Lifespan
. By the default the lifespan is 30 days. It allows to perform any cleanup opeations e.g. delete the dataset from the test environment.
This method is optional. If not defined, no cleanup will be done.
Method templates
This method is optionally used to define a list of templates and their minimum count to be always maintained in stock. When some datasets take time to produce and / or their production is not stable, specified datasets returned by this method will be requested upfront so that they are already available when a tester requests them. The templates define the input combinations. The structure to return is a list of templates where a template is a pair of input and minimum count.
For example:
public class InvoiceGenerator extends Generator {
(...)
public List<Template> templates() {
return List.of(
new Template(
new InvoiceRequest(Language.English, null, null, null),
10),
new Template(
new InvoiceRequest(Language.French, null, null, null),
5));
}
}
This will tell Sixpack to always try to maintain at least 10 invoices in English and 5 invoices in French in stock.
In case the method templates
is not defined, Sixpack will compute templates automatically by creating all combinations of fields of type enum
and Boolean
and will try to maintain at least 1 for each such combination. If you would like not to pre-generate datasets, just return an empty list from templates
.
Orchestrator
To be able to combine or chain the output of generators, it is possible to use an orchestrator. It behaves almost the same as a generator with following differences:
- Must be deterministic
- Can use the method
obtain
to request data from other generators
Bootstrap with an orchestrator
@SupplierName("Billing")
public class BillingSupplier extends Supplier {
public static void main(String... args) {
new BillingSupplier()
.withOrchestrators(
new InvoiceOrchestrator())
.bootstrap();
}
}
public class InvoiceOrchestrator extends Orchestrator {
public record InvoiceRequest(Language language,
Integer amountToBill,
Boolean extendedTerm,
String vatId) {
}
public enum Language {English, French}
public record CustomerRequest(Language language){}
public record Customer(String customerId) {}
public record Invoice(String customerId,
String invoiceId) {}
public Invoice generate(InvoiceRequest input) {
var customer = obtain(new CustomerRequest(input.language()));
var randomId = String.valueOf(System.currentTimeMillis() % 1000);
return new Invoice(customer.customerId(),randomId);
}
}
The method obtain behaves similarly to dataset request on the UI or via REST so in case the requested dataset is in stock, it's returned immediately, otherwise it is requested ad-hoc to the corresponding generator or orchestrator. Yes orchestrators can obtain data from other orchestrators making it very easy to compose complex datasets using simple native code.
Determinism
Determinism is important for technical reasons and is a downside of allowing to have plain code representing your business logic in the orchestrator. Being deterministic means that it:
- Must not access external systems directly, only through generators with the
obtain
method- This includes accessing a database or local file system
- Must not use time and random functions directly but only through provided helper methods
Following happens under the hood:
- Each method call of the method
obtain()
anywhere in the code causes the code to be replayed from the beginning once theobtain
method returns. - During the replay phase, methods
obtain()
and any of the provided deterministic methods are skipped with previous results injected. - In case you use a non-deterministic method such as Instant.now(), it will be called again with a different result.
The impact is anything between:
- No impact: your code uses the value of the non-deterministic method only once
- Full failure: some if switch decides based on the output of your non-deterministic method and will cause the replay phase to fail because the history of calls does not match current order of calls --> the whole orchestrator task will be abandoned and restarted a few times before a final failure.
Helper methods for time and random functions
The SDK provides equivalents of main time and random functions so that they can be used in the orchestrator in the class Deterministic
. For example
deterministicNow()
instead ofInstant.now()
deterministicZonedNow(ZoneId zoneId)
deterministicRandomInt(int lowerBound, int upperBound)
- ...