Skip to main content

platform_provider/
router.rs

1use crate::body_evidence::capture_json_body;
2use crate::invocation::{self, InvocationContext};
3use crate::protocol::{
4    ProviderHttpProxyInvokeRequest, ProviderHttpProxyInvokeResponse, ProviderInvocationMode,
5    ProviderOperationKind,
6};
7use crate::request::{ProxyRequestBody, apply_grpc_proxy_request_policy};
8use crate::{ProviderHttpProxyMatch, ProviderHttpProxyRegistry};
9use axum::Json;
10use axum::body::{Body, Bytes, to_bytes};
11use axum::extract::{Path, State};
12use axum::http::{HeaderMap, Request};
13use platform_core::{
14    AppContext, AppError, ErrorCode, ProviderHttpCallBodyEvidence, ProviderHttpCallRecord,
15    insert_provider_http_call_with_body_evidence,
16};
17use platform_http::{
18    AdminActor, ApiErrorResponse, ApiOpenApiRouter, ErrorResponse, HttpRequestContext,
19    OpenApiRouter, routes,
20};
21use platform_module::ModuleHttpMethod;
22use serde::{Serialize, Serializer};
23use serde_json::{Value, json};
24use std::collections::BTreeMap;
25use std::sync::{Arc, OnceLock, RwLock};
26use std::time::{Duration, Instant};
27use utoipa::ToSchema;
28
29static PROVIDER_HTTP_PROXY_REGISTRY: OnceLock<RwLock<Arc<ProviderHttpProxyRegistry>>> =
30    OnceLock::new();
31const MAX_PROXY_DELETE_REQUEST_BYTES: usize = 1024 * 1024;
32
33#[derive(Debug, Serialize, ToSchema)]
34pub struct ProviderHttpProxyResponse {
35    pub status: ProviderHttpProxyStatus,
36    pub module_name: String,
37    pub method: ModuleHttpMethod,
38    pub declared_path: String,
39    pub provider_path: String,
40    pub capability: String,
41    pub path_params: BTreeMap<String, String>,
42    pub data: Value,
43}
44
45#[derive(Debug, ToSchema)]
46#[serde(rename_all = "snake_case")]
47pub enum ProviderHttpProxyStatus {
48    Forwarded,
49}
50
51impl Serialize for ProviderHttpProxyStatus {
52    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
53    where
54        S: Serializer,
55    {
56        match self {
57            Self::Forwarded => serializer.serialize_str("forwarded"),
58        }
59    }
60}
61
62#[must_use]
63pub fn router() -> ApiOpenApiRouter {
64    OpenApiRouter::new()
65        .routes(routes!(proxy_get))
66        .routes(routes!(proxy_post))
67        .routes(routes!(proxy_put))
68        .routes(routes!(proxy_patch))
69        .routes(routes!(proxy_delete))
70}
71
72pub fn install_provider_http_proxy_registry(registry: ProviderHttpProxyRegistry) {
73    let storage = PROVIDER_HTTP_PROXY_REGISTRY
74        .get_or_init(|| RwLock::new(Arc::new(ProviderHttpProxyRegistry::from_modules(&[], &[]))));
75    *storage
76        .write()
77        .expect("provider HTTP proxy registry lock poisoned") = Arc::new(registry);
78}
79
80fn provider_http_proxy_registry() -> Arc<ProviderHttpProxyRegistry> {
81    PROVIDER_HTTP_PROXY_REGISTRY
82        .get()
83        .map(|storage| {
84            storage
85                .read()
86                .expect("provider HTTP proxy registry lock poisoned")
87                .clone()
88        })
89        .unwrap_or_else(|| Arc::new(ProviderHttpProxyRegistry::from_modules(&[], &[])))
90}
91
92#[utoipa::path(
93    get,
94    path = "/modules/{module}/http/{*path}",
95    operation_id = "service_module_http_proxy_get",
96    tag = "modules",
97    params(
98        ("module" = String, Path, description = "Configured Service-provided Module name"),
99        ("path" = String, Path, description = "Module-local HTTP path matched against the Service-provided manifest"),
100        ("authorization" = String, Header, description = "Development service bearer token")
101    ),
102    responses(
103        (status = 200, description = "Provider route forwarded through the host.", body = ProviderHttpProxyResponse, content_type = "application/json"),
104        (status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
105        (status = 403, description = "Service/system authentication or declared capability is required", body = ErrorResponse, content_type = "application/problem+json"),
106        (status = 404, description = "No configured Service-provided route matched", body = ErrorResponse, content_type = "application/problem+json"),
107        (status = 502, description = "Provider export request failed", body = ErrorResponse, content_type = "application/problem+json"),
108    )
109)]
110async fn proxy_get(
111    State(ctx): State<AppContext>,
112    admin: AdminActor,
113    HttpRequestContext(request_ctx): HttpRequestContext,
114    headers: HeaderMap,
115    Path((module, path)): Path<(String, String)>,
116) -> Result<Json<ProviderHttpProxyResponse>, ApiErrorResponse> {
117    let request_path = format!("/{path}");
118    let matched = provider_http_proxy_registry()
119        .match_route(&module, ModuleHttpMethod::Get, &request_path)
120        .ok_or_else(|| {
121            ApiErrorResponse::with_context(
122                AppError::new(
123                    ErrorCode::NotFound,
124                    format!("provider HTTP route not found: {module}{request_path}"),
125                ),
126                &request_ctx,
127            )
128        })?;
129
130    ensure_capability(&admin, &matched, &request_ctx)?;
131    let data = forward_get(&ctx, &matched, &headers, &request_ctx).await?;
132    Ok(Json(ProviderHttpProxyResponse::from_match(matched, data)))
133}
134
135#[utoipa::path(
136    post,
137    path = "/modules/{module}/http/{*path}",
138    operation_id = "service_module_http_proxy_post",
139    tag = "modules",
140    request_body(
141        content = Value,
142        content_type = "application/json",
143        description = "JSON request body forwarded to the matched Service-provided Module route"
144    ),
145    params(
146        ("module" = String, Path, description = "Configured Service-provided Module name"),
147        ("path" = String, Path, description = "Module-local HTTP path matched against the Service-provided manifest"),
148        ("authorization" = String, Header, description = "Development service bearer token")
149    ),
150    responses(
151        (status = 200, description = "Provider route forwarded through the host.", body = ProviderHttpProxyResponse, content_type = "application/json"),
152        (status = 400, description = "Request body policy rejected the request", body = ErrorResponse, content_type = "application/problem+json"),
153        (status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
154        (status = 403, description = "Service/system authentication or declared capability is required", body = ErrorResponse, content_type = "application/problem+json"),
155        (status = 404, description = "No configured Service-provided route matched", body = ErrorResponse, content_type = "application/problem+json"),
156        (status = 502, description = "Provider export request failed", body = ErrorResponse, content_type = "application/problem+json"),
157    )
158)]
159async fn proxy_post(
160    State(ctx): State<AppContext>,
161    admin: AdminActor,
162    HttpRequestContext(request_ctx): HttpRequestContext,
163    headers: HeaderMap,
164    Path((module, path)): Path<(String, String)>,
165    body: Bytes,
166) -> Result<Json<ProviderHttpProxyResponse>, ApiErrorResponse> {
167    proxy_body_method(
168        ModuleHttpMethod::Post,
169        ctx,
170        admin,
171        request_ctx,
172        headers,
173        module,
174        path,
175        body,
176    )
177    .await
178}
179
180#[utoipa::path(
181    put,
182    path = "/modules/{module}/http/{*path}",
183    operation_id = "service_module_http_proxy_put",
184    tag = "modules",
185    request_body(
186        content = Value,
187        content_type = "application/json",
188        description = "JSON request body forwarded to the matched Service-provided Module route"
189    ),
190    params(
191        ("module" = String, Path, description = "Configured Service-provided Module name"),
192        ("path" = String, Path, description = "Module-local HTTP path matched against the Service-provided manifest"),
193        ("authorization" = String, Header, description = "Development service bearer token")
194    ),
195    responses(
196        (status = 200, description = "Provider route forwarded through the host.", body = ProviderHttpProxyResponse, content_type = "application/json"),
197        (status = 400, description = "Request body policy rejected the request", body = ErrorResponse, content_type = "application/problem+json"),
198        (status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
199        (status = 403, description = "Service/system authentication or declared capability is required", body = ErrorResponse, content_type = "application/problem+json"),
200        (status = 404, description = "No configured Service-provided route matched", body = ErrorResponse, content_type = "application/problem+json"),
201        (status = 502, description = "Provider export request failed", body = ErrorResponse, content_type = "application/problem+json"),
202    )
203)]
204async fn proxy_put(
205    State(ctx): State<AppContext>,
206    admin: AdminActor,
207    HttpRequestContext(request_ctx): HttpRequestContext,
208    headers: HeaderMap,
209    Path((module, path)): Path<(String, String)>,
210    body: Bytes,
211) -> Result<Json<ProviderHttpProxyResponse>, ApiErrorResponse> {
212    proxy_body_method(
213        ModuleHttpMethod::Put,
214        ctx,
215        admin,
216        request_ctx,
217        headers,
218        module,
219        path,
220        body,
221    )
222    .await
223}
224
225#[utoipa::path(
226    patch,
227    path = "/modules/{module}/http/{*path}",
228    operation_id = "service_module_http_proxy_patch",
229    tag = "modules",
230    request_body(
231        content = Value,
232        content_type = "application/json",
233        description = "JSON request body forwarded to the matched Service-provided Module route"
234    ),
235    params(
236        ("module" = String, Path, description = "Configured Service-provided Module name"),
237        ("path" = String, Path, description = "Module-local HTTP path matched against the Service-provided manifest"),
238        ("authorization" = String, Header, description = "Development service bearer token")
239    ),
240    responses(
241        (status = 200, description = "Provider route forwarded through the host.", body = ProviderHttpProxyResponse, content_type = "application/json"),
242        (status = 400, description = "Request body policy rejected the request", body = ErrorResponse, content_type = "application/problem+json"),
243        (status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
244        (status = 403, description = "Service/system authentication or declared capability is required", body = ErrorResponse, content_type = "application/problem+json"),
245        (status = 404, description = "No configured Service-provided route matched", body = ErrorResponse, content_type = "application/problem+json"),
246        (status = 502, description = "Provider export request failed", body = ErrorResponse, content_type = "application/problem+json"),
247    )
248)]
249async fn proxy_patch(
250    State(ctx): State<AppContext>,
251    admin: AdminActor,
252    HttpRequestContext(request_ctx): HttpRequestContext,
253    headers: HeaderMap,
254    Path((module, path)): Path<(String, String)>,
255    body: Bytes,
256) -> Result<Json<ProviderHttpProxyResponse>, ApiErrorResponse> {
257    proxy_body_method(
258        ModuleHttpMethod::Patch,
259        ctx,
260        admin,
261        request_ctx,
262        headers,
263        module,
264        path,
265        body,
266    )
267    .await
268}
269
270#[utoipa::path(
271    delete,
272    path = "/modules/{module}/http/{*path}",
273    operation_id = "service_module_http_proxy_delete",
274    tag = "modules",
275    params(
276        ("module" = String, Path, description = "Configured Service-provided Module name"),
277        ("path" = String, Path, description = "Module-local HTTP path matched against the Service-provided manifest"),
278        ("authorization" = String, Header, description = "Development service bearer token")
279    ),
280    responses(
281        (status = 200, description = "Provider route forwarded through the host.", body = ProviderHttpProxyResponse, content_type = "application/json"),
282        (status = 400, description = "Request body policy rejected the request", body = ErrorResponse, content_type = "application/problem+json"),
283        (status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
284        (status = 403, description = "Service/system authentication or declared capability is required", body = ErrorResponse, content_type = "application/problem+json"),
285        (status = 404, description = "No configured Service-provided route matched", body = ErrorResponse, content_type = "application/problem+json"),
286        (status = 502, description = "Provider export request failed", body = ErrorResponse, content_type = "application/problem+json"),
287    )
288)]
289async fn proxy_delete(
290    State(ctx): State<AppContext>,
291    admin: AdminActor,
292    HttpRequestContext(request_ctx): HttpRequestContext,
293    Path((module, path)): Path<(String, String)>,
294    request: Request<Body>,
295) -> Result<Json<ProviderHttpProxyResponse>, ApiErrorResponse> {
296    let (parts, body) = request.into_parts();
297    let body = to_bytes(body, MAX_PROXY_DELETE_REQUEST_BYTES)
298        .await
299        .map_err(|error| {
300            ApiErrorResponse::with_context(
301                AppError::new(
302                    ErrorCode::Validation,
303                    format!("provider HTTP proxy DELETE request body could not be read: {error}"),
304                ),
305                &request_ctx,
306            )
307        })?;
308    if !body.is_empty() {
309        return Err(ApiErrorResponse::with_context(
310            AppError::new(
311                ErrorCode::Validation,
312                "provider HTTP proxy DELETE request body must be empty",
313            ),
314            &request_ctx,
315        ));
316    }
317
318    let request_path = format!("/{path}");
319    let matched = provider_http_proxy_registry()
320        .match_route(&module, ModuleHttpMethod::Delete, &request_path)
321        .ok_or_else(|| {
322            ApiErrorResponse::with_context(
323                AppError::new(
324                    ErrorCode::NotFound,
325                    format!("provider HTTP route not found: {module}{request_path}"),
326                ),
327                &request_ctx,
328            )
329        })?;
330
331    ensure_capability(&admin, &matched, &request_ctx)?;
332    let data = forward_delete(&ctx, &matched, &parts.headers, &request_ctx).await?;
333    Ok(Json(ProviderHttpProxyResponse::from_match(matched, data)))
334}
335
336async fn proxy_body_method(
337    method: ModuleHttpMethod,
338    ctx: AppContext,
339    admin: AdminActor,
340    request_ctx: platform_core::RequestContext,
341    headers: HeaderMap,
342    module: String,
343    path: String,
344    body: Bytes,
345) -> Result<Json<ProviderHttpProxyResponse>, ApiErrorResponse> {
346    let request_path = format!("/{path}");
347    let matched = provider_http_proxy_registry()
348        .match_route(&module, method, &request_path)
349        .ok_or_else(|| {
350            ApiErrorResponse::with_context(
351                AppError::new(
352                    ErrorCode::NotFound,
353                    format!("provider HTTP route not found: {module}{request_path}"),
354                ),
355                &request_ctx,
356            )
357        })?;
358
359    ensure_capability(&admin, &matched, &request_ctx)?;
360    let data = forward_body_method(method, &ctx, &matched, &headers, body, &request_ctx).await?;
361    Ok(Json(ProviderHttpProxyResponse::from_match(matched, data)))
362}
363
364fn ensure_capability(
365    admin: &AdminActor,
366    matched: &ProviderHttpProxyMatch,
367    request_ctx: &platform_core::RequestContext,
368) -> Result<(), ApiErrorResponse> {
369    let Some(capability) = matched.capability.as_deref() else {
370        return Err(ApiErrorResponse::with_context(
371            AppError::new(
372                ErrorCode::Forbidden,
373                "provider HTTP route has no declared capability",
374            ),
375            request_ctx,
376        ));
377    };
378
379    match admin {
380        AdminActor::System => Ok(()),
381        AdminActor::Service { scopes, .. } | AdminActor::User { scopes, .. }
382            if scopes.iter().any(|scope| scope == capability) =>
383        {
384            Ok(())
385        }
386        AdminActor::Service { .. } | AdminActor::User { .. } => {
387            Err(ApiErrorResponse::with_context(
388                AppError::new(
389                    ErrorCode::Forbidden,
390                    format!("missing provider HTTP route capability: {capability}"),
391                ),
392                request_ctx,
393            ))
394        }
395    }
396}
397
398#[derive(Debug, Clone)]
399struct ProxyForwardRequest<'a> {
400    ctx: &'a AppContext,
401    matched: &'a ProviderHttpProxyMatch,
402    method: ModuleHttpMethod,
403    headers: &'a HeaderMap,
404    request_ctx: &'a platform_core::RequestContext,
405    body: ProxyRequestBody,
406}
407
408async fn forward_get(
409    ctx: &AppContext,
410    matched: &ProviderHttpProxyMatch,
411    headers: &HeaderMap,
412    request_ctx: &platform_core::RequestContext,
413) -> Result<Value, ApiErrorResponse> {
414    forward_proxy_request(ProxyForwardRequest {
415        ctx,
416        matched,
417        method: ModuleHttpMethod::Get,
418        headers,
419        request_ctx,
420        body: ProxyRequestBody::Empty,
421    })
422    .await
423}
424
425async fn forward_body_method(
426    method: ModuleHttpMethod,
427    ctx: &AppContext,
428    matched: &ProviderHttpProxyMatch,
429    headers: &HeaderMap,
430    body: Bytes,
431    request_ctx: &platform_core::RequestContext,
432) -> Result<Value, ApiErrorResponse> {
433    forward_proxy_request(ProxyForwardRequest {
434        ctx,
435        matched,
436        method,
437        headers,
438        request_ctx,
439        body: ProxyRequestBody::Json(body),
440    })
441    .await
442}
443
444async fn forward_delete(
445    ctx: &AppContext,
446    matched: &ProviderHttpProxyMatch,
447    headers: &HeaderMap,
448    request_ctx: &platform_core::RequestContext,
449) -> Result<Value, ApiErrorResponse> {
450    forward_proxy_request(ProxyForwardRequest {
451        ctx,
452        matched,
453        method: ModuleHttpMethod::Delete,
454        headers,
455        request_ctx,
456        body: ProxyRequestBody::Empty,
457    })
458    .await
459}
460
461async fn forward_proxy_request(
462    request: ProxyForwardRequest<'_>,
463) -> Result<Value, ApiErrorResponse> {
464    forward_provider_invocation(request).await
465}
466
467async fn forward_provider_invocation(
468    request: ProxyForwardRequest<'_>,
469) -> Result<Value, ApiErrorResponse> {
470    let started_at = Instant::now();
471    let matched = request.matched;
472    let request_ctx = request.request_ctx;
473    let parts =
474        apply_grpc_proxy_request_policy(request.method, request.headers, request_ctx, request.body)
475            .map_err(|error| ApiErrorResponse::with_context(error, request_ctx))?;
476    let payload = ProviderHttpProxyInvokeRequest {
477        request_id: request_ctx.request_id.0.clone(),
478        correlation_id: request_ctx.correlation_id.0.clone(),
479        module_name: matched.module_name.clone(),
480        method: module_http_method_label(request.method).to_owned(),
481        declared_path: matched.declared_path.clone(),
482        provider_path: matched.provider_path.clone(),
483        path_params: matched.path_params.clone(),
484        headers: parts.headers,
485        body: parts.body,
486    };
487    let request_body_evidence = capture_json_body(payload.body.as_ref(), "method_without_body");
488    let invocation = invocation::build(
489        &matched.config,
490        ProviderOperationKind::HttpRoute,
491        format!("{} {}", payload.method, payload.declared_path),
492        "1",
493        if matches!(request.method, ModuleHttpMethod::Get) {
494            ProviderInvocationMode::ReadOnly
495        } else {
496            ProviderInvocationMode::Durable
497        },
498        InvocationContext {
499            invocation_id: request_ctx.request_id.0.clone(),
500            request_id: request_ctx.request_id.0.clone(),
501            attempt: 1,
502            actor: request_ctx.actor.clone(),
503            tenant_id: request_ctx
504                .tenant_id
505                .as_ref()
506                .map(|tenant| tenant.0.clone()),
507            correlation_id: request_ctx.correlation_id.0.clone(),
508            causation_id: request_ctx.causation_id.clone(),
509            trace: request_ctx.trace.clone(),
510        },
511        serde_json::to_value(payload).map_err(|error| {
512            ApiErrorResponse::with_context(
513                AppError::new(
514                    ErrorCode::Internal,
515                    format!("encode Provider payload: {error}"),
516                ),
517                request_ctx,
518            )
519        })?,
520    )
521    .map_err(|error| ApiErrorResponse::with_context(error, request_ctx))?;
522    let client = reqwest::Client::builder()
523        .timeout(Duration::from_millis(matched.timeout_ms))
524        .build()
525        .map_err(|error| {
526            ApiErrorResponse::with_context(
527                AppError::new(
528                    ErrorCode::Internal,
529                    format!("build Provider client: {error}"),
530                ),
531                request_ctx,
532            )
533        })?;
534    let effects = crate::ProviderHostEffectCoordinator::new(request.ctx.db.clone());
535    let outcome = invocation::send(
536        &client,
537        &matched.config,
538        &effects,
539        "http:invoke",
540        &invocation,
541    )
542    .await
543    .map_err(|error| ApiErrorResponse::with_context(error, request_ctx))?;
544    let value = invocation::result(&invocation, outcome)
545        .map_err(|error| ApiErrorResponse::with_context(error, request_ctx))?;
546    let (response, provider_status) = decode_http_proxy_result(value)
547        .map_err(|error| ApiErrorResponse::with_context(error, request_ctx))?;
548    let response_body_evidence = capture_json_body(response.body.as_ref(), "empty_response_body");
549    record_proxy_call(
550        request.ctx,
551        matched,
552        request_ctx,
553        started_at,
554        Some(provider_status),
555        None,
556        ProviderHttpCallBodyEvidence {
557            request: request_body_evidence,
558            response: response_body_evidence,
559        },
560    )
561    .await;
562    Ok(response.body.unwrap_or(Value::Null))
563}
564
565async fn record_proxy_call(
566    ctx: &AppContext,
567    matched: &ProviderHttpProxyMatch,
568    request_ctx: &platform_core::RequestContext,
569    started_at: Instant,
570    provider_status: Option<reqwest::StatusCode>,
571    error: Option<&AppError>,
572    body_evidence: ProviderHttpCallBodyEvidence,
573) {
574    let duration_ms = started_at.elapsed().as_millis().min(i64::MAX as u128) as i64;
575    match error {
576        Some(error) => {
577            tracing::warn!(
578                module_name = %matched.module_name,
579                declared_path = %matched.declared_path,
580                provider_path = %matched.provider_path,
581                http_method = %module_http_method_label(matched.method),
582                provider_status = provider_status.map_or(0, |status| status.as_u16()),
583                duration_ms,
584                error_code = error.code.as_str(),
585                retryable = error.retryable,
586                request_id = %request_ctx.request_id.0,
587                correlation_id = %request_ctx.correlation_id.0,
588                "provider HTTP proxy call failed"
589            );
590        }
591        None => {
592            tracing::info!(
593                module_name = %matched.module_name,
594                declared_path = %matched.declared_path,
595                provider_path = %matched.provider_path,
596                http_method = %module_http_method_label(matched.method),
597                provider_status = provider_status.map_or(0, |status| status.as_u16()),
598                duration_ms,
599                request_id = %request_ctx.request_id.0,
600                correlation_id = %request_ctx.correlation_id.0,
601                "provider HTTP proxy call completed"
602            );
603        }
604    }
605
606    let record = ProviderHttpCallRecord {
607        module_name: matched.module_name.clone(),
608        method: module_http_method_label(matched.method).to_owned(),
609        declared_path: matched.declared_path.clone(),
610        provider_path: matched.provider_path.clone(),
611        capability: matched.capability.clone(),
612        display_name: matched.display_name.clone(),
613        story_title: matched.story_title.clone(),
614        provider_status: provider_status.map(|status| status.as_u16()),
615        duration_ms,
616        success: error.is_none(),
617        error_code: error.map(|error| error.code.as_str().to_owned()),
618        retryable: error.is_some_and(|error| error.retryable),
619        path_params: json!(matched.path_params),
620        error_details: error
621            .map(|error| json!(error.details))
622            .unwrap_or_else(|| Value::Array(Vec::new())),
623    };
624
625    if let Err(error) = insert_provider_http_call_with_body_evidence(
626        &ctx.db,
627        ctx.ids.as_ref(),
628        request_ctx,
629        record,
630        body_evidence,
631    )
632    .await
633    {
634        tracing::warn!(
635            error = ?error,
636            module_name = %matched.module_name,
637            declared_path = %matched.declared_path,
638            provider_path = %matched.provider_path,
639            http_method = %module_http_method_label(matched.method),
640            request_id = %request_ctx.request_id.0,
641            correlation_id = %request_ctx.correlation_id.0,
642            "failed to persist provider HTTP proxy call"
643        );
644    }
645}
646
647fn decode_http_proxy_result(
648    value: Value,
649) -> platform_core::AppResult<(ProviderHttpProxyInvokeResponse, reqwest::StatusCode)> {
650    let response: ProviderHttpProxyInvokeResponse =
651        serde_json::from_value(value).map_err(|error| {
652            AppError::new(
653                ErrorCode::ExternalDependency,
654                format!("Provider HTTP result violated its contract: {error}"),
655            )
656        })?;
657    let provider_status = reqwest::StatusCode::from_u16(response.status_code)
658        .map_err(|error| AppError::new(ErrorCode::ExternalDependency, error.to_string()))?;
659    Ok((response, provider_status))
660}
661
662fn module_http_method_label(method: ModuleHttpMethod) -> &'static str {
663    match method {
664        ModuleHttpMethod::Get => "GET",
665        ModuleHttpMethod::Post => "POST",
666        ModuleHttpMethod::Put => "PUT",
667        ModuleHttpMethod::Patch => "PATCH",
668        ModuleHttpMethod::Delete => "DELETE",
669        _ => "UNKNOWN",
670    }
671}
672
673impl ProviderHttpProxyResponse {
674    fn from_match(matched: ProviderHttpProxyMatch, data: Value) -> Self {
675        Self {
676            status: ProviderHttpProxyStatus::Forwarded,
677            module_name: matched.module_name,
678            method: matched.method,
679            declared_path: matched.declared_path,
680            provider_path: matched.provider_path,
681            capability: matched.capability.unwrap_or_default(),
682            path_params: matched.path_params,
683            data,
684        }
685    }
686}