Skip to main content

platform_module_remote/
router.rs

1use crate::config::{RemoteModuleConfig, RemoteModuleTransport};
2use crate::protocol::{RemoteErrorEnvelope, RemoteHttpProxyInvokeRequest};
3use crate::request::{
4    ProxyRequestBody, apply_grpc_proxy_request_policy, apply_proxy_request_policy,
5};
6use crate::response::ResponseBodyPolicy;
7use crate::{RemoteHttpProxyMatch, RemoteHttpProxyRegistry};
8use axum::Json;
9use axum::body::{Body, Bytes, to_bytes};
10use axum::extract::{Path, State};
11use axum::http::{HeaderMap, Request};
12use platform_core::error::ErrorDetail;
13use platform_core::{
14    AppContext, AppError, ErrorCode, RemoteHttpProxyCallRecord, insert_remote_http_proxy_call,
15};
16use platform_http::{
17    AdminActor, ApiErrorResponse, ApiOpenApiRouter, ErrorResponse, HttpRequestContext,
18    OpenApiRouter, routes,
19};
20use platform_module::ModuleHttpMethod;
21use serde::{Serialize, Serializer};
22use serde_json::{Value, json};
23use std::collections::BTreeMap;
24use std::sync::{Arc, OnceLock, RwLock};
25use std::time::{Duration, Instant};
26use utoipa::ToSchema;
27
28static REMOTE_HTTP_PROXY_REGISTRY: OnceLock<RwLock<Arc<RemoteHttpProxyRegistry>>> = OnceLock::new();
29const MAX_PROXY_RESPONSE_BYTES: u64 = 4 * 1024 * 1024;
30const MAX_PROXY_DELETE_REQUEST_BYTES: usize = 1024 * 1024;
31
32#[derive(Debug, Serialize, ToSchema)]
33pub struct RemoteHttpProxyResponse {
34    pub status: RemoteHttpProxyStatus,
35    pub module_name: String,
36    pub method: ModuleHttpMethod,
37    pub declared_path: String,
38    pub remote_path: String,
39    pub capability: String,
40    pub path_params: BTreeMap<String, String>,
41    pub data: Value,
42}
43
44#[derive(Debug, ToSchema)]
45#[serde(rename_all = "snake_case")]
46pub enum RemoteHttpProxyStatus {
47    Forwarded,
48}
49
50impl Serialize for RemoteHttpProxyStatus {
51    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
52    where
53        S: Serializer,
54    {
55        match self {
56            Self::Forwarded => serializer.serialize_str("forwarded"),
57        }
58    }
59}
60
61#[must_use]
62pub fn router() -> ApiOpenApiRouter {
63    OpenApiRouter::new()
64        .routes(routes!(proxy_get))
65        .routes(routes!(proxy_post))
66        .routes(routes!(proxy_put))
67        .routes(routes!(proxy_patch))
68        .routes(routes!(proxy_delete))
69}
70
71pub fn install_remote_http_proxy_registry(registry: RemoteHttpProxyRegistry) {
72    let storage = REMOTE_HTTP_PROXY_REGISTRY
73        .get_or_init(|| RwLock::new(Arc::new(RemoteHttpProxyRegistry::from_modules(&[], &[]))));
74    *storage
75        .write()
76        .expect("remote HTTP proxy registry lock poisoned") = Arc::new(registry);
77}
78
79fn remote_http_proxy_registry() -> Arc<RemoteHttpProxyRegistry> {
80    REMOTE_HTTP_PROXY_REGISTRY
81        .get()
82        .map(|storage| {
83            storage
84                .read()
85                .expect("remote HTTP proxy registry lock poisoned")
86                .clone()
87        })
88        .unwrap_or_else(|| Arc::new(RemoteHttpProxyRegistry::from_modules(&[], &[])))
89}
90
91#[utoipa::path(
92    get,
93    path = "/modules/{module}/http/{*path}",
94    operation_id = "remote_module_http_proxy_get",
95    tag = "modules",
96    params(
97        ("module" = String, Path, description = "Configured remote module name"),
98        ("path" = String, Path, description = "Module-local HTTP path matched against the remote manifest"),
99        ("authorization" = String, Header, description = "Development service bearer token")
100    ),
101    responses(
102        (status = 200, description = "Remote route forwarded through the host.", body = RemoteHttpProxyResponse, content_type = "application/json"),
103        (status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
104        (status = 403, description = "Service/system authentication or declared capability is required", body = ErrorResponse, content_type = "application/problem+json"),
105        (status = 404, description = "No configured remote route matched", body = ErrorResponse, content_type = "application/problem+json"),
106        (status = 502, description = "Remote module request failed", body = ErrorResponse, content_type = "application/problem+json"),
107    )
108)]
109async fn proxy_get(
110    State(ctx): State<AppContext>,
111    admin: AdminActor,
112    HttpRequestContext(request_ctx): HttpRequestContext,
113    headers: HeaderMap,
114    Path((module, path)): Path<(String, String)>,
115) -> Result<Json<RemoteHttpProxyResponse>, ApiErrorResponse> {
116    let request_path = format!("/{path}");
117    let matched = remote_http_proxy_registry()
118        .match_route(&module, ModuleHttpMethod::Get, &request_path)
119        .ok_or_else(|| {
120            ApiErrorResponse::with_context(
121                AppError::new(
122                    ErrorCode::NotFound,
123                    format!("remote HTTP route not found: {module}{request_path}"),
124                ),
125                &request_ctx,
126            )
127        })?;
128
129    ensure_capability(&admin, &matched, &request_ctx)?;
130    let data = forward_get(&ctx, &matched, &headers, &request_ctx).await?;
131    Ok(Json(RemoteHttpProxyResponse::from_match(matched, data)))
132}
133
134#[utoipa::path(
135    post,
136    path = "/modules/{module}/http/{*path}",
137    operation_id = "remote_module_http_proxy_post",
138    tag = "modules",
139    request_body(
140        content = Value,
141        content_type = "application/json",
142        description = "JSON request body forwarded to the matched remote module route"
143    ),
144    params(
145        ("module" = String, Path, description = "Configured remote module name"),
146        ("path" = String, Path, description = "Module-local HTTP path matched against the remote manifest"),
147        ("authorization" = String, Header, description = "Development service bearer token")
148    ),
149    responses(
150        (status = 200, description = "Remote route forwarded through the host.", body = RemoteHttpProxyResponse, content_type = "application/json"),
151        (status = 400, description = "Request body policy rejected the request", body = ErrorResponse, content_type = "application/problem+json"),
152        (status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
153        (status = 403, description = "Service/system authentication or declared capability is required", body = ErrorResponse, content_type = "application/problem+json"),
154        (status = 404, description = "No configured remote route matched", body = ErrorResponse, content_type = "application/problem+json"),
155        (status = 502, description = "Remote module request failed", body = ErrorResponse, content_type = "application/problem+json"),
156    )
157)]
158async fn proxy_post(
159    State(ctx): State<AppContext>,
160    admin: AdminActor,
161    HttpRequestContext(request_ctx): HttpRequestContext,
162    headers: HeaderMap,
163    Path((module, path)): Path<(String, String)>,
164    body: Bytes,
165) -> Result<Json<RemoteHttpProxyResponse>, ApiErrorResponse> {
166    proxy_body_method(
167        ModuleHttpMethod::Post,
168        ctx,
169        admin,
170        request_ctx,
171        headers,
172        module,
173        path,
174        body,
175    )
176    .await
177}
178
179#[utoipa::path(
180    put,
181    path = "/modules/{module}/http/{*path}",
182    operation_id = "remote_module_http_proxy_put",
183    tag = "modules",
184    request_body(
185        content = Value,
186        content_type = "application/json",
187        description = "JSON request body forwarded to the matched remote module route"
188    ),
189    params(
190        ("module" = String, Path, description = "Configured remote module name"),
191        ("path" = String, Path, description = "Module-local HTTP path matched against the remote manifest"),
192        ("authorization" = String, Header, description = "Development service bearer token")
193    ),
194    responses(
195        (status = 200, description = "Remote route forwarded through the host.", body = RemoteHttpProxyResponse, content_type = "application/json"),
196        (status = 400, description = "Request body policy rejected the request", body = ErrorResponse, content_type = "application/problem+json"),
197        (status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
198        (status = 403, description = "Service/system authentication or declared capability is required", body = ErrorResponse, content_type = "application/problem+json"),
199        (status = 404, description = "No configured remote route matched", body = ErrorResponse, content_type = "application/problem+json"),
200        (status = 502, description = "Remote module request failed", body = ErrorResponse, content_type = "application/problem+json"),
201    )
202)]
203async fn proxy_put(
204    State(ctx): State<AppContext>,
205    admin: AdminActor,
206    HttpRequestContext(request_ctx): HttpRequestContext,
207    headers: HeaderMap,
208    Path((module, path)): Path<(String, String)>,
209    body: Bytes,
210) -> Result<Json<RemoteHttpProxyResponse>, ApiErrorResponse> {
211    proxy_body_method(
212        ModuleHttpMethod::Put,
213        ctx,
214        admin,
215        request_ctx,
216        headers,
217        module,
218        path,
219        body,
220    )
221    .await
222}
223
224#[utoipa::path(
225    patch,
226    path = "/modules/{module}/http/{*path}",
227    operation_id = "remote_module_http_proxy_patch",
228    tag = "modules",
229    request_body(
230        content = Value,
231        content_type = "application/json",
232        description = "JSON request body forwarded to the matched remote module route"
233    ),
234    params(
235        ("module" = String, Path, description = "Configured remote module name"),
236        ("path" = String, Path, description = "Module-local HTTP path matched against the remote manifest"),
237        ("authorization" = String, Header, description = "Development service bearer token")
238    ),
239    responses(
240        (status = 200, description = "Remote route forwarded through the host.", body = RemoteHttpProxyResponse, content_type = "application/json"),
241        (status = 400, description = "Request body policy rejected the request", body = ErrorResponse, content_type = "application/problem+json"),
242        (status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
243        (status = 403, description = "Service/system authentication or declared capability is required", body = ErrorResponse, content_type = "application/problem+json"),
244        (status = 404, description = "No configured remote route matched", body = ErrorResponse, content_type = "application/problem+json"),
245        (status = 502, description = "Remote module request failed", body = ErrorResponse, content_type = "application/problem+json"),
246    )
247)]
248async fn proxy_patch(
249    State(ctx): State<AppContext>,
250    admin: AdminActor,
251    HttpRequestContext(request_ctx): HttpRequestContext,
252    headers: HeaderMap,
253    Path((module, path)): Path<(String, String)>,
254    body: Bytes,
255) -> Result<Json<RemoteHttpProxyResponse>, ApiErrorResponse> {
256    proxy_body_method(
257        ModuleHttpMethod::Patch,
258        ctx,
259        admin,
260        request_ctx,
261        headers,
262        module,
263        path,
264        body,
265    )
266    .await
267}
268
269#[utoipa::path(
270    delete,
271    path = "/modules/{module}/http/{*path}",
272    operation_id = "remote_module_http_proxy_delete",
273    tag = "modules",
274    params(
275        ("module" = String, Path, description = "Configured remote module name"),
276        ("path" = String, Path, description = "Module-local HTTP path matched against the remote manifest"),
277        ("authorization" = String, Header, description = "Development service bearer token")
278    ),
279    responses(
280        (status = 200, description = "Remote route forwarded through the host.", body = RemoteHttpProxyResponse, content_type = "application/json"),
281        (status = 400, description = "Request body policy rejected the request", body = ErrorResponse, content_type = "application/problem+json"),
282        (status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
283        (status = 403, description = "Service/system authentication or declared capability is required", body = ErrorResponse, content_type = "application/problem+json"),
284        (status = 404, description = "No configured remote route matched", body = ErrorResponse, content_type = "application/problem+json"),
285        (status = 502, description = "Remote module request failed", body = ErrorResponse, content_type = "application/problem+json"),
286    )
287)]
288async fn proxy_delete(
289    State(ctx): State<AppContext>,
290    admin: AdminActor,
291    HttpRequestContext(request_ctx): HttpRequestContext,
292    Path((module, path)): Path<(String, String)>,
293    request: Request<Body>,
294) -> Result<Json<RemoteHttpProxyResponse>, ApiErrorResponse> {
295    let (parts, body) = request.into_parts();
296    let body = to_bytes(body, MAX_PROXY_DELETE_REQUEST_BYTES)
297        .await
298        .map_err(|error| {
299            ApiErrorResponse::with_context(
300                AppError::new(
301                    ErrorCode::Validation,
302                    format!("remote HTTP proxy DELETE request body could not be read: {error}"),
303                ),
304                &request_ctx,
305            )
306        })?;
307    if !body.is_empty() {
308        return Err(ApiErrorResponse::with_context(
309            AppError::new(
310                ErrorCode::Validation,
311                "remote HTTP proxy DELETE request body must be empty",
312            ),
313            &request_ctx,
314        ));
315    }
316
317    let request_path = format!("/{path}");
318    let matched = remote_http_proxy_registry()
319        .match_route(&module, ModuleHttpMethod::Delete, &request_path)
320        .ok_or_else(|| {
321            ApiErrorResponse::with_context(
322                AppError::new(
323                    ErrorCode::NotFound,
324                    format!("remote HTTP route not found: {module}{request_path}"),
325                ),
326                &request_ctx,
327            )
328        })?;
329
330    ensure_capability(&admin, &matched, &request_ctx)?;
331    let data = forward_delete(&ctx, &matched, &parts.headers, &request_ctx).await?;
332    Ok(Json(RemoteHttpProxyResponse::from_match(matched, data)))
333}
334
335async fn proxy_body_method(
336    method: ModuleHttpMethod,
337    ctx: AppContext,
338    admin: AdminActor,
339    request_ctx: platform_core::RequestContext,
340    headers: HeaderMap,
341    module: String,
342    path: String,
343    body: Bytes,
344) -> Result<Json<RemoteHttpProxyResponse>, ApiErrorResponse> {
345    let request_path = format!("/{path}");
346    let matched = remote_http_proxy_registry()
347        .match_route(&module, method, &request_path)
348        .ok_or_else(|| {
349            ApiErrorResponse::with_context(
350                AppError::new(
351                    ErrorCode::NotFound,
352                    format!("remote HTTP route not found: {module}{request_path}"),
353                ),
354                &request_ctx,
355            )
356        })?;
357
358    ensure_capability(&admin, &matched, &request_ctx)?;
359    let data = forward_body_method(method, &ctx, &matched, &headers, body, &request_ctx).await?;
360    Ok(Json(RemoteHttpProxyResponse::from_match(matched, data)))
361}
362
363fn ensure_capability(
364    admin: &AdminActor,
365    matched: &RemoteHttpProxyMatch,
366    request_ctx: &platform_core::RequestContext,
367) -> Result<(), ApiErrorResponse> {
368    let Some(capability) = matched.capability.as_deref() else {
369        return Err(ApiErrorResponse::with_context(
370            AppError::new(
371                ErrorCode::Forbidden,
372                "remote HTTP route has no declared capability",
373            ),
374            request_ctx,
375        ));
376    };
377
378    match admin {
379        AdminActor::System => Ok(()),
380        AdminActor::Service { scopes, .. } | AdminActor::User { scopes, .. }
381            if scopes.iter().any(|scope| scope == capability) =>
382        {
383            Ok(())
384        }
385        AdminActor::Service { .. } | AdminActor::User { .. } => {
386            Err(ApiErrorResponse::with_context(
387                AppError::new(
388                    ErrorCode::Forbidden,
389                    format!("missing remote HTTP route capability: {capability}"),
390                ),
391                request_ctx,
392            ))
393        }
394    }
395}
396
397#[derive(Debug, Clone)]
398struct ProxyForwardRequest<'a> {
399    ctx: &'a AppContext,
400    matched: &'a RemoteHttpProxyMatch,
401    method: ModuleHttpMethod,
402    headers: &'a HeaderMap,
403    request_ctx: &'a platform_core::RequestContext,
404    body: ProxyRequestBody,
405}
406
407async fn forward_get(
408    ctx: &AppContext,
409    matched: &RemoteHttpProxyMatch,
410    headers: &HeaderMap,
411    request_ctx: &platform_core::RequestContext,
412) -> Result<Value, ApiErrorResponse> {
413    forward_proxy_request(ProxyForwardRequest {
414        ctx,
415        matched,
416        method: ModuleHttpMethod::Get,
417        headers,
418        request_ctx,
419        body: ProxyRequestBody::Empty,
420    })
421    .await
422}
423
424async fn forward_body_method(
425    method: ModuleHttpMethod,
426    ctx: &AppContext,
427    matched: &RemoteHttpProxyMatch,
428    headers: &HeaderMap,
429    body: Bytes,
430    request_ctx: &platform_core::RequestContext,
431) -> Result<Value, ApiErrorResponse> {
432    forward_proxy_request(ProxyForwardRequest {
433        ctx,
434        matched,
435        method,
436        headers,
437        request_ctx,
438        body: ProxyRequestBody::Json(body),
439    })
440    .await
441}
442
443async fn forward_delete(
444    ctx: &AppContext,
445    matched: &RemoteHttpProxyMatch,
446    headers: &HeaderMap,
447    request_ctx: &platform_core::RequestContext,
448) -> Result<Value, ApiErrorResponse> {
449    forward_proxy_request(ProxyForwardRequest {
450        ctx,
451        matched,
452        method: ModuleHttpMethod::Delete,
453        headers,
454        request_ctx,
455        body: ProxyRequestBody::Empty,
456    })
457    .await
458}
459
460async fn forward_proxy_request(
461    request: ProxyForwardRequest<'_>,
462) -> Result<Value, ApiErrorResponse> {
463    match request.matched.transport {
464        RemoteModuleTransport::HttpJson => forward_http_json_proxy_request(request).await,
465        RemoteModuleTransport::Grpc => forward_grpc_proxy_request(request).await,
466    }
467}
468
469async fn forward_http_json_proxy_request(
470    request: ProxyForwardRequest<'_>,
471) -> Result<Value, ApiErrorResponse> {
472    let ctx = request.ctx;
473    let matched = request.matched;
474    let request_ctx = request.request_ctx;
475    let started_at = Instant::now();
476    let client = reqwest::Client::builder()
477        .timeout(Duration::from_millis(matched.timeout_ms))
478        .build()
479        .map_err(|error| {
480            ApiErrorResponse::with_context(
481                AppError::new(
482                    ErrorCode::Internal,
483                    format!("failed to build remote HTTP proxy client: {error}"),
484                ),
485                request_ctx,
486            )
487        })?;
488    let outbound = client.request(reqwest_method(request.method), remote_url(matched));
489    let outbound = apply_proxy_request_policy(
490        outbound,
491        request.method,
492        request.headers,
493        request_ctx,
494        matched.auth_token.as_deref(),
495        request.body,
496    )
497    .map_err(|error| ApiErrorResponse::with_context(error, request_ctx))?;
498
499    let response = match outbound.send().await {
500        Ok(response) => response,
501        Err(error) => {
502            let app_error = AppError::new(
503                ErrorCode::ExternalDependency,
504                format!("remote HTTP proxy request failed: {error}"),
505            )
506            .retryable();
507            let app_error = with_proxy_error_details(app_error, matched, request.method, None);
508            record_proxy_call(
509                ctx,
510                matched,
511                request_ctx,
512                started_at,
513                None,
514                Some(&app_error),
515            )
516            .await;
517            return Err(ApiErrorResponse::with_context(app_error, request_ctx));
518        }
519    };
520    let remote_status = response.status();
521
522    match crate::response::decode_json_response_with_policy::<Value>(
523        response,
524        "HTTP proxy",
525        false,
526        ResponseBodyPolicy {
527            max_bytes: Some(MAX_PROXY_RESPONSE_BYTES),
528            require_json_content_type: true,
529            allow_empty_success: request.method == ModuleHttpMethod::Delete,
530        },
531    )
532    .await
533    {
534        Ok(Some(data)) => {
535            record_proxy_call(
536                ctx,
537                matched,
538                request_ctx,
539                started_at,
540                Some(remote_status),
541                None,
542            )
543            .await;
544            Ok(data)
545        }
546        Ok(None) => {
547            if request.method == ModuleHttpMethod::Delete && remote_status.is_success() {
548                record_proxy_call(
549                    ctx,
550                    matched,
551                    request_ctx,
552                    started_at,
553                    Some(remote_status),
554                    None,
555                )
556                .await;
557                Ok(Value::Null)
558            } else {
559                let app_error = AppError::new(ErrorCode::NotFound, "remote HTTP route not found");
560                let app_error = with_proxy_error_details(
561                    app_error,
562                    matched,
563                    request.method,
564                    Some(remote_status),
565                );
566                record_proxy_call(
567                    ctx,
568                    matched,
569                    request_ctx,
570                    started_at,
571                    Some(remote_status),
572                    Some(&app_error),
573                )
574                .await;
575                Err(ApiErrorResponse::with_context(app_error, request_ctx))
576            }
577        }
578        Err(error) => {
579            let error =
580                with_proxy_error_details(error, matched, request.method, Some(remote_status));
581            record_proxy_call(
582                ctx,
583                matched,
584                request_ctx,
585                started_at,
586                Some(remote_status),
587                Some(&error),
588            )
589            .await;
590            Err(ApiErrorResponse::with_context(error, request_ctx))
591        }
592    }
593}
594
595async fn forward_grpc_proxy_request(
596    request: ProxyForwardRequest<'_>,
597) -> Result<Value, ApiErrorResponse> {
598    let ctx = request.ctx;
599    let matched = request.matched;
600    let request_ctx = request.request_ctx;
601    let started_at = Instant::now();
602    let parts =
603        apply_grpc_proxy_request_policy(request.method, request.headers, request_ctx, request.body)
604            .map_err(|error| ApiErrorResponse::with_context(error, request_ctx))?;
605    let config = RemoteModuleConfig {
606        name: matched.module_name.clone(),
607        base_url: matched.base_url.clone(),
608        transport: RemoteModuleTransport::Grpc,
609        auth_token: matched.auth_token.clone(),
610        timeout_ms: matched.timeout_ms,
611    };
612    let response = match crate::grpc::proxy_http_route(
613        &config,
614        &RemoteHttpProxyInvokeRequest {
615            request_id: request_ctx.request_id.0.clone(),
616            correlation_id: request_ctx.correlation_id.0.clone(),
617            module_name: matched.module_name.clone(),
618            method: module_http_method_label(request.method).to_owned(),
619            declared_path: matched.declared_path.clone(),
620            remote_path: matched.remote_path.clone(),
621            path_params: matched.path_params.clone(),
622            headers: parts.headers,
623            body: parts.body,
624        },
625    )
626    .await
627    {
628        Ok(response) => response,
629        Err(error) => {
630            let app_error = with_proxy_error_details(error, matched, request.method, None);
631            record_proxy_call(
632                ctx,
633                matched,
634                request_ctx,
635                started_at,
636                None,
637                Some(&app_error),
638            )
639            .await;
640            return Err(ApiErrorResponse::with_context(app_error, request_ctx));
641        }
642    };
643    let remote_status = match reqwest::StatusCode::from_u16(response.status_code) {
644        Ok(status) => status,
645        Err(error) => {
646            let app_error = AppError::new(
647                ErrorCode::ExternalDependency,
648                format!("remote HTTP proxy gRPC status code was invalid: {error}"),
649            );
650            let app_error = with_proxy_error_details(app_error, matched, request.method, None);
651            record_proxy_call(
652                ctx,
653                matched,
654                request_ctx,
655                started_at,
656                None,
657                Some(&app_error),
658            )
659            .await;
660            return Err(ApiErrorResponse::with_context(app_error, request_ctx));
661        }
662    };
663
664    match decode_grpc_proxy_response(response.body, remote_status, request.method) {
665        Ok(data) => {
666            record_proxy_call(
667                ctx,
668                matched,
669                request_ctx,
670                started_at,
671                Some(remote_status),
672                None,
673            )
674            .await;
675            Ok(data)
676        }
677        Err(error) => {
678            let error =
679                with_proxy_error_details(error, matched, request.method, Some(remote_status));
680            record_proxy_call(
681                ctx,
682                matched,
683                request_ctx,
684                started_at,
685                Some(remote_status),
686                Some(&error),
687            )
688            .await;
689            Err(ApiErrorResponse::with_context(error, request_ctx))
690        }
691    }
692}
693
694fn decode_grpc_proxy_response(
695    body: Option<Value>,
696    remote_status: reqwest::StatusCode,
697    method: ModuleHttpMethod,
698) -> Result<Value, AppError> {
699    if remote_status.is_success() {
700        if method == ModuleHttpMethod::Delete
701            && remote_status == reqwest::StatusCode::NO_CONTENT
702            && body.is_none()
703        {
704            return Ok(Value::Null);
705        }
706        return body.ok_or_else(|| {
707            AppError::new(
708                ErrorCode::ExternalDependency,
709                "remote HTTP proxy gRPC response body was missing",
710            )
711        });
712    }
713
714    if let Some(body) = body
715        && let Ok(envelope) = serde_json::from_value::<RemoteErrorEnvelope>(body)
716    {
717        return Err(crate::response::remote_error(remote_status, envelope));
718    }
719
720    Err(crate::response::fallback_status_error(
721        remote_status,
722        "HTTP proxy",
723    ))
724}
725
726fn with_proxy_error_details(
727    mut error: AppError,
728    matched: &RemoteHttpProxyMatch,
729    method: ModuleHttpMethod,
730    remote_status: Option<reqwest::StatusCode>,
731) -> AppError {
732    push_error_detail(&mut error, "remote_module", matched.module_name.clone());
733    push_error_detail(
734        &mut error,
735        "remote_method",
736        module_http_method_label(method),
737    );
738    push_error_detail(&mut error, "declared_path", matched.declared_path.clone());
739    push_error_detail(&mut error, "remote_path", matched.remote_path.clone());
740    if let Some(status) = remote_status {
741        push_error_detail(&mut error, "remote_status", status.as_u16().to_string());
742    }
743    error
744}
745
746fn push_error_detail(error: &mut AppError, field: &'static str, reason: impl Into<String>) {
747    if error
748        .details
749        .iter()
750        .any(|detail| detail.field.as_deref() == Some(field))
751    {
752        return;
753    }
754    error.details.push(ErrorDetail {
755        field: Some(field.to_owned()),
756        reason: reason.into(),
757    });
758}
759
760fn remote_url(matched: &RemoteHttpProxyMatch) -> String {
761    format!(
762        "{}/{}",
763        matched.base_url.trim_end_matches('/'),
764        matched.remote_path.trim_start_matches('/')
765    )
766}
767
768fn reqwest_method(method: ModuleHttpMethod) -> reqwest::Method {
769    match method {
770        ModuleHttpMethod::Get => reqwest::Method::GET,
771        ModuleHttpMethod::Post => reqwest::Method::POST,
772        ModuleHttpMethod::Put => reqwest::Method::PUT,
773        ModuleHttpMethod::Patch => reqwest::Method::PATCH,
774        ModuleHttpMethod::Delete => reqwest::Method::DELETE,
775        _ => reqwest::Method::GET,
776    }
777}
778
779async fn record_proxy_call(
780    ctx: &AppContext,
781    matched: &RemoteHttpProxyMatch,
782    request_ctx: &platform_core::RequestContext,
783    started_at: Instant,
784    remote_status: Option<reqwest::StatusCode>,
785    error: Option<&AppError>,
786) {
787    let duration_ms = started_at.elapsed().as_millis().min(i64::MAX as u128) as i64;
788    match error {
789        Some(error) => {
790            tracing::warn!(
791                module_name = %matched.module_name,
792                declared_path = %matched.declared_path,
793                remote_path = %matched.remote_path,
794                http_method = %module_http_method_label(matched.method),
795                remote_status = remote_status.map_or(0, |status| status.as_u16()),
796                duration_ms,
797                error_code = error.code.as_str(),
798                retryable = error.retryable,
799                request_id = %request_ctx.request_id.0,
800                correlation_id = %request_ctx.correlation_id.0,
801                "remote HTTP proxy call failed"
802            );
803        }
804        None => {
805            tracing::info!(
806                module_name = %matched.module_name,
807                declared_path = %matched.declared_path,
808                remote_path = %matched.remote_path,
809                http_method = %module_http_method_label(matched.method),
810                remote_status = remote_status.map_or(0, |status| status.as_u16()),
811                duration_ms,
812                request_id = %request_ctx.request_id.0,
813                correlation_id = %request_ctx.correlation_id.0,
814                "remote HTTP proxy call completed"
815            );
816        }
817    }
818
819    let record = RemoteHttpProxyCallRecord {
820        module_name: matched.module_name.clone(),
821        method: module_http_method_label(matched.method).to_owned(),
822        declared_path: matched.declared_path.clone(),
823        remote_path: matched.remote_path.clone(),
824        capability: matched.capability.clone(),
825        display_name: matched.display_name.clone(),
826        story_title: matched.story_title.clone(),
827        remote_status: remote_status.map(|status| status.as_u16()),
828        duration_ms,
829        success: error.is_none(),
830        error_code: error.map(|error| error.code.as_str().to_owned()),
831        retryable: error.is_some_and(|error| error.retryable),
832        path_params: json!(matched.path_params),
833        error_details: error
834            .map(|error| json!(error.details))
835            .unwrap_or_else(|| Value::Array(Vec::new())),
836    };
837
838    if let Err(error) =
839        insert_remote_http_proxy_call(&ctx.db, ctx.ids.as_ref(), request_ctx, record).await
840    {
841        tracing::warn!(
842            error = ?error,
843            module_name = %matched.module_name,
844            declared_path = %matched.declared_path,
845            remote_path = %matched.remote_path,
846            http_method = %module_http_method_label(matched.method),
847            request_id = %request_ctx.request_id.0,
848            correlation_id = %request_ctx.correlation_id.0,
849            "failed to persist remote HTTP proxy call"
850        );
851    }
852}
853
854fn module_http_method_label(method: ModuleHttpMethod) -> &'static str {
855    match method {
856        ModuleHttpMethod::Get => "GET",
857        ModuleHttpMethod::Post => "POST",
858        ModuleHttpMethod::Put => "PUT",
859        ModuleHttpMethod::Patch => "PATCH",
860        ModuleHttpMethod::Delete => "DELETE",
861        _ => "UNKNOWN",
862    }
863}
864
865impl RemoteHttpProxyResponse {
866    fn from_match(matched: RemoteHttpProxyMatch, data: Value) -> Self {
867        Self {
868            status: RemoteHttpProxyStatus::Forwarded,
869            module_name: matched.module_name,
870            method: matched.method,
871            declared_path: matched.declared_path,
872            remote_path: matched.remote_path,
873            capability: matched.capability.unwrap_or_default(),
874            path_params: matched.path_params,
875            data,
876        }
877    }
878}
879
880#[cfg(test)]
881mod tests {
882    use super::*;
883    use std::collections::BTreeMap;
884
885    fn matched(method: ModuleHttpMethod) -> RemoteHttpProxyMatch {
886        RemoteHttpProxyMatch {
887            module_name: "remote-crm".to_owned(),
888            base_url: "http://127.0.0.1:4100/lenso/module/v1/".to_owned(),
889            transport: RemoteModuleTransport::HttpJson,
890            timeout_ms: 5_000,
891            auth_token: None,
892            method,
893            declared_path: "/contacts/{id}".to_owned(),
894            remote_path: "/contacts/contact_1".to_owned(),
895            capability: Some("remote_crm.contacts.read".to_owned()),
896            display_name: Some("Fetch Contact".to_owned()),
897            story_title: Some("Fetch Contact".to_owned()),
898            path_params: BTreeMap::new(),
899        }
900    }
901
902    #[test]
903    fn remote_url_joins_base_and_remote_path_once() {
904        assert_eq!(
905            remote_url(&matched(ModuleHttpMethod::Get)),
906            "http://127.0.0.1:4100/lenso/module/v1/contacts/contact_1"
907        );
908    }
909
910    #[test]
911    fn reqwest_method_maps_declared_methods() {
912        assert_eq!(reqwest_method(ModuleHttpMethod::Get), reqwest::Method::GET);
913        assert_eq!(
914            reqwest_method(ModuleHttpMethod::Post),
915            reqwest::Method::POST
916        );
917        assert_eq!(reqwest_method(ModuleHttpMethod::Put), reqwest::Method::PUT);
918        assert_eq!(
919            reqwest_method(ModuleHttpMethod::Patch),
920            reqwest::Method::PATCH
921        );
922        assert_eq!(
923            reqwest_method(ModuleHttpMethod::Delete),
924            reqwest::Method::DELETE
925        );
926    }
927}