use serde_json::json;
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq)]
pub struct OpenApiSpec {
pub info: ApiInfo,
pub servers: Vec<ApiServer>,
pub operations: Vec<ApiOperation>,
pub tags: Vec<ApiTag>,
pub schemas: BTreeMap<String, SchemaDefinition>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ApiInfo {
pub title: String,
pub version: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ApiServer {
pub url: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ApiTag {
pub name: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpMethod {
Get,
Post,
Put,
Delete,
Patch,
Head,
Options,
}
impl HttpMethod {
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"get" => Some(Self::Get),
"post" => Some(Self::Post),
"put" => Some(Self::Put),
"delete" => Some(Self::Delete),
"patch" => Some(Self::Patch),
"head" => Some(Self::Head),
"options" => Some(Self::Options),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Get => "GET",
Self::Post => "POST",
Self::Put => "PUT",
Self::Delete => "DELETE",
Self::Patch => "PATCH",
Self::Head => "HEAD",
Self::Options => "OPTIONS",
}
}
pub fn badge_class(&self) -> &'static str {
match self {
Self::Get => "badge-soft badge-success",
Self::Post => "badge-soft badge-primary",
Self::Put => "badge-soft badge-warning",
Self::Delete => "badge-soft badge-error",
Self::Patch => "badge-soft badge-info",
Self::Head => "badge-soft badge-ghost",
Self::Options => "badge-soft badge-ghost",
}
}
pub fn bg_class(&self) -> &'static str {
match self {
Self::Get => "bg-success/10 border-success/30 text-success",
Self::Post => "bg-primary/10 border-primary/30 text-primary",
Self::Put => "bg-warning/10 border-warning/30 text-warning",
Self::Delete => "bg-error/10 border-error/30 text-error",
Self::Patch => "bg-info/10 border-info/30 text-info",
Self::Head => "bg-base-300 border-base-content/20 text-base-content/70",
Self::Options => "bg-base-300 border-base-content/20 text-base-content/70",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ApiOperation {
pub operation_id: Option<String>,
pub method: HttpMethod,
pub path: String,
pub summary: Option<String>,
pub description: Option<String>,
pub tags: Vec<String>,
pub parameters: Vec<ApiParameter>,
pub request_body: Option<ApiRequestBody>,
pub responses: Vec<ApiResponse>,
pub deprecated: bool,
}
impl ApiOperation {
pub fn slug(&self) -> String {
if let Some(op_id) = &self.operation_id {
slugify_operation_id(op_id)
} else {
let path_slug = self
.path
.trim_matches('/')
.replace('/', "-")
.replace(['{', '}'], "");
format!("{}-{}", self.method.as_str().to_lowercase(), path_slug)
}
}
pub fn generate_curl(&self, base_url: &str) -> String {
let mut parts = vec!["curl".to_string()];
if !matches!(self.method, HttpMethod::Get) {
parts.push(format!("-X {}", self.method.as_str()));
}
let mut url = format!("{}{}", base_url.trim_end_matches('/'), self.path);
let mut query_parts = Vec::new();
for param in &self.parameters {
match param.location {
ParameterLocation::Path => {
let placeholder = if let Some(schema) = ¶m.schema {
let val = schema.generate_example_json(0);
val.as_str()
.map(|s| s.to_string())
.unwrap_or_else(|| val.to_string())
} else {
format!("{{{}}}", param.name)
};
url = url.replace(&format!("{{{}}}", param.name), &placeholder);
}
ParameterLocation::Query => {
if let Some(schema) = ¶m.schema {
let val = schema.generate_example_json(0);
let val_str = val
.as_str()
.map(|s| s.to_string())
.unwrap_or_else(|| val.to_string());
query_parts.push(format!("{}={}", param.name, val_str));
}
}
_ => {}
}
}
if !query_parts.is_empty() {
url = format!("{}?{}", url, query_parts.join("&"));
}
parts.push(format!("\"{}\"", url));
if self.request_body.is_some() {
parts.push("-H \"Content-Type: application/json\"".to_string());
}
if let Some(body) = &self.request_body {
for content in &body.content {
if content.media_type.contains("json") {
if let Some(schema) = &content.schema {
let example = schema.generate_example_json(0);
if let Ok(pretty) = serde_json::to_string_pretty(&example) {
parts.push(format!("-d '{}'", pretty));
}
}
break;
}
}
}
parts.join(" \\\n ")
}
pub fn generate_response_example(&self) -> Option<(String, String)> {
for response in &self.responses {
if response.status_code.starts_with('2') {
for content in &response.content {
if let Some(schema) = &content.schema {
let example = schema.generate_example_json(0);
if let Ok(pretty) = serde_json::to_string_pretty(&example) {
return Some((response.status_code.clone(), pretty));
}
}
}
}
}
None
}
}
fn slugify_operation_id(id: &str) -> String {
let mut result = String::new();
for (i, ch) in id.chars().enumerate() {
if ch.is_uppercase() && i > 0 {
result.push('-');
}
result.push(ch.to_lowercase().next().unwrap_or(ch));
}
result
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParameterLocation {
Path,
Query,
Header,
Cookie,
}
impl ParameterLocation {
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"path" => Some(Self::Path),
"query" => Some(Self::Query),
"header" => Some(Self::Header),
"cookie" => Some(Self::Cookie),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Path => "path",
Self::Query => "query",
Self::Header => "header",
Self::Cookie => "cookie",
}
}
pub fn badge_class(&self) -> &'static str {
match self {
Self::Path => "badge-primary",
Self::Query => "badge-info",
Self::Header => "badge-warning",
Self::Cookie => "badge-secondary",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ApiParameter {
pub name: String,
pub location: ParameterLocation,
pub description: Option<String>,
pub required: bool,
pub deprecated: bool,
pub schema: Option<SchemaDefinition>,
pub example: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ApiRequestBody {
pub description: Option<String>,
pub required: bool,
pub content: Vec<MediaTypeContent>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct MediaTypeContent {
pub media_type: String,
pub schema: Option<SchemaDefinition>,
pub example: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ApiResponse {
pub status_code: String,
pub description: String,
pub content: Vec<MediaTypeContent>,
}
impl ApiResponse {
pub fn status_badge_class(&self) -> &'static str {
match self.status_code.chars().next() {
Some('2') => "badge-success",
Some('3') => "badge-info",
Some('4') => "badge-warning",
Some('5') => "badge-error",
_ => "badge-ghost",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum SchemaType {
String,
Number,
Integer,
Boolean,
Array,
Object,
Null,
Any,
}
impl SchemaType {
pub fn as_str(&self) -> &'static str {
match self {
Self::String => "string",
Self::Number => "number",
Self::Integer => "integer",
Self::Boolean => "boolean",
Self::Array => "array",
Self::Object => "object",
Self::Null => "null",
Self::Any => "any",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SchemaDefinition {
pub schema_type: SchemaType,
pub format: Option<String>,
pub description: Option<String>,
pub items: Option<Box<SchemaDefinition>>,
pub properties: BTreeMap<String, SchemaDefinition>,
pub required: Vec<String>,
pub ref_name: Option<String>,
pub enum_values: Vec<String>,
pub example: Option<String>,
pub default: Option<String>,
pub nullable: bool,
pub additional_properties: Option<Box<SchemaDefinition>>,
pub one_of: Vec<SchemaDefinition>,
pub any_of: Vec<SchemaDefinition>,
pub all_of: Vec<SchemaDefinition>,
}
impl Default for SchemaDefinition {
fn default() -> Self {
Self {
schema_type: SchemaType::Any,
format: None,
description: None,
items: None,
properties: BTreeMap::new(),
required: Vec::new(),
ref_name: None,
enum_values: Vec::new(),
example: None,
default: None,
nullable: false,
additional_properties: None,
one_of: Vec::new(),
any_of: Vec::new(),
all_of: Vec::new(),
}
}
}
impl SchemaDefinition {
pub fn display_type(&self) -> String {
if let Some(ref_name) = &self.ref_name {
return ref_name.clone();
}
match &self.schema_type {
SchemaType::Array => {
if let Some(items) = &self.items {
format!("array<{}>", items.display_type())
} else {
"array".to_string()
}
}
SchemaType::Object if !self.properties.is_empty() => "object".to_string(),
other => {
let mut s = other.as_str().to_string();
if let Some(format) = &self.format {
s.push_str(&format!(" ({format})"));
}
s
}
}
}
pub fn is_complex(&self) -> bool {
matches!(self.schema_type, SchemaType::Object | SchemaType::Array)
|| !self.one_of.is_empty()
|| !self.any_of.is_empty()
|| !self.all_of.is_empty()
}
pub fn generate_example_json(&self, depth: usize) -> serde_json::Value {
if depth > 5 {
return json!({});
}
if let Some(example) = &self.example {
if let Ok(val) = serde_json::from_str(example) {
return val;
}
return json!(example);
}
match &self.schema_type {
SchemaType::String => {
if !self.enum_values.is_empty() {
return json!(self.enum_values[0]);
}
match self.format.as_deref() {
Some("uuid") => json!("550e8400-e29b-41d4-a716-446655440000"),
Some("date-time") => json!("2024-01-15T09:30:00Z"),
Some("date") => json!("2024-01-15"),
Some("uri") | Some("url") => json!("https://example.com"),
Some("email") => json!("user@example.com"),
_ => json!("string"),
}
}
SchemaType::Integer => {
if let Some(default) = &self.default
&& let Ok(n) = default.parse::<i64>()
{
return json!(n);
}
json!(0)
}
SchemaType::Number => json!(0.0),
SchemaType::Boolean => json!(true),
SchemaType::Array => {
if let Some(items) = &self.items {
json!([items.generate_example_json(depth + 1)])
} else {
json!([])
}
}
SchemaType::Object => {
if self.properties.is_empty() {
return json!({});
}
let mut map = serde_json::Map::new();
for (name, prop) in &self.properties {
map.insert(name.clone(), prop.generate_example_json(depth + 1));
}
serde_json::Value::Object(map)
}
SchemaType::Null => json!(null),
SchemaType::Any => json!("any"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::openapi_parser::parse_openapi;
const SPEC: &str = r#"
openapi: "3.0.0"
info:
title: Test API
version: "1.0.0"
paths:
/users/{id}/posts:
post:
operationId: createUserPost
summary: Create post
parameters:
- name: id
in: path
required: true
schema:
type: string
- name: dryRun
in: query
schema:
type: boolean
requestBody:
content:
application/json:
schema:
type: object
properties:
title:
type: string
responses:
"200":
description: OK
/health:
get:
summary: Health
responses:
"200":
description: OK
"#;
fn find_op<'a>(spec: &'a OpenApiSpec, path: &str) -> &'a ApiOperation {
spec.operations.iter().find(|op| op.path == path).unwrap()
}
#[test]
fn slug_kebab_cases_operation_id() {
let spec = parse_openapi(SPEC).unwrap();
assert_eq!(
find_op(&spec, "/users/{id}/posts").slug(),
"create-user-post"
);
}
#[test]
fn slug_falls_back_to_method_path() {
let spec = parse_openapi(SPEC).unwrap();
assert_eq!(find_op(&spec, "/health").slug(), "get-health");
}
#[test]
fn generate_curl_includes_method_url_headers_and_body() {
let spec = parse_openapi(SPEC).unwrap();
let curl = find_op(&spec, "/users/{id}/posts").generate_curl("https://api.example.com/");
assert!(curl.starts_with("curl"));
assert!(curl.contains("-X POST"));
assert!(curl.contains("https://api.example.com/users/"));
assert!(curl.contains("dryRun="));
assert!(curl.contains("-H \"Content-Type: application/json\""));
assert!(curl.contains("-d '"));
assert!(curl.contains("\"title\""));
}
#[test]
fn generate_curl_omits_method_for_get() {
let spec = parse_openapi(SPEC).unwrap();
let curl = find_op(&spec, "/health").generate_curl("https://api.example.com");
assert!(!curl.contains("-X"));
assert!(curl.contains("https://api.example.com/health"));
}
#[test]
fn display_type_formats_arrays_refs_and_formats() {
let string_schema = SchemaDefinition {
schema_type: SchemaType::String,
..Default::default()
};
let array = SchemaDefinition {
schema_type: SchemaType::Array,
items: Some(Box::new(string_schema.clone())),
..Default::default()
};
assert_eq!(array.display_type(), "array<string>");
let reference = SchemaDefinition {
ref_name: Some("User".to_string()),
..Default::default()
};
assert_eq!(reference.display_type(), "User");
let email = SchemaDefinition {
schema_type: SchemaType::String,
format: Some("email".to_string()),
..Default::default()
};
assert_eq!(email.display_type(), "string (email)");
}
#[test]
fn generate_example_json_stops_at_depth_limit() {
let schema = SchemaDefinition::default();
assert_eq!(schema.generate_example_json(6), json!({}));
}
#[test]
fn generate_example_json_prefers_explicit_example() {
let schema = SchemaDefinition {
schema_type: SchemaType::Integer,
example: Some("42".to_string()),
..Default::default()
};
assert_eq!(schema.generate_example_json(0), json!(42));
}
}