Skip to content

Module Authoring Guide

This guide walks through building a CloudStub service module from scratch. At the end you will have a working module that integrates with the core engine via ServiceLoader, registers its stubs through StubRegistrar, and is tested with real AWS SDK v2 clients.

Use cloudstub-sqs and cloudstub-secretsmanager as reference implementations throughout.


1. Choose the right protocol

Every AWS service uses one of three wire protocols. Your module implements whichever one its service uses.

Protocol Services Matched on StubRegistrar method
JSON / X-Amz-Target SQS, Secrets Manager, DynamoDB X-Amz-Target request header registerJsonTargetStub
XML / Form URL SNS (legacy SDK v1) Action form body parameter registerXmlFormStub
REST path S3, Lambda HTTP method + URL path regex registerRestStub

Check the AWS SDK v2 source or Smithy model for your target service to confirm which protocol it uses.

REST-path modules share one URL matching space. When more than one is loaded (for example S3 and Lambda in one standalone server), their path patterns compete, and S3's catch-all object patterns (/[^/]+/.+) overlap most other services' paths. The engine picks the most specific match: a stub's priority is PRIORITY_BASE - literalLength(pattern), so the pattern with more literal characters wins. Anchor your patterns with a literal path prefix (Lambda uses /2015-03-31/functions/...) so they outscore a generic catch-all rather than relying on registration order.


2. Create the Gradle subproject

cloudstub-myservice/
├── build.gradle
└── src/
    ├── main/
    │   ├── java/io/cloudstub/myservice/CloudStubMyServiceService.java
    │   └── resources/META-INF/services/io.cloudstub.core.spi.CloudStubService
    └── test/
        └── java/io/cloudstub/myservice/CloudStubMyServiceTest.java
// cloudstub-myservice/build.gradle
dependencies {
    compileOnly project(':cloudstub-core')      // SPI only, not bundled in the module JAR
    testImplementation project(':cloudstub-core')
    testImplementation 'software.amazon.awssdk:myservice:2.25.70'
}

compileOnly keeps cloudstub-core off the module's runtime classpath. The core engine loads the module at runtime, not the other way around.

Register the new subproject in settings.gradle:

include 'cloudstub-myservice'

3. Implement CloudStubService

package io.cloudstub.myservice;

import io.cloudstub.core.spi.CloudStubService;
import io.cloudstub.core.spi.StubRegistrar;

public class CloudStubMyServiceService implements CloudStubService {

    @Override
    public String serviceId() {
        return "myservice"; // (1)!
    }

    @Override
    public void register(StubRegistrar registrar) {
        registrar.registerJsonTargetStub(
            "MyService.DescribeWidget",  // (2)!
            DESCRIBE_WIDGET_RESPONSE
        );
        // register one stub per operation
    }

    private static final String DESCRIBE_WIDGET_RESPONSE = """
        {"Widget":{"WidgetId":"{{jsonPath request.body '$.WidgetId'}}","Status":"ACTIVE"}}
        """; // (3)!
}
  1. Lowercase identifier used in logging and fault injection annotations (e.g. @SimulateThrottle(service = "myservice")).
  2. The full X-Amz-Target header value. Find it in the SDK's generated request class or by capturing a real SDK call with a proxy.
  3. A Handlebars template. The engine evaluates it at request time for each incoming call.

4. Write response templates

Templates are Handlebars strings evaluated per request. CloudStub provides helpers on top of WireMock's built-in set.

Built-in WireMock helpers

{{randomValue type='UUID'}}               → fresh UUID per request
{{jsonPath request.body '$.FieldName'}}   → extract a field from the JSON request body
{{now}}                                   → current timestamp

CloudStub custom helpers

{{md5 'some string'}}                     → MD5 hex digest (used for SQS message checksums)
{{md5 (jsonPath request.body '$.Body')}}  → MD5 of an extracted request field

Example: echo back an identifier

// Request body: {"QueueName": "my-queue"}
// Response:
private static final String CREATE_QUEUE = """
        {"QueueUrl":"http://localhost/000000000000/{{jsonPath request.body '$.QueueName'}}"}
        """;

Example: generate a stable ARN

// Request body: {"Name": "my-secret"}
private static final String CREATE_SECRET = """
        {
          "ARN": "arn:aws:myservice:us-east-1:000000000000:widget/{{jsonPath request.body '$.Name'}}",
          "Name": "{{jsonPath request.body '$.Name'}}",
          "VersionId": "{{randomValue type='UUID'}}"
        }
        """;

Validate your templates

Use the WireMock standalone JAR locally to test Handlebars templates interactively before adding them to your module. The {{jsonPath}} syntax is the same.


5. Register via META-INF/services

Create the file src/main/resources/META-INF/services/io.cloudstub.core.spi.CloudStubService containing the fully qualified class name of your implementation:

io.cloudstub.myservice.CloudStubMyServiceService

This is standard Java ServiceLoader registration. When the module JAR is on the classpath, CloudStub.start() discovers and calls register() automatically.


6. Write the module test

Test your module by driving real AWS SDK v2 clients against a live CloudStub instance. The goal is to verify that the SDK can parse your responses without error, not to reproduce AWS semantics.

class CloudStubMyServiceTest {

    static CloudStub cloudMock;
    static MyServiceClient client;

    @BeforeAll
    static void start() {
        cloudMock = new CloudStub().withService(new CloudStubMyServiceService()); // (1)!
        cloudMock.start();
        client = MyServiceClient.builder()
            .endpointOverride(URI.create("http://localhost:" + cloudMock.port()))
            .credentialsProvider(AnonymousCredentialsProvider.create())
            .region(Region.US_EAST_1)
            .build();
    }

    @AfterAll
    static void stop() {
        client.close();
        cloudMock.stop();
    }

    @Test
    void describeWidgetReturnsNonNullId() {
        DescribeWidgetResponse response = client.describeWidget(b -> b.widgetId("w-123"));
        assertNotNull(response.widget().widgetId());
    }
}
  1. Use .withService() in module tests rather than relying on ServiceLoader. Classpath structure inside the Gradle multi-project build may prevent auto-discovery during module-level test runs.

7. Module isolation

A module must not depend on another module at compile or runtime. All inter-module calls go through the core SPI. This is enforced by Gradle:

// This will fail the build:
dependencies {
    implementation project(':cloudstub-sqs')   // ← forbidden in a service module
}

testImplementation is exempt: integration tests may combine multiple modules.


8. Exposing REST API routes

CloudStubService registers the AWS wire-protocol stubs your module serves on the mock port. A module may also expose a small REST surface under /api/<serviceId>/… by implementing the optional CloudStubApiService SPI. Each route you register advertises a command name and parameters in /api/status. A command-line client that turns each route into a <serviceId> <command> subcommand automatically is coming soon.

CloudStubApiService depends only on core SPI types (no WireMock, no AWS SDK, no picocli). Handlers return an ApiResponse(statusCode, body) whose body map is serialised to JSON. Parameters arrive as query-string values via ApiRequest.queryParams(); the request body is not read.

package io.cloudstub.myservice;

import io.cloudstub.core.spi.CloudStubApiService;
import io.cloudstub.core.spi.HttpMethod;
import io.cloudstub.core.spi.restapi.ApiParam;
import io.cloudstub.core.spi.restapi.CloudStubApiContext;

import java.util.List;
import java.util.Map;

public class CloudStubMyServiceApiService implements CloudStubApiService {

    @Override
    public String serviceId() {
        return "myservice"; // must match CloudStubService.serviceId()
    }

    @Override
    public void registerRoutes(CloudStubApiContext context) {
        // context.stateStore() is the same store the module's stubs use; read/write it here to
        // return live data instead of synthetic responses.
        var r = context.registrar();
        r.register(
            HttpMethod.POST,                                  // HTTP method
            "/describe-widget",                               // path under /api/myservice
            "describe-widget",                                // command name
            "Describe a widget",                              // help text
            List.of(new ApiParam("id", true, "Widget id")),  // params
            req -> new io.cloudstub.core.spi.restapi.ApiResponse(200, Map.of(
                "id", req.queryParams().getOrDefault("id", ""),
                "status", "ACTIVE")));
    }
}

Register it alongside the stub service with a second ServiceLoader file, src/main/resources/META-INF/services/io.cloudstub.core.spi.CloudStubApiService:

io.cloudstub.myservice.CloudStubMyServiceApiService

Now POST /api/myservice/describe-widget?id=w-123 works against any standalone instance that has the module loaded; a command-line client to drive it is coming soon. Routes for a service that is not enabled with --services are not registered, keeping the stub view and the API view consistent.

State-backed or synthetic: your call

Handlers receive the shared StateStore via context.stateStore(), so they can read and write the same data as the module's stubs (use the same key scheme on both surfaces so they can't drift). CloudStubSqsApiService does this: a message sent through the AWS SDK is returned by its receive-message route. A handler that ignores the store simply stays synthetic.


Reference implementations

Module Protocol used Reference for
cloudstub-sqs JSON / X-Amz-Target Header-matched stubs, UUID + MD5 helpers, array responses
cloudstub-secretsmanager JSON / X-Amz-Target ARN construction, nested JSON responses
cloudstub-sns XML / Form URL Action-matched stubs, XML responses
cloudstub-s3 REST path HTTP method + path-regex stubs, XML responses
cloudstub-lambda REST path Literal-prefixed path-regex stubs (coexist with S3)

For CloudStubApiService (§8), CloudStubSqsApiService, CloudStubS3ApiService, and CloudStubSecretsManagerApiService are the reference implementations.