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"};