A test suite whose tests each need a record of their own can create it through Traffic Parrot with mappings and templates alone: the test's setup posts the fields it cares about, a template merges them onto a defaults file, stores the result with dataSource INSERTs and answers with the key, and the ordinary mappings serve the record for the rest of the test. This page is a recipe: copy the files below into an installation, rename the entity, and edit. It keeps customers, each with a list of addresses, in a database keyed by an eight-digit reference. A second entity type that refers to the first, such as a relation between two customers, is the same files again, with the two references as foreign keys.
The files, in the order the sections below show them:
Every file is edited in the installation directory or in the web console: the mappings on the HTTP Add/Edit page, and the templates as files. The recipe was proven on a release build, against H2 in memory and against PostgreSQL.
The same files, ready to copy: download the example and unzip it into the installation directory, where its folders match the paths above (an installation that already lists database connections keeps its own file and takes the example.db entry); put the JDBC driver jar in lib/external and start Traffic Parrot. Copying the files into place is the whole installation. There is no import step for a bundle like this: the HTTP mapping import takes mappings alone, not templates or database connections.
The tables, and a sequence for the references of records a test creates without choosing one. On a database server, run this when the database is created, as a drop, create and seed script does; the H2 in-memory connection below runs it on every connection, so it creates only what is missing:
CREATE TABLE IF NOT EXISTS customer_record (
reference CHAR(8) PRIMARY KEY CHECK (reference ~ '^[0-9]{8}$'),
given_name VARCHAR(80) NOT NULL,
family_name VARCHAR(80) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active'
);
CREATE TABLE IF NOT EXISTS customer_address (
id SERIAL PRIMARY KEY,
customer_reference CHAR(8) NOT NULL REFERENCES customer_record (reference) ON DELETE CASCADE,
kind VARCHAR(20) NOT NULL DEFAULT 'home',
line_1 VARCHAR(120) NOT NULL,
city VARCHAR(80) NOT NULL,
postcode VARCHAR(20) NOT NULL
);
CREATE SEQUENCE IF NOT EXISTS customer_reference_seq START WITH 90000000 MAXVALUE 99999999;
The connection in database-connections.json, here H2 in memory with the script above saved as sql/test-data.sql and the driver jar in lib/external as for any JDBC database. DB_CLOSE_DELAY=-1 keeps the database alive between calls, because each dataSource call opens its own connection, and INIT runs the script on each of them. For a database server, use its own JDBC URL and leave INIT out:
[
{
"connectionId": "example.db",
"type": "JDBC_CONNECTION",
"driverClass": "org.h2.Driver",
"jdbcUrl": "jdbc:h2:mem:example;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;INIT=RUNSCRIPT FROM 'sql/test-data.sql'",
"properties": {
"user": "sa",
"password": ""
}
}
]
templates/test-data/customer-defaults.json, a plain JSON file with every field a test may leave out:
{
"givenName": "Alex",
"familyName": "Example",
"status": "active",
"addresses": [
{
"kind": "home",
"line1": "10 Default Road",
"city": "Exampleton",
"postcode": "EX9 9ZZ"
}
]
}
The create mapping, POST /test-data/customers. Its matchesJsonSchema guard admits a JSON object only, with a reference of exactly eight digits when one is sent and an object for each address (the template completes a partial one, so the schema asks for nothing more), and its response body is a template file:
{
"request": {
"method": "POST",
"urlPath": "/test-data/customers",
"bodyPatterns": [
{
"matchesJsonSchema": {
"type": "object",
"properties": {
"reference": {"type": "string", "pattern": "^[0-9]{8}$"},
"addresses": {
"type": "array",
"items": {"type": "object"}
}
}
}
}
]
},
"response": {
"status": 201,
"headers": {
"Content-Type": "application/json"
},
"body": "{{> templates/test-data/customer-create}}"
}
}
The template, templates/test-data/customer-create.hbs. It takes the reference from the request or, when none was sent, from the sequence; completes each address the request sends from the defaults' first address, field by field, so a test sends only the fields it cares about ({"city": "Worktown"} is read back as the default address with the city replaced, and {} as the default address whole); merges the request onto the defaults with jsonMerge (objects merge field by field, and the completed list replaces the default list whole: an empty list stores no address, an omitted one keeps the default); then binds each field to its column in two INSERTs, the record and its addresses as one batch. An INSERT renders nothing when it succeeds and its error otherwise, so each is read into a variable and the template answers 500 with modifyResponse when either fails, never 201. The answer is the reference on one line, so a test can read it back with sed:
{{#trim}}
{{!-- reference, recordError and addressesError are used as they are (a key, and "did it fail?"),
so ~ drops the line breaks and indentation beside their values, and a dataSource call keeps
its line breaks inside the tag, where they render nothing. --}}
{{#assign 'reference'~}}
{{#if (jsonPath request.body '$.reference')~}}
{{jsonPath request.body '$.reference'}}
{{~else~}}
{{dataSource 'example.db' "SELECT nextval('customer_reference_seq')" single=true default=''}}
{{~/if~}}
{{/assign}}
{{#if reference}}
{{#assign 'referenceOnly'}}{"reference": "{{reference}}"}{{/assign}}
{{#assign 'defaults'}}{{> templates/test-data/customer-defaults.json}}{{/assign}}
{{#assign 'defaultAddress'}}{{toJson (jsonPath defaults '$.addresses[0]')}}{{/assign}}
{{#assign 'addresses'}}
{{#if (jsonPath request.body '$.addresses')}}
{"addresses": [
{{#each (jsonPath request.body '$.addresses')}}
{{#unless @first}},{{/unless}}
{{jsonMerge defaultAddress (toJson this)}}
{{/each}}
]}
{{else}}
{}
{{/if}}
{{/assign}}
{{#assign 'customer'}}
{{jsonMerge (jsonMerge (jsonMerge defaults request.body) addresses) referenceOnly}}
{{/assign}}
{{#assign 'recordError'}}{{dataSource 'example.db'
'INSERT INTO customer_record (reference, given_name, family_name, status) VALUES (:reference, :givenName, :familyName, :status)'
reference=reference
givenName=(jsonPath customer '$.givenName')
familyName=(jsonPath customer '$.familyName')
status=(jsonPath customer '$.status')}}{{/assign}}
{{#unless recordError}}
{{#assign 'addressesError'}}{{dataSource 'example.db'
'INSERT INTO customer_address (customer_reference, kind, line_1, city, postcode) VALUES (:reference, :kind, :line1, :city, :postcode)'
reference=reference
kind=(jsonPath customer '$.addresses[*].kind')
line1=(jsonPath customer '$.addresses[*].line1')
city=(jsonPath customer '$.addresses[*].city')
postcode=(jsonPath customer '$.addresses[*].postcode')}}{{/assign}}
{{/unless}}
{{#if recordError}}
{{modifyResponse 'statusCode' 500}}
{
"error": "Could not store customer '{{reference}}'; the Traffic Parrot log names the database error",
"reference": "{{reference}}"
}
{{else if addressesError}}
{{modifyResponse 'statusCode' 500}}
{
"error": "Stored customer '{{reference}}' but not its addresses; the Traffic Parrot log names the database error",
"reference": "{{reference}}"
}
{{else}}
{{!-- One line, so a test can keep the reference with a single read. --}}
{"reference": "{{reference}}"}
{{/if}}
{{else}}
{{modifyResponse 'statusCode' 500}}
{
"error": "Could not allocate a reference from customer_reference_seq; the Traffic Parrot log names the database error"
}
{{/if}}
{{/trim}}
A reference that already exists answers 409 and never reaches the create: a mapping with a higher priority (a lower number) whose request matcher script, templates/test-data/customer-exists.hbs, looks the reference up and matches only when it renders true; the ~ on each tag drops the line breaks beside it, so the script renders exactly true or false. The database's primary key is the backstop: a create that slips past answers 500, not a silent overwrite.
{{!-- A request matcher reads this output, which must be exactly true or false,
so each ~ drops the line breaks and indentation beside it. --}}
{{~#assign 'reference'}}{{jsonPath request.body '$.reference'}}{{/assign~}}
{{#if (dataSource 'example.db'
'SELECT reference FROM customer_record WHERE reference = :reference'
reference=reference
single=true
default='')~}}
true
{{~else~}}
false
{{~/if}}
{
"priority": 1,
"request": {
"method": "POST",
"urlPath": "/test-data/customers",
"bodyPatterns": [
{
"matchesJsonSchema": {
"type": "object",
"required": ["reference"],
"properties": {
"reference": {"type": "string", "pattern": "^[0-9]{8}$"}
}
}
}
],
"customMatcher": {
"name": "requestMatchers",
"parameters": {
"matchesScript": {
"script": "{{> templates/test-data/customer-exists}}",
"type": "handlebars"
}
}
}
},
"response": {
"status": 409,
"headers": {
"Content-Type": "application/json"
},
"body": "{{> templates/test-data/customer-exists-response}}"
}
}
with templates/test-data/customer-exists-response.hbs holding the body, {"error": "Customer '{{jsonPath request.body '$.reference'}}' already exists"}.
Reading it back is an ordinary mapping, GET with urlPattern /customers/[0-9]{8}, whose template templates/test-data/customer-by-reference.hbs answers 404 for an unknown reference and an empty list for a customer without addresses, with the zero rows defaults. Each column is written with toJson, which quotes and escapes it, so a stored value with a quote in it (a name such as Jo "JJ") comes back as valid JSON:
{{#trim}}
{{#assign 'reference'}}{{request.pathSegments.[1]}}{{/assign}}
{{#with (dataSource 'example.db'
'SELECT reference, given_name, family_name, status FROM customer_record WHERE reference = :reference'
reference=reference
singleRow=true
default=false)}}
{
"reference": {{toJson reference}},
"givenName": {{toJson given_name}},
"familyName": {{toJson family_name}},
"status": {{toJson status}},
"addresses": [
{{~#each (dataSource 'example.db'
'SELECT kind, line_1, city, postcode FROM customer_address WHERE customer_reference = :reference ORDER BY id'
reference=reference
default=(array))}}
{
"kind": {{toJson kind}},
"line1": {{toJson line_1}},
"city": {{toJson city}},
"postcode": {{toJson postcode}}
}{{#unless @last}},{{/unless}}
{{~/each}}
]
}
{{else}}
{{modifyResponse 'statusCode' 404}}
{
"error": "No customer '{{reference}}'"
}
{{/with}}
{{/trim}}
A record lives until the database is recreated: the JDBC data source runs SELECT, INSERT and UPDATE, not DELETE, so the reset stays the drop, create and seed script (a restart, for H2 in memory), and a test never reuses a reference. Before a suite relies on it:
The objectStore helper stores each record as a JSON file and serves it back with get, and the same merge, 409 and 404 shapes apply; the priority-1 mapping is still needed, because create on an existing id renders its error into the body rather than refusing, and the files survive a restart, so a reset means deleting them outside Traffic Parrot.
These templates are near the limit of what Handlebars alone does well: the create is two dozen lines that a reader has to trace, and each new rule adds a branch or a merge. Signs that a jar compiled against the SDK is the cheaper route: a create that must be one transaction across two tables, validation beyond what a JSON schema and a regular expression express, or more than a handful of entity types, each with its own defaults, exists check and read. A custom HTTP response transformer keeps the same endpoints and answers and moves the merge and the INSERTs into Java, where they are ordinary code with a unit test; the recipe above is then the specification it replaces. How to build and install one: write your own extension.