From ce6515b68ad69ecc3e18905cbc88c0e5ffb58136 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 10 Aug 2021 16:44:53 -0700 Subject: [PATCH] Initial commit --- .gitignore | 3 + Cargo.toml | 14 ++ LICENSE | 21 +++ README.md | 1 + rustify_derive/Cargo.toml | 16 +++ rustify_derive/src/lib.rs | 269 ++++++++++++++++++++++++++++++++++++++ src/client.rs | 58 ++++++++ src/clients.rs | 1 + src/clients/reqwest.rs | 109 +++++++++++++++ src/endpoint.rs | 37 ++++++ src/enums.rs | 8 ++ src/errors.rs | 37 ++++++ src/lib.rs | 13 ++ 13 files changed, 587 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 rustify_derive/Cargo.toml create mode 100644 rustify_derive/src/lib.rs create mode 100644 src/client.rs create mode 100644 src/clients.rs create mode 100644 src/clients/reqwest.rs create mode 100644 src/endpoint.rs create mode 100644 src/enums.rs create mode 100644 src/errors.rs create mode 100644 src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfb86b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +/rustify_derive/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..419ae75 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "rustify" +version = "0.1.0" +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +reqwest = { version = "0.11.4", features = ["blocking", "json"] } +rustify_derive = { path = "rustify_derive" } +serde = { version = "1.0.127", features = ["derive"] } +serde_json = "1.0.66" +thiserror = "1.0.26" +url = "2.2.2" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7375c4a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Joshua Gilman + +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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0dc5604 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +A Rust crate which provides an abstraction layer over HTTP REST endpoints \ No newline at end of file diff --git a/rustify_derive/Cargo.toml b/rustify_derive/Cargo.toml new file mode 100644 index 0000000..eec4515 --- /dev/null +++ b/rustify_derive/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "rustify_derive" +version = "0.1.0" +edition = "2018" + +[lib] +proc-macro = true + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +syn = "1.0" +quote = "1.0" +synstructure = "0.12.5" +proc-macro2 = "1.0.28" +regex = "1.5.4" diff --git a/rustify_derive/src/lib.rs b/rustify_derive/src/lib.rs new file mode 100644 index 0000000..ac2861f --- /dev/null +++ b/rustify_derive/src/lib.rs @@ -0,0 +1,269 @@ +#[macro_use] +extern crate synstructure; + +extern crate proc_macro; + +use std::ops::Deref; + +use proc_macro2::Span; +use quote::{quote, ToTokens}; +use regex::Regex; +use syn::{self, spanned::Spanned}; + +const MACRO_NAME: &str = "Endpoint"; +const ATTR_NAME: &str = "endpoint"; +const DATA_ATTR_NAME: &str = "data"; + +#[derive(Debug)] +struct Error(proc_macro2::TokenStream); + +impl Error { + fn new(span: Span, message: &str) -> Error { + Error(quote_spanned! { span => + compile_error!(#message); + }) + } + + fn into_tokens(self) -> proc_macro2::TokenStream { + self.0 + } +} + +impl From for Error { + fn from(e: syn::Error) -> Error { + Error(e.to_compile_error()) + } +} + +#[derive(Default, Debug)] +struct Parameters { + path: Option, + method: Option, + result: Option, +} + +fn parse_attr(meta: &syn::Meta) -> Result { + let mut params = Parameters::default(); + if let syn::Meta::List(l) = meta { + // Verify the attribute list isn't empty + if l.nested.is_empty() { + return Err(Error::new( + meta.span(), + format!( + "The `{}` attribute must be a list of name/value pairs", + ATTR_NAME + ) + .as_str(), + )); + } + + // Collect name/value arguments + let mut args: Vec<&syn::MetaNameValue> = Vec::new(); + for nm in l.nested.iter() { + if let syn::NestedMeta::Meta(m) = nm { + if let syn::Meta::NameValue(nv) = m { + args.push(nv); + } else { + return Err(Error::new( + m.span(), + format!( + "The `{}` attribute must only contain name/value pairs", + ATTR_NAME + ) + .as_str(), + )); + } + } else { + return Err(Error::new( + nm.span(), + "The `action` attribute must not contain any literals", + )); + } + } + + // Extract arguments + for arg in args { + if let syn::Lit::Str(val) = &arg.lit { + match arg.path.get_ident().unwrap().to_string().as_str() { + "path" => { + params.path = Some(val.deref().clone()); + } + "method" => { + params.method = Some(val.deref().clone().parse().map_err(|_| { + Error::new(arg.lit.span(), "Unable to parse value into expression") + })?); + } + "result" => { + params.result = Some(val.deref().clone().parse().map_err(|_| { + Error::new(arg.lit.span(), "Unable to parse value into expression") + })?); + } + _ => { + return Err(Error::new(arg.span(), "Unsupported argument")); + } + } + } else { + return Err(Error::new(arg.span(), "Invalid value for argument")); + } + } + } else { + return Err(Error::new( + meta.span(), + format!( + "The `{}` attribute must be a list of key/value pairs", + ATTR_NAME + ) + .as_str(), + )); + } + Ok(params) +} + +fn gen_action(path: &syn::LitStr) -> Result { + let re = Regex::new(r"\{(.*?)\}").unwrap(); + let mut fmt_args: Vec = Vec::new(); + for cap in re.captures_iter(path.value().as_str()) { + let expr = syn::parse_str(&cap[1]); + match expr { + Ok(ex) => fmt_args.push(ex), + Err(_) => { + return Err(Error::new( + path.span(), + format!("Failed parsing format argument as expression: {}", &cap[1]).as_str(), + )); + } + } + } + let path = syn::LitStr::new( + re.replace_all(path.value().as_str(), "{}") + .to_string() + .as_str(), + Span::call_site(), + ); + + if !fmt_args.is_empty() { + Ok(quote! { + format!(#path, #(#fmt_args),*) + }) + } else { + Ok(quote! { + String::from(#path) + }) + } +} + +fn endpoint_derive(s: synstructure::Structure) -> proc_macro2::TokenStream { + let mut found_attr = false; + let mut params = Parameters::default(); + for attr in &s.ast().attrs { + match attr.parse_meta() { + Ok(meta) => { + if meta.path().is_ident(ATTR_NAME) { + found_attr = true; + match parse_attr(&meta) { + Ok(p) => { + params = p; + } + Err(e) => return e.into_tokens(), + } + } + } + Err(e) => return e.to_compile_error(), + } + } + + if !found_attr { + return Error::new( + Span::call_site(), + format!( + "Must supply the `{}` attribute when deriving `{}`", + ATTR_NAME, MACRO_NAME + ) + .as_str(), + ) + .into_tokens(); + } + + // Find data attribute + let mut field_name: Option = None; + let mut ty: Option = None; + if let syn::Data::Struct(data) = &s.ast().data { + for field in data.fields.iter() { + if &field.ident.clone().unwrap().to_string() == DATA_ATTR_NAME { + field_name = Some(field.ident.to_token_stream()); + ty = Some(field.ty.to_token_stream()); + } else { + for attr in field.attrs.iter() { + if attr.path.is_ident(DATA_ATTR_NAME) { + field_name = Some(field.ident.to_token_stream()); + ty = Some(field.ty.to_token_stream()); + } + } + } + } + } + + let mut data_empty = false; + let data_type = match ty { + Some(t) => t, + None => { + data_empty = true; + quote! {EmptyEndpointData} + } + }; + + let data_fn = match data_empty { + true => quote! {None}, + false => quote! {Some(&self.#field_name)}, + }; + + // Parse arguments + let path = match params.path { + Some(p) => p, + None => { + return Error::new(Span::call_site(), "Missing required `path` argument").into_tokens() + } + }; + let method = match params.method { + Some(m) => m, + None => match data_empty { + true => syn::parse_str("RequestType::GET").unwrap(), + false => syn::parse_str("RequestType::POST").unwrap(), + }, + }; + let result = match params.result { + Some(r) => r, + None => syn::parse_str("EmptyEndpointResult").unwrap(), + }; + + // Hacky variable substitution + let action = match gen_action(&path) { + Ok(a) => a, + Err(e) => return e.into_tokens(), + }; + + // Generate Endpoint implementation + s.gen_impl(quote! { + use ::rustify::endpoint::{Endpoint, EmptyEndpointData, EmptyEndpointResult}; + use ::rustify::enums::RequestType; + + gen impl Endpoint for @Self { + type RequestData = #data_type; + type Response = #result; + + fn action(&self) -> String { + #action + } + + fn method(&self) -> RequestType { + #method + } + + fn data(&self) -> Option<&Self::RequestData> { + #data_fn + } + } + }) +} + +synstructure::decl_derive!([Endpoint, attributes(endpoint, data)] => endpoint_derive); diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..c22fa01 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,58 @@ +use serde::{de::DeserializeOwned, Serialize}; +use url::Url; + +use crate::{endpoint::Endpoint, enums::RequestType, errors::ClientError}; + +const HTTP_SUCCESS_CODES: [u16; 2] = [200, 204]; +pub trait Client { + fn send( + &self, + req: crate::client::Request, + ) -> Result; + + fn base(&self) -> &str; + + fn execute( + &self, + endpoint: &E, + ) -> Result, ClientError> { + let url = endpoint.build_url(self.base())?; + let method = endpoint.method(); + let data = endpoint.data(); + let response = self.send(crate::client::Request { url, method, data })?; + + // Check response + if !HTTP_SUCCESS_CODES.contains(&response.code) { + return Err(ClientError::ServerResponseError { + url: response.url.to_string(), + code: response.code, + content: response.content.clone(), + }); + } + + // Check for response content + if response.content.is_empty() { + return Ok(None); + } + + // Parse response content + serde_json::from_str(response.content.as_str()).map_err(|e| { + ClientError::ResponseParseError { + source: Box::new(e), + content: response.content.clone(), + } + }) + } +} + +pub struct Request<'a, S: Serialize> { + pub url: Url, + pub method: RequestType, + pub data: Option<&'a S>, +} + +pub struct Response { + pub url: Url, + pub code: u16, + pub content: String, +} diff --git a/src/clients.rs b/src/clients.rs new file mode 100644 index 0000000..e54cd61 --- /dev/null +++ b/src/clients.rs @@ -0,0 +1 @@ +pub mod reqwest; diff --git a/src/clients/reqwest.rs b/src/clients/reqwest.rs new file mode 100644 index 0000000..30a7307 --- /dev/null +++ b/src/clients/reqwest.rs @@ -0,0 +1,109 @@ +use std::str::FromStr; + +use reqwest::Method; +use serde::Serialize; +use url::Url; + +use crate::{client::Client, enums::RequestType, errors::ClientError}; +type MiddleWare = Box reqwest::blocking::Request>; +pub struct ReqwestClient { + pub http: reqwest::blocking::Client, + pub base: String, + pub middle: MiddleWare, +} + +impl ReqwestClient { + pub fn new(base: &str, http: reqwest::blocking::Client, middle: MiddleWare) -> Self { + ReqwestClient { + base: base.to_string(), + http, + middle, + } + } + + pub fn default(base: &str) -> Self { + ReqwestClient { + base: base.to_string(), + http: reqwest::blocking::Client::default(), + middle: Box::new(|r| r), + } + } + + fn build_request( + &self, + method: &RequestType, + url: &Url, + data: Option<&S>, + ) -> Result { + let builder = match method { + RequestType::DELETE => match data { + Some(d) => self.http.delete(url.as_ref()).json(&d), + None => self.http.delete(url.as_ref()), + }, + RequestType::GET => self.http.get(url.as_ref()), + RequestType::HEAD => match data { + Some(d) => self.http.head(url.as_ref()).json(&d), + None => self.http.head(url.as_ref()), + }, + RequestType::LIST => match data { + Some(d) => self + .http + .request(Method::from_str("LIST").unwrap(), url.as_ref()) + .json(&d), + None => self + .http + .request(Method::from_str("LIST").unwrap(), url.as_ref()), + }, + RequestType::POST => match data { + Some(d) => self.http.post(url.as_ref()).json(&d), + None => self.http.post(url.as_ref()), + }, + }; + let req = builder + .build() + .map_err(|e| ClientError::RequestBuildError { + source: Box::new(e), + url: url.to_string(), + method: method.clone(), + })?; + Ok((self.middle)(req)) + } +} + +impl Client for ReqwestClient { + fn base(&self) -> &str { + self.base.as_str() + } + + fn send( + &self, + req: crate::client::Request, + ) -> Result { + let request = self.build_request(&req.method, &req.url, req.data)?; + + let body = match req.data { + Some(d) => serde_json::to_string(d).ok(), + None => None, + }; + let response = self + .http + .execute(request) + .map_err(|e| ClientError::RequestError { + source: Box::new(e), + url: req.url.to_string(), + method: req.method.clone(), + body: body, + })?; + + let url = response.url().clone(); + let status_code = response.status().as_u16(); + let content = response.text().map_err(|e| ClientError::ResponseError { + source: Box::new(e), + })?; + Ok(crate::client::Response { + url, + code: status_code, + content, + }) + } +} diff --git a/src/endpoint.rs b/src/endpoint.rs new file mode 100644 index 0000000..546067b --- /dev/null +++ b/src/endpoint.rs @@ -0,0 +1,37 @@ +use crate::{enums::RequestType, errors::ClientError}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::fmt::Debug; +use url::Url; + +pub trait Endpoint: Debug + Sized { + type RequestData: Serialize; + type Response: DeserializeOwned; + + fn action(&self) -> String; + fn method(&self) -> RequestType; + fn data(&self) -> Option<&Self::RequestData>; + + fn build_url(&self, base: &str) -> Result { + let mut url = Url::parse(base).map_err(|e| ClientError::UrlParseError { + url: base.to_string(), + source: e, + })?; + url.path_segments_mut() + .unwrap() + .extend(self.action().split("/")); + Ok(url) + } + + fn execute( + &self, + client: &C, + ) -> Result, ClientError> { + client.execute(self) + } +} + +#[derive(Deserialize, Debug)] +pub struct EmptyEndpointResult {} + +#[derive(serde::Serialize, Debug)] +pub struct EmptyEndpointData {} diff --git a/src/enums.rs b/src/enums.rs new file mode 100644 index 0000000..63418fa --- /dev/null +++ b/src/enums.rs @@ -0,0 +1,8 @@ +#[derive(Clone, Debug)] +pub enum RequestType { + DELETE, + GET, + HEAD, + LIST, + POST, +} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..2329585 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,37 @@ +use std::error::Error as StdError; +use thiserror::Error; +use url::ParseError; + +use crate::enums::RequestType; + +#[derive(Error, Debug)] +pub enum ClientError { + #[error("Error sending HTTP request")] + RequestError { + source: Box, + method: RequestType, + url: String, + body: Option, + }, + #[error("Error building HTTP request")] + RequestBuildError { + source: Box, + method: RequestType, + url: String, + }, + #[error("Error retrieving HTTP response")] + ResponseError { source: Box }, + #[error("Error parsing HTTP response")] + ResponseParseError { + source: Box, + content: String, + }, + #[error("Server returned error")] + ServerResponseError { + url: String, + code: u16, + content: String, + }, + #[error("Error parsing URL")] + UrlParseError { source: ParseError, url: String }, +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..87dbc78 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,13 @@ +pub mod client; +pub mod clients; +pub mod endpoint; +pub mod enums; +pub mod errors; + +#[cfg(test)] +mod tests { + #[test] + fn it_works() { + assert_eq!(2 + 2, 4); + } +}