From fd568980098ec57647fce5df771d2bc8c89f080a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 06:12:18 +0000 Subject: [PATCH] Phase 5: generated-client vs generated-server integration harness Every fixture module now ships a generated tests/integration_test.cc (behind the new integrationTests plugin setting / --integration-tests runner flag): the module's own generated client drives its own generated server over BOTH the in-memory loopback and a real socket on an ephemeral port, with identical parameterized test bodies. Coverage matrix per operation: - Seeded random round-trips (8 iterations): a ScriptedHandler records the parsed input and answers with a scripted output, so client serialize -> server parse and server serialize -> client parse are asserted independently with operator==. - A maximal row: every optional member set, containers at max size. - One test per modeled error: a random typed detail must survive the wire (code, kind, full detail equality). - Unknown-response-member tolerance: a new smithy::testing::MutatingTransport injects an unknown member into document-map response bodies; clients must ignore it. RandomValueGenerator emits constraint-valid, wire-exact Random builders: pattern strings use fixed Java-checked candidates, numbers respect @range with boundary bias, @uniqueItems lists stay distinct, @idempotencyToken members are always set, timestamps are whole seconds, floats are dyadic eighths, and engaged containers are never empty. UBSan immediately earned its keep by catching an int64 span overflow in the first version of the generated Rng. Fixture corpus: new examples/roundtrip kitchen-sink model generated in BOTH protocol variants from shared shapes - RoundTripRest (every supported HTTP binding at once: label, query, headers, prefix headers, blob and structure payloads, documents, all three timestamp formats, sparse/unique lists, unions, both error classes) and RoundTripRpc (the same shapes over CBOR). Mutation-checked: corrupting a generated serializer (1 + value.precise) fails the suite on the spot; regenerating restores green. CI already runs the full matrix (Linux gcc/clang, macOS, Windows + ASan/UBSan) on every PR, which now includes all four integration suites in both transport modes. Docs: docs/design/integration-testing.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WjaNFwBZxoHdqagvq8ycQf --- README.md | 2 +- codegen/smithy-cpp-codegen/build.gradle.kts | 26 +- .../smithycpp/codegen/CppCodegenRunner.java | 5 + .../io/smithycpp/codegen/CppSettings.java | 24 +- .../smithycpp/codegen/DirectedCppCodegen.java | 7 +- .../codegen/IntegrationTestGenerator.java | 324 +++++++ .../codegen/RandomValueGenerator.java | 352 +++++++ .../codegen/TestsBuildFileGenerator.java | 25 +- docs/design/codegen-architecture.md | 2 + docs/design/integration-testing.md | 79 ++ examples/cafe/generated/tests/BUILD.bazel | 13 + .../cafe/generated/tests/integration_test.cc | 342 +++++++ examples/roundtrip/model/roundtrip.smithy | 239 +++++ examples/roundtrip/rest/generated/BUILD.bazel | 53 ++ .../include/example/roundtrip/rest/client.h | 41 + .../include/example/roundtrip/rest/serde.h | 69 ++ .../include/example/roundtrip/rest/server.h | 43 + .../include/example/roundtrip/rest/types.h | 218 +++++ .../roundtrip/rest/generated/src/client.cc | 293 ++++++ .../roundtrip/rest/generated/src/serde.cc | 871 ++++++++++++++++++ .../roundtrip/rest/generated/src/server.cc | 453 +++++++++ .../rest/generated/tests/BUILD.bazel | 29 + .../rest/generated/tests/integration_test.cc | 464 ++++++++++ .../rest/generated/tests/smoke_test.cc | 144 +++ examples/roundtrip/rpc/generated/BUILD.bazel | 53 ++ .../include/example/roundtrip/rpc/client.h | 36 + .../include/example/roundtrip/rpc/serde.h | 54 ++ .../include/example/roundtrip/rpc/server.h | 38 + .../include/example/roundtrip/rpc/types.h | 171 ++++ .../roundtrip/rpc/generated/src/client.cc | 137 +++ examples/roundtrip/rpc/generated/src/serde.cc | 614 ++++++++++++ .../roundtrip/rpc/generated/src/server.cc | 163 ++++ .../roundtrip/rpc/generated/tests/BUILD.bazel | 29 + .../rpc/generated/tests/integration_test.cc | 301 ++++++ .../rpc/generated/tests/smoke_test.cc | 88 ++ examples/weather/generated/tests/BUILD.bazel | 13 + .../generated/tests/integration_test.cc | 505 ++++++++++ .../include/smithy/testing/protocol_test.h | 23 + .../testing/protocol_test_support_test.cc | 10 + 39 files changed, 6345 insertions(+), 8 deletions(-) create mode 100644 codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/IntegrationTestGenerator.java create mode 100644 codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/RandomValueGenerator.java create mode 100644 docs/design/integration-testing.md create mode 100644 examples/cafe/generated/tests/integration_test.cc create mode 100644 examples/roundtrip/model/roundtrip.smithy create mode 100644 examples/roundtrip/rest/generated/BUILD.bazel create mode 100644 examples/roundtrip/rest/generated/include/example/roundtrip/rest/client.h create mode 100644 examples/roundtrip/rest/generated/include/example/roundtrip/rest/serde.h create mode 100644 examples/roundtrip/rest/generated/include/example/roundtrip/rest/server.h create mode 100644 examples/roundtrip/rest/generated/include/example/roundtrip/rest/types.h create mode 100644 examples/roundtrip/rest/generated/src/client.cc create mode 100644 examples/roundtrip/rest/generated/src/serde.cc create mode 100644 examples/roundtrip/rest/generated/src/server.cc create mode 100644 examples/roundtrip/rest/generated/tests/BUILD.bazel create mode 100644 examples/roundtrip/rest/generated/tests/integration_test.cc create mode 100644 examples/roundtrip/rest/generated/tests/smoke_test.cc create mode 100644 examples/roundtrip/rpc/generated/BUILD.bazel create mode 100644 examples/roundtrip/rpc/generated/include/example/roundtrip/rpc/client.h create mode 100644 examples/roundtrip/rpc/generated/include/example/roundtrip/rpc/serde.h create mode 100644 examples/roundtrip/rpc/generated/include/example/roundtrip/rpc/server.h create mode 100644 examples/roundtrip/rpc/generated/include/example/roundtrip/rpc/types.h create mode 100644 examples/roundtrip/rpc/generated/src/client.cc create mode 100644 examples/roundtrip/rpc/generated/src/serde.cc create mode 100644 examples/roundtrip/rpc/generated/src/server.cc create mode 100644 examples/roundtrip/rpc/generated/tests/BUILD.bazel create mode 100644 examples/roundtrip/rpc/generated/tests/integration_test.cc create mode 100644 examples/roundtrip/rpc/generated/tests/smoke_test.cc create mode 100644 examples/weather/generated/tests/integration_test.cc diff --git a/README.md b/README.md index 7a52e723..54eb0bd1 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ See [`docs/PLAN.md`](docs/PLAN.md) for the full phased plan and | 2 | Codegen plugin + type generation | ✅ Done — types generated for both fixtures, golden+compile+behavior tested ([docs/design/codegen-architecture.md](docs/design/codegen-architecture.md)) | | 3 | Client generation (restJson1 + rpcv2Cbor) | ✅ Done — serde + clients for both protocols with typed errors ([docs/generated-types.md](docs/generated-types.md)); ~240 official protocol conformance cases green (documented exclusions) | | 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 | Not started | +| 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 | Not started | | 7 | Hardening, fuzzing, v0.1.0 | Not started | | 8 | Bidirectional streaming (event streams, WebSockets) | Not started | diff --git a/codegen/smithy-cpp-codegen/build.gradle.kts b/codegen/smithy-cpp-codegen/build.gradle.kts index 84cc424c..570de40f 100644 --- a/codegen/smithy-cpp-codegen/build.gradle.kts +++ b/codegen/smithy-cpp-codegen/build.gradle.kts @@ -20,6 +20,7 @@ fun registerFixtureTask( "--runtime-target", "//runtime:core", "--output", File(repoRoot, outputPath).absolutePath, "--tests-package", "//" + outputPath, + "--integration-tests", "true", ) doFirst { project.delete(File(repoRoot, outputPath)) @@ -42,6 +43,24 @@ val generateCafeFixture = registerFixtureTask( "examples/cafe/generated", ) +// The kitchen-sink round-trip fixture: one model, two protocol variants, so the +// Phase 5 integration matrix covers REST and RPC with the same shapes. +val generateRoundTripRestFixture = registerFixtureTask( + "generateRoundTripRestFixture", + "examples/roundtrip/model/roundtrip.smithy", + "example.roundtrip#RoundTripRest", + "example::roundtrip::rest", + "examples/roundtrip/rest/generated", +) + +val generateRoundTripRpcFixture = registerFixtureTask( + "generateRoundTripRpcFixture", + "examples/roundtrip/model/roundtrip.smithy", + "example.roundtrip#RoundTripRpc", + "example::roundtrip::rpc", + "examples/roundtrip/rpc/generated", +) + // The official protocol-test suite models, kept off the main runtime classpath // so ordinary fixture generation doesn't assemble them. val protocolTestModels: Configuration by configurations.creating @@ -131,7 +150,12 @@ tasks.register("generateProtocolTests") { tasks.register("generateFixtures") { group = "smithy-cpp" description = "Regenerates all checked-in generated code under examples/ (the goldens)" - dependsOn(generateWeatherFixture, generateCafeFixture) + dependsOn( + generateWeatherFixture, + generateCafeFixture, + generateRoundTripRestFixture, + generateRoundTripRpcFixture, + ) } tasks.withType().configureEach { diff --git a/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/CppCodegenRunner.java b/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/CppCodegenRunner.java index d7fe2812..72e49573 100644 --- a/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/CppCodegenRunner.java +++ b/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/CppCodegenRunner.java @@ -41,6 +41,7 @@ public static void main(String[] args) { String output = null; String testsPackage = null; boolean malformedTests = false; + boolean integrationTests = false; List omitOperations = new ArrayList<>(); for (int i = 0; i + 1 < args.length; i += 2) { switch (args[i]) { @@ -52,6 +53,7 @@ public static void main(String[] args) { case "--tests-package" -> testsPackage = args[i + 1]; case "--omit-operation" -> omitOperations.add(args[i + 1]); case "--malformed-tests" -> malformedTests = Boolean.parseBoolean(args[i + 1]); + case "--integration-tests" -> integrationTests = Boolean.parseBoolean(args[i + 1]); default -> throw new IllegalArgumentException("unknown argument: " + args[i]); } } @@ -87,6 +89,9 @@ public static void main(String[] args) { if (malformedTests) { settings.withMember("malformedTests", true); } + if (integrationTests) { + settings.withMember("integrationTests", true); + } PluginContext context = PluginContext.builder() diff --git a/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/CppSettings.java b/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/CppSettings.java index 3ea5a395..033fa773 100644 --- a/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/CppSettings.java +++ b/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/CppSettings.java @@ -22,18 +22,21 @@ public final class CppSettings { private final String runtimeTarget; private final String testsPackage; private final boolean malformedTests; + private final boolean integrationTests; private CppSettings( ShapeId service, String namespace, String runtimeTarget, String testsPackage, - boolean malformedTests) { + boolean malformedTests, + boolean integrationTests) { this.service = service; this.namespace = namespace; this.runtimeTarget = runtimeTarget; this.testsPackage = testsPackage; this.malformedTests = malformedTests; + this.integrationTests = integrationTests; } public static CppSettings fromNode(ObjectNode node) { @@ -43,11 +46,13 @@ public static CppSettings fromNode(ObjectNode node) { node.getStringMemberOrDefault("runtimeTarget", "@smithy_cpp//runtime:core"); String testsPackage = node.getStringMemberOrDefault("testsPackage", null); boolean malformedTests = node.getBooleanMemberOrDefault("malformedTests", false); + boolean integrationTests = node.getBooleanMemberOrDefault("integrationTests", false); if (!namespace.matches("[A-Za-z_][A-Za-z0-9_]*(::[A-Za-z_][A-Za-z0-9_]*)*")) { throw new IllegalArgumentException( "cpp-codegen: 'namespace' must be a C++ namespace like a::b, got: " + namespace); } - return new CppSettings(service, namespace, runtimeTarget, testsPackage, malformedTests); + return new CppSettings( + service, namespace, runtimeTarget, testsPackage, malformedTests, integrationTests); } public ShapeId service() { @@ -110,6 +115,15 @@ public boolean malformedTests() { return malformedTests; } + /** + * Whether to generate tests/integration_test.cc: generated client vs generated server over + * loopback and real sockets with random round-trips (PLAN Phase 5). Per-module, like the + * conformance suites. + */ + public boolean integrationTests() { + return integrationTests; + } + @Override public boolean equals(Object other) { if (!(other instanceof CppSettings that)) { @@ -119,11 +133,13 @@ public boolean equals(Object other) { && namespace.equals(that.namespace) && runtimeTarget.equals(that.runtimeTarget) && Objects.equals(testsPackage, that.testsPackage) - && malformedTests == that.malformedTests; + && malformedTests == that.malformedTests + && integrationTests == that.integrationTests; } @Override public int hashCode() { - return Objects.hash(service, namespace, runtimeTarget, testsPackage, malformedTests); + return Objects.hash( + service, namespace, runtimeTarget, testsPackage, malformedTests, integrationTests); } } diff --git a/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/DirectedCppCodegen.java b/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/DirectedCppCodegen.java index e312f862..6687505f 100644 --- a/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/DirectedCppCodegen.java +++ b/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/DirectedCppCodegen.java @@ -91,7 +91,12 @@ public void generateService(GenerateServiceDirective di hasMalformedTests) .run(); } - TestsBuildFileGenerator.run(directive.context(), hasProtocolTests, hasMalformedTests); + boolean hasIntegrationTests = directive.settings().integrationTests(); + if (hasIntegrationTests) { + new IntegrationTestGenerator(directive.context(), service, protocol, operations).run(); + } + TestsBuildFileGenerator.run( + directive.context(), hasProtocolTests, hasMalformedTests, hasIntegrationTests); } } } diff --git a/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/IntegrationTestGenerator.java b/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/IntegrationTestGenerator.java new file mode 100644 index 00000000..dc1c1229 --- /dev/null +++ b/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/IntegrationTestGenerator.java @@ -0,0 +1,324 @@ +package io.smithycpp.codegen; + +import java.util.List; +import software.amazon.smithy.model.knowledge.HttpBinding; +import software.amazon.smithy.model.knowledge.HttpBindingIndex; +import software.amazon.smithy.model.shapes.OperationShape; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.shapes.StructureShape; +import software.amazon.smithy.model.traits.HttpTrait; + +/** + * Emits tests/integration_test.cc: the generated client exercises the generated server over both + * the loopback and a real-socket transport (PLAN Phase 5). Per operation: seeded random-input round + * trips (client serialize → server deserialize compared member-wise, and server serialize → client + * deserialize likewise), a "maximal" row with every optional member set, one test per modeled error + * with a random typed detail, and unknown-response-member tolerance through a body-mutating + * transport. + */ +final class IntegrationTestGenerator { + + private final CppContext context; + private final ServiceShape service; + private final ProtocolGenerator protocol; + private final List operations; + private final RandomValueGenerator random; + + IntegrationTestGenerator( + CppContext context, + ServiceShape service, + ProtocolGenerator protocol, + List operations) { + this.context = context; + this.service = service; + this.protocol = protocol; + this.operations = operations; + this.random = new RandomValueGenerator(context); + } + + void run() { + context.writerDelegator().useFileWriter("tests/integration_test.cc", this::writeSource); + } + + private String serviceName() { + return CppReservedWords.escape(service.getId().getName()); + } + + private String opName(OperationShape operation) { + return CppReservedWords.escape(operation.getId().getName()); + } + + private StructureShape input(OperationShape operation) { + return ProtocolSupport.inputShape(context, operation); + } + + private StructureShape output(OperationShape operation) { + return ProtocolSupport.outputShape(context, operation); + } + + private String typeName(StructureShape shape) { + return context.cppSymbols().toSymbol(shape).getName(); + } + + private String randomName(StructureShape shape) { + return "Random" + SerdeCodeGen.serdeFunctionSuffix(context, shape); + } + + /** "output.X = 201;" when the output binds @httpResponseCode (random ints aren't statuses). */ + private String responseCodeOverride(OperationShape operation) { + if (operation.getTrait(HttpTrait.class).isEmpty()) { + return null; + } + var index = HttpBindingIndex.of(context.model()); + for (var binding : index.getResponseBindings(operation).values()) { + if (binding.getLocation() == HttpBinding.Location.RESPONSE_CODE) { + return "output." + context.cppSymbols().toMemberName(binding.getMember()) + " = 201;"; + } + } + return null; + } + + /** Unknown-member injection only works on document(-map) response bodies. */ + private boolean unknownFieldEligible(OperationShape operation) { + if (operation.getTrait(HttpTrait.class).isEmpty()) { + return true; // rpcv2Cbor: the response body is always a CBOR map. + } + HttpTrait http = operation.expectTrait(HttpTrait.class); + if (http.getCode() == 204) { + return false; + } + var index = HttpBindingIndex.of(context.model()); + return index.getResponseBindings(operation).values().stream() + .noneMatch(b -> b.getLocation() == HttpBinding.Location.PAYLOAD); + } + + private void writeSource(CppWriter w) { + w.addInclude(""); + w.addInclude(""); + w.addInclude(""); + w.addInclude(""); + w.addInclude(""); + w.addInclude(""); + w.addInclude("\"" + context.settings().includePrefix() + "/client.h\""); + w.addInclude("\"" + context.settings().includePrefix() + "/server.h\""); + w.addInclude("\"smithy/client/config.h\""); + w.addInclude("\"smithy/http/loopback.h\""); + w.addInclude("\"smithy/http/socket_transport.h\""); + w.addInclude("\"smithy/testing/protocol_test.h\""); + if (protocol.contentType().equals("application/cbor")) { + w.addInclude("\"smithy/cbor/cbor.h\""); + w.addInclude("\"smithy/core/blob.h\""); + } else { + w.addInclude("\"smithy/json/json.h\""); + } + + String name = serviceName(); + w.write("// Integration tests for the generated $L service (PLAN Phase 5): the", name); + w.write("// generated client drives the generated server over the loopback transport"); + w.write("// AND a real socket on an ephemeral port. Random inputs are seeded and"); + w.write("// constraint-valid; a failure reproduces deterministically."); + w.write(""); + w.write("namespace {"); + w.write(""); + RandomValueGenerator.writeRngStruct(w); + random.writeBuilders(w); + writeScriptedHandler(w); + writeFixture(w); + w.write(""); + + for (OperationShape operation : operations) { + writeRoundTripTests(w, operation); + } + for (OperationShape operation : operations) { + writeErrorTests(w, operation); + } + for (OperationShape operation : operations) { + if (unknownFieldEligible(operation)) { + writeUnknownFieldTest(w, operation); + } + } + w.write( + "INSTANTIATE_TEST_SUITE_P(Transports, $LIntegrationTest, " + + "::testing::Values(TransportKind::kLoopback, TransportKind::kSocket), " + + "[](const auto& info) { return info.param == TransportKind::kLoopback ? " + + "\"Loopback\" : \"Socket\"; });", + name); + w.write(""); + w.write("} // namespace"); + } + + /** Handler that records the parsed input and answers with a scripted output or error. */ + private void writeScriptedHandler(CppWriter w) { + w.openBlock("class ScriptedHandler final : public $LHandler {", serviceName()); + w.write("public:").indent(); + for (OperationShape operation : operations) { + String op = opName(operation); + w.openBlock( + "smithy::Outcome<$L> $L(const $L& input) override {", + typeName(output(operation)), + op, + typeName(input(operation))); + w.write("last$L = input;", op); + w.write("if (next$LError.has_value()) return *next$LError;", op, op); + w.write("return next$LOutput;", op); + w.closeBlock("}"); + w.write("std::optional<$L> last$L;", typeName(input(operation)), op); + w.write("$L next$LOutput{};", typeName(output(operation)), op); + w.write("std::optional next$LError;", op); + } + w.dedent(); + w.closeBlock("};"); + w.write(""); + } + + private void writeFixture(CppWriter w) { + String name = serviceName(); + w.write("enum class TransportKind { kLoopback, kSocket };"); + w.write(""); + w.openBlock("class $LIntegrationTest : public ::testing::TestWithParam {", name); + w.write("protected:").indent(); + w.openBlock("void SetUp() override {"); + w.write("handler_ = std::make_shared();"); + w.write("server_ = std::make_unique<$LServer>(handler_);", name); + w.write("smithy::ClientConfig config;"); + w.openBlock("if (GetParam() == TransportKind::kLoopback) {"); + w.write("auto loopback = std::make_shared();"); + w.write("ASSERT_TRUE(loopback->Start(server_->Handler()).ok());"); + w.write("config.http_client = loopback;"); + w.closeBlock("} else {"); + w.indent(); + w.write("socket_server_ = std::make_unique();"); + w.write("ASSERT_TRUE(socket_server_->Start(server_->Handler()).ok());"); + w.write("config.endpoint = \"http://127.0.0.1:\" + std::to_string(socket_server_->port());"); + w.closeBlock("}"); + w.write("auto client = $LClient::Create(std::move(config));", name); + w.write("ASSERT_TRUE(client.ok()) << client.error().message();"); + w.write("client_ = std::make_unique<$LClient>(std::move(*client));", name); + w.closeBlock("}"); + w.write(""); + w.write("void TearDown() override { if (socket_server_ != nullptr) socket_server_->Stop(); }"); + w.write(""); + w.write("std::shared_ptr handler_;"); + w.write("std::unique_ptr<$LServer> server_;", name); + w.write("std::unique_ptr socket_server_;"); + w.write("std::unique_ptr<$LClient> client_;", name); + w.dedent(); + w.closeBlock("};"); + } + + /** The shared round-trip body: send random input, compare both directions. */ + private void writeRoundTripBody(CppWriter w, OperationShape operation) { + String op = opName(operation); + w.write("const $L input = $L(rng);", typeName(input(operation)), randomName(input(operation))); + w.write("$L output = $L(rng);", typeName(output(operation)), randomName(output(operation))); + String responseCodeOverride = responseCodeOverride(operation); + if (responseCodeOverride != null) { + w.write("// The @httpResponseCode member drives the wire status; use a real 2xx."); + w.write("$L", responseCodeOverride); + } + w.write("handler_->next$LOutput = output;", op); + w.write("const auto outcome = client_->$L(input);", op); + w.write("ASSERT_TRUE(outcome.ok()) << outcome.error().message();"); + w.write("ASSERT_TRUE(handler_->last$L.has_value());", op); + w.write("EXPECT_EQ(*handler_->last$L, input);", op); + w.write("EXPECT_EQ(*outcome, output);"); + } + + private void writeRoundTripTests(CppWriter w, OperationShape operation) { + String name = serviceName(); + String op = opName(operation); + w.openBlock("TEST_P($LIntegrationTest, $LRandomRoundTrips) {", name, op); + w.write("Rng rng{std::mt19937{20260707U}, /*fill_all=*/false};"); + w.openBlock("for (int iteration = 0; iteration < 8; ++iteration) {"); + writeRoundTripBody(w, operation); + w.closeBlock("}"); + w.closeBlock("}"); + w.write(""); + w.openBlock("TEST_P($LIntegrationTest, $LMaximalRoundTrips) {", name, op); + w.write("Rng rng{std::mt19937{7U}, /*fill_all=*/true};"); + writeRoundTripBody(w, operation); + w.closeBlock("}"); + w.write(""); + } + + /** One test per modeled error: a random typed detail must survive the wire. */ + private void writeErrorTests(CppWriter w, OperationShape operation) { + String name = serviceName(); + String op = opName(operation); + for (ShapeId errorId : operation.getErrors(service)) { + StructureShape error = context.model().expectShape(errorId).asStructureShape().orElseThrow(); + String errorType = context.cppSymbols().toSymbol(error).getName(); + String wireName = error.getId().getName(); + w.openBlock("TEST_P($LIntegrationTest, $L$LMapsAcrossTheWire) {", name, op, errorType); + w.write("Rng rng{std::mt19937{42U}, /*fill_all=*/true};"); + w.write( + "const $L detail = Random$L(rng);", + errorType, + SerdeCodeGen.serdeFunctionSuffix(context, error)); + w.write("smithy::Error error = smithy::Error::Modeled($S, \"integration\");", wireName); + w.write("error.set_detail(detail);"); + w.write("handler_->next$LError = error;", op); + writeMinimalCall(w, operation); + w.write("ASSERT_FALSE(outcome.ok());"); + w.write("EXPECT_EQ(outcome.error().kind(), smithy::ErrorKind::kModeled);"); + w.write("EXPECT_EQ(outcome.error().code(), $S);", wireName); + w.write("ASSERT_NE(outcome.error().detail<$L>(), nullptr);", errorType); + w.write("EXPECT_EQ(*outcome.error().detail<$L>(), detail);", errorType); + w.closeBlock("}"); + w.write(""); + } + } + + /** Calls the operation with a routable random input, leaving `outcome` in scope. */ + private void writeMinimalCall(CppWriter w, OperationShape operation) { + w.write("const $L input = $L(rng);", typeName(input(operation)), randomName(input(operation))); + w.write("const auto outcome = client_->$L(input);", opName(operation)); + } + + /** Servers may add members old clients don't know; clients must ignore them. */ + private void writeUnknownFieldTest(CppWriter w, OperationShape operation) { + String name = serviceName(); + String op = opName(operation); + boolean cbor = protocol.contentType().equals("application/cbor"); + w.openBlock("TEST($LIntegrationUnknownMembers, $LToleratesUnknownResponseMembers) {", name, op); + w.write("auto handler = std::make_shared();"); + w.write("$LServer server(handler);", name); + w.write("auto loopback = std::make_shared();"); + w.write("ASSERT_TRUE(loopback->Start(server.Handler()).ok());"); + w.openBlock("auto inject = [](smithy::http::HttpResponse& response) {"); + if (cbor) { + w.write("auto doc = smithy::cbor::Decode(smithy::Blob::FromString(response.body));"); + } else { + w.write("auto doc = smithy::json::Decode(response.body);"); + } + w.write("if (!doc.ok() || !doc->is_map()) return;"); + w.write("auto map = doc->as_map();"); + w.write("map.insert_or_assign(\"smithy_cpp_unknown_member\", smithy::Document(42));"); + if (cbor) { + w.write("response.body = smithy::cbor::Encode(smithy::Document(std::move(map))).ToString();"); + } else { + w.write("response.body = smithy::json::Encode(smithy::Document(std::move(map)));"); + } + w.closeBlock("};"); + w.write( + "auto transport = std::make_shared(loopback, " + + "inject);"); + w.write("smithy::ClientConfig config;"); + w.write("config.http_client = transport;"); + w.write("auto client = *$LClient::Create(std::move(config));", name); + w.write("Rng rng{std::mt19937{99U}, /*fill_all=*/true};"); + w.write("const $L input = $L(rng);", typeName(input(operation)), randomName(input(operation))); + w.write("$L output = $L(rng);", typeName(output(operation)), randomName(output(operation))); + String responseCodeOverride = responseCodeOverride(operation); + if (responseCodeOverride != null) { + w.write("$L", responseCodeOverride); + } + w.write("handler->next$LOutput = output;", op); + w.write("const auto outcome = client.$L(input);", op); + w.write("ASSERT_TRUE(outcome.ok()) << outcome.error().message();"); + w.write("EXPECT_EQ(*outcome, output);"); + w.closeBlock("}"); + w.write(""); + } +} diff --git a/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/RandomValueGenerator.java b/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/RandomValueGenerator.java new file mode 100644 index 00000000..f9541c60 --- /dev/null +++ b/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/RandomValueGenerator.java @@ -0,0 +1,352 @@ +package io.smithycpp.codegen; + +import java.util.Optional; +import software.amazon.smithy.codegen.core.CodegenException; +import software.amazon.smithy.model.shapes.ListShape; +import software.amazon.smithy.model.shapes.MapShape; +import software.amazon.smithy.model.shapes.MemberShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.UnionShape; +import software.amazon.smithy.model.traits.LengthTrait; +import software.amazon.smithy.model.traits.PatternTrait; +import software.amazon.smithy.model.traits.RangeTrait; +import software.amazon.smithy.model.traits.SparseTrait; +import software.amazon.smithy.model.traits.Trait; +import software.amazon.smithy.model.traits.UniqueItemsTrait; + +/** + * Emits deterministic random-value builders for the generated integration tests: an {@code Rng} + * helper plus one {@code Random(Rng&)} function per aggregate shape. Values always satisfy + * the model's constraint traits (the server validates before the handler runs) and stay inside the + * subset that round-trips exactly on the wire: alphanumeric strings, whole-second timestamps, + * dyadic-fraction floats, non-empty engaged containers, and non-empty blobs. + */ +final class RandomValueGenerator { + + private final CppContext context; + + RandomValueGenerator(CppContext context) { + this.context = context; + } + + private Shape target(MemberShape member) { + return context.model().expectShape(member.getTarget()); + } + + private Optional constraint(MemberShape member, Class t) { + Optional onMember = member.getTrait(t); + return onMember.isPresent() ? onMember : target(member).getTrait(t); + } + + /** The shared Rng helper (deterministic; {@code fill_all} drives the maximal coverage rows). */ + static void writeRngStruct(CppWriter w) { + w.addInclude(""); + w.addInclude(""); + w.addInclude(""); + w.addInclude(""); + w.write("// Deterministic pseudo-random source for round-trip inputs; fill_all forces"); + w.write("// every optional member on and maximum container sizes (the \"maximal\" rows)."); + w.openBlock("struct Rng {"); + w.write("std::mt19937 engine;"); + w.write("bool fill_all = false;"); + w.write(""); + w.write("bool Coin() { return fill_all || (engine() & 1U) != 0; }"); + w.write(""); + w.openBlock("std::size_t Size(std::size_t min_size, std::size_t max_size) {"); + w.write("if (fill_all || max_size <= min_size) return max_size;"); + w.write("return min_size + engine() % (max_size - min_size + 1);"); + w.closeBlock("}"); + w.write(""); + w.write("// Inclusive and boundary-biased: min/max come up 10% of the time each."); + w.openBlock("std::int64_t Int(std::int64_t min_value, std::int64_t max_value) {"); + w.write("const auto pick = engine() % 10;"); + w.write("if (pick == 0 || min_value >= max_value) return min_value;"); + w.write("if (pick == 1) return max_value;"); + w.write("// The span is computed unsigned: wide ranges overflow int64 arithmetic."); + w.write( + "const std::uint64_t span = static_cast(max_value) - " + + "static_cast(min_value);"); + w.write("return min_value + static_cast(engine() % (span + 1));"); + w.closeBlock("}"); + w.write(""); + w.openBlock("std::string Text(std::size_t min_len, std::size_t max_len) {"); + w.write( + "static const char kAlphabet[] = " + + "\"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\";"); + w.write("const std::size_t n = Size(min_len, max_len);"); + w.write("std::string out;"); + w.write("out.reserve(n);"); + w.write("for (std::size_t i = 0; i < n; ++i) out.push_back(kAlphabet[engine() % 62]);"); + w.write("return out;"); + w.closeBlock("}"); + w.closeBlock("};"); + w.write(""); + } + + /** Emits Random for every aggregate serde shape, in topological order. */ + void writeBuilders(CppWriter w) { + SerdeGenerator ordering = new SerdeGenerator(context, false); + for (Shape shape : ordering.serdeShapes()) { + writeBuilder(w, shape); + } + } + + private String builderName(Shape shape) { + return "Random" + SerdeCodeGen.serdeFunctionSuffix(context, shape); + } + + private void writeBuilder(CppWriter w, Shape shape) { + String type = context.cppSymbols().toSymbol(shape).getName(); + w.openBlock("$L $L(Rng& rng) {", type, builderName(shape)); + if (shape.isStructureShape()) { + w.write("$L v{};", type); + for (MemberShape member : shape.members()) { + String field = "v." + context.cppSymbols().toMemberName(member); + // @idempotencyToken members always get a value: the client would + // auto-fill an unset one and break request equality. + boolean alwaysSet = + member.isRequired() + || member.hasTrait(software.amazon.smithy.model.traits.IdempotencyTokenTrait.class); + if (alwaysSet) { + w.write("$L = $L;", field, expression(member)); + } else { + w.write("if (rng.Coin()) $L = $L;", field, expression(member)); + } + } + w.write("return v;"); + } else if (shape.isUnionShape()) { + UnionShape union = shape.asUnionShape().orElseThrow(); + var members = union.members().stream().toList(); + w.openBlock("switch (rng.engine() % $L) {", members.size()); + for (int i = 0; i < members.size(); ++i) { + MemberShape member = members.get(i); + String factory = + software.amazon.smithy.utils.CaseUtils.toPascalCase( + context.cppSymbols().toMemberName(member)); + if (i + 1 == members.size()) { + w.write("default:").indent(); + } else { + w.write("case $L:", i).indent(); + } + w.write("return $L::From$L($L);", type, factory, expression(member)); + w.dedent(); + } + w.closeBlock("}"); + } else if (shape.isListShape()) { + ListShape list = shape.asListShape().orElseThrow(); + MemberShape element = list.getMember(); + boolean sparse = shape.hasTrait(SparseTrait.class); + boolean unique = hasEffective(element, shape, UniqueItemsTrait.class); + long[] size = containerSize(shape, unique); + w.write("$L v{};", type); + w.write("const std::size_t n = rng.Size($L, $L);", size[0], size[1]); + w.openBlock("for (std::size_t i = 0; i < n; ++i) {"); + if (sparse) { + w.write("if (!rng.fill_all && rng.engine() % 4 == 0) {"); + w.indent(); + w.write("v.push_back(std::nullopt);"); + w.write("continue;"); + w.dedent(); + w.write("}"); + } + if (unique && target(element).isStringShape()) { + // Distinct entries so @uniqueItems validation passes. + w.write("v.push_back($L + std::to_string(i));", expression(element)); + } else { + w.write("v.push_back($L);", expression(element)); + } + w.closeBlock("}"); + w.write("return v;"); + } else if (shape.isMapShape()) { + MapShape map = shape.asMapShape().orElseThrow(); + long[] size = containerSize(shape, false); + w.write("$L v{};", type); + w.write("const std::size_t n = rng.Size($L, $L);", size[0], size[1]); + w.openBlock("for (std::size_t i = 0; i < n; ++i) {"); + // The index suffix keeps keys distinct; key constraints still hold + // because the base stays within bounds and the suffix is one digit. + w.write( + "v.insert_or_assign($L + std::to_string(i), $L);", + keyExpression(map.getKey()), + expression(map.getValue())); + w.closeBlock("}"); + w.write("return v;"); + } else { + throw new CodegenException("cpp-codegen: no random builder for " + shape.getId()); + } + w.closeBlock("}"); + w.write(""); + } + + private static boolean hasEffective( + MemberShape member, Shape container, Class t) { + return member.hasTrait(t) || container.hasTrait(t); + } + + /** min/max entry count for a container, honoring @length and keeping tests fast. */ + private long[] containerSize(Shape shape, boolean unique) { + Optional length = shape.getTrait(LengthTrait.class); + long min = Math.max(1, length.flatMap(LengthTrait::getMin).orElse(1L)); + long cap = unique ? Math.max(min, 2) : min + 2; + long max = Math.min(length.flatMap(LengthTrait::getMax).orElse(cap), cap); + return new long[] {min, Math.max(min, max)}; + } + + /** Map keys are plain strings; honor key-member constraints minus the digit suffix. */ + private String keyExpression(MemberShape key) { + Optional pattern = constraint(key, PatternTrait.class); + long min = constraint(key, LengthTrait.class).flatMap(LengthTrait::getMin).orElse(1L); + if (pattern.isPresent()) { + return "std::string(" + CppLiterals.stringLiteral(patternCandidate(pattern.get(), min)) + ")"; + } + long max = + Math.min( + constraint(key, LengthTrait.class).flatMap(LengthTrait::getMax).orElse(min + 6) - 1, + min + 6); + return "rng.Text(" + Math.max(1, min) + ", " + Math.max(Math.max(1, min), max) + ")"; + } + + /** A fixed string satisfying the pattern (best effort, Java-checked candidates). */ + private static String patternCandidate(PatternTrait pattern, long minLength) { + int n = (int) Math.max(minLength, 1); + for (String fill : new String[] {"0", "a", "A"}) { + String candidate = fill.repeat(n); + try { + if (java.util.regex.Pattern.compile(pattern.getValue()).matcher(candidate).find()) { + return candidate; + } + } catch (java.util.regex.PatternSyntaxException e) { + return candidate; + } + } + return "0".repeat(n); + } + + /** A C++ expression producing a random valid value for the member's target. */ + String expression(MemberShape member) { + Shape target = target(member); + if (target.getId().toString().equals("smithy.api#Unit")) { + return "smithy::Unit{}"; + } + String type = context.cppSymbols().toSymbol(target).getName(); + return switch (target.getType()) { + case BOOLEAN -> "(rng.engine() & 1U) != 0"; + case BYTE, SHORT, INTEGER, LONG -> { + long[] bounds = intBounds(member, target); + yield "static_cast<" + type + ">(rng.Int(" + bounds[0] + "LL, " + bounds[1] + "LL))"; + } + case INT_ENUM -> { + var values = target.asIntEnumShape().orElseThrow().getEnumValues().values(); + StringBuilder choices = new StringBuilder(); + for (Integer value : values) { + if (choices.length() > 0) { + choices.append(", "); + } + choices.append(value); + } + yield "static_cast<" + + type + + ">(std::array{" + + choices + + "}[rng.engine() % " + + values.size() + + "])"; + } + case FLOAT, DOUBLE -> floatingExpression(member, target, type); + case STRING -> stringExpression(member); + case ENUM -> { + var values = target.asEnumShape().orElseThrow().getEnumValues().values(); + StringBuilder choices = new StringBuilder(); + for (String value : values) { + if (choices.length() > 0) { + choices.append(", "); + } + choices.append(CppLiterals.stringLiteral(value)); + } + yield type + + "::FromString(std::array{" + + choices + + "}[rng.engine() % " + + values.size() + + "])"; + } + case BLOB -> { + long min = + Math.max( + 1, constraint(member, LengthTrait.class).flatMap(LengthTrait::getMin).orElse(1L)); + long max = + Math.min( + constraint(member, LengthTrait.class).flatMap(LengthTrait::getMax).orElse(min + 8), + min + 8); + yield "smithy::Blob::FromString(rng.Text(" + min + ", " + Math.max(min, max) + "))"; + } + // Whole seconds survive every timestamp format (http-date has second + // precision); the range stays http-date friendly (1970..2100). + case TIMESTAMP -> "smithy::Timestamp::FromEpochMilliseconds(rng.Int(0, 4102444799LL) * 1000)"; + case DOCUMENT -> + "smithy::Document(smithy::DocumentMap{{\"key\", smithy::Document(rng.Int(0, 1000))}})"; + case STRUCTURE, UNION, LIST, MAP -> builderName(target) + "(rng)"; + default -> throw new CodegenException("cpp-codegen: no random value for " + target.getId()); + }; + } + + private long[] intBounds(MemberShape member, Shape target) { + long lo = + switch (target.getType()) { + case BYTE -> -128; + case SHORT -> -32768; + case INTEGER -> Integer.MIN_VALUE; + default -> -4611686018427387904L; + }; + long hi = + switch (target.getType()) { + case BYTE -> 127; + case SHORT -> 32767; + case INTEGER -> Integer.MAX_VALUE; + default -> 4611686018427387903L; + }; + Optional range = constraint(member, RangeTrait.class); + if (range.isPresent()) { + if (range.get().getMin().isPresent()) { + lo = Math.max(lo, range.get().getMin().get().longValue()); + } + if (range.get().getMax().isPresent()) { + hi = Math.min(hi, range.get().getMax().get().longValue()); + } + } + return new long[] {lo, hi}; + } + + /** Dyadic eighths round-trip exactly through JSON/CBOR in both widths. */ + private String floatingExpression(MemberShape member, Shape target, String type) { + Optional range = constraint(member, RangeTrait.class); + if (range.isPresent()) { + long lo = range.get().getMin().map(min -> (long) Math.ceil(min.doubleValue())).orElse(-1000L); + long hi = range.get().getMax().map(max -> (long) Math.floor(max.doubleValue())).orElse(1000L); + return "static_cast<" + type + ">(rng.Int(" + lo + "LL, " + hi + "LL))"; + } + return "static_cast<" + + type + + ">(rng.Int(-8000000LL, 8000000LL)) / static_cast<" + + type + + ">(8)"; + } + + private String stringExpression(MemberShape member) { + Optional pattern = constraint(member, PatternTrait.class); + long min = constraint(member, LengthTrait.class).flatMap(LengthTrait::getMin).orElse(0L); + if (pattern.isPresent()) { + return "std::string(" + CppLiterals.stringLiteral(patternCandidate(pattern.get(), min)) + ")"; + } + long lo = Math.max(1, min); + long hi = + Math.min( + constraint(member, LengthTrait.class).flatMap(LengthTrait::getMax).orElse(lo + 8), + lo + 8); + return "rng.Text(" + lo + ", " + Math.max(lo, hi) + ")"; + } +} diff --git a/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/TestsBuildFileGenerator.java b/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/TestsBuildFileGenerator.java index 178b34f7..85056857 100644 --- a/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/TestsBuildFileGenerator.java +++ b/codegen/smithy-cpp-codegen/src/main/java/io/smithycpp/codegen/TestsBuildFileGenerator.java @@ -5,7 +5,11 @@ final class TestsBuildFileGenerator { private TestsBuildFileGenerator() {} - static void run(CppContext context, boolean hasProtocolTests, boolean hasMalformedTests) { + static void run( + CppContext context, + boolean hasProtocolTests, + boolean hasMalformedTests, + boolean hasIntegrationTests) { String module = context.settings().testsPackage(); String runtime = context.settings().runtimePackage(); StringBuilder out = new StringBuilder(); @@ -84,6 +88,25 @@ static void run(CppContext context, boolean hasProtocolTests, boolean hasMalform """ .formatted(module, module, runtime)); } + if (hasIntegrationTests) { + out.append( + """ + + cc_test( + name = "integration_test", + size = "small", + srcs = ["integration_test.cc"], + deps = [ + "%s:client", + "%s:server", + "%s:http", + "%s:protocol_test_support", + "@googletest//:gtest_main", + ], + ) + """ + .formatted(module, module, runtime, runtime)); + } context.fileManifest().writeFile("tests/BUILD.bazel", out.toString()); } } diff --git a/docs/design/codegen-architecture.md b/docs/design/codegen-architecture.md index 9a3bbcc4..43c7ea34 100644 --- a/docs/design/codegen-architecture.md +++ b/docs/design/codegen-architecture.md @@ -24,6 +24,8 @@ smithy-rs's `codegen-core` structure (PLAN §3.2a). | `ServerGenerator` | `server.h`/`src/server.cc`: `Handler` interface + `Server` over the runtime router (routing, request parsing, response serialization, error mapping) | | `SmokeTestGenerator` | `tests/smoke_test.cc`: generated client ↔ generated server over loopback, every operation + error mapping (user-facing, passes out of the box) | | `ValidationGenerator` | Server-side constraint validation: per-shape `Validate*` functions (`@required`, `@length`, `@range`, `@pattern`, `@uniqueItems`, enum membership) producing the suite-exact 400 `ValidationException` failures before the handler runs | +| `IntegrationTestGenerator` | `tests/integration_test.cc`: generated client vs generated server over loopback AND real sockets — seeded random round-trips, maximal rows, per-error mapping, unknown-member tolerance (docs/design/integration-testing.md) | +| `RandomValueGenerator` | Constraint-valid, wire-exact `Random(Rng&)` builders backing the integration suites | | `ProtocolTestGenerator` | GoogleTest conformance suites from `smithy.test#httpRequestTests`/`#httpResponseTests`/`#httpMalformedRequestTests` (client and server cases, incl. error shapes), with the must-shrink exclusion list in `resources/.../protocol-test-exclusions.txt` | | `TestsBuildFileGenerator` | The module's `tests/BUILD.bazel` (smoke test + conformance suites when present) | | `NodeLiteralGenerator` / `CppLiterals` | Protocol-test `params` nodes → C++ literals constructing generated types | diff --git a/docs/design/integration-testing.md b/docs/design/integration-testing.md new file mode 100644 index 00000000..0f64d29f --- /dev/null +++ b/docs/design/integration-testing.md @@ -0,0 +1,79 @@ +# Integration testing: generated clients vs. generated servers + +Phase 5's headline requirement: **generated clients integration-test generated servers**, +systematically, for every fixture model. Every module generated with `--integration-tests true` +ships `tests/integration_test.cc` (Bazel target `:integration_test`), in which the module's own +generated client calls its own generated server through real transports. + +## How a suite works + +The generated fixture is parameterized over two transports with identical test bodies: + +- **Loopback** — in-memory `smithy::http::Loopback`; fast, deterministic, sanitizer-friendly. +- **Socket** — `smithy::http::SocketHttpServer` bound to an ephemeral port on 127.0.0.1 with the + client pointed at it; catches transport/framing bugs the loopback can't. + +A generated `ScriptedHandler` records the input each operation received and answers with a +scripted output (or error), so both wire directions are asserted independently: + +1. client serialize → server parse: `handler->lastOp == input` +2. server serialize → client parse: `*outcome == output` + +## The coverage matrix + +Per operation, the generated suite contains: + +| Row | What it does | +|---|---| +| `RandomRoundTrips` | 8 seeded random inputs/outputs round-trip exactly (`operator==`) | +| `MaximalRoundTrips` | every optional member set, containers at max size | +| `MapsAcrossTheWire` | one per modeled error: a random typed detail must survive the wire (code, kind, and full detail equality) | +| `ToleratesUnknownResponseMembers` | a `MutatingTransport` injects an unknown member into the response body; the client must ignore it (documents-map bodies only) | + +## Random values (`RandomValueGenerator`) + +`Random(Rng&)` builders are generated per aggregate shape from the same model the serde +comes from. Two properties matter: + +- **Constraint-valid**: values always satisfy `@length`/`@range`/`@pattern`/`@uniqueItems`/enum + membership — the server validates before the handler runs, so an invalid input would 400. + Pattern-constrained strings use a fixed Java-checked candidate; numbers respect range bounds + (boundary-biased: min/max come up 10% of the time each); `@uniqueItems` string lists get + distinct suffixes; `@idempotencyToken` members are always set (the client would auto-fill an + unset one and break request equality). +- **Wire-exact**: values stay inside the subset that round-trips exactly — alphanumeric strings + (no header/URI escaping edge cases), whole-second timestamps (http-date has second precision), + dyadic-fraction floats (exact in JSON/CBOR and both widths), non-empty engaged containers + (engaged-empty header/query lists don't survive), and non-empty blobs (an empty optional blob + payload reads back as unset). + +The `Rng` is seeded `std::mt19937`, so any failure reproduces deterministically. + +## Fixtures + +| Module | Protocol | Coverage | +|---|---|---| +| `examples/weather` | restJson1 | labels, query, resources, errors, 204 | +| `examples/cafe` | rpcv2Cbor | unions (incl. Unit members), documents-free CBOR | +| `examples/roundtrip` (rest) | restJson1 | kitchen sink: every binding location at once, all three timestamp formats, sparse/unique lists, maps, unions, blob payload + struct payload, prefix headers, documents, both error classes | +| `examples/roundtrip` (rpc) | rpcv2Cbor | the same kitchen-sink shapes over CBOR | + +`examples/roundtrip/model/roundtrip.smithy` defines both services over shared shapes, so the +REST and RPC matrices exercise identical structures. To add a fixture: write the model, register +a `registerFixtureTask` in `codegen/smithy-cpp-codegen/build.gradle.kts` (the +`--integration-tests true` flag is on for all fixture tasks), regenerate, and the suite exists. + +## CI and mutation checking + +`bazel test //...` runs every integration suite — including the socket mode — on Linux +(gcc + clang), macOS, and Windows on every PR, plus a clang ASan+UBSan job. The harness's own +correctness is guarded by mutation checks: deliberately corrupting a generated serializer (e.g. +`1 + value.precise` in the roundtrip serde) makes the suite fail on the spot; this was verified +when the harness landed and should be re-verified when the round-trip machinery changes. + +## Triaging a failure + +Failures name the operation, the direction (`handler->lastOp` mismatch = request path, +`*outcome` mismatch = response path), and the transport (test suffix `/Loopback` or `/Socket`). +A socket-only failure is a transport/framing bug; a both-transport failure is serde or bindings. +Seeds are fixed in the generated file, so rerunning the one test reproduces the exact values. diff --git a/examples/cafe/generated/tests/BUILD.bazel b/examples/cafe/generated/tests/BUILD.bazel index b811012b..c3da8c13 100644 --- a/examples/cafe/generated/tests/BUILD.bazel +++ b/examples/cafe/generated/tests/BUILD.bazel @@ -14,3 +14,16 @@ cc_test( "@googletest//:gtest_main", ], ) + +cc_test( + name = "integration_test", + size = "small", + srcs = ["integration_test.cc"], + deps = [ + "//examples/cafe/generated:client", + "//examples/cafe/generated:server", + "//runtime:http", + "//runtime:protocol_test_support", + "@googletest//:gtest_main", + ], +) diff --git a/examples/cafe/generated/tests/integration_test.cc b/examples/cafe/generated/tests/integration_test.cc new file mode 100644 index 00000000..739370d6 --- /dev/null +++ b/examples/cafe/generated/tests/integration_test.cc @@ -0,0 +1,342 @@ +// Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "example/cafe/client.h" +#include "example/cafe/server.h" +#include "smithy/cbor/cbor.h" +#include "smithy/client/config.h" +#include "smithy/core/blob.h" +#include "smithy/http/loopback.h" +#include "smithy/http/socket_transport.h" +#include "smithy/testing/protocol_test.h" + +namespace example::cafe { + +// Integration tests for the generated Cafe service (PLAN Phase 5): the +// generated client drives the generated server over the loopback transport +// AND a real socket on an ephemeral port. Random inputs are seeded and +// constraint-valid; a failure reproduces deterministically. + +namespace { + +// Deterministic pseudo-random source for round-trip inputs; fill_all forces +// every optional member on and maximum container sizes (the "maximal" rows). +struct Rng { + std::mt19937 engine; + bool fill_all = false; + + bool Coin() { return fill_all || (engine() & 1U) != 0; } + + std::size_t Size(std::size_t min_size, std::size_t max_size) { + if (fill_all || max_size <= min_size) return max_size; + return min_size + engine() % (max_size - min_size + 1); + } + + // Inclusive and boundary-biased: min/max come up 10% of the time each. + std::int64_t Int(std::int64_t min_value, std::int64_t max_value) { + const auto pick = engine() % 10; + if (pick == 0 || min_value >= max_value) return min_value; + if (pick == 1) return max_value; + // The span is computed unsigned: wide ranges overflow int64 arithmetic. + const std::uint64_t span = static_cast(max_value) - static_cast(min_value); + return min_value + static_cast(engine() % (span + 1)); + } + + std::string Text(std::size_t min_len, std::size_t max_len) { + static const char kAlphabet[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + const std::size_t n = Size(min_len, max_len); + std::string out; + out.reserve(n); + for (std::size_t i = 0; i < n; ++i) out.push_back(kAlphabet[engine() % 62]); + return out; + } +}; + +AlternativeMilk RandomAlternativeMilk(Rng& rng) { + AlternativeMilk v{}; + v.kind = rng.Text(1, 9); + return v; +} + +GetOrderInput RandomGetOrderInput(Rng& rng) { + GetOrderInput v{}; + v.orderId = rng.Text(1, 9); + return v; +} + +CancelledStatus RandomCancelledStatus(Rng& rng) { + CancelledStatus v{}; + if (rng.Coin()) v.reason = rng.Text(1, 9); + return v; +} + +PendingStatus RandomPendingStatus(Rng& rng) { + PendingStatus v{}; + v.position = static_cast(rng.Int(-2147483648LL, 2147483647LL)); + return v; +} + +ReadyStatus RandomReadyStatus(Rng& rng) { + ReadyStatus v{}; + v.readyAt = smithy::Timestamp::FromEpochMilliseconds(rng.Int(0, 4102444799LL) * 1000); + return v; +} + +OrderStatus RandomOrderStatus(Rng& rng) { + switch (rng.engine() % 3) { + case 0: + return OrderStatus::FromPending(RandomPendingStatus(rng)); + case 1: + return OrderStatus::FromReady(RandomReadyStatus(rng)); + default: + return OrderStatus::FromCancelled(RandomCancelledStatus(rng)); + } +} + +GetOrderOutput RandomGetOrderOutput(Rng& rng) { + GetOrderOutput v{}; + v.orderId = rng.Text(1, 9); + v.coffeeType = CoffeeType::FromString(std::array{"DRIP", "ESPRESSO", "CORTADO", "LATTE"}[rng.engine() % 4]); + v.status = RandomOrderStatus(rng); + return v; +} + +OrderNotFound RandomOrderNotFound(Rng& rng) { + OrderNotFound v{}; + v.orderId = rng.Text(1, 9); + return v; +} + +DairyMilk RandomDairyMilk(Rng& rng) { + DairyMilk v{}; + v.percentFat = static_cast(rng.Int(-8000000LL, 8000000LL)) / static_cast(8); + return v; +} + +MilkOption RandomMilkOption(Rng& rng) { + switch (rng.engine() % 3) { + case 0: + return MilkOption::FromNone(smithy::Unit{}); + case 1: + return MilkOption::FromDairy(RandomDairyMilk(rng)); + default: + return MilkOption::FromAlternative(RandomAlternativeMilk(rng)); + } +} + +OrderCoffeeInput RandomOrderCoffeeInput(Rng& rng) { + OrderCoffeeInput v{}; + v.coffeeType = CoffeeType::FromString(std::array{"DRIP", "ESPRESSO", "CORTADO", "LATTE"}[rng.engine() % 4]); + if (rng.Coin()) v.milk = RandomMilkOption(rng); + v.clientToken = rng.Text(1, 9); + return v; +} + +OrderCoffeeOutput RandomOrderCoffeeOutput(Rng& rng) { + OrderCoffeeOutput v{}; + v.orderId = rng.Text(1, 9); + v.status = RandomOrderStatus(rng); + return v; +} + +OutOfBeans RandomOutOfBeans(Rng& rng) { + OutOfBeans v{}; + if (rng.Coin()) v.message = rng.Text(1, 9); + return v; +} + +class ScriptedHandler final : public CafeHandler { + public: + smithy::Outcome GetOrder(const GetOrderInput& input) override { + lastGetOrder = input; + if (nextGetOrderError.has_value()) return *nextGetOrderError; + return nextGetOrderOutput; + } + std::optional lastGetOrder; + GetOrderOutput nextGetOrderOutput{}; + std::optional nextGetOrderError; + smithy::Outcome OrderCoffee(const OrderCoffeeInput& input) override { + lastOrderCoffee = input; + if (nextOrderCoffeeError.has_value()) return *nextOrderCoffeeError; + return nextOrderCoffeeOutput; + } + std::optional lastOrderCoffee; + OrderCoffeeOutput nextOrderCoffeeOutput{}; + std::optional nextOrderCoffeeError; +}; + +enum class TransportKind { kLoopback, kSocket }; + +class CafeIntegrationTest : public ::testing::TestWithParam { + protected: + void SetUp() override { + handler_ = std::make_shared(); + server_ = std::make_unique(handler_); + smithy::ClientConfig config; + if (GetParam() == TransportKind::kLoopback) { + auto loopback = std::make_shared(); + ASSERT_TRUE(loopback->Start(server_->Handler()).ok()); + config.http_client = loopback; + } else { + socket_server_ = std::make_unique(); + ASSERT_TRUE(socket_server_->Start(server_->Handler()).ok()); + config.endpoint = "http://127.0.0.1:" + std::to_string(socket_server_->port()); + } + auto client = CafeClient::Create(std::move(config)); + ASSERT_TRUE(client.ok()) << client.error().message(); + client_ = std::make_unique(std::move(*client)); + } + + void TearDown() override { if (socket_server_ != nullptr) socket_server_->Stop(); } + + std::shared_ptr handler_; + std::unique_ptr server_; + std::unique_ptr socket_server_; + std::unique_ptr client_; +}; + +TEST_P(CafeIntegrationTest, GetOrderRandomRoundTrips) { + Rng rng{std::mt19937{20260707U}, /*fill_all=*/false}; + for (int iteration = 0; iteration < 8; ++iteration) { + const GetOrderInput input = RandomGetOrderInput(rng); + GetOrderOutput output = RandomGetOrderOutput(rng); + handler_->nextGetOrderOutput = output; + const auto outcome = client_->GetOrder(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastGetOrder.has_value()); + EXPECT_EQ(*handler_->lastGetOrder, input); + EXPECT_EQ(*outcome, output); + } +} + +TEST_P(CafeIntegrationTest, GetOrderMaximalRoundTrips) { + Rng rng{std::mt19937{7U}, /*fill_all=*/true}; + const GetOrderInput input = RandomGetOrderInput(rng); + GetOrderOutput output = RandomGetOrderOutput(rng); + handler_->nextGetOrderOutput = output; + const auto outcome = client_->GetOrder(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastGetOrder.has_value()); + EXPECT_EQ(*handler_->lastGetOrder, input); + EXPECT_EQ(*outcome, output); +} + +TEST_P(CafeIntegrationTest, OrderCoffeeRandomRoundTrips) { + Rng rng{std::mt19937{20260707U}, /*fill_all=*/false}; + for (int iteration = 0; iteration < 8; ++iteration) { + const OrderCoffeeInput input = RandomOrderCoffeeInput(rng); + OrderCoffeeOutput output = RandomOrderCoffeeOutput(rng); + handler_->nextOrderCoffeeOutput = output; + const auto outcome = client_->OrderCoffee(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastOrderCoffee.has_value()); + EXPECT_EQ(*handler_->lastOrderCoffee, input); + EXPECT_EQ(*outcome, output); + } +} + +TEST_P(CafeIntegrationTest, OrderCoffeeMaximalRoundTrips) { + Rng rng{std::mt19937{7U}, /*fill_all=*/true}; + const OrderCoffeeInput input = RandomOrderCoffeeInput(rng); + OrderCoffeeOutput output = RandomOrderCoffeeOutput(rng); + handler_->nextOrderCoffeeOutput = output; + const auto outcome = client_->OrderCoffee(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastOrderCoffee.has_value()); + EXPECT_EQ(*handler_->lastOrderCoffee, input); + EXPECT_EQ(*outcome, output); +} + +TEST_P(CafeIntegrationTest, GetOrderOrderNotFoundMapsAcrossTheWire) { + Rng rng{std::mt19937{42U}, /*fill_all=*/true}; + const OrderNotFound detail = RandomOrderNotFound(rng); + smithy::Error error = smithy::Error::Modeled("OrderNotFound", "integration"); + error.set_detail(detail); + handler_->nextGetOrderError = error; + const GetOrderInput input = RandomGetOrderInput(rng); + const auto outcome = client_->GetOrder(input); + ASSERT_FALSE(outcome.ok()); + EXPECT_EQ(outcome.error().kind(), smithy::ErrorKind::kModeled); + EXPECT_EQ(outcome.error().code(), "OrderNotFound"); + ASSERT_NE(outcome.error().detail(), nullptr); + EXPECT_EQ(*outcome.error().detail(), detail); +} + +TEST_P(CafeIntegrationTest, OrderCoffeeOutOfBeansMapsAcrossTheWire) { + Rng rng{std::mt19937{42U}, /*fill_all=*/true}; + const OutOfBeans detail = RandomOutOfBeans(rng); + smithy::Error error = smithy::Error::Modeled("OutOfBeans", "integration"); + error.set_detail(detail); + handler_->nextOrderCoffeeError = error; + const OrderCoffeeInput input = RandomOrderCoffeeInput(rng); + const auto outcome = client_->OrderCoffee(input); + ASSERT_FALSE(outcome.ok()); + EXPECT_EQ(outcome.error().kind(), smithy::ErrorKind::kModeled); + EXPECT_EQ(outcome.error().code(), "OutOfBeans"); + ASSERT_NE(outcome.error().detail(), nullptr); + EXPECT_EQ(*outcome.error().detail(), detail); +} + +TEST(CafeIntegrationUnknownMembers, GetOrderToleratesUnknownResponseMembers) { + auto handler = std::make_shared(); + CafeServer server(handler); + auto loopback = std::make_shared(); + ASSERT_TRUE(loopback->Start(server.Handler()).ok()); + auto inject = [](smithy::http::HttpResponse& response) { + auto doc = smithy::cbor::Decode(smithy::Blob::FromString(response.body)); + if (!doc.ok() || !doc->is_map()) return; + auto map = doc->as_map(); + map.insert_or_assign("smithy_cpp_unknown_member", smithy::Document(42)); + response.body = smithy::cbor::Encode(smithy::Document(std::move(map))).ToString(); + }; + auto transport = std::make_shared(loopback, inject); + smithy::ClientConfig config; + config.http_client = transport; + auto client = *CafeClient::Create(std::move(config)); + Rng rng{std::mt19937{99U}, /*fill_all=*/true}; + const GetOrderInput input = RandomGetOrderInput(rng); + GetOrderOutput output = RandomGetOrderOutput(rng); + handler->nextGetOrderOutput = output; + const auto outcome = client.GetOrder(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + EXPECT_EQ(*outcome, output); +} + +TEST(CafeIntegrationUnknownMembers, OrderCoffeeToleratesUnknownResponseMembers) { + auto handler = std::make_shared(); + CafeServer server(handler); + auto loopback = std::make_shared(); + ASSERT_TRUE(loopback->Start(server.Handler()).ok()); + auto inject = [](smithy::http::HttpResponse& response) { + auto doc = smithy::cbor::Decode(smithy::Blob::FromString(response.body)); + if (!doc.ok() || !doc->is_map()) return; + auto map = doc->as_map(); + map.insert_or_assign("smithy_cpp_unknown_member", smithy::Document(42)); + response.body = smithy::cbor::Encode(smithy::Document(std::move(map))).ToString(); + }; + auto transport = std::make_shared(loopback, inject); + smithy::ClientConfig config; + config.http_client = transport; + auto client = *CafeClient::Create(std::move(config)); + Rng rng{std::mt19937{99U}, /*fill_all=*/true}; + const OrderCoffeeInput input = RandomOrderCoffeeInput(rng); + OrderCoffeeOutput output = RandomOrderCoffeeOutput(rng); + handler->nextOrderCoffeeOutput = output; + const auto outcome = client.OrderCoffee(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + EXPECT_EQ(*outcome, output); +} + +INSTANTIATE_TEST_SUITE_P(Transports, CafeIntegrationTest, ::testing::Values(TransportKind::kLoopback, TransportKind::kSocket), [](const auto& info) { return info.param == TransportKind::kLoopback ? "Loopback" : "Socket"; }); + +} // namespace +} // namespace example::cafe diff --git a/examples/roundtrip/model/roundtrip.smithy b/examples/roundtrip/model/roundtrip.smithy new file mode 100644 index 00000000..b964cfc2 --- /dev/null +++ b/examples/roundtrip/model/roundtrip.smithy @@ -0,0 +1,239 @@ +$version: "2.0" + +namespace example.roundtrip + +use aws.protocols#restJson1 +use smithy.protocols#rpcv2Cbor + +/// Kitchen-sink fixture for the Phase 5 integration matrix: the same shapes +/// served over restJson1 (with every supported HTTP binding) and rpcv2Cbor, +/// so random round-trips exercise both protocols' serde end to end. +@restJson1 +service RoundTripRest { + version: "2026-01-01" + operations: [PutSink, UploadAttachment, DescribeSink] +} + +@rpcv2Cbor +service RoundTripRpc { + version: "2026-01-01" + operations: [PutSinkRpc] +} + +/// Every binding location at once: label, query, @httpQueryParams, headers, +/// prefix headers, and a JSON body full of aggregate shapes. +@idempotent +@http(method: "PUT", uri: "/sinks/{sinkId}") +operation PutSink { + input := { + @required + @httpLabel + sinkId: SinkId + + @httpQuery("tag") + tag: String + + @httpQuery("limit") + limit: PageLimit + + @httpHeader("x-sink-priority") + priority: Priority + + @httpHeader("x-sink-created") + created: Timestamp + + @httpPrefixHeaders("x-meta-") + metadata: StringMap + + sink: KitchenSink + + // rpcv2Cbor forbids document types, so the document member lives on + // the REST operation rather than the shared KitchenSink. + freeform: Document + } + + output := { + @required + sinkId: String + + @httpHeader("x-sink-revision") + revision: Integer + + @httpPrefixHeaders("x-echo-") + echoedMetadata: StringMap + + sink: KitchenSink + } + + errors: [SinkNotFound, SinkQuotaExceeded] +} + +/// Raw blob payload with an extra header member. +@http(method: "POST", uri: "/sinks/{sinkId}/attachment") +operation UploadAttachment { + input := { + @required + @httpLabel + sinkId: SinkId + + @httpHeader("x-attachment-name") + name: String + + @httpPayload + data: Blob + } + + output := { + @httpPayload + receipt: Receipt + } + + errors: [SinkNotFound] +} + +/// Read-only operation: no body in, body out. +@readonly +@http(method: "GET", uri: "/sinks/{sinkId}") +operation DescribeSink { + input := { + @required + @httpLabel + sinkId: SinkId + } + + output := { + sink: KitchenSink + } + + errors: [SinkNotFound] +} + +/// The RPC variant round-trips the same kitchen sink over CBOR. +operation PutSinkRpc { + input := { + @required + sinkId: String + + sink: KitchenSink + } + + output := { + @required + sinkId: String + + sink: KitchenSink + } + + errors: [SinkNotFound, SinkQuotaExceeded] +} + +@pattern("^[A-Za-z0-9]+$") +@length(min: 1, max: 32) +string SinkId + +@range(min: 1, max: 100) +integer PageLimit + +enum Priority { + LOW = "low" + MEDIUM = "medium" + HIGH = "high" +} + +intEnum Weight { + LIGHT = 1 + HEAVY = 2 +} + +structure KitchenSink { + @required + name: String + + flag: Boolean + tiny: Byte + small: Short + medium: Integer + big: Long + ratio: Float + precise: Double + blob: Blob + priority: Priority + weight: Weight + + dateTime: DateTimeStamp + httpDate: HttpDateStamp + epoch: EpochStamp + + names: StringList + uniqueNames: UniqueStringList + sparseNumbers: SparseIntegerList + attributes: StringMap + nested: NestedConfig + choice: SinkChoice +} + +structure NestedConfig { + @required + label: String + + depth: Integer +} + +union SinkChoice { + text: String + count: Integer + nested: NestedConfig +} + +@timestampFormat("date-time") +timestamp DateTimeStamp + +@timestampFormat("http-date") +timestamp HttpDateStamp + +@timestampFormat("epoch-seconds") +timestamp EpochStamp + +list StringList { + member: String +} + +@uniqueItems +list UniqueStringList { + member: String +} + +@sparse +list SparseIntegerList { + member: Integer +} + +map StringMap { + key: String + value: String +} + +structure Receipt { + @required + receiptId: String + + size: Long +} + +@error("client") +@httpError(404) +structure SinkNotFound { + @required + message: String + + resourceType: String +} + +@error("server") +@httpError(503) +structure SinkQuotaExceeded { + message: String + + @httpHeader("x-retry-after-seconds") + retryAfterSeconds: Integer +} diff --git a/examples/roundtrip/rest/generated/BUILD.bazel b/examples/roundtrip/rest/generated/BUILD.bazel new file mode 100644 index 00000000..a32a30a9 --- /dev/null +++ b/examples/roundtrip/rest/generated/BUILD.bazel @@ -0,0 +1,53 @@ +# Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +load("@rules_cc//cc:defs.bzl", "cc_library") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "types", + hdrs = ["include/example/roundtrip/rest/types.h"], + includes = ["include"], + deps = ["//runtime:core"], +) + +cc_library( + name = "serde", + srcs = ["src/serde.cc"], + hdrs = ["include/example/roundtrip/rest/serde.h"], + includes = ["include"], + deps = [ + ":types", + "//runtime:core", + ], +) + +cc_library( + name = "client", + srcs = ["src/client.cc"], + hdrs = ["include/example/roundtrip/rest/client.h"], + includes = ["include"], + deps = [ + ":serde", + ":types", + "//runtime:client", + "//runtime:core", + "//runtime:http", + "//runtime:json", + ], +) + +cc_library( + name = "server", + srcs = ["src/server.cc"], + hdrs = ["include/example/roundtrip/rest/server.h"], + includes = ["include"], + deps = [ + ":serde", + ":types", + "//runtime:core", + "//runtime:http", + "//runtime:json", + "//runtime:server", + ], +) diff --git a/examples/roundtrip/rest/generated/include/example/roundtrip/rest/client.h b/examples/roundtrip/rest/generated/include/example/roundtrip/rest/client.h new file mode 100644 index 00000000..c32c5170 --- /dev/null +++ b/examples/roundtrip/rest/generated/include/example/roundtrip/rest/client.h @@ -0,0 +1,41 @@ +// Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +#pragma once + +#include +#include + +#include "example/roundtrip/rest/types.h" +#include "smithy/client/config.h" +#include "smithy/core/outcome.h" +#include "smithy/http/transport.h" + +namespace example::roundtrip::rest { + +/// restJson1 client for example.roundtrip#RoundTripRest. +/// Modeled service errors surface as smithy::Error with kind kModeled, +/// code() set to the error shape name, and the deserialized error +/// structure attached: error.detail(). +class RoundTripRestClient { + public: + /// Fails when the endpoint cannot be parsed and no transport is injected. + static smithy::Outcome Create(smithy::ClientConfig config); + + /// Read-only operation: no body in, body out. + smithy::Outcome DescribeSink(const DescribeSinkInput& input) const; + /// Every binding location at once: label, query, @httpQueryParams, headers, + /// prefix headers, and a JSON body full of aggregate shapes. + smithy::Outcome PutSink(const PutSinkInput& input) const; + /// Raw blob payload with an extra header member. + smithy::Outcome UploadAttachment(const UploadAttachmentInput& input) const; + + private: + RoundTripRestClient(smithy::ClientConfig config, std::shared_ptr transport, std::string path_prefix); + smithy::Outcome Send(smithy::http::HttpRequest request) const; + + smithy::ClientConfig config_; + std::shared_ptr transport_; + std::string path_prefix_; +}; + +} // namespace example::roundtrip::rest diff --git a/examples/roundtrip/rest/generated/include/example/roundtrip/rest/serde.h b/examples/roundtrip/rest/generated/include/example/roundtrip/rest/serde.h new file mode 100644 index 00000000..8b415741 --- /dev/null +++ b/examples/roundtrip/rest/generated/include/example/roundtrip/rest/serde.h @@ -0,0 +1,69 @@ +// Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +#pragma once + +#include +#include +#include +#include +#include + +#include "example/roundtrip/rest/types.h" +#include "smithy/core/document.h" +#include "smithy/core/outcome.h" + +namespace example::roundtrip::rest { + +// Document-pivot serde for every aggregate shape in the model closure. +// Serializers never fail; deserializers return smithy::Error on wire +// mismatches and enforce @required members. + +smithy::Document SerializeDescribeSinkInput(const DescribeSinkInput& value); +smithy::Outcome DeserializeDescribeSinkInput(const smithy::Document& doc); + +smithy::Document SerializeStringMap(const std::map& value); +smithy::Outcome> DeserializeStringMap(const smithy::Document& doc); + +smithy::Document SerializeNestedConfig(const NestedConfig& value); +smithy::Outcome DeserializeNestedConfig(const smithy::Document& doc); + +smithy::Document SerializeSinkChoice(const SinkChoice& value); +smithy::Outcome DeserializeSinkChoice(const smithy::Document& doc); + +smithy::Document SerializeStringList(const std::vector& value); +smithy::Outcome> DeserializeStringList(const smithy::Document& doc); + +smithy::Document SerializeSparseIntegerList(const std::vector>& value); +smithy::Outcome>> DeserializeSparseIntegerList(const smithy::Document& doc); + +smithy::Document SerializeUniqueStringList(const std::vector& value); +smithy::Outcome> DeserializeUniqueStringList(const smithy::Document& doc); + +smithy::Document SerializeKitchenSink(const KitchenSink& value); +smithy::Outcome DeserializeKitchenSink(const smithy::Document& doc); + +smithy::Document SerializeDescribeSinkOutput(const DescribeSinkOutput& value); +smithy::Outcome DeserializeDescribeSinkOutput(const smithy::Document& doc); + +smithy::Document SerializeSinkNotFound(const SinkNotFound& value); +smithy::Outcome DeserializeSinkNotFound(const smithy::Document& doc); + +smithy::Document SerializePutSinkInput(const PutSinkInput& value); +smithy::Outcome DeserializePutSinkInput(const smithy::Document& doc); + +smithy::Document SerializePutSinkOutput(const PutSinkOutput& value); +smithy::Outcome DeserializePutSinkOutput(const smithy::Document& doc); + +smithy::Document SerializeSinkQuotaExceeded(const SinkQuotaExceeded& value); +smithy::Outcome DeserializeSinkQuotaExceeded(const smithy::Document& doc); + +smithy::Document SerializeReceipt(const Receipt& value); +smithy::Outcome DeserializeReceipt(const smithy::Document& doc); + +smithy::Document SerializeUploadAttachmentInput(const UploadAttachmentInput& value); +smithy::Outcome DeserializeUploadAttachmentInput(const smithy::Document& doc); + +smithy::Document SerializeUploadAttachmentOutput(const UploadAttachmentOutput& value); +smithy::Outcome DeserializeUploadAttachmentOutput(const smithy::Document& doc); + +} // namespace example::roundtrip::rest diff --git a/examples/roundtrip/rest/generated/include/example/roundtrip/rest/server.h b/examples/roundtrip/rest/generated/include/example/roundtrip/rest/server.h new file mode 100644 index 00000000..490020b9 --- /dev/null +++ b/examples/roundtrip/rest/generated/include/example/roundtrip/rest/server.h @@ -0,0 +1,43 @@ +// Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +#pragma once + +#include + +#include "example/roundtrip/rest/types.h" +#include "smithy/core/outcome.h" +#include "smithy/http/transport.h" +#include "smithy/server/router.h" + +namespace example::roundtrip::rest { + +/// Implement one method per operation. Return a modeled error as +/// smithy::Error::Modeled("", message), optionally with the +/// typed error structure attached via set_detail() so it serializes fully. +class RoundTripRestHandler { + public: + virtual ~RoundTripRestHandler() = default; + + /// Read-only operation: no body in, body out. + virtual smithy::Outcome DescribeSink(const DescribeSinkInput& input) = 0; + /// Every binding location at once: label, query, @httpQueryParams, headers, + /// prefix headers, and a JSON body full of aggregate shapes. + virtual smithy::Outcome PutSink(const PutSinkInput& input) = 0; + /// Raw blob payload with an extra header member. + virtual smithy::Outcome UploadAttachment(const UploadAttachmentInput& input) = 0; +}; + +/// restJson1 server for example.roundtrip#RoundTripRest: routing, deserialization, handler dispatch, +/// response serialization, and modeled-error mapping. Pass Handler() to any +/// smithy::http::HttpServerTransport. +class RoundTripRestServer { + public: + explicit RoundTripRestServer(std::shared_ptr handler); + + smithy::http::RequestHandler Handler() const; + + private: + std::shared_ptr router_; +}; + +} // namespace example::roundtrip::rest diff --git a/examples/roundtrip/rest/generated/include/example/roundtrip/rest/types.h b/examples/roundtrip/rest/generated/include/example/roundtrip/rest/types.h new file mode 100644 index 00000000..090f0edb --- /dev/null +++ b/examples/roundtrip/rest/generated/include/example/roundtrip/rest/types.h @@ -0,0 +1,218 @@ +// Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "smithy/core/blob.h" +#include "smithy/core/document.h" +#include "smithy/core/timestamp.h" + +namespace example::roundtrip::rest { + +struct DescribeSinkInput { + std::string sinkId{}; + + friend bool operator==(const DescribeSinkInput&, const DescribeSinkInput&) = default; +}; + + +struct NestedConfig { + std::string label{}; + std::optional depth{}; + + friend bool operator==(const NestedConfig&, const NestedConfig&) = default; +}; + + +class SinkChoice { + public: + SinkChoice() = default; + + static SinkChoice FromText(std::string value) { + SinkChoice result; + result.value_.emplace<1>(std::move(value)); + return result; + } + bool is_text() const { return value_.index() == 1; } + const std::string& as_text() const { return std::get<1>(value_); } + + static SinkChoice FromCount(std::int32_t value) { + SinkChoice result; + result.value_.emplace<2>(std::move(value)); + return result; + } + bool is_count() const { return value_.index() == 2; } + const std::int32_t& as_count() const { return std::get<2>(value_); } + + static SinkChoice FromNested(NestedConfig value) { + SinkChoice result; + result.value_.emplace<3>(std::move(value)); + return result; + } + bool is_nested() const { return value_.index() == 3; } + const NestedConfig& as_nested() const { return std::get<3>(value_); } + + /// True until one of the From* factories has been used. + bool empty() const { return value_.index() == 0; } + + friend bool operator==(const SinkChoice&, const SinkChoice&) = default; + + private: + std::variant value_; +}; + + +class Priority { + public: + enum class Value { + kLow, + kMedium, + kHigh, + kUnknown, + }; + + Priority() = default; + Priority(Value value) : value_(value) {} // NOLINT(*-explicit-*) + + /// Unknown wire values are preserved and reported as Value::kUnknown. + static Priority FromString(std::string_view text) { + if (text == "low") return Priority(Value::kLow); + if (text == "medium") return Priority(Value::kMedium); + if (text == "high") return Priority(Value::kHigh); + Priority result; + result.unknown_ = std::string(text); + return result; + } + + Value value() const { return value_; } + + /// The wire text, including the original text of unknown values. + std::string_view ToString() const { + switch (value_) { + case Value::kLow: return "low"; + case Value::kMedium: return "medium"; + case Value::kHigh: return "high"; + case Value::kUnknown: return unknown_; + } + return unknown_; + } + + friend bool operator==(const Priority&, const Priority&) = default; + + private: + Value value_ = Value::kUnknown; + std::string unknown_; +}; + + +enum class Weight : std::int32_t { + kLight = 1, + kHeavy = 2, +}; + + +struct KitchenSink { + std::string name{}; + std::optional flag{}; + std::optional tiny{}; + std::optional small{}; + std::optional medium{}; + std::optional big{}; + std::optional ratio{}; + std::optional precise{}; + std::optional blob{}; + std::optional priority{}; + std::optional weight{}; + std::optional dateTime{}; + std::optional httpDate{}; + std::optional epoch{}; + std::optional> names{}; + std::optional> uniqueNames{}; + std::optional>> sparseNumbers{}; + std::optional> attributes{}; + std::optional nested{}; + std::optional choice{}; + + friend bool operator==(const KitchenSink&, const KitchenSink&) = default; +}; + + +struct DescribeSinkOutput { + std::optional sink{}; + + friend bool operator==(const DescribeSinkOutput&, const DescribeSinkOutput&) = default; +}; + + +struct SinkNotFound { + std::string message{}; + std::optional resourceType{}; + + friend bool operator==(const SinkNotFound&, const SinkNotFound&) = default; +}; + + +struct PutSinkInput { + std::string sinkId{}; + std::optional tag{}; + std::optional limit{}; + std::optional priority{}; + std::optional created{}; + std::optional> metadata{}; + std::optional sink{}; + std::optional freeform{}; + + friend bool operator==(const PutSinkInput&, const PutSinkInput&) = default; +}; + + +struct PutSinkOutput { + std::string sinkId{}; + std::optional revision{}; + std::optional> echoedMetadata{}; + std::optional sink{}; + + friend bool operator==(const PutSinkOutput&, const PutSinkOutput&) = default; +}; + + +struct SinkQuotaExceeded { + std::optional message{}; + std::optional retryAfterSeconds{}; + + friend bool operator==(const SinkQuotaExceeded&, const SinkQuotaExceeded&) = default; +}; + + +struct Receipt { + std::string receiptId{}; + std::optional size{}; + + friend bool operator==(const Receipt&, const Receipt&) = default; +}; + + +struct UploadAttachmentInput { + std::string sinkId{}; + std::optional name{}; + std::optional data{}; + + friend bool operator==(const UploadAttachmentInput&, const UploadAttachmentInput&) = default; +}; + + +struct UploadAttachmentOutput { + std::optional receipt{}; + + friend bool operator==(const UploadAttachmentOutput&, const UploadAttachmentOutput&) = default; +}; + +} // namespace example::roundtrip::rest diff --git a/examples/roundtrip/rest/generated/src/client.cc b/examples/roundtrip/rest/generated/src/client.cc new file mode 100644 index 00000000..dfb08d6a --- /dev/null +++ b/examples/roundtrip/rest/generated/src/client.cc @@ -0,0 +1,293 @@ +// Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "example/roundtrip/rest/client.h" +#include "example/roundtrip/rest/serde.h" +#include "smithy/core/base64.h" +#include "smithy/core/blob.h" +#include "smithy/core/document_serde.h" +#include "smithy/core/uuid.h" +#include "smithy/http/headers.h" +#include "smithy/http/socket_transport.h" +#include "smithy/http/uri.h" +#include "smithy/json/json.h" + +namespace example::roundtrip::rest { + +namespace { + +// The error shape name arrives namespaced ("ns#Shape") and possibly +// URI-qualified; modeled error codes keep only the shape name. +std::string SanitizeErrorCode(std::string_view raw) { + if (const auto colon = raw.find(':'); colon != std::string_view::npos) raw = raw.substr(0, colon); + if (const auto hash = raw.find('#'); hash != std::string_view::npos) raw = raw.substr(hash + 1); + return std::string(raw); +} + +struct ParsedError { + int status = 0; + std::string code = "UnknownError"; + std::string message; + smithy::Document doc; +}; + +ParsedError ParseError(const smithy::http::HttpResponse& response) { + ParsedError parsed; + parsed.status = response.status; + parsed.message = "HTTP " + std::to_string(response.status); + auto doc = smithy::json::Decode(response.body); + if (doc.ok()) parsed.doc = *std::move(doc); + const auto type_header = response.headers.Get("x-amzn-errortype"); + if (type_header.has_value()) parsed.code = SanitizeErrorCode(*type_header); + if (parsed.doc.is_map()) { + const smithy::Document* type = parsed.doc.Find("__type"); + if (type == nullptr) type = parsed.doc.Find("code"); + if (parsed.code == "UnknownError" && type != nullptr && type->is_string()) parsed.code = SanitizeErrorCode(type->as_string()); + const smithy::Document* text = parsed.doc.Find("message"); + if (text != nullptr && text->is_string()) parsed.message = text->as_string(); + } + return parsed; +} + +smithy::Error GenericError(ParsedError parsed) { + const bool retryable = parsed.status >= 500; + if (parsed.code == "UnknownError") return smithy::Error(smithy::ErrorKind::kUnknown, std::move(parsed.code), std::move(parsed.message), retryable); + return smithy::Error::Modeled(std::move(parsed.code), std::move(parsed.message), retryable); +} + +// Strict text parsing for label/query/header bindings ([[maybe_unused]]: +// emitted for every service; not every service binds numeric values). +// Trailing text, floats-for-ints, and out-of-range values are rejected +// (the malformed-request suites pin this). +[[maybe_unused]] smithy::Outcome ParseInt64Text(const std::string& text, std::int64_t min_value, std::int64_t max_value) { + std::int64_t value = 0; + const char* first = text.data(); + const char* last = first + text.size(); + const auto result = std::from_chars(first, last, value, 10); + if (text.empty() || result.ec != std::errc() || result.ptr != last || value < min_value || value > max_value) { + return smithy::Error::Serialization("invalid integer: " + text); + } + return value; +} + +[[maybe_unused]] smithy::Outcome ParseDoubleText(const std::string& text) { + if (text == "NaN") return std::numeric_limits::quiet_NaN(); + if (text == "Infinity") return std::numeric_limits::infinity(); + if (text == "-Infinity") return -std::numeric_limits::infinity(); + const auto valid_char = [](char c) { + return (c >= '0' && c <= '9') || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-'; + }; + if (text.empty() || text.front() == '+' || !std::all_of(text.begin(), text.end(), valid_char)) { + return smithy::Error::Serialization("invalid number: " + text); + } + char* parse_end = nullptr; + const double value = std::strtod(text.c_str(), &parse_end); + if (parse_end != text.c_str() + text.size() || !std::isfinite(value)) { + return smithy::Error::Serialization("invalid number: " + text); + } + return value; +} + +smithy::Error MakeSinkNotFoundError(const smithy::http::HttpResponse& response, ParsedError parsed) { + (void)response; + const bool retryable = parsed.status >= 500; + smithy::Error error = smithy::Error::Modeled("SinkNotFound", std::move(parsed.message), retryable); + if (!parsed.doc.is_map()) parsed.doc = smithy::Document(smithy::DocumentMap{}); + auto detail = DeserializeSinkNotFound(parsed.doc); + if (detail.ok()) { + error.set_detail(*std::move(detail)); + } + return error; +} + +smithy::Error MakeSinkQuotaExceededError(const smithy::http::HttpResponse& response, ParsedError parsed) { + (void)response; + const bool retryable = parsed.status >= 500; + smithy::Error error = smithy::Error::Modeled("SinkQuotaExceeded", std::move(parsed.message), retryable); + if (!parsed.doc.is_map()) parsed.doc = smithy::Document(smithy::DocumentMap{}); + auto detail = DeserializeSinkQuotaExceeded(parsed.doc); + if (detail.ok()) { + if (const auto header_value = response.headers.Get("x-retry-after-seconds"); header_value.has_value()) { + if (auto parsed_num = ParseInt64Text(*header_value, -2147483648LL, 2147483647LL)) detail->retryAfterSeconds = static_cast(*parsed_num); + } + error.set_detail(*std::move(detail)); + } + return error; +} + +smithy::Error DeserializeDescribeSinkError(const smithy::http::HttpResponse& response) { + ParsedError parsed = ParseError(response); + if (parsed.code == "SinkNotFound") return MakeSinkNotFoundError(response, std::move(parsed)); + return GenericError(std::move(parsed)); +} + +smithy::Error DeserializePutSinkError(const smithy::http::HttpResponse& response) { + ParsedError parsed = ParseError(response); + if (parsed.code == "SinkNotFound") return MakeSinkNotFoundError(response, std::move(parsed)); + if (parsed.code == "SinkQuotaExceeded") return MakeSinkQuotaExceededError(response, std::move(parsed)); + return GenericError(std::move(parsed)); +} + +smithy::Error DeserializeUploadAttachmentError(const smithy::http::HttpResponse& response) { + ParsedError parsed = ParseError(response); + if (parsed.code == "SinkNotFound") return MakeSinkNotFoundError(response, std::move(parsed)); + return GenericError(std::move(parsed)); +} + +} // namespace + +smithy::Outcome RoundTripRestClient::Create(smithy::ClientConfig config) { + std::shared_ptr transport = config.http_client; + std::string prefix; + if (!config.endpoint.empty()) { + auto endpoint = smithy::http::ParseEndpoint(config.endpoint); + if (!endpoint) return std::move(endpoint).error(); + prefix = endpoint->path_prefix; + if (transport == nullptr) { + transport = std::make_shared(endpoint->host, endpoint->port, config.request_timeout_ms); + } + } + if (transport == nullptr) { + return smithy::Error::Validation("RoundTripRestClient: config needs an endpoint or an http_client"); + } + return RoundTripRestClient(std::move(config), std::move(transport), std::move(prefix)); +} + +RoundTripRestClient::RoundTripRestClient(smithy::ClientConfig config, std::shared_ptr transport, std::string path_prefix) + : config_(std::move(config)), + transport_(std::move(transport)), + path_prefix_(std::move(path_prefix)) {} + +smithy::Outcome RoundTripRestClient::Send(smithy::http::HttpRequest request) const { + // 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); + if (!request.body.empty()) { + request.headers.Set("content-length", std::to_string(request.body.size())); + } + return transport_->Send(request); +} + +smithy::Outcome RoundTripRestClient::DescribeSink(const DescribeSinkInput& input) const { + std::string target = path_prefix_; + target += "/sinks"; + target += "/"; + target += smithy::http::EncodePathSegment(input.sinkId); + smithy::http::HttpRequest request; + request.method = "GET"; + request.target = std::move(target); + auto response = Send(std::move(request)); + if (!response) return std::move(response).error(); + if (response->status != 200) return DeserializeDescribeSinkError(*response); + if (response->body.empty()) return DescribeSinkOutput{}; + auto body_doc = smithy::json::Decode(response->body); + if (!body_doc) return std::move(body_doc).error(); + return DeserializeDescribeSinkOutput(*body_doc); +} + +smithy::Outcome RoundTripRestClient::PutSink(const PutSinkInput& input) const { + std::string target = path_prefix_; + target += "/sinks"; + target += "/"; + target += smithy::http::EncodePathSegment(input.sinkId); + smithy::http::QueryString query; + if (input.limit.has_value()) { + query.Add("limit", std::to_string(static_cast((*input.limit)))); + } + if (input.tag.has_value()) { + query.Add("tag", (*input.tag)); + } + target += query.ToString(); + smithy::http::HttpRequest request; + request.method = "PUT"; + request.target = std::move(target); + if (input.created.has_value()) { + request.headers.Set("x-sink-created", (*input.created).Format(smithy::TimestampFormat::kHttpDate)); + } + if (input.priority.has_value()) { + request.headers.Set("x-sink-priority", std::string((*input.priority).ToString())); + } + if (input.metadata.has_value()) { + for (const auto& [map_key, map_value] : (*input.metadata)) { + request.headers.Set("x-meta-" + map_key, map_value); + } + } + smithy::DocumentMap body_map; + if (input.sink.has_value()) { + body_map.emplace("sink", SerializeKitchenSink((*input.sink))); + } + if (input.freeform.has_value()) { + body_map.emplace("freeform", (*input.freeform)); + } + request.body = smithy::json::Encode(smithy::Document(std::move(body_map))); + request.headers.Set("content-type", "application/json"); + auto response = Send(std::move(request)); + if (!response) return std::move(response).error(); + if (response->status != 200) return DeserializePutSinkError(*response); + PutSinkOutput out{}; + auto body_doc = smithy::json::Decode(response->body); + if (!body_doc) return std::move(body_doc).error(); + auto parsed = DeserializePutSinkOutput(*body_doc); + if (!parsed) return std::move(parsed).error(); + out = *std::move(parsed); + if (const auto header_value = response->headers.Get("x-sink-revision"); header_value.has_value()) { + auto parsed_num = ParseInt64Text((*header_value), -2147483648LL, 2147483647LL); + if (!parsed_num) return std::move(parsed_num).error(); + out.revision = static_cast(*parsed_num); + } + for (const auto& [header_name, header_value] : response->headers.entries()) { + if (!smithy::http::HeaderNameStartsWith(header_name, "x-echo-")) continue; + if (!out.echoedMetadata.has_value()) out.echoedMetadata.emplace(); + (*out.echoedMetadata).insert_or_assign(header_name.substr(7), header_value); + } + return out; +} + +smithy::Outcome RoundTripRestClient::UploadAttachment(const UploadAttachmentInput& input) const { + std::string target = path_prefix_; + target += "/sinks"; + target += "/"; + target += smithy::http::EncodePathSegment(input.sinkId); + target += "/attachment"; + smithy::http::HttpRequest request; + request.method = "POST"; + request.target = std::move(target); + if (input.name.has_value()) { + request.headers.Set("x-attachment-name", (*input.name)); + } + if (input.data.has_value()) { + request.body = (*input.data).ToString(); + if (!request.headers.Get("content-type").has_value()) request.headers.Set("content-type", "application/octet-stream"); + } + request.headers.Set("accept", "application/json"); + auto response = Send(std::move(request)); + if (!response) return std::move(response).error(); + if (response->status != 200) return DeserializeUploadAttachmentError(*response); + UploadAttachmentOutput out{}; + if (!response->body.empty()) { + auto payload_doc = smithy::json::Decode(response->body); + if (!payload_doc) return std::move(payload_doc).error(); + const smithy::Document* payload_ptr = &*payload_doc; + if (!payload_ptr->is_map() || !payload_ptr->as_map().empty()) { + Receipt parsed_payload{}; + { + auto parsed = DeserializeReceipt(*payload_ptr); + if (!parsed) return std::move(parsed).error(); + parsed_payload = std::move(*parsed); + } + out.receipt = std::move(parsed_payload); + } + } + return out; +} + +} // namespace example::roundtrip::rest diff --git a/examples/roundtrip/rest/generated/src/serde.cc b/examples/roundtrip/rest/generated/src/serde.cc new file mode 100644 index 00000000..29709ae4 --- /dev/null +++ b/examples/roundtrip/rest/generated/src/serde.cc @@ -0,0 +1,871 @@ +// Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +#include +#include + +#include "example/roundtrip/rest/serde.h" +#include "smithy/core/document_serde.h" + +namespace example::roundtrip::rest { + +smithy::Document SerializeDescribeSinkInput(const DescribeSinkInput& value) { + smithy::DocumentMap map; + map.emplace("sinkId", smithy::Document(value.sinkId)); + return smithy::Document(std::move(map)); +} + +smithy::Outcome DeserializeDescribeSinkInput(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("DescribeSinkInput: expected a map on the wire"); + DescribeSinkInput out; + { + const smithy::Document* member = doc.Find("sinkId"); + if (member == nullptr || member->is_null()) { + return smithy::Error::Serialization("DescribeSinkInput: missing required member: sinkId"); + } + if (!member->is_string()) return smithy::Error::Serialization("DescribeSinkInput.sinkId: unexpected type on the wire"); + out.sinkId = member->as_string(); + } + return out; +} + +smithy::Document SerializeStringMap(const std::map& value) { + smithy::DocumentMap map; + for (const auto& [key, item] : value) { + map.emplace(key, smithy::Document(item)); + } + return smithy::Document(std::move(map)); +} + +smithy::Outcome> DeserializeStringMap(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("std::map: expected a map on the wire"); + std::map out; + for (const auto& [key, item_doc] : doc.as_map()) { + const smithy::Document* item = &item_doc; + // Tolerant read: null values in dense maps are skipped, not errors. + if (item->is_null()) continue; + std::string parsed_item{}; + if (!item->is_string()) return smithy::Error::Serialization("std::map{}: unexpected type on the wire"); + parsed_item = item->as_string(); + out.emplace(key, std::move(parsed_item)); + } + return out; +} + +smithy::Document SerializeNestedConfig(const NestedConfig& value) { + smithy::DocumentMap map; + map.emplace("label", smithy::Document(value.label)); + if (value.depth.has_value()) { + map.emplace("depth", smithy::Document(static_cast((*value.depth)))); + } + return smithy::Document(std::move(map)); +} + +smithy::Outcome DeserializeNestedConfig(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("NestedConfig: expected a map on the wire"); + NestedConfig out; + { + const smithy::Document* member = doc.Find("label"); + if (member == nullptr || member->is_null()) { + return smithy::Error::Serialization("NestedConfig: missing required member: label"); + } + if (!member->is_string()) return smithy::Error::Serialization("NestedConfig.label: unexpected type on the wire"); + out.label = member->as_string(); + } + { + const smithy::Document* member = doc.Find("depth"); + if (member != nullptr && !member->is_null()) { + std::int32_t parsed_member{}; + if (!member->is_int()) return smithy::Error::Serialization("NestedConfig.depth: unexpected type on the wire"); + if (member->as_int() < -2147483648LL || member->as_int() > 2147483647LL) return smithy::Error::Serialization("NestedConfig.depth: value out of range"); + parsed_member = static_cast(member->as_int()); + out.depth = std::move(parsed_member); + } + } + return out; +} + +smithy::Document SerializeSinkChoice(const SinkChoice& value) { + smithy::DocumentMap map; + if (value.is_text()) { + map.emplace("text", smithy::Document(value.as_text())); + } + if (value.is_count()) { + map.emplace("count", smithy::Document(static_cast(value.as_count()))); + } + if (value.is_nested()) { + map.emplace("nested", SerializeNestedConfig(value.as_nested())); + } + return smithy::Document(std::move(map)); +} + +smithy::Outcome DeserializeSinkChoice(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("SinkChoice: expected a map on the wire"); + if (doc.as_map().size() - (doc.Find("__type") != nullptr ? 1 : 0) != 1) return smithy::Error::Serialization("SinkChoice: expected exactly one union member"); + if (const smithy::Document* member = doc.Find("text"); member != nullptr && !member->is_null()) { + std::string parsed_member{}; + if (!member->is_string()) return smithy::Error::Serialization("SinkChoice.text: unexpected type on the wire"); + parsed_member = member->as_string(); + return SinkChoice::FromText(std::move(parsed_member)); + } + if (const smithy::Document* member = doc.Find("count"); member != nullptr && !member->is_null()) { + std::int32_t parsed_member{}; + if (!member->is_int()) return smithy::Error::Serialization("SinkChoice.count: unexpected type on the wire"); + if (member->as_int() < -2147483648LL || member->as_int() > 2147483647LL) return smithy::Error::Serialization("SinkChoice.count: value out of range"); + parsed_member = static_cast(member->as_int()); + return SinkChoice::FromCount(std::move(parsed_member)); + } + if (const smithy::Document* member = doc.Find("nested"); member != nullptr && !member->is_null()) { + NestedConfig parsed_member{}; + { + auto parsed = DeserializeNestedConfig(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + return SinkChoice::FromNested(std::move(parsed_member)); + } + return smithy::Error::Serialization("SinkChoice: unknown or missing union member"); +} + +smithy::Document SerializeStringList(const std::vector& value) { + smithy::DocumentList list; + list.reserve(value.size()); + for (const auto& item : value) { + list.push_back(smithy::Document(item)); + } + return smithy::Document(std::move(list)); +} + +smithy::Outcome> DeserializeStringList(const smithy::Document& doc) { + if (!doc.is_list()) return smithy::Error::Serialization("std::vector: expected a list on the wire"); + std::vector out; + out.reserve(doc.as_list().size()); + for (const smithy::Document& item_doc : doc.as_list()) { + const smithy::Document* item = &item_doc; + if (item->is_null()) return smithy::Error::Serialization("std::vector: null element in a dense list"); + std::string parsed_item{}; + if (!item->is_string()) return smithy::Error::Serialization("std::vector[]: unexpected type on the wire"); + parsed_item = item->as_string(); + out.push_back(std::move(parsed_item)); + } + return out; +} + +smithy::Document SerializeSparseIntegerList(const std::vector>& value) { + smithy::DocumentList list; + list.reserve(value.size()); + for (const auto& item : value) { + if (!item.has_value()) { + list.emplace_back(nullptr); + continue; + } + list.push_back(smithy::Document(static_cast((*item)))); + } + return smithy::Document(std::move(list)); +} + +smithy::Outcome>> DeserializeSparseIntegerList(const smithy::Document& doc) { + if (!doc.is_list()) return smithy::Error::Serialization("std::vector>: expected a list on the wire"); + std::vector> out; + out.reserve(doc.as_list().size()); + for (const smithy::Document& item_doc : doc.as_list()) { + const smithy::Document* item = &item_doc; + if (item->is_null()) { + out.emplace_back(std::nullopt); + continue; + } + std::int32_t parsed_item{}; + if (!item->is_int()) return smithy::Error::Serialization("std::vector>[]: unexpected type on the wire"); + if (item->as_int() < -2147483648LL || item->as_int() > 2147483647LL) return smithy::Error::Serialization("std::vector>[]: value out of range"); + parsed_item = static_cast(item->as_int()); + out.push_back(std::move(parsed_item)); + } + return out; +} + +smithy::Document SerializeUniqueStringList(const std::vector& value) { + smithy::DocumentList list; + list.reserve(value.size()); + for (const auto& item : value) { + list.push_back(smithy::Document(item)); + } + return smithy::Document(std::move(list)); +} + +smithy::Outcome> DeserializeUniqueStringList(const smithy::Document& doc) { + if (!doc.is_list()) return smithy::Error::Serialization("std::vector: expected a list on the wire"); + std::vector out; + out.reserve(doc.as_list().size()); + for (const smithy::Document& item_doc : doc.as_list()) { + const smithy::Document* item = &item_doc; + if (item->is_null()) return smithy::Error::Serialization("std::vector: null element in a dense list"); + std::string parsed_item{}; + if (!item->is_string()) return smithy::Error::Serialization("std::vector[]: unexpected type on the wire"); + parsed_item = item->as_string(); + out.push_back(std::move(parsed_item)); + } + return out; +} + +smithy::Document SerializeKitchenSink(const KitchenSink& value) { + smithy::DocumentMap map; + map.emplace("name", smithy::Document(value.name)); + if (value.flag.has_value()) { + map.emplace("flag", smithy::Document((*value.flag))); + } + if (value.tiny.has_value()) { + map.emplace("tiny", smithy::Document(static_cast((*value.tiny)))); + } + if (value.small.has_value()) { + map.emplace("small", smithy::Document(static_cast((*value.small)))); + } + if (value.medium.has_value()) { + map.emplace("medium", smithy::Document(static_cast((*value.medium)))); + } + if (value.big.has_value()) { + map.emplace("big", smithy::Document(static_cast((*value.big)))); + } + if (value.ratio.has_value()) { + map.emplace("ratio", smithy::Document(static_cast((*value.ratio)))); + } + if (value.precise.has_value()) { + map.emplace("precise", smithy::Document(static_cast((*value.precise)))); + } + if (value.blob.has_value()) { + map.emplace("blob", smithy::Document((*value.blob))); + } + if (value.priority.has_value()) { + map.emplace("priority", smithy::Document(std::string((*value.priority).ToString()))); + } + if (value.weight.has_value()) { + map.emplace("weight", smithy::Document(static_cast((*value.weight)))); + } + if (value.dateTime.has_value()) { + map.emplace("dateTime", smithy::Document::FromTimestamp((*value.dateTime), smithy::TimestampFormat::kDateTime)); + } + if (value.httpDate.has_value()) { + map.emplace("httpDate", smithy::Document::FromTimestamp((*value.httpDate), smithy::TimestampFormat::kHttpDate)); + } + if (value.epoch.has_value()) { + map.emplace("epoch", smithy::Document::FromTimestamp((*value.epoch), smithy::TimestampFormat::kEpochSeconds)); + } + if (value.names.has_value()) { + map.emplace("names", SerializeStringList((*value.names))); + } + if (value.uniqueNames.has_value()) { + map.emplace("uniqueNames", SerializeUniqueStringList((*value.uniqueNames))); + } + if (value.sparseNumbers.has_value()) { + map.emplace("sparseNumbers", SerializeSparseIntegerList((*value.sparseNumbers))); + } + if (value.attributes.has_value()) { + map.emplace("attributes", SerializeStringMap((*value.attributes))); + } + if (value.nested.has_value()) { + map.emplace("nested", SerializeNestedConfig((*value.nested))); + } + if (value.choice.has_value()) { + map.emplace("choice", SerializeSinkChoice((*value.choice))); + } + return smithy::Document(std::move(map)); +} + +smithy::Outcome DeserializeKitchenSink(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("KitchenSink: expected a map on the wire"); + KitchenSink out; + { + const smithy::Document* member = doc.Find("name"); + if (member == nullptr || member->is_null()) { + return smithy::Error::Serialization("KitchenSink: missing required member: name"); + } + if (!member->is_string()) return smithy::Error::Serialization("KitchenSink.name: unexpected type on the wire"); + out.name = member->as_string(); + } + { + const smithy::Document* member = doc.Find("flag"); + if (member != nullptr && !member->is_null()) { + bool parsed_member{}; + if (!member->is_bool()) return smithy::Error::Serialization("KitchenSink.flag: unexpected type on the wire"); + parsed_member = member->as_bool(); + out.flag = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("tiny"); + if (member != nullptr && !member->is_null()) { + std::int8_t parsed_member{}; + if (!member->is_int()) return smithy::Error::Serialization("KitchenSink.tiny: unexpected type on the wire"); + if (member->as_int() < -128 || member->as_int() > 127) return smithy::Error::Serialization("KitchenSink.tiny: value out of range"); + parsed_member = static_cast(member->as_int()); + out.tiny = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("small"); + if (member != nullptr && !member->is_null()) { + std::int16_t parsed_member{}; + if (!member->is_int()) return smithy::Error::Serialization("KitchenSink.small: unexpected type on the wire"); + if (member->as_int() < -32768 || member->as_int() > 32767) return smithy::Error::Serialization("KitchenSink.small: value out of range"); + parsed_member = static_cast(member->as_int()); + out.small = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("medium"); + if (member != nullptr && !member->is_null()) { + std::int32_t parsed_member{}; + if (!member->is_int()) return smithy::Error::Serialization("KitchenSink.medium: unexpected type on the wire"); + if (member->as_int() < -2147483648LL || member->as_int() > 2147483647LL) return smithy::Error::Serialization("KitchenSink.medium: value out of range"); + parsed_member = static_cast(member->as_int()); + out.medium = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("big"); + if (member != nullptr && !member->is_null()) { + std::int64_t parsed_member{}; + if (!member->is_int()) return smithy::Error::Serialization("KitchenSink.big: unexpected type on the wire"); + parsed_member = static_cast(member->as_int()); + out.big = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("ratio"); + if (member != nullptr && !member->is_null()) { + float parsed_member{}; + { + auto parsed = smithy::DoubleFromDocument(*member); + if (!parsed) return smithy::Error::Serialization("KitchenSink.ratio: expected a number"); + parsed_member = static_cast(*parsed); + } + out.ratio = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("precise"); + if (member != nullptr && !member->is_null()) { + double parsed_member{}; + { + auto parsed = smithy::DoubleFromDocument(*member); + if (!parsed) return smithy::Error::Serialization("KitchenSink.precise: expected a number"); + parsed_member = static_cast(*parsed); + } + out.precise = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("blob"); + if (member != nullptr && !member->is_null()) { + smithy::Blob parsed_member{}; + { + auto parsed = smithy::BlobFromDocument(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.blob = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("priority"); + if (member != nullptr && !member->is_null()) { + Priority parsed_member{}; + if (!member->is_string()) return smithy::Error::Serialization("KitchenSink.priority: unexpected type on the wire"); + parsed_member = Priority::FromString(member->as_string()); + out.priority = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("weight"); + if (member != nullptr && !member->is_null()) { + Weight parsed_member{}; + if (!member->is_int()) return smithy::Error::Serialization("KitchenSink.weight: unexpected type on the wire"); + parsed_member = static_cast(member->as_int()); + out.weight = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("dateTime"); + if (member != nullptr && !member->is_null()) { + smithy::Timestamp parsed_member{}; + { + auto parsed = smithy::TimestampFromDocument(*member, smithy::TimestampFormat::kDateTime); + if (!parsed) return std::move(parsed).error(); + parsed_member = *parsed; + } + out.dateTime = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("httpDate"); + if (member != nullptr && !member->is_null()) { + smithy::Timestamp parsed_member{}; + { + auto parsed = smithy::TimestampFromDocument(*member, smithy::TimestampFormat::kHttpDate); + if (!parsed) return std::move(parsed).error(); + parsed_member = *parsed; + } + out.httpDate = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("epoch"); + if (member != nullptr && !member->is_null()) { + smithy::Timestamp parsed_member{}; + { + auto parsed = smithy::TimestampFromDocument(*member, smithy::TimestampFormat::kEpochSeconds); + if (!parsed) return std::move(parsed).error(); + parsed_member = *parsed; + } + out.epoch = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("names"); + if (member != nullptr && !member->is_null()) { + std::vector parsed_member{}; + { + auto parsed = DeserializeStringList(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.names = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("uniqueNames"); + if (member != nullptr && !member->is_null()) { + std::vector parsed_member{}; + { + auto parsed = DeserializeUniqueStringList(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.uniqueNames = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("sparseNumbers"); + if (member != nullptr && !member->is_null()) { + std::vector> parsed_member{}; + { + auto parsed = DeserializeSparseIntegerList(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.sparseNumbers = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("attributes"); + if (member != nullptr && !member->is_null()) { + std::map parsed_member{}; + { + auto parsed = DeserializeStringMap(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.attributes = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("nested"); + if (member != nullptr && !member->is_null()) { + NestedConfig parsed_member{}; + { + auto parsed = DeserializeNestedConfig(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.nested = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("choice"); + if (member != nullptr && !member->is_null()) { + SinkChoice parsed_member{}; + { + auto parsed = DeserializeSinkChoice(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.choice = std::move(parsed_member); + } + } + return out; +} + +smithy::Document SerializeDescribeSinkOutput(const DescribeSinkOutput& value) { + smithy::DocumentMap map; + if (value.sink.has_value()) { + map.emplace("sink", SerializeKitchenSink((*value.sink))); + } + return smithy::Document(std::move(map)); +} + +smithy::Outcome DeserializeDescribeSinkOutput(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("DescribeSinkOutput: expected a map on the wire"); + DescribeSinkOutput out; + { + const smithy::Document* member = doc.Find("sink"); + if (member != nullptr && !member->is_null()) { + KitchenSink parsed_member{}; + { + auto parsed = DeserializeKitchenSink(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.sink = std::move(parsed_member); + } + } + return out; +} + +smithy::Document SerializeSinkNotFound(const SinkNotFound& value) { + smithy::DocumentMap map; + map.emplace("message", smithy::Document(value.message)); + if (value.resourceType.has_value()) { + map.emplace("resourceType", smithy::Document((*value.resourceType))); + } + return smithy::Document(std::move(map)); +} + +smithy::Outcome DeserializeSinkNotFound(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("SinkNotFound: expected a map on the wire"); + SinkNotFound out; + { + const smithy::Document* member = doc.Find("message"); + if (member == nullptr || member->is_null()) { + return smithy::Error::Serialization("SinkNotFound: missing required member: message"); + } + if (!member->is_string()) return smithy::Error::Serialization("SinkNotFound.message: unexpected type on the wire"); + out.message = member->as_string(); + } + { + const smithy::Document* member = doc.Find("resourceType"); + if (member != nullptr && !member->is_null()) { + std::string parsed_member{}; + if (!member->is_string()) return smithy::Error::Serialization("SinkNotFound.resourceType: unexpected type on the wire"); + parsed_member = member->as_string(); + out.resourceType = std::move(parsed_member); + } + } + return out; +} + +smithy::Document SerializePutSinkInput(const PutSinkInput& value) { + smithy::DocumentMap map; + map.emplace("sinkId", smithy::Document(value.sinkId)); + if (value.tag.has_value()) { + map.emplace("tag", smithy::Document((*value.tag))); + } + if (value.limit.has_value()) { + map.emplace("limit", smithy::Document(static_cast((*value.limit)))); + } + if (value.priority.has_value()) { + map.emplace("priority", smithy::Document(std::string((*value.priority).ToString()))); + } + if (value.created.has_value()) { + map.emplace("created", smithy::Document::FromTimestamp((*value.created), smithy::TimestampFormat::kEpochSeconds)); + } + if (value.metadata.has_value()) { + map.emplace("metadata", SerializeStringMap((*value.metadata))); + } + if (value.sink.has_value()) { + map.emplace("sink", SerializeKitchenSink((*value.sink))); + } + if (value.freeform.has_value()) { + map.emplace("freeform", (*value.freeform)); + } + return smithy::Document(std::move(map)); +} + +smithy::Outcome DeserializePutSinkInput(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("PutSinkInput: expected a map on the wire"); + PutSinkInput out; + { + const smithy::Document* member = doc.Find("sinkId"); + if (member == nullptr || member->is_null()) { + return smithy::Error::Serialization("PutSinkInput: missing required member: sinkId"); + } + if (!member->is_string()) return smithy::Error::Serialization("PutSinkInput.sinkId: unexpected type on the wire"); + out.sinkId = member->as_string(); + } + { + const smithy::Document* member = doc.Find("tag"); + if (member != nullptr && !member->is_null()) { + std::string parsed_member{}; + if (!member->is_string()) return smithy::Error::Serialization("PutSinkInput.tag: unexpected type on the wire"); + parsed_member = member->as_string(); + out.tag = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("limit"); + if (member != nullptr && !member->is_null()) { + std::int32_t parsed_member{}; + if (!member->is_int()) return smithy::Error::Serialization("PutSinkInput.limit: unexpected type on the wire"); + if (member->as_int() < -2147483648LL || member->as_int() > 2147483647LL) return smithy::Error::Serialization("PutSinkInput.limit: value out of range"); + parsed_member = static_cast(member->as_int()); + out.limit = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("priority"); + if (member != nullptr && !member->is_null()) { + Priority parsed_member{}; + if (!member->is_string()) return smithy::Error::Serialization("PutSinkInput.priority: unexpected type on the wire"); + parsed_member = Priority::FromString(member->as_string()); + out.priority = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("created"); + if (member != nullptr && !member->is_null()) { + smithy::Timestamp parsed_member{}; + { + auto parsed = smithy::TimestampFromDocument(*member, smithy::TimestampFormat::kEpochSeconds); + if (!parsed) return std::move(parsed).error(); + parsed_member = *parsed; + } + out.created = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("metadata"); + if (member != nullptr && !member->is_null()) { + std::map parsed_member{}; + { + auto parsed = DeserializeStringMap(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.metadata = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("sink"); + if (member != nullptr && !member->is_null()) { + KitchenSink parsed_member{}; + { + auto parsed = DeserializeKitchenSink(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.sink = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("freeform"); + if (member != nullptr && !member->is_null()) { + smithy::Document parsed_member{}; + parsed_member = *member; + out.freeform = std::move(parsed_member); + } + } + return out; +} + +smithy::Document SerializePutSinkOutput(const PutSinkOutput& value) { + smithy::DocumentMap map; + map.emplace("sinkId", smithy::Document(value.sinkId)); + if (value.revision.has_value()) { + map.emplace("revision", smithy::Document(static_cast((*value.revision)))); + } + if (value.echoedMetadata.has_value()) { + map.emplace("echoedMetadata", SerializeStringMap((*value.echoedMetadata))); + } + if (value.sink.has_value()) { + map.emplace("sink", SerializeKitchenSink((*value.sink))); + } + return smithy::Document(std::move(map)); +} + +smithy::Outcome DeserializePutSinkOutput(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("PutSinkOutput: expected a map on the wire"); + PutSinkOutput out; + { + const smithy::Document* member = doc.Find("sinkId"); + if (member == nullptr || member->is_null()) { + return smithy::Error::Serialization("PutSinkOutput: missing required member: sinkId"); + } + if (!member->is_string()) return smithy::Error::Serialization("PutSinkOutput.sinkId: unexpected type on the wire"); + out.sinkId = member->as_string(); + } + { + const smithy::Document* member = doc.Find("revision"); + if (member != nullptr && !member->is_null()) { + std::int32_t parsed_member{}; + if (!member->is_int()) return smithy::Error::Serialization("PutSinkOutput.revision: unexpected type on the wire"); + if (member->as_int() < -2147483648LL || member->as_int() > 2147483647LL) return smithy::Error::Serialization("PutSinkOutput.revision: value out of range"); + parsed_member = static_cast(member->as_int()); + out.revision = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("echoedMetadata"); + if (member != nullptr && !member->is_null()) { + std::map parsed_member{}; + { + auto parsed = DeserializeStringMap(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.echoedMetadata = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("sink"); + if (member != nullptr && !member->is_null()) { + KitchenSink parsed_member{}; + { + auto parsed = DeserializeKitchenSink(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.sink = std::move(parsed_member); + } + } + return out; +} + +smithy::Document SerializeSinkQuotaExceeded(const SinkQuotaExceeded& value) { + smithy::DocumentMap map; + if (value.message.has_value()) { + map.emplace("message", smithy::Document((*value.message))); + } + if (value.retryAfterSeconds.has_value()) { + map.emplace("retryAfterSeconds", smithy::Document(static_cast((*value.retryAfterSeconds)))); + } + return smithy::Document(std::move(map)); +} + +smithy::Outcome DeserializeSinkQuotaExceeded(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("SinkQuotaExceeded: expected a map on the wire"); + SinkQuotaExceeded out; + { + const smithy::Document* member = doc.Find("message"); + if (member != nullptr && !member->is_null()) { + std::string parsed_member{}; + if (!member->is_string()) return smithy::Error::Serialization("SinkQuotaExceeded.message: unexpected type on the wire"); + parsed_member = member->as_string(); + out.message = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("retryAfterSeconds"); + if (member != nullptr && !member->is_null()) { + std::int32_t parsed_member{}; + if (!member->is_int()) return smithy::Error::Serialization("SinkQuotaExceeded.retryAfterSeconds: unexpected type on the wire"); + if (member->as_int() < -2147483648LL || member->as_int() > 2147483647LL) return smithy::Error::Serialization("SinkQuotaExceeded.retryAfterSeconds: value out of range"); + parsed_member = static_cast(member->as_int()); + out.retryAfterSeconds = std::move(parsed_member); + } + } + return out; +} + +smithy::Document SerializeReceipt(const Receipt& value) { + smithy::DocumentMap map; + map.emplace("receiptId", smithy::Document(value.receiptId)); + if (value.size.has_value()) { + map.emplace("size", smithy::Document(static_cast((*value.size)))); + } + return smithy::Document(std::move(map)); +} + +smithy::Outcome DeserializeReceipt(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("Receipt: expected a map on the wire"); + Receipt out; + { + const smithy::Document* member = doc.Find("receiptId"); + if (member == nullptr || member->is_null()) { + return smithy::Error::Serialization("Receipt: missing required member: receiptId"); + } + if (!member->is_string()) return smithy::Error::Serialization("Receipt.receiptId: unexpected type on the wire"); + out.receiptId = member->as_string(); + } + { + const smithy::Document* member = doc.Find("size"); + if (member != nullptr && !member->is_null()) { + std::int64_t parsed_member{}; + if (!member->is_int()) return smithy::Error::Serialization("Receipt.size: unexpected type on the wire"); + parsed_member = static_cast(member->as_int()); + out.size = std::move(parsed_member); + } + } + return out; +} + +smithy::Document SerializeUploadAttachmentInput(const UploadAttachmentInput& value) { + smithy::DocumentMap map; + map.emplace("sinkId", smithy::Document(value.sinkId)); + if (value.name.has_value()) { + map.emplace("name", smithy::Document((*value.name))); + } + if (value.data.has_value()) { + map.emplace("data", smithy::Document((*value.data))); + } + return smithy::Document(std::move(map)); +} + +smithy::Outcome DeserializeUploadAttachmentInput(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("UploadAttachmentInput: expected a map on the wire"); + UploadAttachmentInput out; + { + const smithy::Document* member = doc.Find("sinkId"); + if (member == nullptr || member->is_null()) { + return smithy::Error::Serialization("UploadAttachmentInput: missing required member: sinkId"); + } + if (!member->is_string()) return smithy::Error::Serialization("UploadAttachmentInput.sinkId: unexpected type on the wire"); + out.sinkId = member->as_string(); + } + { + const smithy::Document* member = doc.Find("name"); + if (member != nullptr && !member->is_null()) { + std::string parsed_member{}; + if (!member->is_string()) return smithy::Error::Serialization("UploadAttachmentInput.name: unexpected type on the wire"); + parsed_member = member->as_string(); + out.name = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("data"); + if (member != nullptr && !member->is_null()) { + smithy::Blob parsed_member{}; + { + auto parsed = smithy::BlobFromDocument(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.data = std::move(parsed_member); + } + } + return out; +} + +smithy::Document SerializeUploadAttachmentOutput(const UploadAttachmentOutput& value) { + smithy::DocumentMap map; + if (value.receipt.has_value()) { + map.emplace("receipt", SerializeReceipt((*value.receipt))); + } + return smithy::Document(std::move(map)); +} + +smithy::Outcome DeserializeUploadAttachmentOutput(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("UploadAttachmentOutput: expected a map on the wire"); + UploadAttachmentOutput out; + { + const smithy::Document* member = doc.Find("receipt"); + if (member != nullptr && !member->is_null()) { + Receipt parsed_member{}; + { + auto parsed = DeserializeReceipt(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.receipt = std::move(parsed_member); + } + } + return out; +} + +} // namespace example::roundtrip::rest diff --git a/examples/roundtrip/rest/generated/src/server.cc b/examples/roundtrip/rest/generated/src/server.cc new file mode 100644 index 00000000..61950daa --- /dev/null +++ b/examples/roundtrip/rest/generated/src/server.cc @@ -0,0 +1,453 @@ +// Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "example/roundtrip/rest/serde.h" +#include "example/roundtrip/rest/server.h" +#include "smithy/core/base64.h" +#include "smithy/core/blob.h" +#include "smithy/core/document.h" +#include "smithy/core/document_serde.h" +#include "smithy/core/text.h" +#include "smithy/http/headers.h" +#include "smithy/json/json.h" +#include "smithy/server/router.h" + +namespace example::roundtrip::rest { + +namespace { + +// Strict text parsing for label/query/header bindings ([[maybe_unused]]: +// emitted for every service; not every service binds numeric values). +// Trailing text, floats-for-ints, and out-of-range values are rejected +// (the malformed-request suites pin this). +[[maybe_unused]] smithy::Outcome ParseInt64Text(const std::string& text, std::int64_t min_value, std::int64_t max_value) { + std::int64_t value = 0; + const char* first = text.data(); + const char* last = first + text.size(); + const auto result = std::from_chars(first, last, value, 10); + if (text.empty() || result.ec != std::errc() || result.ptr != last || value < min_value || value > max_value) { + return smithy::Error::Serialization("invalid integer: " + text); + } + return value; +} + +[[maybe_unused]] smithy::Outcome ParseDoubleText(const std::string& text) { + if (text == "NaN") return std::numeric_limits::quiet_NaN(); + if (text == "Infinity") return std::numeric_limits::infinity(); + if (text == "-Infinity") return -std::numeric_limits::infinity(); + const auto valid_char = [](char c) { + return (c >= '0' && c <= '9') || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-'; + }; + if (text.empty() || text.front() == '+' || !std::all_of(text.begin(), text.end(), valid_char)) { + return smithy::Error::Serialization("invalid number: " + text); + } + char* parse_end = nullptr; + const double value = std::strtod(text.c_str(), &parse_end); + if (parse_end != text.c_str() + text.size() || !std::isfinite(value)) { + return smithy::Error::Serialization("invalid number: " + text); + } + return value; +} + +smithy::http::HttpResponse JsonError(int status, const std::string& code, const std::string& message, smithy::DocumentMap body) { + if (!code.empty()) body.insert_or_assign("__type", smithy::Document(code)); + if (!message.empty()) body.insert_or_assign("message", smithy::Document(message)); + smithy::http::HttpResponse response; + response.status = status; + response.headers.Set("content-type", "application/json"); + response.body = smithy::json::Encode(smithy::Document(std::move(body))); + return response; +} + +smithy::http::HttpResponse ErrorToResponse(const smithy::Error& error) { + std::vector> header_values; + (void)header_values; + if (error.kind() == smithy::ErrorKind::kModeled) { + if (error.code() == "SinkNotFound") { + smithy::DocumentMap body; + if (const auto* detail = error.detail()) { + body = SerializeSinkNotFound(*detail).as_map(); + } + // The typed detail's own message member wins over the generic one. + const bool has_message = body.count("message") != 0 || body.count("Message") != 0; + if (!has_message && !error.message().empty()) { + body.emplace("message", smithy::Document(error.message())); + } + auto response = JsonError(404, "", "", std::move(body)); + response.headers.Set("x-amzn-errortype", error.code()); + for (const auto& [name, value] : header_values) response.headers.Set(name, value); + return response; + } + if (error.code() == "SinkQuotaExceeded") { + smithy::DocumentMap body; + if (const auto* detail = error.detail()) { + body = SerializeSinkQuotaExceeded(*detail).as_map(); + } + // The typed detail's own message member wins over the generic one. + const bool has_message = body.count("message") != 0 || body.count("Message") != 0; + if (!has_message && !error.message().empty()) { + body.emplace("message", smithy::Document(error.message())); + } + auto response = JsonError(503, "", "", std::move(body)); + response.headers.Set("x-amzn-errortype", error.code()); + for (const auto& [name, value] : header_values) response.headers.Set(name, value); + return response; + } + return JsonError(400, error.code(), error.message(), {}); + } + if (error.kind() == smithy::ErrorKind::kValidation || error.kind() == smithy::ErrorKind::kSerialization) { + auto response = JsonError(400, "", error.message(), {}); + response.headers.Set("x-amzn-errortype", "SerializationException"); + return response; + } + // Never leak internal detail on unexpected failures. + return JsonError(500, "InternalFailure", "internal failure", {}); +} + +// Constraint validation (smithy.framework#ValidationException): messages +// and '/member' paths follow the official validation conformance suite. +void AddValidationFailure(std::vector* failures, std::string path, std::string message) { + failures->push_back({std::move(path), std::move(message)}); +} + +void ValidateDescribeSinkInput(const DescribeSinkInput& value, const std::string& path, std::vector* failures) { + const std::string member_path = path + "/sinkId"; + { + const std::size_t member_length = smithy::Utf8CodePointCount(value.sinkId); + if (member_length < 1ULL || member_length > 32ULL) { + AddValidationFailure(failures, member_path, "Value with length " + std::to_string(member_length) + " at '" + member_path + "' failed to satisfy constraint: Member must have length between 1 and 32, inclusive"); + } + } + static const std::regex kPattern0{R"__smithy(^[A-Za-z0-9]+$)__smithy", std::regex::ECMAScript}; + if (!std::regex_search(value.sinkId, kPattern0)) { + AddValidationFailure(failures, member_path, "Value at '" + member_path + "' failed to satisfy constraint: Member must satisfy regular expression pattern: " + std::string("^[A-Za-z0-9]+$")); + } +} + +void ValidateKitchenSink(const KitchenSink& value, const std::string& path, std::vector* failures) { + if (value.priority.has_value()) { + const std::string member_path = path + "/priority"; + if ((*value.priority).value() == Priority::Value::kUnknown) { + AddValidationFailure(failures, member_path, "Value at '" + member_path + "' failed to satisfy constraint: Member must satisfy enum value set: [low, medium, high]"); + } + } + if (value.uniqueNames.has_value()) { + const std::string member_path = path + "/uniqueNames"; + { + bool unique = true; + for (std::size_t i = 0; i < (*value.uniqueNames).size() && unique; ++i) { + for (std::size_t j = i + 1; j < (*value.uniqueNames).size(); ++j) { + if ((*value.uniqueNames)[i] == (*value.uniqueNames)[j]) { unique = false; break; } + } + } + if (!unique) { + AddValidationFailure(failures, member_path, "Value at '" + member_path + "' failed to satisfy constraint: Member must have unique values"); + } + } + } +} + +void ValidatePutSinkInput(const PutSinkInput& value, const std::string& path, std::vector* failures) { + const std::string member_path = path + "/sinkId"; + { + const std::size_t member_length = smithy::Utf8CodePointCount(value.sinkId); + if (member_length < 1ULL || member_length > 32ULL) { + AddValidationFailure(failures, member_path, "Value with length " + std::to_string(member_length) + " at '" + member_path + "' failed to satisfy constraint: Member must have length between 1 and 32, inclusive"); + } + } + static const std::regex kPattern1{R"__smithy(^[A-Za-z0-9]+$)__smithy", std::regex::ECMAScript}; + if (!std::regex_search(value.sinkId, kPattern1)) { + AddValidationFailure(failures, member_path, "Value at '" + member_path + "' failed to satisfy constraint: Member must satisfy regular expression pattern: " + std::string("^[A-Za-z0-9]+$")); + } + if (value.limit.has_value()) { + const std::string member_path = path + "/limit"; + if ((*value.limit) < 1 || (*value.limit) > 100) { + AddValidationFailure(failures, member_path, "Value at '" + member_path + "' failed to satisfy constraint: Member must be between 1 and 100, inclusive"); + } + } + if (value.priority.has_value()) { + const std::string member_path = path + "/priority"; + if ((*value.priority).value() == Priority::Value::kUnknown) { + AddValidationFailure(failures, member_path, "Value at '" + member_path + "' failed to satisfy constraint: Member must satisfy enum value set: [low, medium, high]"); + } + } + if (value.sink.has_value()) { + const std::string member_path = path + "/sink"; + ValidateKitchenSink((*value.sink), member_path, failures); + } +} + +void ValidateUploadAttachmentInput(const UploadAttachmentInput& value, const std::string& path, std::vector* failures) { + const std::string member_path = path + "/sinkId"; + { + const std::size_t member_length = smithy::Utf8CodePointCount(value.sinkId); + if (member_length < 1ULL || member_length > 32ULL) { + AddValidationFailure(failures, member_path, "Value with length " + std::to_string(member_length) + " at '" + member_path + "' failed to satisfy constraint: Member must have length between 1 and 32, inclusive"); + } + } + static const std::regex kPattern2{R"__smithy(^[A-Za-z0-9]+$)__smithy", std::regex::ECMAScript}; + if (!std::regex_search(value.sinkId, kPattern2)) { + AddValidationFailure(failures, member_path, "Value at '" + member_path + "' failed to satisfy constraint: Member must satisfy regular expression pattern: " + std::string("^[A-Za-z0-9]+$")); + } +} + +smithy::http::HttpResponse ValidationErrorResponse(const std::vector& failures) { + std::string summary = std::to_string(failures.size()) + " validation error" + (failures.size() == 1 ? "" : "s") + " detected. "; + smithy::DocumentList field_list; + for (std::size_t i = 0; i < failures.size(); ++i) { + if (i > 0) summary += "; "; + summary += failures[i].message; + smithy::DocumentMap field; + field.emplace("message", smithy::Document(failures[i].message)); + field.emplace("path", smithy::Document(failures[i].path)); + field_list.push_back(smithy::Document(std::move(field))); + } + smithy::DocumentMap body; + body.emplace("fieldList", smithy::Document(std::move(field_list))); + smithy::http::HttpResponse response = JsonError(400, "", summary, std::move(body)); + response.headers.Set("x-amzn-errortype", "ValidationException"); + return response; +} + +smithy::Outcome ParseDescribeSinkInput(const smithy::http::HttpRequest& request, const smithy::server::RequestContext& context, std::vector* validation_failures) { + (void)request; + (void)context; + (void)validation_failures; + DescribeSinkInput input{}; + { + const std::string& label_value = context.labels.at("sinkId"); + input.sinkId = label_value; + } + return input; +} + +smithy::http::HttpResponse SerializeDescribeSinkResponse(const DescribeSinkOutput& output) { + (void)output; + smithy::http::HttpResponse response; + response.status = 200; + smithy::DocumentMap body_map; + if (output.sink.has_value()) { + body_map.emplace("sink", SerializeKitchenSink((*output.sink))); + } + response.headers.Set("content-type", "application/json"); + response.body = smithy::json::Encode(smithy::Document(std::move(body_map))); + return response; +} + +smithy::Outcome ParsePutSinkInput(const smithy::http::HttpRequest& request, const smithy::server::RequestContext& context, std::vector* validation_failures) { + (void)request; + (void)context; + (void)validation_failures; + PutSinkInput input{}; + { + const std::string& label_value = context.labels.at("sinkId"); + input.sinkId = label_value; + } + if (const auto header_value = request.headers.Get("x-sink-created"); header_value.has_value()) { + auto parsed_ts = smithy::Timestamp::Parse((*header_value), smithy::TimestampFormat::kHttpDate); + if (!parsed_ts) return std::move(parsed_ts).error(); + input.created = *std::move(parsed_ts); + } + if (const auto header_value = request.headers.Get("x-sink-priority"); header_value.has_value()) { + input.priority = Priority::FromString((*header_value)); + } + for (const auto& [key, value] : context.query_params) { + if (key == "limit") { + auto parsed_num = ParseInt64Text(value, -2147483648LL, 2147483647LL); + if (!parsed_num) return std::move(parsed_num).error(); + input.limit = static_cast(*parsed_num); + continue; + } + if (key == "tag") { + input.tag = value; + continue; + } + } + for (const auto& [header_name, header_value] : request.headers.entries()) { + if (!smithy::http::HeaderNameStartsWith(header_name, "x-meta-")) continue; + if (!input.metadata.has_value()) input.metadata.emplace(); + (*input.metadata).insert_or_assign(header_name.substr(7), header_value); + } + auto body_doc = smithy::json::Decode(request.body.empty() ? "{}" : request.body); + if (!body_doc) return std::move(body_doc).error(); + if (!body_doc->is_map()) return smithy::Error::Serialization("PutSink: expected a JSON object body"); + { + const smithy::Document* member = body_doc->Find("sink"); + if (member != nullptr && !member->is_null()) { + KitchenSink parsed_member{}; + { + auto parsed = DeserializeKitchenSink(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + input.sink = std::move(parsed_member); + } + } + { + const smithy::Document* member = body_doc->Find("freeform"); + if (member != nullptr && !member->is_null()) { + smithy::Document parsed_member{}; + parsed_member = *member; + input.freeform = std::move(parsed_member); + } + } + return input; +} + +smithy::http::HttpResponse SerializePutSinkResponse(const PutSinkOutput& output) { + (void)output; + smithy::http::HttpResponse response; + response.status = 200; + if (output.revision.has_value()) { + response.headers.Set("x-sink-revision", std::to_string(static_cast((*output.revision)))); + } + if (output.echoedMetadata.has_value()) { + for (const auto& [map_key, map_value] : (*output.echoedMetadata)) { + response.headers.Set("x-echo-" + map_key, map_value); + } + } + if (output.echoedMetadata.has_value()) { + for (const auto& [map_key, map_value] : (*output.echoedMetadata)) { + response.headers.Set("x-echo-" + map_key, map_value); + } + } + smithy::DocumentMap body_map; + body_map.emplace("sinkId", smithy::Document(output.sinkId)); + if (output.sink.has_value()) { + body_map.emplace("sink", SerializeKitchenSink((*output.sink))); + } + response.headers.Set("content-type", "application/json"); + response.body = smithy::json::Encode(smithy::Document(std::move(body_map))); + return response; +} + +smithy::Outcome ParseUploadAttachmentInput(const smithy::http::HttpRequest& request, const smithy::server::RequestContext& context, std::vector* validation_failures) { + (void)request; + (void)context; + (void)validation_failures; + UploadAttachmentInput input{}; + { + const std::string& label_value = context.labels.at("sinkId"); + input.sinkId = label_value; + } + if (const auto header_value = request.headers.Get("x-attachment-name"); header_value.has_value()) { + input.name = (*header_value); + } + if (!request.body.empty()) { + input.data = smithy::Blob::FromString(request.body); + } + return input; +} + +smithy::http::HttpResponse SerializeUploadAttachmentResponse(const UploadAttachmentOutput& output) { + (void)output; + smithy::http::HttpResponse response; + response.status = 200; + if (output.receipt.has_value()) { + response.body = smithy::json::Encode(SerializeReceipt((*output.receipt))); + } else { + response.body = "{}"; + } + if (!response.headers.Get("content-type").has_value()) response.headers.Set("content-type", "application/json"); + response.headers.Set("content-length", std::to_string(response.body.size())); + return response; +} + +} // namespace + +RoundTripRestServer::RoundTripRestServer(std::shared_ptr handler) + : router_(std::make_shared()) { + // The route table is derived from the model's @http traits; conflicts are + // a modeling error surfaced by Router::Add (checked at generation time in a + // later phase), so registration results are intentionally discarded. + (void)router_->Add("GET", "/sinks/{sinkId}", [handler](const smithy::http::HttpRequest& request, const smithy::server::RequestContext& context) -> smithy::http::HttpResponse { + // Content-Type validation per the HTTP binding spec (415), then Accept (406); + // the malformed-request suite pins the error-identity headers. A missing + // content-type is tolerated, and blob payloads without @mediaType accept + // any content type / accept. + if (const auto content_type = request.headers.Get("content-type"); content_type.has_value() && smithy::http::MediaTypeOf(*content_type) != "application/json") { + auto error_response = JsonError(415, "", "unsupported media type", {}); + error_response.headers.Set("x-amzn-errortype", "UnsupportedMediaTypeException"); + return error_response; + } + if (const auto accept = request.headers.Get("accept"); accept.has_value() && !smithy::http::AcceptMatches(*accept, "application/json")) { + auto error_response = JsonError(406, "", "not acceptable", {}); + error_response.headers.Set("x-amzn-errortype", "NotAcceptableException"); + return error_response; + } + std::vector validation_failures; + auto input = ParseDescribeSinkInput(request, context, &validation_failures); + if (!validation_failures.empty()) return ValidationErrorResponse(validation_failures); + if (!input) return ErrorToResponse(input.error()); + ValidateDescribeSinkInput(*input, "", &validation_failures); + if (!validation_failures.empty()) return ValidationErrorResponse(validation_failures); + auto outcome = handler->DescribeSink(*input); + if (!outcome) return ErrorToResponse(outcome.error()); + return SerializeDescribeSinkResponse(*outcome); + }); + (void)router_->Add("PUT", "/sinks/{sinkId}", [handler](const smithy::http::HttpRequest& request, const smithy::server::RequestContext& context) -> smithy::http::HttpResponse { + // Content-Type validation per the HTTP binding spec (415), then Accept (406); + // the malformed-request suite pins the error-identity headers. A missing + // content-type is tolerated, and blob payloads without @mediaType accept + // any content type / accept. + if (const auto content_type = request.headers.Get("content-type"); content_type.has_value() && smithy::http::MediaTypeOf(*content_type) != "application/json") { + auto error_response = JsonError(415, "", "unsupported media type", {}); + error_response.headers.Set("x-amzn-errortype", "UnsupportedMediaTypeException"); + return error_response; + } + if (const auto accept = request.headers.Get("accept"); accept.has_value() && !smithy::http::AcceptMatches(*accept, "application/json")) { + auto error_response = JsonError(406, "", "not acceptable", {}); + error_response.headers.Set("x-amzn-errortype", "NotAcceptableException"); + return error_response; + } + std::vector validation_failures; + auto input = ParsePutSinkInput(request, context, &validation_failures); + if (!validation_failures.empty()) return ValidationErrorResponse(validation_failures); + if (!input) return ErrorToResponse(input.error()); + ValidatePutSinkInput(*input, "", &validation_failures); + if (!validation_failures.empty()) return ValidationErrorResponse(validation_failures); + auto outcome = handler->PutSink(*input); + if (!outcome) return ErrorToResponse(outcome.error()); + return SerializePutSinkResponse(*outcome); + }); + (void)router_->Add("POST", "/sinks/{sinkId}/attachment", [handler](const smithy::http::HttpRequest& request, const smithy::server::RequestContext& context) -> smithy::http::HttpResponse { + // Content-Type validation per the HTTP binding spec (415), then Accept (406); + // the malformed-request suite pins the error-identity headers. A missing + // content-type is tolerated, and blob payloads without @mediaType accept + // any content type / accept. + if (const auto accept = request.headers.Get("accept"); accept.has_value() && !smithy::http::AcceptMatches(*accept, "application/json")) { + auto error_response = JsonError(406, "", "not acceptable", {}); + error_response.headers.Set("x-amzn-errortype", "NotAcceptableException"); + return error_response; + } + std::vector validation_failures; + auto input = ParseUploadAttachmentInput(request, context, &validation_failures); + if (!validation_failures.empty()) return ValidationErrorResponse(validation_failures); + if (!input) return ErrorToResponse(input.error()); + ValidateUploadAttachmentInput(*input, "", &validation_failures); + if (!validation_failures.empty()) return ValidationErrorResponse(validation_failures); + auto outcome = handler->UploadAttachment(*input); + if (!outcome) return ErrorToResponse(outcome.error()); + return SerializeUploadAttachmentResponse(*outcome); + }); +} + +smithy::http::RequestHandler RoundTripRestServer::Handler() const { + auto router = router_; + return [router](const smithy::http::HttpRequest& request) { return router->Route(request); }; +} + +} // namespace example::roundtrip::rest diff --git a/examples/roundtrip/rest/generated/tests/BUILD.bazel b/examples/roundtrip/rest/generated/tests/BUILD.bazel new file mode 100644 index 00000000..06d91b8a --- /dev/null +++ b/examples/roundtrip/rest/generated/tests/BUILD.bazel @@ -0,0 +1,29 @@ +# Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +load("@rules_cc//cc:defs.bzl", "cc_test") + +cc_test( + name = "smoke_test", + size = "small", + srcs = ["smoke_test.cc"], + deps = [ + "//examples/roundtrip/rest/generated:client", + "//examples/roundtrip/rest/generated:server", + "//runtime:client", + "//runtime:http", + "@googletest//:gtest_main", + ], +) + +cc_test( + name = "integration_test", + size = "small", + srcs = ["integration_test.cc"], + deps = [ + "//examples/roundtrip/rest/generated:client", + "//examples/roundtrip/rest/generated:server", + "//runtime:http", + "//runtime:protocol_test_support", + "@googletest//:gtest_main", + ], +) diff --git a/examples/roundtrip/rest/generated/tests/integration_test.cc b/examples/roundtrip/rest/generated/tests/integration_test.cc new file mode 100644 index 00000000..104cb605 --- /dev/null +++ b/examples/roundtrip/rest/generated/tests/integration_test.cc @@ -0,0 +1,464 @@ +// Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "example/roundtrip/rest/client.h" +#include "example/roundtrip/rest/server.h" +#include "smithy/client/config.h" +#include "smithy/http/loopback.h" +#include "smithy/http/socket_transport.h" +#include "smithy/json/json.h" +#include "smithy/testing/protocol_test.h" + +namespace example::roundtrip::rest { + +// Integration tests for the generated RoundTripRest service (PLAN Phase 5): the +// generated client drives the generated server over the loopback transport +// AND a real socket on an ephemeral port. Random inputs are seeded and +// constraint-valid; a failure reproduces deterministically. + +namespace { + +// Deterministic pseudo-random source for round-trip inputs; fill_all forces +// every optional member on and maximum container sizes (the "maximal" rows). +struct Rng { + std::mt19937 engine; + bool fill_all = false; + + bool Coin() { return fill_all || (engine() & 1U) != 0; } + + std::size_t Size(std::size_t min_size, std::size_t max_size) { + if (fill_all || max_size <= min_size) return max_size; + return min_size + engine() % (max_size - min_size + 1); + } + + // Inclusive and boundary-biased: min/max come up 10% of the time each. + std::int64_t Int(std::int64_t min_value, std::int64_t max_value) { + const auto pick = engine() % 10; + if (pick == 0 || min_value >= max_value) return min_value; + if (pick == 1) return max_value; + // The span is computed unsigned: wide ranges overflow int64 arithmetic. + const std::uint64_t span = static_cast(max_value) - static_cast(min_value); + return min_value + static_cast(engine() % (span + 1)); + } + + std::string Text(std::size_t min_len, std::size_t max_len) { + static const char kAlphabet[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + const std::size_t n = Size(min_len, max_len); + std::string out; + out.reserve(n); + for (std::size_t i = 0; i < n; ++i) out.push_back(kAlphabet[engine() % 62]); + return out; + } +}; + +DescribeSinkInput RandomDescribeSinkInput(Rng& rng) { + DescribeSinkInput v{}; + v.sinkId = std::string("0"); + return v; +} + +std::map RandomStringMap(Rng& rng) { + std::map v{}; + const std::size_t n = rng.Size(1, 3); + for (std::size_t i = 0; i < n; ++i) { + v.insert_or_assign(rng.Text(1, 6) + std::to_string(i), rng.Text(1, 9)); + } + return v; +} + +NestedConfig RandomNestedConfig(Rng& rng) { + NestedConfig v{}; + v.label = rng.Text(1, 9); + if (rng.Coin()) v.depth = static_cast(rng.Int(-2147483648LL, 2147483647LL)); + return v; +} + +SinkChoice RandomSinkChoice(Rng& rng) { + switch (rng.engine() % 3) { + case 0: + return SinkChoice::FromText(rng.Text(1, 9)); + case 1: + return SinkChoice::FromCount(static_cast(rng.Int(-2147483648LL, 2147483647LL))); + default: + return SinkChoice::FromNested(RandomNestedConfig(rng)); + } +} + +std::vector RandomStringList(Rng& rng) { + std::vector v{}; + const std::size_t n = rng.Size(1, 3); + for (std::size_t i = 0; i < n; ++i) { + v.push_back(rng.Text(1, 9)); + } + return v; +} + +std::vector> RandomSparseIntegerList(Rng& rng) { + std::vector> v{}; + const std::size_t n = rng.Size(1, 3); + for (std::size_t i = 0; i < n; ++i) { + if (!rng.fill_all && rng.engine() % 4 == 0) { + v.push_back(std::nullopt); + continue; + } + v.push_back(static_cast(rng.Int(-2147483648LL, 2147483647LL))); + } + return v; +} + +std::vector RandomUniqueStringList(Rng& rng) { + std::vector v{}; + const std::size_t n = rng.Size(1, 2); + for (std::size_t i = 0; i < n; ++i) { + v.push_back(rng.Text(1, 9) + std::to_string(i)); + } + return v; +} + +KitchenSink RandomKitchenSink(Rng& rng) { + KitchenSink v{}; + v.name = rng.Text(1, 9); + if (rng.Coin()) v.flag = (rng.engine() & 1U) != 0; + if (rng.Coin()) v.tiny = static_cast(rng.Int(-128LL, 127LL)); + if (rng.Coin()) v.small = static_cast(rng.Int(-32768LL, 32767LL)); + if (rng.Coin()) v.medium = static_cast(rng.Int(-2147483648LL, 2147483647LL)); + if (rng.Coin()) v.big = static_cast(rng.Int(-4611686018427387904LL, 4611686018427387903LL)); + if (rng.Coin()) v.ratio = static_cast(rng.Int(-8000000LL, 8000000LL)) / static_cast(8); + if (rng.Coin()) v.precise = static_cast(rng.Int(-8000000LL, 8000000LL)) / static_cast(8); + if (rng.Coin()) v.blob = smithy::Blob::FromString(rng.Text(1, 9)); + if (rng.Coin()) v.priority = Priority::FromString(std::array{"low", "medium", "high"}[rng.engine() % 3]); + if (rng.Coin()) v.weight = static_cast(std::array{1, 2}[rng.engine() % 2]); + if (rng.Coin()) v.dateTime = smithy::Timestamp::FromEpochMilliseconds(rng.Int(0, 4102444799LL) * 1000); + if (rng.Coin()) v.httpDate = smithy::Timestamp::FromEpochMilliseconds(rng.Int(0, 4102444799LL) * 1000); + if (rng.Coin()) v.epoch = smithy::Timestamp::FromEpochMilliseconds(rng.Int(0, 4102444799LL) * 1000); + if (rng.Coin()) v.names = RandomStringList(rng); + if (rng.Coin()) v.uniqueNames = RandomUniqueStringList(rng); + if (rng.Coin()) v.sparseNumbers = RandomSparseIntegerList(rng); + if (rng.Coin()) v.attributes = RandomStringMap(rng); + if (rng.Coin()) v.nested = RandomNestedConfig(rng); + if (rng.Coin()) v.choice = RandomSinkChoice(rng); + return v; +} + +DescribeSinkOutput RandomDescribeSinkOutput(Rng& rng) { + DescribeSinkOutput v{}; + if (rng.Coin()) v.sink = RandomKitchenSink(rng); + return v; +} + +SinkNotFound RandomSinkNotFound(Rng& rng) { + SinkNotFound v{}; + v.message = rng.Text(1, 9); + if (rng.Coin()) v.resourceType = rng.Text(1, 9); + return v; +} + +PutSinkInput RandomPutSinkInput(Rng& rng) { + PutSinkInput v{}; + v.sinkId = std::string("0"); + if (rng.Coin()) v.tag = rng.Text(1, 9); + if (rng.Coin()) v.limit = static_cast(rng.Int(1LL, 100LL)); + if (rng.Coin()) v.priority = Priority::FromString(std::array{"low", "medium", "high"}[rng.engine() % 3]); + if (rng.Coin()) v.created = smithy::Timestamp::FromEpochMilliseconds(rng.Int(0, 4102444799LL) * 1000); + if (rng.Coin()) v.metadata = RandomStringMap(rng); + if (rng.Coin()) v.sink = RandomKitchenSink(rng); + if (rng.Coin()) v.freeform = smithy::Document(smithy::DocumentMap{{"key", smithy::Document(rng.Int(0, 1000))}}); + return v; +} + +PutSinkOutput RandomPutSinkOutput(Rng& rng) { + PutSinkOutput v{}; + v.sinkId = rng.Text(1, 9); + if (rng.Coin()) v.revision = static_cast(rng.Int(-2147483648LL, 2147483647LL)); + if (rng.Coin()) v.echoedMetadata = RandomStringMap(rng); + if (rng.Coin()) v.sink = RandomKitchenSink(rng); + return v; +} + +SinkQuotaExceeded RandomSinkQuotaExceeded(Rng& rng) { + SinkQuotaExceeded v{}; + if (rng.Coin()) v.message = rng.Text(1, 9); + if (rng.Coin()) v.retryAfterSeconds = static_cast(rng.Int(-2147483648LL, 2147483647LL)); + return v; +} + +Receipt RandomReceipt(Rng& rng) { + Receipt v{}; + v.receiptId = rng.Text(1, 9); + if (rng.Coin()) v.size = static_cast(rng.Int(-4611686018427387904LL, 4611686018427387903LL)); + return v; +} + +UploadAttachmentInput RandomUploadAttachmentInput(Rng& rng) { + UploadAttachmentInput v{}; + v.sinkId = std::string("0"); + if (rng.Coin()) v.name = rng.Text(1, 9); + if (rng.Coin()) v.data = smithy::Blob::FromString(rng.Text(1, 9)); + return v; +} + +UploadAttachmentOutput RandomUploadAttachmentOutput(Rng& rng) { + UploadAttachmentOutput v{}; + if (rng.Coin()) v.receipt = RandomReceipt(rng); + return v; +} + +class ScriptedHandler final : public RoundTripRestHandler { + public: + smithy::Outcome DescribeSink(const DescribeSinkInput& input) override { + lastDescribeSink = input; + if (nextDescribeSinkError.has_value()) return *nextDescribeSinkError; + return nextDescribeSinkOutput; + } + std::optional lastDescribeSink; + DescribeSinkOutput nextDescribeSinkOutput{}; + std::optional nextDescribeSinkError; + smithy::Outcome PutSink(const PutSinkInput& input) override { + lastPutSink = input; + if (nextPutSinkError.has_value()) return *nextPutSinkError; + return nextPutSinkOutput; + } + std::optional lastPutSink; + PutSinkOutput nextPutSinkOutput{}; + std::optional nextPutSinkError; + smithy::Outcome UploadAttachment(const UploadAttachmentInput& input) override { + lastUploadAttachment = input; + if (nextUploadAttachmentError.has_value()) return *nextUploadAttachmentError; + return nextUploadAttachmentOutput; + } + std::optional lastUploadAttachment; + UploadAttachmentOutput nextUploadAttachmentOutput{}; + std::optional nextUploadAttachmentError; +}; + +enum class TransportKind { kLoopback, kSocket }; + +class RoundTripRestIntegrationTest : public ::testing::TestWithParam { + protected: + void SetUp() override { + handler_ = std::make_shared(); + server_ = std::make_unique(handler_); + smithy::ClientConfig config; + if (GetParam() == TransportKind::kLoopback) { + auto loopback = std::make_shared(); + ASSERT_TRUE(loopback->Start(server_->Handler()).ok()); + config.http_client = loopback; + } else { + socket_server_ = std::make_unique(); + ASSERT_TRUE(socket_server_->Start(server_->Handler()).ok()); + config.endpoint = "http://127.0.0.1:" + std::to_string(socket_server_->port()); + } + auto client = RoundTripRestClient::Create(std::move(config)); + ASSERT_TRUE(client.ok()) << client.error().message(); + client_ = std::make_unique(std::move(*client)); + } + + void TearDown() override { if (socket_server_ != nullptr) socket_server_->Stop(); } + + std::shared_ptr handler_; + std::unique_ptr server_; + std::unique_ptr socket_server_; + std::unique_ptr client_; +}; + +TEST_P(RoundTripRestIntegrationTest, DescribeSinkRandomRoundTrips) { + Rng rng{std::mt19937{20260707U}, /*fill_all=*/false}; + for (int iteration = 0; iteration < 8; ++iteration) { + const DescribeSinkInput input = RandomDescribeSinkInput(rng); + DescribeSinkOutput output = RandomDescribeSinkOutput(rng); + handler_->nextDescribeSinkOutput = output; + const auto outcome = client_->DescribeSink(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastDescribeSink.has_value()); + EXPECT_EQ(*handler_->lastDescribeSink, input); + EXPECT_EQ(*outcome, output); + } +} + +TEST_P(RoundTripRestIntegrationTest, DescribeSinkMaximalRoundTrips) { + Rng rng{std::mt19937{7U}, /*fill_all=*/true}; + const DescribeSinkInput input = RandomDescribeSinkInput(rng); + DescribeSinkOutput output = RandomDescribeSinkOutput(rng); + handler_->nextDescribeSinkOutput = output; + const auto outcome = client_->DescribeSink(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastDescribeSink.has_value()); + EXPECT_EQ(*handler_->lastDescribeSink, input); + EXPECT_EQ(*outcome, output); +} + +TEST_P(RoundTripRestIntegrationTest, PutSinkRandomRoundTrips) { + Rng rng{std::mt19937{20260707U}, /*fill_all=*/false}; + for (int iteration = 0; iteration < 8; ++iteration) { + const PutSinkInput input = RandomPutSinkInput(rng); + PutSinkOutput output = RandomPutSinkOutput(rng); + handler_->nextPutSinkOutput = output; + const auto outcome = client_->PutSink(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastPutSink.has_value()); + EXPECT_EQ(*handler_->lastPutSink, input); + EXPECT_EQ(*outcome, output); + } +} + +TEST_P(RoundTripRestIntegrationTest, PutSinkMaximalRoundTrips) { + Rng rng{std::mt19937{7U}, /*fill_all=*/true}; + const PutSinkInput input = RandomPutSinkInput(rng); + PutSinkOutput output = RandomPutSinkOutput(rng); + handler_->nextPutSinkOutput = output; + const auto outcome = client_->PutSink(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastPutSink.has_value()); + EXPECT_EQ(*handler_->lastPutSink, input); + EXPECT_EQ(*outcome, output); +} + +TEST_P(RoundTripRestIntegrationTest, UploadAttachmentRandomRoundTrips) { + Rng rng{std::mt19937{20260707U}, /*fill_all=*/false}; + for (int iteration = 0; iteration < 8; ++iteration) { + const UploadAttachmentInput input = RandomUploadAttachmentInput(rng); + UploadAttachmentOutput output = RandomUploadAttachmentOutput(rng); + handler_->nextUploadAttachmentOutput = output; + const auto outcome = client_->UploadAttachment(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastUploadAttachment.has_value()); + EXPECT_EQ(*handler_->lastUploadAttachment, input); + EXPECT_EQ(*outcome, output); + } +} + +TEST_P(RoundTripRestIntegrationTest, UploadAttachmentMaximalRoundTrips) { + Rng rng{std::mt19937{7U}, /*fill_all=*/true}; + const UploadAttachmentInput input = RandomUploadAttachmentInput(rng); + UploadAttachmentOutput output = RandomUploadAttachmentOutput(rng); + handler_->nextUploadAttachmentOutput = output; + const auto outcome = client_->UploadAttachment(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastUploadAttachment.has_value()); + EXPECT_EQ(*handler_->lastUploadAttachment, input); + EXPECT_EQ(*outcome, output); +} + +TEST_P(RoundTripRestIntegrationTest, DescribeSinkSinkNotFoundMapsAcrossTheWire) { + Rng rng{std::mt19937{42U}, /*fill_all=*/true}; + const SinkNotFound detail = RandomSinkNotFound(rng); + smithy::Error error = smithy::Error::Modeled("SinkNotFound", "integration"); + error.set_detail(detail); + handler_->nextDescribeSinkError = error; + const DescribeSinkInput input = RandomDescribeSinkInput(rng); + const auto outcome = client_->DescribeSink(input); + ASSERT_FALSE(outcome.ok()); + EXPECT_EQ(outcome.error().kind(), smithy::ErrorKind::kModeled); + EXPECT_EQ(outcome.error().code(), "SinkNotFound"); + ASSERT_NE(outcome.error().detail(), nullptr); + EXPECT_EQ(*outcome.error().detail(), detail); +} + +TEST_P(RoundTripRestIntegrationTest, PutSinkSinkNotFoundMapsAcrossTheWire) { + Rng rng{std::mt19937{42U}, /*fill_all=*/true}; + const SinkNotFound detail = RandomSinkNotFound(rng); + smithy::Error error = smithy::Error::Modeled("SinkNotFound", "integration"); + error.set_detail(detail); + handler_->nextPutSinkError = error; + const PutSinkInput input = RandomPutSinkInput(rng); + const auto outcome = client_->PutSink(input); + ASSERT_FALSE(outcome.ok()); + EXPECT_EQ(outcome.error().kind(), smithy::ErrorKind::kModeled); + EXPECT_EQ(outcome.error().code(), "SinkNotFound"); + ASSERT_NE(outcome.error().detail(), nullptr); + EXPECT_EQ(*outcome.error().detail(), detail); +} + +TEST_P(RoundTripRestIntegrationTest, PutSinkSinkQuotaExceededMapsAcrossTheWire) { + Rng rng{std::mt19937{42U}, /*fill_all=*/true}; + const SinkQuotaExceeded detail = RandomSinkQuotaExceeded(rng); + smithy::Error error = smithy::Error::Modeled("SinkQuotaExceeded", "integration"); + error.set_detail(detail); + handler_->nextPutSinkError = error; + const PutSinkInput input = RandomPutSinkInput(rng); + const auto outcome = client_->PutSink(input); + ASSERT_FALSE(outcome.ok()); + EXPECT_EQ(outcome.error().kind(), smithy::ErrorKind::kModeled); + EXPECT_EQ(outcome.error().code(), "SinkQuotaExceeded"); + ASSERT_NE(outcome.error().detail(), nullptr); + EXPECT_EQ(*outcome.error().detail(), detail); +} + +TEST_P(RoundTripRestIntegrationTest, UploadAttachmentSinkNotFoundMapsAcrossTheWire) { + Rng rng{std::mt19937{42U}, /*fill_all=*/true}; + const SinkNotFound detail = RandomSinkNotFound(rng); + smithy::Error error = smithy::Error::Modeled("SinkNotFound", "integration"); + error.set_detail(detail); + handler_->nextUploadAttachmentError = error; + const UploadAttachmentInput input = RandomUploadAttachmentInput(rng); + const auto outcome = client_->UploadAttachment(input); + ASSERT_FALSE(outcome.ok()); + EXPECT_EQ(outcome.error().kind(), smithy::ErrorKind::kModeled); + EXPECT_EQ(outcome.error().code(), "SinkNotFound"); + ASSERT_NE(outcome.error().detail(), nullptr); + EXPECT_EQ(*outcome.error().detail(), detail); +} + +TEST(RoundTripRestIntegrationUnknownMembers, DescribeSinkToleratesUnknownResponseMembers) { + auto handler = std::make_shared(); + RoundTripRestServer server(handler); + auto loopback = std::make_shared(); + ASSERT_TRUE(loopback->Start(server.Handler()).ok()); + auto inject = [](smithy::http::HttpResponse& response) { + auto doc = smithy::json::Decode(response.body); + if (!doc.ok() || !doc->is_map()) return; + auto map = doc->as_map(); + map.insert_or_assign("smithy_cpp_unknown_member", smithy::Document(42)); + response.body = smithy::json::Encode(smithy::Document(std::move(map))); + }; + auto transport = std::make_shared(loopback, inject); + smithy::ClientConfig config; + config.http_client = transport; + auto client = *RoundTripRestClient::Create(std::move(config)); + Rng rng{std::mt19937{99U}, /*fill_all=*/true}; + const DescribeSinkInput input = RandomDescribeSinkInput(rng); + DescribeSinkOutput output = RandomDescribeSinkOutput(rng); + handler->nextDescribeSinkOutput = output; + const auto outcome = client.DescribeSink(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + EXPECT_EQ(*outcome, output); +} + +TEST(RoundTripRestIntegrationUnknownMembers, PutSinkToleratesUnknownResponseMembers) { + auto handler = std::make_shared(); + RoundTripRestServer server(handler); + auto loopback = std::make_shared(); + ASSERT_TRUE(loopback->Start(server.Handler()).ok()); + auto inject = [](smithy::http::HttpResponse& response) { + auto doc = smithy::json::Decode(response.body); + if (!doc.ok() || !doc->is_map()) return; + auto map = doc->as_map(); + map.insert_or_assign("smithy_cpp_unknown_member", smithy::Document(42)); + response.body = smithy::json::Encode(smithy::Document(std::move(map))); + }; + auto transport = std::make_shared(loopback, inject); + smithy::ClientConfig config; + config.http_client = transport; + auto client = *RoundTripRestClient::Create(std::move(config)); + Rng rng{std::mt19937{99U}, /*fill_all=*/true}; + const PutSinkInput input = RandomPutSinkInput(rng); + PutSinkOutput output = RandomPutSinkOutput(rng); + handler->nextPutSinkOutput = output; + const auto outcome = client.PutSink(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + EXPECT_EQ(*outcome, output); +} + +INSTANTIATE_TEST_SUITE_P(Transports, RoundTripRestIntegrationTest, ::testing::Values(TransportKind::kLoopback, TransportKind::kSocket), [](const auto& info) { return info.param == TransportKind::kLoopback ? "Loopback" : "Socket"; }); + +} // namespace +} // namespace example::roundtrip::rest diff --git a/examples/roundtrip/rest/generated/tests/smoke_test.cc b/examples/roundtrip/rest/generated/tests/smoke_test.cc new file mode 100644 index 00000000..9e031dda --- /dev/null +++ b/examples/roundtrip/rest/generated/tests/smoke_test.cc @@ -0,0 +1,144 @@ +// Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +#include +#include +#include + +#include "example/roundtrip/rest/client.h" +#include "example/roundtrip/rest/server.h" +#include "smithy/client/config.h" +#include "smithy/http/loopback.h" + +namespace example::roundtrip::rest { + +// Smoke tests for the generated RoundTripRest service: the generated client calls the +// generated server over the in-memory loopback transport. A passing suite +// pins routing, serde symmetry, required members, and error mapping; swap the +// stub handler for a real one to grow it into your service's test suite. + +namespace { + +DescribeSinkOutput MinimalDescribeSinkOutput() { + return [] { + DescribeSinkOutput v{}; + return v; + }(); +} + +PutSinkOutput MinimalPutSinkOutput() { + return [] { + PutSinkOutput v{}; + return v; + }(); +} + +UploadAttachmentOutput MinimalUploadAttachmentOutput() { + return [] { + UploadAttachmentOutput v{}; + return v; + }(); +} + +class SmokeHandler : public RoundTripRestHandler { + public: + smithy::Outcome DescribeSink(const DescribeSinkInput& input) override { + (void)input; + return MinimalDescribeSinkOutput(); + } + smithy::Outcome PutSink(const PutSinkInput& input) override { + (void)input; + return MinimalPutSinkOutput(); + } + smithy::Outcome UploadAttachment(const UploadAttachmentInput& input) override { + (void)input; + return MinimalUploadAttachmentOutput(); + } +}; + +RoundTripRestClient MakeClient(std::shared_ptr handler) { + RoundTripRestServer server(std::move(handler)); + auto loopback = std::make_shared(); + (void)loopback->Start(server.Handler()); + smithy::ClientConfig config; + config.http_client = loopback; + // Create cannot fail when a transport is injected. + return *RoundTripRestClient::Create(std::move(config)); +} + +} // namespace + +TEST(RoundTripRestSmokeTest, DescribeSinkRoundTrips) { + RoundTripRestClient client = MakeClient(std::make_shared()); + DescribeSinkInput input = [] { + DescribeSinkInput v{}; + v.sinkId = "0"; + return v; + }(); + // @httpLabel members must be non-empty to route. + input.sinkId = "smoke"; + const auto outcome = client.DescribeSink(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + EXPECT_EQ(*outcome, MinimalDescribeSinkOutput()); +} + +TEST(RoundTripRestSmokeTest, PutSinkRoundTrips) { + RoundTripRestClient client = MakeClient(std::make_shared()); + PutSinkInput input = [] { + PutSinkInput v{}; + v.sinkId = "0"; + return v; + }(); + // @httpLabel members must be non-empty to route. + input.sinkId = "smoke"; + const auto outcome = client.PutSink(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + EXPECT_EQ(*outcome, MinimalPutSinkOutput()); +} + +TEST(RoundTripRestSmokeTest, UploadAttachmentRoundTrips) { + RoundTripRestClient client = MakeClient(std::make_shared()); + UploadAttachmentInput input = [] { + UploadAttachmentInput v{}; + v.sinkId = "0"; + return v; + }(); + // @httpLabel members must be non-empty to route. + input.sinkId = "smoke"; + const auto outcome = client.UploadAttachment(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + EXPECT_EQ(*outcome, MinimalUploadAttachmentOutput()); +} + +TEST(RoundTripRestSmokeTest, ModeledErrorsMapAcrossTheWire) { + class FailingHandler final : public SmokeHandler { + public: + smithy::Outcome DescribeSink(const DescribeSinkInput& input) override { + (void)input; + smithy::Error error = smithy::Error::Modeled("SinkNotFound", "smoke"); + auto detail = [] { + SinkNotFound v{}; + return v; + }(); + detail.message = "smoke"; + error.set_detail(std::move(detail)); + return error; + } + }; + + RoundTripRestClient client = MakeClient(std::make_shared()); + DescribeSinkInput input = [] { + DescribeSinkInput v{}; + v.sinkId = "0"; + return v; + }(); + // @httpLabel members must be non-empty to route. + input.sinkId = "smoke"; + const auto outcome = client.DescribeSink(input); + ASSERT_FALSE(outcome.ok()); + EXPECT_EQ(outcome.error().kind(), smithy::ErrorKind::kModeled); + EXPECT_EQ(outcome.error().code(), "SinkNotFound"); + EXPECT_EQ(outcome.error().message(), "smoke"); + EXPECT_NE(outcome.error().detail(), nullptr); +} + +} // namespace example::roundtrip::rest diff --git a/examples/roundtrip/rpc/generated/BUILD.bazel b/examples/roundtrip/rpc/generated/BUILD.bazel new file mode 100644 index 00000000..a292db96 --- /dev/null +++ b/examples/roundtrip/rpc/generated/BUILD.bazel @@ -0,0 +1,53 @@ +# Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +load("@rules_cc//cc:defs.bzl", "cc_library") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "types", + hdrs = ["include/example/roundtrip/rpc/types.h"], + includes = ["include"], + deps = ["//runtime:core"], +) + +cc_library( + name = "serde", + srcs = ["src/serde.cc"], + hdrs = ["include/example/roundtrip/rpc/serde.h"], + includes = ["include"], + deps = [ + ":types", + "//runtime:core", + ], +) + +cc_library( + name = "client", + srcs = ["src/client.cc"], + hdrs = ["include/example/roundtrip/rpc/client.h"], + includes = ["include"], + deps = [ + ":serde", + ":types", + "//runtime:cbor", + "//runtime:client", + "//runtime:core", + "//runtime:http", + ], +) + +cc_library( + name = "server", + srcs = ["src/server.cc"], + hdrs = ["include/example/roundtrip/rpc/server.h"], + includes = ["include"], + deps = [ + ":serde", + ":types", + "//runtime:cbor", + "//runtime:core", + "//runtime:http", + "//runtime:server", + ], +) diff --git a/examples/roundtrip/rpc/generated/include/example/roundtrip/rpc/client.h b/examples/roundtrip/rpc/generated/include/example/roundtrip/rpc/client.h new file mode 100644 index 00000000..5a8fc7de --- /dev/null +++ b/examples/roundtrip/rpc/generated/include/example/roundtrip/rpc/client.h @@ -0,0 +1,36 @@ +// Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +#pragma once + +#include +#include + +#include "example/roundtrip/rpc/types.h" +#include "smithy/client/config.h" +#include "smithy/core/outcome.h" +#include "smithy/http/transport.h" + +namespace example::roundtrip::rpc { + +/// rpcv2Cbor client for example.roundtrip#RoundTripRpc. +/// Modeled service errors surface as smithy::Error with kind kModeled, +/// code() set to the error shape name, and the deserialized error +/// structure attached: error.detail(). +class RoundTripRpcClient { + public: + /// Fails when the endpoint cannot be parsed and no transport is injected. + static smithy::Outcome Create(smithy::ClientConfig config); + + /// The RPC variant round-trips the same kitchen sink over CBOR. + smithy::Outcome PutSinkRpc(const PutSinkRpcInput& input) const; + + private: + RoundTripRpcClient(smithy::ClientConfig config, std::shared_ptr transport, std::string path_prefix); + smithy::Outcome Send(smithy::http::HttpRequest request) const; + + smithy::ClientConfig config_; + std::shared_ptr transport_; + std::string path_prefix_; +}; + +} // namespace example::roundtrip::rpc diff --git a/examples/roundtrip/rpc/generated/include/example/roundtrip/rpc/serde.h b/examples/roundtrip/rpc/generated/include/example/roundtrip/rpc/serde.h new file mode 100644 index 00000000..71621cac --- /dev/null +++ b/examples/roundtrip/rpc/generated/include/example/roundtrip/rpc/serde.h @@ -0,0 +1,54 @@ +// Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +#pragma once + +#include +#include +#include +#include +#include + +#include "example/roundtrip/rpc/types.h" +#include "smithy/core/document.h" +#include "smithy/core/outcome.h" + +namespace example::roundtrip::rpc { + +// Document-pivot serde for every aggregate shape in the model closure. +// Serializers never fail; deserializers return smithy::Error on wire +// mismatches and enforce @required members. + +smithy::Document SerializeStringMap(const std::map& value); +smithy::Outcome> DeserializeStringMap(const smithy::Document& doc); + +smithy::Document SerializeNestedConfig(const NestedConfig& value); +smithy::Outcome DeserializeNestedConfig(const smithy::Document& doc); + +smithy::Document SerializeSinkChoice(const SinkChoice& value); +smithy::Outcome DeserializeSinkChoice(const smithy::Document& doc); + +smithy::Document SerializeStringList(const std::vector& value); +smithy::Outcome> DeserializeStringList(const smithy::Document& doc); + +smithy::Document SerializeSparseIntegerList(const std::vector>& value); +smithy::Outcome>> DeserializeSparseIntegerList(const smithy::Document& doc); + +smithy::Document SerializeUniqueStringList(const std::vector& value); +smithy::Outcome> DeserializeUniqueStringList(const smithy::Document& doc); + +smithy::Document SerializeKitchenSink(const KitchenSink& value); +smithy::Outcome DeserializeKitchenSink(const smithy::Document& doc); + +smithy::Document SerializeSinkNotFound(const SinkNotFound& value); +smithy::Outcome DeserializeSinkNotFound(const smithy::Document& doc); + +smithy::Document SerializeSinkQuotaExceeded(const SinkQuotaExceeded& value); +smithy::Outcome DeserializeSinkQuotaExceeded(const smithy::Document& doc); + +smithy::Document SerializePutSinkRpcInput(const PutSinkRpcInput& value); +smithy::Outcome DeserializePutSinkRpcInput(const smithy::Document& doc); + +smithy::Document SerializePutSinkRpcOutput(const PutSinkRpcOutput& value); +smithy::Outcome DeserializePutSinkRpcOutput(const smithy::Document& doc); + +} // namespace example::roundtrip::rpc diff --git a/examples/roundtrip/rpc/generated/include/example/roundtrip/rpc/server.h b/examples/roundtrip/rpc/generated/include/example/roundtrip/rpc/server.h new file mode 100644 index 00000000..8b8448ac --- /dev/null +++ b/examples/roundtrip/rpc/generated/include/example/roundtrip/rpc/server.h @@ -0,0 +1,38 @@ +// Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +#pragma once + +#include + +#include "example/roundtrip/rpc/types.h" +#include "smithy/core/outcome.h" +#include "smithy/http/transport.h" +#include "smithy/server/router.h" + +namespace example::roundtrip::rpc { + +/// Implement one method per operation. Return a modeled error as +/// smithy::Error::Modeled("", message), optionally with the +/// typed error structure attached via set_detail() so it serializes fully. +class RoundTripRpcHandler { + public: + virtual ~RoundTripRpcHandler() = default; + + /// The RPC variant round-trips the same kitchen sink over CBOR. + virtual smithy::Outcome PutSinkRpc(const PutSinkRpcInput& input) = 0; +}; + +/// rpcv2Cbor server for example.roundtrip#RoundTripRpc: routing, deserialization, handler dispatch, +/// response serialization, and modeled-error mapping. Pass Handler() to any +/// smithy::http::HttpServerTransport. +class RoundTripRpcServer { + public: + explicit RoundTripRpcServer(std::shared_ptr handler); + + smithy::http::RequestHandler Handler() const; + + private: + std::shared_ptr router_; +}; + +} // namespace example::roundtrip::rpc diff --git a/examples/roundtrip/rpc/generated/include/example/roundtrip/rpc/types.h b/examples/roundtrip/rpc/generated/include/example/roundtrip/rpc/types.h new file mode 100644 index 00000000..ac047b52 --- /dev/null +++ b/examples/roundtrip/rpc/generated/include/example/roundtrip/rpc/types.h @@ -0,0 +1,171 @@ +// Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "smithy/core/blob.h" +#include "smithy/core/timestamp.h" + +namespace example::roundtrip::rpc { + +struct NestedConfig { + std::string label{}; + std::optional depth{}; + + friend bool operator==(const NestedConfig&, const NestedConfig&) = default; +}; + + +class SinkChoice { + public: + SinkChoice() = default; + + static SinkChoice FromText(std::string value) { + SinkChoice result; + result.value_.emplace<1>(std::move(value)); + return result; + } + bool is_text() const { return value_.index() == 1; } + const std::string& as_text() const { return std::get<1>(value_); } + + static SinkChoice FromCount(std::int32_t value) { + SinkChoice result; + result.value_.emplace<2>(std::move(value)); + return result; + } + bool is_count() const { return value_.index() == 2; } + const std::int32_t& as_count() const { return std::get<2>(value_); } + + static SinkChoice FromNested(NestedConfig value) { + SinkChoice result; + result.value_.emplace<3>(std::move(value)); + return result; + } + bool is_nested() const { return value_.index() == 3; } + const NestedConfig& as_nested() const { return std::get<3>(value_); } + + /// True until one of the From* factories has been used. + bool empty() const { return value_.index() == 0; } + + friend bool operator==(const SinkChoice&, const SinkChoice&) = default; + + private: + std::variant value_; +}; + + +class Priority { + public: + enum class Value { + kLow, + kMedium, + kHigh, + kUnknown, + }; + + Priority() = default; + Priority(Value value) : value_(value) {} // NOLINT(*-explicit-*) + + /// Unknown wire values are preserved and reported as Value::kUnknown. + static Priority FromString(std::string_view text) { + if (text == "low") return Priority(Value::kLow); + if (text == "medium") return Priority(Value::kMedium); + if (text == "high") return Priority(Value::kHigh); + Priority result; + result.unknown_ = std::string(text); + return result; + } + + Value value() const { return value_; } + + /// The wire text, including the original text of unknown values. + std::string_view ToString() const { + switch (value_) { + case Value::kLow: return "low"; + case Value::kMedium: return "medium"; + case Value::kHigh: return "high"; + case Value::kUnknown: return unknown_; + } + return unknown_; + } + + friend bool operator==(const Priority&, const Priority&) = default; + + private: + Value value_ = Value::kUnknown; + std::string unknown_; +}; + + +enum class Weight : std::int32_t { + kLight = 1, + kHeavy = 2, +}; + + +struct KitchenSink { + std::string name{}; + std::optional flag{}; + std::optional tiny{}; + std::optional small{}; + std::optional medium{}; + std::optional big{}; + std::optional ratio{}; + std::optional precise{}; + std::optional blob{}; + std::optional priority{}; + std::optional weight{}; + std::optional dateTime{}; + std::optional httpDate{}; + std::optional epoch{}; + std::optional> names{}; + std::optional> uniqueNames{}; + std::optional>> sparseNumbers{}; + std::optional> attributes{}; + std::optional nested{}; + std::optional choice{}; + + friend bool operator==(const KitchenSink&, const KitchenSink&) = default; +}; + + +struct SinkNotFound { + std::string message{}; + std::optional resourceType{}; + + friend bool operator==(const SinkNotFound&, const SinkNotFound&) = default; +}; + + +struct SinkQuotaExceeded { + std::optional message{}; + std::optional retryAfterSeconds{}; + + friend bool operator==(const SinkQuotaExceeded&, const SinkQuotaExceeded&) = default; +}; + + +struct PutSinkRpcInput { + std::string sinkId{}; + std::optional sink{}; + + friend bool operator==(const PutSinkRpcInput&, const PutSinkRpcInput&) = default; +}; + + +struct PutSinkRpcOutput { + std::string sinkId{}; + std::optional sink{}; + + friend bool operator==(const PutSinkRpcOutput&, const PutSinkRpcOutput&) = default; +}; + +} // namespace example::roundtrip::rpc diff --git a/examples/roundtrip/rpc/generated/src/client.cc b/examples/roundtrip/rpc/generated/src/client.cc new file mode 100644 index 00000000..77490bc4 --- /dev/null +++ b/examples/roundtrip/rpc/generated/src/client.cc @@ -0,0 +1,137 @@ +// Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +#include +#include +#include + +#include "example/roundtrip/rpc/client.h" +#include "example/roundtrip/rpc/serde.h" +#include "smithy/cbor/cbor.h" +#include "smithy/core/blob.h" +#include "smithy/core/document_serde.h" +#include "smithy/core/uuid.h" +#include "smithy/http/socket_transport.h" +#include "smithy/http/uri.h" + +namespace example::roundtrip::rpc { + +namespace { + +// The error shape name arrives namespaced ("ns#Shape") and possibly +// URI-qualified; modeled error codes keep only the shape name. +std::string SanitizeErrorCode(std::string_view raw) { + if (const auto colon = raw.find(':'); colon != std::string_view::npos) raw = raw.substr(0, colon); + if (const auto hash = raw.find('#'); hash != std::string_view::npos) raw = raw.substr(hash + 1); + return std::string(raw); +} + +struct ParsedError { + int status = 0; + std::string code = "UnknownError"; + std::string message; + smithy::Document doc; +}; + +ParsedError ParseError(const smithy::http::HttpResponse& response) { + ParsedError parsed; + parsed.status = response.status; + parsed.message = "HTTP " + std::to_string(response.status); + auto doc = smithy::cbor::Decode(smithy::Blob::FromString(response.body)); + if (doc.ok()) parsed.doc = *std::move(doc); + if (parsed.doc.is_map()) { + const smithy::Document* type = parsed.doc.Find("__type"); + if (type == nullptr) type = parsed.doc.Find("code"); + if (parsed.code == "UnknownError" && type != nullptr && type->is_string()) parsed.code = SanitizeErrorCode(type->as_string()); + const smithy::Document* text = parsed.doc.Find("message"); + if (text != nullptr && text->is_string()) parsed.message = text->as_string(); + } + return parsed; +} + +smithy::Error GenericError(ParsedError parsed) { + const bool retryable = parsed.status >= 500; + if (parsed.code == "UnknownError") return smithy::Error(smithy::ErrorKind::kUnknown, std::move(parsed.code), std::move(parsed.message), retryable); + return smithy::Error::Modeled(std::move(parsed.code), std::move(parsed.message), retryable); +} + +smithy::Error MakeSinkNotFoundError(const smithy::http::HttpResponse& response, ParsedError parsed) { + (void)response; + const bool retryable = parsed.status >= 500; + smithy::Error error = smithy::Error::Modeled("SinkNotFound", std::move(parsed.message), retryable); + if (!parsed.doc.is_map()) parsed.doc = smithy::Document(smithy::DocumentMap{}); + auto detail = DeserializeSinkNotFound(parsed.doc); + if (detail.ok()) { + error.set_detail(*std::move(detail)); + } + return error; +} + +smithy::Error MakeSinkQuotaExceededError(const smithy::http::HttpResponse& response, ParsedError parsed) { + (void)response; + const bool retryable = parsed.status >= 500; + smithy::Error error = smithy::Error::Modeled("SinkQuotaExceeded", std::move(parsed.message), retryable); + if (!parsed.doc.is_map()) parsed.doc = smithy::Document(smithy::DocumentMap{}); + auto detail = DeserializeSinkQuotaExceeded(parsed.doc); + if (detail.ok()) { + error.set_detail(*std::move(detail)); + } + return error; +} + +smithy::Error DeserializePutSinkRpcError(const smithy::http::HttpResponse& response) { + ParsedError parsed = ParseError(response); + if (parsed.code == "SinkNotFound") return MakeSinkNotFoundError(response, std::move(parsed)); + if (parsed.code == "SinkQuotaExceeded") return MakeSinkQuotaExceededError(response, std::move(parsed)); + return GenericError(std::move(parsed)); +} + +} // namespace + +smithy::Outcome RoundTripRpcClient::Create(smithy::ClientConfig config) { + std::shared_ptr transport = config.http_client; + std::string prefix; + if (!config.endpoint.empty()) { + auto endpoint = smithy::http::ParseEndpoint(config.endpoint); + if (!endpoint) return std::move(endpoint).error(); + prefix = endpoint->path_prefix; + if (transport == nullptr) { + transport = std::make_shared(endpoint->host, endpoint->port, config.request_timeout_ms); + } + } + if (transport == nullptr) { + return smithy::Error::Validation("RoundTripRpcClient: config needs an endpoint or an http_client"); + } + return RoundTripRpcClient(std::move(config), std::move(transport), std::move(prefix)); +} + +RoundTripRpcClient::RoundTripRpcClient(smithy::ClientConfig config, std::shared_ptr transport, std::string path_prefix) + : config_(std::move(config)), + transport_(std::move(transport)), + path_prefix_(std::move(path_prefix)) {} + +smithy::Outcome RoundTripRpcClient::Send(smithy::http::HttpRequest request) const { + // 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); + if (!request.body.empty()) { + request.headers.Set("content-length", std::to_string(request.body.size())); + } + return transport_->Send(request); +} + +smithy::Outcome RoundTripRpcClient::PutSinkRpc(const PutSinkRpcInput& input) const { + smithy::http::HttpRequest request; + request.method = "POST"; + request.target = path_prefix_ + "/service/RoundTripRpc/operation/PutSinkRpc"; + request.headers.Set("smithy-protocol", "rpc-v2-cbor"); + request.headers.Set("content-type", "application/cbor"); + request.body = smithy::cbor::Encode(SerializePutSinkRpcInput(input)).ToString(); + auto response = Send(std::move(request)); + if (!response) return std::move(response).error(); + if (response->status != 200) return DeserializePutSinkRpcError(*response); + auto body_doc = smithy::cbor::Decode(smithy::Blob::FromString(response->body)); + if (!body_doc) return std::move(body_doc).error(); + return DeserializePutSinkRpcOutput(*body_doc); +} + +} // namespace example::roundtrip::rpc diff --git a/examples/roundtrip/rpc/generated/src/serde.cc b/examples/roundtrip/rpc/generated/src/serde.cc new file mode 100644 index 00000000..8e431729 --- /dev/null +++ b/examples/roundtrip/rpc/generated/src/serde.cc @@ -0,0 +1,614 @@ +// Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +#include +#include + +#include "example/roundtrip/rpc/serde.h" +#include "smithy/core/document_serde.h" + +namespace example::roundtrip::rpc { + +smithy::Document SerializeStringMap(const std::map& value) { + smithy::DocumentMap map; + for (const auto& [key, item] : value) { + map.emplace(key, smithy::Document(item)); + } + return smithy::Document(std::move(map)); +} + +smithy::Outcome> DeserializeStringMap(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("std::map: expected a map on the wire"); + std::map out; + for (const auto& [key, item_doc] : doc.as_map()) { + const smithy::Document* item = &item_doc; + // Tolerant read: null values in dense maps are skipped, not errors. + if (item->is_null()) continue; + std::string parsed_item{}; + if (!item->is_string()) return smithy::Error::Serialization("std::map{}: unexpected type on the wire"); + parsed_item = item->as_string(); + out.emplace(key, std::move(parsed_item)); + } + return out; +} + +smithy::Document SerializeNestedConfig(const NestedConfig& value) { + smithy::DocumentMap map; + map.emplace("label", smithy::Document(value.label)); + if (value.depth.has_value()) { + map.emplace("depth", smithy::Document(static_cast((*value.depth)))); + } + return smithy::Document(std::move(map)); +} + +smithy::Outcome DeserializeNestedConfig(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("NestedConfig: expected a map on the wire"); + NestedConfig out; + { + const smithy::Document* member = doc.Find("label"); + if (member == nullptr || member->is_null()) { + return smithy::Error::Serialization("NestedConfig: missing required member: label"); + } + if (!member->is_string()) return smithy::Error::Serialization("NestedConfig.label: unexpected type on the wire"); + out.label = member->as_string(); + } + { + const smithy::Document* member = doc.Find("depth"); + if (member != nullptr && !member->is_null()) { + std::int32_t parsed_member{}; + if (!member->is_int()) return smithy::Error::Serialization("NestedConfig.depth: unexpected type on the wire"); + if (member->as_int() < -2147483648LL || member->as_int() > 2147483647LL) return smithy::Error::Serialization("NestedConfig.depth: value out of range"); + parsed_member = static_cast(member->as_int()); + out.depth = std::move(parsed_member); + } + } + return out; +} + +smithy::Document SerializeSinkChoice(const SinkChoice& value) { + smithy::DocumentMap map; + if (value.is_text()) { + map.emplace("text", smithy::Document(value.as_text())); + } + if (value.is_count()) { + map.emplace("count", smithy::Document(static_cast(value.as_count()))); + } + if (value.is_nested()) { + map.emplace("nested", SerializeNestedConfig(value.as_nested())); + } + return smithy::Document(std::move(map)); +} + +smithy::Outcome DeserializeSinkChoice(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("SinkChoice: expected a map on the wire"); + if (doc.as_map().size() - (doc.Find("__type") != nullptr ? 1 : 0) != 1) return smithy::Error::Serialization("SinkChoice: expected exactly one union member"); + if (const smithy::Document* member = doc.Find("text"); member != nullptr && !member->is_null()) { + std::string parsed_member{}; + if (!member->is_string()) return smithy::Error::Serialization("SinkChoice.text: unexpected type on the wire"); + parsed_member = member->as_string(); + return SinkChoice::FromText(std::move(parsed_member)); + } + if (const smithy::Document* member = doc.Find("count"); member != nullptr && !member->is_null()) { + std::int32_t parsed_member{}; + if (!member->is_int()) return smithy::Error::Serialization("SinkChoice.count: unexpected type on the wire"); + if (member->as_int() < -2147483648LL || member->as_int() > 2147483647LL) return smithy::Error::Serialization("SinkChoice.count: value out of range"); + parsed_member = static_cast(member->as_int()); + return SinkChoice::FromCount(std::move(parsed_member)); + } + if (const smithy::Document* member = doc.Find("nested"); member != nullptr && !member->is_null()) { + NestedConfig parsed_member{}; + { + auto parsed = DeserializeNestedConfig(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + return SinkChoice::FromNested(std::move(parsed_member)); + } + return smithy::Error::Serialization("SinkChoice: unknown or missing union member"); +} + +smithy::Document SerializeStringList(const std::vector& value) { + smithy::DocumentList list; + list.reserve(value.size()); + for (const auto& item : value) { + list.push_back(smithy::Document(item)); + } + return smithy::Document(std::move(list)); +} + +smithy::Outcome> DeserializeStringList(const smithy::Document& doc) { + if (!doc.is_list()) return smithy::Error::Serialization("std::vector: expected a list on the wire"); + std::vector out; + out.reserve(doc.as_list().size()); + for (const smithy::Document& item_doc : doc.as_list()) { + const smithy::Document* item = &item_doc; + if (item->is_null()) return smithy::Error::Serialization("std::vector: null element in a dense list"); + std::string parsed_item{}; + if (!item->is_string()) return smithy::Error::Serialization("std::vector[]: unexpected type on the wire"); + parsed_item = item->as_string(); + out.push_back(std::move(parsed_item)); + } + return out; +} + +smithy::Document SerializeSparseIntegerList(const std::vector>& value) { + smithy::DocumentList list; + list.reserve(value.size()); + for (const auto& item : value) { + if (!item.has_value()) { + list.emplace_back(nullptr); + continue; + } + list.push_back(smithy::Document(static_cast((*item)))); + } + return smithy::Document(std::move(list)); +} + +smithy::Outcome>> DeserializeSparseIntegerList(const smithy::Document& doc) { + if (!doc.is_list()) return smithy::Error::Serialization("std::vector>: expected a list on the wire"); + std::vector> out; + out.reserve(doc.as_list().size()); + for (const smithy::Document& item_doc : doc.as_list()) { + const smithy::Document* item = &item_doc; + if (item->is_null()) { + out.emplace_back(std::nullopt); + continue; + } + std::int32_t parsed_item{}; + if (!item->is_int()) return smithy::Error::Serialization("std::vector>[]: unexpected type on the wire"); + if (item->as_int() < -2147483648LL || item->as_int() > 2147483647LL) return smithy::Error::Serialization("std::vector>[]: value out of range"); + parsed_item = static_cast(item->as_int()); + out.push_back(std::move(parsed_item)); + } + return out; +} + +smithy::Document SerializeUniqueStringList(const std::vector& value) { + smithy::DocumentList list; + list.reserve(value.size()); + for (const auto& item : value) { + list.push_back(smithy::Document(item)); + } + return smithy::Document(std::move(list)); +} + +smithy::Outcome> DeserializeUniqueStringList(const smithy::Document& doc) { + if (!doc.is_list()) return smithy::Error::Serialization("std::vector: expected a list on the wire"); + std::vector out; + out.reserve(doc.as_list().size()); + for (const smithy::Document& item_doc : doc.as_list()) { + const smithy::Document* item = &item_doc; + if (item->is_null()) return smithy::Error::Serialization("std::vector: null element in a dense list"); + std::string parsed_item{}; + if (!item->is_string()) return smithy::Error::Serialization("std::vector[]: unexpected type on the wire"); + parsed_item = item->as_string(); + out.push_back(std::move(parsed_item)); + } + return out; +} + +smithy::Document SerializeKitchenSink(const KitchenSink& value) { + smithy::DocumentMap map; + map.emplace("name", smithy::Document(value.name)); + if (value.flag.has_value()) { + map.emplace("flag", smithy::Document((*value.flag))); + } + if (value.tiny.has_value()) { + map.emplace("tiny", smithy::Document(static_cast((*value.tiny)))); + } + if (value.small.has_value()) { + map.emplace("small", smithy::Document(static_cast((*value.small)))); + } + if (value.medium.has_value()) { + map.emplace("medium", smithy::Document(static_cast((*value.medium)))); + } + if (value.big.has_value()) { + map.emplace("big", smithy::Document(static_cast((*value.big)))); + } + if (value.ratio.has_value()) { + map.emplace("ratio", smithy::Document(static_cast((*value.ratio)))); + } + if (value.precise.has_value()) { + map.emplace("precise", smithy::Document(static_cast((*value.precise)))); + } + if (value.blob.has_value()) { + map.emplace("blob", smithy::Document((*value.blob))); + } + if (value.priority.has_value()) { + map.emplace("priority", smithy::Document(std::string((*value.priority).ToString()))); + } + if (value.weight.has_value()) { + map.emplace("weight", smithy::Document(static_cast((*value.weight)))); + } + if (value.dateTime.has_value()) { + map.emplace("dateTime", smithy::Document::FromTimestamp((*value.dateTime), smithy::TimestampFormat::kDateTime)); + } + if (value.httpDate.has_value()) { + map.emplace("httpDate", smithy::Document::FromTimestamp((*value.httpDate), smithy::TimestampFormat::kHttpDate)); + } + if (value.epoch.has_value()) { + map.emplace("epoch", smithy::Document::FromTimestamp((*value.epoch), smithy::TimestampFormat::kEpochSeconds)); + } + if (value.names.has_value()) { + map.emplace("names", SerializeStringList((*value.names))); + } + if (value.uniqueNames.has_value()) { + map.emplace("uniqueNames", SerializeUniqueStringList((*value.uniqueNames))); + } + if (value.sparseNumbers.has_value()) { + map.emplace("sparseNumbers", SerializeSparseIntegerList((*value.sparseNumbers))); + } + if (value.attributes.has_value()) { + map.emplace("attributes", SerializeStringMap((*value.attributes))); + } + if (value.nested.has_value()) { + map.emplace("nested", SerializeNestedConfig((*value.nested))); + } + if (value.choice.has_value()) { + map.emplace("choice", SerializeSinkChoice((*value.choice))); + } + return smithy::Document(std::move(map)); +} + +smithy::Outcome DeserializeKitchenSink(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("KitchenSink: expected a map on the wire"); + KitchenSink out; + { + const smithy::Document* member = doc.Find("name"); + if (member == nullptr || member->is_null()) { + return smithy::Error::Serialization("KitchenSink: missing required member: name"); + } + if (!member->is_string()) return smithy::Error::Serialization("KitchenSink.name: unexpected type on the wire"); + out.name = member->as_string(); + } + { + const smithy::Document* member = doc.Find("flag"); + if (member != nullptr && !member->is_null()) { + bool parsed_member{}; + if (!member->is_bool()) return smithy::Error::Serialization("KitchenSink.flag: unexpected type on the wire"); + parsed_member = member->as_bool(); + out.flag = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("tiny"); + if (member != nullptr && !member->is_null()) { + std::int8_t parsed_member{}; + if (!member->is_int()) return smithy::Error::Serialization("KitchenSink.tiny: unexpected type on the wire"); + if (member->as_int() < -128 || member->as_int() > 127) return smithy::Error::Serialization("KitchenSink.tiny: value out of range"); + parsed_member = static_cast(member->as_int()); + out.tiny = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("small"); + if (member != nullptr && !member->is_null()) { + std::int16_t parsed_member{}; + if (!member->is_int()) return smithy::Error::Serialization("KitchenSink.small: unexpected type on the wire"); + if (member->as_int() < -32768 || member->as_int() > 32767) return smithy::Error::Serialization("KitchenSink.small: value out of range"); + parsed_member = static_cast(member->as_int()); + out.small = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("medium"); + if (member != nullptr && !member->is_null()) { + std::int32_t parsed_member{}; + if (!member->is_int()) return smithy::Error::Serialization("KitchenSink.medium: unexpected type on the wire"); + if (member->as_int() < -2147483648LL || member->as_int() > 2147483647LL) return smithy::Error::Serialization("KitchenSink.medium: value out of range"); + parsed_member = static_cast(member->as_int()); + out.medium = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("big"); + if (member != nullptr && !member->is_null()) { + std::int64_t parsed_member{}; + if (!member->is_int()) return smithy::Error::Serialization("KitchenSink.big: unexpected type on the wire"); + parsed_member = static_cast(member->as_int()); + out.big = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("ratio"); + if (member != nullptr && !member->is_null()) { + float parsed_member{}; + { + auto parsed = smithy::DoubleFromDocument(*member); + if (!parsed) return smithy::Error::Serialization("KitchenSink.ratio: expected a number"); + parsed_member = static_cast(*parsed); + } + out.ratio = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("precise"); + if (member != nullptr && !member->is_null()) { + double parsed_member{}; + { + auto parsed = smithy::DoubleFromDocument(*member); + if (!parsed) return smithy::Error::Serialization("KitchenSink.precise: expected a number"); + parsed_member = static_cast(*parsed); + } + out.precise = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("blob"); + if (member != nullptr && !member->is_null()) { + smithy::Blob parsed_member{}; + { + auto parsed = smithy::BlobFromDocument(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.blob = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("priority"); + if (member != nullptr && !member->is_null()) { + Priority parsed_member{}; + if (!member->is_string()) return smithy::Error::Serialization("KitchenSink.priority: unexpected type on the wire"); + parsed_member = Priority::FromString(member->as_string()); + out.priority = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("weight"); + if (member != nullptr && !member->is_null()) { + Weight parsed_member{}; + if (!member->is_int()) return smithy::Error::Serialization("KitchenSink.weight: unexpected type on the wire"); + parsed_member = static_cast(member->as_int()); + out.weight = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("dateTime"); + if (member != nullptr && !member->is_null()) { + smithy::Timestamp parsed_member{}; + { + auto parsed = smithy::TimestampFromDocument(*member, smithy::TimestampFormat::kDateTime); + if (!parsed) return std::move(parsed).error(); + parsed_member = *parsed; + } + out.dateTime = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("httpDate"); + if (member != nullptr && !member->is_null()) { + smithy::Timestamp parsed_member{}; + { + auto parsed = smithy::TimestampFromDocument(*member, smithy::TimestampFormat::kHttpDate); + if (!parsed) return std::move(parsed).error(); + parsed_member = *parsed; + } + out.httpDate = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("epoch"); + if (member != nullptr && !member->is_null()) { + smithy::Timestamp parsed_member{}; + { + auto parsed = smithy::TimestampFromDocument(*member, smithy::TimestampFormat::kEpochSeconds); + if (!parsed) return std::move(parsed).error(); + parsed_member = *parsed; + } + out.epoch = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("names"); + if (member != nullptr && !member->is_null()) { + std::vector parsed_member{}; + { + auto parsed = DeserializeStringList(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.names = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("uniqueNames"); + if (member != nullptr && !member->is_null()) { + std::vector parsed_member{}; + { + auto parsed = DeserializeUniqueStringList(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.uniqueNames = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("sparseNumbers"); + if (member != nullptr && !member->is_null()) { + std::vector> parsed_member{}; + { + auto parsed = DeserializeSparseIntegerList(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.sparseNumbers = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("attributes"); + if (member != nullptr && !member->is_null()) { + std::map parsed_member{}; + { + auto parsed = DeserializeStringMap(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.attributes = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("nested"); + if (member != nullptr && !member->is_null()) { + NestedConfig parsed_member{}; + { + auto parsed = DeserializeNestedConfig(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.nested = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("choice"); + if (member != nullptr && !member->is_null()) { + SinkChoice parsed_member{}; + { + auto parsed = DeserializeSinkChoice(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.choice = std::move(parsed_member); + } + } + return out; +} + +smithy::Document SerializeSinkNotFound(const SinkNotFound& value) { + smithy::DocumentMap map; + map.emplace("message", smithy::Document(value.message)); + if (value.resourceType.has_value()) { + map.emplace("resourceType", smithy::Document((*value.resourceType))); + } + return smithy::Document(std::move(map)); +} + +smithy::Outcome DeserializeSinkNotFound(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("SinkNotFound: expected a map on the wire"); + SinkNotFound out; + { + const smithy::Document* member = doc.Find("message"); + if (member == nullptr || member->is_null()) { + return smithy::Error::Serialization("SinkNotFound: missing required member: message"); + } + if (!member->is_string()) return smithy::Error::Serialization("SinkNotFound.message: unexpected type on the wire"); + out.message = member->as_string(); + } + { + const smithy::Document* member = doc.Find("resourceType"); + if (member != nullptr && !member->is_null()) { + std::string parsed_member{}; + if (!member->is_string()) return smithy::Error::Serialization("SinkNotFound.resourceType: unexpected type on the wire"); + parsed_member = member->as_string(); + out.resourceType = std::move(parsed_member); + } + } + return out; +} + +smithy::Document SerializeSinkQuotaExceeded(const SinkQuotaExceeded& value) { + smithy::DocumentMap map; + if (value.message.has_value()) { + map.emplace("message", smithy::Document((*value.message))); + } + if (value.retryAfterSeconds.has_value()) { + map.emplace("retryAfterSeconds", smithy::Document(static_cast((*value.retryAfterSeconds)))); + } + return smithy::Document(std::move(map)); +} + +smithy::Outcome DeserializeSinkQuotaExceeded(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("SinkQuotaExceeded: expected a map on the wire"); + SinkQuotaExceeded out; + { + const smithy::Document* member = doc.Find("message"); + if (member != nullptr && !member->is_null()) { + std::string parsed_member{}; + if (!member->is_string()) return smithy::Error::Serialization("SinkQuotaExceeded.message: unexpected type on the wire"); + parsed_member = member->as_string(); + out.message = std::move(parsed_member); + } + } + { + const smithy::Document* member = doc.Find("retryAfterSeconds"); + if (member != nullptr && !member->is_null()) { + std::int32_t parsed_member{}; + if (!member->is_int()) return smithy::Error::Serialization("SinkQuotaExceeded.retryAfterSeconds: unexpected type on the wire"); + if (member->as_int() < -2147483648LL || member->as_int() > 2147483647LL) return smithy::Error::Serialization("SinkQuotaExceeded.retryAfterSeconds: value out of range"); + parsed_member = static_cast(member->as_int()); + out.retryAfterSeconds = std::move(parsed_member); + } + } + return out; +} + +smithy::Document SerializePutSinkRpcInput(const PutSinkRpcInput& value) { + smithy::DocumentMap map; + map.emplace("sinkId", smithy::Document(value.sinkId)); + if (value.sink.has_value()) { + map.emplace("sink", SerializeKitchenSink((*value.sink))); + } + return smithy::Document(std::move(map)); +} + +smithy::Outcome DeserializePutSinkRpcInput(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("PutSinkRpcInput: expected a map on the wire"); + PutSinkRpcInput out; + { + const smithy::Document* member = doc.Find("sinkId"); + if (member == nullptr || member->is_null()) { + return smithy::Error::Serialization("PutSinkRpcInput: missing required member: sinkId"); + } + if (!member->is_string()) return smithy::Error::Serialization("PutSinkRpcInput.sinkId: unexpected type on the wire"); + out.sinkId = member->as_string(); + } + { + const smithy::Document* member = doc.Find("sink"); + if (member != nullptr && !member->is_null()) { + KitchenSink parsed_member{}; + { + auto parsed = DeserializeKitchenSink(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.sink = std::move(parsed_member); + } + } + return out; +} + +smithy::Document SerializePutSinkRpcOutput(const PutSinkRpcOutput& value) { + smithy::DocumentMap map; + map.emplace("sinkId", smithy::Document(value.sinkId)); + if (value.sink.has_value()) { + map.emplace("sink", SerializeKitchenSink((*value.sink))); + } + return smithy::Document(std::move(map)); +} + +smithy::Outcome DeserializePutSinkRpcOutput(const smithy::Document& doc) { + if (!doc.is_map()) return smithy::Error::Serialization("PutSinkRpcOutput: expected a map on the wire"); + PutSinkRpcOutput out; + { + const smithy::Document* member = doc.Find("sinkId"); + if (member == nullptr || member->is_null()) { + return smithy::Error::Serialization("PutSinkRpcOutput: missing required member: sinkId"); + } + if (!member->is_string()) return smithy::Error::Serialization("PutSinkRpcOutput.sinkId: unexpected type on the wire"); + out.sinkId = member->as_string(); + } + { + const smithy::Document* member = doc.Find("sink"); + if (member != nullptr && !member->is_null()) { + KitchenSink parsed_member{}; + { + auto parsed = DeserializeKitchenSink(*member); + if (!parsed) return std::move(parsed).error(); + parsed_member = std::move(*parsed); + } + out.sink = std::move(parsed_member); + } + } + return out; +} + +} // namespace example::roundtrip::rpc diff --git a/examples/roundtrip/rpc/generated/src/server.cc b/examples/roundtrip/rpc/generated/src/server.cc new file mode 100644 index 00000000..4a128db4 --- /dev/null +++ b/examples/roundtrip/rpc/generated/src/server.cc @@ -0,0 +1,163 @@ +// Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +#include +#include +#include +#include +#include +#include + +#include "example/roundtrip/rpc/serde.h" +#include "example/roundtrip/rpc/server.h" +#include "smithy/cbor/cbor.h" +#include "smithy/core/blob.h" +#include "smithy/core/document.h" +#include "smithy/server/router.h" + +namespace example::roundtrip::rpc { + +namespace { + +smithy::http::HttpResponse CborError(int status, const std::string& code, const std::string& message, smithy::DocumentMap body) { + if (!code.empty()) body.insert_or_assign("__type", smithy::Document(code)); + if (!message.empty()) body.insert_or_assign("message", smithy::Document(message)); + smithy::http::HttpResponse response; + response.status = status; + response.headers.Set("smithy-protocol", "rpc-v2-cbor"); + response.headers.Set("content-type", "application/cbor"); + response.body = smithy::cbor::Encode(smithy::Document(std::move(body))).ToString(); + return response; +} + +smithy::http::HttpResponse ErrorToResponse(const smithy::Error& error) { + if (error.kind() == smithy::ErrorKind::kModeled) { + if (error.code() == "SinkNotFound") { + smithy::DocumentMap body; + if (const auto* detail = error.detail()) { + body = SerializeSinkNotFound(*detail).as_map(); + } + // The typed detail's own message member wins over the generic one. + const bool has_message = body.count("message") != 0 || body.count("Message") != 0; + if (!has_message && !error.message().empty()) { + body.emplace("message", smithy::Document(error.message())); + } + return CborError(404, "example.roundtrip#SinkNotFound", "", std::move(body)); + } + if (error.code() == "SinkQuotaExceeded") { + smithy::DocumentMap body; + if (const auto* detail = error.detail()) { + body = SerializeSinkQuotaExceeded(*detail).as_map(); + } + // The typed detail's own message member wins over the generic one. + const bool has_message = body.count("message") != 0 || body.count("Message") != 0; + if (!has_message && !error.message().empty()) { + body.emplace("message", smithy::Document(error.message())); + } + return CborError(503, "example.roundtrip#SinkQuotaExceeded", "", std::move(body)); + } + return CborError(400, error.code(), error.message(), {}); + } + if (error.kind() == smithy::ErrorKind::kValidation || error.kind() == smithy::ErrorKind::kSerialization) return CborError(400, "SerializationException", error.message(), {}); + // Never leak internal detail on unexpected failures. + return CborError(500, "InternalFailure", "internal failure", {}); +} + +// Constraint validation (smithy.framework#ValidationException): messages +// and '/member' paths follow the official validation conformance suite. +void AddValidationFailure(std::vector* failures, std::string path, std::string message) { + failures->push_back({std::move(path), std::move(message)}); +} + +void ValidateKitchenSink(const KitchenSink& value, const std::string& path, std::vector* failures) { + if (value.priority.has_value()) { + const std::string member_path = path + "/priority"; + if ((*value.priority).value() == Priority::Value::kUnknown) { + AddValidationFailure(failures, member_path, "Value at '" + member_path + "' failed to satisfy constraint: Member must satisfy enum value set: [low, medium, high]"); + } + } + if (value.uniqueNames.has_value()) { + const std::string member_path = path + "/uniqueNames"; + { + bool unique = true; + for (std::size_t i = 0; i < (*value.uniqueNames).size() && unique; ++i) { + for (std::size_t j = i + 1; j < (*value.uniqueNames).size(); ++j) { + if ((*value.uniqueNames)[i] == (*value.uniqueNames)[j]) { unique = false; break; } + } + } + if (!unique) { + AddValidationFailure(failures, member_path, "Value at '" + member_path + "' failed to satisfy constraint: Member must have unique values"); + } + } + } +} + +void ValidatePutSinkRpcInput(const PutSinkRpcInput& value, const std::string& path, std::vector* failures) { + if (value.sink.has_value()) { + const std::string member_path = path + "/sink"; + ValidateKitchenSink((*value.sink), member_path, failures); + } +} + +smithy::http::HttpResponse ValidationErrorResponse(const std::vector& failures) { + std::string summary = std::to_string(failures.size()) + " validation error" + (failures.size() == 1 ? "" : "s") + " detected. "; + smithy::DocumentList field_list; + for (std::size_t i = 0; i < failures.size(); ++i) { + if (i > 0) summary += "; "; + summary += failures[i].message; + smithy::DocumentMap field; + field.emplace("message", smithy::Document(failures[i].message)); + field.emplace("path", smithy::Document(failures[i].path)); + field_list.push_back(smithy::Document(std::move(field))); + } + smithy::DocumentMap body; + body.emplace("fieldList", smithy::Document(std::move(field_list))); + smithy::http::HttpResponse response = CborError(400, "smithy.framework#ValidationException", summary, std::move(body)); + return response; +} + +} // namespace + +RoundTripRpcServer::RoundTripRpcServer(std::shared_ptr handler) + : router_(std::make_shared()) { + // The route table is derived from the model's @http traits; conflicts are + // a modeling error surfaced by Router::Add (checked at generation time in a + // later phase), so registration results are intentionally discarded. + (void)router_->Add("POST", "/service/RoundTripRpc/operation/PutSinkRpc", [handler](const smithy::http::HttpRequest& request, const smithy::server::RequestContext&) -> smithy::http::HttpResponse { + if (request.headers.Get("smithy-protocol").value_or("") != "rpc-v2-cbor") { + return CborError(400, "SerializationException", "expected smithy-protocol: rpc-v2-cbor", {}); + } + // Content-Type validation per the rpcv2Cbor spec: a present header must + // carry application/cbor (parameters ignored); 415 otherwise. + if (const auto content_type = request.headers.Get("content-type"); content_type.has_value() && smithy::http::MediaTypeOf(*content_type) != "application/cbor") { + return CborError(415, "UnsupportedMediaTypeException", "expected content-type: application/cbor", {}); + } + PutSinkRpcInput input{}; + // An absent body deserializes like an empty CBOR map. + smithy::Document body_doc{smithy::DocumentMap{}}; + if (!request.body.empty()) { + auto decoded = smithy::cbor::Decode(smithy::Blob::FromString(request.body)); + if (!decoded) return CborError(400, "SerializationException", decoded.error().message(), {}); + body_doc = *std::move(decoded); + } + auto parsed = DeserializePutSinkRpcInput(body_doc); + if (!parsed) return CborError(400, "SerializationException", parsed.error().message(), {}); + input = *std::move(parsed); + std::vector validation_failures; + ValidatePutSinkRpcInput(input, "", &validation_failures); + if (!validation_failures.empty()) return ValidationErrorResponse(validation_failures); + auto outcome = handler->PutSinkRpc(input); + if (!outcome) return ErrorToResponse(outcome.error()); + smithy::http::HttpResponse response; + response.headers.Set("smithy-protocol", "rpc-v2-cbor"); + response.headers.Set("content-type", "application/cbor"); + response.body = smithy::cbor::Encode(SerializePutSinkRpcOutput(*outcome)).ToString(); + return response; + }); +} + +smithy::http::RequestHandler RoundTripRpcServer::Handler() const { + auto router = router_; + return [router](const smithy::http::HttpRequest& request) { return router->Route(request); }; +} + +} // namespace example::roundtrip::rpc diff --git a/examples/roundtrip/rpc/generated/tests/BUILD.bazel b/examples/roundtrip/rpc/generated/tests/BUILD.bazel new file mode 100644 index 00000000..2ada5b86 --- /dev/null +++ b/examples/roundtrip/rpc/generated/tests/BUILD.bazel @@ -0,0 +1,29 @@ +# Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +load("@rules_cc//cc:defs.bzl", "cc_test") + +cc_test( + name = "smoke_test", + size = "small", + srcs = ["smoke_test.cc"], + deps = [ + "//examples/roundtrip/rpc/generated:client", + "//examples/roundtrip/rpc/generated:server", + "//runtime:client", + "//runtime:http", + "@googletest//:gtest_main", + ], +) + +cc_test( + name = "integration_test", + size = "small", + srcs = ["integration_test.cc"], + deps = [ + "//examples/roundtrip/rpc/generated:client", + "//examples/roundtrip/rpc/generated:server", + "//runtime:http", + "//runtime:protocol_test_support", + "@googletest//:gtest_main", + ], +) diff --git a/examples/roundtrip/rpc/generated/tests/integration_test.cc b/examples/roundtrip/rpc/generated/tests/integration_test.cc new file mode 100644 index 00000000..c61f382c --- /dev/null +++ b/examples/roundtrip/rpc/generated/tests/integration_test.cc @@ -0,0 +1,301 @@ +// Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "example/roundtrip/rpc/client.h" +#include "example/roundtrip/rpc/server.h" +#include "smithy/cbor/cbor.h" +#include "smithy/client/config.h" +#include "smithy/core/blob.h" +#include "smithy/http/loopback.h" +#include "smithy/http/socket_transport.h" +#include "smithy/testing/protocol_test.h" + +namespace example::roundtrip::rpc { + +// Integration tests for the generated RoundTripRpc service (PLAN Phase 5): the +// generated client drives the generated server over the loopback transport +// AND a real socket on an ephemeral port. Random inputs are seeded and +// constraint-valid; a failure reproduces deterministically. + +namespace { + +// Deterministic pseudo-random source for round-trip inputs; fill_all forces +// every optional member on and maximum container sizes (the "maximal" rows). +struct Rng { + std::mt19937 engine; + bool fill_all = false; + + bool Coin() { return fill_all || (engine() & 1U) != 0; } + + std::size_t Size(std::size_t min_size, std::size_t max_size) { + if (fill_all || max_size <= min_size) return max_size; + return min_size + engine() % (max_size - min_size + 1); + } + + // Inclusive and boundary-biased: min/max come up 10% of the time each. + std::int64_t Int(std::int64_t min_value, std::int64_t max_value) { + const auto pick = engine() % 10; + if (pick == 0 || min_value >= max_value) return min_value; + if (pick == 1) return max_value; + // The span is computed unsigned: wide ranges overflow int64 arithmetic. + const std::uint64_t span = static_cast(max_value) - static_cast(min_value); + return min_value + static_cast(engine() % (span + 1)); + } + + std::string Text(std::size_t min_len, std::size_t max_len) { + static const char kAlphabet[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + const std::size_t n = Size(min_len, max_len); + std::string out; + out.reserve(n); + for (std::size_t i = 0; i < n; ++i) out.push_back(kAlphabet[engine() % 62]); + return out; + } +}; + +std::map RandomStringMap(Rng& rng) { + std::map v{}; + const std::size_t n = rng.Size(1, 3); + for (std::size_t i = 0; i < n; ++i) { + v.insert_or_assign(rng.Text(1, 6) + std::to_string(i), rng.Text(1, 9)); + } + return v; +} + +NestedConfig RandomNestedConfig(Rng& rng) { + NestedConfig v{}; + v.label = rng.Text(1, 9); + if (rng.Coin()) v.depth = static_cast(rng.Int(-2147483648LL, 2147483647LL)); + return v; +} + +SinkChoice RandomSinkChoice(Rng& rng) { + switch (rng.engine() % 3) { + case 0: + return SinkChoice::FromText(rng.Text(1, 9)); + case 1: + return SinkChoice::FromCount(static_cast(rng.Int(-2147483648LL, 2147483647LL))); + default: + return SinkChoice::FromNested(RandomNestedConfig(rng)); + } +} + +std::vector RandomStringList(Rng& rng) { + std::vector v{}; + const std::size_t n = rng.Size(1, 3); + for (std::size_t i = 0; i < n; ++i) { + v.push_back(rng.Text(1, 9)); + } + return v; +} + +std::vector> RandomSparseIntegerList(Rng& rng) { + std::vector> v{}; + const std::size_t n = rng.Size(1, 3); + for (std::size_t i = 0; i < n; ++i) { + if (!rng.fill_all && rng.engine() % 4 == 0) { + v.push_back(std::nullopt); + continue; + } + v.push_back(static_cast(rng.Int(-2147483648LL, 2147483647LL))); + } + return v; +} + +std::vector RandomUniqueStringList(Rng& rng) { + std::vector v{}; + const std::size_t n = rng.Size(1, 2); + for (std::size_t i = 0; i < n; ++i) { + v.push_back(rng.Text(1, 9) + std::to_string(i)); + } + return v; +} + +KitchenSink RandomKitchenSink(Rng& rng) { + KitchenSink v{}; + v.name = rng.Text(1, 9); + if (rng.Coin()) v.flag = (rng.engine() & 1U) != 0; + if (rng.Coin()) v.tiny = static_cast(rng.Int(-128LL, 127LL)); + if (rng.Coin()) v.small = static_cast(rng.Int(-32768LL, 32767LL)); + if (rng.Coin()) v.medium = static_cast(rng.Int(-2147483648LL, 2147483647LL)); + if (rng.Coin()) v.big = static_cast(rng.Int(-4611686018427387904LL, 4611686018427387903LL)); + if (rng.Coin()) v.ratio = static_cast(rng.Int(-8000000LL, 8000000LL)) / static_cast(8); + if (rng.Coin()) v.precise = static_cast(rng.Int(-8000000LL, 8000000LL)) / static_cast(8); + if (rng.Coin()) v.blob = smithy::Blob::FromString(rng.Text(1, 9)); + if (rng.Coin()) v.priority = Priority::FromString(std::array{"low", "medium", "high"}[rng.engine() % 3]); + if (rng.Coin()) v.weight = static_cast(std::array{1, 2}[rng.engine() % 2]); + if (rng.Coin()) v.dateTime = smithy::Timestamp::FromEpochMilliseconds(rng.Int(0, 4102444799LL) * 1000); + if (rng.Coin()) v.httpDate = smithy::Timestamp::FromEpochMilliseconds(rng.Int(0, 4102444799LL) * 1000); + if (rng.Coin()) v.epoch = smithy::Timestamp::FromEpochMilliseconds(rng.Int(0, 4102444799LL) * 1000); + if (rng.Coin()) v.names = RandomStringList(rng); + if (rng.Coin()) v.uniqueNames = RandomUniqueStringList(rng); + if (rng.Coin()) v.sparseNumbers = RandomSparseIntegerList(rng); + if (rng.Coin()) v.attributes = RandomStringMap(rng); + if (rng.Coin()) v.nested = RandomNestedConfig(rng); + if (rng.Coin()) v.choice = RandomSinkChoice(rng); + return v; +} + +SinkNotFound RandomSinkNotFound(Rng& rng) { + SinkNotFound v{}; + v.message = rng.Text(1, 9); + if (rng.Coin()) v.resourceType = rng.Text(1, 9); + return v; +} + +SinkQuotaExceeded RandomSinkQuotaExceeded(Rng& rng) { + SinkQuotaExceeded v{}; + if (rng.Coin()) v.message = rng.Text(1, 9); + if (rng.Coin()) v.retryAfterSeconds = static_cast(rng.Int(-2147483648LL, 2147483647LL)); + return v; +} + +PutSinkRpcInput RandomPutSinkRpcInput(Rng& rng) { + PutSinkRpcInput v{}; + v.sinkId = rng.Text(1, 9); + if (rng.Coin()) v.sink = RandomKitchenSink(rng); + return v; +} + +PutSinkRpcOutput RandomPutSinkRpcOutput(Rng& rng) { + PutSinkRpcOutput v{}; + v.sinkId = rng.Text(1, 9); + if (rng.Coin()) v.sink = RandomKitchenSink(rng); + return v; +} + +class ScriptedHandler final : public RoundTripRpcHandler { + public: + smithy::Outcome PutSinkRpc(const PutSinkRpcInput& input) override { + lastPutSinkRpc = input; + if (nextPutSinkRpcError.has_value()) return *nextPutSinkRpcError; + return nextPutSinkRpcOutput; + } + std::optional lastPutSinkRpc; + PutSinkRpcOutput nextPutSinkRpcOutput{}; + std::optional nextPutSinkRpcError; +}; + +enum class TransportKind { kLoopback, kSocket }; + +class RoundTripRpcIntegrationTest : public ::testing::TestWithParam { + protected: + void SetUp() override { + handler_ = std::make_shared(); + server_ = std::make_unique(handler_); + smithy::ClientConfig config; + if (GetParam() == TransportKind::kLoopback) { + auto loopback = std::make_shared(); + ASSERT_TRUE(loopback->Start(server_->Handler()).ok()); + config.http_client = loopback; + } else { + socket_server_ = std::make_unique(); + ASSERT_TRUE(socket_server_->Start(server_->Handler()).ok()); + config.endpoint = "http://127.0.0.1:" + std::to_string(socket_server_->port()); + } + auto client = RoundTripRpcClient::Create(std::move(config)); + ASSERT_TRUE(client.ok()) << client.error().message(); + client_ = std::make_unique(std::move(*client)); + } + + void TearDown() override { if (socket_server_ != nullptr) socket_server_->Stop(); } + + std::shared_ptr handler_; + std::unique_ptr server_; + std::unique_ptr socket_server_; + std::unique_ptr client_; +}; + +TEST_P(RoundTripRpcIntegrationTest, PutSinkRpcRandomRoundTrips) { + Rng rng{std::mt19937{20260707U}, /*fill_all=*/false}; + for (int iteration = 0; iteration < 8; ++iteration) { + const PutSinkRpcInput input = RandomPutSinkRpcInput(rng); + PutSinkRpcOutput output = RandomPutSinkRpcOutput(rng); + handler_->nextPutSinkRpcOutput = output; + const auto outcome = client_->PutSinkRpc(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastPutSinkRpc.has_value()); + EXPECT_EQ(*handler_->lastPutSinkRpc, input); + EXPECT_EQ(*outcome, output); + } +} + +TEST_P(RoundTripRpcIntegrationTest, PutSinkRpcMaximalRoundTrips) { + Rng rng{std::mt19937{7U}, /*fill_all=*/true}; + const PutSinkRpcInput input = RandomPutSinkRpcInput(rng); + PutSinkRpcOutput output = RandomPutSinkRpcOutput(rng); + handler_->nextPutSinkRpcOutput = output; + const auto outcome = client_->PutSinkRpc(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastPutSinkRpc.has_value()); + EXPECT_EQ(*handler_->lastPutSinkRpc, input); + EXPECT_EQ(*outcome, output); +} + +TEST_P(RoundTripRpcIntegrationTest, PutSinkRpcSinkNotFoundMapsAcrossTheWire) { + Rng rng{std::mt19937{42U}, /*fill_all=*/true}; + const SinkNotFound detail = RandomSinkNotFound(rng); + smithy::Error error = smithy::Error::Modeled("SinkNotFound", "integration"); + error.set_detail(detail); + handler_->nextPutSinkRpcError = error; + const PutSinkRpcInput input = RandomPutSinkRpcInput(rng); + const auto outcome = client_->PutSinkRpc(input); + ASSERT_FALSE(outcome.ok()); + EXPECT_EQ(outcome.error().kind(), smithy::ErrorKind::kModeled); + EXPECT_EQ(outcome.error().code(), "SinkNotFound"); + ASSERT_NE(outcome.error().detail(), nullptr); + EXPECT_EQ(*outcome.error().detail(), detail); +} + +TEST_P(RoundTripRpcIntegrationTest, PutSinkRpcSinkQuotaExceededMapsAcrossTheWire) { + Rng rng{std::mt19937{42U}, /*fill_all=*/true}; + const SinkQuotaExceeded detail = RandomSinkQuotaExceeded(rng); + smithy::Error error = smithy::Error::Modeled("SinkQuotaExceeded", "integration"); + error.set_detail(detail); + handler_->nextPutSinkRpcError = error; + const PutSinkRpcInput input = RandomPutSinkRpcInput(rng); + const auto outcome = client_->PutSinkRpc(input); + ASSERT_FALSE(outcome.ok()); + EXPECT_EQ(outcome.error().kind(), smithy::ErrorKind::kModeled); + EXPECT_EQ(outcome.error().code(), "SinkQuotaExceeded"); + ASSERT_NE(outcome.error().detail(), nullptr); + EXPECT_EQ(*outcome.error().detail(), detail); +} + +TEST(RoundTripRpcIntegrationUnknownMembers, PutSinkRpcToleratesUnknownResponseMembers) { + auto handler = std::make_shared(); + RoundTripRpcServer server(handler); + auto loopback = std::make_shared(); + ASSERT_TRUE(loopback->Start(server.Handler()).ok()); + auto inject = [](smithy::http::HttpResponse& response) { + auto doc = smithy::cbor::Decode(smithy::Blob::FromString(response.body)); + if (!doc.ok() || !doc->is_map()) return; + auto map = doc->as_map(); + map.insert_or_assign("smithy_cpp_unknown_member", smithy::Document(42)); + response.body = smithy::cbor::Encode(smithy::Document(std::move(map))).ToString(); + }; + auto transport = std::make_shared(loopback, inject); + smithy::ClientConfig config; + config.http_client = transport; + auto client = *RoundTripRpcClient::Create(std::move(config)); + Rng rng{std::mt19937{99U}, /*fill_all=*/true}; + const PutSinkRpcInput input = RandomPutSinkRpcInput(rng); + PutSinkRpcOutput output = RandomPutSinkRpcOutput(rng); + handler->nextPutSinkRpcOutput = output; + const auto outcome = client.PutSinkRpc(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + EXPECT_EQ(*outcome, output); +} + +INSTANTIATE_TEST_SUITE_P(Transports, RoundTripRpcIntegrationTest, ::testing::Values(TransportKind::kLoopback, TransportKind::kSocket), [](const auto& info) { return info.param == TransportKind::kLoopback ? "Loopback" : "Socket"; }); + +} // namespace +} // namespace example::roundtrip::rpc diff --git a/examples/roundtrip/rpc/generated/tests/smoke_test.cc b/examples/roundtrip/rpc/generated/tests/smoke_test.cc new file mode 100644 index 00000000..f7225db3 --- /dev/null +++ b/examples/roundtrip/rpc/generated/tests/smoke_test.cc @@ -0,0 +1,88 @@ +// Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +#include +#include +#include + +#include "example/roundtrip/rpc/client.h" +#include "example/roundtrip/rpc/server.h" +#include "smithy/client/config.h" +#include "smithy/http/loopback.h" + +namespace example::roundtrip::rpc { + +// Smoke tests for the generated RoundTripRpc service: the generated client calls the +// generated server over the in-memory loopback transport. A passing suite +// pins routing, serde symmetry, required members, and error mapping; swap the +// stub handler for a real one to grow it into your service's test suite. + +namespace { + +PutSinkRpcOutput MinimalPutSinkRpcOutput() { + return [] { + PutSinkRpcOutput v{}; + return v; + }(); +} + +class SmokeHandler : public RoundTripRpcHandler { + public: + smithy::Outcome PutSinkRpc(const PutSinkRpcInput& input) override { + (void)input; + return MinimalPutSinkRpcOutput(); + } +}; + +RoundTripRpcClient MakeClient(std::shared_ptr handler) { + RoundTripRpcServer server(std::move(handler)); + auto loopback = std::make_shared(); + (void)loopback->Start(server.Handler()); + smithy::ClientConfig config; + config.http_client = loopback; + // Create cannot fail when a transport is injected. + return *RoundTripRpcClient::Create(std::move(config)); +} + +} // namespace + +TEST(RoundTripRpcSmokeTest, PutSinkRpcRoundTrips) { + RoundTripRpcClient client = MakeClient(std::make_shared()); + const PutSinkRpcInput input = [] { + PutSinkRpcInput v{}; + return v; + }(); + const auto outcome = client.PutSinkRpc(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + EXPECT_EQ(*outcome, MinimalPutSinkRpcOutput()); +} + +TEST(RoundTripRpcSmokeTest, ModeledErrorsMapAcrossTheWire) { + class FailingHandler final : public SmokeHandler { + public: + smithy::Outcome PutSinkRpc(const PutSinkRpcInput& input) override { + (void)input; + smithy::Error error = smithy::Error::Modeled("SinkNotFound", "smoke"); + auto detail = [] { + SinkNotFound v{}; + return v; + }(); + detail.message = "smoke"; + error.set_detail(std::move(detail)); + return error; + } + }; + + RoundTripRpcClient client = MakeClient(std::make_shared()); + const PutSinkRpcInput input = [] { + PutSinkRpcInput v{}; + return v; + }(); + const auto outcome = client.PutSinkRpc(input); + ASSERT_FALSE(outcome.ok()); + EXPECT_EQ(outcome.error().kind(), smithy::ErrorKind::kModeled); + EXPECT_EQ(outcome.error().code(), "SinkNotFound"); + EXPECT_EQ(outcome.error().message(), "smoke"); + EXPECT_NE(outcome.error().detail(), nullptr); +} + +} // namespace example::roundtrip::rpc diff --git a/examples/weather/generated/tests/BUILD.bazel b/examples/weather/generated/tests/BUILD.bazel index 6f800831..dce7ae3f 100644 --- a/examples/weather/generated/tests/BUILD.bazel +++ b/examples/weather/generated/tests/BUILD.bazel @@ -14,3 +14,16 @@ cc_test( "@googletest//:gtest_main", ], ) + +cc_test( + name = "integration_test", + size = "small", + srcs = ["integration_test.cc"], + deps = [ + "//examples/weather/generated:client", + "//examples/weather/generated:server", + "//runtime:http", + "//runtime:protocol_test_support", + "@googletest//:gtest_main", + ], +) diff --git a/examples/weather/generated/tests/integration_test.cc b/examples/weather/generated/tests/integration_test.cc new file mode 100644 index 00000000..cf28c11c --- /dev/null +++ b/examples/weather/generated/tests/integration_test.cc @@ -0,0 +1,505 @@ +// Code generated by smithy-cpp (cpp-codegen). DO NOT EDIT. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "example/weather/client.h" +#include "example/weather/server.h" +#include "smithy/client/config.h" +#include "smithy/http/loopback.h" +#include "smithy/http/socket_transport.h" +#include "smithy/json/json.h" +#include "smithy/testing/protocol_test.h" + +namespace example::weather { + +// Integration tests for the generated Weather service (PLAN Phase 5): the +// generated client drives the generated server over the loopback transport +// AND a real socket on an ephemeral port. Random inputs are seeded and +// constraint-valid; a failure reproduces deterministically. + +namespace { + +// Deterministic pseudo-random source for round-trip inputs; fill_all forces +// every optional member on and maximum container sizes (the "maximal" rows). +struct Rng { + std::mt19937 engine; + bool fill_all = false; + + bool Coin() { return fill_all || (engine() & 1U) != 0; } + + std::size_t Size(std::size_t min_size, std::size_t max_size) { + if (fill_all || max_size <= min_size) return max_size; + return min_size + engine() % (max_size - min_size + 1); + } + + // Inclusive and boundary-biased: min/max come up 10% of the time each. + std::int64_t Int(std::int64_t min_value, std::int64_t max_value) { + const auto pick = engine() % 10; + if (pick == 0 || min_value >= max_value) return min_value; + if (pick == 1) return max_value; + // The span is computed unsigned: wide ranges overflow int64 arithmetic. + const std::uint64_t span = static_cast(max_value) - static_cast(min_value); + return min_value + static_cast(engine() % (span + 1)); + } + + std::string Text(std::size_t min_len, std::size_t max_len) { + static const char kAlphabet[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + const std::size_t n = Size(min_len, max_len); + std::string out; + out.reserve(n); + for (std::size_t i = 0; i < n; ++i) out.push_back(kAlphabet[engine() % 62]); + return out; + } +}; + +DeleteCityInput RandomDeleteCityInput(Rng& rng) { + DeleteCityInput v{}; + v.cityId = std::string("0"); + return v; +} + +DeleteCityOutput RandomDeleteCityOutput(Rng& rng) { + DeleteCityOutput v{}; + return v; +} + +NoSuchResource RandomNoSuchResource(Rng& rng) { + NoSuchResource v{}; + v.resourceType = rng.Text(1, 9); + return v; +} + +GetForecastInput RandomGetForecastInput(Rng& rng) { + GetForecastInput v{}; + v.cityId = std::string("0"); + return v; +} + +GetForecastOutput RandomGetForecastOutput(Rng& rng) { + GetForecastOutput v{}; + if (rng.Coin()) v.chanceOfRain = static_cast(rng.Int(-8000000LL, 8000000LL)) / static_cast(8); + return v; +} + +GetCityInput RandomGetCityInput(Rng& rng) { + GetCityInput v{}; + v.cityId = std::string("0"); + return v; +} + +CityCoordinates RandomCityCoordinates(Rng& rng) { + CityCoordinates v{}; + v.latitude = static_cast(rng.Int(-8000000LL, 8000000LL)) / static_cast(8); + v.longitude = static_cast(rng.Int(-8000000LL, 8000000LL)) / static_cast(8); + return v; +} + +GetCityOutput RandomGetCityOutput(Rng& rng) { + GetCityOutput v{}; + v.name = rng.Text(1, 9); + v.coordinates = RandomCityCoordinates(rng); + return v; +} + +ListCitiesInput RandomListCitiesInput(Rng& rng) { + ListCitiesInput v{}; + if (rng.Coin()) v.nextToken = rng.Text(1, 9); + if (rng.Coin()) v.pageSize = static_cast(rng.Int(-2147483648LL, 2147483647LL)); + return v; +} + +CitySummary RandomCitySummary(Rng& rng) { + CitySummary v{}; + v.cityId = std::string("0"); + v.name = rng.Text(1, 9); + return v; +} + +std::vector RandomCitySummaries(Rng& rng) { + std::vector v{}; + const std::size_t n = rng.Size(1, 3); + for (std::size_t i = 0; i < n; ++i) { + v.push_back(RandomCitySummary(rng)); + } + return v; +} + +ListCitiesOutput RandomListCitiesOutput(Rng& rng) { + ListCitiesOutput v{}; + if (rng.Coin()) v.nextToken = rng.Text(1, 9); + v.items = RandomCitySummaries(rng); + return v; +} + +GetCurrentTimeInput RandomGetCurrentTimeInput(Rng& rng) { + GetCurrentTimeInput v{}; + return v; +} + +GetCurrentTimeOutput RandomGetCurrentTimeOutput(Rng& rng) { + GetCurrentTimeOutput v{}; + v.time = smithy::Timestamp::FromEpochMilliseconds(rng.Int(0, 4102444799LL) * 1000); + return v; +} + +class ScriptedHandler final : public WeatherHandler { + public: + smithy::Outcome DeleteCity(const DeleteCityInput& input) override { + lastDeleteCity = input; + if (nextDeleteCityError.has_value()) return *nextDeleteCityError; + return nextDeleteCityOutput; + } + std::optional lastDeleteCity; + DeleteCityOutput nextDeleteCityOutput{}; + std::optional nextDeleteCityError; + smithy::Outcome GetCity(const GetCityInput& input) override { + lastGetCity = input; + if (nextGetCityError.has_value()) return *nextGetCityError; + return nextGetCityOutput; + } + std::optional lastGetCity; + GetCityOutput nextGetCityOutput{}; + std::optional nextGetCityError; + smithy::Outcome GetCurrentTime(const GetCurrentTimeInput& input) override { + lastGetCurrentTime = input; + if (nextGetCurrentTimeError.has_value()) return *nextGetCurrentTimeError; + return nextGetCurrentTimeOutput; + } + std::optional lastGetCurrentTime; + GetCurrentTimeOutput nextGetCurrentTimeOutput{}; + std::optional nextGetCurrentTimeError; + smithy::Outcome GetForecast(const GetForecastInput& input) override { + lastGetForecast = input; + if (nextGetForecastError.has_value()) return *nextGetForecastError; + return nextGetForecastOutput; + } + std::optional lastGetForecast; + GetForecastOutput nextGetForecastOutput{}; + std::optional nextGetForecastError; + smithy::Outcome ListCities(const ListCitiesInput& input) override { + lastListCities = input; + if (nextListCitiesError.has_value()) return *nextListCitiesError; + return nextListCitiesOutput; + } + std::optional lastListCities; + ListCitiesOutput nextListCitiesOutput{}; + std::optional nextListCitiesError; +}; + +enum class TransportKind { kLoopback, kSocket }; + +class WeatherIntegrationTest : public ::testing::TestWithParam { + protected: + void SetUp() override { + handler_ = std::make_shared(); + server_ = std::make_unique(handler_); + smithy::ClientConfig config; + if (GetParam() == TransportKind::kLoopback) { + auto loopback = std::make_shared(); + ASSERT_TRUE(loopback->Start(server_->Handler()).ok()); + config.http_client = loopback; + } else { + socket_server_ = std::make_unique(); + ASSERT_TRUE(socket_server_->Start(server_->Handler()).ok()); + config.endpoint = "http://127.0.0.1:" + std::to_string(socket_server_->port()); + } + auto client = WeatherClient::Create(std::move(config)); + ASSERT_TRUE(client.ok()) << client.error().message(); + client_ = std::make_unique(std::move(*client)); + } + + void TearDown() override { if (socket_server_ != nullptr) socket_server_->Stop(); } + + std::shared_ptr handler_; + std::unique_ptr server_; + std::unique_ptr socket_server_; + std::unique_ptr client_; +}; + +TEST_P(WeatherIntegrationTest, DeleteCityRandomRoundTrips) { + Rng rng{std::mt19937{20260707U}, /*fill_all=*/false}; + for (int iteration = 0; iteration < 8; ++iteration) { + const DeleteCityInput input = RandomDeleteCityInput(rng); + DeleteCityOutput output = RandomDeleteCityOutput(rng); + handler_->nextDeleteCityOutput = output; + const auto outcome = client_->DeleteCity(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastDeleteCity.has_value()); + EXPECT_EQ(*handler_->lastDeleteCity, input); + EXPECT_EQ(*outcome, output); + } +} + +TEST_P(WeatherIntegrationTest, DeleteCityMaximalRoundTrips) { + Rng rng{std::mt19937{7U}, /*fill_all=*/true}; + const DeleteCityInput input = RandomDeleteCityInput(rng); + DeleteCityOutput output = RandomDeleteCityOutput(rng); + handler_->nextDeleteCityOutput = output; + const auto outcome = client_->DeleteCity(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastDeleteCity.has_value()); + EXPECT_EQ(*handler_->lastDeleteCity, input); + EXPECT_EQ(*outcome, output); +} + +TEST_P(WeatherIntegrationTest, GetCityRandomRoundTrips) { + Rng rng{std::mt19937{20260707U}, /*fill_all=*/false}; + for (int iteration = 0; iteration < 8; ++iteration) { + const GetCityInput input = RandomGetCityInput(rng); + GetCityOutput output = RandomGetCityOutput(rng); + handler_->nextGetCityOutput = output; + const auto outcome = client_->GetCity(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastGetCity.has_value()); + EXPECT_EQ(*handler_->lastGetCity, input); + EXPECT_EQ(*outcome, output); + } +} + +TEST_P(WeatherIntegrationTest, GetCityMaximalRoundTrips) { + Rng rng{std::mt19937{7U}, /*fill_all=*/true}; + const GetCityInput input = RandomGetCityInput(rng); + GetCityOutput output = RandomGetCityOutput(rng); + handler_->nextGetCityOutput = output; + const auto outcome = client_->GetCity(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastGetCity.has_value()); + EXPECT_EQ(*handler_->lastGetCity, input); + EXPECT_EQ(*outcome, output); +} + +TEST_P(WeatherIntegrationTest, GetCurrentTimeRandomRoundTrips) { + Rng rng{std::mt19937{20260707U}, /*fill_all=*/false}; + for (int iteration = 0; iteration < 8; ++iteration) { + const GetCurrentTimeInput input = RandomGetCurrentTimeInput(rng); + GetCurrentTimeOutput output = RandomGetCurrentTimeOutput(rng); + handler_->nextGetCurrentTimeOutput = output; + const auto outcome = client_->GetCurrentTime(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastGetCurrentTime.has_value()); + EXPECT_EQ(*handler_->lastGetCurrentTime, input); + EXPECT_EQ(*outcome, output); + } +} + +TEST_P(WeatherIntegrationTest, GetCurrentTimeMaximalRoundTrips) { + Rng rng{std::mt19937{7U}, /*fill_all=*/true}; + const GetCurrentTimeInput input = RandomGetCurrentTimeInput(rng); + GetCurrentTimeOutput output = RandomGetCurrentTimeOutput(rng); + handler_->nextGetCurrentTimeOutput = output; + const auto outcome = client_->GetCurrentTime(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastGetCurrentTime.has_value()); + EXPECT_EQ(*handler_->lastGetCurrentTime, input); + EXPECT_EQ(*outcome, output); +} + +TEST_P(WeatherIntegrationTest, GetForecastRandomRoundTrips) { + Rng rng{std::mt19937{20260707U}, /*fill_all=*/false}; + for (int iteration = 0; iteration < 8; ++iteration) { + const GetForecastInput input = RandomGetForecastInput(rng); + GetForecastOutput output = RandomGetForecastOutput(rng); + handler_->nextGetForecastOutput = output; + const auto outcome = client_->GetForecast(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastGetForecast.has_value()); + EXPECT_EQ(*handler_->lastGetForecast, input); + EXPECT_EQ(*outcome, output); + } +} + +TEST_P(WeatherIntegrationTest, GetForecastMaximalRoundTrips) { + Rng rng{std::mt19937{7U}, /*fill_all=*/true}; + const GetForecastInput input = RandomGetForecastInput(rng); + GetForecastOutput output = RandomGetForecastOutput(rng); + handler_->nextGetForecastOutput = output; + const auto outcome = client_->GetForecast(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastGetForecast.has_value()); + EXPECT_EQ(*handler_->lastGetForecast, input); + EXPECT_EQ(*outcome, output); +} + +TEST_P(WeatherIntegrationTest, ListCitiesRandomRoundTrips) { + Rng rng{std::mt19937{20260707U}, /*fill_all=*/false}; + for (int iteration = 0; iteration < 8; ++iteration) { + const ListCitiesInput input = RandomListCitiesInput(rng); + ListCitiesOutput output = RandomListCitiesOutput(rng); + handler_->nextListCitiesOutput = output; + const auto outcome = client_->ListCities(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastListCities.has_value()); + EXPECT_EQ(*handler_->lastListCities, input); + EXPECT_EQ(*outcome, output); + } +} + +TEST_P(WeatherIntegrationTest, ListCitiesMaximalRoundTrips) { + Rng rng{std::mt19937{7U}, /*fill_all=*/true}; + const ListCitiesInput input = RandomListCitiesInput(rng); + ListCitiesOutput output = RandomListCitiesOutput(rng); + handler_->nextListCitiesOutput = output; + const auto outcome = client_->ListCities(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(handler_->lastListCities.has_value()); + EXPECT_EQ(*handler_->lastListCities, input); + EXPECT_EQ(*outcome, output); +} + +TEST_P(WeatherIntegrationTest, DeleteCityNoSuchResourceMapsAcrossTheWire) { + Rng rng{std::mt19937{42U}, /*fill_all=*/true}; + const NoSuchResource detail = RandomNoSuchResource(rng); + smithy::Error error = smithy::Error::Modeled("NoSuchResource", "integration"); + error.set_detail(detail); + handler_->nextDeleteCityError = error; + const DeleteCityInput input = RandomDeleteCityInput(rng); + const auto outcome = client_->DeleteCity(input); + ASSERT_FALSE(outcome.ok()); + EXPECT_EQ(outcome.error().kind(), smithy::ErrorKind::kModeled); + EXPECT_EQ(outcome.error().code(), "NoSuchResource"); + ASSERT_NE(outcome.error().detail(), nullptr); + EXPECT_EQ(*outcome.error().detail(), detail); +} + +TEST_P(WeatherIntegrationTest, GetCityNoSuchResourceMapsAcrossTheWire) { + Rng rng{std::mt19937{42U}, /*fill_all=*/true}; + const NoSuchResource detail = RandomNoSuchResource(rng); + smithy::Error error = smithy::Error::Modeled("NoSuchResource", "integration"); + error.set_detail(detail); + handler_->nextGetCityError = error; + const GetCityInput input = RandomGetCityInput(rng); + const auto outcome = client_->GetCity(input); + ASSERT_FALSE(outcome.ok()); + EXPECT_EQ(outcome.error().kind(), smithy::ErrorKind::kModeled); + EXPECT_EQ(outcome.error().code(), "NoSuchResource"); + ASSERT_NE(outcome.error().detail(), nullptr); + EXPECT_EQ(*outcome.error().detail(), detail); +} + +TEST_P(WeatherIntegrationTest, GetForecastNoSuchResourceMapsAcrossTheWire) { + Rng rng{std::mt19937{42U}, /*fill_all=*/true}; + const NoSuchResource detail = RandomNoSuchResource(rng); + smithy::Error error = smithy::Error::Modeled("NoSuchResource", "integration"); + error.set_detail(detail); + handler_->nextGetForecastError = error; + const GetForecastInput input = RandomGetForecastInput(rng); + const auto outcome = client_->GetForecast(input); + ASSERT_FALSE(outcome.ok()); + EXPECT_EQ(outcome.error().kind(), smithy::ErrorKind::kModeled); + EXPECT_EQ(outcome.error().code(), "NoSuchResource"); + ASSERT_NE(outcome.error().detail(), nullptr); + EXPECT_EQ(*outcome.error().detail(), detail); +} + +TEST(WeatherIntegrationUnknownMembers, GetCityToleratesUnknownResponseMembers) { + auto handler = std::make_shared(); + WeatherServer server(handler); + auto loopback = std::make_shared(); + ASSERT_TRUE(loopback->Start(server.Handler()).ok()); + auto inject = [](smithy::http::HttpResponse& response) { + auto doc = smithy::json::Decode(response.body); + if (!doc.ok() || !doc->is_map()) return; + auto map = doc->as_map(); + map.insert_or_assign("smithy_cpp_unknown_member", smithy::Document(42)); + response.body = smithy::json::Encode(smithy::Document(std::move(map))); + }; + auto transport = std::make_shared(loopback, inject); + smithy::ClientConfig config; + config.http_client = transport; + auto client = *WeatherClient::Create(std::move(config)); + Rng rng{std::mt19937{99U}, /*fill_all=*/true}; + const GetCityInput input = RandomGetCityInput(rng); + GetCityOutput output = RandomGetCityOutput(rng); + handler->nextGetCityOutput = output; + const auto outcome = client.GetCity(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + EXPECT_EQ(*outcome, output); +} + +TEST(WeatherIntegrationUnknownMembers, GetCurrentTimeToleratesUnknownResponseMembers) { + auto handler = std::make_shared(); + WeatherServer server(handler); + auto loopback = std::make_shared(); + ASSERT_TRUE(loopback->Start(server.Handler()).ok()); + auto inject = [](smithy::http::HttpResponse& response) { + auto doc = smithy::json::Decode(response.body); + if (!doc.ok() || !doc->is_map()) return; + auto map = doc->as_map(); + map.insert_or_assign("smithy_cpp_unknown_member", smithy::Document(42)); + response.body = smithy::json::Encode(smithy::Document(std::move(map))); + }; + auto transport = std::make_shared(loopback, inject); + smithy::ClientConfig config; + config.http_client = transport; + auto client = *WeatherClient::Create(std::move(config)); + Rng rng{std::mt19937{99U}, /*fill_all=*/true}; + const GetCurrentTimeInput input = RandomGetCurrentTimeInput(rng); + GetCurrentTimeOutput output = RandomGetCurrentTimeOutput(rng); + handler->nextGetCurrentTimeOutput = output; + const auto outcome = client.GetCurrentTime(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + EXPECT_EQ(*outcome, output); +} + +TEST(WeatherIntegrationUnknownMembers, GetForecastToleratesUnknownResponseMembers) { + auto handler = std::make_shared(); + WeatherServer server(handler); + auto loopback = std::make_shared(); + ASSERT_TRUE(loopback->Start(server.Handler()).ok()); + auto inject = [](smithy::http::HttpResponse& response) { + auto doc = smithy::json::Decode(response.body); + if (!doc.ok() || !doc->is_map()) return; + auto map = doc->as_map(); + map.insert_or_assign("smithy_cpp_unknown_member", smithy::Document(42)); + response.body = smithy::json::Encode(smithy::Document(std::move(map))); + }; + auto transport = std::make_shared(loopback, inject); + smithy::ClientConfig config; + config.http_client = transport; + auto client = *WeatherClient::Create(std::move(config)); + Rng rng{std::mt19937{99U}, /*fill_all=*/true}; + const GetForecastInput input = RandomGetForecastInput(rng); + GetForecastOutput output = RandomGetForecastOutput(rng); + handler->nextGetForecastOutput = output; + const auto outcome = client.GetForecast(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + EXPECT_EQ(*outcome, output); +} + +TEST(WeatherIntegrationUnknownMembers, ListCitiesToleratesUnknownResponseMembers) { + auto handler = std::make_shared(); + WeatherServer server(handler); + auto loopback = std::make_shared(); + ASSERT_TRUE(loopback->Start(server.Handler()).ok()); + auto inject = [](smithy::http::HttpResponse& response) { + auto doc = smithy::json::Decode(response.body); + if (!doc.ok() || !doc->is_map()) return; + auto map = doc->as_map(); + map.insert_or_assign("smithy_cpp_unknown_member", smithy::Document(42)); + response.body = smithy::json::Encode(smithy::Document(std::move(map))); + }; + auto transport = std::make_shared(loopback, inject); + smithy::ClientConfig config; + config.http_client = transport; + auto client = *WeatherClient::Create(std::move(config)); + Rng rng{std::mt19937{99U}, /*fill_all=*/true}; + const ListCitiesInput input = RandomListCitiesInput(rng); + ListCitiesOutput output = RandomListCitiesOutput(rng); + handler->nextListCitiesOutput = output; + const auto outcome = client.ListCities(input); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + EXPECT_EQ(*outcome, output); +} + +INSTANTIATE_TEST_SUITE_P(Transports, WeatherIntegrationTest, ::testing::Values(TransportKind::kLoopback, TransportKind::kSocket), [](const auto& info) { return info.param == TransportKind::kLoopback ? "Loopback" : "Socket"; }); + +} // namespace +} // namespace example::weather diff --git a/runtime/testing/include/smithy/testing/protocol_test.h b/runtime/testing/include/smithy/testing/protocol_test.h index cb231d72..e9808e7a 100644 --- a/runtime/testing/include/smithy/testing/protocol_test.h +++ b/runtime/testing/include/smithy/testing/protocol_test.h @@ -9,6 +9,8 @@ #include #include +#include +#include #include #include #include @@ -38,6 +40,27 @@ class CapturingTransport final : public smithy::http::HttpClient { smithy::http::HttpResponse next_response{200, {}, ""}; }; +// Wraps a transport and mutates successful responses in flight — e.g. the +// integration tests inject unknown body members that clients must ignore. +class MutatingTransport final : public smithy::http::HttpClient { + public: + using Mutator = std::function; + + MutatingTransport(std::shared_ptr inner, Mutator mutate) + : inner_(std::move(inner)), mutate_(std::move(mutate)) {} + + smithy::Outcome Send( + const smithy::http::HttpRequest& request) override { + auto response = inner_->Send(request); + if (response.ok()) mutate_(*response); + return response; + } + + private: + std::shared_ptr inner_; + Mutator mutate_; +}; + // Path portion of an origin-form request target ("/a/b?c=d" -> "/a/b"). inline std::string UriPath(const std::string& target) { return target.substr(0, target.find('?')); } diff --git a/runtime/tests/testing/protocol_test_support_test.cc b/runtime/tests/testing/protocol_test_support_test.cc index 234a760a..279d62cc 100644 --- a/runtime/tests/testing/protocol_test_support_test.cc +++ b/runtime/tests/testing/protocol_test_support_test.cc @@ -92,6 +92,16 @@ TEST(BodyMessageMatchesTest, MatchesJsonMessageMember) { EXPECT_FALSE(BodyMessageMatches(".*", "not json")); } +TEST(MutatingTransportTest, MutatesSuccessfulResponses) { + auto inner = std::make_shared(); + inner->next_response = {200, {}, "before"}; + MutatingTransport transport(inner, + [](smithy::http::HttpResponse& response) { response.body += "!"; }); + const auto response = transport.Send({}); + ASSERT_TRUE(response.ok()); + EXPECT_EQ(response->body, "before!"); +} + TEST(CapturingTransportTest, RecordsAndReplays) { CapturingTransport transport; transport.next_response = {418, {}, "teapot"};