diff --git a/Include/UrlLib/UrlLib.h b/Include/UrlLib/UrlLib.h index ca42336..9ded3b7 100644 --- a/Include/UrlLib/UrlLib.h +++ b/Include/UrlLib/UrlLib.h @@ -1,12 +1,14 @@ #pragma once #include +#include #include #include #include #include #include #include +#include namespace UrlLib { @@ -28,6 +30,21 @@ namespace UrlLib Buffer, }; + // Result returned by a custom URL scheme resolver (see UrlRequest::RegisterSchemeResolver). + // When `handled` is false the URL had no live entry (e.g. a revoked blob: URL) and the request + // surfaces as a network error (status stays 0/None), mirroring the transport-failure contract. + struct UrlSchemeResolverResult + { + bool handled{false}; + UrlStatusCode statusCode{UrlStatusCode::None}; + std::string statusText{}; + std::string contentType{}; + std::shared_ptr> body{}; + }; + + // Resolves a URL of a registered non-transport scheme (e.g. "blob") to an in-memory response. + using UrlSchemeResolver = std::function; + class UrlRequest final { public: @@ -46,6 +63,27 @@ namespace UrlLib void Open(UrlMethod method, const std::string& url); + // Registers a resolver for a non-transport URL scheme such as "blob". Registration is + // process-global. When a UrlRequest is opened with a URL whose scheme has a registered + // resolver, the platform transport is bypassed and the resolver supplies the response at + // SendAsync() time, so every consumer (fetch, XMLHttpRequest, image / video src, texture + // loaders, ...) resolves such URLs uniformly through UrlRequest instead of each carrying + // its own branch. + // + // A resolver that reports the URL as not handled -- or that throws -- surfaces as a + // transport-style failure (status stays None, with an error symbol recorded); an exception + // never escapes SendAsync(). + // + // Throws std::invalid_argument if `scheme` is empty or `resolver` is null. Removal is the + // explicit UnregisterSchemeResolver call rather than registering an empty std::function, so + // a moved-from or default-constructed resolver cannot silently unregister a scheme. + static void RegisterSchemeResolver(std::string scheme, UrlSchemeResolver resolver); + + // Removes the resolver registered for `scheme`, if any. Unknown schemes are ignored. + // In-flight requests already diverted to that resolver are unaffected: each request holds + // its own reference to the resolver from the time it was opened. + static void UnregisterSchemeResolver(std::string scheme); + UrlResponseType ResponseType() const; void ResponseType(UrlResponseType value); diff --git a/Source/UrlRequest_Base.h b/Source/UrlRequest_Base.h index 2ddd53f..4cd1888 100644 --- a/Source/UrlRequest_Base.h +++ b/Source/UrlRequest_Base.h @@ -2,9 +2,13 @@ #include #include -#include #include +#include +#include +#include +#include #include +#include namespace UrlLib { @@ -21,6 +25,151 @@ namespace UrlLib m_cancellationSource.cancel(); } + // ---- Custom scheme resolvers (e.g. blob:) --------------------------------------------- + // Resolution for registered schemes is handled entirely in the shared layer; the platform + // transport is never involved. RegisterSchemeResolver installs a process-global resolver; + // BeginSchemeResolution is called from Open() to divert a matching URL; ResolveScheme is + // called from SendAsync() so revoke-after-open is honored; ResolvedResponseBuffer backs + // ResponseBuffer() for the Buffer response type. + // + // A null resolver is rejected rather than treated as an implicit removal: a resolver can + // capture state that must outlive registration, so silently unregistering on a moved-from + // or default-constructed std::function would be an easy mistake to miss. Removal is the + // explicit UnregisterSchemeResolver call. + static void RegisterSchemeResolver(std::string scheme, UrlSchemeResolver resolver) + { + if (!resolver) + { + throw std::invalid_argument{"RegisterSchemeResolver: resolver must not be null (use UnregisterSchemeResolver to remove a scheme)"}; + } + + ToLower(scheme); + if (scheme.empty()) + { + throw std::invalid_argument{"RegisterSchemeResolver: scheme must not be empty"}; + } + + auto& registry = Registry(); + const std::lock_guard lock{registry.mutex}; + registry.resolvers[std::move(scheme)] = std::move(resolver); + } + + // Removes the resolver registered for `scheme`, if any. Unknown schemes are ignored. + static void UnregisterSchemeResolver(std::string scheme) + { + ToLower(scheme); + auto& registry = Registry(); + const std::lock_guard lock{registry.mutex}; + registry.resolvers.erase(scheme); + } + + // Returns true (and defers the actual work to ResolveScheme()) when `url`'s scheme has a + // registered resolver, in which case the caller must not touch the platform transport. + bool BeginSchemeResolution(const std::string& url) + { + const std::string scheme = SchemeOf(url); + if (scheme.empty()) + { + return false; + } + + UrlSchemeResolver resolver{}; + { + auto& registry = Registry(); + const std::lock_guard lock{registry.mutex}; + const auto it = registry.resolvers.find(scheme); + if (it == registry.resolvers.end()) + { + return false; + } + resolver = it->second; + } + + ResetForOpen(); + m_pendingResolver = std::move(resolver); + m_pendingResolverUrl = url; + m_usingSchemeResolver = true; + return true; + } + + bool IsSchemeResolution() const + { + return m_usingSchemeResolver; + } + + // Invokes the registered resolver and populates the response state. A resolver that reports + // the URL as not handled (e.g. a revoked blob: URL) -- or that throws -- leaves the status at + // 0 (None) and records a transport-style error, mirroring how a genuine network failure + // surfaces. The pending resolver is consumed here so a second SendAsync() on the same request + // does not re-run the resolver (which could re-trigger side effects or clobber the response); + // the resolved response state is retained and IsSchemeResolution() stays true so + // ResponseBuffer() keeps serving the resolved bytes. + void ResolveScheme() + { + if (!m_pendingResolver) + { + return; + } + + // Move the resolver/url out and clear them before invoking, so this runs exactly once. + const UrlSchemeResolver resolver = std::move(m_pendingResolver); + const std::string url = std::move(m_pendingResolverUrl); + m_pendingResolver = nullptr; + m_pendingResolverUrl.clear(); + + m_responseUrl = url; + + // A throwing resolver is contained here and reported through the same error surface as + // any other failure, so it cannot escape SendAsync() synchronously -- callers observe a + // failed request (status 0 + error) exactly as they would for a transport failure. + UrlSchemeResolverResult result{}; + try + { + result = resolver(url); + } + catch (const std::exception& e) + { + SetError("urllib", "SchemeResolverThrew", 0, e.what()); + return; + } + catch (...) + { + SetError("urllib", "SchemeResolverThrew", 0, "unknown exception"); + return; + } + + if (!result.handled) + { + SetError("urllib", "SchemeResolverNotFound", 0, "no live entry for '" + url + "'"); + return; + } + + m_statusCode = result.statusCode; + if (!result.statusText.empty()) + { + m_statusText = result.statusText; + } + if (!result.contentType.empty()) + { + m_headers["content-type"] = result.contentType; + } + + m_resolvedBuffer = result.body ? result.body : std::make_shared>(); + if (m_responseType == UrlResponseType::String) + { + m_responseString.assign(reinterpret_cast(m_resolvedBuffer->data()), m_resolvedBuffer->size()); + } + } + + gsl::span ResolvedResponseBuffer() const + { + if (m_resolvedBuffer) + { + return {m_resolvedBuffer->data(), m_resolvedBuffer->size()}; + } + return {}; + } + void SetRequestBody(std::string requestBody) { m_requestBody = requestBody; } @@ -113,6 +262,20 @@ namespace UrlLib std::transform(s.cbegin(), s.cend(), s.begin(), [](auto c) { return static_cast(std::tolower(c)); }); } + // Returns the lower-cased scheme of `url` (the substring before the first ':'), or "" if + // the URL has no scheme. + static std::string SchemeOf(const std::string& url) + { + const auto pos = url.find(':'); + if (pos == std::string::npos) + { + return {}; + } + std::string scheme = url.substr(0, pos); + ToLower(scheme); + return scheme; + } + // Canonical HTTP reason phrases, used as a fallback when the transport does not carry // a reason phrase on the wire (HTTP/2+ status lines omit it, and some platform HTTP // stacks don't surface it). Returns "" for codes not in the table. @@ -213,6 +376,10 @@ namespace UrlLib m_errorCode = 0; m_errorSymbol.clear(); m_errorString.clear(); + m_usingSchemeResolver = false; + m_pendingResolver = nullptr; + m_pendingResolverUrl.clear(); + m_resolvedBuffer.reset(); } arcana::cancellation_source m_cancellationSource{}; @@ -228,5 +395,28 @@ namespace UrlLib std::unordered_map m_headers; std::string m_requestBody{}; std::unordered_map m_requestHeaders; + + // Custom-scheme (e.g. blob:) resolution state. Populated by BeginSchemeResolution() / + // ResolveScheme(); inert for ordinary transport requests. + bool m_usingSchemeResolver{false}; + UrlSchemeResolver m_pendingResolver{}; + std::string m_pendingResolverUrl{}; + std::shared_ptr> m_resolvedBuffer{}; + + private: + // Process-global registry of scheme resolvers, keyed by lower-cased scheme (no trailing + // ':'). Guarded by a mutex since RegisterSchemeResolver and request handling can run on + // different threads. + struct ResolverRegistry + { + std::mutex mutex; + std::unordered_map resolvers; + }; + + static ResolverRegistry& Registry() + { + static ResolverRegistry registry; + return registry; + } }; } diff --git a/Source/UrlRequest_Shared.h b/Source/UrlRequest_Shared.h index 78e5e07..f0b10e8 100644 --- a/Source/UrlRequest_Shared.h +++ b/Source/UrlRequest_Shared.h @@ -24,9 +24,26 @@ namespace UrlLib void UrlRequest::Open(UrlMethod method, const std::string& url) { + // Divert URLs whose scheme has a registered resolver (e.g. blob:) away from the platform + // transport; the resolver supplies the response in SendAsync(). + if (m_impl->BeginSchemeResolution(url)) + { + return; + } + m_impl->Open(method, url); } + void UrlRequest::RegisterSchemeResolver(std::string scheme, UrlSchemeResolver resolver) + { + Impl::RegisterSchemeResolver(std::move(scheme), std::move(resolver)); + } + + void UrlRequest::UnregisterSchemeResolver(std::string scheme) + { + Impl::UnregisterSchemeResolver(std::move(scheme)); + } + UrlResponseType UrlRequest::ResponseType() const { return m_impl->ResponseType(); @@ -59,6 +76,15 @@ namespace UrlLib arcana::task UrlRequest::SendAsync() { + // Registered-scheme requests (e.g. blob:) are served synchronously from the resolver; the + // resolution is deferred to here (rather than Open) so a blob: URL revoked between open() + // and send() is honored. + if (m_impl->IsSchemeResolution()) + { + m_impl->ResolveScheme(); + return arcana::task_from_result(); + } + return m_impl->SendAsync(); } @@ -99,6 +125,11 @@ namespace UrlLib gsl::span UrlRequest::ResponseBuffer() const { + if (m_impl->IsSchemeResolution()) + { + return m_impl->ResolvedResponseBuffer(); + } + return m_impl->ResponseBuffer(); } } diff --git a/Tests/CMakeLists.txt b/Tests/CMakeLists.txt index c0ef53a..aea1951 100644 --- a/Tests/CMakeLists.txt +++ b/Tests/CMakeLists.txt @@ -9,7 +9,7 @@ FetchContent_MakeAvailable(googletest) set_property(TARGET gtest PROPERTY FOLDER Dependencies) set_property(TARGET gtest_main PROPERTY FOLDER Dependencies) -add_executable(UrlLibTests UrlRequestErrorReporting.cpp) +add_executable(UrlLibTests UrlRequestErrorReporting.cpp SchemeResolver.cpp) target_link_libraries(UrlLibTests PRIVATE UrlLib diff --git a/Tests/SchemeResolver.cpp b/Tests/SchemeResolver.cpp new file mode 100644 index 0000000..dbc24fb --- /dev/null +++ b/Tests/SchemeResolver.cpp @@ -0,0 +1,286 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + std::shared_ptr> MakeBody(const std::string& text) + { + const auto* first = reinterpret_cast(text.data()); + return std::make_shared>(first, first + text.size()); + } + + // Scheme resolution is synchronous: SendAsync() returns an already-completed task, so the inline + // continuation runs before this returns. The flag is held by shared_ptr rather than captured by + // reference so the continuation stays safe even if a caller uses this on a request that was NOT + // diverted to a resolver and therefore completes later on the real transport. + bool SendCompletesSynchronously(UrlLib::UrlRequest& request) + { + auto completed = std::make_shared>(false); + request.SendAsync().then(arcana::inline_scheduler, arcana::cancellation::none(), + [completed](const arcana::expected&) { completed->store(true); }); + return completed->load(); + } + + // Sends a request that is expected to be served by a scheme resolver, so it settles inline and + // leaves the request fully populated by the time this returns. + void Send(UrlLib::UrlRequest& request) + { + ASSERT_TRUE(SendCompletesSynchronously(request)); + } + + // Sends a request that goes to the real platform transport and blocks until it settles. A + // request must never be abandoned while in flight: backends complete on their own thread and + // write the response into the request's impl through a raw `this`, so letting the request go out + // of scope first would corrupt whatever memory the impl's allocation is reused for. Returns + // false if the request did not settle within the timeout. + bool SendAndWait(UrlLib::UrlRequest& request, std::chrono::seconds timeout = std::chrono::seconds{60}) + { + auto settled = std::make_shared>(); + auto future = settled->get_future(); + request.SendAsync().then(arcana::inline_scheduler, arcana::cancellation::none(), + [settled](const arcana::expected&) { settled->set_value(); }); + return future.wait_for(timeout) == std::future_status::ready; + } +} + +// A handled resolver populates status, status text, the content-type header and the response body. +TEST(SchemeResolver, HandledResolverPopulatesStringResponse) +{ + const std::string scheme = "urllibtest-string"; + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, [](const std::string&) { + UrlLib::UrlSchemeResolverResult result; + result.handled = true; + result.statusCode = UrlLib::UrlStatusCode::Ok; + result.statusText = "OK"; + result.contentType = "text/plain"; + result.body = MakeBody("hello"); + return result; + }); + + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); + request.ResponseType(UrlLib::UrlResponseType::String); + Send(request); + + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::Ok); + EXPECT_EQ(request.StatusText(), "OK"); + EXPECT_EQ(request.ResponseString(), "hello"); + EXPECT_EQ(request.ResponseUrl(), scheme + ":anything"); + ASSERT_TRUE(request.GetResponseHeader("content-type").has_value()); + EXPECT_EQ(*request.GetResponseHeader("content-type"), "text/plain"); + EXPECT_TRUE(request.ErrorSymbol().empty()); + EXPECT_TRUE(request.ErrorString().empty()); + + UrlLib::UrlRequest::UnregisterSchemeResolver(scheme); +} + +// A handled resolver serves the raw bytes for a Buffer response type. +TEST(SchemeResolver, HandledResolverPopulatesBufferResponse) +{ + const std::string scheme = "urllibtest-buffer"; + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, [](const std::string&) { + UrlLib::UrlSchemeResolverResult result; + result.handled = true; + result.statusCode = UrlLib::UrlStatusCode::Ok; + result.body = MakeBody("world!"); + return result; + }); + + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); + request.ResponseType(UrlLib::UrlResponseType::Buffer); + Send(request); + + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::Ok); + const auto buffer = request.ResponseBuffer(); + ASSERT_EQ(buffer.size(), static_cast(6)); + EXPECT_EQ(std::string(reinterpret_cast(buffer.data()), buffer.size()), "world!"); + + UrlLib::UrlRequest::UnregisterSchemeResolver(scheme); +} + +// A resolver that reports handled == false (e.g. a revoked blob: URL) surfaces as a transport-style +// error: status stays None (0), an error symbol is recorded, and the response buffer is empty. +TEST(SchemeResolver, NotHandledResolverSurfacesTransportError) +{ + const std::string scheme = "urllibtest-missing"; + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, [](const std::string&) { + return UrlLib::UrlSchemeResolverResult{}; // handled == false + }); + + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, scheme + ":gone"); + request.ResponseType(UrlLib::UrlResponseType::Buffer); + Send(request); + + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::None); + EXPECT_EQ(request.ErrorSymbol(), "SchemeResolverNotFound"); + EXPECT_FALSE(request.ErrorString().empty()); + EXPECT_TRUE(request.ResponseBuffer().empty()); + + UrlLib::UrlRequest::UnregisterSchemeResolver(scheme); +} + +// The resolver is consumed on the first SendAsync(); a second send does not re-run it (no repeated +// side effects) while the already-resolved response remains available. +TEST(SchemeResolver, ResolverRunsExactlyOncePerRequest) +{ + const std::string scheme = "urllibtest-once"; + std::atomic calls{0}; + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, [&calls](const std::string&) { + ++calls; + UrlLib::UrlSchemeResolverResult result; + result.handled = true; + result.statusCode = UrlLib::UrlStatusCode::Ok; + result.body = MakeBody("once"); + return result; + }); + + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); + request.ResponseType(UrlLib::UrlResponseType::Buffer); + Send(request); + Send(request); + + EXPECT_EQ(calls.load(), 1); + EXPECT_EQ(request.ResponseBuffer().size(), static_cast(4)); + + UrlLib::UrlRequest::UnregisterSchemeResolver(scheme); +} + + +// A handled resolver that leaves statusText empty still reports the canonical reason phrase, since +// StatusText() falls back to the code->phrase table for the resolver path exactly as it does for +// the transport path (HTTP/2+ status lines carry no reason phrase). +TEST(SchemeResolver, EmptyStatusTextFallsBackToReasonPhrase) +{ + const std::string scheme = "urllibtest-nostatustext"; + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, [](const std::string&) { + UrlLib::UrlSchemeResolverResult result; + result.handled = true; + result.statusCode = UrlLib::UrlStatusCode::Ok; + result.body = MakeBody("x"); + return result; // statusText intentionally left empty + }); + + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); + Send(request); + + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::Ok); + EXPECT_EQ(request.StatusText(), "OK"); + + UrlLib::UrlRequest::UnregisterSchemeResolver(scheme); +} + +// A resolver that throws must not let the exception escape SendAsync(); it is reported through the +// same transport-style error surface as a failed request. +TEST(SchemeResolver, ThrowingResolverSurfacesTransportError) +{ + const std::string scheme = "urllibtest-throws"; + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, [](const std::string&) -> UrlLib::UrlSchemeResolverResult { + throw std::runtime_error{"resolver blew up"}; + }); + + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); + request.ResponseType(UrlLib::UrlResponseType::Buffer); + EXPECT_NO_THROW(Send(request)); + + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::None); + EXPECT_EQ(request.ErrorSymbol(), "SchemeResolverThrew"); + EXPECT_NE(std::string{request.ErrorString()}.find("resolver blew up"), std::string::npos); + EXPECT_TRUE(request.ResponseBuffer().empty()); + + UrlLib::UrlRequest::UnregisterSchemeResolver(scheme); +} + +// Removal is explicit: a null resolver is rejected rather than silently unregistering the scheme. +TEST(SchemeResolver, RegisteringNullResolverThrows) +{ + const std::string scheme = "urllibtest-null"; + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, [](const std::string&) { + UrlLib::UrlSchemeResolverResult result; + result.handled = true; + result.statusCode = UrlLib::UrlStatusCode::Ok; + return result; + }); + + EXPECT_THROW(UrlLib::UrlRequest::RegisterSchemeResolver(scheme, {}), std::invalid_argument); + EXPECT_THROW(UrlLib::UrlRequest::RegisterSchemeResolver("", [](const std::string&) { + return UrlLib::UrlSchemeResolverResult{}; + }), std::invalid_argument); + + // The rejected registration left the existing resolver in place. + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); + Send(request); + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::Ok); + + UrlLib::UrlRequest::UnregisterSchemeResolver(scheme); +} + +// After unregistering, the scheme is no longer diverted: the resolver is never invoked again and the +// request falls through to the platform transport. The transport's outcome for an unknown scheme is +// platform-specific (it may throw at Open, fail synchronously, or fail asynchronously), so this +// asserts only the property under test -- that the resolver is no longer consulted. +TEST(SchemeResolver, UnregisterStopsDivertingScheme) +{ + const std::string scheme = "urllibtest-unregister"; + auto calls = std::make_shared>(0); + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, [calls](const std::string&) { + ++*calls; + UrlLib::UrlSchemeResolverResult result; + result.handled = true; + result.statusCode = UrlLib::UrlStatusCode::Ok; + result.body = MakeBody("still here"); + return result; + }); + + UrlLib::UrlRequest resolved; + resolved.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); + Send(resolved); + EXPECT_EQ(resolved.StatusCode(), UrlLib::UrlStatusCode::Ok); + EXPECT_EQ(calls->load(), 1); + + UrlLib::UrlRequest::UnregisterSchemeResolver(scheme); + + // Unregistering an unknown scheme is a no-op, not an error. + EXPECT_NO_THROW(UrlLib::UrlRequest::UnregisterSchemeResolver(scheme)); + + UrlLib::UrlRequest afterUnregister; + try + { + afterUnregister.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); + + // Not diverted, so this now goes to the real transport. Wait for it to settle before + // leaving scope: some backends (e.g. NSURLSession) neither cancel nor keep the impl alive + // for an abandoned request, and their completion handler would then write the response + // into freed memory. The transport's verdict on an unknown scheme is platform-specific, so + // only assert that it is not the resolver's response. + if (SendAndWait(afterUnregister)) + { + EXPECT_NE(afterUnregister.StatusCode(), UrlLib::UrlStatusCode::Ok); + } + else + { + ADD_FAILURE() << "transport request did not settle; leaving it in flight would be unsafe"; + } + } + catch (...) + { + // Some backends reject an unknown scheme outright at Open(); that is also "not diverted". + } + + EXPECT_EQ(calls->load(), 1); // the resolver was not consulted after unregistering +}