use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::sync::Arc;
use serde_json::Value;
use tensor_wasm_api::{
build_router_with_config, routes::CreateFunctionRequest, AppState, AuthConfig, TenantConfig,
};
fn spec_path() -> PathBuf {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest_dir
.parent()
.and_then(|p| p.parent())
.map(|workspace_root| workspace_root.join("openapi/tensor-wasm-api.yaml"))
.expect("workspace root resolves from CARGO_MANIFEST_DIR")
}
fn read_spec() -> String {
let path = spec_path();
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read openapi spec at {path:?}: {e}"))
}
fn expected_routes() -> Vec<(&'static str, &'static str)> {
#[allow(unused_mut)]
let mut routes: Vec<(&'static str, &'static str)> = vec![
("GET", "/healthz"),
("GET", "/metrics"),
("POST", "/functions"),
("DELETE", "/functions/{id}"),
("POST", "/functions/{id}/invoke"),
("POST", "/functions/{id}/invoke-async"),
("POST", "/functions/{id}/invoke-stream"),
("GET", "/jobs/{id}"),
("POST", "/v1/completions"),
("POST", "/v1/chat/completions"),
("POST", "/snapshot/save"),
("POST", "/snapshot/restore"),
];
#[cfg(feature = "kernel-registry-api")]
{
routes.push(("POST", "/kernels"));
routes.push(("GET", "/kernels"));
routes.push(("GET", "/kernels/{name}/{version}"));
}
routes
}
fn live_router() -> axum::Router {
build_router_with_config(
Arc::new(AppState::default()),
AuthConfig::default(),
TenantConfig::default(),
)
}
fn parse_spec_path_keys(yaml: &str) -> BTreeSet<String> {
let mut paths = BTreeSet::new();
let mut in_paths = false;
for raw_line in yaml.lines() {
let line = strip_trailing_comment(raw_line);
if line.trim().is_empty() {
continue;
}
if !line.starts_with(' ') {
in_paths = line == "paths:";
continue;
}
if !in_paths {
continue;
}
if let Some(rest) = line.strip_prefix(" ") {
if !rest.starts_with(' ') && rest.starts_with('/') && rest.ends_with(':') {
let key = rest.trim_end_matches(':').trim().to_string();
paths.insert(key);
}
}
}
paths
}
fn parse_spec_path_methods(yaml: &str) -> BTreeMap<String, BTreeSet<String>> {
let mut by_path: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut in_paths = false;
let mut current: Option<String> = None;
for raw_line in yaml.lines() {
let line = strip_trailing_comment(raw_line);
if line.trim().is_empty() {
continue;
}
if !line.starts_with(' ') {
in_paths = line == "paths:";
current = None;
continue;
}
if !in_paths {
continue;
}
if let Some(rest) = line.strip_prefix(" ") {
if !rest.starts_with(' ') && rest.starts_with('/') && rest.ends_with(':') {
let key = rest.trim_end_matches(':').trim().to_string();
by_path.entry(key.clone()).or_default();
current = Some(key);
continue;
}
}
if let Some(after4) = line.strip_prefix(" ") {
if !after4.starts_with(' ') && after4.ends_with(':') {
let method = after4.trim_end_matches(':').trim().to_ascii_uppercase();
if is_http_method(&method) {
if let Some(path) = ¤t {
by_path
.get_mut(path)
.expect("path entry seeded")
.insert(method);
}
}
}
}
}
by_path
}
fn parse_spec_schema_names(yaml: &str) -> BTreeSet<String> {
let mut names = BTreeSet::new();
let mut in_components = false;
let mut in_schemas = false;
for raw_line in yaml.lines() {
let line = strip_trailing_comment(raw_line);
if line.trim().is_empty() {
continue;
}
if !line.starts_with(' ') {
in_components = line == "components:";
in_schemas = false;
continue;
}
if !in_components {
continue;
}
if let Some(rest) = line.strip_prefix(" ") {
if !rest.starts_with(' ') {
in_schemas = rest == "schemas:";
continue;
}
}
if !in_schemas {
continue;
}
if let Some(after4) = line.strip_prefix(" ") {
if !after4.starts_with(' ') && after4.ends_with(':') {
let name = after4.trim_end_matches(':').trim().to_string();
if is_likely_identifier(&name) {
names.insert(name);
}
}
}
}
names
}
fn parse_schema_required_and_strictness(yaml: &str, schema_name: &str) -> (BTreeSet<String>, bool) {
let mut required = BTreeSet::new();
let mut additional_properties_strict = false;
let mut in_components = false;
let mut in_schemas = false;
let mut in_target = false;
const SCHEMA_CHILD_INDENT: usize = 6;
for raw_line in yaml.lines() {
let line = strip_trailing_comment(raw_line);
if line.trim().is_empty() {
continue;
}
if !line.starts_with(' ') {
in_components = line == "components:";
in_schemas = false;
in_target = false;
continue;
}
if !in_components {
continue;
}
if let Some(rest) = line.strip_prefix(" ") {
if !rest.starts_with(' ') {
in_schemas = rest == "schemas:";
in_target = false;
continue;
}
}
if !in_schemas {
continue;
}
if let Some(after4) = line.strip_prefix(" ") {
if !after4.starts_with(' ') && after4.ends_with(':') {
let name = after4.trim_end_matches(':').trim();
in_target = name == schema_name;
continue;
}
}
if !in_target {
continue;
}
let indent = leading_space_count(line);
if indent != SCHEMA_CHILD_INDENT {
continue;
}
let payload = &line[SCHEMA_CHILD_INDENT..];
if let Some(list) = payload.strip_prefix("required:").map(str::trim) {
if let Some(inner) = list.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
for item in inner.split(',') {
let cleaned = item.trim().trim_matches(|c| c == '"' || c == '\'');
if !cleaned.is_empty() {
required.insert(cleaned.to_string());
}
}
}
} else if let Some(value) = payload.strip_prefix("additionalProperties:").map(str::trim) {
additional_properties_strict = value == "false";
}
}
(required, additional_properties_strict)
}
fn is_http_method(s: &str) -> bool {
matches!(
s,
"GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS" | "TRACE"
)
}
fn is_likely_identifier(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn leading_space_count(s: &str) -> usize {
s.bytes().take_while(|b| *b == b' ').count()
}
fn strip_trailing_comment(line: &str) -> &str {
if line.contains('"') || line.contains('\'') {
return line.trim_end();
}
match line.find('#') {
Some(idx) => line[..idx].trim_end(),
None => line.trim_end(),
}
}
fn axum_to_openapi_path(p: &str) -> String {
let mut out = String::with_capacity(p.len());
let mut chars = p.chars().peekable();
while let Some(c) = chars.next() {
if c == ':' {
let mut ident = String::new();
while let Some(&next) = chars.peek() {
if next == '/' {
break;
}
ident.push(next);
chars.next();
}
out.push('{');
out.push_str(&ident);
out.push('}');
} else {
out.push(c);
}
}
out
}
async fn assert_router_serves(method: &str, path: &str) {
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use tower::ServiceExt;
let concrete = path.replace("{id}", "00000000-0000-4000-8000-000000000000");
let m = Method::from_bytes(method.as_bytes()).expect("known HTTP method");
let mut builder = Request::builder().method(m.clone()).uri(&concrete);
let body = if matches!(m, Method::POST) {
builder = builder.header("content-type", "application/json");
Body::from(b"{}".to_vec())
} else {
Body::empty()
};
let req = builder.body(body).expect("request builds");
let resp = live_router()
.oneshot(req)
.await
.expect("router serves the request");
assert_ne!(
resp.status(),
StatusCode::METHOD_NOT_ALLOWED,
"router 405 for {method} {concrete}: route registered with wrong verb?",
);
let probe = Request::builder()
.method(Method::PATCH)
.uri(&concrete)
.body(Body::empty())
.expect("probe builds");
let probe_resp = live_router()
.oneshot(probe)
.await
.expect("router serves the probe");
assert_eq!(
probe_resp.status(),
StatusCode::METHOD_NOT_ALLOWED,
"PATCH {concrete} returned {} — expected 405 (proving the path \
exists). 404 implies the route is missing from src/server.rs.",
probe_resp.status(),
);
}
#[test]
fn spec_file_exists_and_is_nonempty() {
let yaml = read_spec();
assert!(
yaml.len() > 256,
"spec is suspiciously short ({} bytes)",
yaml.len()
);
assert!(
yaml.starts_with("# SPDX-License-Identifier: Apache-2.0"),
"spec must start with SPDX header",
);
assert!(
yaml.contains("openapi: 3.1"),
"spec must declare OpenAPI 3.1 (found header `openapi:` other than 3.1)",
);
}
#[test]
fn spec_paths_cover_router() {
let yaml = read_spec();
let spec_paths: BTreeSet<String> = {
let all = parse_spec_path_keys(&yaml);
#[cfg(not(feature = "kernel-registry-api"))]
{
all.into_iter()
.filter(|p| !p.starts_with("/kernels"))
.collect()
}
#[cfg(feature = "kernel-registry-api")]
{
all
}
};
let expected: BTreeSet<String> = expected_routes()
.iter()
.map(|(_, p)| p.to_string())
.collect();
assert_eq!(
spec_paths, expected,
"OpenAPI spec `paths:` keys do not match the live router. \
Either add the missing entry to openapi/tensor-wasm-api.yaml \
or remove the stale route from `expected_routes()` (and the \
router in src/server.rs).",
);
}
#[test]
fn spec_methods_match_router_methods() {
let yaml = read_spec();
let documented = parse_spec_path_methods(&yaml);
let documented: BTreeMap<String, BTreeSet<String>> = {
#[cfg(not(feature = "kernel-registry-api"))]
{
documented
.into_iter()
.filter(|(p, _)| !p.starts_with("/kernels"))
.collect()
}
#[cfg(feature = "kernel-registry-api")]
{
documented
}
};
let mut expected: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for (method, path) in expected_routes() {
expected
.entry(path.to_string())
.or_default()
.insert(method.to_ascii_uppercase());
}
assert_eq!(
documented, expected,
"OpenAPI spec method maps do not match the live router. \
Compare keys per path and add/remove operations under the \
relevant `paths:` entry in openapi/tensor-wasm-api.yaml.",
);
}
#[test]
fn spec_declares_required_component_schemas() {
let yaml = read_spec();
let schemas = parse_spec_schema_names(&yaml);
for needed in [
"FunctionId",
"JobId",
"Health",
"CreateFunctionRequest",
"CreateFunctionResponse",
"InvokeRequest",
"InvokeResult",
"InvokeAsyncResponse",
"JobStatus",
"JobRecord",
"ApiErrorBody",
"ApiErrorEnvelope",
] {
assert!(
schemas.contains(needed),
"components.schemas is missing `{needed}`. Existing schemas: {schemas:?}",
);
}
}
#[test]
fn create_function_request_round_trips_through_spec() {
let req = CreateFunctionRequest {
name: "round-trip".to_string(),
wasm_b64: "AGFzbQEAAAA=".to_string(),
};
let payload = serde_json::json!({
"name": req.name,
"wasm_b64": req.wasm_b64,
});
let yaml = read_spec();
let (required, strict) = parse_schema_required_and_strictness(&yaml, "CreateFunctionRequest");
assert!(
!required.is_empty(),
"spec must declare `required:` for CreateFunctionRequest",
);
assert!(
strict,
"spec must declare `additionalProperties: false` on CreateFunctionRequest \
so unknown keys are rejected by generated clients",
);
let obj = payload.as_object().expect("payload is a JSON object");
for required_key in &required {
assert!(
obj.contains_key(required_key),
"CreateFunctionRequest is missing required key `{required_key}` per the spec; \
got keys {:?}",
obj.keys().collect::<Vec<_>>(),
);
}
for actual_key in obj.keys() {
assert!(
required.contains(actual_key),
"payload key `{actual_key}` is not declared in the spec's \
CreateFunctionRequest schema; required set is {required:?}",
);
}
let _parsed: CreateFunctionRequest =
serde_json::from_value(Value::Object(obj.clone())).expect("payload deserialises");
}
#[tokio::test]
async fn router_routes_match_expected_set() {
for (method, path) in expected_routes() {
assert_router_serves(method, path).await;
}
}
#[tokio::test]
async fn round_trip_synthetic_request_through_router() {
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
let payload = serde_json::json!({
"name": "openapi-round-trip",
"wasm_b64": "AGFzbQEAAAA=",
});
let req = Request::builder()
.method(Method::POST)
.uri("/functions")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&payload).unwrap()))
.expect("request builds");
let resp = live_router()
.oneshot(req)
.await
.expect("router serves /functions");
assert_eq!(
resp.status(),
StatusCode::OK,
"POST /functions with a valid CreateFunctionRequest should be 200",
);
let body_bytes = resp
.into_body()
.collect()
.await
.expect("body collects")
.to_bytes();
let body: Value = serde_json::from_slice(&body_bytes).expect("response is JSON");
let id = body
.get("id")
.and_then(Value::as_str)
.expect("CreateFunctionResponse has `id` per the spec");
uuid::Uuid::parse_str(id).expect("id is a uuid per the CreateFunctionResponse schema");
}
#[test]
fn scanner_extracts_paths() {
let yaml = "\
paths:
/a:
get:
summary: x
/b/{id}:
post:
summary: y
components:
schemas:
Foo:
type: object
";
let paths = parse_spec_path_keys(yaml);
let expected: BTreeSet<String> = ["/a", "/b/{id}"].iter().map(|s| s.to_string()).collect();
assert_eq!(paths, expected);
let methods = parse_spec_path_methods(yaml);
assert_eq!(
methods
.get("/a")
.map(|m| m.iter().cloned().collect::<Vec<_>>()),
Some(vec!["GET".to_string()]),
);
assert_eq!(
methods
.get("/b/{id}")
.map(|m| m.iter().cloned().collect::<Vec<_>>()),
Some(vec!["POST".to_string()]),
);
let schemas = parse_spec_schema_names(yaml);
assert!(schemas.contains("Foo"));
}
#[test]
fn scanner_parses_required_and_strictness() {
let yaml = "\
components:
schemas:
Bar:
type: object
properties:
a: { type: string }
b: { type: integer }
required: [a, b]
additionalProperties: false
";
let (required, strict) = parse_schema_required_and_strictness(yaml, "Bar");
assert_eq!(required, ["a", "b"].iter().map(|s| s.to_string()).collect());
assert!(strict);
}
#[test]
fn scanner_axum_to_openapi_translation() {
assert_eq!(axum_to_openapi_path("/healthz"), "/healthz");
assert_eq!(axum_to_openapi_path("/functions/:id"), "/functions/{id}");
assert_eq!(
axum_to_openapi_path("/functions/:id/invoke"),
"/functions/{id}/invoke",
);
assert_eq!(
axum_to_openapi_path("/functions/:id/invoke-async"),
"/functions/{id}/invoke-async",
);
}