use std::io::Read;
use std::path::{Path, PathBuf};
use anyhow::Context;
use greentic_deploy_spec::{BundleId, DeploymentId, RevisionId};
use greentic_types::{ExtensionInline, PackManifest, decode_pack_manifest};
use serde::Deserialize;
use zip::ZipArchive;
use crate::domains::{self, Domain};
pub const EXT_HTTP_ROUTES_V1: &str = "greentic.http-routes.v1";
pub const INGEST_HTTP_OP: &str = "ingest_http";
#[derive(Clone, Debug)]
pub struct RevisionScope {
pub deployment_id: DeploymentId,
pub bundle_id: BundleId,
pub revision_id: RevisionId,
}
#[derive(Clone, Debug)]
pub struct HttpRouteDescriptor {
#[allow(dead_code)]
pub route_id: String,
pub pack_id: String,
pub pattern: String,
pub methods: Vec<String>,
#[allow(dead_code)]
pub provider_op: String,
#[allow(dead_code)]
pub provider_type: Option<String>,
pub domain: Domain,
pub scope: Option<RevisionScope>,
segments: Vec<RouteSegment>,
}
#[derive(Clone, Debug)]
enum RouteSegment {
Literal(String),
Tenant,
Team,
Wildcard,
}
#[derive(Default)]
pub struct HttpRouteTable {
routes: Vec<HttpRouteDescriptor>,
}
pub struct HttpRouteMatch<'a> {
pub descriptor: &'a HttpRouteDescriptor,
pub tenant: String,
pub team: String,
}
#[cfg(test)]
pub(crate) fn descriptor_for_test(
pattern: &str,
methods: &[&str],
domain: Domain,
scope: Option<RevisionScope>,
) -> HttpRouteDescriptor {
HttpRouteDescriptor {
route_id: pattern.to_string(),
pack_id: "test-pack".to_string(),
pattern: pattern.to_string(),
methods: methods.iter().map(|m| m.to_string()).collect(),
provider_op: INGEST_HTTP_OP.to_string(),
provider_type: None,
domain,
scope,
segments: parse_route_pattern(pattern),
}
}
#[cfg(test)]
pub(crate) fn provider_descriptor_for_test(
pattern: &str,
provider_type: &str,
scope: RevisionScope,
) -> HttpRouteDescriptor {
HttpRouteDescriptor {
route_id: pattern.to_string(),
pack_id: "test-pack".to_string(),
pattern: pattern.to_string(),
methods: vec!["POST".to_string()],
provider_op: INGEST_HTTP_OP.to_string(),
provider_type: Some(provider_type.to_string()),
domain: Domain::Messaging,
scope: Some(scope),
segments: parse_route_pattern(pattern),
}
}
impl HttpRouteTable {
pub fn from_descriptors(mut routes: Vec<HttpRouteDescriptor>) -> Self {
routes.sort_by(|a, b| {
let a_wild = a
.segments
.iter()
.any(|s| matches!(s, RouteSegment::Wildcard));
let b_wild = b
.segments
.iter()
.any(|s| matches!(s, RouteSegment::Wildcard));
b.segments
.len()
.cmp(&a.segments.len())
.then(a_wild.cmp(&b_wild))
});
Self { routes }
}
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.routes.is_empty()
}
#[allow(dead_code)]
pub fn routes(&self) -> &[HttpRouteDescriptor] {
&self.routes
}
pub fn match_request(&self, path: &str, method: &str) -> Option<HttpRouteMatch<'_>> {
self.match_first(path, method, |route| route.scope.is_none())
}
pub fn match_request_for_revision(
&self,
path: &str,
method: &str,
scope: &RevisionScope,
) -> Option<HttpRouteMatch<'_>> {
self.match_first(path, method, |route| {
route.scope.as_ref().is_some_and(|s| {
s.deployment_id == scope.deployment_id
&& s.bundle_id == scope.bundle_id
&& s.revision_id == scope.revision_id
})
})
}
fn match_first(
&self,
path: &str,
method: &str,
accept: impl Fn(&HttpRouteDescriptor) -> bool,
) -> Option<HttpRouteMatch<'_>> {
let request_segments: Vec<&str> = path
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.collect();
for route in &self.routes {
if !accept(route) {
continue;
}
if !route.methods.is_empty()
&& !route.methods.iter().any(|m| m.eq_ignore_ascii_case(method))
{
continue;
}
if let Some(m) = try_match_route(route, &request_segments) {
return Some(m);
}
}
None
}
}
fn try_match_route<'a>(
route: &'a HttpRouteDescriptor,
request_segments: &[&str],
) -> Option<HttpRouteMatch<'a>> {
let mut tenant = String::from("default");
let mut team = String::from("default");
let mut req_idx = 0;
for seg in &route.segments {
match seg {
RouteSegment::Literal(expected) => {
if req_idx >= request_segments.len() {
return None;
}
if !request_segments[req_idx].eq_ignore_ascii_case(expected) {
return None;
}
req_idx += 1;
}
RouteSegment::Tenant => {
if req_idx >= request_segments.len() {
return None;
}
tenant = request_segments[req_idx].to_string();
if tenant.is_empty() {
return None;
}
req_idx += 1;
}
RouteSegment::Team => {
if req_idx >= request_segments.len() {
return None;
}
team = request_segments[req_idx].to_string();
req_idx += 1;
}
RouteSegment::Wildcard => {
return Some(HttpRouteMatch {
descriptor: route,
tenant,
team,
});
}
}
}
if req_idx <= request_segments.len() {
Some(HttpRouteMatch {
descriptor: route,
tenant,
team,
})
} else {
None
}
}
fn parse_route_pattern(pattern: &str) -> Vec<RouteSegment> {
pattern
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.map(|seg| {
if seg == "{tenant}" {
RouteSegment::Tenant
} else if seg == "{team}" {
RouteSegment::Team
} else if seg.ends_with("*}") || seg == "*" {
RouteSegment::Wildcard
} else {
let cleaned = seg.trim_start_matches('{').trim_end_matches('}');
RouteSegment::Literal(cleaned.to_string())
}
})
.collect()
}
fn parse_domain(domain_str: &str) -> Option<Domain> {
match domain_str.to_ascii_lowercase().as_str() {
"messaging" => Some(Domain::Messaging),
"events" => Some(Domain::Events),
"secrets" => Some(Domain::Secrets),
"oauth" => Some(Domain::OAuth),
_ => None,
}
}
#[derive(Deserialize)]
struct HttpRoutesExtensionV1 {
#[serde(default = "default_schema_version")]
schema_version: u32,
#[serde(default)]
routes: Vec<HttpRouteRecord>,
}
fn default_schema_version() -> u32 {
1
}
#[derive(Deserialize)]
struct HttpRouteRecord {
#[serde(default)]
id: Option<String>,
pattern: String,
#[serde(default)]
methods: Vec<String>,
#[serde(default = "default_provider_op")]
provider_op: String,
#[serde(default = "default_domain")]
domain: String,
}
fn default_provider_op() -> String {
"ingest_http".to_string()
}
fn default_domain() -> String {
"messaging".to_string()
}
pub fn discover_http_routes_from_bundle(
bundle_root: &Path,
) -> anyhow::Result<Vec<HttpRouteDescriptor>> {
let pack_paths = collect_runtime_pack_paths(bundle_root)?;
Ok(discover_routes_from_packs(&pack_paths, None))
}
#[cfg(test)]
pub(crate) fn discover_revision_http_routes(
pack_paths: &[PathBuf],
scope: &RevisionScope,
) -> Vec<HttpRouteDescriptor> {
discover_routes_from_packs(pack_paths, Some(scope))
}
fn discover_routes_from_packs(
pack_paths: &[PathBuf],
scope: Option<&RevisionScope>,
) -> Vec<HttpRouteDescriptor> {
let mut routes = Vec::new();
for pack_path in pack_paths {
match read_pack_http_routes(pack_path) {
Ok(Some(mut pack_routes)) => {
if let Some(scope) = scope {
for route in &mut pack_routes {
route.scope = Some(scope.clone());
}
}
routes.extend(pack_routes);
}
Ok(None) => continue,
Err(err) => {
crate::operator_log::warn(
module_path!(),
format!(
"failed to read http-routes from {}: {err:#}",
pack_path.display()
),
);
}
}
}
routes
}
fn read_pack_manifest(pack_path: &Path) -> anyhow::Result<PackManifest> {
let file = std::fs::File::open(pack_path)?;
let mut archive = ZipArchive::new(file)?;
let mut manifest_entry = archive.by_name("manifest.cbor").map_err(|err| {
anyhow::anyhow!(
"failed to open manifest.cbor in {}: {err}",
pack_path.display()
)
})?;
let mut bytes = Vec::new();
manifest_entry.read_to_end(&mut bytes)?;
decode_pack_manifest(&bytes)
.with_context(|| format!("failed to decode pack manifest in {}", pack_path.display()))
}
fn read_pack_http_routes(pack_path: &Path) -> anyhow::Result<Option<Vec<HttpRouteDescriptor>>> {
let manifest = read_pack_manifest(pack_path)?;
http_routes_from_manifest(&manifest, pack_path)
}
fn http_routes_from_manifest(
manifest: &PackManifest,
pack_path: &Path,
) -> anyhow::Result<Option<Vec<HttpRouteDescriptor>>> {
let Some(extensions) = manifest.extensions.as_ref() else {
return Ok(None);
};
let Some(extension) = extensions.get(EXT_HTTP_ROUTES_V1) else {
return Ok(None);
};
parse_http_routes_v1(extension, manifest.pack_id.as_str(), pack_path)
}
fn parse_http_routes_v1(
extension: &greentic_types::pack_manifest::ExtensionRef,
pack_id: &str,
pack_path: &Path,
) -> anyhow::Result<Option<Vec<HttpRouteDescriptor>>> {
let inline = extension
.inline
.as_ref()
.ok_or_else(|| anyhow::anyhow!("http-routes extension inline payload missing"))?;
let ExtensionInline::Other(value) = inline else {
anyhow::bail!("http-routes extension inline payload has unexpected type");
};
let decoded: HttpRoutesExtensionV1 = serde_json::from_value(value.clone())
.with_context(|| "failed to parse greentic.http-routes.v1 payload")?;
if decoded.schema_version != 1 {
anyhow::bail!(
"unsupported http-routes extension schema_version={} in {}",
decoded.schema_version,
pack_path.display()
);
}
let mut routes = Vec::new();
for (idx, record) in decoded.routes.into_iter().enumerate() {
let route_id = record
.id
.unwrap_or_else(|| format!("{pack_id}:http-route-{idx}"));
let domain = parse_domain(&record.domain).ok_or_else(|| {
anyhow::anyhow!(
"unknown domain '{}' in http-route {route_id}",
record.domain
)
})?;
let segments = parse_route_pattern(&record.pattern);
routes.push(HttpRouteDescriptor {
route_id,
pack_id: pack_id.to_string(),
pattern: record.pattern,
methods: record.methods,
provider_op: record.provider_op,
provider_type: None,
domain,
scope: None,
segments,
});
}
Ok(Some(routes))
}
fn collect_runtime_pack_paths(bundle_root: &Path) -> anyhow::Result<Vec<PathBuf>> {
let mut seen = std::collections::BTreeMap::new();
let discover = if bundle_root.join("greentic.demo.yaml").exists() {
domains::discover_provider_packs_cbor_only
} else {
domains::discover_provider_packs
};
for domain in [
Domain::Messaging,
Domain::Events,
Domain::Secrets,
Domain::OAuth,
] {
for pack in discover(bundle_root, domain)? {
seen.entry(pack.path.clone()).or_insert(pack.path);
}
}
Ok(seen.into_values().collect())
}
#[cfg(test)]
pub(crate) fn synthesize_provider_ingest_routes(
pack_paths: &[PathBuf],
scope: &RevisionScope,
path_prefixes: &[String],
) -> Vec<HttpRouteDescriptor> {
let root_fallback = [String::new()];
let prefixes: &[String] = if path_prefixes.is_empty() {
&root_fallback
} else {
path_prefixes
};
let mut routes = Vec::new();
for pack_path in pack_paths {
let manifest = match read_pack_manifest(pack_path) {
Ok(m) => m,
Err(err) => {
crate::operator_log::warn(
module_path!(),
format!(
"failed to read manifest from {}: {err:#}",
pack_path.display()
),
);
continue;
}
};
routes.extend(synthesize_provider_routes_from_manifest(
&manifest, pack_path, scope, prefixes,
));
}
routes
}
fn synthesize_provider_routes_from_manifest(
manifest: &PackManifest,
pack_path: &Path,
scope: &RevisionScope,
prefixes: &[String],
) -> Vec<HttpRouteDescriptor> {
let Some(inline) = manifest.provider_extension_inline() else {
return Vec::new();
};
let pack_id = manifest.pack_id.as_str();
let mut routes = Vec::new();
for provider in &inline.providers {
if !provider.ops.iter().any(|op| op == INGEST_HTTP_OP) {
continue;
}
let Some(name) = derive_provider_name(&provider.provider_type) else {
crate::operator_log::warn(
module_path!(),
format!(
"skipping ingest_http route synthesis for {}: cannot derive a \
provider-name from provider_type `{}`",
pack_path.display(),
provider.provider_type,
),
);
continue;
};
for prefix in prefixes {
let pattern = build_webhook_pattern(prefix, &name);
let segments = parse_route_pattern(&pattern);
routes.push(HttpRouteDescriptor {
route_id: format!("{pack_id}:provider-webhook:{name}@{prefix}"),
pack_id: pack_id.to_string(),
pattern,
methods: vec!["POST".to_string()],
provider_op: INGEST_HTTP_OP.to_string(),
provider_type: Some(provider.provider_type.clone()),
domain: Domain::Messaging,
scope: Some(scope.clone()),
segments,
});
}
}
routes
}
pub fn discover_revision_routes(
pack_paths: &[PathBuf],
scope: &RevisionScope,
path_prefixes: &[String],
) -> Vec<HttpRouteDescriptor> {
let root_fallback = [String::new()];
let prefixes: &[String] = if path_prefixes.is_empty() {
&root_fallback
} else {
path_prefixes
};
let mut routes = Vec::new();
for pack_path in pack_paths {
let manifest = match read_pack_manifest(pack_path) {
Ok(m) => m,
Err(err) => {
crate::operator_log::warn(
module_path!(),
format!(
"failed to read manifest from {}: {err:#}",
pack_path.display()
),
);
continue;
}
};
match http_routes_from_manifest(&manifest, pack_path) {
Ok(Some(mut declared)) => {
for route in &mut declared {
route.scope = Some(scope.clone());
}
routes.extend(declared);
}
Ok(None) => {}
Err(err) => {
crate::operator_log::warn(
module_path!(),
format!(
"failed to read http-routes from {}: {err:#}",
pack_path.display()
),
);
}
}
routes.extend(synthesize_provider_routes_from_manifest(
&manifest, pack_path, scope, prefixes,
));
}
routes
}
pub(crate) fn derive_provider_name(provider_type: &str) -> Option<String> {
let trimmed = provider_type
.strip_prefix("messaging.")
.unwrap_or(provider_type);
let mut parts: Vec<&str> = trimmed.split('.').filter(|s| !s.is_empty()).collect();
if parts.is_empty() {
return None;
}
if parts.len() > 1 {
let last = *parts.last().unwrap();
if matches!(last, "bot" | "graph" | "client" | "gui" | "webhook") {
parts.pop();
}
}
let name = parts.join("-").to_ascii_lowercase();
if name.is_empty() { None } else { Some(name) }
}
fn build_webhook_pattern(prefix: &str, name: &str) -> String {
let trimmed = prefix.trim_matches('/');
if trimmed.is_empty() {
format!("/webhook/{name}")
} else {
format!("/{trimmed}/webhook/{name}")
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
fn make_route(pattern: &str, methods: &[&str], domain: Domain) -> HttpRouteDescriptor {
make_scoped_route(pattern, methods, domain, None)
}
fn make_scoped_route(
pattern: &str,
methods: &[&str],
domain: Domain,
scope: Option<RevisionScope>,
) -> HttpRouteDescriptor {
super::descriptor_for_test(pattern, methods, domain, scope)
}
#[test]
fn matches_exact_literal_route() {
let table = HttpRouteTable::from_descriptors(vec![make_route(
"/v1/messaging/webchat/{tenant}/token",
&["GET"],
Domain::Messaging,
)]);
let m = table
.match_request("/v1/messaging/webchat/demo/token", "GET")
.expect("should match");
assert_eq!(m.tenant, "demo");
assert_eq!(m.descriptor.pack_id, "test-pack");
assert!(
table
.match_request("/v1/messaging/webchat/demo/token", "DELETE")
.is_none()
);
assert!(
table
.match_request("/v1/messaging/webchat/demo/other", "GET")
.is_none()
);
}
#[test]
fn matches_wildcard_route() {
let table = HttpRouteTable::from_descriptors(vec![make_route(
"/v1/messaging/webchat/{tenant}/v3/directline/{path*}",
&["GET", "POST"],
Domain::Messaging,
)]);
let m = table
.match_request(
"/v1/messaging/webchat/acme/v3/directline/conversations/123/activities",
"POST",
)
.expect("should match wildcard");
assert_eq!(m.tenant, "acme");
let m = table
.match_request(
"/v1/messaging/webchat/demo/v3/directline/conversations",
"GET",
)
.expect("should match wildcard");
assert_eq!(m.tenant, "demo");
}
#[test]
fn empty_methods_matches_any_method() {
let table = HttpRouteTable::from_descriptors(vec![make_route(
"/v1/messaging/webchat/{tenant}/auth/config",
&[],
Domain::Messaging,
)]);
assert!(
table
.match_request("/v1/messaging/webchat/demo/auth/config", "GET")
.is_some()
);
assert!(
table
.match_request("/v1/messaging/webchat/demo/auth/config", "POST")
.is_some()
);
}
#[test]
fn specific_routes_take_priority_over_wildcards() {
let table = HttpRouteTable::from_descriptors(vec![
make_route(
"/v1/messaging/webchat/{tenant}/v3/directline/{path*}",
&[],
Domain::Messaging,
),
make_route(
"/v1/messaging/webchat/{tenant}/token",
&["GET"],
Domain::Messaging,
),
]);
let m = table
.match_request("/v1/messaging/webchat/demo/token", "GET")
.expect("should match specific route");
assert!(m.descriptor.pattern.contains("token"));
}
#[test]
fn no_match_returns_none() {
let table = HttpRouteTable::from_descriptors(vec![make_route(
"/v1/messaging/webchat/{tenant}/token",
&["GET"],
Domain::Messaging,
)]);
assert!(table.match_request("/healthz", "GET").is_none());
assert!(
table
.match_request("/v1/events/ingress/p/t", "GET")
.is_none()
);
}
#[test]
fn tenant_and_team_extraction() {
let table = HttpRouteTable::from_descriptors(vec![make_route(
"/v1/messaging/ingress/{tenant}/{team}/handler",
&[],
Domain::Messaging,
)]);
let m = table
.match_request("/v1/messaging/ingress/acme/support/handler", "POST")
.expect("should match");
assert_eq!(m.tenant, "acme");
assert_eq!(m.team, "support");
}
fn scope_for(deployment_id: DeploymentId, revision_id: RevisionId) -> RevisionScope {
RevisionScope {
deployment_id,
bundle_id: BundleId::new("acme-bundle"),
revision_id,
}
}
#[test]
fn match_request_skips_revision_scoped_routes() {
let rev = RevisionId::new();
let table = HttpRouteTable::from_descriptors(vec![make_scoped_route(
"/v1/messaging/webchat/{tenant}/token",
&["GET"],
Domain::Messaging,
Some(scope_for(DeploymentId::new(), rev)),
)]);
assert!(
table
.match_request("/v1/messaging/webchat/demo/token", "GET")
.is_none()
);
}
#[test]
fn match_request_for_revision_only_matches_that_revision() {
let deployment = DeploymentId::new();
let scope_a = scope_for(deployment, RevisionId::new());
let scope_b = scope_for(deployment, RevisionId::new());
let table = HttpRouteTable::from_descriptors(vec![
make_scoped_route(
"/v1/messaging/webchat/{tenant}/token",
&["GET"],
Domain::Messaging,
Some(scope_a.clone()),
),
make_scoped_route(
"/v1/messaging/webchat/{tenant}/token",
&["GET"],
Domain::Messaging,
Some(scope_b.clone()),
),
]);
let m = table
.match_request_for_revision("/v1/messaging/webchat/demo/token", "GET", &scope_a)
.expect("should match revision A's route");
assert_eq!(
m.descriptor.scope.as_ref().unwrap().revision_id,
scope_a.revision_id
);
assert!(
table
.match_request("/v1/messaging/webchat/demo/token", "GET")
.is_none()
);
let unknown = scope_for(deployment, RevisionId::new());
assert!(
table
.match_request_for_revision("/v1/messaging/webchat/demo/token", "GET", &unknown)
.is_none()
);
}
#[test]
fn match_request_for_revision_distinguishes_deployments() {
let revision = RevisionId::new();
let scope_a = scope_for(DeploymentId::new(), revision);
let scope_b = scope_for(DeploymentId::new(), revision);
let table = HttpRouteTable::from_descriptors(vec![make_scoped_route(
"/v1/messaging/webchat/{tenant}/token",
&["GET"],
Domain::Messaging,
Some(scope_a.clone()),
)]);
assert!(
table
.match_request_for_revision("/v1/messaging/webchat/demo/token", "GET", &scope_a)
.is_some(),
"deployment A's scope matches its own route"
);
assert!(
table
.match_request_for_revision("/v1/messaging/webchat/demo/token", "GET", &scope_b)
.is_none(),
"deployment B's scope must not match deployment A's route despite equal revision id"
);
}
#[test]
fn match_request_for_revision_skips_legacy_routes() {
let table = HttpRouteTable::from_descriptors(vec![make_route(
"/v1/messaging/webchat/{tenant}/token",
&["GET"],
Domain::Messaging,
)]);
let scope = scope_for(DeploymentId::new(), RevisionId::new());
assert!(
table
.match_request_for_revision("/v1/messaging/webchat/demo/token", "GET", &scope)
.is_none()
);
}
#[test]
fn parse_route_pattern_handles_variants() {
let segs = parse_route_pattern("/v1/{tenant}/v3/directline/{path*}");
assert!(matches!(segs[0], RouteSegment::Literal(ref s) if s == "v1"));
assert!(matches!(segs[1], RouteSegment::Tenant));
assert!(matches!(segs[2], RouteSegment::Literal(ref s) if s == "v3"));
assert!(matches!(segs[3], RouteSegment::Literal(ref s) if s == "directline"));
assert!(matches!(segs[4], RouteSegment::Wildcard));
}
fn write_http_routes_pack(path: &Path, pack_id: &str, pattern: &str) {
use std::io::Write as _;
use zip::write::FileOptions;
let manifest_json = serde_json::json!({
"schema_version": "1.0.0",
"pack_id": pack_id,
"version": "1.0.0",
"kind": "provider",
"publisher": "tests",
"extensions": {
EXT_HTTP_ROUTES_V1: {
"kind": EXT_HTTP_ROUTES_V1,
"version": "1.0.0",
"inline": {
"schema_version": 1,
"routes": [{
"pattern": pattern,
"methods": ["GET"],
"domain": "messaging"
}]
}
}
}
});
let manifest: greentic_types::PackManifest =
serde_json::from_value(manifest_json).expect("manifest deserializes");
let bytes = greentic_types::encode_pack_manifest(&manifest).expect("manifest encodes");
let file = std::fs::File::create(path).unwrap();
let mut zip = zip::ZipWriter::new(file);
zip.start_file("manifest.cbor", FileOptions::<()>::default())
.unwrap();
zip.write_all(&bytes).unwrap();
zip.finish().unwrap();
}
#[test]
fn discover_revision_http_routes_stamps_scope() {
let dir = tempfile::tempdir().unwrap();
let pack = dir.path().join("alpha.gtpack");
let pattern = "/v1/messaging/webchat/{tenant}/token";
write_http_routes_pack(&pack, "alpha", pattern);
let scope = RevisionScope {
deployment_id: DeploymentId::new(),
bundle_id: BundleId::new("acme-bundle"),
revision_id: RevisionId::new(),
};
let routes = discover_revision_http_routes(&[pack], &scope);
assert_eq!(routes.len(), 1, "one route discovered");
assert_eq!(routes[0].pattern, pattern);
let stamped = routes[0].scope.as_ref().expect("scope stamped");
assert_eq!(stamped.deployment_id, scope.deployment_id);
assert_eq!(stamped.bundle_id, scope.bundle_id);
assert_eq!(stamped.revision_id, scope.revision_id);
let table = HttpRouteTable::from_descriptors(routes);
assert!(
table
.match_request_for_revision("/v1/messaging/webchat/demo/token", "GET", &scope)
.is_some()
);
assert!(
table
.match_request("/v1/messaging/webchat/demo/token", "GET")
.is_none()
);
}
#[test]
fn discover_revision_http_routes_skips_unreadable_pack() {
let dir = tempfile::tempdir().unwrap();
let bad = dir.path().join("garbage.gtpack");
std::fs::write(&bad, b"not a zip archive").unwrap();
let missing = dir.path().join("does-not-exist.gtpack");
let scope = RevisionScope {
deployment_id: DeploymentId::new(),
bundle_id: BundleId::new("acme-bundle"),
revision_id: RevisionId::new(),
};
let routes = discover_revision_http_routes(&[bad, missing], &scope);
assert!(routes.is_empty(), "unreadable packs yield no routes");
}
#[test]
fn derive_provider_name_strips_messaging_prefix_and_kind_suffix() {
assert_eq!(
derive_provider_name("messaging.telegram.bot").as_deref(),
Some("telegram")
);
assert_eq!(
derive_provider_name("messaging.teams.graph").as_deref(),
Some("teams")
);
assert_eq!(
derive_provider_name("messaging.slack.client").as_deref(),
Some("slack")
);
assert_eq!(
derive_provider_name("messaging.webchat.gui").as_deref(),
Some("webchat")
);
}
#[test]
fn derive_provider_name_preserves_unknown_shapes() {
assert_eq!(
derive_provider_name("events.timer").as_deref(),
Some("events-timer")
);
assert_eq!(
derive_provider_name("messaging.bot").as_deref(),
Some("bot")
);
assert!(derive_provider_name("").is_none());
assert!(derive_provider_name(".").is_none());
assert!(derive_provider_name("messaging.").is_none());
}
#[test]
fn build_webhook_pattern_handles_prefix_variants() {
assert_eq!(
build_webhook_pattern("/bot", "telegram"),
"/bot/webhook/telegram"
);
assert_eq!(
build_webhook_pattern("bot", "telegram"),
"/bot/webhook/telegram"
);
assert_eq!(
build_webhook_pattern("/bot/", "telegram"),
"/bot/webhook/telegram"
);
assert_eq!(build_webhook_pattern("/", "telegram"), "/webhook/telegram");
assert_eq!(build_webhook_pattern("", "telegram"), "/webhook/telegram");
assert_eq!(
build_webhook_pattern("/api/v1", "teams"),
"/api/v1/webhook/teams"
);
}
pub(crate) fn write_provider_pack(
path: &Path,
pack_id: &str,
provider_type: &str,
ops: &[&str],
) {
use std::io::Write as _;
use zip::write::FileOptions;
let manifest_json = serde_json::json!({
"schema_version": "1.0.0",
"pack_id": pack_id,
"version": "1.0.0",
"kind": "provider",
"publisher": "tests",
"extensions": {
greentic_types::PROVIDER_EXTENSION_ID: {
"kind": greentic_types::PROVIDER_EXTENSION_ID,
"version": "1.0.0",
"inline": {
"providers": [{
"provider_type": provider_type,
"capabilities": [],
"ops": ops,
"config_schema_ref": "config.schema.json",
"runtime": {
"component_ref": format!("{pack_id}-component"),
"export": "schema-core-api",
"world": "greentic:provider/schema-core@1.0.0"
}
}]
}
}
}
});
let manifest: greentic_types::PackManifest =
serde_json::from_value(manifest_json).expect("manifest deserializes");
let bytes = greentic_types::encode_pack_manifest(&manifest).expect("manifest encodes");
let file = std::fs::File::create(path).unwrap();
let mut zip = zip::ZipWriter::new(file);
zip.start_file("manifest.cbor", FileOptions::<()>::default())
.unwrap();
zip.write_all(&bytes).unwrap();
zip.finish().unwrap();
}
#[test]
fn synthesize_provider_routes_for_ingest_http_provider() {
let dir = tempfile::tempdir().unwrap();
let pack = dir.path().join("telegram.gtpack");
write_provider_pack(
&pack,
"telegram-pack",
"messaging.telegram.bot",
&["ingest_http"],
);
let scope = RevisionScope {
deployment_id: DeploymentId::new(),
bundle_id: BundleId::new("acme-bundle"),
revision_id: RevisionId::new(),
};
let routes = synthesize_provider_ingest_routes(&[pack], &scope, &["/bot".to_string()]);
assert_eq!(
routes.len(),
1,
"one prefix × one ingest_http provider = one route"
);
let route = &routes[0];
assert_eq!(route.pattern, "/bot/webhook/telegram");
assert_eq!(route.methods, vec!["POST".to_string()]);
assert_eq!(route.provider_op, INGEST_HTTP_OP);
assert_eq!(
route.provider_type.as_deref(),
Some("messaging.telegram.bot")
);
let stamped = route.scope.as_ref().expect("scope stamped");
assert_eq!(stamped.deployment_id, scope.deployment_id);
assert_eq!(stamped.revision_id, scope.revision_id);
}
#[test]
fn synthesize_provider_routes_skips_providers_without_ingest_http() {
let dir = tempfile::tempdir().unwrap();
let pack = dir.path().join("egress-only.gtpack");
write_provider_pack(&pack, "egress-only", "messaging.email.smtp", &["send"]);
let scope = RevisionScope {
deployment_id: DeploymentId::new(),
bundle_id: BundleId::new("acme-bundle"),
revision_id: RevisionId::new(),
};
let routes = synthesize_provider_ingest_routes(&[pack], &scope, &["/bot".to_string()]);
assert!(routes.is_empty());
}
#[test]
fn synthesize_provider_routes_emits_one_per_prefix() {
let dir = tempfile::tempdir().unwrap();
let pack = dir.path().join("telegram.gtpack");
write_provider_pack(
&pack,
"telegram-pack",
"messaging.telegram.bot",
&["ingest_http"],
);
let scope = RevisionScope {
deployment_id: DeploymentId::new(),
bundle_id: BundleId::new("acme-bundle"),
revision_id: RevisionId::new(),
};
let routes = synthesize_provider_ingest_routes(
&[pack],
&scope,
&["/bot".to_string(), "/api/bot".to_string()],
);
let patterns: Vec<&str> = routes.iter().map(|r| r.pattern.as_str()).collect();
assert_eq!(
patterns,
vec!["/bot/webhook/telegram", "/api/bot/webhook/telegram"]
);
}
#[test]
fn synthesize_provider_routes_empty_prefixes_mounts_at_root() {
let dir = tempfile::tempdir().unwrap();
let pack = dir.path().join("telegram.gtpack");
write_provider_pack(
&pack,
"telegram-pack",
"messaging.telegram.bot",
&["ingest_http"],
);
let scope = RevisionScope {
deployment_id: DeploymentId::new(),
bundle_id: BundleId::new("acme-bundle"),
revision_id: RevisionId::new(),
};
let routes = synthesize_provider_ingest_routes(&[pack], &scope, &[]);
assert_eq!(
routes.len(),
1,
"empty prefixes treated as a single root binding"
);
assert_eq!(routes[0].pattern, "/webhook/telegram");
assert_eq!(routes[0].methods, vec!["POST".to_string()]);
let table = HttpRouteTable::from_descriptors(routes);
assert!(
table
.match_request_for_revision("/webhook/telegram", "POST", &scope)
.is_some(),
"root-bound synthesized route must match /webhook/<name>"
);
}
#[test]
fn synthesize_provider_routes_skips_unreadable_pack() {
let dir = tempfile::tempdir().unwrap();
let bad = dir.path().join("garbage.gtpack");
std::fs::write(&bad, b"not a zip").unwrap();
let missing = dir.path().join("absent.gtpack");
let scope = RevisionScope {
deployment_id: DeploymentId::new(),
bundle_id: BundleId::new("acme-bundle"),
revision_id: RevisionId::new(),
};
let routes =
synthesize_provider_ingest_routes(&[bad, missing], &scope, &["/bot".to_string()]);
assert!(routes.is_empty());
}
#[test]
fn synthesized_route_matches_only_inside_its_scope() {
let dir = tempfile::tempdir().unwrap();
let pack = dir.path().join("telegram.gtpack");
write_provider_pack(
&pack,
"telegram-pack",
"messaging.telegram.bot",
&["ingest_http"],
);
let scope_a = RevisionScope {
deployment_id: DeploymentId::new(),
bundle_id: BundleId::new("a"),
revision_id: RevisionId::new(),
};
let scope_b = RevisionScope {
deployment_id: DeploymentId::new(),
bundle_id: BundleId::new("b"),
revision_id: RevisionId::new(),
};
let routes = synthesize_provider_ingest_routes(&[pack], &scope_a, &["/bot".to_string()]);
let table = HttpRouteTable::from_descriptors(routes);
assert!(
table
.match_request_for_revision("/bot/webhook/telegram", "POST", &scope_a)
.is_some()
);
assert!(
table
.match_request_for_revision("/bot/webhook/telegram", "POST", &scope_b)
.is_none()
);
assert!(
table
.match_request("/bot/webhook/telegram", "POST")
.is_none()
);
}
}