1 unstable release

0.6.0 Apr 2, 2024

#553 in Web programming

Download history 215/week @ 2024-04-01 443/week @ 2024-04-08 494/week @ 2024-04-15

1,152 downloads per month
Used in surf-disco

Custom license

280KB
5K SLoC

Tide Disco

Discoverability support for Tide

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.

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

WebSocket support Runtime control over logging


lib.rs:

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>> {
    // ...
    # async { Ok(*state.read().await) }
}

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)
}

Dependencies

~26–44MB
~712K SLoC