Skip to content

Commit 39b4174

Browse files
authored
Add PlatformHttpClient and PlatformBackend traits (EdgeZero PR6) (#581)
* Rename crates to trusted-server-core and trusted-server-adapter-fastly Rename crates/common → crates/trusted-server-core and crates/fastly → crates/trusted-server-adapter-fastly following the EdgeZero naming convention. Add EdgeZero workspace dependencies pinned to rev 170b74b. Update all references across docs, CI workflows, scripts, agent files, and configuration. * Add platform abstraction layer with traits and RuntimeServices Introduces trusted-server-core::platform with PlatformConfigStore, PlatformSecretStore, PlatformKvStore, PlatformBackend, PlatformHttpClient, and PlatformGeo traits alongside ClientInfo, PlatformError, and RuntimeServices. Wires the Fastly adapter implementations and threads RuntimeServices into route_request. Moves GeoInfo to platform/types as platform-neutral data and adds geo_from_fastly for field mapping. * Address platform layer review feedback - Defer KV store opening: replace early error return with a local UnavailableKvStore fallback so routes that do not need synthetic ID access succeed when the KV store is missing or temporarily unavailable - Use ConfigStore::try_open + try_get and SecretStore::try_get throughout FastlyPlatformConfigStore and FastlyPlatformSecretStore to honour the Result contract instead of panicking on open/lookup failure - Encapsulate RuntimeServices service fields as pub(crate) with public getter methods (config_store, secret_store, backend, http_client, geo) and a pub new() constructor; adapter updated to use new() - Reference #487 in FastlyPlatformHttpClient stub (PR 6 implements it) - Remove unused KvPage re-export from platform/mod.rs - Use super::KvHandle shorthand in RuntimeServices::kv_handle() * Reject host strings containing control characters in BackendConfig * Fix clippy error * Validate scheme and host for control characters in BackendConfig * Address review findings on platform abstraction layer * Address review findings on platform abstraction layer * Add config store read path and storage module split - Split fastly_storage.rs into storage/{config_store,secret_store,api_client,mod}.rs - Add PlatformConfigStore read path via FastlyPlatformConfigStore::get using ConfigStore::try_open/try_get - Add PlatformError::NotImplemented variant; stub write methods on FastlyPlatformConfigStore and FastlyPlatformSecretStore - Add StoreName/StoreId newtypes with From<String>, From<&str>, AsRef<str> - Add UnavailableKvStore to core platform module - Add RuntimeServicesBuilder replacing 7-arg constructor - Migrate get_active_jwks and handle_trusted_server_discovery to use &RuntimeServices - Update call sites in signing.rs, rotation.rs, main.rs - Add success-path test for handle_trusted_server_discovery using StubJwksConfigStore - Fix test_parse_cookies_to_jar_empty typo (was emtpy) * Harden legacy config-store reads and align Fastly adapter stubs * Address storage review feedback * Resolved github-advanced-security bot problems * Address PR review feedback on platform abstraction layer - Make StoreName and StoreId inner fields private; From/AsRef provide all needed construction and access - Add #[deprecated] to GeoInfo::from_request with #[allow(deprecated)] at the three legacy call sites to track migration progress - Enumerate the six platform traits in the platform module doc comment - Extract backend_config_from_spec helper to remove duplicate BackendConfig construction in predict_name and ensure - Replace .into_iter().collect() with .to_vec() on secret plaintext bytes - Remove unused bytes dependency from trusted-server-adapter-fastly - Add comment on SecretStore::open clarifying it already returns Result (unlike ConfigStore::open which panics) * Add PR 4 design spec for secret store trait (read-only) * Clarify test scope and deferred branches in PR 4 spec * Add implementation plan for PR 4 secret store trait * Add test for get_secret_bytes open-failure path * Add NotImplemented tests for FastlyPlatformSecretStore write stubs * Inline StoreId binding and add section comment in write-stub tests * Remove plan * Add PR 6 design spec for backend and HTTP client traits * Address spec review findings on PR 6 design * Implement PlatformHttpClient and thread RuntimeServices through proxy layer - Add PlatformHttpClient trait with send(), send_async(), and select() methods - Add PlatformBackend trait with predict_name() and ensure() methods - Add PlatformResponse wrapper around EdgeZero HTTP responses - Add PlatformPendingRequest and PlatformSelectResult for auction fan-out - Thread RuntimeServices through IntegrationProxy::handle(), IntegrationRegistry::handle_proxy(), and all first-party proxy endpoints so handlers can reach the HTTP client - Add StubHttpClient and StubBackend test stubs with build_services_with_http_client helper - Add proxy_request_calls_platform_http_client_send integration test - Fix proxy_with_redirects to stay within 7-arg clippy limit via ProxyRequestHeaders struct - Document Body::Stream limitation in edge_request_to_fastly with warning log - Document intentional duplication of platform_response_to_fastly across proxy and orchestrator - Remove spec file (promoted to plan + implementation) * Address pr review findings * Resolve pr review findings * Fix ci test and format failure * Address review findings * Address PR review findings * Address review findings * Extract client IP and TLS info once at adapter boundary (PR7) (#599) * Add PR7 design spec for geo lookup + client info extract-once Documents the call site migration plan: five Fastly SDK extraction points in trusted-server-core replaced by RuntimeServices::client_info reads, following Phase 1 injection pattern from the EdgeZero migration design. * Fix spec review issues in PR7 design doc - Correct erroneous claim about generate_synthetic_id being called twice via DeviceInfo; it is called once (line 91 for fresh_id), DeviceInfo.ip is a separate req.get_client_ip_addr() call fixed independently - Add before/after snippet for handle_publisher_request call site in main.rs - Add noop_services import instruction for http_util.rs test module - Clarify _services rename (drop underscore, not add new param) in didomi.rs - Clarify nextjs #[allow(deprecated)] annotations are out of scope (different function) * Update PR7 spec to address all five agent review findings - Change RequestInfo::from_request signature to &ClientInfo (not &RuntimeServices) so prebid can call it with context.client_info - Scope SDK-call acceptance criteria to active non-deprecated code only - List all six AuctionContext construction sites including two production sites in orchestrator.rs and three test helpers in orchestrator/prebid - Add explicit warn-and-continue pattern for publisher.rs geo lookup - Correct testing table: formats.rs and endpoints.rs have no test modules; add orchestrator.rs and prebid.rs test helper update rows * Add PR7 implementation plan and address plan review findings Plan covers 6 tasks in compilation-safe order: AuctionContext struct change first, then from_request signature, then synthetic.rs cascade, then publisher geo, then didomi. Includes two new copy_headers unit tests (Some/None). Spec fixes: clarify injection pattern exceptions for &ClientInfo and Option<IpAddr>; reword acceptance criterion to reflect that provider-layer reads flow through AuctionContext.client_info. * Fix three plan review findings and two open questions - Finding 1 (High): Add missing publisher.rs test call site at line ~695 for get_or_generate_synthetic_id — was omitted from Task 3 Step 6 - Finding 2 (Medium): Remove crate::geo::GeoInfo import from endpoints.rs rather than replacing it — type is not used by name after the change, keeping any import fails clippy -D warnings - Finding 3 (Low): Replace interactive git add -p in Task 6 with explicit file staging instruction - Open Q1: Add Task 2 step to update stale handle_publisher_request signature in auction/README.md - Open Q2: Add Task 2 step to update from_request doc comment to reflect ClientInfo-based TLS detection instead of Fastly SDK calls * Broaden two low-severity doc cleanup steps in PR7 plan - Step 7: cover all four stale Fastly-SDK-specific locations in http_util.rs (SPOOFABLE_FORWARDED_HEADERS doc, RequestInfo struct doc, from_request doc, detect_request_scheme doc) - Step 8: replace the whole routing snippet in auction/README.md, not just the one handle_publisher_request line — handle_auction and integration_registry.handle_proxy are also stale in that snippet * Fix two remaining low findings in PR7 plan - Add missing Location 2 (RequestInfo.scheme field doc, line ~67) to Step 7; renumber subsequent locations 3-5 - Replace &runtime_services with runtime_services in Step 5 and README snippet — runtime_services is already &RuntimeServices in route_request * Fix count drift in Step 7: four → five locations * Add client_info field to AuctionContext and fix all construction sites * Change RequestInfo::from_request to take &ClientInfo, thread services into handle_publisher_request * Add Task 2 follow-up coverage and README route fixes * Add services param to generate_synthetic_id, remove Fastly IP/geo calls in formats and endpoints * Revert premature publisher geo change from Task 3 * Replace deprecated GeoInfo::from_request in publisher.rs with services.geo().lookup() * Remove Fastly IP extraction from Didomi copy_headers, use ClientInfo instead * Move IpAddr import to test module level in didomi.rs * Apply rustfmt formatting to didomi.rs, publisher.rs, and synthetic.rs Fix multi-line function call style in didomi.rs, line-break wrapping in publisher.rs test, and import ordering in synthetic.rs test module. * Add test coverage for generate_synthetic_id with concrete client IP Adds noop_services_with_client_ip helper to test_support and a new test that verifies the client_ip path through generate_synthetic_id by asserting the HMAC differs when the IP changes. * Align geo lookup warn log format with codebase convention ({e} not {e:?}) * Apply Prettier formatting to PR7 plan and spec docs * Verify content rewriting pipeline is platform-agnostic (PR 8) (#600) * Document content rewriting as platform-agnostic in platform module * Document html_processor as platform-agnostic * Document streaming_processor as platform-agnostic * Fix unresolved doc link: replace EdgeRequest with edgezero_core::http::Request - Fix intra-doc link syntax and restore missing blank line in `html_processor` - Replace opaque PR number references with descriptive context labels - Move HTTP-type coupling caveat from `platform` module down to `publisher.rs` - Convert `StreamingPipeline::process` plain-text generics to an intra-doc link
1 parent 0c2e401 commit 39b4174

31 files changed

Lines changed: 3578 additions & 315 deletions

crates/trusted-server-adapter-fastly/src/main.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -161,16 +161,20 @@ async fn route_request(
161161
}
162162

163163
// tsjs endpoints
164-
(Method::GET, "/first-party/proxy") => handle_first_party_proxy(settings, req).await,
165-
(Method::GET, "/first-party/click") => handle_first_party_click(settings, req).await,
164+
(Method::GET, "/first-party/proxy") => {
165+
handle_first_party_proxy(settings, runtime_services, req).await
166+
}
167+
(Method::GET, "/first-party/click") => {
168+
handle_first_party_click(settings, runtime_services, req).await
169+
}
166170
(Method::GET, "/first-party/sign") | (Method::POST, "/first-party/sign") => {
167-
handle_first_party_proxy_sign(settings, req).await
171+
handle_first_party_proxy_sign(settings, runtime_services, req).await
168172
}
169173
(Method::POST, "/first-party/proxy-rebuild") => {
170-
handle_first_party_proxy_rebuild(settings, req).await
174+
handle_first_party_proxy_rebuild(settings, runtime_services, req).await
171175
}
172176
(m, path) if integration_registry.has_route(&m, path) => integration_registry
173-
.handle_proxy(&m, path, settings, req)
177+
.handle_proxy(&m, path, settings, runtime_services, req)
174178
.await
175179
.unwrap_or_else(|| {
176180
Err(Report::new(TrustedServerError::BadRequest {

crates/trusted-server-adapter-fastly/src/platform.rs

Lines changed: 251 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -187,45 +187,147 @@ impl PlatformBackend for FastlyPlatformBackend {
187187
}
188188

189189
// ---------------------------------------------------------------------------
190-
// FastlyPlatformHttpClient
190+
// FastlyPlatformHttpClient — helpers
191191
// ---------------------------------------------------------------------------
192192

193-
/// Placeholder Fastly implementation of [`PlatformHttpClient`].
193+
/// Convert a platform-neutral [`edgezero_core::http::Request`] to a [`fastly::Request`].
194+
///
195+
/// Only buffered `Body::Once` bodies are supported on this path.
194196
///
195-
/// The Fastly-backed `send` / `send_async` / `select` behavior lands in a
196-
/// follow-up PR once the orchestrator migration is complete. Until then all
197-
/// methods return [`PlatformError::Unsupported`].
197+
/// # Errors
198198
///
199-
/// Implementation lands in #487 (PR 6: Backend + HTTP client traits).
199+
/// Returns [`PlatformError::HttpClient`] when the request body is streaming.
200+
fn edge_request_to_fastly(
201+
request: edgezero_core::http::Request,
202+
) -> Result<fastly::Request, Report<PlatformError>> {
203+
let (parts, body) = request.into_parts();
204+
let mut fastly_req = fastly::Request::new(parts.method, parts.uri.to_string());
205+
for (name, value) in parts.headers.iter() {
206+
fastly_req.append_header(name.as_str(), value.as_bytes());
207+
}
208+
match body {
209+
edgezero_core::body::Body::Once(bytes) => {
210+
if !bytes.is_empty() {
211+
fastly_req.set_body(bytes.to_vec());
212+
}
213+
}
214+
edgezero_core::body::Body::Stream(_) => {
215+
return Err(Report::new(PlatformError::HttpClient)
216+
.attach("streaming request body is not supported by Fastly request conversion"));
217+
}
218+
}
219+
Ok(fastly_req)
220+
}
221+
222+
/// Convert a [`fastly::Response`] to a [`PlatformResponse`] with the given backend name.
223+
fn fastly_response_to_platform(
224+
mut resp: fastly::Response,
225+
backend_name: impl Into<String>,
226+
) -> Result<PlatformResponse, Report<PlatformError>> {
227+
let status = resp.get_status();
228+
let mut builder = edgezero_core::http::response_builder().status(status);
229+
for (name, value) in resp.get_headers() {
230+
builder = builder.header(name.as_str(), value.as_bytes());
231+
}
232+
let body_bytes = resp.take_body_bytes();
233+
let edge_response = builder
234+
.body(edgezero_core::body::Body::from(body_bytes))
235+
.change_context(PlatformError::HttpClient)?;
236+
Ok(PlatformResponse::new(edge_response).with_backend_name(backend_name))
237+
}
238+
239+
// ---------------------------------------------------------------------------
240+
// FastlyPlatformHttpClient
241+
// ---------------------------------------------------------------------------
242+
243+
/// Fastly implementation of [`PlatformHttpClient`].
244+
///
245+
/// - [`send`](PlatformHttpClient::send) — converts the platform request to a
246+
/// `fastly::Request`, calls `.send()`, and wraps the response.
247+
/// - [`send_async`](PlatformHttpClient::send_async) — same conversion but
248+
/// calls `.send_async()` and wraps the `fastly::PendingRequest`.
249+
/// - [`select`](PlatformHttpClient::select) — downcasts each
250+
/// [`PlatformPendingRequest`] back to `fastly::PendingRequest` and calls
251+
/// `fastly::http::request::select()`.
200252
pub struct FastlyPlatformHttpClient;
201253

202254
#[async_trait::async_trait(?Send)]
203255
impl PlatformHttpClient for FastlyPlatformHttpClient {
204256
async fn send(
205257
&self,
206-
_request: PlatformHttpRequest,
258+
request: PlatformHttpRequest,
207259
) -> Result<PlatformResponse, Report<PlatformError>> {
208-
log::warn!("FastlyPlatformHttpClient::send called before #487 lands");
209-
Err(Report::new(PlatformError::Unsupported)
210-
.attach("FastlyPlatformHttpClient::send is not yet implemented"))
260+
let backend_name = request.backend_name.clone();
261+
let fastly_req = edge_request_to_fastly(request.request)?;
262+
let fastly_resp = fastly_req
263+
.send(&backend_name)
264+
.change_context(PlatformError::HttpClient)?;
265+
fastly_response_to_platform(fastly_resp, backend_name)
211266
}
212267

213268
async fn send_async(
214269
&self,
215-
_request: PlatformHttpRequest,
270+
request: PlatformHttpRequest,
216271
) -> Result<PlatformPendingRequest, Report<PlatformError>> {
217-
log::warn!("FastlyPlatformHttpClient::send_async called before #487 lands");
218-
Err(Report::new(PlatformError::Unsupported)
219-
.attach("FastlyPlatformHttpClient::send_async is not yet implemented"))
272+
let backend_name = request.backend_name.clone();
273+
let fastly_req = edge_request_to_fastly(request.request)?;
274+
let pending = fastly_req
275+
.send_async(&backend_name)
276+
.change_context(PlatformError::HttpClient)?;
277+
Ok(PlatformPendingRequest::new(pending).with_backend_name(backend_name))
220278
}
221279

222280
async fn select(
223281
&self,
224-
_pending_requests: Vec<PlatformPendingRequest>,
282+
pending_requests: Vec<PlatformPendingRequest>,
225283
) -> Result<PlatformSelectResult, Report<PlatformError>> {
226-
log::warn!("FastlyPlatformHttpClient::select called before #487 lands");
227-
Err(Report::new(PlatformError::Unsupported)
228-
.attach("FastlyPlatformHttpClient::select is not yet implemented"))
284+
use fastly::http::request::{select, PendingRequest};
285+
286+
if pending_requests.is_empty() {
287+
return Err(Report::new(PlatformError::HttpClient)
288+
.attach("select called with an empty pending_requests list"));
289+
}
290+
291+
let mut fastly_pending: Vec<PendingRequest> = Vec::with_capacity(pending_requests.len());
292+
293+
for platform_req in pending_requests {
294+
let inner = platform_req.downcast::<PendingRequest>().map_err(|platform_req| {
295+
let backend_name = platform_req.backend_name().unwrap_or("<unknown>");
296+
Report::new(PlatformError::HttpClient).attach(format!(
297+
"PlatformPendingRequest inner type is not fastly::PendingRequest for backend '{backend_name}'"
298+
))
299+
})?;
300+
fastly_pending.push(inner);
301+
}
302+
303+
let (result, remaining_fastly) = select(fastly_pending);
304+
305+
// Fastly's select() does not preserve input order for remaining requests,
306+
// so positional backend-name re-association is unreliable. Backend names
307+
// are re-derived from get_backend_name() when each remaining request completes.
308+
let remaining: Vec<PlatformPendingRequest> = remaining_fastly
309+
.into_iter()
310+
.map(PlatformPendingRequest::new)
311+
.collect();
312+
313+
let ready = match result {
314+
Ok(fastly_resp) => {
315+
let backend_name = fastly_resp
316+
.get_backend_name()
317+
.unwrap_or_else(|| {
318+
log::warn!("select: response has no backend name, correlation will fail");
319+
""
320+
})
321+
.to_string();
322+
fastly_response_to_platform(fastly_resp, backend_name)
323+
}
324+
Err(e) => {
325+
Err(Report::new(PlatformError::HttpClient)
326+
.attach(format!("fastly select error: {e}")))
327+
}
328+
};
329+
330+
Ok(PlatformSelectResult { ready, remaining })
229331
}
230332
}
231333

@@ -296,9 +398,12 @@ pub fn open_kv_store(store_name: &str) -> Result<Arc<dyn PlatformKvStore>, KvErr
296398

297399
#[cfg(test)]
298400
mod tests {
401+
use std::io;
299402
use std::sync::Arc;
300403
use std::time::Duration;
301404

405+
use edgezero_core::body::Body;
406+
use edgezero_core::http::request_builder;
302407
use edgezero_core::key_value_store::NoopKvStore;
303408

304409
use super::*;
@@ -417,4 +522,132 @@ mod tests {
417522
"should preserve client_ip through clone"
418523
);
419524
}
525+
526+
// --- FastlyPlatformHttpClient -------------------------------------------
527+
528+
#[test]
529+
fn fastly_platform_http_client_send_returns_error_for_unregistered_backend() {
530+
let client = FastlyPlatformHttpClient;
531+
let request = request_builder()
532+
.method("GET")
533+
.uri("https://example.com/")
534+
.body(Body::empty())
535+
.expect("should build test request");
536+
let err = futures::executor::block_on(
537+
client.send(PlatformHttpRequest::new(request, "nonexistent-backend")),
538+
)
539+
.expect_err("should return error for unregistered backend");
540+
541+
assert!(
542+
matches!(err.current_context(), &PlatformError::HttpClient),
543+
"should be HttpClient error, got: {:?}",
544+
err.current_context()
545+
);
546+
}
547+
548+
#[test]
549+
fn fastly_platform_http_client_send_async_returns_error_for_unregistered_backend() {
550+
let client = FastlyPlatformHttpClient;
551+
let request = request_builder()
552+
.method("GET")
553+
.uri("https://example.com/")
554+
.body(Body::empty())
555+
.expect("should build test request");
556+
let err = futures::executor::block_on(
557+
client.send_async(PlatformHttpRequest::new(request, "nonexistent-backend")),
558+
)
559+
.expect_err("should return error for unregistered backend");
560+
561+
assert!(
562+
matches!(err.current_context(), &PlatformError::HttpClient),
563+
"should be HttpClient error, got: {:?}",
564+
err.current_context()
565+
);
566+
}
567+
568+
#[test]
569+
fn fastly_platform_http_client_select_returns_error_for_empty_list() {
570+
let client = FastlyPlatformHttpClient;
571+
let err = futures::executor::block_on(client.select(vec![]))
572+
.expect_err("should return error for empty pending list");
573+
574+
assert!(
575+
matches!(err.current_context(), &PlatformError::HttpClient),
576+
"should be HttpClient error, got: {:?}",
577+
err.current_context()
578+
);
579+
}
580+
581+
#[test]
582+
fn fastly_platform_http_client_select_returns_error_for_wrong_inner_type() {
583+
let client = FastlyPlatformHttpClient;
584+
// Wrap a non-PendingRequest type to trigger the downcast failure.
585+
let wrong = PlatformPendingRequest::new(42u32).with_backend_name("origin-a");
586+
let err = futures::executor::block_on(client.select(vec![wrong]))
587+
.expect_err("should return error for wrong inner type");
588+
589+
assert!(
590+
matches!(err.current_context(), &PlatformError::HttpClient),
591+
"should be HttpClient error, got: {:?}",
592+
err.current_context()
593+
);
594+
assert!(
595+
format!("{err:?}").contains("origin-a"),
596+
"should include backend name in error report: {err:?}"
597+
);
598+
}
599+
600+
#[test]
601+
fn fastly_platform_http_client_send_returns_error_for_streaming_body() {
602+
let client = FastlyPlatformHttpClient;
603+
let request = request_builder()
604+
.method("POST")
605+
.uri("https://example.com/")
606+
.body(Body::from_stream(futures::stream::empty::<
607+
Result<_, io::Error>,
608+
>()))
609+
.expect("should build streaming test request");
610+
611+
let err = futures::executor::block_on(
612+
client.send(PlatformHttpRequest::new(request, "nonexistent-backend")),
613+
)
614+
.expect_err("should reject streaming request bodies before sending");
615+
616+
assert!(
617+
matches!(err.current_context(), &PlatformError::HttpClient),
618+
"should be HttpClient error, got: {:?}",
619+
err.current_context()
620+
);
621+
assert!(
622+
format!("{err:?}").contains("streaming request body"),
623+
"should describe the unsupported streaming body: {err:?}"
624+
);
625+
}
626+
627+
#[test]
628+
fn fastly_platform_http_client_send_async_returns_error_for_streaming_body() {
629+
let client = FastlyPlatformHttpClient;
630+
let request = request_builder()
631+
.method("POST")
632+
.uri("https://example.com/")
633+
.body(Body::from_stream(futures::stream::empty::<
634+
Result<_, io::Error>,
635+
>()))
636+
.expect("should build streaming test request");
637+
638+
let err = futures::executor::block_on(
639+
client.send_async(PlatformHttpRequest::new(request, "nonexistent-backend")),
640+
)
641+
.expect_err("should reject streaming request bodies before launching async send");
642+
643+
assert!(
644+
matches!(err.current_context(), &PlatformError::HttpClient),
645+
"should be HttpClient error, got: {:?}",
646+
err.current_context()
647+
);
648+
assert!(
649+
format!("{err:?}").contains("streaming request body"),
650+
"should describe the unsupported streaming body: {err:?}"
651+
);
652+
}
420653
}

0 commit comments

Comments
 (0)