Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
458 changes: 458 additions & 0 deletions .github/workflows/apigatewayv2-test.yml

Large diffs are not rendered by default.

481 changes: 480 additions & 1 deletion Cargo.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ ruststack-secretsmanager-core = { path = "crates/ruststack-secretsmanager-core"
ruststack-ses-model = { path = "crates/ruststack-ses-model" }
ruststack-ses-http = { path = "crates/ruststack-ses-http" }
ruststack-ses-core = { path = "crates/ruststack-ses-core" }
ruststack-apigatewayv2-model = { path = "crates/ruststack-apigatewayv2-model" }
ruststack-apigatewayv2-http = { path = "crates/ruststack-apigatewayv2-http" }
ruststack-apigatewayv2-core = { path = "crates/ruststack-apigatewayv2-core" }

# Async runtime
tokio = { version = "1.49", features = [
Expand Down Expand Up @@ -153,5 +156,7 @@ aws-sdk-kms = "1"
aws-sdk-kinesis = "1"
aws-sdk-secretsmanager = "1"
aws-sdk-ses = "1"
aws-sdk-apigatewayv2 = "1"
reqwest = { version = "0.12", features = ["json"] }
aws-credential-types = "1.2"
aws-smithy-runtime-api = "1.11"
16 changes: 14 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ codegen-ses:
@cd codegen && cargo run -- --config services/ses.toml --model smithy-model/ses.json --output ../crates/ruststack-ses-model/src
@cargo +nightly fmt -p ruststack-ses-model

codegen-apigatewayv2:
@cd codegen && cargo run -- --config services/apigatewayv2.toml --model smithy-model/apigatewayv2.json --output ../crates/ruststack-apigatewayv2-model/src
@cargo +nightly fmt -p ruststack-apigatewayv2-model

codegen: codegen-s3

SMITHY_MODELS_REPO = https://raw.githubusercontent.com/aws/api-models-aws/main
Expand All @@ -93,6 +97,7 @@ codegen-download:
@curl -sL $(SMITHY_MODELS_REPO)/models/cloudwatch-logs/service/2014-03-28/cloudwatch-logs-2014-03-28.json -o codegen/smithy-model/logs.json
@curl -sL $(SMITHY_MODELS_REPO)/models/secrets-manager/service/2017-10-17/secrets-manager-2017-10-17.json -o codegen/smithy-model/secretsmanager.json
@curl -sL $(SMITHY_MODELS_REPO)/models/ses/service/2010-12-01/ses-2010-12-01.json -o codegen/smithy-model/ses.json
@curl -sL $(SMITHY_MODELS_REPO)/models/apigatewayv2/service/2018-11-29/apigatewayv2-2018-11-29.json -o codegen/smithy-model/apigatewayv2.json
@echo "Done."

integration:
Expand Down Expand Up @@ -206,13 +211,20 @@ test-events-patterns:
test-events-integration:
@cargo test -p ruststack-integration -- events --ignored

test-apigatewayv2-unit:
@cargo test -p ruststack-apigatewayv2-model -p ruststack-apigatewayv2-core -p ruststack-apigatewayv2-http

test-apigatewayv2-integration:
@cargo test -p ruststack-integration -- apigatewayv2 --ignored

update-submodule:
@git submodule update --init --recursive --remote

.PHONY: build check test fmt clippy audit deny run release update-submodule integration \
codegen codegen-s3 codegen-ssm codegen-events codegen-dynamodb codegen-sqs codegen-sns codegen-lambda \
codegen-kms codegen-kinesis codegen-logs codegen-secretsmanager codegen-ses codegen-download \
codegen-kms codegen-kinesis codegen-logs codegen-secretsmanager codegen-ses codegen-apigatewayv2 codegen-download \
mint mint-build mint-start mint-run mint-stop \
alternator alternator-setup alternator-run alternator-stop \
sqs-compat sqs-compat-setup sqs-compat-run \
test-events-unit test-events-patterns test-events-integration
test-events-unit test-events-patterns test-events-integration \
test-apigatewayv2-unit test-apigatewayv2-integration
7 changes: 6 additions & 1 deletion apps/ruststack-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ name = "ruststack-server"
path = "src/main.rs"

[features]
default = ["s3", "dynamodb", "sqs", "ssm", "sns", "lambda", "events", "logs", "kms", "kinesis", "secretsmanager", "ses"]
default = ["s3", "dynamodb", "sqs", "ssm", "sns", "lambda", "events", "logs", "kms", "kinesis", "secretsmanager", "ses", "apigatewayv2"]
s3 = ["dep:ruststack-s3-core", "dep:ruststack-s3-http", "dep:ruststack-s3-model"]
dynamodb = ["dep:ruststack-dynamodb-core", "dep:ruststack-dynamodb-http"]
sqs = ["dep:ruststack-sqs-core", "dep:ruststack-sqs-http"]
Expand All @@ -28,6 +28,7 @@ kms = ["dep:ruststack-kms-core", "dep:ruststack-kms-http"]
kinesis = ["dep:ruststack-kinesis-core", "dep:ruststack-kinesis-http"]
secretsmanager = ["dep:ruststack-secretsmanager-core", "dep:ruststack-secretsmanager-http"]
ses = ["dep:ruststack-ses-core", "dep:ruststack-ses-http"]
apigatewayv2 = ["dep:ruststack-apigatewayv2-core", "dep:ruststack-apigatewayv2-http"]

[dependencies]
# Internal crates - shared
Expand Down Expand Up @@ -84,6 +85,10 @@ ruststack-secretsmanager-http = { workspace = true, optional = true }
ruststack-ses-core = { workspace = true, optional = true }
ruststack-ses-http = { workspace = true, optional = true }

# Internal crates - API Gateway v2 (optional, gated by "apigatewayv2" feature)
ruststack-apigatewayv2-core = { workspace = true, optional = true }
ruststack-apigatewayv2-http = { workspace = true, optional = true }

# Async
tokio = { workspace = true }

Expand Down
53 changes: 52 additions & 1 deletion apps/ruststack-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,15 @@ use ruststack_ses_http::service::{SesHttpConfig, SesHttpService};
#[cfg(feature = "ses")]
use ruststack_ses_http::v2::SesV2HttpService;

#[cfg(feature = "apigatewayv2")]
use ruststack_apigatewayv2_core::config::ApiGatewayV2Config;
#[cfg(feature = "apigatewayv2")]
use ruststack_apigatewayv2_core::handler::RustStackApiGatewayV2Handler;
#[cfg(feature = "apigatewayv2")]
use ruststack_apigatewayv2_core::provider::RustStackApiGatewayV2;
#[cfg(feature = "apigatewayv2")]
use ruststack_apigatewayv2_http::service::{ApiGatewayV2HttpConfig, ApiGatewayV2HttpService};

#[cfg(feature = "s3")]
use ruststack_s3_core::{RustStackS3, S3Config};
#[cfg(feature = "s3")]
Expand Down Expand Up @@ -326,6 +335,18 @@ fn build_ses_http_config(config: &SesConfig) -> SesHttpConfig {
}
}

/// Build the [`ApiGatewayV2HttpConfig`] from the [`ApiGatewayV2Config`].
#[cfg(feature = "apigatewayv2")]
fn build_apigatewayv2_http_config(config: &ApiGatewayV2Config) -> ApiGatewayV2HttpConfig {
let credential_provider = build_credential_provider();

ApiGatewayV2HttpConfig {
skip_signature_validation: config.skip_signature_validation,
region: config.default_region.clone(),
credential_provider,
}
}

/// Build a credential provider from `ACCESS_KEY` / `SECRET_KEY` environment
/// variables (used by MinIO Mint and other test harnesses).
#[cfg(any(
Expand All @@ -340,7 +361,8 @@ fn build_ses_http_config(config: &SesConfig) -> SesHttpConfig {
feature = "kms",
feature = "kinesis",
feature = "secretsmanager",
feature = "ses"
feature = "ses",
feature = "apigatewayv2"
))]
fn build_credential_provider() -> Option<Arc<dyn ruststack_auth::CredentialProvider>> {
use ruststack_auth::StaticCredentialProvider;
Expand Down Expand Up @@ -424,6 +446,7 @@ fn is_compiled_in(name: &str) -> bool {
|| (name == "kinesis" && cfg!(feature = "kinesis"))
|| (name == "secretsmanager" && cfg!(feature = "secretsmanager"))
|| (name == "ses" && cfg!(feature = "ses"))
|| (name == "apigatewayv2" && cfg!(feature = "apigatewayv2"))
}

/// Parse the `SERVICES` environment variable into a list of service names.
Expand Down Expand Up @@ -478,6 +501,9 @@ fn parse_services_value(raw: &str) -> Vec<String> {
if cfg!(feature = "ses") {
all.push("ses".to_string());
}
if cfg!(feature = "apigatewayv2") {
all.push("apigatewayv2".to_string());
}
all
} else {
trimmed
Expand Down Expand Up @@ -723,6 +749,27 @@ fn build_services(is_enabled: impl Fn(&str) -> bool) -> Vec<Box<dyn ServiceRoute
)));
}

// ----- API Gateway v2 (register before Lambda and S3: S3 is the catch-all) -----
#[cfg(feature = "apigatewayv2")]
if is_enabled("apigatewayv2") {
let apigw_config = ApiGatewayV2Config::from_env();
info!(
apigatewayv2_skip_signature_validation = apigw_config.skip_signature_validation,
"initializing API Gateway v2 service",
);
let apigw_provider = Arc::new(RustStackApiGatewayV2::new(apigw_config.clone()));
let apigw_handler = RustStackApiGatewayV2Handler::new(Arc::clone(&apigw_provider));
let apigw_http_config = build_apigatewayv2_http_config(&apigw_config);
let apigw_service =
ApiGatewayV2HttpService::new(Arc::new(apigw_handler), apigw_http_config);
services.push(Box::new(service::ApiGatewayV2ManagementRouter::new(
apigw_service,
)));
services.push(Box::new(service::ApiGatewayV2ExecutionRouter::new(
Arc::clone(&apigw_provider),
)));
}

// ----- Lambda (register before S3: S3 is the catch-all) -----
#[cfg(feature = "lambda")]
if is_enabled("lambda") {
Expand Down Expand Up @@ -870,6 +917,10 @@ mod tests {
cfg!(feature = "secretsmanager")
);
assert_eq!(is_compiled_in("ses"), cfg!(feature = "ses"));
assert_eq!(
is_compiled_in("apigatewayv2"),
cfg!(feature = "apigatewayv2")
);
}

#[cfg(feature = "s3")]
Expand Down
188 changes: 188 additions & 0 deletions apps/ruststack-server/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -943,3 +943,191 @@ mod ses_router {

#[cfg(feature = "ses")]
pub use ses_router::SesServiceRouter;

// ---------------------------------------------------------------------------
// API Gateway v2
// ---------------------------------------------------------------------------

#[cfg(feature = "apigatewayv2")]
mod apigatewayv2_router {
use std::convert::Infallible;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;

use bytes::Bytes;
use http_body_util::BodyExt;
use hyper::body::Incoming;
use hyper::service::Service;
use ruststack_apigatewayv2_core::execution::{handle_execution, parse_execution_path};
use ruststack_apigatewayv2_core::provider::RustStackApiGatewayV2;
use ruststack_apigatewayv2_http::dispatch::ApiGatewayV2Handler;
use ruststack_apigatewayv2_http::service::ApiGatewayV2HttpService;

use super::{GatewayBody, ServiceRouter, gateway_body_from_string};

/// Routes management API requests to the API Gateway v2 service.
///
/// Matches requests whose URL path starts with `/v2/apis`, `/v2/domainnames`,
/// `/v2/vpclinks`, or `/v2/tags`.
pub struct ApiGatewayV2ManagementRouter<H: ApiGatewayV2Handler> {
inner: ApiGatewayV2HttpService<H>,
}

impl<H: ApiGatewayV2Handler> ApiGatewayV2ManagementRouter<H> {
/// Wrap an [`ApiGatewayV2HttpService`] in a management router.
pub fn new(inner: ApiGatewayV2HttpService<H>) -> Self {
Self { inner }
}
}

impl<H: ApiGatewayV2Handler> ServiceRouter for ApiGatewayV2ManagementRouter<H> {
fn name(&self) -> &'static str {
"apigatewayv2"
}

fn matches(&self, req: &http::Request<Incoming>) -> bool {
let path = req.uri().path();
path.starts_with("/v2/apis")
|| path.starts_with("/v2/domainnames")
|| path.starts_with("/v2/vpclinks")
|| path.starts_with("/v2/tags")
}

fn call(
&self,
req: http::Request<Incoming>,
) -> Pin<Box<dyn Future<Output = Result<http::Response<GatewayBody>, Infallible>> + Send>>
{
let svc = self.inner.clone();
Box::pin(async move {
let resp = svc.call(req).await;
Ok(resp.unwrap_or_else(|e| match e {}).map(BodyExt::boxed))
})
}
}

/// Routes execution requests to the API Gateway v2 execution engine.
///
/// Matches requests whose URL path starts with `/_aws/execute-api/`.
pub struct ApiGatewayV2ExecutionRouter {
provider: Arc<RustStackApiGatewayV2>,
}

impl ApiGatewayV2ExecutionRouter {
/// Create a new execution router.
pub fn new(provider: Arc<RustStackApiGatewayV2>) -> Self {
Self { provider }
}
}

impl ServiceRouter for ApiGatewayV2ExecutionRouter {
fn name(&self) -> &'static str {
"apigatewayv2-execution"
}

fn matches(&self, req: &http::Request<Incoming>) -> bool {
req.uri().path().starts_with("/_aws/execute-api/")
}

fn call(
&self,
req: http::Request<Incoming>,
) -> Pin<Box<dyn Future<Output = Result<http::Response<GatewayBody>, Infallible>> + Send>>
{
let provider = Arc::clone(&self.provider);
Box::pin(async move {
let method = req.method().clone();
let path = req.uri().path().to_owned();
let headers = req.headers().clone();

// Strip the /_aws/execute-api prefix.
let exec_path = path.strip_prefix("/_aws/execute-api").unwrap_or(&path);

let target = match parse_execution_path(exec_path) {
Ok(t) => t,
Err(e) => {
let body = serde_json::json!({"message": e.to_string()});
let resp = http::Response::builder()
.status(http::StatusCode::BAD_REQUEST)
.header("content-type", "application/json")
.body(gateway_body_from_string(body.to_string()))
.unwrap_or_else(|_| {
http::Response::new(gateway_body_from_string(
"Bad Request".to_owned(),
))
});
return Ok(resp);
}
};

// Collect request body.
let body_bytes: Bytes = match http_body_util::BodyExt::collect(req.into_body())
.await
.map(http_body_util::Collected::to_bytes)
{
Ok(b) => b,
Err(e) => {
let body =
serde_json::json!({"message": format!("Failed to read body: {e}")});
let resp = http::Response::builder()
.status(http::StatusCode::BAD_REQUEST)
.header("content-type", "application/json")
.body(gateway_body_from_string(body.to_string()))
.unwrap_or_else(|_| {
http::Response::new(gateway_body_from_string(
"Bad Request".to_owned(),
))
});
return Ok(resp);
}
};

match handle_execution(
&provider,
&target.api_id,
&target.stage_name,
&method,
&target.path,
&headers,
&body_bytes,
)
.await
{
Ok(resp) => {
let (parts, body) = resp.into_parts();
Ok(http::Response::from_parts(
parts,
gateway_body_from_string(String::from_utf8_lossy(&body).into_owned()),
))
}
Err(e) => {
let status = match &e {
ruststack_apigatewayv2_core::error::ApiGatewayV2ServiceError::NotFound(_) => {
http::StatusCode::NOT_FOUND
}
ruststack_apigatewayv2_core::error::ApiGatewayV2ServiceError::BadRequest(_) => {
http::StatusCode::BAD_REQUEST
}
_ => http::StatusCode::INTERNAL_SERVER_ERROR,
};
let body = serde_json::json!({"message": e.to_string()});
let resp = http::Response::builder()
.status(status)
.header("content-type", "application/json")
.body(gateway_body_from_string(body.to_string()))
.unwrap_or_else(|_| {
http::Response::new(gateway_body_from_string(
"Internal error".to_owned(),
))
});
Ok(resp)
}
}
})
}
}
}

#[cfg(feature = "apigatewayv2")]
pub use apigatewayv2_router::{ApiGatewayV2ExecutionRouter, ApiGatewayV2ManagementRouter};
Loading
Loading