1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
//! `OpenAPI` Specification Parser
//!
//! This module provides functionality to load, parse, and validate `OpenAPI` 3.x specifications.
//! It supports loading from files (JSON/YAML) and URLs with strict format detection.
use openapiv3::OpenAPI;
use std::collections::HashSet;
use std::path::Path;
/// Parsed `OpenAPI` specification with extracted metadata
#[derive(Debug)]
pub struct OpenApiSpec {
/// The parsed `OpenAPI` specification
spec: OpenAPI,
/// Base URL extracted from servers section
base_url: String,
}
impl OpenApiSpec {
/// Load `OpenAPI` spec from file with STRICT format detection
///
/// File extension MUST match content:
/// - `.json` → JSON content (fails if YAML)
/// - `.yaml` or `.yml` → YAML content (fails if JSON)
/// - No extension or unknown → Error
///
/// # Arguments
/// * `path` - Path to the `OpenAPI` spec file
///
/// # Example
/// ```no_run
/// use radkit::tools::openapi::OpenApiSpec;
///
/// let spec = OpenApiSpec::from_file("specs/petstore.yaml").unwrap();
/// ```
///
/// # Errors
///
/// Returns an error if the file cannot be read, parsed, or validated.
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, String> {
let path = path.as_ref();
let content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read spec file '{}': {}", path.display(), e))?;
// STRICT: Use extension to determine format
let spec = match path.extension().and_then(|e| e.to_str()) {
Some("json") => serde_json::from_str::<OpenAPI>(&content).map_err(|e| {
format!(
"Failed to parse '{}' as JSON (extension is .json): {}\n\
Hint: If the file contains YAML, rename it to .yaml or .yml",
path.display(),
e
)
})?,
Some("yaml" | "yml") => serde_yaml::from_str::<OpenAPI>(&content).map_err(|e| {
format!(
"Failed to parse '{}' as YAML (extension is .yaml/.yml): {}\n\
Hint: If the file contains JSON, rename it to .json",
path.display(),
e
)
})?,
Some(ext) => {
return Err(format!(
"Unsupported file extension '.{}' for '{}'.\n\
OpenAPI specs must have .json, .yaml, or .yml extension",
ext,
path.display()
));
}
None => {
return Err(format!(
"No file extension for '{}'.\n\
OpenAPI specs must have .json, .yaml, or .yml extension",
path.display()
));
}
};
// Extract base URL from servers
let base_url = spec
.servers
.first()
.map_or_else(|| "http://localhost".to_string(), |s| s.url.clone());
// Validate spec
Self::validate_spec(&spec)?;
Ok(Self { spec, base_url })
}
/// Load `OpenAPI` spec from URL with Content-Type detection
///
/// # Arguments
/// * `url` - URL to fetch the `OpenAPI` spec from
///
/// # Example
/// ```no_run
/// use radkit::tools::openapi::OpenApiSpec;
///
/// # async fn example() -> Result<(), String> {
/// let spec = OpenApiSpec::from_url("https://petstore3.swagger.io/api/v3/openapi.json")
/// .await?;
/// # Ok(())
/// # }
/// ```
///
/// # Errors
///
/// Returns an error if the URL cannot be fetched, the response cannot be parsed, or validation fails.
pub async fn from_url(url: &str) -> Result<Self, String> {
let response = reqwest::get(url)
.await
.map_err(|e| format!("Failed to fetch spec from '{url}': {e}"))?;
// Check Content-Type header (clone to avoid borrow issue)
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(std::string::ToString::to_string)
.unwrap_or_default();
let content = response
.text()
.await
.map_err(|e| format!("Failed to read response from '{url}': {e}"))?;
// Use Content-Type to determine format
let spec = if content_type.contains("json") || Self::url_has_extension(url, "json") {
serde_json::from_str::<OpenAPI>(&content)
.map_err(|e| format!("Failed to parse JSON from '{url}': {e}"))?
} else if content_type.contains("yaml")
|| Self::url_has_extension(url, "yaml")
|| Self::url_has_extension(url, "yml")
{
serde_yaml::from_str::<OpenAPI>(&content)
.map_err(|e| format!("Failed to parse YAML from '{url}': {e}"))?
} else {
// Try JSON first, then YAML
serde_json::from_str::<OpenAPI>(&content)
.or_else(|_| serde_yaml::from_str::<OpenAPI>(&content))
.map_err(|e| {
format!("Failed to parse spec from '{url}' (tried both JSON and YAML): {e}")
})?
};
// Extract base URL from servers (same as from_file)
let base_url = spec
.servers
.first()
.map_or_else(|| url.to_string(), |s| s.url.clone());
// Validate spec
Self::validate_spec(&spec)?;
Ok(Self { spec, base_url })
}
/// Load `OpenAPI` spec from string (auto-detect format)
///
/// # Arguments
/// * `content` - `OpenAPI` spec content as string
/// * `base_url` - Base URL to use for API calls
///
/// # Errors
///
/// Returns an error if the content cannot be parsed as JSON or YAML, or if validation fails.
pub fn from_str(content: &str, base_url: String) -> Result<Self, String> {
// Try JSON first (more common), then YAML
let spec = serde_json::from_str::<OpenAPI>(content)
.or_else(|_| serde_yaml::from_str::<OpenAPI>(content))
.map_err(|e| format!("Failed to parse OpenAPI spec string: {e}"))?;
// Validate spec
Self::validate_spec(&spec)?;
Ok(Self { spec, base_url })
}
fn url_has_extension(target: &str, expected: &str) -> bool {
let trimmed = target.split(['?', '#']).next().unwrap_or(target);
Path::new(trimmed)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case(expected))
}
/// PRAGMATIC VALIDATION: Fail only on critical issues
fn validate_spec(spec: &OpenAPI) -> Result<(), String> {
// 1. OpenAPI version (critical)
if !spec.openapi.starts_with("3.") {
return Err(format!(
"Unsupported OpenAPI version '{}'. Only 3.x is supported.\n\
Hint: OpenAPI 2.0 (Swagger) specs must be converted to 3.x first",
spec.openapi
));
}
// 2. Has at least one path (critical)
if spec.paths.paths.is_empty() {
return Err(
"OpenAPI spec has no paths defined. Cannot generate tools without API operations."
.to_string(),
);
}
// 3. Check for duplicate operation IDs (critical)
let mut operation_ids = HashSet::new();
let mut duplicates = Vec::new();
for path_item in spec.paths.paths.values() {
for operation in Self::get_operations_from_path_item(path_item) {
if let Some(op_id) = &operation.operation_id {
if !operation_ids.insert(op_id.clone()) {
duplicates.push(op_id.clone());
}
}
}
}
if !duplicates.is_empty() {
return Err(format!(
"Duplicate operation IDs found: [{}]\n\
Each operation must have a unique operationId for tool generation.\n\
Hint: Add unique operationId to each operation in your spec",
duplicates.join(", ")
));
}
Ok(())
}
/// Helper to extract operations from a path item reference
fn get_operations_from_path_item(
path_item_ref: &openapiv3::ReferenceOr<openapiv3::PathItem>,
) -> Vec<&openapiv3::Operation> {
let mut ops = Vec::new();
// TODO(Phase 2): Resolve $ref path items instead of skipping
let path_item = match path_item_ref {
openapiv3::ReferenceOr::Item(item) => item,
openapiv3::ReferenceOr::Reference { .. } => return ops,
};
if let Some(op) = &path_item.get {
ops.push(op);
}
if let Some(op) = &path_item.post {
ops.push(op);
}
if let Some(op) = &path_item.put {
ops.push(op);
}
if let Some(op) = &path_item.delete {
ops.push(op);
}
if let Some(op) = &path_item.patch {
ops.push(op);
}
if let Some(op) = &path_item.head {
ops.push(op);
}
if let Some(op) = &path_item.options {
ops.push(op);
}
if let Some(op) = &path_item.trace {
ops.push(op);
}
ops
}
/// Get the base URL for API calls
#[must_use]
#[allow(clippy::missing_const_for_fn)] // `String` -> `&str` deref is not const-stable yet.
pub fn base_url(&self) -> &str {
&self.base_url
}
/// Get access to the underlying `OpenAPI` spec
#[must_use]
pub const fn spec(&self) -> &OpenAPI {
&self.spec
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_from_str_json() {
let json_spec = r#"{
"openapi": "3.0.0",
"info": {"title": "Test API", "version": "1.0.0"},
"paths": {
"/test": {
"get": {
"operationId": "getTest",
"responses": {"200": {"description": "OK"}}
}
}
}
}"#;
let spec = OpenApiSpec::from_str(json_spec, "http://localhost".to_string());
assert!(spec.is_ok());
}
#[test]
fn test_validation_requires_version_3() {
let json_spec = r#"{
"openapi": "2.0",
"info": {"title": "Test API", "version": "1.0.0"},
"paths": {}
}"#;
let spec = OpenApiSpec::from_str(json_spec, "http://localhost".to_string());
assert!(spec.is_err());
assert!(spec.unwrap_err().contains("Unsupported OpenAPI version"));
}
#[test]
fn test_validation_requires_paths() {
let json_spec = r#"{
"openapi": "3.0.0",
"info": {"title": "Test API", "version": "1.0.0"},
"paths": {}
}"#;
let spec = OpenApiSpec::from_str(json_spec, "http://localhost".to_string());
assert!(spec.is_err());
assert!(spec.unwrap_err().contains("no paths defined"));
}
}