Imported from cloudstub/cloudstub (
.claude/skills/start-generating/SKILL.md). Install upstream withnpx skills add cloudstub/cloudstub --skill start-generating. Copyright stays with the author.
Implement a cloudstub-<service> module so it behaves like the real AWS service, driven by a GitHub
issue number. The goal is always full stateful fidelity, never a bare stateless scaffold: the
Smithy model/codegen only gives the operation surface and stateless placeholder templates; the
state-backed behavior is always hand-written. Do not ask which level of finish.
cloudstub-sqs is the working reference to copy patterns from. Adapt the design to the service;
mirror SQS's level of finish, not its literal structure.
How to run this skill (compliance rules — do these, not just the steps)
A skill is guidance, not a guarantee: it is easy to silently reinterpret or skip a step. These three rules make that impossible to do quietly.
- Checklist first. Before writing any code, create a task list (TaskCreate) with one item per
step below (0–8), and keep it updated: mark each
in_progresswhen you start it andcompletedwhen its evidence exists. The reader can then see, at any moment, which steps ran. - Deviation rule. If you skip a step, change its approach, or find it does not apply, stop and say so, with the reason, before continuing — do not proceed and justify it later only if asked. "Codegen can't scaffold this service" is a valid deviation; deciding that silently is not.
- Evidence, not claims. Every "verify" action (Step 8, and the build after each code change)
is done by running the command and showing its real output (the
BUILD SUCCESSFUL/PASS:/ test-count lines). A step is not complete because you intended it; it is complete because its command passed.
Documentation style
Every doc you write (javadoc, comments, reference docs) describes only actual behavior: what it does, parameters, contracts, concrete "not simulated" caveats. No narrative: no project history, no issue-number storytelling, no "generated by codegen" provenance, no cross-service references, no marketing ("reference implementation"). No em-dashes in prose docs (use commas/colons/parentheses). Never name LocalStack in CloudStub's own docs/issues.
Step 0 — Read the issue and branch
gh issue view <N> --repo cloudstub/cloudstub
git checkout -b feature/<service>-module # from main; never commit feature work to main
Read the issue's acceptance criteria and subtasks. Do not trust the issue's stated protocol —
it is copied from a template and has been wrong before (it claimed X-Amz-Target for both SQS and
Lambda; Lambda is actually REST path). Confirm the protocol from the model in Step 2, and pick
the reference module that matches the real protocol (cloudstub-s3 for REST path, cloudstub-sns
for XML/Query, cloudstub-sqs/cloudstub-secretsmanager for JSON/X-Amz-Target).
Step 1 — Study the reference
Read cloudstub-sqs: CloudStubSqsService, CloudStubSqsApiService, SqsKeys, SqsHelpers, both
test classes, and build.gradle. Read the core SPI: StubRegistrar, StubHandler, StubRequest
(note jsonField returns scalars only), StubResponse, StateStore, Json, Digest, and the
restapi/* types. These are the only types a module may use besides the JDK.
Step 2 — Get the model, confirm the protocol, then generate the scaffold
2a. Pick the right model file. A service can have several models in api-models-aws (Lambda has
lambda, lambda-core, lambda-microvms — only lambda has the full API). List them and choose
the one whose service has the most operations / actually contains the core ops:
gh api repos/aws/api-models-aws/contents/models --jq '.[].name' | grep -i <service>
gh api repos/aws/api-models-aws/contents/models/<dir>/service/<version> --jq '.[] | "\(.name)\t\(.download_url)"'
2b. Confirm the protocol and the operation surface from the model itself (do not trust the
issue). Download it and inspect, or run codegen --validate which prints the detected protocol and
operation count:
./gradlew :cloudstub-codegen:run --args="--model <raw-url> --validate"
Protocol → registrar method: restJson1/restXml → REST path (registerRestStub, reference
cloudstub-s3); awsJson1_0/awsJson1_1 → registerJsonTargetStub (reference cloudstub-sqs);
awsQuery/ec2Query → XML/form (registerXmlFormStub, reference cloudstub-sns).
2c. Codegen only sees service.operations — it does NOT traverse Smithy resources. Operations
bound to a resource's lifecycle (create/read/update/delete/list/put) or its nested
operations are silently omitted from the scaffold. Check whether the model is resource-structured
before relying on codegen:
python3 -c "import json,sys; m=json.load(open('<model.json>')); s=m['shapes']; svc=[k for k,v in s.items() if v.get('type')=='service'][0]; print('service.operations',len(s[svc].get('operations',[])),'| resources',len(s[svc].get('resources',[])),'| total op shapes',sum(1 for v in s.values() if v.get('type')=='operation'))"
If total op shapes >> service.operations (e.g. Lambda: 85 vs 19 with 10 resources), codegen's
scaffold is incomplete and misleading — it will miss the core CRUD/invoke ops. In that case do
NOT use the scaffold as the base: hand-author the operations from the model's http traits
(traits."smithy.api#http" → method, uri, code) so paths and status codes match AWS exactly.
Announce this deviation (compliance rule 2) and proceed.
2d. Generate the scaffold (skip only when 2c showed it is incomplete — say so if you skip):
./gradlew :cloudstub-codegen:run --args="--model <raw-url> --output <scratch>/gen --core-version <current-version>"
<current-version> is the version= in gradle.properties. The scaffold gives the service class
(operations registered as template stubs), one .hbs per operation, and a response/ builder
package. Keep it as reference; you will re-author most of it. Codegen only ever emits stateless
placeholders — the state-backed behavior in Step 3 is always hand-written regardless.
Step 3 — Hand-finish the module (the real work)
Create the module under cloudstub-<service>/src/main/java/io/cloudstub/<service>/.
- Service class
CloudStub<ACRONYM>Service(match the acronym casing of siblings:CloudStubSNSService,CloudStubS3Service). Wire each operation to the real protocol: JSON/X-Amz-Target uses the target prefix (e.g.DynamoDB_20120810.); REST path uses the model's method +uriper operation (e.g.POST /2015-03-31/functions). Register the resource/CRUD operations asStubHandlers that read/write the sharedStateStorekeyed under<service>/; keep the rest asStubTemplates.load(...)template stubs for breadth so SDK calls do not error. Delete the templates for ops you converted; drop the generatedresponse/builders (dead code, unused by handler-based modules). REST-path note:registerRestStubuses WireMockurlMatching, which is anchored to the full URL (path + query) — write patterns that match the whole URL (allow an optional(\\?.*)?query suffix) and rememberreq.path()excludes the query. Extract path parameters (e.g. the resource name) fromreq.path(). <Service>Keys— the state-store key scheme, defined once and shared by the service and the API surface so they cannot drift (mirrorSqsKeys, including a marker-key vs sub-resource-key test).- Protocol helpers as needed.
StubRequestexposes onlyjsonField(scalar). For nested request bodies (DynamoDBItem/Key, etc.) write a small JDK-only JSON parser in the module — modules cannot see core's shaded jackson. For XML/Query add form/XML helpers (seeSnsForm/SnsXml). Store parsedMap/Listtrees directly;StateStoreround-trips concrete JDK types. - Error + response conventions. Build responses with
StubResponse.json(Json.object(...)). For AWS JSON errors returnStubResponse.json(status, Json.object("__type", "<namespace>#<Error>", "message", ...)); the SDK maps__type. Enforce realistic errors (missing resource → NotFound, duplicate → InUse).
Step 4 — API service (lights up REST + CLI + console)
Add CloudStub<ACRONYM>ApiService implementing CloudStubApiService, sharing <Service>Keys with
the state stubs (state-backed: the same data the AWS stubs read/write). Register routes with a
command name + params so /api/status drives the CLI. Read-oriented routes are fine when the
resource is a typed structure that does not map to query params. Register both SPIs:
src/main/resources/META-INF/services/io.cloudstub.core.spi.CloudStubService
src/main/resources/META-INF/services/io.cloudstub.core.spi.CloudStubApiService
Step 5 — build.gradle (deps + manifest)
dependencies {
compileOnly project(':cloudstub-core')
testImplementation project(':cloudstub-core')
testImplementation libs.aws.<service>
}
// Declares the minimum cloudstub-core this module's SPI usage requires; core warns if it is older.
jar {
manifest {
attributes('CloudStub-Core-Min-Version': '<current-version>')
}
}
Ensure cloudstub-<service> is in settings.gradle and in serviceModules in
gradle/modules.gradle. The isolation guard forbids depending on any other cloudstub-* module.
Step 6 — Tests (the definition of done is a real consumer)
- Service test (
CloudStub<ACRONYM>ServiceTest): boot embeddednew CloudStub().withService(...), drive the real AWS SDK v2 client, assert state-backed round-trips (write then read), plus a raw protocol-match test (an X-Amz-Target header for JSON services, or a raw HTTP path request for REST services) and a template-op smoke (assertDoesNotThrow) if you kept any template stubs. - Persistence test:
withStoreDirectory(tempDir), write, restart, assert the data survived. - Add a regression test for any parsing edge case you hand-rolled.
Step 7 — Full deliverable set (what "complete like SQS" means)
- Standalone wiring in
cloudstub-local/build.gradle: add the module tointegrationTestModuleJars, addtestImplementation libs.aws.<service>, and add a state-backed round-trip toLocalIntegrationTest(subprocess,--services=...,<service>). - Spring example in
cloudstub-example/junit6:implementation libs.aws.<service>+testImplementation project(':cloudstub-<service>'); a<Service>ConfigextendingAwsClientSupport; a consumer service (test the reader's own code, not the mock's response); a@Profile("<service>")demo runner; a@SpringBootTest+CloudStubExtensiontest. - smoke.sh: add
<service>to the default--serviceslist and a section exercising the protocol + a REST cross-surface assertion. - Docs:
docs/gh-pages/services/<service>.md(Overview, Standalone usage with CLI/curl tabs, Test example, REST API access, Supported operations table, Limitations), wired intomkdocs.ymlnav and theservices/index.mdcard. - CLAUDE.md: flip the module's row to
Doneand update the "Current state" / "Work remaining" lines.
Step 8 — Verify (run these, do not just claim)
./gradlew spotlessApply && ./gradlew build # all unit tests + spotlessCheck
./gradlew :cloudstub-<service>:test
./gradlew :cloudstub-local:integrationTest --tests '*LocalIntegrationTest'
./.claude/skills/run-cloudstub/smoke.sh --build # standalone, all protocols + REST
Run each command and show its real output (compliance rule 3) — a green BUILD SUCCESSFUL and
the PASS: lines from the smoke run, not a summary. Then run /code-review on the diff and fix real
findings before finishing. To validate the real distribution path (auto-download of the published
module jar), run /test-local <service>.
Gotchas learned
- Codegen ignores Smithy
resources. It enumerates onlyservice.operations; lifecycle- and resource-bound operations are silently dropped from the scaffold. For resource-structured services (Lambda) the core ops are missing — hand-author from the model'shttptraits (see Step 2c). - The issue's stated protocol can be wrong (template-copied). Confirm it from the model; it was wrong for SQS and Lambda.
- Modules cannot import jackson/WireMock/AWS SDK/picocli; only the core SPI + JDK. Hand-roll a JSON parser for nested bodies.
jsonField(path)returnsnullfor objects/arrays (scalars only); dotted paths into objects work for scalar leaves.registerRestStubmatching is anchored to the full URL (path + query) viaurlMatching;req.path()excludes the query. Allow an optional(\\?.*)?suffix in patterns.StubResponse.jsonuses theapplication/x-amz-json-1.1content type; the SDK parses by operation model, so this works for json-1.0 and REST-json services too (SQS/S3 precedent).- Publishing is a separate gated step: only add the module to
publishedServices+ apomInfoentry once validated (see/test-local). - Never manually
git tagor editversion=; releases go through./gradlew release -PreleaseVersion=....