diff --git a/README.md b/README.md index 94bb47b9..7aacb9e0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/ClientGenerator.java b/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/ClientGenerator.java index 1f75d965..0e45c660 100644 --- a/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/ClientGenerator.java +++ b/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/ClientGenerator.java @@ -49,6 +49,16 @@ private void writeHeader(CppWriter w) { w.addInclude("\"smithy/http/transport.h\""); String name = clientName(); + List paginated = + operations().stream().filter(op -> pagination(op).isPresent()).toList(); + if (!paginated.isEmpty()) { + w.addInclude(""); + w.addInclude(""); + 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"); @@ -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(); @@ -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> 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 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) { @@ -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("}"); @@ -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> $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(""); } } diff --git a/docs/production-guide.md b/docs/production-guide.md index ed920ae2..c37d0bc4 100644 --- a/docs/production-guide.md +++ b/docs/production-guide.md @@ -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 `; 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 diff --git a/docs/runtime.md b/docs/runtime.md index fec0dbae..c51aa4d5 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -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 diff --git a/examples/cafe/generated/src/client.cc b/examples/cafe/generated/src/client.cc index 4e0a8504..67479ded 100644 --- a/examples/cafe/generated/src/client.cc +++ b/examples/cafe/generated/src/client.cc @@ -118,6 +118,10 @@ smithy::Outcome 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())); } diff --git a/examples/cafe/generated_client_test.cc b/examples/cafe/generated_client_test.cc index 229ed3e8..b297d0a8 100644 --- a/examples/cafe/generated_client_test.cc +++ b/examples/cafe/generated_client_test.cc @@ -6,6 +6,7 @@ #include #include +#include #include #include "example/cafe/client.h" @@ -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(); + 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 diff --git a/examples/cafe/model/cafe.smithy b/examples/cafe/model/cafe.smithy index 3748946e..604ed25e 100644 --- a/examples/cafe/model/cafe.smithy +++ b/examples/cafe/model/cafe.smithy @@ -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] diff --git a/examples/roundtrip/model/roundtrip.smithy b/examples/roundtrip/model/roundtrip.smithy index b964cfc2..fb252820 100644 --- a/examples/roundtrip/model/roundtrip.smithy +++ b/examples/roundtrip/model/roundtrip.smithy @@ -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] diff --git a/examples/roundtrip/rest/generated/src/client.cc b/examples/roundtrip/rest/generated/src/client.cc index 11b3255c..738a77cb 100644 --- a/examples/roundtrip/rest/generated/src/client.cc +++ b/examples/roundtrip/rest/generated/src/client.cc @@ -171,6 +171,11 @@ smithy::Outcome 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())); } diff --git a/examples/weather/generated/include/example/weather/client.h b/examples/weather/generated/include/example/weather/client.h index 2a3bf03f..c38fdfb0 100644 --- a/examples/weather/generated/include/example/weather/client.h +++ b/examples/weather/generated/include/example/weather/client.h @@ -3,7 +3,9 @@ #pragma once #include +#include #include +#include #include "example/weather/types.h" #include "smithy/client/config.h" @@ -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 @@ -27,6 +31,8 @@ class WeatherClient { smithy::Outcome GetCurrentTime(const GetCurrentTimeInput& input = {}) const; smithy::Outcome GetForecast(const GetForecastInput& input) const; smithy::Outcome 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 transport, std::string path_prefix); @@ -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> 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 diff --git a/examples/weather/generated/src/client.cc b/examples/weather/generated/src/client.cc index 985732af..7fcb51a4 100644 --- a/examples/weather/generated/src/client.cc +++ b/examples/weather/generated/src/client.cc @@ -155,6 +155,10 @@ smithy::Outcome WeatherClient::Send(smithy::http::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); + // @httpBearerAuth: attach the configured token (fetched per request). + if (config_.bearer_token) { + request.headers.Set("authorization", "Bearer " + config_.bearer_token()); + } if (!request.body.empty()) { request.headers.Set("content-length", std::to_string(request.body.size())); } @@ -246,4 +250,23 @@ smithy::Outcome WeatherClient::ListCities(const ListCitiesInpu return DeserializeListCitiesOutput(*body_doc); } +ListCitiesPaginator WeatherClient::PaginateListCities(ListCitiesInput input) const { + return ListCitiesPaginator(*this, std::move(input)); +} + +smithy::Outcome> ListCitiesPaginator::Next() { + if (done_) return std::optional(); + auto page = client_.ListCities(input_); + if (!page) { + done_ = true; + return std::move(page).error(); + } + if (!page->nextToken.has_value() || page->nextToken->empty()) { + done_ = true; + } else { + input_.nextToken = *page->nextToken; + } + return std::optional(std::move(*page)); +} + } // namespace example::weather diff --git a/examples/weather/generated_server_e2e_test.cc b/examples/weather/generated_server_e2e_test.cc index 1aaa2398..e98956f5 100644 --- a/examples/weather/generated_server_e2e_test.cc +++ b/examples/weather/generated_server_e2e_test.cc @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -184,6 +185,68 @@ TEST_F(GeneratedServerEndToEndTest, InterceptorAndMiddlewareCarryAuthAcrossTheWi EXPECT_EQ(observations[0].status, 200); } +// @httpBearerAuth end to end (Phase 7c): config.bearer_token feeds the +// generated client; RequireBearerAuth guards the generated server. +TEST_F(GeneratedServerEndToEndTest, BearerTokenFlowsFromConfigThroughAuthMiddleware) { + auto handler = + smithy::server::Chain({smithy::server::RequireBearerAuth( + [](const std::string& token) { return token == "weather-token"; })}, + server_->Handler()); + auto loopback = std::make_shared(); + ASSERT_TRUE(loopback->Start(handler).ok()); + + { + smithy::ClientConfig config; + config.http_client = loopback; + config.retry.max_attempts = 1; + auto anonymous = example::weather::WeatherClient::Create(std::move(config)); + ASSERT_TRUE(anonymous.ok()); + EXPECT_FALSE(anonymous->GetCity(example::weather::GetCityInput{.cityId = "seattle"}).ok()); + } + + smithy::ClientConfig config; + config.http_client = loopback; + config.bearer_token = [] { return std::string("weather-token"); }; + auto client = example::weather::WeatherClient::Create(std::move(config)); + ASSERT_TRUE(client.ok()); + const auto city = client->GetCity(example::weather::GetCityInput{.cityId = "seattle"}); + ASSERT_TRUE(city.ok()) << city.error().message(); + EXPECT_EQ(city->name, "Seattle"); +} + +// The generated @paginated paginator walks pages until the server stops +// returning a next token (ReferenceHandler pages when pageSize < 2). +TEST_F(GeneratedServerEndToEndTest, PaginatorWalksAllPages) { + auto loopback = std::make_shared(); + ASSERT_TRUE(loopback->Start(server_->Handler()).ok()); + smithy::ClientConfig config; + config.http_client = loopback; + auto client = example::weather::WeatherClient::Create(std::move(config)); + ASSERT_TRUE(client.ok()); + + auto paginator = client->PaginateListCities(example::weather::ListCitiesInput{.pageSize = 1}); + + const auto first = paginator.Next(); + ASSERT_TRUE(first.ok()) << first.error().message(); + ASSERT_TRUE(first->has_value()); + ASSERT_EQ((*first)->items.size(), 1u); + EXPECT_EQ((*first)->items[0].cityId, "seattle"); + + const auto second = paginator.Next(); + ASSERT_TRUE(second.ok()) << second.error().message(); + ASSERT_TRUE(second->has_value()); + ASSERT_EQ((*second)->items.size(), 1u); + EXPECT_EQ((*second)->items[0].cityId, "rain city"); + + const auto done = paginator.Next(); + ASSERT_TRUE(done.ok()); + EXPECT_FALSE(done->has_value()); + // Exhausted paginators stay exhausted. + const auto still_done = paginator.Next(); + ASSERT_TRUE(still_done.ok()); + EXPECT_FALSE(still_done->has_value()); +} + TEST_F(GeneratedServerEndToEndTest, DeleteCityIs204WithNoBody) { smithy::http::HttpRequest request; request.method = "DELETE"; diff --git a/examples/weather/model/weather.smithy b/examples/weather/model/weather.smithy index 354cd6c7..68219718 100644 --- a/examples/weather/model/weather.smithy +++ b/examples/weather/model/weather.smithy @@ -12,6 +12,7 @@ use aws.protocols#restJson1 @restJson1 @title("Weather Service") @paginated(inputToken: "nextToken", outputToken: "nextToken", pageSize: "pageSize") +@httpBearerAuth service Weather { version: "2026-07-06" resources: [City] diff --git a/runtime/include/smithy/client/config.h b/runtime/include/smithy/client/config.h index f1289ed2..a7f2833f 100644 --- a/runtime/include/smithy/client/config.h +++ b/runtime/include/smithy/client/config.h @@ -1,6 +1,7 @@ #ifndef SMITHY_CLIENT_CONFIG_H_ #define SMITHY_CLIENT_CONFIG_H_ +#include #include #include #include @@ -32,6 +33,16 @@ struct ClientConfig { // (the Smithy default; 0 compresses everything). int request_min_compression_size_bytes = 10240; + // @httpBearerAuth: when set on a service modeled with the trait, every + // request carries "authorization: Bearer ". Called per request, so + // rotating credentials just works. + std::function bearer_token; + + // @httpApiKeyAuth: when set on a service modeled with the trait, every + // request carries the key where the model binds it (named header with + // optional scheme, or query parameter). Called per request. + std::function api_key; + // User-supplied hooks around every HTTP attempt (auth headers, logging, // tracing); run in registration order. See smithy/client/interceptor.h. std::vector> interceptors; diff --git a/runtime/include/smithy/server/middleware.h b/runtime/include/smithy/server/middleware.h index b2c9eee2..72ab978a 100644 --- a/runtime/include/smithy/server/middleware.h +++ b/runtime/include/smithy/server/middleware.h @@ -40,6 +40,17 @@ struct RequestObservation { Middleware Observe(std::function callback, std::function now = nullptr); +// 401 unless the request carries "authorization: Bearer " (scheme +// matched case-insensitively per RFC 6750) and validator(token) returns +// true — the server-side counterpart of @httpBearerAuth. +Middleware RequireBearerAuth(std::function validator); + +// 401 unless header_name carries the key — prefixed " " when scheme +// is non-empty — and validator(key) returns true; the server-side +// counterpart of @httpApiKeyAuth(in: "header"). +Middleware RequireApiKeyHeader(std::string header_name, std::string scheme, + std::function validator); + } // namespace smithy::server #endif // SMITHY_SERVER_MIDDLEWARE_H_ diff --git a/runtime/src/server/middleware.cc b/runtime/src/server/middleware.cc index 85b98368..b10c85e0 100644 --- a/runtime/src/server/middleware.cc +++ b/runtime/src/server/middleware.cc @@ -1,6 +1,10 @@ #include "smithy/server/middleware.h" +#include +#include +#include #include +#include #include namespace smithy::server { @@ -33,4 +37,52 @@ Middleware Observe(std::function callback, }; } +namespace { + +http::HttpResponse Unauthorized() { + http::HttpResponse response; + response.status = 401; + return response; +} + +// The credential after " " (scheme matched case-insensitively), or +// nullopt when the value does not carry that scheme. +std::optional StripScheme(const std::string& value, const std::string& scheme) { + const std::size_t prefix = scheme.size() + 1; + if (value.size() <= prefix || value[scheme.size()] != ' ') { + return std::nullopt; + } + for (std::size_t i = 0; i < scheme.size(); ++i) { + if (std::tolower(static_cast(value[i])) != + std::tolower(static_cast(scheme[i]))) { + return std::nullopt; + } + } + return value.substr(prefix); +} + +} // namespace + +Middleware RequireBearerAuth(std::function validator) { + return RequireApiKeyHeader("authorization", "Bearer", std::move(validator)); +} + +Middleware RequireApiKeyHeader(std::string header_name, std::string scheme, + std::function validator) { + return [header_name = std::move(header_name), scheme = std::move(scheme), + validator = std::move(validator)](http::RequestHandler next) { + return + [header_name, scheme, validator, next = std::move(next)](const http::HttpRequest& request) { + std::optional credential = request.headers.Get(header_name); + if (credential.has_value() && !scheme.empty()) { + credential = StripScheme(*credential, scheme); + } + if (!credential.has_value() || !validator(*credential)) { + return Unauthorized(); + } + return next(request); + }; + }; +} + } // namespace smithy::server diff --git a/runtime/tests/server/middleware_test.cc b/runtime/tests/server/middleware_test.cc index 208cecde..2ff76aec 100644 --- a/runtime/tests/server/middleware_test.cc +++ b/runtime/tests/server/middleware_test.cc @@ -94,6 +94,54 @@ TEST(ObserveTest, ReportsMethodTargetStatusAndDuration) { EXPECT_EQ(observations[0].duration, milliseconds(7)); } +TEST(RequireBearerAuthTest, ValidatesTheBearerToken) { + auto handler = Chain({RequireBearerAuth([](const std::string& token) { return token == "s3"; })}, + [](const http::HttpRequest&) { return Ok("in"); }); + + http::HttpRequest request; + EXPECT_EQ(handler(request).status, 401); // no header + + request.headers.Set("authorization", "Bearer s3"); + EXPECT_EQ(handler(request).status, 200); + + request.headers.Set("authorization", "bearer s3"); // scheme is case-insensitive + EXPECT_EQ(handler(request).status, 200); + + request.headers.Set("authorization", "Bearer nope"); + EXPECT_EQ(handler(request).status, 401); + + request.headers.Set("authorization", "Basic s3"); // wrong scheme + EXPECT_EQ(handler(request).status, 401); + + request.headers.Set("authorization", "Bearer"); // scheme without credential + EXPECT_EQ(handler(request).status, 401); +} + +TEST(RequireApiKeyHeaderTest, ValidatesTheNamedHeader) { + auto handler = Chain( + {RequireApiKeyHeader("x-api-key", "", [](const std::string& key) { return key == "k"; })}, + [](const http::HttpRequest&) { return Ok("in"); }); + + http::HttpRequest request; + EXPECT_EQ(handler(request).status, 401); + request.headers.Set("x-api-key", "k"); + EXPECT_EQ(handler(request).status, 200); + request.headers.Set("x-api-key", "wrong"); + EXPECT_EQ(handler(request).status, 401); +} + +TEST(RequireApiKeyHeaderTest, SchemePrefixesTheKey) { + auto handler = Chain({RequireApiKeyHeader("authorization", "ApiKey", + [](const std::string& key) { return key == "k"; })}, + [](const http::HttpRequest&) { return Ok("in"); }); + + http::HttpRequest request; + request.headers.Set("authorization", "ApiKey k"); + EXPECT_EQ(handler(request).status, 200); + request.headers.Set("authorization", "k"); // missing scheme + EXPECT_EQ(handler(request).status, 401); +} + TEST(ObserveTest, CountsEveryRequest) { int count = 0; auto handler = Chain({Observe([&](const RequestObservation&) { ++count; })},