Skip to main content

platform_provider/
router.rs

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