//! Method handling. //! //! This has to perform quite a few things: infer types from parameters, deal with optional types //! and defaults, expose parameter and return value schema to the public, and finally create the //! wrapper function converting from a json Value hash to the parameters listed in the function //! signature, while recognizing specially handling `RPCEnvironment` and `ApiMethod` parameters. use std::convert::{TryFrom, TryInto}; use std::mem; use anyhow::Error; use proc_macro2::{Span, TokenStream}; use quote::{quote, quote_spanned, ToTokens}; use syn::ext::IdentExt; use syn::spanned::Spanned; use syn::visit_mut::{self, VisitMut}; use syn::Ident; use super::{ObjectEntry, Schema, SchemaItem, SchemaObject}; use crate::util::{self, FieldName, JSONObject, JSONValue, Maybe}; /// A return type in a schema can have an `optional` flag. Other than that it is just a regular /// schema, but we also want to be able to reference external `ReturnType` values for this. pub enum ReturnType { Explicit(ReturnSchema), Extern(syn::Expr), } impl ReturnType { fn as_mut_schema(&mut self) -> Option<&mut Schema> { match self { ReturnType::Explicit(ReturnSchema { ref mut schema, .. }) => Some(schema), _ => None, } } } impl TryFrom for ReturnType { type Error = syn::Error; fn try_from(value: JSONValue) -> Result { Ok(match value { JSONValue::Object(obj) => ReturnType::Explicit(obj.try_into()?), JSONValue::Expr(ext) => ReturnType::Extern(ext), }) } } impl ReturnType { fn to_schema(&self, ts: &mut TokenStream) -> Result<(), Error> { match self { ReturnType::Explicit(exp) => exp.to_schema(ts)?, ReturnType::Extern(exp) => exp.to_tokens(ts), } Ok(()) } } /// A return type in a schema can have an `optional` flag. Other than that it is just a regular /// schema. pub struct ReturnSchema { /// If optional, we store `Some(span)`, otherwise `None`. optional: Option, schema: Schema, } impl ReturnSchema { fn to_schema(&self, ts: &mut TokenStream) -> Result<(), Error> { let optional = match self.optional { Some(span) => quote_spanned! { span => true }, None => quote! { false }, }; let mut out = TokenStream::new(); self.schema.to_schema(&mut out)?; ts.extend(quote! { ::proxmox_schema::ReturnType::new( #optional , &#out ) }); Ok(()) } } /// To go from a `JSONObject` to a `ReturnSchema` we first extract the `optional` flag, then forward /// to the `Schema` parser. impl TryFrom for ReturnSchema { type Error = syn::Error; fn try_from(mut obj: JSONObject) -> Result { let optional = match obj.remove("optional") { Some(value) => { let span = value.span(); let is_optional: bool = value.try_into()?; if is_optional { Some(span) } else { None } } None => None, }; Ok(Self { optional, schema: obj.try_into()?, }) } } /// Parse `input`, `returns` and `protected` attributes out of an function annotated /// with an `#[api]` attribute and produce a `const ApiMethod` named after the function. /// /// See the top level macro documentation for a complete example. pub fn handle_method(mut attribs: JSONObject, mut func: syn::ItemFn) -> Result { let input_schema: Schema = match attribs.remove("input") { Some(input) => input.into_object("input schema definition")?.try_into()?, None => Schema { span: Span::call_site(), description: Maybe::None, item: SchemaItem::Object(SchemaObject::new(Span::call_site())), properties: Vec::new(), }, }; let mut input_schema = if input_schema.as_object().is_some() { input_schema } else { error!( input_schema.span, "method input schema must be an object schema" ); let mut schema = Schema::empty_object(input_schema.span); schema.description = input_schema.description; schema.properties = input_schema.properties; schema }; let mut return_type: Option = attribs .remove("returns") .map(|ret| ret.try_into()) .transpose()?; let access_setter = match attribs.remove("access") { Some(access) => { let access = Access::try_from(access.into_object("access rules")?)?; let permission = access.permission; let description = match access.description { Some(desc) => quote_spanned! { desc.span() => Some(#desc) }, None => quote_spanned! { access.span => None }, }; quote_spanned! { access.span => .access(#description, #permission) } } None => TokenStream::new(), }; let reload_timezone: bool = attribs .remove("reload_timezone") .map(TryFrom::try_from) .transpose()? .unwrap_or(false); let protected: bool = attribs .remove("protected") .map(TryFrom::try_from) .transpose()? .unwrap_or(false); if !attribs.is_empty() { error!( attribs.span(), "unexpected api elements: {}", util::join_debug(", ", attribs.elements.keys()), ); } let (doc_comment, doc_span) = util::get_doc_comments(&func.attrs)?; util::derive_descriptions( &mut input_schema, return_type.as_mut().and_then(ReturnType::as_mut_schema), &doc_comment, doc_span, )?; let mut wrapper_ts = TokenStream::new(); let mut default_consts = TokenStream::new(); let is_async = func.sig.asyncness.is_some(); let api_func_name = handle_function_signature( &mut input_schema, &mut return_type, &mut func, &mut wrapper_ts, &mut default_consts, )?; // input schema is done, let's give the method body a chance to extract default parameters: DefaultParameters(&input_schema).visit_item_fn_mut(&mut func); let vis = &func.vis; let func_name = &func.sig.ident; let api_method_name = Ident::new( &format!("API_METHOD_{}", func_name.to_string().to_uppercase()), func.sig.ident.span(), ); let (input_schema_code, input_schema_parameter) = serialize_input_schema(input_schema, &func.sig.ident, func.sig.span())?; let mut returns_schema_setter = TokenStream::new(); if let Some(return_type) = return_type { let mut inner = TokenStream::new(); return_type.to_schema(&mut inner)?; returns_schema_setter = quote! { .returns(#inner) }; } let api_handler = if is_async { quote! { ::proxmox_router::ApiHandler::Async(&#api_func_name) } } else { quote! { ::proxmox_router::ApiHandler::Sync(&#api_func_name) } }; Ok(quote_spanned! { func.sig.span() => #input_schema_code #vis const #api_method_name: ::proxmox_router::ApiMethod = ::proxmox_router::ApiMethod::new_full( &#api_handler, #input_schema_parameter, ) #returns_schema_setter #access_setter .reload_timezone(#reload_timezone) .protected(#protected); #default_consts #wrapper_ts #func }) //Ok(quote::quote!(#func)) } enum ParameterType<'a> { Value, ApiMethod, RpcEnv, Normal(NormalParameter<'a>), } struct NormalParameter<'a> { ty: &'a syn::Type, entry: &'a ObjectEntry, } fn check_input_type(input: &syn::FnArg) -> Result<(&syn::PatType, &syn::PatIdent), syn::Error> { // `self` types are not supported: let pat_type = match input { syn::FnArg::Receiver(r) => bail!(r => "methods taking a 'self' are not supported"), syn::FnArg::Typed(pat_type) => pat_type, }; // Normally function parameters are simple Ident patterns. Anything else is an error. let pat = match &*pat_type.pat { syn::Pat::Ident(pat) => pat, _ => bail!(pat_type => "unsupported parameter type"), }; Ok((pat_type, pat)) } fn handle_function_signature( input_schema: &mut Schema, return_type: &mut Option, func: &mut syn::ItemFn, wrapper_ts: &mut TokenStream, default_consts: &mut TokenStream, ) -> Result { let sig = &func.sig; let is_async = sig.asyncness.is_some(); let mut api_method_param = None; let mut rpc_env_param = None; let mut value_param = None; let mut param_list = Vec::<(FieldName, ParameterType)>::new(); for input in sig.inputs.iter() { let (pat_type, pat) = match check_input_type(input) { Ok(input) => input, Err(err) => { crate::add_error(err); continue; } }; // For any named type which exists on the function signature... if let Some(entry) = input_schema.find_obj_property_by_ident_mut(&pat.ident.to_string()) { // try to infer the type in the schema if it is not specified explicitly: let is_option = util::infer_type(&mut entry.schema, &*pat_type.ty)?; let has_default = entry.schema.find_schema_property("default").is_some(); if !is_option && entry.optional.expect_bool() && !has_default { error!(pat_type => "optional types need a default or be an Option"); } if has_default && !entry.optional.expect_bool() { error!(pat_type => "non-optional parameter cannot have a default"); } } else { continue; }; } for input in sig.inputs.iter() { let (pat_type, pat) = match check_input_type(input) { Ok(input) => input, Err(_err) => continue, // we already produced errors above, }; // Here's the deal: we need to distinguish between parameters we need to extract before // calling the function, a general "Value" parameter covering all the remaining json // values, and our 2 fixed function parameters: `&ApiMethod` and `&mut dyn RpcEnvironment`. // // Our strategy is as follows: // 1) See if the parameter name also appears in the input schema. In this case we // assume that we want the parameter to be extracted from the `Value` and passed // directly to the function. // // 2) Check the parameter type for `&ApiMethod` and remember its position (since we may // need to reorder it!) // // 3) Check the parameter type for `&dyn RpcEnvironment` and remember its position // (since we may need to reorder it!). // // 4) Check for a `Value` or `serde_json::Value` parameter. This becomes the // "catch-all" parameter and only 1 may exist. // Note that we may still use further `Value` parameters if they have been // explicitly named in the `input_schema`. However, only 1 unnamed `Value` parameter // is allowed. // If no such parameter exists, we automatically fail the function if the `Value` is // not empty after extracting the parameters. // // 5) Finally, if none of the above conditions are met, we do not know what to do and // bail out with an error. let pat_ident = pat.ident.unraw(); let mut param_name: FieldName = pat_ident.clone().into(); let param_type = if let Some(entry) = input_schema.find_obj_property_by_ident(&pat_ident.to_string()) { if let SchemaItem::Inferred(span) = &entry.schema.item { bail!(*span, "failed to infer type"); } param_name = entry.name.clone(); // Found an explicit parameter: extract it: ParameterType::Normal(NormalParameter { ty: &pat_type.ty, entry: &entry, }) } else if is_api_method_type(&pat_type.ty) { if api_method_param.is_some() { error!(pat_type => "multiple ApiMethod parameters found"); continue; } api_method_param = Some(param_list.len()); ParameterType::ApiMethod } else if is_rpc_env_type(&pat_type.ty) { if rpc_env_param.is_some() { error!(pat_type => "multiple RpcEnvironment parameters found"); continue; } rpc_env_param = Some(param_list.len()); ParameterType::RpcEnv } else if is_value_type(&pat_type.ty) { if value_param.is_some() { error!(pat_type => "multiple additional Value parameters found"); continue; } value_param = Some(param_list.len()); ParameterType::Value } else { error!(&pat_ident => "unexpected parameter {:?}", pat_ident.to_string()); continue; }; param_list.push((param_name, param_type)); } /* * Doing this is actually unreliable, since we cannot support aliased Result types, or all * poassible combinations of paths like `result::Result<>` or `std::result::Result<>` or * `ApiResult`. // Secondly, take a look at the return type, and then decide what to do: // If our function has the correct signature we may not even need a wrapper. if is_default_return_type(&sig.output) && ( param_list.len(), value_param, api_method_param, rpc_env_param, ) == (3, Some(0), Some(1), Some(2)) { return Ok(sig.ident.clone()); } */ create_wrapper_function( input_schema, return_type, param_list, func, wrapper_ts, default_consts, is_async, ) } fn is_api_method_type(ty: &syn::Type) -> bool { if let syn::Type::Reference(r) = ty { if let syn::Type::Path(p) = &*r.elem { if p.qself.is_some() { return false; } if let Some(ps) = p.path.segments.last() { return ps.ident == "ApiMethod"; } } } false } fn is_rpc_env_type(ty: &syn::Type) -> bool { if let syn::Type::Reference(r) = ty { if let syn::Type::TraitObject(t) = &*r.elem { if let Some(syn::TypeParamBound::Trait(b)) = t.bounds.first() { if let Some(ps) = b.path.segments.last() { return ps.ident == "RpcEnvironment"; } } } } false } /// Note that we cannot handle renamed imports at all here... fn is_value_type(ty: &syn::Type) -> bool { if let syn::Type::Path(p) = ty { if p.qself.is_some() { return false; } let segs = &p.path.segments; match segs.len() { 1 => return segs.last().unwrap().ident == "Value", 2 => { return segs.first().unwrap().ident == "serde_json" && segs.last().unwrap().ident == "Value" } _ => return false, } } false } fn create_wrapper_function( _input_schema: &Schema, _returns_schema: &Option, param_list: Vec<(FieldName, ParameterType)>, func: &syn::ItemFn, wrapper_ts: &mut TokenStream, default_consts: &mut TokenStream, is_async: bool, ) -> Result { let api_func_name = Ident::new( &format!("api_function_{}", &func.sig.ident), func.sig.ident.span(), ); let mut body = TokenStream::new(); let mut args = TokenStream::new(); let func_uc = func.sig.ident.to_string().to_uppercase(); for (name, param) in param_list { let span = name.span(); match param { ParameterType::Value => args.extend(quote_spanned! { span => input_params, }), ParameterType::ApiMethod => args.extend(quote_spanned! { span => api_method_param, }), ParameterType::RpcEnv => args.extend(quote_spanned! { span => rpc_env_param, }), ParameterType::Normal(param) => { extract_normal_parameter( param, &mut body, &mut args, &func_uc, name, span, default_consts, )?; } } } // build the wrapping function: let func_name = &func.sig.ident; let await_keyword = if is_async { Some(quote!(.await)) } else { None }; let question_mark = match func.sig.output { syn::ReturnType::Default => None, _ => Some(quote!(?)), }; let body = quote! { if let ::serde_json::Value::Object(ref mut input_map) = &mut input_params { #body Ok(::serde_json::to_value(#func_name(#args) #await_keyword #question_mark)?) } else { ::anyhow::bail!("api function wrapper called with a non-object json value"); } }; if is_async { wrapper_ts.extend(quote! { fn #api_func_name<'a>( mut input_params: ::serde_json::Value, api_method_param: &'static ::proxmox_router::ApiMethod, rpc_env_param: &'a mut dyn ::proxmox_router::RpcEnvironment, ) -> ::proxmox_router::ApiFuture<'a> { //async fn func<'a>( // mut input_params: ::serde_json::Value, // api_method_param: &'static ::proxmox_router::ApiMethod, // rpc_env_param: &'a mut dyn ::proxmox_router::RpcEnvironment, //) -> ::std::result::Result<::serde_json::Value, ::anyhow::Error> { // #body //} //::std::boxed::Box::pin(async move { // func(input_params, api_method_param, rpc_env_param).await //}) ::std::boxed::Box::pin(async move { #body }) } }); } else { wrapper_ts.extend(quote! { fn #api_func_name( mut input_params: ::serde_json::Value, api_method_param: &::proxmox_router::ApiMethod, rpc_env_param: &mut dyn ::proxmox_router::RpcEnvironment, ) -> ::std::result::Result<::serde_json::Value, ::anyhow::Error> { #body } }); } Ok(api_func_name) } fn extract_normal_parameter( param: NormalParameter, body: &mut TokenStream, args: &mut TokenStream, func_uc: &str, name: FieldName, name_span: Span, default_consts: &mut TokenStream, ) -> Result<(), Error> { let span = name_span; // renamed during refactorization let name_str = syn::LitStr::new(name.as_str(), span); let arg_name = Ident::new(&format!("input_arg_{}", name.as_ident().to_string()), span); let default_value = param.entry.schema.find_schema_property("default"); // Optional parameters are expected to be Option<> types in the real function // signature, so we can just keep the returned Option from `input_map.remove()`. match param.entry.flatten { None => { // regular parameter, we just remove it and call `from_value`. body.extend(quote_spanned! { span => let #arg_name = input_map .remove(#name_str) .map(::serde_json::from_value) .transpose()? }); if !param.entry.optional.expect_bool() { // Non-optional types need to be extracted out of the option though (unless // they have a default): // // Whether the parameter is optional should have been verified by the schema // verifier already, so here we just use anyhow::bail! instead of building a // proper http error! body.extend(quote_spanned! { span => .ok_or_else(|| ::anyhow::format_err!( "missing non-optional parameter: {}", #name_str, ))? }); } let no_option_type = util::is_option_type(param.ty).is_none(); if let Some(def) = &default_value { let name_uc = name.as_ident().to_string().to_uppercase(); let name = Ident::new( &format!("API_METHOD_{}_PARAM_DEFAULT_{}", func_uc, name_uc), span, ); // strip possible Option<> from this type: let ty = util::is_option_type(param.ty).unwrap_or(param.ty); default_consts.extend(quote_spanned! { span => pub const #name: #ty = #def; }); if param.entry.optional.expect_bool() && no_option_type { // Optional parameter without an Option type requires a default: body.extend(quote_spanned! { span => .unwrap_or(#name) }); } } else if param.entry.optional.expect_bool() && no_option_type { // FIXME: we should not be able to reach this without having produced another // error above already anyway? error!(param.ty => "Optional parameter without Option requires a default"); // we produced an error so just write something that will compile body.extend(quote_spanned! { span => .unwrap_or_else(|| unreachable!()) }); } body.extend(quote_spanned! { span => ; }); } Some(flatten_span) => { // Flattened parameter, we need ot use our special partial-object deserializer. // Also note that we do not support simply nesting schemas. We need a referenced type. // Otherwise the expanded code here gets ugly and we'd need to make sure we pull out // nested schemas into named variables first... No thanks. if default_value.is_some() { error!( default_value => "flattened parameter cannot have a default as it cannot be optional", ); } if let Some(schema_ref) = param.entry.schema.to_schema_reference() { let ty = param.ty; body.extend(quote_spanned! { span => let #arg_name = <#ty as ::serde::Deserialize>::deserialize( ::proxmox_schema::de::ExtractValueDeserializer::try_new( input_map, #schema_ref, ) .ok_or_else(|| ::anyhow::format_err!( "flattened parameter {:?} has invalid schema", #name_str, ))?, )?; }); } else { error!( flatten_span, "flattened parameter schema must be a schema reference" ); body.extend(quote_spanned! { span => let #arg_name = unreachable!(); }); } } } args.extend(quote_spanned! { span => #arg_name, }); Ok(()) } /// Returns a tuple containing the schema code first and the `ParameterSchema` parameter for the /// `ApiMethod` second. fn serialize_input_schema( mut input_schema: Schema, func_name: &Ident, func_sig_span: Span, ) -> Result<(TokenStream, TokenStream), Error> { let input_schema_name = Ident::new( &format!( "API_PARAMETER_SCHEMA_{}", func_name.to_string().to_uppercase() ), func_name.span(), ); let (flattened, has_params) = match &mut input_schema.item { SchemaItem::Object(obj) => { let flattened = obj.drain_filter(|entry| entry.flatten.is_none()); (flattened, !obj.is_empty()) } _ => (Vec::new(), true), }; if flattened.is_empty() { let mut ts = TokenStream::new(); input_schema.to_typed_schema(&mut ts)?; return Ok(( quote_spanned! { func_sig_span => pub const #input_schema_name: ::proxmox_schema::ObjectSchema = #ts; }, quote_spanned! { func_sig_span => ::proxmox_schema::ParameterSchema::Object(&#input_schema_name) }, )); } let mut all_of_schemas = TokenStream::new(); for entry in flattened { if entry.optional.expect_bool() { // openapi & json schema don't exactly have a proper way to represent // this, so we simply refuse: error!( entry.schema.span, "optional flattened parameters are not supported (by JSONSchema)" ); } all_of_schemas.extend(quote::quote! {&}); entry.schema.to_schema(&mut all_of_schemas)?; all_of_schemas.extend(quote::quote! {,}); } let description = match input_schema.description.take().ok() { Some(description) => description, None => { error!(input_schema.span, "missing description on api type struct"); syn::LitStr::new("", input_schema.span) } }; // and replace it with a "dummy" input_schema.description = Maybe::Derived(syn::LitStr::new( &format!("", description.value()), description.span(), )); let (inner_schema, inner_schema_ref) = if has_params { // regular parameters go into the "inner" schema to merge into the AllOfSchema let inner_schema_name = Ident::new( &format!( "API_REGULAR_PARAMETER_SCHEMA_{}", func_name.to_string().to_uppercase() ), func_name.span(), ); let obj_schema = { let mut ts = TokenStream::new(); input_schema.to_schema(&mut ts)?; ts }; ( quote_spanned!(func_sig_span => const #inner_schema_name: ::proxmox_schema::Schema = #obj_schema; ), quote_spanned!(func_sig_span => &#inner_schema_name,), ) } else { // otherwise it stays empty (TokenStream::new(), TokenStream::new()) }; Ok(( quote_spanned! { func_sig_span => #inner_schema pub const #input_schema_name: ::proxmox_schema::AllOfSchema = ::proxmox_schema::AllOfSchema::new( #description, &[ #inner_schema_ref #all_of_schemas ], ); }, quote_spanned! { func_sig_span => ::proxmox_schema::ParameterSchema::AllOf(&#input_schema_name) }, )) } struct DefaultParameters<'a>(&'a Schema); impl<'a> VisitMut for DefaultParameters<'a> { fn visit_expr_mut(&mut self, i: &mut syn::Expr) { if let syn::Expr::Macro(exprmac) = i { if exprmac.mac.path.is_ident("api_get_default") { // replace api_get_default macros with the actual default found in the #[api] // macro. match self.get_default(mem::take(&mut exprmac.mac.tokens)) { Ok(expr) => *i = expr, Err(err) => { *i = syn::Expr::Verbatim(err.to_compile_error()); return; } } } } visit_mut::visit_expr_mut(self, i) } } impl<'a> DefaultParameters<'a> { fn get_default(&self, param_tokens: TokenStream) -> Result { let param_name: syn::LitStr = syn::parse2(param_tokens)?; match self.0.find_obj_property_by_ident(¶m_name.value()) { Some(entry) => match entry.schema.find_schema_property("default") { Some(def) => Ok(def.clone()), None => bail!(param_name => "no default found in schema"), }, None => bail!(param_name => "todo"), } } } struct Access { span: Span, description: Option, permission: syn::Expr, } impl TryFrom for Access { type Error = syn::Error; fn try_from(value: JSONValue) -> Result { Self::try_from(value.into_object("an access definition")?) } } impl TryFrom for Access { type Error = syn::Error; fn try_from(mut obj: JSONObject) -> Result { let description = match obj.remove("description") { Some(v) => Some(v.try_into()?), None => None, }; let permission = obj .remove("permission") .ok_or_else(|| format_err!(obj.span(), "missing `permission` field"))? .try_into()?; if !obj.is_empty() { bail!( obj.span(), "unexpected elements: {}", util::join_debug(", ", obj.elements.keys()), ); } Ok(Self { span: obj.span(), description, permission, }) } }