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::{MAX_REMOTE_JSON_RESPONSE_BYTES, 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, AppResult, ErrorCode, RemoteHttpProxyCallRecord,
15    insert_remote_http_proxy_call,
16};
17use platform_http::{
18    AdminActor, ApiErrorResponse, ApiOpenApiRouter, ErrorResponse, HttpRequestContext,
19    OpenApiRouter, routes,
20};
21use platform_module::ModuleHttpMethod;
22use serde::{Serialize, Serializer};
23use serde_json::{Value, json};
24use std::collections::BTreeMap;
25use std::sync::{Arc, OnceLock, RwLock};
26use std::time::{Duration, Instant};
27use utoipa::ToSchema;
28
29static REMOTE_HTTP_PROXY_REGISTRY: OnceLock<RwLock<Arc<RemoteHttpProxyRegistry>>> = OnceLock::new();
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 remote_url =
489        remote_url(matched).map_err(|error| ApiErrorResponse::with_context(error, request_ctx))?;
490    let outbound = client.request(reqwest_method(request.method), remote_url);
491    let outbound = apply_proxy_request_policy(
492        outbound,
493        request.method,
494        request.headers,
495        request_ctx,
496        matched.auth_token.as_deref(),
497        request.body,
498    )
499    .map_err(|error| ApiErrorResponse::with_context(error, request_ctx))?;
500
501    let response = match outbound.send().await {
502        Ok(response) => response,
503        Err(error) => {
504            let app_error = AppError::new(
505                ErrorCode::ExternalDependency,
506                format!("remote HTTP proxy request failed: {error}"),
507            )
508            .retryable();
509            let app_error = with_proxy_error_details(app_error, matched, request.method, None);
510            record_proxy_call(
511                ctx,
512                matched,
513                request_ctx,
514                started_at,
515                None,
516                Some(&app_error),
517            )
518            .await;
519            return Err(ApiErrorResponse::with_context(app_error, request_ctx));
520        }
521    };
522    let remote_status = response.status();
523
524    match crate::response::decode_json_response_with_policy::<Value>(
525        response,
526        "HTTP proxy",
527        false,
528        ResponseBodyPolicy {
529            max_bytes: Some(MAX_REMOTE_JSON_RESPONSE_BYTES),
530            require_json_content_type: true,
531            allow_empty_success: request.method == ModuleHttpMethod::Delete,
532        },
533    )
534    .await
535    {
536        Ok(Some(data)) => {
537            record_proxy_call(
538                ctx,
539                matched,
540                request_ctx,
541                started_at,
542                Some(remote_status),
543                None,
544            )
545            .await;
546            Ok(data)
547        }
548        Ok(None) => {
549            if request.method == ModuleHttpMethod::Delete && remote_status.is_success() {
550                record_proxy_call(
551                    ctx,
552                    matched,
553                    request_ctx,
554                    started_at,
555                    Some(remote_status),
556                    None,
557                )
558                .await;
559                Ok(Value::Null)
560            } else {
561                let app_error = AppError::new(ErrorCode::NotFound, "remote HTTP route not found");
562                let app_error = with_proxy_error_details(
563                    app_error,
564                    matched,
565                    request.method,
566                    Some(remote_status),
567                );
568                record_proxy_call(
569                    ctx,
570                    matched,
571                    request_ctx,
572                    started_at,
573                    Some(remote_status),
574                    Some(&app_error),
575                )
576                .await;
577                Err(ApiErrorResponse::with_context(app_error, request_ctx))
578            }
579        }
580        Err(error) => {
581            let error =
582                with_proxy_error_details(error, matched, request.method, Some(remote_status));
583            record_proxy_call(
584                ctx,
585                matched,
586                request_ctx,
587                started_at,
588                Some(remote_status),
589                Some(&error),
590            )
591            .await;
592            Err(ApiErrorResponse::with_context(error, request_ctx))
593        }
594    }
595}
596
597async fn forward_grpc_proxy_request(
598    request: ProxyForwardRequest<'_>,
599) -> Result<Value, ApiErrorResponse> {
600    let ctx = request.ctx;
601    let matched = request.matched;
602    let request_ctx = request.request_ctx;
603    let started_at = Instant::now();
604    let parts =
605        apply_grpc_proxy_request_policy(request.method, request.headers, request_ctx, request.body)
606            .map_err(|error| ApiErrorResponse::with_context(error, request_ctx))?;
607    let config = RemoteModuleConfig {
608        name: matched.module_name.clone(),
609        base_url: matched.base_url.clone(),
610        transport: RemoteModuleTransport::Grpc,
611        auth_token: matched.auth_token.clone(),
612        timeout_ms: matched.timeout_ms,
613    };
614    let response = match crate::grpc::proxy_http_route(
615        &config,
616        &RemoteHttpProxyInvokeRequest {
617            request_id: request_ctx.request_id.0.clone(),
618            correlation_id: request_ctx.correlation_id.0.clone(),
619            module_name: matched.module_name.clone(),
620            method: module_http_method_label(request.method).to_owned(),
621            declared_path: matched.declared_path.clone(),
622            remote_path: matched.remote_path.clone(),
623            path_params: matched.path_params.clone(),
624            headers: parts.headers,
625            body: parts.body,
626        },
627    )
628    .await
629    {
630        Ok(response) => response,
631        Err(error) => {
632            let app_error = with_proxy_error_details(error, matched, request.method, None);
633            record_proxy_call(
634                ctx,
635                matched,
636                request_ctx,
637                started_at,
638                None,
639                Some(&app_error),
640            )
641            .await;
642            return Err(ApiErrorResponse::with_context(app_error, request_ctx));
643        }
644    };
645    let remote_status = match reqwest::StatusCode::from_u16(response.status_code) {
646        Ok(status) => status,
647        Err(error) => {
648            let app_error = AppError::new(
649                ErrorCode::ExternalDependency,
650                format!("remote HTTP proxy gRPC status code was invalid: {error}"),
651            );
652            let app_error = with_proxy_error_details(app_error, matched, request.method, None);
653            record_proxy_call(
654                ctx,
655                matched,
656                request_ctx,
657                started_at,
658                None,
659                Some(&app_error),
660            )
661            .await;
662            return Err(ApiErrorResponse::with_context(app_error, request_ctx));
663        }
664    };
665
666    match decode_grpc_proxy_response(response.body, remote_status, request.method) {
667        Ok(data) => {
668            record_proxy_call(
669                ctx,
670                matched,
671                request_ctx,
672                started_at,
673                Some(remote_status),
674                None,
675            )
676            .await;
677            Ok(data)
678        }
679        Err(error) => {
680            let error =
681                with_proxy_error_details(error, matched, request.method, Some(remote_status));
682            record_proxy_call(
683                ctx,
684                matched,
685                request_ctx,
686                started_at,
687                Some(remote_status),
688                Some(&error),
689            )
690            .await;
691            Err(ApiErrorResponse::with_context(error, request_ctx))
692        }
693    }
694}
695
696fn decode_grpc_proxy_response(
697    body: Option<Value>,
698    remote_status: reqwest::StatusCode,
699    method: ModuleHttpMethod,
700) -> Result<Value, AppError> {
701    if remote_status.is_success() {
702        if method == ModuleHttpMethod::Delete
703            && remote_status == reqwest::StatusCode::NO_CONTENT
704            && body.is_none()
705        {
706            return Ok(Value::Null);
707        }
708        return body.ok_or_else(|| {
709            AppError::new(
710                ErrorCode::ExternalDependency,
711                "remote HTTP proxy gRPC response body was missing",
712            )
713        });
714    }
715
716    if let Some(body) = body
717        && let Ok(envelope) = serde_json::from_value::<RemoteErrorEnvelope>(body)
718    {
719        return Err(crate::response::remote_error(remote_status, envelope));
720    }
721
722    Err(crate::response::fallback_status_error(
723        remote_status,
724        "HTTP proxy",
725    ))
726}
727
728fn with_proxy_error_details(
729    mut error: AppError,
730    matched: &RemoteHttpProxyMatch,
731    method: ModuleHttpMethod,
732    remote_status: Option<reqwest::StatusCode>,
733) -> AppError {
734    push_error_detail(&mut error, "remote_module", matched.module_name.clone());
735    push_error_detail(
736        &mut error,
737        "remote_method",
738        module_http_method_label(method),
739    );
740    push_error_detail(&mut error, "declared_path", matched.declared_path.clone());
741    push_error_detail(&mut error, "remote_path", matched.remote_path.clone());
742    if let Some(status) = remote_status {
743        push_error_detail(&mut error, "remote_status", status.as_u16().to_string());
744    }
745    error
746}
747
748fn push_error_detail(error: &mut AppError, field: &'static str, reason: impl Into<String>) {
749    if error
750        .details
751        .iter()
752        .any(|detail| detail.field.as_deref() == Some(field))
753    {
754        return;
755    }
756    error.details.push(ErrorDetail {
757        field: Some(field.to_owned()),
758        reason: reason.into(),
759    });
760}
761
762fn remote_url(matched: &RemoteHttpProxyMatch) -> AppResult<reqwest::Url> {
763    let remote_path_is_safe = matched.remote_path.starts_with('/')
764        && !matched.remote_path.starts_with("//")
765        && !matched.remote_path.contains('\\')
766        && !matched.remote_path.contains("://")
767        && !matched.remote_path.contains('?')
768        && !matched.remote_path.contains('#')
769        && matched
770            .remote_path
771            .split('/')
772            .skip(1)
773            .all(|segment| !segment.is_empty() && segment != "." && segment != "..");
774    if !remote_path_is_safe {
775        return Err(AppError::new(
776            ErrorCode::Validation,
777            "remote HTTP route path is unsafe",
778        ));
779    }
780
781    let mut base = reqwest::Url::parse(&matched.base_url).map_err(|error| {
782        AppError::new(
783            ErrorCode::Validation,
784            format!("configured remote base URL is invalid: {error}"),
785        )
786    })?;
787    if !base.path().ends_with('/') {
788        base.set_path(&format!("{}/", base.path()));
789    }
790    let joined = base
791        .join(matched.remote_path.trim_start_matches('/'))
792        .map_err(|error| {
793            AppError::new(
794                ErrorCode::Validation,
795                format!("remote HTTP route path is invalid: {error}"),
796            )
797        })?;
798    if joined.origin() != base.origin() || !joined.path().starts_with(base.path()) {
799        return Err(AppError::new(
800            ErrorCode::Validation,
801            "remote HTTP route escapes configured base URL",
802        ));
803    }
804    Ok(joined)
805}
806
807fn reqwest_method(method: ModuleHttpMethod) -> reqwest::Method {
808    match method {
809        ModuleHttpMethod::Get => reqwest::Method::GET,
810        ModuleHttpMethod::Post => reqwest::Method::POST,
811        ModuleHttpMethod::Put => reqwest::Method::PUT,
812        ModuleHttpMethod::Patch => reqwest::Method::PATCH,
813        ModuleHttpMethod::Delete => reqwest::Method::DELETE,
814        _ => reqwest::Method::GET,
815    }
816}
817
818async fn record_proxy_call(
819    ctx: &AppContext,
820    matched: &RemoteHttpProxyMatch,
821    request_ctx: &platform_core::RequestContext,
822    started_at: Instant,
823    remote_status: Option<reqwest::StatusCode>,
824    error: Option<&AppError>,
825) {
826    let duration_ms = started_at.elapsed().as_millis().min(i64::MAX as u128) as i64;
827    match error {
828        Some(error) => {
829            tracing::warn!(
830                module_name = %matched.module_name,
831                declared_path = %matched.declared_path,
832                remote_path = %matched.remote_path,
833                http_method = %module_http_method_label(matched.method),
834                remote_status = remote_status.map_or(0, |status| status.as_u16()),
835                duration_ms,
836                error_code = error.code.as_str(),
837                retryable = error.retryable,
838                request_id = %request_ctx.request_id.0,
839                correlation_id = %request_ctx.correlation_id.0,
840                "remote HTTP proxy call failed"
841            );
842        }
843        None => {
844            tracing::info!(
845                module_name = %matched.module_name,
846                declared_path = %matched.declared_path,
847                remote_path = %matched.remote_path,
848                http_method = %module_http_method_label(matched.method),
849                remote_status = remote_status.map_or(0, |status| status.as_u16()),
850                duration_ms,
851                request_id = %request_ctx.request_id.0,
852                correlation_id = %request_ctx.correlation_id.0,
853                "remote HTTP proxy call completed"
854            );
855        }
856    }
857
858    let record = RemoteHttpProxyCallRecord {
859        module_name: matched.module_name.clone(),
860        method: module_http_method_label(matched.method).to_owned(),
861        declared_path: matched.declared_path.clone(),
862        remote_path: matched.remote_path.clone(),
863        capability: matched.capability.clone(),
864        display_name: matched.display_name.clone(),
865        story_title: matched.story_title.clone(),
866        remote_status: remote_status.map(|status| status.as_u16()),
867        duration_ms,
868        success: error.is_none(),
869        error_code: error.map(|error| error.code.as_str().to_owned()),
870        retryable: error.is_some_and(|error| error.retryable),
871        path_params: json!(matched.path_params),
872        error_details: error
873            .map(|error| json!(error.details))
874            .unwrap_or_else(|| Value::Array(Vec::new())),
875    };
876
877    if let Err(error) =
878        insert_remote_http_proxy_call(&ctx.db, ctx.ids.as_ref(), request_ctx, record).await
879    {
880        tracing::warn!(
881            error = ?error,
882            module_name = %matched.module_name,
883            declared_path = %matched.declared_path,
884            remote_path = %matched.remote_path,
885            http_method = %module_http_method_label(matched.method),
886            request_id = %request_ctx.request_id.0,
887            correlation_id = %request_ctx.correlation_id.0,
888            "failed to persist remote HTTP proxy call"
889        );
890    }
891}
892
893fn module_http_method_label(method: ModuleHttpMethod) -> &'static str {
894    match method {
895        ModuleHttpMethod::Get => "GET",
896        ModuleHttpMethod::Post => "POST",
897        ModuleHttpMethod::Put => "PUT",
898        ModuleHttpMethod::Patch => "PATCH",
899        ModuleHttpMethod::Delete => "DELETE",
900        _ => "UNKNOWN",
901    }
902}
903
904impl RemoteHttpProxyResponse {
905    fn from_match(matched: RemoteHttpProxyMatch, data: Value) -> Self {
906        Self {
907            status: RemoteHttpProxyStatus::Forwarded,
908            module_name: matched.module_name,
909            method: matched.method,
910            declared_path: matched.declared_path,
911            remote_path: matched.remote_path,
912            capability: matched.capability.unwrap_or_default(),
913            path_params: matched.path_params,
914            data,
915        }
916    }
917}
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922    use std::collections::BTreeMap;
923
924    fn matched(method: ModuleHttpMethod) -> RemoteHttpProxyMatch {
925        RemoteHttpProxyMatch {
926            module_name: "remote-crm".to_owned(),
927            base_url: "http://127.0.0.1:4100/lenso/module/v1/".to_owned(),
928            transport: RemoteModuleTransport::HttpJson,
929            timeout_ms: 5_000,
930            auth_token: None,
931            method,
932            declared_path: "/contacts/{id}".to_owned(),
933            remote_path: "/contacts/contact_1".to_owned(),
934            capability: Some("remote_crm.contacts.read".to_owned()),
935            display_name: Some("Fetch Contact".to_owned()),
936            story_title: Some("Fetch Contact".to_owned()),
937            path_params: BTreeMap::new(),
938        }
939    }
940
941    #[test]
942    fn remote_url_joins_base_and_remote_path_once() {
943        assert_eq!(
944            remote_url(&matched(ModuleHttpMethod::Get))
945                .unwrap()
946                .as_str(),
947            "http://127.0.0.1:4100/lenso/module/v1/contacts/contact_1"
948        );
949    }
950
951    #[test]
952    fn remote_url_rejects_prefix_and_backslash_escapes() {
953        for path in [
954            "/../admin",
955            "/contacts/./contact_1",
956            "/contacts\\..\\admin",
957            "//evil.example/admin",
958            "https://evil.example/admin",
959        ] {
960            let mut unsafe_match = matched(ModuleHttpMethod::Get);
961            unsafe_match.remote_path = path.to_owned();
962            assert!(remote_url(&unsafe_match).is_err(), "accepted {path}");
963        }
964    }
965
966    #[test]
967    fn reqwest_method_maps_declared_methods() {
968        assert_eq!(reqwest_method(ModuleHttpMethod::Get), reqwest::Method::GET);
969        assert_eq!(
970            reqwest_method(ModuleHttpMethod::Post),
971            reqwest::Method::POST
972        );
973        assert_eq!(reqwest_method(ModuleHttpMethod::Put), reqwest::Method::PUT);
974        assert_eq!(
975            reqwest_method(ModuleHttpMethod::Patch),
976            reqwest::Method::PATCH
977        );
978        assert_eq!(
979            reqwest_method(ModuleHttpMethod::Delete),
980            reqwest::Method::DELETE
981        );
982    }
983}