diff --git a/Cargo.toml b/Cargo.toml index 3e9391d0..f6e46997 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ license-file = "LICENSE" [dependencies] async-std = { version = "1.8.0", features = ["attributes"] } async-trait = "0.1.51" +bincode = "1.3" clap = { version = "3.1.18", features = ["derive"] } config = "0.13.1" edit-distance = "2.1.0" @@ -22,6 +23,7 @@ markdown = "0.3" parking_lot = "0.12.0" routefinder = "0.5.0" serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" shellexpand = "2.0" signal-hook = "0.3.14" signal-hook-async-std = "0.2.2" diff --git a/examples/hello-world/api.toml b/examples/hello-world/api.toml new file mode 100644 index 00000000..9d368262 --- /dev/null +++ b/examples/hello-world/api.toml @@ -0,0 +1,3 @@ +[route.greeting] +PATH = ["greeting", "greeting/:name"] + diff --git a/examples/hello-world/main.rs b/examples/hello-world/main.rs new file mode 100644 index 00000000..bab5acad --- /dev/null +++ b/examples/hello-world/main.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; +use snafu::Snafu; +use std::fs; +use std::io; +use tide_disco::{http::StatusCode, Api, App}; + +#[derive(Clone, Debug, Deserialize, Serialize, Snafu)] +enum HelloError { + Goodbye { + status: StatusCode, + farewell: String, + }, +} + +impl tide_disco::Error for HelloError { + fn catch_all(status: StatusCode, farewell: String) -> Self { + Self::Goodbye { status, farewell } + } + + fn status(&self) -> StatusCode { + match self { + Self::Goodbye { status, .. } => *status, + } + } +} + +#[async_std::main] +async fn main() -> io::Result<()> { + let greeting = "Hello, world!".to_string(); + let mut app = App::::with_state(greeting); + let mut api = Api::::new(toml::from_slice(&fs::read( + "examples/hello-world/api.toml", + )?)?) + .unwrap(); + api.at("greeting", |req| async move { Ok(req.state().clone()) }) + .unwrap(); + app.register_module("", api).unwrap(); + app.serve("0.0.0.0:8080").await +} diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 00000000..fa348bd3 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,118 @@ +use crate::{ + request::RequestParams, + route::{Route, RouteParseError}, +}; +use futures::Future; +use serde::Serialize; +use snafu::{OptionExt, ResultExt, Snafu}; +use std::collections::hash_map::{HashMap, IntoValues, Values}; +use std::ops::Index; + +/// An error encountered when parsing or constructing an [Api]. +#[derive(Clone, Debug, Snafu)] +pub enum ApiError { + Route { source: RouteParseError }, + MissingRoutesTable, + RoutesMustBeTable, + UndefinedRoute, + HandlerAlreadyRegistered, +} + +/// A description of an API. +/// +/// An [Api] is a structured representation of an `api.toml` specification. It contains API-level +/// metadata and descriptions of all of the routes in the specification. It can be parsed from a +/// TOML file and registered as a module of an [App](crate::App). +pub struct Api { + routes: HashMap>, +} + +impl<'a, State, Error> IntoIterator for &'a Api { + type Item = &'a Route; + type IntoIter = Values<'a, String, Route>; + + fn into_iter(self) -> Self::IntoIter { + self.routes.values() + } +} + +impl IntoIterator for Api { + type Item = Route; + type IntoIter = IntoValues>; + + fn into_iter(self) -> Self::IntoIter { + self.routes.into_values() + } +} + +impl Index<&str> for Api { + type Output = Route; + + fn index(&self, index: &str) -> &Route { + &self.routes[index] + } +} + +impl Api { + /// Parse an API from a TOML specification. + pub fn new(api: toml::Value) -> Result { + let routes = match api.get("route") { + Some(routes) => routes.as_table().context(RoutesMustBeTableSnafu)?, + None => return Err(ApiError::MissingRoutesTable), + }; + Ok(Self { + routes: routes + .into_iter() + .map(|(name, spec)| { + let route = Route::new(name.clone(), spec).context(RouteSnafu)?; + Ok((route.name(), route)) + }) + .collect::>()?, + }) + } + + /// Register a handler for a route. + /// + /// When the server receives a request whose URL matches the pattern of the route `name`, + /// `handler` will be invoked with the parameters of the request and the result will be + /// serialized into a response. + /// + /// If the route `name` does not exist in the API specification, or if the route already has a + /// handler registered, an error is returned. Note that all routes are initialized with a + /// default handler that echoes parameters and shows documentation, but this default handler can + /// replaced by this function without raising [ApiError::HandlerAlreadyRegistered]. + pub fn at(&mut self, name: &str, handler: F) -> Result<&mut Self, ApiError> + where + F: 'static + Send + Sync + Fn(RequestParams) -> Fut, + Fut: 'static + Send + Sync + Future>, + T: 'static + Send + Sync + Serialize, + State: 'static + Send + Sync, + { + let route = self.routes.get_mut(name).ok_or(ApiError::UndefinedRoute)?; + if route.has_handler() { + return Err(ApiError::HandlerAlreadyRegistered); + } else { + route.set_fn_handler(handler); + } + Ok(self) + } + + /// Create a new [Api] which is just like this one, except has a transformed `Error` type. + pub fn map_err( + self, + f: impl 'static + Clone + Send + Sync + Fn(Error) -> Error2, + ) -> Api + where + Error: 'static + Send + Sync, + Error2: 'static, + State: 'static + Send + Sync, + { + Api { + routes: self + .routes + .into_iter() + .map(|(name, route)| (name, route.map_err(f.clone()))) + .collect(), + } + } +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 00000000..f99683c1 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,100 @@ +use crate::{ + api::Api, + request::RequestParams, + route::{Handler, RouteError}, +}; +use async_std::sync::Arc; +use snafu::Snafu; +use std::collections::hash_map::{Entry, HashMap}; +use std::io; +use tide::http::StatusCode; + +pub use tide::listener::{Listener, ToListener}; + +/// A tide-disco server application. +/// +/// An [App] is a collection of API modules, plus a global `State`. Modules can be registered by +/// constructing an [Api] for each module and calling [App::register_module]. Once all of the +/// desired modules are registered, the app can be converted into an asynchronous server task using +/// [App::server]. +pub struct App { + // Map from base URL to module API. + apis: HashMap>, + state: Arc, +} + +/// An error encountered while building an [App]. +#[derive(Clone, Debug, Snafu)] +pub enum AppError { + ModuleAlreadyExists, +} + +impl App { + /// Create a new [App] with a given state. + pub fn with_state(state: State) -> Self { + Self { + apis: HashMap::new(), + state: Arc::new(state), + } + } + + /// Register an API module. + pub fn register_module( + &mut self, + base_url: &str, + api: Api, + ) -> Result<&mut Self, AppError> + where + Error: From, + ModuleError: 'static + Send + Sync, + { + match self.apis.entry(base_url.to_string()) { + Entry::Occupied(_) => { + return Err(AppError::ModuleAlreadyExists); + } + Entry::Vacant(e) => { + e.insert(api.map_err(Error::from)); + } + } + + Ok(self) + } +} + +impl App { + /// Serve the [App] asynchronously. + pub async fn serve>>(self, listener: L) -> io::Result<()> { + let state = Arc::new(self); + let mut server = tide::Server::with_state(state.clone()); + for (prefix, api) in &state.apis { + for route in api { + let name = route.name(); + let prefix = prefix.clone(); + server.at(&prefix).at(&name).method( + route.method(), + move |req: tide::Request>| { + let name = name.clone(); + let prefix = prefix.clone(); + async move { + let route = &req.state().apis[&prefix][&name]; + let req = + RequestParams::new(&req, req.state().state.clone(), route.params()) + .map_err(|err| { + Error::from_request_error(err).into_tide_error() + })?; + route + .handle(req) + .await + .map_err(|err| match err { + RouteError::AppSpecific(err) => err.into(), + _ => Error::from_route_error(err), + }) + .map_err(|err| err.into_tide_error()) + } + }, + ); + } + } + server.listen(listener).await + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 00000000..ccf6bf59 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,33 @@ +use crate::request::RequestError; +use crate::route::RouteError; +use serde::{de::DeserializeOwned, Serialize}; +use std::fmt::Display; +use tide::StatusCode; + +/// Errors which can be serialized in a response body. +/// +/// This trait can be used to define a standard error type returned by all API endpoints. When a +/// request fails for any reason, the body of the response will contain a serialization of +/// the error that caused the failure, upcasted into an anyhow::Error. If the error is an instance +/// of the standard error type for that particular API, it can be deserialized and downcasted to +/// this type on the client. +/// +/// Other errors (those which don't downcast to the API's error type, such as errors generated from +/// the [tide] framework) will be serialized as strings using their [Display] instance and encoded +/// as an API error using the [catch_all](Error::catch_all) function. +pub trait Error: std::error::Error + Serialize + DeserializeOwned + Send + Sync + 'static { + fn catch_all(status: StatusCode, msg: String) -> Self; + fn status(&self) -> StatusCode; + + fn from_route_error(source: RouteError) -> Self { + Self::catch_all(source.status(), source.to_string()) + } + + fn from_request_error(source: RequestError) -> Self { + Self::catch_all(StatusCode::BadRequest, source.to_string()) + } + + fn into_tide_error(self) -> tide::Error { + tide::Error::new(self.status(), self) + } +} diff --git a/src/lib.rs b/src/lib.rs index 1e645436..3de09c95 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,18 @@ use toml::value::Value; use tracing::{error, info}; use url::Url; +mod api; +mod app; +mod error; +mod request; +mod route; + +pub use api::Api; +pub use app::App; +pub use error::Error; +pub use request::RequestParams; +pub use tide::http; + #[derive(AsRefStr, Debug)] #[allow(non_camel_case_types)] pub enum ConfigKey { diff --git a/src/request.rs b/src/request.rs new file mode 100644 index 00000000..ee7324b8 --- /dev/null +++ b/src/request.rs @@ -0,0 +1,94 @@ +use async_std::sync::Arc; +use snafu::Snafu; +use std::collections::HashMap; +use strum_macros::EnumString; +use tagged_base64::TaggedBase64; +use tide::http::Headers; + +#[derive(Clone, Debug, Snafu)] +pub enum RequestError { + MissingParam { param: RequestParam }, +} + +/// Parameters passed to a route handler. +/// +/// These parameters describe the incoming request and the current server state. +pub struct RequestParams { + headers: Headers, + state: Arc, + params: HashMap, +} + +impl RequestParams { + pub(crate) fn new( + req: &tide::Request, + state: Arc, + formal_params: &[RequestParam], + ) -> Result { + Ok(Self { + headers: AsRef::::as_ref(req).clone(), + state, + params: formal_params + .iter() + .filter_map(|param| match RequestParamValue::new(req, param) { + Ok(None) => None, + Ok(Some(value)) => Some(Ok((param.name.clone(), value))), + Err(err) => Some(Err(err)), + }) + .collect::>()?, + }) + } + + /// The current server state. + pub fn state(&self) -> &State { + &*self.state + } + + /// The headers of the incoming request. + pub fn headers(&self) -> &Headers { + &self.headers + } +} + +#[derive(Clone, Debug)] +pub enum RequestParamValue { + Boolean(bool), + Hexadecimal(u128), + Integer(u128), + TaggedBase64(TaggedBase64), + Literal(String), +} + +impl RequestParamValue { + /// Parse a parameter from a [Request](tide::Request). + /// + /// Returns `Ok(Some(value))` if the parameter is present and well-formed according to `formal`, + /// `Ok(None)` if the parameter is optional and not present, or an error if the request is + /// required and not present, or present and malformed. + pub fn new( + req: &tide::Request, + formal: &RequestParam, + ) -> Result, RequestError> { + if let Ok(param) = req.param(&formal.name) { + unimplemented!("parsing String into RequestParamValue based on formal.param_type"); + } else { + unimplemented!("check for the parameter in the request body") + } + } +} + +#[derive(Clone, Debug, EnumString)] +pub enum RequestParamType { + Boolean, + Hexadecimal, + Integer, + TaggedBase64, + Literal, +} + +#[derive(Clone, Debug)] +pub struct RequestParam { + name: String, + param_type: RequestParamType, + required: bool, +} diff --git a/src/route.rs b/src/route.rs new file mode 100644 index 00000000..e013f874 --- /dev/null +++ b/src/route.rs @@ -0,0 +1,339 @@ +use crate::request::{RequestParam, RequestParams}; +use async_trait::async_trait; +use futures::Future; +use serde::Serialize; +use snafu::Snafu; +use std::fmt::{self, Display, Formatter}; +use std::marker::PhantomData; +use tide::http::{ + self, + content::Accept, + mime::{self, Mime}, + StatusCode, +}; + +/// An error returned by a route handler. +/// +/// [RouteError] encapsulates application specific errors `E` returned by the user-installed handler +/// itself. It also includes errors in the route dispatching logic, such as failures to turn the +/// result of the user-installed handler into an HTTP response. +pub enum RouteError { + AppSpecific(E), + UnsupportedContentType, + Bincode(bincode::Error), + Json(serde_json::Error), + Tide(tide::Error), +} + +impl Display for RouteError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::AppSpecific(err) => write!(f, "{}", err), + Self::UnsupportedContentType => write!(f, "requested content type is not supported"), + Self::Bincode(err) => write!(f, "error creating byte stream: {}", err), + Self::Json(err) => write!(f, "error creating JSON response: {}", err), + Self::Tide(err) => write!(f, "{}", err), + } + } +} + +impl RouteError { + pub fn status(&self) -> StatusCode { + match self { + Self::UnsupportedContentType => StatusCode::BadRequest, + _ => StatusCode::InternalServerError, + } + } + + pub fn map_app_specific(self, f: impl Fn(E) -> E2) -> RouteError { + match self { + RouteError::AppSpecific(e) => RouteError::AppSpecific(f(e)), + RouteError::UnsupportedContentType => RouteError::UnsupportedContentType, + RouteError::Bincode(err) => RouteError::Bincode(err), + RouteError::Json(err) => RouteError::Json(err), + RouteError::Tide(err) => RouteError::Tide(err), + } + } +} + +/// A route handler. +/// +/// The [Handler] trait defines the interface required of route handlers -- they must be able to +/// take [RequestParams] and produce either a response or an appropriately typed error. +/// +/// Implementations of this trait are provided for handler functions returning a serializable +/// response ([FnHandler]),for boxed handlers, and for the [MapErr] type which can be used to +/// transform the `Error` type of a [Handler]. This trait is usually used as +/// `Box>`, in order to type-erase route-specific details such as the +/// return type of a handler function. The types which are preserved, `State` and `Error`, should be +/// the same for all handlers in an API module. +#[async_trait] +pub(crate) trait Handler: 'static + Send + Sync { + async fn handle(&self, req: RequestParams) -> Result>; +} + +/// A [Handler] which delegates to an async function. +/// +/// The function type `F` should be callable as +/// `async fn(RequestParams) -> Result`. The [Handler] implementation will +/// automatically convert the result `R` to a [tide::Response] by serializing it, or the error +/// `Error` to a [RouteError] using [RouteError::AppSpecific]. Note that the format used for +/// serializing the response is flexible. This implementation will use the [Accept] header of the +/// incoming request to try to provide a format that the client expects. Supported formats are +/// `application/json` (using [serde_json]) and `application/octet-stream` (using [bincode]). +pub(crate) struct FnHandler { + f: F, + _phantom: PhantomData<(Fut, R)>, +} + +impl From for FnHandler { + fn from(f: F) -> Self { + Self { + f, + _phantom: Default::default(), + } + } +} + +#[async_trait] +impl Handler for FnHandler +where + F: 'static + Send + Sync + Fn(RequestParams) -> Fut, + Fut: 'static + Send + Sync + Future>, + R: 'static + Send + Sync + Serialize, + State: 'static + Send + Sync, +{ + async fn handle(&self, req: RequestParams) -> Result> { + let mut accept = Accept::from_headers(req.headers()).map_err(RouteError::Tide)?; + (self.f)(req) + .await + .map_err(RouteError::AppSpecific) + .and_then(|res| respond_with(&mut accept, &res)) + } +} + +#[async_trait] +impl, State: 'static + Send + Sync, Error> Handler + for Box +{ + async fn handle(&self, req: RequestParams) -> Result> { + (**self).handle(req).await + } +} + +/// All the information we need to parse, typecheck, and dispatch a request. +/// +/// A [Route] is a structured representation of a route specification from an `api.toml` API spec. +/// It can be parsed from a TOML specification, and it also includes an optional handler function +/// which the Rust server can register. Routes with no handler will use a default handler that +/// simply returns information about the route. +pub struct Route { + name: String, + patterns: Vec, + params: Vec, + method: http::Method, + doc: String, + handler: Option>>, +} + +#[derive(Clone, Debug, Snafu)] +pub enum RouteParseError {} + +impl Route { + /// Parse a [Route] from a TOML specification. + pub fn new(name: String, spec: &toml::Value) -> Result { + unimplemented!("route parsing") + } + + /// The name of the route. + /// + /// This is the name used to identify the route when binding a handler. It is also the first + /// segment of all of the URL patterns for this route. + pub fn name(&self) -> String { + self.name.clone() + } + + /// The HTTP method of the route. + pub fn method(&self) -> http::Method { + self.method + } + + /// Whether a non-default handler has been bound to this route. + pub fn has_handler(&self) -> bool { + self.handler.is_some() + } + + /// Get all formal parameters. + pub fn params(&self) -> &[RequestParam] { + &self.params + } + + /// Create a new route with a modified error type. + pub fn map_err( + self, + f: impl 'static + Send + Sync + Fn(Error) -> Error2, + ) -> Route + where + State: 'static + Send + Sync, + Error: 'static + Send + Sync, + Error2: 'static, + { + Route { + handler: self.handler.map(|h| { + let h: Box> = + Box::new(MapErr::>, _, Error>::new( + h, f, + )); + h + }), + name: self.name, + patterns: self.patterns, + params: self.params, + method: self.method, + doc: self.doc, + } + } +} + +impl Route { + pub(crate) fn set_handler(&mut self, handler: impl Handler) { + self.handler = Some(Box::new(handler)); + } + + pub(crate) fn set_fn_handler(&mut self, handler: F) + where + F: 'static + Send + Sync + Fn(RequestParams) -> Fut, + Fut: 'static + Send + Sync + Future>, + T: 'static + Send + Sync + Serialize, + State: 'static + Send + Sync, + { + self.set_handler(FnHandler::from(handler)) + } + + pub(crate) fn default_handler( + &self, + _req: RequestParams, + ) -> Result> { + unimplemented!() + } +} + +#[async_trait] +impl Handler for Route +where + Error: 'static, + State: 'static + Send + Sync, +{ + async fn handle(&self, req: RequestParams) -> Result> { + match &self.handler { + Some(handler) => handler.handle(req).await, + None => self.default_handler(req), + } + } +} + +pub struct MapErr { + handler: H, + map: F, + _phantom: std::marker::PhantomData, +} + +impl MapErr { + fn new(handler: H, map: F) -> Self { + Self { + handler, + map, + _phantom: Default::default(), + } + } +} + +#[async_trait] +impl Handler for MapErr +where + H: Handler, + F: 'static + Send + Sync + Fn(Error1) -> Error2, + State: 'static + Send + Sync, + Error1: 'static + Send + Sync, + Error2: 'static, +{ + async fn handle( + &self, + req: RequestParams, + ) -> Result> { + self.handler + .handle(req) + .await + .map_err(|err| err.map_app_specific(&self.map)) + } +} + +fn best_response_type( + accept: &mut Option, + available: &[Mime], +) -> Result> { + match accept { + Some(accept) => { + // The Accept type has a `negotiate` method, but it doesn't properly handle + // wildcards. It handles * but not */* and basetype/*, because for content type + // proposals like */* and basetype/*, it looks for a literal match in `available`, + // it does not perform pattern matching. So, we implement negotiation ourselves. + // + // First sort by the weight parameter, which the Accept type does do correctly. + accept.sort(); + // Go through each proposed content type, in the order specified by the client, and + // match them against our available types, respecting wildcards. + for proposed in accept.iter() { + if proposed.basetype() == "*" { + // The only acceptable Accept value with a basetype of * is */*, therefore + // this will match any available type. + return Ok(available[0].clone()); + } else if proposed.subtype() == "*" { + // If the subtype is * but the basetype is not, look for a proposed type + // with a matching basetype and any subtype. + for mime in available { + if mime.basetype() == proposed.basetype() { + return Ok(mime.clone()); + } + } + } else if available.contains(proposed) { + // If neither part of the proposal is a wildcard, look for a literal match. + return Ok((**proposed).clone()); + } + } + + if accept.wildcard() { + // If no proposals are available but a wildcard flag * was given, return any + // available content type. + Ok(available[0].clone()) + } else { + Err(RouteError::UnsupportedContentType) + } + } + None => { + // If no content type is explicitly requested, default to the first available type. + Ok(available[0].clone()) + } + } +} + +fn respond_with( + accept: &mut Option, + body: T, +) -> Result> { + let ty = best_response_type(accept, &[mime::JSON, mime::BYTE_STREAM])?; + if ty == mime::BYTE_STREAM { + let bytes = bincode::serialize(&body).map_err(RouteError::Bincode)?; + Ok(tide::Response::builder(tide::StatusCode::Ok) + .body(bytes) + .content_type(mime::BYTE_STREAM) + .build()) + } else if ty == mime::JSON { + Ok(tide::Response::builder(tide::StatusCode::Ok) + .body(serde_json::to_string(&body).map_err(RouteError::Json)?) + .content_type(mime::JSON) + .build()) + } else { + unreachable!() + } +}