diff --git a/ruma-api-macros/.gitignore b/ruma-api-macros/.gitignore new file mode 100644 index 00000000..96ef6c0b --- /dev/null +++ b/ruma-api-macros/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/ruma-api-macros/.travis.yml b/ruma-api-macros/.travis.yml new file mode 100644 index 00000000..26a1fc4f --- /dev/null +++ b/ruma-api-macros/.travis.yml @@ -0,0 +1,19 @@ +language: "rust" +cache: "cargo" +before_script: + - "rustup component add rustfmt" + - "rustup component add clippy" + - "cargo install --force cargo-audit" + - "cargo generate-lockfile" +script: + - "cargo audit" + - "cargo fmt --all -- --check" + - "cargo clippy --all-targets --all-features -- -D warnings" + - "cargo build --verbose" + - "cargo test --verbose" +notifications: + email: false + irc: + channels: + - secure: "FiHwNDkLqlzn+fZnn42uZ+GWm59S9OJreUIz9r7+nXrxUBeBcthQlqamJUiuYryVohzqLydBVv6xmT5wgS/LxRnj4f363eBpKejuSktglnf2rl8JjuSXZVgrPMDmrfgkBdC+aMCPzdw2fIHSWmvQMr/t9kGW9cHl0VlLxPAhnAsry+E1Kxrrz4IuOJmyb43VqPf/GO6VCDzTpHiKHKe5Rp7i2IkbGus2GiSD/UMrgUTWmMOFoejl7fWX7SH9kvSrN/SCYldVOYA4nazeZfaHv7mCX6G8U3GGXTHwjAVAluXyYgUCYpsYKC5KGkUJFcLhjaBu5qpmlI0EZd/rsgscOBzqfJ0D/WkahWiKtlQEKZ7UEAhA3SaAhcrSh2kSQFf2GW1T8kfzqlnBtjpqSvCFuOpY5XQcSYEEX7qxT1aiK2UBi9iAKgMnG1SDEfeFERekw0KJPKbwJDMV7NhCg9kYVBHG1hxvFeYqMmnFrjLlRDQQrbDHrP9Avdtg0FScolsFVmT+uatBuRXDcqunssolfnWguyrQ0Z9KGauv0iqkwFwO7jQSA9f87wgsuzqlzstHRxoGGlPtGt4J/+MhyA3lOEXwBa5eotjILI7iykK+ykJ33cOTGcqyXbkWoYRZ6+fS2guI+f2CxxsYWUOK2UgMyYKEwtraC3duVIGtQR+zuvc=" + use_notice: true diff --git a/ruma-api-macros/Cargo.toml b/ruma-api-macros/Cargo.toml new file mode 100644 index 00000000..02b03e52 --- /dev/null +++ b/ruma-api-macros/Cargo.toml @@ -0,0 +1,35 @@ +[package] +authors = ["Jimmy Cuadra "] +categories = ["api-bindings", "web-programming"] +description = "A procedural macro for generating ruma-api Endpoints." +documentation = "https://docs.rs/ruma-api-macros" +homepage = "https://github.com/ruma/ruma-api-macros" +keywords = ["matrix", "chat", "messaging", "ruma"] +license = "MIT" +name = "ruma-api-macros" +readme = "README.md" +repository = "https://github.com/ruma/ruma-api-macros" +version = "0.6.0" +edition = "2018" + +[dependencies] +quote = "0.6.12" +ruma-api = "0.9.0" +proc-macro2 = "0.4.30" + +[dependencies.syn] +version = "0.15.35" +features = ["full"] + +[dev-dependencies] +http = "0.1.17" +serde_json = "1.0.39" +serde_urlencoded = "0.5.5" +url = "1.7.2" + +[dev-dependencies.serde] +version = "1.0.92" +features = ["derive"] + +[lib] +proc-macro = true diff --git a/ruma-api-macros/LICENSE b/ruma-api-macros/LICENSE new file mode 100644 index 00000000..8a75e6ee --- /dev/null +++ b/ruma-api-macros/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2017 Jimmy Cuadra + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/ruma-api-macros/README.md b/ruma-api-macros/README.md new file mode 100644 index 00000000..1024dfe7 --- /dev/null +++ b/ruma-api-macros/README.md @@ -0,0 +1,73 @@ +# ruma-api-macros + +[![Build Status](https://travis-ci.org/ruma/ruma-api-macros.svg?branch=master)](https://travis-ci.org/ruma/ruma-api-macros) + +**ruma-api-macros** provides a procedural macro for easily generating [ruma-api](https://github.com/ruma/ruma-api)-compatible API endpoints. +You define the endpoint's metadata, request fields, and response fields, and the macro generates all the necessary types and implements all the necessary traits. + +## Usage + +Here is an example that shows most of the macro's functionality. + +``` rust +#![feature(proc_macro, try_from)] + +extern crate http; +extern crate ruma_api; +extern crate ruma_api_macros; +extern crate serde; +#[macro_use] extern crate serde_derive; +extern crate serde_json; +extern crate serde_urlencoded; +extern crate url; + +pub mod some_endpoint { + use ruma_api_macros::ruma_api; + + ruma_api! { + metadata { + description: "Does something.", + method: GET, // An `http::Method` constant. No imports required. + name: "some_endpoint", + path: "/_matrix/some/endpoint/:baz", // Variable path components start with a colon. + rate_limited: false, + requires_authentication: false, + } + + request { + // With no attribute on the field, it will be put into the body of the request. + pub foo: String, + + // This value will be put into the "Content-Type" HTTP header. + #[ruma_api(header = CONTENT_TYPE)] + pub content_type: String + + // This value will be put into the query string of the request's URL. + #[ruma_api(query)] + pub bar: String, + + // This value will be inserted into the request's URL in place of the + // ":baz" path component. + #[ruma_api(path)] + pub baz: String, + } + + response { + // This value will be extracted from the "Content-Type" HTTP header. + #[ruma_api(header = CONTENT_TYPE)] + pub content_type: String + + // With no attribute on the field, it will be extracted from the body of the response. + pub value: String, + } + } +} +``` + +## Documentation + +ruma-api-macros has [comprehensive documentation](https://docs.rs/ruma-api-macros) available on docs.rs. + +## License + +[MIT](http://opensource.org/licenses/MIT) diff --git a/ruma-api-macros/src/api/attribute.rs b/ruma-api-macros/src/api/attribute.rs new file mode 100644 index 00000000..70016d12 --- /dev/null +++ b/ruma-api-macros/src/api/attribute.rs @@ -0,0 +1,63 @@ +//! Details of the `#[ruma_api(...)]` attributes. + +use syn::{ + parenthesized, + parse::{Parse, ParseStream}, + Ident, Token, +}; + +/// Like syn::Meta, but only parses ruma_api attributes +pub enum Meta { + /// A single word, like `query` in `#[ruma_api(query)]` + Word(Ident), + /// A name-value pair, like `header = CONTENT_TYPE` in `#[ruma_api(header = CONTENT_TYPE)]` + NameValue(MetaNameValue), +} + +impl Meta { + /// Check if the given attribute is a ruma_api attribute. If it is, parse it, if not, return + /// it unchanged. Panics if the argument is an invalid ruma_api attribute. + pub fn from_attribute(attr: syn::Attribute) -> Result { + match &attr.path { + syn::Path { + leading_colon: None, + segments, + } => { + if segments.len() == 1 && segments[0].ident == "ruma_api" { + Ok(syn::parse2(attr.tts) + .expect("ruma_api! could not parse request field attributes")) + } else { + Err(attr) + } + } + _ => Err(attr), + } + } +} + +/// Like syn::MetaNameValue, but expects an identifier as the value. Also, we don't care about the +/// the span of the equals sign, so we don't have the `eq_token` field from syn::MetaNameValue. +pub struct MetaNameValue { + /// The part left of the equals sign + pub name: Ident, + /// The part right of the equals sign + pub value: Ident, +} + +impl Parse for Meta { + fn parse(input: ParseStream) -> syn::Result { + let content; + let _ = parenthesized!(content in input); + let ident = content.parse()?; + + if content.peek(Token![=]) { + let _ = content.parse::(); + Ok(Meta::NameValue(MetaNameValue { + name: ident, + value: content.parse()?, + })) + } else { + Ok(Meta::Word(ident)) + } + } +} diff --git a/ruma-api-macros/src/api/metadata.rs b/ruma-api-macros/src/api/metadata.rs new file mode 100644 index 00000000..3d7b6a29 --- /dev/null +++ b/ruma-api-macros/src/api/metadata.rs @@ -0,0 +1,111 @@ +//! Details of the `metadata` section of the procedural macro. + +use proc_macro2::Ident; +use syn::{Expr, ExprLit, FieldValue, Lit, LitBool, LitStr, Member}; + +/// The result of processing the `metadata` section of the macro. +pub struct Metadata { + /// The description field. + pub description: LitStr, + /// The method field. + pub method: Ident, + /// The name field. + pub name: LitStr, + /// The path field. + pub path: LitStr, + /// The rate_limited field. + pub rate_limited: LitBool, + /// The description field. + pub requires_authentication: LitBool, +} + +impl From> for Metadata { + fn from(field_values: Vec) -> Self { + let mut description = None; + let mut method = None; + let mut name = None; + let mut path = None; + let mut rate_limited = None; + let mut requires_authentication = None; + + for field_value in field_values { + let identifier = match field_value.member { + Member::Named(identifier) => identifier, + _ => panic!("expected Member::Named"), + }; + + match &identifier.to_string()[..] { + "description" => { + let literal = match field_value.expr { + Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) => s, + _ => panic!("expected string literal"), + }; + description = Some(literal); + } + "method" => { + let expr_path = match field_value.expr { + Expr::Path(expr_path) => expr_path, + _ => panic!("expected Expr::Path"), + }; + let path = expr_path.path; + let mut segments = path.segments.iter(); + let method_name = segments.next().expect("expected non-empty path"); + assert!( + segments.next().is_none(), + "ruma_api! expects a one-component path for `metadata` `method`" + ); + method = Some(method_name.ident.clone()); + } + "name" => { + let literal = match field_value.expr { + Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) => s, + _ => panic!("expected string literal"), + }; + name = Some(literal); + } + "path" => { + let literal = match field_value.expr { + Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) => s, + _ => panic!("expected string literal"), + }; + path = Some(literal); + } + "rate_limited" => { + let literal = match field_value.expr { + Expr::Lit(ExprLit { + lit: Lit::Bool(b), .. + }) => b, + _ => panic!("expected Expr::Lit"), + }; + rate_limited = Some(literal) + } + "requires_authentication" => { + let literal = match field_value.expr { + Expr::Lit(ExprLit { + lit: Lit::Bool(b), .. + }) => b, + _ => panic!("expected Expr::Lit"), + }; + requires_authentication = Some(literal) + } + _ => panic!("ruma_api! metadata included unexpected field"), + } + } + + Self { + description: description.expect("ruma_api! `metadata` is missing `description`"), + method: method.expect("ruma_api! `metadata` is missing `method`"), + name: name.expect("ruma_api! `metadata` is missing `name`"), + path: path.expect("ruma_api! `metadata` is missing `path`"), + rate_limited: rate_limited.expect("ruma_api! `metadata` is missing `rate_limited`"), + requires_authentication: requires_authentication + .expect("ruma_api! `metadata` is missing `requires_authentication`"), + } + } +} diff --git a/ruma-api-macros/src/api/mod.rs b/ruma-api-macros/src/api/mod.rs new file mode 100644 index 00000000..a656becb --- /dev/null +++ b/ruma-api-macros/src/api/mod.rs @@ -0,0 +1,486 @@ +//! Details of the `ruma_api` procedural macro. + +use proc_macro2::{Span, TokenStream}; +use quote::{quote, ToTokens}; +use syn::{ + braced, + parse::{Parse, ParseStream, Result}, + Field, FieldValue, Ident, Meta, Token, +}; + +mod attribute; +mod metadata; +mod request; +mod response; + +use self::{metadata::Metadata, request::Request, response::Response}; + +/// Removes `serde` attributes from struct fields. +pub fn strip_serde_attrs(field: &Field) -> Field { + let mut field = field.clone(); + + field.attrs = field + .attrs + .into_iter() + .filter(|attr| { + let meta = attr + .interpret_meta() + .expect("ruma_api! could not parse field attributes"); + + let meta_list = match meta { + Meta::List(meta_list) => meta_list, + _ => return true, + }; + + if &meta_list.ident.to_string() == "serde" { + return false; + } + + true + }) + .collect(); + + field +} + +/// The result of processing the `ruma_api` macro, ready for output back to source code. +pub struct Api { + /// The `metadata` section of the macro. + metadata: Metadata, + /// The `request` section of the macro. + request: Request, + /// The `response` section of the macro. + response: Response, +} + +impl From for Api { + fn from(raw_api: RawApi) -> Self { + Self { + metadata: raw_api.metadata.into(), + request: raw_api.request.into(), + response: raw_api.response.into(), + } + } +} + +impl ToTokens for Api { + fn to_tokens(&self, tokens: &mut TokenStream) { + let description = &self.metadata.description; + let method = &self.metadata.method; + // We don't (currently) use this literal as a literal in the generated code. Instead we just + // put it into doc comments, for which the span information is irrelevant. So we can work + // with only the literal's value from here on. + let name = &self.metadata.name.value(); + let path = &self.metadata.path; + let rate_limited = &self.metadata.rate_limited; + let requires_authentication = &self.metadata.requires_authentication; + + let request = &self.request; + let request_types = quote! { #request }; + let response = &self.response; + let response_types = quote! { #response }; + + let extract_request_path = if self.request.has_path_fields() { + quote! { + let path_segments: Vec<&str> = request.uri().path()[1..].split('/').collect(); + } + } else { + TokenStream::new() + }; + + let (set_request_path, parse_request_path) = if self.request.has_path_fields() { + let path_str = path.value(); + + assert!(path_str.starts_with('/'), "path needs to start with '/'"); + assert!( + path_str.chars().filter(|c| *c == ':').count() == self.request.path_field_count(), + "number of declared path parameters needs to match amount of placeholders in path" + ); + + let request_path_init_fields = self.request.request_path_init_fields(); + + let path_segments = path_str[1..].split('/'); + let path_segment_push = path_segments.clone().map(|segment| { + let arg = if segment.starts_with(':') { + let path_var = &segment[1..]; + let path_var_ident = Ident::new(path_var, Span::call_site()); + quote!(&request_path.#path_var_ident.to_string()) + } else { + quote!(#segment) + }; + + quote! { + path_segments.push(#arg); + } + }); + + let set_tokens = quote! { + let request_path = RequestPath { + #request_path_init_fields + }; + + // This `unwrap()` can only fail when the url is a + // cannot-be-base url like `mailto:` or `data:`, which is not + // the case for our placeholder url. + let mut path_segments = url.path_segments_mut().unwrap(); + #(#path_segment_push)* + }; + + let path_fields = path_segments + .enumerate() + .filter(|(_, s)| s.starts_with(':')) + .map(|(i, segment)| { + let path_var = &segment[1..]; + let path_var_ident = Ident::new(path_var, Span::call_site()); + let path_field = self + .request + .path_field(path_var) + .expect("expected request to have path field"); + let ty = &path_field.ty; + + quote! { + #path_var_ident: { + let segment = path_segments.get(#i).unwrap().as_bytes(); + let decoded = + ::url::percent_encoding::percent_decode(segment) + .decode_utf8_lossy(); + #ty::deserialize(decoded.into_deserializer()) + .map_err(|e: ::serde_json::error::Error| e)? + } + } + }); + + let parse_tokens = quote! { + #(#path_fields,)* + }; + + (set_tokens, parse_tokens) + } else { + let set_tokens = quote! { + url.set_path(metadata.path); + }; + let parse_tokens = TokenStream::new(); + (set_tokens, parse_tokens) + }; + + let set_request_query = if self.request.has_query_fields() { + let request_query_init_fields = self.request.request_query_init_fields(); + + quote! { + let request_query = RequestQuery { + #request_query_init_fields + }; + + url.set_query(Some(&::serde_urlencoded::to_string(request_query)?)); + } + } else { + TokenStream::new() + }; + + let extract_request_query = if self.request.has_query_fields() { + quote! { + let request_query: RequestQuery = + ::serde_urlencoded::from_str(&request.uri().query().unwrap_or(""))?; + } + } else { + TokenStream::new() + }; + + let parse_request_query = if self.request.has_query_fields() { + self.request.request_init_query_fields() + } else { + TokenStream::new() + }; + + let add_headers_to_request = if self.request.has_header_fields() { + let add_headers = self.request.add_headers_to_request(); + quote! { + let headers = http_request.headers_mut(); + #add_headers + } + } else { + TokenStream::new() + }; + + let extract_request_headers = if self.request.has_header_fields() { + quote! { + let headers = request.headers(); + } + } else { + TokenStream::new() + }; + + let parse_request_headers = if self.request.has_header_fields() { + self.request.parse_headers_from_request() + } else { + TokenStream::new() + }; + + let create_http_request = if let Some(field) = self.request.newtype_body_field() { + let field_name = field + .ident + .as_ref() + .expect("expected field to have an identifier"); + + quote! { + let request_body = RequestBody(request.#field_name); + + let mut http_request = ::http::Request::new(::serde_json::to_vec(&request_body)?); + } + } else if self.request.has_body_fields() { + let request_body_init_fields = self.request.request_body_init_fields(); + + quote! { + let request_body = RequestBody { + #request_body_init_fields + }; + + let mut http_request = ::http::Request::new(::serde_json::to_vec(&request_body)?); + } + } else { + quote! { + let mut http_request = ::http::Request::new(Vec::new()); + } + }; + + let extract_request_body = if let Some(field) = self.request.newtype_body_field() { + let ty = &field.ty; + quote! { + let request_body: #ty = + ::serde_json::from_slice(request.body().as_slice())?; + } + } else if self.request.has_body_fields() { + quote! { + let request_body: RequestBody = + ::serde_json::from_slice(request.body().as_slice())?; + } + } else { + TokenStream::new() + }; + + let parse_request_body = if let Some(field) = self.request.newtype_body_field() { + let field_name = field + .ident + .as_ref() + .expect("expected field to have an identifier"); + + quote! { + #field_name: request_body, + } + } else if self.request.has_body_fields() { + self.request.request_init_body_fields() + } else { + TokenStream::new() + }; + + let try_deserialize_response_body = if let Some(field) = self.response.newtype_body_field() + { + let field_type = &field.ty; + + quote! { + ::serde_json::from_slice::<#field_type>(http_response.into_body().as_slice())? + } + } else if self.response.has_body_fields() { + quote! { + ::serde_json::from_slice::(http_response.into_body().as_slice())? + } + } else { + quote! { + () + } + }; + + let extract_response_headers = if self.response.has_header_fields() { + quote! { + let mut headers = http_response.headers().clone(); + } + } else { + TokenStream::new() + }; + + let response_init_fields = if self.response.has_fields() { + self.response.init_fields() + } else { + TokenStream::new() + }; + + let serialize_response_headers = self.response.apply_header_fields(); + + let try_serialize_response_body = if self.response.has_body() { + let body = self.response.to_body(); + quote! { + ::serde_json::to_vec(&#body)? + } + } else { + quote! { + "{}".as_bytes().to_vec() + } + }; + + let endpoint_doc = format!("The `{}` API endpoint.\n\n{}", name, description.value()); + let request_doc = format!("Data for a request to the `{}` API endpoint.", name); + let response_doc = format!("Data in the response from the `{}` API endpoint.", name); + + let api = quote! { + use ::ruma_api::Endpoint as _; + use ::serde::Deserialize as _; + use ::serde::de::{Error as _, IntoDeserializer as _}; + + use ::std::convert::{TryInto as _}; + + #[doc = #endpoint_doc] + #[derive(Debug)] + pub struct Endpoint; + + #[doc = #request_doc] + #request_types + + impl ::std::convert::TryFrom<::http::Request>> for Request { + type Error = ::ruma_api::Error; + + #[allow(unused_variables)] + fn try_from(request: ::http::Request>) -> Result { + #extract_request_path + #extract_request_query + #extract_request_headers + #extract_request_body + + Ok(Request { + #parse_request_path + #parse_request_query + #parse_request_headers + #parse_request_body + }) + } + } + + impl ::std::convert::TryFrom for ::http::Request> { + type Error = ::ruma_api::Error; + + #[allow(unused_mut, unused_variables)] + fn try_from(request: Request) -> Result { + let metadata = Endpoint::METADATA; + + // Use dummy homeserver url which has to be overwritten in + // the calling code. Previously (with http::Uri) this was + // not required, but Url::parse only accepts absolute urls. + let mut url = ::url::Url::parse("http://invalid-host-please-change/").unwrap(); + + { #set_request_path } + { #set_request_query } + + #create_http_request + + *http_request.method_mut() = ::http::Method::#method; + *http_request.uri_mut() = url.into_string().parse().unwrap(); + + { #add_headers_to_request } + + Ok(http_request) + } + } + + #[doc = #response_doc] + #response_types + + impl ::std::convert::TryFrom for ::http::Response> { + type Error = ::ruma_api::Error; + + #[allow(unused_variables)] + fn try_from(response: Response) -> Result { + let response = ::http::Response::builder() + .header(::http::header::CONTENT_TYPE, "application/json") + #serialize_response_headers + .body(#try_serialize_response_body) + .unwrap(); + Ok(response) + } + } + + impl ::std::convert::TryFrom<::http::Response>> for Response { + type Error = ::ruma_api::Error; + + #[allow(unused_variables)] + fn try_from(http_response: ::http::Response>) -> Result { + if http_response.status().is_success() { + #extract_response_headers + + let response_body = #try_deserialize_response_body; + Ok(Response { + #response_init_fields + }) + } else { + Err(http_response.status().clone().into()) + } + } + } + + impl ::ruma_api::Endpoint for Endpoint { + type Request = Request; + type Response = Response; + + /// Metadata for the `#name` endpoint. + const METADATA: ::ruma_api::Metadata = ::ruma_api::Metadata { + description: #description, + method: ::http::Method::#method, + name: #name, + path: #path, + rate_limited: #rate_limited, + requires_authentication: #requires_authentication, + }; + } + }; + + api.to_tokens(tokens); + } +} + +/// Custom keyword macros for syn. +mod kw { + use syn::custom_keyword; + + custom_keyword!(metadata); + custom_keyword!(request); + custom_keyword!(response); +} + +/// The entire `ruma_api!` macro structure directly as it appears in the source code.. +pub struct RawApi { + /// The `metadata` section of the macro. + pub metadata: Vec, + /// The `request` section of the macro. + pub request: Vec, + /// The `response` section of the macro. + pub response: Vec, +} + +impl Parse for RawApi { + fn parse(input: ParseStream<'_>) -> Result { + input.parse::()?; + let metadata; + braced!(metadata in input); + + input.parse::()?; + let request; + braced!(request in input); + + input.parse::()?; + let response; + braced!(response in input); + + Ok(Self { + metadata: metadata + .parse_terminated::(FieldValue::parse)? + .into_iter() + .collect(), + request: request + .parse_terminated::(Field::parse_named)? + .into_iter() + .collect(), + response: response + .parse_terminated::(Field::parse_named)? + .into_iter() + .collect(), + }) + } +} diff --git a/ruma-api-macros/src/api/request.rs b/ruma-api-macros/src/api/request.rs new file mode 100644 index 00000000..865f040e --- /dev/null +++ b/ruma-api-macros/src/api/request.rs @@ -0,0 +1,442 @@ +//! Details of the `request` section of the procedural macro. + +use proc_macro2::TokenStream; +use quote::{quote, quote_spanned, ToTokens}; +use syn::{spanned::Spanned, Field, Ident}; + +use crate::api::{ + attribute::{Meta, MetaNameValue}, + strip_serde_attrs, +}; + +/// The result of processing the `request` section of the macro. +pub struct Request { + /// The fields of the request. + fields: Vec, +} + +impl Request { + /// Produces code to add necessary HTTP headers to an `http::Request`. + pub fn add_headers_to_request(&self) -> TokenStream { + let append_stmts = self.header_fields().map(|request_field| { + let (field, header_name) = match request_field { + RequestField::Header(field, header_name) => (field, header_name), + _ => panic!("expected request field to be header variant"), + }; + + let field_name = &field.ident; + + quote! { + headers.append( + ::http::header::#header_name, + ::http::header::HeaderValue::from_str(request.#field_name.as_ref()) + .expect("failed to convert value into HeaderValue"), + ); + } + }); + + quote! { + #(#append_stmts)* + } + } + + /// Produces code to extract fields from the HTTP headers in an `http::Request`. + pub fn parse_headers_from_request(&self) -> TokenStream { + let fields = self.header_fields().map(|request_field| { + let (field, header_name) = match request_field { + RequestField::Header(field, header_name) => (field, header_name), + _ => panic!("expected request field to be header variant"), + }; + + let field_name = &field.ident; + let header_name_string = header_name.to_string(); + + quote! { + #field_name: headers.get(::http::header::#header_name) + .and_then(|v| v.to_str().ok()) + .ok_or(::serde_json::Error::missing_field(#header_name_string))? + .to_owned() + } + }); + + quote! { + #(#fields,)* + } + } + + /// Whether or not this request has any data in the HTTP body. + pub fn has_body_fields(&self) -> bool { + self.fields.iter().any(|field| field.is_body()) + } + + /// Whether or not this request has any data in HTTP headers. + pub fn has_header_fields(&self) -> bool { + self.fields.iter().any(|field| field.is_header()) + } + /// Whether or not this request has any data in the URL path. + pub fn has_path_fields(&self) -> bool { + self.fields.iter().any(|field| field.is_path()) + } + + /// Whether or not this request has any data in the query string. + pub fn has_query_fields(&self) -> bool { + self.fields.iter().any(|field| field.is_query()) + } + + /// Produces an iterator over all the header fields. + pub fn header_fields(&self) -> impl Iterator { + self.fields.iter().filter(|field| field.is_header()) + } + + /// Gets the number of path fields. + pub fn path_field_count(&self) -> usize { + self.fields.iter().filter(|field| field.is_path()).count() + } + + /// Gets the path field with the given name. + pub fn path_field(&self, name: &str) -> Option<&Field> { + self.fields + .iter() + .flat_map(|f| f.field_of_kind(RequestFieldKind::Path)) + .find(|field| { + field + .ident + .as_ref() + .expect("expected field to have an identifier") + == name + }) + } + + /// Returns the body field. + pub fn newtype_body_field(&self) -> Option<&Field> { + for request_field in self.fields.iter() { + match *request_field { + RequestField::NewtypeBody(ref field) => { + return Some(field); + } + _ => continue, + } + } + + None + } + + /// Produces code for a struct initializer for body fields on a variable named `request`. + pub fn request_body_init_fields(&self) -> TokenStream { + self.struct_init_fields(RequestFieldKind::Body, quote!(request)) + } + + /// Produces code for a struct initializer for path fields on a variable named `request`. + pub fn request_path_init_fields(&self) -> TokenStream { + self.struct_init_fields(RequestFieldKind::Path, quote!(request)) + } + + /// Produces code for a struct initializer for query string fields on a variable named `request`. + pub fn request_query_init_fields(&self) -> TokenStream { + self.struct_init_fields(RequestFieldKind::Query, quote!(request)) + } + + /// Produces code for a struct initializer for body fields on a variable named `request_body`. + pub fn request_init_body_fields(&self) -> TokenStream { + self.struct_init_fields(RequestFieldKind::Body, quote!(request_body)) + } + + /// Produces code for a struct initializer for query string fields on a variable named + /// `request_query`. + pub fn request_init_query_fields(&self) -> TokenStream { + self.struct_init_fields(RequestFieldKind::Query, quote!(request_query)) + } + + /// Produces code for a struct initializer for the given field kind to be accessed through the + /// given variable name. + fn struct_init_fields( + &self, + request_field_kind: RequestFieldKind, + src: TokenStream, + ) -> TokenStream { + let fields = self.fields.iter().filter_map(|f| { + f.field_of_kind(request_field_kind).map(|field| { + let field_name = field + .ident + .as_ref() + .expect("expected field to have an identifier"); + let span = field.span(); + + quote_spanned! {span=> + #field_name: #src.#field_name + } + }) + }); + + quote! { + #(#fields,)* + } + } +} + +impl From> for Request { + fn from(fields: Vec) -> Self { + let mut has_newtype_body = false; + + let fields = fields.into_iter().map(|mut field| { + let mut field_kind = RequestFieldKind::Body; + let mut header = None; + + field.attrs = field.attrs.into_iter().filter_map(|attr| { + let meta = match Meta::from_attribute(attr) { + Ok(meta) => meta, + Err(attr) => return Some(attr), + }; + + match meta { + Meta::Word(ident) => { + match &ident.to_string()[..] { + "body" => { + has_newtype_body = true; + field_kind = RequestFieldKind::NewtypeBody; + } + "path" => field_kind = RequestFieldKind::Path, + "query" => field_kind = RequestFieldKind::Query, + _ => panic!("ruma_api! single-word attribute on requests must be: body, path, or query"), + } + } + Meta::NameValue(MetaNameValue { name, value }) => { + assert!( + name == "header", + "ruma_api! name/value pair attribute on requests must be: header" + ); + + header = Some(value); + field_kind = RequestFieldKind::Header; + } + } + + None + }).collect(); + + if field_kind == RequestFieldKind::Body { + assert!( + !has_newtype_body, + "ruma_api! requests cannot have both normal body fields and a newtype body field" + ); + } + + RequestField::new(field_kind, field, header) + }).collect(); + + Self { fields } + } +} + +impl ToTokens for Request { + fn to_tokens(&self, tokens: &mut TokenStream) { + let request_struct_header = quote! { + #[derive(Debug, Clone)] + pub struct Request + }; + + let request_struct_body = if self.fields.is_empty() { + quote!(;) + } else { + let fields = self.fields.iter().map(|request_field| { + let field = request_field.field(); + let span = field.span(); + + let stripped_field = strip_serde_attrs(field); + + quote_spanned!(span=> #stripped_field) + }); + + quote! { + { + #(#fields),* + } + } + }; + + let request_body_struct = if let Some(newtype_body_field) = self.newtype_body_field() { + let field = newtype_body_field.clone(); + let ty = &field.ty; + let span = field.span(); + + quote_spanned! {span=> + /// Data in the request body. + #[derive(Debug, Deserialize, Serialize)] + struct RequestBody(#ty); + } + } else if self.has_body_fields() { + let fields = self + .fields + .iter() + .filter_map(|request_field| match *request_field { + RequestField::Body(ref field) => { + let span = field.span(); + Some(quote_spanned!(span=> #field)) + } + _ => None, + }); + + quote! { + /// Data in the request body. + #[derive(Debug, Deserialize, Serialize)] + struct RequestBody { + #(#fields),* + } + } + } else { + TokenStream::new() + }; + + let request_path_struct = if self.has_path_fields() { + let fields = self + .fields + .iter() + .filter_map(|request_field| match *request_field { + RequestField::Path(ref field) => { + let span = field.span(); + + Some(quote_spanned!(span=> #field)) + } + _ => None, + }); + + quote! { + /// Data in the request path. + #[derive(Debug, Deserialize, Serialize)] + struct RequestPath { + #(#fields),* + } + } + } else { + TokenStream::new() + }; + + let request_query_struct = if self.has_query_fields() { + let fields = self + .fields + .iter() + .filter_map(|request_field| match *request_field { + RequestField::Query(ref field) => { + let span = field.span(); + Some(quote_spanned!(span=> #field)) + } + _ => None, + }); + + quote! { + /// Data in the request's query string. + #[derive(Debug, Deserialize, Serialize)] + struct RequestQuery { + #(#fields),* + } + } + } else { + TokenStream::new() + }; + + let request = quote! { + #request_struct_header + #request_struct_body + #request_body_struct + #request_path_struct + #request_query_struct + }; + + request.to_tokens(tokens); + } +} + +/// The types of fields that a request can have. +pub enum RequestField { + /// JSON data in the body of the request. + Body(Field), + /// Data in an HTTP header. + Header(Field, Ident), + /// A specific data type in the body of the request. + NewtypeBody(Field), + /// Data that appears in the URL path. + Path(Field), + /// Data that appears in the query string. + Query(Field), +} + +impl RequestField { + /// Creates a new `RequestField`. + fn new(kind: RequestFieldKind, field: Field, header: Option) -> Self { + match kind { + RequestFieldKind::Body => RequestField::Body(field), + RequestFieldKind::Header => { + RequestField::Header(field, header.expect("missing header name")) + } + RequestFieldKind::NewtypeBody => RequestField::NewtypeBody(field), + RequestFieldKind::Path => RequestField::Path(field), + RequestFieldKind::Query => RequestField::Query(field), + } + } + + /// Gets the kind of the request field. + fn kind(&self) -> RequestFieldKind { + match *self { + RequestField::Body(..) => RequestFieldKind::Body, + RequestField::Header(..) => RequestFieldKind::Header, + RequestField::NewtypeBody(..) => RequestFieldKind::NewtypeBody, + RequestField::Path(..) => RequestFieldKind::Path, + RequestField::Query(..) => RequestFieldKind::Query, + } + } + + /// Whether or not this request field is a body kind. + fn is_body(&self) -> bool { + self.kind() == RequestFieldKind::Body + } + + /// Whether or not this request field is a header kind. + fn is_header(&self) -> bool { + self.kind() == RequestFieldKind::Header + } + + /// Whether or not this request field is a path kind. + fn is_path(&self) -> bool { + self.kind() == RequestFieldKind::Path + } + + /// Whether or not this request field is a query string kind. + fn is_query(&self) -> bool { + self.kind() == RequestFieldKind::Query + } + + /// Gets the inner `Field` value. + fn field(&self) -> &Field { + match *self { + RequestField::Body(ref field) + | RequestField::Header(ref field, _) + | RequestField::NewtypeBody(ref field) + | RequestField::Path(ref field) + | RequestField::Query(ref field) => field, + } + } + + /// Gets the inner `Field` value if it's of the provided kind. + fn field_of_kind(&self, kind: RequestFieldKind) -> Option<&Field> { + if self.kind() == kind { + Some(self.field()) + } else { + None + } + } +} + +/// The types of fields that a request can have, without their values. +#[derive(Clone, Copy, PartialEq, Eq)] +enum RequestFieldKind { + /// See the similarly named variant of `RequestField`. + Body, + /// See the similarly named variant of `RequestField`. + Header, + /// See the similarly named variant of `RequestField`. + NewtypeBody, + /// See the similarly named variant of `RequestField`. + Path, + /// See the similarly named variant of `RequestField`. + Query, +} diff --git a/ruma-api-macros/src/api/response.rs b/ruma-api-macros/src/api/response.rs new file mode 100644 index 00000000..32263702 --- /dev/null +++ b/ruma-api-macros/src/api/response.rs @@ -0,0 +1,331 @@ +//! Details of the `response` section of the procedural macro. + +use proc_macro2::TokenStream; +use quote::{quote, quote_spanned, ToTokens}; +use syn::{spanned::Spanned, Field, Ident}; + +use crate::api::{ + attribute::{Meta, MetaNameValue}, + strip_serde_attrs, +}; + +/// The result of processing the `request` section of the macro. +pub struct Response { + /// The fields of the response. + fields: Vec, +} + +impl Response { + /// Whether or not this response has any data in the HTTP body. + pub fn has_body_fields(&self) -> bool { + self.fields.iter().any(|field| field.is_body()) + } + + /// Whether or not this response has any fields. + pub fn has_fields(&self) -> bool { + !self.fields.is_empty() + } + + /// Whether or not this response has any data in HTTP headers. + pub fn has_header_fields(&self) -> bool { + self.fields.iter().any(|field| field.is_header()) + } + + /// Whether or not this response has any data in the HTTP body. + pub fn has_body(&self) -> bool { + self.fields.iter().any(|field| !field.is_header()) + } + + /// Produces code for a request struct initializer. + pub fn init_fields(&self) -> TokenStream { + let fields = self + .fields + .iter() + .map(|response_field| match *response_field { + ResponseField::Body(ref field) => { + let field_name = field + .ident + .clone() + .expect("expected field to have an identifier"); + let span = field.span(); + + quote_spanned! {span=> + #field_name: response_body.#field_name + } + } + ResponseField::Header(ref field, ref header_name) => { + let field_name = field + .ident + .clone() + .expect("expected field to have an identifier"); + let span = field.span(); + + quote_spanned! {span=> + #field_name: headers.remove(::http::header::#header_name) + .expect("response missing expected header") + .to_str() + .expect("failed to convert HeaderValue to str") + .to_owned() + } + } + ResponseField::NewtypeBody(ref field) => { + let field_name = field + .ident + .clone() + .expect("expected field to have an identifier"); + let span = field.span(); + + quote_spanned! {span=> + #field_name: response_body + } + } + }); + + quote! { + #(#fields,)* + } + } + + /// Produces code to add necessary HTTP headers to an `http::Response`. + pub fn apply_header_fields(&self) -> TokenStream { + let header_calls = self.fields.iter().filter_map(|response_field| { + if let ResponseField::Header(ref field, ref header_name) = *response_field { + let field_name = field + .ident + .as_ref() + .expect("expected field to have an identifier"); + let span = field.span(); + + Some(quote_spanned! {span=> + .header(::http::header::#header_name, response.#field_name) + }) + } else { + None + } + }); + + quote! { + #(#header_calls)* + } + } + + /// Produces code to initialize the struct that will be used to create the response body. + pub fn to_body(&self) -> TokenStream { + if let Some(field) = self.newtype_body_field() { + let field_name = field + .ident + .as_ref() + .expect("expected field to have an identifier"); + let span = field.span(); + quote_spanned!(span=> response.#field_name) + } else { + let fields = self.fields.iter().filter_map(|response_field| { + if let ResponseField::Body(ref field) = *response_field { + let field_name = field + .ident + .as_ref() + .expect("expected field to have an identifier"); + let span = field.span(); + + Some(quote_spanned! {span=> + #field_name: response.#field_name + }) + } else { + None + } + }); + + quote! { + ResponseBody { + #(#fields),* + } + } + } + } + + /// Gets the newtype body field, if this request has one. + pub fn newtype_body_field(&self) -> Option<&Field> { + for response_field in self.fields.iter() { + match *response_field { + ResponseField::NewtypeBody(ref field) => { + return Some(field); + } + _ => continue, + } + } + + None + } +} + +impl From> for Response { + fn from(fields: Vec) -> Self { + let mut has_newtype_body = false; + + let fields = fields.into_iter().map(|mut field| { + let mut field_kind = ResponseFieldKind::Body; + let mut header = None; + + field.attrs = field.attrs.into_iter().filter_map(|attr| { + let meta = match Meta::from_attribute(attr) { + Ok(meta) => meta, + Err(attr) => return Some(attr), + }; + + match meta { + Meta::Word(ident) => { + assert!( + ident == "body", + "ruma_api! single-word attribute on responses must be: body" + ); + + has_newtype_body = true; + field_kind = ResponseFieldKind::NewtypeBody; + } + Meta::NameValue(MetaNameValue { name, value }) => { + assert!( + name == "header", + "ruma_api! name/value pair attribute on requests must be: header" + ); + + header = Some(value); + field_kind = ResponseFieldKind::Header; + } + } + + None + }).collect(); + + match field_kind { + ResponseFieldKind::Body => { + assert!( + !has_newtype_body, + "ruma_api! responses cannot have both normal body fields and a newtype body field" + ); + + ResponseField::Body(field) + } + ResponseFieldKind::Header => ResponseField::Header(field, header.expect("missing header name")), + ResponseFieldKind::NewtypeBody => ResponseField::NewtypeBody(field), + } + }).collect(); + + Self { fields } + } +} + +impl ToTokens for Response { + fn to_tokens(&self, tokens: &mut TokenStream) { + let response_struct_header = quote! { + #[derive(Debug, Clone)] + pub struct Response + }; + + let response_struct_body = if self.fields.is_empty() { + quote!(;) + } else { + let fields = self.fields.iter().map(|response_field| { + let field = response_field.field(); + let span = field.span(); + + let stripped_field = strip_serde_attrs(field); + + quote_spanned!(span=> #stripped_field) + }); + + quote! { + { + #(#fields),* + } + } + }; + + let response_body_struct = if let Some(newtype_body_field) = self.newtype_body_field() { + let field = newtype_body_field.clone(); + let ty = &field.ty; + let span = field.span(); + + quote_spanned! {span=> + /// Data in the response body. + #[derive(Debug, Deserialize, Serialize)] + struct ResponseBody(#ty); + } + } else if self.has_body_fields() { + let fields = self + .fields + .iter() + .filter_map(|response_field| match *response_field { + ResponseField::Body(ref field) => { + let span = field.span(); + Some(quote_spanned!(span=> #field)) + } + _ => None, + }); + + quote! { + /// Data in the response body. + #[derive(Debug, Deserialize, Serialize)] + struct ResponseBody { + #(#fields),* + } + } + } else { + TokenStream::new() + }; + + let response = quote! { + #response_struct_header + #response_struct_body + #response_body_struct + }; + + response.to_tokens(tokens); + } +} + +/// The types of fields that a response can have. +pub enum ResponseField { + /// JSON data in the body of the response. + Body(Field), + /// Data in an HTTP header. + Header(Field, Ident), + /// A specific data type in the body of the response. + NewtypeBody(Field), +} + +impl ResponseField { + /// Gets the inner `Field` value. + fn field(&self) -> &Field { + match *self { + ResponseField::Body(ref field) + | ResponseField::Header(ref field, _) + | ResponseField::NewtypeBody(ref field) => field, + } + } + + /// Whether or not this response field is a body kind. + fn is_body(&self) -> bool { + match *self { + ResponseField::Body(_) => true, + _ => false, + } + } + + /// Whether or not this response field is a header kind. + fn is_header(&self) -> bool { + match *self { + ResponseField::Header(..) => true, + _ => false, + } + } +} + +/// The types of fields that a response can have, without their values. +enum ResponseFieldKind { + /// See the similarly named variant of `ResponseField`. + Body, + /// See the similarly named variant of `ResponseField`. + Header, + /// See the similarly named variant of `ResponseField`. + NewtypeBody, +} diff --git a/ruma-api-macros/src/lib.rs b/ruma-api-macros/src/lib.rs new file mode 100644 index 00000000..909463c5 --- /dev/null +++ b/ruma-api-macros/src/lib.rs @@ -0,0 +1,219 @@ +//! Crate `ruma-api-macros` provides a procedural macro for easily generating +//! [ruma-api](https://github.com/ruma/ruma-api)-compatible endpoints. +//! +//! See the documentation for the `ruma_api!` macro for usage details. + +#![deny( + missing_copy_implementations, + missing_debug_implementations, + // missing_docs, # Uncomment when https://github.com/rust-lang/rust/pull/60562 is released. + warnings +)] +#![warn( + clippy::empty_line_after_outer_attr, + clippy::expl_impl_clone_on_copy, + clippy::if_not_else, + clippy::items_after_statements, + clippy::match_same_arms, + clippy::mem_forget, + clippy::missing_docs_in_private_items, + clippy::multiple_inherent_impl, + clippy::mut_mut, + clippy::needless_borrow, + clippy::needless_continue, + clippy::single_match_else, + clippy::unicode_not_nfc, + clippy::use_self, + clippy::used_underscore_binding, + clippy::wrong_pub_self_convention, + clippy::wrong_self_convention +)] +#![recursion_limit = "256"] + +extern crate proc_macro; + +use proc_macro::TokenStream; +use quote::ToTokens; + +use crate::api::{Api, RawApi}; + +mod api; + +/// Generates a `ruma_api::Endpoint` from a concise definition. +/// +/// The macro expects the following structure as input: +/// +/// ```text +/// ruma_api! { +/// metadata { +/// description: &'static str +/// method: http::Method, +/// name: &'static str, +/// path: &'static str, +/// rate_limited: bool, +/// requires_authentication: bool, +/// } +/// +/// request { +/// // Struct fields for each piece of data required +/// // to make a request to this API endpoint. +/// } +/// +/// response { +/// // Struct fields for each piece of data expected +/// // in the response from this API endpoint. +/// } +/// } +/// # } +/// ``` +/// +/// This will generate a `ruma_api::Metadata` value to be used for the `ruma_api::Endpoint`'s +/// associated constant, single `Request` and `Response` structs, and the necessary trait +/// implementations to convert the request into a `http::Request` and to create a response from a +/// `http::Response` and vice versa. +/// +/// The details of each of the three sections of the macros are documented below. +/// +/// ## Metadata +/// +/// * `description`: A short description of what the endpoint does. +/// * `method`: The HTTP method used for requests to the endpoint. +/// It's not necessary to import `http::Method`'s associated constants. Just write +/// the value as if it was imported, e.g. `GET`. +/// * `name`: A unique name for the endpoint. +/// Generally this will be the same as the containing module. +/// * `path`: The path component of the URL for the endpoint, e.g. "/foo/bar". +/// Components of the path that are parameterized can indicate a varible by using a Rust +/// identifier prefixed with a colon, e.g. `/foo/:some_parameter`. +/// A corresponding query string parameter will be expected in the request struct (see below +/// for details). +/// * `rate_limited`: Whether or not the endpoint enforces rate limiting on requests. +/// * `requires_authentication`: Whether or not the endpoint requires a valid access token. +/// +/// ## Request +/// +/// The request block contains normal struct field definitions. +/// Doc comments and attributes are allowed as normal. +/// There are also a few special attributes available to control how the struct is converted into a +/// `http::Request`: +/// +/// * `#[ruma_api(header = "HEADER_NAME")]`: Fields with this attribute will be treated as HTTP +/// headers on the request. +/// The value must implement `AsRef`. +/// Generally this is a `String`. +/// The attribute value shown above as `HEADER_NAME` must be a header name constant from +/// `http::header`, e.g. `CONTENT_TYPE`. +/// * `#[ruma_api(path)]`: Fields with this attribute will be inserted into the matching path +/// component of the request URL. +/// * `#[ruma_api(query)]`: Fields with this attribute will be inserting into the URL's query +/// string. +/// +/// Any field that does not include one of these attributes will be part of the request's JSON +/// body. +/// +/// ## Response +/// +/// Like the request block, the response block consists of normal struct field definitions. +/// Doc comments and attributes are allowed as normal. +/// There is also a special attribute available to control how the struct is created from a +/// `http::Request`: +/// +/// * `#[ruma_api(header = "HEADER_NAME")]`: Fields with this attribute will be treated as HTTP +/// headers on the response. +/// The value must implement `AsRef`. +/// Generally this is a `String`. +/// The attribute value shown above as `HEADER_NAME` must be a header name constant from +/// `http::header`, e.g. `CONTENT_TYPE`. +/// +/// Any field that does not include the above attribute will be expected in the response's JSON +/// body. +/// +/// ## Newtype bodies +/// +/// Both the request and response block also support "newtype bodies" by using the +/// `#[ruma_api(body)]` attribute on a field. If present on a field, the entire request or response +/// body will be treated as the value of the field. This allows you to treat the entire request or +/// response body as a specific type, rather than a JSON object with named fields. Only one field in +/// each struct can be marked with this attribute. It is an error to have a newtype body field and +/// normal body fields within the same struct. +/// +/// # Examples +/// +/// ```rust,no_run +/// # fn main() { +/// pub mod some_endpoint { +/// use ruma_api_macros::ruma_api; +/// use serde::{Deserialize, Serialize}; +/// +/// ruma_api! { +/// metadata { +/// description: "Does something.", +/// method: GET, +/// name: "some_endpoint", +/// path: "/_matrix/some/endpoint/:baz", +/// rate_limited: false, +/// requires_authentication: false, +/// } +/// +/// request { +/// pub foo: String, +/// +/// #[ruma_api(header = CONTENT_TYPE)] +/// pub content_type: String, +/// +/// #[ruma_api(query)] +/// pub bar: String, +/// +/// #[ruma_api(path)] +/// pub baz: String, +/// } +/// +/// response { +/// #[ruma_api(header = CONTENT_TYPE)] +/// pub content_type: String, +/// +/// pub value: String, +/// } +/// } +/// } +/// +/// pub mod newtype_body_endpoint { +/// use ruma_api_macros::ruma_api; +/// use serde::{Deserialize, Serialize}; +/// +/// #[derive(Clone, Debug, Deserialize, Serialize)] +/// pub struct MyCustomType { +/// pub foo: String, +/// } +/// +/// ruma_api! { +/// metadata { +/// description: "Does something.", +/// method: GET, +/// name: "newtype_body_endpoint", +/// path: "/_matrix/some/newtype/body/endpoint", +/// rate_limited: false, +/// requires_authentication: false, +/// } +/// +/// request { +/// #[ruma_api(body)] +/// pub file: Vec, +/// } +/// +/// response { +/// #[ruma_api(body)] +/// pub my_custom_type: MyCustomType, +/// } +/// } +/// } +/// # } +/// ``` +#[proc_macro] +pub fn ruma_api(input: TokenStream) -> TokenStream { + let raw_api = syn::parse_macro_input!(input as RawApi); + + let api = Api::from(raw_api); + + api.into_token_stream().into() +} diff --git a/ruma-api-macros/tests/ruma_api_macros.rs b/ruma-api-macros/tests/ruma_api_macros.rs new file mode 100644 index 00000000..292f1c7a --- /dev/null +++ b/ruma-api-macros/tests/ruma_api_macros.rs @@ -0,0 +1,42 @@ +pub mod some_endpoint { + use ruma_api_macros::ruma_api; + use serde::{Deserialize, Serialize}; + + ruma_api! { + metadata { + description: "Does something.", + method: GET, // An `http::Method` constant. No imports required. + name: "some_endpoint", + path: "/_matrix/some/endpoint/:baz", + rate_limited: false, + requires_authentication: false, + } + + request { + // With no attribute on the field, it will be put into the body of the request. + pub foo: String, + + // This value will be put into the "Content-Type" HTTP header. + #[ruma_api(header = CONTENT_TYPE)] + pub content_type: String, + + // This value will be put into the query string of the request's URL. + #[ruma_api(query)] + pub bar: String, + + // This value will be inserted into the request's URL in place of the + // ":baz" path component. + #[ruma_api(path)] + pub baz: String, + } + + response { + // This value will be extracted from the "Content-Type" HTTP header. + #[ruma_api(header = CONTENT_TYPE)] + pub content_type: String, + + // With no attribute on the field, it will be extracted from the body of the response. + pub value: String, + } + } +}