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/json"),
104        (status = 403, description = "Service/system authentication or declared capability is required", body = ErrorResponse, content_type = "application/json"),
105        (status = 404, description = "No configured remote route matched", body = ErrorResponse, content_type = "application/json"),
106        (status = 502, description = "Remote module request failed", body = ErrorResponse, content_type = "application/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/json"),
152        (status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/json"),
153        (status = 403, description = "Service/system authentication or declared capability is required", body = ErrorResponse, content_type = "application/json"),
154        (status = 404, description = "No configured remote route matched", body = ErrorResponse, content_type = "application/json"),
155        (status = 502, description = "Remote module request failed", body = ErrorResponse, content_type = "application/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/json"),
197        (status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/json"),
198        (status = 403, description = "Service/system authentication or declared capability is required", body = ErrorResponse, content_type = "application/json"),
199        (status = 404, description = "No configured remote route matched", body = ErrorResponse, content_type = "application/json"),
200        (status = 502, description = "Remote module request failed", body = ErrorResponse, content_type = "application/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/json"),
242        (status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/json"),
243        (status = 403, description = "Service/system authentication or declared capability is required", body = ErrorResponse, content_type = "application/json"),
244        (status = 404, description = "No configured remote route matched", body = ErrorResponse, content_type = "application/json"),
245        (status = 502, description = "Remote module request failed", body = ErrorResponse, content_type = "application/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/json"),
282        (status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/json"),
283        (status = 403, description = "Service/system authentication or declared capability is required", body = ErrorResponse, content_type = "application/json"),
284        (status = 404, description = "No configured remote route matched", body = ErrorResponse, content_type = "application/json"),
285        (status = 502, description = "Remote module request failed", body = ErrorResponse, content_type = "application/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, .. } if scopes.iter().any(|scope| scope == capability) => {
381            Ok(())
382        }
383        AdminActor::Service { .. } => Err(ApiErrorResponse::with_context(
384            AppError::new(
385                ErrorCode::Forbidden,
386                format!("missing remote HTTP route capability: {capability}"),
387            ),
388            request_ctx,
389        )),
390    }
391}
392
393#[derive(Debug, Clone)]
394struct ProxyForwardRequest<'a> {
395    ctx: &'a AppContext,
396    matched: &'a RemoteHttpProxyMatch,
397    method: ModuleHttpMethod,
398    headers: &'a HeaderMap,
399    request_ctx: &'a platform_core::RequestContext,
400    body: ProxyRequestBody,
401}
402
403async fn forward_get(
404    ctx: &AppContext,
405    matched: &RemoteHttpProxyMatch,
406    headers: &HeaderMap,
407    request_ctx: &platform_core::RequestContext,
408) -> Result<Value, ApiErrorResponse> {
409    forward_proxy_request(ProxyForwardRequest {
410        ctx,
411        matched,
412        method: ModuleHttpMethod::Get,
413        headers,
414        request_ctx,
415        body: ProxyRequestBody::Empty,
416    })
417    .await
418}
419
420async fn forward_body_method(
421    method: ModuleHttpMethod,
422    ctx: &AppContext,
423    matched: &RemoteHttpProxyMatch,
424    headers: &HeaderMap,
425    body: Bytes,
426    request_ctx: &platform_core::RequestContext,
427) -> Result<Value, ApiErrorResponse> {
428    forward_proxy_request(ProxyForwardRequest {
429        ctx,
430        matched,
431        method,
432        headers,
433        request_ctx,
434        body: ProxyRequestBody::Json(body),
435    })
436    .await
437}
438
439async fn forward_delete(
440    ctx: &AppContext,
441    matched: &RemoteHttpProxyMatch,
442    headers: &HeaderMap,
443    request_ctx: &platform_core::RequestContext,
444) -> Result<Value, ApiErrorResponse> {
445    forward_proxy_request(ProxyForwardRequest {
446        ctx,
447        matched,
448        method: ModuleHttpMethod::Delete,
449        headers,
450        request_ctx,
451        body: ProxyRequestBody::Empty,
452    })
453    .await
454}
455
456async fn forward_proxy_request(
457    request: ProxyForwardRequest<'_>,
458) -> Result<Value, ApiErrorResponse> {
459    match request.matched.transport {
460        RemoteModuleTransport::HttpJson => forward_http_json_proxy_request(request).await,
461        RemoteModuleTransport::Grpc => forward_grpc_proxy_request(request).await,
462    }
463}
464
465async fn forward_http_json_proxy_request(
466    request: ProxyForwardRequest<'_>,
467) -> Result<Value, ApiErrorResponse> {
468    let ctx = request.ctx;
469    let matched = request.matched;
470    let request_ctx = request.request_ctx;
471    let started_at = Instant::now();
472    let client = reqwest::Client::builder()
473        .timeout(Duration::from_millis(matched.timeout_ms))
474        .build()
475        .map_err(|error| {
476            ApiErrorResponse::with_context(
477                AppError::new(
478                    ErrorCode::Internal,
479                    format!("failed to build remote HTTP proxy client: {error}"),
480                ),
481                request_ctx,
482            )
483        })?;
484    let outbound = client.request(reqwest_method(request.method), remote_url(matched));
485    let outbound = apply_proxy_request_policy(
486        outbound,
487        request.method,
488        request.headers,
489        request_ctx,
490        matched.auth_token.as_deref(),
491        request.body,
492    )
493    .map_err(|error| ApiErrorResponse::with_context(error, request_ctx))?;
494
495    let response = match outbound.send().await {
496        Ok(response) => response,
497        Err(error) => {
498            let app_error = AppError::new(
499                ErrorCode::ExternalDependency,
500                format!("remote HTTP proxy request failed: {error}"),
501            )
502            .retryable();
503            let app_error = with_proxy_error_details(app_error, matched, request.method, None);
504            record_proxy_call(
505                ctx,
506                matched,
507                request_ctx,
508                started_at,
509                None,
510                Some(&app_error),
511            )
512            .await;
513            return Err(ApiErrorResponse::with_context(app_error, request_ctx));
514        }
515    };
516    let remote_status = response.status();
517
518    match crate::response::decode_json_response_with_policy::<Value>(
519        response,
520        "HTTP proxy",
521        false,
522        ResponseBodyPolicy {
523            max_bytes: Some(MAX_PROXY_RESPONSE_BYTES),
524            require_json_content_type: true,
525            allow_empty_success: request.method == ModuleHttpMethod::Delete,
526        },
527    )
528    .await
529    {
530        Ok(Some(data)) => {
531            record_proxy_call(
532                ctx,
533                matched,
534                request_ctx,
535                started_at,
536                Some(remote_status),
537                None,
538            )
539            .await;
540            Ok(data)
541        }
542        Ok(None) => {
543            if request.method == ModuleHttpMethod::Delete && remote_status.is_success() {
544                record_proxy_call(
545                    ctx,
546                    matched,
547                    request_ctx,
548                    started_at,
549                    Some(remote_status),
550                    None,
551                )
552                .await;
553                Ok(Value::Null)
554            } else {
555                let app_error = AppError::new(ErrorCode::NotFound, "remote HTTP route not found");
556                let app_error = with_proxy_error_details(
557                    app_error,
558                    matched,
559                    request.method,
560                    Some(remote_status),
561                );
562                record_proxy_call(
563                    ctx,
564                    matched,
565                    request_ctx,
566                    started_at,
567                    Some(remote_status),
568                    Some(&app_error),
569                )
570                .await;
571                Err(ApiErrorResponse::with_context(app_error, request_ctx))
572            }
573        }
574        Err(error) => {
575            let error =
576                with_proxy_error_details(error, matched, request.method, Some(remote_status));
577            record_proxy_call(
578                ctx,
579                matched,
580                request_ctx,
581                started_at,
582                Some(remote_status),
583                Some(&error),
584            )
585            .await;
586            Err(ApiErrorResponse::with_context(error, request_ctx))
587        }
588    }
589}
590
591async fn forward_grpc_proxy_request(
592    request: ProxyForwardRequest<'_>,
593) -> Result<Value, ApiErrorResponse> {
594    let ctx = request.ctx;
595    let matched = request.matched;
596    let request_ctx = request.request_ctx;
597    let started_at = Instant::now();
598    let parts =
599        apply_grpc_proxy_request_policy(request.method, request.headers, request_ctx, request.body)
600            .map_err(|error| ApiErrorResponse::with_context(error, request_ctx))?;
601    let config = RemoteModuleConfig {
602        name: matched.module_name.clone(),
603        base_url: matched.base_url.clone(),
604        transport: RemoteModuleTransport::Grpc,
605        auth_token: matched.auth_token.clone(),
606        timeout_ms: matched.timeout_ms,
607    };
608    let response = match crate::grpc::proxy_http_route(
609        &config,
610        &RemoteHttpProxyInvokeRequest {
611            request_id: request_ctx.request_id.0.clone(),
612            correlation_id: request_ctx.correlation_id.0.clone(),
613            module_name: matched.module_name.clone(),
614            method: module_http_method_label(request.method).to_owned(),
615            declared_path: matched.declared_path.clone(),
616            remote_path: matched.remote_path.clone(),
617            path_params: matched.path_params.clone(),
618            headers: parts.headers,
619            body: parts.body,
620        },
621    )
622    .await
623    {
624        Ok(response) => response,
625        Err(error) => {
626            let app_error = with_proxy_error_details(error, matched, request.method, None);
627            record_proxy_call(
628                ctx,
629                matched,
630                request_ctx,
631                started_at,
632                None,
633                Some(&app_error),
634            )
635            .await;
636            return Err(ApiErrorResponse::with_context(app_error, request_ctx));
637        }
638    };
639    let remote_status = match reqwest::StatusCode::from_u16(response.status_code) {
640        Ok(status) => status,
641        Err(error) => {
642            let app_error = AppError::new(
643                ErrorCode::ExternalDependency,
644                format!("remote HTTP proxy gRPC status code was invalid: {error}"),
645            );
646            let app_error = with_proxy_error_details(app_error, matched, request.method, None);
647            record_proxy_call(
648                ctx,
649                matched,
650                request_ctx,
651                started_at,
652                None,
653                Some(&app_error),
654            )
655            .await;
656            return Err(ApiErrorResponse::with_context(app_error, request_ctx));
657        }
658    };
659
660    match decode_grpc_proxy_response(response.body, remote_status, request.method) {
661        Ok(data) => {
662            record_proxy_call(
663                ctx,
664                matched,
665                request_ctx,
666                started_at,
667                Some(remote_status),
668                None,
669            )
670            .await;
671            Ok(data)
672        }
673        Err(error) => {
674            let error =
675                with_proxy_error_details(error, matched, request.method, Some(remote_status));
676            record_proxy_call(
677                ctx,
678                matched,
679                request_ctx,
680                started_at,
681                Some(remote_status),
682                Some(&error),
683            )
684            .await;
685            Err(ApiErrorResponse::with_context(error, request_ctx))
686        }
687    }
688}
689
690fn decode_grpc_proxy_response(
691    body: Option<Value>,
692    remote_status: reqwest::StatusCode,
693    method: ModuleHttpMethod,
694) -> Result<Value, AppError> {
695    if remote_status.is_success() {
696        if method == ModuleHttpMethod::Delete
697            && remote_status == reqwest::StatusCode::NO_CONTENT
698            && body.is_none()
699        {
700            return Ok(Value::Null);
701        }
702        return body.ok_or_else(|| {
703            AppError::new(
704                ErrorCode::ExternalDependency,
705                "remote HTTP proxy gRPC response body was missing",
706            )
707        });
708    }
709
710    if let Some(body) = body
711        && let Ok(envelope) = serde_json::from_value::<RemoteErrorEnvelope>(body)
712    {
713        return Err(crate::response::remote_error(remote_status, envelope));
714    }
715
716    Err(crate::response::fallback_status_error(
717        remote_status,
718        "HTTP proxy",
719    ))
720}
721
722fn with_proxy_error_details(
723    mut error: AppError,
724    matched: &RemoteHttpProxyMatch,
725    method: ModuleHttpMethod,
726    remote_status: Option<reqwest::StatusCode>,
727) -> AppError {
728    push_error_detail(&mut error, "remote_module", matched.module_name.clone());
729    push_error_detail(
730        &mut error,
731        "remote_method",
732        module_http_method_label(method),
733    );
734    push_error_detail(&mut error, "declared_path", matched.declared_path.clone());
735    push_error_detail(&mut error, "remote_path", matched.remote_path.clone());
736    if let Some(status) = remote_status {
737        push_error_detail(&mut error, "remote_status", status.as_u16().to_string());
738    }
739    error
740}
741
742fn push_error_detail(error: &mut AppError, field: &'static str, reason: impl Into<String>) {
743    if error
744        .details
745        .iter()
746        .any(|detail| detail.field.as_deref() == Some(field))
747    {
748        return;
749    }
750    error.details.push(ErrorDetail {
751        field: Some(field.to_owned()),
752        reason: reason.into(),
753    });
754}
755
756fn remote_url(matched: &RemoteHttpProxyMatch) -> String {
757    format!(
758        "{}/{}",
759        matched.base_url.trim_end_matches('/'),
760        matched.remote_path.trim_start_matches('/')
761    )
762}
763
764fn reqwest_method(method: ModuleHttpMethod) -> reqwest::Method {
765    match method {
766        ModuleHttpMethod::Get => reqwest::Method::GET,
767        ModuleHttpMethod::Post => reqwest::Method::POST,
768        ModuleHttpMethod::Put => reqwest::Method::PUT,
769        ModuleHttpMethod::Patch => reqwest::Method::PATCH,
770        ModuleHttpMethod::Delete => reqwest::Method::DELETE,
771        _ => reqwest::Method::GET,
772    }
773}
774
775async fn record_proxy_call(
776    ctx: &AppContext,
777    matched: &RemoteHttpProxyMatch,
778    request_ctx: &platform_core::RequestContext,
779    started_at: Instant,
780    remote_status: Option<reqwest::StatusCode>,
781    error: Option<&AppError>,
782) {
783    let duration_ms = started_at.elapsed().as_millis().min(i64::MAX as u128) as i64;
784    match error {
785        Some(error) => {
786            tracing::warn!(
787                module_name = %matched.module_name,
788                declared_path = %matched.declared_path,
789                remote_path = %matched.remote_path,
790                http_method = %module_http_method_label(matched.method),
791                remote_status = remote_status.map_or(0, |status| status.as_u16()),
792                duration_ms,
793                error_code = error.code.as_str(),
794                retryable = error.retryable,
795                request_id = %request_ctx.request_id.0,
796                correlation_id = %request_ctx.correlation_id.0,
797                "remote HTTP proxy call failed"
798            );
799        }
800        None => {
801            tracing::info!(
802                module_name = %matched.module_name,
803                declared_path = %matched.declared_path,
804                remote_path = %matched.remote_path,
805                http_method = %module_http_method_label(matched.method),
806                remote_status = remote_status.map_or(0, |status| status.as_u16()),
807                duration_ms,
808                request_id = %request_ctx.request_id.0,
809                correlation_id = %request_ctx.correlation_id.0,
810                "remote HTTP proxy call completed"
811            );
812        }
813    }
814
815    let record = RemoteHttpProxyCallRecord {
816        module_name: matched.module_name.clone(),
817        method: module_http_method_label(matched.method).to_owned(),
818        declared_path: matched.declared_path.clone(),
819        remote_path: matched.remote_path.clone(),
820        capability: matched.capability.clone(),
821        display_name: matched.display_name.clone(),
822        story_title: matched.story_title.clone(),
823        remote_status: remote_status.map(|status| status.as_u16()),
824        duration_ms,
825        success: error.is_none(),
826        error_code: error.map(|error| error.code.as_str().to_owned()),
827        retryable: error.is_some_and(|error| error.retryable),
828        path_params: json!(matched.path_params),
829        error_details: error
830            .map(|error| json!(error.details))
831            .unwrap_or_else(|| Value::Array(Vec::new())),
832    };
833
834    if let Err(error) =
835        insert_remote_http_proxy_call(&ctx.db, ctx.ids.as_ref(), request_ctx, record).await
836    {
837        tracing::warn!(
838            error = ?error,
839            module_name = %matched.module_name,
840            declared_path = %matched.declared_path,
841            remote_path = %matched.remote_path,
842            http_method = %module_http_method_label(matched.method),
843            request_id = %request_ctx.request_id.0,
844            correlation_id = %request_ctx.correlation_id.0,
845            "failed to persist remote HTTP proxy call"
846        );
847    }
848}
849
850fn module_http_method_label(method: ModuleHttpMethod) -> &'static str {
851    match method {
852        ModuleHttpMethod::Get => "GET",
853        ModuleHttpMethod::Post => "POST",
854        ModuleHttpMethod::Put => "PUT",
855        ModuleHttpMethod::Patch => "PATCH",
856        ModuleHttpMethod::Delete => "DELETE",
857        _ => "UNKNOWN",
858    }
859}
860
861impl RemoteHttpProxyResponse {
862    fn from_match(matched: RemoteHttpProxyMatch, data: Value) -> Self {
863        Self {
864            status: RemoteHttpProxyStatus::Forwarded,
865            module_name: matched.module_name,
866            method: matched.method,
867            declared_path: matched.declared_path,
868            remote_path: matched.remote_path,
869            capability: matched.capability.unwrap_or_default(),
870            path_params: matched.path_params,
871            data,
872        }
873    }
874}
875
876#[cfg(test)]
877mod tests {
878    use super::*;
879    use std::collections::BTreeMap;
880
881    fn matched(method: ModuleHttpMethod) -> RemoteHttpProxyMatch {
882        RemoteHttpProxyMatch {
883            module_name: "remote-crm".to_owned(),
884            base_url: "http://127.0.0.1:4100/lenso/module/v1/".to_owned(),
885            transport: RemoteModuleTransport::HttpJson,
886            timeout_ms: 5_000,
887            auth_token: None,
888            method,
889            declared_path: "/contacts/{id}".to_owned(),
890            remote_path: "/contacts/contact_1".to_owned(),
891            capability: Some("remote_crm.contacts.read".to_owned()),
892            display_name: Some("Fetch Contact".to_owned()),
893            story_title: Some("Fetch Contact".to_owned()),
894            path_params: BTreeMap::new(),
895        }
896    }
897
898    #[test]
899    fn remote_url_joins_base_and_remote_path_once() {
900        assert_eq!(
901            remote_url(&matched(ModuleHttpMethod::Get)),
902            "http://127.0.0.1:4100/lenso/module/v1/contacts/contact_1"
903        );
904    }
905
906    #[test]
907    fn reqwest_method_maps_declared_methods() {
908        assert_eq!(reqwest_method(ModuleHttpMethod::Get), reqwest::Method::GET);
909        assert_eq!(
910            reqwest_method(ModuleHttpMethod::Post),
911            reqwest::Method::POST
912        );
913        assert_eq!(reqwest_method(ModuleHttpMethod::Put), reqwest::Method::PUT);
914        assert_eq!(
915            reqwest_method(ModuleHttpMethod::Patch),
916            reqwest::Method::PATCH
917        );
918        assert_eq!(
919            reqwest_method(ModuleHttpMethod::Delete),
920            reqwest::Method::DELETE
921        );
922    }
923}