use std::collections::BTreeMap;
use http::StatusCode as HttpStatus;
use openapiv3::ObjectType;
use openapiv3::Operation as OasOperation;
use openapiv3::Parameter;
use openapiv3::ParameterData;
use openapiv3::ParameterSchemaOrContent;
use openapiv3::QueryStyle;
use openapiv3::ReferenceOr;
use openapiv3::RequestBody;
use openapiv3::Response as OasResponse;
use openapiv3::Schema;
use openapiv3::SchemaKind;
use openapiv3::StatusCode;
use openapiv3::Type;
use crate::error::Error;
use crate::error::Result;
use crate::ir::Body;
use crate::ir::BodyKind;
use crate::ir::BodyVariant;
use crate::ir::CookieParam;
use crate::ir::Cookies;
use crate::ir::Field;
use crate::ir::HeaderParam;
use crate::ir::Headers;
use crate::ir::Multipart;
use crate::ir::MultipartField;
use crate::ir::NegotiatedBody;
use crate::ir::Operation;
use crate::ir::Param;
use crate::ir::RequestPayload;
use crate::ir::ResponseBody;
use crate::ir::ResponseCase;
use crate::ir::ResponseStatus;
use crate::ir::RustType;
use crate::ir::Service;
use crate::ir::Struct;
use crate::loader::Resolved;
use crate::loader::Spec;
use crate::loader::ref_component_name;
use crate::loader::ref_file_part;
use crate::lower::default::lower_default;
use crate::lower::schema::integer_type;
use crate::lower::schema::string_format_type;
use crate::lower::security;
use crate::naming::Case;
use crate::naming::RustIdent;
use crate::naming::X_RUST_NAME;
use crate::naming::operations;
use crate::naming::to_ident;
const IGNORED_HEADER_NAMES: [&str; 3] = ["accept", "content-type", "authorization"];
const REQUEST_BODY_PRIORITY: [BodyKind; 4] = [BodyKind::Json, BodyKind::Form, BodyKind::Multipart, BodyKind::Text];
const RESPONSE_BODY_PRIORITY: [BodyKind; 3] = [BodyKind::Json, BodyKind::Form, BodyKind::Text];
enum LoweredResponseBody {
Single(Body),
Negotiated(Vec<BodyVariant>),
}
const RESERVED_RESPONSE_FIELDS: [&str; 2] = ["status", "body"];
fn is_valid_header_name(name: &str) -> bool {
if name.is_empty() {
return false;
}
for byte in name.as_bytes() {
let valid = matches!(
byte,
b'!' | b'#'..=b'\'' | b'*'..=b'+' | b'-' | b'.' | b'0'..=b'9' | b'A'..=b'Z' | b'^'..=b'z' | b'|' | b'~'
);
if !valid {
return false;
}
}
return true;
}
pub fn generate_service(
spec: &Spec,
import_mapping: &BTreeMap<String, String>,
response_type_suffix: &str,
) -> Result<Service> {
let lowerer = Lowerer {
spec,
import_mapping,
response_type_suffix,
};
return lowerer.lower();
}
struct Lowerer<'a> {
spec: &'a Spec,
import_mapping: &'a BTreeMap<String, String>,
response_type_suffix: &'a str,
}
impl Lowerer<'_> {
fn lower(&self) -> Result<Service> {
let catalogue = security::scheme_catalogue(self.spec);
let mut operations = Vec::new();
let mut used_schemes: Vec<String> = Vec::new();
let mut claimed: BTreeMap<String, String> = BTreeMap::new();
let mut collisions = crate::lower::validate::Diagnostics::new();
for (path, entry) in self.spec.paths().iter() {
let item = match entry {
ReferenceOr::Item(item) => item,
ReferenceOr::Reference { .. } => {
return Err(Error::UnsupportedOperation {
method: "*".to_owned(),
path: path.clone(),
reason: "path-item `$ref`s are not supported".to_owned(),
});
}
};
for (method, operation) in item.iter() {
let mut lowered = self.lower_operation(path, method, operation, &item.parameters)?;
let route = format!("{method} {path}");
match claimed.get(lowered.name.logical()) {
Some(first) => {
collisions.push(Error::OperationNameCollision {
ident: lowered.name.logical().to_owned(),
first: first.clone(),
second: route,
hint: operation_collision_hint(operation),
});
continue;
}
None => {
claimed.insert(lowered.name.logical().to_owned(), route);
}
}
lowered.security = self.operation_security(operation);
for key in &lowered.security {
if !used_schemes.iter().any(|existing| return existing == key) {
used_schemes.push(key.clone());
}
}
operations.push(lowered);
}
}
collisions.into_result()?;
let security_schemes = catalogue
.into_iter()
.filter(|scheme| return used_schemes.iter().any(|key| return *key == scheme.key))
.collect();
return Ok(Service {
operations,
security_schemes,
});
}
fn operation_security(&self, operation: &OasOperation) -> Vec<String> {
let effective = security::effective_requirements(operation.security.as_deref(), self.spec.global_security());
let Some(requirements) = effective else {
return Vec::new();
};
return security::required_keys(requirements);
}
fn lower_operation(
&self,
path: &str,
method: &str,
operation: &OasOperation,
shared_params: &[ReferenceOr<Parameter>],
) -> Result<Operation> {
let name = operation_name(path, method, operation)?;
let response_enum = operations::response_enum_name(&name, self.response_type_suffix);
let params = self.resolve_parameters(operation, shared_params)?;
let path_params = self.lower_path_params(path, method, ¶ms)?;
let query = self.lower_query_params(path, method, ¶ms, &name)?;
let headers = self.lower_header_params(path, method, ¶ms, &name)?;
let cookies = self.lower_cookie_params(path, method, ¶ms, &name)?;
let request = self.lower_request_body(path, method, &name, operation)?;
let responses = self.lower_responses(path, method, &response_enum, operation)?;
return Ok(Operation {
name,
response_enum,
doc: operation_doc(operation),
method: method.to_owned(),
path: path.to_owned(),
path_params,
query,
headers,
cookies,
request,
responses,
security: Vec::new(),
});
}
fn resolve_parameters(
&self,
operation: &OasOperation,
shared_params: &[ReferenceOr<Parameter>],
) -> Result<Vec<Resolved<Parameter>>> {
let mut resolved = Vec::new();
for parameter in operation.parameters.iter().chain(shared_params) {
let entry = match parameter {
ReferenceOr::Item(param) => Resolved {
value: param.clone(),
origin: None,
},
ReferenceOr::Reference { reference } => self.spec.resolve_parameter(reference)?,
};
resolved.push(entry);
}
return Ok(resolved);
}
fn lower_path_params(&self, path: &str, method: &str, params: &[Resolved<Parameter>]) -> Result<Vec<Param>> {
let placeholders = path_param_names(path);
let mut path_params = Vec::new();
for name in &placeholders {
let declared = path_param_schema(name, params);
let ty = match declared {
Some((format, origin)) => self.param_type(path, method, name, origin, format)?,
None => {
return Err(Error::UndeclaredPathParameter {
method: method.to_owned(),
path: path.to_owned(),
name: name.clone(),
});
}
};
path_params.push(Param {
name: to_ident(name, Case::Snake),
ty,
});
}
for parameter in params {
let Parameter::Path { parameter_data, .. } = ¶meter.value else {
continue;
};
if !placeholders
.iter()
.any(|placeholder| return placeholder == ¶meter_data.name)
{
return Err(Error::InvalidPathParameter {
method: method.to_owned(),
path: path.to_owned(),
name: parameter_data.name.clone(),
});
}
}
return Ok(path_params);
}
fn lower_query_params(
&self,
path: &str,
method: &str,
params: &[Resolved<Parameter>],
operation_name: &RustIdent,
) -> Result<Option<Struct>> {
let owner = operations::query_struct_name(operation_name);
let mut fields = Vec::new();
let mut seen: Vec<&str> = Vec::new();
for parameter in params {
let Parameter::Query {
parameter_data, style, ..
} = ¶meter.value
else {
continue;
};
if seen.contains(¶meter_data.name.as_str()) {
continue;
}
seen.push(¶meter_data.name);
fields.push(self.query_field(path, method, parameter.origin.as_deref(), parameter_data, style, &owner)?);
}
if fields.is_empty() {
return Ok(None);
}
let name = owner;
return Ok(Some(Struct {
name,
doc: None,
deprecated: None,
fields,
additional_properties: None,
deny_unknown_fields: false,
}));
}
fn query_field(
&self,
path: &str,
method: &str,
origin: Option<&str>,
data: &ParameterData,
style: &QueryStyle,
owner: &RustIdent,
) -> Result<Field> {
let schema = self.query_param_schema(path, method, origin, data)?;
let mut ty = self.query_param_type(path, method, origin, data, style, &schema)?;
let declared = match data.required {
true => None,
false => schema.schema_data.default.clone(),
};
if !data.required && declared.is_none() {
ty = ty.optional();
}
let default = match &declared {
Some(json) => Some(lower_default(json, &ty, &|_| return None, owner.logical(), &data.name)?),
None => None,
};
let ident = to_ident(&data.name, Case::Snake);
let rename = crate::naming::rename_for(&data.name, &ident);
let constraints = crate::lower::constraints::constraints_of(&schema);
let field = Field {
name: ident,
rename,
doc: data.description.as_deref().and_then(trimmed),
deprecated: None,
ty,
required: data.required,
omit_empty: None,
serde_skip: false,
default,
constraints,
};
crate::lower::constraints::check_constraints(&field)?;
return Ok(field);
}
fn query_param_schema(
&self,
path: &str,
method: &str,
origin: Option<&str>,
data: &ParameterData,
) -> Result<Schema> {
let ParameterSchemaOrContent::Schema(schema) = &data.format else {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("query parameter `{}` uses `content`, which is not supported", data.name),
});
};
return self.resolve_param_schema(path, method, origin, &data.name, schema);
}
fn query_param_type(
&self,
path: &str,
method: &str,
origin: Option<&str>,
data: &ParameterData,
style: &QueryStyle,
schema: &Schema,
) -> Result<RustType> {
let name = data.name.as_str();
let explode = data.explode;
if let SchemaKind::Type(Type::Array(array)) = &schema.schema_kind {
if !matches!(style, QueryStyle::Form) || explode == Some(false) {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!(
"query parameter `{name}` uses a non-default array encoding; only `style: form` with `explode: true` (repeated keys) is supported"
),
});
}
let element = match &array.items {
Some(ReferenceOr::Item(item)) => scalar_type(&item.schema_kind),
Some(ReferenceOr::Reference { reference }) if ref_file_part(reference).is_some() => {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!(
"query parameter `{name}` uses array items via a cross-file `$ref`, which is not supported"
),
});
}
Some(ReferenceOr::Reference { reference }) => {
let item = self.spec.resolve_schema(origin, reference)?;
scalar_type(&item.schema_kind)
}
None => {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("query parameter `{name}` is an array without `items`"),
});
}
};
let element = element.ok_or_else(|| {
return Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("query parameter `{name}` must be an array of scalars"),
};
})?;
return Ok(RustType::Vec(Box::new(element)));
}
let ty = scalar_type(&schema.schema_kind).ok_or_else(|| {
return Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("query parameter `{name}` must be a scalar or an array of scalars"),
};
})?;
return Ok(ty);
}
fn resolve_param_schema(
&self,
path: &str,
method: &str,
origin: Option<&str>,
name: &str,
schema: &ReferenceOr<Schema>,
) -> Result<Schema> {
match schema {
ReferenceOr::Item(schema) => return Ok(schema.clone()),
ReferenceOr::Reference { reference } if ref_file_part(reference).is_some() => {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("query parameter `{name}` uses a cross-file `$ref`, which is not supported"),
});
}
ReferenceOr::Reference { reference } => return self.spec.resolve_schema(origin, reference),
}
}
fn lower_header_params(
&self,
path: &str,
method: &str,
params: &[Resolved<Parameter>],
operation_name: &RustIdent,
) -> Result<Option<Headers>> {
let mut header_params = Vec::new();
let mut seen: Vec<&str> = Vec::new();
for parameter in params {
let Parameter::Header { parameter_data, .. } = ¶meter.value else {
continue;
};
let name = parameter_data.name.as_str();
if IGNORED_HEADER_NAMES
.iter()
.any(|ignored| return ignored.eq_ignore_ascii_case(name))
{
continue;
}
if seen.iter().any(|other| return other.eq_ignore_ascii_case(name)) {
continue;
}
seen.push(name);
header_params.push(self.header_param(path, method, parameter.origin.as_deref(), parameter_data)?);
}
if header_params.is_empty() {
return Ok(None);
}
let name = operations::headers_struct_name(operation_name);
return Ok(Some(Headers {
name,
params: header_params,
}));
}
fn header_param(
&self,
path: &str,
method: &str,
origin: Option<&str>,
data: &ParameterData,
) -> Result<HeaderParam> {
let ty = self.header_param_type(path, method, origin, &data.name, &data.format)?;
return Ok(HeaderParam {
name: to_ident(&data.name, Case::Snake),
header_name: data.name.clone(),
ty,
required: data.required,
doc: data.description.as_deref().and_then(trimmed),
});
}
fn scalar_from_format(
&self,
path: &str,
method: &str,
origin: Option<&str>,
kind_label: &str,
name: &str,
format: &ParameterSchemaOrContent,
) -> Result<RustType> {
let schema = match format {
ParameterSchemaOrContent::Schema(schema) => schema,
ParameterSchemaOrContent::Content(_) => {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("{kind_label} `{name}` uses `content`, which is not supported"),
});
}
};
let schema = match schema {
ReferenceOr::Item(schema) => schema.clone(),
ReferenceOr::Reference { reference } if ref_file_part(reference).is_some() => {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("{kind_label} `{name}` uses a cross-file `$ref`, which is not supported"),
});
}
ReferenceOr::Reference { reference } => self.spec.resolve_schema(origin, reference)?,
};
let ty = scalar_type(&schema.schema_kind).ok_or_else(|| {
return Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("{kind_label} `{name}` must be a scalar"),
};
})?;
if matches!(ty, RustType::Bytes) {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("{kind_label} `{name}` uses a `byte`/`binary` format, which is not supported"),
});
}
return Ok(ty);
}
fn header_param_type(
&self,
path: &str,
method: &str,
origin: Option<&str>,
name: &str,
format: &ParameterSchemaOrContent,
) -> Result<RustType> {
return self.scalar_from_format(path, method, origin, "header parameter", name, format);
}
fn lower_cookie_params(
&self,
path: &str,
method: &str,
params: &[Resolved<Parameter>],
operation_name: &RustIdent,
) -> Result<Option<Cookies>> {
let mut cookie_params = Vec::new();
let mut seen: Vec<&str> = Vec::new();
for parameter in params {
let Parameter::Cookie { parameter_data, .. } = ¶meter.value else {
continue;
};
let name = parameter_data.name.as_str();
if seen.contains(&name) {
continue;
}
seen.push(name);
let ty = self.cookie_param_type(
path,
method,
parameter.origin.as_deref(),
¶meter_data.name,
¶meter_data.format,
)?;
cookie_params.push(CookieParam {
name: to_ident(¶meter_data.name, Case::Snake),
cookie_name: parameter_data.name.clone(),
ty,
required: parameter_data.required,
doc: parameter_data.description.as_deref().and_then(trimmed),
});
}
if cookie_params.is_empty() {
return Ok(None);
}
let name = operations::cookies_struct_name(operation_name);
return Ok(Some(Cookies {
name,
params: cookie_params,
}));
}
fn cookie_param_type(
&self,
path: &str,
method: &str,
origin: Option<&str>,
name: &str,
format: &ParameterSchemaOrContent,
) -> Result<RustType> {
let schema = match format {
ParameterSchemaOrContent::Schema(schema) => schema,
ParameterSchemaOrContent::Content(_) => {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("cookie parameter `{name}` uses `content`, which is not supported"),
});
}
};
let schema = match schema {
ReferenceOr::Item(schema) => schema.clone(),
ReferenceOr::Reference { reference } if ref_file_part(reference).is_some() => {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("cookie parameter `{name}` uses a cross-file `$ref`, which is not supported"),
});
}
ReferenceOr::Reference { reference } => self.spec.resolve_schema(origin, reference)?,
};
let ty = scalar_type(&schema.schema_kind).ok_or_else(|| {
return Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("cookie parameter `{name}` must be a scalar"),
};
})?;
if matches!(ty, RustType::Bytes) {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("cookie parameter `{name}` uses a `byte`/`binary` format, which is not supported"),
});
}
return Ok(ty);
}
fn supported_bodies<'m>(
&self,
content: &'m indexmap::IndexMap<String, openapiv3::MediaType>,
priority: &[BodyKind],
) -> Vec<(BodyKind, &'m openapiv3::MediaType)> {
let mut selected = Vec::new();
for &wanted in priority {
for (name, media) in content {
if media_type_kind(name) == Some(wanted) {
selected.push((wanted, media));
break;
}
}
}
return selected;
}
fn body_from_media(
&self,
path: &str,
method: &str,
origin: Option<&str>,
kind: BodyKind,
media: &openapiv3::MediaType,
) -> Result<Option<Body>> {
let schema = match &media.schema {
Some(schema) => schema,
None => return Ok(None),
};
let ty = match kind {
BodyKind::Json => self.body_type(path, method, origin, schema)?,
BodyKind::Text => {
let resolved = match schema {
ReferenceOr::Item(schema) => schema.clone(),
ReferenceOr::Reference { reference } => self.spec.resolve_schema(origin, reference)?,
};
if !matches!(resolved.schema_kind, SchemaKind::Type(Type::String(_))) {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: "text/plain body must be a `string` schema".to_owned(),
});
}
RustType::String
}
BodyKind::Form => match schema {
ReferenceOr::Reference { reference } => {
if ref_file_part(reference).is_none() {
let resolved = self.spec.resolve_schema(origin, reference)?;
if !matches!(resolved.schema_kind, SchemaKind::Type(Type::Object(_))) {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason:
"form (`application/x-www-form-urlencoded`) body must reference an `object` schema"
.to_owned(),
});
}
}
self.schema_ref_type(path, method, origin, reference)?
}
ReferenceOr::Item(_) => {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: "form (`application/x-www-form-urlencoded`) body must reference a named object schema"
.to_owned(),
});
}
},
BodyKind::Multipart => {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: "multipart/form-data is only supported for request bodies".to_owned(),
});
}
};
return Ok(Some(Body { ty, kind }));
}
}
fn path_param_schema<'a>(
name: &str,
params: &'a [Resolved<Parameter>],
) -> Option<(&'a ParameterSchemaOrContent, Option<&'a str>)> {
for parameter in params {
let Parameter::Path { parameter_data, .. } = ¶meter.value else {
continue;
};
if parameter_data.name == name {
return Some((¶meter_data.format, parameter.origin.as_deref()));
}
}
return None;
}
fn body_kind_ident(kind: BodyKind) -> RustIdent {
let name = match kind {
BodyKind::Json => "Json",
BodyKind::Form => "Form",
BodyKind::Text => "Text",
BodyKind::Multipart => "Multipart",
};
return to_ident(name, Case::Pascal);
}
fn declared_content_types(content: &indexmap::IndexMap<String, openapiv3::MediaType>) -> String {
return content.keys().cloned().collect::<Vec<_>>().join(", ");
}
fn media_type_kind(name: &str) -> Option<BodyKind> {
let base = name.split(';').next().unwrap_or(name).trim().to_ascii_lowercase();
if base == "application/json" || base.ends_with("+json") {
return Some(BodyKind::Json);
}
if base == "application/x-www-form-urlencoded" {
return Some(BodyKind::Form);
}
if base == "multipart/form-data" {
return Some(BodyKind::Multipart);
}
if base == "text/plain" {
return Some(BodyKind::Text);
}
return None;
}
fn scalar_type(kind: &SchemaKind) -> Option<RustType> {
let ty = match kind {
SchemaKind::Type(Type::String(st)) => string_format_type(&st.format),
SchemaKind::Type(Type::Integer(it)) => integer_type(it),
SchemaKind::Type(Type::Number(_)) => RustType::F64,
SchemaKind::Type(Type::Boolean(_)) => RustType::Bool,
_ => return None,
};
return Some(ty);
}
impl Lowerer<'_> {
fn param_type(
&self,
path: &str,
method: &str,
name: &str,
origin: Option<&str>,
format: &ParameterSchemaOrContent,
) -> Result<RustType> {
let schema = match format {
ParameterSchemaOrContent::Schema(schema) => schema,
ParameterSchemaOrContent::Content(_) => {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("path parameter `{name}` uses `content`, which is not supported"),
});
}
};
let schema = match schema {
ReferenceOr::Item(schema) => schema.clone(),
ReferenceOr::Reference { reference } if ref_file_part(reference).is_some() => {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("path parameter `{name}` uses a cross-file `$ref`, which is not supported"),
});
}
ReferenceOr::Reference { reference } => self.spec.resolve_schema(origin, reference)?,
};
let ty = scalar_type(&schema.schema_kind).ok_or_else(|| {
return Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("path parameter `{name}` must be a scalar type"),
};
})?;
return Ok(ty);
}
fn lower_request_body(
&self,
path: &str,
method: &str,
op_name: &RustIdent,
operation: &OasOperation,
) -> Result<Option<RequestPayload>> {
let body = match &operation.request_body {
Some(body) => body,
None => return Ok(None),
};
let (body, origin): (RequestBody, Option<String>) = match body {
ReferenceOr::Item(body) => (body.clone(), None),
ReferenceOr::Reference { reference } => {
let resolved = self.spec.resolve_request_body(reference)?;
(resolved.value, resolved.origin)
}
};
let supported = self.supported_bodies(&body.content, &REQUEST_BODY_PRIORITY);
if supported.is_empty() {
if body.content.is_empty() {
return Ok(None);
}
return Err(Error::UnsupportedContentType {
method: method.to_owned(),
path: path.to_owned(),
location: "request body".to_owned(),
declared: declared_content_types(&body.content),
hint: "A request body must declare `application/json`, `application/x-www-form-urlencoded`, `multipart/form-data`, or `text/plain`. Add one of them, or remove the `requestBody`.".to_owned(),
});
}
let has_multipart = supported.iter().any(|(kind, _)| return *kind == BodyKind::Multipart);
if has_multipart {
if supported.len() > 1 {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: "multipart/form-data cannot be combined with other request content types".to_owned(),
});
}
let Some(&(_, media)) = supported.first() else {
unreachable!("multipart body content type count already validated to be exactly one");
};
let multipart = self.lower_multipart_body(path, method, op_name, origin.as_deref(), media)?;
return Ok(Some(RequestPayload::Multipart(multipart)));
}
let mut variants = Vec::with_capacity(supported.len());
for (kind, media) in supported {
if let Some(body) = self.body_from_media(path, method, origin.as_deref(), kind, media)? {
variants.push(BodyVariant {
variant: body_kind_ident(kind),
body,
});
}
}
if variants.len() == 1 {
let Some(variant) = variants.pop() else {
unreachable!("length checked to be 1 above");
};
return Ok(Some(RequestPayload::Single(variant.body)));
}
if variants.is_empty() {
return Ok(None);
}
return Ok(Some(RequestPayload::Negotiated(NegotiatedBody {
name: operations::request_body_enum_name(op_name),
variants,
})));
}
fn lower_multipart_body(
&self,
path: &str,
method: &str,
op_name: &RustIdent,
origin: Option<&str>,
media: &openapiv3::MediaType,
) -> Result<Multipart> {
let unsupported = |reason: String| {
return Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason,
};
};
let schema = media
.schema
.as_ref()
.ok_or_else(|| return unsupported("multipart/form-data body must declare a schema".to_owned()))?;
let object = self.multipart_object(path, method, origin, schema)?;
let fields = self.lower_multipart_fields(path, method, &object)?;
return Ok(Multipart {
name: operations::multipart_struct_name(op_name),
fields,
});
}
fn multipart_object(
&self,
path: &str,
method: &str,
origin: Option<&str>,
schema: &ReferenceOr<Schema>,
) -> Result<ObjectType> {
let reject = |reason: &str| {
return Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: reason.to_owned(),
};
};
let not_object = "multipart/form-data body schema must be an `object`";
let cross_file = "multipart/form-data body must be an inline object or a same-document `$ref`; cross-file/external multipart is unsupported";
match schema {
ReferenceOr::Item(item) => {
if origin.is_some() {
return Err(reject(cross_file));
}
match &item.schema_kind {
SchemaKind::Type(Type::Object(object)) => return Ok(object.clone()),
_ => return Err(reject(not_object)),
}
}
ReferenceOr::Reference { reference } => {
if origin.is_some() || ref_file_part(reference).is_some() {
return Err(reject(cross_file));
}
let resolved = self.spec.resolve_schema(origin, reference)?;
match &resolved.schema_kind {
SchemaKind::Type(Type::Object(object)) => return Ok(object.clone()),
_ => return Err(reject(not_object)),
}
}
}
}
fn lower_multipart_fields(&self, path: &str, method: &str, object: &ObjectType) -> Result<Vec<MultipartField>> {
let mut fields = Vec::with_capacity(object.properties.len());
for (wire_name, property) in &object.properties {
let required = object.required.iter().any(|name| {
return name == wire_name;
});
let (kind, nullable) = match property {
ReferenceOr::Item(schema) => (schema.schema_kind.clone(), schema.schema_data.nullable),
ReferenceOr::Reference { reference } => {
if ref_file_part(reference).is_some() {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!(
"multipart field `{wire_name}` uses a cross-file `$ref`, which is not supported"
),
});
}
let resolved = self.spec.resolve_schema(None, reference)?;
(resolved.schema_kind, resolved.schema_data.nullable)
}
};
let ty = scalar_type(&kind).ok_or_else(|| {
return Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!(
"multipart field `{wire_name}` must be a scalar or binary string; nested objects and arrays are not supported"
),
};
})?;
fields.push(MultipartField {
wire_name: wire_name.clone(),
rust_name: to_ident(wire_name, Case::Snake),
is_file: matches!(ty, RustType::Bytes),
ty,
optional: !required || nullable,
});
}
return Ok(fields);
}
fn lower_response_headers(
&self,
path: &str,
method: &str,
origin: Option<&str>,
response: &OasResponse,
) -> Result<Vec<crate::ir::ResponseHeader>> {
let mut headers = Vec::new();
let mut seen: Vec<String> = Vec::new();
let mut seen_idents: Vec<String> = Vec::new();
for (header_name, header_ref) in &response.headers {
let header = match header_ref {
ReferenceOr::Item(header) => header,
ReferenceOr::Reference { .. } => {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("response header `{header_name}` uses a `$ref`, which is not supported"),
});
}
};
if seen.iter().any(|other| return other.eq_ignore_ascii_case(header_name)) {
continue;
}
if !is_valid_header_name(header_name) {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("response header `{header_name}` has an invalid header name"),
});
}
let ident = to_ident(header_name, Case::Snake);
if RESERVED_RESPONSE_FIELDS.contains(&ident.logical()) {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!(
"response header `{header_name}` maps to the reserved Rust field name `{}`",
ident.logical()
),
});
}
if seen_idents.iter().any(|other| return other == ident.logical()) {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!(
"response header `{header_name}` maps to the same Rust field name as another header (`{}`)",
ident.logical()
),
});
}
seen.push(header_name.clone());
seen_idents.push(ident.logical().to_owned());
let ty = self.scalar_from_format(path, method, origin, "response header", header_name, &header.format)?;
headers.push(crate::ir::ResponseHeader {
name: ident,
header_name: header_name.clone(),
ty,
required: header.required,
doc: header.description.as_deref().and_then(trimmed),
});
}
return Ok(headers);
}
fn lower_responses(
&self,
path: &str,
method: &str,
response_enum: &RustIdent,
operation: &OasOperation,
) -> Result<Vec<ResponseCase>> {
let mut cases = Vec::new();
for (status_code, response) in &operation.responses.responses {
let (status, variant) = match status_code {
StatusCode::Code(code) => {
let reason = HttpStatus::from_u16(*code).ok().and_then(|status| {
return status.canonical_reason();
});
let reason = reason.ok_or_else(|| {
return Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("status code `{code}` is not a recognised HTTP status"),
};
})?;
(ResponseStatus::Fixed(*code), to_ident(reason, Case::Pascal))
}
StatusCode::Range(range) => {
if !(1..=5).contains(range) {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("response range `{range}XX` is not a valid HTTP status class"),
});
}
let variant = to_ident(&format!("status_{range}xx"), Case::Pascal);
let Ok(range) = u8::try_from(*range) else {
unreachable!("range checked to be within 1..=5 above");
};
(ResponseStatus::Range(range), variant)
}
};
let response = self.resolve_response_ref(response)?;
let location = format!("`{status_code}` response");
let body = self.response_body(path, method, &location, response.origin.as_deref(), &response.value)?;
let body = self.name_response_body(response_enum, &variant, body);
let headers = self.lower_response_headers(path, method, response.origin.as_deref(), &response.value)?;
cases.push(ResponseCase {
variant,
status,
body,
headers,
doc: trimmed(&response.value.description),
});
}
if let Some(default) = &operation.responses.default {
let response = self.resolve_response_ref(default)?;
let variant = to_ident("default", Case::Pascal);
let body = self.response_body(
path,
method,
"`default` response",
response.origin.as_deref(),
&response.value,
)?;
let body = self.name_response_body(response_enum, &variant, body);
let headers = self.lower_response_headers(path, method, response.origin.as_deref(), &response.value)?;
cases.push(ResponseCase {
variant,
status: ResponseStatus::Default,
body,
headers,
doc: trimmed(&response.value.description),
});
}
if cases.is_empty() {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: "operation declares no responses".to_owned(),
});
}
return Ok(cases);
}
fn resolve_response_ref(&self, response: &ReferenceOr<OasResponse>) -> Result<Resolved<OasResponse>> {
match response {
ReferenceOr::Item(response) => {
return Ok(Resolved {
value: response.clone(),
origin: None,
});
}
ReferenceOr::Reference { reference } => return self.spec.resolve_response(reference),
}
}
fn response_body(
&self,
path: &str,
method: &str,
location: &str,
origin: Option<&str>,
response: &OasResponse,
) -> Result<Option<LoweredResponseBody>> {
let supported = self.supported_bodies(&response.content, &RESPONSE_BODY_PRIORITY);
if supported.is_empty() && !response.content.is_empty() {
return Err(Error::UnsupportedContentType {
method: method.to_owned(),
path: path.to_owned(),
location: location.to_owned(),
declared: declared_content_types(&response.content),
hint: "A response body must declare `application/json`, `application/x-www-form-urlencoded`, or `text/plain`. Add one of them, or declare no `content:` for a bodyless response.".to_owned(),
});
}
let mut variants = Vec::with_capacity(supported.len());
for (kind, media) in supported {
if let Some(body) = self.body_from_media(path, method, origin, kind, media)? {
variants.push(BodyVariant {
variant: body_kind_ident(kind),
body,
});
}
}
if variants.len() == 1 {
let Some(variant) = variants.pop() else {
unreachable!("length checked to be 1 above");
};
return Ok(Some(LoweredResponseBody::Single(variant.body)));
}
if variants.is_empty() {
return Ok(None);
}
return Ok(Some(LoweredResponseBody::Negotiated(variants)));
}
fn name_response_body(
&self,
response_enum: &RustIdent,
variant: &RustIdent,
lowered: Option<LoweredResponseBody>,
) -> Option<ResponseBody> {
return lowered.map(|body| {
return match body {
LoweredResponseBody::Single(body) => ResponseBody::Single(body),
LoweredResponseBody::Negotiated(variants) => ResponseBody::Negotiated(NegotiatedBody {
name: operations::response_body_enum_name(response_enum, variant),
variants,
}),
};
});
}
fn body_type(
&self,
path: &str,
method: &str,
origin: Option<&str>,
schema: &ReferenceOr<Schema>,
) -> Result<RustType> {
match schema {
ReferenceOr::Reference { reference } => return self.schema_ref_type(path, method, origin, reference),
ReferenceOr::Item(schema) => return self.inline_body_type(path, method, origin, schema),
}
}
fn inline_body_type(&self, path: &str, method: &str, origin: Option<&str>, schema: &Schema) -> Result<RustType> {
let ty = match &schema.schema_kind {
SchemaKind::Type(Type::String(st)) => string_format_type(&st.format),
SchemaKind::Type(Type::Integer(it)) => integer_type(it),
SchemaKind::Type(Type::Number(_)) => RustType::F64,
SchemaKind::Type(Type::Boolean(_)) => RustType::Bool,
SchemaKind::Type(Type::Array(at)) => {
let element = match &at.items {
Some(ReferenceOr::Reference { reference }) => {
self.schema_ref_type(path, method, origin, reference)?
}
Some(ReferenceOr::Item(item)) => self.inline_body_type(path, method, origin, item)?,
None => RustType::Value,
};
RustType::Vec(Box::new(element))
}
SchemaKind::Any(_) => RustType::Value,
_ => {
return Err(Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: "composite request/response bodies must reference a named schema (`$ref`)".to_owned(),
});
}
};
return Ok(ty);
}
fn schema_ref_type(&self, path: &str, method: &str, origin: Option<&str>, reference: &str) -> Result<RustType> {
let target = ref_component_name(reference, "schemas").ok_or_else(|| {
return Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("reference `{reference}` must point at a component schema"),
};
})?;
let file = ref_file_part(reference)
.map(str::to_owned)
.or_else(|| return origin.map(str::to_owned));
let Some(file) = file else {
if !self.spec.schemas().contains_key(target) {
return Err(Error::UnresolvedRef(reference.to_owned()));
}
return Ok(RustType::Named(target.to_owned()));
};
let module = self.import_mapping.get(&file).ok_or_else(|| {
return Error::UnsupportedOperation {
method: method.to_owned(),
path: path.to_owned(),
reason: format!("cross-file reference `{reference}` needs an `import-mapping` entry for `{file}`"),
};
})?;
return Ok(RustType::External {
module: module.clone(),
name: self.spec.external_schema_name(&file, target, reference)?,
});
}
}
fn operation_name(path: &str, method: &str, operation: &OasOperation) -> Result<crate::naming::RustIdent> {
let at = format!("{method} {path}");
if let Some(name) = crate::lower::extension::str_value(&operation.extensions, X_RUST_NAME, &at)? {
return Ok(operations::operation_method_name(name));
}
if let Some(id) = &operation.operation_id {
return Ok(operations::operation_method_name(id));
}
return Ok(operations::operation_method_name(&at));
}
fn operation_collision_hint(operation: &OasOperation) -> String {
if operation.extensions.contains_key(X_RUST_NAME) {
return format!(
"This operation already sets `{X_RUST_NAME}`, and that name collides too. \
Give it a name that no other operation uses."
);
}
if operation.operation_id.is_some() {
return format!(
"Two `operationId`s that differ only in case or in punctuation produce one Rust name. \
Give one of the two operations a different `operationId`, or set `{X_RUST_NAME}` on it \
to name the generated method directly."
);
}
return format!(
"This operation declares no `operationId`, so its name comes from the method and the path. \
Add an `operationId`, or set `{X_RUST_NAME}` on it to name the generated method directly."
);
}
fn operation_doc(operation: &OasOperation) -> Option<String> {
if let Some(summary) = &operation.summary
&& let Some(text) = trimmed(summary)
{
return Some(text);
}
return operation.description.as_ref().and_then(|text| {
return trimmed(text);
});
}
fn path_param_names(path: &str) -> Vec<String> {
let mut names = Vec::new();
let mut rest = path;
while let Some(open) = rest.find('{') {
let after_open = &rest[open + 1..];
let Some(close) = after_open.find('}') else {
break;
};
names.push(after_open[..close].to_owned());
rest = &after_open[close + 1..];
}
return names;
}
fn trimmed(text: &str) -> Option<String> {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
return Some(trimmed.to_owned());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_header_names_accept_tokens_and_reject_separators() {
assert!(is_valid_header_name("X-Request-Id"));
assert!(is_valid_header_name("X-RateLimit-Remaining"));
assert!(is_valid_header_name("Sec-CH-UA-Platform-Version"));
assert!(is_valid_header_name("a.b"));
assert!(!is_valid_header_name(""));
assert!(!is_valid_header_name("X/Y"));
assert!(!is_valid_header_name("X:Y"));
assert!(!is_valid_header_name("X Y"));
assert!(!is_valid_header_name("X(Y)"));
}
#[test]
fn extracts_path_param_names_in_order() {
assert_eq!(path_param_names("/v1/widgets"), Vec::<String>::new());
assert_eq!(path_param_names("/pets/{id}"), vec!["id".to_owned()]);
assert_eq!(
path_param_names("/orgs/{org}/pets/{petId}"),
vec!["org".to_owned(), "petId".to_owned()],
);
}
#[test]
fn response_variants_are_named_after_the_status_reason() {
let variant = |code| {
let reason = HttpStatus::from_u16(code)
.expect("test status code is a valid HTTP status")
.canonical_reason()
.expect("status code has a canonical reason phrase");
return to_ident(reason, Case::Pascal).logical().to_owned();
};
assert_eq!(variant(200), "Ok");
assert_eq!(variant(204), "NoContent");
assert_eq!(variant(404), "NotFound");
assert_eq!(variant(500), "InternalServerError");
}
#[test]
fn range_response_variants_are_derived_from_the_range_digit() {
let variant = |range: u16| {
return to_ident(&format!("status_{range}xx"), Case::Pascal)
.logical()
.to_owned();
};
assert_eq!(variant(4), "Status4xx");
assert_eq!(variant(5), "Status5xx");
}
}