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