Skip to content
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

Merged
merged 1 commit into from
Jun 16, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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> {
Copy link
Contributor

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 each Api....

Copy link
Member Author

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

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>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue for adding a streaming version?

Copy link
Member Author

Choose a reason for hiding this comment

The 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(),
}
}
}
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,
Copy link
Contributor

Choose a reason for hiding this comment

The 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/"?

Copy link
Member Author

Choose a reason for hiding this comment

The 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:

  • currently there is no way to combine two sets of routes under the same base_url. It is assumed that each module you register has its own base url. This could be relaxed if we want to
  • special top level routes like /version, /healthceck, and /help are handled specially and indeed registered with no base url (automatically, internally to this library). I'm going to make a separate PR using /healthcheck as an example, and we can discuss it more there
  • it should be possible to register a user-defined module at the top level, which is especially useful if you only have one app, like the address book. But since that's effectively just a special case of everything in here, I thought I'd do that as a separate PR once we agreed that we are happy with the basic version

Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would that work? Parse the full path? Have the Api register all routes to the App, but bundle each route on the App with the dispatch code for the Api that registered it, essentially including the unpacking and routing as a closure?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could see that creating ambiguity, if you had /my/module + /route from a normal namespaced module, and /my + /module with a parameter /route from a "global namespace" module, would you find out at runtime?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should happen in serve, when it registers all the routes with tide. The App would have to check that no global route has the same name as any module. If it failed you'd know because you wouldn't be able to start the server

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);
Copy link
Contributor

Choose a reason for hiding this comment

The 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

  • Authorized vs not authorized
  • While performing a long data structure rebuild
  • Progressive revelation, i.e. certain routes only become available after some proficiency is demonstrated
  • Features have been paid for
    None of these are use cases we care about, but if we want tide-disco to get traction, could consider as an enhancement.

Copy link
Member Author

Choose a reason for hiding this comment

The 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
}
}
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 @@ -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 {
Expand Down
Loading