use crate::openapi::swagger_convert;
use crate::{Error, Result};
use openapiv3::{OpenAPI, ReferenceOr, Schema};
use std::collections::HashSet;
use std::path::Path;
use tokio::fs;
use tracing;
#[derive(Debug, Clone)]
pub struct OpenApiSpec {
pub spec: OpenAPI,
pub file_path: Option<String>,
pub raw_document: Option<serde_json::Value>,
}
impl OpenApiSpec {
pub async fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let path_ref = path.as_ref();
let content = fs::read_to_string(path_ref)
.await
.map_err(|e| Error::io_with_context("reading OpenAPI spec file", e.to_string()))?;
let raw_json = if path_ref.extension().and_then(|s| s.to_str()) == Some("yaml")
|| path_ref.extension().and_then(|s| s.to_str()) == Some("yml")
{
let yaml_value: serde_yaml::Value = serde_yaml::from_str(&content)
.map_err(|e| Error::config(format!("Failed to parse YAML OpenAPI spec: {}", e)))?;
serde_json::to_value(&yaml_value).map_err(|e| {
Error::config(format!("Failed to convert YAML OpenAPI spec to JSON: {}", e))
})?
} else {
serde_json::from_str(&content)
.map_err(|e| Error::config(format!("Failed to parse JSON OpenAPI spec: {}", e)))?
};
let (raw_document, spec) = if swagger_convert::is_swagger_2(&raw_json) {
tracing::info!("Detected Swagger 2.0 specification, converting to OpenAPI 3.0");
let converted =
swagger_convert::convert_swagger_to_openapi3(&raw_json).map_err(|e| {
Error::config(format!("Failed to convert Swagger 2.0 to OpenAPI 3.0: {}", e))
})?;
let spec: OpenAPI = serde_json::from_value(converted.clone()).map_err(|e| {
Error::config(format!("Failed to parse converted OpenAPI spec: {}", e))
})?;
(converted, spec)
} else {
let spec: OpenAPI = serde_json::from_value(raw_json.clone()).map_err(|e| {
let error_str = format!("{}", e);
let mut error_msg = format!("Failed to read OpenAPI spec: {}", e);
if error_str.contains("missing field") {
tracing::error!("OpenAPI deserialization error: {}", error_str);
if let Some(info) = raw_json.get("info") {
if let Some(info_obj) = info.as_object() {
let has_desc = info_obj.contains_key("description");
error_msg
.push_str(&format!(" | Info.description present: {}", has_desc));
}
}
if let Some(servers) = raw_json.get("servers") {
if let Some(servers_arr) = servers.as_array() {
error_msg.push_str(&format!(" | Servers count: {}", servers_arr.len()));
}
}
}
Error::config(error_msg)
})?;
(raw_json, spec)
};
Ok(Self {
spec,
file_path: path_ref.to_str().map(|s| s.to_string()),
raw_document: Some(raw_document),
})
}
pub fn from_string(content: &str, format: Option<&str>) -> Result<Self> {
let raw_json = if format == Some("yaml") || format == Some("yml") {
let yaml_value: serde_yaml::Value = serde_yaml::from_str(content)
.map_err(|e| Error::config(format!("Failed to parse YAML OpenAPI spec: {}", e)))?;
serde_json::to_value(&yaml_value).map_err(|e| {
Error::config(format!("Failed to convert YAML OpenAPI spec to JSON: {}", e))
})?
} else {
serde_json::from_str(content)
.map_err(|e| Error::config(format!("Failed to parse JSON OpenAPI spec: {}", e)))?
};
let (raw_document, spec) = if swagger_convert::is_swagger_2(&raw_json) {
let converted =
swagger_convert::convert_swagger_to_openapi3(&raw_json).map_err(|e| {
Error::config(format!("Failed to convert Swagger 2.0 to OpenAPI 3.0: {}", e))
})?;
let spec: OpenAPI = serde_json::from_value(converted.clone()).map_err(|e| {
Error::config(format!("Failed to parse converted OpenAPI spec: {}", e))
})?;
(converted, spec)
} else {
let spec: OpenAPI = serde_json::from_value(raw_json.clone())
.map_err(|e| Error::io_with_context("reading OpenAPI spec", e.to_string()))?;
(raw_json, spec)
};
Ok(Self {
spec,
file_path: None,
raw_document: Some(raw_document),
})
}
pub fn from_json(json: serde_json::Value) -> Result<Self> {
let (raw_document, spec) = if swagger_convert::is_swagger_2(&json) {
let converted = swagger_convert::convert_swagger_to_openapi3(&json).map_err(|e| {
Error::config(format!("Failed to convert Swagger 2.0 to OpenAPI 3.0: {}", e))
})?;
let spec: OpenAPI = serde_json::from_value(converted.clone()).map_err(|e| {
Error::config(format!("Failed to parse converted OpenAPI spec: {}", e))
})?;
(converted, spec)
} else {
let json_for_doc = json.clone();
let spec: OpenAPI = serde_json::from_value(json)
.map_err(|e| Error::config(format!("Failed to parse JSON OpenAPI spec: {}", e)))?;
(json_for_doc, spec)
};
Ok(Self {
spec,
file_path: None,
raw_document: Some(raw_document),
})
}
pub fn validate(&self) -> Result<()> {
if self.spec.paths.paths.is_empty() {
return Err(Error::validation("OpenAPI spec must contain at least one path"));
}
if self.spec.info.title.is_empty() {
return Err(Error::validation("OpenAPI spec info must have a title"));
}
if self.spec.info.version.is_empty() {
return Err(Error::validation("OpenAPI spec info must have a version"));
}
Ok(())
}
pub fn validate_enhanced(&self) -> crate::spec_parser::ValidationResult {
if let Some(raw) = &self.raw_document {
let format = if raw.get("swagger").is_some() {
crate::spec_parser::SpecFormat::OpenApi20
} else if let Some(version) = raw.get("openapi").and_then(|v| v.as_str()) {
if version.starts_with("3.1") {
crate::spec_parser::SpecFormat::OpenApi31
} else {
crate::spec_parser::SpecFormat::OpenApi30
}
} else {
crate::spec_parser::SpecFormat::OpenApi30
};
crate::spec_parser::OpenApiValidator::validate(raw, format)
} else {
crate::spec_parser::ValidationResult::failure(vec![
crate::spec_parser::ValidationError::new(
"Cannot perform enhanced validation without raw document".to_string(),
),
])
}
}
pub fn version(&self) -> &str {
&self.spec.openapi
}
pub fn title(&self) -> &str {
&self.spec.info.title
}
pub fn description(&self) -> Option<&str> {
self.spec.info.description.as_deref()
}
pub fn api_version(&self) -> &str {
&self.spec.info.version
}
pub fn servers(&self) -> &[openapiv3::Server] {
&self.spec.servers
}
pub fn paths(&self) -> &openapiv3::Paths {
&self.spec.paths
}
pub fn schemas(&self) -> Option<&indexmap::IndexMap<String, ReferenceOr<Schema>>> {
self.spec.components.as_ref().map(|c| &c.schemas)
}
pub fn security_schemes(
&self,
) -> Option<&indexmap::IndexMap<String, ReferenceOr<openapiv3::SecurityScheme>>> {
self.spec.components.as_ref().map(|c| &c.security_schemes)
}
pub fn operations_for_path(
&self,
path: &str,
) -> std::collections::HashMap<String, openapiv3::Operation> {
let mut operations = std::collections::HashMap::new();
if let Some(path_item_ref) = self.spec.paths.paths.get(path) {
if let Some(path_item) = path_item_ref.as_item() {
if let Some(op) = &path_item.get {
operations.insert("GET".to_string(), op.clone());
}
if let Some(op) = &path_item.post {
operations.insert("POST".to_string(), op.clone());
}
if let Some(op) = &path_item.put {
operations.insert("PUT".to_string(), op.clone());
}
if let Some(op) = &path_item.delete {
operations.insert("DELETE".to_string(), op.clone());
}
if let Some(op) = &path_item.patch {
operations.insert("PATCH".to_string(), op.clone());
}
if let Some(op) = &path_item.head {
operations.insert("HEAD".to_string(), op.clone());
}
if let Some(op) = &path_item.options {
operations.insert("OPTIONS".to_string(), op.clone());
}
if let Some(op) = &path_item.trace {
operations.insert("TRACE".to_string(), op.clone());
}
}
}
operations
}
pub fn all_paths_and_operations(
&self,
) -> std::collections::HashMap<String, std::collections::HashMap<String, openapiv3::Operation>>
{
self.spec
.paths
.paths
.iter()
.map(|(path, _)| (path.clone(), self.operations_for_path(path)))
.collect()
}
pub fn get_schema(&self, reference: &str) -> Option<crate::openapi::schema::OpenApiSchema> {
self.resolve_schema(reference).map(crate::openapi::schema::OpenApiSchema::new)
}
pub fn resolve_schema_ref(&self, reference: &str) -> Option<Schema> {
self.resolve_schema(reference)
}
pub fn validate_security_requirements(
&self,
security_requirements: &[openapiv3::SecurityRequirement],
auth_header: Option<&str>,
api_key: Option<&str>,
) -> Result<()> {
if security_requirements.is_empty() {
return Ok(());
}
for requirement in security_requirements {
if self.is_security_requirement_satisfied(requirement, auth_header, api_key)? {
return Ok(());
}
}
Err(Error::validation(
"Security validation failed: no valid authentication provided",
))
}
fn resolve_schema(&self, reference: &str) -> Option<Schema> {
let mut visited = HashSet::new();
self.resolve_schema_recursive(reference, &mut visited)
}
fn resolve_schema_recursive(
&self,
reference: &str,
visited: &mut HashSet<String>,
) -> Option<Schema> {
if !visited.insert(reference.to_string()) {
tracing::warn!("Detected recursive schema reference: {}", reference);
return None;
}
let schema_name = reference.strip_prefix("#/components/schemas/")?;
let components = self.spec.components.as_ref()?;
let schema_ref = components.schemas.get(schema_name)?;
match schema_ref {
ReferenceOr::Item(schema) => Some(schema.clone()),
ReferenceOr::Reference { reference: nested } => {
self.resolve_schema_recursive(nested, visited)
}
}
}
fn is_security_requirement_satisfied(
&self,
requirement: &openapiv3::SecurityRequirement,
auth_header: Option<&str>,
api_key: Option<&str>,
) -> Result<bool> {
for (scheme_name, _scopes) in requirement {
if !self.is_security_scheme_satisfied(scheme_name, auth_header, api_key)? {
return Ok(false);
}
}
Ok(true)
}
fn is_security_scheme_satisfied(
&self,
scheme_name: &str,
auth_header: Option<&str>,
api_key: Option<&str>,
) -> Result<bool> {
let security_schemes = match self.security_schemes() {
Some(schemes) => schemes,
None => return Ok(false),
};
let scheme = match security_schemes.get(scheme_name) {
Some(scheme) => scheme,
None => {
return Err(Error::config(format!("Security scheme '{}' not found", scheme_name)))
}
};
let scheme = match scheme {
ReferenceOr::Item(s) => s,
ReferenceOr::Reference { reference } => {
let ref_name =
reference.strip_prefix("#/components/securitySchemes/").ok_or_else(|| {
Error::config(format!(
"Unsupported security scheme reference format: {}",
reference
))
})?;
match security_schemes.get(ref_name) {
Some(ReferenceOr::Item(resolved)) => resolved,
Some(ReferenceOr::Reference { .. }) => {
return Err(Error::config(format!(
"Nested security scheme reference not supported: {}",
ref_name
)))
}
None => {
return Err(Error::config(format!(
"Security scheme '{}' not found",
ref_name
)))
}
}
}
};
match scheme {
openapiv3::SecurityScheme::HTTP { scheme, .. } => {
match scheme.as_str() {
"bearer" => match auth_header {
Some(header) if header.starts_with("Bearer ") => Ok(true),
_ => Ok(false),
},
"basic" => match auth_header {
Some(header) if header.starts_with("Basic ") => Ok(true),
_ => Ok(false),
},
_ => Ok(false), }
}
openapiv3::SecurityScheme::APIKey { location, .. } => match location {
openapiv3::APIKeyLocation::Header => Ok(auth_header.is_some()),
openapiv3::APIKeyLocation::Query => Ok(api_key.is_some()),
openapiv3::APIKeyLocation::Cookie => Ok(api_key.is_some()),
},
openapiv3::SecurityScheme::OpenIDConnect { .. } => {
match auth_header {
Some(header) if header.starts_with("Bearer ") => Ok(true),
_ => Ok(false),
}
}
openapiv3::SecurityScheme::OAuth2 { .. } => {
match auth_header {
Some(header) if header.starts_with("Bearer ") => Ok(true),
_ => Ok(false),
}
}
}
}
pub fn get_global_security_requirements(&self) -> Vec<openapiv3::SecurityRequirement> {
self.spec.security.clone().unwrap_or_default()
}
pub fn get_request_body(&self, reference: &str) -> Option<&openapiv3::RequestBody> {
if let Some(components) = &self.spec.components {
if let Some(param_name) = reference.strip_prefix("#/components/requestBodies/") {
if let Some(request_body_ref) = components.request_bodies.get(param_name) {
return request_body_ref.as_item();
}
}
}
None
}
pub fn get_response(&self, reference: &str) -> Option<&openapiv3::Response> {
if let Some(components) = &self.spec.components {
if let Some(response_name) = reference.strip_prefix("#/components/responses/") {
if let Some(response_ref) = components.responses.get(response_name) {
return response_ref.as_item();
}
}
}
None
}
pub fn get_example(&self, reference: &str) -> Option<&openapiv3::Example> {
if let Some(components) = &self.spec.components {
if let Some(example_name) = reference.strip_prefix("#/components/examples/") {
if let Some(example_ref) = components.examples.get(example_name) {
return example_ref.as_item();
}
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use openapiv3::{SchemaKind, Type};
#[test]
fn resolves_security_scheme_ref() {
let yaml = r#"
openapi: 3.0.3
info:
title: Test API
version: "1.0.0"
paths:
/test:
get:
security:
- BearerRef: []
responses:
'200':
description: OK
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
BearerRef:
$ref: '#/components/securitySchemes/BearerAuth'
"#;
let spec = OpenApiSpec::from_string(yaml, Some("yaml")).expect("spec parses");
let result = spec
.is_security_scheme_satisfied("BearerRef", Some("Bearer token123"), None)
.expect("should resolve ref");
assert!(result);
let result = spec
.is_security_scheme_satisfied("BearerRef", None, None)
.expect("should resolve ref");
assert!(!result);
}
#[test]
fn resolves_nested_schema_references() {
let yaml = r#"
openapi: 3.0.3
info:
title: Test API
version: "1.0.0"
paths: {}
components:
schemas:
Apiary:
type: object
properties:
id:
type: string
hive:
$ref: '#/components/schemas/Hive'
Hive:
type: object
properties:
name:
type: string
HiveWrapper:
$ref: '#/components/schemas/Hive'
"#;
let spec = OpenApiSpec::from_string(yaml, Some("yaml")).expect("spec parses");
let apiary = spec.get_schema("#/components/schemas/Apiary").expect("resolve apiary schema");
assert!(matches!(apiary.schema.schema_kind, SchemaKind::Type(Type::Object(_))));
let wrapper = spec
.get_schema("#/components/schemas/HiveWrapper")
.expect("resolve wrapper schema");
assert!(matches!(wrapper.schema.schema_kind, SchemaKind::Type(Type::Object(_))));
}
}