-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Define the basic server types and interfaces and add a minimal example. #21
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
[route.greeting] | ||
PATH = ["greeting", "greeting/:name"] | ||
|
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 | ||
} |
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> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Issue for adding a streaming version? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No issue, just wanted to get the basic thing first. I'll create an issue for it |
||
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(), | ||
} | ||
} | ||
} |
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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there an easy way to combine two sets of routes that should both have the same base_url? I.e. routes like /version, /healthcheck, and /help should be at the top level, but they also should come from a standard reusable module. If these are at the top level does that mean all the application stuff needs to have a base_url like "https://acme.com/appname/"? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think there's a few questions here, so here are a few answers:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This leaves me inclined to do conflict detection at the route level, not the module level, and make it possible for the application to override the conflict behavior. Sometimes, "halt and catch fire" is the right approach. Other times, pave over this buggy route defined in someone else's library is just the desperate measure you need. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How would that work? Parse the full path? Have the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I could see that creating ambiguity, if you had There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It should happen in |
||
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is probably what we want for now. I could imagine other applications replacing modules at runtime to make it easier to reason about modal behavior such as responses when
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think a lot of those use cases are better handled with internal mutability in the state, rather than replacing the module itself. But point taken, replacing a module is something we could consider in the future (another use case that comes to mind is hot loading modules for upgrades without down time) |
||
} | ||
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 | ||
} | ||
} |
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) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems I was mistaken; we will end up with a single
State
type shared across eachApi
....There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, as discussed we can do something like
tide::nest
later on but for starters the single global state seems like the better fit for the Espresso APIs