Skip to content

Commit

Permalink
Merge pull request #21 from EspressoSystems/feat/interfaces
Browse files Browse the repository at this point in the history
Define the basic server types and interfaces and add a minimal example.
  • Loading branch information
jbearer authored Jun 16, 2022
2 parents f02fdca + 27a8ce4 commit 1ba02e7
Show file tree
Hide file tree
Showing 9 changed files with 740 additions and 0 deletions.
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions examples/hello-world/api.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[route.greeting]
PATH = ["greeting", "greeting/:name"]

39 changes: 39 additions & 0 deletions examples/hello-world/main.rs
Original file line number Diff line number Diff line change
@@ -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::<String, HelloError>::with_state(greeting);
let mut api = Api::<String, HelloError>::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
}
118 changes: 118 additions & 0 deletions src/api.rs
Original file line number Diff line number Diff line change
@@ -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<State, Error> {
routes: HashMap<String, Route<State, Error>>,
}

impl<'a, State, Error> IntoIterator for &'a Api<State, Error> {
type Item = &'a Route<State, Error>;
type IntoIter = Values<'a, String, Route<State, Error>>;

fn into_iter(self) -> Self::IntoIter {
self.routes.values()
}
}

impl<State, Error> IntoIterator for Api<State, Error> {
type Item = Route<State, Error>;
type IntoIter = IntoValues<String, Route<State, Error>>;

fn into_iter(self) -> Self::IntoIter {
self.routes.into_values()
}
}

impl<State, Error> Index<&str> for Api<State, Error> {
type Output = Route<State, Error>;

fn index(&self, index: &str) -> &Route<State, Error> {
&self.routes[index]
}
}

impl<State, Error> Api<State, Error> {
/// Parse an API from a TOML specification.
pub fn new(api: toml::Value) -> Result<Self, ApiError> {
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::<Result<_, _>>()?,
})
}

/// 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<F, Fut, T>(&mut self, name: &str, handler: F) -> Result<&mut Self, ApiError>
where
F: 'static + Send + Sync + Fn(RequestParams<State>) -> Fut,
Fut: 'static + Send + Sync + Future<Output = Result<T, Error>>,
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<Error2>(
self,
f: impl 'static + Clone + Send + Sync + Fn(Error) -> Error2,
) -> Api<State, Error2>
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(),
}
}
}
100 changes: 100 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
@@ -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<State, Error> {
// Map from base URL to module API.
apis: HashMap<String, Api<State, Error>>,
state: Arc<State>,
}

/// An error encountered while building an [App].
#[derive(Clone, Debug, Snafu)]
pub enum AppError {
ModuleAlreadyExists,
}

impl<State: Clone + Send + Sync + 'static, Error: 'static> App<State, Error> {
/// 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<ModuleError>(
&mut self,
base_url: &str,
api: Api<State, ModuleError>,
) -> Result<&mut Self, AppError>
where
Error: From<ModuleError>,
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<State: Clone + Send + Sync + 'static, Error: 'static + crate::Error> App<State, Error> {
/// Serve the [App] asynchronously.
pub async fn serve<L: ToListener<Arc<Self>>>(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<Arc<Self>>| {
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
}
}
33 changes: 33 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -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<E: Display>(source: RouteError<E>) -> 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)
}
}
12 changes: 12 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ use toml::value::Value;
use tracing::error;
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 {
Expand Down
Loading

0 comments on commit 1ba02e7

Please sign in to comment.