From 5918d72e7d646cc904127335e24dea9ae1961f8e Mon Sep 17 00:00:00 2001 From: Maxim Schuwalow Date: Mon, 26 Jan 2026 16:57:09 +0100 Subject: [PATCH 1/5] code-first-routes: cors support --- .../proto/golem/customapi/core.proto | 12 +- golem-common/src/base_model/agent.rs | 8 +- golem-common/src/base_model/mod.rs | 2 +- .../src/model/api_definition.rs | 4 +- .../services/deployment/deployment_context.rs | 126 +++++++++- golem-service-base/src/custom_api/mod.rs | 53 ++++- golem-service-base/src/custom_api/protobuf.rs | 58 ++++- .../agent_response_mapping.rs | 69 ++++-- .../src/gateway_execution/model.rs | 46 ++-- .../src/gateway_execution/request.rs | 16 ++ .../src/gateway_execution/request_handler.rs | 126 ++++++++-- golem-worker-service/src/model.rs | 3 +- .../tests/custom_api/agent_http_routes_ts.rs | 218 +++++++++++++++--- .../golem-it-agent-http-routes-ts/src/main.ts | 39 +++- .../golem_it_agent_http_routes_ts.wasm | Bin 5328502 -> 5431508 bytes 15 files changed, 647 insertions(+), 133 deletions(-) diff --git a/golem-api-grpc/proto/golem/customapi/core.proto b/golem-api-grpc/proto/golem/customapi/core.proto index ccfe0d23c8..120da4f50f 100644 --- a/golem-api-grpc/proto/golem/customapi/core.proto +++ b/golem-api-grpc/proto/golem/customapi/core.proto @@ -163,12 +163,13 @@ message CompiledRoute { RequestBodySchema body = 4; RouteBehaviour behavior = 5; optional golem.registry.SecuritySchemeId security_scheme = 6; - golem.component.CorsOptions cors = 7; + CorsOptions cors = 7; } message RouteBehaviour { oneof kind { CallAgent call_agent = 1; + CorsPreflight cors_preflight = 2; } message CallAgent { @@ -181,6 +182,11 @@ message RouteBehaviour { repeated MethodParameter method_parameters = 7; golem.component.DataSchema expected_agent_response = 8; } + + message CorsPreflight { + repeated string allowed_origins = 1; + repeated golem.component.HttpMethod allowed_methods = 2; + } } message SecuritySchemeDetails { @@ -192,3 +198,7 @@ message SecuritySchemeDetails { string redirect_url = 6; repeated string scopes = 7; } + +message CorsOptions { + repeated string allowed_patterns = 1; +} diff --git a/golem-common/src/base_model/agent.rs b/golem-common/src/base_model/agent.rs index 66b4eafdd1..2d5cbe3895 100644 --- a/golem-common/src/base_model/agent.rs +++ b/golem-common/src/base_model/agent.rs @@ -737,7 +737,9 @@ pub struct HttpEndpointDetails { pub cors_options: CorsOptions, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, IntoValue, FromValue)] +#[derive( + Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, IntoValue, FromValue, +)] #[cfg_attr( feature = "full", derive(desert_rust::BinaryCodec, poem_openapi::Union) @@ -790,7 +792,9 @@ impl TryFrom for http::Method { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, IntoValue, FromValue)] +#[derive( + Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, IntoValue, FromValue, +)] #[cfg_attr( feature = "full", derive(desert_rust::BinaryCodec, poem_openapi::Object) diff --git a/golem-common/src/base_model/mod.rs b/golem-common/src/base_model/mod.rs index aedaf74039..1682cc4485 100644 --- a/golem-common/src/base_model/mod.rs +++ b/golem-common/src/base_model/mod.rs @@ -157,7 +157,7 @@ impl FromValue for Timestamp { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, Default)] #[cfg_attr( feature = "full", derive(desert_rust::BinaryCodec, poem_openapi::Object) diff --git a/golem-registry-service/src/model/api_definition.rs b/golem-registry-service/src/model/api_definition.rs index 6afde2bf7c..0a4b86d747 100644 --- a/golem-registry-service/src/model/api_definition.rs +++ b/golem-registry-service/src/model/api_definition.rs @@ -14,13 +14,13 @@ use desert_rust::BinaryCodec; use golem_common::model::account::AccountId; -use golem_common::model::agent::{CorsOptions, HttpMethod}; +use golem_common::model::agent::HttpMethod; use golem_common::model::deployment::DeploymentRevision; use golem_common::model::domain_registration::Domain; use golem_common::model::environment::EnvironmentId; use golem_common::model::security_scheme::{SecuritySchemeId, SecuritySchemeName}; use golem_service_base::custom_api::{ - PathSegment, RequestBodySchema, RouteBehaviour, RouteId, SecuritySchemeDetails, + CorsOptions, PathSegment, RequestBodySchema, RouteBehaviour, RouteId, SecuritySchemeDetails, }; use std::collections::HashMap; diff --git a/golem-registry-service/src/services/deployment/deployment_context.rs b/golem-registry-service/src/services/deployment/deployment_context.rs index 0e56ef572c..e2e6b751fc 100644 --- a/golem-registry-service/src/services/deployment/deployment_context.rs +++ b/golem-registry-service/src/services/deployment/deployment_context.rs @@ -19,6 +19,7 @@ use super::http_parameter_conversion::{ use crate::model::api_definition::UnboundCompiledRoute; use crate::model::component::Component; use crate::services::deployment::write::DeployValidationError; +use golem_common::model::Empty; use golem_common::model::agent::wit_naming::ToWitNaming; use golem_common::model::agent::{ AgentMethod, AgentType, AgentTypeName, DataSchema, ElementSchema, HttpEndpointDetails, @@ -29,9 +30,12 @@ use golem_common::model::component::ComponentName; use golem_common::model::diff::{self, HashOf, Hashable}; use golem_common::model::domain_registration::Domain; use golem_common::model::http_api_deployment::HttpApiDeployment; -use golem_service_base::custom_api::{ConstructorParameter, PathSegment, RouteBehaviour}; +use golem_service_base::custom_api::{ + ConstructorParameter, CorsOptions, OriginPattern, PathSegment, RequestBodySchema, + RouteBehaviour, +}; use itertools::Itertools; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; macro_rules! ok_or_continue { ($expr:expr, $errors:ident) => {{ @@ -186,7 +190,24 @@ impl DeploymentContext { constructor_parameters: Vec, errors: &mut Vec, ) -> Vec { - let mut compiled_routes = Vec::new(); + let mut compiled_routes: HashMap<(HttpMethod, Vec), UnboundCompiledRoute> = + HashMap::new(); + + struct PreflightMapEntry { + allowed_methods: BTreeSet, + allowed_origins: BTreeSet, + } + + impl PreflightMapEntry { + fn new() -> Self { + PreflightMapEntry { + allowed_methods: BTreeSet::new(), + allowed_origins: BTreeSet::new(), + } + } + } + + let mut preflight_map: HashMap, PreflightMapEntry> = HashMap::new(); for agent_method in agent_methods { for http_endpoint in &agent_method.http_endpoint { @@ -198,12 +219,33 @@ impl DeploymentContext { agent_method, ); - let cors = if !http_endpoint.cors_options.allowed_patterns.is_empty() { - http_endpoint.cors_options.clone() - } else { - http_mount.cors_options.clone() + let mut cors = CorsOptions { + allowed_patterns: vec![], }; + if !http_mount.cors_options.allowed_patterns.is_empty() { + cors.allowed_patterns.extend( + http_mount + .cors_options + .allowed_patterns + .iter() + .cloned() + .map(OriginPattern), + ); + } + if !http_endpoint.cors_options.allowed_patterns.is_empty() { + cors.allowed_patterns.extend( + http_endpoint + .cors_options + .allowed_patterns + .iter() + .cloned() + .map(OriginPattern), + ); + } + cors.allowed_patterns.sort(); + cors.allowed_patterns.dedup(); + let route_id = *current_route_id; *current_route_id = current_route_id.checked_add(1).unwrap(); @@ -225,7 +267,7 @@ impl DeploymentContext { errors ); - let path = http_mount + let path_segments: Vec = http_mount .path_prefix .iter() .cloned() @@ -233,11 +275,24 @@ impl DeploymentContext { .map(|p| compile_agent_path_segment(agent, implementer, p)) .collect(); + if !cors.allowed_patterns.is_empty() { + let entry = preflight_map + .entry(path_segments.clone()) + .or_insert(PreflightMapEntry::new()); + + entry + .allowed_methods + .insert(http_endpoint.http_method.clone()); + for allowed_pattern in &cors.allowed_patterns { + entry.allowed_origins.insert(allowed_pattern.clone()); + } + } + let compiled = UnboundCompiledRoute { route_id, domain: deployment.domain.clone(), method: http_endpoint.http_method.clone(), - path, + path: path_segments.clone(), body, behaviour: RouteBehaviour::CallAgent { component_id: implementer.component_id, @@ -253,11 +308,60 @@ impl DeploymentContext { cors, }; - compiled_routes.push(compiled); + { + let key = (http_endpoint.http_method.clone(), path_segments); + if let std::collections::hash_map::Entry::Vacant(e) = compiled_routes.entry(key) + { + e.insert(compiled); + } else { + errors.push(make_route_validation_error( + "Duplicate route detected".into(), + )); + } + } + } + } + + // Generate synthetic OPTIONS routes for preflight requests + for ( + path_segments, + PreflightMapEntry { + allowed_methods, + allowed_origins, + }, + ) in preflight_map + { + let key = (HttpMethod::Options(Empty {}), path_segments.clone()); + if compiled_routes.contains_key(&key) { + // Skip synthetic OPTIONS if user already defined one + // TODO: Emit to the cli as warning + continue; } + + let route_id = *current_route_id; + *current_route_id = current_route_id.checked_add(1).unwrap(); + + compiled_routes.insert( + key, + UnboundCompiledRoute { + route_id, + domain: deployment.domain.clone(), + method: HttpMethod::Options(Empty {}), + path: path_segments, + body: RequestBodySchema::Unused, + behaviour: RouteBehaviour::CorsPreflight { + allowed_origins, + allowed_methods, + }, + security_scheme: None, + cors: CorsOptions { + allowed_patterns: vec![], + }, + }, + ); } - compiled_routes + compiled_routes.into_values().collect() } } diff --git a/golem-service-base/src/custom_api/mod.rs b/golem-service-base/src/custom_api/mod.rs index 2c9e8b64c5..866abe691a 100644 --- a/golem-service-base/src/custom_api/mod.rs +++ b/golem-service-base/src/custom_api/mod.rs @@ -18,7 +18,7 @@ mod protobuf; use crate::model::SafeIndex; use desert_rust::BinaryCodec; use golem_common::model::account::AccountId; -use golem_common::model::agent::{AgentTypeName, CorsOptions, DataSchema, HttpMethod}; +use golem_common::model::agent::{AgentTypeName, DataSchema, HttpMethod}; use golem_common::model::component::{ComponentId, ComponentRevision}; use golem_common::model::deployment::DeploymentRevision; use golem_common::model::environment::EnvironmentId; @@ -26,12 +26,12 @@ use golem_common::model::security_scheme::{Provider, SecuritySchemeId, SecurityS use golem_wasm::analysis::analysed_type; use golem_wasm::analysis::{AnalysedType, TypeList, TypeOption}; use openidconnect::{ClientId, ClientSecret, RedirectUrl, Scope}; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::fmt; pub type RouteId = i32; -#[derive(Debug, Clone, BinaryCodec)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, BinaryCodec)] #[desert(evolution())] pub enum PathSegment { Literal { value: String }, @@ -249,6 +249,10 @@ pub enum RouteBehaviour { method_parameters: Vec, expected_agent_response: DataSchema, }, + CorsPreflight { + allowed_origins: BTreeSet, + allowed_methods: BTreeSet, + }, } #[derive(Debug, Clone)] @@ -261,3 +265,46 @@ pub struct SecuritySchemeDetails { pub redirect_url: RedirectUrl, pub scopes: Vec, } + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, BinaryCodec)] +#[desert(evolution())] +pub struct CorsOptions { + pub allowed_patterns: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, BinaryCodec)] +#[desert(transparent)] +// Note: Wildcards are only considered during matching. When setting the allow-origin header +// always use the exact origin that made the request to avoid complications with +// presence of auth information. +// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin#sect +pub struct OriginPattern(pub String); + +impl OriginPattern { + // match origin according to cors spec https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin + pub fn matches(&self, origin: &str) -> bool { + if self.0 == "*" { + true + } else { + self.0 == origin + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_r::test; + + #[test] + fn test_origin_pattern_matches() { + let wildcard = OriginPattern("*".to_string()); + assert!(wildcard.matches("https://example.com")); + assert!(wildcard.matches("https://foo.bar")); + + let exact = OriginPattern("https://example.com".to_string()); + assert!(exact.matches("https://example.com")); + assert!(!exact.matches("https://other.com")); + assert!(!exact.matches("http://example.com")); // scheme matters + } +} diff --git a/golem-service-base/src/custom_api/protobuf.rs b/golem-service-base/src/custom_api/protobuf.rs index 686602049c..8ca83175ea 100644 --- a/golem-service-base/src/custom_api/protobuf.rs +++ b/golem-service-base/src/custom_api/protobuf.rs @@ -12,16 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::SecuritySchemeDetails; use super::{CompiledRoute, CompiledRoutes}; +use super::{CorsOptions, SecuritySchemeDetails}; use super::{PathSegment, PathSegmentType, RequestBodySchema, RouteBehaviour}; -use crate::custom_api::{ConstructorParameter, MethodParameter, QueryOrHeaderType}; +use crate::custom_api::{ConstructorParameter, MethodParameter, OriginPattern, QueryOrHeaderType}; use golem_api_grpc::proto; -use golem_common::model::agent::AgentTypeName; +use golem_common::model::agent::{AgentTypeName, HttpMethod}; use golem_common::model::security_scheme::SecuritySchemeName; use golem_wasm::analysis::TypeEnum; use openidconnect::{ClientId, ClientSecret, RedirectUrl, Scope}; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::ops::Deref; impl TryFrom for SecuritySchemeDetails { @@ -182,6 +182,18 @@ impl TryFrom for RouteBehaviour { .ok_or("Missing expected_agent_response")? .try_into()?, }), + Kind::CorsPreflight(cors_preflight) => Ok(RouteBehaviour::CorsPreflight { + allowed_origins: cors_preflight + .allowed_origins + .into_iter() + .map(OriginPattern) + .collect(), + allowed_methods: cors_preflight + .allowed_methods + .into_iter() + .map(HttpMethod::try_from) + .collect::, _>>()?, + }), } } } @@ -217,6 +229,20 @@ impl From for proto::golem::customapi::RouteBehaviour { }, )), }, + RouteBehaviour::CorsPreflight { + allowed_origins, + allowed_methods, + } => Self { + kind: Some(Kind::CorsPreflight( + proto::golem::customapi::route_behaviour::CorsPreflight { + allowed_origins: allowed_origins.into_iter().map(|ao| ao.0).collect(), + allowed_methods: allowed_methods + .into_iter() + .map(proto::golem::component::HttpMethod::from) + .collect(), + }, + )), + }, } } } @@ -578,3 +604,27 @@ impl From for proto::golem::customapi::QueryOrHeaderType { Self { kind: Some(kind) } } } + +impl TryFrom for CorsOptions { + type Error = String; + + fn try_from( + value: golem_api_grpc::proto::golem::customapi::CorsOptions, + ) -> Result { + Ok(Self { + allowed_patterns: value + .allowed_patterns + .into_iter() + .map(OriginPattern) + .collect(), + }) + } +} + +impl From for golem_api_grpc::proto::golem::customapi::CorsOptions { + fn from(value: CorsOptions) -> Self { + Self { + allowed_patterns: value.allowed_patterns.into_iter().map(|op| op.0).collect(), + } + } +} diff --git a/golem-worker-service/src/gateway_execution/agent_response_mapping.rs b/golem-worker-service/src/gateway_execution/agent_response_mapping.rs index 35fbf9b8c9..9c6cde8032 100644 --- a/golem-worker-service/src/gateway_execution/agent_response_mapping.rs +++ b/golem-worker-service/src/gateway_execution/agent_response_mapping.rs @@ -13,7 +13,7 @@ // limitations under the License. use super::request_handler::RequestHandlerError; -use super::RouteExecutionResult; +use super::{ResponseBody, RouteExecutionResult}; use anyhow::anyhow; use golem_common::model::agent::{ AgentError, BinaryReference, DataSchema, DataValue, ElementValue, ElementValues, @@ -22,6 +22,7 @@ use golem_common::model::agent::{ use golem_wasm::analysis::AnalysedType; use golem_wasm::{FromValue, ValueAndType}; use http::StatusCode; +use std::collections::HashMap; use tracing::debug; pub fn interpret_agent_response( @@ -57,9 +58,11 @@ fn map_agent_error(agent_error: AgentError) -> Result Err(RequestHandlerError::invariant_violated( "unexpected agent error type", )), - AgentError::CustomError(inner) => { - Ok(RouteExecutionResult::CustomAgentError { body: inner }) - } + AgentError::CustomError(inner) => Ok(RouteExecutionResult { + status: StatusCode::INTERNAL_SERVER_ERROR, + headers: HashMap::new(), + body: ResponseBody::ComponentModelJsonBody { body: inner }, + }), } } @@ -74,8 +77,10 @@ fn map_successful_agent_response( match typed_value { DataValue::Tuple(ElementValues { elements }) => match elements.len() { - 0 => Ok(RouteExecutionResult::NoBody { + 0 => Ok(RouteExecutionResult { status: StatusCode::NO_CONTENT, + headers: HashMap::new(), + body: ResponseBody::NoBody, }), 1 => map_single_element_agent_response(elements.into_iter().next().unwrap()), _ => Err(RequestHandlerError::invariant_violated( @@ -97,7 +102,11 @@ fn map_single_element_agent_response( } ElementValue::UnstructuredBinary(BinaryReference::Inline(binary)) => { - Ok(RouteExecutionResult::UnstructuredBinaryBody { body: binary }) + Ok(RouteExecutionResult { + status: StatusCode::OK, + headers: HashMap::new(), + body: ResponseBody::UnstructuredBinaryBody { body: binary }, + }) } _ => Err(RequestHandlerError::invariant_violated( @@ -112,40 +121,55 @@ fn map_component_model_agent_response( use golem_wasm::Value; match value_and_type.value { - Value::Option(None) => Ok(RouteExecutionResult::NoBody { + Value::Option(None) => Ok(RouteExecutionResult { status: StatusCode::NOT_FOUND, + headers: HashMap::new(), + body: ResponseBody::NoBody, }), Value::Option(Some(inner)) => { let inner_type = unwrap_option_type(value_and_type.typ)?; - Ok(json_response_body(*inner, inner_type, StatusCode::OK)) + Ok(RouteExecutionResult { + status: StatusCode::OK, + headers: HashMap::new(), + body: json_response_body(*inner, inner_type), + }) } - Value::Result(Ok(None)) => Ok(RouteExecutionResult::NoBody { + Value::Result(Ok(None)) => Ok(RouteExecutionResult { status: StatusCode::NO_CONTENT, + headers: HashMap::new(), + body: ResponseBody::NoBody, }), Value::Result(Ok(Some(inner))) => { let inner_type = unwrap_result_ok_type(value_and_type.typ)?; - Ok(json_response_body(*inner, inner_type, StatusCode::OK)) + Ok(RouteExecutionResult { + status: StatusCode::OK, + headers: HashMap::new(), + body: json_response_body(*inner, inner_type), + }) } - Value::Result(Err(None)) => Ok(RouteExecutionResult::NoBody { + Value::Result(Err(None)) => Ok(RouteExecutionResult { status: StatusCode::INTERNAL_SERVER_ERROR, + headers: HashMap::new(), + body: ResponseBody::NoBody, }), Value::Result(Err(Some(inner))) => { let inner_type = unwrap_result_err_type(value_and_type.typ)?; - Ok(json_response_body( - *inner, - inner_type, - StatusCode::INTERNAL_SERVER_ERROR, - )) + Ok(RouteExecutionResult { + status: StatusCode::INTERNAL_SERVER_ERROR, + headers: HashMap::new(), + body: json_response_body(*inner, inner_type), + }) } - other => Ok(RouteExecutionResult::ComponentModelJsonBody { - body: ValueAndType::new(other, value_and_type.typ), + other => Ok(RouteExecutionResult { status: StatusCode::OK, + headers: HashMap::new(), + body: json_response_body(other, value_and_type.typ), }), } } @@ -192,13 +216,8 @@ fn unwrap_result_err_type(typ: AnalysedType) -> Result RouteExecutionResult { - RouteExecutionResult::ComponentModelJsonBody { +fn json_response_body(value: golem_wasm::Value, typ: AnalysedType) -> ResponseBody { + ResponseBody::ComponentModelJsonBody { body: ValueAndType::new(value, typ), - status, } } diff --git a/golem-worker-service/src/gateway_execution/model.rs b/golem-worker-service/src/gateway_execution/model.rs index 3dd2788ef9..1e94d1d8c1 100644 --- a/golem-worker-service/src/gateway_execution/model.rs +++ b/golem-worker-service/src/gateway_execution/model.rs @@ -13,44 +13,32 @@ // limitations under the License. use golem_common::model::agent::BinarySource; -use golem_wasm::ValueAndType; -use http::StatusCode; +use http::{HeaderName, StatusCode}; +use std::collections::HashMap; use std::fmt; -pub enum RouteExecutionResult { - NoBody { - status: StatusCode, - }, - ComponentModelJsonBody { - body: golem_wasm::ValueAndType, - status: StatusCode, - }, - UnstructuredBinaryBody { - body: BinarySource, - }, - CustomAgentError { - body: ValueAndType, - }, +#[derive(Debug)] +pub struct RouteExecutionResult { + pub status: StatusCode, + pub headers: HashMap, + pub body: ResponseBody, } -impl fmt::Debug for RouteExecutionResult { +pub enum ResponseBody { + NoBody, + ComponentModelJsonBody { body: golem_wasm::ValueAndType }, + UnstructuredBinaryBody { body: BinarySource }, +} + +impl fmt::Debug for ResponseBody { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - RouteExecutionResult::NoBody { status } => { - f.debug_struct("NoBody").field("status", status).finish() - } - RouteExecutionResult::ComponentModelJsonBody { body, status } => f + ResponseBody::NoBody => f.debug_struct("NoBody").finish(), + ResponseBody::ComponentModelJsonBody { body } => f .debug_struct("ComponentModelJsonBody") .field("body", body) - .field("status", status) - .finish(), - RouteExecutionResult::UnstructuredBinaryBody { .. } => { - f.write_str("UnstructuredBinaryBody") - } - RouteExecutionResult::CustomAgentError { body } => f - .debug_struct("CustomAgentError") - .field("body", body) .finish(), + ResponseBody::UnstructuredBinaryBody { .. } => f.write_str("UnstructuredBinaryBody"), } } } diff --git a/golem-worker-service/src/gateway_execution/request.rs b/golem-worker-service/src/gateway_execution/request.rs index 160330ec5b..d70b6016c4 100644 --- a/golem-worker-service/src/gateway_execution/request.rs +++ b/golem-worker-service/src/gateway_execution/request.rs @@ -15,6 +15,7 @@ // use super::gateway_session_store::{DataKey, GatewaySessionStore, SessionId}; // use crate::gateway_router::PathParamExtractor; // use crate::model::{HttpMiddleware, RichGatewayBindingCompiled}; +use super::request_handler::RequestHandlerError; use http::HeaderMap; use std::collections::HashMap; use uuid::Uuid; @@ -47,6 +48,21 @@ impl RichRequest { // self.auth_data.as_ref() // } + pub fn origin(&self) -> Result, RequestHandlerError> { + match self.underlying.headers().get("Origin") { + Some(header) => { + let result = + header + .to_str() + .map_err(|_| RequestHandlerError::HeaderIsNotAscii { + header_name: "Origin".to_string(), + })?; + Ok(Some(result)) + } + None => Ok(None), + } + } + pub fn headers(&self) -> &HeaderMap { self.underlying.headers() } diff --git a/golem-worker-service/src/gateway_execution/request_handler.rs b/golem-worker-service/src/gateway_execution/request_handler.rs index 8844e51c2f..01e3a4f579 100644 --- a/golem-worker-service/src/gateway_execution/request_handler.rs +++ b/golem-worker-service/src/gateway_execution/request_handler.rs @@ -19,7 +19,7 @@ use super::parameter_parsing::{ }; use super::request::RichRequest; use super::route_resolver::{ResolvedRouteEntry, RouteResolver, RouteResolverError}; -use super::{ParsedRequestBody, RouteExecutionResult}; +use super::{ParsedRequestBody, ResponseBody, RouteExecutionResult}; use crate::service::worker::{WorkerService, WorkerServiceError}; use anyhow::anyhow; use golem_common::model::agent::{ @@ -34,7 +34,8 @@ use golem_wasm::json::ValueAndTypeJsonExtensions; use golem_wasm::IntoValue; use golem_wasm::ValueAndType; use http::StatusCode; -use poem::{Request, Response}; +use poem::{Request, Response, ResponseBuilder}; +use std::collections::HashMap; use std::sync::Arc; use tracing::debug; use uuid::Uuid; @@ -127,7 +128,8 @@ impl RequestHandler { let matching_route = self.route_resolver.resolve_matching_route(&request).await?; let mut request = RichRequest::new(request); let execution_result = self.execute_route(&mut request, &matching_route).await?; - let response = route_execution_result_to_response(execution_result)?; + let response = + route_execution_result_to_response(execution_result, &request, &matching_route)?; Ok(response) } @@ -140,6 +142,58 @@ impl RequestHandler { RouteBehaviour::CallAgent { .. } => { self.execute_call_agent(request, resolved_route).await } + + RouteBehaviour::CorsPreflight { + allowed_origins, + allowed_methods, + } => { + let origin = request.origin()?.ok_or(RequestHandlerError::MissingValue { + expected: "Origin header", + })?; + + let origin_allowed = allowed_origins + .iter() + .any(|pattern| pattern.matches(origin)); + if !origin_allowed { + return Ok(RouteExecutionResult { + status: StatusCode::FORBIDDEN, + headers: HashMap::new(), + body: ResponseBody::NoBody, + }); + } + + let allow_methods = allowed_methods + .iter() + .map(|m| { + let converted = http::Method::try_from(m.clone()).map_err(|_| { + RequestHandlerError::invariant_violated("HttpMethod conversion error") + })?; + let rendered = converted.to_string(); + Ok::<_, RequestHandlerError>(rendered) + }) + .collect::, _>>()? + .join(", "); + + let mut headers = HashMap::new(); + + headers.insert( + http::header::ACCESS_CONTROL_ALLOW_ORIGIN, + origin.to_string(), + ); + headers.insert(http::header::ACCESS_CONTROL_ALLOW_METHODS, allow_methods); + headers.insert( + http::header::ACCESS_CONTROL_ALLOW_HEADERS, + "Content-Type, Authorization".to_string(), + ); + headers.insert(http::header::ACCESS_CONTROL_MAX_AGE, "3600".to_string()); + headers.insert(http::header::VARY, "Origin".to_string()); + + Ok(RouteExecutionResult { + status: StatusCode::NO_CONTENT, + headers, + body: ResponseBody::NoBody, + }) + } } } @@ -175,11 +229,11 @@ impl RequestHandler { agent_response.clone().unwrap().to_json_value().unwrap() ); - let mapped_result = interpret_agent_response(agent_response, expected_agent_response)?; + let route_result = interpret_agent_response(agent_response, expected_agent_response)?; - debug!("Returning mapped agent result: {mapped_result:?}"); + debug!("Returning call agent route result: {route_result:?}"); - Ok(mapped_result) + Ok(route_result) } fn build_worker_id( @@ -366,35 +420,63 @@ impl RequestHandler { fn route_execution_result_to_response( result: RouteExecutionResult, + request: &RichRequest, + resolved_route: &ResolvedRouteEntry, ) -> Result { - match result { - RouteExecutionResult::NoBody { status } => Ok(Response::builder().status(status).finish()), + let mut response_builder = Response::builder().status(result.status); + + for (name, value) in result.headers { + response_builder = response_builder.header(name, value); + } + + response_builder = apply_cors_headers(response_builder, request, resolved_route)?; + + match result.body { + ResponseBody::NoBody => Ok(response_builder.finish()), - RouteExecutionResult::ComponentModelJsonBody { body, status } => { + ResponseBody::ComponentModelJsonBody { body } => { let body = poem::Body::from_json( body.to_json_value() .map_err(|e| anyhow!("ComponentModelJsonBody conversion error: {e}"))?, ) .map_err(anyhow::Error::from)?; - Ok(Response::builder().status(status).body(body)) + Ok(response_builder.body(body)) } - RouteExecutionResult::UnstructuredBinaryBody { body } => Ok(Response::builder() - .status(StatusCode::OK) + ResponseBody::UnstructuredBinaryBody { body } => Ok(response_builder .body(body.data) .set_content_type(body.binary_type.mime_type)), + } +} - RouteExecutionResult::CustomAgentError { body } => { - let body = poem::Body::from_json( - body.to_json_value() - .map_err(|e| anyhow!("CustomAgentError conversion error: {e}"))?, - ) - .map_err(anyhow::Error::from)?; +fn apply_cors_headers( + mut builder: ResponseBuilder, + request: &RichRequest, + resolved_route: &ResolvedRouteEntry, +) -> Result { + let cors = &resolved_route.route.cors; - Ok(Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(body)) - } + if cors.allowed_patterns.is_empty() { + return Ok(builder); } + + let origin = match request.origin()? { + Some(o) => o, + None => return Ok(builder), // non-CORS request + }; + + if !cors + .allowed_patterns + .iter() + .any(|pattern| pattern.matches(origin)) + { + return Ok(builder); + } + + builder = builder.header(http::header::ACCESS_CONTROL_ALLOW_ORIGIN, origin); + builder = builder.header(http::header::VARY, "Origin"); + builder = builder.header(http::header::ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"); + + Ok(builder) } diff --git a/golem-worker-service/src/model.rs b/golem-worker-service/src/model.rs index f9f631c98a..2ebaac80d9 100644 --- a/golem-worker-service/src/model.rs +++ b/golem-worker-service/src/model.rs @@ -18,9 +18,8 @@ use golem_common::model::worker::WorkerMetadataDto; use golem_common::model::ScanCursor; // use golem_service_base::custom_api::HttpCors; use golem_common::model::account::AccountId; -use golem_common::model::agent::CorsOptions; use golem_common::model::environment::EnvironmentId; -use golem_service_base::custom_api::SecuritySchemeDetails; +use golem_service_base::custom_api::{CorsOptions, SecuritySchemeDetails}; use golem_service_base::custom_api::{PathSegment, RequestBodySchema, RouteBehaviour, RouteId}; use http::Method; use poem_openapi::Object; diff --git a/integration-tests/tests/custom_api/agent_http_routes_ts.rs b/integration-tests/tests/custom_api/agent_http_routes_ts.rs index 5f578140e7..54102a8847 100644 --- a/integration-tests/tests/custom_api/agent_http_routes_ts.rs +++ b/integration-tests/tests/custom_api/agent_http_routes_ts.rs @@ -75,7 +75,10 @@ async fn test_context_internal(deps: &EnvBasedTestDependencies) -> anyhow::Resul let http_api_deployment_creation = HttpApiDeploymentCreation { domain: domain.clone(), - agent_types: BTreeSet::from_iter([AgentTypeName("http-agent".into())]), + agent_types: BTreeSet::from_iter([ + AgentTypeName("http-agent".into()), + AgentTypeName("cors-agent".into()), + ]), }; client @@ -111,7 +114,7 @@ async fn string_path_var(agent: &TestContext) -> anyhow::Result<()> { .get( agent .base_url - .join("/agents/test-agent/string-path-var/foo")?, + .join("/http-agents/test-agent/string-path-var/foo")?, ) .send() .await?; @@ -130,7 +133,7 @@ async fn multi_path_vars(agent: &TestContext) -> anyhow::Result<()> { .get( agent .base_url - .join("/agents/test-agent/multi-path-vars/foo/bar")?, + .join("/http-agents/test-agent/multi-path-vars/foo/bar")?, ) .send() .await?; @@ -148,7 +151,11 @@ async fn multi_path_vars(agent: &TestContext) -> anyhow::Result<()> { async fn remaining_path_variable(agent: &TestContext) -> anyhow::Result<()> { let response = agent .client - .get(agent.base_url.join("/agents/test-agent/rest/a/b/c/d")?) + .get( + agent + .base_url + .join("/http-agents/test-agent/rest/a/b/c/d")?, + ) .send() .await?; @@ -170,7 +177,7 @@ async fn remaining_path_variable(agent: &TestContext) -> anyhow::Result<()> { async fn remaining_path_missing(agent: &TestContext) -> anyhow::Result<()> { let response = agent .client - .get(agent.base_url.join("/agents/test-agent/rest")?) + .get(agent.base_url.join("/http-agents/test-agent/rest")?) .send() .await?; @@ -186,7 +193,7 @@ async fn path_and_query(agent: &TestContext) -> anyhow::Result<()> { .get( agent .base_url - .join("/agents/test-agent/path-and-query/item-123?limit=10")?, + .join("/http-agents/test-agent/path-and-query/item-123?limit=10")?, ) .send() .await?; @@ -213,7 +220,7 @@ async fn path_and_header(agent: &TestContext) -> anyhow::Result<()> { .get( agent .base_url - .join("/agents/test-agent/path-and-header/res-42")?, + .join("/http-agents/test-agent/path-and-header/res-42")?, ) .header("x-request-id", "req-abc") .send() @@ -238,7 +245,11 @@ async fn path_and_header(agent: &TestContext) -> anyhow::Result<()> { async fn json_body(agent: &TestContext) -> anyhow::Result<()> { let response = agent .client - .post(agent.base_url.join("/agents/test-agent/json-body/item-1")?) + .post( + agent + .base_url + .join("/http-agents/test-agent/json-body/item-1")?, + ) .json(&json!({ "name": "test", "count": 42 @@ -259,7 +270,11 @@ async fn json_body(agent: &TestContext) -> anyhow::Result<()> { async fn json_body_missing_field(agent: &TestContext) -> anyhow::Result<()> { let response = agent .client - .post(agent.base_url.join("/agents/test-agent/json-body/item-1")?) + .post( + agent + .base_url + .join("/http-agents/test-agent/json-body/item-1")?, + ) .json(&json!({ "name": "test" })) @@ -275,7 +290,11 @@ async fn json_body_missing_field(agent: &TestContext) -> anyhow::Result<()> { async fn json_body_wrong_type(agent: &TestContext) -> anyhow::Result<()> { let response = agent .client - .post(agent.base_url.join("/agents/test-agent/json-body/item-1")?) + .post( + agent + .base_url + .join("/http-agents/test-agent/json-body/item-1")?, + ) .json(&json!({ "name": "test", "count": "not-a-number" @@ -295,7 +314,7 @@ async fn unrestricted_unstructured_binary_inline(agent: &TestContext) -> anyhow: .post( agent .base_url - .join("/agents/test-agent/unrestricted-unstructured-binary/my-bucket")?, + .join("/http-agents/test-agent/unrestricted-unstructured-binary/my-bucket")?, ) .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") .body(vec![1u8, 2, 3, 4, 5]) @@ -318,7 +337,7 @@ async fn unrestricted_unstructured_binary_missing_body(agent: &TestContext) -> a .post( agent .base_url - .join("/agents/test-agent/unrestricted-unstructured-binary/my-bucket")?, + .join("/http-agents/test-agent/unrestricted-unstructured-binary/my-bucket")?, ) .send() .await?; @@ -341,7 +360,7 @@ async fn unrestricted_unstructured_binary_json_content_type( .post( agent .base_url - .join("/agents/test-agent/unrestricted-unstructured-binary/my-bucket")?, + .join("/http-agents/test-agent/unrestricted-unstructured-binary/my-bucket")?, ) .json(&json!({ "oops": true })) .send() @@ -363,7 +382,7 @@ async fn restricted_unstructured_binary_inline(agent: &TestContext) -> anyhow::R .post( agent .base_url - .join("/agents/test-agent/restricted-unstructured-binary/my-bucket")?, + .join("/http-agents/test-agent/restricted-unstructured-binary/my-bucket")?, ) .header(reqwest::header::CONTENT_TYPE, "image/gif") .body(vec![1u8, 2, 3, 4, 5]) @@ -386,7 +405,7 @@ async fn restricted_unstructured_binary_missing_body(agent: &TestContext) -> any .post( agent .base_url - .join("/agents/test-agent/restricted-unstructured-binary/my-bucket")?, + .join("/http-agents/test-agent/restricted-unstructured-binary/my-bucket")?, ) .send() .await?; @@ -406,7 +425,7 @@ async fn restricted_unstructured_binary_unsupported_mime_type( .post( agent .base_url - .join("/agents/test-agent/restricted-unstructured-binary/my-bucket")?, + .join("/http-agents/test-agent/restricted-unstructured-binary/my-bucket")?, ) .json(&json!({ "oops": true })) .send() @@ -422,7 +441,11 @@ async fn restricted_unstructured_binary_unsupported_mime_type( async fn response_no_content(agent: &TestContext) -> anyhow::Result<()> { let response = agent .client - .get(agent.base_url.join("/agents/test-agent/resp/no-content")?) + .get( + agent + .base_url + .join("/http-agents/test-agent/resp/no-content")?, + ) .send() .await?; @@ -437,7 +460,7 @@ async fn response_no_content(agent: &TestContext) -> anyhow::Result<()> { async fn response_json(agent: &TestContext) -> anyhow::Result<()> { let response = agent .client - .get(agent.base_url.join("/agents/test-agent/resp/json")?) + .get(agent.base_url.join("/http-agents/test-agent/resp/json")?) .send() .await?; @@ -457,7 +480,7 @@ async fn response_optional_found(agent: &TestContext) -> anyhow::Result<()> { .get( agent .base_url - .join("/agents/test-agent/resp/optional/true")?, + .join("/http-agents/test-agent/resp/optional/true")?, ) .send() .await?; @@ -478,7 +501,7 @@ async fn response_optional_not_found(agent: &TestContext) -> anyhow::Result<()> .get( agent .base_url - .join("/agents/test-agent/resp/optional/false")?, + .join("/http-agents/test-agent/resp/optional/false")?, ) .send() .await?; @@ -497,7 +520,7 @@ async fn response_result_ok(agent: &TestContext) -> anyhow::Result<()> { .get( agent .base_url - .join("/agents/test-agent/resp/result-json-json/true")?, + .join("/http-agents/test-agent/resp/result-json-json/true")?, ) .send() .await?; @@ -518,7 +541,7 @@ async fn response_result_err(agent: &TestContext) -> anyhow::Result<()> { .get( agent .base_url - .join("/agents/test-agent/resp/result-json-json/false")?, + .join("/http-agents/test-agent/resp/result-json-json/false")?, ) .send() .await?; @@ -542,7 +565,7 @@ async fn response_result_void_err(agent: &TestContext) -> anyhow::Result<()> { .post( agent .base_url - .join("/agents/test-agent/resp/result-void-json")?, + .join("/http-agents/test-agent/resp/result-void-json")?, ) .send() .await?; @@ -566,7 +589,7 @@ async fn response_result_json_void(agent: &TestContext) -> anyhow::Result<()> { .get( agent .base_url - .join("/agents/test-agent/resp/result-json-void")?, + .join("/http-agents/test-agent/resp/result-json-void")?, ) .send() .await?; @@ -584,7 +607,7 @@ async fn response_result_json_void(agent: &TestContext) -> anyhow::Result<()> { async fn response_binary(agent: &TestContext) -> anyhow::Result<()> { let response = agent .client - .get(agent.base_url.join("/agents/test-agent/resp/binary")?) + .get(agent.base_url.join("/http-agents/test-agent/resp/binary")?) .send() .await?; @@ -612,7 +635,7 @@ async fn negative_missing_path_var(agent: &TestContext) -> anyhow::Result<()> { .get( agent .base_url - .join("/agents/test-agent/multi-path-vars/foo")?, + .join("/http-agents/test-agent/multi-path-vars/foo")?, ) .send() .await?; @@ -629,7 +652,7 @@ async fn negative_extra_path_segment(agent: &TestContext) -> anyhow::Result<()> .get( agent .base_url - .join("/agents/test-agent/string-path-var/foo/bar")?, + .join("/http-agents/test-agent/string-path-var/foo/bar")?, ) .send() .await?; @@ -646,7 +669,7 @@ async fn negative_missing_query_param(agent: &TestContext) -> anyhow::Result<()> .get( agent .base_url - .join("/agents/test-agent/path-and-query/item-123")?, + .join("/http-agents/test-agent/path-and-query/item-123")?, ) .send() .await?; @@ -663,7 +686,7 @@ async fn negative_invalid_query_param_type(agent: &TestContext) -> anyhow::Resul .get( agent .base_url - .join("/agents/test-agent/path-and-query/item-123?limit=not-a-number")?, + .join("/http-agents/test-agent/path-and-query/item-123?limit=not-a-number")?, ) .send() .await?; @@ -680,7 +703,7 @@ async fn negative_missing_header(agent: &TestContext) -> anyhow::Result<()> { .get( agent .base_url - .join("/agents/test-agent/path-and-header/res-42")?, + .join("/http-agents/test-agent/path-and-header/res-42")?, ) // no x-request-id header .send() @@ -689,3 +712,138 @@ async fn negative_missing_header(agent: &TestContext) -> anyhow::Result<()> { assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); Ok(()) } + +#[test] +#[tracing::instrument] +async fn cors_preflight_wildcard(agent: &TestContext) -> anyhow::Result<()> { + let response = agent + .client + .request( + reqwest::Method::OPTIONS, + agent.base_url.join("/cors-agents/test-agent/wildcard")?, + ) + .header("Origin", "https://any-origin.com") + .send() + .await?; + + assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); + + let allow_origin = response + .headers() + .get("access-control-allow-origin") + .unwrap() + .to_str()?; + assert_eq!(allow_origin, "https://any-origin.com"); + + let vary = response.headers().get("vary").unwrap().to_str()?; + assert_eq!(vary, "Origin"); + + Ok(()) +} + +#[test] +#[tracing::instrument] +async fn cors_preflight_specific_origin(agent: &TestContext) -> anyhow::Result<()> { + let response = agent + .client + .request( + reqwest::Method::OPTIONS, + agent + .base_url + .join("/cors-agents/test-agent/preflight-required")?, + ) + .header("Origin", "https://app.example.com") + .send() + .await?; + + assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); + + let allow_origin = response + .headers() + .get("access-control-allow-origin") + .unwrap() + .to_str()?; + assert_eq!(allow_origin, "https://app.example.com"); + + let allow_methods = response + .headers() + .get("access-control-allow-methods") + .unwrap() + .to_str()?; + assert!(allow_methods.contains("POST")); + + let vary = response.headers().get("vary").unwrap().to_str()?; + assert_eq!(vary, "Origin"); + + Ok(()) +} + +#[test] +#[tracing::instrument] +async fn cors_get_with_origin_header(agent: &TestContext) -> anyhow::Result<()> { + let response = agent + .client + .get(agent.base_url.join("/cors-agents/test-agent/inherited")?) + .header("Origin", "https://mount.example.com") + .send() + .await?; + + assert_eq!(response.status(), reqwest::StatusCode::OK); + + let allow_origin = response + .headers() + .get("access-control-allow-origin") + .unwrap() + .to_str()?; + assert_eq!(allow_origin, "https://mount.example.com"); + + let vary = response.headers().get("vary").unwrap().to_str()?; + assert_eq!(vary, "Origin"); + + Ok(()) +} + +#[test] +#[tracing::instrument] +async fn cors_get_with_origin_header_invalid(agent: &TestContext) -> anyhow::Result<()> { + let response = agent + .client + .get(agent.base_url.join("/cors-agents/test-agent/inherited")?) + .header("Origin", "https://not-allowed.com") + .send() + .await?; + + assert_eq!(response.status(), reqwest::StatusCode::OK); + + assert!(response + .headers() + .get("access-control-allow-origin") + .is_none()); + + Ok(()) +} + +#[test] +#[tracing::instrument] +async fn cors_get_wildcard_origin(agent: &TestContext) -> anyhow::Result<()> { + let response = agent + .client + .get(agent.base_url.join("/cors-agents/test-agent/wildcard")?) + .header("Origin", "https://random-origin.com") + .send() + .await?; + + assert_eq!(response.status(), reqwest::StatusCode::OK); + + let allow_origin = response + .headers() + .get("access-control-allow-origin") + .unwrap() + .to_str()?; + assert_eq!(allow_origin, "https://random-origin.com"); + + let vary = response.headers().get("vary").unwrap().to_str()?; + assert_eq!(vary, "Origin"); + + Ok(()) +} diff --git a/test-components/agent-http-routes-ts/components-ts/golem-it-agent-http-routes-ts/src/main.ts b/test-components/agent-http-routes-ts/components-ts/golem-it-agent-http-routes-ts/src/main.ts index 155672d109..6f23b4833a 100644 --- a/test-components/agent-http-routes-ts/components-ts/golem-it-agent-http-routes-ts/src/main.ts +++ b/test-components/agent-http-routes-ts/components-ts/golem-it-agent-http-routes-ts/src/main.ts @@ -6,7 +6,7 @@ import { UnstructuredBinary, } from '@golemcloud/golem-ts-sdk'; -@agent({ mount: '/agents/{agentName}' }) +@agent({ mount: '/http-agents/{agentName}' }) class HttpAgent extends BaseAgent { constructor(readonly agentName: string) { @@ -112,3 +112,40 @@ class HttpAgent extends BaseAgent { return UnstructuredBinary.fromInline(new Uint8Array([1, 2, 3, 4]), 'application/octet-stream') } } + +@agent({ + mount: '/cors-agents/{agentName}', + cors: ["https://mount.example.com"] +}) +class CorsAgent extends BaseAgent { + + constructor(readonly agentName: string) { + super(); + } + + // GET endpoint adds additional CORS on top of mount + @endpoint({ + get: "/wildcard", + cors: ["*"] // union with mount CORS + }) + wildcard(): { ok: boolean } { + return { ok: true }; + } + + // GET endpoint inherits mount CORS if empty + @endpoint({ + get: "/inherited" + }) + inherited(): { ok: boolean } { + return { ok: true }; + } + + // POST endpoint requiring preflight + @endpoint({ + post: "/preflight-required", + cors: ["https://app.example.com"] + }) + preflight(body: { name: string }): { received: string } { + return { received: body.name }; + } +} diff --git a/test-components/golem_it_agent_http_routes_ts.wasm b/test-components/golem_it_agent_http_routes_ts.wasm index 0ed0b1b3eed402b541a4f0ebf456f224b481402e..39e8c6998241787ffae64c7718ca1cb0b91f8203 100644 GIT binary patch delta 137015 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 sjSjEF_t&o7|WaTCMFTFcd>D;h!wk7 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 Date: Mon, 26 Jan 2026 17:28:58 +0100 Subject: [PATCH 2/5] code-first-routes: delete old request execution code --- golem-worker-service/src/api/common.rs | 4 +- .../{custom_http_request.rs => custom_api.rs} | 8 +- golem-worker-service/src/api/mod.rs | 8 +- .../agent_response_mapping.rs | 0 .../api_definition_lookup.rs | 0 .../{gateway_execution => custom_api}/mod.rs | 5 +- .../model.rs | 19 + .../parameter_parsing.rs | 0 .../src/custom_api/request.rs | 83 ++ .../request_handler.rs | 0 .../route_resolver.rs | 4 +- .../router}/mod.rs | 4 +- .../router}/tree.rs | 0 .../security}/identity_provider.rs | 2 +- .../security}/identity_provider_metadata.rs | 0 .../security}/mod.rs | 0 .../security}/open_id_client.rs | 0 .../auth_call_back_binding_handler.rs | 208 ---- .../file_server_binding_handler.rs | 301 ------ .../gateway_http_input_executor.rs | 927 ----------------- .../gateway_session_store.rs | 382 ------- .../gateway_worker_request_executor.rs | 222 ---- .../http_content_type_mapper.rs | 956 ------------------ .../http_handler_binding_handler.rs | 160 --- .../src/gateway_execution/request.rs | 274 ----- .../src/gateway_execution/to_response.rs | 534 ---------- .../gateway_execution/to_response_failure.rs | 38 - .../src/gateway_middleware/auth.rs | 246 ----- .../src/gateway_middleware/cors.rs | 156 --- .../src/gateway_middleware/mod.rs | 104 -- golem-worker-service/src/lib.rs | 6 +- golem-worker-service/src/model.rs | 93 -- golem-worker-service/src/service/mod.rs | 6 +- 33 files changed, 124 insertions(+), 4626 deletions(-) rename golem-worker-service/src/api/{custom_http_request.rs => custom_api.rs} (91%) rename golem-worker-service/src/{gateway_execution => custom_api}/agent_response_mapping.rs (100%) rename golem-worker-service/src/{gateway_execution => custom_api}/api_definition_lookup.rs (100%) rename golem-worker-service/src/{gateway_execution => custom_api}/mod.rs (97%) rename golem-worker-service/src/{gateway_execution => custom_api}/model.rs (76%) rename golem-worker-service/src/{gateway_execution => custom_api}/parameter_parsing.rs (100%) create mode 100644 golem-worker-service/src/custom_api/request.rs rename golem-worker-service/src/{gateway_execution => custom_api}/request_handler.rs (100%) rename golem-worker-service/src/{gateway_execution => custom_api}/route_resolver.rs (99%) rename golem-worker-service/src/{gateway_router => custom_api/router}/mod.rs (97%) rename golem-worker-service/src/{gateway_router => custom_api/router}/tree.rs (100%) rename golem-worker-service/src/{gateway_security => custom_api/security}/identity_provider.rs (99%) rename golem-worker-service/src/{gateway_security => custom_api/security}/identity_provider_metadata.rs (100%) rename golem-worker-service/src/{gateway_security => custom_api/security}/mod.rs (100%) rename golem-worker-service/src/{gateway_security => custom_api/security}/open_id_client.rs (100%) delete mode 100644 golem-worker-service/src/gateway_execution/auth_call_back_binding_handler.rs delete mode 100644 golem-worker-service/src/gateway_execution/file_server_binding_handler.rs delete mode 100644 golem-worker-service/src/gateway_execution/gateway_http_input_executor.rs delete mode 100644 golem-worker-service/src/gateway_execution/gateway_session_store.rs delete mode 100644 golem-worker-service/src/gateway_execution/gateway_worker_request_executor.rs delete mode 100644 golem-worker-service/src/gateway_execution/http_content_type_mapper.rs delete mode 100644 golem-worker-service/src/gateway_execution/http_handler_binding_handler.rs delete mode 100644 golem-worker-service/src/gateway_execution/request.rs delete mode 100644 golem-worker-service/src/gateway_execution/to_response.rs delete mode 100644 golem-worker-service/src/gateway_execution/to_response_failure.rs delete mode 100644 golem-worker-service/src/gateway_middleware/auth.rs delete mode 100644 golem-worker-service/src/gateway_middleware/cors.rs delete mode 100644 golem-worker-service/src/gateway_middleware/mod.rs diff --git a/golem-worker-service/src/api/common.rs b/golem-worker-service/src/api/common.rs index 56ed5dc873..6c6f2a87b8 100644 --- a/golem-worker-service/src/api/common.rs +++ b/golem-worker-service/src/api/common.rs @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::gateway_execution::request_handler::RequestHandlerError; -use crate::gateway_execution::route_resolver::RouteResolverError; +use crate::custom_api::request_handler::RequestHandlerError; +use crate::custom_api::route_resolver::RouteResolverError; use crate::service::auth::AuthServiceError; use crate::service::component::ComponentServiceError; use crate::service::limit::LimitServiceError; diff --git a/golem-worker-service/src/api/custom_http_request.rs b/golem-worker-service/src/api/custom_api.rs similarity index 91% rename from golem-worker-service/src/api/custom_http_request.rs rename to golem-worker-service/src/api/custom_api.rs index 3e975424b3..eb108d85c0 100644 --- a/golem-worker-service/src/api/custom_http_request.rs +++ b/golem-worker-service/src/api/custom_api.rs @@ -16,17 +16,17 @@ use std::future::Future; use std::sync::Arc; use crate::api::common::ApiEndpointError; -use crate::gateway_execution::request_handler::RequestHandler; +use crate::custom_api::request_handler::RequestHandler; use futures::{FutureExt, TryFutureExt}; use golem_common::recorded_http_api_request; use poem::{Endpoint, IntoResponse, Request, Response}; use tracing::Instrument; -pub struct CustomHttpRequestApi { +pub struct CustomApiApi { pub request_handler: Arc, } -impl CustomHttpRequestApi { +impl CustomApiApi { pub fn new(request_handler: Arc) -> Self { Self { request_handler } } @@ -50,7 +50,7 @@ impl CustomHttpRequestApi { } } -impl Endpoint for CustomHttpRequestApi { +impl Endpoint for CustomApiApi { type Output = Response; fn call(&self, req: Request) -> impl Future> + Send { diff --git a/golem-worker-service/src/api/mod.rs b/golem-worker-service/src/api/mod.rs index 470cb467e0..2d6e01c61f 100644 --- a/golem-worker-service/src/api/mod.rs +++ b/golem-worker-service/src/api/mod.rs @@ -14,10 +14,10 @@ pub mod agents; pub mod common; -mod custom_http_request; +mod custom_api; mod worker; -use self::custom_http_request::CustomHttpRequestApi; +use self::custom_api::CustomApiApi; use crate::api::agents::AgentsApi; use crate::api::worker::WorkerApi; use crate::service::Services; @@ -45,6 +45,6 @@ pub fn make_open_api_service(services: &Services) -> OpenApiService { ) } -pub fn custom_http_request_api(services: &Services) -> CustomHttpRequestApi { - CustomHttpRequestApi::new(services.request_handler.clone()) +pub fn custom_api_api(services: &Services) -> CustomApiApi { + CustomApiApi::new(services.request_handler.clone()) } diff --git a/golem-worker-service/src/gateway_execution/agent_response_mapping.rs b/golem-worker-service/src/custom_api/agent_response_mapping.rs similarity index 100% rename from golem-worker-service/src/gateway_execution/agent_response_mapping.rs rename to golem-worker-service/src/custom_api/agent_response_mapping.rs diff --git a/golem-worker-service/src/gateway_execution/api_definition_lookup.rs b/golem-worker-service/src/custom_api/api_definition_lookup.rs similarity index 100% rename from golem-worker-service/src/gateway_execution/api_definition_lookup.rs rename to golem-worker-service/src/custom_api/api_definition_lookup.rs diff --git a/golem-worker-service/src/gateway_execution/mod.rs b/golem-worker-service/src/custom_api/mod.rs similarity index 97% rename from golem-worker-service/src/gateway_execution/mod.rs rename to golem-worker-service/src/custom_api/mod.rs index bc9f799fb3..564ea072f8 100644 --- a/golem-worker-service/src/gateway_execution/mod.rs +++ b/golem-worker-service/src/custom_api/mod.rs @@ -26,9 +26,8 @@ mod parameter_parsing; pub mod request; pub mod request_handler; pub mod route_resolver; -// pub mod to_response; -// pub mod to_response_failure; -// pub use gateway_worker_request_executor::*; +pub mod router; +pub mod security; pub use model::*; diff --git a/golem-worker-service/src/gateway_execution/model.rs b/golem-worker-service/src/custom_api/model.rs similarity index 76% rename from golem-worker-service/src/gateway_execution/model.rs rename to golem-worker-service/src/custom_api/model.rs index 1e94d1d8c1..7e79c64ce7 100644 --- a/golem-worker-service/src/gateway_execution/model.rs +++ b/golem-worker-service/src/custom_api/model.rs @@ -12,10 +12,29 @@ // See the License for the specific language governing permissions and // limitations under the License. +use golem_common::model::account::AccountId; use golem_common::model::agent::BinarySource; +use golem_common::model::environment::EnvironmentId; +use golem_service_base::custom_api::{CorsOptions, SecuritySchemeDetails}; +use golem_service_base::custom_api::{PathSegment, RequestBodySchema, RouteBehaviour, RouteId}; +use http::Method; use http::{HeaderName, StatusCode}; use std::collections::HashMap; use std::fmt; +use std::sync::Arc; + +#[derive(Debug)] +pub struct RichCompiledRoute { + pub account_id: AccountId, + pub environment_id: EnvironmentId, + pub route_id: RouteId, + pub method: Method, + pub path: Vec, + pub body: RequestBodySchema, + pub behavior: RouteBehaviour, + pub security_scheme: Option>, + pub cors: CorsOptions, +} #[derive(Debug)] pub struct RouteExecutionResult { diff --git a/golem-worker-service/src/gateway_execution/parameter_parsing.rs b/golem-worker-service/src/custom_api/parameter_parsing.rs similarity index 100% rename from golem-worker-service/src/gateway_execution/parameter_parsing.rs rename to golem-worker-service/src/custom_api/parameter_parsing.rs diff --git a/golem-worker-service/src/custom_api/request.rs b/golem-worker-service/src/custom_api/request.rs new file mode 100644 index 0000000000..71a6659ed9 --- /dev/null +++ b/golem-worker-service/src/custom_api/request.rs @@ -0,0 +1,83 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::request_handler::RequestHandlerError; +use http::HeaderMap; +use std::collections::HashMap; +use uuid::Uuid; + +const COOKIE_HEADER_NAMES: [&str; 2] = ["cookie", "Cookie"]; + +pub struct RichRequest { + pub underlying: poem::Request, + pub request_id: Uuid, +} + +impl RichRequest { + pub fn new(underlying: poem::Request) -> RichRequest { + RichRequest { + underlying, + request_id: Uuid::new_v4(), + } + } + + pub fn origin(&self) -> Result, RequestHandlerError> { + match self.underlying.headers().get("Origin") { + Some(header) => { + let result = + header + .to_str() + .map_err(|_| RequestHandlerError::HeaderIsNotAscii { + header_name: "Origin".to_string(), + })?; + Ok(Some(result)) + } + None => Ok(None), + } + } + + pub fn headers(&self) -> &HeaderMap { + self.underlying.headers() + } + + pub fn query_params(&self) -> HashMap> { + let mut params: HashMap> = HashMap::new(); + + if let Some(q) = self.underlying.uri().query() { + for (key, value) in url::form_urlencoded::parse(q.as_bytes()).into_owned() { + params.entry(key).or_default().push(value); + } + } + + params + } + + pub fn cookies(&self) -> HashMap<&str, &str> { + let mut result = HashMap::new(); + + for header_name in COOKIE_HEADER_NAMES.iter() { + if let Some(value) = self.underlying.header(header_name) { + let parts: Vec<&str> = value.split(';').collect(); + for part in parts { + let key_value: Vec<&str> = part.split('=').collect(); + if let (Some(key), Some(value)) = (key_value.first(), key_value.get(1)) { + result.insert(key.trim(), value.trim()); + } + } + } + } + + result + } +} diff --git a/golem-worker-service/src/gateway_execution/request_handler.rs b/golem-worker-service/src/custom_api/request_handler.rs similarity index 100% rename from golem-worker-service/src/gateway_execution/request_handler.rs rename to golem-worker-service/src/custom_api/request_handler.rs diff --git a/golem-worker-service/src/gateway_execution/route_resolver.rs b/golem-worker-service/src/custom_api/route_resolver.rs similarity index 99% rename from golem-worker-service/src/gateway_execution/route_resolver.rs rename to golem-worker-service/src/custom_api/route_resolver.rs index 65baeab361..fce623872c 100644 --- a/golem-worker-service/src/gateway_execution/route_resolver.rs +++ b/golem-worker-service/src/custom_api/route_resolver.rs @@ -13,8 +13,8 @@ // limitations under the License. use super::api_definition_lookup::{ApiDefinitionLookupError, HttpApiDefinitionsLookup}; +use super::router::Router; use crate::config::RouteResolverConfig; -use crate::gateway_router::Router; // use crate::model::{HttpMiddleware, RichCompiledRoute, RichGatewayBindingCompiled, SwaggerHtml}; // use crate::swagger_ui::generate_swagger_html; use golem_common::cache::SimpleCache; @@ -24,7 +24,7 @@ use golem_common::model::domain_registration::Domain; // use golem_service_base::custom_api::HttpCors; use golem_service_base::custom_api::CompiledRoutes; // use golem_service_base::custom_api::{RouteBehaviour, SwaggerUiBindingCompiled}; -use crate::model::RichCompiledRoute; +use super::model::RichCompiledRoute; use golem_common::SafeDisplay; use std::collections::HashMap; use std::sync::Arc; diff --git a/golem-worker-service/src/gateway_router/mod.rs b/golem-worker-service/src/custom_api/router/mod.rs similarity index 97% rename from golem-worker-service/src/gateway_router/mod.rs rename to golem-worker-service/src/custom_api/router/mod.rs index 010c81df56..6975c4e355 100644 --- a/golem-worker-service/src/gateway_router/mod.rs +++ b/golem-worker-service/src/custom_api/router/mod.rs @@ -15,7 +15,7 @@ pub mod tree; use self::tree::RadixNode; -use crate::model::RichCompiledRoute; +use super::RichCompiledRoute; use golem_service_base::custom_api::PathSegment; use http::Method; use std::sync::Arc; @@ -69,7 +69,7 @@ impl Router> { #[cfg(test)] mod tests { - use crate::gateway_router::Router; + use super::Router; use golem_service_base::custom_api::PathSegment; use http::Method; use test_r::test; diff --git a/golem-worker-service/src/gateway_router/tree.rs b/golem-worker-service/src/custom_api/router/tree.rs similarity index 100% rename from golem-worker-service/src/gateway_router/tree.rs rename to golem-worker-service/src/custom_api/router/tree.rs diff --git a/golem-worker-service/src/gateway_security/identity_provider.rs b/golem-worker-service/src/custom_api/security/identity_provider.rs similarity index 99% rename from golem-worker-service/src/gateway_security/identity_provider.rs rename to golem-worker-service/src/custom_api/security/identity_provider.rs index 10a4ecf4ab..350b0b52f4 100644 --- a/golem-worker-service/src/gateway_security/identity_provider.rs +++ b/golem-worker-service/src/custom_api/security/identity_provider.rs @@ -13,7 +13,7 @@ // limitations under the License. use super::identity_provider_metadata::GolemIdentityProviderMetadata; -use crate::gateway_security::open_id_client::OpenIdClient; +use super::open_id_client::OpenIdClient; use async_trait::async_trait; use golem_common::model::security_scheme::Provider; use golem_common::SafeDisplay; diff --git a/golem-worker-service/src/gateway_security/identity_provider_metadata.rs b/golem-worker-service/src/custom_api/security/identity_provider_metadata.rs similarity index 100% rename from golem-worker-service/src/gateway_security/identity_provider_metadata.rs rename to golem-worker-service/src/custom_api/security/identity_provider_metadata.rs diff --git a/golem-worker-service/src/gateway_security/mod.rs b/golem-worker-service/src/custom_api/security/mod.rs similarity index 100% rename from golem-worker-service/src/gateway_security/mod.rs rename to golem-worker-service/src/custom_api/security/mod.rs diff --git a/golem-worker-service/src/gateway_security/open_id_client.rs b/golem-worker-service/src/custom_api/security/open_id_client.rs similarity index 100% rename from golem-worker-service/src/gateway_security/open_id_client.rs rename to golem-worker-service/src/custom_api/security/open_id_client.rs diff --git a/golem-worker-service/src/gateway_execution/auth_call_back_binding_handler.rs b/golem-worker-service/src/gateway_execution/auth_call_back_binding_handler.rs deleted file mode 100644 index e97038aa24..0000000000 --- a/golem-worker-service/src/gateway_execution/auth_call_back_binding_handler.rs +++ /dev/null @@ -1,208 +0,0 @@ -// Copyright 2024-2025 Golem Cloud -// -// Licensed under the Golem Source License v1.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://license.golem.cloud/LICENSE -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use crate::gateway_execution::gateway_session_store::{ - DataKey, DataValue, GatewaySessionError, GatewaySessionStore, SessionId, -}; -use crate::gateway_security::{IdentityProvider, IdentityProviderError}; -use async_trait::async_trait; -use golem_common::SafeDisplay; -use golem_service_base::custom_api::SecuritySchemeDetails; -use openidconnect::core::CoreTokenResponse; -use openidconnect::{AuthorizationCode, OAuth2TokenResponse}; -use std::collections::HashMap; -use std::sync::Arc; - -#[async_trait] -pub trait AuthCallBackBindingHandler: Send + Sync { - async fn handle_auth_call_back( - &self, - query_params: &HashMap, - security_scheme: &SecuritySchemeDetails, - ) -> Result; -} - -pub struct AuthenticationSuccess { - pub token_response: CoreTokenResponse, - pub target_path: String, - pub id_token: Option, - pub access_token: String, - pub session: String, -} - -#[derive(Debug)] -pub enum AuthorisationError { - Internal(String), - CodeNotFound, - InvalidCode, - StateNotFound, - InvalidState, - InvalidSession, - InvalidNonce, - MissingParametersInSession, - AccessTokenNotFound, - InvalidToken, - IdTokenNotFound, - ConflictingState, // Possible CSRF attack - NonceNotFound, - FailedCodeExchange(IdentityProviderError), - ClaimFetchError(IdentityProviderError), - IdentityProviderError(IdentityProviderError), - SessionError(GatewaySessionError), -} - -// Only SafeDisplay is allowed for AuthorisationError -impl SafeDisplay for AuthorisationError { - fn to_safe_string(&self) -> String { - match self { - AuthorisationError::Internal(_) => "Failed authentication".to_string(), - AuthorisationError::InvalidNonce => "Failed authentication".to_string(), - AuthorisationError::CodeNotFound => "The authorisation code is missing.".to_string(), - AuthorisationError::InvalidCode => "The authorisation code is invalid.".to_string(), - AuthorisationError::StateNotFound => { - "Missing parameters from identity provider".to_string() - } - AuthorisationError::InvalidState => { - "Invalid parameters from identity provider.".to_string() - } - AuthorisationError::InvalidSession => "The session is no longer valid.".to_string(), - AuthorisationError::MissingParametersInSession => "Session failures".to_string(), - AuthorisationError::ClaimFetchError(err) => { - format!( - "Failed to fetch claims. Error details: {}", - err.to_safe_string() - ) - } - AuthorisationError::InvalidToken => "Invalid token".to_string(), - AuthorisationError::IdentityProviderError(err) => { - format!("Identity provider error: {}", err.to_safe_string()) - } - AuthorisationError::AccessTokenNotFound => { - "Unable to continue with authorisation".to_string() - } - AuthorisationError::IdTokenNotFound => { - "Unable to continue with authentication.".to_string() - } - AuthorisationError::ConflictingState => "Suspicious login attempt".to_string(), - AuthorisationError::FailedCodeExchange(err) => { - format!( - "Failed to exchange code for tokens. Error details: {}", - err.to_safe_string() - ) - } - AuthorisationError::NonceNotFound => { - "Suspicious authorisation attempt. Failed checks.".to_string() - } - AuthorisationError::SessionError(err) => format!( - "An error occurred while updating the session. Error details: {}", - err.to_safe_string() - ), - } - } -} - -pub struct DefaultAuthCallBackBindingHandler { - gateway_session_store: Arc, - identity_provider: Arc, -} - -impl DefaultAuthCallBackBindingHandler { - pub fn new( - gateway_session_store: Arc, - identity_provider: Arc, - ) -> Self { - Self { - gateway_session_store, - identity_provider, - } - } -} - -#[async_trait] -impl AuthCallBackBindingHandler for DefaultAuthCallBackBindingHandler { - async fn handle_auth_call_back( - &self, - query_params: &HashMap, - security_scheme: &SecuritySchemeDetails, - ) -> Result { - let code = query_params - .get("code") - .map(|c| AuthorizationCode::new(c.to_string())); - let state = query_params.get("state").cloned(); - - let authorisation_code = code.ok_or(AuthorisationError::CodeNotFound)?; - let state = state.ok_or(AuthorisationError::StateNotFound)?; - - let target_path = self - .gateway_session_store - .get( - &SessionId(state.clone()), - &DataKey("redirect_url".to_string()), - ) - .await - .map_err(AuthorisationError::SessionError)? - .as_string() - .ok_or(AuthorisationError::Internal( - "Invalid redirect url (target url of the protected resource)".to_string(), - ))?; - - let open_id_client = self - .identity_provider - .get_client(security_scheme) - .await - .map_err(AuthorisationError::IdentityProviderError)?; - - let token_response = self - .identity_provider - .exchange_code_for_tokens(&open_id_client, &authorisation_code) - .await - .map_err(AuthorisationError::FailedCodeExchange)?; - - let access_token = token_response.access_token().secret().clone(); - let id_token = token_response - .extra_fields() - .id_token() - .map(|x| x.to_string()); - - // access token in session store - self.gateway_session_store - .insert( - SessionId(state.clone()), - DataKey::access_token(), - DataValue(serde_json::Value::String(access_token.clone())), - ) - .await - .map_err(AuthorisationError::SessionError)?; - - if let Some(id_token) = &id_token { - // id token in session store - self.gateway_session_store - .insert( - SessionId(state.clone()), - DataKey::id_token(), - DataValue(serde_json::Value::String(id_token.to_string())), - ) - .await - .map_err(AuthorisationError::SessionError)?; - } - - Ok(AuthenticationSuccess { - token_response, - target_path, - id_token, - access_token, - session: state, - }) - } -} diff --git a/golem-worker-service/src/gateway_execution/file_server_binding_handler.rs b/golem-worker-service/src/gateway_execution/file_server_binding_handler.rs deleted file mode 100644 index e8b64aa96b..0000000000 --- a/golem-worker-service/src/gateway_execution/file_server_binding_handler.rs +++ /dev/null @@ -1,301 +0,0 @@ -// Copyright 2024-2025 Golem Cloud -// -// Licensed under the Golem Source License v1.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://license.golem.cloud/LICENSE -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use crate::getter::{get_response_headers_or_default, get_status_code}; -use crate::service::component::{ComponentService, ComponentServiceError}; -use crate::service::worker::{WorkerService, WorkerServiceError}; -use bytes::Bytes; -use futures::Stream; -use futures::TryStreamExt; -use golem_common::model::account::AccountId; -use golem_common::model::component::{ComponentDto, ComponentFilePath, ComponentId}; -use golem_common::model::environment::EnvironmentId; -use golem_common::model::WorkerId; -use golem_common::SafeDisplay; -use golem_service_base::model::auth::AuthCtx; -use golem_service_base::service::initial_component_files::InitialComponentFilesService; -use golem_wasm::analysis::AnalysedType; -use golem_wasm::{Value, ValueAndType}; -use http::StatusCode; -use poem::web::headers::ContentType; -use rib::RibResult; -use std::pin::Pin; -use std::str::FromStr; -use std::sync::Arc; - -pub struct FileServerBindingHandler { - component_service: Arc, - initial_component_files_service: Arc, - worker_service: Arc, -} - -impl FileServerBindingHandler { - pub fn new( - component_service: Arc, - initial_component_files_service: Arc, - worker_service: Arc, - ) -> Self { - Self { - component_service, - initial_component_files_service, - worker_service, - } - } - - pub async fn handle_file_server_binding_result( - &self, - worker_name: &str, - component_id: ComponentId, - environment_id: EnvironmentId, - account_id: AccountId, - original_result: RibResult, - ) -> Result { - let binding_details = FileServerBindingDetails::from_rib_result(original_result) - .map_err(FileServerBindingError::InvalidRibResult)?; - - let component_metadata = self - .get_component_metadata(worker_name, component_id, account_id) - .await?; - - // if we are serving a read_only file, we can just go straight to the blob storage. - let matching_ro_file = component_metadata - .files - .iter() - .find(|file| file.path == binding_details.file_path && file.is_read_only()); - - if let Some(file) = matching_ro_file { - let data = self - .initial_component_files_service - .get(environment_id, file.content_hash) - .await - .map_err(|e| { - FileServerBindingError::InternalError(format!( - "Failed looking up file in storage: {e}" - )) - })? - .ok_or(FileServerBindingError::InternalError(format!( - "File not found in file storage: {}", - file.content_hash - ))) - .map(|stream| { - let mapped = stream.map_err(std::io::Error::other); - Box::pin(mapped) - })?; - - Ok(FileServerBindingSuccess { - binding_details, - data, - }) - } else { - // Read write files need to be fetched from a running worker. - // Ask the worker service to get the file contents. If no worker is running, one will be started. - - let worker_id = WorkerId::from_component_metadata_and_worker_id( - component_id, - &component_metadata.metadata, - worker_name, - ) - .map_err(|e| { - FileServerBindingError::InternalError(format!("Invalid worker name: {e}")) - })?; - - let stream = self - .worker_service - .get_file_contents( - &worker_id, - binding_details.file_path.clone(), - AuthCtx::impersonated_user(account_id), - ) - .await?; - - let stream = stream.map_err(|e| std::io::Error::other(e.to_string())); - - Ok(FileServerBindingSuccess { - binding_details, - data: Box::pin(stream), - }) - } - } - - async fn get_component_metadata( - &self, - worker_name: &str, - component_id: ComponentId, - account_id: AccountId, - ) -> Result { - // Two cases, we either have an existing worker or not (either not configured or not existing). - // If there is no worker we need use the lastest component version, if there is none we need to use the exact component version - // the worker is using. Not doing that would make the blob_storage optimization for read-only files visible to users. - - let component_revision = { - let worker_metadata = self - .worker_service - .get_metadata( - &WorkerId { - component_id, - worker_name: worker_name.to_string(), - }, - AuthCtx::impersonated_user(account_id), - ) - .await; - - match worker_metadata { - Ok(metadata) => Some(metadata.component_revision), - Err(WorkerServiceError::WorkerNotFound(_)) => None, - Err(other) => Err(other)?, - } - }; - - let component_metadata = if let Some(component_revision) = component_revision { - self.component_service - .get_revision(component_id, component_revision) - .await - .map_err(FileServerBindingError::ComponentServiceError)? - } else { - self.component_service - .get_latest_by_id(component_id) - .await - .map_err(FileServerBindingError::ComponentServiceError)? - }; - - Ok(component_metadata) - } -} - -pub struct FileServerBindingSuccess { - pub binding_details: FileServerBindingDetails, - pub data: Pin> + Send + 'static>>, -} - -#[derive(Debug, thiserror::Error)] -pub enum FileServerBindingError { - #[error(transparent)] - WorkerServiceError(#[from] WorkerServiceError), - #[error(transparent)] - ComponentServiceError(#[from] ComponentServiceError), - #[error("Internal error: {0}")] - InternalError(String), - #[error("Invalid rib result: {0}")] - InvalidRibResult(String), -} - -impl SafeDisplay for FileServerBindingError { - fn to_safe_string(&self) -> String { - match self { - Self::WorkerServiceError(inner) => inner.to_safe_string(), - Self::ComponentServiceError(inner) => inner.to_safe_string(), - - Self::InternalError(_) => self.to_string(), - Self::InvalidRibResult(_) => self.to_string(), - } - } -} - -#[derive(Debug, Clone)] -pub struct FileServerBindingDetails { - pub content_type: ContentType, - pub status_code: StatusCode, - pub file_path: ComponentFilePath, -} - -impl FileServerBindingDetails { - pub fn from_rib_result(result: RibResult) -> Result { - // Three supported formats: - // 1. A string path. Mime type is guessed from the path. Status code is 200. - // 2. A record with a 'file-path' field. Mime type and status are optionally taken from the record, otherwise guessed. - // 3. A result of either of the above, with the same rules applied. - match result { - RibResult::Val(value) => match value { - ValueAndType { - value: Value::Result(value), - typ: AnalysedType::Result(typ), - } => match value { - Ok(ok) => { - let ok = ValueAndType::new( - *ok.ok_or("ok unset".to_string())?, - (*typ.ok.ok_or("Missing 'ok' type")?).clone(), - ); - Self::from_rib_happy(ok) - } - Err(err) => { - let value = err.ok_or("err unset".to_string())?; - Err(format!("Error result: {value:?}")) - } - }, - other => Self::from_rib_happy(other), - }, - RibResult::Unit => Err("Expected a value".to_string()), - } - } - - /// Like the above, just without the result case. - fn from_rib_happy(value: ValueAndType) -> Result { - match &value { - ValueAndType { - value: Value::String(raw_path), - .. - } => Self::make_from(raw_path.clone(), None, None), - ValueAndType { - value: Value::Record(field_values), - typ: AnalysedType::Record(record), - } => { - let path_position = record - .fields - .iter() - .position(|pair| &pair.name == "file-path") - .ok_or("Record must contain 'file-path' field")?; - - let path = if let Value::String(path) = &field_values[path_position] { - path - } else { - return Err("file-path must be a string".to_string()); - }; - - let status = get_status_code(field_values, record)?; - let headers = get_response_headers_or_default(&value)?; - let content_type = headers.get_content_type(); - - Self::make_from(path.to_string(), content_type, status) - } - _ => Err("Response value expected".to_string()), - } - } - - fn make_from( - path: String, - content_type: Option, - status_code: Option, - ) -> Result { - let file_path = ComponentFilePath::from_either_str(&path)?; - - let content_type = match content_type { - Some(content_type) => content_type, - None => { - let mime_type = mime_guess::from_path(&path) - .first() - .ok_or("Could not determine mime type")?; - ContentType::from_str(mime_type.as_ref()) - .map_err(|e| format!("Invalid mime type: {e}"))? - } - }; - - let status_code = status_code.unwrap_or(StatusCode::OK); - - Ok(FileServerBindingDetails { - status_code, - content_type, - file_path, - }) - } -} diff --git a/golem-worker-service/src/gateway_execution/gateway_http_input_executor.rs b/golem-worker-service/src/gateway_execution/gateway_http_input_executor.rs deleted file mode 100644 index a04b5888b4..0000000000 --- a/golem-worker-service/src/gateway_execution/gateway_http_input_executor.rs +++ /dev/null @@ -1,927 +0,0 @@ -// Copyright 2024-2025 Golem Cloud -// -// Licensed under the Golem Source License v1.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://license.golem.cloud/LICENSE -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use super::auth_call_back_binding_handler::AuthenticationSuccess; -use super::file_server_binding_handler::{FileServerBindingError, FileServerBindingSuccess}; -use super::http_handler_binding_handler::{HttpHandlerBindingHandler, HttpHandlerBindingResult}; -use super::request::{split_resolved_route_entry, RichRequest, SplitResolvedRouteEntryResult}; -use super::route_resolver::{GatewayBindingResolverError, RouteResolver}; -use super::to_response::GatewayHttpResult; -use super::{GatewayWorkerRequestExecutor, WorkerDetails}; -use crate::gateway_execution::auth_call_back_binding_handler::AuthCallBackBindingHandler; -use crate::gateway_execution::file_server_binding_handler::FileServerBindingHandler; -use crate::gateway_execution::gateway_session_store::GatewaySessionStore; -use crate::gateway_execution::to_response::{GatewayHttpError, ToHttpResponse}; -use crate::gateway_execution::to_response_failure::ToHttpResponseFromSafeDisplay; -use crate::gateway_middleware::{ - process_middleware_in, process_middleware_out, MiddlewareError, MiddlewareSuccess, -}; -use crate::gateway_security::IdentityProvider; -use crate::http_invocation_context::{extract_request_attributes, invocation_context_from_request}; -use crate::model::{HttpMiddleware, RichGatewayBindingCompiled}; -use golem_common::model::account::AccountId; -use golem_common::model::component::{ComponentId, ComponentRevision}; -use golem_common::model::environment::EnvironmentId; -use golem_common::model::invocation_context::{ - AttributeValue, InvocationContextSpan, InvocationContextStack, SpanId, TraceId, -}; -use golem_common::model::IdempotencyKey; -use golem_common::SafeDisplay; -use golem_service_base::custom_api::SecuritySchemeDetails; -use golem_service_base::custom_api::{ - FileServerBindingCompiled, HttpHandlerBindingCompiled, IdempotencyKeyCompiled, - InvocationContextCompiled, ResponseMappingCompiled, WorkerBindingCompiled, WorkerNameCompiled, -}; -use golem_service_base::headers::TraceContextHeaders; -use golem_wasm::analysis::analysed_type::record; -use golem_wasm::analysis::{AnalysedType, NameTypePair}; -use golem_wasm::json::ValueAndTypeJsonExtensions; -use golem_wasm::{IntoValue, IntoValueAndType, ValueAndType}; -use http::StatusCode; -use poem::Body; -use rib::{RibInput, RibInputTypeInfo, RibResult, TypeName}; -use std::collections::HashMap; -use std::ops::Deref; -use std::str::FromStr; -use std::sync::Arc; -use tracing::error; -use uuid::Uuid; - -pub struct GatewayHttpInputExecutor { - route_resolver: Arc, - gateway_worker_request_executor: Arc, - file_server_binding_handler: Arc, - auth_call_back_binding_handler: Arc, - http_handler_binding_handler: Arc, - gateway_session_store: Arc, - identity_provider: Arc, -} - -impl GatewayHttpInputExecutor { - pub fn new( - route_resolver: Arc, - gateway_worker_request_executor: Arc, - file_server_binding_handler: Arc, - auth_call_back_binding_handler: Arc, - http_handler_binding_handler: Arc, - gateway_session_store: Arc, - identity_provider: Arc, - ) -> Self { - Self { - route_resolver, - gateway_worker_request_executor, - file_server_binding_handler, - auth_call_back_binding_handler, - http_handler_binding_handler, - gateway_session_store, - identity_provider, - } - } - - pub async fn execute_http_request(&self, request: poem::Request) -> poem::Response { - let resolved_route_entry = match self.route_resolver.resolve_route(&request).await { - Ok(value) => value, - Err(GatewayBindingResolverError::CouldNotBuildRouter) => { - return poem::Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::from_string( - "Failed to build router for request".to_string(), - )); - } - Err(GatewayBindingResolverError::CouldNotGetDomainFromRequest(err)) => { - return poem::Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(Body::from_string(err)); - } - Err(GatewayBindingResolverError::NoMatchingRoute) => { - return poem::Response::builder() - .status(StatusCode::NOT_FOUND) - .body(Body::from_string("Route not found".to_string())); - } - }; - - let SplitResolvedRouteEntryResult { - binding, - middlewares, - rich_request, - account_id, - environment_id, - } = split_resolved_route_entry(request, resolved_route_entry); - - let mut rich_request = match self.apply_middlewares_in(rich_request, &middlewares).await { - Ok(req) => req, - Err(resp) => { - tracing::debug!("Middleware short-circuited the request handling"); - return resp; - } - }; - - match binding { - RichGatewayBindingCompiled::HttpCorsPreflight(inner) => { - inner - .http_cors - .to_response(&rich_request, &self.gateway_session_store) - .await - } - - RichGatewayBindingCompiled::HttpAuthCallBack(security_scheme) => { - let result = self - .handle_http_auth_callback_binding(&security_scheme, &rich_request) - .await; - - let response = result - .to_response(&rich_request, &self.gateway_session_store) - .await; - - apply_middlewares_out(response, &middlewares).await - } - - RichGatewayBindingCompiled::Worker(resolved_worker_binding) => { - let result = self - .handle_worker_binding(&mut rich_request, *resolved_worker_binding, account_id) - .await; - - let response = result - .to_response(&rich_request, &self.gateway_session_store) - .await; - - apply_middlewares_out(response, &middlewares).await - } - - RichGatewayBindingCompiled::HttpHandler(http_handler_binding) => { - let result = self - .handle_http_handler_binding( - &mut rich_request, - *http_handler_binding, - account_id, - ) - .await; - - let response = result - .to_response(&rich_request, &self.gateway_session_store) - .await; - - apply_middlewares_out(response, &middlewares).await - } - - RichGatewayBindingCompiled::FileServer(resolved_file_server_binding) => { - let result = self - .handle_file_server_binding( - &mut rich_request, - *resolved_file_server_binding, - account_id, - environment_id, - ) - .await; - - let response = result - .to_response(&rich_request, &self.gateway_session_store) - .await; - - apply_middlewares_out(response, &middlewares).await - } - - RichGatewayBindingCompiled::SwaggerUi(swagger_binding) => { - let result = swagger_binding.swagger_html.deref().clone(); - - let response = result - .to_response(&rich_request, &self.gateway_session_store) - .await; - - apply_middlewares_out(response, &middlewares).await - } - } - } - - async fn handle_worker_binding( - &self, - request: &mut RichRequest, - binding: WorkerBindingCompiled, - account_id: AccountId, - ) -> GatewayHttpResult { - let WorkerBindingCompiled { - response_compiled, - component_id, - component_revision, - idempotency_key_compiled, - invocation_context_compiled, - .. - } = binding; - - let worker_detail = self - .get_worker_details( - request, - None, - idempotency_key_compiled, - component_id, - component_revision, - invocation_context_compiled, - ) - .await?; - - self.execute_response_mapping_script(response_compiled, request, worker_detail, account_id) - .await - } - - async fn handle_http_handler_binding( - &self, - request: &mut RichRequest, - binding: HttpHandlerBindingCompiled, - account_id: AccountId, - ) -> GatewayHttpResult { - let HttpHandlerBindingCompiled { - component_id, - component_revision, - worker_name_compiled, - idempotency_key_compiled, - invocation_context_compiled, - .. - } = binding; - - let worker_detail = self - .get_worker_details( - request, - Some(worker_name_compiled), - idempotency_key_compiled, - component_id, - component_revision, - invocation_context_compiled, - ) - .await?; - - let incoming_http_request = request - .as_wasi_http_input() - .await - .map_err(GatewayHttpError::BadRequest)?; - - let result = self - .http_handler_binding_handler - .handle_http_handler_binding(&worker_detail, incoming_http_request, account_id) - .await; - - match result { - Ok(_) => tracing::debug!("http handler binding successful"), - Err(ref e) => tracing::warn!("http handler binding failed: {e:?}"), - } - - Ok(result) - } - - async fn handle_file_server_binding( - &self, - request: &mut RichRequest, - binding: FileServerBindingCompiled, - account_id: AccountId, - environment_id: EnvironmentId, - ) -> GatewayHttpResult { - let FileServerBindingCompiled { - component_id, - component_revision, - worker_name_compiled, - response_compiled, - .. - } = binding; - - let worker_detail = self - .get_worker_details( - request, - Some(worker_name_compiled), - None, - component_id, - component_revision, - None, - ) - .await?; - - let worker_name = worker_detail - .worker_name - .as_ref() - .ok_or_else(|| { - GatewayHttpError::FileServerBindingError(FileServerBindingError::InternalError( - "Missing worker name".to_string(), - )) - })? - .clone(); - - let response_script_result = self - .execute_response_mapping_script(response_compiled, request, worker_detail, account_id) - .await?; - - self.file_server_binding_handler - .handle_file_server_binding_result( - &worker_name, - component_id, - environment_id, - account_id, - response_script_result, - ) - .await - .map_err(GatewayHttpError::FileServerBindingError) - } - - async fn handle_http_auth_callback_binding( - &self, - security_scheme: &SecuritySchemeDetails, - request: &RichRequest, - ) -> GatewayHttpResult { - self.auth_call_back_binding_handler - .handle_auth_call_back(&request.query_params(), security_scheme) - .await - .map_err(GatewayHttpError::AuthorisationError) - } - - async fn evaluate_worker_name_rib_script( - &self, - script: WorkerNameCompiled, - request: &mut RichRequest, - ) -> GatewayHttpResult { - let WorkerNameCompiled { - compiled_worker_name, - rib_input: rib_input_type_info, - .. - } = script; - - let rib_input: RibInput = resolve_rib_input(request, &rib_input_type_info).await?; - - let result = rib::interpret_pure(compiled_worker_name, rib_input, None) - .await - .map_err(|err| GatewayHttpError::RibInterpretPureError(err.to_string()))? - .get_literal() - .ok_or(GatewayHttpError::BadRequest( - "Worker name is not a Rib expression that resolves to String".to_string(), - ))? - .as_string(); - - Ok(result) - } - - async fn evaluate_idempotency_key_rib_script( - &self, - script: IdempotencyKeyCompiled, - request: &mut RichRequest, - ) -> GatewayHttpResult { - let IdempotencyKeyCompiled { - compiled_idempotency_key, - rib_input, - .. - } = script; - - let rib_input: RibInput = resolve_rib_input(request, &rib_input).await?; - - let value = rib::interpret_pure(compiled_idempotency_key, rib_input, None) - .await - .map_err(|err| GatewayHttpError::RibInterpretPureError(err.to_string()))? - .get_literal() - .ok_or(GatewayHttpError::BadRequest( - "Idempotency key is not a Rib expression that resolves to String".to_string(), - ))? - .as_string(); - - Ok(IdempotencyKey::new(value)) - } - - async fn evaluate_invocation_context_rib_script( - &self, - script: InvocationContextCompiled, - request: &mut RichRequest, - ) -> GatewayHttpResult<(Option, HashMap)> { - let InvocationContextCompiled { - compiled_invocation_context, - rib_input, - .. - } = script; - - let rib_input: RibInput = resolve_rib_input(request, &rib_input).await?; - - let value = rib::interpret_pure(compiled_invocation_context, rib_input, None) - .await - .map_err(|err| GatewayHttpError::RibInterpretPureError(err.to_string()))? - .get_record() - .ok_or(GatewayHttpError::BadRequest( - "Invocation context must be a Rib expression that resolves to record".to_string(), - ))?; - let record: HashMap = HashMap::from_iter(value); - - let trace_id = record - .get("trace_id") - .or(record.get("trace-id")) - .map(to_attribute_value) - .transpose()? - .map(TraceId::from_attribute_value) - .transpose() - .map_err(|err| GatewayHttpError::BadRequest(format!("Invalid Trace ID: {err}")))?; - - Ok((trace_id, record)) - } - - fn materialize_user_invocation_context( - record: HashMap, - parent: Option>, - request_attributes: HashMap, - ) -> GatewayHttpResult> { - let span_id = record - .get("span_id") - .or(record.get("span-id")) - .map(to_attribute_value) - .transpose()? - .map(SpanId::from_attribute_value) - .transpose() - .map_err(|err| GatewayHttpError::BadRequest(format!("Invalid Span ID: {err}")))?; - - let span = InvocationContextSpan::local() - .span_id(span_id) - .parent(parent) - .with_attributes(request_attributes) - .build(); - - for (key, value) in record { - if key != "span_id" && key != "span-id" && key != "trace_id" && key != "trace-id" { - span.set_attribute(key, to_attribute_value(&value)?); - } - } - - Ok(span) - } - - async fn get_worker_details( - &self, - request: &mut RichRequest, - worker_name_compiled: Option, - idempotency_key_compiled: Option, - component_id: ComponentId, - component_revision: ComponentRevision, - invocation_context_compiled: Option, - ) -> GatewayHttpResult { - let worker_name = if let Some(worker_name_compiled) = worker_name_compiled { - let result = self - .evaluate_worker_name_rib_script(worker_name_compiled, request) - .await?; - Some(result) - } else { - None - }; - - // We prefer to take the idempotency key from the rib script, - // if that is not available, we fall back to our custom header. - // If neither is available, the worker-executor will later generate an idempotency key. - let idempotency_key = if let Some(idempotency_key_compiled) = idempotency_key_compiled { - let result = self - .evaluate_idempotency_key_rib_script(idempotency_key_compiled, request) - .await?; - Some(result) - } else { - request - .underlying - .headers() - .get("idempotency-key") - .and_then(|h| h.to_str().ok()) - .map(|value| IdempotencyKey::new(value.to_string())) - }; - - let invocation_context = if let Some(invocation_context_compiled) = - invocation_context_compiled - { - let request_attributes = extract_request_attributes(&request.underlying); - - let trace_context_headers = TraceContextHeaders::parse(request.underlying.headers()); - - let (user_defined_trace_id, user_defined_span) = self - .evaluate_invocation_context_rib_script(invocation_context_compiled, request) - .await?; - - match (trace_context_headers, &user_defined_trace_id) { - (Some(ctx), None) => { - // Trace context found in headers and not overridden, starting a new span in it - let mut ctx = InvocationContextStack::new( - ctx.trace_id, - InvocationContextSpan::external_parent(ctx.parent_id), - ctx.trace_states, - ); - let user_defined_span = Self::materialize_user_invocation_context( - user_defined_span, - Some(ctx.spans.first().clone()), - request_attributes, - )?; - ctx.push(user_defined_span); - ctx - } - (_, Some(trace_id)) => { - // Forced a new trace, ignoring the trace context in the headers - let user_defined_span = Self::materialize_user_invocation_context( - user_defined_span, - None, - request_attributes, - )?; - InvocationContextStack::new(trace_id.clone(), user_defined_span, Vec::new()) - } - (None, _) => { - // No trace context in headers, starting a new trace - let user_defined_span = Self::materialize_user_invocation_context( - user_defined_span, - None, - request_attributes, - )?; - InvocationContextStack::new( - user_defined_trace_id.unwrap_or_else(TraceId::generate), - user_defined_span, - Vec::new(), - ) - } - } - } else { - invocation_context_from_request(&request.underlying) - }; - - Ok(WorkerDetails { - component_id, - component_revision, - worker_name, - idempotency_key, - invocation_context, - }) - } - - async fn execute_response_mapping_script( - &self, - compiled_response_mapping: ResponseMappingCompiled, - request: &mut RichRequest, - worker_detail: WorkerDetails, - account_id: AccountId, - ) -> GatewayHttpResult { - let WorkerDetails { - invocation_context, - idempotency_key, - .. - } = worker_detail; - - let ResponseMappingCompiled { - response_mapping_compiled, - rib_input, - .. - } = compiled_response_mapping; - - let rib_input = resolve_rib_input(request, &rib_input).await?; - - self.gateway_worker_request_executor - .evaluate_rib( - idempotency_key, - invocation_context, - account_id, - response_mapping_compiled, - rib_input, - ) - .await - .map_err(GatewayHttpError::EvaluationError) - } - - async fn apply_middlewares_in( - &self, - mut request: RichRequest, - middlewares: &Vec, - ) -> Result { - let input_middleware_result = process_middleware_in( - middlewares, - &request, - &self.gateway_session_store, - &self.identity_provider, - ) - .await; - - let input_middleware_result = match input_middleware_result { - Ok(MiddlewareSuccess::PassThrough { - session_id: session_id_opt, - }) => { - if let Some(session_id) = session_id_opt.as_ref() { - let result = request - .add_auth_details(session_id, &self.gateway_session_store) - .await; - - if let Err(err_response) = result { - Err(MiddlewareError::InternalError(err_response)) - } else { - Ok(MiddlewareSuccess::PassThrough { - session_id: session_id_opt, - }) - } - } else { - Ok(MiddlewareSuccess::PassThrough { - session_id: session_id_opt, - }) - } - } - other => other, - }; - - match input_middleware_result { - Ok(MiddlewareSuccess::Redirect(response)) => Err(response)?, - Ok(MiddlewareSuccess::PassThrough { .. }) => Ok(request), - Err(err) => { - error!("Middleware error: {}", err.to_safe_string()); - let response = err.to_response_from_safe_display(|error| match error { - MiddlewareError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR, - MiddlewareError::Unauthorized(_) => StatusCode::UNAUTHORIZED, - MiddlewareError::CorsError(_) => StatusCode::FORBIDDEN, - }); - Err(response)? - } - } - } -} - -async fn resolve_rib_input( - rich_request: &mut RichRequest, - required_types: &RibInputTypeInfo, -) -> Result { - let mut values: Vec = vec![]; - let mut types: Vec = vec![]; - - let request_analysed_type = required_types.types.get("request"); - - match request_analysed_type { - Some(AnalysedType::Record(type_record)) => { - for record in type_record.fields.iter() { - let field_name = record.name.as_str(); - - types.push(NameTypePair { - name: field_name.to_string(), - typ: record.typ.clone(), - }); - - match field_name { - "body" => { - let body = rich_request.request_body().await.map_err(|err| { - GatewayHttpError::BadRequest(format!( - "invalid http request body. {err}" - )) - })?; - - let body_value = - ValueAndType::parse_with_type(body, &record.typ).map_err(|err| { - GatewayHttpError::BadRequest(format!( - "invalid http request body\n{}\nexpected request body: {}", - err.join("\n"), - TypeName::try_from(record.typ.clone()) - .map(|x| x.to_string()) - .unwrap_or_else(|_| format!("{:?}", &record.typ)) - )) - })?; - - values.push(body_value.value); - } - "headers" | "header" => { - let header_values = get_wasm_rpc_value_for_primitives( - &record.typ, - rich_request, - &|request, key| { - request - .headers() - .get(key) - .map(|x| x.to_str().unwrap().to_string()) - .ok_or(format!("missing header: {}", &key)) - }, - ) - .map_err(|err| { - GatewayHttpError::BadRequest(format!( - "invalid http request header. {err}" - )) - })?; - - values.push(header_values); - } - "query" => { - let query_value = get_wasm_rpc_value_for_primitives( - &record.typ, - rich_request, - &|request, key| { - request - .query_params() - .get(key) - .map(|x| x.to_string()) - .ok_or(format!("Missing query parameter: {key}")) - }, - ) - .map_err(|err| { - GatewayHttpError::BadRequest(format!( - "invalid http request query. {err}" - )) - })?; - - values.push(query_value); - } - "path" => { - let path_values = get_wasm_rpc_value_for_primitives( - &record.typ, - rich_request, - &|request, key| { - request - .path_params() - .get(key) - .map(|x| x.to_string()) - .ok_or(format!("Missing path parameter: {key}")) - }, - ) - .map_err(|err| { - GatewayHttpError::BadRequest(format!( - "invalid http request path. {err}" - )) - })?; - - values.push(path_values); - } - - "auth" => { - let auth_data = - rich_request - .auth_data() - .ok_or(GatewayHttpError::BadRequest( - "missing auth data".to_string(), - ))?; - - let auth_value = ValueAndType::parse_with_type(auth_data, &record.typ) - .map_err(|err| { - GatewayHttpError::BadRequest(format!( - "invalid auth data\n{}\nexpected auth: {}", - err.join("\n"), - TypeName::try_from(record.typ.clone()) - .map(|x| x.to_string()) - .unwrap_or_else(|_| format!("{:?}", &record.typ)) - )) - })?; - - values.push(auth_value.value); - } - - "request_id" => { - // Limitation of the current GlobalVariableTypeSpec. We cannot tell rib to directly the type of this field, only of all children. - // Add a dummy value field that needs to be used so inference works. - let value_and_type = RequestIdContainer { - value: rich_request.request_id, - } - .into_value_and_type(); - let expected_type = value_and_type.typ.with_optional_name(None); - - if record.typ != expected_type { - return Err(GatewayHttpError::InternalError(format!( - "invalid expected rib script input type for request.request_id: {:?}; Should be: {:?}", - record.typ, - expected_type - ))); - } - - values.push(value_and_type.value); - } - - field_name => { - // This is already type checked during API registration, - // however we still fail if we happen to have other inputs - // at this stage instead of silently ignoring them. - return Err(GatewayHttpError::InternalError(format!( - "invalid rib script with unknown input: request.{field_name}" - ))); - } - } - } - - let mut result_map: HashMap = HashMap::new(); - - result_map.insert( - "request".to_string(), - ValueAndType::new(golem_wasm::Value::Record(values), record(types)), - ); - - Ok(RibInput { input: result_map }) - } - - Some(_) => Err(GatewayHttpError::InternalError( - "invalid rib script with unsupported type for `request`".to_string(), - )), - - None => Ok(RibInput::default()), - } -} - -async fn apply_middlewares_out( - mut response: poem::Response, - middlewares: &Vec, -) -> poem::Response { - let result = process_middleware_out(middlewares, &mut response).await; - match result { - Ok(_) => response, - Err(err) => { - error!("Middleware error: {}", err.to_safe_string()); - err.to_response_from_safe_display(|_| StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -fn to_attribute_value(value: &ValueAndType) -> GatewayHttpResult { - match &value.value { - golem_wasm::Value::String(value) => Ok(AttributeValue::String(value.clone())), - _ => Err(GatewayHttpError::BadRequest( - "Invocation context values must be string".to_string(), - )), - } -} - -/// Map against the required types and get `wasm_rpc::Value` from http request -/// # Parameters -/// - `analysed_type: &AnalysedType` -/// - RibInput requirement follows a pseudo form like `{request : {headers: record-type, query: record-type, path: record-type, body: analysed-type}}`. -/// - The `analysed_type` here is the type of headers, query, or path (and not body). i.e, `record-type` in the above pseudo form. -/// - This `record-type` is expected to have primitive field types. Example for a Rib `request.path.user-id` `user-id` is some primitive and `path` should be hence a record. -/// - This analysed doesn't handle (or shouldn't correspond to) the `body` field because it can be anything and not a record of primitives -/// - `request: RichRequest` -/// - The incoming request from the client -/// - `fetch_input: &FnOnce(RichRequest) -> String`, making sure we fetch anything out of the request only if it is needed -/// -fn get_wasm_rpc_value_for_primitives( - required_type: &AnalysedType, - request: &RichRequest, - fetch_key_value: &F, -) -> Result -where - F: Fn(&RichRequest, &String) -> Result, -{ - let mut header_values: Vec = vec![]; - - if let AnalysedType::Record(record_type) = required_type { - for field in record_type.fields.iter() { - let typ = &field.typ; - - let header_value = fetch_key_value(request, &field.name)?; - - let value_and_type = match typ { - AnalysedType::Str(_) => { - parse_to_value::(field.name.clone(), header_value, "string")? - } - AnalysedType::Bool(_) => { - parse_to_value::(field.name.clone(), header_value, "bool")? - } - AnalysedType::U8(_) => { - parse_to_value::(field.name.clone(), header_value, "number")? - } - AnalysedType::U16(_) => { - parse_to_value::(field.name.clone(), header_value, "number")? - } - AnalysedType::U32(_) => { - parse_to_value::(field.name.clone(), header_value, "number")? - } - AnalysedType::U64(_) => { - parse_to_value::(field.name.clone(), header_value, "number")? - } - AnalysedType::S8(_) => { - parse_to_value::(field.name.clone(), header_value, "number")? - } - AnalysedType::S16(_) => { - parse_to_value::(field.name.clone(), header_value, "number")? - } - AnalysedType::S32(_) => { - parse_to_value::(field.name.clone(), header_value, "number")? - } - AnalysedType::S64(_) => { - parse_to_value::(field.name.clone(), header_value, "number")? - } - AnalysedType::F32(_) => { - parse_to_value::(field.name.clone(), header_value, "number")? - } - AnalysedType::F64(_) => { - parse_to_value::(field.name.clone(), header_value, "number")? - } - _ => { - return Err(format!("Invalid type: {}", field.name)); - } - }; - - header_values.push(value_and_type); - } - } - - Ok(golem_wasm::Value::Record(header_values)) -} - -fn parse_to_value( - field_name: String, - field_value: String, - type_name: &str, -) -> Result { - let value = field_value.parse::().map_err(|_| { - format!("Invalid value for key {field_name}. Expected {type_name}, Found {field_value}") - })?; - Ok(value.into_value_and_type().value) -} - -#[derive(golem_wasm_derive::IntoValue)] -struct RequestIdContainer { - value: Uuid, -} diff --git a/golem-worker-service/src/gateway_execution/gateway_session_store.rs b/golem-worker-service/src/gateway_execution/gateway_session_store.rs deleted file mode 100644 index e00505ff98..0000000000 --- a/golem-worker-service/src/gateway_execution/gateway_session_store.rs +++ /dev/null @@ -1,382 +0,0 @@ -// Copyright 2024-2025 Golem Cloud -// -// Licensed under the Golem Source License v1.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://license.golem.cloud/LICENSE -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use async_trait::async_trait; -use bytes::Bytes; -use desert_rust::BinaryCodec; -use golem_common::redis::{RedisError, RedisPool}; -use golem_common::SafeDisplay; -use golem_service_base::db::sqlite::SqlitePool; -use sqlx::Row; -use std::collections::HashMap; -use std::hash::Hash; -use std::time::Duration; -use tokio::task; -use tokio::time::interval; -use tracing::{error, info, Instrument}; - -#[async_trait] -pub trait GatewaySessionStore: Send + Sync { - async fn insert( - &self, - session_id: SessionId, - data_key: DataKey, - data_value: DataValue, - ) -> Result<(), GatewaySessionError>; - - async fn get( - &self, - session_id: &SessionId, - data_key: &DataKey, - ) -> Result; -} - -#[derive(Debug, Clone)] -pub enum GatewaySessionError { - InternalError(String), - MissingValue { - session_id: SessionId, - data_key: DataKey, - }, -} - -impl SafeDisplay for GatewaySessionError { - fn to_safe_string(&self) -> String { - match self { - GatewaySessionError::InternalError(e) => format!("Internal error: {e}"), - GatewaySessionError::MissingValue { session_id, .. } => { - format!("Invalid session {}", session_id.0) - } - } - } -} - -#[derive(Debug, Hash, PartialEq, Eq, Clone)] -pub struct SessionId(pub String); - -#[derive(Debug, Hash, PartialEq, Eq, Clone)] -pub struct DataKey(pub String); - -impl DataKey { - pub fn nonce() -> DataKey { - DataKey("nonce".to_string()) - } - - pub fn access_token() -> DataKey { - DataKey("access_token".to_string()) - } - - pub fn id_token() -> DataKey { - DataKey("id_token".to_string()) - } - - pub fn claims() -> DataKey { - DataKey("claims".to_string()) - } - - pub fn redirect_url() -> DataKey { - DataKey("redirect_url".to_string()) - } -} - -#[derive(Debug, Eq, PartialEq, Clone, BinaryCodec)] -#[desert(transparent)] -pub struct DataValue(pub serde_json::Value); - -impl DataValue { - pub fn as_string(&self) -> Option { - self.0.as_str().map(|s| s.to_string()) - } -} - -#[derive(Clone)] -pub struct SessionData { - pub value: HashMap, -} - -#[derive(Clone)] -pub struct RedisGatewaySession { - redis: RedisPool, - expiration: RedisGatewaySessionExpiration, -} - -impl RedisGatewaySession { - pub fn new(redis: RedisPool, expiration: RedisGatewaySessionExpiration) -> Self { - Self { redis, expiration } - } - - pub fn redis_key(session_id: &SessionId) -> String { - format!("gateway_session:{}", session_id.0) - } -} - -#[derive(Clone)] -pub struct RedisGatewaySessionExpiration { - pub session_expiry: Duration, -} - -impl RedisGatewaySessionExpiration { - pub fn new(session_expiry: Duration) -> Self { - Self { session_expiry } - } -} - -impl Default for RedisGatewaySessionExpiration { - fn default() -> Self { - Self::new(Duration::from_secs(60 * 60)) - } -} - -#[async_trait] -impl GatewaySessionStore for RedisGatewaySession { - async fn insert( - &self, - session_id: SessionId, - data_key: DataKey, - data_value: DataValue, - ) -> Result<(), GatewaySessionError> { - let serialised = golem_common::serialization::serialize(&data_value) - .map_err(|e| GatewaySessionError::InternalError(e.to_string()))?; - - let result: Result<(), RedisError> = self - .redis - .with("gateway_session", "insert") - .hset( - Self::redis_key(&session_id), - (data_key.0.as_str(), serialised), - ) - .await; - - result.map_err(|e| { - error!("Failed to insert session data into Redis: {}", e); - GatewaySessionError::InternalError(e.to_string()) - })?; - - self.redis - .with("gateway_session", "insert") - .expire( - Self::redis_key(&session_id), - self.expiration.session_expiry.as_secs() as i64, - ) - .await - .map_err(|e| { - error!("Failed to set expiry on session data in Redis: {}", e); - GatewaySessionError::InternalError(e.to_string()) - }) - } - - async fn get( - &self, - session_id: &SessionId, - data_key: &DataKey, - ) -> Result { - let result: Option = self - .redis - .with("gateway_session", "get_data_value") - .hget(Self::redis_key(session_id), data_key.0.as_str()) - .await - .map_err(|e| { - error!("Failed to get session data from Redis: {}", e); - GatewaySessionError::InternalError(e.to_string()) - })?; - - if let Some(result) = result { - let data_value: DataValue = golem_common::serialization::deserialize(&result) - .map_err(|e| GatewaySessionError::InternalError(e.to_string()))?; - - Ok(data_value) - } else { - Err(GatewaySessionError::MissingValue { - session_id: session_id.clone(), - data_key: data_key.clone(), - }) - } - } -} - -#[derive(Debug, Clone)] -pub struct SqliteGatewaySession { - pool: SqlitePool, - expiration: SqliteGatewaySessionExpiration, -} - -#[derive(Debug, Clone)] -pub struct SqliteGatewaySessionExpiration { - pub session_expiry: Duration, - pub cleanup_interval: Duration, -} - -impl SqliteGatewaySessionExpiration { - pub fn new(session_expiry: Duration, cleanup_interval: Duration) -> Self { - Self { - session_expiry, - cleanup_interval, - } - } -} - -impl Default for SqliteGatewaySessionExpiration { - fn default() -> Self { - Self::new(Duration::from_secs(60 * 60), Duration::from_secs(60)) - } -} - -impl SqliteGatewaySession { - pub async fn new( - pool: SqlitePool, - expiration: SqliteGatewaySessionExpiration, - ) -> Result { - let result = Self { pool, expiration }; - - result.init().await?; - - let cloned_session = result.clone(); - - Self::spawn_expiration_task( - cloned_session.expiration.cleanup_interval, - cloned_session.pool, - ); - - Ok(result) - } - - async fn init(&self) -> Result<(), String> { - self.pool - .with_rw("gateway_session", "init") - .execute(sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS gateway_session ( - session_id TEXT NOT NULL, - data_key TEXT NOT NULL, - data_value BLOB NOT NULL, - expiry_time INTEGER NOT NULL, - PRIMARY KEY (session_id, data_key) - ); - "#, - )) - .await - .map_err(|err| err.to_safe_string())?; - - info!("Initialized gateway session SQLite table"); - - Ok(()) - } - - pub fn spawn_expiration_task(cleanup_internal: Duration, db_pool: SqlitePool) { - task::spawn( - async move { - let mut cleanup_interval = interval(cleanup_internal); - - loop { - cleanup_interval.tick().await; - - if let Err(e) = - Self::cleanup_expired(db_pool.clone(), Self::current_time()).await - { - error!("Failed to expire sessions: {}", e); - } - } - } - .in_current_span(), - ); - } - - pub async fn cleanup_expired(pool: SqlitePool, current_time: i64) -> Result<(), String> { - let query = - sqlx::query("DELETE FROM gateway_session WHERE expiry_time < ?;").bind(current_time); - - pool.with_rw("gateway_session", "cleanup_expired") - .execute(query) - .await - .map(|_| ()) - .map_err(|err| err.to_safe_string()) - } - - pub fn current_time() -> i64 { - chrono::Utc::now().timestamp() - } -} - -#[async_trait] -impl GatewaySessionStore for SqliteGatewaySession { - async fn insert( - &self, - session_id: SessionId, - data_key: DataKey, - data_value: DataValue, - ) -> Result<(), GatewaySessionError> { - let expiry_time = Self::current_time() + self.expiration.session_expiry.as_secs() as i64; - - let serialized_value: &[u8] = &golem_common::serialization::serialize(&data_value) - .map_err(|e| GatewaySessionError::InternalError(e.to_string()))?; - - let result = self - .pool - .with_rw("gateway_session", "insert") - .execute( - sqlx::query( - r#" - INSERT INTO gateway_session (session_id, data_key, data_value, expiry_time) - VALUES (?, ?, ?, ?); - "#, - ) - .bind(session_id.0) - .bind(data_key.0) - .bind(serialized_value) - .bind(expiry_time), - ) - .await; - - result.map_err(|e| { - error!("Failed to insert session data into SQLite: {}", e); - GatewaySessionError::InternalError(e.to_string()) - })?; - - Ok(()) - } - - async fn get( - &self, - session_id: &SessionId, - data_key: &DataKey, - ) -> Result { - let query = sqlx::query( - "SELECT data_value FROM gateway_session WHERE session_id = ? AND data_key = ?;", - ) - .bind(&session_id.0) - .bind(&data_key.0); - - let result = self - .pool - .with_ro("gateway_sesssion", "get") - .fetch_optional(query) - .await - .map_err(|e| GatewaySessionError::InternalError(e.to_string()))?; - - match result { - Some(row) => { - let row = row.get::, _>(0); - - let data_value = golem_common::serialization::deserialize(&row) - .map_err(|e| GatewaySessionError::InternalError(e.to_string()))?; - - Ok(data_value) - } - None => Err(GatewaySessionError::MissingValue { - session_id: session_id.clone(), - data_key: data_key.clone(), - }), - } - } -} diff --git a/golem-worker-service/src/gateway_execution/gateway_worker_request_executor.rs b/golem-worker-service/src/gateway_execution/gateway_worker_request_executor.rs deleted file mode 100644 index 26e2b47db1..0000000000 --- a/golem-worker-service/src/gateway_execution/gateway_worker_request_executor.rs +++ /dev/null @@ -1,222 +0,0 @@ -// Copyright 2024-2025 Golem Cloud -// -// Licensed under the Golem Source License v1.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://license.golem.cloud/LICENSE -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use crate::gateway_execution::GatewayResolvedWorkerRequest; -use crate::service::component::ComponentService; -use crate::service::worker::WorkerService; -use async_trait::async_trait; -use golem_common::model::account::AccountId; -use golem_common::model::agent::{AgentId, AgentMode, AgentTypeName}; -use golem_common::model::component::{ComponentId, ComponentRevision}; -use golem_common::model::invocation_context::InvocationContextStack; -use golem_common::model::{IdempotencyKey, WorkerId}; -use golem_common::SafeDisplay; -use golem_service_base::model::auth::AuthCtx; -use golem_wasm::analysis::AnalysedType; -use golem_wasm::ValueAndType; -use rib::InstructionId; -use rib::{ - ComponentDependencyKey, EvaluatedFnArgs, EvaluatedFqFn, EvaluatedWorkerName, RibByteCode, - RibComponentFunctionInvoke, RibFunctionInvokeResult, RibInput, RibResult, -}; -use std::collections::BTreeMap; -use std::fmt::Display; -use std::sync::Arc; -use tracing::debug; -use uuid::Uuid; - -pub struct GatewayWorkerRequestExecutor { - worker_service: Arc, - component_service: Arc, -} - -impl GatewayWorkerRequestExecutor { - pub fn new( - worker_service: Arc, - component_service: Arc, - ) -> Self { - Self { - worker_service, - component_service, - } - } - - pub async fn evaluate_rib( - self: &Arc, - idempotency_key: Option, - invocation_context: InvocationContextStack, - account_id: AccountId, - expr: RibByteCode, - rib_input: RibInput, - ) -> Result { - let worker_invoke_function: Arc = - Arc::new(self.rib_invoke(idempotency_key, invocation_context, account_id)); - - let result = rib::interpret(expr, rib_input, worker_invoke_function, None) - .await - .map_err(|err| WorkerRequestExecutorError(err.to_string()))?; - Ok(result) - } - - pub async fn execute( - &self, - resolved_worker_request: GatewayResolvedWorkerRequest, - account_id: AccountId, - ) -> Result, WorkerRequestExecutorError> { - let component = self - .component_service - .get_revision( - resolved_worker_request.component_id, - resolved_worker_request.component_revision, - ) - .await - .map_err(|err| WorkerRequestExecutorError(err.to_safe_string()))?; - - let mut worker_name = resolved_worker_request.worker_name; - - if component.metadata.is_agent() { - let agent_type_name = AgentId::parse_agent_type_name(&worker_name) - .map_err(|err| WorkerRequestExecutorError(format!("Invalid agent ID: {err}")))?; - let agent_type = component - .metadata - .find_agent_type_by_wrapper_name(&AgentTypeName(agent_type_name.to_string())) - .map_err(|err| { - WorkerRequestExecutorError(format!("Failed to extract agent type: {err}")) - })? - .ok_or_else(|| WorkerRequestExecutorError("Agent type not found".to_string()))?; - - if agent_type.mode == AgentMode::Ephemeral { - let phantom_id = Uuid::new_v4(); - let phantom_id_postfix = format!("[{phantom_id}]"); - worker_name.push_str(&phantom_id_postfix); - } - } - - let worker_id = WorkerId::from_component_metadata_and_worker_id( - component.id, - &component.metadata, - worker_name, - )?; - - debug!( - component_id = resolved_worker_request.component_id.to_string(), - function_name = resolved_worker_request.function_name, - worker_name = worker_id.worker_name.clone(), - "Executing invocation", - ); - - let result = self - .worker_service - .invoke_and_await_typed( - &worker_id, - resolved_worker_request.idempotency_key, - resolved_worker_request.function_name.to_string(), - resolved_worker_request.function_params, - Some(golem_api_grpc::proto::golem::worker::InvocationContext { - parent: None, - env: Default::default(), - wasi_config_vars: Some(BTreeMap::new().into()), - tracing: Some(resolved_worker_request.invocation_context.into()), - }), - AuthCtx::impersonated_user(account_id), - ) - .await - .map_err(|e| format!("Error when executing resolved worker request. Error: {e}"))?; - - Ok(result) - } - - fn rib_invoke( - self: &Arc, - idempotency_key: Option, - invocation_context: InvocationContextStack, - account_id: AccountId, - ) -> WorkerRequestExecutorRibInvoke { - WorkerRequestExecutorRibInvoke { - idempotency_key, - invocation_context, - executor: self.clone(), - account_id, - } - } -} - -#[derive(Clone, Debug)] -pub struct WorkerRequestExecutorError(pub String); - -impl std::error::Error for WorkerRequestExecutorError {} - -impl Display for WorkerRequestExecutorError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl> From for WorkerRequestExecutorError { - fn from(err: T) -> Self { - WorkerRequestExecutorError(err.as_ref().to_string()) - } -} - -impl SafeDisplay for WorkerRequestExecutorError { - fn to_safe_string(&self) -> String { - self.0.clone() - } -} - -struct WorkerRequestExecutorRibInvoke { - executor: Arc, - idempotency_key: Option, - invocation_context: InvocationContextStack, - account_id: AccountId, -} - -#[async_trait] -impl RibComponentFunctionInvoke for WorkerRequestExecutorRibInvoke { - async fn invoke( - &self, - component_dependency_key: ComponentDependencyKey, - _instruction_id: &InstructionId, - worker_name: EvaluatedWorkerName, - function_name: EvaluatedFqFn, - parameters: EvaluatedFnArgs, - _return_type: Option, - ) -> RibFunctionInvokeResult { - let worker_name = worker_name.0; - - let idempotency_key = self.idempotency_key.clone(); - let invocation_context = self.invocation_context.clone(); - let executor = self.executor.clone(); - - let function_name = function_name.0; - let function_params: Vec = parameters.0; - - let component_id = ComponentId(component_dependency_key.component_id); - let component_revision: ComponentRevision = - component_dependency_key.component_revision.try_into()?; - - let worker_request = GatewayResolvedWorkerRequest { - component_id, - component_revision, - worker_name, - function_name, - function_params, - idempotency_key, - invocation_context, - }; - - let result = executor.execute(worker_request, self.account_id).await?; - Ok(result) - } -} diff --git a/golem-worker-service/src/gateway_execution/http_content_type_mapper.rs b/golem-worker-service/src/gateway_execution/http_content_type_mapper.rs deleted file mode 100644 index 152dec2850..0000000000 --- a/golem-worker-service/src/gateway_execution/http_content_type_mapper.rs +++ /dev/null @@ -1,956 +0,0 @@ -// Copyright 2024-2025 Golem Cloud -// -// Licensed under the Golem Source License v1.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://license.golem.cloud/LICENSE -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use golem_wasm::analysis::AnalysedType; -use golem_wasm::ValueAndType; -use mime::Mime; -use poem::web::headers::ContentType; -use poem::web::WithContentType; -use poem::Body; -use std::fmt::{Display, Formatter}; -use std::str::FromStr; - -pub trait HttpContentTypeResponseMapper { - fn to_http_resp_with_content_type( - &self, - content_type_headers: ContentTypeHeaders, - ) -> Result, ContentTypeMapError>; -} - -#[derive(Debug, Clone)] -pub enum ContentTypeHeaders { - FromClientAccept(AcceptHeaders), - FromUserDefinedResponseMapping(ContentType), - Empty, -} - -#[derive(Clone, Debug)] -pub struct AcceptHeaders(Vec); - -pub trait ContentTypeHeaderExt { - fn has_application_json(&self) -> bool; - fn response_content_type(&self) -> Result; -} - -impl ContentTypeHeaderExt for ContentType { - fn has_application_json(&self) -> bool { - self == &ContentType::json() - } - fn response_content_type(&self) -> Result { - Ok(self.clone()) - } -} -impl ContentTypeHeaderExt for AcceptHeaders { - fn has_application_json(&self) -> bool { - self.0.iter().any(|v| { - if let Ok(mime) = Mime::from_str(v) { - matches!( - (mime.type_(), mime.subtype()), - (mime::APPLICATION, mime::JSON) - | (mime::APPLICATION, mime::STAR) - | (mime::STAR, mime::STAR) - | (mime::STAR, mime::JSON) - ) - } else { - false - } - }) - } - - fn response_content_type(&self) -> Result { - internal::pick_highest_priority_content_type(self) - } -} - -impl AcceptHeaders { - fn from_str>(input: A) -> AcceptHeaders { - let headers = input - .as_ref() - .split(',') - .map(|v| v.trim().to_string()) - .collect::>(); - - AcceptHeaders(headers) - } -} - -impl Display for AcceptHeaders { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "{:?}", self.0.join(", ")) - } -} - -impl AcceptHeaders { - fn contains(&self, content_type: &ContentType) -> bool { - self.0 - .iter() - .any(|accept_header| accept_header.contains(content_type.to_string().as_str())) - } -} - -impl ContentTypeHeaders { - pub fn from( - response_content_type: Option, - accepted_content_types: Option, - ) -> Self { - if let Some(response_content_type) = response_content_type { - ContentTypeHeaders::FromUserDefinedResponseMapping(response_content_type) - } else if let Some(accept_header_string) = accepted_content_types { - ContentTypeHeaders::FromClientAccept(AcceptHeaders::from_str(accept_header_string)) - } else { - ContentTypeHeaders::Empty - } - } -} - -impl HttpContentTypeResponseMapper for ValueAndType { - fn to_http_resp_with_content_type( - &self, - content_type_headers: ContentTypeHeaders, - ) -> Result, ContentTypeMapError> { - match content_type_headers { - ContentTypeHeaders::FromUserDefinedResponseMapping(content_type) => { - internal::get_response_body_based_on_content_type(self, &content_type) - } - ContentTypeHeaders::FromClientAccept(accept_content_headers) => { - internal::get_response_body_based_on_content_type(self, &accept_content_headers) - } - ContentTypeHeaders::Empty => internal::get_response_body(self), - } - } -} - -#[derive(PartialEq, Debug)] -pub enum ContentTypeMapError { - IllegalMapping { - input_type: AnalysedType, - expected_content_types: String, - }, - InternalError(String), -} - -impl ContentTypeMapError { - fn internal>(msg: A) -> ContentTypeMapError { - ContentTypeMapError::InternalError(msg.as_ref().to_string()) - } - - fn illegal_mapping( - input_type: &AnalysedType, - expected_content_types: &A, - ) -> ContentTypeMapError { - ContentTypeMapError::IllegalMapping { - input_type: input_type.clone(), - expected_content_types: expected_content_types.to_string(), - } - } -} - -impl Display for ContentTypeMapError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - ContentTypeMapError::InternalError(message) => { - write!(f, "{message}") - } - ContentTypeMapError::IllegalMapping { - input_type, - expected_content_types, - } => { - write!( - f, - "Failed to map input type {input_type:?} to any of the expected content types: {expected_content_types:?}" - ) - } - } - } -} - -mod internal { - use crate::gateway_execution::http_content_type_mapper::{ - AcceptHeaders, ContentTypeHeaderExt, ContentTypeMapError, - }; - use golem_wasm::analysis::{AnalysedType, TypeEnum, TypeList, TypeOption, TypeRecord}; - use golem_wasm::json::ValueAndTypeJsonExtensions; - use golem_wasm::{Value, ValueAndType}; - use poem::web::headers::ContentType; - use poem::web::WithContentType; - use poem::{Body, IntoResponse}; - use std::fmt::Display; - - pub(crate) fn get_response_body_based_on_content_type( - value_and_type: &ValueAndType, - content_header: &A, - ) -> Result, ContentTypeMapError> { - match (&value_and_type.typ, &value_and_type.value) { - (AnalysedType::Record(_record), Value::Record(_values)) => { - handle_record(value_and_type, content_header) - } - (AnalysedType::Variant(_variant), Value::Variant { .. }) => { - handle_record(value_and_type, content_header) - } - (AnalysedType::List(TypeList { inner, .. }), Value::List(values)) => { - handle_list(value_and_type, values, inner, content_header) - } - (AnalysedType::Bool(_), Value::Bool(value)) => { - handle_primitive(value, &value_and_type.typ, content_header) - } - (AnalysedType::S8(_), Value::S8(value)) => { - handle_primitive(value, &value_and_type.typ, content_header) - } - (AnalysedType::U8(_), Value::U8(value)) => { - handle_primitive(value, &value_and_type.typ, content_header) - } - (AnalysedType::S16(_), Value::S16(value)) => { - handle_primitive(value, &value_and_type.typ, content_header) - } - (AnalysedType::U16(_), Value::U16(value)) => { - handle_primitive(value, &value_and_type.typ, content_header) - } - (AnalysedType::S32(_), Value::S32(value)) => { - handle_primitive(value, &value_and_type.typ, content_header) - } - (AnalysedType::U32(_), Value::U32(value)) => { - handle_primitive(value, &value_and_type.typ, content_header) - } - (AnalysedType::S64(_), Value::S64(value)) => { - handle_primitive(value, &value_and_type.typ, content_header) - } - (AnalysedType::U64(_), Value::U64(value)) => { - handle_primitive(value, &value_and_type.typ, content_header) - } - (AnalysedType::F32(_), Value::F32(value)) => { - handle_primitive(value, &value_and_type.typ, content_header) - } - (AnalysedType::F64(_), Value::F64(value)) => { - handle_primitive(value, &value_and_type.typ, content_header) - } - (AnalysedType::Chr(_), Value::Char(value)) => { - handle_primitive(value, &value_and_type.typ, content_header) - } - (AnalysedType::Str(_), Value::String(string)) => handle_string(string, content_header), - (AnalysedType::Tuple(_), Value::Tuple(_)) => { - handle_complex(value_and_type, content_header) - } - (AnalysedType::Flags(_), Value::Flags(_)) => { - handle_complex(value_and_type, content_header) - } - // Can be considered as a record - (AnalysedType::Result(_), Value::Result(_)) => { - handle_complex(value_and_type, content_header) - } - (AnalysedType::Handle(_), Value::Handle { .. }) => { - handle_complex(value_and_type, content_header) - } - (AnalysedType::Enum(TypeEnum { cases, .. }), Value::Enum(name_idx)) => { - let name = cases - .get(*name_idx as usize) - .ok_or(ContentTypeMapError::internal("Invalid enum index"))?; - handle_string(name, content_header) - } - (AnalysedType::Option(TypeOption { inner, .. }), Value::Option(value)) => match value { - Some(value) => { - let value_and_type = ValueAndType::new((**value).clone(), (**inner).clone()); - get_response_body_based_on_content_type(&value_and_type, content_header) - } - None => { - if content_header.has_application_json() { - get_json_null() - } else { - let typ = value_and_type.typ.clone(); - Err(ContentTypeMapError::illegal_mapping(&typ, content_header)) - } - } - }, - _ => Err(ContentTypeMapError::InternalError( - "Value and type mismatch".to_string(), - )), - } - } - - pub(crate) fn get_response_body( - value_and_type: &ValueAndType, - ) -> Result, ContentTypeMapError> { - match (&value_and_type.typ, &value_and_type.value) { - (AnalysedType::Record(TypeRecord { .. }), Value::Record { .. }) => { - get_json(value_and_type) - } - (AnalysedType::List(TypeList { inner, .. }), Value::List(values)) => match &**inner { - AnalysedType::U8(_) => get_byte_stream_body(values), - _ => get_json(value_and_type), - }, - (AnalysedType::Str(_), Value::String(string)) => { - Ok(Body::from_string(string.to_string()) - .with_content_type(ContentType::json().to_string())) - } - (AnalysedType::Enum(TypeEnum { cases, .. }), Value::Enum(case_idx)) => { - let case_name = cases - .get(*case_idx as usize) - .ok_or(ContentTypeMapError::internal("Invalid enum index"))?; - Ok(Body::from_string(case_name.to_string()) - .with_content_type(ContentType::json().to_string())) - } - (AnalysedType::Bool(_), Value::Bool(bool)) => get_json_of(bool), - (AnalysedType::S8(_), Value::S8(s8)) => get_json_of(s8), - (AnalysedType::U8(_), Value::U8(u8)) => get_json_of(u8), - (AnalysedType::S16(_), Value::S16(s16)) => get_json_of(s16), - (AnalysedType::U16(_), Value::U16(u16)) => get_json_of(u16), - (AnalysedType::S32(_), Value::S32(s32)) => get_json_of(s32), - (AnalysedType::U32(_), Value::U32(u32)) => get_json_of(u32), - (AnalysedType::S64(_), Value::S64(s64)) => get_json_of(s64), - (AnalysedType::U64(_), Value::U64(u64)) => get_json_of(u64), - (AnalysedType::F32(_), Value::F32(f32)) => get_json_of(f32), - (AnalysedType::F64(_), Value::F64(f64)) => get_json_of(f64), - (AnalysedType::Chr(_), Value::Char(char)) => get_json_of(char), - (AnalysedType::Tuple(_), Value::Tuple(_)) => get_json(value_and_type), - (AnalysedType::Flags(_), Value::Flags(_)) => get_json(value_and_type), - (AnalysedType::Variant(_), Value::Variant { .. }) => get_json(value_and_type), - (AnalysedType::Result(_), Value::Result { .. }) => get_json(value_and_type), - (AnalysedType::Handle(_), Value::Handle { .. }) => get_json(value_and_type), - (AnalysedType::Option(TypeOption { inner, .. }), Value::Option(value)) => match value { - Some(value) => { - let value = ValueAndType::new((**value).clone(), (**inner).clone()); - get_response_body(&value) - } - None => get_json_null(), - }, - _ => Err(ContentTypeMapError::internal("Value and type mismatch")), - } - } - - pub(crate) fn pick_highest_priority_content_type( - input_content_types: &AcceptHeaders, - ) -> Result { - let content_headers_in_priority: Vec = vec![ - ContentType::json(), - ContentType::text(), - ContentType::text_utf8(), - ContentType::html(), - ContentType::xml(), - ContentType::form_url_encoded(), - ContentType::jpeg(), - ContentType::png(), - ContentType::octet_stream(), - ]; - - let mut prioritised_content_type = None; - for content_type in &content_headers_in_priority { - if input_content_types.contains(content_type) { - prioritised_content_type = Some(content_type.clone()); - break; - } - } - - if let Some(prioritised) = prioritised_content_type { - Ok(prioritised) - } else { - Err(ContentTypeMapError::internal( - "Failed to pick a content type to set in response headers", - )) - } - } - - fn get_byte_stream(values: &[Value]) -> Result, ContentTypeMapError> { - let bytes = values - .iter() - .map(|v| match v { - Value::U8(u8) => Ok(*u8), - _ => Err(ContentTypeMapError::internal( - "The analysed type is a binary stream however unable to fetch vec", - )), - }) - .collect::, ContentTypeMapError>>()?; - - Ok(bytes) - } - - fn get_byte_stream_body( - values: &[Value], - ) -> Result, ContentTypeMapError> { - let bytes = get_byte_stream(values)?; - Ok(Body::from_bytes(bytes::Bytes::from(bytes)) - .with_content_type(ContentType::octet_stream().to_string())) - } - - fn get_json( - value_and_type: &ValueAndType, - ) -> Result, ContentTypeMapError> { - let json = value_and_type.to_json_value().map_err(|err| { - ContentTypeMapError::internal(format!("Failed to encode value as JSON: {err}")) - })?; - Body::from_json(json) - .map(|body| body.with_content_type(ContentType::json().to_string())) - .map_err(|_| ContentTypeMapError::internal("Failed to convert to json body")) - } - - fn get_json_of( - a: A, - ) -> Result, ContentTypeMapError> { - let json = serde_json::to_value(&a).map_err(|_| { - ContentTypeMapError::internal(format!("Failed to serialise {a} to json")) - })?; - let body = Body::from_json(json) - .map_err(|_| ContentTypeMapError::internal("Failed to create body from JSON"))?; - - Ok(body.with_content_type(ContentType::json().to_string())) - } - - fn get_json_null() -> Result, ContentTypeMapError> { - Body::from_json(serde_json::Value::Null) - .map(|body| body.with_content_type(ContentType::json().to_string())) - .map_err(|_| ContentTypeMapError::internal("Failed to convert to json body")) - } - - fn handle_complex( - complex: &ValueAndType, - content_header: &A, - ) -> Result, ContentTypeMapError> { - if content_header.has_application_json() { - get_json(complex) - } else { - let typ = complex.typ.clone(); - Err(ContentTypeMapError::illegal_mapping(&typ, content_header)) - } - } - - fn handle_list( - original: &ValueAndType, - inner_values: &[Value], - elem_type: &AnalysedType, - content_header: &A, - ) -> Result, ContentTypeMapError> { - match elem_type { - AnalysedType::U8(_) => { - let byte_stream = get_byte_stream(inner_values)?; - let body = Body::from_bytes(bytes::Bytes::from(byte_stream)); - let content_type_header = content_header.response_content_type()?; - Ok(body.with_content_type(content_type_header.to_string())) - } - _ => { - if content_header.has_application_json() { - get_json(original) - } else { - Err(ContentTypeMapError::illegal_mapping( - elem_type, - content_header, - )) - } - } - } - } - - fn handle_primitive( - input: &A, - primitive_type: &AnalysedType, - content_header: &B, - ) -> Result, ContentTypeMapError> where { - if content_header.has_application_json() { - let json = serde_json::to_value(input) - .map_err(|_| ContentTypeMapError::internal("Failed to convert to json body"))?; - - let body = Body::from_json(json).map_err(|_| { - ContentTypeMapError::internal(format!("Failed to convert {input} to json body")) - })?; - - Ok(body.with_content_type(ContentType::json().to_string())) - } else { - Err(ContentTypeMapError::illegal_mapping( - primitive_type, - content_header, - )) - } - } - - fn handle_record( - value_and_type: &ValueAndType, - content_header: &A, - ) -> Result, ContentTypeMapError> { - // if record, we prioritise JSON - if content_header.has_application_json() { - get_json(value_and_type) - } else { - let typ = value_and_type.typ.clone(); - // There is no way a Record can be properly serialised into any other formats to satisfy any other headers, therefore fail - Err(ContentTypeMapError::illegal_mapping(&typ, content_header)) - } - } - - fn handle_string( - string: &str, - content_type: &A, - ) -> Result, ContentTypeMapError> { - let response_content_type = content_type.response_content_type()?; - - let body = if content_type.has_application_json() { - bytes::Bytes::from(format!("\"{string}\"")) - } else { - bytes::Bytes::from(string.to_string()) - }; - - Ok(Body::from_bytes(body).with_content_type(response_content_type.to_string())) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use golem_wasm::analysis::analysed_type::{field, list, record, str}; - use golem_wasm::{IntoValue, Value}; - use poem::web::headers::ContentType; - use poem::IntoResponse; - - fn sample_record() -> ValueAndType { - ValueAndType::new( - Value::Record(vec!["Hello".into_value()]), - record(vec![field("name", str())]), - ) - } - - fn create_list(vec: Vec, analysed_type: AnalysedType) -> ValueAndType { - ValueAndType::new(Value::List(vec), list(analysed_type)) - } - - fn create_record(values: Vec<(&str, ValueAndType)>) -> ValueAndType { - ValueAndType::new( - Value::Record(values.iter().map(|(_, v)| v.value.clone()).collect()), - record( - values - .iter() - .map(|(name, value)| field(name, value.typ.clone())) - .collect(), - ), - ) - } - - #[cfg(test)] - mod no_content_type_header { - use test_r::test; - - use super::*; - use golem_wasm::analysis::analysed_type::{u16, u8}; - use golem_wasm::IntoValueAndType; - - fn get_content_type_and_body(input: &ValueAndType) -> (Option, Body) { - let response_body = internal::get_response_body(input).unwrap(); - let response = response_body.into_response(); - let (parts, body) = response.into_parts(); - let content_type = parts - .headers - .get("content-type") - .map(|v| v.to_str().unwrap().to_string()); - (content_type, body) - } - - #[test] - async fn test_string_type() { - let type_annotated_value = "Hello".into_value_and_type(); - let (content_type, body) = get_content_type_and_body(&type_annotated_value); - let result = String::from_utf8_lossy(&body.into_bytes().await.unwrap()).to_string(); - // Had it serialized as json, it would have been "\"Hello\"" - assert_eq!( - (result, content_type), - ("Hello".to_string(), Some("application/json".to_string())) - ); - } - - #[test] - async fn test_singleton_u8_type() { - let type_annotated_value = 10u8.into_value_and_type(); - let (content_type, body) = get_content_type_and_body(&type_annotated_value); - let result = String::from_utf8_lossy(&body.into_bytes().await.unwrap()).to_string(); - assert_eq!( - (result, content_type), - ("10".to_string(), Some("application/json".to_string())) - ); - } - - #[test] - async fn test_list_u8_type() { - let type_annotated_value = create_list(vec![10u8.into_value()], u8()); - let (content_type, body) = get_content_type_and_body(&type_annotated_value); - let result = body.into_bytes().await.unwrap(); - - assert_eq!( - (result, content_type), - ( - bytes::Bytes::from(vec![10]), - Some("application/octet-stream".to_string()) - ) - ); - } - - #[test] - async fn test_list_non_u8_type() { - let type_annotated_value = create_list(vec![10u16.into_value()], u16()); - - let (content_type, body) = get_content_type_and_body(&type_annotated_value); - let data_as_str = - String::from_utf8_lossy(&body.into_bytes().await.unwrap()).to_string(); - let result_json: serde_json::Value = - serde_json::from_str(data_as_str.as_str()).unwrap(); - let expected_json = serde_json::Value::Array(vec![serde_json::Value::Number( - serde_json::Number::from(10), - )]); - assert_eq!( - (result_json, content_type), - (expected_json, Some("application/json".to_string())) - ); - } - - #[test] - async fn test_record_type() { - let type_annotated_value = create_record(vec![("name", "Hello".into_value_and_type())]); - - let (content_type, body) = get_content_type_and_body(&type_annotated_value); - let data_as_str = - String::from_utf8_lossy(&body.into_bytes().await.unwrap()).to_string(); - let result_json: serde_json::Value = - serde_json::from_str(data_as_str.as_str()).unwrap(); - let expected_json = serde_json::json!({"name": "Hello"}); - assert_eq!( - (result_json, content_type), - (expected_json, Some("application/json".to_string())) - ); - } - } - - #[cfg(test)] - mod with_response_type_header { - use test_r::test; - - use super::*; - use golem_wasm::analysis::analysed_type::{u16, u8}; - use golem_wasm::IntoValueAndType; - - fn get_content_type_and_body( - input: &ValueAndType, - header: &ContentType, - ) -> (Option, Body) { - let response_body = - internal::get_response_body_based_on_content_type(input, header).unwrap(); - let response = response_body.into_response(); - let (parts, body) = response.into_parts(); - let content_type = parts - .headers - .get("content-type") - .map(|v| v.to_str().unwrap().to_string()); - (content_type, body) - } - - #[test] - async fn test_string_type_as_text() { - let type_annotated_value = "Hello".into_value_and_type(); - let (content_type, body) = - get_content_type_and_body(&type_annotated_value, &ContentType::text()); - let result = String::from_utf8_lossy(&body.into_bytes().await.unwrap()).to_string(); - // Had it serialized as json, it would have been "\"Hello\"" - assert_eq!( - (result, content_type), - ("Hello".to_string(), Some("text/plain".to_string())) - ); - } - - #[test] - async fn test_string_type_as_json() { - let type_annotated_value = "\"Hello\"".into_value_and_type(); - let (content_type, body) = - get_content_type_and_body(&type_annotated_value, &ContentType::json()); - let result = String::from_utf8_lossy(&body.into_bytes().await.unwrap()).to_string(); - - // It doesn't matter if the string is already jsonified, it will be jsonified again - assert_eq!( - (result, content_type), - ( - "\"\"Hello\"\"".to_string(), - Some("application/json".to_string()) - ) - ); - } - - #[test] - async fn test_singleton_u8_type() { - let type_annotated_value = 10u8.into_value_and_type(); - let (content_type, body) = - get_content_type_and_body(&type_annotated_value, &ContentType::json()); - let result = String::from_utf8_lossy(&body.into_bytes().await.unwrap()).to_string(); - assert_eq!( - (result, content_type), - ("10".to_string(), Some("application/json".to_string())) - ); - } - - #[test] - async fn test_list_u8_type() { - let type_annotated_value = create_list(vec![10u8.into_value()], u8()); - - let (content_type, body) = - get_content_type_and_body(&type_annotated_value, &ContentType::json()); - let result = &body.into_bytes().await.unwrap(); - let data_as_str = String::from_utf8_lossy(result).to_string(); - let result_json: Result = - serde_json::from_str(data_as_str.as_str()); - - assert_eq!( - (result, content_type), - ( - &bytes::Bytes::from(vec![10]), - Some("application/json".to_string()) - ) - ); - assert!(result_json.is_err()); // That we haven't jsonified this case explicitly - } - - #[test] - async fn test_list_non_u8_type() { - let type_annotated_value = create_list(vec![10u16.into_value()], u16()); - - let (content_type, body) = - get_content_type_and_body(&type_annotated_value, &ContentType::json()); - let data_as_str = - String::from_utf8_lossy(&body.into_bytes().await.unwrap()).to_string(); - let result_json: serde_json::Value = - serde_json::from_str(data_as_str.as_str()).unwrap(); - let expected_json = serde_json::Value::Array(vec![serde_json::Value::Number( - serde_json::Number::from(10), - )]); - // That we jsonify any list other than u8, and can be retrieveed as a valid JSON - assert_eq!( - (result_json, content_type), - (expected_json, Some("application/json".to_string())) - ); - } - - #[test] - async fn test_record_type() { - // Record - let type_annotated_value = sample_record(); - - let (content_type, body) = - get_content_type_and_body(&type_annotated_value, &ContentType::json()); - let data_as_str = - String::from_utf8_lossy(&body.into_bytes().await.unwrap()).to_string(); - let result_json: serde_json::Value = - serde_json::from_str(data_as_str.as_str()).unwrap(); - let expected_json = serde_json::json!({"name": "Hello"}); - assert_eq!( - (result_json, content_type), - (expected_json, Some("application/json".to_string())) - ); - } - } - - #[cfg(test)] - mod with_accept_headers { - use test_r::test; - - use super::*; - use golem_wasm::analysis::analysed_type::{u16, u8}; - use golem_wasm::IntoValueAndType; - - fn get_content_type_and_body( - input: &ValueAndType, - headers: &AcceptHeaders, - ) -> (Option, Body) { - let response_body = - internal::get_response_body_based_on_content_type(input, headers).unwrap(); - let response = response_body.into_response(); - let (parts, body) = response.into_parts(); - let content_type = parts - .headers - .get("content-type") - .map(|v| v.to_str().unwrap().to_string()); - (content_type, body) - } - - #[test] - async fn test_string_type_with_json() { - let type_annotated_value = "Hello".into_value_and_type(); - let (content_type, body) = get_content_type_and_body( - &type_annotated_value, - &AcceptHeaders::from_str("text/html;q=0.8, application/json;q=0.5"), - ); - let result = String::from_utf8_lossy(&body.into_bytes().await.unwrap()).to_string(); - assert_eq!( - (result, content_type), - ( - "\"Hello\"".to_string(), - Some("application/json".to_string()) - ) - ); - } - - #[test] - async fn test_string_type_without_json() { - let type_annotated_value = "Hello".into_value_and_type(); - let (content_type, body) = get_content_type_and_body( - &type_annotated_value, - &AcceptHeaders::from_str("text/html;q=0.8, application/json;q=0.5"), - ); - let result = String::from_utf8_lossy(&body.into_bytes().await.unwrap()).to_string(); - assert_eq!( - (result, content_type), - ( - "\"Hello\"".to_string(), - Some("application/json".to_string()) - ) - ); - } - - #[test] - async fn test_string_type_with_html() { - let type_annotated_value = "Hello".into_value_and_type(); - let (content_type, body) = get_content_type_and_body( - &type_annotated_value, - &AcceptHeaders::from_str("text/html"), - ); - let result = String::from_utf8_lossy(&body.into_bytes().await.unwrap()).to_string(); - assert_eq!( - (result, content_type), - ("Hello".to_string(), Some("text/html".to_string())) - ); - } - - #[test] - async fn test_singleton_u8_type_text() { - let type_annotated_value = 10u8.into_value_and_type(); - let (content_type, body) = get_content_type_and_body( - &type_annotated_value, - &AcceptHeaders::from_str("application/json"), - ); - let result = String::from_utf8_lossy(&body.into_bytes().await.unwrap()).to_string(); - assert_eq!( - (result, content_type), - ("10".to_string(), Some("application/json".to_string())) - ); - } - - #[test] - async fn test_singleton_u8_type_json() { - let type_annotated_value = 10u8.into_value_and_type(); - let (content_type, body) = get_content_type_and_body( - &type_annotated_value, - &AcceptHeaders::from_str("application/json"), - ); - let result = String::from_utf8_lossy(&body.into_bytes().await.unwrap()).to_string(); - assert_eq!( - (result, content_type), - ("10".to_string(), Some("application/json".to_string())) - ); - } - - #[test] - async fn test_singleton_u8_failed_content_mapping() { - let type_annotated_value = 10u8.into_value_and_type(); - let result = internal::get_response_body_based_on_content_type( - &type_annotated_value, - &AcceptHeaders::from_str("text/html"), - ); - - assert!(matches!( - result, - Err(ContentTypeMapError::IllegalMapping { .. }) - )); - } - - #[test] - async fn test_list_u8_type_with_json() { - let type_annotated_value = create_list(vec![10u8.into_value()], u8()); - - let (content_type, body) = get_content_type_and_body( - &type_annotated_value, - &AcceptHeaders::from_str("text/html;q=0.8, application/json;q=0.50"), - ); - let result = &body.into_bytes().await.unwrap(); - let data_as_str = String::from_utf8_lossy(result).to_string(); - let result_json: Result = - serde_json::from_str(data_as_str.as_str()); - - assert_eq!( - (result, content_type), - ( - &bytes::Bytes::from(vec![10]), - Some("application/json".to_string()) - ) - ); - assert!(result_json.is_err()); // That we haven't jsonified this case explicitly - } - - #[test] - async fn test_list_non_u8_type_with_json() { - let type_annotated_value = create_list(vec![10u16.into_value()], u16()); - - let (content_type, body) = get_content_type_and_body( - &type_annotated_value, - &AcceptHeaders::from_str("text/html;q=0.8, application/json;q=0.5"), - ); - let data_as_str = - String::from_utf8_lossy(&body.into_bytes().await.unwrap()).to_string(); - let result_json: serde_json::Value = - serde_json::from_str(data_as_str.as_str()).unwrap(); - let expected_json = serde_json::Value::Array(vec![serde_json::Value::Number( - serde_json::Number::from(10), - )]); - assert_eq!( - (result_json, content_type), - (expected_json, Some("application/json".to_string())) - ); - } - - #[test] - async fn test_list_non_u8_type_with_html_fail() { - let type_annotated_value = create_list(vec![10u16.into_value()], u16()); - - let result = internal::get_response_body_based_on_content_type( - &type_annotated_value, - &AcceptHeaders::from_str("text/html"), - ); - - assert!(matches!( - result, - Err(ContentTypeMapError::IllegalMapping { .. }) - )); - } - - #[test] - async fn test_record_type_json() { - let type_annotated_value = sample_record(); - let (content_type, body) = get_content_type_and_body( - &type_annotated_value, - &AcceptHeaders::from_str("text/html;q=0.8, application/json;q=0.5"), - ); - let data_as_str = - String::from_utf8_lossy(&body.into_bytes().await.unwrap()).to_string(); - let result_json: serde_json::Value = - serde_json::from_str(data_as_str.as_str()).unwrap(); - let expected_json = serde_json::json!({"name": "Hello"}); - assert_eq!( - (result_json, content_type), - (expected_json, Some("application/json".to_string())) - ); - } - - #[test] - async fn test_record_type_html() { - let type_annotated_value = sample_record(); - - let result = internal::get_response_body_based_on_content_type( - &type_annotated_value, - &AcceptHeaders::from_str("text/html"), - ); - - assert!(matches!( - result, - Err(ContentTypeMapError::IllegalMapping { .. }) - )); - } - } -} diff --git a/golem-worker-service/src/gateway_execution/http_handler_binding_handler.rs b/golem-worker-service/src/gateway_execution/http_handler_binding_handler.rs deleted file mode 100644 index 495daa7ea3..0000000000 --- a/golem-worker-service/src/gateway_execution/http_handler_binding_handler.rs +++ /dev/null @@ -1,160 +0,0 @@ -// Copyright 2024-2025 Golem Cloud -// -// Licensed under the Golem Source License v1.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://license.golem.cloud/LICENSE -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use super::{GatewayWorkerRequestExecutor, WorkerRequestExecutorError}; -use crate::gateway_execution::{GatewayResolvedWorkerRequest, WorkerDetails}; -use bytes::Bytes; -use golem_common::model::account::AccountId; -use golem_common::virtual_exports::http_incoming_handler::IncomingHttpRequest; -use golem_common::{virtual_exports, widen_infallible}; -use golem_wasm::ValueAndType; -use http::StatusCode; -use http_body_util::combinators::BoxBody; -use http_body_util::BodyExt; -use std::str::FromStr; -use std::sync::Arc; - -pub struct HttpHandlerBindingHandler { - worker_request_executor: Arc, -} - -impl HttpHandlerBindingHandler { - pub fn new(worker_request_executor: Arc) -> Self { - Self { - worker_request_executor, - } - } - - pub async fn handle_http_handler_binding( - &self, - worker_detail: &WorkerDetails, - incoming_http_request: IncomingHttpRequest, - account_id: AccountId, - ) -> HttpHandlerBindingResult { - let type_annotated_param = ValueAndType::new( - incoming_http_request.to_value(), - IncomingHttpRequest::analysed_type(), - ); - - let resolved_request = GatewayResolvedWorkerRequest { - component_id: worker_detail.component_id, - component_revision: worker_detail.component_revision, - worker_name: worker_detail - .worker_name - .as_ref() - .ok_or_else(|| { - HttpHandlerBindingError::InternalError("Missing worker name".to_string()) - })? - .clone(), - function_name: virtual_exports::http_incoming_handler::FUNCTION_NAME.to_string(), - function_params: vec![type_annotated_param], - idempotency_key: worker_detail.idempotency_key.clone(), - invocation_context: worker_detail.invocation_context.clone(), - }; - - let response = self - .worker_request_executor - .execute(resolved_request, account_id) - .await; - - match response { - Ok(_) => { - tracing::debug!("http_handler received successful response from worker invocation") - } - Err(ref e) => tracing::warn!("worker invocation of http_handler failed: {}", e), - } - - let response = response.map_err(HttpHandlerBindingError::WorkerRequestExecutorError)?; - - let poem_response = { - use golem_common::virtual_exports::http_incoming_handler as hic; - - let parsed_response = - hic::HttpResponse::from_function_output(response).map_err(|e| { - HttpHandlerBindingError::InternalError(format!("Failed parsing response: {e}")) - })?; - - let converted_status_code = - StatusCode::from_u16(parsed_response.status).map_err(|e| { - HttpHandlerBindingError::InternalError(format!( - "Failed to parse response status: {e}" - )) - })?; - - let mut builder = poem::Response::builder().status(converted_status_code); - - for (header_name, header_value) in parsed_response.headers.0 { - let converted_header_value = - http::HeaderValue::from_bytes(&header_value).map_err(|e| { - HttpHandlerBindingError::InternalError(format!( - "Failed to parse response header: {e}" - )) - })?; - builder = builder.header(header_name, converted_header_value); - } - - if let Some(body) = parsed_response.body { - let converted_body = http_body_util::Full::new(body.content.0); - - let trailers = if let Some(trailers) = body.trailers { - let mut acc = http::HeaderMap::new(); - for (header_name, header_value) in trailers.0.into_iter() { - let converted_header_name = http::HeaderName::from_str(&header_name) - .map_err(|e| { - HttpHandlerBindingError::InternalError(format!( - "Failed to parse response trailer name: {e}" - )) - })?; - let converted_header_value = http::HeaderValue::from_bytes(&header_value) - .map_err(|e| { - HttpHandlerBindingError::InternalError(format!( - "Failed to parse response trailer value: {e}" - )) - })?; - - acc.insert(converted_header_name, converted_header_value); - } - Some(Ok(acc)) - } else { - None - }; - - let body_with_trailers = converted_body.with_trailers(async { trailers }); - - let boxed: BoxBody = - BoxBody::new(body_with_trailers.map_err(widen_infallible::)); - - builder.body(boxed) - } else { - builder.body(poem::Body::empty()) - } - }; - - Ok(HttpHandlerBindingSuccess { - response: poem_response, - }) - } -} - -pub type HttpHandlerBindingResult = Result; - -pub struct HttpHandlerBindingSuccess { - pub response: poem::Response, -} - -#[derive(Debug)] -pub enum HttpHandlerBindingError { - InternalError(String), - WorkerRequestExecutorError(WorkerRequestExecutorError), -} diff --git a/golem-worker-service/src/gateway_execution/request.rs b/golem-worker-service/src/gateway_execution/request.rs deleted file mode 100644 index d70b6016c4..0000000000 --- a/golem-worker-service/src/gateway_execution/request.rs +++ /dev/null @@ -1,274 +0,0 @@ -// Copyright 2024-2025 Golem Cloud -// -// Licensed under the Golem Source License v1.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://license.golem.cloud/LICENSE -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// use super::gateway_session_store::{DataKey, GatewaySessionStore, SessionId}; -// use crate::gateway_router::PathParamExtractor; -// use crate::model::{HttpMiddleware, RichGatewayBindingCompiled}; -use super::request_handler::RequestHandlerError; -use http::HeaderMap; -use std::collections::HashMap; -use uuid::Uuid; - -const COOKIE_HEADER_NAMES: [&str; 2] = ["cookie", "Cookie"]; - -/// Thin wrapper around a poem::Request that is used to evaluate all binding types when coming from an http gateway. -pub struct RichRequest { - pub underlying: poem::Request, - pub request_id: Uuid, - // path_segments: Vec, - // path_param_extractors: Vec, - // auth_data: Option, - // cached_request_body: Value, -} - -impl RichRequest { - pub fn new(underlying: poem::Request) -> RichRequest { - RichRequest { - underlying, - request_id: Uuid::new_v4(), - // path_segments: vec![], - // path_param_extractors: vec![], - // auth_data: None, - // cached_request_body: serde_json::Value::Null, - } - } - - // pub fn auth_data(&self) -> Option<&Value> { - // self.auth_data.as_ref() - // } - - pub fn origin(&self) -> Result, RequestHandlerError> { - match self.underlying.headers().get("Origin") { - Some(header) => { - let result = - header - .to_str() - .map_err(|_| RequestHandlerError::HeaderIsNotAscii { - header_name: "Origin".to_string(), - })?; - Ok(Some(result)) - } - None => Ok(None), - } - } - - pub fn headers(&self) -> &HeaderMap { - self.underlying.headers() - } - - pub fn query_params(&self) -> HashMap> { - let mut params: HashMap> = HashMap::new(); - - if let Some(q) = self.underlying.uri().query() { - for (key, value) in url::form_urlencoded::parse(q.as_bytes()).into_owned() { - params.entry(key).or_default().push(value); - } - } - - params - } - - // pub async fn add_auth_details( - // &mut self, - // session_id: &SessionId, - // gateway_session_store: &Arc, - // ) -> Result<(), String> { - // let claims = gateway_session_store - // .get(session_id, &DataKey::claims()) - // .await - // .map_err(|err| err.to_safe_string())?; - - // self.auth_data = Some(claims.0); - - // Ok(()) - // } - - // fn path_and_query(&self) -> Result { - // self.underlying - // .uri() - // .path_and_query() - // .map(|paq| paq.to_string()) - // .ok_or("No path and query provided".to_string()) - // } - - // pub fn path_params(&self) -> HashMap { - // self.path_param_extractors - // .iter() - // .map(|param| match param { - // PathParamExtractor::Single { var_info, index } => ( - // var_info.key_name.clone(), - // self.path_segments[*index].clone(), - // ), - // PathParamExtractor::AllFollowing { var_info, index } => { - // let value = self.path_segments[*index..].join("/"); - // (var_info.key_name.clone(), value) - // } - // }) - // .collect() - // } - - // pub async fn request_body(&mut self) -> Result<&Value, String> { - // self.take_request_body().await?; - // Ok(self.cached_request_body()) - // } - - pub fn cookies(&self) -> HashMap<&str, &str> { - let mut result = HashMap::new(); - - for header_name in COOKIE_HEADER_NAMES.iter() { - if let Some(value) = self.underlying.header(header_name) { - let parts: Vec<&str> = value.split(';').collect(); - for part in parts { - let key_value: Vec<&str> = part.split('=').collect(); - if let (Some(key), Some(value)) = (key_value.first(), key_value.get(1)) { - result.insert(key.trim(), value.trim()); - } - } - } - } - - result - } - - // fn cached_request_body(&self) -> &Value { - // &self.cached_request_body - // } - - // /// Consumes the body of the underlying request, and make it as part of RichRequest as `cached_request_body`. - // /// The following logic is subtle enough that it takes the following into consideration: - // /// 99% of the time, number of separate rib scripts in API definition that needs to look up request body is 1, - // /// and for that rib-script, there will be no extra logic to read the request body in the hot path. - // /// At the same, if by any chance, multiple rib scripts exist (within a request) that require to lookup the request body, `take_request_body` - // /// is idempotent, that it doesn't affect correctness. - // /// We intentionally don't consume the body if its not required in any Rib script. - // async fn take_request_body(&mut self) -> Result<(), String> { - // let body = self.underlying.take_body(); - - // if !body.is_empty() { - // match body.into_json().await { - // Ok(json_request_body) => { - // self.cached_request_body = json_request_body; - // } - // Err(err) => { - // tracing::error!("Failed reading http request body as json: {}", err); - // return Err(format!("Request body parse error: {err}"))?; - // } - // } - // }; - - // Ok(()) - // } -} - -// pub struct SplitResolvedRouteEntryResult { -// pub binding: RichGatewayBindingCompiled, -// pub middlewares: Vec, -// pub rich_request: RichRequest, -// pub account_id: AccountId, -// pub environment_id: EnvironmentId, -// } - -// pub fn split_resolved_route_entry( -// request: poem::Request, -// entry: ResolvedRouteEntry, -// ) -> SplitResolvedRouteEntryResult { -// // helper function to save a few clones - -// let binding = entry.route_entry.binding; -// let middlewares = entry.route_entry.middlewares; -// let account_id = entry.route_entry.account_id; -// let environment_id = entry.route_entry.environment_id; - -// let rich_request = RichRequest { -// underlying: request, -// request_id: Uuid::new_v4(), -// path_segments: entry.path_segments, -// path_param_extractors: entry.route_entry.path_params, -// auth_data: None, -// cached_request_body: Value::Null, -// }; - -// SplitResolvedRouteEntryResult { -// binding, -// middlewares, -// rich_request, -// account_id, -// environment_id, -// } -// } - -// #[derive(Debug, Clone)] -// pub struct RequestQueryValues(pub HashMap); - -// impl RequestQueryValues { -// pub fn from( -// query_key_values: &HashMap, -// query_keys: &[QueryInfo], -// ) -> Result> { -// let mut unavailable_query_variables: Vec = vec![]; -// let mut query_variable_map: HashMap = HashMap::new(); - -// for spec_query_variable in query_keys.iter() { -// let key = &spec_query_variable.key_name; -// if let Some(query_value) = query_key_values.get(key) { -// query_variable_map.insert(key.clone(), query_value.to_string()); -// } else { -// unavailable_query_variables.push(spec_query_variable.to_string()); -// } -// } - -// if unavailable_query_variables.is_empty() { -// Ok(RequestQueryValues(query_variable_map)) -// } else { -// Err(unavailable_query_variables) -// } -// } -// } - -// #[derive(Debug, Clone)] -// pub struct RequestHeaderValues(pub HashMap); - -// impl RequestHeaderValues { -// pub fn from(headers: &HeaderMap) -> Result> { -// let mut headers_map: HashMap = HashMap::new(); - -// for (header_name, header_value) in headers { -// let header_value_str = header_value.to_str().map_err(|err| vec![err.to_string()])?; - -// headers_map.insert(header_name.to_string(), header_value_str.to_string()); -// } - -// Ok(RequestHeaderValues(headers_map)) -// } -// } - -// #[cfg(test)] -// mod tests { -// use super::*; -// use poem::http::Uri; -// use test_r::test; - -// #[test] -// fn test_query_params_with_plus_encoded_spaces() -> anyhow::Result<()> { -// let uri: Uri = "/search?q=hello+world&lang=rust".parse()?; -// let req = poem::Request::builder().uri(uri).finish(); -// let rich_req = RichRequest::new(req); -// let params = rich_req.query_params(); - -// assert_eq!(params.get("q"), Some(&"hello world".to_string())); // '+' decoded to space -// assert_eq!(params.get("lang"), Some(&"rust".to_string())); - -// Ok(()) -// } -// } diff --git a/golem-worker-service/src/gateway_execution/to_response.rs b/golem-worker-service/src/gateway_execution/to_response.rs deleted file mode 100644 index e3a5196eb7..0000000000 --- a/golem-worker-service/src/gateway_execution/to_response.rs +++ /dev/null @@ -1,534 +0,0 @@ -// Copyright 2024-2025 Golem Cloud -// -// Licensed under the Golem Source License v1.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://license.golem.cloud/LICENSE -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use super::auth_call_back_binding_handler::{AuthenticationSuccess, AuthorisationError}; -use super::file_server_binding_handler::FileServerBindingSuccess; -use super::http_handler_binding_handler::{HttpHandlerBindingError, HttpHandlerBindingSuccess}; -use super::{RibInputTypeMismatch, WorkerRequestExecutorError}; -use crate::api::common::ApiEndpointError; -use crate::gateway_execution::file_server_binding_handler::FileServerBindingError; -use crate::gateway_execution::gateway_session_store::GatewaySessionStore; -use crate::gateway_execution::request::RichRequest; -use crate::gateway_execution::to_response_failure::ToHttpResponseFromSafeDisplay; -use crate::model::SwaggerHtml; -use async_trait::async_trait; -use golem_service_base::custom_api::HttpCors; -use http::header::*; -use http::StatusCode; -use poem::Body; -use poem::IntoResponse; -use rib::RibResult; -use std::sync::Arc; - -#[async_trait] -pub trait ToHttpResponse { - async fn to_response( - self, - request: &RichRequest, - session_store: &Arc, - ) -> poem::Response; -} - -#[async_trait] -impl ToHttpResponse for Result { - async fn to_response( - self, - request: &RichRequest, - session_store: &Arc, - ) -> poem::Response { - match self { - Ok(t) => t.to_response(request, session_store).await, - Err(e) => e.to_response(request, session_store).await, - } - } -} - -pub type GatewayHttpResult = Result; - -pub enum GatewayHttpError { - BadRequest(String), - InternalError(String), - RibInputTypeMismatch(RibInputTypeMismatch), - EvaluationError(WorkerRequestExecutorError), - RibInterpretPureError(String), - HttpHandlerBindingError(HttpHandlerBindingError), - FileServerBindingError(FileServerBindingError), - AuthorisationError(AuthorisationError), -} - -#[async_trait] -impl ToHttpResponse for GatewayHttpError { - async fn to_response( - self, - request_details: &RichRequest, - session_store: &Arc, - ) -> poem::Response { - match self { - GatewayHttpError::BadRequest(e) => poem::Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(Body::from_string(e)), - GatewayHttpError::RibInputTypeMismatch(err) => { - err.to_response_from_safe_display(|_| StatusCode::BAD_REQUEST) - } - GatewayHttpError::RibInterpretPureError(err) => poem::Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::from_string(format!( - "Failed interpreting pure rib expression: {err}" - ))), - GatewayHttpError::EvaluationError(err) => { - err.to_response_from_safe_display(|_| StatusCode::INTERNAL_SERVER_ERROR) - } - GatewayHttpError::HttpHandlerBindingError(inner) => { - inner.to_response(request_details, session_store).await - } - GatewayHttpError::FileServerBindingError(inner) => { - inner.to_response(request_details, session_store).await - } - GatewayHttpError::AuthorisationError(inner) => { - inner.to_response(request_details, session_store).await - } - GatewayHttpError::InternalError(e) => poem::Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::from_string(e)), - } - } -} - -#[async_trait] -impl ToHttpResponse for FileServerBindingSuccess { - async fn to_response( - self, - _request_details: &RichRequest, - _session_store: &Arc, - ) -> poem::Response { - Body::from_bytes_stream(self.data) - .with_content_type(self.binding_details.content_type.to_string()) - .with_status(self.binding_details.status_code) - .into_response() - } -} - -#[async_trait] -impl ToHttpResponse for FileServerBindingError { - async fn to_response( - self, - _request_details: &RichRequest, - _session_store: &Arc, - ) -> poem::Response { - match self { - FileServerBindingError::InternalError(e) => poem::Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::from_string(format!("Error {e}"))), - FileServerBindingError::ComponentServiceError(inner) => { - ApiEndpointError::from(inner).into_response() - } - FileServerBindingError::WorkerServiceError(inner) => { - ApiEndpointError::from(inner).into_response() - } - FileServerBindingError::InvalidRibResult(e) => poem::Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(Body::from_string(format!( - "Error while processing rib result: {e}" - ))), - } - } -} - -#[async_trait] -impl ToHttpResponse for HttpHandlerBindingSuccess { - async fn to_response( - self, - _request_details: &RichRequest, - _session_store: &Arc, - ) -> poem::Response { - self.response - } -} - -#[async_trait] -impl ToHttpResponse for HttpHandlerBindingError { - async fn to_response( - self, - _request_details: &RichRequest, - _session_store: &Arc, - ) -> poem::Response { - match self { - HttpHandlerBindingError::InternalError(e) => poem::Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::from_string(format!("Error {e}"))), - HttpHandlerBindingError::WorkerRequestExecutorError(e) => poem::Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::from_string(format!( - "Error calling worker executor {e}" - ))), - } - } -} - -// Preflight (OPTIONS) response that will consist of all configured CORS headers -#[async_trait] -impl ToHttpResponse for HttpCors { - async fn to_response( - self, - _request_details: &RichRequest, - _session_store: &Arc, - ) -> poem::Response { - let mut response = poem::Response::builder().status(StatusCode::OK).finish(); - - // TODO: should not unwrap here - response.headers_mut().insert( - ACCESS_CONTROL_ALLOW_ORIGIN, - self.allow_origin.clone().parse().unwrap(), - ); - response.headers_mut().insert( - ACCESS_CONTROL_ALLOW_METHODS, - self.allow_methods.clone().parse().unwrap(), - ); - response.headers_mut().insert( - ACCESS_CONTROL_ALLOW_HEADERS, - self.allow_headers.clone().parse().unwrap(), - ); - - if let Some(expose_headers) = &self.expose_headers { - response.headers_mut().insert( - ACCESS_CONTROL_EXPOSE_HEADERS, - expose_headers.clone().parse().unwrap(), - ); - } - - if let Some(allow_credentials) = self.allow_credentials { - response.headers_mut().insert( - ACCESS_CONTROL_ALLOW_CREDENTIALS, - allow_credentials.to_string().parse().unwrap(), - ); - } - - if let Some(max_age) = self.max_age { - response - .headers_mut() - .insert(ACCESS_CONTROL_MAX_AGE, max_age.to_string().parse().unwrap()); - } - - response - } -} - -#[async_trait] -impl ToHttpResponse for RibResult { - async fn to_response( - self, - request_details: &RichRequest, - _session_store: &Arc, - ) -> poem::Response { - match internal::IntermediateRibResultHttpResponse::from(&self) { - Ok(intermediate_response) => intermediate_response.to_http_response(request_details), - Err(e) => e.to_response_from_safe_display(|_| StatusCode::INTERNAL_SERVER_ERROR), - } - } -} - -#[async_trait] -impl ToHttpResponse for AuthenticationSuccess { - async fn to_response( - self, - _request_details: &RichRequest, - _session_store: &Arc, - ) -> poem::Response { - let access_token = self.access_token; - let id_token = self.id_token; - let session_id = self.session; - - let mut response = poem::Response::builder() - .status(StatusCode::FOUND) - .header("Location", self.target_path) - .header( - "Set-Cookie", - format!("access_token={access_token}; HttpOnly; Secure; Path=/; SameSite=None") - .as_str(), - ); - - if let Some(id_token) = id_token { - response = response.header( - "Set-Cookie", - format!("id_token={id_token}; HttpOnly; Secure; Path=/; SameSite=None").as_str(), - ) - } - - response = response.header( - "Set-Cookie", - format!("session_id={session_id}; HttpOnly; Secure; Path=/; SameSite=None").as_str(), - ); - - response.body(()) - } -} - -#[async_trait] -impl ToHttpResponse for AuthorisationError { - async fn to_response( - self, - _request_details: &RichRequest, - _session_store: &Arc, - ) -> poem::Response { - self.to_response_from_safe_display(|_| StatusCode::UNAUTHORIZED) - } -} - -#[async_trait] -impl ToHttpResponse for SwaggerHtml { - async fn to_response( - self, - _request_details: &RichRequest, - _session_store: &Arc, - ) -> poem::Response { - poem::Response::builder() - .content_type("text/html") - .body(Body::from_string(self.0)) - } -} - -mod internal { - use crate::gateway_execution::http_content_type_mapper::{ - ContentTypeHeaders, HttpContentTypeResponseMapper, - }; - use crate::gateway_execution::request::RichRequest; - use http::StatusCode; - - use crate::getter::{get_response_headers_or_default, get_status_code_or_ok, GetterExt}; - use crate::path::Path; - - use crate::gateway_execution::WorkerRequestExecutorError; - use crate::headers::ResolvedResponseHeaders; - use golem_wasm::ValueAndType; - use poem::{Body, IntoResponse, ResponseParts}; - use rib::RibResult; - - #[derive(Debug)] - pub(crate) struct IntermediateRibResultHttpResponse { - body: Option, - status: StatusCode, - headers: ResolvedResponseHeaders, - } - - impl IntermediateRibResultHttpResponse { - pub(crate) fn from( - evaluation_result: &RibResult, - ) -> Result { - match evaluation_result { - RibResult::Val(rib_result) => { - let status = - get_status_code_or_ok(rib_result).map_err(WorkerRequestExecutorError)?; - - let headers = get_response_headers_or_default(rib_result) - .map_err(WorkerRequestExecutorError)?; - - let body = rib_result - .get_optional(&Path::from_key("body")) - .unwrap_or(rib_result.clone()); - - Ok(IntermediateRibResultHttpResponse { - body: Some(body), - status, - headers, - }) - } - RibResult::Unit => Ok(IntermediateRibResultHttpResponse { - body: None, - status: StatusCode::default(), - headers: ResolvedResponseHeaders::default(), - }), - } - } - - pub(crate) fn to_http_response(&self, request_details: &RichRequest) -> poem::Response { - let response_content_type = self.headers.get_content_type(); - let response_headers = self.headers.headers.clone(); - - let status = &self.status; - let evaluation_result = &self.body; - - let accepted_content_types = request_details - .underlying - .header(http::header::ACCEPT) - .map(|s| s.to_string()); - - let content_type = - ContentTypeHeaders::from(response_content_type, accepted_content_types); - - let response = match evaluation_result { - Some(type_annotated_value) => { - match type_annotated_value.to_http_resp_with_content_type(content_type) { - Ok(body_with_header) => { - let mut response = body_with_header.into_response(); - response.set_status(*status); - response.headers_mut().extend(response_headers); - response - } - Err(content_map_error) => poem::Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(Body::from_string(content_map_error.to_string())), - } - } - None => { - let parts = ResponseParts { - status: *status, - version: Default::default(), - headers: response_headers, - extensions: Default::default(), - }; - - poem::Response::from_parts(parts, Body::empty()) - } - }; - - response - } - } -} - -#[cfg(test)] -mod test { - use async_trait::async_trait; - use std::sync::Arc; - use test_r::test; - - use crate::gateway_execution::gateway_session_store::{ - DataKey, DataValue, GatewaySessionError, GatewaySessionStore, SessionId, - }; - use crate::gateway_execution::request::RichRequest; - use crate::gateway_execution::to_response::ToHttpResponse; - use golem_wasm::analysis::analysed_type::record; - use golem_wasm::analysis::NameTypePair; - use golem_wasm::{IntoValueAndType, Value, ValueAndType}; - use http::header::CONTENT_TYPE; - use http::StatusCode; - use rib::RibResult; - - fn create_record(values: Vec<(String, ValueAndType)>) -> ValueAndType { - let mut fields = vec![]; - let mut field_values = vec![]; - - for (key, vnt) in values { - fields.push(NameTypePair { - name: key, - typ: vnt.typ, - }); - field_values.push(vnt.value); - } - - ValueAndType { - value: Value::Record(field_values), - typ: record(fields), - } - } - - fn test_request() -> RichRequest { - RichRequest::new(poem::Request::default()) - } - - #[test] - async fn test_evaluation_result_to_response_with_http_specifics() { - let record = create_record(vec![ - ("status".to_string(), 400u16.into_value_and_type()), - ( - "headers".to_string(), - create_record(vec![( - "Content-Type".to_string(), - "application/json".into_value_and_type(), - )]), - ), - ("body".to_string(), "Hello".into_value_and_type()), - ]); - - let evaluation_result: RibResult = RibResult::Val(record); - - let session_store: Arc = Arc::new(TestSessionStore); - - let http_response: poem::Response = evaluation_result - .to_response(&test_request(), &session_store) - .await; - - let (response_parts, body) = http_response.into_parts(); - let body = body.into_string().await.unwrap(); - let headers = response_parts.headers; - let status = response_parts.status; - - let expected_body = "\"Hello\""; - let expected_headers = poem::web::headers::HeaderMap::from_iter(vec![( - CONTENT_TYPE, - "application/json".parse().unwrap(), - )]); - - let expected_status = StatusCode::BAD_REQUEST; - - assert_eq!(body, expected_body); - assert_eq!(headers.clone(), expected_headers); - assert_eq!(status, expected_status); - } - - #[test] - async fn test_evaluation_result_to_response_with_no_http_specifics() { - let evaluation_result: RibResult = RibResult::Val("Healthy".into_value_and_type()); - - let session_store: Arc = Arc::new(TestSessionStore); - - let http_response: poem::Response = evaluation_result - .to_response(&test_request(), &session_store) - .await; - - let (response_parts, body) = http_response.into_parts(); - let body = body.into_string().await.unwrap(); - let headers = response_parts.headers; - let status = response_parts.status; - - let expected_body = "Healthy"; - - // Deault content response is application/json. Refer HttpResponse - let expected_headers = poem::web::headers::HeaderMap::from_iter(vec![( - CONTENT_TYPE, - "application/json".parse().unwrap(), - )]); - let expected_status = StatusCode::OK; - - assert_eq!(body, expected_body); - assert_eq!(headers.clone(), expected_headers); - assert_eq!(status, expected_status); - } - - struct TestSessionStore; - - #[async_trait] - impl GatewaySessionStore for TestSessionStore { - async fn insert( - &self, - _session_id: SessionId, - _data_key: DataKey, - _data_value: DataValue, - ) -> Result<(), GatewaySessionError> { - Err(GatewaySessionError::InternalError( - "unimplemented".to_string(), - )) - } - - async fn get( - &self, - _session_id: &SessionId, - _data_key: &DataKey, - ) -> Result { - Err(GatewaySessionError::InternalError( - "unimplemented".to_string(), - )) - } - } -} diff --git a/golem-worker-service/src/gateway_execution/to_response_failure.rs b/golem-worker-service/src/gateway_execution/to_response_failure.rs deleted file mode 100644 index d48e078aba..0000000000 --- a/golem-worker-service/src/gateway_execution/to_response_failure.rs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2024-2025 Golem Cloud -// -// Licensed under the Golem Source License v1.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://license.golem.cloud/LICENSE -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use golem_common::SafeDisplay; -use http::StatusCode; -use poem::Body; - -pub trait ToHttpResponseFromSafeDisplay { - fn to_response_from_safe_display(&self, get_status_code: F) -> poem::Response - where - Self: SafeDisplay, - F: Fn(&Self) -> StatusCode, - Self: Sized; -} - -// Only SafeDisplay'd errors are allowed to be embedded in any output response -impl ToHttpResponseFromSafeDisplay for E { - fn to_response_from_safe_display(&self, get_status_code: F) -> poem::Response - where - F: Fn(&Self) -> StatusCode, - Self: Sized, - { - poem::Response::builder() - .status(get_status_code(self)) - .body(Body::from_string(self.to_safe_string())) - } -} diff --git a/golem-worker-service/src/gateway_middleware/auth.rs b/golem-worker-service/src/gateway_middleware/auth.rs deleted file mode 100644 index 4a741e279a..0000000000 --- a/golem-worker-service/src/gateway_middleware/auth.rs +++ /dev/null @@ -1,246 +0,0 @@ -// Copyright 2024-2025 Golem Cloud -// -// Licensed under the Golem Source License v1.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://license.golem.cloud/LICENSE -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use super::{MiddlewareError, MiddlewareSuccess}; -use crate::gateway_execution::auth_call_back_binding_handler::AuthorisationError; -use crate::gateway_execution::gateway_session_store::{ - DataKey, DataValue, GatewaySessionError, GatewaySessionStore, SessionId, -}; -use crate::gateway_execution::request::RichRequest; -use crate::gateway_security::{IdentityProvider, OpenIdClient}; -use golem_common::SafeDisplay; -use golem_service_base::custom_api::SecuritySchemeDetails; -use http::StatusCode; -use openidconnect::core::{CoreIdToken, CoreIdTokenClaims, CoreIdTokenVerifier}; -use openidconnect::{ClaimsVerificationError, Nonce}; -use std::str::FromStr; -use std::sync::Arc; -use tracing::{debug, error}; - -pub async fn apply_http_auth( - security_scheme: &SecuritySchemeDetails, - input: &RichRequest, - session_store: &Arc, - identity_provider: &Arc, -) -> Result { - let open_id_client = identity_provider - .get_client(security_scheme) - .await - .map_err(|err| { - MiddlewareError::Unauthorized(AuthorisationError::IdentityProviderError(err)) - })?; - - let identity_token_verifier = open_id_client.id_token_verifier(); - - let cookie_values = input.get_cookie_values(); - - let id_token = cookie_values.get("id_token"); - let state = cookie_values.get("session_id"); - - if let (Some(id_token), Some(state)) = (id_token, state) { - get_session_details_or_redirect( - state, - identity_token_verifier, - id_token, - session_store, - input, - identity_provider, - &open_id_client, - security_scheme, - ) - .await - } else { - redirect( - session_store, - input, - identity_provider, - &open_id_client, - security_scheme, - ) - .await - } -} - -async fn get_session_details_or_redirect( - state_from_request: &str, - identity_token_verifier: CoreIdTokenVerifier<'_>, - id_token: &str, - session_store: &Arc, - input: &RichRequest, - identity_provider: &Arc, - open_id_client: &OpenIdClient, - security_scheme: &SecuritySchemeDetails, -) -> Result { - let session_id = SessionId(state_from_request.to_string()); - - let nonce_from_session = session_store.get(&session_id, &DataKey::nonce()).await; - - match nonce_from_session { - Ok(nonce) => { - let id_token = CoreIdToken::from_str(id_token).map_err(|err| { - debug!( - "Failed to parse id token for session {}: {}", - err, session_id.0 - ); - MiddlewareError::Unauthorized(AuthorisationError::InvalidToken) - })?; - - get_claims( - &nonce, - id_token, - identity_token_verifier, - &session_id, - session_store, - input, - identity_provider, - open_id_client, - security_scheme, - ) - .await - } - Err(GatewaySessionError::MissingValue { .. }) => { - redirect( - session_store, - input, - identity_provider, - open_id_client, - security_scheme, - ) - .await - } - Err(err) => { - debug!( - "Failed to get nonce from session store: {:?} for session {}", - err, session_id.0 - ); - Err(MiddlewareError::Unauthorized( - AuthorisationError::SessionError(err), - )) - } - } -} - -async fn get_claims( - nonce: &DataValue, - id_token: CoreIdToken, - identity_token_verifier: CoreIdTokenVerifier<'_>, - session_id: &SessionId, - session_store: &Arc, - input: &RichRequest, - identity_provider: &Arc, - open_id_client: &OpenIdClient, - security_scheme: &SecuritySchemeDetails, -) -> Result { - if let Some(nonce) = nonce.as_string() { - let token_claims_result: Result<&CoreIdTokenClaims, ClaimsVerificationError> = - id_token.claims(&identity_token_verifier, &Nonce::new(nonce)); - - match token_claims_result { - Ok(claims) => { - store_claims_in_session_store(session_id, claims, session_store).await?; - - Ok(MiddlewareSuccess::PassThrough { - session_id: Some(session_id.clone()), - }) - } - Err(ClaimsVerificationError::Expired(_)) => { - redirect( - session_store, - input, - identity_provider, - open_id_client, - security_scheme, - ) - .await - } - Err(claims_verification_error) => { - error!("Invalid token for session {}", claims_verification_error); - - Err(MiddlewareError::Unauthorized( - AuthorisationError::InvalidToken, - )) - } - } - } else { - Err(MiddlewareError::Unauthorized( - AuthorisationError::InvalidNonce, - )) - } -} - -async fn redirect( - session_store: &Arc, - input: &RichRequest, - identity_provider: &Arc, - client: &OpenIdClient, - security_scheme: &SecuritySchemeDetails, -) -> Result { - let redirect_uri = input - .underlying - .uri() - .path_and_query() - .ok_or(MiddlewareError::InternalError( - "Failed to get redirect uri".to_string(), - ))? - .to_string(); - - let authorization = - identity_provider.get_authorization_url(client, security_scheme.scopes.clone(), None, None); - - let state = authorization.csrf_state.secret(); - - let session_id = SessionId(state.clone()); - let nonce_data_key = DataKey::nonce(); - let nonce_data_value = DataValue(serde_json::Value::String( - authorization.nonce.secret().clone(), - )); - - let redirect_url_data_key = DataKey::redirect_url(); - - let redirect_url_data_value = DataValue(serde_json::Value::String(redirect_uri)); - - session_store - .insert(session_id.clone(), nonce_data_key, nonce_data_value) - .await - .map_err(|err| MiddlewareError::Unauthorized(AuthorisationError::SessionError(err)))?; - session_store - .insert(session_id, redirect_url_data_key, redirect_url_data_value) - .await - .map_err(|err| MiddlewareError::Unauthorized(AuthorisationError::SessionError(err)))?; - - let response = poem::Response::builder(); - let result = response - .header("Location", authorization.url.to_string()) - .status(StatusCode::FOUND) - .body(()); - - Ok(MiddlewareSuccess::Redirect(result)) -} - -async fn store_claims_in_session_store( - session_id: &SessionId, - claims: &CoreIdTokenClaims, - session_store: &Arc, -) -> Result<(), MiddlewareError> { - let claims_data_key = DataKey::claims(); - let json = serde_json::to_value(claims) - .map_err(|err| MiddlewareError::InternalError(err.to_string()))?; - - let claims_data_value = DataValue(json); - - session_store - .insert(session_id.clone(), claims_data_key, claims_data_value) - .await - .map_err(|err| MiddlewareError::InternalError(err.to_safe_string())) -} diff --git a/golem-worker-service/src/gateway_middleware/cors.rs b/golem-worker-service/src/gateway_middleware/cors.rs deleted file mode 100644 index 598743a2a6..0000000000 --- a/golem-worker-service/src/gateway_middleware/cors.rs +++ /dev/null @@ -1,156 +0,0 @@ -// Copyright 2024-2025 Golem Cloud -// -// Licensed under the Golem Source License v1.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://license.golem.cloud/LICENSE -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use crate::gateway_execution::request::RichRequest; -use golem_service_base::custom_api::HttpCors; -use http::{HeaderValue, Method}; - -#[derive(Debug)] -#[allow(clippy::enum_variant_names)] -pub enum CorsError { - OriginNotAllowed, - MethodNotAllowed, - HeadersNotAllowed, -} - -pub fn apply_cors(cors: &HttpCors, request: &RichRequest) -> Result<(), CorsError> { - let origin = match request.headers().get(http::header::ORIGIN) { - Some(origin) => origin.clone(), - None => return Ok(()), - }; - - if let OriginStatus::NotAllowed = check_origin(cors, &origin) { - return Err(CorsError::OriginNotAllowed); - } - - if request.underlying.method() == Method::OPTIONS { - let allow_method = request - .headers() - .get(http::header::ACCESS_CONTROL_REQUEST_METHOD) - .and_then(|val| val.to_str().ok()) - .and_then(|m| m.parse::().ok()); - - if let Some(method) = allow_method { - if !cors.allow_methods.trim().is_empty() - && !split_origin(&cors.allow_methods) - .any(|m| m.eq_ignore_ascii_case(method.as_str())) - { - return Err(CorsError::MethodNotAllowed); - } - } else { - return Err(CorsError::MethodNotAllowed); - } - - check_headers_allowed(cors, request)?; - } - - Ok(()) -} - -pub fn add_cors_headers_to_response(cors: &HttpCors, response: &mut poem::Response) { - response.headers_mut().insert( - http::header::ACCESS_CONTROL_ALLOW_ORIGIN, - cors.allow_origin.clone().parse().unwrap(), - ); - - if let Some(allow_credentials) = &cors.allow_credentials { - response.headers_mut().insert( - http::header::ACCESS_CONTROL_ALLOW_CREDENTIALS, - allow_credentials.to_string().clone().parse().unwrap(), - ); - } - - if let Some(expose_headers) = &cors.expose_headers { - response.headers_mut().insert( - http::header::ACCESS_CONTROL_EXPOSE_HEADERS, - expose_headers.clone().parse().unwrap(), - ); - } -} - -fn split_origin(input: &str) -> impl Iterator { - input.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) -} - -enum OriginStatus { - AllowedExact, - AllowedWildcard, - NotAllowed, -} - -fn check_origin(cors: &HttpCors, origin: &HeaderValue) -> OriginStatus { - let origin_str = match origin.to_str() { - Ok(s) => s, - Err(_) => return OriginStatus::NotAllowed, - }; - - if split_origin(&cors.allow_origin).any(|o| o == origin_str) { - return OriginStatus::AllowedExact; - } - - if split_origin(&cors.allow_origin) - .any(|pattern| pattern.contains('*') && wildcard_match(pattern, origin_str)) - { - return OriginStatus::AllowedWildcard; - } - - OriginStatus::NotAllowed -} - -fn wildcard_match(pattern: &str, text: &str) -> bool { - if !pattern.contains('*') { - return pattern == text; - } - - let parts: Vec<&str> = pattern.split('*').collect(); - if parts.len() == 2 { - text.starts_with(parts[0]) && text.ends_with(parts[1]) - } else { - false - } -} - -fn check_headers_allowed<'a>( - cors: &HttpCors, - req: &'a RichRequest, -) -> Result, CorsError> { - let request_headers = req - .headers() - .get(http::header::ACCESS_CONTROL_REQUEST_HEADERS); - - if let Some(headers_value) = request_headers { - let allow_list: Vec<_> = split_origin(&cors.allow_headers).collect(); - if allow_list.is_empty() { - return Ok(Some(headers_value)); - } - - let header_str = headers_value - .to_str() - .map_err(|_| CorsError::HeadersNotAllowed)?; - - let all_allowed = split_origin(header_str).all(|h| { - allow_list - .iter() - .any(|&allowed| allowed.eq_ignore_ascii_case(h)) - }); - - if !all_allowed { - return Err(CorsError::HeadersNotAllowed); - } - - Ok(Some(headers_value)) - } else { - Ok(None) - } -} diff --git a/golem-worker-service/src/gateway_middleware/mod.rs b/golem-worker-service/src/gateway_middleware/mod.rs deleted file mode 100644 index 4d0a822ea6..0000000000 --- a/golem-worker-service/src/gateway_middleware/mod.rs +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright 2024-2025 Golem Cloud -// -// Licensed under the Golem Source License v1.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://license.golem.cloud/LICENSE -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -mod auth; -mod cors; - -use self::auth::apply_http_auth; -use self::cors::{add_cors_headers_to_response, apply_cors, CorsError}; -use crate::gateway_execution::auth_call_back_binding_handler::AuthorisationError; -use crate::gateway_execution::gateway_session_store::{GatewaySessionStore, SessionId}; -use crate::gateway_execution::request::RichRequest; -use crate::gateway_security::IdentityProvider; -use crate::model::HttpMiddleware; -use golem_common::SafeDisplay; -use std::sync::Arc; - -pub enum MiddlewareSuccess { - PassThrough { session_id: Option }, - Redirect(poem::Response), -} - -#[derive(Debug)] -pub enum MiddlewareError { - Unauthorized(AuthorisationError), - CorsError(CorsError), - InternalError(String), -} - -impl SafeDisplay for MiddlewareError { - fn to_safe_string(&self) -> String { - match self { - MiddlewareError::Unauthorized(msg) => format!("Unauthorized: {}", msg.to_safe_string()), - MiddlewareError::CorsError(error) => match error { - CorsError::OriginNotAllowed => "CORS Error: Origin not allowed".to_string(), - CorsError::MethodNotAllowed => "CORS Error: Method not allowed".to_string(), - CorsError::HeadersNotAllowed => "CORS Error: Headers not allowed".to_string(), - }, - MiddlewareError::InternalError(msg) => { - format!("Internal Server Error: {msg}") - } - } - } -} - -pub async fn process_middleware_in( - middlewares: &Vec, - rich_request: &RichRequest, - session_store: &Arc, - identity_provider: &Arc, -) -> Result { - let mut final_session_id = None; - - for middleware in middlewares { - match middleware { - HttpMiddleware::Cors(cors) => { - apply_cors(cors, rich_request).map_err(MiddlewareError::CorsError)?; - } - HttpMiddleware::AuthenticateRequest(auth) => { - let result = - apply_http_auth(auth, rich_request, session_store, identity_provider).await?; - - match result { - MiddlewareSuccess::Redirect(response) => { - return Ok(MiddlewareSuccess::Redirect(response)) - } - MiddlewareSuccess::PassThrough { session_id } => { - final_session_id = session_id; - } - } - } - } - } - - Ok(MiddlewareSuccess::PassThrough { - session_id: final_session_id, - }) -} - -pub async fn process_middleware_out( - middlewares: &Vec, - response: &mut poem::Response, -) -> Result<(), MiddlewareError> { - for middleware in middlewares { - match middleware { - HttpMiddleware::Cors(cors) => { - add_cors_headers_to_response(cors, response); - } - HttpMiddleware::AuthenticateRequest(_) => {} - } - } - - Ok(()) -} diff --git a/golem-worker-service/src/lib.rs b/golem-worker-service/src/lib.rs index 42b6e003ed..5e26c75efc 100644 --- a/golem-worker-service/src/lib.rs +++ b/golem-worker-service/src/lib.rs @@ -14,9 +14,7 @@ pub mod api; pub mod config; -pub mod gateway_execution; -// pub mod gateway_middleware; -pub mod gateway_router; +pub mod custom_api; // pub mod gateway_security; pub mod getter; pub mod grpcapi; @@ -180,7 +178,7 @@ impl WorkerService { tracer: Option, ) -> Result { let route = Route::new() - .nest("/", api::custom_http_request_api(&self.services)) + .nest("/", api::custom_api_api(&self.services)) .with(OpenTelemetryMetrics::new()) .with_if_lazy(tracer.is_some(), || { OpenTelemetryTracing::new(tracer.unwrap()) diff --git a/golem-worker-service/src/model.rs b/golem-worker-service/src/model.rs index 2ebaac80d9..0e656e7790 100644 --- a/golem-worker-service/src/model.rs +++ b/golem-worker-service/src/model.rs @@ -17,104 +17,11 @@ use golem_common::model::worker::WorkerMetadataDto; use golem_common::model::ScanCursor; // use golem_service_base::custom_api::HttpCors; -use golem_common::model::account::AccountId; -use golem_common::model::environment::EnvironmentId; -use golem_service_base::custom_api::{CorsOptions, SecuritySchemeDetails}; -use golem_service_base::custom_api::{PathSegment, RequestBodySchema, RouteBehaviour, RouteId}; -use http::Method; use poem_openapi::Object; use std::fmt::Debug; -use std::sync::Arc; #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, Object)] pub struct WorkersMetadataResponse { pub workers: Vec, pub cursor: Option, } - -// #[derive(Debug, Clone)] -// pub enum HttpMiddleware { -// Cors(HttpCors), -// AuthenticateRequest(SecuritySchemeDetails), -// } - -#[derive(Debug, Clone)] -pub struct SwaggerHtml(pub String); - -#[derive(Debug, Clone)] -pub struct SwaggerUiBinding { - pub swagger_html: Arc, -} - -#[derive(Debug)] -pub struct RichCompiledRoute { - pub account_id: AccountId, - pub environment_id: EnvironmentId, - pub route_id: RouteId, - pub method: Method, - pub path: Vec, - pub body: RequestBodySchema, - pub behavior: RouteBehaviour, - pub security_scheme: Option>, - pub cors: CorsOptions, -} - -// #[derive(Debug, Clone)] -// pub enum RichGatewayBindingCompiled { -// HttpCorsPreflight(Box), -// HttpAuthCallBack(Box), -// Worker(Box), -// FileServer(Box), -// HttpHandler(Box), -// SwaggerUi(SwaggerUiBinding), -// } - -// impl RichGatewayBindingCompiled { -// pub fn from_compiled_binding( -// binding: RouteBehaviour, -// precomputed_swagger_ui_htmls: &HashMap>, -// ) -> Result { -// match binding { -// RouteBehaviour::FileServer(inner) => { -// Ok(RichGatewayBindingCompiled::FileServer(inner)) -// } -// RouteBehaviour::HttpCorsPreflight(inner) => { -// Ok(RichGatewayBindingCompiled::HttpCorsPreflight(inner)) -// } -// RouteBehaviour::Worker(inner) => Ok(RichGatewayBindingCompiled::Worker(inner)), -// RouteBehaviour::HttpHandler(inner) => { -// Ok(RichGatewayBindingCompiled::HttpHandler(inner)) -// } -// RouteBehaviour::SwaggerUi(inner) => { -// let swagger_html = precomputed_swagger_ui_htmls -// .get(&inner.http_api_definition_id) -// .ok_or("no precomputed swagger html".to_string())? -// .clone(); -// Ok(RichGatewayBindingCompiled::SwaggerUi(SwaggerUiBinding { -// swagger_html, -// })) -// } -// } -// } -// } - -// #[derive(Debug, Clone)] -// pub struct RichCompiledRoute { -// pub account_id: AccountId, -// pub environment_id: EnvironmentId, -// pub method: RouteMethod, -// pub path: AllPathPatterns, -// pub binding: RichGatewayBindingCompiled, -// pub middlewares: Vec, -// } - -// impl RichCompiledRoute { -// pub fn get_security_middleware(&self) -> Option { -// for middleware in &self.middlewares { -// if let HttpMiddleware::AuthenticateRequest(security) = middleware { -// return Some(security.clone()); -// } -// } -// None -// } -// } diff --git a/golem-worker-service/src/service/mod.rs b/golem-worker-service/src/service/mod.rs index ce6c293c8e..c9fa64e952 100644 --- a/golem-worker-service/src/service/mod.rs +++ b/golem-worker-service/src/service/mod.rs @@ -38,11 +38,11 @@ use crate::config::WorkerServiceConfig; // use crate::gateway_execution::route_resolver::RouteResolver; // use crate::gateway_execution::GatewayWorkerRequestExecutor; // use crate::gateway_security::DefaultIdentityProvider; -use crate::gateway_execution::api_definition_lookup::{ +use crate::custom_api::api_definition_lookup::{ HttpApiDefinitionsLookup, RegistryServiceApiDefinitionsLookup, }; -use crate::gateway_execution::request_handler::RequestHandler; -use crate::gateway_execution::route_resolver::RouteResolver; +use crate::custom_api::request_handler::RequestHandler; +use crate::custom_api::route_resolver::RouteResolver; use crate::service::component::ComponentService; use crate::service::worker::{AgentsService, WorkerClient, WorkerExecutorWorkerClient}; use golem_api_grpc::proto::golem::workerexecutor::v1::worker_executor_client::WorkerExecutorClient; From b0052dc59ba9a57b628c5e27fbd20f481160294a Mon Sep 17 00:00:00 2001 From: Maxim Schuwalow Date: Mon, 26 Jan 2026 17:42:21 +0100 Subject: [PATCH 3/5] more cleanups --- golem-worker-service/src/api/mod.rs | 6 - golem-worker-service/src/custom_api/mod.rs | 107 +-------- .../poem_endpoint.rs} | 11 +- golem-worker-service/src/getter.rs | 221 ------------------ golem-worker-service/src/headers.rs | 115 --------- .../src/http_invocation_context.rs | 71 ------ golem-worker-service/src/lib.rs | 7 +- golem-worker-service/src/swagger_ui.rs | 75 ------ 8 files changed, 12 insertions(+), 601 deletions(-) rename golem-worker-service/src/{api/custom_api.rs => custom_api/poem_endpoint.rs} (94%) delete mode 100644 golem-worker-service/src/getter.rs delete mode 100644 golem-worker-service/src/headers.rs delete mode 100644 golem-worker-service/src/http_invocation_context.rs delete mode 100644 golem-worker-service/src/swagger_ui.rs diff --git a/golem-worker-service/src/api/mod.rs b/golem-worker-service/src/api/mod.rs index 2d6e01c61f..0e641fa0a1 100644 --- a/golem-worker-service/src/api/mod.rs +++ b/golem-worker-service/src/api/mod.rs @@ -14,10 +14,8 @@ pub mod agents; pub mod common; -mod custom_api; mod worker; -use self::custom_api::CustomApiApi; use crate::api::agents::AgentsApi; use crate::api::worker::WorkerApi; use crate::service::Services; @@ -44,7 +42,3 @@ pub fn make_open_api_service(services: &Services) -> OpenApiService { "1.0", ) } - -pub fn custom_api_api(services: &Services) -> CustomApiApi { - CustomApiApi::new(services.request_handler.clone()) -} diff --git a/golem-worker-service/src/custom_api/mod.rs b/golem-worker-service/src/custom_api/mod.rs index 564ea072f8..50b265d60e 100644 --- a/golem-worker-service/src/custom_api/mod.rs +++ b/golem-worker-service/src/custom_api/mod.rs @@ -12,116 +12,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -pub mod api_definition_lookup; -// pub mod auth_call_back_binding_handler; -// pub mod file_server_binding_handler; -// pub mod gateway_http_input_executor; -// pub mod gateway_session_store; -// mod gateway_worker_request_executor; -// mod http_content_type_mapper; -// pub mod http_handler_binding_handler; mod agent_response_mapping; +pub mod api_definition_lookup; pub mod model; mod parameter_parsing; +pub mod poem_endpoint; pub mod request; pub mod request_handler; pub mod route_resolver; pub mod router; pub mod security; +use self::poem_endpoint::CustomApiPoemEndpoint; +use crate::service::Services; pub use model::*; -use golem_common::model::component::{ComponentId, ComponentRevision}; -use golem_common::model::invocation_context::InvocationContextStack; -use golem_common::model::IdempotencyKey; -use golem_common::SafeDisplay; -use golem_wasm::json::ValueAndTypeJsonExtensions; -use golem_wasm::ValueAndType; -use rib::{RibInput, RibInputTypeInfo}; -use serde_json::Value; -use std::collections::HashMap; -use std::fmt::Display; - -#[derive(PartialEq, Debug, Clone)] -pub struct GatewayResolvedWorkerRequest { - pub component_id: ComponentId, - pub component_revision: ComponentRevision, - pub worker_name: String, - pub function_name: String, - pub function_params: Vec, - pub idempotency_key: Option, - pub invocation_context: InvocationContextStack, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct WorkerDetails { - pub component_id: ComponentId, - pub component_revision: ComponentRevision, - pub worker_name: Option, - pub idempotency_key: Option, - pub invocation_context: InvocationContextStack, -} - -impl WorkerDetails { - fn as_json(&self) -> Value { - let mut worker_detail_content = HashMap::new(); - worker_detail_content.insert( - "component_id".to_string(), - Value::String(self.component_id.0.to_string()), - ); - - if let Some(worker_name) = &self.worker_name { - worker_detail_content - .insert("name".to_string(), Value::String(worker_name.to_string())); - } - - if let Some(idempotency_key) = &self.idempotency_key { - worker_detail_content.insert( - "idempotency_key".to_string(), - Value::String(idempotency_key.value.clone()), - ); - } - - let map = serde_json::Map::from_iter(worker_detail_content); - - Value::Object(map) - } - - pub fn resolve_rib_input_value( - &self, - required_types: &RibInputTypeInfo, - ) -> Result { - let request_type_info = required_types.types.get("worker"); - - match request_type_info { - Some(worker_details_type) => { - let rib_input_with_request_content = &self.as_json(); - let request_value = - ValueAndType::parse_with_type(rib_input_with_request_content, worker_details_type) - .map_err(|err| RibInputTypeMismatch(format!("Worker details don't match the requirements for rib expression to execute: {}. Requirements. {:?}", err.join(", "), worker_details_type)))?; - - let mut rib_input_map = HashMap::new(); - rib_input_map.insert("worker".to_string(), request_value); - Ok(RibInput { - input: rib_input_map, - }) - } - None => Ok(RibInput::default()), - } - } -} - -#[derive(Debug)] -pub struct RibInputTypeMismatch(pub String); - -impl Display for RibInputTypeMismatch { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "Rib input type mismatch: {}", self.0) - } -} - -impl SafeDisplay for RibInputTypeMismatch { - fn to_safe_string(&self) -> String { - self.0.clone() - } +pub fn make_custom_api_endpoint(services: &Services) -> CustomApiPoemEndpoint { + CustomApiPoemEndpoint::new(services.request_handler.clone()) } diff --git a/golem-worker-service/src/api/custom_api.rs b/golem-worker-service/src/custom_api/poem_endpoint.rs similarity index 94% rename from golem-worker-service/src/api/custom_api.rs rename to golem-worker-service/src/custom_api/poem_endpoint.rs index eb108d85c0..1860ad3623 100644 --- a/golem-worker-service/src/api/custom_api.rs +++ b/golem-worker-service/src/custom_api/poem_endpoint.rs @@ -12,21 +12,20 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::future::Future; -use std::sync::Arc; - use crate::api::common::ApiEndpointError; use crate::custom_api::request_handler::RequestHandler; use futures::{FutureExt, TryFutureExt}; use golem_common::recorded_http_api_request; use poem::{Endpoint, IntoResponse, Request, Response}; +use std::future::Future; +use std::sync::Arc; use tracing::Instrument; -pub struct CustomApiApi { +pub struct CustomApiPoemEndpoint { pub request_handler: Arc, } -impl CustomApiApi { +impl CustomApiPoemEndpoint { pub fn new(request_handler: Arc) -> Self { Self { request_handler } } @@ -50,7 +49,7 @@ impl CustomApiApi { } } -impl Endpoint for CustomApiApi { +impl Endpoint for CustomApiPoemEndpoint { type Output = Response; fn call(&self, req: Request) -> impl Future> + Send { diff --git a/golem-worker-service/src/getter.rs b/golem-worker-service/src/getter.rs deleted file mode 100644 index 0922d2792b..0000000000 --- a/golem-worker-service/src/getter.rs +++ /dev/null @@ -1,221 +0,0 @@ -// Copyright 2024-2025 Golem Cloud -// -// Licensed under the Golem Source License v1.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://license.golem.cloud/LICENSE -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use crate::headers::ResolvedResponseHeaders; -use crate::path::{Path, PathComponent}; -use golem_wasm::analysis::{AnalysedType, TypeList, TypeRecord, TypeTuple}; -use golem_wasm::json::ValueAndTypeJsonExtensions; -use golem_wasm::{Value, ValueAndType}; -use http::StatusCode; -use rib::GetLiteralValue; -use rib::LiteralValue; - -pub trait Getter { - fn get(&self, key: &Path) -> Result; -} - -#[derive(Debug, PartialEq, Eq, thiserror::Error)] -pub enum GetError { - #[error("Key not found: {0}")] - KeyNotFound(String), - #[error("Index not found: {0}")] - IndexNotFound(usize), - #[error("Not a record: key_name: {key_name}, original_value: {found}")] - NotRecord { key_name: String, found: String }, - #[error("Not an array: index: {index}, original_value: {found}")] - NotArray { index: usize, found: String }, - #[error("Internal error: {0}")] - Internal(String), -} - -// To deal with fields in a TypeAnnotatedValue (that's returned from golem-rib) -impl Getter for ValueAndType { - fn get(&self, key: &Path) -> Result { - let size = key.0.len(); - fn go( - value_and_type: &ValueAndType, - paths: Vec, - index: usize, - size: usize, - ) -> Result { - if index < size { - match &paths[index] { - PathComponent::KeyName(key) => { - match (&value_and_type.typ, &value_and_type.value) { - ( - AnalysedType::Record(TypeRecord { fields, .. }), - Value::Record(field_values), - ) => { - let new_value = fields - .iter() - .zip(field_values) - .find(|(field, _value)| field.name == key.0) - .map(|(field, value)| { - ValueAndType::new(value.clone(), field.typ.clone()) - }); - match new_value { - Some(new_value) => go(&new_value, paths, index + 1, size), - _ => Err(GetError::KeyNotFound(key.0.clone())), - } - } - _ => match value_and_type.to_json_value() { - Ok(json) => Err(GetError::NotRecord { - key_name: key.0.clone(), - found: json.to_string(), - }), - Err(err) => Err(GetError::Internal(err)), - }, - } - } - PathComponent::Index(value_index) => match get_array(value_and_type) { - Some(type_values) => { - let new_value = type_values.get(value_index.0); - match new_value { - Some(new_value) => go(new_value, paths, index + 1, size), - None => Err(GetError::IndexNotFound(value_index.0)), - } - } - None => match value_and_type.to_json_value() { - Ok(json) => Err(GetError::NotArray { - index: value_index.0, - found: json.to_string(), - }), - Err(err) => Err(GetError::Internal(err)), - }, - }, - } - } else { - Ok(value_and_type.clone()) - } - } - - go(self, key.0.clone(), 0, size) - } -} - -fn get_array(value: &ValueAndType) -> Option> { - match (&value.typ, &value.value) { - (AnalysedType::List(TypeList { inner, .. }), Value::List(values)) => { - let vec = values - .iter() - .map(|v| ValueAndType::new(v.clone(), (**inner).clone())) - .collect::>(); - Some(vec) - } - (AnalysedType::Tuple(TypeTuple { items, .. }), Value::Tuple(values)) => { - let vec = items - .iter() - .zip(values) - .map(|(typ, v)| ValueAndType::new(v.clone(), typ.clone())) - .collect::>(); - Some(vec) - } - _ => None, - } -} - -pub trait GetterExt { - fn get_optional(&self, key: &Path) -> Option; -} - -impl> GetterExt for T { - fn get_optional(&self, key: &Path) -> Option { - self.get(key).ok() - } -} - -pub fn get_response_headers( - field_values: &[Value], - record: &TypeRecord, -) -> Result, String> { - match record - .fields - .iter() - .position(|pair| &pair.name == "headers") - { - None => Ok(None), - Some(field_position) => Ok(Some(ResolvedResponseHeaders::from_typed_value( - ValueAndType::new( - field_values[field_position].clone(), - record.fields[field_position].typ.clone(), - ), - )?)), - } -} - -pub fn get_response_headers_or_default( - value: &ValueAndType, -) -> Result { - match value { - ValueAndType { - value: Value::Record(field_values), - typ: AnalysedType::Record(record), - } => get_response_headers(field_values, record).map(|headers| headers.unwrap_or_default()), - _ => Ok(ResolvedResponseHeaders::default()), - } -} - -pub fn get_status_code( - field_values: &[Value], - record: &TypeRecord, -) -> Result, String> { - match record - .fields - .iter() - .position(|field| &field.name == "status") - { - None => Ok(None), - Some(field_position) => Ok(Some(get_status_code_inner(ValueAndType::new( - field_values[field_position].clone(), - record.fields[field_position].typ.clone(), - ))?)), - } -} - -pub fn get_status_code_or_ok(value: &ValueAndType) -> Result { - match value { - ValueAndType { - value: Value::Record(field_values), - typ: AnalysedType::Record(record), - } => get_status_code(field_values, record).map(|status| status.unwrap_or(StatusCode::OK)), - _ => Ok(StatusCode::OK), - } -} - -fn get_status_code_inner(status_code: ValueAndType) -> Result { - let status_res: Result = - match status_code.get_literal() { - Some(LiteralValue::String(status_str)) => status_str.parse().map_err(|e| { - format!( - "Invalid Status Code Expression. It is resolved to a string but not a number {status_str}. Error: {e}" - ) - }), - Some(LiteralValue::Num(number)) => number.to_string().parse().map_err(|e| { - format!( - "Invalid Status Code Expression. It is resolved to a number but not a u16 {number}. Error: {e}" - ) - }), - _ => Err(format!( - "Status Code Expression is evaluated to a complex value. It is resolved to {:?}", - status_code.value - )) - }; - - let status_u16 = status_res?; - - StatusCode::from_u16(status_u16).map_err(|e| - format!( - "Invalid Status Code. A valid status code cannot be formed from the evaluated status code expression {status_u16}. Error: {e}" - )) -} diff --git a/golem-worker-service/src/headers.rs b/golem-worker-service/src/headers.rs deleted file mode 100644 index a655e2feaa..0000000000 --- a/golem-worker-service/src/headers.rs +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright 2024-2025 Golem Cloud -// -// Licensed under the Golem Source License v1.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://license.golem.cloud/LICENSE -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use golem_wasm::analysis::AnalysedType; -use golem_wasm::{Value, ValueAndType}; -use http::HeaderMap; -use poem::web::headers::ContentType; -use rib::GetLiteralValue; -use std::collections::HashMap; -use std::str::FromStr; - -#[derive(Default, Debug, PartialEq)] -pub struct ResolvedResponseHeaders { - pub headers: HeaderMap, -} - -impl ResolvedResponseHeaders { - pub fn from_typed_value(header_map: ValueAndType) -> Result { - match header_map { - ValueAndType { - value: Value::Record(field_values), - typ: AnalysedType::Record(record), - } => { - let mut resolved_headers: HashMap = HashMap::new(); - - for (value, field_def) in field_values.into_iter().zip(record.fields) { - let value = ValueAndType::new(value, field_def.typ); - let value_str = value - .get_literal() - .map(|primitive| primitive.to_string()) - .unwrap_or_else(|| { - "header values in the http response should be a literal".to_string() - }); - - resolved_headers.insert(field_def.name, value_str); - } - - let headers = (&resolved_headers) - .try_into() - .map_err(|e: http::Error| e.to_string()) - .map_err(|e| format!("unable to infer valid headers. Error: {e}"))?; - - Ok(ResolvedResponseHeaders { headers }) - } - - _ => Err(format!( - "Header expression is not a record. It is resolved to {header_map}", - )), - } - } - - pub fn get_content_type(&self) -> Option { - self.headers - .get(http::header::CONTENT_TYPE.to_string()) - .and_then(|header_value| { - header_value - .to_str() - .ok() - .and_then(|header_str| ContentType::from_str(header_str).ok()) - }) - } -} - -#[cfg(test)] -mod test { - use crate::headers::ResolvedResponseHeaders; - use golem_wasm::analysis::analysed_type::{field, record}; - use golem_wasm::{IntoValueAndType, Value, ValueAndType}; - use http::{HeaderMap, HeaderValue}; - use test_r::test; - - fn create_record(values: Vec<(&str, ValueAndType)>) -> ValueAndType { - ValueAndType::new( - Value::Record(values.iter().map(|(_, vnt)| vnt.value.clone()).collect()), - record( - values - .iter() - .map(|(name, vnt)| field(name, vnt.typ.clone())) - .collect(), - ), - ) - } - - #[test] - fn test_get_response_headers_from_typed_value() { - let header_map: ValueAndType = create_record(vec![ - ("header1", "value1".into_value_and_type()), - ("header2", 1.0f32.into_value_and_type()), - ]); - - let resolved_headers = ResolvedResponseHeaders::from_typed_value(header_map).unwrap(); - - let mut header_map = HeaderMap::new(); - - header_map.insert("header1", HeaderValue::from_str("value1").unwrap()); - header_map.insert("header2", HeaderValue::from_str("1").unwrap()); - - let expected = ResolvedResponseHeaders { - headers: header_map, - }; - - assert_eq!(resolved_headers, expected) - } -} diff --git a/golem-worker-service/src/http_invocation_context.rs b/golem-worker-service/src/http_invocation_context.rs deleted file mode 100644 index c471f0dd33..0000000000 --- a/golem-worker-service/src/http_invocation_context.rs +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright 2024-2025 Golem Cloud -// -// Licensed under the Golem Source License v1.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://license.golem.cloud/LICENSE -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use golem_common::model::invocation_context::{ - AttributeValue, InvocationContextSpan, InvocationContextStack, TraceId, -}; -use golem_service_base::headers::TraceContextHeaders; -use std::collections::HashMap; - -pub fn extract_request_attributes(request: &poem::Request) -> HashMap { - let mut result = HashMap::new(); - - result.insert( - "request.method".to_string(), - AttributeValue::String(request.method().to_string()), - ); - result.insert( - "request.uri".to_string(), - AttributeValue::String(request.uri().to_string()), - ); - result.insert( - "request.remote_addr".to_string(), - AttributeValue::String(request.remote_addr().to_string()), - ); - - result -} - -pub fn invocation_context_from_request(request: &poem::Request) -> InvocationContextStack { - let trace_context_headers = TraceContextHeaders::parse(request.headers()); - let request_attributes = extract_request_attributes(request); - - match trace_context_headers { - Some(ctx) => { - // Trace context found in headers, starting a new span - let mut ctx = InvocationContextStack::new( - ctx.trace_id, - InvocationContextSpan::external_parent(ctx.parent_id), - ctx.trace_states, - ); - ctx.push( - InvocationContextSpan::local() - .with_attributes(request_attributes) - .with_parent(ctx.spans.first().clone()) - .build(), - ); - ctx - } - None => { - // No trace context in headers, starting a new trace - InvocationContextStack::new( - TraceId::generate(), - InvocationContextSpan::local() - .with_attributes(request_attributes) - .build(), - Vec::new(), - ) - } - } -} diff --git a/golem-worker-service/src/lib.rs b/golem-worker-service/src/lib.rs index 5e26c75efc..404426c3c9 100644 --- a/golem-worker-service/src/lib.rs +++ b/golem-worker-service/src/lib.rs @@ -15,16 +15,11 @@ pub mod api; pub mod config; pub mod custom_api; -// pub mod gateway_security; -pub mod getter; pub mod grpcapi; -pub mod headers; -pub mod http_invocation_context; pub mod metrics; pub mod model; pub mod path; pub mod service; -// pub mod swagger_ui; use crate::config::WorkerServiceConfig; use crate::service::Services; @@ -178,7 +173,7 @@ impl WorkerService { tracer: Option, ) -> Result { let route = Route::new() - .nest("/", api::custom_api_api(&self.services)) + .nest("/", custom_api::make_custom_api_endpoint(&self.services)) .with(OpenTelemetryMetrics::new()) .with_if_lazy(tracer.is_some(), || { OpenTelemetryTracing::new(tracer.unwrap()) diff --git a/golem-worker-service/src/swagger_ui.rs b/golem-worker-service/src/swagger_ui.rs deleted file mode 100644 index e963be003e..0000000000 --- a/golem-worker-service/src/swagger_ui.rs +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright 2024-2025 Golem Cloud -// -// Licensed under the Golem Source License v1.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://license.golem.cloud/LICENSE -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use crate::model::SwaggerHtml; -use golem_service_base::custom_api::openapi::HttpApiDefinitionOpenApiSpec; - -pub fn generate_swagger_html( - authority: &str, - open_api_spec: HttpApiDefinitionOpenApiSpec, -) -> Result { - let mut spec = open_api_spec.0; - - // Add server information to the OpenAPI spec - if spec.servers.is_empty() { - spec.servers = Vec::new(); - } - - spec.servers.push(openapiv3::Server { - url: format!("http://{authority}"), - description: Some("Local Development Server".to_string()), - variables: Default::default(), - extensions: Default::default(), - }); - - // Convert back to JSON - let modified_spec_json = serde_json::to_string(&spec) - .map_err(|e| format!("Failed to serialize OpenAPI spec: {e}"))?; - - // Generate Swagger UI HTML - let html = format!( - r#" - - - -Swagger UI - - - - - -
- - -"# - ); - - Ok(SwaggerHtml(html)) -} From b0bef0144e86751e45c8b661d154c910f813001d Mon Sep 17 00:00:00 2001 From: Maxim Schuwalow Date: Tue, 27 Jan 2026 15:44:14 +0100 Subject: [PATCH 4/5] code-first-routes: security schemes support --- Cargo.lock | 1 + Cargo.toml | 1 + cli/golem/src/launch.rs | 27 +- .../services/deployment/deployment_context.rs | 12 +- golem-service-base/src/custom_api/mod.rs | 36 +- golem-service-base/src/custom_api/protobuf.rs | 43 +- golem-worker-service/Cargo.toml | 3 +- .../config/worker-service.sample.env | 3 +- .../config/worker-service.toml | 7 +- golem-worker-service/src/api/agents.rs | 2 +- golem-worker-service/src/api/common.rs | 23 +- golem-worker-service/src/api/mod.rs | 2 +- golem-worker-service/src/api/worker.rs | 8 +- golem-worker-service/src/bootstrap.rs | 164 ++++++ golem-worker-service/src/config.rs | 81 ++- .../src/custom_api/api_definition_lookup.rs | 2 +- .../src/custom_api/call_agent/mod.rs | 288 ++++++++++ .../{ => call_agent}/parameter_parsing.rs | 22 +- .../response_mapping.rs} | 4 +- golem-worker-service/src/custom_api/cors.rs | 108 ++++ golem-worker-service/src/custom_api/error.rs | 97 ++++ golem-worker-service/src/custom_api/mod.rs | 8 +- golem-worker-service/src/custom_api/model.rs | 158 +++++- .../src/custom_api/request.rs | 83 --- .../src/custom_api/request_handler.rs | 458 ++------------- .../src/custom_api/route_resolver.rs | 156 ++---- .../src/custom_api/security/handler.rs | 198 +++++++ .../custom_api/security/identity_provider.rs | 96 +--- .../src/custom_api/security/mod.rs | 3 + .../src/custom_api/security/model.rs | 33 ++ .../src/custom_api/security/session_store.rs | 525 ++++++++++++++++++ golem-worker-service/src/grpcapi/mod.rs | 8 +- golem-worker-service/src/grpcapi/worker.rs | 16 +- golem-worker-service/src/lib.rs | 11 +- golem-worker-service/src/model.rs | 5 +- golem-worker-service/src/server.rs | 11 +- golem-worker-service/src/service/auth.rs | 2 +- golem-worker-service/src/service/component.rs | 2 +- golem-worker-service/src/service/limit.rs | 4 +- golem-worker-service/src/service/mod.rs | 204 ------- .../src/service/worker/agents.rs | 2 +- .../src/service/worker/client.rs | 6 +- .../src/service/worker/connect.rs | 2 +- .../src/service/worker/connect_proxy.rs | 4 +- .../src/service/worker/error.rs | 6 +- .../service/worker/invocation_parameters.rs | 2 +- .../src/service/worker/routing_logic.rs | 8 +- .../src/service/worker/service.rs | 18 +- .../src/service/worker/worker_stream.rs | 2 +- 49 files changed, 1910 insertions(+), 1055 deletions(-) create mode 100644 golem-worker-service/src/bootstrap.rs create mode 100644 golem-worker-service/src/custom_api/call_agent/mod.rs rename golem-worker-service/src/custom_api/{ => call_agent}/parameter_parsing.rs (97%) rename golem-worker-service/src/custom_api/{agent_response_mapping.rs => call_agent/response_mapping.rs} (98%) create mode 100644 golem-worker-service/src/custom_api/cors.rs create mode 100644 golem-worker-service/src/custom_api/error.rs delete mode 100644 golem-worker-service/src/custom_api/request.rs create mode 100644 golem-worker-service/src/custom_api/security/handler.rs create mode 100644 golem-worker-service/src/custom_api/security/model.rs create mode 100644 golem-worker-service/src/custom_api/security/session_store.rs diff --git a/Cargo.lock b/Cargo.lock index 25499d70e8..f0bbfafb30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4935,6 +4935,7 @@ dependencies = [ "bytes 1.11.0", "chrono", "conditional-trait-gen", + "cookie", "criterion", "darling 0.20.11", "derive_more", diff --git a/Cargo.toml b/Cargo.toml index 48f39c2e49..bc9ceefc09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -145,6 +145,7 @@ combine = "4.6.7" conditional-trait-gen = "0.4.1" console-subscriber = "0.4.1" convert_case = "0.8.0" +cookie = "0.18.1" criterion = "0.5" crossterm = "0.28.1" darling = "0.20.11" diff --git a/cli/golem/src/launch.rs b/cli/golem/src/launch.rs index fe3846e6ee..42c17a1a6d 100644 --- a/cli/golem/src/launch.rs +++ b/cli/golem/src/launch.rs @@ -40,7 +40,9 @@ use golem_worker_executor::services::golem_config::{ KeyValueStorageMultiSqliteConfig, ResourceLimitsConfig, ResourceLimitsGrpcConfig, ShardManagerServiceConfig, ShardManagerServiceGrpcConfig, WorkerServiceGrpcConfig, }; -use golem_worker_service::config::{RouteResolverConfig, WorkerServiceConfig}; +use golem_worker_service::config::{ + RouteResolverConfig, SqliteSessionStoreConfig, WorkerServiceConfig, +}; use golem_worker_service::WorkerService; use opentelemetry::global; use opentelemetry_sdk::metrics::MeterProviderBuilder; @@ -325,18 +327,21 @@ fn worker_service_config( port: 0, ..Default::default() }, - gateway_session_storage: golem_worker_service::config::GatewaySessionStorageConfig::Sqlite( - DbSqliteConfig { - database: args - .data_dir - .join("gateway-sessions.db") - .to_string_lossy() - .to_string(), - max_connections: 4, - foreign_keys: false, + gateway_session_storage: golem_worker_service::config::SessionStoreConfig::Sqlite( + SqliteSessionStoreConfig { + pending_login_expiration: Duration::from_hours(1), + cleanup_interval: Duration::from_mins(5), + sqlite_config: DbSqliteConfig { + database: args + .data_dir + .join("gateway-sessions.db") + .to_string_lossy() + .to_string(), + max_connections: 4, + foreign_keys: false, + }, }, ), - blob_storage: blob_storage_config(args), routing_table: RoutingTableConfig { host: args.router_addr.clone(), port: shard_manager_run_details.grpc_port, diff --git a/golem-registry-service/src/services/deployment/deployment_context.rs b/golem-registry-service/src/services/deployment/deployment_context.rs index e2e6b751fc..1ece62c157 100644 --- a/golem-registry-service/src/services/deployment/deployment_context.rs +++ b/golem-registry-service/src/services/deployment/deployment_context.rs @@ -31,8 +31,8 @@ use golem_common::model::diff::{self, HashOf, Hashable}; use golem_common::model::domain_registration::Domain; use golem_common::model::http_api_deployment::HttpApiDeployment; use golem_service_base::custom_api::{ - ConstructorParameter, CorsOptions, OriginPattern, PathSegment, RequestBodySchema, - RouteBehaviour, + CallAgentBehaviour, ConstructorParameter, CorsOptions, CorsPreflightBehaviour, OriginPattern, + PathSegment, RequestBodySchema, RouteBehaviour, }; use itertools::Itertools; use std::collections::{BTreeMap, BTreeSet, HashMap}; @@ -294,7 +294,7 @@ impl DeploymentContext { method: http_endpoint.http_method.clone(), path: path_segments.clone(), body, - behaviour: RouteBehaviour::CallAgent { + behaviour: RouteBehaviour::CallAgent(CallAgentBehaviour { component_id: implementer.component_id, component_revision: implementer.component_revision, agent_type: agent.type_name.clone(), @@ -303,7 +303,7 @@ impl DeploymentContext { constructor_parameters: constructor_parameters.clone(), method_parameters, expected_agent_response: agent_method.output_schema.clone(), - }, + }), security_scheme: None, cors, }; @@ -349,10 +349,10 @@ impl DeploymentContext { method: HttpMethod::Options(Empty {}), path: path_segments, body: RequestBodySchema::Unused, - behaviour: RouteBehaviour::CorsPreflight { + behaviour: RouteBehaviour::CorsPreflight(CorsPreflightBehaviour { allowed_origins, allowed_methods, - }, + }), security_scheme: None, cors: CorsOptions { allowed_patterns: vec![], diff --git a/golem-service-base/src/custom_api/mod.rs b/golem-service-base/src/custom_api/mod.rs index 866abe691a..7b012da3df 100644 --- a/golem-service-base/src/custom_api/mod.rs +++ b/golem-service-base/src/custom_api/mod.rs @@ -239,20 +239,28 @@ pub struct CompiledRoute { #[derive(Debug, BinaryCodec)] #[desert(evolution())] pub enum RouteBehaviour { - CallAgent { - component_id: ComponentId, - component_revision: ComponentRevision, - agent_type: AgentTypeName, - constructor_parameters: Vec, - phantom: bool, - method_name: String, - method_parameters: Vec, - expected_agent_response: DataSchema, - }, - CorsPreflight { - allowed_origins: BTreeSet, - allowed_methods: BTreeSet, - }, + CallAgent(CallAgentBehaviour), + CorsPreflight(CorsPreflightBehaviour), +} + +#[derive(Debug, BinaryCodec)] +#[desert(evolution())] +pub struct CallAgentBehaviour { + pub component_id: ComponentId, + pub component_revision: ComponentRevision, + pub agent_type: AgentTypeName, + pub constructor_parameters: Vec, + pub phantom: bool, + pub method_name: String, + pub method_parameters: Vec, + pub expected_agent_response: DataSchema, +} + +#[derive(Debug, BinaryCodec)] +#[desert(evolution())] +pub struct CorsPreflightBehaviour { + pub allowed_origins: BTreeSet, + pub allowed_methods: BTreeSet, } #[derive(Debug, Clone)] diff --git a/golem-service-base/src/custom_api/protobuf.rs b/golem-service-base/src/custom_api/protobuf.rs index 8ca83175ea..acf51d37c2 100644 --- a/golem-service-base/src/custom_api/protobuf.rs +++ b/golem-service-base/src/custom_api/protobuf.rs @@ -15,7 +15,10 @@ use super::{CompiledRoute, CompiledRoutes}; use super::{CorsOptions, SecuritySchemeDetails}; use super::{PathSegment, PathSegmentType, RequestBodySchema, RouteBehaviour}; -use crate::custom_api::{ConstructorParameter, MethodParameter, OriginPattern, QueryOrHeaderType}; +use crate::custom_api::{ + CallAgentBehaviour, ConstructorParameter, CorsPreflightBehaviour, MethodParameter, + OriginPattern, QueryOrHeaderType, +}; use golem_api_grpc::proto; use golem_common::model::agent::{AgentTypeName, HttpMethod}; use golem_common::model::security_scheme::SecuritySchemeName; @@ -158,7 +161,7 @@ impl TryFrom for RouteBehaviour { use proto::golem::customapi::route_behaviour::Kind; match value.kind.ok_or("RouteBehaviour.kind missing")? { - Kind::CallAgent(agent) => Ok(RouteBehaviour::CallAgent { + Kind::CallAgent(agent) => Ok(RouteBehaviour::CallAgent(CallAgentBehaviour { component_id: agent .component_id .ok_or("Missing component_id")? @@ -181,19 +184,21 @@ impl TryFrom for RouteBehaviour { .expected_agent_response .ok_or("Missing expected_agent_response")? .try_into()?, - }), - Kind::CorsPreflight(cors_preflight) => Ok(RouteBehaviour::CorsPreflight { - allowed_origins: cors_preflight - .allowed_origins - .into_iter() - .map(OriginPattern) - .collect(), - allowed_methods: cors_preflight - .allowed_methods - .into_iter() - .map(HttpMethod::try_from) - .collect::, _>>()?, - }), + })), + Kind::CorsPreflight(cors_preflight) => { + Ok(RouteBehaviour::CorsPreflight(CorsPreflightBehaviour { + allowed_origins: cors_preflight + .allowed_origins + .into_iter() + .map(OriginPattern) + .collect(), + allowed_methods: cors_preflight + .allowed_methods + .into_iter() + .map(HttpMethod::try_from) + .collect::, _>>()?, + })) + } } } } @@ -203,7 +208,7 @@ impl From for proto::golem::customapi::RouteBehaviour { use proto::golem::customapi::route_behaviour::Kind; match value { - RouteBehaviour::CallAgent { + RouteBehaviour::CallAgent(CallAgentBehaviour { component_id, component_revision, agent_type, @@ -212,7 +217,7 @@ impl From for proto::golem::customapi::RouteBehaviour { method_name, method_parameters, expected_agent_response, - } => Self { + }) => Self { kind: Some(Kind::CallAgent( proto::golem::customapi::route_behaviour::CallAgent { component_id: Some(component_id.into()), @@ -229,10 +234,10 @@ impl From for proto::golem::customapi::RouteBehaviour { }, )), }, - RouteBehaviour::CorsPreflight { + RouteBehaviour::CorsPreflight(CorsPreflightBehaviour { allowed_origins, allowed_methods, - } => Self { + }) => Self { kind: Some(Kind::CorsPreflight( proto::golem::customapi::route_behaviour::CorsPreflight { allowed_origins: allowed_origins.into_iter().map(|ao| ao.0).collect(), diff --git a/golem-worker-service/Cargo.toml b/golem-worker-service/Cargo.toml index f0edd6bcd4..4b770b9c2c 100644 --- a/golem-worker-service/Cargo.toml +++ b/golem-worker-service/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "golem-worker-service" version = "0.0.0" -edition = "2021" +edition = "2024" homepage = "https://golem.cloud" repository = "https://github.com/golemcloud/golem" @@ -44,6 +44,7 @@ bigdecimal = { workspace = true } bytes = { workspace = true } chrono = { workspace = true } conditional-trait-gen = { workspace = true } +cookie = { workspace = true } darling = { workspace = true } derive_more = { workspace = true } desert_rust = { workspace = true } diff --git a/golem-worker-service/config/worker-service.sample.env b/golem-worker-service/config/worker-service.sample.env index 38d598f6c1..70e6a45b27 100644 --- a/golem-worker-service/config/worker-service.sample.env +++ b/golem-worker-service/config/worker-service.sample.env @@ -11,14 +11,13 @@ GOLEM__AUTH_SERVICE__AUTH_CTX_CACHE_TTL="10m" GOLEM__AUTH_SERVICE__ENVIRONMENT_AUTH_DETAILS_CACHE_EVICTION_PERIOD="1m" GOLEM__AUTH_SERVICE__ENVIRONMENT_AUTH_DETAILS_CACHE_MAX_CAPACITY=1024 GOLEM__AUTH_SERVICE__ENVIRONMENT_AUTH_DETAILS_CACHE_TTL="10m" -GOLEM__BLOB_STORAGE__TYPE="LocalFileSystem" -GOLEM__BLOB_STORAGE__CONFIG__ROOT="../data/blob_storage" GOLEM__COMPONENT_SERVICE__COMPONENT_CACHE_MAX_CAPACITY=1024 GOLEM__GATEWAY_SESSION_STORAGE__TYPE="Redis" GOLEM__GATEWAY_SESSION_STORAGE__CONFIG__DATABASE=0 GOLEM__GATEWAY_SESSION_STORAGE__CONFIG__HOST="localhost" GOLEM__GATEWAY_SESSION_STORAGE__CONFIG__KEY_PREFIX="" #GOLEM__GATEWAY_SESSION_STORAGE__CONFIG__PASSWORD= +GOLEM__GATEWAY_SESSION_STORAGE__CONFIG__PENDING_LOGIN_EXPIRATION="1h" GOLEM__GATEWAY_SESSION_STORAGE__CONFIG__POOL_SIZE=8 GOLEM__GATEWAY_SESSION_STORAGE__CONFIG__PORT=6380 GOLEM__GATEWAY_SESSION_STORAGE__CONFIG__TRACING=false diff --git a/golem-worker-service/config/worker-service.toml b/golem-worker-service/config/worker-service.toml index 185616f14c..0f12abe030 100644 --- a/golem-worker-service/config/worker-service.toml +++ b/golem-worker-service/config/worker-service.toml @@ -13,12 +13,6 @@ environment_auth_details_cache_eviction_period = "1m" environment_auth_details_cache_max_capacity = 1024 environment_auth_details_cache_ttl = "10m" -[blob_storage] -type = "LocalFileSystem" - -[blob_storage.config] -root = "../data/blob_storage" - [component_service] component_cache_max_capacity = 1024 @@ -29,6 +23,7 @@ type = "Redis" database = 0 host = "localhost" key_prefix = "" +pending_login_expiration = "1h" pool_size = 8 port = 6380 tracing = false diff --git a/golem-worker-service/src/api/agents.rs b/golem-worker-service/src/api/agents.rs index 34a3f1d71e..fa7c949044 100644 --- a/golem-worker-service/src/api/agents.rs +++ b/golem-worker-service/src/api/agents.rs @@ -2,10 +2,10 @@ use crate::api::common::ApiEndpointError; use crate::service::auth::AuthService; use crate::service::worker::AgentsService; use chrono::{DateTime, Utc}; +use golem_common::model::IdempotencyKey; use golem_common::model::agent::{AgentTypeName, UntypedJsonDataValue}; use golem_common::model::application::ApplicationName; use golem_common::model::environment::EnvironmentName; -use golem_common::model::IdempotencyKey; use golem_common::recorded_http_api_request; use golem_service_base::api_tags::ApiTags; use golem_service_base::model::auth::GolemSecurityScheme; diff --git a/golem-worker-service/src/api/common.rs b/golem-worker-service/src/api/common.rs index 6c6f2a87b8..13317ce83b 100644 --- a/golem-worker-service/src/api/common.rs +++ b/golem-worker-service/src/api/common.rs @@ -12,21 +12,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::custom_api::request_handler::RequestHandlerError; +use crate::custom_api::error::RequestHandlerError; use crate::custom_api::route_resolver::RouteResolverError; use crate::service::auth::AuthServiceError; use crate::service::component::ComponentServiceError; use crate::service::limit::LimitServiceError; use crate::service::worker::{CallWorkerExecutorError, WorkerServiceError}; +use golem_common::SafeDisplay; use golem_common::metrics::api::ApiErrorDetails; use golem_common::model::error::ErrorBody; use golem_common::model::error::ErrorsBody; -use golem_common::SafeDisplay; use golem_service_base::clients::registry::RegistryServiceError; use golem_service_base::error::worker_executor::WorkerExecutorError; use golem_service_base::model::auth::AuthorizationError; -use poem_openapi::payload::Json; use poem_openapi::ApiResponse; +use poem_openapi::payload::Json; use serde::{Deserialize, Serialize}; /// Detail in case the error was caused by the worker failing @@ -275,23 +275,26 @@ impl From for ApiEndpointError { | RequestHandlerError::HeaderIsNotAscii { .. } | RequestHandlerError::BodyIsNotValidJson { .. } | RequestHandlerError::JsonBodyParsingFailed { .. } - | RequestHandlerError::UnsupportedMimeType { .. } => Self::bad_request(value), - - RequestHandlerError::ResolvingRouteFailed( + | RequestHandlerError::UnsupportedMimeType { .. } + | RequestHandlerError::ResolvingRouteFailed( RouteResolverError::CouldNotGetDomainFromRequest(_) | RouteResolverError::MalformedPath(_), ) => Self::bad_request(value), - RequestHandlerError::ResolvingRouteFailed(RouteResolverError::CouldNotBuildRouter) => { - Self::internal(value) - } + RequestHandlerError::ResolvingRouteFailed(RouteResolverError::NoMatchingRoute) => { Self::not_found(value) } + RequestHandlerError::OidcTokenExchangeFailed + | RequestHandlerError::UnknownOidcState => Self::forbidden(value), + RequestHandlerError::AgentResponseTypeMismatch { .. } | RequestHandlerError::InvariantViolated { .. } | RequestHandlerError::AgentInvocationFailed(_) - | RequestHandlerError::InternalError(_) => Self::internal(value), + | RequestHandlerError::InternalError(_) + | RequestHandlerError::ResolvingRouteFailed(RouteResolverError::CouldNotBuildRouter) => { + Self::internal(value) + } } } } diff --git a/golem-worker-service/src/api/mod.rs b/golem-worker-service/src/api/mod.rs index 0e641fa0a1..19bbb0e0a2 100644 --- a/golem-worker-service/src/api/mod.rs +++ b/golem-worker-service/src/api/mod.rs @@ -18,7 +18,7 @@ mod worker; use crate::api::agents::AgentsApi; use crate::api::worker::WorkerApi; -use crate::service::Services; +use crate::bootstrap::Services; use golem_service_base::api::HealthcheckApi; use poem_openapi::OpenApiService; diff --git a/golem-worker-service/src/api/worker.rs b/golem-worker-service/src/api/worker.rs index 0385996920..cc56b4f647 100644 --- a/golem-worker-service/src/api/worker.rs +++ b/golem-worker-service/src/api/worker.rs @@ -17,7 +17,7 @@ use crate::model; use crate::service::auth::AuthService; use crate::service::component::ComponentService; use crate::service::worker::ConnectWorkerStream; -use crate::service::worker::{proxy_worker_connection, InvocationParameters, WorkerService}; +use crate::service::worker::{InvocationParameters, WorkerService, proxy_worker_connection}; use futures::StreamExt; use futures::TryStreamExt; use golem_common::model::auth::TokenSecret; @@ -30,14 +30,14 @@ use golem_common::model::oplog::OplogCursor; use golem_common::model::oplog::OplogIndex; use golem_common::model::worker::{RevertWorkerTarget, WorkerCreationRequest, WorkerMetadataDto}; use golem_common::model::{IdempotencyKey, ScanCursor, WorkerFilter, WorkerId}; -use golem_common::{recorded_http_api_request, SafeDisplay}; +use golem_common::{SafeDisplay, recorded_http_api_request}; use golem_service_base::api_tags::ApiTags; use golem_service_base::model::auth::{ AuthCtx, EnvironmentAction, GolemSecurityScheme, WrappedGolemSecuritySchema, }; use golem_service_base::model::*; -use poem::web::websocket::{BoxWebSocketUpgraded, WebSocket}; use poem::Body; +use poem::web::websocket::{BoxWebSocketUpgraded, WebSocket}; use poem_openapi::param::{Header, Path, Query}; use poem_openapi::payload::{Binary, Json}; use poem_openapi::*; @@ -810,7 +810,7 @@ impl WorkerApi { match (from, query) { (Some(_), Some(_)) => Err(ApiEndpointError::BadRequest(Json(ErrorsBody { errors: vec![ - "Cannot specify both the 'from' and the 'query' parameters".to_string() + "Cannot specify both the 'from' and the 'query' parameters".to_string(), ], cause: None, }))), diff --git a/golem-worker-service/src/bootstrap.rs b/golem-worker-service/src/bootstrap.rs new file mode 100644 index 0000000000..74863b3bbc --- /dev/null +++ b/golem-worker-service/src/bootstrap.rs @@ -0,0 +1,164 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::config::{SessionStoreConfig, WorkerServiceConfig}; +use crate::custom_api::api_definition_lookup::{ + HttpApiDefinitionsLookup, RegistryServiceApiDefinitionsLookup, +}; +use crate::custom_api::call_agent::CallAgentHandler; +use crate::custom_api::request_handler::RequestHandler; +use crate::custom_api::route_resolver::RouteResolver; +use crate::custom_api::security::DefaultIdentityProvider; +use crate::custom_api::security::handler::OidcHandler; +use crate::custom_api::security::session_store::{ + RedisSessionStore, SessionStore, SqliteSessionStore, +}; +use crate::service::auth::{AuthService, RemoteAuthService}; +use crate::service::component::{ComponentService, RemoteComponentService}; +use crate::service::limit::{LimitService, RemoteLimitService}; +use crate::service::worker::{ + AgentsService, WorkerClient, WorkerExecutorWorkerClient, WorkerService, +}; +use golem_api_grpc::proto::golem::workerexecutor::v1::worker_executor_client::WorkerExecutorClient; +use golem_common::redis::RedisPool; +use golem_service_base::clients::registry::{GrpcRegistryService, RegistryService}; +use golem_service_base::db::sqlite::SqlitePool; +use golem_service_base::grpc::client::MultiTargetGrpcClient; +use golem_service_base::service::routing_table::{RoutingTableService, RoutingTableServiceDefault}; +use std::sync::Arc; +use tonic::codec::CompressionEncoding; + +#[derive(Clone)] +pub struct Services { + pub auth_service: Arc, + pub limit_service: Arc, + pub component_service: Arc, + pub worker_service: Arc, + pub request_handler: Arc, + pub agents_service: Arc, +} + +impl Services { + pub async fn new(config: &WorkerServiceConfig) -> anyhow::Result { + let registry_service_client: Arc = + Arc::new(GrpcRegistryService::new(&config.registry_service)); + + let auth_service: Arc = Arc::new(RemoteAuthService::new( + registry_service_client.clone(), + &config.auth_service, + )); + + let component_service: Arc = Arc::new(RemoteComponentService::new( + registry_service_client.clone(), + &config.component_service, + )); + + let limit_service: Arc = + Arc::new(RemoteLimitService::new(registry_service_client.clone())); + + let routing_table_service: Arc = Arc::new( + RoutingTableServiceDefault::new(config.routing_table.clone()), + ); + + let worker_executor_clients = MultiTargetGrpcClient::new( + "worker_executor", + |channel| { + WorkerExecutorClient::new(channel) + .send_compressed(CompressionEncoding::Gzip) + .accept_compressed(CompressionEncoding::Gzip) + }, + config.worker_executor.client.clone(), + ); + + let worker_client: Arc = Arc::new(WorkerExecutorWorkerClient::new( + worker_executor_clients.clone(), + config.worker_executor.retries.clone(), + routing_table_service.clone(), + )); + + let worker_service: Arc = Arc::new(WorkerService::new( + component_service.clone(), + auth_service.clone(), + limit_service.clone(), + worker_client.clone(), + )); + + let api_definition_lookup_service: Arc = Arc::new( + RegistryServiceApiDefinitionsLookup::new(registry_service_client.clone()), + ); + + let route_resolver = Arc::new(RouteResolver::new( + &config.route_resolver, + api_definition_lookup_service.clone(), + )); + + let call_agent_handler = Arc::new(CallAgentHandler::new(worker_service.clone())); + + let identity_provider = Arc::new(DefaultIdentityProvider); + + let session_store: Arc = match &config.gateway_session_storage { + SessionStoreConfig::Redis(inner) => { + let redis = RedisPool::configured(&inner.redis_config).await?; + + let session_store = RedisSessionStore::new( + redis, + fred::types::Expiration::EX( + inner.pending_login_expiration.as_secs().try_into()?, + ), + ); + + Arc::new(session_store) + } + + SessionStoreConfig::Sqlite(inner) => { + let pool = SqlitePool::configured(&inner.sqlite_config).await?; + + let gateway_session_with_sqlite = SqliteSessionStore::new( + pool, + inner.pending_login_expiration.as_secs().try_into()?, + inner.cleanup_interval, + ) + .await?; + + Arc::new(gateway_session_with_sqlite) + } + }; + + let oidc_handler = Arc::new(OidcHandler::new( + session_store.clone(), + identity_provider.clone(), + )); + + let request_handler = Arc::new(RequestHandler::new( + route_resolver.clone(), + call_agent_handler.clone(), + oidc_handler.clone(), + )); + + let agents_service: Arc = Arc::new(AgentsService::new( + registry_service_client.clone(), + component_service.clone(), + worker_service.clone(), + )); + + Ok(Self { + auth_service, + limit_service, + component_service, + worker_service, + request_handler, + agents_service, + }) + } +} diff --git a/golem-worker-service/src/config.rs b/golem-worker-service/src/config.rs index 959b4b5c93..21751412b0 100644 --- a/golem-worker-service/src/config.rs +++ b/golem-worker-service/src/config.rs @@ -12,15 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -// use crate::service::gateway::api_definition::ApiDefinitionServiceConfig; +use golem_common::SafeDisplay; use golem_common::config::DbSqliteConfig; use golem_common::config::RedisConfig; use golem_common::config::{ConfigExample, ConfigLoader, HasConfigExamples}; use golem_common::model::RetryConfig; use golem_common::tracing::TracingConfig; -use golem_common::SafeDisplay; use golem_service_base::clients::registry::GrpcRegistryServiceConfig; -use golem_service_base::config::BlobStorageConfig; use golem_service_base::grpc::client::GrpcClientConfig; use golem_service_base::grpc::server::GrpcServerTlsConfig; use golem_service_base::service::routing_table::RoutingTableConfig; @@ -33,13 +31,12 @@ use std::time::Duration; pub struct WorkerServiceConfig { pub environment: String, pub tracing: TracingConfig, - pub gateway_session_storage: GatewaySessionStorageConfig, + pub gateway_session_storage: SessionStoreConfig, pub port: u16, pub custom_request_port: u16, pub grpc: GrpcApiConfig, pub routing_table: RoutingTableConfig, pub worker_executor: WorkerExecutorClientConfig, - pub blob_storage: BlobStorageConfig, pub workspace: String, pub registry_service: GrpcRegistryServiceConfig, pub cors_origin_regex: String, @@ -84,12 +81,7 @@ impl SafeDisplay for WorkerServiceConfig { ); let _ = writeln!(&mut result, "worker executor:"); let _ = writeln!(result, "{}", self.worker_executor.to_safe_string_indented()); - let _ = writeln!(&mut result, "blob storage:"); - let _ = writeln!( - &mut result, - "{}", - self.blob_storage.to_safe_string_indented() - ); + let _ = writeln!(&mut result, "workspace: {}", self.workspace); let _ = writeln!(&mut result, "registry service:"); let _ = writeln!( @@ -129,14 +121,13 @@ impl Default for WorkerServiceConfig { fn default() -> Self { Self { environment: "local".to_string(), - gateway_session_storage: GatewaySessionStorageConfig::default_redis(), + gateway_session_storage: SessionStoreConfig::Redis(Default::default()), tracing: TracingConfig::local_dev("worker-service"), port: 9005, custom_request_port: 9006, grpc: GrpcApiConfig::default(), routing_table: RoutingTableConfig::default(), worker_executor: WorkerExecutorClientConfig::default(), - blob_storage: BlobStorageConfig::default(), workspace: "release".to_string(), registry_service: GrpcRegistryServiceConfig::default(), cors_origin_regex: "https://*.golem.cloud".to_string(), @@ -183,20 +174,20 @@ impl Default for GrpcApiConfig { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "type", content = "config")] -pub enum GatewaySessionStorageConfig { - Redis(RedisConfig), - Sqlite(DbSqliteConfig), +pub enum SessionStoreConfig { + Redis(RedisSessionStoreConfig), + Sqlite(SqliteSessionStoreConfig), } -impl SafeDisplay for GatewaySessionStorageConfig { +impl SafeDisplay for SessionStoreConfig { fn to_safe_string(&self) -> String { let mut result = String::new(); match self { - GatewaySessionStorageConfig::Redis(redis) => { + SessionStoreConfig::Redis(redis) => { let _ = writeln!(&mut result, "redis:"); let _ = writeln!(&mut result, "{}", redis.to_safe_string_indented()); } - GatewaySessionStorageConfig::Sqlite(sqlite) => { + SessionStoreConfig::Sqlite(sqlite) => { let _ = writeln!(&mut result, "sqlite:"); let _ = writeln!(&mut result, "{}", sqlite.to_safe_string_indented()); } @@ -205,15 +196,57 @@ impl SafeDisplay for GatewaySessionStorageConfig { } } -impl Default for GatewaySessionStorageConfig { +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RedisSessionStoreConfig { + #[serde(with = "humantime_serde")] + pub pending_login_expiration: std::time::Duration, + #[serde(flatten)] + pub redis_config: RedisConfig, +} + +impl Default for RedisSessionStoreConfig { fn default() -> Self { - Self::default_redis() + Self { + pending_login_expiration: Duration::from_hours(1), + redis_config: RedisConfig::default(), + } } } -impl GatewaySessionStorageConfig { - pub fn default_redis() -> Self { - Self::Redis(RedisConfig::default()) +impl SafeDisplay for RedisSessionStoreConfig { + fn to_safe_string(&self) -> String { + let mut result = String::new(); + let _ = writeln!( + &mut result, + "pending_login_expiration: {:?}", + self.pending_login_expiration + ); + let _ = writeln!(&mut result, "{}", self.redis_config.to_safe_string()); + result + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SqliteSessionStoreConfig { + #[serde(with = "humantime_serde")] + pub pending_login_expiration: std::time::Duration, + #[serde(with = "humantime_serde")] + pub cleanup_interval: std::time::Duration, + #[serde(flatten)] + pub sqlite_config: DbSqliteConfig, +} + +impl SafeDisplay for SqliteSessionStoreConfig { + fn to_safe_string(&self) -> String { + let mut result = String::new(); + let _ = writeln!( + &mut result, + "pending_login_expiration: {:?}", + self.pending_login_expiration + ); + let _ = writeln!(&mut result, "cleanup_interval: {:?}", self.cleanup_interval); + let _ = writeln!(&mut result, "{}", self.sqlite_config.to_safe_string()); + result } } diff --git a/golem-worker-service/src/custom_api/api_definition_lookup.rs b/golem-worker-service/src/custom_api/api_definition_lookup.rs index 82029fb02f..a5180e2dbf 100644 --- a/golem-worker-service/src/custom_api/api_definition_lookup.rs +++ b/golem-worker-service/src/custom_api/api_definition_lookup.rs @@ -14,7 +14,7 @@ use async_trait::async_trait; use golem_common::model::domain_registration::Domain; -use golem_common::{error_forwarding, SafeDisplay}; +use golem_common::{SafeDisplay, error_forwarding}; use golem_service_base::clients::registry::{RegistryService, RegistryServiceError}; use golem_service_base::custom_api::CompiledRoutes; use std::sync::Arc; diff --git a/golem-worker-service/src/custom_api/call_agent/mod.rs b/golem-worker-service/src/custom_api/call_agent/mod.rs new file mode 100644 index 0000000000..d3334c541d --- /dev/null +++ b/golem-worker-service/src/custom_api/call_agent/mod.rs @@ -0,0 +1,288 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +mod parameter_parsing; +mod response_mapping; + +use self::parameter_parsing::{ + parse_path_segment_value, parse_path_segment_value_to_component_model, + parse_query_or_header_value, parse_request_body, +}; +use self::response_mapping::interpret_agent_response; +use super::error::RequestHandlerError; +use super::model::RichRequest; +use super::route_resolver::ResolvedRouteEntry; +use super::{ParsedRequestBody, RouteExecutionResult}; +use crate::service::worker::WorkerService; +use anyhow::anyhow; +use golem_common::model::agent::{ + AgentId, BinaryReference, BinaryReferenceValue, DataValue, ElementValue, ElementValues, + OidcPrincipal, Principal, UntypedDataValue, UntypedElementValue, +}; +use golem_common::model::{IdempotencyKey, WorkerId}; +use golem_service_base::custom_api::{CallAgentBehaviour, ConstructorParameter, MethodParameter}; +use golem_service_base::model::auth::AuthCtx; +use golem_wasm::json::ValueAndTypeJsonExtensions; +use golem_wasm::{IntoValue, ValueAndType}; +use std::sync::Arc; +use tracing::debug; +use uuid::Uuid; + +pub struct CallAgentHandler { + worker_service: Arc, +} + +impl CallAgentHandler { + pub fn new(worker_service: Arc) -> Self { + Self { worker_service } + } + + pub async fn handle_call_agent_behaviour( + &self, + request: &mut RichRequest, + resolved_route: &ResolvedRouteEntry, + behaviour: &CallAgentBehaviour, + ) -> Result { + let worker_id = self.build_worker_id(resolved_route, behaviour)?; + + let parsed_body = parse_request_body(request, &resolved_route.route.body).await?; + + let method_params = + self.resolve_method_arguments(resolved_route, request, behaviour, parsed_body)?; + + debug!("Invoking agent {worker_id}"); + + let agent_response = self + .invoke_agent( + &worker_id, + resolved_route, + method_params, + behaviour, + request, + ) + .await?; + + debug!("Received agent response: {agent_response:?}"); + + debug!( + "Json agent response: {}", + agent_response.clone().unwrap().to_json_value().unwrap() + ); + + let route_result = + interpret_agent_response(agent_response, &behaviour.expected_agent_response)?; + + debug!("Returning call agent route result: {route_result:?}"); + + Ok(route_result) + } + + fn build_worker_id( + &self, + resolved_route: &ResolvedRouteEntry, + behaviour: &CallAgentBehaviour, + ) -> Result { + let CallAgentBehaviour { + component_id, + agent_type, + constructor_parameters, + phantom, + .. + } = behaviour; + + let mut values = Vec::with_capacity(constructor_parameters.len()); + + for param in constructor_parameters { + match param { + ConstructorParameter::Path { + path_segment_index, + parameter_type, + } => { + let raw = resolved_route.captured_path_parameters + [usize::from(*path_segment_index)] + .clone(); + + let value = parse_path_segment_value_to_component_model(raw, parameter_type)?; + + values.push(ElementValue::ComponentModel(ValueAndType::new( + value, + parameter_type.clone().into(), + ))); + } + } + } + + let data_value = DataValue::Tuple(ElementValues { elements: values }); + + let phantom_id = phantom.then(Uuid::new_v4); + + let agent_id = AgentId::new(agent_type.clone(), data_value, phantom_id); + + Ok(WorkerId { + component_id: *component_id, + worker_name: agent_id.to_string(), + }) + } + + fn resolve_method_arguments( + &self, + resolved_route: &ResolvedRouteEntry, + request: &RichRequest, + behaviour: &CallAgentBehaviour, + mut body: ParsedRequestBody, + ) -> Result, RequestHandlerError> { + let query_params = request.query_params(); + let headers = request.headers(); + + let mut values = Vec::with_capacity(behaviour.method_parameters.len()); + + for param in &behaviour.method_parameters { + let value = match param { + MethodParameter::Path { + path_segment_index, + parameter_type, + } => { + let raw = resolved_route.captured_path_parameters + [usize::from(*path_segment_index)] + .clone(); + + parse_path_segment_value(raw, parameter_type)? + } + + MethodParameter::Query { + query_parameter_name, + parameter_type, + } => { + let empty = Vec::new(); + let vals = query_params.get(query_parameter_name).unwrap_or(&empty); + + parse_query_or_header_value(vals, parameter_type)? + } + + MethodParameter::Header { + header_name, + parameter_type, + } => { + let vals = headers + .get_all(header_name) + .iter() + .map(|h| { + h.to_str().map(String::from).map_err(|_| { + RequestHandlerError::HeaderIsNotAscii { + header_name: header_name.clone(), + } + }) + }) + .collect::, _>>()?; + + parse_query_or_header_value(&vals, parameter_type)? + } + + MethodParameter::JsonObjectBodyField { field_index } => match &body { + ParsedRequestBody::JsonBody(golem_wasm::Value::Record(fields)) => { + UntypedElementValue::ComponentModel( + fields[usize::from(*field_index)].clone(), + ) + } + + ParsedRequestBody::JsonBody(_) => { + return Err(RequestHandlerError::invariant_violated( + "Inconsistent API definition: JSON field parameter but body is not an object", + )); + } + + _ => { + return Err(RequestHandlerError::invariant_violated( + "JSON body parameter used but no JSON body schema", + )); + } + }, + + MethodParameter::UnstructuredBinaryBody => match &mut body { + ParsedRequestBody::UnstructuredBinary(binary_source) => { + let binary_source = binary_source.take().ok_or_else(|| { + RequestHandlerError::invariant_violated( + "Parsed body was already consumed", + ) + })?; + + UntypedElementValue::UnstructuredBinary(BinaryReferenceValue { + value: BinaryReference::Inline(binary_source), + }) + } + + _ => { + return Err(RequestHandlerError::invariant_violated( + "Binary body parameter used but no binary body schema", + )); + } + }, + }; + + values.push(value); + } + + Ok(values) + } + + async fn invoke_agent( + &self, + worker_id: &WorkerId, + resolved_route: &ResolvedRouteEntry, + params: Vec, + behaviour: &CallAgentBehaviour, + request: &RichRequest, + ) -> Result, RequestHandlerError> { + let method_params_data_value = UntypedDataValue::Tuple(params); + + let principal = principal_from_request(request)?; + + self.worker_service + .invoke_and_await_owned_agent( + worker_id, + Some(IdempotencyKey::fresh()), + "golem:agent/guest.{invoke}".to_string(), + vec![ + golem_wasm::protobuf::Val::from(behaviour.method_name.clone().into_value()), + golem_wasm::protobuf::Val::from(method_params_data_value.into_value()), + golem_wasm::protobuf::Val::from(principal.into_value()), + ], + None, + resolved_route.route.environment_id, + resolved_route.route.account_id, + AuthCtx::impersonated_user(resolved_route.route.account_id), + ) + .await + .map_err(Into::into) + } +} + +fn principal_from_request(request: &RichRequest) -> Result { + match request.authenticated_session() { + Some(session) => Ok(Principal::Oidc(OidcPrincipal { + sub: session.subject.clone(), + issuer: session.issuer.clone(), + email: session.email.clone(), + name: session.name.clone(), + email_verified: session.email_verified, + given_name: session.given_name.clone(), + family_name: session.family_name.clone(), + picture: session.picture.clone(), + preferred_username: session.preferred_username.clone(), + claims: serde_json::to_string(&session.claims) + .map_err(|e| anyhow!("CoreIdTokenClaims serialization error: {e}"))?, + })), + None => Ok(Principal::anonymous()), + } +} diff --git a/golem-worker-service/src/custom_api/parameter_parsing.rs b/golem-worker-service/src/custom_api/call_agent/parameter_parsing.rs similarity index 97% rename from golem-worker-service/src/custom_api/parameter_parsing.rs rename to golem-worker-service/src/custom_api/call_agent/parameter_parsing.rs index 5f202bfb9f..bfef971306 100644 --- a/golem-worker-service/src/custom_api/parameter_parsing.rs +++ b/golem-worker-service/src/custom_api/call_agent/parameter_parsing.rs @@ -12,14 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::request::RichRequest; -use super::request_handler::RequestHandlerError; use super::ParsedRequestBody; +use crate::custom_api::error::RequestHandlerError; +use crate::custom_api::model::RichRequest; use anyhow::anyhow; use golem_common::model::agent::{BinarySource, BinaryType, UntypedElementValue}; use golem_service_base::custom_api::{PathSegmentType, QueryOrHeaderType, RequestBodySchema}; -use golem_wasm::json::ValueAndTypeJsonExtensions; use golem_wasm::ValueAndType; +use golem_wasm::json::ValueAndTypeJsonExtensions; pub fn parse_path_segment_value( value: String, @@ -263,13 +263,13 @@ async fn parse_binary_body( .map(|v| v.to_string()) .unwrap_or_else(|| "application/octet-stream".to_string()); - if let Some(allowed) = allowed_mime_types { - if !allowed.iter().any(|allowed| allowed == &mime_type) { - return Err(RequestHandlerError::UnsupportedMimeType { - mime_type, - allowed_mime_types: allowed.clone(), - }); - } + if let Some(allowed) = allowed_mime_types + && !allowed.iter().any(|allowed| allowed == &mime_type) + { + return Err(RequestHandlerError::UnsupportedMimeType { + mime_type, + allowed_mime_types: allowed.clone(), + }); } Ok(ParsedRequestBody::UnstructuredBinary(Some(BinarySource { @@ -528,7 +528,7 @@ mod request_body_tests { use super::*; use assert2::{assert, let_assert}; use golem_service_base::custom_api::RequestBodySchema; - use golem_wasm::analysis::{analysed_type, NameTypePair}; + use golem_wasm::analysis::{NameTypePair, analysed_type}; use http::Method; use poem::{Body, Request}; use serde_json::json; diff --git a/golem-worker-service/src/custom_api/agent_response_mapping.rs b/golem-worker-service/src/custom_api/call_agent/response_mapping.rs similarity index 98% rename from golem-worker-service/src/custom_api/agent_response_mapping.rs rename to golem-worker-service/src/custom_api/call_agent/response_mapping.rs index 9c6cde8032..9c9d5d60b8 100644 --- a/golem-worker-service/src/custom_api/agent_response_mapping.rs +++ b/golem-worker-service/src/custom_api/call_agent/response_mapping.rs @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::request_handler::RequestHandlerError; -use super::{ResponseBody, RouteExecutionResult}; +use crate::custom_api::error::RequestHandlerError; +use crate::custom_api::{ResponseBody, RouteExecutionResult}; use anyhow::anyhow; use golem_common::model::agent::{ AgentError, BinaryReference, DataSchema, DataValue, ElementValue, ElementValues, diff --git a/golem-worker-service/src/custom_api/cors.rs b/golem-worker-service/src/custom_api/cors.rs new file mode 100644 index 0000000000..f0ba3deff3 --- /dev/null +++ b/golem-worker-service/src/custom_api/cors.rs @@ -0,0 +1,108 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::error::RequestHandlerError; +use super::model::RichRequest; +use super::route_resolver::ResolvedRouteEntry; +use super::{ResponseBody, RouteExecutionResult}; +use golem_common::model::agent::HttpMethod; +use golem_service_base::custom_api::OriginPattern; +use http::StatusCode; +use std::collections::{BTreeSet, HashMap}; +use tracing::debug; + +pub fn handle_cors_preflight_behaviour( + request: &RichRequest, + allowed_origins: &BTreeSet, + allowed_methods: &BTreeSet, +) -> Result { + let origin = request.origin()?.ok_or(RequestHandlerError::MissingValue { + expected: "Origin header", + })?; + + let origin_allowed = allowed_origins + .iter() + .any(|pattern| pattern.matches(origin)); + if !origin_allowed { + return Ok(RouteExecutionResult { + status: StatusCode::FORBIDDEN, + headers: HashMap::new(), + body: ResponseBody::NoBody, + }); + } + + let allow_methods = allowed_methods + .iter() + .map(|m| { + let converted = http::Method::try_from(m.clone()).map_err(|_| { + RequestHandlerError::invariant_violated("HttpMethod conversion error") + })?; + let rendered = converted.to_string(); + Ok::<_, RequestHandlerError>(rendered) + }) + .collect::, _>>()? + .join(", "); + + let mut headers = HashMap::new(); + + headers.insert( + http::header::ACCESS_CONTROL_ALLOW_ORIGIN, + origin.to_string(), + ); + headers.insert(http::header::ACCESS_CONTROL_ALLOW_METHODS, allow_methods); + headers.insert( + http::header::ACCESS_CONTROL_ALLOW_HEADERS, + "Content-Type, Authorization".to_string(), + ); + headers.insert(http::header::ACCESS_CONTROL_MAX_AGE, "3600".to_string()); + headers.insert(http::header::VARY, "Origin".to_string()); + + Ok(RouteExecutionResult { + status: StatusCode::NO_CONTENT, + headers, + body: ResponseBody::NoBody, + }) +} + +pub async fn apply_cors_outgoing_middleware( + result: &mut RouteExecutionResult, + request: &RichRequest, + resolved_route: &ResolvedRouteEntry, +) -> Result<(), RequestHandlerError> { + debug!("Begin executing SetCorsResponseHeadersMiddleware"); + + let cors = &resolved_route.route.cors; + + if cors.allowed_patterns.is_empty() { + return Ok(()); + } + + if let Some(origin) = request.origin()? + && cors.allowed_patterns.iter().any(|p| p.matches(origin)) + { + result.headers.insert( + http::header::ACCESS_CONTROL_ALLOW_ORIGIN, + origin.to_string(), + ); + result + .headers + .insert(http::header::VARY, "Origin".to_string()); + result.headers.insert( + http::header::ACCESS_CONTROL_ALLOW_CREDENTIALS, + "true".to_string(), + ); + } + + Ok(()) +} diff --git a/golem-worker-service/src/custom_api/error.rs b/golem-worker-service/src/custom_api/error.rs new file mode 100644 index 0000000000..f151be92d0 --- /dev/null +++ b/golem-worker-service/src/custom_api/error.rs @@ -0,0 +1,97 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::route_resolver::RouteResolverError; +use super::security::IdentityProviderError; +use super::security::session_store::SessionStoreError; +use crate::service::worker::WorkerServiceError; +use golem_common::{SafeDisplay, error_forwarding}; + +#[derive(Debug, thiserror::Error)] +pub enum RequestHandlerError { + #[error("Failed parsing value; Provided: {value}; Expected type: {expected}")] + ValueParsingFailed { + value: String, + expected: &'static str, + }, + #[error("Expected {expected} values to be provided, but found none")] + MissingValue { expected: &'static str }, + #[error("Expected {expected} values to be provided, but found too many")] + TooManyValues { expected: &'static str }, + #[error("Header value of {header_name} is not valid ascii")] + HeaderIsNotAscii { header_name: String }, + #[error("Request body was not valid json: {error}")] + BodyIsNotValidJson { error: String }, + #[error("Failed parsing json body: [{formatted}]", formatted=.errors.join(","))] + JsonBodyParsingFailed { errors: Vec }, + #[error("Agent response did not match expected type: {error}")] + AgentResponseTypeMismatch { error: String }, + #[error("Mime type {mime_type} is not supported. Allowed mime types: [{formatted_mime_types}]", formatted_mime_types=.allowed_mime_types.join(","))] + UnsupportedMimeType { + mime_type: String, + allowed_mime_types: Vec, + }, + #[error("Unknown OIDC state")] + UnknownOidcState, + #[error("OIDC token exchange failed")] + OidcTokenExchangeFailed, + #[error("Invariant violated: {msg}")] + InvariantViolated { msg: &'static str }, + #[error("Resolving route failed: {0}")] + ResolvingRouteFailed(#[from] RouteResolverError), + #[error("Invocation failed: {0}")] + AgentInvocationFailed(#[from] WorkerServiceError), + #[error(transparent)] + InternalError(#[from] anyhow::Error), +} + +impl RequestHandlerError { + pub fn invariant_violated(msg: &'static str) -> Self { + Self::InvariantViolated { msg } + } +} + +impl SafeDisplay for RequestHandlerError { + fn to_safe_string(&self) -> String { + match self { + Self::ValueParsingFailed { .. } => self.to_string(), + Self::MissingValue { .. } => self.to_string(), + Self::TooManyValues { .. } => self.to_string(), + Self::HeaderIsNotAscii { .. } => self.to_string(), + Self::BodyIsNotValidJson { .. } => self.to_string(), + Self::JsonBodyParsingFailed { .. } => self.to_string(), + Self::AgentResponseTypeMismatch { .. } => self.to_string(), + Self::UnsupportedMimeType { .. } => self.to_string(), + Self::UnknownOidcState => self.to_string(), + Self::OidcTokenExchangeFailed => self.to_string(), + + Self::InvariantViolated { .. } => "internal error".to_string(), + + Self::ResolvingRouteFailed(inner) => { + format!("Resolving route failed: {}", inner.to_safe_string()) + } + Self::AgentInvocationFailed(inner) => { + format!("Invocation failed: {}", inner.to_safe_string()) + } + + Self::InternalError(_) => "internal error".to_string(), + } + } +} + +error_forwarding!( + RequestHandlerError, + SessionStoreError, + IdentityProviderError +); diff --git a/golem-worker-service/src/custom_api/mod.rs b/golem-worker-service/src/custom_api/mod.rs index 50b265d60e..70be135f9c 100644 --- a/golem-worker-service/src/custom_api/mod.rs +++ b/golem-worker-service/src/custom_api/mod.rs @@ -12,19 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -mod agent_response_mapping; pub mod api_definition_lookup; +pub mod call_agent; +mod cors; +pub mod error; pub mod model; -mod parameter_parsing; pub mod poem_endpoint; -pub mod request; pub mod request_handler; pub mod route_resolver; pub mod router; pub mod security; use self::poem_endpoint::CustomApiPoemEndpoint; -use crate::service::Services; +use crate::bootstrap::Services; pub use model::*; pub fn make_custom_api_endpoint(services: &Services) -> CustomApiPoemEndpoint { diff --git a/golem-worker-service/src/custom_api/model.rs b/golem-worker-service/src/custom_api/model.rs index 7e79c64ce7..2120e713f1 100644 --- a/golem-worker-service/src/custom_api/model.rs +++ b/golem-worker-service/src/custom_api/model.rs @@ -12,16 +12,143 @@ // See the License for the specific language governing permissions and // limitations under the License. +use super::error::RequestHandlerError; +use chrono::{DateTime, Utc}; use golem_common::model::account::AccountId; use golem_common::model::agent::BinarySource; use golem_common::model::environment::EnvironmentId; -use golem_service_base::custom_api::{CorsOptions, SecuritySchemeDetails}; +use golem_service_base::custom_api::{ + CallAgentBehaviour, CorsOptions, CorsPreflightBehaviour, SecuritySchemeDetails, +}; use golem_service_base::custom_api::{PathSegment, RequestBodySchema, RouteBehaviour, RouteId}; -use http::Method; +use http::{HeaderMap, Method}; use http::{HeaderName, StatusCode}; -use std::collections::HashMap; +use openidconnect::Scope; +use openidconnect::core::CoreIdTokenClaims; +use std::collections::{HashMap, HashSet}; use std::fmt; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; +use uuid::Uuid; + +const COOKIE_HEADER_NAMES: [&str; 2] = ["cookie", "Cookie"]; + +pub struct RichRequest { + pub underlying: poem::Request, + pub request_id: Uuid, + pub authenticated_session: Option, + + parsed_cookies: OnceLock>, + parsed_query_params: OnceLock>>, +} + +impl RichRequest { + pub fn new(underlying: poem::Request) -> RichRequest { + RichRequest { + underlying, + request_id: Uuid::new_v4(), + authenticated_session: None, + parsed_cookies: OnceLock::new(), + parsed_query_params: OnceLock::new(), + } + } + + pub fn origin(&self) -> Result, RequestHandlerError> { + match self.underlying.headers().get("Origin") { + Some(header) => { + let result = + header + .to_str() + .map_err(|_| RequestHandlerError::HeaderIsNotAscii { + header_name: "Origin".to_string(), + })?; + Ok(Some(result)) + } + None => Ok(None), + } + } + + pub fn headers(&self) -> &HeaderMap { + self.underlying.headers() + } + + pub fn query_params(&self) -> &HashMap> { + self.parsed_query_params.get_or_init(|| { + let mut params: HashMap> = HashMap::new(); + + if let Some(q) = self.underlying.uri().query() { + for (key, value) in url::form_urlencoded::parse(q.as_bytes()).into_owned() { + params.entry(key).or_default().push(value); + } + } + + params + }) + } + + pub fn get_single_param(&self, name: &'static str) -> Result<&str, RequestHandlerError> { + match self.query_params().get(name).map(|qp| qp.as_slice()) { + Some([single]) => Ok(single), + None | Some([]) => Err(RequestHandlerError::MissingValue { expected: name }), + _ => Err(RequestHandlerError::TooManyValues { expected: name }), + } + } + + pub fn cookies(&self) -> &HashMap { + self.parsed_cookies.get_or_init(|| { + let mut map = HashMap::new(); + for header_name in COOKIE_HEADER_NAMES.iter() { + if let Some(value) = self.underlying.header(header_name) { + for part in value.split(';') { + let mut kv = part.splitn(2, '='); + if let (Some(k), Some(v)) = (kv.next(), kv.next()) { + map.insert(k.trim().to_string(), v.trim().to_string()); + } + } + } + } + map + }) + } + + pub fn cookie(&self, name: &str) -> Option<&str> { + self.cookies().get(name).map(|s| s.as_str()) + } + + pub fn set_authenticated_session(&mut self, session: OidcSession) { + self.authenticated_session = Some(session); + } + + pub fn authenticated_session(&self) -> Option<&OidcSession> { + self.authenticated_session.as_ref() + } +} + +pub struct OidcSession { + pub subject: String, + pub issuer: String, + + pub email: Option, + pub name: Option, + pub email_verified: Option, + pub given_name: Option, + pub family_name: Option, + pub picture: Option, + pub preferred_username: Option, + + pub claims: CoreIdTokenClaims, + pub scopes: HashSet, + pub expires_at: DateTime, +} + +impl OidcSession { + pub fn is_expired(&self) -> bool { + Utc::now() >= self.expires_at + } + + pub fn scopes(&self) -> &HashSet { + &self.scopes + } +} #[derive(Debug)] pub struct RichCompiledRoute { @@ -31,11 +158,32 @@ pub struct RichCompiledRoute { pub method: Method, pub path: Vec, pub body: RequestBodySchema, - pub behavior: RouteBehaviour, + pub behavior: RichRouteBehaviour, pub security_scheme: Option>, pub cors: CorsOptions, } +#[derive(Debug)] +pub enum RichRouteBehaviour { + CallAgent(CallAgentBehaviour), + CorsPreflight(CorsPreflightBehaviour), + OidcCallback(OidcCallbackBehaviour), +} + +impl From for RichRouteBehaviour { + fn from(value: RouteBehaviour) -> Self { + match value { + RouteBehaviour::CallAgent(inner) => Self::CallAgent(inner), + RouteBehaviour::CorsPreflight(inner) => Self::CorsPreflight(inner), + } + } +} + +#[derive(Debug)] +pub struct OidcCallbackBehaviour { + pub security_scheme: Arc, +} + #[derive(Debug)] pub struct RouteExecutionResult { pub status: StatusCode, diff --git a/golem-worker-service/src/custom_api/request.rs b/golem-worker-service/src/custom_api/request.rs deleted file mode 100644 index 71a6659ed9..0000000000 --- a/golem-worker-service/src/custom_api/request.rs +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright 2024-2025 Golem Cloud -// -// Licensed under the Golem Source License v1.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://license.golem.cloud/LICENSE -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use super::request_handler::RequestHandlerError; -use http::HeaderMap; -use std::collections::HashMap; -use uuid::Uuid; - -const COOKIE_HEADER_NAMES: [&str; 2] = ["cookie", "Cookie"]; - -pub struct RichRequest { - pub underlying: poem::Request, - pub request_id: Uuid, -} - -impl RichRequest { - pub fn new(underlying: poem::Request) -> RichRequest { - RichRequest { - underlying, - request_id: Uuid::new_v4(), - } - } - - pub fn origin(&self) -> Result, RequestHandlerError> { - match self.underlying.headers().get("Origin") { - Some(header) => { - let result = - header - .to_str() - .map_err(|_| RequestHandlerError::HeaderIsNotAscii { - header_name: "Origin".to_string(), - })?; - Ok(Some(result)) - } - None => Ok(None), - } - } - - pub fn headers(&self) -> &HeaderMap { - self.underlying.headers() - } - - pub fn query_params(&self) -> HashMap> { - let mut params: HashMap> = HashMap::new(); - - if let Some(q) = self.underlying.uri().query() { - for (key, value) in url::form_urlencoded::parse(q.as_bytes()).into_owned() { - params.entry(key).or_default().push(value); - } - } - - params - } - - pub fn cookies(&self) -> HashMap<&str, &str> { - let mut result = HashMap::new(); - - for header_name in COOKIE_HEADER_NAMES.iter() { - if let Some(value) = self.underlying.header(header_name) { - let parts: Vec<&str> = value.split(';').collect(); - for part in parts { - let key_value: Vec<&str> = part.split('=').collect(); - if let (Some(key), Some(value)) = (key_value.first(), key_value.get(1)) { - result.insert(key.trim(), value.trim()); - } - } - } - } - - result - } -} diff --git a/golem-worker-service/src/custom_api/request_handler.rs b/golem-worker-service/src/custom_api/request_handler.rs index 01e3a4f579..0de49e77d7 100644 --- a/golem-worker-service/src/custom_api/request_handler.rs +++ b/golem-worker-service/src/custom_api/request_handler.rs @@ -12,113 +12,38 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::agent_response_mapping::interpret_agent_response; -use super::parameter_parsing::{ - parse_path_segment_value, parse_path_segment_value_to_component_model, - parse_query_or_header_value, parse_request_body, -}; -use super::request::RichRequest; -use super::route_resolver::{ResolvedRouteEntry, RouteResolver, RouteResolverError}; -use super::{ParsedRequestBody, ResponseBody, RouteExecutionResult}; -use crate::service::worker::{WorkerService, WorkerServiceError}; +use super::call_agent::CallAgentHandler; +use super::cors::{apply_cors_outgoing_middleware, handle_cors_preflight_behaviour}; +use super::error::RequestHandlerError; +use super::model::RichRequest; +use super::model::RichRouteBehaviour; +use super::route_resolver::{ResolvedRouteEntry, RouteResolver}; +use super::security::handler::OidcHandler; +use super::{OidcCallbackBehaviour, ResponseBody, RouteExecutionResult}; use anyhow::anyhow; -use golem_common::model::agent::{ - AgentId, BinaryReference, BinaryReferenceValue, DataValue, ElementValue, ElementValues, - UntypedDataValue, UntypedElementValue, -}; -use golem_common::model::{IdempotencyKey, WorkerId}; -use golem_common::{error_forwarding, SafeDisplay}; -use golem_service_base::custom_api::{ConstructorParameter, MethodParameter, RouteBehaviour}; -use golem_service_base::model::auth::AuthCtx; +use golem_service_base::custom_api::CorsPreflightBehaviour; use golem_wasm::json::ValueAndTypeJsonExtensions; -use golem_wasm::IntoValue; -use golem_wasm::ValueAndType; -use http::StatusCode; -use poem::{Request, Response, ResponseBuilder}; -use std::collections::HashMap; +use poem::{Request, Response}; use std::sync::Arc; -use tracing::debug; -use uuid::Uuid; - -#[derive(Debug, thiserror::Error)] -pub enum RequestHandlerError { - #[error("Failed parsing value; Provided: {value}; Expected type: {expected}")] - ValueParsingFailed { - value: String, - expected: &'static str, - }, - #[error("Expected {expected} values to be provided, but found none")] - MissingValue { expected: &'static str }, - #[error("Expected {expected} values to be provided, but found too many")] - TooManyValues { expected: &'static str }, - #[error("Header value of {header_name} is not valid ascii")] - HeaderIsNotAscii { header_name: String }, - #[error("Request body was not valid json: {error}")] - BodyIsNotValidJson { error: String }, - #[error("Failed parsing json body: [{formatted}]", formatted=.errors.join(","))] - JsonBodyParsingFailed { errors: Vec }, - #[error("Agent response did not match expected type: {error}")] - AgentResponseTypeMismatch { error: String }, - #[error("Mime type {mime_type} is not supported. Allowed mime types: [{formatted_mime_types}]", formatted_mime_types=.allowed_mime_types.join(","))] - UnsupportedMimeType { - mime_type: String, - allowed_mime_types: Vec, - }, - #[error("Invariant violated: {msg}")] - InvariantViolated { msg: &'static str }, - #[error("Resolving route failed: {0}")] - ResolvingRouteFailed(#[from] RouteResolverError), - #[error("Invocation failed: {0}")] - AgentInvocationFailed(#[from] WorkerServiceError), - #[error(transparent)] - InternalError(#[from] anyhow::Error), -} - -impl RequestHandlerError { - pub fn invariant_violated(msg: &'static str) -> Self { - Self::InvariantViolated { msg } - } -} - -impl SafeDisplay for RequestHandlerError { - fn to_safe_string(&self) -> String { - match self { - Self::ValueParsingFailed { .. } => self.to_string(), - Self::MissingValue { .. } => self.to_string(), - Self::TooManyValues { .. } => self.to_string(), - Self::HeaderIsNotAscii { .. } => self.to_string(), - Self::BodyIsNotValidJson { .. } => self.to_string(), - Self::JsonBodyParsingFailed { .. } => self.to_string(), - Self::AgentResponseTypeMismatch { .. } => self.to_string(), - Self::UnsupportedMimeType { .. } => self.to_string(), - - Self::InvariantViolated { .. } => "internal error".to_string(), - - Self::ResolvingRouteFailed(inner) => { - format!("Resolving route failed: {}", inner.to_safe_string()) - } - Self::AgentInvocationFailed(inner) => { - format!("Invocation failed: {}", inner.to_safe_string()) - } - - Self::InternalError(_) => "internal error".to_string(), - } - } -} - -error_forwarding!(RequestHandlerError); +use tracing::{Instrument, debug}; pub struct RequestHandler { route_resolver: Arc, - worker_service: Arc, + call_agent_handler: Arc, + oidc_handler: Arc, } #[allow(irrefutable_let_patterns)] impl RequestHandler { - pub fn new(route_resolver: Arc, worker_service: Arc) -> Self { + pub fn new( + route_resolver: Arc, + call_agent_handler: Arc, + oidc_handler: Arc, + ) -> Self { Self { route_resolver, - worker_service, + call_agent_handler, + oidc_handler, } } @@ -127,301 +52,71 @@ impl RequestHandler { let matching_route = self.route_resolver.resolve_matching_route(&request).await?; let mut request = RichRequest::new(request); - let execution_result = self.execute_route(&mut request, &matching_route).await?; - let response = - route_execution_result_to_response(execution_result, &request, &matching_route)?; - Ok(response) - } - async fn execute_route( - &self, - request: &mut RichRequest, - resolved_route: &ResolvedRouteEntry, - ) -> Result { - match &resolved_route.route.behavior { - RouteBehaviour::CallAgent { .. } => { - self.execute_call_agent(request, resolved_route).await - } - - RouteBehaviour::CorsPreflight { - allowed_origins, - allowed_methods, - } => { - let origin = request.origin()?.ok_or(RequestHandlerError::MissingValue { - expected: "Origin header", - })?; - - let origin_allowed = allowed_origins - .iter() - .any(|pattern| pattern.matches(origin)); - if !origin_allowed { - return Ok(RouteExecutionResult { - status: StatusCode::FORBIDDEN, - headers: HashMap::new(), - body: ResponseBody::NoBody, - }); - } - - let allow_methods = allowed_methods - .iter() - .map(|m| { - let converted = http::Method::try_from(m.clone()).map_err(|_| { - RequestHandlerError::invariant_violated("HttpMethod conversion error") - })?; - let rendered = converted.to_string(); - Ok::<_, RequestHandlerError>(rendered) - }) - .collect::, _>>()? - .join(", "); - - let mut headers = HashMap::new(); + let execution_result = self + .execute_route_and_middlewares(&mut request, &matching_route) + .instrument(tracing::span!( + tracing::Level::INFO, + "handle_route", + domain = %matching_route.domain, + method = %matching_route.route.method, + route = %matching_route.route.path.iter().map(|p| p.to_string()).collect::>().join("/") + )) + .await?; - headers.insert( - http::header::ACCESS_CONTROL_ALLOW_ORIGIN, - origin.to_string(), - ); - headers.insert(http::header::ACCESS_CONTROL_ALLOW_METHODS, allow_methods); - headers.insert( - http::header::ACCESS_CONTROL_ALLOW_HEADERS, - "Content-Type, Authorization".to_string(), - ); - headers.insert(http::header::ACCESS_CONTROL_MAX_AGE, "3600".to_string()); - headers.insert(http::header::VARY, "Origin".to_string()); + let response = route_execution_result_to_response(execution_result)?; - Ok(RouteExecutionResult { - status: StatusCode::NO_CONTENT, - headers, - body: ResponseBody::NoBody, - }) - } - } + Ok(response) } - async fn execute_call_agent( + async fn execute_route_and_middlewares( &self, request: &mut RichRequest, resolved_route: &ResolvedRouteEntry, ) -> Result { - let RouteBehaviour::CallAgent { - expected_agent_response, - .. - } = &resolved_route.route.behavior - else { - unreachable!() - }; - - let worker_id = self.build_worker_id(resolved_route)?; - - let parsed_body = parse_request_body(request, &resolved_route.route.body).await?; - - let method_params = self.resolve_method_arguments(resolved_route, request, parsed_body)?; - - debug!("Invoking agent {worker_id}"); - - let agent_response = self - .invoke_agent(&worker_id, resolved_route, method_params) - .await?; - - debug!("Received agent response: {agent_response:?}"); - - debug!( - "Json agent response: {}", - agent_response.clone().unwrap().to_json_value().unwrap() - ); - - let route_result = interpret_agent_response(agent_response, expected_agent_response)?; - - debug!("Returning call agent route result: {route_result:?}"); - - Ok(route_result) - } - - fn build_worker_id( - &self, - resolved_route: &ResolvedRouteEntry, - ) -> Result { - let RouteBehaviour::CallAgent { - component_id, - agent_type, - constructor_parameters, - phantom, - .. - } = &resolved_route.route.behavior - else { - unreachable!() - }; - - let mut values = Vec::with_capacity(constructor_parameters.len()); - - for param in constructor_parameters { - match param { - ConstructorParameter::Path { - path_segment_index, - parameter_type, - } => { - let raw = resolved_route.captured_path_parameters - [usize::from(*path_segment_index)] - .clone(); - - let value = parse_path_segment_value_to_component_model(raw, parameter_type)?; - - values.push(ElementValue::ComponentModel(ValueAndType::new( - value, - parameter_type.clone().into(), - ))); - } - } + if let Some(short_circuit) = self + .oidc_handler + .apply_oidc_incoming_middleware(request, resolved_route) + .await? + { + return Ok(short_circuit); } - let data_value = DataValue::Tuple(ElementValues { elements: values }); - - let phantom_id = phantom.then(Uuid::new_v4); + let mut result = self.execute_route(request, resolved_route).await?; - let agent_id = AgentId::new(agent_type.clone(), data_value, phantom_id); + apply_cors_outgoing_middleware(&mut result, request, resolved_route).await?; - Ok(WorkerId { - component_id: *component_id, - worker_name: agent_id.to_string(), - }) + Ok(result) } - fn resolve_method_arguments( + async fn execute_route( &self, + request: &mut RichRequest, resolved_route: &ResolvedRouteEntry, - request: &RichRequest, - mut body: ParsedRequestBody, - ) -> Result, RequestHandlerError> { - let RouteBehaviour::CallAgent { - method_parameters, .. - } = &resolved_route.route.behavior - else { - unreachable!() - }; - - let query_params = request.query_params(); - let headers = request.headers(); - - let mut values = Vec::with_capacity(method_parameters.len()); - - for param in method_parameters { - let value = match param { - MethodParameter::Path { - path_segment_index, - parameter_type, - } => { - let raw = resolved_route.captured_path_parameters[usize::from(*path_segment_index)].clone(); - - parse_path_segment_value(raw, parameter_type)? - } - - MethodParameter::Query { - query_parameter_name, - parameter_type, - } => { - let empty = Vec::new(); - let vals = query_params.get(query_parameter_name).unwrap_or(&empty); - - parse_query_or_header_value(vals, parameter_type)? - } - - MethodParameter::Header { - header_name, - parameter_type, - } => { - let vals = headers - .get_all(header_name) - .iter() - .map(|h| { - h.to_str().map(String::from).map_err(|_| { - RequestHandlerError::HeaderIsNotAscii { - header_name: header_name.clone(), - } - }) - }) - .collect::, _>>()?; - - parse_query_or_header_value(&vals, parameter_type)? - } - - MethodParameter::JsonObjectBodyField { field_index } => { - match &body { - ParsedRequestBody::JsonBody(golem_wasm::Value::Record(fields)) => { - UntypedElementValue::ComponentModel(fields[usize::from(*field_index)].clone()) - } - - ParsedRequestBody::JsonBody(_) => { - return Err(RequestHandlerError::invariant_violated( - "Inconsistent API definition: JSON field parameter but body is not an object", - )) - } - - _ => return Err(RequestHandlerError::invariant_violated( - "JSON body parameter used but no JSON body schema", - )), - } - } - - MethodParameter::UnstructuredBinaryBody => { - match &mut body { - ParsedRequestBody::UnstructuredBinary(binary_source) => { - let binary_source = binary_source.take().ok_or_else(|| RequestHandlerError::invariant_violated( - "Parsed body was already consumed", - ))?; - - UntypedElementValue::UnstructuredBinary(BinaryReferenceValue { value: BinaryReference::Inline(binary_source) }) - } + ) -> Result { + match &resolved_route.route.behavior { + RichRouteBehaviour::CallAgent(behaviour) => { + self.call_agent_handler + .handle_call_agent_behaviour(request, resolved_route, behaviour) + .await + } - _ => return Err(RequestHandlerError::invariant_violated( - "Binary body parameter used but no binary body schema", - )), - } - } - }; + RichRouteBehaviour::CorsPreflight(CorsPreflightBehaviour { + allowed_origins, + allowed_methods, + }) => handle_cors_preflight_behaviour(request, allowed_origins, allowed_methods), - values.push(value); + RichRouteBehaviour::OidcCallback(OidcCallbackBehaviour { security_scheme }) => { + self.oidc_handler + .handle_oidc_callback_behaviour(request, security_scheme) + .await + } } - - Ok(values) - } - - async fn invoke_agent( - &self, - worker_id: &WorkerId, - resolved_route: &ResolvedRouteEntry, - params: Vec, - ) -> Result, RequestHandlerError> { - let RouteBehaviour::CallAgent { method_name, .. } = &resolved_route.route.behavior else { - unreachable!() - }; - - let method_params_data_value = UntypedDataValue::Tuple(params); - - self.worker_service - .invoke_and_await_owned_agent( - worker_id, - Some(IdempotencyKey::fresh()), - "golem:agent/guest.{invoke}".to_string(), - vec![ - golem_wasm::protobuf::Val::from(method_name.clone().into_value()), - golem_wasm::protobuf::Val::from(method_params_data_value.into_value()), - golem_wasm::protobuf::Val::from( - golem_common::model::agent::Principal::anonymous().into_value(), - ), - ], - None, - resolved_route.route.environment_id, - resolved_route.route.account_id, - AuthCtx::impersonated_user(resolved_route.route.account_id), - ) - .await - .map_err(Into::into) } } fn route_execution_result_to_response( result: RouteExecutionResult, - request: &RichRequest, - resolved_route: &ResolvedRouteEntry, ) -> Result { let mut response_builder = Response::builder().status(result.status); @@ -429,8 +124,6 @@ fn route_execution_result_to_response( response_builder = response_builder.header(name, value); } - response_builder = apply_cors_headers(response_builder, request, resolved_route)?; - match result.body { ResponseBody::NoBody => Ok(response_builder.finish()), @@ -449,34 +142,3 @@ fn route_execution_result_to_response( .set_content_type(body.binary_type.mime_type)), } } - -fn apply_cors_headers( - mut builder: ResponseBuilder, - request: &RichRequest, - resolved_route: &ResolvedRouteEntry, -) -> Result { - let cors = &resolved_route.route.cors; - - if cors.allowed_patterns.is_empty() { - return Ok(builder); - } - - let origin = match request.origin()? { - Some(o) => o, - None => return Ok(builder), // non-CORS request - }; - - if !cors - .allowed_patterns - .iter() - .any(|pattern| pattern.matches(origin)) - { - return Ok(builder); - } - - builder = builder.header(http::header::ACCESS_CONTROL_ALLOW_ORIGIN, origin); - builder = builder.header(http::header::VARY, "Origin"); - builder = builder.header(http::header::ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"); - - Ok(builder) -} diff --git a/golem-worker-service/src/custom_api/route_resolver.rs b/golem-worker-service/src/custom_api/route_resolver.rs index fce623872c..44611ddd27 100644 --- a/golem-worker-service/src/custom_api/route_resolver.rs +++ b/golem-worker-service/src/custom_api/route_resolver.rs @@ -13,19 +13,15 @@ // limitations under the License. use super::api_definition_lookup::{ApiDefinitionLookupError, HttpApiDefinitionsLookup}; +use super::model::RichCompiledRoute; use super::router::Router; use crate::config::RouteResolverConfig; -// use crate::model::{HttpMiddleware, RichCompiledRoute, RichGatewayBindingCompiled, SwaggerHtml}; -// use crate::swagger_ui::generate_swagger_html; +use crate::custom_api::{OidcCallbackBehaviour, RichRouteBehaviour}; +use golem_common::SafeDisplay; use golem_common::cache::SimpleCache; use golem_common::cache::{BackgroundEvictionMode, Cache, FullCacheEvictionMode}; use golem_common::model::domain_registration::Domain; -// use golem_service_base::custom_api::openapi::HttpApiDefinitionOpenApiSpec; -// use golem_service_base::custom_api::HttpCors; -use golem_service_base::custom_api::CompiledRoutes; -// use golem_service_base::custom_api::{RouteBehaviour, SwaggerUiBindingCompiled}; -use super::model::RichCompiledRoute; -use golem_common::SafeDisplay; +use golem_service_base::custom_api::{CompiledRoutes, CorsOptions, PathSegment, RequestBodySchema}; use std::collections::HashMap; use std::sync::Arc; use tracing::debug; @@ -151,46 +147,19 @@ impl RouteResolver { .map(|(id, details)| (id, Arc::new(details))) .collect(); - // let swagger_ui_htmls = Self::precompute_swagger_ui_htmls( - // domain, - // &compiled_routes.routes, - // &compiled_routes.security_schemes, - // ) - // .await?; - - // let cors_routes: HashMap = compiled_routes - // .routes - // .iter() - // .filter_map(|r| match &r.binding { - // RouteBehaviour::HttpCorsPreflight(inner) => { - // Some((r.path.clone(), inner.http_cors.clone())) - // } - // _ => None, - // }) - // .collect(); - let mut enriched_routes = Vec::with_capacity(compiled_routes.routes.len()); for route in compiled_routes.routes { - // let mut middlewares = Vec::new(); - let security_scheme = if let Some(security_scheme_id) = route.security_scheme { let security_scheme = security_schemes .get(&security_scheme_id) .ok_or(format!("Security scheme {security_scheme_id} not found"))? .clone(); - // middlewares.push(HttpMiddleware::AuthenticateRequest(security_scheme.clone())); Some(security_scheme) } else { None }; - // if route.method != RouteMethod::Options { - // if let Some(cors) = cors_routes.get(&route.path) { - // middlewares.push(HttpMiddleware::Cors(cors.clone())); - // } - // } - let enriched = RichCompiledRoute { account_id: compiled_routes.account_id, environment_id: compiled_routes.environment_id, @@ -201,7 +170,7 @@ impl RouteResolver { .map_err(|e| format!("Failed converting HttpMethod to http::Method: {e}"))?, path: route.path, body: route.body, - behavior: route.behavior, + behavior: route.behavior.into(), security_scheme, cors: route.cors, }; @@ -209,95 +178,40 @@ impl RouteResolver { enriched_routes.push(enriched); } - // let auth_call_back_routes = Self::get_auth_call_back_routes( - // &compiled_routes.account_id, - // &compiled_routes.environment_id, - // compiled_routes.security_schemes, - // )?; + // add synthethic oidc callback routes + for scheme in security_schemes.values() { + let redirect_url_path_segments: Vec = scheme + .redirect_url + .url() + .path_segments() + .ok_or_else(|| "Failed splitting security scheme redirect url".to_string())? + .map(|s| PathSegment::Literal { + value: s.to_string(), + }) + .collect(); + + let callback_route = RichCompiledRoute { + account_id: compiled_routes.account_id, + environment_id: compiled_routes.environment_id, + // TODO: Have some helper for synthethic vs user defined routes + route_id: -1, + method: http::Method::GET, + path: redirect_url_path_segments, + body: RequestBodySchema::Unused, + behavior: RichRouteBehaviour::OidcCallback(OidcCallbackBehaviour { + security_scheme: scheme.clone(), + }), + security_scheme: None, + cors: CorsOptions { + allowed_patterns: Vec::new(), + }, + }; - // for auth_call_back_route in auth_call_back_routes { - // let existing_route = transformed_routes.iter().find(|r| { - // r.method == auth_call_back_route.method && r.path == auth_call_back_route.path - // }); - // if existing_route.is_none() { - // transformed_routes.push(auth_call_back_route); - // } - // } + enriched_routes.push(callback_route); + } Ok(enriched_routes) } - - // async fn precompute_swagger_ui_htmls( - // domain: &Domain, - // compiled_routes: &[CompiledRoute], - // security_schemes: &HashMap, - // ) -> Result>, String> { - // let definitions_that_need_ui: HashMap = - // compiled_routes - // .iter() - // .filter_map(|r| match &r.binding { - // RouteBehaviour::SwaggerUi(inner) => { - // Some((inner.http_api_definition_id, *inner.clone())) - // } - // _ => None, - // }) - // .collect(); - - // let mut swagger_uis = HashMap::with_capacity(definitions_that_need_ui.len()); - // for (_, swagger_ui_binding) in definitions_that_need_ui { - // let matching_routes: Vec<&CompiledRoute> = compiled_routes - // .iter() - // .filter(|cr| cr.http_api_definition_id == swagger_ui_binding.http_api_definition_id) - // .collect(); - - // let openapi_definition = HttpApiDefinitionOpenApiSpec::from_routes( - // &swagger_ui_binding.http_api_definition_name, - // &swagger_ui_binding.http_api_definition_version, - // matching_routes, - // security_schemes, - // ) - // .await?; - - // let swagger_html = generate_swagger_html(&domain.0, openapi_definition)?; - // swagger_uis.insert( - // swagger_ui_binding.http_api_definition_id, - // Arc::new(swagger_html), - // ); - // } - - // Ok(swagger_uis) - // } - - // fn get_auth_call_back_routes( - // account_id: &AccountId, - // environment_id: &EnvironmentId, - // security_schemes: HashMap, - // ) -> Result, String> { - // let mut routes = vec![]; - - // for (_, scheme) in security_schemes { - // // In a security scheme, the auth-call-back (aka redirect_url) is full URL - // // and not just the relative path - // let redirect_url = scheme.redirect_url.clone(); - // let path = redirect_url.url().path(); - // let path = AllPathPatterns::parse(path)?; - // let method = RouteMethod::Get; - // let binding = RichGatewayBindingCompiled::HttpAuthCallBack(Box::new(scheme)); - - // let route = RichCompiledRoute { - // path, - // method, - // binding, - // middlewares: Vec::new(), - // account_id: *account_id, - // environment_id: *environment_id, - // }; - - // routes.push(route) - // } - - // Ok(routes) - // } } fn authority_from_request(request: &poem::Request) -> Result { diff --git a/golem-worker-service/src/custom_api/security/handler.rs b/golem-worker-service/src/custom_api/security/handler.rs new file mode 100644 index 0000000000..95a61e393f --- /dev/null +++ b/golem-worker-service/src/custom_api/security/handler.rs @@ -0,0 +1,198 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::IdentityProvider; +use super::model::AuthorizationUrl; +use super::session_store::SessionStore; +use crate::custom_api::error::RequestHandlerError; +use crate::custom_api::model::{OidcSession, RichRequest}; +use crate::custom_api::route_resolver::ResolvedRouteEntry; +use crate::custom_api::security::model::SessionId; +use crate::custom_api::{ResponseBody, RouteExecutionResult}; +use cookie::Cookie; +use golem_service_base::custom_api::SecuritySchemeDetails; +use http::StatusCode; +use openidconnect::{AuthorizationCode, OAuth2TokenResponse}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use tracing::debug; +use uuid::Uuid; + +const GOLEM_SESSION_ID_COOKIE_NAME: &str = "golem_session_id"; + +pub struct OidcHandler { + session_store: Arc, + identity_provider: Arc, +} + +impl OidcHandler { + pub fn new( + session_store: Arc, + identity_provider: Arc, + ) -> Self { + Self { + session_store, + identity_provider, + } + } + + pub async fn handle_oidc_callback_behaviour( + &self, + request: &mut RichRequest, + scheme: &Arc, + ) -> Result { + let code = request.get_single_param("code")?; + let state = request.get_single_param("state")?; + + let pending_login = self + .session_store + .take_pending_oidc_login(state) + .await? + .ok_or(RequestHandlerError::UnknownOidcState)?; + + let client = self.identity_provider.get_client(scheme).await?; + + let nonce = pending_login.nonce.clone(); + + let token_response = self + .identity_provider + .exchange_code_for_tokens(&client, &AuthorizationCode::new(code.to_string())) + .await + .map_err(|err| { + tracing::warn!("OIDC token exchange failed: {err}"); + RequestHandlerError::OidcTokenExchangeFailed + })?; + + let id_token_verifier = self.identity_provider.get_id_token_verifier(&client); + let id_token_claims = + self.identity_provider + .get_claims(&id_token_verifier, &token_response, &nonce)?; + + let session = OidcSession { + subject: id_token_claims.subject().to_string(), + issuer: id_token_claims.issuer().to_string(), + + email: id_token_claims.email().map(|v| v.to_string()), + name: id_token_claims + .name() + .and_then(|v| v.get(None)) + .map(|v| v.to_string()), + email_verified: id_token_claims.email_verified(), + given_name: id_token_claims + .given_name() + .and_then(|v| v.get(None)) + .map(|v| v.to_string()), + family_name: id_token_claims + .family_name() + .and_then(|v| v.get(None)) + .map(|v| v.to_string()), + picture: id_token_claims + .picture() + .and_then(|v| v.get(None)) + .map(|v| v.to_string()), + preferred_username: id_token_claims.preferred_username().map(|v| v.to_string()), + + claims: id_token_claims.clone(), + scopes: HashSet::from_iter(token_response.scopes().cloned().unwrap_or_default()), + expires_at: id_token_claims.expiration(), + }; + + let session_id = SessionId(Uuid::new_v4()); + + self.session_store + .store_authenticated_session(&session_id, session) + .await?; + + let cookie = Cookie::build((GOLEM_SESSION_ID_COOKIE_NAME, session_id.0.to_string())) + .path("/") + .http_only(true) + .secure(true) + .same_site(cookie::SameSite::Lax) + .build(); + + let mut headers = HashMap::new(); + headers.insert(http::header::SET_COOKIE, cookie.to_string()); + headers.insert(http::header::LOCATION, pending_login.original_uri.clone()); + + Ok(RouteExecutionResult { + status: StatusCode::FOUND, + headers, + body: ResponseBody::NoBody, + }) + } + + pub async fn apply_oidc_incoming_middleware( + &self, + request: &mut RichRequest, + resolved_route: &ResolvedRouteEntry, + ) -> Result, RequestHandlerError> { + debug!("Begin executing OidcSecurityMiddleware"); + + let Some(security_scheme) = resolved_route.route.security_scheme.as_ref() else { + return Ok(None); + }; + + let session_id = if let Some(s) = request.cookie(GOLEM_SESSION_ID_COOKIE_NAME) + && let Ok(parsed) = Uuid::parse_str(s) + { + SessionId(parsed) + } else { + // missing or invalid session_id -> restart flow + let execution_result = + start_oidc_flow_for_route(security_scheme, self.identity_provider.clone()).await?; + return Ok(Some(execution_result)); + }; + + let session_opt = self + .session_store + .get_authenticated_session(&session_id) + .await?; + + let Some(session) = session_opt else { + // session information missing, restart flow + let auth_url = + start_oidc_flow_for_route(security_scheme, self.identity_provider.clone()).await?; + return Ok(Some(auth_url)); + }; + + request.set_authenticated_session(session); + + Ok(None) + } +} + +async fn start_oidc_flow_for_route( + security_scheme: &SecuritySchemeDetails, + identity_provider: Arc, +) -> Result { + let client = identity_provider.get_client(security_scheme).await?; + let auth_url = identity_provider.get_authorization_url( + &client, + security_scheme.scopes.clone(), + None, + None, + ); + + Ok(start_oidc_flow(auth_url)) +} + +fn start_oidc_flow(auth_url: AuthorizationUrl) -> RouteExecutionResult { + let mut headers = std::collections::HashMap::new(); + headers.insert(http::header::LOCATION, auth_url.url.to_string()); + RouteExecutionResult { + status: http::StatusCode::FOUND, + headers, + body: ResponseBody::NoBody, + } +} diff --git a/golem-worker-service/src/custom_api/security/identity_provider.rs b/golem-worker-service/src/custom_api/security/identity_provider.rs index 350b0b52f4..69348ae52e 100644 --- a/golem-worker-service/src/custom_api/security/identity_provider.rs +++ b/golem-worker-service/src/custom_api/security/identity_provider.rs @@ -13,67 +13,66 @@ // limitations under the License. use super::identity_provider_metadata::GolemIdentityProviderMetadata; +use super::model::AuthorizationUrl; use super::open_id_client::OpenIdClient; use async_trait::async_trait; +use golem_common::IntoAnyhow; use golem_common::model::security_scheme::Provider; -use golem_common::SafeDisplay; use golem_service_base::custom_api::SecuritySchemeDetails; use openidconnect::core::{ CoreClient, CoreIdTokenClaims, CoreIdTokenVerifier, CoreProviderMetadata, CoreResponseType, CoreTokenResponse, }; use openidconnect::{AuthenticationFlow, AuthorizationCode, CsrfToken, Nonce, Scope}; -use std::fmt::{Display, Formatter}; use tracing::debug; -use url::Url; -// A high level abstraction of an identity-provider, that expose -// necessary functionalities that gets called at various points in gateway security integration. +#[derive(Debug, thiserror::Error)] +pub enum IdentityProviderError { + #[error("Failed to initialize client: {0}")] + ClientInitError(String), + #[error("Invalid issuer URL: {0}")] + InvalidIssuerUrl(String), + #[error("Failed to discover provider metadata: {0}")] + FailedToDiscoverProviderMetadata(String), + #[error("Failed to exchange code for tokens: {0}")] + FailedToExchangeCodeForTokens(String), + #[error("ID token verification error: {0}")] + IdTokenVerificationError(String), +} + +impl IntoAnyhow for IdentityProviderError { + fn into_anyhow(self) -> anyhow::Error { + anyhow::Error::from(self).context("IdentityProviderError") + } +} + #[async_trait] pub trait IdentityProvider: Send + Sync { - // Fetches the provider metadata from the issuer url, and this must be called - // during the registration of the security scheme with golem. - // The security scheme regisration stores the provider metadata, along with the security scheme - // in the security scheme store of Golem async fn get_provider_metadata( &self, provider: &Provider, ) -> Result; - // Exchange of Code token happens during the auth_call_back phase of the OpenID workflow - // In other words, this gets called only during the execution of static binding backing auth_call_back endpoint. async fn exchange_code_for_tokens( &self, client: &OpenIdClient, code: &AuthorizationCode, ) -> Result; - // A client can be created given provider-metadata at any phase of the security workflow in API Gateway. - // It can be created to create the authorisation URL to redirect user to the provider's login page - // Or It can be created before exchange of token during the execution of static binding backing auth_call_back endpoint. async fn get_client( &self, security_scheme: &SecuritySchemeDetails, ) -> Result; - // Get IDToken verifier - // For the most part, this is an internal detail to openidconnect, however, - // to test verifying claims using our own key pairs, this can be exposed fn get_id_token_verifier<'a>(&self, client: &'a OpenIdClient) -> CoreIdTokenVerifier<'a>; - // Claims are fetched from the ID token, and this gets called during the execution of static binding backing auth_call_back endpoint. - // If needed this can be called just before serving the protected route, to fetch the claims from the ID token as a middleware - // and feed it to the protected route handler through Rib. In any case, claims needs to be stored in a session - // as the OAuth2 workflow in OpenID gets initiated by the gateway and not the client user-agent. fn get_claims( &self, client: &CoreIdTokenVerifier, - core_token_response: CoreTokenResponse, + core_token_response: &CoreTokenResponse, nonce: &Nonce, ) -> Result; - // This gets called during the redirect to the provider's login page, - // and this is the first step in the OAuth2 workflow in serving a protected route. fn get_authorization_url( &self, client: &OpenIdClient, @@ -83,67 +82,24 @@ pub trait IdentityProvider: Send + Sync { ) -> AuthorizationUrl; } -pub struct AuthorizationUrl { - pub url: Url, - pub csrf_state: CsrfToken, - pub nonce: Nonce, -} - -#[derive(Debug, Clone)] -pub enum IdentityProviderError { - ClientInitError(String), - InvalidIssuerUrl(String), - FailedToDiscoverProviderMetadata(String), - FailedToExchangeCodeForTokens(String), - IdTokenVerificationError(String), -} - -// To satisfy thiserror -// https://github.com/golemcloud/golem/issues/1071 -impl Display for IdentityProviderError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.to_safe_string()) - } -} - -impl SafeDisplay for IdentityProviderError { - fn to_safe_string(&self) -> String { - match self { - IdentityProviderError::ClientInitError(err) => format!("ClientInitError: {err}"), - IdentityProviderError::InvalidIssuerUrl(err) => format!("InvalidIssuerUrl: {err}"), - IdentityProviderError::FailedToDiscoverProviderMetadata(err) => { - format!("FailedToDiscoverProviderMetadata: {err}") - } - IdentityProviderError::FailedToExchangeCodeForTokens(err) => { - format!("FailedToExchangeCodeForTokens: {err}") - } - IdentityProviderError::IdTokenVerificationError(err) => { - format!("IdTokenVerificationError: {err}") - } - } - } -} - pub struct DefaultIdentityProvider; #[async_trait] impl IdentityProvider for DefaultIdentityProvider { - // To be called during API definition registration to then store them in the database async fn get_provider_metadata( &self, provider: &Provider, ) -> Result { - let provide_metadata = CoreProviderMetadata::discover_async( + let provider_metadata = CoreProviderMetadata::discover_async( provider.issuer_url(), openidconnect::reqwest::async_http_client, ) .await .map_err(|err| IdentityProviderError::FailedToDiscoverProviderMetadata(err.to_string()))?; - Ok(provide_metadata) + Ok(provider_metadata) } - // To be called during call_back authentication URL which is a injected URL async fn exchange_code_for_tokens( &self, client: &OpenIdClient, @@ -189,7 +145,7 @@ impl IdentityProvider for DefaultIdentityProvider { fn get_claims( &self, id_token_verifier: &CoreIdTokenVerifier, - core_token_response: CoreTokenResponse, + core_token_response: &CoreTokenResponse, nonce: &Nonce, ) -> Result { let id_token_claims: &CoreIdTokenClaims = core_token_response diff --git a/golem-worker-service/src/custom_api/security/mod.rs b/golem-worker-service/src/custom_api/security/mod.rs index c47cd40544..ef986a331d 100644 --- a/golem-worker-service/src/custom_api/security/mod.rs +++ b/golem-worker-service/src/custom_api/security/mod.rs @@ -12,9 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +pub mod handler; mod identity_provider; mod identity_provider_metadata; +mod model; mod open_id_client; +pub mod session_store; pub use identity_provider::*; pub use open_id_client::*; diff --git a/golem-worker-service/src/custom_api/security/model.rs b/golem-worker-service/src/custom_api/security/model.rs new file mode 100644 index 0000000000..18d58af8b3 --- /dev/null +++ b/golem-worker-service/src/custom_api/security/model.rs @@ -0,0 +1,33 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use golem_common::model::security_scheme::SecuritySchemeId; +use openidconnect::{CsrfToken, Nonce}; +use url::Url; +use uuid::Uuid; + +pub struct SessionId(pub Uuid); + +#[derive(Debug)] +pub struct PendingOidcLogin { + pub scheme_id: SecuritySchemeId, + pub original_uri: String, + pub nonce: Nonce, +} + +pub struct AuthorizationUrl { + pub url: Url, + pub csrf_state: CsrfToken, + pub nonce: Nonce, +} diff --git a/golem-worker-service/src/custom_api/security/session_store.rs b/golem-worker-service/src/custom_api/security/session_store.rs new file mode 100644 index 0000000000..0d927366c4 --- /dev/null +++ b/golem-worker-service/src/custom_api/security/session_store.rs @@ -0,0 +1,525 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::model::{PendingOidcLogin, SessionId}; +use crate::custom_api::model::OidcSession; +use anyhow::anyhow; +use async_trait::async_trait; +use bytes::Bytes; +use chrono::{TimeDelta, Utc}; +use fred::types::Expiration; +use golem_common::error_forwarding; +use golem_common::redis::{RedisError, RedisPool}; +use golem_service_base::db::sqlite::SqlitePool; +use golem_service_base::repo::RepoError; +use sqlx::Row; +use std::time::Duration; +use tokio::task; +use tokio::time::interval; +use tracing::{Instrument, error}; + +#[derive(Debug, thiserror::Error)] +pub enum SessionStoreError { + #[error(transparent)] + InternalError(#[from] anyhow::Error), +} + +error_forwarding!(SessionStoreError, RepoError); + +impl From for SessionStoreError { + fn from(value: RedisError) -> Self { + Self::InternalError(anyhow::Error::from(value).context("RedisError")) + } +} + +#[async_trait] +pub trait SessionStore: Send + Sync { + async fn store_pending_oidc_login( + &self, + state: String, + login: PendingOidcLogin, + ) -> Result<(), SessionStoreError>; + + async fn take_pending_oidc_login( + &self, + state: &str, + ) -> Result, SessionStoreError>; + + async fn store_authenticated_session( + &self, + session_id: &SessionId, + session: OidcSession, + ) -> Result<(), SessionStoreError>; + + async fn get_authenticated_session( + &self, + session_id: &SessionId, + ) -> Result, SessionStoreError>; +} + +#[derive(Clone)] +pub struct RedisSessionStore { + redis: RedisPool, + pending_login_expiration: Expiration, +} + +impl RedisSessionStore { + pub fn new(redis: RedisPool, pending_login_expiration: Expiration) -> Self { + Self { + redis, + pending_login_expiration, + } + } + + fn redis_key_for_session(session_id: &SessionId) -> String { + format!("oidc_session:{}", session_id.0) + } + + fn redis_key_for_pending(state: &str) -> String { + format!("oidc_pending_login:{}", state) + } +} + +#[async_trait] +impl SessionStore for RedisSessionStore { + async fn store_pending_oidc_login( + &self, + state: String, + login: PendingOidcLogin, + ) -> Result<(), SessionStoreError> { + let record = records::PendingOidcLoginRecord::from(login); + let serialized = golem_common::serialization::serialize(&record) + .map_err(|e| anyhow!("PendingOidcLoginRecord serialization error: {e}"))?; + + let _: () = self + .redis + .with("session_store", "store_pending_oidc_login") + .set( + Self::redis_key_for_pending(&state), + serialized, + Some(self.pending_login_expiration.clone()), + None, + false, + ) + .await?; + + Ok(()) + } + + async fn take_pending_oidc_login( + &self, + state: &str, + ) -> Result, SessionStoreError> { + let key = Self::redis_key_for_pending(state); + let maybe_bytes: Option = self + .redis + .with("session_store", "take_pending_oidc_login") + .get(&key) + .await?; + + if let Some(bytes) = maybe_bytes { + let record: records::PendingOidcLoginRecord = + golem_common::serialization::deserialize(&bytes) + .map_err(|e| anyhow!("PendingOidcLogin deserialization error: {e}"))?; + let login = PendingOidcLogin::from(record); + + let _: i32 = self + .redis + .with("session_store", "del_pending") + .del(&key) + .await?; + Ok(Some(login)) + } else { + Ok(None) + } + } + + async fn store_authenticated_session( + &self, + session_id: &SessionId, + session: OidcSession, + ) -> Result<(), SessionStoreError> { + let record = records::OidcSessionRecord::try_from(session)?; + let serialized = golem_common::serialization::serialize(&record) + .map_err(|e| anyhow!("OidcSessionRecord serialization error: {e}"))?; + + let now = chrono::Utc::now(); + let ttl_secs = (record.expires_at - now).num_seconds(); + let expiration = if ttl_secs > 0 { + Expiration::EX(ttl_secs) + } else { + Expiration::EX(1) + }; + + let _: () = self + .redis + .with("session_store", "store_authenticated_session") + .set( + Self::redis_key_for_session(session_id), + serialized, + Some(expiration), + None, + false, + ) + .await?; + + Ok(()) + } + + async fn get_authenticated_session( + &self, + session_id: &SessionId, + ) -> Result, SessionStoreError> { + let maybe_bytes: Option = self + .redis + .with("session_store", "get_authenticated_session") + .get(&Self::redis_key_for_session(session_id)) + .await?; + + if let Some(bytes) = maybe_bytes { + let record: records::OidcSessionRecord = + golem_common::serialization::deserialize(&bytes) + .map_err(|e| anyhow!("OidcSession deserialization error: {e}"))?; + let session = OidcSession::try_from(record)?; + + Ok(Some(session)) + } else { + Ok(None) + } + } +} + +pub struct SqliteSessionStore { + pool: SqlitePool, + pending_login_expiration: i64, +} + +impl SqliteSessionStore { + pub async fn new( + pool: SqlitePool, + pending_login_expiration: i64, + cleanup_interval: Duration, + ) -> anyhow::Result { + Self::init(&pool).await?; + Self::spawn_expiration_task(pool.clone(), cleanup_interval); + Ok(Self { + pool, + pending_login_expiration, + }) + } + + async fn init(pool: &SqlitePool) -> anyhow::Result<()> { + pool.with_rw("session_store", "init") + .execute(sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS oidc_pending_login ( + state TEXT PRIMARY KEY, + value BLOB NOT NULL, + expires_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS oidc_session ( + session_id TEXT PRIMARY KEY, + value BLOB NOT NULL, + expires_at INTEGER NOT NULL + ); + "#, + )) + .await?; + + Ok(()) + } + + fn spawn_expiration_task(db_pool: SqlitePool, cleanup_interval: Duration) { + task::spawn( + async move { + let mut cleanup_interval = interval(cleanup_interval); + + loop { + cleanup_interval.tick().await; + + if let Err(e) = Self::cleanup_expired_oidc_pending_login( + db_pool.clone(), + Self::current_time(), + ) + .await + { + error!("Failed to expire oidc pending logins: {}", e); + } + + if let Err(e) = + Self::cleanup_expired_oidc_session(db_pool.clone(), Self::current_time()) + .await + { + error!("Failed to expire oidc sessions: {}", e); + } + } + } + .in_current_span(), + ); + } + + async fn cleanup_expired_oidc_pending_login( + pool: SqlitePool, + current_time: i64, + ) -> anyhow::Result<()> { + let query = + sqlx::query("DELETE FROM oidc_pending_login WHERE expiry_time < ?;").bind(current_time); + + pool.with_rw("session_store", "cleanup_expired_oidc_pending_login") + .execute(query) + .await?; + + Ok(()) + } + + async fn cleanup_expired_oidc_session( + pool: SqlitePool, + current_time: i64, + ) -> anyhow::Result<()> { + let query = + sqlx::query("DELETE FROM oidc_session WHERE expiry_time < ?;").bind(current_time); + + pool.with_rw("session_store", "cleanup_expired_oidc_session") + .execute(query) + .await?; + + Ok(()) + } + + pub fn current_time() -> i64 { + chrono::Utc::now().timestamp() + } +} + +#[async_trait] +impl SessionStore for SqliteSessionStore { + async fn store_pending_oidc_login( + &self, + state: String, + login: PendingOidcLogin, + ) -> Result<(), SessionStoreError> { + let record = records::PendingOidcLoginRecord::from(login); + let serialized = golem_common::serialization::serialize(&record) + .map_err(|e| SessionStoreError::InternalError(anyhow::anyhow!(e)))?; + + let expiry = Utc::now() + .checked_add_signed(TimeDelta::seconds(self.pending_login_expiration)) + .ok_or_else(|| anyhow!("Failed to compute expiry"))? + .timestamp(); + + self + .pool + .with_rw("session_store", "store_pending_oidc_login") + .execute( + sqlx::query("INSERT OR REPLACE INTO oidc_pending_login (state, value, expires_at) VALUES (?, ?, ?)") + .bind(state) + .bind(serialized) + .bind(expiry) + ) + .await?; + + Ok(()) + } + + async fn take_pending_oidc_login( + &self, + state: &str, + ) -> Result, SessionStoreError> { + let row = self + .pool + .with_ro("session_store", "take_pending_oidc_login_read") + .fetch_optional( + sqlx::query("SELECT value FROM oidc_pending_login WHERE state = ?").bind(state), + ) + .await?; + + if let Some(row) = row { + let bytes: Vec = row.get(0); + let record: records::PendingOidcLoginRecord = + golem_common::serialization::deserialize(&bytes) + .map_err(|e| SessionStoreError::InternalError(anyhow::anyhow!(e)))?; + + let login = PendingOidcLogin::from(record); + + self.pool + .with_rw("session_store", "take_pending_oidc_login_write") + .execute(sqlx::query("DELETE FROM oidc_pending_login WHERE state = ?").bind(state)) + .await?; + + Ok(Some(login)) + } else { + Ok(None) + } + } + + async fn store_authenticated_session( + &self, + session_id: &SessionId, + session: OidcSession, + ) -> Result<(), SessionStoreError> { + let record = records::OidcSessionRecord::try_from(session)?; + let serialized = golem_common::serialization::serialize(&record) + .map_err(|e| SessionStoreError::InternalError(anyhow::anyhow!(e)))?; + + let expires_at = record.expires_at.timestamp(); + + self + .pool + .with_rw("session_store", "store_authenticated_session") + .execute( + sqlx::query("INSERT OR REPLACE INTO oidc_session (session_id, value, expires_at) VALUES (?, ?, ?)") + .bind(session_id.0) + .bind(serialized) + .bind(expires_at) + ) + .await?; + + Ok(()) + } + + async fn get_authenticated_session( + &self, + session_id: &SessionId, + ) -> Result, SessionStoreError> { + let row = self + .pool + .with_ro("session_store", "get_authenticated_session_read") + .fetch_optional( + sqlx::query("SELECT value, expires_at FROM oidc_session WHERE session_id = ?") + .bind(session_id.0), + ) + .await?; + + if let Some(row) = row { + let bytes: Vec = row.get(0); + let record: records::OidcSessionRecord = + golem_common::serialization::deserialize(&bytes) + .map_err(|e| SessionStoreError::InternalError(anyhow::anyhow!(e)))?; + + let session = OidcSession::try_from(record)?; + + Ok(Some(session)) + } else { + Ok(None) + } + } +} + +mod records { + use super::SessionStoreError; + use crate::custom_api::model::OidcSession; + use crate::custom_api::security::model::PendingOidcLogin; + use anyhow::anyhow; + use chrono::{DateTime, Utc}; + use desert_rust::BinaryCodec; + use golem_common::model::security_scheme::SecuritySchemeId; + use openidconnect::{Nonce, Scope}; + use std::collections::HashSet; + + #[derive(Debug, BinaryCodec)] + #[desert(evolution())] + pub struct PendingOidcLoginRecord { + pub scheme_id: SecuritySchemeId, + pub original_uri: String, + pub nonce: String, + } + + impl From for PendingOidcLoginRecord { + fn from(value: PendingOidcLogin) -> Self { + Self { + scheme_id: value.scheme_id, + original_uri: value.original_uri, + nonce: value.nonce.secret().clone(), + } + } + } + + impl From for PendingOidcLogin { + fn from(value: PendingOidcLoginRecord) -> Self { + Self { + scheme_id: value.scheme_id, + original_uri: value.original_uri, + nonce: Nonce::new(value.nonce), + } + } + } + + #[derive(Debug, BinaryCodec)] + #[desert(evolution())] + pub struct OidcSessionRecord { + pub subject: String, + pub issuer: String, + + pub email: Option, + pub name: Option, + pub email_verified: Option, + pub given_name: Option, + pub family_name: Option, + pub picture: Option, + pub preferred_username: Option, + + pub claims: String, + pub scopes: HashSet, + pub expires_at: DateTime, + } + + impl TryFrom for OidcSessionRecord { + type Error = SessionStoreError; + + fn try_from(value: OidcSession) -> Result { + Ok(Self { + subject: value.subject, + issuer: value.issuer, + + email: value.email, + name: value.name, + email_verified: value.email_verified, + given_name: value.given_name, + family_name: value.family_name, + picture: value.picture, + preferred_username: value.preferred_username, + + claims: serde_json::to_string(&value.claims) + .map_err(|e| anyhow!("CoreIdTokenClaims serialization error: {e}"))?, + scopes: value.scopes.into_iter().map(|s| s.to_string()).collect(), + expires_at: value.expires_at, + }) + } + } + + impl TryFrom for OidcSession { + type Error = SessionStoreError; + + fn try_from(value: OidcSessionRecord) -> Result { + Ok(Self { + subject: value.subject, + issuer: value.issuer, + + email: value.email, + name: value.name, + email_verified: value.email_verified, + given_name: value.given_name, + family_name: value.family_name, + picture: value.picture, + preferred_username: value.preferred_username, + + claims: serde_json::from_str(&value.claims) + .map_err(|e| anyhow!("CoreIdTokenClaims deserialization error: {e}"))?, + scopes: value.scopes.into_iter().map(Scope::new).collect(), + expires_at: value.expires_at, + }) + } + } +} diff --git a/golem-worker-service/src/grpcapi/mod.rs b/golem-worker-service/src/grpcapi/mod.rs index b3235c27cd..12e44b0f80 100644 --- a/golem-worker-service/src/grpcapi/mod.rs +++ b/golem-worker-service/src/grpcapi/mod.rs @@ -15,27 +15,27 @@ mod error; mod worker; +use crate::bootstrap::Services; use crate::config::GrpcApiConfig; use crate::grpcapi::worker::WorkerGrpcApi; -use crate::service::Services; use futures::TryFutureExt; use golem_api_grpc::proto; use golem_api_grpc::proto::golem::common::{ErrorBody, ErrorsBody}; use golem_api_grpc::proto::golem::worker::v1::worker_service_server::WorkerServiceServer; use golem_api_grpc::proto::golem::worker::v1::{ - worker_error, worker_execution_error, WorkerError, WorkerExecutionError, + WorkerError, WorkerExecutionError, worker_error, worker_execution_error, }; -use golem_common::model::component::ComponentFilePath; use golem_common::model::WorkerId; +use golem_common::model::component::ComponentFilePath; use golem_service_base::grpc::server::GrpcServerTlsConfig; use golem_wasm::json::OptionallyValueAndTypeJson; use std::net::{Ipv4Addr, SocketAddrV4}; use tokio::net::TcpListener; use tokio::task::JoinSet; use tokio_stream::wrappers::TcpListenerStream; +use tonic::Status; use tonic::codec::CompressionEncoding; use tonic::transport::Server; -use tonic::Status; use tonic_tracing_opentelemetry::middleware; use tonic_tracing_opentelemetry::middleware::filters; use tracing::Instrument; diff --git a/golem-worker-service/src/grpcapi/worker.rs b/golem-worker-service/src/grpcapi/worker.rs index 182d6e7a92..d894dd79d3 100644 --- a/golem-worker-service/src/grpcapi/worker.rs +++ b/golem-worker-service/src/grpcapi/worker.rs @@ -16,22 +16,22 @@ use super::error::WorkerTraceErrorKind; use super::{bad_request_error, validate_protobuf_worker_id}; use crate::service::worker::WorkerService; use golem_api_grpc::proto::golem::common::Empty; +use golem_api_grpc::proto::golem::worker::InvokeResultTyped; use golem_api_grpc::proto::golem::worker::v1::worker_service_server::WorkerService as GrpcWorkerService; use golem_api_grpc::proto::golem::worker::v1::{ + CompletePromiseRequest, CompletePromiseResponse, ForkWorkerRequest, ForkWorkerResponse, + InvokeAndAwaitRequest, InvokeAndAwaitResponse, InvokeRequest, InvokeResponse, + LaunchNewWorkerRequest, LaunchNewWorkerResponse, LaunchNewWorkerSuccessResponse, + ResumeWorkerRequest, ResumeWorkerResponse, RevertWorkerRequest, RevertWorkerResponse, + UpdateWorkerRequest, UpdateWorkerResponse, WorkerError as GrpcWorkerError, complete_promise_response, fork_worker_response, invoke_and_await_response, invoke_response, launch_new_worker_response, resume_worker_response, revert_worker_response, - update_worker_response, CompletePromiseRequest, CompletePromiseResponse, ForkWorkerRequest, - ForkWorkerResponse, InvokeAndAwaitRequest, InvokeAndAwaitResponse, InvokeRequest, - InvokeResponse, LaunchNewWorkerRequest, LaunchNewWorkerResponse, - LaunchNewWorkerSuccessResponse, ResumeWorkerRequest, ResumeWorkerResponse, RevertWorkerRequest, - RevertWorkerResponse, UpdateWorkerRequest, UpdateWorkerResponse, - WorkerError as GrpcWorkerError, + update_worker_response, }; -use golem_api_grpc::proto::golem::worker::InvokeResultTyped; +use golem_common::model::WorkerId; use golem_common::model::component::ComponentRevision; use golem_common::model::oplog::OplogIndex; use golem_common::model::worker::WorkerUpdateMode; -use golem_common::model::WorkerId; use golem_common::recorded_grpc_api_request; use golem_service_base::grpc::{ proto_component_id_string, proto_idempotency_key_string, diff --git a/golem-worker-service/src/lib.rs b/golem-worker-service/src/lib.rs index 404426c3c9..dd641a5fb8 100644 --- a/golem-worker-service/src/lib.rs +++ b/golem-worker-service/src/lib.rs @@ -13,6 +13,7 @@ // limitations under the License. pub mod api; +pub mod bootstrap; pub mod config; pub mod custom_api; pub mod grpcapi; @@ -21,9 +22,9 @@ pub mod model; pub mod path; pub mod service; +use crate::bootstrap::Services; use crate::config::WorkerServiceConfig; -use crate::service::Services; -use anyhow::{anyhow, Context}; +use anyhow::{Context, anyhow}; use golem_common::poem::LazyEndpointExt; use opentelemetry_sdk::trace::SdkTracer; use poem::endpoint::{BoxEndpoint, PrometheusExporter}; @@ -33,7 +34,7 @@ use poem::middleware::{CookieJarManager, Cors, OpenTelemetryMetrics, OpenTelemet use poem::{EndpointExt, Route}; use prometheus::Registry; use tokio::task::JoinSet; -use tracing::{info, Instrument}; +use tracing::{Instrument, info}; #[cfg(test)] test_r::enable!(); @@ -62,9 +63,7 @@ impl WorkerService { config: WorkerServiceConfig, prometheus_registry: Registry, ) -> anyhow::Result { - let services: Services = Services::new(&config) - .await - .map_err(|err| anyhow!(err).context("Service initialization"))?; + let services: Services = Services::new(&config).await?; Ok(Self { config, diff --git a/golem-worker-service/src/model.rs b/golem-worker-service/src/model.rs index 0e656e7790..4dabb076ad 100644 --- a/golem-worker-service/src/model.rs +++ b/golem-worker-service/src/model.rs @@ -12,11 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -// use crate::gateway_api_definition::{ApiDefinitionId, ApiVersion}; -// use crate::gateway_api_deployment::ApiSite; -use golem_common::model::worker::WorkerMetadataDto; use golem_common::model::ScanCursor; -// use golem_service_base::custom_api::HttpCors; +use golem_common::model::worker::WorkerMetadataDto; use poem_openapi::Object; use std::fmt::Debug; diff --git a/golem-worker-service/src/server.rs b/golem-worker-service/src/server.rs index 3fced9d00b..4bdb7cf1f8 100644 --- a/golem-worker-service/src/server.rs +++ b/golem-worker-service/src/server.rs @@ -12,12 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -use anyhow::anyhow; -use golem_common::tracing::init_tracing_with_default_env_filter; use golem_common::SafeDisplay; -use golem_worker_service::config::{make_worker_service_config_loader, WorkerServiceConfig}; -use golem_worker_service::service::Services; +use golem_common::tracing::init_tracing_with_default_env_filter; use golem_worker_service::WorkerService; +use golem_worker_service::bootstrap::Services; +use golem_worker_service::config::{WorkerServiceConfig, make_worker_service_config_loader}; use opentelemetry::global; use opentelemetry_sdk::metrics::MeterProviderBuilder; use opentelemetry_sdk::trace::SdkTracer; @@ -62,9 +61,7 @@ fn main() -> anyhow::Result<()> { pub async fn dump_openapi_yaml() -> anyhow::Result<()> { let config = WorkerServiceConfig::default(); - let services = Services::new(&config) - .await - .map_err(|e| anyhow!("Services - init error: {}", e))?; + let services = Services::new(&config).await?; let open_api_service = golem_worker_service::api::make_open_api_service(&services); println!("{}", open_api_service.spec_yaml()); Ok(()) diff --git a/golem-worker-service/src/service/auth.rs b/golem-worker-service/src/service/auth.rs index a1ab610644..a6654274f6 100644 --- a/golem-worker-service/src/service/auth.rs +++ b/golem-worker-service/src/service/auth.rs @@ -18,7 +18,7 @@ use async_trait::async_trait; use golem_common::cache::{BackgroundEvictionMode, Cache, FullCacheEvictionMode, SimpleCache}; use golem_common::model::auth::TokenSecret; use golem_common::model::environment::EnvironmentId; -use golem_common::{error_forwarding, SafeDisplay}; +use golem_common::{SafeDisplay, error_forwarding}; use golem_service_base::clients::registry::{RegistryService, RegistryServiceError}; use golem_service_base::model::auth::AuthorizationError; use golem_service_base::model::auth::{AuthCtx, AuthDetailsForEnvironment, EnvironmentAction}; diff --git a/golem-worker-service/src/service/component.rs b/golem-worker-service/src/service/component.rs index df22b7f5fc..58eaf86722 100644 --- a/golem-worker-service/src/service/component.rs +++ b/golem-worker-service/src/service/component.rs @@ -18,7 +18,7 @@ use async_trait::async_trait; use golem_common::cache::{BackgroundEvictionMode, Cache, FullCacheEvictionMode, SimpleCache}; use golem_common::model::component::ComponentId; use golem_common::model::component::{ComponentDto, ComponentRevision}; -use golem_common::{error_forwarding, SafeDisplay}; +use golem_common::{SafeDisplay, error_forwarding}; use golem_service_base::clients::registry::{RegistryService, RegistryServiceError}; use std::sync::Arc; diff --git a/golem-worker-service/src/service/limit.rs b/golem-worker-service/src/service/limit.rs index 193df4df55..5549d12667 100644 --- a/golem-worker-service/src/service/limit.rs +++ b/golem-worker-service/src/service/limit.rs @@ -13,9 +13,9 @@ // limitations under the License. use async_trait::async_trait; -use golem_common::model::account::AccountId; use golem_common::model::WorkerId; -use golem_common::{error_forwarding, SafeDisplay}; +use golem_common::model::account::AccountId; +use golem_common::{SafeDisplay, error_forwarding}; use golem_service_base::clients::registry::{RegistryService, RegistryServiceError}; use std::sync::Arc; diff --git a/golem-worker-service/src/service/mod.rs b/golem-worker-service/src/service/mod.rs index c9fa64e952..bcd0ffb775 100644 --- a/golem-worker-service/src/service/mod.rs +++ b/golem-worker-service/src/service/mod.rs @@ -16,207 +16,3 @@ pub mod auth; pub mod component; pub mod limit; pub mod worker; - -use self::auth::{AuthService, RemoteAuthService}; -use self::component::RemoteComponentService; -use self::limit::{LimitService, RemoteLimitService}; -use self::worker::WorkerService; -use crate::config::WorkerServiceConfig; -// use crate::gateway_execution::api_definition_lookup::{ -// HttpApiDefinitionsLookup, RegistryServiceApiDefinitionsLookup, -// }; -// use crate::gateway_execution::auth_call_back_binding_handler::{ -// AuthCallBackBindingHandler, DefaultAuthCallBackBindingHandler, -// }; -// use crate::gateway_execution::file_server_binding_handler::FileServerBindingHandler; -// use crate::gateway_execution::gateway_http_input_executor::GatewayHttpInputExecutor; -// use crate::gateway_execution::gateway_session_store::{ -// GatewaySessionStore, RedisGatewaySession, RedisGatewaySessionExpiration, SqliteGatewaySession, -// SqliteGatewaySessionExpiration, -// }; -// use crate::gateway_execution::http_handler_binding_handler::HttpHandlerBindingHandler; -// use crate::gateway_execution::route_resolver::RouteResolver; -// use crate::gateway_execution::GatewayWorkerRequestExecutor; -// use crate::gateway_security::DefaultIdentityProvider; -use crate::custom_api::api_definition_lookup::{ - HttpApiDefinitionsLookup, RegistryServiceApiDefinitionsLookup, -}; -use crate::custom_api::request_handler::RequestHandler; -use crate::custom_api::route_resolver::RouteResolver; -use crate::service::component::ComponentService; -use crate::service::worker::{AgentsService, WorkerClient, WorkerExecutorWorkerClient}; -use golem_api_grpc::proto::golem::workerexecutor::v1::worker_executor_client::WorkerExecutorClient; -use golem_service_base::clients::registry::{GrpcRegistryService, RegistryService}; -use golem_service_base::config::BlobStorageConfig; -use golem_service_base::db::sqlite::SqlitePool; -use golem_service_base::grpc::client::MultiTargetGrpcClient; -use golem_service_base::service::initial_component_files::InitialComponentFilesService; -use golem_service_base::service::routing_table::{RoutingTableService, RoutingTableServiceDefault}; -use golem_service_base::storage::blob::BlobStorage; -use std::sync::Arc; -use tonic::codec::CompressionEncoding; - -#[derive(Clone)] -pub struct Services { - pub auth_service: Arc, - pub limit_service: Arc, - pub component_service: Arc, - pub worker_service: Arc, - pub request_handler: Arc, - pub agents_service: Arc, -} - -impl Services { - pub async fn new(config: &WorkerServiceConfig) -> Result { - let registry_service_client: Arc = - Arc::new(GrpcRegistryService::new(&config.registry_service)); - - let auth_service: Arc = Arc::new(RemoteAuthService::new( - registry_service_client.clone(), - &config.auth_service, - )); - - // let gateway_session_store: Arc = - // match &config.gateway_session_storage { - // GatewaySessionStorageConfig::Redis(redis_config) => { - // let redis = RedisPool::configured(redis_config) - // .await - // .map_err(|e| e.to_string())?; - - // let gateway_session_with_redis = - // RedisGatewaySession::new(redis, RedisGatewaySessionExpiration::default()); - - // Arc::new(gateway_session_with_redis) - // } - - // GatewaySessionStorageConfig::Sqlite(sqlite_config) => { - // let pool = SqlitePool::configured(sqlite_config) - // .await - // .map_err(|e| e.to_string())?; - - // let gateway_session_with_sqlite = - // SqliteGatewaySession::new(pool, SqliteGatewaySessionExpiration::default()) - // .await?; - - // Arc::new(gateway_session_with_sqlite) - // } - // }; - - let blob_storage: Arc = match &config.blob_storage { - BlobStorageConfig::S3(config) => Arc::new( - golem_service_base::storage::blob::s3::S3BlobStorage::new(config.clone()).await, - ), - BlobStorageConfig::LocalFileSystem(config) => Arc::new( - golem_service_base::storage::blob::fs::FileSystemBlobStorage::new(&config.root) - .await - .map_err(|e| e.to_string())?, - ), - BlobStorageConfig::Sqlite(sqlite) => { - let pool = SqlitePool::configured(sqlite) - .await - .map_err(|e| format!("Failed to create sqlite pool: {e}"))?; - Arc::new( - golem_service_base::storage::blob::sqlite::SqliteBlobStorage::new(pool.clone()) - .await - .map_err(|e| e.to_string())?, - ) - } - BlobStorageConfig::InMemory(_) => { - Arc::new(golem_service_base::storage::blob::memory::InMemoryBlobStorage::new()) - } - _ => { - return Err("Unsupported blob storage configuration".to_string()); - } - }; - - let _initial_component_files_service: Arc = - Arc::new(InitialComponentFilesService::new(blob_storage.clone())); - - let component_service: Arc = Arc::new(RemoteComponentService::new( - registry_service_client.clone(), - &config.component_service, - )); - - // let identity_provider = Arc::new(DefaultIdentityProvider); - - let limit_service: Arc = - Arc::new(RemoteLimitService::new(registry_service_client.clone())); - - let routing_table_service: Arc = Arc::new( - RoutingTableServiceDefault::new(config.routing_table.clone()), - ); - - let worker_executor_clients = MultiTargetGrpcClient::new( - "worker_executor", - |channel| { - WorkerExecutorClient::new(channel) - .send_compressed(CompressionEncoding::Gzip) - .accept_compressed(CompressionEncoding::Gzip) - }, - config.worker_executor.client.clone(), - ); - - let worker_client: Arc = Arc::new(WorkerExecutorWorkerClient::new( - worker_executor_clients.clone(), - config.worker_executor.retries.clone(), - routing_table_service.clone(), - )); - - let worker_service: Arc = Arc::new(WorkerService::new( - component_service.clone(), - auth_service.clone(), - limit_service.clone(), - worker_client.clone(), - )); - - // let gateway_worker_request_executor: Arc = Arc::new( - // GatewayWorkerRequestExecutor::new(worker_service.clone(), component_service.clone()), - // ); - - // let file_server_binding_handler: Arc = - // Arc::new(FileServerBindingHandler::new( - // component_service.clone(), - // initial_component_files_service.clone(), - // worker_service.clone(), - // )); - - // let auth_call_back_binding_handler: Arc = - // Arc::new(DefaultAuthCallBackBindingHandler::new( - // gateway_session_store.clone(), - // identity_provider.clone(), - // )); - - // let http_handler_binding_handler: Arc = Arc::new( - // HttpHandlerBindingHandler::new(gateway_worker_request_executor.clone()), - // ); - - let api_definition_lookup_service: Arc = Arc::new( - RegistryServiceApiDefinitionsLookup::new(registry_service_client.clone()), - ); - - let route_resolver = Arc::new(RouteResolver::new( - &config.route_resolver, - api_definition_lookup_service.clone(), - )); - - let request_handler = Arc::new(RequestHandler::new( - route_resolver.clone(), - worker_service.clone(), - )); - - let agents_service: Arc = Arc::new(AgentsService::new( - registry_service_client.clone(), - component_service.clone(), - worker_service.clone(), - )); - - Ok(Self { - auth_service, - limit_service, - component_service, - worker_service, - request_handler, - agents_service, - }) - } -} diff --git a/golem-worker-service/src/service/worker/agents.rs b/golem-worker-service/src/service/worker/agents.rs index 9837d1e3ee..e5396c72ad 100644 --- a/golem-worker-service/src/service/worker/agents.rs +++ b/golem-worker-service/src/service/worker/agents.rs @@ -15,8 +15,8 @@ use crate::api::agents::{AgentInvocationMode, AgentInvocationRequest, AgentInvocationResult}; use crate::service::component::ComponentService; use crate::service::worker::{WorkerResult, WorkerService, WorkerServiceError}; -use golem_common::model::agent::{AgentError, AgentId, DataValue, UntypedDataValue}; use golem_common::model::WorkerId; +use golem_common::model::agent::{AgentError, AgentId, DataValue, UntypedDataValue}; use golem_service_base::clients::registry::RegistryService; use golem_service_base::model::auth::AuthCtx; use golem_wasm::{FromValue, IntoValueAndType, Value}; diff --git a/golem-worker-service/src/service/worker/client.rs b/golem-worker-service/src/service/worker/client.rs index 10a461db9c..6375200c5c 100644 --- a/golem-worker-service/src/service/worker/client.rs +++ b/golem-worker-service/src/service/worker/client.rs @@ -30,6 +30,7 @@ use golem_api_grpc::proto::golem::workerexecutor::v1::{ InvokeAndAwaitWorkerJsonRequest, InvokeAndAwaitWorkerRequest, ResumeWorkerRequest, RevertWorkerRequest, SearchOplogResponse, UpdateWorkerRequest, }; +use golem_common::model::RetryConfig; use golem_common::model::account::AccountId; use golem_common::model::component::{ ComponentFilePath, ComponentId, ComponentRevision, PluginPriority, @@ -39,7 +40,6 @@ use golem_common::model::oplog::{OplogCursor, PublicOplogEntry}; use golem_common::model::oplog::{OplogIndex, PublicOplogEntryWithIndex}; use golem_common::model::worker::WorkerUpdateMode; use golem_common::model::worker::{RevertWorkerTarget, WorkerMetadataDto}; -use golem_common::model::RetryConfig; use golem_common::model::{ FilterComparator, IdempotencyKey, PromiseId, ScanCursor, WorkerFilter, WorkerId, WorkerStatus, }; @@ -48,14 +48,14 @@ use golem_service_base::grpc::client::MultiTargetGrpcClient; use golem_service_base::model::auth::AuthCtx; use golem_service_base::model::{ComponentFileSystemNode, GetOplogResponse}; use golem_service_base::service::routing_table::{HasRoutingTableService, RoutingTableService}; +use golem_wasm::ValueAndType; use golem_wasm::analysis::AnalysedFunctionResult; use golem_wasm::protobuf::Val as ProtoVal; -use golem_wasm::ValueAndType; use std::collections::BTreeMap; use std::pin::Pin; use std::{collections::HashMap, sync::Arc}; -use tonic::transport::Channel; use tonic::Code; +use tonic::transport::Channel; use tonic_tracing_opentelemetry::middleware::client::OtelGrpcService; #[async_trait] diff --git a/golem-worker-service/src/service/worker/connect.rs b/golem-worker-service/src/service/worker/connect.rs index 53d33e4921..eb69fa3c6f 100644 --- a/golem-worker-service/src/service/worker/connect.rs +++ b/golem-worker-service/src/service/worker/connect.rs @@ -16,8 +16,8 @@ use super::WorkerStream; use crate::service::limit::LimitService; use futures::{Stream, StreamExt}; use golem_api_grpc::proto::golem::worker::LogEvent; -use golem_common::model::account::AccountId; use golem_common::model::WorkerId; +use golem_common::model::account::AccountId; use std::sync::Arc; use tonic::Status; diff --git a/golem-worker-service/src/service/worker/connect_proxy.rs b/golem-worker-service/src/service/worker/connect_proxy.rs index 90eb49b863..38b12c9f9c 100644 --- a/golem-worker-service/src/service/worker/connect_proxy.rs +++ b/golem-worker-service/src/service/worker/connect_proxy.rs @@ -320,13 +320,13 @@ mod keep_alive { mod test { use test_r::test; - use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Once; + use std::sync::atomic::{AtomicBool, Ordering}; use super::*; use poem::web::websocket::Message; use tokio::sync::mpsc; - use tokio::time::{timeout, Duration}; + use tokio::time::{Duration, timeout}; use tokio_stream::wrappers::ReceiverStream; use tokio_util::sync::PollSender; use tracing::{Instrument, Level}; diff --git a/golem-worker-service/src/service/worker/error.rs b/golem-worker-service/src/service/worker/error.rs index 538d077288..c988e007a8 100644 --- a/golem-worker-service/src/service/worker/error.rs +++ b/golem-worker-service/src/service/worker/error.rs @@ -16,10 +16,10 @@ use crate::service::auth::AuthServiceError; use crate::service::component::ComponentServiceError; use crate::service::limit::LimitServiceError; use crate::service::worker::CallWorkerExecutorError; +use golem_common::SafeDisplay; +use golem_common::model::WorkerId; use golem_common::model::account::AccountId; use golem_common::model::component::{ComponentFilePath, ComponentId}; -use golem_common::model::WorkerId; -use golem_common::SafeDisplay; use golem_service_base::clients::registry::RegistryServiceError; use golem_service_base::error::worker_executor::WorkerExecutorError; @@ -84,9 +84,9 @@ impl From for golem_api_grpc::proto::golem::worker::v1::Work impl From for golem_api_grpc::proto::golem::worker::v1::worker_error::Error { fn from(error: WorkerServiceError) -> Self { use golem_api_grpc::proto::golem::common::{ErrorBody, ErrorsBody}; - use golem_api_grpc::proto::golem::worker::v1::worker_execution_error::Error as GrpcError; use golem_api_grpc::proto::golem::worker::v1::UnknownError; use golem_api_grpc::proto::golem::worker::v1::WorkerExecutionError; + use golem_api_grpc::proto::golem::worker::v1::worker_execution_error::Error as GrpcError; match error { WorkerServiceError::ComponentNotFound(_) diff --git a/golem-worker-service/src/service/worker/invocation_parameters.rs b/golem-worker-service/src/service/worker/invocation_parameters.rs index 38420a3cdc..bcdf8e7c97 100644 --- a/golem-worker-service/src/service/worker/invocation_parameters.rs +++ b/golem-worker-service/src/service/worker/invocation_parameters.rs @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -use golem_wasm::json::OptionallyValueAndTypeJson; use golem_wasm::ValueAndType; +use golem_wasm::json::OptionallyValueAndTypeJson; pub enum InvocationParameters { TypedProtoVals(Vec), diff --git a/golem-worker-service/src/service/worker/routing_logic.rs b/golem-worker-service/src/service/worker/routing_logic.rs index 4f525f690e..2febbdd949 100644 --- a/golem-worker-service/src/service/worker/routing_logic.rs +++ b/golem-worker-service/src/service/worker/routing_logic.rs @@ -16,11 +16,11 @@ use crate::service::worker::WorkerServiceError; use async_trait::async_trait; use golem_api_grpc::proto::golem::worker::v1::WorkerExecutionError; use golem_api_grpc::proto::golem::workerexecutor::v1::worker_executor_client::WorkerExecutorClient; +use golem_common::SafeDisplay; use golem_common::model::RetryConfig; use golem_common::model::{Pod, ShardId, WorkerId}; use golem_common::retriable_error::IsRetriableError; use golem_common::retries::get_delay; -use golem_common::SafeDisplay; use golem_service_base::error::worker_executor::WorkerExecutorError; use golem_service_base::grpc::client::MultiTargetGrpcClient; use golem_service_base::service::routing_table::{HasRoutingTableService, RoutingTableError}; @@ -29,11 +29,11 @@ use std::fmt::Debug; use std::future::Future; use std::pin::Pin; use tokio::task::JoinSet; -use tokio::time::{sleep, Instant}; -use tonic::transport::Channel; +use tokio::time::{Instant, sleep}; use tonic::Status; +use tonic::transport::Channel; use tonic_tracing_opentelemetry::middleware::client::OtelGrpcService; -use tracing::{debug, error, info, trace, warn, Instrument}; +use tracing::{Instrument, debug, error, info, trace, warn}; #[async_trait] pub trait RoutingLogic { diff --git a/golem-worker-service/src/service/worker/service.rs b/golem-worker-service/src/service/worker/service.rs index 17f3354906..054b31ef78 100644 --- a/golem-worker-service/src/service/worker/service.rs +++ b/golem-worker-service/src/service/worker/service.rs @@ -32,9 +32,9 @@ use golem_common::model::worker::{RevertWorkerTarget, WorkerMetadataDto}; use golem_common::model::{IdempotencyKey, ScanCursor, WorkerFilter, WorkerId}; use golem_service_base::model::auth::{AuthCtx, EnvironmentAction}; use golem_service_base::model::{ComponentFileSystemNode, GetOplogResponse}; +use golem_wasm::ValueAndType; use golem_wasm::protobuf::Val as ProtoVal; use golem_wasm::protobuf::Val; -use golem_wasm::ValueAndType; use std::collections::{BTreeMap, BTreeSet}; use std::pin::Pin; use std::{collections::HashMap, sync::Arc}; @@ -267,13 +267,15 @@ impl WorkerService { auth_ctx: AuthCtx, ) -> WorkerResult> { // sanity check for consistency of auth and owner information - assert!(auth_ctx - .authorize_environment_action( - account_id_owning_environment, - &BTreeSet::new(), - EnvironmentAction::UpdateWorker, - ) - .is_ok()); + assert!( + auth_ctx + .authorize_environment_action( + account_id_owning_environment, + &BTreeSet::new(), + EnvironmentAction::UpdateWorker, + ) + .is_ok() + ); let result = self .worker_client diff --git a/golem-worker-service/src/service/worker/worker_stream.rs b/golem-worker-service/src/service/worker/worker_stream.rs index fd2e427c16..af96de8f69 100644 --- a/golem-worker-service/src/service/worker/worker_stream.rs +++ b/golem-worker-service/src/service/worker/worker_stream.rs @@ -21,7 +21,7 @@ use futures::{Stream, StreamExt}; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use tonic::{Status, Streaming}; -use tracing::{error, Instrument}; +use tracing::{Instrument, error}; use golem_common::metrics::api::{ record_closed_grpc_api_active_stream, record_new_grpc_api_active_stream, From e69da5b149bea082e524281be9377c508bdac0f2 Mon Sep 17 00:00:00 2001 From: Maxim Schuwalow Date: Tue, 27 Jan 2026 17:09:48 +0100 Subject: [PATCH 5/5] support agent options on http api deployment --- .../src/command_handler/api/deployment.rs | 2 +- cli/golem-cli/src/command_handler/app/mod.rs | 4 +- .../src/base_model/http_api_deployment.rs | 19 +++- golem-common/src/model/diff/deployment.rs | 6 +- .../src/model/diff/http_api_deployment.rs | 52 ++++++++++- golem-common/src/model/http_api_deployment.rs | 13 ++- .../src/model/http_api_deployment_legacy.rs | 4 +- .../postgres/002_code_first_routes.sql | 5 +- .../sqlite/002_code_first_routes.sql | 25 ++++- .../src/repo/http_api_deployment.rs | 32 ++++--- .../src/repo/model/http_api_deployment.rs | 93 +++++++------------ .../services/deployment/deployment_context.rs | 34 ++++++- .../src/services/deployment/write.rs | 2 + .../src/services/http_api_deployment.rs | 7 +- golem-registry-service/tests/repo/common.rs | 10 +- .../src/benchmarks/throughput.rs | 11 ++- integration-tests/tests/api/deployment.rs | 9 +- .../tests/api/http_api_deployment.rs | 93 +++++++++++++------ .../tests/custom_api/agent_http_routes_ts.rs | 18 +++- integration-tests/tests/invocation_context.rs | 11 ++- openapi/golem-registry-service.yaml | 38 +++++--- openapi/golem-service.yaml | 38 +++++--- 22 files changed, 359 insertions(+), 167 deletions(-) diff --git a/cli/golem-cli/src/command_handler/api/deployment.rs b/cli/golem-cli/src/command_handler/api/deployment.rs index 8e772264f8..a1e5a8a71e 100644 --- a/cli/golem-cli/src/command_handler/api/deployment.rs +++ b/cli/golem-cli/src/command_handler/api/deployment.rs @@ -292,7 +292,7 @@ impl ApiDeploymentCommandHandler { &self, http_api_deployment: &DeploymentPlanHttpApiDeploymentEntry, deployable_http_api_deployment: &[HttpApiDefinitionName], - _diff: &diff::DiffForHashOf, + _diff: &diff::DiffForHashOf, ) -> anyhow::Result<()> { log_action( "Updating", diff --git a/cli/golem-cli/src/command_handler/app/mod.rs b/cli/golem-cli/src/command_handler/app/mod.rs index f8f42d1662..c6ba90345b 100644 --- a/cli/golem-cli/src/command_handler/app/mod.rs +++ b/cli/golem-cli/src/command_handler/app/mod.rs @@ -759,11 +759,11 @@ impl AppCommandHandler { let diffable_local_http_api_deployments = { let mut diffable_local_http_api_deployments = - BTreeMap::>::new(); + BTreeMap::>::new(); for (domain, http_api_deployment) in &deployable_manifest_http_api_deployments { diffable_local_http_api_deployments.insert( domain.0.clone(), - diff::HttpApiDeployment { + diff::HttpApiDeploymentLegacy { agent_types: http_api_deployment .iter() .map(|def| def.0.clone()) diff --git a/golem-common/src/base_model/http_api_deployment.rs b/golem-common/src/base_model/http_api_deployment.rs index 00d29898d5..30dd54b97e 100644 --- a/golem-common/src/base_model/http_api_deployment.rs +++ b/golem-common/src/base_model/http_api_deployment.rs @@ -12,27 +12,38 @@ // See the License for the specific language governing permissions and // limitations under the License. +use super::security_scheme::SecuritySchemeName; use crate::base_model::agent::AgentTypeName; use crate::base_model::diff; use crate::base_model::domain_registration::Domain; use crate::base_model::environment::EnvironmentId; use crate::{declare_revision, declare_structs, newtype_uuid}; use chrono::DateTime; -use std::collections::BTreeSet; +use std::collections::BTreeMap; newtype_uuid!(HttpApiDeploymentId); declare_revision!(HttpApiDeploymentRevision); declare_structs! { + #[derive(Default)] + #[cfg_attr(feature = "full", derive(desert_rust::BinaryCodec))] + #[cfg_attr(feature = "full", desert(transparent))] + pub struct HttpApiDeploymentAgentOptions { + /// Security scheme to use for all agent methods that require auth. + /// Failure to provide a security scheme for an agent that requires one will lead to a deployment failure. + /// If the requested security scheme does not exist in the environment, the route will be disabled at runtime. + pub security_scheme: Option + } + pub struct HttpApiDeploymentCreation { pub domain: Domain, - pub agent_types: BTreeSet + pub agents: BTreeMap } pub struct HttpApiDeploymentUpdate { pub current_revision: HttpApiDeploymentRevision, - pub agent_types: Option> + pub agents: Option> } pub struct HttpApiDeployment { @@ -41,7 +52,7 @@ declare_structs! { pub environment_id: EnvironmentId, pub domain: Domain, pub hash: diff::Hash, - pub agent_types: BTreeSet, + pub agents: BTreeMap, pub created_at: DateTime, } } diff --git a/golem-common/src/model/diff/deployment.rs b/golem-common/src/model/diff/deployment.rs index eadc3ef220..085ff69fdb 100644 --- a/golem-common/src/model/diff/deployment.rs +++ b/golem-common/src/model/diff/deployment.rs @@ -15,7 +15,7 @@ use crate::model::diff::component::Component; use crate::model::diff::hash::{hash_from_serialized_value, Hash, HashOf, Hashable}; use crate::model::diff::http_api_definition::HttpApiDefinition; -use crate::model::diff::http_api_deployment::HttpApiDeployment; +use crate::model::diff::http_api_deployment::HttpApiDeploymentLegacy; use crate::model::diff::ser::serialize_with_mode; use crate::model::diff::{BTreeMapDiff, Diffable}; use serde::Serialize; @@ -32,7 +32,7 @@ pub struct Deployment { pub http_api_definitions: BTreeMap>, #[serde(skip_serializing_if = "BTreeMap::is_empty")] #[serde(serialize_with = "serialize_with_mode")] - pub http_api_deployments: BTreeMap>, + pub http_api_deployments: BTreeMap>, } #[derive(Debug, Clone, PartialEq, Serialize)] @@ -43,7 +43,7 @@ pub struct DeploymentDiff { #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub http_api_definitions: BTreeMapDiff>, #[serde(skip_serializing_if = "BTreeMap::is_empty")] - pub http_api_deployments: BTreeMapDiff>, + pub http_api_deployments: BTreeMapDiff>, } impl Diffable for Deployment { diff --git a/golem-common/src/model/diff/http_api_deployment.rs b/golem-common/src/model/diff/http_api_deployment.rs index 146449044d..cb9366660d 100644 --- a/golem-common/src/model/diff/http_api_deployment.rs +++ b/golem-common/src/model/diff/http_api_deployment.rs @@ -12,13 +12,42 @@ // See the License for the specific language governing permissions and // limitations under the License. +use super::BTreeMapDiff; use crate::model::diff::{hash_from_serialized_value, BTreeSetDiff, Diffable, Hash, Hashable}; use serde::Serialize; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HttpApiDeploymentAgentOptions { + pub security_scheme: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HttpApiDeploymentAgentOptionsDiff { + pub security_scheme_changed: bool, +} + +impl Diffable for HttpApiDeploymentAgentOptions { + type DiffResult = HttpApiDeploymentAgentOptionsDiff; + + fn diff(new: &Self, current: &Self) -> Option { + let security_scheme_changed = new.security_scheme != current.security_scheme; + + if security_scheme_changed { + Some(HttpApiDeploymentAgentOptionsDiff { + security_scheme_changed, + }) + } else { + None + } + } +} #[derive(Debug, Clone, PartialEq, Serialize)] pub struct HttpApiDeployment { - pub agent_types: BTreeSet, + pub agents: BTreeMap, } impl Hashable for HttpApiDeployment { @@ -28,6 +57,25 @@ impl Hashable for HttpApiDeployment { } impl Diffable for HttpApiDeployment { + type DiffResult = BTreeMapDiff; + + fn diff(new: &Self, current: &Self) -> Option { + new.agents.diff_with_current(¤t.agents) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct HttpApiDeploymentLegacy { + pub agent_types: BTreeSet, +} + +impl Hashable for HttpApiDeploymentLegacy { + fn hash(&self) -> Hash { + hash_from_serialized_value(self) + } +} + +impl Diffable for HttpApiDeploymentLegacy { type DiffResult = BTreeSetDiff; fn diff(new: &Self, current: &Self) -> Option { diff --git a/golem-common/src/model/http_api_deployment.rs b/golem-common/src/model/http_api_deployment.rs index 764ddde324..7034ec0182 100644 --- a/golem-common/src/model/http_api_deployment.rs +++ b/golem-common/src/model/http_api_deployment.rs @@ -19,7 +19,18 @@ pub use crate::base_model::http_api_deployment::*; impl HttpApiDeployment { pub fn to_diffable(&self) -> diff::HttpApiDeployment { diff::HttpApiDeployment { - agent_types: self.agent_types.iter().map(|def| def.0.clone()).collect(), + agents: self + .agents + .iter() + .map(|(k, v)| { + ( + k.0.clone(), + diff::HttpApiDeploymentAgentOptions { + security_scheme: v.security_scheme.as_ref().map(|v| v.0.clone()), + }, + ) + }) + .collect(), } } } diff --git a/golem-common/src/model/http_api_deployment_legacy.rs b/golem-common/src/model/http_api_deployment_legacy.rs index d96c287a4e..94adc1750a 100644 --- a/golem-common/src/model/http_api_deployment_legacy.rs +++ b/golem-common/src/model/http_api_deployment_legacy.rs @@ -17,8 +17,8 @@ use crate::model::diff; pub use crate::base_model::http_api_deployment_legacy::*; impl LegacyHttpApiDeployment { - pub fn to_diffable(&self) -> diff::HttpApiDeployment { - diff::HttpApiDeployment { + pub fn to_diffable(&self) -> diff::HttpApiDeploymentLegacy { + diff::HttpApiDeploymentLegacy { agent_types: self .api_definitions .iter() diff --git a/golem-registry-service/db/migration/postgres/002_code_first_routes.sql b/golem-registry-service/db/migration/postgres/002_code_first_routes.sql index 00887d7cec..577d2f25f0 100644 --- a/golem-registry-service/db/migration/postgres/002_code_first_routes.sql +++ b/golem-registry-service/db/migration/postgres/002_code_first_routes.sql @@ -17,7 +17,10 @@ DELETE FROM component_plugin_installations; DELETE FROM component_revisions; DELETE FROM components; -ALTER TABLE http_api_deployment_revisions RENAME COLUMN http_api_definitions TO agent_types; +ALTER TABLE http_api_deployment_revisions RENAME COLUMN http_api_definitions TO data; +ALTER TABLE http_api_deployment_revisions + ALTER COLUMN data TYPE BYTEA + USING data::bytea; DROP TABLE deployment_compiled_http_api_definition_routes; DROP TABLE deployment_domain_http_api_definitions; diff --git a/golem-registry-service/db/migration/sqlite/002_code_first_routes.sql b/golem-registry-service/db/migration/sqlite/002_code_first_routes.sql index 00887d7cec..95d248f48d 100644 --- a/golem-registry-service/db/migration/sqlite/002_code_first_routes.sql +++ b/golem-registry-service/db/migration/sqlite/002_code_first_routes.sql @@ -17,7 +17,30 @@ DELETE FROM component_plugin_installations; DELETE FROM component_revisions; DELETE FROM components; -ALTER TABLE http_api_deployment_revisions RENAME COLUMN http_api_definitions TO agent_types; +DROP TABLE http_api_deployment_revisions; + +CREATE TABLE http_api_deployment_revisions +( + http_api_deployment_id UUID NOT NULL, + revision_id BIGINT NOT NULL, + + hash BYTEA NOT NULL, + + created_at TIMESTAMP NOT NULL, + created_by UUID NOT NULL, + deleted BOOLEAN NOT NULL, + + data BYTEA NOT NULL, + + CONSTRAINT http_api_deployment_revisions_pk + PRIMARY KEY (http_api_deployment_id, revision_id), + CONSTRAINT http_api_deployment_revisions_deployments_fk + FOREIGN KEY (http_api_deployment_id) + REFERENCES http_api_deployments +); + +CREATE INDEX http_api_deployment_revisions_latest_revision_by_id_idx + ON http_api_deployment_revisions (http_api_deployment_id, revision_id DESC); DROP TABLE deployment_compiled_http_api_definition_routes; DROP TABLE deployment_domain_http_api_definitions; diff --git a/golem-registry-service/src/repo/http_api_deployment.rs b/golem-registry-service/src/repo/http_api_deployment.rs index 8b2e4cc28e..0fbf369ee0 100644 --- a/golem-registry-service/src/repo/http_api_deployment.rs +++ b/golem-registry-service/src/repo/http_api_deployment.rs @@ -275,19 +275,21 @@ impl HttpApiDeploymentRepo for DbHttpApiDeploymentRepo { domain: &str, revision: HttpApiDeploymentRevisionRecord, ) -> Result { - let opt_deleted_revision: Option = - self.with_ro("create - get opt deleted").fetch_optional_as( + let opt_deleted_revision: Option = self + .with_ro("create - get opt deleted") + .fetch_optional_as( sqlx::query_as(indoc! { r#" - SELECT h.http_api_deployment_id, h.domain, hr.revision_id, hr.hash, hr.agent_types + SELECT h.http_api_deployment_id, h.domain, hr.revision_id, hr.hash, hr.data FROM http_api_deployments h JOIN http_api_deployment_revisions hr ON h.http_api_deployment_id = hr.http_api_deployment_id AND h.current_revision_id = hr.revision_id WHERE environment_id = $1 AND domain = $2 AND deleted_at IS NOT NULL "#}) - .bind(environment_id) - .bind(domain) - ).await?; + .bind(environment_id) + .bind(domain), + ) + .await?; if let Some(deleted_revision) = opt_deleted_revision { let recreated_revision = revision.for_recreation( @@ -419,7 +421,7 @@ impl HttpApiDeploymentRepo for DbHttpApiDeploymentRepo { .fetch_optional_as( sqlx::query_as(indoc! { r#" SELECT d.environment_id, d.domain, dr.http_api_deployment_id, - dr.revision_id, dr.hash, dr.agent_types, + dr.revision_id, dr.hash, dr.data, dr.created_at, dr.created_by, dr.deleted, d.created_at as entity_created_at FROM http_api_deployments d @@ -441,7 +443,7 @@ impl HttpApiDeploymentRepo for DbHttpApiDeploymentRepo { .fetch_optional_as( sqlx::query_as(indoc! { r#" SELECT d.environment_id, d.domain, dr.http_api_deployment_id, - dr.revision_id, dr.hash, dr.agent_types, + dr.revision_id, dr.hash, dr.data, dr.created_at, dr.created_by, dr.deleted, d.created_at as entity_created_at FROM http_api_deployments d @@ -464,7 +466,7 @@ impl HttpApiDeploymentRepo for DbHttpApiDeploymentRepo { .fetch_optional_as( sqlx::query_as(indoc! { r#" SELECT d.environment_id, d.domain, dr.http_api_deployment_id, - dr.revision_id, dr.hash, dr.agent_types, + dr.revision_id, dr.hash, dr.data, dr.created_at, dr.created_by, dr.deleted, d.created_at as entity_created_at FROM http_api_deployments d @@ -486,7 +488,7 @@ impl HttpApiDeploymentRepo for DbHttpApiDeploymentRepo { .fetch_all_as( sqlx::query_as(indoc! { r#" SELECT d.environment_id, d.domain, dr.http_api_deployment_id, - dr.revision_id, dr.hash, dr.agent_types, + dr.revision_id, dr.hash, dr.data, dr.created_at, dr.created_by, dr.deleted, d.created_at as entity_created_at FROM http_api_deployments d @@ -509,7 +511,7 @@ impl HttpApiDeploymentRepo for DbHttpApiDeploymentRepo { .fetch_all_as( sqlx::query_as(indoc! { r#" SELECT had.environment_id, had.domain, hadr.http_api_deployment_id, - hadr.revision_id, hadr.hash, hadr.agent_types, + hadr.revision_id, hadr.hash, hadr.data, hadr.created_at, hadr.created_by, hadr.deleted, had.created_at as entity_created_at FROM http_api_deployments had @@ -536,7 +538,7 @@ impl HttpApiDeploymentRepo for DbHttpApiDeploymentRepo { .fetch_optional_as( sqlx::query_as(indoc! { r#" SELECT had.environment_id, had.domain, hadr.http_api_deployment_id, - hadr.revision_id, hadr.hash, hadr.agent_types, + hadr.revision_id, hadr.hash, hadr.data, hadr.created_at, hadr.created_by, hadr.deleted, had.created_at as entity_created_at FROM http_api_deployments had @@ -578,15 +580,15 @@ impl HttpApiDeploymentRepoInternal for DbHttpApiDeploymentRepo { tx.fetch_one_as( sqlx::query_as(indoc! { r#" INSERT INTO http_api_deployment_revisions - (http_api_deployment_id, revision_id, agent_types, + (http_api_deployment_id, revision_id, data, hash, created_at, created_by, deleted) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING http_api_deployment_id, revision_id, hash, - created_at, created_by, deleted, agent_types + created_at, created_by, deleted, data "# }) .bind(revision.http_api_deployment_id) .bind(revision.revision_id) - .bind(revision.agent_types) + .bind(&revision.data) .bind(revision.hash) .bind_deletable_revision_audit(revision.audit), ) diff --git a/golem-registry-service/src/repo/model/http_api_deployment.rs b/golem-registry-service/src/repo/model/http_api_deployment.rs index 510ba0f75e..6fa968fb31 100644 --- a/golem-registry-service/src/repo/model/http_api_deployment.rs +++ b/golem-registry-service/src/repo/model/http_api_deployment.rs @@ -15,6 +15,7 @@ use super::datetime::SqlDateTime; use crate::repo::model::audit::{AuditFields, DeletableRevisionAuditFields}; use crate::repo::model::hash::SqlBlake3Hash; +use desert_rust::BinaryCodec; use golem_common::error_forwarding; use golem_common::model::account::AccountId; use golem_common::model::agent::AgentTypeName; @@ -24,13 +25,13 @@ use golem_common::model::diff::Hashable; use golem_common::model::domain_registration::Domain; use golem_common::model::environment::EnvironmentId; use golem_common::model::http_api_deployment::{ - HttpApiDeployment, HttpApiDeploymentId, HttpApiDeploymentRevision, + HttpApiDeployment, HttpApiDeploymentAgentOptions, HttpApiDeploymentId, + HttpApiDeploymentRevision, }; use golem_service_base::repo::RepoError; -use sqlx::encode::IsNull; -use sqlx::error::BoxDynError; -use sqlx::{Database, FromRow}; -use std::collections::BTreeSet; +use golem_service_base::repo::blob::Blob; +use sqlx::FromRow; +use std::collections::BTreeMap; use uuid::Uuid; #[derive(Debug, thiserror::Error)] @@ -45,52 +46,10 @@ pub enum HttpApiDeploymentRepoError { error_forwarding!(HttpApiDeploymentRepoError, RepoError); -// stored as string containing a json array -#[derive(Debug, Clone, PartialEq)] -pub struct AgentTypeSet(pub BTreeSet); - -impl sqlx::Type for AgentTypeSet -where - String: sqlx::Type, -{ - fn type_info() -> DB::TypeInfo { - >::type_info() - } - - fn compatible(ty: &DB::TypeInfo) -> bool { - >::compatible(ty) - } -} - -impl<'q, DB: Database> sqlx::Encode<'q, DB> for AgentTypeSet -where - String: sqlx::Encode<'q, DB>, -{ - fn encode_by_ref( - &self, - buf: &mut ::ArgumentBuffer<'q>, - ) -> Result { - let serialized = serde_json::to_string(&self.0)?; - serialized.encode(buf) - } - - fn size_hint(&self) -> usize { - match serde_json::to_string(&self.0) { - Ok(string) => string.size_hint(), - Err(_) => 0, - } - } -} - -impl<'r, DB: Database> sqlx::Decode<'r, DB> for AgentTypeSet -where - &'r str: sqlx::Decode<'r, DB>, -{ - fn decode(value: ::ValueRef<'r>) -> Result { - let deserialized: BTreeSet = - serde_json::from_str(<&'r str>::decode(value)?)?; - Ok(Self(deserialized)) - } +#[derive(Debug, Clone, PartialEq, BinaryCodec)] +#[desert(evolution())] +pub struct HttpApiDeploymentData { + pub agents: BTreeMap, } #[derive(Debug, Clone, FromRow, PartialEq)] @@ -111,8 +70,7 @@ pub struct HttpApiDeploymentRevisionRecord { #[sqlx(flatten)] pub audit: DeletableRevisionAuditFields, - // json string array as string - pub agent_types: AgentTypeSet, + pub data: Blob, } impl HttpApiDeploymentRevisionRecord { @@ -132,7 +90,7 @@ impl HttpApiDeploymentRevisionRecord { pub fn creation( http_api_deployment_id: HttpApiDeploymentId, - agent_types: BTreeSet, + agents: BTreeMap, actor: AccountId, ) -> Self { let mut value = Self { @@ -140,7 +98,7 @@ impl HttpApiDeploymentRevisionRecord { revision_id: HttpApiDeploymentRevision::INITIAL.into(), hash: SqlBlake3Hash::empty(), audit: DeletableRevisionAuditFields::new(actor.0), - agent_types: AgentTypeSet(agent_types), + data: Blob::new(HttpApiDeploymentData { agents }), }; value.update_hash(); value @@ -152,7 +110,9 @@ impl HttpApiDeploymentRevisionRecord { revision_id: value.revision.into(), hash: SqlBlake3Hash::empty(), audit, - agent_types: AgentTypeSet(value.agent_types), + data: Blob::new(HttpApiDeploymentData { + agents: value.agents, + }), }; value.update_hash(); value @@ -168,13 +128,28 @@ impl HttpApiDeploymentRevisionRecord { revision_id: current_revision_id, hash: SqlBlake3Hash::empty(), audit: DeletableRevisionAuditFields::deletion(created_by), - agent_types: AgentTypeSet(BTreeSet::new()), + data: Blob::new(HttpApiDeploymentData { + agents: BTreeMap::new(), + }), } } pub fn to_diffable(&self) -> diff::HttpApiDeployment { diff::HttpApiDeployment { - agent_types: self.agent_types.0.iter().map(|had| had.0.clone()).collect(), + agents: self + .data + .value() + .agents + .iter() + .map(|(k, v)| { + ( + k.0.clone(), + diff::HttpApiDeploymentAgentOptions { + security_scheme: v.security_scheme.as_ref().map(|v| v.0.clone()), + }, + ) + }) + .collect(), } } @@ -208,7 +183,7 @@ impl TryFrom for HttpApiDeployment { environment_id: EnvironmentId(value.environment_id), domain: Domain(value.domain), hash: value.revision.hash.into(), - agent_types: value.revision.agent_types.0, + agents: value.revision.data.into_value().agents, created_at: value.entity_created_at.into(), }) } diff --git a/golem-registry-service/src/services/deployment/deployment_context.rs b/golem-registry-service/src/services/deployment/deployment_context.rs index 1ece62c157..7cf1d4bf62 100644 --- a/golem-registry-service/src/services/deployment/deployment_context.rs +++ b/golem-registry-service/src/services/deployment/deployment_context.rs @@ -29,7 +29,7 @@ use golem_common::model::agent::{ use golem_common::model::component::ComponentName; use golem_common::model::diff::{self, HashOf, Hashable}; use golem_common::model::domain_registration::Domain; -use golem_common::model::http_api_deployment::HttpApiDeployment; +use golem_common::model::http_api_deployment::{HttpApiDeployment, HttpApiDeploymentAgentOptions}; use golem_service_base::custom_api::{ CallAgentBehaviour, ConstructorParameter, CorsOptions, CorsPreflightBehaviour, OriginPattern, PathSegment, RequestBodySchema, RouteBehaviour, @@ -125,7 +125,7 @@ impl DeploymentContext { let mut errors = Vec::new(); for deployment in self.http_api_deployments.values() { - for agent_type in &deployment.agent_types { + for (agent_type, agent_options) in &deployment.agents { let registered_agent_type = ok_or_continue!( registered_agent_types.get(agent_type).ok_or( DeployValidationError::HttpApiDeploymentMissingAgentType { @@ -160,6 +160,7 @@ impl DeploymentContext { http_mount, ®istered_agent_type.agent_type.methods, constructor_parameters, + agent_options, &mut errors, ); @@ -188,6 +189,7 @@ impl DeploymentContext { http_mount: &HttpMountDetails, agent_methods: &[AgentMethod], constructor_parameters: Vec, + agent_options: &HttpApiDeploymentAgentOptions, errors: &mut Vec, ) -> Vec { let mut compiled_routes: HashMap<(HttpMethod, Vec), UnboundCompiledRoute> = @@ -288,6 +290,32 @@ impl DeploymentContext { } } + let mut auth_required = false; + if let Some(auth_details) = &http_mount.auth_details { + auth_required = auth_details.required; + } + if let Some(auth_details) = &http_endpoint.auth_details { + auth_required = auth_details.required; + } + + let security_scheme = if auth_required { + let security_scheme = ok_or_continue!( + agent_options.security_scheme.clone().ok_or( + DeployValidationError::NoSecuritySchemeConfigured( + agent.type_name.clone() + ) + ), + errors + ); + + Some(security_scheme) + } else { + None + }; + + // TODO: check whether a security scheme with this name currently exists in the environment + // and emit a warning to the cli if it doesn't. + let compiled = UnboundCompiledRoute { route_id, domain: deployment.domain.clone(), @@ -304,7 +332,7 @@ impl DeploymentContext { method_parameters, expected_agent_response: agent_method.output_schema.clone(), }), - security_scheme: None, + security_scheme, cors, }; diff --git a/golem-registry-service/src/services/deployment/write.rs b/golem-registry-service/src/services/deployment/write.rs index a04a00e4c8..ab377f7c4d 100644 --- a/golem-registry-service/src/services/deployment/write.rs +++ b/golem-registry-service/src/services/deployment/write.rs @@ -107,6 +107,8 @@ pub enum DeployValidationError { ComponentNotFound(ComponentName), #[error("Agent type name {0} is provided by multiple components")] AmbiguousAgentTypeName(AgentTypeName), + #[error("No security scheme configured for agent {0} but agent has methods that require auth")] + NoSecuritySchemeConfigured(AgentTypeName), #[error( "Method {agent_method} of agent {agent_type} used by http api at {method} {domain}/{path} is invalid: {error}" )] diff --git a/golem-registry-service/src/services/http_api_deployment.rs b/golem-registry-service/src/services/http_api_deployment.rs index 1c22d978e9..a3726d94a6 100644 --- a/golem-registry-service/src/services/http_api_deployment.rs +++ b/golem-registry-service/src/services/http_api_deployment.rs @@ -135,8 +135,7 @@ impl HttpApiDeploymentService { })?; let id = HttpApiDeploymentId::new(); - let record = - HttpApiDeploymentRevisionRecord::creation(id, data.agent_types, auth.account_id()); + let record = HttpApiDeploymentRevisionRecord::creation(id, data.agents, auth.account_id()); let stored_http_api_deployment: HttpApiDeployment = self .http_api_deployment_repo @@ -200,8 +199,8 @@ impl HttpApiDeploymentService { }; http_api_deployment.revision = http_api_deployment.revision.next()?; - if let Some(api_definitions) = update.agent_types { - http_api_deployment.agent_types = api_definitions; + if let Some(api_definitions) = update.agents { + http_api_deployment.agents = api_definitions; }; let record = HttpApiDeploymentRevisionRecord::from_model( diff --git a/golem-registry-service/tests/repo/common.rs b/golem-registry-service/tests/repo/common.rs index 97d122a028..dda34f3c51 100644 --- a/golem-registry-service/tests/repo/common.rs +++ b/golem-registry-service/tests/repo/common.rs @@ -19,6 +19,7 @@ use futures::future::join_all; use golem_common::model::agent::AgentTypeName; use golem_common::model::component::ComponentFilePermissions; use golem_common::model::component_metadata::ComponentMetadata; +use golem_common::model::http_api_deployment::HttpApiDeploymentAgentOptions; use golem_registry_service::repo::environment::EnvironmentRevisionRecord; use golem_registry_service::repo::model::account::{ AccountExtRevisionRecord, AccountRepoError, AccountRevisionRecord, @@ -37,7 +38,7 @@ use golem_registry_service::repo::model::datetime::SqlDateTime; use golem_registry_service::repo::model::environment::EnvironmentRepoError; use golem_registry_service::repo::model::hash::SqlBlake3Hash; use golem_registry_service::repo::model::http_api_deployment::{ - AgentTypeSet, HttpApiDeploymentRepoError, HttpApiDeploymentRevisionRecord, + HttpApiDeploymentData, HttpApiDeploymentRepoError, HttpApiDeploymentRevisionRecord, }; use golem_registry_service::repo::model::new_repo_uuid; use golem_registry_service::repo::model::plugin::PluginRecord; @@ -816,7 +817,12 @@ pub async fn test_http_api_deployment_stage(deps: &Deps) { revision_id: 0, hash: SqlBlake3Hash::empty(), audit: DeletableRevisionAuditFields::new(user.revision.account_id), - agent_types: AgentTypeSet([AgentTypeName("test-agent".to_string())].into()), + data: Blob::new(HttpApiDeploymentData { + agents: BTreeMap::from_iter([( + AgentTypeName("test-agent".to_string()), + HttpApiDeploymentAgentOptions::default(), + )]), + }), } .with_updated_hash(); diff --git a/integration-tests/src/benchmarks/throughput.rs b/integration-tests/src/benchmarks/throughput.rs index ce787d1d50..91f9a8d13a 100644 --- a/integration-tests/src/benchmarks/throughput.rs +++ b/integration-tests/src/benchmarks/throughput.rs @@ -27,7 +27,9 @@ use golem_common::model::http_api_definition::{ GatewayBinding, HttpApiDefinitionCreation, HttpApiDefinitionName, HttpApiDefinitionVersion, HttpApiRoute, RouteMethod, WorkerGatewayBinding, }; -use golem_common::model::http_api_deployment::HttpApiDeploymentCreation; +use golem_common::model::http_api_deployment::{ + HttpApiDeploymentAgentOptions, HttpApiDeploymentCreation, +}; use golem_common::model::{RoutingTable, WorkerId}; use golem_test_framework::benchmark::{Benchmark, BenchmarkRecorder, RunConfig}; use golem_test_framework::config::benchmark::TestMode; @@ -38,7 +40,7 @@ use golem_wasm::{IntoValueAndType, ValueAndType}; use indoc::indoc; use reqwest::{Body, Method, Request, Url}; use serde_json::json; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use tracing::{info, Level}; pub struct ThroughputEcho { @@ -670,7 +672,10 @@ impl ThroughputBenchmark { let http_api_deployment_creation = HttpApiDeploymentCreation { domain: domain.clone(), - agent_types: [AgentTypeName("benchmark-agent".to_string())].into(), + agents: BTreeMap::from_iter([( + AgentTypeName("benchmark-agent".to_string()), + HttpApiDeploymentAgentOptions::default(), + )]), }; client diff --git a/integration-tests/tests/api/deployment.rs b/integration-tests/tests/api/deployment.rs index 9bb4516c2c..04bb33ff4a 100644 --- a/integration-tests/tests/api/deployment.rs +++ b/integration-tests/tests/api/deployment.rs @@ -31,7 +31,9 @@ use golem_common::model::http_api_definition::{ GatewayBinding, HttpApiDefinitionCreation, HttpApiDefinitionName, HttpApiDefinitionVersion, HttpApiRoute, RouteMethod, WorkerGatewayBinding, }; -use golem_common::model::http_api_deployment::HttpApiDeploymentCreation; +use golem_common::model::http_api_deployment::{ + HttpApiDeploymentAgentOptions, HttpApiDeploymentCreation, +}; use golem_test_framework::config::{EnvBasedTestDependencies, TestDependencies}; use golem_test_framework::dsl::{TestDsl, TestDslExtended}; use std::collections::BTreeMap; @@ -252,7 +254,10 @@ async fn full_deployment(deps: &EnvBasedTestDependencies) -> anyhow::Result<()> let http_api_deployment_creation = HttpApiDeploymentCreation { domain: domain.clone(), - agent_types: [AgentTypeName("shopping-cart".to_string())].into(), + agents: BTreeMap::from_iter([( + AgentTypeName("shopping-cart".to_string()), + HttpApiDeploymentAgentOptions::default(), + )]), }; let http_api_deployment = client diff --git a/integration-tests/tests/api/http_api_deployment.rs b/integration-tests/tests/api/http_api_deployment.rs index 80a59b49cc..441094267d 100644 --- a/integration-tests/tests/api/http_api_deployment.rs +++ b/integration-tests/tests/api/http_api_deployment.rs @@ -27,10 +27,11 @@ use golem_common::model::http_api_definition::{ HttpApiRoute, RouteMethod, WorkerGatewayBinding, }; use golem_common::model::http_api_deployment::{ - HttpApiDeploymentCreation, HttpApiDeploymentUpdate, + HttpApiDeploymentAgentOptions, HttpApiDeploymentCreation, HttpApiDeploymentUpdate, }; use golem_test_framework::config::{EnvBasedTestDependencies, TestDependencies}; use golem_test_framework::dsl::{TestDsl, TestDslExtended}; +use std::collections::BTreeMap; use test_r::{inherit_test_dep, test}; inherit_test_dep!(EnvBasedTestDependencies); @@ -47,7 +48,10 @@ async fn create_http_api_deployment_for_nonexitant_domain( let http_api_deployment_creation = HttpApiDeploymentCreation { domain: Domain("testdomain.com".to_string()), - agent_types: [AgentTypeName("test-api".to_string())].into(), + agents: BTreeMap::from_iter([( + AgentTypeName("test-api".to_string()), + HttpApiDeploymentAgentOptions::default(), + )]), }; let result = client @@ -74,7 +78,10 @@ async fn create_http_api_deployment(deps: &EnvBasedTestDependencies) -> anyhow:: let http_api_deployment_creation = HttpApiDeploymentCreation { domain, - agent_types: [AgentTypeName("test-api".to_string())].into(), + agents: BTreeMap::from_iter([( + AgentTypeName("test-api".to_string()), + HttpApiDeploymentAgentOptions::default(), + )]), }; let http_api_deployment = client @@ -118,7 +125,10 @@ async fn update_http_api_deployment(deps: &EnvBasedTestDependencies) -> anyhow:: let http_api_deployment_creation = HttpApiDeploymentCreation { domain, - agent_types: [AgentTypeName("test-api".to_string())].into(), + agents: BTreeMap::from_iter([( + AgentTypeName("test-api".to_string()), + HttpApiDeploymentAgentOptions::default(), + )]), }; let http_api_deployment = client @@ -127,13 +137,16 @@ async fn update_http_api_deployment(deps: &EnvBasedTestDependencies) -> anyhow:: let http_api_deployment_update = HttpApiDeploymentUpdate { current_revision: http_api_deployment.revision, - agent_types: Some( - [ + agents: Some(BTreeMap::from_iter([ + ( AgentTypeName("test-api".to_string()), + HttpApiDeploymentAgentOptions::default(), + ), + ( AgentTypeName("test-api-2".to_string()), - ] - .into(), - ), + HttpApiDeploymentAgentOptions::default(), + ), + ])), }; let updated_http_api_deployment = client @@ -142,9 +155,7 @@ async fn update_http_api_deployment(deps: &EnvBasedTestDependencies) -> anyhow:: assert!(updated_http_api_deployment.id == http_api_deployment.id); assert!(updated_http_api_deployment.revision == http_api_deployment.revision.next()?); - assert!( - updated_http_api_deployment.agent_types == http_api_deployment_update.agent_types.unwrap() - ); + assert!(updated_http_api_deployment.agents == http_api_deployment_update.agents.unwrap()); { let fetched_http_api_deployment = client @@ -201,7 +212,10 @@ async fn delete_http_api_deployment(deps: &EnvBasedTestDependencies) -> anyhow:: let http_api_deployment_creation = HttpApiDeploymentCreation { domain, - agent_types: [AgentTypeName("test-api".to_string())].into(), + agents: BTreeMap::from_iter([( + AgentTypeName("test-api".to_string()), + HttpApiDeploymentAgentOptions::default(), + )]), }; let http_api_deployment = client @@ -260,7 +274,10 @@ async fn cannot_create_two_http_api_deployments_for_same_domain( let http_api_deployment_creation = HttpApiDeploymentCreation { domain, - agent_types: [AgentTypeName("test-api".to_string())].into(), + agents: BTreeMap::from_iter([( + AgentTypeName("test-api".to_string()), + HttpApiDeploymentAgentOptions::default(), + )]), }; client @@ -293,7 +310,10 @@ async fn updates_with_wrong_revision_number_are_rejected( let http_api_deployment_creation = HttpApiDeploymentCreation { domain, - agent_types: [AgentTypeName("test-api".to_string())].into(), + agents: BTreeMap::from_iter([( + AgentTypeName("test-api".to_string()), + HttpApiDeploymentAgentOptions::default(), + )]), }; let http_api_deployment = client @@ -302,13 +322,16 @@ async fn updates_with_wrong_revision_number_are_rejected( let http_api_deployment_update = HttpApiDeploymentUpdate { current_revision: http_api_deployment.revision.next()?, - agent_types: Some( - [ + agents: Some(BTreeMap::from_iter([ + ( AgentTypeName("test-api".to_string()), + HttpApiDeploymentAgentOptions::default(), + ), + ( AgentTypeName("test-api-2".to_string()), - ] - .into(), - ), + HttpApiDeploymentAgentOptions::default(), + ), + ])), }; let result = client @@ -335,7 +358,10 @@ async fn http_api_deployment_recreation(deps: &EnvBasedTestDependencies) -> anyh let http_api_deployment_creation = HttpApiDeploymentCreation { domain, - agent_types: [AgentTypeName("test-api".to_string())].into(), + agents: BTreeMap::from_iter([( + AgentTypeName("test-api".to_string()), + HttpApiDeploymentAgentOptions::default(), + )]), }; let http_api_deployment_1 = client @@ -414,7 +440,10 @@ async fn fetch_in_deployment(deps: &EnvBasedTestDependencies) -> anyhow::Result< let http_api_deployment_creation = HttpApiDeploymentCreation { domain: domain.clone(), - agent_types: [AgentTypeName("ephemeral-echo-agent".to_string())].into(), + agents: BTreeMap::from_iter([( + AgentTypeName("ephemeral-echo-agent".to_string()), + HttpApiDeploymentAgentOptions::default(), + )]), }; let http_api_deployment = client @@ -453,7 +482,10 @@ async fn cannot_access_http_api_deployment_from_another_user( let creation = HttpApiDeploymentCreation { domain: domain.clone(), - agent_types: [AgentTypeName("test-api".to_string())].into(), + agents: BTreeMap::from_iter([( + AgentTypeName("test-api".to_string()), + HttpApiDeploymentAgentOptions::default(), + )]), }; let deployment = client_a @@ -488,7 +520,10 @@ async fn cannot_delete_http_api_deployment_from_another_user( let creation = HttpApiDeploymentCreation { domain, - agent_types: [AgentTypeName("test-api".to_string())].into(), + agents: BTreeMap::from_iter([( + AgentTypeName("test-api".to_string()), + HttpApiDeploymentAgentOptions::default(), + )]), }; let deployment = client_a @@ -520,7 +555,10 @@ async fn delete_with_wrong_revision_is_rejected( let creation = HttpApiDeploymentCreation { domain, - agent_types: [AgentTypeName("test-api".to_string())].into(), + agents: BTreeMap::from_iter([( + AgentTypeName("test-api".to_string()), + HttpApiDeploymentAgentOptions::default(), + )]), }; let deployment = client @@ -553,7 +591,10 @@ async fn deleting_twice_returns_404(deps: &EnvBasedTestDependencies) -> anyhow:: let creation = HttpApiDeploymentCreation { domain, - agent_types: [AgentTypeName("test-api".to_string())].into(), + agents: BTreeMap::from_iter([( + AgentTypeName("test-api".to_string()), + HttpApiDeploymentAgentOptions::default(), + )]), }; let deployment = client diff --git a/integration-tests/tests/custom_api/agent_http_routes_ts.rs b/integration-tests/tests/custom_api/agent_http_routes_ts.rs index 54102a8847..fe16bf78ae 100644 --- a/integration-tests/tests/custom_api/agent_http_routes_ts.rs +++ b/integration-tests/tests/custom_api/agent_http_routes_ts.rs @@ -18,14 +18,16 @@ use golem_common::model::agent::AgentTypeName; use golem_common::model::deployment::DeploymentRevision; use golem_common::model::domain_registration::{Domain, DomainRegistrationCreation}; use golem_common::model::environment::EnvironmentId; -use golem_common::model::http_api_deployment::HttpApiDeploymentCreation; +use golem_common::model::http_api_deployment::{ + HttpApiDeploymentAgentOptions, HttpApiDeploymentCreation, +}; use golem_test_framework::config::dsl_impl::TestUserContext; use golem_test_framework::config::{EnvBasedTestDependencies, TestDependencies}; use golem_test_framework::dsl::{TestDsl, TestDslExtended}; use pretty_assertions::assert_eq; use reqwest::Url; use serde_json::json; -use std::collections::BTreeSet; +use std::collections::BTreeMap; use std::fmt::{Debug, Formatter}; use test_r::test_dep; use test_r::{inherit_test_dep, test}; @@ -75,9 +77,15 @@ async fn test_context_internal(deps: &EnvBasedTestDependencies) -> anyhow::Resul let http_api_deployment_creation = HttpApiDeploymentCreation { domain: domain.clone(), - agent_types: BTreeSet::from_iter([ - AgentTypeName("http-agent".into()), - AgentTypeName("cors-agent".into()), + agents: BTreeMap::from_iter([ + ( + AgentTypeName("http-agent".to_string()), + HttpApiDeploymentAgentOptions::default(), + ), + ( + AgentTypeName("cors-agent".to_string()), + HttpApiDeploymentAgentOptions::default(), + ), ]), }; diff --git a/integration-tests/tests/invocation_context.rs b/integration-tests/tests/invocation_context.rs index 6ab87a7ece..c2d5540ff5 100644 --- a/integration-tests/tests/invocation_context.rs +++ b/integration-tests/tests/invocation_context.rs @@ -26,14 +26,16 @@ use golem_common::model::http_api_definition::{ GatewayBinding, HttpApiDefinitionCreation, HttpApiDefinitionName, HttpApiDefinitionVersion, HttpApiRoute, RouteMethod, WorkerGatewayBinding, }; -use golem_common::model::http_api_deployment::HttpApiDeploymentCreation; +use golem_common::model::http_api_deployment::{ + HttpApiDeploymentAgentOptions, HttpApiDeploymentCreation, +}; use golem_common::model::invocation_context::{SpanId, TraceId}; use golem_test_framework::config::{EnvBasedTestDependencies, TestDependencies}; use golem_test_framework::dsl::{TestDsl, TestDslExtended}; use reqwest::header::HeaderValue; use reqwest::Client; use serde_json::Value; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::net::SocketAddr; use std::sync::{Arc, Mutex}; use test_r::{inherit_test_dep, test, timeout}; @@ -157,7 +159,10 @@ async fn invocation_context_test(deps: &EnvBasedTestDependencies) -> anyhow::Res let http_api_deployment_creation = HttpApiDeploymentCreation { domain: domain.clone(), - agent_types: [AgentTypeName("placeholder-agent".to_string())].into(), + agents: BTreeMap::from_iter([( + AgentTypeName("placeholder-agent".to_string()), + HttpApiDeploymentAgentOptions::default(), + )]), }; client diff --git a/openapi/golem-registry-service.yaml b/openapi/golem-registry-service.yaml index b2c88a536c..1cd55ee950 100644 --- a/openapi/golem-registry-service.yaml +++ b/openapi/golem-registry-service.yaml @@ -9213,7 +9213,7 @@ components: - environmentId - domain - hash - - agentTypes + - agents - createdAt properties: id: @@ -9230,26 +9230,36 @@ components: hash: type: string format: hash - agentTypes: - type: array - items: - type: string + agents: + type: object + additionalProperties: + $ref: '#/components/schemas/HttpApiDeploymentAgentOptions' createdAt: type: string format: date-time + HttpApiDeploymentAgentOptions: + type: object + title: HttpApiDeploymentAgentOptions + properties: + securityScheme: + type: string + description: |- + Security scheme to use for all agent methods that require auth. + Failure to provide a security scheme for an agent that requires one will lead to a deployment failure. + If the requested security scheme does not exist in the environment, the route will be disabled at runtime. HttpApiDeploymentCreation: type: object title: HttpApiDeploymentCreation required: - domain - - agentTypes + - agents properties: domain: type: string - agentTypes: - type: array - items: - type: string + agents: + type: object + additionalProperties: + $ref: '#/components/schemas/HttpApiDeploymentAgentOptions' HttpApiDeploymentUpdate: type: object title: HttpApiDeploymentUpdate @@ -9259,10 +9269,10 @@ components: currentRevision: type: integer format: uint64 - agentTypes: - type: array - items: - type: string + agents: + type: object + additionalProperties: + $ref: '#/components/schemas/HttpApiDeploymentAgentOptions' HttpApiRoute: type: object title: HttpApiRoute diff --git a/openapi/golem-service.yaml b/openapi/golem-service.yaml index dfec3d147b..5d6f684d53 100644 --- a/openapi/golem-service.yaml +++ b/openapi/golem-service.yaml @@ -13792,10 +13792,10 @@ components: hash: type: string format: hash - agentTypes: - type: array - items: - type: string + agents: + type: object + additionalProperties: + $ref: '#/components/schemas/HttpApiDeploymentAgentOptions' createdAt: type: string format: date-time @@ -13805,21 +13805,31 @@ components: - environmentId - domain - hash - - agentTypes + - agents - createdAt + HttpApiDeploymentAgentOptions: + title: HttpApiDeploymentAgentOptions + type: object + properties: + securityScheme: + description: |- + Security scheme to use for all agent methods that require auth. + Failure to provide a security scheme for an agent that requires one will lead to a deployment failure. + If the requested security scheme does not exist in the environment, the route will be disabled at runtime. + type: string HttpApiDeploymentCreation: title: HttpApiDeploymentCreation type: object properties: domain: type: string - agentTypes: - type: array - items: - type: string + agents: + type: object + additionalProperties: + $ref: '#/components/schemas/HttpApiDeploymentAgentOptions' required: - domain - - agentTypes + - agents HttpApiDeploymentUpdate: title: HttpApiDeploymentUpdate type: object @@ -13827,10 +13837,10 @@ components: currentRevision: type: integer format: uint64 - agentTypes: - type: array - items: - type: string + agents: + type: object + additionalProperties: + $ref: '#/components/schemas/HttpApiDeploymentAgentOptions' required: - currentRevision HttpApiRoute: