diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..3148815 --- /dev/null +++ b/.clang-format @@ -0,0 +1 @@ +BreakBeforeBraces: Attach \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index f952fbc..02ec871 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ # From https://github.com/XiangpengHao/cxx-cmake-example/blob/master/fastly/CMakeLists.txt cmake_minimum_required(VERSION 3.29) -project(compute-sdk-cpp VERSION 0.3.0 LANGUAGES CXX) +project(compute-sdk-cpp VERSION 0.4.0 LANGUAGES CXX) if(VERBOSE) set(VERBOSE_FLAG "--verbose") @@ -85,6 +85,9 @@ add_custom_command( target_include_directories(fastly-thin PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_BINARY_DIR}/include) +target_include_directories(fastly-sys PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_BINARY_DIR}/include) target_link_libraries(fastly-thin PUBLIC fastly-sys-orig fastly::sys) diff --git a/Cargo.lock b/Cargo.lock index 0e701a1..4ea9643 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -228,6 +228,20 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "esi" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8aec2576e951a80a55f7cbb541149afa271f58a5e3d70d82d45c90e7bc939298" +dependencies = [ + "fastly", + "html-escape", + "log", + "quick-xml", + "regex", + "thiserror 2.0.12", +] + [[package]] name = "fastly" version = "0.11.5" @@ -279,16 +293,18 @@ dependencies = [ [[package]] name = "fastly-sys" -version = "0.3.0" +version = "0.4.0" dependencies = [ "cxx", "cxx-build", + "esi", "fastly", "fastly-shared", "http", "link-cplusplus", "log", "log-fastly", + "quick-xml", "thiserror 2.0.12", ] @@ -341,6 +357,15 @@ version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + [[package]] name = "http" version = "1.3.1" @@ -582,6 +607,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.40" @@ -881,6 +915,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf8-width" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 91410b6..e260689 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fastly-sys" -version = "0.3.0" +version = "0.4.0" authors = ["Kat Marchán "] edition = "2024" @@ -12,6 +12,8 @@ http = "1.3.1" log = "0.4.27" log-fastly = "0.11.5" thiserror = "2.0.12" +esi = "0.6.1" +quick-xml = "0.38.3" [build-dependencies] cxx-build = "1.0" diff --git a/Doxyfile b/Doxyfile index 93c3cac..ab0da1e 100644 --- a/Doxyfile +++ b/Doxyfile @@ -991,7 +991,7 @@ WARN_LOGFILE = # spaces. See also FILE_PATTERNS and EXTENSION_MAPPING # Note: If this tag is empty the current directory is searched. -INPUT = README.md CONTRIBUTING.md examples target/release/dist/fastly +INPUT = README.md CONTRIBUTING.md examples include/fastly # This tag can be used to specify the character encoding of the source files # that Doxygen parses. Internally Doxygen uses the UTF-8 encoding. Doxygen uses diff --git a/examples/esi.cpp b/examples/esi.cpp new file mode 100644 index 0000000..f8ee3ef --- /dev/null +++ b/examples/esi.cpp @@ -0,0 +1,31 @@ +#include "fastly/sdk.h" + +int main() { + fastly::log::init_simple("logs", fastly::log::LogLevelFilter::Debug); + auto req{fastly::http::Request::from_client()}; + + auto bereq = fastly::http::Request(fastly::http::Method::GET, + "https://esi-cpp-demo.edgecompute.app/") + .with_auto_decompress_gzip(true); + auto beresp = bereq.clone_without_body().send("esi-cpp-demo").value(); + + // Pass in the request made to the backend as a template for fragment + // requests: this ensures that the fragment requests also ask for gzipped + // content and automatically gunzip the response content. + fastly::esi::Processor processor(std::move(bereq)); + auto dispatch_fragment_request = [](fastly::http::Request req) + -> std::optional { + auto pending = req.send_async("esi-cpp-demo"); + if (pending) { + return fastly::esi::PendingFragmentContent{std::move(*pending)}; + } else { + return std::nullopt; + } + }; + + auto result = processor.process_response( + beresp, std::nullopt, dispatch_fragment_request, std::nullopt); + if (!result) { + fastly::log::error("Failed to process response"); + } +} diff --git a/examples/fastly.toml b/examples/fastly.toml index fd8a642..679e5d2 100644 --- a/examples/fastly.toml +++ b/examples/fastly.toml @@ -9,5 +9,7 @@ name = "cpp-example" service_id = "" [local_server] -backends.fastly.url = "https://www.fastly.com" -backends.wikipedia.url = "https://en.wikipedia.org" \ No newline at end of file +[local_server.backends] +fastly.url = "https://www.fastly.com" +wikipedia.url = "https://en.wikipedia.org" +esi-cpp-demo.url = "https://esi-cpp-demo.edgecompute.app" diff --git a/include/fastly/detail/access_bridge_internals.h b/include/fastly/detail/access_bridge_internals.h new file mode 100644 index 0000000..1a6da47 --- /dev/null +++ b/include/fastly/detail/access_bridge_internals.h @@ -0,0 +1,20 @@ +#ifndef FASTLY_ACCESS_BRIDGE_INTERNALS_H +#define FASTLY_ACCESS_BRIDGE_INTERNALS_H + +#include + +namespace fastly::detail { +// This type can be used to access the inner `rust::Box` of +// various wrapper types in the C++ SDK. +// It can also be used to construct wrapper types from raw pointers. +// This is intended for internal use only. +struct AccessBridgeInternals { + template static auto &get(T &obj) { return obj.inner(); } + template static auto &get(const T &obj) { return obj.inner(); } + template static auto from_raw(U *ptr) { + return T(rust::Box::from_raw(ptr)); + } +}; +} // namespace fastly::detail + +#endif \ No newline at end of file diff --git a/include/fastly/detail/rust_bridge_tags.h b/include/fastly/detail/rust_bridge_tags.h new file mode 100644 index 0000000..98f92a1 --- /dev/null +++ b/include/fastly/detail/rust_bridge_tags.h @@ -0,0 +1,30 @@ +// Some types (primarily callback types) must be defined in C++, but depend upon +// types defined in Rust. To break this circular dependency, we define empty +// "tag" structs here in C++ that can be referenced both from C++ and Rust, +// allowing Rust to pass pointers to these types back to C++ without needing to +// know their full definition. +// +// C++ types that implement the tags should inherit from the tag structs and the +// bindings should cast to/from the tag types as necessary. + +#ifndef FASTLY_DETAIL_RUST_BRIDGE_TAGS_H +#define FASTLY_DETAIL_RUST_BRIDGE_TAGS_H + +namespace fastly::detail::rust_bridge_tags { +namespace esi { +// esi.h:DispatchFragmentRequestFn +struct DispatchFragmentRequestFnTag { + // Ensure that only classes that inherit from this tag can be used as + // DispatchFragmentRequestFn. +protected: + DispatchFragmentRequestFnTag() = default; +}; +// esi.h:ProcessFragmentResponseFn +struct ProcessFragmentResponseFnTag { +protected: + ProcessFragmentResponseFnTag() = default; +}; +} // namespace esi +} // namespace fastly::detail::rust_bridge_tags + +#endif \ No newline at end of file diff --git a/include/fastly/esi.h b/include/fastly/esi.h new file mode 100644 index 0000000..2670b32 --- /dev/null +++ b/include/fastly/esi.h @@ -0,0 +1,123 @@ +#ifndef FASTLY_ESI_H +#define FASTLY_ESI_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fastly::esi { +/// Used to configure optional behaviour within the ESI processor. +struct Configuration { +public: + /// Create a new configuration object. + /// \param namespc The namespace to use for ESI tags. + /// \param is_escaped_content Whether to escape content by default. + Configuration(std::string namespc = "esi", bool is_escaped_content = true) + : namespace_(std::move(namespc)), + is_escaped_content_(is_escaped_content) {} + + std::string_view get_namespace() const { return namespace_; } + bool is_escaped_content() const { return is_escaped_content_; } + +private: + std::string namespace_; + bool is_escaped_content_; +}; + +/// Content that can be returned from a fragment request dispatcher. This can +/// either be a pending request, a response, or an empty value to indicate that +/// no content is available. +using PendingFragmentContent = + std::variant; + +/// A callback type used to dispatch requests for ESI fragments. +class DispatchFragmentRequestFn + : public detail::rust_bridge_tags::esi::DispatchFragmentRequestFnTag { +public: + /// The type of the dispatch function. + using function_type = + std::function(Request)>; + + template F> + DispatchFragmentRequestFn(F &&fn) : fn_(std::forward(fn)) {} + +private: + friend detail::AccessBridgeInternals; + auto &inner() const { return fn_; } + function_type fn_; +}; + +/// A callback type used to process responses from ESI fragment requests. +class ProcessFragmentResponseFn + : public detail::rust_bridge_tags::esi::ProcessFragmentResponseFnTag { +public: + /// The type of the processing function. + using function_type = + std::function(Request &, Response)>; + + template F> + ProcessFragmentResponseFn(F &&fn) : fn_(std::forward(fn)) {} + +private: + friend detail::AccessBridgeInternals; + auto &inner() const { return fn_; } + function_type fn_; +}; + +/// An ESI processor that can process a response containing ESI tags, dispatch +/// requests for fragments, and process the fragment responses. +class Processor { +public: + /// Create a new ESI processor with the given configuration. + Processor(std::optional original_request_metadata = std::nullopt, + Configuration config = Configuration()); + + /// Process a response containing ESI tags, optionally using the given + /// callbacks to dispatch requests for fragments and process the fragment + /// responses. + /// + /// \param src_document The response containing ESI tags to process. + /// \param client_response_metadata Optional original client request data used + /// for fragment requests. + /// \param dispatch_fragment_request Optional callback to dispatch requests + /// for fragments. + /// \param process_fragment_response Optional callback to process fragment + /// responses. + fastly::expected process_response( + Response &src_document, + std::optional client_response_metadata = std::nullopt, + std::optional dispatch_fragment_request = + std::nullopt, + std::optional process_fragment_response = + std::nullopt); + + /// Process a string containing ESI tags, optionally using the given + /// callbacks to dispatch requests for fragments and process the fragment + /// responses. + /// \param src_document The string containing ESI tags to process. + /// \param dispatch_fragment_request Optional callback to dispatch requests + /// for fragments. + /// \param process_fragment_response Optional callback to process fragment + /// responses. + fastly::expected process_document( + const std::string &src_document, + std::optional dispatch_fragment_request = + std::nullopt, + std::optional process_fragment_response = + std::nullopt); + +private: + rust::Box processor_; +}; + +} // namespace fastly::esi +#endif \ No newline at end of file diff --git a/include/fastly/http/request.h b/include/fastly/http/request.h index 6aa3122..8a21583 100644 --- a/include/fastly/http/request.h +++ b/include/fastly/http/request.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -40,7 +41,7 @@ namespace request { /// `http::select`. It can also be discarded if the request was sent for effects /// it might have, and the response is unimportant. class PendingRequest { - + friend detail::AccessBridgeInternals; friend Request; friend std::pair, std::vector> select(std::vector &reqs); @@ -65,6 +66,7 @@ class PendingRequest { Request cloned_sent_req(); private: + auto &inner() { return req; } rust::Box req; PendingRequest(rust::Box r) @@ -147,6 +149,7 @@ select(std::vector &reqs); class Request { friend request::PendingRequest; friend Response; + friend detail::AccessBridgeInternals; public: /// Create a new request with the given method and URL, no headers, and an @@ -737,6 +740,7 @@ class Request { bool is_cacheable(); private: + auto &inner() { return req; } Request(rust::Box r) : req(std::move(r)) {}; rust::Box req; }; diff --git a/include/fastly/http/response.h b/include/fastly/http/response.h index 9342597..efeeeb2 100644 --- a/include/fastly/http/response.h +++ b/include/fastly/http/response.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -91,6 +92,7 @@ select(std::vector &reqs); /// resp.send_to_client(); /// ``` class Response { + friend detail::AccessBridgeInternals; friend Request; friend request::PendingRequest; friend std::pair, @@ -554,6 +556,7 @@ class Response { std::optional get_stale_while_revalidate(); private: + auto &inner() { return res; } Response(rust::Box response) : res(std::move(response)) {}; rust::Box res; diff --git a/src/cpp/esi.cpp b/src/cpp/esi.cpp new file mode 100644 index 0000000..cfcead6 --- /dev/null +++ b/src/cpp/esi.cpp @@ -0,0 +1,138 @@ +#include +#include +#include +#include +#include +#include + +namespace fastly::esi { +// These functions are called by Rust to invoke the C++ callbacks. +extern "C" fastly::sys::esi::DispatchFragmentRequestFnResult +fastly$esi$manualbridge$DispatchFragmentRequestFn$call( + const detail::rust_bridge_tags::esi::DispatchFragmentRequestFnTag &fn_tag, + fastly::sys::http::Request *raw_req, + fastly::sys::http::request::PendingRequest *&out_pending, + fastly::sys::http::Response *&out_complete) { + auto req = detail::AccessBridgeInternals::from_raw(raw_req); + + // The real callback type is cast to this tag type before being passed in to + // Rust to break circular dependencies. It's safe to cast it back here, as if + // some other type was passed in, something has gone horribly wrong. + auto fn = static_cast(fn_tag); + + auto res = detail::AccessBridgeInternals::get(fn)(std::move(req)); + if (!res) { + return fastly::sys::esi::DispatchFragmentRequestFnResult::Error; + } + if (std::holds_alternative(*res)) { + out_pending = detail::AccessBridgeInternals::get( + std::get(*res)) + .into_raw(); + return fastly::sys::esi::DispatchFragmentRequestFnResult::PendingRequest; + } else if (std::holds_alternative(*res)) { + out_complete = + detail::AccessBridgeInternals::get(std::get(*res)) + .into_raw(); + return fastly::sys::esi::DispatchFragmentRequestFnResult::CompletedRequest; + } else { + return fastly::sys::esi::DispatchFragmentRequestFnResult::NoContent; + } +} + +extern "C" bool fastly$esi$manualbridge$ProcessFragmentResponseFn$call( + const detail::rust_bridge_tags::esi::ProcessFragmentResponseFnTag &fn_tag, + fastly::sys::http::Request *raw_req, fastly::sys::http::Response *raw_resp, + fastly::sys::http::Response *&out_resp) { + // Ideally we wouldn't do this and would instead pass a reference, but + // that would require a separate wrapper type for Request references. + auto req = detail::AccessBridgeInternals::from_raw(raw_req); + + // The real callback type is cast to this tag type before being passed in to + // Rust to break circular dependencies. It's safe to cast it back here, as if + // some other type was passed in, something has gone horribly wrong. + auto fn = static_cast(fn_tag); + + auto resp = detail::AccessBridgeInternals::from_raw(raw_resp); + auto res = detail::AccessBridgeInternals::get(fn)(req, std::move(resp)); + + if (res) { + out_resp = detail::AccessBridgeInternals::get(*res).into_raw(); + return true; + } else { + return false; + } +} + +Processor::Processor(std::optional original_request_metadata, + Configuration config) + : processor_(fastly::sys::esi::m_static_esi_processor_new( + // The Rust side will take ownership + original_request_metadata.has_value() + ? detail::AccessBridgeInternals::get(*original_request_metadata) + .into_raw() + : nullptr, + static_cast(config.get_namespace()), + config.is_escaped_content())) {} + +tl::expected Processor::process_response( + Response &src_document, std::optional client_response_metadata, + std::optional dispatch_fragment_request, + std::optional process_fragment_response) { + fastly::sys::error::FastlyError *err; + // The Rust side will take ownership + auto raw_metadata = + client_response_metadata.has_value() + ? detail::AccessBridgeInternals::get(*client_response_metadata) + .into_raw() + : nullptr; + + // We convert the callbacks to their tag types here, or pass null if not + // present. They will be converted back to their real types when the C++ + // callback bindings are invoked from Rust. + detail::rust_bridge_tags::esi::DispatchFragmentRequestFnTag + *dispatch_fragment_tag = + dispatch_fragment_request.has_value() ? &*dispatch_fragment_request + : nullptr; + detail::rust_bridge_tags::esi::ProcessFragmentResponseFnTag + *process_fragment_tag = + process_fragment_response.has_value() ? &*process_fragment_response + : nullptr; + + bool success = fastly::sys::esi::m_esi_processor_process_response( + std::move(processor_), *detail::AccessBridgeInternals::get(src_document), + raw_metadata, dispatch_fragment_tag, process_fragment_tag, err); + if (success) { + return {}; + } else { + return tl::unexpected(error::FastlyError(err)); + } +} + +tl::expected Processor::process_document( + const std::string &src_document, + std::optional dispatch_fragment_request, + std::optional process_fragment_response) { + fastly::sys::error::FastlyError *err; + // We convert the callbacks to their tag types here, or pass null if not + // present. They will be converted back to their real types when the C++ + // callback bindings are invoked from Rust. + detail::rust_bridge_tags::esi::DispatchFragmentRequestFnTag + *dispatch_fragment_tag = + dispatch_fragment_request.has_value() ? &*dispatch_fragment_request + : nullptr; + detail::rust_bridge_tags::esi::ProcessFragmentResponseFnTag + *process_fragment_tag = + process_fragment_response.has_value() ? &*process_fragment_response + : nullptr; + + std::string out; + bool success = fastly::sys::esi::m_esi_processor_process_document( + std::move(processor_), src_document, dispatch_fragment_tag, + process_fragment_tag, out, err); + if (success) { + return out; + } else { + return tl::unexpected(error::FastlyError(err)); + } +} +} // namespace fastly::esi \ No newline at end of file diff --git a/src/cpp/log.cpp b/src/cpp/log.cpp index 2e62853..4032e52 100644 --- a/src/cpp/log.cpp +++ b/src/cpp/log.cpp @@ -21,7 +21,7 @@ fastly::expected Endpoint::from_name(std::string_view name) { fastly::sys::error::FastlyError *err; fastly::sys::log::m_static_log_endpoint_try_from_name( static_cast(name), out, err); - if (err != nullptr) { + if (err == nullptr) { return FSLY_BOX(log, Endpoint, out); } else { return fastly::unexpected(err); diff --git a/src/error.rs b/src/error.rs index c15b4c5..c6fc257 100644 --- a/src/error.rs +++ b/src/error.rs @@ -59,6 +59,8 @@ pub enum FastlyError { SecretStoreLookupError(#[from] fastly::secret_store::LookupError), #[error(transparent)] LogError(#[from] fastly::log::LogError), + #[error(transparent)] + ESIError(#[from] esi::ExecutionError), // Make sure to add any new variants to the `FastlyErrorCode` enum in `lib.rs` _and_ to the match below! } @@ -111,6 +113,7 @@ impl FastlyError { FastlyError::SecretStoreOpenError(_) => FastlyErrorCode::SecretStoreOpenError, FastlyError::SecretStoreLookupError(_) => FastlyErrorCode::SecretStoreLookupError, FastlyError::LogError(_) => FastlyErrorCode::LogError, + FastlyError::ESIError(_) => FastlyErrorCode::ESIError, } } } diff --git a/src/esi.rs b/src/esi.rs new file mode 100644 index 0000000..a55d154 --- /dev/null +++ b/src/esi.rs @@ -0,0 +1,163 @@ +use std::{ + pin::{Pin, pin}, + ptr, +}; + +use cxx::CxxString; +use esi::Configuration; + +use crate::{ + error::FastlyError, + ffi::{ + DispatchFragmentRequestFnResult, DispatchFragmentRequestFnTag, ProcessFragmentResponseFnTag, + }, + http::{request::Request, response::Response}, + try_fe, +}; + +pub(crate) struct Processor(esi::Processor); + +type DispatchFragmentRequestFnType = + dyn Fn(fastly::Request) -> Result; +fn shim_dispatch_fragment_request_fn( + func: *const DispatchFragmentRequestFnTag, +) -> Option> { + if func.is_null() { + return None; + } + let shim = Box::new(move |req| { + let mut out_pending = pin!(ptr::null_mut()); + let mut out_completed = pin!(ptr::null_mut()); + let result = unsafe { + crate::manual_ffi::fastly_esi_manualbridge_DispatchFragmentRequestFn_call( + func, + Box::into_raw(Box::new(Request(req))), + &mut out_pending, + &mut out_completed, + ) + }; + match result { + DispatchFragmentRequestFnResult::PendingRequest => { + Ok(unsafe { esi::PendingFragmentContent::PendingRequest(out_pending.read().0) }) + } + DispatchFragmentRequestFnResult::CompletedRequest => { + Ok( + unsafe { + esi::PendingFragmentContent::CompletedRequest(out_completed.read().0) + }, + ) + } + DispatchFragmentRequestFnResult::NoContent => { + Ok(esi::PendingFragmentContent::NoContent) + } + DispatchFragmentRequestFnResult::Error => Err(esi::ExecutionError::FunctionError( + "dispatch_fragment_request".into(), + )), + _ => unreachable!(), + } + }); + Some(shim) +} + +type ProcessFragmentResponseFnType = + dyn Fn(&mut fastly::Request, fastly::Response) -> Result; +fn shim_process_fragment_response_fn( + func: *const ProcessFragmentResponseFnTag, +) -> Option> { + if func.is_null() { + return None; + } + let shim = Box::new(move |req: &mut fastly::Request, resp| { + let mut out_resp = pin!(ptr::null_mut()); + let result = unsafe { + crate::manual_ffi::fastly_esi_manualbridge_ProcessFragmentResponseFn_call( + func, + // Ideally we wouldn't do this and would instead pass a reference, but + // that would require a separate wrapper type for Request references. + Box::into_raw(Box::new(Request(req.clone_with_body()))), + Box::into_raw(Box::new(Response(resp))), + &mut out_resp, + ) + }; + match result { + true => Ok(unsafe { out_resp.read().0 }), + false => Err(esi::ExecutionError::FunctionError( + "process_fragment_response".into(), + )), + } + }); + Some(shim) +} +pub fn m_esi_processor_process_response( + processor: Box, + src_document: &mut Response, + client_response_metadata: *mut Response, + dispatch_fragment_request: *const DispatchFragmentRequestFnTag, + process_fragment_response: *const ProcessFragmentResponseFnTag, + mut err: Pin<&mut *mut FastlyError>, +) -> bool { + let client_response_metadata = if client_response_metadata.is_null() { + None + } else { + // Make sure to take ownership, as this pointer is modelling an Optional> + Some(unsafe { Box::from_raw(client_response_metadata) }) + }; + try_fe!( + err, + processor + .0 + .process_response( + &mut src_document.0, + client_response_metadata.map(|r| r.0), + shim_dispatch_fragment_request_fn(dispatch_fragment_request).as_deref(), + shim_process_fragment_response_fn(process_fragment_response).as_deref(), + ) + .map_err(FastlyError::ESIError) + ); + true +} + +pub fn m_esi_processor_process_document( + processor: Box, + src_document: &CxxString, + dispatch_fragment_request: *const DispatchFragmentRequestFnTag, + process_fragment_response: *const ProcessFragmentResponseFnTag, + out: Pin<&mut CxxString>, + mut err: Pin<&mut *mut FastlyError>, +) -> bool { + let doc_str = try_fe!(err, src_document.to_str().map_err(FastlyError::Utf8Error)); + let reader = quick_xml::reader::Reader::from_str(doc_str); + let mut writer = quick_xml::Writer::new(out); + try_fe!( + err, + processor + .0 + .process_document( + reader, + &mut writer, + shim_dispatch_fragment_request_fn(dispatch_fragment_request).as_deref(), + shim_process_fragment_response_fn(process_fragment_response).as_deref(), + ) + .map_err(FastlyError::ESIError) + ); + true +} + +pub fn m_static_esi_processor_new( + original_request_metadata: *mut Request, + namespace: &CxxString, + is_escaped_content: bool, +) -> Box { + let original_request_metadata = if original_request_metadata.is_null() { + None + } else { + // Make sure to take ownership, as this pointer is modelling an Optional> + Some(unsafe { Box::from_raw(original_request_metadata) }) + }; + Box::new(Processor(esi::Processor::new( + original_request_metadata.map(|r| r.0), + Configuration::default() + .with_escaped(is_escaped_content) + .with_namespace(namespace.to_string()), + ))) +} diff --git a/src/lib.rs b/src/lib.rs index 2f6d456..0fbd5e1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ use backend::*; use config_store::*; use device_detection::*; use error::*; +use esi::*; use geo::*; use http::{ body::*, header::*, purge::*, request::request::*, request::*, response::*, status_code::*, @@ -18,6 +19,7 @@ mod backend; mod config_store; mod device_detection; mod error; +mod esi; mod geo; mod http; mod kv_store; @@ -49,6 +51,7 @@ mod ffi { SecretStoreOpenError, SecretStoreLookupError, LogError, + ESIError, } #[namespace = "fastly::sys::http"] @@ -220,6 +223,15 @@ mod ffi { Trace = 5, } + #[namespace = "fastly::sys::esi"] + #[repr(usize)] + pub enum DispatchFragmentRequestFnResult { + Error = 0, + PendingRequest = 1, + CompletedRequest = 2, + NoContent = 3, + } + #[namespace = "fastly::sys::error"] extern "Rust" { type FastlyError; @@ -1018,4 +1030,75 @@ mod ffi { mut err: Pin<&mut *mut KVStoreError>, ) -> bool; } + + // These tag types are empty types used to communicate callbacks from C++ to Rust. + // They will be cast back to the real callback types on the C++ side. + #[namespace = "fastly::detail::rust_bridge_tags::esi"] + unsafe extern "C++" { + include!("fastly/detail/rust_bridge_tags.h"); + type DispatchFragmentRequestFnTag; + type ProcessFragmentResponseFnTag; + } + + #[namespace = "fastly::sys::esi"] + extern "Rust" { + type Processor; + pub unsafe fn m_esi_processor_process_response( + processor: Box, + src_document: &mut Response, + // SAFETY: this parameter models an Option>, but CXX does not + // support this type directly. Care must be taken to take ownership of the pointer + // and free it if it is non-null. + client_response_metadata: *mut Response, + dispatch_fragment_request: *const DispatchFragmentRequestFnTag, + process_fragment_response: *const ProcessFragmentResponseFnTag, + mut err: Pin<&mut *mut FastlyError>, + ) -> bool; + pub unsafe fn m_esi_processor_process_document( + processor: Box, + src_document: &CxxString, + dispatch_fragment_request: *const DispatchFragmentRequestFnTag, + process_fragment_response: *const ProcessFragmentResponseFnTag, + out: Pin<&mut CxxString>, + mut err: Pin<&mut *mut FastlyError>, + ) -> bool; + pub unsafe fn m_static_esi_processor_new( + // SAFETY: this parameter models an Option>, but CXX does not + // support this type directly. Care must be taken to take ownership of the pointer + // and free it if it is non-null. + original_request_metadata: *mut Request, + namespc: &CxxString, + is_escaped_content: bool, + ) -> Box; + } +} + +// Some types (notably callback functions) are not supported by CXX at all, so we +// define manual FFI bindings for them here. +mod manual_ffi { + use crate::ffi::{ + DispatchFragmentRequestFnResult, DispatchFragmentRequestFnTag, ProcessFragmentResponseFnTag, + }; + + // We never rely on the layout of Rust types passed to these functions, + // so we can ignore the improper_ctypes warning. + #[allow(improper_ctypes)] + unsafe extern "C" { + // The link names here must exactly match the names of the extern "C" functions defined on the C++ side + #[link_name = "fastly$esi$manualbridge$DispatchFragmentRequestFn$call"] + pub(crate) fn fastly_esi_manualbridge_DispatchFragmentRequestFn_call( + func: *const DispatchFragmentRequestFnTag, + req: *mut crate::Request, + out_pending: &mut *mut crate::PendingRequest, + out_complete: &mut *mut crate::Response, + ) -> DispatchFragmentRequestFnResult; + + #[link_name = "fastly$esi$manualbridge$ProcessFragmentResponseFn$call"] + pub(crate) fn fastly_esi_manualbridge_ProcessFragmentResponseFn_call( + func: *const ProcessFragmentResponseFnTag, + request: *mut crate::Request, + response: *mut crate::Response, + out_response: &mut *mut crate::Response, + ) -> bool; + } } diff --git a/test/esi.cpp b/test/esi.cpp new file mode 100644 index 0000000..e714351 --- /dev/null +++ b/test/esi.cpp @@ -0,0 +1,179 @@ +#include +#include +#include +#include +#include +#include +#include + +using namespace fastly::esi; +using fastly::http::Request; +using fastly::http::Response; + +TEST_CASE("Configuration default and custom values") { + Configuration def; + REQUIRE(def.get_namespace() == "esi"); + REQUIRE(def.is_escaped_content()); + + Configuration custom("foo", false); + REQUIRE(custom.get_namespace() == "foo"); + REQUIRE_FALSE(custom.is_escaped_content()); +} + +std::string_view html = R"( + + + My Shopping Website + + +
+

My Shopping Website

+
+
+ + + + +
+ + +)"; + +#include +TEST_CASE("Dispatch fragment callback works") { + fastly::esi::Processor processor; + + auto dispatch_fragment_request = [](fastly::http::Request req) + -> std::optional { + auto pending = req.send_async("esi-cpp-demo"); + if (pending) { + return fastly::esi::PendingFragmentContent{std::move(*pending)}; + } else { + return std::nullopt; + } + }; + + auto result = processor.process_document( + std::string(html), dispatch_fragment_request, std::nullopt); + REQUIRE(result.has_value()); + + std::string_view expected = R"( + + + My Shopping Website + + +
+

My Shopping Website

+
+
+ + +
+
+ + + +
+

This is the page content.

+
+
+
+ + + +
+

This is the page content.

+
+ +
+ + +)"; + REQUIRE(*result == expected); +} + +TEST_CASE("Process response callback works") { + fastly::esi::Processor processor; + + auto dispatch_fragment_request = [](fastly::http::Request req) + -> std::optional { + auto pending = req.send_async("esi-cpp-demo"); + if (pending) { + return fastly::esi::PendingFragmentContent{std::move(*pending)}; + } else { + return std::nullopt; + } + }; + auto process_response = + [](fastly::http::Request &, + fastly::http::Response) -> std::optional { + return fastly::http::Response::from_body( + fastly::http::Body("")); + }; + + auto result = processor.process_document( + std::string(html), dispatch_fragment_request, process_response); + REQUIRE(result.has_value()); + + std::string_view expected = R"( + + + My Shopping Website + + +
+

My Shopping Website

+
+
+ + + + +
+ + +)"; + REQUIRE(*result == expected); +} + +TEST_CASE("Return error from dispatch fragment callback fails processing") { + fastly::esi::Processor processor; + + auto dispatch_fragment_request = [](fastly::http::Request) + -> std::optional { + return std::nullopt; + }; + + auto result = processor.process_document( + std::string(html), dispatch_fragment_request, std::nullopt); + REQUIRE_FALSE(result.has_value()); +} + +TEST_CASE("Return error from process response callback fails processing") { + fastly::esi::Processor processor; + + auto dispatch_fragment_request = [](fastly::http::Request req) + -> std::optional { + auto pending = req.send_async("esi-cpp-demo"); + if (pending) { + return fastly::esi::PendingFragmentContent{std::move(*pending)}; + } else { + return std::nullopt; + } + }; + auto process_response = [](fastly::http::Request &, fastly::http::Response) + -> std::optional { return std::nullopt; }; + + auto result = processor.process_document( + std::string(html), dispatch_fragment_request, process_response); + REQUIRE_FALSE(result.has_value()); +} + +// Required due to https://github.com/WebAssembly/wasi-libc/issues/485 +#include +int main(int argc, char *argv[]) { return Catch::Session().run(argc, argv); } \ No newline at end of file diff --git a/test/fastly.toml b/test/fastly.toml index e088133..3049518 100644 --- a/test/fastly.toml +++ b/test/fastly.toml @@ -11,6 +11,7 @@ service_id = "" [local_server] backends.fastly.url = "https://www.fastly.com" backends.wikipedia.url = "https://en.wikipedia.org" +backends.esi-cpp-demo.url = "https://esi-cpp-demo.edgecompute.app" [local_server.kv_stores] test-store = { file = 'kv_store.json', format = 'json' }