From 5721d21a5ef8c48c71aba93250972506fdfc6744 Mon Sep 17 00:00:00 2001 From: Wolfgang Bumiller Date: Fri, 22 Nov 2019 09:54:29 +0100 Subject: [PATCH] import a first draft of api macros Signed-off-by: Wolfgang Bumiller --- proxmox-api-macro/src/api.rs | 625 ++++++++++++++++++++++++++++++++ proxmox-api-macro/src/lib.rs | 128 +++++++ proxmox-api-macro/tests/api1.rs | 30 ++ 3 files changed, 783 insertions(+) create mode 100644 proxmox-api-macro/src/api.rs create mode 100644 proxmox-api-macro/tests/api1.rs diff --git a/proxmox-api-macro/src/api.rs b/proxmox-api-macro/src/api.rs new file mode 100644 index 00000000..85769361 --- /dev/null +++ b/proxmox-api-macro/src/api.rs @@ -0,0 +1,625 @@ +extern crate proc_macro; +extern crate proc_macro2; + +use std::mem; + +use failure::Error; + +use proc_macro2::{Span, TokenStream}; +use quote::{quote, quote_spanned}; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::spanned::Spanned; +use syn::Ident; +use syn::{parenthesized, Token}; + +/// Any 'keywords' we introduce as part of our schema related api macro syntax. +mod token { + syn::custom_keyword!(optional); +} + +/// Our syntax elements which represent an API Schema implement this. This is similar to +/// `quote::ToTokens`, but rather than translating back into the input, this produces the resulting +/// `proxmox::api::schema::Schema` instantiation. +/// +/// For example: +/// ```ignore +/// Schema { +/// item_type: "Boolean", +/// paren_token: ..., +/// description: Some("Some value"), +/// comma_token: ..., +/// item: SchemaItem::Boolean(SchemaItemBoolean { +/// default_value: Some(DefaultValue { +/// default_token: ..., +/// colon: ..., +/// value: syn::ExprLit(syn::LitBool(true)), // simplified... +/// }), +/// }), +/// constraints: Vec::new(), +/// }.to_schema(ts); +/// ``` +/// +/// produces: +/// +/// ```ignore +/// ::proxmox::api::schema::BooleanSchema::new("Some value") +/// .default(true) +/// ``` +trait ToSchema { + fn to_schema(&self, ts: &mut TokenStream) -> Result<(), Error>; + + #[inline] + fn add_constraints(&self, ts: &mut TokenStream) -> Result<(), Error> { + let _ = ts; + Ok(()) + } +} + +/// A generic schema entry. +/// +/// Since all our schema types have at least a description, we define this "top level" schema +/// syntax element which parses the description as first parameter (if it is available), and then +/// parses the remaining parts as `SchemaItem`. +/// +/// ```text +/// Object ( "Description", { Elements } ) .default_key("hello") +/// ^^^^^^ ~ ^^^^^^^^^^^^^^ ~~~~~~~~~~~~ ^ ~~~~~~~~~~~~~~~~~~~~~ +/// item_type description item constraints +/// ``` +struct Schema { + pub item_type: Ident, + pub paren_token: syn::token::Paren, + pub description: Option, + pub comma_token: Option, + pub item: SchemaItem, + pub constraints: Vec, +} + +impl ToSchema for Schema { + fn to_schema(&self, ts: &mut TokenStream) -> Result<(), Error> { + let item_type = &self.item_type; + let schema_type = Ident::new( + &format!("{}Schema", item_type.to_string()), + item_type.span(), + ); + let description = self + .description + .as_ref() + .ok_or_else(|| format_err!(item_type => "missing description"))?; + + let mut item = TokenStream::new(); + self.item.to_schema(&mut item)?; + + ts.extend(quote! { + ::proxmox::api::schema::#schema_type::new( + #description, + #item + ) + }); + self.item.add_constraints(ts)?; + + for constraint in self.constraints.iter() { + ts.extend(quote! { . #constraint }); + } + + Ok(()) + } +} + +impl Parse for Schema { + fn parse(input: ParseStream) -> syn::Result { + let item_type: Ident = input.parse()?; + let item_type_span = item_type.span(); + let item_type_str = item_type.to_string(); + let content; + let mut comma_token = None; + Ok(Self { + item_type, + paren_token: parenthesized!(content in input), + description: { + let lookahead = content.lookahead1(); + if lookahead.peek(syn::LitStr) { + let desc = content.parse()?; + if !content.is_empty() { + comma_token = Some(content.parse()?); + } + Some(desc) + } else { + None + } + }, + comma_token, + item: { + match item_type_str.as_str() { + "Null" => content.parse().map(SchemaItem::Null)?, + "Boolean" => content.parse().map(SchemaItem::Boolean)?, + "Integer" => content.parse().map(SchemaItem::Integer)?, + "String" => content.parse().map(SchemaItem::String)?, + "Object" => content.parse().map(SchemaItem::Object)?, + "Array" => content.parse().map(SchemaItem::Array)?, + _ => bail!(item_type_span, "unknown schema type"), + } + }, + constraints: { + let mut constraints = Vec::::new(); + while input.lookahead1().peek(Token![.]) { + let _dot: Token![.] = input.parse()?; + constraints.push(input.parse()?); + } + constraints + }, + }) + } +} + +/// This is the collection of possible schema elements we have. +/// +/// Its `ToSchema` implementation simply defers to the inner types. It has no `Parse` +/// implementation directly. This is handled by the parser for `Schema`. +enum SchemaItem { + Null(SchemaItemNull), + Boolean(SchemaItemBoolean), + Integer(SchemaItemInteger), + String(SchemaItemString), + Object(SchemaItemObject), + Array(SchemaItemArray), +} + +impl ToSchema for SchemaItem { + fn to_schema(&self, ts: &mut TokenStream) -> Result<(), Error> { + match self { + SchemaItem::Null(i) => i.to_schema(ts), + SchemaItem::Boolean(i) => i.to_schema(ts), + SchemaItem::Integer(i) => i.to_schema(ts), + SchemaItem::String(i) => i.to_schema(ts), + SchemaItem::Object(i) => i.to_schema(ts), + SchemaItem::Array(i) => i.to_schema(ts), + } + } + + #[inline] + fn add_constraints(&self, ts: &mut TokenStream) -> Result<(), Error> { + match self { + SchemaItem::Null(i) => i.add_constraints(ts), + SchemaItem::Boolean(i) => i.add_constraints(ts), + SchemaItem::Integer(i) => i.add_constraints(ts), + SchemaItem::String(i) => i.add_constraints(ts), + SchemaItem::Object(i) => i.add_constraints(ts), + SchemaItem::Array(i) => i.add_constraints(ts), + } + } +} + +/// A "default key" for an object schema. +/// +/// This serves mostly as an example of how we could extend the macro syntax. +/// This is used typing the following: +/// +/// ```ignore +/// Object("Description", default: "foo", { "foo": String("Foo"), "bar": String("Bar") }) +/// ``` +/// +/// instead of: +/// +/// ```ignore +/// Object("Description", { "foo": String("Foo"), "bar": String("Bar") }).default_key("foo") +/// ``` +struct DefaultKey { + pub default_token: Token![default], + pub colon: Token![:], + pub key_name: syn::LitStr, + pub comma_token: Token![,], +} + +impl Parse for DefaultKey { + fn parse(input: ParseStream) -> syn::Result { + Ok(Self { + default_token: input.parse()?, + colon: input.parse()?, + key_name: input.parse()?, + comma_token: input.parse()?, + }) + } +} + +/// An object schema. This currently allows parsing a default key as an example of what we could do +/// instead of keeping the builder-pattern syntax within the macro invocation. +/// +/// The elements then follow enclosed in braces: +/// +/// ```ignore +/// Object("Description", { "key1": Integer("Key One"), optional "key2": Integer("Key Two") }) +/// ``` +struct SchemaItemObject { + pub default_key: Option, + pub brace_token: syn::token::Brace, + pub elements: Punctuated, +} + +impl ToSchema for SchemaItemObject { + fn to_schema(&self, ts: &mut TokenStream) -> Result<(), Error> { + let mut elements: Vec<&ObjectElement> = self.elements.iter().collect(); + elements.sort_by(|a, b| a.cmp(b)); + + let mut elem_ts = TokenStream::new(); + for element in elements { + if !elem_ts.is_empty() { + elem_ts.extend(quote![, ]); + } + + element.to_schema(&mut elem_ts)?; + } + + ts.extend(quote! { & [ #elem_ts ] }); + + Ok(()) + } + + fn add_constraints(&self, ts: &mut TokenStream) -> Result<(), Error> { + if let Some(def) = &self.default_key { + let key = &def.key_name; + ts.extend(quote! { .default_key(#key) }); + } + Ok(()) + } +} + +impl Parse for SchemaItemObject { + fn parse(input: ParseStream) -> syn::Result { + let elements; + Ok(Self { + default_key: { + let lookahead = input.lookahead1(); + if lookahead.peek(Token![default]) { + Some(input.parse()?) + } else { + None + } + }, + brace_token: syn::braced!(elements in input), + elements: elements.parse_terminated(ObjectElement::parse)?, + }) + } +} + +/// This represents a member in the comma separated list of fields of an object. +/// +/// ```text +/// Object("Description", { "key1": Integer("Key One"), optional "key2": Integer("Key Two") }) +/// ^^^^^^^^^^^^^^^^^^^^^^^^^^ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +/// one `ObjectElement` another `ObjectElement` +/// ``` +struct ObjectElement { + pub optional: Option, + pub field_name: syn::LitStr, + pub colon: Token![:], + pub item: Schema, +} + +impl ObjectElement { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.field_name.suffix().cmp(other.field_name.suffix()) + } +} + +impl ToSchema for ObjectElement { + fn to_schema(&self, ts: &mut TokenStream) -> Result<(), Error> { + let mut schema = TokenStream::new(); + self.item.to_schema(&mut schema)?; + + let name = &self.field_name; + + let optional = if self.optional.is_some() { + quote!(true) + } else { + quote!(false) + }; + + ts.extend(quote! { + (#name, #optional, & #schema .schema()) + }); + + Ok(()) + } +} + +impl Parse for ObjectElement { + fn parse(input: ParseStream) -> syn::Result { + Ok(Self { + optional: input.parse()?, + field_name: input.parse()?, + colon: input.parse()?, + item: input.parse()?, + }) + } +} + +/// Array schemas simply contain their inner type. +/// +/// ```ignore +/// Array("Some data", Integer("A data element")) +/// ``` +struct SchemaItemArray { + pub item_schema: Box, +} + +impl ToSchema for SchemaItemArray { + fn to_schema(&self, ts: &mut TokenStream) -> Result<(), Error> { + ts.extend(quote! { & }); + self.item_schema.to_schema(ts)?; + self.item_schema.add_constraints(ts)?; + ts.extend(quote! { .schema() }); + Ok(()) + } +} + +impl Parse for SchemaItemArray { + fn parse(input: ParseStream) -> syn::Result { + Ok(Self { + item_schema: Box::new(input.parse()?), + }) + } +} + +/// The `Null` schema. +struct SchemaItemNull {} + +impl ToSchema for SchemaItemNull { + fn to_schema(&self, _ts: &mut TokenStream) -> Result<(), Error> { + Ok(()) + } +} + +impl Parse for SchemaItemNull { + fn parse(_input: ParseStream) -> syn::Result { + Ok(Self {}) + } +} + +/// A default value. Similar to the default keys in objects, this is an example of a different +/// syntax instead of the builder pattern. +/// +/// ```ignore +/// String("Something", default: "The default value") +/// ``` +/// +/// instead of: +/// +/// ```ignore +/// String("Something").default("The default value") +/// ``` +struct DefaultValue { + pub default_token: Token![default], + pub colon: Token![:], + pub value: syn::Expr, +} + +impl ToSchema for DefaultValue { + fn to_schema(&self, _ts: &mut TokenStream) -> Result<(), Error> { + Ok(()) + } + + fn add_constraints(&self, ts: &mut TokenStream) -> Result<(), Error> { + let value = &self.value; + ts.extend(quote! { .default(#value) }); + Ok(()) + } +} + +impl Parse for DefaultValue { + fn parse(input: ParseStream) -> syn::Result { + Ok(Self { + default_token: input.parse()?, + colon: input.parse()?, + value: input.parse()?, + }) + } +} + +macro_rules! try_parse_default_value { + ($input:expr) => {{ + let input = $input; + let lookahead = input.lookahead1(); + if lookahead.peek(Token![default]) { + Some(input.parse()?) + } else { + None + } + }}; +} + +/// A boolean schema entry. +struct SchemaItemBoolean { + pub default_value: Option, +} + +impl ToSchema for SchemaItemBoolean { + fn to_schema(&self, _ts: &mut TokenStream) -> Result<(), Error> { + Ok(()) + } + + fn add_constraints(&self, ts: &mut TokenStream) -> Result<(), Error> { + if let Some(def) = &self.default_value { + def.add_constraints(ts)?; + } + Ok(()) + } +} + +impl Parse for SchemaItemBoolean { + fn parse(input: ParseStream) -> syn::Result { + Ok(Self { + default_value: try_parse_default_value!(input), + }) + } +} + +/// An integer schema entry. +struct SchemaItemInteger { + pub default_value: Option, +} + +impl ToSchema for SchemaItemInteger { + fn to_schema(&self, _ts: &mut TokenStream) -> Result<(), Error> { + Ok(()) + } + + fn add_constraints(&self, ts: &mut TokenStream) -> Result<(), Error> { + if let Some(def) = &self.default_value { + def.add_constraints(ts)?; + } + Ok(()) + } +} + +impl Parse for SchemaItemInteger { + fn parse(input: ParseStream) -> syn::Result { + Ok(Self { + default_value: try_parse_default_value!(input), + }) + } +} + +/// An string schema entry. +struct SchemaItemString { + pub default_value: Option, +} + +impl ToSchema for SchemaItemString { + fn to_schema(&self, _ts: &mut TokenStream) -> Result<(), Error> { + Ok(()) + } + + fn add_constraints(&self, ts: &mut TokenStream) -> Result<(), Error> { + if let Some(def) = &self.default_value { + def.add_constraints(ts)?; + } + Ok(()) + } +} + +impl Parse for SchemaItemString { + fn parse(input: ParseStream) -> syn::Result { + Ok(Self { + default_value: try_parse_default_value!(input), + }) + } +} + +/// We get macro attributes like `#[input(THIS)]` with the parenthesis around `THIS` included. +struct Parenthesized { + pub token: syn::token::Paren, + pub content: T, +} + +impl Parse for Parenthesized { + fn parse(input: ParseStream) -> syn::Result { + let content; + Ok(Self { + token: parenthesized!(content in input), + content: content.parse()?, + }) + } +} + +/// We get macro attributes like `#[doc = "TEXT"]` with the `=` included. +struct BareAssignment { + pub token: Token![=], + pub content: T, +} + +impl Parse for BareAssignment { + fn parse(input: ParseStream) -> syn::Result { + Ok(Self { + token: input.parse()?, + content: input.parse()?, + }) + } +} + +/// 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(crate) fn api(_attr: TokenStream, item: TokenStream) -> Result { + let mut func: syn::ItemFn = syn::parse2(item)?; + + let sig_span = func.sig.span(); + + let mut protected = false; + + let mut input_schema = None; + let mut returns_schema = None; + let mut doc_comment = String::new(); + let doc_span = Span::call_site(); // FIXME: set to first doc comment + for attr in mem::replace(&mut func.attrs, Vec::new()) { + // don't mess with #![...] + if let syn::AttrStyle::Inner(_) = &attr.style { + func.attrs.push(attr); + continue; + } + + if attr.path.is_ident("doc") { + let doc: BareAssignment = syn::parse2(attr.tokens.clone())?; + doc_comment.push_str(&doc.content.value()); + func.attrs.push(attr); + } else if attr.path.is_ident("input") { + let input: Parenthesized = syn::parse2(attr.tokens)?; + input_schema = Some(input.content); + } else if attr.path.is_ident("returns") { + let input: Parenthesized = syn::parse2(attr.tokens)?; + returns_schema = Some(input.content); + } else if attr.path.is_ident("protected") { + if attr.tokens.is_empty() { + protected = true; + } else { + let value: Parenthesized = syn::parse2(attr.tokens)?; + protected = value.content.value; + } + } else { + func.attrs.push(attr); + } + } + + let mut input_schema = + input_schema.ok_or_else(|| format_err!(sig_span, "missing input schema"))?; + + if input_schema.description.is_none() { + input_schema.description = Some(syn::LitStr::new(&doc_comment, doc_span)); + } + + let input_schema = { + let mut ts = TokenStream::new(); + input_schema.to_schema(&mut ts)?; + ts + }; + + let returns_schema = + returns_schema.ok_or_else(|| format_err!(sig_span, "missing returns schema"))?; + + let returns_schema = { + let mut ts = TokenStream::new(); + returns_schema.to_schema(&mut ts)?; + ts + }; + + 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(), + ); + + Ok(quote_spanned! { sig_span => + #vis const #api_method_name: ::proxmox::api::ApiMethod = + ::proxmox::api::ApiMethod::new( + &::proxmox::api::ApiHandler::Sync(&#func_name), + &#input_schema, + ) + .returns(& #returns_schema .schema()) + .protected(#protected); + #func + }) + //Ok(quote::quote!(#func)) +} diff --git a/proxmox-api-macro/src/lib.rs b/proxmox-api-macro/src/lib.rs index ff35caa4..e4060c0b 100644 --- a/proxmox-api-macro/src/lib.rs +++ b/proxmox-api-macro/src/lib.rs @@ -1 +1,129 @@ #![recursion_limit = "256"] + +extern crate proc_macro; +extern crate proc_macro2; + +use failure::Error; + +use proc_macro::TokenStream as TokenStream_1; +use proc_macro2::TokenStream; + +macro_rules! format_err { + ($span:expr => $($msg:tt)*) => { syn::Error::new_spanned($span, format!($($msg)*)) }; + ($span:expr, $($msg:tt)*) => { syn::Error::new($span, format!($($msg)*)) }; +} + +macro_rules! bail { + ($span:expr => $($msg:tt)*) => { return Err(format_err!($span => $($msg)*).into()) }; + ($span:expr, $($msg:tt)*) => { return Err(format_err!($span, $($msg)*).into()) }; +} + +mod api; + +fn handle_error(mut item: TokenStream, data: Result) -> TokenStream { + match data { + Ok(output) => output, + Err(err) => match err.downcast::() { + Ok(err) => { + item.extend(err.to_compile_error()); + item + } + Err(err) => panic!("error in api/router macro: {}", err), + }, + } +} + +/// Macro for building a Router: +/// +/// ```ignore +/// router! { +/// pub const ROUTER = { +/// "access": { +/// "ticket": { +/// post = create_ticket, +/// } +/// } +/// }; +/// } +/// +/// #[api] +/// fn create_ticket(param: Value) -> Result { ... } +/// ``` +#[proc_macro] +pub fn router(item: TokenStream_1) -> TokenStream_1 { + let item: TokenStream = item.into(); + handle_error(item.clone(), router_do(item)).into() +} + +fn router_do(item: TokenStream) -> Result { + Ok(item) +} + +/** + Macro for building an API method: + + ``` + # use proxmox_api_macro::api; + # use proxmox::api::{ApiMethod, RpcEnvironment}; + + use failure::Error; + use serde_json::Value; + + #[api] + #[input(Object({ + "username": String("User name.").max_length(64), + "password": String("The secret password or a valid ticket."), + }))] + #[returns(Object("Returns a ticket", { + "username": String("User name."), + "ticket": String("Auth ticket."), + "CSRFPreventionToken": String("Cross Site Request Forgerty Prevention Token."), + }))] + /// Create or verify authentication ticket. + /// + /// Returns: ... + fn create_ticket( + _param: Value, + _info: &ApiMethod, + _rpcenv: &mut dyn RpcEnvironment, + ) -> Result { + panic!("implement me"); + } + ``` + + The above code expands to: + + ```ignore + const API_METHOD_CREATE_TICKET: ApiMethod = + ApiMethod::new( + &ApiHandler::Sync(&create_ticket), + &ObjectSchema::new( + "Create or verify authentication ticket", + &[ // Sorted: + ("password", false, &StringSchema::new("The secret password or a valid ticket.") + .schema()), + ("username", false, &StringSchema::new("User name.") + .max_length(64) + .schema()), + ] + ) + ) + .returns( + &ObjectSchema::new( + ) + ) + .protected(false); + fn create_ticket( + param: Value, + info: &ApiMethod, + rpcenv: &mut dyn RpcEnvironment, + ) -> Result { + ... + } + ``` +*/ +#[proc_macro_attribute] +pub fn api(attr: TokenStream_1, item: TokenStream_1) -> TokenStream_1 { + let item: TokenStream = item.into(); + handle_error(item.clone(), api::api(attr.into(), item)).into() +} diff --git a/proxmox-api-macro/tests/api1.rs b/proxmox-api-macro/tests/api1.rs new file mode 100644 index 00000000..37feb662 --- /dev/null +++ b/proxmox-api-macro/tests/api1.rs @@ -0,0 +1,30 @@ +#![allow(dead_code)] + +use proxmox::api::{ApiMethod, RpcEnvironment}; +use proxmox_api_macro::api; + +use failure::Error; +use serde_json::Value; + +#[api] +#[input(Object(default: "test", { + "username": String("User name.").max_length(64), + "password": String("The secret password or a valid ticket."), + optional "test": Integer("What?", default: 3), + "data": Array("Some Integers", Integer("Some Thing").maximum(4)), +}))] +#[returns(Object("Returns a ticket", { + "username": String("User name."), + "ticket": String("Auth ticket."), + "CSRFPreventionToken": String("Cross Site Request Forgerty Prevention Token."), +}))] +/// Create or verify authentication ticket. +/// +/// Returns: ... +fn create_ticket( + _param: Value, + _info: &ApiMethod, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result { + panic!("implement me"); +}