use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Api {
pub name: String,
pub version: String,
#[serde(default)]
pub schemas: Vec<Schema>,
#[serde(default)]
pub operations: Vec<Operation>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub annotations: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Schema {
pub name: String,
pub value: SchemaValue,
}
impl Schema {
pub fn new(name: impl Into<String>, value: SchemaValue) -> Self {
Self {
name: name.into(),
value,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SchemaValue {
pub kind: SchemaKind,
#[serde(default)]
pub nullable: bool,
#[serde(default)]
pub optional: bool,
#[serde(default)]
pub nullish: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub enum_values: Vec<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub const_value: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub deprecated: bool,
#[serde(default)]
pub read_only: bool,
#[serde(default)]
pub write_only: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discriminator: Option<Discriminator>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub constraints: BTreeMap<String, Value>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extensions: BTreeMap<String, Value>,
}
impl SchemaValue {
pub fn new(kind: SchemaKind) -> Self {
Self {
kind,
nullable: false,
optional: false,
nullish: false,
format: None,
enum_values: Vec::new(),
const_value: None,
default: None,
title: None,
description: None,
deprecated: false,
read_only: false,
write_only: false,
discriminator: None,
constraints: BTreeMap::new(),
extensions: BTreeMap::new(),
}
}
pub fn unknown() -> Self {
Self::new(SchemaKind::Any)
}
pub fn reference(reference: impl Into<String>) -> Self {
Self::new(SchemaKind::Reference {
reference: reference.into(),
})
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SchemaKind {
Any,
Null,
Boolean,
Integer,
Number,
String,
Array {
items: Box<SchemaValue>,
},
Object {
fields: Vec<Field>,
additional_properties: AdditionalProperties,
},
Reference {
reference: String,
},
OneOf {
variants: Vec<SchemaValue>,
},
AnyOf {
variants: Vec<SchemaValue>,
},
AllOf {
variants: Vec<SchemaValue>,
},
Not {
schema: Box<SchemaValue>,
},
}
impl SchemaKind {
pub fn reference_name(&self) -> Option<&str> {
let Self::Reference { reference } = self else {
return None;
};
Some(reference.rsplit('/').next().unwrap_or(reference))
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AdditionalProperties {
#[default]
Unspecified,
Any,
Forbidden,
Schema {
value: Box<SchemaValue>,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Discriminator {
pub property_name: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub mapping: BTreeMap<String, String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Field {
pub name: String,
pub value: SchemaValue,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub annotations: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HttpMethod {
Get,
Post,
Put,
Patch,
Delete,
Head,
Options,
Trace,
Query,
Custom(String),
}
impl HttpMethod {
pub fn as_str(&self) -> &str {
match self {
Self::Get => "GET",
Self::Post => "POST",
Self::Put => "PUT",
Self::Patch => "PATCH",
Self::Delete => "DELETE",
Self::Head => "HEAD",
Self::Options => "OPTIONS",
Self::Trace => "TRACE",
Self::Query => "QUERY",
Self::Custom(method) => method,
}
}
}
impl HttpMethod {
pub fn parse(method: &str) -> Result<Self, String> {
if method.is_empty()
|| method.len() > 256
|| !method
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte))
{
return Err("HTTP method must be an ASCII token of 1..=256 bytes".into());
}
if method != method.to_ascii_uppercase()
&& matches!(
method.to_ascii_uppercase().as_str(),
"GET"
| "POST"
| "PUT"
| "PATCH"
| "DELETE"
| "HEAD"
| "OPTIONS"
| "TRACE"
| "QUERY"
)
{
return Err("standard HTTP methods must use canonical uppercase tokens".into());
}
Ok(match method {
"GET" => Self::Get,
"POST" => Self::Post,
"PUT" => Self::Put,
"PATCH" => Self::Patch,
"DELETE" => Self::Delete,
"HEAD" => Self::Head,
"OPTIONS" => Self::Options,
"TRACE" => Self::Trace,
"QUERY" => Self::Query,
other => Self::Custom(other.to_owned()),
})
}
}
impl Serialize for HttpMethod {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for HttpMethod {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let method = String::deserialize(deserializer)?;
Self::parse(&method).map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Operation {
pub id: String,
pub method: HttpMethod,
pub path: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub parameters: Vec<OperationParameter>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_body: Option<OperationRequestBody>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub responses: Vec<OperationResponse>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub security: Vec<SecurityRequirement>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub annotations: BTreeMap<String, Value>,
}
impl Default for Operation {
fn default() -> Self {
Self {
id: String::new(),
method: HttpMethod::Get,
path: String::new(),
parameters: Vec::new(),
request_body: None,
responses: Vec::new(),
security: Vec::new(),
annotations: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OperationParameter {
pub name: String,
pub location: String,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<SchemaValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub annotations: BTreeMap<String, Value>,
}
impl Operation {
pub fn request_schema(&self) -> Option<&SchemaValue> {
preferred_schema(&self.request_body.as_ref()?.media_types)
}
pub fn success_schema(&self) -> Option<&SchemaValue> {
preferred_schema(
&self
.responses
.iter()
.find(|response| response.status.starts_with('2'))?
.media_types,
)
}
}
fn preferred_schema(media_types: &[OperationMediaType]) -> Option<&SchemaValue> {
media_types
.iter()
.find(|media| {
media.content_type == "application/json" || media.content_type.ends_with("+json")
})
.or_else(|| media_types.first())?
.schema
.as_ref()
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OperationRequestBody {
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub media_types: Vec<OperationMediaType>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OperationResponse {
pub status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub media_types: Vec<OperationMediaType>,
}
impl OperationRequestBody {
pub fn json(schema: SchemaValue, required: bool) -> Self {
Self {
required,
description: None,
media_types: vec![OperationMediaType {
content_type: "application/json".into(),
schema: Some(schema),
}],
}
}
}
impl OperationResponse {
pub fn json(status: impl Into<String>, schema: SchemaValue) -> Self {
Self {
status: status.into(),
description: None,
media_types: vec![OperationMediaType {
content_type: "application/json".into(),
schema: Some(schema),
}],
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OperationMediaType {
pub content_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<SchemaValue>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecurityRequirement {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub schemes: BTreeMap<String, Vec<String>>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecuritySchemeCatalog {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub schemes: Vec<SecurityScheme>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecurityScheme {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub kind: SecuritySchemeKind,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SecuritySchemeKind {
ApiKey {
name: Option<String>,
location: Option<String>,
},
Http {
scheme: Option<String>,
bearer_format: Option<String>,
},
OAuth2 {
flows: Vec<OAuthFlow>,
metadata_url: Option<String>,
},
OpenIdConnect {
discovery_url: Option<String>,
},
Other {
type_name: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OAuthFlow {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub device_authorization_url: Option<String>,
pub flow_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authorization_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_url: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub scopes: BTreeMap<String, String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn operation_schema_selection_uses_declared_json_media() {
let mut operation = Operation {
request_body: Some(OperationRequestBody::json(
SchemaValue::reference("Input"),
true,
)),
responses: vec![OperationResponse::json(
"200",
SchemaValue::reference("Output"),
)],
..Operation::default()
};
operation.request_body.as_mut().unwrap().media_types.insert(
0,
OperationMediaType {
content_type: "text/plain".into(),
schema: Some(SchemaValue::new(SchemaKind::String)),
},
);
operation.responses[0].media_types.insert(
0,
OperationMediaType {
content_type: "text/plain".into(),
schema: Some(SchemaValue::new(SchemaKind::String)),
},
);
assert_eq!(
operation.request_schema().unwrap().kind.reference_name(),
Some("Input")
);
assert_eq!(
operation.success_schema().unwrap().kind.reference_name(),
Some("Output")
);
}
#[test]
fn missing_or_empty_success_never_uses_an_error_schema() {
let mut operation = Operation {
responses: vec![OperationResponse::json(
"default",
SchemaValue::reference("Error"),
)],
..Operation::default()
};
assert!(operation.success_schema().is_none());
operation.responses.insert(
0,
OperationResponse {
status: "204".into(),
description: None,
media_types: vec![],
},
);
assert!(operation.success_schema().is_none());
assert!(operation.request_schema().is_none());
}
#[test]
fn preserves_a_recursive_discriminated_union() {
let value = SchemaValue {
nullable: true,
discriminator: Some(Discriminator {
property_name: "kind".into(),
mapping: BTreeMap::from([("cat".into(), "#/components/schemas/Cat".into())]),
}),
..SchemaValue::new(SchemaKind::OneOf {
variants: vec![SchemaValue::reference("#/components/schemas/Cat")],
})
};
let round_trip: SchemaValue =
serde_json::from_str(&serde_json::to_string(&value).unwrap()).unwrap();
assert_eq!(round_trip, value);
assert_eq!(value.kind.reference_name(), None);
}
}
#[cfg(test)]
mod method_tests {
use super::*;
#[test]
fn custom_methods_preserve_case_and_string_serialization() {
for token in ["GET", "COPY", "x-Custom", "X!#$%&'*+-.^_`|~"] {
let method = HttpMethod::parse(token).unwrap();
assert_eq!(method.as_str(), token);
let encoded = serde_json::to_string(&method).unwrap();
assert_eq!(
serde_json::from_str::<HttpMethod>(&encoded).unwrap(),
method
);
}
for token in [
"",
"BAD METHOD",
"BAD\r\nMETHOD",
"Méthod",
"X/Path",
"get",
"pOst",
] {
assert!(HttpMethod::parse(token).is_err());
}
assert!(HttpMethod::parse(&"X".repeat(257)).is_err());
}
}