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"),
}
}
}