Java Control Client

The artifact com.friendly.qa:ft-oneiot-emulator-client is a typed Java client for the control REST API described in REST API. A test engineer calls it from a JUnit test or from the rig instead of writing HTTP requests and parsing JSON by hand.

No version has been published yet. The first planned version is 0.1.0.

What It Is and What It Is Not

The client is a one-to-one typed map of 50 control operations. Each operation is one method. A method sends one request and returns a record, a list, a map, or nothing.

The client has:

  • no waits: it does not poll for a state, you do;

  • no retries: a failed call fails once;

  • no fleet logic: creating and tracking many devices is the job of the test kit.

The client does not log. The caller owns logging.

Coordinates and Resolution

<dependency>
  <groupId>com.friendly.qa</groupId>
  <artifactId>ft-oneiot-emulator-client</artifactId>
  <version>X.Y.Z</version>
</dependency>

The artifact is published to GitHub Packages of the emulator repository:

https://maven.pkg.github.com/Friendly-Technologies/oneiot_emulator

Reading it needs a token with the read:packages scope. Declare the repository in your POM, and a <server> with the same id and the token in your Maven settings.xml.

The artifact needs Java 25 and uses Jackson 3 (tools.jackson.core:jackson-databind). No Jackson type appears in a public signature. The client is built and scanned against Jackson 3.2.3, and its tests also run on the 3.0 line (3.0.4) in CI. If your dependency management holds Jackson at 3.0.x, the client works there, but that line has known vulnerabilities that are fixed only in later lines.

Bind the groupId

com.friendly.qa is the one groupId of every qa-program library. A consumer must bind it to its repositories with Maven’s remote repository filter, so that Maven Central can never serve it. The binding has three parts. One part alone protects nothing.

  1. Switch the filter on in .mvn/maven.config. Without these two lines the files of steps 2 and 3 are not read:

    -Daether.remoteRepositoryFilter.groupId=true
    -Daether.remoteRepositoryFilter.groupId.basedir=${maven.multiModuleProjectDirectory}/.mvn/rrf
  2. Create .mvn/rrf/groupId-<repositoryId>.txt with the single line com.friendly.qa. <repositoryId> is the id you gave the GitHub Packages URL above. This file says what that repository may serve.

  3. Create .mvn/rrf/groupId-central.txt as an allow-list for Maven Central that does not contain com.friendly.qa. This file stops Central from serving the groupId.

If the same groupId is bound to several repositories, Maven asks each of them in turn and you see a 404 line from the ones that do not have the artifact. That is expected.

Creating a Client

EmulatorClient emulator = EmulatorClient.create(URI.create("http://localhost:8556"));

create(URI) uses a connect timeout of 10 seconds and a call deadline of 60 seconds. The base URL must have the scheme http or https and a host, and no query, fragment or user-info. A trailing slash is dropped. A host name with an underscore, such as a compose service name in http://oneiot_emulator:8556, is refused too, because java.net.URI does not read it as a host: use an address, or a name of letters, digits, - and .. Two more authorities java.net.URI cannot parse are refused with the same message: a host label that starts or ends with - (http://-host:8556) and a port that is not a number (http://localhost:85a6). Anything else is an IllegalArgumentException.

The three-argument form takes your own HttpClient and a call deadline:

HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
EmulatorClient a = EmulatorClient.create(URI.create("http://emulator-a:8556"), http, Duration.ofSeconds(120));
EmulatorClient b = EmulatorClient.create(URI.create("http://emulator-b:8556"), http, Duration.ofSeconds(120));

It throws IllegalArgumentException when the HttpClient has no connect timeout, when its redirect policy is not HttpClient.Redirect.NEVER, or when the deadline is zero, negative or longer than Long.MAX_VALUE nanoseconds (about 292 years). A followed redirect would send the request again, with any secret in its body, to another address. Pass your own HttpClient when you need a proxy or a custom SSLContext, and give it a connect timeout and Redirect.NEVER. Redirects are not followed and there is no trust-all option.

  • One client per emulator. An EmulatorClient is immutable and safe to share between threads.

  • One HttpClient for many emulators. create(URI) builds its own HttpClient. When you drive many emulators, create one HttpClient and pass it to every client.

  • Not closeable. The JDK releases the HttpClient when it is no longer reachable.

Deadlines

One deadline covers a whole call: connect, headers and body. When it passes, the exchange is cancelled and the call throws UncheckedIOException. A response body larger than 64 MiB is refused while it is read, with the same exception.

A deadline that passes on the client does not undo what the server already did. Two calls are long:

  • devices().reboot(clientId) blocks for the whole reboot cycle of the device;

  • devices().createBatch(templates) creates its devices synchronously.

For slow devices or large batches, pass a longer deadline to the three-argument create, or make the batch smaller.

The API by Class

EmulatorClient gives six API objects: session(), configFiles(), devices(), storedDevices(), lwm2m() and est(). Every method takes a method and a path from one constant of EmulatorEndpointEnum, which has 50 constants. The tables below list every method with its HTTP method and path.

A path variable (clientId, paramName, protocolName, mtpType) is sent as one percent-encoded path segment, so paramName /3/0/0 travels as %2F3%2F0%2F0. A variable that is null, empty, . or .. is an IllegalArgumentException before any request is sent.

A method that returns nothing accepts any 2xx answer.

A record’s List or Map component is the collection that Jackson built while it read the answer. The records are not deeply immutable: copy a collection before you change it.

SessionApi

Protocol, connection config, transport, lifecycle and logs. 19 operations.

Method HTTP Path Returns

status()

GET

/api/status

EmulatorStatus

protocols()

GET

/api/protocols

List<ProtocolInfo>

availableProtocols()

GET

/api/protocols/available

List<String>

selectProtocol(protocolName, keepOptions)

POST

/api/protocol/{protocolName}?keepOptions=

nothing

config()

GET

/api/config

ConnectionConfig

updateConfig(config)

POST

/api/config

nothing

updateRuntimeOptions(config)

POST

/api/config/runtime

nothing

mtp()

GET

/api/mtp

MtpConfig

mtpPorts(mtpType)

GET

/api/mtp/{mtpType}/ports

List<String>

selectMtp(mtpType)

POST

/api/mtp/{mtpType}

List<String> (the ports of that type)

protocolOptions()

GET

/api/protocolOptions

Map<String, Object>

updateProtocolOptions(options)

POST

/api/protocolOptions

nothing

init()

POST

/api/init

nothing

start()

POST

/api/start

nothing

stop()

POST

/api/stop

nothing

reset()

POST

/api/reset

nothing

logs(since)

GET

/api/logs?since=

LogsPage

clearLogs()

POST

/api/logs/clear

LogsPage

version()

GET

/api/version

Version

ConfigFileApi

The device-tree files (.xml) of the configuration directory, and the selection among them. 3 operations, 4 methods: select and clearSelection are two methods over the same endpoint.

Method HTTP Path Returns

list()

GET

/api/configFiles

List<String>

active()

GET

/api/activeConfigFile

ActiveConfigFile (active, effective, defaultFile)

select(filename)

POST

/api/activeConfigFile

nothing

clearSelection()

POST

/api/activeConfigFile

nothing (sends {}: back to the protocol’s default tree)

select throws IllegalArgumentException before any request for a null or blank name, or a name with / or \. The server drops a directory part and would select another file than the one you named.

While the emulator is initialized or started, the server refuses both select and clearSelection with 400 EMULATOR_NOT_STOPPED. A name that does not end in .xml answers 400 BAD_REQUEST; a file that does not exist answers 400 CONFIG_FILE_NOT_FOUND.

DeviceApi

The devices of the running emulator. 18 operations.

Method HTTP Path Returns

list()

GET

/api/devices

List<Device> (parameters is null in the list)

get(clientId)

GET

/api/devices/{clientId}

Device, with its parameters

create(device)

POST

/api/devices

Device; the device is also stored

createBatch(templates)

POST

/api/devices/batch

List<Device>; the devices are also stored. Refuses a template that holds the mask (see Secrets read back as ***)

startNew(template)

POST

/api/devices/startNew

Device; the device is also stored. Refuses a template that holds the mask (see Secrets read back as ***)

start(clientId)

POST

/api/devices/{clientId}/start

nothing

stop(clientId)

POST

/api/devices/{clientId}/stop

nothing

delete(clientId)

DELETE

/api/devices/{clientId}

nothing

updateServer(clientId, server)

PUT

/api/devices/{clientId}/server

nothing

heartbeat(clientId)

POST

/api/devices/{clientId}/heartbeat

nothing

heartbeatAll()

POST

/api/devices/heartbeat

nothing

reboot(clientId)

POST

/api/devices/{clientId}/reboot

nothing

bootstrap(clientId)

POST

/api/devices/{clientId}/bootstrap

nothing

parameters(clientId)

GET

/api/devices/{clientId}/parameters

List<Parameter>

setParameter(clientId, paramName, parameter)

PUT

/api/devices/{clientId}/parameters/{paramName}

nothing

parameterHistory(clientId, paramName)

GET

/api/devices/{clientId}/parameters/{paramName}/history

ParameterHistory

autoupdate(clientId, paramName)

GET

/api/devices/{clientId}/parameters/{paramName}/autoupdate

AutoupdateConfig

setAutoupdate(clientId, paramName, config)

POST

/api/devices/{clientId}/parameters/{paramName}/autoupdate

nothing

startNew creates and starts a stand-alone device. create, createBatch and startNew all store their devices, see create, createBatch and startNew store their devices. startNew and createBatch throw IllegalArgumentException before any request for a template that holds the mask *** in one of the four fixed secrets or as any protocolOptions value, whatever its key, see Secrets read back as ***.

StoredDeviceApi

The devices the emulator stores on disk. 4 operations.

Method HTTP Path Returns

list()

GET

/api/storedDevices

List<DeviceTemplate> (the secrets are ***; set them again before you send a template, see Secrets read back as ***)

start(clientId)

POST

/api/storedDevices/{clientId}/start

Device

delete(clientId)

DELETE

/api/storedDevices/{clientId}

nothing

clear()

DELETE

/api/storedDevices

nothing

Lwm2mApi

LwM2M fleet operations. 2 operations, 3 methods: deregister and deregisterWithDetails are two methods over the same endpoint.

Method HTTP Path Returns

deregister()

POST

/api/lwm2m/deregister

nothing

deregisterWithDetails()

POST

/api/lwm2m/deregister?details=true

DeregisterResult (the ids that were deregistered)

update()

POST

/api/lwm2m/update

nothing

deregister and update answer 409 when there is nothing to act on: NO_RUNNING_LWM2M_DEVICE.

EstApi

Certificate enrolment (EST) of one device. 4 operations.

Method HTTP Path Returns

status(clientId)

GET

/api/devices/{clientId}/est

EstStatus

enroll(clientId)

POST

/api/devices/{clientId}/est/enroll

EstStatus

reenroll(clientId)

POST

/api/devices/{clientId}/est/reenroll

EstStatus

options(clientId, options)

POST

/api/devices/{clientId}/est/options

nothing

On an emulator without EST configured, enroll and reenroll answer 409 with a plain-text body, and options answers 400 for an option the emulator does not know. These answers have no JSON code, so EmulatorApiException.code() is null for them.

Worked Example: Start a Session

This is the start sequence a test runs: select the protocol, set options and config, initialize, wait until the emulator reports initialized, then start. The client has no wait, so the example uses Awaitility; the client does not depend on it.

import com.friendly.qa.emulator.oneiot.api.EmulatorClient;
import com.friendly.qa.emulator.oneiot.error.EmulatorApiException;
import com.friendly.qa.emulator.oneiot.model.ConnectionConfig;
import com.friendly.qa.emulator.oneiot.model.Device;
import com.friendly.qa.emulator.oneiot.model.DeviceTemplate;

import java.net.URI;
import java.time.Duration;
import java.util.Map;

import static org.awaitility.Awaitility.await;

EmulatorClient emulator = EmulatorClient.create(URI.create("http://localhost:8556"));

// 1. Start from a clean state. stop() answers 409 NOT_INITIALIZED when nothing is initialized.
try {
    emulator.session().stop();
} catch (EmulatorApiException e) {
    if (e.status() != 409 || !"NOT_INITIALIZED".equals(e.code())) {
        throw e;
    }
}

// 2. Protocol and protocol options. false = do not keep the options set so far.
emulator.session().selectProtocol("LWM2M", false);
emulator.session().updateProtocolOptions(Map.of("bootstrap", "false"));

// 3. Config: change what you need and keep the rest (see "Partial config updates").
ConnectionConfig config = emulator.session().config().toBuilder()
        .serverIp("127.0.0.1")
        .serverPort(5683)
        .clientsCount(1)
        .build();
emulator.session().updateConfig(config);

// 4. Initialize and wait for the state.
emulator.session().init();
await().atMost(Duration.ofSeconds(30))
        .until(() -> Boolean.TRUE.equals(emulator.session().status().initialized()));

// 5. Start, then read the device.
emulator.session().start();
Device device = emulator.devices().list().get(0);
System.out.println(device.clientId());

A stand-alone device is created from a template. DeviceTemplate has a builder:

Device standalone = emulator.devices().startNew(DeviceTemplate.builder()
        .clientId("meter-001")
        .serverIp("127.0.0.1")
        .serverPort(5683)
        .build());

Errors

A call that the emulator answers, but not with what you asked for, throws EmulatorApiException. That means a status outside 2xx, or a 2xx answer whose declared body is absent or unreadable. It is an unchecked exception with five accessors:

Accessor Value

method()

The HTTP method sent, for example POST

path()

The path sent, percent-encoded, for example /api/start

status()

The HTTP status, for example 409

code()

The server’s error code, for example NOT_INITIALIZED. Null unless the body carried a code made of A-Z, 0-9 and _, at most 64 characters

serverMessage()

The server’s free text, at most 2000 characters, or null when the body carried none

The message has exactly this format, and the bracketed part is left out when code() is null:

<METHOD> <path> answered <status> [<code>]

For example:

POST /api/start answered 409 [NOT_INITIALIZED]

The server’s text is not in the message, and neither is the request body. An emulator error body can echo the request, a config body carries secrets, and exception messages end up in test reports that can leave the company. Read serverMessage() when you need the text.

The emulator answers errors in several shapes: a JSON error body, a plain-text body (the EST operations), an empty body (reboot, bootstrap, some EST operations) and Spring’s default error object for a path no handler owns. The client reads a JSON object as the error body and a text/plain body as serverMessage(). In every other case code() and serverMessage() are null; status() is always set.

Other failures:

  • UncheckedIOException when no answer arrives: the connection is refused, the deadline passes, the body is too large or an I/O error occurs. The JDK cause is attached. When an I/O error ends the call, the message is <METHOD> <path> failed, with the path as sent (percent-encoded, so a line break in a path variable cannot forge a line of a log), and the original IOException is the cause. An interrupt restores the thread’s interrupt flag and is thrown as an UncheckedIOException that wraps InterruptedIOException.

  • IllegalArgumentException, before any request, for a bad path variable (null, empty, . or ..), a bad base URL, an HttpClient or a deadline that create refuses (see Creating a Client), a bad file name given to ConfigFileApi.select, or a template that holds the mask (see Secrets read back as ***).

  • NullPointerException, before any request, when an operation that takes a body is given null. The message is body.

Server Behaviour a Caller Must Know

stop and reset need an initialized emulator

start(), stop() and reset() answer 409 NOT_INITIALIZED when nothing is initialized. The typed call throws; a call that ignored the status would not have noticed. stop() on a started session also un-initializes it, so status().initialized() is false afterwards.

Partial config updates

updateConfig leaves a null component unchanged, with one exception: a request that omits delaySec sets the delay to 0, because the server applies delaySec whenever it reads >= 0 and an omitted number reads 0. Always start from the current config:

emulator.session().updateConfig(
        emulator.session().config().toBuilder().serverIp("10.0.0.1").build());

setAutoupdate has the same trap: the booleans autoupdatable, global and randomizeStepType read as false when omitted. Start from autoupdate(…​).toBuilder().

Secrets read back as ***

The emulator masks secrets on read: a stored device’s keys and the connection config’s keys and passwords come back as ***. Device parameters are not masked by the emulator at all.

The connection config can be read with config() and sent back unchanged: a masked or omitted secret keeps the stored value.

A stored-device template cannot be sent with the mask in it. The client refuses the mask in these places:

  • one of the four fixed secrets: pskKey, authKeyStorePass, trustStorePass, aliasPass;

  • any protocolOptions value equal to the mask, whatever its key.

devices().startNew(template) and devices().createBatch(templates) throw IllegalArgumentException before any request is sent, and a batch is refused whole. The message names the component, and in a batch the index of the template; it holds neither the mask nor the client id. A null element of a batch is not checked: it is sent as JSON null and the server’s answer is what you get.

Why the client refuses: the mask is what the server returns instead of a secret, and the client cannot tell a real value *** from a mask that was read back.

What the server would do with the mask differs by place:

Where the mask sits What the server does

One of the four fixed secrets

Drops the value. The device has no key of its own and runs on the session’s, and its stored entry loses the key.

A protocolOptions value under a secret option key

Drops the value, as for a fixed secret.

A protocolOptions value under an option key that is not secret

Stores the literal ***.

The client is deliberately stricter than the server for protocolOptions: it refuses the mask under any key, because the server decides which option keys are secret from its own per-protocol properties, which the client cannot evaluate. A real option value that is the literal *** is therefore refused as well.

To restart a template taken from storedDevices().list(), set its secrets again with toBuilder():

DeviceTemplate listed = emulator.storedDevices().list().get(0);
emulator.devices().startNew(listed.toBuilder().pskKey(pskKey).build());

DeviceTemplate.maskedSecret() returns the name of the component that holds the mask, or an empty Optional when the template can be sent as it is.

A second deregister sends nothing

lwm2m().deregister() deregisters every LwM2M device in the emulator, including one already deregistered. It answers 409 NO_RUNNING_LWM2M_DEVICE only when the emulator holds no LwM2M device. After a first deregister the devices stay in the emulator, so a second call answers 204 and sends nothing to the server.

A stored device starts only after a restart

Inside one emulator process a stored device is also a running one. storedDevices().start(clientId) therefore throws 400 DEVICE_START_FAILED while the device exists in that process, whether running or stopped. It succeeds after the emulator restarts over the same configuration directory.

create, createBatch and startNew store their devices

devices().create(…​), devices().createBatch(…​) and devices().startNew(…​) store the devices they create. session().reset() keeps the stored devices, and session().init() creates every stored device again. A test that creates devices therefore leaves them for the next session. storedDevices().clear() is the clean-up between sessions; devices().delete(clientId) removes one device and its stored entry, see Deleting a device also deletes its stored entry.

Deleting a device also deletes its stored entry

devices().delete(clientId) removes the device from the store too. storedDevices().delete(clientId) and clear() remove stored entries only; running devices stay.

A config-file selection survives a protocol switch, not a restart

The selection made with configFiles().select(…​) stays when you call selectProtocol, also across protocols. It is kept in memory only, so a restart of the emulator clears it. In active(), the property for the protocol’s default file is defaultFile.

Secrets in Test Output

A failed assertion prints the objects it compares, and that text goes into a test report. The model records mask their secrets in toString():

  • A component whose lower-cased name contains pass, psw, secret, key or token prints as ***. This also masks authKeyStorePath; the rule errs on the side of masking.

  • DeviceTemplate.protocolOptions prints its keys only.

  • Parameter.value, ParameterHistory.Entry.value, LogEntry.payload, LogEntry.rawMessage, LogEntry.messageOptions, and the server’s message and details in an error body print as ***. A device parameter can hold a private key, and the emulator does not mask parameters.

  • The Lombok builders print their type name only.

One thing toString() cannot stop: AssertJ’s recursive comparison prints the component values directly, without calling toString(). Compare extracted non-secret fields instead:

assertThat(device.clientId()).isEqualTo("meter-001");
assertThat(device.standalone()).isTrue();

Operations Not Covered

The emulator has 60 control operations; the client covers 50. These 10 are left out on purpose.

Method and path Reason

GET /api/config/serverOptions

Dropdown content for the Web GUI.

GET /api/transportConfig

Dropdown content for the Web GUI.

GET /api/rule

The JavaScript rule the emulator runs on every parameter update. A rule is script text that the emulator persists to rules.js of its configuration directory, so one test’s rule outlives its session. No consumer scenario of the program needs it today. Device behaviour a test does need is set through parameters and autoupdate, which are covered. Covering it later costs two endpoint constants, one record and two methods.

POST /api/rule

Sets the rule. Same reason as GET /api/rule.

GET /api/rule/help

GUI help text of the rule editor.

POST /api/acs/extract

Reads an ACS database with credentials in the request. It is not a test-control call.

POST /api/snapshot/save

GUI session snapshot.

POST /api/snapshot/load

GUI session snapshot.

POST /api/uploadConfigFile

Changes the files of the configuration directory (multipart upload). An image ships its device trees, and a test selects among them with configFiles().

POST /api/deleteConfigFile

Changes the files of the configuration directory (delete). Same reason as the upload.

A rule posted by one test is saved to rules.js and stays after the session ends. That is the reason the client does not offer the rule operations.

Versioning and Release

The client has its own semantic version, starting at 0.1.0. A release is a tag oneiot-emulator-client-vX.Y.Z on a commit of the dev branch.

A client version is verified against the emulator commit its tag points at. The emulator image built from that commit is its matching pair. Use that pair together.

How the contract is pinned

Three test classes in the gui test tree (com.friendly.emulator.gui.api.contract) tie the client to the controllers. They run in the emulator build, with no mock of the server.

  • ControlClientEndpointInventoryTest checks that every /api handler of the controllers is either a constant of EmulatorEndpointEnum or on the not-covered list above, and that nothing else is.

  • ControlClientModelParityTest checks that every client record has the same JSON property names and types as the server class it mirrors.

  • ControlClientLiveContractTest starts the emulator as a child process and calls every one of the 50 operations through the client, checking the raw answers against the records and the effect of the calls on a local LwM2M registrar.

A developer who adds, renames or removes a controller path, or changes a field of a DTO the client uses, sees one of these classes fail in the gui build. The fix is to change the client in the same commit, or, for a new operation the client does not need, to add it to the not-covered list with a reason.