Expand description
Tide Disco is a web server framework with built-in discoverability support for Tide
§Overview
We say a system is discoverable if guesses and mistakes regarding usage are rewarded with relevant documentation and assistance at producing correct requests. To offer this capability in a practical way, it is helpful to specify the API in data files, rather than code, so that all relevant text can be edited in one concise readable specification.
Tide Disco leverages TOML to specify
- Routes with typed parameters
- Route documentation
- Route error messages
- General documentation
§Goals
- Context-sensitive help
- Spelling suggestions
- Reference documentation assembled from route documentation
- Forms and other user interfaces to aid in the construction of correct inputs
- Localization
- Novice and expert help
- Flexible route parsing, e.g. named parameters rather than positional parameters
- API fuzz testing automation based on parameter types
§Future work
- WebSocket support
- Runtime control over logging
§Getting started
A Tide Disco app is composed of one or more API modules. An API module consists of a TOML specification and a set of route handlers – Rust functions – to provide the behavior of the routes defined in the TOML. You can learn the format of the TOML file by looking at the examples in this crate. Once you have it, you can load it into an API description using Api::new:
use tide_disco::Api;
use tide_disco::error::ServerError;
use vbs::version::StaticVersion;
type State = ();
type Error = ServerError;
type StaticVer01 = StaticVersion<0, 1>;
let spec: toml::Value = toml::from_str(
std::str::from_utf8(&std::fs::read("/path/to/api.toml").unwrap()).unwrap(),
).unwrap();
let mut api = Api::<State, Error, StaticVer01>::new(spec)?;
Once you have an Api, you can define route handlers for any routes in your TOML specification. Suppose you have the following route definition:
[route.hello]
PATH = ["hello"]
METHOD = "GET"
Register a handler for it like this:
use futures::FutureExt;
api.get("hello", |req, state| async move { Ok("Hello, world!") }.boxed())?;
See the API reference for more details on what you can do to create an Api.
Once you have registered all of your route handlers, you need to register your Api module with an App:
use tide_disco::App;
use vbs::version::{StaticVersion, StaticVersionType};
type StaticVer01 = StaticVersion<0, 1>;
let mut app = App::<State, Error>::with_state(());
app.register_module("api", api);
app.serve("http://localhost:8080", StaticVer01::instance()).await;
Then you can use your application:
curl http://localhost:8080/api/hello
§Boxed futures
As a web server framework, Tide Disco naturally includes many interfaces that take functions as
arguments. For example, route handlers are registered by passing a handler function to an Api
object. Also naturally, many of these function parameters are async, which of course just means
that they are regular functions returning some type F
that implements the
Future trait. This is all perfectly usual, but throughout the interfaces in
this crate, you may notice something that is a bit unusual: many of these functions are required
to return not just any Future, but a
BoxFuture. This is due to a limitation that currently exists
in the Rust compiler.
The problem arises with functions where the returned future is not 'static
, but rather borrows
from the function parameters. Consider the following route definition, for example:
type State = RwLock<u64>;
type Error = ();
api.at("someroute", |_req, state: &State| async {
Ok(*state.read().await)
})
The async
block in the route handler uses the state
reference, so the resulting future is
only valid for as long as the reference state
is valid. We could write the signature of the
route handler like this:
use futures::Future;
use tide_disco::RequestParams;
type State = async_std::sync::RwLock<u64>;
type Error = ();
fn handler<'a>(
req: RequestParams,
state: &'a State,
) -> impl 'a + Future<Output = Result<u64, Error>> {
// ...
}
Notice how we explicitly constrain the future type by the lifetime 'a
using impl
syntax.
Unfortunately, while we can write a function signature like this, we cannot write a type bound
that uses the Fn trait and represents the equivalent function signature. This is a problem,
since interfaces like at would like to consume any function-like object which
implements Fn, not just static function pointers. Here is what we would like to write:
impl<State, Error, VER: StaticVersionType> Api<State, Error, VER> {
pub fn at<F, T>(&mut self, route: &str, handler: F)
where
F: for<'a> Fn<(RequestParams, &'a State)>,
for<'a> <F as Fn<(RequestParams, &'a State)>>::Output:
'a + Future<Output = Result<T, Error>>,
{...}
}
Here we are using a higher-rank trait bound on the associated type Output
of the Fn
implementation for F
in order to constrain the future by the lifetime 'a
, which is the
lifetime of the State
reference. It is actually possible to write this function signature
today in unstable Rust (using the raw Fn trait as a bound is unstable), but even then, no
associated type will be able to implement the HRTB due to a bug in the compiler. This limitation
is described in detail in
this post.
As a workaround until this is fixed, we require the function F
to return a concrete future
type with an explicit lifetime parameter: BoxFuture. This allows
us to specify the lifetime constraint within the HRTB on F
itself, rather than resorting to a
separate HRTB on the associated type Output
in order to be able to name the return type of
F
. Here is the actual (partial) signature of at:
impl<State, Error, VER: StaticVersionType> Api<State, Error, VER> {
pub fn at<F, T>(&mut self, route: &str, handler: F)
where
F: for<'a> Fn(RequestParams, &'a State) -> BoxFuture<'a, Result<T, Error>>,
{...}
}
What this means for your code is that functions you pass to the Tide Disco framework must return
a boxed future. When passing a closure, you can simply add .boxed()
to your async
block,
like this:
use async_std::sync::RwLock;
use futures::FutureExt;
use tide_disco::Api;
use vbs::version::StaticVersion;
type State = RwLock<u64>;
type Error = ();
type StaticVer01 = StaticVersion<0, 1>;
fn define_routes(api: &mut Api<State, Error, StaticVer01>) {
api.at("someroute", |_req, state: &State| async {
Ok(*state.read().await)
}.boxed());
}
This also means that you cannot pass the name of an async fn
directly, since async
functions
declared with the async fn
syntax do not return a boxed future. Instead, you can wrap the
function in a closure:
use async_std::sync::RwLock;
use futures::FutureExt;
use tide_disco::{Api, RequestParams};
use vbs::version::StaticVersion;
type State = RwLock<u64>;
type Error = ();
type StaticVer01 = StaticVersion<0, 1>;
async fn handler(_req: RequestParams, state: &State) -> Result<u64, Error> {
Ok(*state.read().await)
}
fn register(api: &mut Api<State, Error, StaticVer01>) {
api.at("someroute", |req, state: &State| handler(req, state).boxed());
}
In the future, we may create an attribute macro which can rewrite an async fn
to return a
boxed future directly, like
#[boxed_future]
async fn handler(_req: RequestParams, state: &State) -> Result<u64, Error> {
Ok(*state.read().await)
}
Re-exports§
pub use api::Api;
pub use app::App;
pub use error::Error;
pub use method::Method;
pub use request::RequestError;
pub use request::RequestParam;
pub use request::RequestParamType;
pub use request::RequestParamValue;
pub use request::RequestParams;
pub use status::StatusCode;
pub use tide::http;
Modules§
- Interfaces for methods of accessing to state.
- Support for routes using the Prometheus metrics format.
- An interface for asynchronous communication with clients, using WebSockets.
Macros§
Structs§
- A parsed URL record.
Enums§
- Configuration keys for Tide Disco settings
Constants§
- Number of times to poll before failing
- Number of milliseconds to sleep between attempts
Functions§
- Check api.toml for schema compliance errors
- Compose the path to the application’s configuration file
- Get the application configuration
- Add routes from api.toml to the routefinder instance in tide-disco
- Get the path to
api.toml
- Return a JSON expression with status 200 indicating the server is up and running. The JSON expression is normally {“status”: “Available”} When the server is running but unable to process requests normally, a response with status 503 and payload {“status”: “unavailable”} should be added.
- Load the web API or panic
- Wait for the server to respond to a connection request