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}