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), 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), 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 @@ -158,7 +158,9 @@ private void writeSource(CppWriter w) {
w.openBlock("if (!request.body.empty()) {");
w.write("request.headers.Set(\"content-length\", std::to_string(request.body.size()));");
w.closeBlock("}");
w.write("return smithy::SendWithRetries(*transport_, request, config_.retry);");
w.write(
"return smithy::SendWithRetries(*transport_, request, config_.retry, "
+ "config_.interceptors);");
w.closeBlock("}");
w.write("");

Expand Down
9 changes: 9 additions & 0 deletions docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,15 @@ the project) completes the tutorial without help; BCR + Maven Central packaging
malformed tests.
- **Server robustness**: thread-pool tuning knobs, graceful shutdown/drain, request size limits,
slow-client timeouts, structured logging hooks, metrics hooks (request count/latency callbacks).
- **User-supplied middleware**: first-class extension seams on both sides, so auth, logging,
tracing, and metrics are user-composable rather than one-off knobs (smithy-rs prior art:
client interceptors + tower layers). Client side: an interceptor chain on `ClientConfig` with
hooks around the full call and around each attempt (mutate the outgoing `HttpRequest` —
e.g. inject auth headers — and observe the `HttpResponse`/outcome). Server side: middleware
wrapping the transport-facing `RequestHandler` (a decorator: pre-dispatch request
inspection/rejection, post-dispatch response observation), composing outside the generated
router so it works with any transport. The logging/metrics hooks above and the auth hooks
below should be built as middleware on these seams, not as parallel mechanisms.
- **Auth hooks**: `@httpBearerAuth` / `@httpApiKeyAuth` support — client-side credential
providers, server-side authenticator interface (vendor-specific schemes such as SigV4 are out
of scope, per §2).
Expand Down
70 changes: 65 additions & 5 deletions docs/production-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,70 @@ Compression trades CPU for bytes: leave the 10 KiB threshold alone unless
you have measured small-payload wins; compressing tiny bodies usually
inflates them.

## Client interceptors

`config.interceptors` (`smithy/client/interceptor.h`) hooks user code around
every HTTP attempt a generated client makes — auth headers, tracing ids,
request/response logging — without touching generated code:

```cpp
class BearerAuth final : public smithy::Interceptor {
public:
void ModifyBeforeTransmit(smithy::http::HttpRequest& request, int attempt) override {
request.headers.Set("authorization", "Bearer " + LoadToken());
}
void ReadAfterTransmit(const smithy::http::HttpRequest& request,
const smithy::Outcome<smithy::http::HttpResponse>& outcome,
int attempt) override {
LogAttempt(request.target, attempt, outcome.ok() ? outcome->status : -1);
}
};

config.interceptors.push_back(std::make_shared<BearerAuth>());
```

Interceptors run in registration order, around each attempt (retries included
— `attempt` is 1-based). `ModifyBeforeTransmit` mutates a fresh copy of the
request per attempt, so edits never accumulate across retries or leak into
the caller's view. Hooks must not throw.

## Server middleware

Generated servers expose their router as a plain
`smithy::http::RequestHandler`, so cross-cutting server behavior — auth
checks, request logging, metrics — composes as middleware outside the
generated code (`smithy/server/middleware.h`), with any transport:

```cpp
WeatherServer server(handler);

auto require_auth = [](smithy::http::RequestHandler next) {
return [next = std::move(next)](const smithy::http::HttpRequest& request) {
if (!Authorized(request)) return smithy::http::HttpResponse{401, {}, ""};
return next(request);
};
};

transport.Start(smithy::server::Chain(
{require_auth,
smithy::server::Observe([](const smithy::server::RequestObservation& o) {
// o.method, o.target, o.status, o.duration — log or feed metrics
// (count = one callback per request, latency = o.duration).
})},
server.Handler()));
```

The first middleware in the chain is outermost: it sees the request first and
the response last, and can short-circuit before the router runs. `Observe` is
the built-in structured-logging/metrics hook; its callback runs on the
transport's request thread, so keep it cheap or hand off.

## Server hardening

The production server transport (`BeastServerTransport`, ADR-0006) already
enforces per-connection timeouts, body-size limits, and graceful shutdown;
see [server-guide.md](server-guide.md). Phase 7b extends this area
(thread-pool sizing, drain, slow-client handling, logging/metrics hooks) —
see [PLAN.md](PLAN.md).
The production server transport (`BeastServerTransport`, ADR-0006) enforces
per-connection timeouts (`request_timeout_seconds`), body- and header-size
limits (`max_body_bytes`, `max_header_bytes`), and drains on `Stop()`: new
connections and keep-alive reads cease immediately, while requests already
read off the wire get up to `drain_timeout_seconds` (default 10) to finish
writing their responses before the thread pool is torn down. See
[server-guide.md](server-guide.md).
6 changes: 3 additions & 3 deletions docs/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ crates (PLAN §3.2a).
| `//runtime:json` | `smithy::json` | `Document` ⇄ JSON text via nlohmann (blobs as base64, timestamps per stored format) |
| `//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-size limits, graceful shutdown. Separate target so Boost stays out of dep-light builds |
| `//runtime:client` | `smithy` | `ClientConfig` (endpoint, timeout, user-agent, transport injection, `RetryPolicy`, request-compression threshold), `SendWithRetries` (full-jitter exponential backoff over transport errors and 429/5xx — see docs/production-guide.md) |
| `//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: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` |
| `//runtime:server` | `smithy::server` | `Router` (literal > label > greedy precedence, 404/405/400), `RequestContext`, `MakeErrorResponse`, `ValidationFailure`, user-supplied `Middleware` + `Chain` + the `Observe` logging/metrics hook |

## Design rules

Expand Down
5 changes: 5 additions & 0 deletions docs/server-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ transport.Start(server.Handler());
// or smithy::http::Loopback for in-process tests, SocketHttpServer for the built-in listener.
```

Cross-cutting behavior (auth checks, request logging, metrics) wraps `server.Handler()` as
user-supplied middleware — `smithy::server::Chain` composes it outside the generated router,
and `smithy::server::Observe` is the built-in logging/metrics hook. See
[production-guide.md](production-guide.md).

Routing (method + URI pattern from `@http`, greedy labels, 404/405 with `Allow`),
request-binding deserialization (labels, query incl. `@httpQueryParams`, headers, JSON/CBOR
bodies), and response serialization (status, headers, body) are all generated; rpcv2Cbor
Expand Down
2 changes: 1 addition & 1 deletion examples/cafe/generated/src/client.cc
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ smithy::Outcome<smithy::http::HttpResponse> CafeClient::Send(smithy::http::HttpR
if (!request.body.empty()) {
request.headers.Set("content-length", std::to_string(request.body.size()));
}
return smithy::SendWithRetries(*transport_, request, config_.retry);
return smithy::SendWithRetries(*transport_, request, config_.retry, config_.interceptors);
}

smithy::Outcome<GetOrderOutput> CafeClient::GetOrder(const GetOrderInput& input) const {
Expand Down
2 changes: 1 addition & 1 deletion examples/roundtrip/rest/generated/src/client.cc
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ smithy::Outcome<smithy::http::HttpResponse> RoundTripRestClient::Send(smithy::ht
if (!request.body.empty()) {
request.headers.Set("content-length", std::to_string(request.body.size()));
}
return smithy::SendWithRetries(*transport_, request, config_.retry);
return smithy::SendWithRetries(*transport_, request, config_.retry, config_.interceptors);
}

smithy::Outcome<DescribeSinkOutput> RoundTripRestClient::DescribeSink(const DescribeSinkInput& input) const {
Expand Down
2 changes: 1 addition & 1 deletion examples/roundtrip/rpc/generated/src/client.cc
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ smithy::Outcome<smithy::http::HttpResponse> RoundTripRpcClient::Send(smithy::htt
if (!request.body.empty()) {
request.headers.Set("content-length", std::to_string(request.body.size()));
}
return smithy::SendWithRetries(*transport_, request, config_.retry);
return smithy::SendWithRetries(*transport_, request, config_.retry, config_.interceptors);
}

smithy::Outcome<PutSinkRpcOutput> RoundTripRpcClient::PutSinkRpc(const PutSinkRpcInput& input) const {
Expand Down
2 changes: 1 addition & 1 deletion examples/weather/generated/src/client.cc
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ smithy::Outcome<smithy::http::HttpResponse> WeatherClient::Send(smithy::http::Ht
if (!request.body.empty()) {
request.headers.Set("content-length", std::to_string(request.body.size()));
}
return smithy::SendWithRetries(*transport_, request, config_.retry);
return smithy::SendWithRetries(*transport_, request, config_.retry, config_.interceptors);
}

smithy::Outcome<DeleteCityOutput> WeatherClient::DeleteCity(const DeleteCityInput& input) const {
Expand Down
63 changes: 63 additions & 0 deletions examples/weather/generated_server_e2e_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@

#include <chrono>
#include <memory>
#include <utility>
#include <vector>

#include "example/weather/client.h"
#include "example/weather/server.h"
#include "examples/weather/handwritten/weather_client.h"
#include "smithy/client/interceptor.h"
#include "smithy/http/loopback.h"
#include "smithy/http/message.h"
#include "smithy/server/middleware.h"

namespace example::weather {
namespace {
Expand Down Expand Up @@ -121,6 +125,65 @@ TEST_F(GeneratedServerEndToEndTest, GeneratedClientRetriesTransientFailures) {
EXPECT_EQ(city->name, "Seattle");
}

// Client interceptor + server middleware together (Phase 7b): the interceptor
// injects a bearer token on every generated-client request; server middleware
// rejects requests without it before the router runs, and Observe reports the
// served request.
TEST_F(GeneratedServerEndToEndTest, InterceptorAndMiddlewareCarryAuthAcrossTheWire) {
class BearerAuth final : public smithy::Interceptor {
public:
void ModifyBeforeTransmit(smithy::http::HttpRequest& request, int) override {
request.headers.Set("authorization", "Bearer smoke-token");
}
};

std::vector<smithy::server::RequestObservation> observations;
auto require_auth = [](smithy::http::RequestHandler next) {
return [next = std::move(next)](const smithy::http::HttpRequest& request) {
if (request.headers.Get("authorization") != "Bearer smoke-token") {
smithy::http::HttpResponse response;
response.status = 401;
return response;
}
return next(request);
};
};
auto handler = smithy::server::Chain(
{require_auth, smithy::server::Observe([&](const smithy::server::RequestObservation& o) {
observations.push_back(o);
})},
server_->Handler());

auto loopback = std::make_shared<smithy::http::Loopback>();
ASSERT_TRUE(loopback->Start(handler).ok());

// Without the interceptor the middleware rejects the call outright.
{
smithy::ClientConfig config;
config.http_client = loopback;
config.retry.max_attempts = 1;
auto client = example::weather::WeatherClient::Create(std::move(config));
ASSERT_TRUE(client.ok());
const auto city = client->GetCity(example::weather::GetCityInput{.cityId = "seattle"});
ASSERT_FALSE(city.ok());
}

smithy::ClientConfig config;
config.http_client = loopback;
config.interceptors.push_back(std::make_shared<BearerAuth>());
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");

// Observe sits inside the auth check, so only the authorized call reports.
ASSERT_EQ(observations.size(), 1u);
EXPECT_EQ(observations[0].method, "GET");
EXPECT_EQ(observations[0].target, "/cities/seattle");
EXPECT_EQ(observations[0].status, 200);
}

TEST_F(GeneratedServerEndToEndTest, DeleteCityIs204WithNoBody) {
smithy::http::HttpRequest request;
request.method = "DELETE";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ smithy::Outcome<smithy::http::HttpResponse> RestJsonValidationClient::Send(smith
if (!request.body.empty()) {
request.headers.Set("content-length", std::to_string(request.body.size()));
}
return smithy::SendWithRetries(*transport_, request, config_.retry);
return smithy::SendWithRetries(*transport_, request, config_.retry, config_.interceptors);
}

smithy::Outcome<MalformedEnumOutput> RestJsonValidationClient::MalformedEnum(const MalformedEnumInput& input) const {
Expand Down
2 changes: 1 addition & 1 deletion protocol-tests/restjson1/generated/src/client.cc
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ smithy::Outcome<smithy::http::HttpResponse> RestJsonClient::Send(smithy::http::H
if (!request.body.empty()) {
request.headers.Set("content-length", std::to_string(request.body.size()));
}
return smithy::SendWithRetries(*transport_, request, config_.retry);
return smithy::SendWithRetries(*transport_, request, config_.retry, config_.interceptors);
}

smithy::Outcome<AllQueryStringTypesOutput> RestJsonClient::AllQueryStringTypes(const AllQueryStringTypesInput& input) const {
Expand Down
2 changes: 1 addition & 1 deletion protocol-tests/rpcv2cbor/generated/src/client.cc
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ smithy::Outcome<smithy::http::HttpResponse> RpcV2ProtocolClient::Send(smithy::ht
if (!request.body.empty()) {
request.headers.Set("content-length", std::to_string(request.body.size()));
}
return smithy::SendWithRetries(*transport_, request, config_.retry);
return smithy::SendWithRetries(*transport_, request, config_.retry, config_.interceptors);
}

smithy::Outcome<EmptyInputOutputOutput> RpcV2ProtocolClient::EmptyInputOutput(const EmptyInputOutputInput& input) const {
Expand Down
22 changes: 20 additions & 2 deletions runtime/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ cc_library(
srcs = ["src/client/retry.cc"],
hdrs = [
"include/smithy/client/config.h",
"include/smithy/client/interceptor.h",
"include/smithy/client/retry.h",
],
copts = COPTS,
Expand All @@ -224,8 +225,14 @@ cc_test(

cc_library(
name = "server",
srcs = ["src/server/router.cc"],
hdrs = ["include/smithy/server/router.h"],
srcs = [
"src/server/middleware.cc",
"src/server/router.cc",
],
hdrs = [
"include/smithy/server/middleware.h",
"include/smithy/server/router.h",
],
copts = COPTS,
includes = ["include"],
deps = [
Expand All @@ -245,6 +252,17 @@ cc_test(
],
)

cc_test(
name = "middleware_test",
size = "small",
srcs = ["tests/server/middleware_test.cc"],
copts = COPTS,
deps = [
":server",
"@googletest//:gtest_main",
],
)

# Test-only helpers (random Document generator for property tests).
cc_library(
name = "testing",
Expand Down
6 changes: 6 additions & 0 deletions runtime/include/smithy/client/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

#include <memory>
#include <string>
#include <vector>

#include "smithy/client/interceptor.h"
#include "smithy/client/retry.h"
#include "smithy/http/transport.h"

Expand All @@ -30,6 +32,10 @@ struct ClientConfig {
// (the Smithy default; 0 compresses everything).
int request_min_compression_size_bytes = 10240;

// User-supplied hooks around every HTTP attempt (auth headers, logging,
// tracing); run in registration order. See smithy/client/interceptor.h.
std::vector<std::shared_ptr<Interceptor>> interceptors;

// Optional transport override; shared so several clients can reuse one.
std::shared_ptr<http::HttpClient> http_client;
};
Expand Down
38 changes: 38 additions & 0 deletions runtime/include/smithy/client/interceptor.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#ifndef SMITHY_CLIENT_INTERCEPTOR_H_
#define SMITHY_CLIENT_INTERCEPTOR_H_

#include "smithy/core/outcome.h"
#include "smithy/http/message.h"

namespace smithy {

// User-supplied hooks around every HTTP attempt a generated client makes
// (smithy-rs prior art: client interceptors). Register on
// ClientConfig::interceptors; interceptors run in registration order.
// Hooks must not throw — express failures by leaving the request unusable
// for the server (e.g. dropping credentials) rather than raising.
class Interceptor {
public:
virtual ~Interceptor() = default;

// Runs before each attempt (attempt is 1-based; retries see 2, 3, ...).
// Mutate the outgoing request here: auth headers, tracing ids, ...
virtual void ModifyBeforeTransmit(http::HttpRequest& request, int attempt) {
(void)request;
(void)attempt;
}

// Runs after each attempt with the request as sent and the transport
// outcome (a response of any status, or a transport error). Observe only:
// logging, metrics, tracing.
virtual void ReadAfterTransmit(const http::HttpRequest& request,
const Outcome<http::HttpResponse>& outcome, int attempt) {
(void)request;
(void)outcome;
(void)attempt;
}
};

} // namespace smithy

#endif // SMITHY_CLIENT_INTERCEPTOR_H_
Loading
Loading