use std::collections::HashMap;
use serde_json::Value;
#[derive(Debug, Clone)]
pub(crate) struct EndpointInfo {
pub path_params: Vec<String>,
#[allow(dead_code)] pub query_params: Vec<String>,
pub response_schema: Option<Value>,
pub is_sse: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct SpecIndex {
pub endpoints: HashMap<(String, String), EndpointInfo>,
pub schemas: HashMap<String, Value>,
}
pub(crate) fn load_spec(path: &std::path::Path, display_name: &str) -> Result<SpecIndex, String> {
let content = std::fs::read_to_string(path).map_err(|e| {
let msg = match e.kind() {
std::io::ErrorKind::NotFound => "file not found".to_string(),
_ => e.to_string(),
};
format!("failed to read \"{display_name}\": {msg}")
})?;
parse_spec(&content)
}
pub(crate) fn parse_spec(json_str: &str) -> Result<SpecIndex, String> {
let root: Value = serde_json::from_str(json_str).map_err(|e| format!("invalid JSON: {e}"))?;
let mut endpoints = HashMap::new();
if let Some(paths) = root.get("paths").and_then(|p| p.as_object()) {
for (path_str, path_item) in paths {
let path_obj = path_item
.as_object()
.ok_or_else(|| format!("path item for {path_str} is not an object"))?;
for method in &["get", "post", "put", "delete", "patch", "head", "options"] {
if let Some(op) = path_obj.get(*method) {
let info = parse_operation(path_str, method, op)?;
endpoints.insert((method.to_uppercase(), path_str.clone()), info);
}
}
}
}
let mut schemas = HashMap::new();
if let Some(components) = root.get("components").and_then(|c| c.as_object()) {
if let Some(s) = components.get("schemas").and_then(|s| s.as_object()) {
for (name, schema) in s {
schemas.insert(name.clone(), schema.clone());
}
}
}
Ok(SpecIndex { endpoints, schemas })
}
fn parse_operation(path: &str, _method: &str, op: &Value) -> Result<EndpointInfo, String> {
let mut path_params = Vec::new();
let mut query_params = Vec::new();
if let Some(params) = op.get("parameters").and_then(|p| p.as_array()) {
for param in params {
let name = param
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let location = param.get("in").and_then(|i| i.as_str()).unwrap_or("");
match location {
"path" => path_params.push(name),
"query" => query_params.push(name),
_ => {}
}
}
}
let template_params = extract_path_params(path);
for tp in &template_params {
if !path_params.contains(tp) {
path_params.push(tp.clone());
}
}
let response_schema = extract_response_schema(op);
let is_sse = op
.get("responses")
.and_then(|r| r.get("200"))
.and_then(|r| r.get("content"))
.and_then(|c| c.get("text/event-stream"))
.is_some();
Ok(EndpointInfo {
path_params,
query_params,
response_schema,
is_sse,
})
}
fn extract_path_params(path: &str) -> Vec<String> {
let mut params = Vec::new();
let mut chars = path.chars().peekable();
while let Some(c) = chars.next() {
if c == '{' {
let mut name = String::new();
for c2 in chars.by_ref() {
if c2 == '}' {
break;
}
name.push(c2);
}
if !name.is_empty() {
params.push(name);
}
}
}
params
}
fn extract_response_schema(op: &Value) -> Option<Value> {
let responses = op.get("responses")?;
for code in &["200", "201", "202"] {
if let Some(resp) = responses.get(*code) {
if let Some(schema) = resp
.get("content")
.and_then(|c| c.get("application/json"))
.and_then(|j| j.get("schema"))
{
return Some(schema.clone());
}
}
}
None
}
pub(crate) fn available_paths_hint(spec: &SpecIndex, prefix: &str) -> String {
let mut matches: Vec<String> = spec
.endpoints
.keys()
.filter(|(_, p)| p.starts_with(prefix) || prefix.is_empty())
.map(|(m, p)| format!("{m} {p}"))
.collect();
matches.sort();
matches.truncate(5);
matches.join(", ")
}
pub(crate) fn available_methods_hint(spec: &SpecIndex, path: &str) -> String {
let mut methods: Vec<&str> = spec
.endpoints
.keys()
.filter(|(_, p)| p == path)
.map(|(m, _)| m.as_str())
.collect();
methods.sort();
methods.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
fn test_spec_json() -> String {
serde_json::json!({
"openapi": "3.0.3",
"info": {"title": "T", "version": "1"},
"paths": {
"/api/teams/{id}/members": {
"get": {
"parameters": [
{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}
],
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {"type": "array", "items": {"$ref": "#/components/schemas/Member"}}
}
}
}
}
}
},
"/api/sse": {
"get": {
"parameters": [
{"name": "deviceId", "in": "query", "schema": {"type": "string"}}
],
"responses": {
"200": {
"description": "sse",
"content": {"text/event-stream": {"schema": {"type": "string"}}}
}
}
}
},
"/api/simple": {
"post": {
"requestBody": {
"content": {
"application/json": {
"schema": {"type": "object", "properties": {"x": {"type": "integer"}}}
}
}
},
"responses": {"200": {"description": "ok"}}
}
}
},
"components": {
"schemas": {
"Member": {
"type": "object",
"required": ["userId"],
"properties": {
"userId": {"type": "string"},
"name": {"type": "string", "nullable": true}
}
}
}
}
}).to_string()
}
#[test]
fn parse_spec_endpoints() {
let idx = parse_spec(&test_spec_json()).unwrap();
assert_eq!(idx.endpoints.len(), 3);
assert!(
idx.endpoints
.contains_key(&("GET".into(), "/api/teams/{id}/members".into()))
);
assert!(
idx.endpoints
.contains_key(&("GET".into(), "/api/sse".into()))
);
assert!(
idx.endpoints
.contains_key(&("POST".into(), "/api/simple".into()))
);
}
#[test]
fn parse_spec_schemas() {
let idx = parse_spec(&test_spec_json()).unwrap();
assert_eq!(idx.schemas.len(), 1);
assert!(idx.schemas.contains_key("Member"));
}
#[test]
fn path_params_extraction() {
let idx = parse_spec(&test_spec_json()).unwrap();
let ep = &idx.endpoints[&("GET".into(), "/api/teams/{id}/members".into())];
assert_eq!(ep.path_params, vec!["id"]);
}
#[test]
fn query_params_extraction() {
let idx = parse_spec(&test_spec_json()).unwrap();
let ep = &idx.endpoints[&("GET".into(), "/api/sse".into())];
assert_eq!(ep.query_params.len(), 1);
assert_eq!(ep.query_params[0], "deviceId");
}
#[test]
fn sse_detection() {
let idx = parse_spec(&test_spec_json()).unwrap();
let ep = &idx.endpoints[&("GET".into(), "/api/sse".into())];
assert!(ep.is_sse);
let ep2 = &idx.endpoints[&("GET".into(), "/api/teams/{id}/members".into())];
assert!(!ep2.is_sse);
}
#[test]
fn response_schema_extraction() {
let idx = parse_spec(&test_spec_json()).unwrap();
let ep = &idx.endpoints[&("GET".into(), "/api/teams/{id}/members".into())];
assert!(ep.response_schema.is_some());
let schema = ep.response_schema.as_ref().unwrap();
assert_eq!(schema.get("type").and_then(|t| t.as_str()), Some("array"));
}
#[test]
fn no_response_schema() {
let idx = parse_spec(&test_spec_json()).unwrap();
let ep = &idx.endpoints[&("POST".into(), "/api/simple".into())];
assert!(ep.response_schema.is_none());
}
#[test]
fn operation_without_responses_has_no_schema() {
let spec = serde_json::json!({
"openapi": "3.0.3",
"info": {"title": "T", "version": "1"},
"paths": {
"/no-responses": {
"get": {
"description": "missing responses object"
}
}
}
})
.to_string();
let idx = parse_spec(&spec).unwrap();
let ep = &idx.endpoints[&("GET".into(), "/no-responses".into())];
assert!(ep.response_schema.is_none());
assert!(!ep.is_sse);
}
#[test]
fn extract_path_params_multi() {
let params = extract_path_params("/api/{a}/foo/{b}/bar/{c}");
assert_eq!(params, vec!["a", "b", "c"]);
}
#[test]
fn extract_path_params_none() {
let params = extract_path_params("/api/simple");
assert!(params.is_empty());
}
#[test]
fn available_paths_hint_filters() {
let idx = parse_spec(&test_spec_json()).unwrap();
let hint = available_paths_hint(&idx, "/api/te");
assert!(hint.contains("/api/teams/{id}/members"));
}
#[test]
fn available_methods_hint_works() {
let idx = parse_spec(&test_spec_json()).unwrap();
let hint = available_methods_hint(&idx, "/api/sse");
assert_eq!(hint, "GET");
}
#[test]
fn parse_invalid_json() {
let result = parse_spec("not json");
assert!(result.is_err());
}
#[test]
fn parse_path_item_not_object_errors() {
let spec = serde_json::json!({
"openapi": "3.0.3",
"info": {"title": "T", "version": "1"},
"paths": {
"/broken": []
}
})
.to_string();
let result = parse_spec(&spec);
assert!(result.is_err());
assert!(result.unwrap_err().contains("is not an object"));
}
#[test]
fn parse_empty_paths() {
let result =
parse_spec(r#"{"openapi":"3.0.0","info":{"title":"T","version":"1"},"paths":{}}"#)
.unwrap();
assert!(result.endpoints.is_empty());
}
#[test]
fn parse_no_components() {
let result =
parse_spec(r#"{"openapi":"3.0.0","info":{"title":"T","version":"1"},"paths":{}}"#)
.unwrap();
assert!(result.schemas.is_empty());
}
#[test]
fn header_param_ignored() {
let spec = serde_json::json!({
"openapi": "3.0.3",
"info": {"title": "T", "version": "1"},
"paths": {
"/test": {
"get": {
"parameters": [
{"name": "X-Token", "in": "header", "schema": {"type": "string"}}
],
"responses": {"200": {"description": "ok"}}
}
}
}
})
.to_string();
let idx = parse_spec(&spec).unwrap();
let ep = &idx.endpoints[&("GET".into(), "/test".into())];
assert!(ep.path_params.is_empty());
assert!(ep.query_params.is_empty());
}
#[test]
fn template_params_dedup() {
let spec = serde_json::json!({
"openapi": "3.0.3",
"info": {"title": "T", "version": "1"},
"paths": {
"/items/{id}": {
"get": {
"parameters": [
{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}
],
"responses": {"200": {"description": "ok"}}
}
}
}
}).to_string();
let idx = parse_spec(&spec).unwrap();
let ep = &idx.endpoints[&("GET".into(), "/items/{id}".into())];
assert_eq!(ep.path_params.len(), 1);
}
#[test]
fn template_params_added_when_not_in_parameters() {
let spec = serde_json::json!({
"openapi": "3.0.3",
"info": {"title": "T", "version": "1"},
"paths": {
"/items/{id}": {
"get": {
"responses": {"200": {"description": "ok"}}
}
}
}
})
.to_string();
let idx = parse_spec(&spec).unwrap();
let ep = &idx.endpoints[&("GET".into(), "/items/{id}".into())];
assert_eq!(ep.path_params, vec!["id"]);
}
#[test]
fn no_paths_key() {
let spec = serde_json::json!({
"openapi": "3.0.3",
"info": {"title": "T", "version": "1"}
})
.to_string();
let idx = parse_spec(&spec).unwrap();
assert!(idx.endpoints.is_empty());
}
#[test]
fn available_paths_hint_empty_prefix() {
let idx = parse_spec(&test_spec_json()).unwrap();
let hint = available_paths_hint(&idx, "");
assert!(!hint.is_empty());
}
#[test]
fn components_with_non_object_schemas_are_ignored() {
let spec = serde_json::json!({
"openapi": "3.0.3",
"info": {"title": "T", "version": "1"},
"paths": {},
"components": {
"schemas": []
}
})
.to_string();
let idx = parse_spec(&spec).unwrap();
assert!(idx.schemas.is_empty());
}
#[test]
fn load_spec_reads_from_file() {
let path = std::env::temp_dir().join("openapi-contract-load-spec.json");
std::fs::write(
&path,
r#"{"openapi":"3.0.0","info":{"title":"T","version":"1"},"paths":{}}"#,
)
.unwrap();
let idx = load_spec(&path, "test-spec.json").unwrap();
assert!(idx.endpoints.is_empty());
let _ = std::fs::remove_file(path);
}
}