// 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, }; 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 { 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::>>()?; let methods = raw_methods .iter() .map(|method| self.httpmock_method(method)) .collect::>(); let op = raw_methods .iter() .map(|method| format_ident!("{}", &method.operation_id)) .collect::>(); let when = methods.iter().map(|op| &op.when).collect::>(); let when_impl = methods.iter().map(|op| &op.when_impl).collect::>(); let then = methods.iter().map(|op| &op.then).collect::>(); let then_impl = methods.iter().map(|op| &op.then_impl).collect::>(); 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(&self, config_fn: F) -> httpmock::Mock where F: FnOnce(operations::#when, operations::#then); )* } impl MockServerExt for httpmock::MockServer { #( fn #op(&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::(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>, { 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::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, } } }