421 lines
16 KiB
Rust
421 lines
16 KiB
Rust
// Copyright 2023 Oxide Computer Company
|
|
|
|
//! Generation of mocking extensions for `httpmock`
|
|
|
|
use openapiv3::OpenAPI;
|
|
use proc_macro2::TokenStream;
|
|
use quote::{format_ident, quote, ToTokens};
|
|
|
|
use crate::{
|
|
method::{
|
|
BodyContentType, HttpMethod, OperationParameter,
|
|
OperationParameterKind, OperationParameterType, OperationResponse,
|
|
OperationResponseStatus,
|
|
},
|
|
to_schema::ToSchema,
|
|
util::{sanitize, Case},
|
|
validate_openapi, Generator, Result,
|
|
};
|
|
use crate::util::generate_multi_type_identifier;
|
|
|
|
struct MockOp {
|
|
when: TokenStream,
|
|
when_impl: TokenStream,
|
|
then: TokenStream,
|
|
then_impl: TokenStream,
|
|
}
|
|
|
|
impl Generator {
|
|
/// Generate a strongly-typed mocking extension to the `httpmock` crate.
|
|
pub fn httpmock(
|
|
&mut self,
|
|
spec: &OpenAPI,
|
|
crate_name: &str,
|
|
) -> Result<TokenStream> {
|
|
validate_openapi(spec)?;
|
|
|
|
// Convert our components dictionary to schemars
|
|
let schemas = spec.components.iter().flat_map(|components| {
|
|
components.schemas.iter().map(|(name, ref_or_schema)| {
|
|
(name.clone(), ref_or_schema.to_schema())
|
|
})
|
|
});
|
|
|
|
self.type_space.add_ref_types(schemas)?;
|
|
|
|
let raw_methods = spec
|
|
.paths
|
|
.iter()
|
|
.flat_map(|(path, ref_or_item)| {
|
|
// Exclude externally defined path items.
|
|
let item = ref_or_item.as_item().unwrap();
|
|
item.iter().map(move |(method, operation)| {
|
|
(path.as_str(), method, operation, &item.parameters)
|
|
})
|
|
})
|
|
.map(|(path, method, operation, path_parameters)| {
|
|
self.process_operation(
|
|
operation,
|
|
&spec.components,
|
|
path,
|
|
method,
|
|
path_parameters,
|
|
)
|
|
})
|
|
.collect::<Result<Vec<_>>>()?;
|
|
|
|
let methods = raw_methods
|
|
.iter()
|
|
.map(|method| self.httpmock_method(method))
|
|
.collect::<Vec<_>>();
|
|
|
|
let op = raw_methods
|
|
.iter()
|
|
.map(|method| format_ident!("{}", &method.operation_id))
|
|
.collect::<Vec<_>>();
|
|
let when = methods.iter().map(|op| &op.when).collect::<Vec<_>>();
|
|
let when_impl =
|
|
methods.iter().map(|op| &op.when_impl).collect::<Vec<_>>();
|
|
let then = methods.iter().map(|op| &op.then).collect::<Vec<_>>();
|
|
let then_impl =
|
|
methods.iter().map(|op| &op.then_impl).collect::<Vec<_>>();
|
|
|
|
let crate_path = syn::TypePath {
|
|
qself: None,
|
|
path: syn::parse_str(crate_name).unwrap(),
|
|
};
|
|
|
|
let code = quote! {
|
|
pub mod operations {
|
|
|
|
//! [`When`](httpmock::When) and [`Then`](httpmock::Then)
|
|
//! wrappers for each operation. Each can be converted to
|
|
//! its inner type with a call to `into_inner()`. This can
|
|
//! be used to explicitly deviate from permitted values.
|
|
|
|
use #crate_path::*;
|
|
|
|
#(
|
|
pub struct #when(httpmock::When);
|
|
#when_impl
|
|
|
|
pub struct #then(httpmock::Then);
|
|
#then_impl
|
|
)*
|
|
}
|
|
|
|
/// An extension trait for [`MockServer`](httpmock::MockServer) that
|
|
/// adds a method for each operation. These are the equivalent of
|
|
/// type-checked [`mock()`](httpmock::MockServer::mock) calls.
|
|
pub trait MockServerExt {
|
|
#(
|
|
fn #op<F>(&self, config_fn: F) -> httpmock::Mock
|
|
where
|
|
F: FnOnce(operations::#when, operations::#then);
|
|
)*
|
|
}
|
|
|
|
impl MockServerExt for httpmock::MockServer {
|
|
#(
|
|
fn #op<F>(&self, config_fn: F) -> httpmock::Mock
|
|
where
|
|
F: FnOnce(operations::#when, operations::#then)
|
|
{
|
|
self.mock(|when, then| {
|
|
config_fn(
|
|
operations::#when::new(when),
|
|
operations::#then::new(then),
|
|
)
|
|
})
|
|
}
|
|
)*
|
|
}
|
|
};
|
|
Ok(code)
|
|
}
|
|
|
|
fn httpmock_method(
|
|
&mut self,
|
|
method: &crate::method::OperationMethod,
|
|
) -> MockOp {
|
|
let when_name =
|
|
sanitize(&format!("{}-when", method.operation_id), Case::Pascal);
|
|
let when = format_ident!("{}", when_name).to_token_stream();
|
|
let then_name =
|
|
sanitize(&format!("{}-then", method.operation_id), Case::Pascal);
|
|
let then = format_ident!("{}", then_name).to_token_stream();
|
|
|
|
let http_method = match &method.method {
|
|
HttpMethod::Get => quote! { httpmock::Method::GET },
|
|
HttpMethod::Put => quote! { httpmock::Method::PUT },
|
|
HttpMethod::Post => quote! { httpmock::Method::POST },
|
|
HttpMethod::Delete => quote! { httpmock::Method::DELETE },
|
|
HttpMethod::Options => quote! { httpmock::Method::OPTIONS },
|
|
HttpMethod::Head => quote! { httpmock::Method::HEAD },
|
|
HttpMethod::Patch => quote! { httpmock::Method::PATCH },
|
|
HttpMethod::Trace => quote! { httpmock::Method::TRACE },
|
|
};
|
|
|
|
let path_re = method.path.as_wildcard();
|
|
|
|
// Generate methods corresponding to each parameter so that callers
|
|
// can specify a prescribed value for that parameter.
|
|
let when_methods = method.params.iter().map(
|
|
|OperationParameter {
|
|
name,
|
|
typ,
|
|
kind,
|
|
api_name,
|
|
description: _,
|
|
}| {
|
|
let arg_type_name = match typ {
|
|
OperationParameterType::Type(arg_type_id) => self
|
|
.type_space
|
|
.get_type(arg_type_id)
|
|
.unwrap()
|
|
.parameter_ident(),
|
|
OperationParameterType::RawBody => match kind {
|
|
OperationParameterKind::Body(
|
|
BodyContentType::OctetStream,
|
|
) => quote! {
|
|
serde_json::Value
|
|
},
|
|
OperationParameterKind::Body(
|
|
BodyContentType::Text(_),
|
|
) => quote! {
|
|
String
|
|
},
|
|
_ => unreachable!(),
|
|
},
|
|
};
|
|
|
|
let name_ident = format_ident!("{}", name);
|
|
let handler = match kind {
|
|
OperationParameterKind::Path => {
|
|
let re_fmt = method.path.as_wildcard_param(api_name);
|
|
quote! {
|
|
let re = regex::Regex::new(
|
|
&format!(#re_fmt, value.to_string())
|
|
).unwrap();
|
|
Self(self.0.path_matches(re))
|
|
}
|
|
}
|
|
OperationParameterKind::Query(true) => quote! {
|
|
Self(self.0.query_param(#name, value.to_string()))
|
|
},
|
|
|
|
OperationParameterKind::Query(false) => {
|
|
// If the type is a ref, augment it with a lifetime that we'll also use in the function
|
|
let (lifetime, arg_type_name) =
|
|
if let syn::Type::Reference(mut rr) =
|
|
syn::parse2::<syn::Type>(arg_type_name.clone())
|
|
.unwrap()
|
|
{
|
|
rr.lifetime = Some(syn::Lifetime::new(
|
|
"'a",
|
|
proc_macro2::Span::call_site(),
|
|
));
|
|
(Some(quote! { 'a, }), rr.to_token_stream())
|
|
} else {
|
|
(None, arg_type_name)
|
|
};
|
|
|
|
return quote! {
|
|
pub fn #name_ident<#lifetime T>(
|
|
self,
|
|
value: T,
|
|
) -> Self
|
|
where
|
|
T: Into<Option<#arg_type_name>>,
|
|
{
|
|
if let Some(value) = value.into() {
|
|
Self(self.0.query_param(
|
|
#name,
|
|
value.to_string(),
|
|
))
|
|
} else {
|
|
Self(self.0.matches(|req| {
|
|
req.query_params
|
|
.as_ref()
|
|
.and_then(|qs| {
|
|
qs.iter().find(
|
|
|(key, _)| key == #name)
|
|
})
|
|
.is_none()
|
|
}))
|
|
}
|
|
}
|
|
};
|
|
}
|
|
OperationParameterKind::Header(_) => quote! { todo!() },
|
|
OperationParameterKind::Body(body_content_type) => {
|
|
match typ {
|
|
OperationParameterType::Type(_) => quote! {
|
|
Self(self.0.json_body_obj(value))
|
|
|
|
},
|
|
OperationParameterType::RawBody => {
|
|
match body_content_type {
|
|
BodyContentType::OctetStream => quote! {
|
|
Self(self.0.json_body(value))
|
|
},
|
|
BodyContentType::Text(_) => quote! {
|
|
Self(self.0.body(value))
|
|
},
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
quote! {
|
|
pub fn #name_ident(self, value: #arg_type_name) -> Self {
|
|
#handler
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
let when_impl = quote! {
|
|
impl #when {
|
|
pub fn new(inner: httpmock::When) -> Self {
|
|
Self(inner
|
|
.method(#http_method)
|
|
.path_matches(regex::Regex::new(#path_re).unwrap()))
|
|
}
|
|
|
|
pub fn into_inner(self) -> httpmock::When {
|
|
self.0
|
|
}
|
|
|
|
#(#when_methods)*
|
|
}
|
|
};
|
|
|
|
// Methods for each discrete response. For specific status codes we use
|
|
// the name of that code; for classes of codes we use the class name
|
|
// and require a status code that must be within the prescribed range.
|
|
let then_methods = method.responses.iter().map(
|
|
|OperationResponse {
|
|
status_code, typ, ..
|
|
}| {
|
|
let (value_param, value_use) = match typ {
|
|
crate::method::OperationResponseKind::Type(arg_type_id) => {
|
|
let arg_type =
|
|
self.type_space.get_type(arg_type_id).unwrap();
|
|
let arg_type_ident = arg_type.parameter_ident();
|
|
(
|
|
quote! {
|
|
value: #arg_type_ident,
|
|
},
|
|
quote! {
|
|
.header("content-type", "application/json")
|
|
.json_body_obj(value)
|
|
},
|
|
)
|
|
}
|
|
crate::method::OperationResponseKind::Multi(types) => {
|
|
let arg_type = generate_multi_type_identifier(types, &self.type_space);
|
|
(
|
|
quote! {
|
|
value: #arg_type,
|
|
},
|
|
quote! {
|
|
.header("content-type", "application/json")
|
|
.json_body_obj(value)
|
|
},
|
|
)
|
|
}
|
|
crate::method::OperationResponseKind::None => {
|
|
Default::default()
|
|
}
|
|
crate::method::OperationResponseKind::Raw => (
|
|
quote! {
|
|
value: serde_json::Value,
|
|
},
|
|
quote! {
|
|
.header("content-type", "application/json")
|
|
.json_body(value)
|
|
},
|
|
),
|
|
crate::method::OperationResponseKind::Upgrade => {
|
|
Default::default()
|
|
}
|
|
};
|
|
|
|
match status_code {
|
|
OperationResponseStatus::Code(status_code) => {
|
|
let canonical_reason =
|
|
http::StatusCode::from_u16(*status_code)
|
|
.unwrap()
|
|
.canonical_reason()
|
|
.unwrap();
|
|
let fn_name = format_ident!(
|
|
"{}",
|
|
&sanitize(canonical_reason, Case::Snake)
|
|
);
|
|
|
|
quote! {
|
|
pub fn #fn_name(self, #value_param) -> Self {
|
|
Self(self.0
|
|
.status(#status_code)
|
|
#value_use
|
|
)
|
|
}
|
|
}
|
|
}
|
|
OperationResponseStatus::Range(status_type) => {
|
|
let status_string = match status_type {
|
|
1 => "informational",
|
|
2 => "success",
|
|
3 => "redirect",
|
|
4 => "client_error",
|
|
5 => "server_error",
|
|
_ => unreachable!(),
|
|
};
|
|
let fn_name = format_ident!("{}", status_string);
|
|
quote! {
|
|
pub fn #fn_name(self, status: u16, #value_param) -> Self {
|
|
assert_eq!(status / 100u16, #status_type);
|
|
Self(self.0
|
|
.status(status)
|
|
#value_use
|
|
)
|
|
}
|
|
}
|
|
}
|
|
OperationResponseStatus::Default => quote! {
|
|
pub fn default_response(self, status: u16, #value_param) -> Self {
|
|
Self(self.0
|
|
.status(status)
|
|
#value_use
|
|
)
|
|
}
|
|
},
|
|
}
|
|
},
|
|
);
|
|
|
|
let then_impl = quote! {
|
|
impl #then {
|
|
pub fn new(inner: httpmock::Then) -> Self {
|
|
Self(inner)
|
|
}
|
|
|
|
pub fn into_inner(self) -> httpmock::Then {
|
|
self.0
|
|
}
|
|
|
|
#(#then_methods)*
|
|
}
|
|
};
|
|
|
|
MockOp {
|
|
when,
|
|
when_impl,
|
|
then,
|
|
then_impl,
|
|
}
|
|
}
|
|
}
|