|
| 1 | +// Out-of-tree acceptance for the response body sink (issue #213): a consumer |
| 2 | +// module reaching `opal::http::BodySink` through `@opal_cpp//runtime:http` |
| 3 | +// and pulling a blob payload off a generated server without the body landing |
| 4 | +// in a response object. |
| 5 | +// |
| 6 | +// This is the shape a consumer has today, and deliberately so: the sink lives |
| 7 | +// on the transport, so a caller drives it directly. Slice 2 of #213 is what |
| 8 | +// puts a `@streaming` blob behind a generated method; until then this test is |
| 9 | +// the record of what the runtime alone gives you. |
| 10 | +// |
| 11 | +// The transport here is `SocketHttpClient`, which inherits the default |
| 12 | +// `SendStreaming` — it delivers through the sink but buffers on the way, so |
| 13 | +// what this test pins is the contract (the sink gets the bytes, the response |
| 14 | +// does not) rather than the memory bound. The bound is `BeastHttpClient`'s, |
| 15 | +// pinned in the runtime's own beast_client_test.cc, and a consumer gets it by |
| 16 | +// injecting that transport instead. Keeping Beast out of this target is what |
| 17 | +// lets it run behind a download-blocking proxy alongside the other |
| 18 | +// socket-transport tests. |
| 19 | + |
| 20 | +#include <gtest/gtest.h> |
| 21 | + |
| 22 | +#include <cstddef> |
| 23 | +#include <memory> |
| 24 | +#include <string> |
| 25 | +#include <string_view> |
| 26 | + |
| 27 | +#include "acme/redirect/client.h" |
| 28 | +#include "acme/redirect/server.h" |
| 29 | +#include "opal/core/error.h" |
| 30 | +#include "opal/http/message.h" |
| 31 | +#include "opal/http/socket_transport.h" |
| 32 | +#include "opal/http/transport.h" |
| 33 | + |
| 34 | +namespace { |
| 35 | + |
| 36 | +using acme::redirect::FetchInput; |
| 37 | +using acme::redirect::FetchOutput; |
| 38 | +using acme::redirect::NoSuchSlug; |
| 39 | +using acme::redirect::ProbeInput; |
| 40 | +using acme::redirect::ProbeOutput; |
| 41 | +using acme::redirect::RedirectorHandler; |
| 42 | +using acme::redirect::RedirectorServer; |
| 43 | +using acme::redirect::ResolveDynamicInput; |
| 44 | +using acme::redirect::ResolveDynamicOutput; |
| 45 | +using acme::redirect::ResolveInput; |
| 46 | +using acme::redirect::ResolveOutput; |
| 47 | + |
| 48 | +// A payload big enough that nobody would want it in memory twice — the case |
| 49 | +// the sink exists for. The content is a repeating pattern rather than one |
| 50 | +// character so a test can tell a truncated delivery from a short one. |
| 51 | +std::string LargePayload() { |
| 52 | + std::string payload; |
| 53 | + payload.reserve(512 * 1024); |
| 54 | + while (payload.size() < 512 * 1024) { |
| 55 | + payload += "the quick brown fox jumps over the lazy dog\n"; |
| 56 | + } |
| 57 | + return payload; |
| 58 | +} |
| 59 | + |
| 60 | +class DownloadHandler final : public RedirectorHandler { |
| 61 | + public: |
| 62 | + explicit DownloadHandler(std::string payload) : payload_(std::move(payload)) {} |
| 63 | + |
| 64 | + opal::Outcome<FetchOutput> Fetch(const FetchInput& input, |
| 65 | + const opal::server::RequestContext&) override { |
| 66 | + if (input.slug != "big") return NotFound(input.slug); |
| 67 | + return FetchOutput{ |
| 68 | + .status = 200, .etag = "\"big\"", .content = opal::Blob::FromString(payload_)}; |
| 69 | + } |
| 70 | + opal::Outcome<ProbeOutput> Probe(const ProbeInput&, |
| 71 | + const opal::server::RequestContext&) override { |
| 72 | + return ProbeOutput{.etag = "\"big\"", .content = opal::Blob::FromString(payload_)}; |
| 73 | + } |
| 74 | + opal::Outcome<ResolveOutput> Resolve(const ResolveInput& input, |
| 75 | + const opal::server::RequestContext&) override { |
| 76 | + return NotFound(input.slug); |
| 77 | + } |
| 78 | + opal::Outcome<ResolveDynamicOutput> ResolveDynamic(const ResolveDynamicInput& input, |
| 79 | + const opal::server::RequestContext&) override { |
| 80 | + return NotFound(input.slug); |
| 81 | + } |
| 82 | + |
| 83 | + private: |
| 84 | + static opal::Error NotFound(const std::string& slug) { |
| 85 | + opal::Error error = opal::Error::Modeled("NoSuchSlug", "no slug: " + slug); |
| 86 | + error.set_detail(NoSuchSlug{.message = "no slug: " + slug}); |
| 87 | + return error; |
| 88 | + } |
| 89 | + |
| 90 | + std::string payload_; |
| 91 | +}; |
| 92 | + |
| 93 | +class ResponseSinkAcceptanceTest : public ::testing::Test { |
| 94 | + protected: |
| 95 | + void SetUp() override { ASSERT_TRUE(transport_.Start(server_.Handler()).ok()); } |
| 96 | + void TearDown() override { transport_.Stop(); } |
| 97 | + |
| 98 | + opal::http::HttpRequest Get(const std::string& slug) const { |
| 99 | + opal::http::HttpRequest request; |
| 100 | + request.method = "GET"; |
| 101 | + request.target = "/c/" + slug; |
| 102 | + return request; |
| 103 | + } |
| 104 | + |
| 105 | + std::string payload_ = LargePayload(); |
| 106 | + RedirectorServer server_{std::make_shared<DownloadHandler>(payload_)}; |
| 107 | + opal::http::SocketHttpServer transport_; |
| 108 | +}; |
| 109 | + |
| 110 | +TEST_F(ResponseSinkAcceptanceTest, ABlobPayloadArrivesThroughTheSinkAndNotInTheResponse) { |
| 111 | + opal::http::SocketHttpClient client("127.0.0.1", transport_.port()); |
| 112 | + |
| 113 | + // What a real consumer does with the pieces: hand them straight to |
| 114 | + // something that consumes bytes — a file, a hash, a parser. Nothing here |
| 115 | + // keeps the payload, only its length and digest. |
| 116 | + std::size_t received = 0; |
| 117 | + std::size_t digest = 0; |
| 118 | + const opal::http::BodySink to_consumer{ |
| 119 | + .accept = [](int status, const opal::http::Headers&) { return status == 200; }, |
| 120 | + .write = |
| 121 | + [&](std::string_view piece) { |
| 122 | + received += piece.size(); |
| 123 | + for (const char byte : piece) digest = digest * 31 + static_cast<unsigned char>(byte); |
| 124 | + return true; |
| 125 | + }, |
| 126 | + }; |
| 127 | + |
| 128 | + const auto response = client.SendStreaming(Get("big"), to_consumer); |
| 129 | + ASSERT_TRUE(response.ok()) << response.error().message(); |
| 130 | + EXPECT_EQ(response->status, 200); |
| 131 | + EXPECT_EQ(response->headers.Get("etag").value_or(""), "\"big\""); |
| 132 | + EXPECT_EQ(received, payload_.size()); |
| 133 | + EXPECT_TRUE(response->body.empty()) << "the payload was delivered twice"; |
| 134 | + |
| 135 | + std::size_t expected = 0; |
| 136 | + for (const char byte : payload_) expected = expected * 31 + static_cast<unsigned char>(byte); |
| 137 | + EXPECT_EQ(digest, expected) << "the bytes arrived, but not these bytes"; |
| 138 | +} |
| 139 | + |
| 140 | +TEST_F(ResponseSinkAcceptanceTest, AModeledErrorIsLeftWhereTheClientLooksForIt) { |
| 141 | + // The reason accept() is asked per response: a sink that takes payloads |
| 142 | + // must not swallow the error document that the generated client's own |
| 143 | + // deserializer needs to turn a 404 into a NoSuchSlug. |
| 144 | + opal::http::SocketHttpClient client("127.0.0.1", transport_.port()); |
| 145 | + |
| 146 | + bool wrote = false; |
| 147 | + const opal::http::BodySink payloads_only{ |
| 148 | + .accept = [](int status, const opal::http::Headers&) { return status == 200; }, |
| 149 | + .write = |
| 150 | + [&](std::string_view) { |
| 151 | + wrote = true; |
| 152 | + return true; |
| 153 | + }, |
| 154 | + }; |
| 155 | + |
| 156 | + const auto response = client.SendStreaming(Get("missing"), payloads_only); |
| 157 | + ASSERT_TRUE(response.ok()) << response.error().message(); |
| 158 | + EXPECT_EQ(response->status, 404); |
| 159 | + EXPECT_FALSE(wrote); |
| 160 | + // The modeled member is still in the document, which is what the generated |
| 161 | + // client's deserializer reads to build the typed NoSuchSlug detail. Had the |
| 162 | + // sink taken this response, it would have read an empty body instead. |
| 163 | + EXPECT_NE(response->body.find("no slug: missing"), std::string::npos) << response->body; |
| 164 | +} |
| 165 | + |
| 166 | +} // namespace |
0 commit comments