diff --git a/docs/lessons/0001-the-clientprotocol-contract.html b/docs/lessons/0001-the-clientprotocol-contract.html new file mode 100644 index 000000000..3aab62653 --- /dev/null +++ b/docs/lessons/0001-the-clientprotocol-contract.html @@ -0,0 +1,217 @@ + + +
+ + +ClientProtocol ContractBefore you can add a protocol, you need to know exactly what a protocol is in this framework. It's one interface with six methods. Learn them, and you have the skeleton you'll flesh out for the rest of the track.
+ + + +A protocol is the thing that knows how to turn a typed input shape into a transport request, and a transport response back into a typed output shape. That's it. It is the translator between "Java objects the user holds" and "bytes on the wire."
+ +In code, that's a single interface, generic over the request and response types it produces:
+public interface ClientProtocol<RequestT, ResponseT> {
+ ShapeId id(); // which protocol trait am I?
+ Codec payloadCodec(); // how do I encode bodies?
+ MessageExchange<RequestT,ResponseT> messageExchange();
+
+ RequestT createRequest(operation, input, context, endpoint); // shape → request
+ RequestT setServiceEndpoint(request, endpoint); // attach where to send
+ O deserializeResponse(operation, ctx, errors, req, resp); // response → shape
+}
+ Source (verbatim signatures): client/client-core/.../client/core/ClientProtocol.java
RequestT/ResponseT? Because a protocol isn't tied to HTTP. The type parameters let an HTTP protocol work with HttpRequest/HttpResponse while leaving the door open for other transports. The MessageExchange marker is how the pipeline checks a protocol and a transport actually speak the same request/response types before wiring them together.Split them into three that describe the protocol and three that do the work:
+ +| Method | Job | Think of it as… |
|---|---|---|
id() | Returns the protocol trait's ShapeId, e.g. smithy.protocols#rpcv2Cbor. | "My name." The join key to the Smithy model. |
payloadCodec() | Returns the Codec used to encode/decode bodies (JSON, CBOR, XML…). | "My encoder." Often a single shared static instance. |
messageExchange() | Declares the request/response pair (e.g. HTTP). | "What transport I'm compatible with." |
createRequest(...) | Build the transport request from the operation + input shape. Serialize the body, set method/path/headers. | The outbound half. |
setServiceEndpoint(...) | Merge the resolved endpoint (host/path) into the request. | The "where to send it" step. |
deserializeResponse(...) | Turn the response into the output shape — or throw a modeled/unmodeled error. | The inbound half. |
HttpClientProtocol already gives you id(), messageExchange(), and setServiceEndpoint(). You're left writing the two interesting ones — createRequest and deserializeResponse — plus pointing payloadCodec() at a codec. We'll prove this in Lesson 2.Here's where the six methods sit in the life of a single client.call(input, operation). The protocol methods are highlighted; the rest is pipeline machinery you get for free.
client.call(input, operation) with a generated input shape.protocol.createRequest(operation, input, ctx, endpoint) → serializes input through payloadCodec() into a RequestT.protocol.setServiceEndpoint(request, endpoint) attaches host + base path.ResponseT.protocol.deserializeResponse(operation, ctx, errors, req, resp) → output shape, or a thrown error.This mirrors the call-pipeline sketch in the Knowledge-Transfer doc (§8 "Call Pipeline") — now you can see which steps are the protocol's job and which are the pipeline's.
+ + +1. You're adding an HTTP-based protocol. Which two methods will you almost always have to write yourself?
+ + + +id() returns a constant and payloadCodec() usually just returns a shared codec instance. Those are trivial.createRequest) and inbound (deserializeResponse) — carry the protocol's real logic. The base class handles the rest.HttpClientProtocol for HTTP protocols — you wouldn't normally write them.2. What does id() return, and why does it matter?
id() returns something like smithy.protocols#rpcv2Cbor. That's how the framework matches a service's protocol trait to your protocol. We'll use this in the discovery lesson.3. In the call lifecycle, what happens between createRequest and deserializeResponse?
createRequest only builds the request object. A separate transport sends it. Keeping these separate is what lets you swap transports.You can state what a protocol is, name its six methods and their jobs, and point to where each one sits in a single call. That's the scaffold. In Lesson 2 we'll read the smallest real protocol in the repo — RpcV2CborProtocol, which is literally ~45 lines — and watch this contract get satisfied by inheritance, so you see how little code a new protocol actually needs.
Genuinely unsure about something here? Don't move on with a fuzzy spot. Ask me in the chat, for example:
+• "Show me where setServiceEndpoint merges the path for HTTP"
+ • "What's the difference between a protocol and a transport again?"
+ • "Why is Schema the first arg to every serde method?"
When you're ready, just say "next lesson" and I'll build Lesson 2.
+In Lesson 1 you learned the six-method contract. Now watch a real protocol satisfy it in ~45 lines. The trick: inheritance does almost all the work, and that tells you exactly how much code you'd write.
+ + + +This is the complete CBOR client protocol — the entire file, lightly trimmed:
+public final class RpcV2CborProtocol extends AbstractRpcV2ClientProtocol {
+ private static final String PAYLOAD_MEDIA_TYPE = "application/cbor";
+ private static final Codec CBOR_CODEC = Rpcv2CborCodec.builder().build();
+
+ public RpcV2CborProtocol(ShapeId service) {
+ super(Rpcv2CborTrait.ID, service, PAYLOAD_MEDIA_TYPE);
+ }
+
+ @Override protected Codec codec() { return CBOR_CODEC; }
+
+ // The factory — the SPI entry point (Lesson 6 covers this)
+ public static final class Factory implements ClientProtocolFactory<Rpcv2CborTrait> {
+ @Override public ShapeId id() { return Rpcv2CborTrait.ID; }
+ @Override public ClientProtocol<?,?> createProtocol(ProtocolSettings s, Rpcv2CborTrait t) {
+ return new RpcV2CborProtocol(s.service());
+ }
+ }
+}
+ Source: client/client-rpcv2-cbor/.../RpcV2CborProtocol.java
Notice what's missing: no createRequest, no deserializeResponse, no setServiceEndpoint. This class only declares two facts: "my codec is CBOR" and "my media type is application/cbor." Everything else is inherited.
Each layer of the hierarchy contributes a slice of the six-method contract. Read it top-down — the work gets more specific as you descend:
+ +id(), messageExchange() (HTTP), and setServiceEndpoint(). Now Req=HttpRequest, Resp=HttpResponse.createRequest() and deserializeResponse() for the RPC-v2 wire shape (POST to /service/X/operation/Y, body = serialized payload). Leaves one hole: abstract Codec codec().codec() → CBOR. Done.RpcV2JsonProtocol, would be the same 45 lines with JsonCodec and "application/json". The wire framing lives in the abstract base; the concrete class only picks a codec. That's the payoff of schema-directed serde (Lesson 3): the body encoding is a swappable knob.Look inside AbstractRpcV2ClientProtocol to see the contract being honored. Its createRequest is the outbound half from Lesson 1, made concrete:
public HttpRequest createRequest(operation, input, context, endpoint) {
+ var target = "/service/" + service.getName() + "/operation/" + operation.schema().id().getName();
+ var builder = templateRequest.toModifiableCopy(); // POST + smithy-protocol headers
+ builder.setUri(endpoint.withConcatPath(target));
+
+ if (operation.inputSchema().hasTrait(UNIT_TYPE_TRAIT)) {
+ builder.setBody(DataStream.ofEmpty()); // no input → empty body
+ } else {
+ builder.addHeader(CONTENT_TYPE, payloadMediaType);
+ builder.setBody(DataStream.ofByteBuffer(codec().serialize(input), payloadMediaType));
+ } // ▲ the codec turns the shape into bytes
+ return builder;
+}
+ And its deserializeResponse is the inbound half — status check, then hand the bytes to the codec:
public O deserializeResponse(operation, ctx, typeRegistry, request, response) {
+ if (response.statusCode() != 200)
+ throw errorDeserializer().createError(ctx, operation, typeRegistry, response);
+ var builder = operation.outputBuilder();
+ return codec().deserializeShape(response.body().asByteBuffer(), builder);
+}
+ Source: client/client-rpcv2/.../AbstractRpcV2ClientProtocol.java
See how codec() — the one method RpcV2CborProtocol overrides — is the pivot? The base class wrote all the framing logic in terms of a codec it doesn't yet know. The concrete subclass supplies it. This is the Template Method pattern, and it's how you'll structure a new protocol family.
1. RpcV2CborProtocol overrides only codec(). Where does its createRequest() come from?
ClientProtocol declares createRequest but provides no body — it's an interface method, not a default.2. You want to add an "RPC v2 + MessagePack" protocol. Based on this lesson, what's the minimal shape of the work?
+ + + +3. In createRequest, what specifically turns the typed input shape into bytes?
Codec. The protocol decides framing (URI, headers); the codec decides bytes. We unpack how the codec does it — with zero reflection — in Lesson 3.A concrete protocol can be tiny because the contract is satisfied by a chain of base classes, each contributing a slice. You can read RpcV2CborProtocol and immediately say which class supplies which method — and you know that adding a same-family protocol means picking a codec, not rewriting framing. Next, Lesson 3 answers the question this raises: how does codec().serialize(input) work without reflection, and why does that decoupling exist?
• "Show me RpcV2JsonProtocol — is it really the same shape?"
+ • "What's in the templateRequest that gets copied per call?"
+ • "When would I NOT be able to reuse an abstract base like this?"
Say "next lesson" for Lesson 3: schema-directed serde.
+Lesson 2 ended on codec().serialize(input). How does that produce JSON or CBOR from a generated shape — with no reflection, and without the shape knowing which protocol is active? This is the single most important idea in the framework. Get it, and protocols stop being mysterious.
You have generated shape classes (PutItemInput, etc.) and you have protocols (JSON, CBOR, XML). The naive design couples them: each shape knows how to write itself as JSON. But then adding a protocol means regenerating every shape, and the shape carries N serializers. smithy-java refuses that.
The insight: put a Schema between them. The shape knows its schema and pushes its members; the protocol reads schemas and decides wire format. Neither imports the other.
Schema is the first argument to every serde method. That's not a style choice — it's the entire decoupling mechanism. The schema is the contract; the protocol and the shape only ever talk through it.public void serializeMembers(
+ ShapeSerializer ser) {
+ ser.writeString($SCHEMA_NAME, name);
+ ser.writeInteger($SCHEMA_AGE, age);
+}
+ Each member is pushed with its schema. The shape has no idea if this is JSON or CBOR.
+void writeString(Schema s, String v) {
+ // JSON codec: look up the
+ // precomputed field-name bytes
+ // for s, arraycopy, write v
+}
+ The codec implements ShapeSerializer. It reads the schema to decide bytes.
The interface that connects them — every method takes a Schema first:
public interface ShapeSerializer extends Flushable, AutoCloseable {
+ void writeStruct(Schema schema, SerializableStruct struct);
+ void writeString(Schema schema, String value);
+ void writeInteger(Schema schema, int value);
+ void writeTimestamp(Schema schema, Instant value);
+ <T> void writeList(Schema schema, T state, int size, BiConsumer<T, ShapeSerializer> consumer);
+ // ... every write takes the member's Schema first
+}
+ Source: core/.../core/serde/ShapeSerializer.java
Reading is the mirror image. The codec reads wire bytes, resolves them to a member schema, and calls back. The generated builder dispatches on memberIndex() — which the compiler turns into a tableswitch, no string compares:
decoder.readStruct($SCHEMA, this, (builder, member, de) -> {
+ switch (member.memberIndex()) { // JVM tableswitch — O(1)
+ case 0 -> builder.name(de.readString(member));
+ case 1 -> builder.age(de.readInteger(member));
+ }
+});
+ The codec owns the field loop; the shape owns the per-field action. Again: they meet only at the schema.
+ +"The schema decides the wire format" sounds like it'd inspect traits (@jsonName, @timestampFormat) at write time. It doesn't — that would be slow. Instead, each protocol precomputes what it needs onto the schema once, into a schema extension:
| Extension | Precomputes | So writeString can… |
|---|---|---|
SmithyJsonSchemaExtensions | byte[][] field-name tables (with quotes+colon, honoring @jsonName) | do table[memberIndex] → System.arraycopy |
CborSchemaExtensions | byte[][] CBOR-encoded field names | same O(1) array index |
HttpBindingSchemaExtensions | binding location (HEADER/QUERY/LABEL/BODY), timestamp formats | route the member to the right HTTP place |
So at serialization time there are no trait lookups, no hashing — just an array index and a memory copy. The trait reading happened once, at schema construction. You'll meet the extension SPI properly in Lesson 5; for now just hold the shape of it.
+ +serializeMembers() works for any codec, adding a protocol never requires regenerating shapes. You write a codec (or reuse one) + a schema extension to precompute your wire data. The generated code is already protocol-agnostic and waiting for you.1. Why is Schema the first argument to every ShapeSerializer method?
2. A teammate says "let's read the @jsonName trait inside writeString to get the field name." Why does the codebase avoid this?
@jsonName is a JSON concern; and the point is when it's read, not which protocol.3. On the deserialize side, the generated builder does switch(member.memberIndex()). Why an integer index instead of matching field names?
You can explain why a protocol and a generated shape never know about each other, and how the Schema-as-first-argument design makes one shape serialize to any format. You know wire decisions are precomputed into schema extensions, not read per-write. That's the conceptual core. Lesson 4 zooms into the Codec itself — the object your protocol returns from payloadCodec() — and what it takes to pick or build one.
• "Show me a real generated serializeMembers() in the repo"
+ • "How does the codec resolve wire bytes back to a memberIndex?" (the speculative fast path)
+ • "What about the document type — shapes with no fixed schema?"
Say "next lesson" for Lesson 4: the Codec layer.
+Your protocol returns a Codec from payloadCodec(). That object is the bridge between schema-carrying shapes and raw bytes. This lesson shows what the Codec interface actually is — and the key decision: reuse a codec vs write one.
A Codec is just a factory for the two visitor implementations you met in Lesson 3, plus two convenience methods:
public interface Codec extends AutoCloseable {
+ ShapeSerializer createSerializer(OutputStream sink); // struct → bytes
+ ShapeDeserializer createDeserializer(ByteBuffer source); // bytes → struct
+
+ // Convenience, built on the two above:
+ default ByteBuffer serialize(SerializableShape shape) { ... }
+ default <T> T deserializeShape(ByteBuffer src, ShapeBuilder<T> builder) { ... }
+}
+ Source: core/.../core/serde/Codec.java
That's the whole contract. When AbstractRpcV2ClientProtocol called codec().serialize(input) in Lesson 2, it used the default serialize here, which spins up a ShapeSerializer, lets the shape push its members into it, and collects the bytes.
| Codec | Format | Notes |
|---|---|---|
JsonCodec | JSON | Pluggable JsonSerdeProvider; options for useJsonName, timestamp format, pretty-print. Hand-written parser on byte[] — no Jackson on the hot path. |
Rpcv2CborCodec | CBOR | Pluggable CborSerdeProvider; binary, compact. VarHandle for big-endian reads. |
XmlCodec | XML | StAX-based; wrapperElements for AWS Query; honors XML traits. |
Note how protocols configure, not reimplement, these. RestJsonClientProtocol builds its codec with options tuned for REST JSON:
this.codec = JsonCodec.builder()
+ .useJsonName(true) // respect @jsonName
+ .useTimestampFormat(true) // respect @timestampFormat
+ .defaultNamespace(service.getNamespace())
+ .build();
+ Source: RestJsonClientProtocol.java
Two distinct axes, often confused:
+| Axis | What changes | Example |
|---|---|---|
| Protocol | Framing: URI, method, headers, how errors are signaled, where members bind | restJson1 vs AWS JSON 1.0 — both use JSON |
| Codec | The payload byte encoding itself | JSON vs CBOR vs XML |
restJson1 and AWS JSON 1.0 share JsonCodec but are different protocols. rpcv2Cbor and a hypothetical rpcv2Json share framing but differ by codec. Knowing which axis you're moving on tells you how much work you're signing up for.
If you do write a codec, the steps (from the KT doc's "Adding a new codec"):
+1. Implement Codec, ShapeSerializer, ShapeDeserializer in a new module
+2. Add a SchemaExtensionProvider to precompute trait-derived wire data // Lesson 5
+3. Register the codec's extension via ServiceLoader in META-INF/services/
+4. Add protocol compliance tests // Lesson 7
+
+ 1. What are the two methods that define a Codec (everything else is a default)?
ShapeSerializer and a ShapeDeserializer. Those visitors do the work.2. Your new protocol sends JSON bodies but with AWS-JSON-style framing (X-Amz-Target header, full-body payload). Do you write a new codec?
+ + + +JsonCodec.3. How does RestJsonClientProtocol make its codec honor @jsonName?
You can describe the Codec interface (two factory methods + two conveniences), name the three shipping codecs, and — crucially — separate the protocol axis (framing) from the codec axis (byte format). That distinction is what makes "add a protocol" usually a small job. Lesson 5 covers the schema-extension SPI: how a codec/protocol precomputes its wire data and registers it so the framework finds it.
• "Walk me through JsonCodec.serialize end-to-end for a 2-field struct"
+ • "What's a JsonSerdeProvider and why is it pluggable?"
+ • "Is XmlCodec really safe to reuse? The KT doc calls it less battle-tested."
Say "next lesson" for Lesson 5: the schema-extension SPI.
+Two SPIs make a protocol real. Schema extensions precompute your wire data (Lesson 3's promise, kept). ServiceLoader is how the framework finds your protocol at all. Miss either and your protocol is silently absent.
+ + + +Lesson 3 said wire decisions are precomputed, not read per-write. The mechanism is a pair of types. A key (a unique integer ID → array slot):
+public final class SchemaExtensionKey<T> {
+ private static final AtomicInteger NEXT_ID = new AtomicInteger();
+ final int id;
+ public SchemaExtensionKey() { this.id = NEXT_ID.getAndIncrement(); } // O(1) lookup later
+}
+ And a provider that computes the value for each schema (discovered via ServiceLoader):
+public interface SchemaExtensionProvider<T> {
+ SchemaExtensionKey<T> key(); // which slot
+ T provide(Schema schema); // compute wire data from the schema's traits
+}
+ Source: SchemaExtensionProvider.java · SchemaExtensionKey.java
provide()'s result is published across threads via a plain array store (no lock, benign race). The Javadoc requires the returned object's fields to be all final — which is why these are records. The JMM guarantees final-field visibility once the constructor returns (JLS 17.5). Return a mutable object and you have a data race.At serialization, the codec does schema.getExtension(KEY) — an array index, no hashing — to get its precomputed bytes/formatters. This is the "no trait lookups on the hot path" claim made concrete.
META-INF/servicesA provider that isn't registered never runs. Registration is a plain-text file named after the fully-qualified interface, containing the implementation class name(s):
+Real file, verbatim — note JSON registers two providers. CBOR and HTTP-binding each register their own.
+ +Same mechanism, different interface. Your ClientProtocolFactory (the Factory inner class from Lesson 2) is registered under its interface name:
(The $Factory is the nested class.) Now watch the framework consume it — this is the actual discovery loop, from DetectProtocolPlugin:
static {
+ for (var impl : ServiceLoader.load(ClientProtocolFactory.class, ...)) {
+ PROTOCOL_FACTORIES.add(impl); // every registered factory, loaded once
+ }
+}
+// later, given a service's protocol traits:
+for (var impl : PROTOCOL_FACTORIES) {
+ if (protocols.containsKey(impl.id())) { // trait ShapeId == factory.id()
+ return impl.createProtocol(settings, protocols.get(impl.id()));
+ }
+}
+ Source: DetectProtocolPlugin.java
This closes the loop opened in Lesson 1: id() returns the protocol trait's ShapeId precisely so this matching works. The service model says "I speak smithy.protocols#rpcv2Cbor"; the framework finds the factory whose id() equals that ShapeId, and builds it with ProtocolSettings.
META-INF/services line is missing, misspelled, or points to the wrong class (forgetting the $Factory suffix for a nested class). ServiceLoader fails silently — no error, the protocol is just never found. Check this file first.The join key — the ShapeId — comes from a Smithy protocol trait applied to the service, e.g. @rpcv2Cbor or @restJson1. These traits are marked with @protocolDefinition in their Smithy model and ship as *Trait Java classes (Rpcv2CborTrait.ID, RestJson1Trait.ID) you reference directly. For an existing standard protocol the trait already exists; for a brand-new one you'd define the trait in a Smithy model first. Either way, factory.id() returns YourTrait.ID.
1. Why must a SchemaExtensionProvider return a record (or all-final object)?
ServiceLoader instantiates any class with a no-arg constructor; records aren't special to it.2. You wrote a perfect ClientProtocol + Factory, but the client says "no matching protocol." First thing to check?
ServiceLoader failure from a missing/typo'd registration is the classic cause. Verify that file before anything else.3. How does the framework match a service to your protocol implementation?
+ + + +factory.id() == YourTrait.ID, and the service's @yourProtocol trait carries that same ShapeId. That's why Lesson 1 stressed what id() returns.You can wire the two SPIs that make a protocol exist: a SchemaExtensionProvider (record-typed, registered) to precompute wire data, and a ClientProtocolFactory (registered under its interface) so ServiceLoader discovery matches your id() to a service's protocol trait. You know the silent-failure trap. Lesson 6 uses all this to compare the two base-class families head-to-head, so you can pick the right parent for a new protocol.
• "Show me CborSchemaExtensions.provide() — what does it precompute?"
+ • "How do I define a brand-new @protocolDefinition trait in Smithy?"
+ • "What's the difference between AutoClientPlugin discovery and this?"
Say "next lesson" for Lesson 6: choosing your base class.
+The biggest decision when adding an HTTP protocol is which parent to extend: HttpClientProtocol (RPC) or HttpBindingClientProtocol (REST). Pick right and you inherit the correct half of the work. This lesson makes the choice mechanical.
Both bases implement ClientProtocol<HttpRequest, HttpResponse> and both give you id(), messageExchange(), setServiceEndpoint() for free. They differ in how the request body and members are arranged — which determines how much of createRequest/deserializeResponse you write.
The whole input is one serialized payload. Routing is by operation name (path or header). You write createRequest/deserializeResponse (or extend a family base like AbstractRpcV2ClientProtocol that already did).
X-Amz-Target)Action form param)Members scatter across method/path/query/headers/body via @http* traits. The HTTP binding layer does the scattering. You write almost nothing — just declare codec, media type, and error deserializer.
This is the entire abstract surface of HttpBindingClientProtocol — the binding layer already implemented createRequest and deserializeResponse generically:
public abstract class HttpBindingClientProtocol<F extends Frame<?>> extends HttpClientProtocol {
+ abstract protected String payloadMediaType(); // you supply
+ abstract protected HttpErrorDeserializer getErrorDeserializer(Context ctx); // you supply
+ protected boolean omitEmptyPayload() { return false; } // you may override
+
+ // createRequest + deserializeResponse: ALREADY IMPLEMENTED via HttpBinding
+}
+ So a REST protocol like RestJsonClientProtocol mostly just configures a JsonCodec and an error deserializer. The member-to-HTTP-location mapping (@httpHeader, @httpLabel, …) is handled by HttpBindingSchemaExtensions — the extension you met in Lesson 5.
Source: HttpBindingClientProtocol.java
| Protocol | Extends | Family |
|---|---|---|
RestJsonClientProtocol | HttpBindingClientProtocol<AwsEventFrame> | REST |
RestXmlClientProtocol | HttpBindingClientProtocol<AwsEventFrame> | REST |
RpcV2CborProtocol | AbstractRpcV2ClientProtocol → HttpClientProtocol | RPC |
AwsJsonProtocol (sealed) | HttpClientProtocol (permits 1.0 & 1.1) | RPC |
AwsQueryClientProtocol | HttpClientProtocol | RPC |
Ec2QueryClientProtocol | HttpClientProtocol | RPC |
These are the literal class … extends … lines in the repo. Pattern: anything "REST-ish" (URI templates + scattered bindings) extends the binding base; anything "call this operation with this body" extends the plain HTTP base.
@http, @httpLabel, @httpQuery on operations/members?" — Yes → REST → HttpBindingClientProtocol. No; the operation name routes and the whole input is the body → RPC → HttpClientProtocol (and check whether a family base like AbstractRpcV2ClientProtocol already fits, so you write ~45 lines instead of two full methods).Not all protocols are HTTP. ClientProtocol is generic over <RequestT, ResponseT> precisely so you could implement it directly for a different transport — your messageExchange() would return a non-HTTP exchange, and the pipeline would only pair you with a matching transport (recall findProtocolMatchingTransport from Lesson 5). That's a much larger undertaking; for the overwhelming majority of new protocols, one of the two HTTP bases is the right parent.
1. A protocol's Smithy model puts @httpLabel on path members and @httpQuery on others. Which base?
@httpLabel/@httpQuery you want the binding base so you don't hand-write the scattering.HttpBindingClientProtocol. You'd inherit createRequest/deserializeResponse and just supply codec + media type + error deserializer.2. What do you typically NOT have to write in a HttpBindingClientProtocol subclass?
3. AWS JSON 1.0 routes by an X-Amz-Target header and sends the full input as the JSON body. Which family, and why?
@httpHeader member bindings. The body is one payload ⇒ RPC. Indeed AwsJsonProtocol extends HttpClientProtocol.You can look at a protocol's Smithy model and pick its base class in one question (does it use @http* member bindings?), and you know what each base hands you for free. You've now seen every structural piece: the contract, a tiny concrete example, schema-directed serde, codecs, the two SPIs, and base-class selection. Lesson 7 closes the loop with the thing that proves you got it right — protocol compliance tests.
• "Why is AwsJsonProtocol sealed with two permits?"
+ • "Show me how AwsQueryClientProtocol builds its form body in createRequest"
+ • "What's the <F extends Frame> type param on the binding base for?" (event streaming)
Say "next lesson" for Lesson 7: proving it with compliance tests.
+A protocol you can't prove correct is a liability. smithy-java's answer is protocol compliance tests: Smithy-defined request/response cases run against your real code. This is the authoritative correctness bar — and the last piece of adding a protocol.
+ + + +The Smithy spec ships @httpRequestTests and @httpResponseTests — declarative cases that say "given this input, the wire bytes must be exactly this" (and vice-versa for responses). The protocol-test-harness runs them against your actual protocol + codec. From the engineering tenets:
Strikingly little code — the harness generates the cases; you just declare the service under test and how to compare. This is the entire CBOR client test:
+@ProtocolTest(
+ service = "smithy.protocoltests.rpcv2Cbor#RpcV2Protocol",
+ testType = TestType.CLIENT)
+public class RpcV2CborProtocolTests {
+
+ @HttpClientRequestTests
+ @ProtocolTestFilter(skipTests = { "...known-broken-case..." })
+ public void requestTest(DataStream expected, DataStream actual) {
+ CborComparator.assertEquals(expected.asByteBuffer(), actual.asByteBuffer());
+ }
+
+ @HttpClientResponseTests
+ public void responseTest(Runnable test) { test.run(); }
+}
+ Source: RpcV2CborProtocolTests.java · @ProtocolTest is a JUnit 5 extension (ProtocolTest.java).
Three things make this work, and you supply all three in the module's build.gradle.kts:
plugins { id("smithy-java.protocol-testing-conventions") } // wires the harness + integ tests
+
+testImplementation(libs.smithy.protocol.tests) // the Smithy-defined test models
+
+// generate test sources from the protocol's test service:
+addGenerateSrcsTask(generator, "rpcv2Cbor", "smithy.protocoltests.rpcv2Cbor#RpcV2Protocol")
+ Source: client-rpcv2-cbor/build.gradle.kts
# the authoritative correctness check across all protocols
+./gradlew :protocol-test-harness:test
+
+# your protocol's own compliance suite (it = integration tests)
+./gradlew :client:client-rpcv2-cbor:integ
+ The protocol-testing-conventions plugin even wires test to be finalized by integ, so compliance runs as part of the normal suite. A protocol without a green compliance run is not done.
Everything from the track, as the checklist you'd actually follow to add an HTTP protocol:
+*Trait.ID, or define a new @protocolDefinition trait in a Smithy model. Lesson 5 · the join key@http* member bindings → HttpBindingClientProtocol (REST); operation-name routing + whole-body → HttpClientProtocol (RPC). Lesson 6JsonCodec/Rpcv2CborCodec/XmlCodec via its builder; write a new Codec only if the byte format is genuinely new. Lesson 4createRequest/deserializeResponse where possible. Lessons 1–2ClientProtocolFactory (and any SchemaExtensionProvider) in META-INF/services/. The silent-failure trap. Lesson 5@ProtocolTest class, wire the gradle conventions, run the compliance suite green. this lesson1. Your unit tests pass but a @httpRequestTests case fails. What's the verdict?
2. How many wire-format assertion cases do you hand-write in a @ProtocolTest class?
CborComparator.assertEquals).3. Final synthesis: a teammate adds "rpcv2Json." They reuse JsonCodec, extend AbstractRpcV2ClientProtocol, register the factory — but the client can't find it. Most likely cause?
ServiceLoader-registered factories; a bad registration fails silently. Check that file first.JsonCodec is fine for an RPC protocol.You can now trace a call through the contract, read any existing protocol and name its base class, explain why protocols and generated code are decoupled, wire both SPIs, pick the right parent, and prove the result with compliance tests. That's deep architectural mastery — earned through the lens of one concrete task.
+ +• Server side: ServerProtocol / ServerProtocolProvider / ProtocolResolver — the mirror image, for accepting requests.
+ • Build a toy protocol end-to-end in a scratch module and watch the compliance suite go green.
+ • Read AwsQueryClientProtocol — the most "different" protocol (form-encoded body, XML response), to test your model.
• "Let's build a toy protocol end-to-end in a scratch module."
+ • "Now teach me the server-side protocol path."
+ • "Quiz me harder — give me a scenario-based design question."
Say the word and we'll keep going.
+A seven-lesson track that teaches the smithy-java client architecture through one concrete task: adding a protocol. Each lesson is short, interactive, and grounded in real code.
+ +One interface, six methods. What a protocol is, and a traced call through it.
RpcV2CborProtocol in ~45 lines — the contract satisfied by inheritance.
Why protocols and generated code never know about each other. The Schema is the contract.
What a Codec is, the three that ship, and when to reuse vs write one.
Precompute wire data; register via ServiceLoader so the framework finds you.
RPC vs REST: HttpClientProtocol vs HttpBindingClientProtocol, made mechanical.
The authoritative correctness bar, plus the whole add-a-protocol checklist.
The shared vocabulary for adding a new protocol. Every lesson uses these terms exactly as defined here.
+ClientProtocol<RequestT, ResponseT> core interfaceHttpRequest/HttpResponse).
+ client/client-core/.../client/core/ClientProtocol.javaClientProtocolFactory<T extends Trait> SPI entry pointClientProtocol from a Smithy protocol trait. This is the class registered with ServiceLoader. Its id() is the protocol trait's ShapeId; its createProtocol(settings, trait) builds the instance.
+ client/client-core/.../client/core/ClientProtocolFactory.javaProtocolSettingsShapeId, optional service version (AWS Query needs it), and the service Schema (carries service-level traits like xmlNamespace).
+ client/client-core/.../client/core/ProtocolSettings.javaMessageExchange<RequestT, ResponseT>HttpMessageExchange.INSTANCE.
+ client/client-core/.../client/core/MessageExchange.javaSchema runtime shapeCodecShapeSerializer (struct → bytes) and a ShapeDeserializer (bytes → struct). Examples: JsonCodec, Rpcv2CborCodec, XmlCodec. A protocol picks a codec; it rarely writes one.
+ core/.../core/serde/Codec.javaShapeSerializer / ShapeDeserializerShapeSerializer (writeString(schema, value), …). Deserialization is a callback: the codec reads bytes, resolves a member schema, and calls back by memberIndex.
+ core/.../core/serde/Schema is the contract between them. Same serializeMembers() works for JSON, CBOR, or XML — switching protocols needs no regeneration.SchemaExtension / SchemaExtensionProviderSchemaExtensionKey at O(1). This is why no trait lookups happen during serialization.
+ core/.../core/schema/ (e.g. SmithyJsonSchemaExtensions, CborSchemaExtensions, HttpBindingSchemaExtensions)HttpClientProtocol RPC base@http* binding traits). Used by RPC-style protocols (rpcv2Cbor, AWS JSON). Supplies id(), messageExchange(), and setServiceEndpoint().
+ client/client-http/.../client/http/HttpClientProtocol.javaHttpBindingClientProtocol REST base@httpHeader, @httpQuery, @httpLabel, @httpPayload to the wire automatically via the HTTP binding layer. Used by restJson1, restXml.
+ client/client-http-binding/.../client/http/binding/HttpBindingClientProtocol.javaServiceLoader / META-INF/services discoveryFactory is listed in META-INF/services/software.amazon.smithy.java.client.core.ClientProtocolFactory. Missing line = feature silently absent.smithy.protocols#rpcv2Cbor, aws.protocols#restJson1) applied to a service shape, marked with @protocolDefinition. Its ShapeId is the join key between the model and the ClientProtocolFactory.@httpRequestTests / @httpResponseTests run against real client/server code by the protocol-test-harness. A unit test that passes but a compliance test that fails is a bug. This is how correctness is proven.
+ protocol-test-harness/ · annotated via @ProtocolTest, @HttpClientRequestTests... stands for the package prefix
+ src/main/java/software/amazon/smithy/java. Full source lives at
+ github.com/smithy-lang/smithy-java.
+