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            correlation_id: request_ctx.correlation_id.0.clone(),
501            causation_id: request_ctx.causation_id.clone(),
502            trace: request_ctx.trace.clone(),
503        },
504        serde_json::to_value(payload).map_err(|error| {
505            ApiErrorResponse::with_context(
506                AppError::new(
507                    ErrorCode::Internal,
508                    format!("encode Provider payload: {error}"),
509                ),
510                request_ctx,
511            )
512        })?,
513    )
514    .map_err(|error| ApiErrorResponse::with_context(error, request_ctx))?;
515    let client = reqwest::Client::builder()
516        .timeout(Duration::from_millis(matched.timeout_ms))
517        .build()
518        .map_err(|error| {
519            ApiErrorResponse::with_context(
520                AppError::new(
521                    ErrorCode::Internal,
522                    format!("build Provider client: {error}"),
523                ),
524                request_ctx,
525            )
526        })?;
527    let effects = crate::ProviderHostEffectCoordinator::new(request.ctx.db.clone());
528    let outcome = invocation::send(
529        &client,
530        &matched.config,
531        &effects,
532        "http:invoke",
533        &invocation,
534    )
535    .await
536    .map_err(|error| ApiErrorResponse::with_context(error, request_ctx))?;
537    let value = invocation::result(&invocation, outcome)
538        .map_err(|error| ApiErrorResponse::with_context(error, request_ctx))?;
539    let response: ProviderHttpProxyInvokeResponse =
540        serde_json::from_value(value).map_err(|error| {
541            ApiErrorResponse::with_context(
542                AppError::new(
543                    ErrorCode::ExternalDependency,
544                    format!("Provider HTTP result violated its contract: {error}"),
545                ),
546                request_ctx,
547            )
548        })?;
549    let provider_status = reqwest::StatusCode::from_u16(response.status_code).map_err(|error| {
550        ApiErrorResponse::with_context(
551            AppError::new(ErrorCode::ExternalDependency, error.to_string()),
552            request_ctx,
553        )
554    })?;
555    record_proxy_call(
556        request.ctx,
557        matched,
558        request_ctx,
559        started_at,
560        Some(provider_status),
561        None,
562    )
563    .await;
564    Ok(response.body.unwrap_or(Value::Null))
565}
566
567async fn record_proxy_call(
568    ctx: &AppContext,
569    matched: &ProviderHttpProxyMatch,
570    request_ctx: &platform_core::RequestContext,
571    started_at: Instant,
572    provider_status: Option<reqwest::StatusCode>,
573    error: Option<&AppError>,
574) {
575    let duration_ms = started_at.elapsed().as_millis().min(i64::MAX as u128) as i64;
576    match error {
577        Some(error) => {
578            tracing::warn!(
579                module_name = %matched.module_name,
580                declared_path = %matched.declared_path,
581                provider_path = %matched.provider_path,
582                http_method = %module_http_method_label(matched.method),
583                provider_status = provider_status.map_or(0, |status| status.as_u16()),
584                duration_ms,
585                error_code = error.code.as_str(),
586                retryable = error.retryable,
587                request_id = %request_ctx.request_id.0,
588                correlation_id = %request_ctx.correlation_id.0,
589                "provider HTTP proxy call failed"
590            );
591        }
592        None => {
593            tracing::info!(
594                module_name = %matched.module_name,
595                declared_path = %matched.declared_path,
596                provider_path = %matched.provider_path,
597                http_method = %module_http_method_label(matched.method),
598                provider_status = provider_status.map_or(0, |status| status.as_u16()),
599                duration_ms,
600                request_id = %request_ctx.request_id.0,
601                correlation_id = %request_ctx.correlation_id.0,
602                "provider HTTP proxy call completed"
603            );
604        }
605    }
606
607    let record = ProviderHttpCallRecord {
608        module_name: matched.module_name.clone(),
609        method: module_http_method_label(matched.method).to_owned(),
610        declared_path: matched.declared_path.clone(),
611        provider_path: matched.provider_path.clone(),
612        capability: matched.capability.clone(),
613        display_name: matched.display_name.clone(),
614        story_title: matched.story_title.clone(),
615        provider_status: provider_status.map(|status| status.as_u16()),
616        duration_ms,
617        success: error.is_none(),
618        error_code: error.map(|error| error.code.as_str().to_owned()),
619        retryable: error.is_some_and(|error| error.retryable),
620        path_params: json!(matched.path_params),
621        error_details: error
622            .map(|error| json!(error.details))
623            .unwrap_or_else(|| Value::Array(Vec::new())),
624    };
625
626    if let Err(error) =
627        insert_provider_http_call(&ctx.db, ctx.ids.as_ref(), request_ctx, record).await
628    {
629        tracing::warn!(
630            error = ?error,
631            module_name = %matched.module_name,
632            declared_path = %matched.declared_path,
633            provider_path = %matched.provider_path,
634            http_method = %module_http_method_label(matched.method),
635            request_id = %request_ctx.request_id.0,
636            correlation_id = %request_ctx.correlation_id.0,
637            "failed to persist provider HTTP proxy call"
638        );
639    }
640}
641
642fn module_http_method_label(method: ModuleHttpMethod) -> &'static str {
643    match method {
644        ModuleHttpMethod::Get => "GET",
645        ModuleHttpMethod::Post => "POST",
646        ModuleHttpMethod::Put => "PUT",
647        ModuleHttpMethod::Patch => "PATCH",
648        ModuleHttpMethod::Delete => "DELETE",
649        _ => "UNKNOWN",
650    }
651}
652
653impl ProviderHttpProxyResponse {
654    fn from_match(matched: ProviderHttpProxyMatch, data: Value) -> Self {
655        Self {
656            status: ProviderHttpProxyStatus::Forwarded,
657            module_name: matched.module_name,
658            method: matched.method,
659            declared_path: matched.declared_path,
660            provider_path: matched.provider_path,
661            capability: matched.capability.unwrap_or_default(),
662            path_params: matched.path_params,
663            data,
664        }
665    }
666}