1use crate::{
2 AuthenticatedServiceContext, AuthenticatedServicePrincipal, AuthenticatedTransportBinding,
3 CallPolicyDeclaration, CallPolicyEvent, CallPolicyEvidence, CallPolicyFailure,
4 CallPolicyRuntime, CallPolicyTerminalOutcome, DelegatedActorContext, DelegatedContextVerifier,
5 EndpointResolver, IdentityDecisionRecorder, ServiceContext, ServiceContextAdmission,
6 ServiceContextPolicy, ServiceReference, TenantContext, WorkloadIdentityProvider,
7 WorkloadIdentityVerification,
8};
9use axum::{
10 Router,
11 body::{Body, Bytes, to_bytes},
12 extract::{Request, State},
13 http::{HeaderMap, HeaderValue, Method, StatusCode, header},
14 response::{IntoResponse, Response},
15 routing::any,
16};
17use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
18use serde::{Deserialize, Serialize};
19use serde_json::{Value, json};
20use std::{
21 collections::BTreeMap,
22 future::Future,
23 pin::Pin,
24 sync::Arc,
25 time::{Duration, SystemTime, UNIX_EPOCH},
26};
27
28const DEADLINE_HEADER: &str = "x-lenso-deadline-unix-ms";
29const IDEMPOTENCY_HEADER: &str = "idempotency-key";
30const AUTHORIZATION_HEADER: &str = "authorization";
31const DELEGATED_ACTOR_HEADER: &str = "x-lenso-delegated-actor";
32const TENANT_CONTEXT_HEADER: &str = "x-lenso-tenant-context";
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum HttpIdempotency {
37 Unknown,
38 Idempotent,
39 RequiresKey,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct DirectHttpOperation {
45 pub operation_id: String,
46 pub method: String,
47 pub path: String,
48 pub idempotency: HttpIdempotency,
49 pub call_policy: CallPolicyDeclaration,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub request_schema: Option<Value>,
52 pub response_schemas: BTreeMap<String, Value>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub standard_error_schema: Option<Value>,
55}
56
57impl DirectHttpOperation {
58 #[must_use]
59 pub fn no_retry_reason(&self) -> Option<&'static str> {
60 match self.idempotency {
61 HttpIdempotency::Unknown => Some("operation_retry_safety_unknown"),
62 HttpIdempotency::Idempotent | HttpIdempotency::RequiresKey => None,
63 }
64 }
65
66 #[must_use]
67 pub fn retry_decision(&self, status: StatusCode, attempt: u32) -> RetryDecision {
68 self.retry_decision_for(status, attempt, None)
69 }
70
71 fn retry_decision_for(
72 &self,
73 status: StatusCode,
74 attempt: u32,
75 idempotency_key: Option<&str>,
76 ) -> RetryDecision {
77 if self.idempotency == HttpIdempotency::Unknown {
78 return RetryDecision::no("operation_retry_safety_unknown");
79 }
80 if attempt >= self.call_policy.max_attempts {
81 return RetryDecision::no("initial_policy_attempt_limit");
82 }
83 if !matches!(status.as_u16(), 429 | 502 | 503 | 504) {
84 return RetryDecision::no("failure_not_retryable");
85 }
86 match self.idempotency {
87 HttpIdempotency::Idempotent => RetryDecision::yes(),
88 HttpIdempotency::RequiresKey if idempotency_key.is_some_and(|key| !key.is_empty()) => {
89 RetryDecision::yes()
90 }
91 HttpIdempotency::RequiresKey => RetryDecision::no("idempotency_key_required"),
92 HttpIdempotency::Unknown => unreachable!("unknown safety returns before matching"),
93 }
94 }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "camelCase")]
99pub struct DirectHttpBindings {
100 pub contract_id: String,
101 pub version: String,
102 pub operations: Vec<DirectHttpOperation>,
103}
104
105impl DirectHttpBindings {
106 #[must_use]
107 pub fn operation(&self, operation_id: &str) -> Option<&DirectHttpOperation> {
108 self.operations
109 .iter()
110 .find(|item| item.operation_id == operation_id)
111 }
112
113 fn match_request(&self, method: &Method, path: &str) -> Option<&DirectHttpOperation> {
114 self.operations.iter().find(|operation| {
115 operation.method == method.as_str() && path_matches(&operation.path, path)
116 })
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct BindingGenerationError(pub String);
122
123impl std::fmt::Display for BindingGenerationError {
124 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 formatter.write_str(&self.0)
126 }
127}
128
129impl std::error::Error for BindingGenerationError {}
130
131pub fn generate_direct_http_bindings(
132 contract_id: impl Into<String>,
133 version: impl Into<String>,
134 openapi: &Value,
135) -> Result<DirectHttpBindings, BindingGenerationError> {
136 let version = version.into();
137 let document_version = openapi.pointer("/info/version").and_then(Value::as_str);
138 if document_version != Some(version.as_str()) {
139 return Err(BindingGenerationError(
140 "OpenAPI info.version must match the Service Contract version".to_owned(),
141 ));
142 }
143 let paths = openapi
144 .get("paths")
145 .and_then(Value::as_object)
146 .ok_or_else(|| {
147 BindingGenerationError("OpenAPI Service Contract requires paths".to_owned())
148 })?;
149 let mut operations = Vec::new();
150 for (path, item) in paths {
151 let Some(item) = item.as_object() else {
152 continue;
153 };
154 for method in ["get", "post", "put", "patch", "delete"] {
155 let Some(operation) = item.get(method).and_then(Value::as_object) else {
156 continue;
157 };
158 let operation_id = operation
159 .get("operationId")
160 .and_then(Value::as_str)
161 .ok_or_else(|| {
162 BindingGenerationError(format!("{method} {path} requires operationId"))
163 })?;
164 let idempotency = match operation.get("x-lenso-idempotency").and_then(Value::as_str) {
165 Some("idempotent") => HttpIdempotency::Idempotent,
166 Some("requires_key") => HttpIdempotency::RequiresKey,
167 Some(value) => {
168 return Err(BindingGenerationError(format!(
169 "unsupported x-lenso-idempotency `{value}`"
170 )));
171 }
172 None => HttpIdempotency::Unknown,
173 };
174 let retry_safe = idempotency != HttpIdempotency::Unknown;
175 let call_policy = operation
176 .get("x-lenso-call-policy")
177 .ok_or_else(|| {
178 BindingGenerationError(format!("{method} {path} requires x-lenso-call-policy"))
179 })
180 .and_then(|value| {
181 serde_json::from_value::<CallPolicyDeclaration>(value.clone()).map_err(
182 |error| {
183 BindingGenerationError(format!(
184 "{method} {path} has invalid x-lenso-call-policy: {error}"
185 ))
186 },
187 )
188 })?;
189 if let Some(issue) = call_policy.validate(retry_safe).into_iter().next() {
190 return Err(BindingGenerationError(format!(
191 "{method} {path} x-lenso-call-policy.{}: {}",
192 issue.path, issue.code
193 )));
194 }
195 operations.push(DirectHttpOperation {
196 operation_id: operation_id.to_owned(),
197 method: method.to_uppercase(),
198 path: path.clone(),
199 idempotency,
200 call_policy,
201 request_schema: operation
202 .get("requestBody")
203 .and_then(|value| value.pointer("/content/application~1json/schema"))
204 .map(|schema| resolve_local_schema(openapi, schema)),
205 response_schemas: response_schemas(openapi, operation),
206 standard_error_schema: operation
207 .get("responses")
208 .and_then(Value::as_object)
209 .and_then(|responses| {
210 responses.values().find_map(|response| {
211 response
212 .pointer("/content/application~1problem+json/schema")
213 .map(|schema| resolve_local_schema(openapi, schema))
214 })
215 }),
216 });
217 }
218 }
219 operations.sort_by(|left, right| left.operation_id.cmp(&right.operation_id));
220 Ok(DirectHttpBindings {
221 contract_id: contract_id.into(),
222 version,
223 operations,
224 })
225}
226
227#[derive(Debug, Clone)]
228pub struct DirectHttpRequest {
229 pub method: Method,
230 pub path: String,
231 pub headers: HeaderMap,
232 pub body: Bytes,
233 pub deadline_unix_ms: Option<u64>,
234 pub idempotency_key: Option<String>,
235 pub workload_credential: Option<String>,
236 pub authenticated_transport_binding: Option<String>,
237 pub authenticated_service_principal: Option<AuthenticatedServicePrincipal>,
238 pub delegated_actor_context: Option<DelegatedActorContext>,
239 pub tenant_context: Option<TenantContext>,
240 service_context_decode_failed: bool,
241 pub authenticated_service_context: Option<AuthenticatedServiceContext>,
242}
243
244impl DirectHttpRequest {
245 #[must_use]
246 pub fn new(method: Method, path: impl Into<String>) -> Self {
247 Self {
248 method,
249 path: path.into(),
250 headers: HeaderMap::new(),
251 body: Bytes::new(),
252 deadline_unix_ms: None,
253 idempotency_key: None,
254 workload_credential: None,
255 authenticated_transport_binding: None,
256 authenticated_service_principal: None,
257 delegated_actor_context: None,
258 tenant_context: None,
259 service_context_decode_failed: false,
260 authenticated_service_context: None,
261 }
262 }
263
264 #[must_use]
265 pub fn with_deadline(mut self, deadline_unix_ms: u64) -> Self {
266 self.deadline_unix_ms = Some(deadline_unix_ms);
267 self
268 }
269
270 #[must_use]
271 pub fn with_workload_credential(mut self, credential: impl Into<String>) -> Self {
272 self.workload_credential = Some(credential.into());
273 self
274 }
275
276 #[must_use]
277 pub fn with_authenticated_transport_binding(mut self, binding: impl Into<String>) -> Self {
278 self.authenticated_transport_binding = Some(binding.into());
279 self
280 }
281
282 #[must_use]
283 pub fn with_service_context(
284 mut self,
285 actor: DelegatedActorContext,
286 tenant: Option<TenantContext>,
287 ) -> Self {
288 self.delegated_actor_context = Some(actor);
289 self.tenant_context = tenant;
290 self
291 }
292}
293
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295#[serde(rename_all = "camelCase")]
296pub struct DirectHttpEvidence {
297 pub operation_id: Option<String>,
298 pub decision: String,
299 pub call_policy: CallPolicyEvidence,
300 pub native_status: Option<u16>,
301}
302
303#[derive(Debug, Clone)]
304pub struct DirectHttpResponse {
305 pub status: StatusCode,
306 pub headers: HeaderMap,
307 pub body: Bytes,
308 pub standard_error: Option<Value>,
309 pub evidence: Option<DirectHttpEvidence>,
310}
311
312impl DirectHttpResponse {
313 #[must_use]
314 pub fn json(status: StatusCode, body: Value) -> Self {
315 let mut headers = HeaderMap::new();
316 headers.insert(
317 header::CONTENT_TYPE,
318 HeaderValue::from_static("application/json"),
319 );
320 let standard_error =
321 (status.is_client_error() || status.is_server_error()).then(|| body.clone());
322 Self {
323 status,
324 headers,
325 body: Bytes::from(serde_json::to_vec(&body).expect("JSON value must serialize")),
326 standard_error,
327 evidence: None,
328 }
329 }
330
331 fn problem(status: StatusCode, code: &str, detail: &str, operation_id: Option<String>) -> Self {
332 let mut response = Self::json(
333 status,
334 json!({"type":"about:blank","title":detail,"status":status.as_u16(),"detail":detail,"code":code,"request_id":null,"correlation_id":null,"errors":[]}),
335 );
336 response.headers.insert(
337 header::CONTENT_TYPE,
338 HeaderValue::from_static("application/problem+json"),
339 );
340 response.evidence = Some(DirectHttpEvidence {
341 operation_id,
342 decision: code.to_owned(),
343 call_policy: CallPolicyEvidence {
344 events: vec![if code == "overload_rejected" {
345 CallPolicyEvent::OverloadRejected
346 } else {
347 CallPolicyEvent::CallFailed
348 }],
349 attempts: 0,
350 terminal_outcome: CallPolicyTerminalOutcome::Rejected,
351 fallback_handler: None,
352 },
353 native_status: Some(status.as_u16()),
354 });
355 response
356 }
357}
358
359type HandlerFuture = Pin<Box<dyn Future<Output = DirectHttpResponse> + Send>>;
360type Handler = dyn Fn(DirectHttpRequest) -> HandlerFuture + Send + Sync;
361
362#[derive(Clone)]
363pub struct DirectHttpServerBinding {
364 inner: Arc<ServerInner>,
365}
366struct ServerInner {
367 bindings: DirectHttpBindings,
368 handler: Arc<Handler>,
369 policy_runtime: CallPolicyRuntime,
370 workload_identity: Option<DirectHttpWorkloadIdentity>,
371 service_context: Option<DirectHttpServiceContext>,
372}
373
374#[derive(Debug, Clone)]
375struct DirectHttpWorkloadIdentity {
376 provider: Arc<dyn WorkloadIdentityProvider>,
377 audience: String,
378}
379
380#[derive(Debug, Clone)]
381struct DirectHttpServiceContext {
382 admission: ServiceContextAdmission,
383 recorder: Arc<dyn IdentityDecisionRecorder>,
384}
385
386impl std::fmt::Debug for DirectHttpServerBinding {
387 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388 formatter
389 .debug_struct("DirectHttpServerBinding")
390 .field("bindings", &self.inner.bindings)
391 .finish_non_exhaustive()
392 }
393}
394
395impl DirectHttpServerBinding {
396 pub fn new<F, Fut>(
397 bindings: DirectHttpBindings,
398 provider: Arc<dyn WorkloadIdentityProvider>,
399 audience: impl Into<String>,
400 context_provider: Arc<dyn DelegatedContextVerifier>,
401 context_policies: impl IntoIterator<Item = (String, ServiceContextPolicy)>,
402 evidence_recorder: Arc<dyn IdentityDecisionRecorder>,
403 handler: F,
404 ) -> Self
405 where
406 F: Fn(DirectHttpRequest) -> Fut + Send + Sync + 'static,
407 Fut: Future<Output = DirectHttpResponse> + Send + 'static,
408 {
409 Self::new_with_policy_runtime_unchecked(bindings, CallPolicyRuntime::default(), handler)
410 .with_workload_identity(provider, audience)
411 .with_service_context(context_provider, context_policies, evidence_recorder)
412 }
413
414 #[cfg(debug_assertions)]
415 pub fn new_without_workload_identity<F, Fut>(bindings: DirectHttpBindings, handler: F) -> Self
416 where
417 F: Fn(DirectHttpRequest) -> Fut + Send + Sync + 'static,
418 Fut: Future<Output = DirectHttpResponse> + Send + 'static,
419 {
420 Self::new_with_policy_runtime_unchecked(bindings, CallPolicyRuntime::default(), handler)
421 }
422
423 pub fn new_with_policy_runtime<F, Fut>(
424 bindings: DirectHttpBindings,
425 policy_runtime: CallPolicyRuntime,
426 provider: Arc<dyn WorkloadIdentityProvider>,
427 audience: impl Into<String>,
428 context_provider: Arc<dyn DelegatedContextVerifier>,
429 context_policies: impl IntoIterator<Item = (String, ServiceContextPolicy)>,
430 evidence_recorder: Arc<dyn IdentityDecisionRecorder>,
431 handler: F,
432 ) -> Self
433 where
434 F: Fn(DirectHttpRequest) -> Fut + Send + Sync + 'static,
435 Fut: Future<Output = DirectHttpResponse> + Send + 'static,
436 {
437 Self::new_with_policy_runtime_unchecked(bindings, policy_runtime, handler)
438 .with_workload_identity(provider, audience)
439 .with_service_context(context_provider, context_policies, evidence_recorder)
440 }
441
442 #[cfg(debug_assertions)]
443 pub fn new_with_policy_runtime_without_workload_identity<F, Fut>(
444 bindings: DirectHttpBindings,
445 policy_runtime: CallPolicyRuntime,
446 handler: F,
447 ) -> Self
448 where
449 F: Fn(DirectHttpRequest) -> Fut + Send + Sync + 'static,
450 Fut: Future<Output = DirectHttpResponse> + Send + 'static,
451 {
452 Self::new_with_policy_runtime_unchecked(bindings, policy_runtime, handler)
453 }
454
455 fn new_with_policy_runtime_unchecked<F, Fut>(
456 bindings: DirectHttpBindings,
457 policy_runtime: CallPolicyRuntime,
458 handler: F,
459 ) -> Self
460 where
461 F: Fn(DirectHttpRequest) -> Fut + Send + Sync + 'static,
462 Fut: Future<Output = DirectHttpResponse> + Send + 'static,
463 {
464 Self {
465 inner: Arc::new(ServerInner {
466 bindings,
467 handler: Arc::new(move |request| Box::pin(handler(request))),
468 policy_runtime,
469 workload_identity: None,
470 service_context: None,
471 }),
472 }
473 }
474
475 #[must_use]
476 pub fn with_workload_identity(
477 self,
478 provider: Arc<dyn WorkloadIdentityProvider>,
479 audience: impl Into<String>,
480 ) -> Self {
481 Self {
482 inner: Arc::new(ServerInner {
483 bindings: self.inner.bindings.clone(),
484 handler: Arc::clone(&self.inner.handler),
485 policy_runtime: self.inner.policy_runtime.clone(),
486 workload_identity: Some(DirectHttpWorkloadIdentity {
487 provider,
488 audience: audience.into(),
489 }),
490 service_context: self.inner.service_context.clone(),
491 }),
492 }
493 }
494
495 #[must_use]
496 pub fn with_service_context<I, S>(
497 self,
498 provider: Arc<dyn DelegatedContextVerifier>,
499 policies: I,
500 recorder: Arc<dyn IdentityDecisionRecorder>,
501 ) -> Self
502 where
503 I: IntoIterator<Item = (S, ServiceContextPolicy)>,
504 S: Into<String>,
505 {
506 Self {
507 inner: Arc::new(ServerInner {
508 bindings: self.inner.bindings.clone(),
509 handler: Arc::clone(&self.inner.handler),
510 policy_runtime: self.inner.policy_runtime.clone(),
511 workload_identity: self.inner.workload_identity.clone(),
512 service_context: Some(DirectHttpServiceContext {
513 admission: ServiceContextAdmission::new(provider, policies),
514 recorder,
515 }),
516 }),
517 }
518 }
519
520 pub async fn handle(&self, request: DirectHttpRequest) -> DirectHttpResponse {
521 self.inner.handle(request).await
522 }
523
524 #[must_use]
525 pub fn router(self) -> Router {
526 Router::new()
527 .fallback(any(handle_axum))
528 .with_state(self.inner)
529 }
530}
531
532impl ServerInner {
533 async fn handle(&self, mut request: DirectHttpRequest) -> DirectHttpResponse {
534 let Some(operation) = self.bindings.match_request(&request.method, &request.path) else {
535 return DirectHttpResponse::problem(
536 StatusCode::NOT_FOUND,
537 "operation_not_found",
538 "Operation not found",
539 None,
540 );
541 };
542 if let Some(identity) = &self.workload_identity {
543 let Some(credential) = request.workload_credential.as_deref() else {
544 return DirectHttpResponse::problem(
545 StatusCode::UNAUTHORIZED,
546 "workload_identity_required",
547 "Workload Identity credential is required",
548 Some(operation.operation_id.clone()),
549 );
550 };
551 let Some(binding) = request.authenticated_transport_binding.as_deref() else {
552 return DirectHttpResponse::problem(
553 StatusCode::UNAUTHORIZED,
554 "authenticated_transport_binding_required",
555 "Authenticated transport binding is required",
556 Some(operation.operation_id.clone()),
557 );
558 };
559 match identity.provider.verify(
560 credential,
561 &WorkloadIdentityVerification::new(&identity.audience, binding, now_ms()),
562 ) {
563 Ok(principal) => request.authenticated_service_principal = Some(principal),
564 Err(error) => {
565 return DirectHttpResponse::problem(
566 StatusCode::UNAUTHORIZED,
567 &error.evidence.outcome,
568 &error.message,
569 Some(operation.operation_id.clone()),
570 );
571 }
572 }
573 }
574 if let Some(context) = &self.service_context {
575 let decision = if request.service_context_decode_failed {
576 Err(context.admission.invalid_proof(&operation.operation_id))
577 } else {
578 let service_context = request
579 .delegated_actor_context
580 .clone()
581 .map(|actor| ServiceContext::new(actor, request.tenant_context.clone()));
582 context
583 .admission
584 .admit(&operation.operation_id, service_context.as_ref(), now_ms())
585 };
586 match decision {
587 Ok(authenticated) => {
588 if context.recorder.record(&authenticated.evidence).is_err() {
589 return DirectHttpResponse::problem(
590 StatusCode::INTERNAL_SERVER_ERROR,
591 "identity_evidence_persistence_failed",
592 "Identity decision evidence could not be persisted",
593 Some(operation.operation_id.clone()),
594 );
595 }
596 request.authenticated_service_context = Some(authenticated);
597 }
598 Err(error) => {
599 if context.recorder.record(&error.evidence).is_err() {
600 return DirectHttpResponse::problem(
601 StatusCode::INTERNAL_SERVER_ERROR,
602 "identity_evidence_persistence_failed",
603 "Identity decision evidence could not be persisted",
604 Some(operation.operation_id.clone()),
605 );
606 }
607 return DirectHttpResponse::problem(
608 StatusCode::FORBIDDEN,
609 &error.evidence.outcome,
610 &error.message,
611 Some(operation.operation_id.clone()),
612 );
613 }
614 }
615 }
616 if request
617 .deadline_unix_ms
618 .is_none_or(|deadline| deadline <= now_ms())
619 {
620 return DirectHttpResponse::problem(
621 StatusCode::GATEWAY_TIMEOUT,
622 "deadline_expired",
623 "Deadline is missing or expired",
624 Some(operation.operation_id.clone()),
625 );
626 }
627 if operation.idempotency == HttpIdempotency::RequiresKey
628 && request.idempotency_key.as_deref().is_none_or(str::is_empty)
629 {
630 return DirectHttpResponse::problem(
631 StatusCode::BAD_REQUEST,
632 "idempotency_key_required",
633 "Idempotency Key is required",
634 Some(operation.operation_id.clone()),
635 );
636 }
637 let operation_key = format!("{}:{}", self.bindings.contract_id, operation.operation_id);
638 let Ok(_admission) = self
639 .policy_runtime
640 .admit(operation_key, &operation.call_policy)
641 else {
642 return DirectHttpResponse::problem(
643 StatusCode::TOO_MANY_REQUESTS,
644 "overload_rejected",
645 "Service operation is overloaded",
646 Some(operation.operation_id.clone()),
647 );
648 };
649 (self.handler)(request).await
650 }
651}
652
653async fn handle_axum(State(inner): State<Arc<ServerInner>>, request: Request) -> Response {
654 let (parts, body) = request.into_parts();
655 let deadline_unix_ms = parts
656 .headers
657 .get(DEADLINE_HEADER)
658 .and_then(|value| value.to_str().ok())
659 .and_then(|value| value.parse().ok());
660 let idempotency_key = parts
661 .headers
662 .get(IDEMPOTENCY_HEADER)
663 .and_then(|value| value.to_str().ok())
664 .map(str::to_owned);
665 let workload_credential = parts
666 .headers
667 .get(AUTHORIZATION_HEADER)
668 .and_then(|value| value.to_str().ok())
669 .and_then(|value| value.strip_prefix("Bearer "))
670 .map(str::to_owned);
671 let authenticated_transport_binding = parts
672 .extensions
673 .get::<AuthenticatedTransportBinding>()
674 .map(|binding| binding.0.clone());
675 let actor =
676 decode_context_header::<DelegatedActorContext>(&parts.headers, DELEGATED_ACTOR_HEADER);
677 let tenant = decode_context_header::<TenantContext>(&parts.headers, TENANT_CONTEXT_HEADER);
678 let service_context_decode_failed = actor.is_err() || tenant.is_err();
679 let delegated_actor_context = actor.ok().flatten();
680 let tenant_context = tenant.ok().flatten();
681 let body = to_bytes(body, 16 * 1024 * 1024).await.unwrap_or_default();
682 let response = inner
683 .handle(DirectHttpRequest {
684 method: parts.method,
685 path: parts.uri.path().to_owned(),
686 headers: parts.headers,
687 body,
688 deadline_unix_ms,
689 idempotency_key,
690 workload_credential,
691 authenticated_transport_binding,
692 authenticated_service_principal: None,
693 delegated_actor_context,
694 tenant_context,
695 service_context_decode_failed,
696 authenticated_service_context: None,
697 })
698 .await;
699 response.into_response()
700}
701
702impl IntoResponse for DirectHttpResponse {
703 fn into_response(self) -> Response {
704 let mut response = Response::new(Body::from(self.body));
705 *response.status_mut() = self.status;
706 *response.headers_mut() = self.headers;
707 response
708 }
709}
710
711#[derive(Debug, Clone)]
712pub struct DirectHttpCall {
713 operation_id: String,
714 path_parameters: BTreeMap<String, String>,
715 body: Option<Value>,
716 deadline_unix_ms: Option<u64>,
717 idempotency_key: Option<String>,
718 workload_credential: Option<String>,
719 delegated_actor_context: Option<DelegatedActorContext>,
720 tenant_context: Option<TenantContext>,
721}
722impl DirectHttpCall {
723 #[must_use]
724 pub fn new(operation_id: impl Into<String>) -> Self {
725 Self {
726 operation_id: operation_id.into(),
727 path_parameters: BTreeMap::new(),
728 body: None,
729 deadline_unix_ms: None,
730 idempotency_key: None,
731 workload_credential: None,
732 delegated_actor_context: None,
733 tenant_context: None,
734 }
735 }
736 #[must_use]
737 pub fn with_path_parameter(
738 mut self,
739 name: impl Into<String>,
740 value: impl Into<String>,
741 ) -> Self {
742 self.path_parameters.insert(name.into(), value.into());
743 self
744 }
745 #[must_use]
746 pub fn with_json(mut self, body: Value) -> Self {
747 self.body = Some(body);
748 self
749 }
750 #[must_use]
751 pub fn with_deadline(mut self, deadline: u64) -> Self {
752 self.deadline_unix_ms = Some(deadline);
753 self
754 }
755 #[must_use]
756 pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
757 self.idempotency_key = Some(key.into());
758 self
759 }
760
761 #[must_use]
762 pub fn with_workload_credential(mut self, credential: impl Into<String>) -> Self {
763 self.workload_credential = Some(credential.into());
764 self
765 }
766
767 #[must_use]
768 pub fn with_service_context(
769 mut self,
770 actor: DelegatedActorContext,
771 tenant: Option<TenantContext>,
772 ) -> Self {
773 self.delegated_actor_context = Some(actor);
774 self.tenant_context = tenant;
775 self
776 }
777}
778
779pub struct DirectHttpClient<R> {
780 resolver: R,
781 bindings: DirectHttpBindings,
782 http: reqwest::Client,
783 policy_runtime: CallPolicyRuntime,
784 fallbacks: BTreeMap<String, Arc<HttpFallback>>,
785}
786type HttpFallback = dyn Fn(CallPolicyFailure) -> DirectHttpResponse + Send + Sync;
787impl<R> std::fmt::Debug for DirectHttpClient<R> {
788 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
789 formatter
790 .debug_struct("DirectHttpClient")
791 .field("bindings", &self.bindings)
792 .field("policy_runtime", &self.policy_runtime)
793 .field("fallbacks", &self.fallbacks.keys().collect::<Vec<_>>())
794 .finish_non_exhaustive()
795 }
796}
797impl<R: EndpointResolver> DirectHttpClient<R> {
798 #[must_use]
799 pub fn new(resolver: R, bindings: DirectHttpBindings) -> Self {
800 Self {
801 resolver,
802 bindings,
803 http: reqwest::Client::new(),
804 policy_runtime: CallPolicyRuntime::default(),
805 fallbacks: BTreeMap::new(),
806 }
807 }
808
809 #[must_use]
810 pub fn with_policy_runtime(mut self, policy_runtime: CallPolicyRuntime) -> Self {
811 self.policy_runtime = policy_runtime;
812 self
813 }
814
815 #[must_use]
816 pub fn with_fallback<F>(mut self, handler: impl Into<String>, fallback: F) -> Self
817 where
818 F: Fn(CallPolicyFailure) -> DirectHttpResponse + Send + Sync + 'static,
819 {
820 self.fallbacks.insert(handler.into(), Arc::new(fallback));
821 self
822 }
823 pub async fn call(
824 &self,
825 service: &ServiceReference,
826 call: DirectHttpCall,
827 ) -> Result<DirectHttpResponse, DirectHttpCallError> {
828 let operation = self.bindings.operation(&call.operation_id).ok_or_else(|| {
829 DirectHttpCallError::Contract(
830 "operation is not declared by the generated binding".to_owned(),
831 )
832 })?;
833 let deadline = call
834 .deadline_unix_ms
835 .ok_or_else(|| DirectHttpCallError::Contract("deadline_required".to_owned()))?;
836 if deadline <= now_ms() {
837 return self.deadline_failure(operation, 0, Vec::new());
838 }
839 if operation.idempotency == HttpIdempotency::RequiresKey
840 && call.idempotency_key.as_deref().is_none_or(str::is_empty)
841 {
842 return Err(DirectHttpCallError::Contract(
843 "idempotency_key_required".to_owned(),
844 ));
845 }
846 let method = Method::from_bytes(operation.method.as_bytes())
847 .map_err(|error| DirectHttpCallError::Contract(error.to_string()))?;
848 let path = expand_path(&operation.path, &call.path_parameters)?;
849 let operation_key = format!(
850 "{}:{}:{}",
851 service.as_str(),
852 self.bindings.contract_id,
853 operation.operation_id
854 );
855 let permit = match self
856 .policy_runtime
857 .begin_call(operation_key, &operation.call_policy)
858 {
859 Ok(permit) => permit,
860 Err(event) => {
861 let failure = match event {
862 CallPolicyEvent::CircuitOpen => CallPolicyFailure::CircuitOpen,
863 CallPolicyEvent::BulkheadSaturated => CallPolicyFailure::BulkheadSaturated,
864 _ => CallPolicyFailure::NonRetryableFailure,
865 };
866 if let Some(response) =
867 self.fallback_response(operation, failure, 0, vec![event], None)
868 {
869 return Ok(response);
870 }
871 return Err(DirectHttpCallError::Policy {
872 failure,
873 evidence: CallPolicyEvidence {
874 events: vec![event],
875 attempts: 0,
876 terminal_outcome: CallPolicyTerminalOutcome::Rejected,
877 fallback_handler: None,
878 },
879 });
880 }
881 };
882 let state = self
883 .resolver
884 .resolve(service)
885 .map_err(|error| DirectHttpCallError::Resolution(error.to_string()))?;
886 let endpoint = state
887 .endpoints
888 .first()
889 .ok_or_else(|| DirectHttpCallError::Resolution("no usable endpoint".to_owned()))?;
890 let url = format!("{}{}", endpoint.address.trim_end_matches('/'), path);
891 let mut retry_events = Vec::new();
892 for attempt in 1..=operation.call_policy.max_attempts {
893 let remaining_ms = deadline.saturating_sub(now_ms());
894 if remaining_ms == 0 {
895 retry_events.push(CallPolicyEvent::DeadlineExpired);
896 let events = permit.failure_after(retry_events);
897 return self.deadline_failure(operation, attempt - 1, events);
898 }
899 let mut request = self
900 .http
901 .request(method.clone(), &url)
902 .timeout(Duration::from_millis(remaining_ms))
903 .header(DEADLINE_HEADER, deadline);
904 if let Some(key) = call.idempotency_key.as_deref() {
905 request = request.header(IDEMPOTENCY_HEADER, key);
906 }
907 if let Some(credential) = call.workload_credential.as_deref() {
908 request = request.bearer_auth(credential);
909 }
910 if let Some(actor) = call.delegated_actor_context.as_ref() {
911 request = request.header(DELEGATED_ACTOR_HEADER, encode_context_header(actor)?);
912 }
913 if let Some(tenant) = call.tenant_context.as_ref() {
914 request = request.header(TENANT_CONTEXT_HEADER, encode_context_header(tenant)?);
915 }
916 if let Some(body) = call.body.as_ref() {
917 request = request.json(body);
918 }
919 let response = match request.send().await {
920 Ok(response) => response,
921 Err(error) => {
922 let failure = if error.is_timeout() {
923 retry_events.push(CallPolicyEvent::DeadlineExpired);
924 CallPolicyFailure::DeadlineExpired
925 } else {
926 retry_events.push(CallPolicyEvent::CallFailed);
927 CallPolicyFailure::TransportFailure
928 };
929 let events = permit.failure_after(retry_events);
930 if let Some(response) =
931 self.fallback_response(operation, failure, attempt, events.clone(), None)
932 {
933 return Ok(response);
934 }
935 return Err(DirectHttpCallError::Transport {
936 message: format!("transport_failure_no_retry: {error}"),
937 evidence: CallPolicyEvidence {
938 events,
939 attempts: attempt,
940 terminal_outcome: CallPolicyTerminalOutcome::Failed,
941 fallback_handler: None,
942 },
943 });
944 }
945 };
946 let status = response.status();
947 let decision =
948 operation.retry_decision_for(status, attempt, call.idempotency_key.as_deref());
949 if decision.should_retry {
950 retry_events.push(CallPolicyEvent::RetryScheduled);
951 continue;
952 }
953 let headers = response.headers().clone();
954 let body = match response.bytes().await {
955 Ok(body) => body,
956 Err(error) => {
957 retry_events.push(CallPolicyEvent::CallFailed);
958 let events = permit.failure_after(retry_events);
959 return Err(DirectHttpCallError::Transport {
960 message: error.to_string(),
961 evidence: CallPolicyEvidence {
962 events,
963 attempts: attempt,
964 terminal_outcome: CallPolicyTerminalOutcome::Failed,
965 fallback_handler: None,
966 },
967 });
968 }
969 };
970 let standard_error = serde_json::from_slice(&body).ok().filter(|value| {
971 is_standard_problem(value, status, operation.standard_error_schema.as_ref())
972 });
973 let retryable_failure = matches!(status.as_u16(), 429 | 502 | 503 | 504);
974 if status == StatusCode::TOO_MANY_REQUESTS {
975 retry_events.push(CallPolicyEvent::OverloadRejected);
976 }
977 retry_events.push(if status.is_success() {
978 CallPolicyEvent::CallCompleted
979 } else {
980 CallPolicyEvent::CallFailed
981 });
982 let events = if retryable_failure {
983 permit.failure_after(retry_events)
984 } else {
985 permit.success_after(retry_events)
986 };
987 let failure = if status == StatusCode::TOO_MANY_REQUESTS {
988 CallPolicyFailure::OverloadRejected
989 } else if retryable_failure {
990 CallPolicyFailure::RetryableFailure
991 } else {
992 CallPolicyFailure::NonRetryableFailure
993 };
994 if !status.is_success() {
995 if let Some(response) = self.fallback_response(
996 operation,
997 failure,
998 attempt,
999 events.clone(),
1000 Some(status),
1001 ) {
1002 return Ok(response);
1003 }
1004 }
1005 let evidence = Some(DirectHttpEvidence {
1006 operation_id: Some(operation.operation_id.clone()),
1007 decision: if status.is_success() {
1008 "call_completed".to_owned()
1009 } else {
1010 decision.reason.to_owned()
1011 },
1012 call_policy: CallPolicyEvidence {
1013 events,
1014 attempts: attempt,
1015 terminal_outcome: if status.is_success() {
1016 CallPolicyTerminalOutcome::Completed
1017 } else {
1018 CallPolicyTerminalOutcome::Failed
1019 },
1020 fallback_handler: None,
1021 },
1022 native_status: Some(status.as_u16()),
1023 });
1024 return Ok(DirectHttpResponse {
1025 status,
1026 headers,
1027 body,
1028 standard_error,
1029 evidence,
1030 });
1031 }
1032 unreachable!("the direct HTTP call loop always returns by its final attempt")
1033 }
1034
1035 fn fallback_response(
1036 &self,
1037 operation: &DirectHttpOperation,
1038 failure: CallPolicyFailure,
1039 attempts: u32,
1040 mut events: Vec<CallPolicyEvent>,
1041 native_status: Option<StatusCode>,
1042 ) -> Option<DirectHttpResponse> {
1043 let declaration = operation.call_policy.fallback_for(failure)?;
1044 let fallback = self.fallbacks.get(&declaration.handler)?;
1045 let mut response = fallback(failure);
1046 events.push(CallPolicyEvent::FallbackApplied);
1047 response.evidence = Some(DirectHttpEvidence {
1048 operation_id: Some(operation.operation_id.clone()),
1049 decision: "fallback_applied".to_owned(),
1050 call_policy: CallPolicyEvidence {
1051 events,
1052 attempts,
1053 terminal_outcome: CallPolicyTerminalOutcome::Fallback,
1054 fallback_handler: Some(declaration.handler.clone()),
1055 },
1056 native_status: native_status.map(|status| status.as_u16()),
1057 });
1058 Some(response)
1059 }
1060
1061 fn deadline_failure(
1062 &self,
1063 operation: &DirectHttpOperation,
1064 attempts: u32,
1065 mut events: Vec<CallPolicyEvent>,
1066 ) -> Result<DirectHttpResponse, DirectHttpCallError> {
1067 if !events.contains(&CallPolicyEvent::DeadlineExpired) {
1068 events.push(CallPolicyEvent::DeadlineExpired);
1069 }
1070 if let Some(response) = self.fallback_response(
1071 operation,
1072 CallPolicyFailure::DeadlineExpired,
1073 attempts,
1074 events.clone(),
1075 None,
1076 ) {
1077 return Ok(response);
1078 }
1079 Err(DirectHttpCallError::Policy {
1080 failure: CallPolicyFailure::DeadlineExpired,
1081 evidence: CallPolicyEvidence {
1082 events,
1083 attempts,
1084 terminal_outcome: CallPolicyTerminalOutcome::Failed,
1085 fallback_handler: None,
1086 },
1087 })
1088 }
1089}
1090
1091fn encode_context_header<T: Serialize>(value: &T) -> Result<String, DirectHttpCallError> {
1092 serde_json::to_vec(value)
1093 .map(|json| URL_SAFE_NO_PAD.encode(json))
1094 .map_err(|error| DirectHttpCallError::Contract(error.to_string()))
1095}
1096
1097fn decode_context_header<T: for<'de> Deserialize<'de>>(
1098 headers: &HeaderMap,
1099 name: &str,
1100) -> Result<Option<T>, ()> {
1101 let Some(value) = headers.get(name) else {
1102 return Ok(None);
1103 };
1104 value
1105 .to_str()
1106 .map_err(|_| ())
1107 .and_then(|value| URL_SAFE_NO_PAD.decode(value).map_err(|_| ()))
1108 .and_then(|value| serde_json::from_slice(&value).map_err(|_| ()))
1109 .map(Some)
1110}
1111
1112#[derive(Debug, Clone, PartialEq, Eq)]
1113pub enum DirectHttpCallError {
1114 Contract(String),
1115 Resolution(String),
1116 Transport {
1117 message: String,
1118 evidence: CallPolicyEvidence,
1119 },
1120 Policy {
1121 failure: CallPolicyFailure,
1122 evidence: CallPolicyEvidence,
1123 },
1124}
1125impl std::fmt::Display for DirectHttpCallError {
1126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1127 match self {
1128 Self::Contract(value) | Self::Resolution(value) => f.write_str(value),
1129 Self::Transport { message, .. } => f.write_str(message),
1130 Self::Policy { failure, .. } => write!(f, "call policy rejected: {failure:?}"),
1131 }
1132 }
1133}
1134impl std::error::Error for DirectHttpCallError {}
1135
1136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1137pub struct RetryDecision {
1138 pub should_retry: bool,
1139 pub reason: &'static str,
1140}
1141impl RetryDecision {
1142 pub(crate) fn yes() -> Self {
1143 Self {
1144 should_retry: true,
1145 reason: "declared_safe_retry",
1146 }
1147 }
1148 pub(crate) fn no(reason: &'static str) -> Self {
1149 Self {
1150 should_retry: false,
1151 reason,
1152 }
1153 }
1154}
1155
1156fn path_matches(template: &str, actual: &str) -> bool {
1157 let template = template.trim_matches('/').split('/');
1158 let actual = actual.trim_matches('/').split('/');
1159 let template: Vec<_> = template.collect();
1160 let actual: Vec<_> = actual.collect();
1161 template.len() == actual.len()
1162 && template.iter().zip(actual).all(|(expected, value)| {
1163 (expected.starts_with('{') && expected.ends_with('}')) || *expected == value
1164 })
1165}
1166
1167fn expand_path(
1168 template: &str,
1169 parameters: &BTreeMap<String, String>,
1170) -> Result<String, DirectHttpCallError> {
1171 let mut path = String::new();
1172 for segment in template.trim_start_matches('/').split('/') {
1173 path.push('/');
1174 if let Some(name) = segment
1175 .strip_prefix('{')
1176 .and_then(|value| value.strip_suffix('}'))
1177 {
1178 let value = parameters
1179 .get(name)
1180 .filter(|value| !value.is_empty())
1181 .ok_or_else(|| {
1182 DirectHttpCallError::Contract(format!("missing path parameter `{name}`"))
1183 })?;
1184 if value.contains('/') {
1185 return Err(DirectHttpCallError::Contract(format!(
1186 "path parameter `{name}` must be one segment"
1187 )));
1188 }
1189 path.push_str(&encode_path_segment(value));
1190 } else if !segment.is_empty() {
1191 path.push_str(segment);
1192 }
1193 }
1194 Ok(path)
1195}
1196
1197fn encode_path_segment(value: &str) -> String {
1198 let mut encoded = String::new();
1199 for byte in value.as_bytes() {
1200 if byte.is_ascii_alphanumeric() || matches!(*byte, b'-' | b'.' | b'_' | b'~') {
1201 encoded.push(char::from(*byte));
1202 } else {
1203 encoded.push_str(&format!("%{byte:02X}"));
1204 }
1205 }
1206 encoded
1207}
1208
1209fn is_standard_problem(value: &Value, status: StatusCode, schema: Option<&Value>) -> bool {
1210 let Some(required) = schema
1211 .and_then(|schema| schema.get("required"))
1212 .and_then(Value::as_array)
1213 else {
1214 return false;
1215 };
1216 required
1217 .iter()
1218 .filter_map(Value::as_str)
1219 .all(|field| value.get(field).is_some())
1220 && value.get("status").and_then(Value::as_u64) == Some(u64::from(status.as_u16()))
1221}
1222
1223fn resolve_local_schema(openapi: &Value, schema: &Value) -> Value {
1224 schema
1225 .get("$ref")
1226 .and_then(Value::as_str)
1227 .and_then(|reference| reference.strip_prefix('#'))
1228 .and_then(|pointer| openapi.pointer(pointer))
1229 .cloned()
1230 .unwrap_or_else(|| schema.clone())
1231}
1232
1233fn response_schemas(
1234 openapi: &Value,
1235 operation: &serde_json::Map<String, Value>,
1236) -> BTreeMap<String, Value> {
1237 operation
1238 .get("responses")
1239 .and_then(Value::as_object)
1240 .into_iter()
1241 .flatten()
1242 .filter_map(|(status, response)| {
1243 response
1244 .get("content")
1245 .and_then(Value::as_object)
1246 .and_then(|content| content.values().find_map(|media| media.get("schema")))
1247 .map(|schema| (status.clone(), resolve_local_schema(openapi, schema)))
1248 })
1249 .collect()
1250}
1251
1252fn now_ms() -> u64 {
1253 SystemTime::now()
1254 .duration_since(UNIX_EPOCH)
1255 .unwrap_or_default()
1256 .as_millis() as u64
1257}