Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ See [`docs/PLAN.md`](docs/PLAN.md) for the full phased plan and
| 4 | Server generation (restJson1 + rpcv2Cbor) | ✅ Done — handlers, routing, serde, all HTTP bindings incl. `@httpPayload`/`@httpPrefixHeaders`, constraint validation, parser strictness, content negotiation; ~1,175 official conformance cases green ([docs/server-guide.md](docs/server-guide.md)) |
| 5 | Generated-client ↔ generated-server integration harness | ✅ Done — every fixture ships a generated integration suite: seeded random round-trips over loopback and real sockets, per-error mapping, unknown-member tolerance, mutation-checked ([docs/design/integration-testing.md](docs/design/integration-testing.md)) |
| 6 | Bazel rules, CLI, packaging (BCR + Maven Central), docs site | 🔨 In progress — `smithy_cpp_{types,client,server}_library` rules run the generator hermetically inside the build graph, out-of-tree consumer module tested in CI, CLI via `bazel run //codegen:generator` ([docs/quickstart.md](docs/quickstart.md)); BCR/Maven publishing deferred until production validation; docs site pending |
| 7 | Hardening, fuzzing, v0.1.0 | 🔨 In progress — retries with full-jitter exponential backoff, gzip `@requestCompression` (client + server), client interceptors + server middleware (auth/logging/metrics seams), Beast graceful drain + header limits, consumer CI across linux/macos/windows ([docs/production-guide.md](docs/production-guide.md)) |
| 7 | Hardening, fuzzing, v0.1.0 | 🔨 In progress — retries with full-jitter exponential backoff, gzip `@requestCompression` (client + server), client interceptors + server middleware (auth/logging/metrics seams), `@httpBearerAuth`/`@httpApiKeyAuth` credential wiring, generated `@paginated` paginators, Beast graceful drain + header limits, consumer CI across linux/macos/windows ([docs/production-guide.md](docs/production-guide.md)) |
| 8 | Bidirectional streaming (event streams, WebSockets) | Not started |

## Building
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ private void writeHeader(CppWriter w) {
w.addInclude("\"smithy/http/transport.h\"");

String name = clientName();
List<OperationShape> paginated =
operations().stream().filter(op -> pagination(op).isPresent()).toList();
if (!paginated.isEmpty()) {
w.addInclude("<optional>");
w.addInclude("<utility>");
for (OperationShape operation : paginated) {
w.write("class $L;", paginatorName(operation));
}
w.write("");
}
w.write("/// $L client for $L.", protocol.name(), service.getId().toString());
w.write("/// Modeled service errors surface as smithy::Error with kind kModeled,");
w.write("/// code() set to the error shape name, and the deserialized error");
Expand Down Expand Up @@ -78,6 +88,17 @@ private void writeHeader(CppWriter w) {
CppReservedWords.escape(operation.getId().getName()),
inputType,
defaulted);
if (pagination(operation).isPresent()) {
w.write(
"/// Pages $L until the service stops returning a next token (@paginated).",
CppReservedWords.escape(operation.getId().getName()));
w.write(
"$L Paginate$L($L input$L) const;",
paginatorName(operation),
CppReservedWords.escape(operation.getId().getName()),
inputType,
defaulted);
}
}
w.write("").dedent();
w.write("private:").indent();
Expand All @@ -94,6 +115,94 @@ private void writeHeader(CppWriter w) {
w.write("std::string path_prefix_;").dedent();
w.closeBlock("};");
w.write("");

for (OperationShape operation : paginated) {
String opName = CppReservedWords.escape(operation.getId().getName());
StructureShape input = ProtocolSupport.inputShape(context, operation);
String inputType = context.cppSymbols().toSymbol(input).getName();
String outputType =
context.cppSymbols().toSymbol(ProtocolSupport.outputShape(context, operation)).getName();
w.write("/// Lazily pages $L; owns a copy of the client and the input.", opName);
w.openBlock("class $L {", paginatorName(operation));
w.write("public:").indent();
w.write("/// The next page, std::nullopt once pagination is complete, or the");
w.write("/// first failed call's error (pagination then stops).");
w.write("smithy::Outcome<std::optional<$L>> Next();", outputType);
w.write("").dedent();
w.write("private:").indent();
w.write("friend class $L;", name);
w.write(
"$L($L client, $L input) : client_(std::move(client)), input_(std::move(input)) {}",
paginatorName(operation),
name,
inputType);
w.write("$L client_;", name);
w.write("$L input_;", inputType);
w.write("bool done_ = false;").dedent();
w.closeBlock("};");
w.write("");
}
}

/**
* Resolved pagination for the operation, when the generator supports it: top-level string
* input/output token members (nested output-token paths and non-string tokens are skipped).
*/
private java.util.Optional<software.amazon.smithy.model.knowledge.PaginationInfo> pagination(
OperationShape operation) {
return software.amazon.smithy.model.knowledge.PaginatedIndex.of(context.model())
.getPaginationInfo(service, operation)
.filter(
info ->
info.getOutputTokenMemberPath().size() == 1
&& context
.model()
.expectShape(info.getInputTokenMember().getTarget())
.isStringShape()
&& context
.model()
.expectShape(info.getOutputTokenMemberPath().get(0).getTarget())
.isStringShape());
}

private String paginatorName(OperationShape operation) {
return CppReservedWords.escape(operation.getId().getName()) + "Paginator";
}

/** Auth from the service's traits; a null provider leaves the request anonymous. */
private void writeAuth(CppWriter w) {
if (service.hasTrait(software.amazon.smithy.model.traits.HttpBearerAuthTrait.class)) {
w.write("// @httpBearerAuth: attach the configured token (fetched per request).");
w.openBlock("if (config_.bearer_token) {");
w.write("request.headers.Set(\"authorization\", \"Bearer \" + config_.bearer_token());");
w.closeBlock("}");
}
service
.getTrait(software.amazon.smithy.model.traits.HttpApiKeyAuthTrait.class)
.ifPresent(
trait -> {
w.write("// @httpApiKeyAuth: attach the configured key where the model binds it.");
w.openBlock("if (config_.api_key) {");
if (trait.getIn()
== software.amazon.smithy.model.traits.HttpApiKeyAuthTrait.Location.HEADER) {
String prefix = trait.getScheme().map(scheme -> scheme + " ").orElse("");
if (prefix.isEmpty()) {
w.write("request.headers.Set($S, config_.api_key());", trait.getName());
} else {
w.write(
"request.headers.Set($S, $S + config_.api_key());", trait.getName(), prefix);
}
} else {
w.write(
"request.target += request.target.find('?') == std::string::npos "
+ "? \"?\" : \"&\";");
w.write(
"request.target += \"$L=\" + "
+ "smithy::http::EncodeQueryComponent(config_.api_key());",
trait.getName());
}
w.closeBlock("}");
});
}

private void writeSource(CppWriter w) {
Expand Down Expand Up @@ -155,6 +264,7 @@ private void writeSource(CppWriter w) {
+ "request.headers.Set(\"accept\", $S);",
protocol.contentType());
w.write("request.headers.Set(\"user-agent\", config_.user_agent);");
writeAuth(w);
w.openBlock("if (!request.body.empty()) {");
w.write("request.headers.Set(\"content-length\", std::to_string(request.body.size()));");
w.closeBlock("}");
Expand Down Expand Up @@ -182,5 +292,54 @@ private void writeSource(CppWriter w) {
w.closeBlock("}");
w.write("");
}

for (OperationShape operation : operations()) {
pagination(operation).ifPresent(info -> writePaginator(w, operation, info));
}
}

private void writePaginator(
CppWriter w,
OperationShape operation,
software.amazon.smithy.model.knowledge.PaginationInfo info) {
String name = clientName();
String pager = paginatorName(operation);
String opName = CppReservedWords.escape(operation.getId().getName());
StructureShape input = ProtocolSupport.inputShape(context, operation);
String inputType = context.cppSymbols().toSymbol(input).getName();
String outputType =
context.cppSymbols().toSymbol(ProtocolSupport.outputShape(context, operation)).getName();
String inToken = context.cppSymbols().toMemberName(info.getInputTokenMember());
var outTokenMember = info.getOutputTokenMemberPath().get(0);
String outToken = context.cppSymbols().toMemberName(outTokenMember);
boolean outRequired = outTokenMember.isRequired();

w.openBlock("$L $L::Paginate$L($L input) const {", pager, name, opName, inputType);
w.write("return $L(*this, std::move(input));", pager);
w.closeBlock("}");
w.write("");

String exhausted =
outRequired
? "page->" + outToken + ".empty()"
: "!page->" + outToken + ".has_value() || page->" + outToken + "->empty()";
String tokenValue = (outRequired ? "page->" : "*page->") + outToken;
w.openBlock("smithy::Outcome<std::optional<$L>> $L::Next() {", outputType, pager);
w.write("if (done_) return std::optional<$L>();", outputType);
w.write("auto page = client_.$L(input_);", opName);
w.openBlock("if (!page) {");
w.write("done_ = true;");
w.write("return std::move(page).error();");
w.closeBlock("}");
w.openBlock("if ($L) {", exhausted);
w.write("done_ = true;");
w.dedent();
w.write("} else {");
w.indent();
w.write("input_.$L = $L;", inToken, tokenValue);
w.closeBlock("}");
w.write("return std::optional<$L>(std::move(*page));", outputType);
w.closeBlock("}");
w.write("");
}
}
52 changes: 52 additions & 0 deletions docs/production-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,58 @@ Compression trades CPU for bytes: leave the 10 KiB threshold alone unless
you have measured small-payload wins; compressing tiny bodies usually
inflates them.

## Auth

Services modeled with `@httpBearerAuth` or `@httpApiKeyAuth` get credential
wiring generated into their clients — set the provider on the config and
every request carries it (providers are called per request, so rotation
just works):

```cpp
config.bearer_token = [] { return LoadToken(); }; // @httpBearerAuth
config.api_key = [] { return LoadApiKey(); }; // @httpApiKeyAuth
```

Bearer tokens ride as `authorization: Bearer <token>`; API keys go where
the model binds them — a named header (with the trait's scheme prefix, if
any) or a query parameter. A null provider leaves requests anonymous.

Server-side, the matching guards ship as middleware
(`smithy/server/middleware.h`):

```cpp
transport.Start(smithy::server::Chain(
{smithy::server::RequireBearerAuth([](const std::string& token) {
return TokenIsValid(token); // 401 otherwise
})},
server.Handler()));
// Or: smithy::server::RequireApiKeyHeader("x-api-key", /*scheme=*/"", validator)
```

Vendor-specific signing schemes (e.g. SigV4) are out of scope by design;
implement them as an `Interceptor` (below).

## Pagination

Operations modeled with `@paginated` (top-level string tokens) get a
generated paginator: `client.PaginateListCities(input)` returns a
`ListCitiesPaginator` whose `Next()` yields one page at a time and
`std::nullopt` once the service stops returning a next token. The paginator
owns a copy of the client and input, so it outlives both:

```cpp
auto paginator = client.PaginateListCities({.pageSize = 100});
while (true) {
auto page = paginator.Next();
if (!page.ok()) return page.error(); // pagination stops on first error
if (!page->has_value()) break; // exhausted
for (const auto& city : (*page)->items) Process(city);
}
```

An empty-string token is treated as end-of-pagination (defensive: it can
never loop forever on a server echoing empty tokens).

## Client interceptors

`config.interceptors` (`smithy/client/interceptor.h`) hooks user code around
Expand Down
4 changes: 2 additions & 2 deletions docs/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ crates (PLAN §3.2a).
| `//runtime:cbor` | `smithy::cbor` | `Document` ⇄ deterministic CBOR (RFC 8949; tag-1 timestamps; tolerant decoder) — ADR-0005 |
| `//runtime:http` | `smithy::http` | `Headers` (case-insensitive), URI percent-encoding per the Smithy HTTP binding rules, `HttpRequest`/`HttpResponse`, `HttpClient`/`HttpServerTransport` interfaces, `Loopback` in-memory transport, built-in `SocketHttpClient`/`SocketHttpServer` (test/reference only — ADR-0006) |
| `//runtime:http_beast` | `smithy::http` | `BeastServerTransport` (ADR-0006): the production server transport on BCR modular Boost.Beast/asio — concurrent connections on a thread pool, keep-alive, per-connection timeouts, body- and header-size limits, graceful drain on Stop. Separate target so Boost stays out of dep-light builds |
| `//runtime:client` | `smithy` | `ClientConfig` (endpoint, timeout, user-agent, transport injection, `RetryPolicy`, request-compression threshold, `Interceptor` hooks around every attempt), `SendWithRetries` (full-jitter exponential backoff over transport errors and 429/5xx — see docs/production-guide.md) |
| `//runtime:client` | `smithy` | `ClientConfig` (endpoint, timeout, user-agent, transport injection, `RetryPolicy`, request-compression threshold, `Interceptor` hooks around every attempt, `bearer_token`/`api_key` credential providers), `SendWithRetries` (full-jitter exponential backoff over transport errors and 429/5xx — see docs/production-guide.md) |
| `//runtime:compression` | `smithy` | `GzipCompress`/`GzipDecompress` (zlib; decompression-bomb guard, trailing-garbage rejection) backing `@requestCompression` |
| `//runtime:server` | `smithy::server` | `Router` (literal > label > greedy precedence, 404/405/400), `RequestContext`, `MakeErrorResponse`, `ValidationFailure`, user-supplied `Middleware` + `Chain` + the `Observe` logging/metrics hook |
| `//runtime:server` | `smithy::server` | `Router` (literal > label > greedy precedence, 404/405/400), `RequestContext`, `MakeErrorResponse`, `ValidationFailure`, user-supplied `Middleware` + `Chain`, the `Observe` logging/metrics hook, and `RequireBearerAuth`/`RequireApiKeyHeader` guards |

## Design rules

Expand Down
4 changes: 4 additions & 0 deletions examples/cafe/generated/src/client.cc
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ smithy::Outcome<smithy::http::HttpResponse> CafeClient::Send(smithy::http::HttpR
// Operations with a non-document response payload set their own accept.
if (!request.headers.Get("accept").has_value()) request.headers.Set("accept", "application/cbor");
request.headers.Set("user-agent", config_.user_agent);
// @httpApiKeyAuth: attach the configured key where the model binds it.
if (config_.api_key) {
request.headers.Set("x-api-key", config_.api_key());
}
if (!request.body.empty()) {
request.headers.Set("content-length", std::to_string(request.body.size()));
}
Expand Down
18 changes: 18 additions & 0 deletions examples/cafe/generated_client_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include <gtest/gtest.h>

#include <memory>
#include <string>
#include <utility>

#include "example/cafe/client.h"
Expand Down Expand Up @@ -224,5 +225,22 @@ TEST_F(CafeClientTest, SerdeRoundTripsThroughGeneratedFunctions) {
EXPECT_EQ(*round, input);
}

// @httpApiKeyAuth(name: "x-api-key", in: "header") — the configured key
// rides every request; absent config leaves requests anonymous.
TEST_F(CafeClientTest, ApiKeyHeaderComesFromConfig) {
EXPECT_FALSE(transport_->last_request.headers.Get("x-api-key").has_value());

auto transport = std::make_shared<CapturingTransport>();
smithy::ClientConfig config;
config.http_client = transport;
config.api_key = [] { return std::string("cafe-key"); };
auto client = CafeClient::Create(std::move(config));
ASSERT_TRUE(client.ok());
transport->next_response.body = EncodeBody(Document(DocumentMap{}));
transport->next_response.headers.Set("smithy-protocol", "rpc-v2-cbor");
(void)client->GetOrder(GetOrderInput{.orderId = "abc"});
EXPECT_EQ(transport->last_request.headers.Get("x-api-key"), "cafe-key");
}

} // namespace
} // namespace example::cafe
1 change: 1 addition & 0 deletions examples/cafe/model/cafe.smithy
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use smithy.protocols#rpcv2Cbor
/// @streaming shapes.
@rpcv2Cbor
@title("Cafe Service")
@httpApiKeyAuth(name: "x-api-key", in: "header")
service Cafe {
version: "2026-07-06"
operations: [OrderCoffee, GetOrder]
Expand Down
1 change: 1 addition & 0 deletions examples/roundtrip/model/roundtrip.smithy
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use smithy.protocols#rpcv2Cbor
/// served over restJson1 (with every supported HTTP binding) and rpcv2Cbor,
/// so random round-trips exercise both protocols' serde end to end.
@restJson1
@httpApiKeyAuth(name: "api-key", in: "query")
service RoundTripRest {
version: "2026-01-01"
operations: [PutSink, UploadAttachment, DescribeSink]
Expand Down
5 changes: 5 additions & 0 deletions examples/roundtrip/rest/generated/src/client.cc
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,11 @@ smithy::Outcome<smithy::http::HttpResponse> RoundTripRestClient::Send(smithy::ht
// Operations with a non-document response payload set their own accept.
if (!request.headers.Get("accept").has_value()) request.headers.Set("accept", "application/json");
request.headers.Set("user-agent", config_.user_agent);
// @httpApiKeyAuth: attach the configured key where the model binds it.
if (config_.api_key) {
request.target += request.target.find('?') == std::string::npos ? "?" : "&";
request.target += "api-key=" + smithy::http::EncodeQueryComponent(config_.api_key());
}
if (!request.body.empty()) {
request.headers.Set("content-length", std::to_string(request.body.size()));
}
Expand Down
21 changes: 21 additions & 0 deletions examples/weather/generated/include/example/weather/client.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
#pragma once

#include <memory>
#include <optional>
#include <string>
#include <utility>

#include "example/weather/types.h"
#include "smithy/client/config.h"
Expand All @@ -12,6 +14,8 @@

namespace example::weather {

class ListCitiesPaginator;

/// restJson1 client for example.weather#Weather.
/// Modeled service errors surface as smithy::Error with kind kModeled,
/// code() set to the error shape name, and the deserialized error
Expand All @@ -27,6 +31,8 @@ class WeatherClient {
smithy::Outcome<GetCurrentTimeOutput> GetCurrentTime(const GetCurrentTimeInput& input = {}) const;
smithy::Outcome<GetForecastOutput> GetForecast(const GetForecastInput& input) const;
smithy::Outcome<ListCitiesOutput> ListCities(const ListCitiesInput& input) const;
/// Pages ListCities until the service stops returning a next token (@paginated).
ListCitiesPaginator PaginateListCities(ListCitiesInput input) const;

private:
WeatherClient(smithy::ClientConfig config, std::shared_ptr<smithy::http::HttpClient> transport, std::string path_prefix);
Expand All @@ -37,4 +43,19 @@ class WeatherClient {
std::string path_prefix_;
};

/// Lazily pages ListCities; owns a copy of the client and the input.
class ListCitiesPaginator {
public:
/// The next page, std::nullopt once pagination is complete, or the
/// first failed call's error (pagination then stops).
smithy::Outcome<std::optional<ListCitiesOutput>> Next();

private:
friend class WeatherClient;
ListCitiesPaginator(WeatherClient client, ListCitiesInput input) : client_(std::move(client)), input_(std::move(input)) {}
WeatherClient client_;
ListCitiesInput input_;
bool done_ = false;
};

} // namespace example::weather
Loading
Loading