use anyhow::{Context, Result};
use indexmap::{IndexMap, IndexSet};
use log::{debug, warn};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use crate::models::{
ApiDocumentation, Endpoint, Example, OpenApiSpec, Parameter, Response, Schema, Service,
};
use crate::utils::{
extract_security_schemes, extract_servers, resolve_parameter_ref, resolve_path_item_ref,
resolve_request_body_ref, resolve_response_ref,
};
pub fn parse_openapi<P: AsRef<Path>>(path: P) -> Result<ApiDocumentation> {
let path_ref = path.as_ref();
let bytes = std::fs::read(path_ref)
.with_context(|| format!("Failed to open OpenAPI file: {}", path_ref.display()))?;
let extension = path_ref
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default();
parse_openapi_bytes(&bytes, extension, Some(path_ref))
}
pub fn parse_openapi_bytes(
bytes: &[u8],
extension: &str,
path: Option<&Path>,
) -> Result<ApiDocumentation> {
let content = String::from_utf8(bytes.to_vec()).context("OpenAPI file is not valid UTF-8")?;
let file_extension = extension.to_ascii_lowercase();
let spec: OpenApiSpec = if file_extension == "yaml" || file_extension == "yml" {
parse_yaml_spec(&content)
.or_else(|yaml_err| parse_json_spec(&content).map_err(|_| yaml_err))?
} else {
parse_json_spec(&content)
.or_else(|json_err| parse_yaml_spec(&content).map_err(|_| json_err))?
};
validate_openapi(&spec, path)?;
let spec_json = serde_json::to_value(&spec)
.context("Failed to serialize OpenAPI spec for reference resolution")?;
let services = extract_services(&spec, &spec_json);
debug!("Extracted {} services", services.len());
let servers = extract_servers(&spec);
debug!("Extracted {} server URLs", servers.len());
let security_schemes = extract_security_schemes(&spec);
debug!("Extracted {} security schemes", security_schemes.len());
let endpoints = extract_endpoints(&spec, &spec_json, &services);
debug!("Extracted {} endpoints", endpoints.len());
let schemas = extract_schemas(&spec);
debug!("Extracted {} reusable schemas", schemas.len());
let examples = extract_examples(&spec);
debug!("Extracted {} reusable examples", examples.len());
Ok(ApiDocumentation {
title: spec.info.title,
version: spec.info.version,
description: spec.info.description,
services,
endpoints,
servers,
security_schemes,
schemas,
examples,
})
}
fn parse_spec<F, G>(
content: &str,
deserializer: F,
generic_deserializer: G,
format_name: &str,
) -> Result<OpenApiSpec>
where
F: FnOnce(&str) -> Result<OpenApiSpec, anyhow::Error>,
G: FnOnce(&str) -> Result<serde_json::Value, anyhow::Error>,
{
deserializer(content).map_err(|err| {
match generic_deserializer(content) {
Ok(json) => {
if !json.is_object() {
return anyhow::anyhow!("Root element is not a {} object", format_name);
}
let obj = json.as_object().unwrap();
if !obj.contains_key("swagger") && !obj.contains_key("openapi") {
return anyhow::anyhow!(
"Missing 'swagger' or 'openapi' field - not a valid OpenAPI specification"
);
}
if !obj.contains_key("paths") {
return anyhow::anyhow!(
"Missing 'paths' field - not a valid OpenAPI specification"
);
}
if !obj.contains_key("info") {
return anyhow::anyhow!(
"Missing 'info' field - not a valid OpenAPI specification"
);
}
anyhow::anyhow!("Invalid OpenAPI specification structure: {}", err)
}
Err(_) => {
anyhow::anyhow!("File is not valid {}: {}", format_name, err)
}
}
})
}
fn parse_yaml_spec(content: &str) -> Result<OpenApiSpec> {
parse_spec(
content,
|s| serde_norway::from_str(s).map_err(anyhow::Error::new),
|s| serde_norway::from_str(s).map_err(anyhow::Error::new),
"YAML",
)
}
fn parse_json_spec(content: &str) -> Result<OpenApiSpec> {
parse_spec(
content,
|s| serde_json::from_str(s).map_err(anyhow::Error::new),
|s| serde_json::from_str(s).map_err(anyhow::Error::new),
"JSON",
)
}
fn validate_openapi(spec: &OpenApiSpec, path: Option<&Path>) -> Result<()> {
if let Some(version) = &spec.spec_version {
debug!("OpenAPI specification version: {}", version);
} else if let Some(path) = path {
warn!(
"OpenAPI specification version not found in {}, continuing anyway",
path.display()
);
} else {
warn!("OpenAPI specification version not found, continuing anyway");
}
if spec.info.title.is_empty() {
warn!("OpenAPI specification is missing a title");
}
if spec.info.version.is_empty() {
warn!("OpenAPI specification is missing a version");
}
if spec.paths.is_empty() {
warn!("OpenAPI specification has no paths defined");
}
Ok(())
}
fn extract_services(spec: &OpenApiSpec, spec_json: &serde_json::Value) -> Vec<Service> {
let mut services = Vec::new();
let mut seen: IndexSet<String> = IndexSet::new();
if let Some(tags) = &spec.tags {
for tag in tags {
if seen.insert(tag.name.clone()) {
services.push(Service {
name: tag.name.clone(),
description: tag.description.clone(),
});
}
}
}
for (_, path_item) in &spec.paths {
let resolved_path_item;
let path_item = if path_item.reference.is_some() {
match resolve_path_item_ref(spec_json, path_item) {
Some(item) => {
resolved_path_item = item;
&resolved_path_item
}
None => continue,
}
} else {
path_item
};
for op in path_item
.operations()
.into_iter()
.filter_map(|(_, op)| op.as_ref())
{
if let Some(tags) = &op.tags {
for tag in tags {
if seen.insert(tag.clone()) {
services.push(Service {
name: tag.clone(),
description: None,
});
}
}
}
}
}
if services.is_empty() {
services.push(Service {
name: "API".to_string(),
description: None,
});
}
services
}
fn default_service(services: &[Service]) -> String {
services
.first()
.map(|s| s.name.clone())
.unwrap_or_else(|| "API".to_string())
}
fn dedup_parameters(parameters: Vec<Parameter>) -> Vec<Parameter> {
let mut by_key: IndexMap<(String, String), Parameter> = IndexMap::new();
for parameter in parameters {
let key = (parameter.name.clone(), parameter.parameter_in.clone());
by_key.insert(key, parameter);
}
by_key.into_values().collect()
}
fn extract_endpoints(
spec: &OpenApiSpec,
spec_json: &serde_json::Value,
services: &[Service],
) -> Vec<Endpoint> {
let mut endpoints = Vec::new();
let service_map: HashSet<String> = services.iter().map(|s| s.name.clone()).collect();
for (path, path_item) in &spec.paths {
let resolved_path_item;
let path_item = if path_item.reference.is_some() {
match resolve_path_item_ref(spec_json, path_item) {
Some(item) => {
resolved_path_item = item;
&resolved_path_item
}
None => {
warn!("Unresolved $ref for path '{}'; skipping", path);
continue;
}
}
} else {
path_item
};
let path_parameters = path_item
.parameters
.as_ref()
.map(|params| {
params
.iter()
.filter_map(|p| resolve_parameter_ref(spec_json, p))
.collect::<Vec<_>>()
})
.unwrap_or_default();
for (method, operation_opt) in path_item.operations() {
if let Some(operation) = operation_opt {
let known_tags = operation
.tags
.as_ref()
.map(|tags| {
tags.iter()
.filter(|tag| service_map.contains(*tag))
.cloned()
.collect::<Vec<_>>()
})
.filter(|filtered| !filtered.is_empty());
let untagged = known_tags.is_none();
let service_tags = known_tags.unwrap_or_else(|| vec![default_service(services)]);
let mut parameters = path_parameters.clone();
if let Some(op_params) = &operation.parameters {
for param in op_params {
if let Some(resolved_param) = resolve_parameter_ref(spec_json, param) {
parameters.push(resolved_param);
}
}
}
let mut parameters = dedup_parameters(parameters);
if let Some(req_body) = &operation.request_body {
let req_body = resolve_request_body_ref(spec_json, req_body)
.unwrap_or_else(|| req_body.clone());
for (content_type, media_type) in &req_body.content {
let param_name = if req_body.content.len() > 1 {
format!("requestBody ({})", content_type)
} else {
"requestBody".to_string()
};
parameters.push(Parameter {
reference: None,
name: param_name,
description: req_body.description.clone(),
parameter_in: "body".to_string(),
required: Some(req_body.required.unwrap_or(false)),
schema: media_type.schema.clone(),
example: media_type.example.clone(),
examples: media_type.examples.clone(),
extensions: HashMap::new(),
});
}
}
let resolved_responses: IndexMap<String, Response> = operation
.responses
.iter()
.map(|(status_code, response)| {
let resolved = resolve_response_ref(spec_json, response)
.unwrap_or_else(|| response.clone());
(status_code.clone(), resolved)
})
.collect();
endpoints.push(Endpoint {
path: path.clone(),
method: method.to_uppercase(),
services: service_tags,
summary: operation.summary.clone(),
description: operation.description.clone(),
operation_id: operation.operation_id.clone(),
parameters,
responses: resolved_responses,
deprecated: operation.deprecated.unwrap_or(false),
untagged,
});
}
}
}
endpoints
}
fn extract_schemas(spec: &OpenApiSpec) -> IndexMap<String, Schema> {
let mut schemas = IndexMap::new();
if let Some(components) = &spec.components
&& let Some(component_schemas) = &components.schemas
{
for (name, schema) in component_schemas {
schemas.insert(name.clone(), schema.clone());
}
}
if let Some(definitions) = spec
.extensions
.get("definitions")
.and_then(|v| v.as_object())
{
for (name, raw_schema) in definitions {
if schemas.contains_key(name) {
continue;
}
match serde_json::from_value::<Schema>(raw_schema.clone()) {
Ok(schema) => {
schemas.insert(name.clone(), schema);
}
Err(err) => {
warn!(
"Skipping definition '{}': failed to parse schema: {}",
name, err
);
}
}
}
}
schemas
}
fn extract_examples(spec: &OpenApiSpec) -> IndexMap<String, Example> {
let mut examples = IndexMap::new();
if let Some(components) = &spec.components
&& let Some(component_examples) = &components.examples
{
for (name, example) in component_examples {
examples.insert(name.clone(), example.clone());
}
}
examples
}
#[cfg(test)]
mod tests {
use super::*;
use std::assert_matches;
#[test]
fn test_parse_non_existent_file() {
let res = parse_openapi("non_existent_file.json");
assert_matches!(res, Err(_));
}
#[test]
fn test_parse_invalid_format() {
let temp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(temp.path(), "not json or yaml").unwrap();
let res = parse_openapi(temp.path());
assert_matches!(res, Err(_));
}
}