diff --git a/crates/apl-cmf/src/capability_namespaces.rs b/crates/apl-cmf/src/capability_namespaces.rs index ac50f98f..7bbdbe80 100644 --- a/crates/apl-cmf/src/capability_namespaces.rs +++ b/crates/apl-cmf/src/capability_namespaces.rs @@ -146,6 +146,11 @@ const TABLE: &[CapabilityEntry] = &[ prefixes: &[ BAG_HTTP_REQUEST_HEADERS_PREFIX, BAG_HTTP_RESPONSE_HEADERS_PREFIX, + // The request line rides the same capability as headers. + BAG_HTTP_METHOD, + BAG_HTTP_PATH, + BAG_HTTP_HOST, + BAG_HTTP_SCHEME, ], }, CapabilityEntry { diff --git a/crates/apl-cmf/src/constants.rs b/crates/apl-cmf/src/constants.rs index 276fb4a6..85e0f096 100644 --- a/crates/apl-cmf/src/constants.rs +++ b/crates/apl-cmf/src/constants.rs @@ -105,6 +105,13 @@ pub const BAG_META_PREFIX: &str = "meta."; pub const BAG_REQUEST_PREFIX: &str = "request."; pub const BAG_HTTP_REQUEST_HEADERS_PREFIX: &str = "http.request_headers."; pub const BAG_HTTP_RESPONSE_HEADERS_PREFIX: &str = "http.response_headers."; +// HTTP request line — exact keys. These ride the same `read_headers` +// capability as headers (the whole `http` slot is gated together in +// `cpex-core::extensions::filter`). +pub const BAG_HTTP_METHOD: &str = "http.method"; +pub const BAG_HTTP_PATH: &str = "http.path"; +pub const BAG_HTTP_HOST: &str = "http.host"; +pub const BAG_HTTP_SCHEME: &str = "http.scheme"; pub const BAG_LLM_PREFIX: &str = "llm."; pub const BAG_MCP_PREFIX: &str = "mcp."; pub const BAG_COMPLETION_PREFIX: &str = "completion."; diff --git a/crates/apl-cmf/src/http.rs b/crates/apl-cmf/src/http.rs index a28fdf9e..b0950bb4 100644 --- a/crates/apl-cmf/src/http.rs +++ b/crates/apl-cmf/src/http.rs @@ -10,13 +10,31 @@ // to remember the original case. // // Namespace: +// http.method : String (request line) +// http.path : String +// http.host : String +// http.scheme : String // http.request_headers. : String (lowercased name) // http.response_headers. : String (lowercased name) use apl_core::AttributeBag; use cpex_core::extensions::HttpExtension; +use crate::constants::{BAG_HTTP_HOST, BAG_HTTP_METHOD, BAG_HTTP_PATH, BAG_HTTP_SCHEME}; + pub fn extract_http(http: &HttpExtension, bag: &mut AttributeBag) { + if let Some(method) = &http.method { + bag.set(BAG_HTTP_METHOD.to_string(), method.clone()); + } + if let Some(path) = &http.path { + bag.set(BAG_HTTP_PATH.to_string(), path.clone()); + } + if let Some(host) = &http.host { + bag.set(BAG_HTTP_HOST.to_string(), host.clone()); + } + if let Some(scheme) = &http.scheme { + bag.set(BAG_HTTP_SCHEME.to_string(), scheme.clone()); + } for (k, v) in &http.request_headers { bag.set( format!("http.request_headers.{}", k.to_lowercase()), @@ -35,6 +53,31 @@ pub fn extract_http(http: &HttpExtension, bag: &mut AttributeBag) { mod tests { use super::*; + #[test] + fn request_line_surfaced_in_bag() { + let http = HttpExtension { + method: Some("POST".to_string()), + path: Some("/api/widgets".to_string()), + host: Some("api.example.com".to_string()), + scheme: Some("https".to_string()), + ..Default::default() + }; + let mut bag = AttributeBag::new(); + extract_http(&http, &mut bag); + assert_eq!(bag.get_string("http.method"), Some("POST")); + assert_eq!(bag.get_string("http.path"), Some("/api/widgets")); + assert_eq!(bag.get_string("http.host"), Some("api.example.com")); + assert_eq!(bag.get_string("http.scheme"), Some("https")); + } + + #[test] + fn request_line_absent_when_unset() { + let http = HttpExtension::default(); + let mut bag = AttributeBag::new(); + extract_http(&http, &mut bag); + assert_eq!(bag.get_string("http.method"), None); + } + #[test] fn headers_lowercased_in_bag() { let mut http = HttpExtension::default(); diff --git a/crates/apl-cmf/src/lib.rs b/crates/apl-cmf/src/lib.rs index 47c63a48..cb71f3dc 100644 --- a/crates/apl-cmf/src/lib.rs +++ b/crates/apl-cmf/src/lib.rs @@ -29,7 +29,8 @@ // AgentExtension → agent.* (session, conversation, lineage) // MetaExtension → meta.* // RequestExtension → request.* -// HttpExtension → http.request_headers.*, http.response_headers.* +// HttpExtension → http.method, http.path, http.host, http.scheme, +// http.request_headers.*, http.response_headers.* // LLMExtension → llm.* // MCPExtension → mcp.tool.*, mcp.resource.*, mcp.prompt.* // CompletionExtension → completion.* diff --git a/crates/apl-core/src/lib.rs b/crates/apl-core/src/lib.rs index 48d12e7c..e4ed02e5 100644 --- a/crates/apl-core/src/lib.rs +++ b/crates/apl-core/src/lib.rs @@ -36,7 +36,8 @@ pub use plugin_decl::{ }; pub use route::{evaluate_post, evaluate_pre, evaluate_route, RouteDecision, RoutePayload}; pub use rules::{ - CompareOp, CompiledRoute, Condition, Effect, Expression, Literal, Phase, PhaseSet, Rule, + CompareOp, CompiledRoute, Condition, DenyResponse, Effect, Expression, Literal, Phase, + PhaseSet, Rule, }; pub use step::{ delegation_bag_keys, DelegateStep, DelegationError, DelegationInvoker, DelegationOutcome, diff --git a/crates/apl-core/src/rules.rs b/crates/apl-core/src/rules.rs index 557765fa..50247bdc 100644 --- a/crates/apl-core/src/rules.rs +++ b/crates/apl-core/src/rules.rs @@ -405,6 +405,25 @@ impl PhaseSet { } } +/// Custom response to attach when a route's policy denies — the +/// transpiled form of a Kuadrant `AuthPolicy` `response.unauthorized` +/// `denyWith`. Carried on the route and surfaced on the deny outcome's +/// `details` map by the host (apl-cpex), so a host can render a custom +/// HTTP response. All fields optional; an absent block leaves the host's +/// default denial response unchanged. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct DenyResponse { + /// HTTP status to use for the denial (e.g. 403, 302). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + /// Response body. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body: Option, + /// Response headers (e.g. `Location` for a redirect, `WWW-Authenticate`). + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub headers: std::collections::BTreeMap, +} + /// Compiler output for a single route. /// /// One `CompiledRoute` per route_key. The compiler merges global / default / @@ -434,6 +453,11 @@ pub struct CompiledRoute { /// hooks/kind/source always come from the global declaration. #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] pub plugin_overrides: std::collections::HashMap, + + /// Custom denial response (transpiled `denyWith`). Most-specific layer + /// wins on collision. `None` leaves the host's default denial behavior. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response: Option, } impl CompiledRoute { @@ -515,6 +539,11 @@ impl CompiledRoute { // plugin_overrides: HashMap::extend overwrites on key collision, // which is exactly the more_specific-wins semantic. self.plugin_overrides.extend(more_specific.plugin_overrides); + + // response: most-specific declared block wins; absent leaves self's. + if more_specific.response.is_some() { + self.response = more_specific.response; + } } } @@ -522,6 +551,32 @@ impl CompiledRoute { mod tests { use super::*; + #[test] + fn apply_layer_response_most_specific_wins() { + let mut base = CompiledRoute::new("tool:x"); + base.response = Some(DenyResponse { + status: Some(401), + ..Default::default() + }); + + let mut layer = CompiledRoute::new("tool:x"); + layer.response = Some(DenyResponse { + status: Some(403), + body: Some("forbidden".to_string()), + ..Default::default() + }); + base.apply_layer(layer); + assert_eq!(base.response.as_ref().unwrap().status, Some(403)); + assert_eq!( + base.response.as_ref().unwrap().body.as_deref(), + Some("forbidden") + ); + + // A layer without a response leaves the existing one intact. + base.apply_layer(CompiledRoute::new("tool:x")); + assert_eq!(base.response.as_ref().unwrap().status, Some(403)); + } + #[test] fn phase_set_basic() { let mut set = PhaseSet::new(); diff --git a/crates/apl-cpex/src/route_handler.rs b/crates/apl-cpex/src/route_handler.rs index 16cf4cf6..9371bda8 100644 --- a/crates/apl-cpex/src/route_handler.rs +++ b/crates/apl-cpex/src/route_handler.rs @@ -424,6 +424,25 @@ impl AnyHookHandler for AplRouteHandler { }, }; + // Attach the route's transpiled `denyWith` (status/body/headers) to + // the violation's `details` map so the host can render a custom HTTP + // denial response. Carried via `details` (not new violation fields) + // to keep the violation type stable. Absent → host default response. + if let (Some(v), Some(resp)) = (violation.as_mut(), self.route.response.as_ref()) { + if let Some(status) = resp.status { + v.details + .insert("http.status".to_string(), serde_json::json!(status)); + } + if let Some(body) = &resp.body { + v.details + .insert("http.body".to_string(), serde_json::json!(body)); + } + if !resp.headers.is_empty() { + v.details + .insert("http.headers".to_string(), serde_json::json!(resp.headers)); + } + } + // Append fail-closed (R18) with merge precedence: // - decision Allow + append Err → flip to Deny with a // distinguished `session.persist_failed` violation. diff --git a/crates/apl-cpex/src/visitor.rs b/crates/apl-cpex/src/visitor.rs index ccb07b17..8c61db6b 100644 --- a/crates/apl-cpex/src/visitor.rs +++ b/crates/apl-cpex/src/visitor.rs @@ -51,10 +51,10 @@ use std::collections::HashMap; use std::sync::{Arc, RwLock, Weak}; use cpex_core::cmf::constants::{ - ENTITY_LLM, ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL, HOOK_CMF_LLM_INPUT, - HOOK_CMF_LLM_OUTPUT, HOOK_CMF_PROMPT_POST_INVOKE, HOOK_CMF_PROMPT_PRE_INVOKE, - HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH, HOOK_CMF_TOOL_POST_INVOKE, - HOOK_CMF_TOOL_PRE_INVOKE, + ENTITY_HTTP, ENTITY_LLM, ENTITY_NAME_GLOBAL, ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL, + HOOK_CMF_HTTP_REQUEST, HOOK_CMF_LLM_INPUT, HOOK_CMF_LLM_OUTPUT, HOOK_CMF_PROMPT_POST_INVOKE, + HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH, + HOOK_CMF_TOOL_POST_INVOKE, HOOK_CMF_TOOL_PRE_INVOKE, }; use cpex_core::config::RouteEntry; use cpex_core::manager::PluginManager; @@ -63,7 +63,7 @@ use cpex_core::visitor::{ConfigVisitor, VisitorError}; use apl_core::parser::compile_policy_block_value; use apl_core::plugin_decl::{PluginDeclaration, PluginRegistry}; -use apl_core::rules::CompiledRoute; +use apl_core::rules::{CompiledRoute, DenyResponse}; use apl_core::step::{PdpFactory, PdpResolver}; use crate::dispatch_plan::DispatchCache; @@ -357,7 +357,7 @@ impl ConfigVisitor for AplConfigVisitor { fn visit_global( &self, - _mgr: &Arc, + mgr: &Arc, yaml: &serde_yaml::Value, ) -> Result<(), VisitorError> { let Some(apl_block) = apl_subblock(yaml) else { @@ -386,8 +386,50 @@ impl ConfigVisitor for AplConfigVisitor { // `post_policy:` / `args:` / `result:` / `plugins:` (and inert // fields it ignores), so a shallow strip on a clone is enough. let policy_only = strip_non_dsl_keys(&apl_block); - let compiled = compile_policy_block_value("global.apl", &policy_only) + let mut compiled = compile_policy_block_value("global.apl", &policy_only) .map_err(|e| Box::new(e) as VisitorError)?; + // A `response:` block at the global scope is the catch-all denyWith. + compiled.response = response_subblock(yaml, "global"); + + // Install a catch-all handler so the global policy also evaluates for + // generic (non-MCP/A2A) HTTP requests, which carry no entity (U3). + // Entity routes still stack `global` via apply_layer in visit_route; + // this is the *entity-less* evaluation path. Pre-phase only — + // authorization is an admission check, so there is no post handler. + if !compiled.policy.is_empty() { + let (plugin_registry, pdp_router_arc) = { + let state = self.state.read().unwrap_or_else(|p| p.into_inner()); + ( + Arc::new(state.plugin_registry.clone()), + Arc::new(state.pdp_router.clone()) as Arc, + ) + }; + let session_store = self + .session_store + .read() + .unwrap_or_else(|p| p.into_inner()) + .clone(); + // The global HTTP policy reads the request line / headers, so + // grant `read_headers` on top of the visitor baseline. + let mut caps = self.base_capabilities.clone(); + caps.insert("read_headers".to_string()); + install_handler( + mgr, + ENTITY_HTTP, + ENTITY_NAME_GLOBAL, + None, + HOOK_CMF_HTTP_REQUEST, + Phase::Pre, + Arc::new(compiled.clone()), + &plugin_registry, + &self.dispatch_cache, + &session_store, + &self.manager, + Some(pdp_router_arc), + &caps, + ); + } + self.state .write() .unwrap_or_else(|p| p.into_inner()) @@ -518,6 +560,13 @@ impl ConfigVisitor for AplConfigVisitor { effective.apply_layer(route_layer); } + // Route-level denial response (transpiled `denyWith`). Read from + // the route YAML alongside the APL block; cpex-core tolerates the + // out-of-band key. Route scope is most-specific, so set directly. + if let Some(resp) = response_subblock(yaml, &route_key) { + effective.response = Some(resp); + } + // Load-time lint, once per route: flag any APL `plugins:` // override declared for a plugin that no policy / delegate step // references. Checked on the fully-stacked `effective` route so @@ -880,14 +929,52 @@ fn apl_subblock(yaml: &serde_yaml::Value) -> Option { } } +/// Extract a route-level `response:` block — the transpiled `denyWith`. +/// cpex-core tolerates this out-of-band key on the route; here we +/// deserialize it into a [`DenyResponse`]. A malformed block is logged +/// and skipped (best-effort) rather than failing the whole config. +fn response_subblock(yaml: &serde_yaml::Value, route_key: &str) -> Option { + let block = yaml.get("response")?; + if block.is_null() { + return None; + } + match serde_yaml::from_value::(block.clone()) { + Ok(resp) => Some(resp), + Err(e) => { + tracing::warn!(route = route_key, error = %e, "APL visitor: ignoring malformed route `response:` block"); + None + }, + } +} + #[cfg(test)] mod tests { - use super::apl_subblock; + use super::{apl_subblock, response_subblock}; fn yaml(s: &str) -> serde_yaml::Value { serde_yaml::from_str(s).expect("valid yaml") } + #[test] + fn response_subblock_parses_denywith() { + let v = yaml( + "tool: \"*\"\nresponse:\n status: 403\n body: \"{\\\"error\\\":\\\"forbidden\\\"}\"\n headers:\n WWW-Authenticate: \"Bearer\"\n", + ); + let resp = response_subblock(&v, "tool:*").expect("response present"); + assert_eq!(resp.status, Some(403)); + assert_eq!(resp.body.as_deref(), Some("{\"error\":\"forbidden\"}")); + assert_eq!( + resp.headers.get("WWW-Authenticate").map(String::as_str), + Some("Bearer") + ); + } + + #[test] + fn response_subblock_absent_is_none() { + let v = yaml("tool: \"*\"\npolicy:\n - \"deny\"\n"); + assert!(response_subblock(&v, "tool:*").is_none()); + } + #[test] fn apl_wrapper_is_returned_as_is() { let v = yaml("apl:\n policy:\n - \"deny\"\n"); diff --git a/crates/apl-cpex/tests/global_http_authz.rs b/crates/apl-cpex/tests/global_http_authz.rs new file mode 100644 index 00000000..90909c85 --- /dev/null +++ b/crates/apl-cpex/tests/global_http_authz.rs @@ -0,0 +1,131 @@ +// Location: ./crates/apl-cpex/tests/global_http_authz.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// End-to-end: a `global` APL policy is evaluated for a generic +// (non-MCP/A2A) HTTP request that carries no entity. The visitor installs +// a catch-all handler under (ENTITY_HTTP, ENTITY_NAME_GLOBAL, +// HOOK_CMF_HTTP_REQUEST); the host fires that hook with `meta` set to the +// reserved coordinates and an `http` extension carrying the request line. +// This is the entity-less authorization path the Praxis AuthPolicy +// transpiler targets (spike Phase B / U3). It also exercises U1 +// (http.method in the bag) and U2 (custom denyWith via the route +// `response:` block surfaced on the violation details). + +use std::sync::Arc; + +use cpex_core::cmf::constants::{ENTITY_HTTP, ENTITY_NAME_GLOBAL, HOOK_CMF_HTTP_REQUEST}; +use cpex_core::cmf::enums::Role; +use cpex_core::cmf::{CmfHook, Message, MessagePayload}; +use cpex_core::extensions::{Extensions, HttpExtension, MetaExtension}; +use cpex_core::manager::PluginManager; + +use apl_cpex::{register_apl, AplOptions}; + +async fn manager_with(yaml: &str) -> Arc { + let mgr = Arc::new(PluginManager::default()); + register_apl(&mgr, AplOptions::in_process()); + mgr.load_config_yaml(yaml).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + mgr +} + +/// A generic-HTTP request: reserved entity coordinates + an `http` +/// extension carrying the request method. +fn http_request(method: &str) -> Extensions { + let mut meta = MetaExtension::default(); + meta.entity_type = Some(ENTITY_HTTP.to_string()); + meta.entity_name = Some(ENTITY_NAME_GLOBAL.to_string()); + let http = HttpExtension { + method: Some(method.to_string()), + ..Default::default() + }; + Extensions { + meta: Some(Arc::new(meta)), + http: Some(Arc::new(http)), + ..Default::default() + } +} + +fn payload() -> MessagePayload { + MessagePayload { + message: Message::text(Role::User, "hi"), + } +} + +// APL predicate:action form: deny when the method is not GET. (Comparisons +// use this form; `require(...)` is truthiness-only.) +const GET_ONLY: &str = r#" +plugin_settings: + routing_enabled: true +global: + apl: + policy: + - "http.method != 'GET': deny" +"#; + +#[tokio::test] +async fn global_policy_allows_matching_http_request() { + let mgr = manager_with(GET_ONLY).await; + let (res, _bg) = mgr + .invoke_named::(HOOK_CMF_HTTP_REQUEST, payload(), http_request("GET"), None) + .await; + assert!( + res.continue_processing, + "GET must be allowed by the global policy; violation = {:?}", + res.violation + ); +} + +#[tokio::test] +async fn global_policy_denies_nonmatching_http_request() { + let mgr = manager_with(GET_ONLY).await; + let (res, _bg) = mgr + .invoke_named::(HOOK_CMF_HTTP_REQUEST, payload(), http_request("POST"), None) + .await; + assert!( + !res.continue_processing, + "POST must be denied by the global policy" + ); +} + +/// A route-level `response:` block (transpiled `denyWith`) surfaces custom +/// status/body/headers on the violation `details` map (U2) when the global +/// policy denies. +#[tokio::test] +async fn global_policy_deny_carries_custom_response() { + const YAML: &str = r#" +plugin_settings: + routing_enabled: true +global: + apl: + policy: + - "http.method != 'GET': deny" + response: + status: 403 + body: "{\"error\":\"forbidden\"}" + headers: + X-Reason: "method-not-allowed" +"#; + let mgr = manager_with(YAML).await; + let (res, _bg) = mgr + .invoke_named::( + HOOK_CMF_HTTP_REQUEST, + payload(), + http_request("DELETE"), + None, + ) + .await; + assert!(!res.continue_processing, "DELETE must be denied"); + let v = res.violation.expect("deny must surface a violation"); + assert_eq!(v.details.get("http.status"), Some(&serde_json::json!(403))); + assert_eq!( + v.details.get("http.body"), + Some(&serde_json::json!("{\"error\":\"forbidden\"}")) + ); + assert_eq!( + v.details.get("http.headers"), + Some(&serde_json::json!({ "X-Reason": "method-not-allowed" })) + ); +} diff --git a/crates/cpex-core/src/cmf/constants.rs b/crates/cpex-core/src/cmf/constants.rs index 454ec7b2..1be8e581 100644 --- a/crates/cpex-core/src/cmf/constants.rs +++ b/crates/cpex-core/src/cmf/constants.rs @@ -76,6 +76,15 @@ pub const ENTITY_LLM: &str = "llm"; pub const ENTITY_PROMPT: &str = "prompt"; pub const ENTITY_RESOURCE: &str = "resource"; +/// Reserved entity type for generic (non-MCP/A2A) HTTP requests. The +/// catch-all `global` policy is dispatched under this entity so an +/// entity-less request can be authorized; hosts set `meta.entity_type` to +/// this and `meta.entity_name` to [`ENTITY_NAME_GLOBAL`]. +pub const ENTITY_HTTP: &str = "http"; + +/// Reserved entity name for the global catch-all policy annotation. +pub const ENTITY_NAME_GLOBAL: &str = "*"; + // --------------------------------------------------------------------------- // CMF hook names — the canonical names plugins register under and hosts // pass to `PluginManager::invoke_named::(...)`. Two per entity @@ -94,3 +103,9 @@ pub const HOOK_CMF_PROMPT_PRE_INVOKE: &str = "cmf.prompt_pre_invoke"; pub const HOOK_CMF_PROMPT_POST_INVOKE: &str = "cmf.prompt_post_invoke"; pub const HOOK_CMF_RESOURCE_PRE_FETCH: &str = "cmf.resource_pre_fetch"; pub const HOOK_CMF_RESOURCE_POST_FETCH: &str = "cmf.resource_post_fetch"; + +/// Generic HTTP request hook. Hosts fire this for non-MCP/A2A HTTP +/// requests; the catch-all `global` policy (if any) is annotated under +/// it via [`ENTITY_HTTP`] / [`ENTITY_NAME_GLOBAL`]. Pre-invocation only — +/// authorization is an admission check, so there is no post counterpart. +pub const HOOK_CMF_HTTP_REQUEST: &str = "cmf.http_request"; diff --git a/crates/cpex-core/src/extensions/http.rs b/crates/cpex-core/src/extensions/http.rs index 3fa1157a..464f0889 100644 --- a/crates/cpex-core/src/extensions/http.rs +++ b/crates/cpex-core/src/extensions/http.rs @@ -28,6 +28,27 @@ pub struct HttpExtension { /// HTTP response headers (from upstream, populated post-invoke). #[serde(default)] pub response_headers: HashMap, + + /// HTTP request method (e.g. `GET`, `POST`). Set by the host when + /// the request is HTTP; `None` for non-HTTP transports. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub method: Option, + + /// HTTP request path (e.g. `/api/v1/widgets`). Excludes the query + /// string unless the host chooses to include it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + + /// HTTP request authority/host. The host MUST populate this from a + /// validated authority (e.g. the HTTP/2 `:authority` pseudo-header), + /// never a raw client-supplied `Host` header, so host-based policy + /// is not bypassable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host: Option, + + /// HTTP request scheme (`http` / `https`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scheme: Option, } impl HttpExtension {