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}