use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use harn_parser::{Attribute, AttributeArg, Node, TypeExpr};
use crate::limits::{limits_and_budget_from_attributes, BudgetSpec, RouteLimits};
use crate::DispatchError;
#[derive(Clone, Debug, PartialEq)]
pub struct ExportedParam {
pub name: String,
pub type_expr: Option<TypeExpr>,
pub input_schema: serde_json::Value,
pub has_default: bool,
pub rest: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ExportedCallableKind {
Function,
Pipeline,
}
#[derive(Clone, Debug)]
pub struct ExportedFunction {
pub name: String,
pub kind: ExportedCallableKind,
pub params: Vec<ExportedParam>,
pub return_type: Option<TypeExpr>,
pub input_schema: serde_json::Value,
pub output_schema: Option<serde_json::Value>,
pub required_scopes: BTreeSet<String>,
pub limits: Option<RouteLimits>,
pub budget: Option<BudgetSpec>,
pub route: Option<RouteSpec>,
pub job: Option<JobSpec>,
pub stream: bool,
pub raw: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct JobSpec {
pub name: String,
pub schedule: Option<ScheduleSpec>,
pub queue: Option<String>,
pub retry: Option<RetrySpec>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ScheduleSpec {
pub cron: String,
pub timezone: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RetrySpec {
pub max_attempts: u32,
pub backoff: RetryBackoff,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum RetryBackoff {
#[default]
Svix,
Linear,
Exponential,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RouteSpec {
pub method: String,
pub path: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExportDiagnostic {
pub code: &'static str,
pub line: usize,
pub message: String,
}
pub const ROUTE_ARG_NOT_STRING: &str = "HARN-SRV-001";
pub const ROUTE_BAD_ARITY: &str = "HARN-SRV-002";
pub const SCOPES_ARG_NOT_STRING: &str = "HARN-SRV-003";
pub const JOB_BAD_NAME: &str = "HARN-SRV-004";
pub const SCHEDULE_BAD_ARGS: &str = "HARN-SRV-005";
pub const QUEUE_BAD_NAME: &str = "HARN-SRV-006";
pub const RETRY_BAD_ARGS: &str = "HARN-SRV-007";
pub const JOB_MODIFIER_WITHOUT_JOB: &str = "HARN-SRV-008";
pub const STREAM_BAD_ARGS: &str = "HARN-SRV-009";
pub const STREAM_WITHOUT_ROUTE: &str = "HARN-SRV-010";
pub const RAW_BAD_ARGS: &str = "HARN-SRV-011";
pub const RAW_WITHOUT_ROUTE: &str = "HARN-SRV-012";
pub const RAW_CONFLICTS_WITH_STREAM: &str = "HARN-SRV-013";
impl std::fmt::Display for ExportDiagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.line > 0 {
write!(f, "{}: {} (line {})", self.code, self.message, self.line)
} else {
write!(f, "{}: {}", self.code, self.message)
}
}
}
pub fn emit_export_diagnostics(diagnostics: &[ExportDiagnostic]) {
for diagnostic in diagnostics {
eprintln!("[harn] warning: {diagnostic}");
}
}
#[derive(Clone, Debug)]
pub struct ExportCatalog {
pub script_path: PathBuf,
pub functions: BTreeMap<String, ExportedFunction>,
pub diagnostics: Vec<ExportDiagnostic>,
}
impl ExportCatalog {
pub fn from_path(path: &Path) -> Result<Self, DispatchError> {
let source = fs::read_to_string(path).map_err(|error| {
DispatchError::Io(format!("failed to read {}: {error}", path.display()))
})?;
let program = harn_parser::parse_source(&source).map_err(|error| {
DispatchError::Validation(format!("failed to parse {}: {error}", path.display()))
})?;
let mut functions = BTreeMap::new();
let mut diagnostics = Vec::new();
for node in &program {
let (attrs, inner) = harn_parser::peel_attributes(node);
let Node::FnDecl {
name,
params,
return_type,
is_pub,
..
} = &inner.node
else {
continue;
};
if !*is_pub {
continue;
}
let (limits, budget) = limits_and_budget_from_attributes(attrs);
let route = route_from_attributes(attrs, name, &mut diagnostics);
let stream = stream_from_attributes(attrs, name, route.as_ref(), &mut diagnostics);
let raw = raw_from_attributes(attrs, name, route.as_ref(), stream, &mut diagnostics);
functions.insert(
name.clone(),
ExportedFunction {
name: name.clone(),
kind: ExportedCallableKind::Function,
params: exported_params(params),
return_type: return_type.clone(),
input_schema: harn_vm::json_schema_for_typed_params(params),
output_schema: return_type
.as_ref()
.and_then(harn_vm::json_schema_for_type_expr),
required_scopes: scopes_from_attributes(attrs, name, &mut diagnostics),
limits,
budget,
route,
stream,
raw,
job: job_from_attributes(attrs, name, &mut diagnostics),
},
);
}
let has_public_exports = !functions.is_empty();
for node in &program {
let (attrs, inner) = harn_parser::peel_attributes(node);
let Node::Pipeline {
name,
params,
return_type,
is_pub,
..
} = &inner.node
else {
continue;
};
if has_public_exports && !*is_pub {
continue;
}
let required_scopes = scopes_from_attributes(attrs, name, &mut diagnostics);
let (limits, budget) = limits_and_budget_from_attributes(attrs);
let stream = stream_from_attributes(attrs, name, None, &mut diagnostics);
let raw = raw_from_attributes(attrs, name, None, stream, &mut diagnostics);
functions
.entry(name.clone())
.or_insert_with(|| ExportedFunction {
name: name.clone(),
kind: ExportedCallableKind::Pipeline,
params: pipeline_exported_params(params),
return_type: return_type.clone(),
input_schema: pipeline_input_schema(params),
output_schema: return_type
.as_ref()
.and_then(harn_vm::json_schema_for_type_expr),
required_scopes,
limits,
budget,
route: None,
stream,
raw,
job: job_from_attributes(attrs, name, &mut diagnostics),
});
}
Ok(Self {
script_path: path.to_path_buf(),
functions,
diagnostics,
})
}
pub fn function(&self, name: &str) -> Option<&ExportedFunction> {
self.functions.get(name)
}
pub fn diagnostics(&self) -> &[ExportDiagnostic] {
&self.diagnostics
}
}
fn exported_params(params: &[harn_parser::TypedParam]) -> Vec<ExportedParam> {
params
.iter()
.map(|param| ExportedParam {
name: param.name.clone(),
type_expr: param.type_expr.clone(),
input_schema: param
.type_expr
.as_ref()
.and_then(harn_vm::json_schema_for_type_expr)
.unwrap_or_else(|| serde_json::json!({})),
has_default: param.default_value.is_some(),
rest: param.rest,
})
.collect()
}
fn pipeline_exported_params(params: &[String]) -> Vec<ExportedParam> {
params
.iter()
.map(|name| ExportedParam {
name: name.clone(),
type_expr: None,
input_schema: serde_json::json!({}),
has_default: false,
rest: false,
})
.collect()
}
fn scopes_from_attributes(
attrs: &[Attribute],
fn_name: &str,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> BTreeSet<String> {
let mut set = BTreeSet::new();
for attr in attrs {
if attr.name != "scopes" {
continue;
}
for arg in &attr.args {
match &arg.value.node {
Node::StringLiteral(value) | Node::RawStringLiteral(value) => {
set.insert(value.clone());
}
_ => diagnostics.push(ExportDiagnostic {
code: SCOPES_ARG_NOT_STRING,
line: arg.span.line,
message: format!(
"`@scopes` on `{fn_name}` requires string-literal arguments; \
dropping a non-string scope leaves the route less restricted"
),
}),
}
}
}
set
}
fn route_from_attributes(
attrs: &[Attribute],
fn_name: &str,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> Option<RouteSpec> {
if attrs.iter().any(|attr| attr.name == "route") {
return explicit_route_attribute(attrs, fn_name, diagnostics);
}
handler_convention_route(fn_name)
}
fn explicit_route_attribute(
attrs: &[Attribute],
fn_name: &str,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> Option<RouteSpec> {
let attr = attrs.iter().find(|attr| attr.name == "route")?;
let literals: Vec<&str> = attr
.args
.iter()
.filter_map(|arg| match &arg.value.node {
Node::StringLiteral(value) | Node::RawStringLiteral(value) => Some(value.as_str()),
_ => None,
})
.collect();
if literals.len() != attr.args.len() {
diagnostics.push(ExportDiagnostic {
code: ROUTE_ARG_NOT_STRING,
line: attr.span.line,
message: format!(
"`@route` on `{fn_name}` requires string-literal arguments \
(`@route(\"/path\")` or `@route(\"METHOD\", \"/path\")`); handler not mounted"
),
});
return None;
}
match literals.as_slice() {
[path] => Some(RouteSpec {
method: "GET".to_string(),
path: normalize_route_path(path),
}),
[method, path] => Some(RouteSpec {
method: normalize_route_method(method),
path: normalize_route_path(path),
}),
_ => {
diagnostics.push(ExportDiagnostic {
code: ROUTE_BAD_ARITY,
line: attr.span.line,
message: format!(
"`@route` on `{fn_name}` takes a path or a method and a path \
(`@route(\"/path\")` or `@route(\"METHOD\", \"/path\")`), \
found {} arguments; handler not mounted",
literals.len()
),
});
None
}
}
}
fn handler_convention_route(fn_name: &str) -> Option<RouteSpec> {
let path = match fn_name {
"handler" => "/".to_string(),
other => {
let suffix = other.strip_prefix("handler_")?;
if suffix.is_empty() {
return None;
}
format!("/{suffix}")
}
};
Some(RouteSpec {
method: "*".to_string(),
path,
})
}
fn normalize_route_method(method: &str) -> String {
let upper = method.trim().to_ascii_uppercase();
if upper == "ANY" || upper.is_empty() {
"*".to_string()
} else {
upper
}
}
fn normalize_route_path(path: &str) -> String {
let trimmed = path.trim();
if trimmed.starts_with('/') {
trimmed.to_string()
} else {
format!("/{trimmed}")
}
}
fn stream_from_attributes(
attrs: &[Attribute],
fn_name: &str,
route: Option<&RouteSpec>,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> bool {
bare_route_marker_from_attributes(
attrs,
"stream",
fn_name,
route,
diagnostics,
STREAM_BAD_ARGS,
STREAM_WITHOUT_ROUTE,
)
}
fn raw_from_attributes(
attrs: &[Attribute],
fn_name: &str,
route: Option<&RouteSpec>,
stream: bool,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> bool {
let raw = bare_route_marker_from_attributes(
attrs,
"raw",
fn_name,
route,
diagnostics,
RAW_BAD_ARGS,
RAW_WITHOUT_ROUTE,
);
if raw && stream {
let line = attrs
.iter()
.find(|attr| attr.name == "raw")
.map(|attr| attr.span.line)
.unwrap_or(0);
diagnostics.push(ExportDiagnostic {
code: RAW_CONFLICTS_WITH_STREAM,
line,
message: format!(
"`@raw` on `{fn_name}` conflicts with `@stream` (one never reads the request \
body, the other buffers it); dropping `@raw` — the route behaves as `@stream`"
),
});
return false;
}
raw
}
fn bare_route_marker_from_attributes(
attrs: &[Attribute],
marker: &str,
fn_name: &str,
route: Option<&RouteSpec>,
diagnostics: &mut Vec<ExportDiagnostic>,
bad_args_code: &'static str,
without_route_code: &'static str,
) -> bool {
let Some(attr) = attrs.iter().find(|attr| attr.name == marker) else {
return false;
};
if route.is_none() {
diagnostics.push(ExportDiagnostic {
code: without_route_code,
line: attr.span.line,
message: format!(
"`@{marker}` on `{fn_name}` has no effect without an HTTP route \
(`@route(...)` or the `handler_*` convention); ignoring it"
),
});
return false;
}
if !attr.args.is_empty() {
diagnostics.push(ExportDiagnostic {
code: bad_args_code,
line: attr.span.line,
message: format!(
"`@{marker}` on `{fn_name}` takes no arguments, found {}; marker dropped — \
the route dispatches as a plain handler",
attr.args.len()
),
});
return false;
}
true
}
fn job_from_attributes(
attrs: &[Attribute],
fn_name: &str,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> Option<JobSpec> {
let Some(job_attr) = attrs.iter().find(|attr| attr.name == "job") else {
for modifier in ["schedule", "queue"] {
if let Some(attr) = attrs.iter().find(|attr| attr.name == modifier) {
diagnostics.push(ExportDiagnostic {
code: JOB_MODIFIER_WITHOUT_JOB,
line: attr.span.line,
message: format!(
"`@{modifier}` on `{fn_name}` has no effect without a `@job(\"name\")` \
attribute; ignoring it"
),
});
}
}
return None;
};
let positionals: Vec<&AttributeArg> = job_attr
.args
.iter()
.filter(|arg| arg.name.is_none())
.collect();
let name = match positionals.as_slice() {
[] => fn_name.to_string(),
[arg] => match &arg.value.node {
Node::StringLiteral(value) | Node::RawStringLiteral(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
fn_name.to_string()
} else {
trimmed.to_string()
}
}
_ => {
diagnostics.push(ExportDiagnostic {
code: JOB_BAD_NAME,
line: job_attr.span.line,
message: format!(
"`@job` on `{fn_name}` takes an optional string-literal name \
(`@job` or `@job(\"name\")`); function not registered as a job"
),
});
return None;
}
},
_ => {
diagnostics.push(ExportDiagnostic {
code: JOB_BAD_NAME,
line: job_attr.span.line,
message: format!(
"`@job` on `{fn_name}` takes at most one string-literal name, found {}; \
function not registered as a job",
positionals.len()
),
});
return None;
}
};
Some(JobSpec {
name,
schedule: schedule_from_attributes(attrs, fn_name, diagnostics),
queue: queue_from_attributes(attrs, fn_name, diagnostics),
retry: retry_from_job_attr(job_attr, fn_name, diagnostics),
})
}
fn schedule_from_attributes(
attrs: &[Attribute],
fn_name: &str,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> Option<ScheduleSpec> {
let attr = attrs.iter().find(|attr| attr.name == "schedule")?;
let literals: Vec<&str> = attr
.args
.iter()
.filter_map(|arg| match &arg.value.node {
Node::StringLiteral(value) | Node::RawStringLiteral(value) => Some(value.as_str()),
_ => None,
})
.collect();
if literals.len() != attr.args.len() {
diagnostics.push(ExportDiagnostic {
code: SCHEDULE_BAD_ARGS,
line: attr.span.line,
message: format!(
"`@schedule` on `{fn_name}` requires string-literal arguments \
(`@schedule(\"cron\")` or `@schedule(\"cron\", \"timezone\")`); schedule dropped"
),
});
return None;
}
match literals.as_slice() {
[cron] => Some(ScheduleSpec {
cron: cron.trim().to_string(),
timezone: None,
}),
[cron, timezone] => Some(ScheduleSpec {
cron: cron.trim().to_string(),
timezone: Some(timezone.trim().to_string()),
}),
_ => {
diagnostics.push(ExportDiagnostic {
code: SCHEDULE_BAD_ARGS,
line: attr.span.line,
message: format!(
"`@schedule` on `{fn_name}` takes a cron expression and an optional timezone, \
found {} arguments; schedule dropped",
literals.len()
),
});
None
}
}
}
fn queue_from_attributes(
attrs: &[Attribute],
fn_name: &str,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> Option<String> {
let attr = attrs.iter().find(|attr| attr.name == "queue")?;
match attr.args.as_slice() {
[arg] => match &arg.value.node {
Node::StringLiteral(value) | Node::RawStringLiteral(value)
if !value.trim().is_empty() =>
{
Some(value.trim().to_string())
}
_ => {
diagnostics.push(ExportDiagnostic {
code: QUEUE_BAD_NAME,
line: attr.span.line,
message: format!(
"`@queue` on `{fn_name}` requires a non-empty string-literal queue name \
(`@queue(\"queue-name\")`); queue dropped"
),
});
None
}
},
_ => {
diagnostics.push(ExportDiagnostic {
code: QUEUE_BAD_NAME,
line: attr.span.line,
message: format!(
"`@queue` on `{fn_name}` takes exactly one string-literal queue name, found {}; \
queue dropped",
attr.args.len()
),
});
None
}
}
}
fn retry_from_job_attr(
job_attr: &Attribute,
fn_name: &str,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> Option<RetrySpec> {
let retry_arg = job_attr
.args
.iter()
.find(|arg| arg.name.as_deref() == Some("retry"))?;
let Node::DictLiteral(entries) = &retry_arg.value.node else {
diagnostics.push(ExportDiagnostic {
code: RETRY_BAD_ARGS,
line: retry_arg.span.line,
message: format!(
"`@job(retry:)` on `{fn_name}` requires a dict \
(`retry: {{ max: 3, backoff: \"exponential\" }}`); retry dropped"
),
});
return None;
};
let mut max_attempts: u32 = 0;
let mut backoff = RetryBackoff::default();
for entry in entries {
let key = match &entry.key.node {
Node::Identifier(name) => name.clone(),
Node::StringLiteral(name) | Node::RawStringLiteral(name) => name.clone(),
_ => continue,
};
match key.as_str() {
"max" | "max_attempts" => match &entry.value.node {
Node::IntLiteral(value) if *value >= 0 => max_attempts = *value as u32,
_ => diagnostics.push(ExportDiagnostic {
code: RETRY_BAD_ARGS,
line: retry_arg.span.line,
message: format!(
"`@job(retry:)` `max` on `{fn_name}` requires a non-negative integer; \
using the dispatcher default"
),
}),
},
"backoff" | "policy" => match &entry.value.node {
Node::StringLiteral(value) | Node::RawStringLiteral(value) => {
match value.trim().to_ascii_lowercase().as_str() {
"svix" | "" => backoff = RetryBackoff::Svix,
"linear" => backoff = RetryBackoff::Linear,
"exponential" | "exp" => backoff = RetryBackoff::Exponential,
other => diagnostics.push(ExportDiagnostic {
code: RETRY_BAD_ARGS,
line: retry_arg.span.line,
message: format!(
"`@job(retry:)` `backoff` on `{fn_name}` got unknown strategy \
'{other}' (expected 'svix', 'linear', or 'exponential'); using 'svix'"
),
}),
}
}
_ => diagnostics.push(ExportDiagnostic {
code: RETRY_BAD_ARGS,
line: retry_arg.span.line,
message: format!(
"`@job(retry:)` `backoff` on `{fn_name}` requires a string-literal \
strategy; using 'svix'"
),
}),
},
_ => continue,
}
}
Some(RetrySpec {
max_attempts,
backoff,
})
}
fn pipeline_input_schema(params: &[String]) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": params
.iter()
.map(|name| (name.clone(), serde_json::json!({})))
.collect::<serde_json::Map<_, _>>(),
"required": params,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn export_catalog_only_includes_public_functions() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("server.harn");
std::fs::write(
&path,
r#"
fn hidden() { return "nope" }
pub fn greet(name: string, excited: bool = false) -> string {
if excited { return "hi!" }
return name
}
"#,
)
.expect("write script");
let catalog = ExportCatalog::from_path(&path).expect("catalog");
assert!(catalog.function("hidden").is_none());
let greet = catalog.function("greet").expect("greet export");
assert_eq!(greet.params.len(), 2);
assert_eq!(greet.input_schema["type"], "object");
assert_eq!(
greet.output_schema.as_ref().expect("output")["type"],
"string"
);
}
#[test]
fn export_catalog_captures_scopes_attribute_from_function_decl() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("server.harn");
std::fs::write(
&path,
r#"
@scopes("personas:read", "sessions:write")
pub fn list_sessions() -> string {
return "ok"
}
pub fn ping() -> string {
return "pong"
}
"#,
)
.expect("write script");
let catalog = ExportCatalog::from_path(&path).expect("catalog");
let list = catalog.function("list_sessions").expect("list_sessions");
assert_eq!(
list.required_scopes,
BTreeSet::from(["personas:read".to_string(), "sessions:write".to_string()])
);
let ping = catalog.function("ping").expect("ping");
assert!(ping.required_scopes.is_empty());
}
#[test]
fn export_catalog_parses_limits_and_budget_attributes() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("server.harn");
std::fs::write(
&path,
r#"
@limits(
per_tenant: "100/min",
per_route: "5000/min",
burst: 50,
algorithm: "sliding_window",
in_flight_max: 20,
)
@budget(llm_cost_usd: 0.50, mcp_calls: 20)
pub fn create() -> string { return "ok" }
pub fn ping() -> string { return "pong" }
"#,
)
.expect("write script");
let catalog = ExportCatalog::from_path(&path).expect("catalog");
let create = catalog.function("create").expect("create export");
let limits = create.limits.as_ref().expect("limits parsed");
assert_eq!(limits.per_tenant.unwrap().count, 100);
assert_eq!(limits.per_route.unwrap().count, 5_000);
assert_eq!(limits.burst, Some(50));
assert_eq!(limits.algorithm, crate::limits::Algorithm::SlidingWindow);
assert_eq!(limits.in_flight_max, Some(20));
let budget = create.budget.as_ref().expect("budget parsed");
assert_eq!(budget.llm_cost_usd, Some(0.50));
assert_eq!(budget.mcp_calls, Some(20));
let ping = catalog.function("ping").expect("ping export");
assert!(ping.limits.is_none());
assert!(ping.budget.is_none());
}
#[test]
fn route_attribute_parses_method_and_path() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("server.harn");
std::fs::write(
&path,
r#"
@route("POST", "/users/{id}")
pub fn update_user(req: dict) -> dict { return req }
@route("/health")
pub fn liveness(req: dict) -> dict { return req }
@route("any", "metrics")
pub fn metrics(req: dict) -> dict { return req }
pub fn helper(req: dict) -> dict { return req }
"#,
)
.expect("write script");
let catalog = ExportCatalog::from_path(&path).expect("catalog");
let update = catalog.function("update_user").expect("update_user");
assert_eq!(
update.route,
Some(RouteSpec {
method: "POST".to_string(),
path: "/users/{id}".to_string()
})
);
let liveness = catalog.function("liveness").expect("liveness");
assert_eq!(
liveness.route,
Some(RouteSpec {
method: "GET".to_string(),
path: "/health".to_string()
})
);
let metrics = catalog.function("metrics").expect("metrics");
assert_eq!(
metrics.route,
Some(RouteSpec {
method: "*".to_string(),
path: "/metrics".to_string()
})
);
let helper = catalog.function("helper").expect("helper");
assert_eq!(helper.route, None);
}
#[test]
fn handler_naming_convention_infers_route() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("server.harn");
std::fs::write(
&path,
r"
pub fn handler(req: dict) -> dict { return req }
pub fn handler_echo(req: dict) -> dict { return req }
",
)
.expect("write script");
let catalog = ExportCatalog::from_path(&path).expect("catalog");
assert_eq!(
catalog.function("handler").expect("handler").route,
Some(RouteSpec {
method: "*".to_string(),
path: "/".to_string()
})
);
assert_eq!(
catalog
.function("handler_echo")
.expect("handler_echo")
.route,
Some(RouteSpec {
method: "*".to_string(),
path: "/echo".to_string()
})
);
}
#[test]
fn export_catalog_falls_back_to_legacy_pipelines_without_public_exports() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("server.harn");
std::fs::write(
&path,
r"
pipeline default(task) {
__io_println(task)
}
",
)
.expect("write script");
let catalog = ExportCatalog::from_path(&path).expect("catalog");
let default = catalog.function("default").expect("default pipeline");
assert_eq!(default.kind, ExportedCallableKind::Pipeline);
assert_eq!(default.params[0].name, "task");
}
fn catalog_from_source(source: &str) -> ExportCatalog {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("server.harn");
std::fs::write(&path, source).expect("write script");
ExportCatalog::from_path(&path).expect("catalog")
}
#[test]
fn well_formed_attributes_emit_no_diagnostics() {
let catalog = catalog_from_source(
r#"
@scopes("personas:read")
@route("POST", "/users/{id}")
pub fn update_user(req: dict) -> dict { return req }
@route("/health")
pub fn liveness(req: dict) -> dict { return req }
"#,
);
assert!(
catalog.diagnostics().is_empty(),
"unexpected diagnostics: {:?}",
catalog.diagnostics()
);
}
#[test]
fn route_with_non_string_arg_is_diagnosed_and_unmounted() {
let catalog = catalog_from_source(
r#"
pub fn make_path(req: dict) -> string { return "/x" }
@route("GET", make_path)
pub fn handler_users(req: dict) -> dict { return req }
"#,
);
let handler = catalog.function("handler_users").expect("handler_users");
assert_eq!(
handler.route, None,
"a malformed @route must not fall back to the handler_ convention route"
);
let codes: Vec<&str> = catalog.diagnostics().iter().map(|d| d.code).collect();
assert_eq!(codes, vec![ROUTE_ARG_NOT_STRING]);
}
#[test]
fn route_with_zero_args_is_diagnosed_and_unmounted() {
let catalog = catalog_from_source(
r"
@route()
pub fn handler_status(req: dict) -> dict { return req }
",
);
let handler = catalog.function("handler_status").expect("handler_status");
assert_eq!(handler.route, None);
let codes: Vec<&str> = catalog.diagnostics().iter().map(|d| d.code).collect();
assert_eq!(codes, vec![ROUTE_BAD_ARITY]);
}
#[test]
fn route_with_too_many_args_is_diagnosed_and_unmounted() {
let catalog = catalog_from_source(
r#"
@route("GET", "/x", "/y")
pub fn handler_overspecified(req: dict) -> dict { return req }
"#,
);
let handler = catalog
.function("handler_overspecified")
.expect("handler_overspecified");
assert_eq!(handler.route, None);
let codes: Vec<&str> = catalog.diagnostics().iter().map(|d| d.code).collect();
assert_eq!(codes, vec![ROUTE_BAD_ARITY]);
}
#[test]
fn scopes_with_non_string_arg_is_diagnosed_but_keeps_valid_scopes() {
let catalog = catalog_from_source(
r#"
pub fn make_scope(req: dict) -> string { return "sessions:write" }
@scopes("personas:read", make_scope)
pub fn list_sessions() -> string { return "ok" }
"#,
);
let list = catalog.function("list_sessions").expect("list_sessions");
assert_eq!(
list.required_scopes,
BTreeSet::from(["personas:read".to_string()])
);
let diagnostic = catalog
.diagnostics()
.iter()
.find(|d| d.code == SCOPES_ARG_NOT_STRING)
.expect("scopes diagnostic");
assert!(diagnostic.message.contains("list_sessions"));
}
#[test]
fn job_attribute_parses_name_schedule_queue_and_retry() {
let catalog = catalog_from_source(
r#"
@job("scan", retry: { max: 3, backoff: "exponential" })
@schedule("0 * * * *", "UTC")
@queue("scan-jobs")
pub fn scan(event: TriggerEvent) -> dict { return {ok: true} }
@job
pub fn sweep(event: TriggerEvent) -> dict { return {ok: true} }
pub fn helper(req: dict) -> dict { return req }
"#,
);
assert!(
catalog.diagnostics().is_empty(),
"unexpected diagnostics: {:?}",
catalog.diagnostics()
);
let scan = catalog.function("scan").expect("scan export");
let job = scan.job.as_ref().expect("scan is a job");
assert_eq!(job.name, "scan");
assert_eq!(
job.schedule,
Some(ScheduleSpec {
cron: "0 * * * *".to_string(),
timezone: Some("UTC".to_string()),
})
);
assert_eq!(job.queue.as_deref(), Some("scan-jobs"));
assert_eq!(
job.retry,
Some(RetrySpec {
max_attempts: 3,
backoff: RetryBackoff::Exponential,
})
);
let sweep = catalog.function("sweep").expect("sweep export");
let sweep_job = sweep.job.as_ref().expect("sweep is a job");
assert_eq!(sweep_job.name, "sweep");
assert!(sweep_job.schedule.is_none());
assert!(sweep_job.queue.is_none());
assert!(sweep_job.retry.is_none());
let helper = catalog.function("helper").expect("helper export");
assert!(helper.job.is_none());
}
#[test]
fn job_with_non_string_name_is_diagnosed_and_unregistered() {
let catalog = catalog_from_source(
r#"
pub fn name_of(event: TriggerEvent) -> string { return "x" }
@job(name_of)
pub fn scan(event: TriggerEvent) -> dict { return {ok: true} }
"#,
);
let scan = catalog.function("scan").expect("scan export");
assert!(scan.job.is_none());
let codes: Vec<&str> = catalog.diagnostics().iter().map(|d| d.code).collect();
assert_eq!(codes, vec![JOB_BAD_NAME]);
}
#[test]
fn schedule_modifier_without_job_is_diagnosed() {
let catalog = catalog_from_source(
r#"
@schedule("0 * * * *")
pub fn orphan(event: TriggerEvent) -> dict { return {ok: true} }
"#,
);
let orphan = catalog.function("orphan").expect("orphan export");
assert!(orphan.job.is_none());
let codes: Vec<&str> = catalog.diagnostics().iter().map(|d| d.code).collect();
assert_eq!(codes, vec![JOB_MODIFIER_WITHOUT_JOB]);
}
#[test]
fn retry_with_unknown_backoff_keeps_max_and_diagnoses() {
let catalog = catalog_from_source(
r#"
@job("scan", retry: { max: 5, backoff: "wishful" })
pub fn scan(event: TriggerEvent) -> dict { return {ok: true} }
"#,
);
let scan = catalog.function("scan").expect("scan export");
let retry = scan
.job
.as_ref()
.expect("job")
.retry
.as_ref()
.expect("retry");
assert_eq!(retry.max_attempts, 5);
assert_eq!(retry.backoff, RetryBackoff::Svix);
let codes: Vec<&str> = catalog.diagnostics().iter().map(|d| d.code).collect();
assert_eq!(codes, vec![RETRY_BAD_ARGS]);
}
#[test]
fn stream_attribute_marks_routed_functions_only() {
let catalog = catalog_from_source(
r#"
@stream
@route("GET", "/events")
pub fn events(req: dict) -> dict { return http_ok({}) }
@stream
pub fn handler_feed(req: dict) -> dict { return http_ok({}) }
@route("GET", "/plain")
pub fn plain(req: dict) -> dict { return http_ok({}) }
"#,
);
assert!(
catalog.diagnostics().is_empty(),
"unexpected diagnostics: {:?}",
catalog.diagnostics()
);
assert!(catalog.function("events").expect("events").stream);
assert!(catalog.function("handler_feed").expect("feed").stream);
assert!(!catalog.function("plain").expect("plain").stream);
}
#[test]
fn stream_with_args_is_diagnosed_and_dropped() {
let catalog = catalog_from_source(
r#"
@stream("sse")
@route("GET", "/events")
pub fn events(req: dict) -> dict { return http_ok({}) }
"#,
);
assert!(!catalog.function("events").expect("events").stream);
let codes: Vec<&str> = catalog.diagnostics().iter().map(|d| d.code).collect();
assert_eq!(codes, vec![STREAM_BAD_ARGS]);
}
#[test]
fn stream_without_route_is_diagnosed_and_ignored() {
let catalog = catalog_from_source(
r"
@stream
pub fn helper(req: dict) -> dict { return req }
",
);
assert!(!catalog.function("helper").expect("helper").stream);
let codes: Vec<&str> = catalog.diagnostics().iter().map(|d| d.code).collect();
assert_eq!(codes, vec![STREAM_WITHOUT_ROUTE]);
}
#[test]
fn raw_attribute_marks_routed_functions_only() {
let catalog = catalog_from_source(
r#"
@raw
@route("POST", "/packs/publish")
pub fn publish(req: dict) -> dict { return http_ok({}) }
@raw
pub fn handler_upload(req: dict) -> dict { return http_ok({}) }
@route("GET", "/plain")
pub fn plain(req: dict) -> dict { return http_ok({}) }
"#,
);
assert!(
catalog.diagnostics().is_empty(),
"unexpected diagnostics: {:?}",
catalog.diagnostics()
);
assert!(catalog.function("publish").expect("publish").raw);
assert!(catalog.function("handler_upload").expect("upload").raw);
assert!(!catalog.function("plain").expect("plain").raw);
assert!(!catalog.function("publish").expect("publish").stream);
}
#[test]
fn raw_with_args_is_diagnosed_and_dropped() {
let catalog = catalog_from_source(
r#"
@raw("bytes")
@route("POST", "/upload")
pub fn upload(req: dict) -> dict { return http_ok({}) }
"#,
);
assert!(!catalog.function("upload").expect("upload").raw);
let codes: Vec<&str> = catalog.diagnostics().iter().map(|d| d.code).collect();
assert_eq!(codes, vec![RAW_BAD_ARGS]);
}
#[test]
fn raw_without_route_is_diagnosed_and_ignored() {
let catalog = catalog_from_source(
r"
@raw
pub fn helper(req: dict) -> dict { return req }
",
);
assert!(!catalog.function("helper").expect("helper").raw);
let codes: Vec<&str> = catalog.diagnostics().iter().map(|d| d.code).collect();
assert_eq!(codes, vec![RAW_WITHOUT_ROUTE]);
}
#[test]
fn raw_conflicting_with_stream_is_diagnosed_and_dropped() {
let catalog = catalog_from_source(
r#"
@stream
@raw
@route("GET", "/both")
pub fn both(req: dict) -> dict { return http_ok({}) }
"#,
);
let function = catalog.function("both").expect("both");
assert!(function.stream);
assert!(!function.raw);
let codes: Vec<&str> = catalog.diagnostics().iter().map(|d| d.code).collect();
assert_eq!(codes, vec![RAW_CONFLICTS_WITH_STREAM]);
}
}