tide_disco/
lib.rs

1// Copyright (c) 2022 Espresso Systems (espressosys.com)
2// This file is part of the tide-disco library.
3
4// You should have received a copy of the MIT License
5// along with the tide-disco library. If not, see <https://mit-license.org/>.
6
7//! _Tide Disco is a web server framework with built-in discoverability support for
8//! [Tide](https://github.com/http-rs/tide)_
9//!
10//! # Overview
11//!
12//! We say a system is _discoverable_ if guesses and mistakes regarding usage are rewarded with
13//! relevant documentation and assistance at producing correct requests. To offer this capability in
14//! a practical way, it is helpful to specify the API in data files, rather than code, so that all
15//! relevant text can be edited in one concise readable specification.
16//!
17//! Tide Disco leverages TOML to specify
18//! - Routes with typed parameters
19//! - Route documentation
20//! - Route error messages
21//! - General documentation
22//!
23//! ## Goals
24//!
25//! - Context-sensitive help
26//! - Spelling suggestions
27//! - Reference documentation assembled from route documentation
28//! - Forms and other user interfaces to aid in the construction of correct inputs
29//! - Localization
30//! - Novice and expert help
31//! - Flexible route parsing, e.g. named parameters rather than positional parameters
32//! - API fuzz testing automation based on parameter types
33//!
34//! ## Future work
35//!
36//! - WebSocket support
37//! - Runtime control over logging
38//!
39//! # Getting started
40//!
41//! A Tide Disco app is composed of one or more _API modules_. An API module consists of a TOML
42//! specification and a set of route handlers -- Rust functions -- to provide the behavior of the
43//! routes defined in the TOML. You can learn the format of the TOML file by looking at the examples
44//! in this crate. Once you have it, you can load it into an API description using [Api::new]:
45//!
46//! ```no_run
47//! # fn main() -> Result<(), tide_disco::api::ApiError> {
48//! use tide_disco::Api;
49//! use tide_disco::error::ServerError;
50//! use vbs::version::StaticVersion;
51//!
52//! type State = ();
53//! type Error = ServerError;
54//! type StaticVer01 = StaticVersion<0, 1>;
55//!
56//! let spec: toml::Value = toml::from_str(
57//!     std::str::from_utf8(&std::fs::read("/path/to/api.toml").unwrap()).unwrap(),
58//! ).unwrap();
59//! let mut api = Api::<State, Error, StaticVer01>::new(spec)?;
60//! # Ok(())
61//! # }
62//! ```
63//!
64//! Once you have an [Api], you can define route handlers for any routes in your TOML specification.
65//! Suppose you have the following route definition:
66//!
67//! ```toml
68//! [route.hello]
69//! PATH = ["hello"]
70//! METHOD = "GET"
71//! ```
72//!
73//! Register a handler for it like this:
74//!
75//! ```no_run
76//! # use tide_disco::Api;
77//! # use vbs::version::StaticVersion;
78//! # type StaticVer01 = StaticVersion<0, 1>;
79//! # fn main() -> Result<(), tide_disco::api::ApiError> {
80//! # let spec: toml::Value = toml::from_str(std::str::from_utf8(&std::fs::read("/path/to/api.toml").unwrap()).unwrap()).unwrap();
81//! # let mut api = Api::<(), tide_disco::error::ServerError, StaticVer01>::new(spec)?;
82//! use futures::FutureExt;
83//!
84//! api.get("hello", |req, state| async move { Ok("Hello, world!") }.boxed())?;
85//! # Ok(())
86//! # }
87//! ```
88//!
89//! See [the API reference](Api) for more details on what you can do to create an [Api].
90//!
91//! Once you have registered all of your route handlers, you need to register your [Api] module with
92//! an [App]:
93//!
94//! ```no_run
95//! # use vbs::version::StaticVersion;
96//! # type State = ();
97//! # type Error = tide_disco::error::ServerError;
98//! # type StaticVer01 = StaticVersion<0, 1>;
99//! # #[async_std::main] async fn main() {
100//! # let spec: toml::Value = toml::from_str(std::str::from_utf8(&std::fs::read("/path/to/api.toml").unwrap()).unwrap()).unwrap();
101//! # let api = tide_disco::Api::<State, Error, StaticVer01>::new(spec).unwrap();
102//! use tide_disco::App;
103//! use vbs::version::{StaticVersion, StaticVersionType};
104//!
105//! type StaticVer01 = StaticVersion<0, 1>;
106//!
107//! let mut app = App::<State, Error>::with_state(());
108//! app.register_module("api", api);
109//! app.serve("http://localhost:8080", StaticVer01::instance()).await;
110//! # }
111//! ```
112//!
113//! Then you can use your application:
114//!
115//! ```text
116//! curl http://localhost:8080/api/hello
117//! ```
118//!
119//! # Boxed futures
120//!
121//! As a web server framework, Tide Disco naturally includes many interfaces that take functions as
122//! arguments. For example, route handlers are registered by passing a handler function to an [Api]
123//! object. Also naturally, many of these function parameters are async, which of course just means
124//! that they are regular functions returning some type `F` that implements the
125//! [Future](futures::Future) trait. This is all perfectly usual, but throughout the interfaces in
126//! this crate, you may notice something that is a bit unusual: many of these functions are required
127//! to return not just any [Future](futures::Future), but a
128//! [BoxFuture](futures::future::BoxFuture). This is due to a limitation that currently exists
129//! in the Rust compiler.
130//!
131//! The problem arises with functions where the returned future is not `'static`, but rather borrows
132//! from the function parameters. Consider the following route definition, for example:
133//!
134//! ```ignore
135//! type State = RwLock<u64>;
136//! type Error = ();
137//!
138//! api.at("someroute", |_req, state: &State| async {
139//!     Ok(*state.read().await)
140//! })
141//! ```
142//!
143//! The `async` block in the route handler uses the `state` reference, so the resulting future is
144//! only valid for as long as the reference `state` is valid. We could write the signature of the
145//! route handler like this:
146//!
147//! ```
148//! use futures::Future;
149//! use tide_disco::RequestParams;
150//!
151//! type State = async_std::sync::RwLock<u64>;
152//! type Error = ();
153//!
154//! fn handler<'a>(
155//!     req: RequestParams,
156//!     state: &'a State,
157//! ) -> impl 'a + Future<Output = Result<u64, Error>> {
158//!     // ...
159//!     # async { Ok(*state.read().await) }
160//! }
161//! ```
162//!
163//! Notice how we explicitly constrain the future type by the lifetime `'a` using `impl` syntax.
164//! Unfortunately, while we can write a function signature like this, we cannot write a type bound
165//! that uses the [Fn] trait and represents the equivalent function signature. This is a problem,
166//! since interfaces like [at](Api::at) would like to consume any function-like object which
167//! implements [Fn], not just static function pointers. Here is what we would _like_ to write:
168//!
169//! ```ignore
170//! impl<State, Error, VER: StaticVersionType> Api<State, Error, VER> {
171//!     pub fn at<F, T>(&mut self, route: &str, handler: F)
172//!     where
173//!         F: for<'a> Fn<(RequestParams, &'a State)>,
174//!         for<'a> <F as Fn<(RequestParams, &'a State)>>::Output:
175//!             'a + Future<Output = Result<T, Error>>,
176//!     {...}
177//! }
178//! ```
179//!
180//! Here we are using a higher-rank trait bound on the associated type `Output` of the [Fn]
181//! implementation for `F` in order to constrain the future by the lifetime `'a`, which is the
182//! lifetime of the `State` reference. It is actually possible to write this function signature
183//! today in unstable Rust (using the raw [Fn] trait as a bound is unstable), but even then, no
184//! associated type will be able to implement the HRTB due to a bug in the compiler. This limitation
185//! is described in detail in
186//! [this post](https://users.rust-lang.org/t/trait-bounds-for-fn-returning-a-future-that-forwards-the-lifetime-of-the-fn-s-arguments/63275/7).
187//!
188//! As a workaround until this is fixed, we require the function `F` to return a concrete future
189//! type with an explicit lifetime parameter: [BoxFuture](futures::future::BoxFuture). This allows
190//! us to specify the lifetime constraint within the HRTB on `F` itself, rather than resorting to a
191//! separate HRTB on the associated type `Output` in order to be able to name the return type of
192//! `F`. Here is the actual (partial) signature of [at](Api::at):
193//!
194//! ```ignore
195//! impl<State, Error, VER: StaticVersionType> Api<State, Error, VER> {
196//!     pub fn at<F, T>(&mut self, route: &str, handler: F)
197//!     where
198//!         F: for<'a> Fn(RequestParams, &'a State) -> BoxFuture<'a, Result<T, Error>>,
199//!     {...}
200//! }
201//! ```
202//!
203//! What this means for your code is that functions you pass to the Tide Disco framework must return
204//! a boxed future. When passing a closure, you can simply add `.boxed()` to your `async` block,
205//! like this:
206//!
207//! ```
208//! use async_std::sync::RwLock;
209//! use futures::FutureExt;
210//! use tide_disco::Api;
211//! use vbs::version::StaticVersion;
212//!
213//! type State = RwLock<u64>;
214//! type Error = ();
215//!
216//! type StaticVer01 = StaticVersion<0, 1>;
217//!
218//! fn define_routes(api: &mut Api<State, Error, StaticVer01>) {
219//!     api.at("someroute", |_req, state: &State| async {
220//!         Ok(*state.read().await)
221//!     }.boxed());
222//! }
223//! ```
224//!
225//! This also means that you cannot pass the name of an `async fn` directly, since `async` functions
226//! declared with the `async fn` syntax do not return a boxed future. Instead, you can wrap the
227//! function in a closure:
228//!
229//! ```
230//! use async_std::sync::RwLock;
231//! use futures::FutureExt;
232//! use tide_disco::{Api, RequestParams};
233//! use vbs::version::StaticVersion;
234//!
235//! type State = RwLock<u64>;
236//! type Error = ();
237//! type StaticVer01 = StaticVersion<0, 1>;
238//!
239//! async fn handler(_req: RequestParams, state: &State) -> Result<u64, Error> {
240//!     Ok(*state.read().await)
241//! }
242//!
243//! fn register(api: &mut Api<State, Error, StaticVer01>) {
244//!     api.at("someroute", |req, state: &State| handler(req, state).boxed());
245//! }
246//! ```
247//!
248//! In the future, we may create an attribute macro which can rewrite an `async fn` to return a
249//! boxed future directly, like
250//!
251//! ```ignore
252//! #[boxed_future]
253//! async fn handler(_req: RequestParams, state: &State) -> Result<u64, Error> {
254//!     Ok(*state.read().await)
255//! }
256//! ```
257//!
258
259use crate::ApiKey::*;
260use async_std::sync::{Arc, RwLock};
261use async_std::task::sleep;
262use clap::CommandFactory;
263use config::{Config, ConfigError};
264use routefinder::Router;
265use serde::Deserialize;
266use std::fs::{read_to_string, OpenOptions};
267use std::io::Write;
268use std::str::FromStr;
269use std::time::Duration;
270use std::{
271    collections::HashMap,
272    env,
273    path::{Path, PathBuf},
274};
275use strum_macros::{AsRefStr, EnumString};
276use tagged_base64::TaggedBase64;
277use tide::http::mime;
278use toml::value::Value;
279use tracing::{error, trace};
280
281pub mod api;
282pub mod app;
283pub mod error;
284pub mod healthcheck;
285pub mod listener;
286pub mod method;
287pub mod metrics;
288pub mod request;
289pub mod socket;
290pub mod status;
291pub mod testing;
292
293mod dispatch;
294mod middleware;
295mod route;
296
297pub use api::Api;
298pub use app::App;
299pub use error::Error;
300pub use method::Method;
301pub use request::{RequestError, RequestParam, RequestParamType, RequestParamValue, RequestParams};
302pub use status::StatusCode;
303pub use tide::http;
304pub use url::Url;
305
306pub type Html = maud::Markup;
307
308/// Number of times to poll before failing
309pub const SERVER_STARTUP_RETRIES: u64 = 255;
310
311/// Number of milliseconds to sleep between attempts
312pub const SERVER_STARTUP_SLEEP_MS: u64 = 100;
313
314#[derive(clap::Args, Debug)]
315#[clap(author, version, about, long_about = None)]
316pub struct DiscoArgs {
317    #[clap(long)]
318    /// Server address
319    pub base_url: Option<Url>,
320    #[clap(long)]
321    /// HTTP routes
322    pub api_toml: Option<PathBuf>,
323    /// If true, log in color. Otherwise, no color.
324    #[clap(long)]
325    pub ansi_color: Option<bool>,
326}
327
328/// Configuration keys for Tide Disco settings
329///
330/// The application is expected to define additional keys. Note, string literals could be used
331/// directly, but defining an enum allows the compiler to catch typos.
332#[derive(AsRefStr, Debug)]
333#[allow(non_camel_case_types)]
334pub enum DiscoKey {
335    ansi_color,
336    api_toml,
337    app_toml,
338    base_url,
339    disco_toml,
340}
341
342#[derive(AsRefStr, Clone, Debug, Deserialize, strum_macros::Display)]
343pub enum HealthStatus {
344    Starting,
345    Available,
346    Stopping,
347}
348
349#[derive(Clone)]
350pub struct ServerState<AppState> {
351    pub health_status: Arc<RwLock<HealthStatus>>,
352    pub app_state: AppState,
353    pub router: Arc<Router<usize>>,
354}
355
356pub type AppState = Value;
357
358pub type AppServerState = ServerState<AppState>;
359
360#[allow(non_camel_case_types, clippy::upper_case_acronyms)]
361#[derive(AsRefStr, Debug)]
362enum ApiKey {
363    DOC,
364    METHOD,
365    PATH,
366    #[strum(serialize = "route")]
367    ROUTE,
368}
369
370/// Check api.toml for schema compliance errors
371///
372/// Checks
373/// - Unsupported request method
374/// - Missing DOC string
375/// - Route paths missing or not an array
376pub fn check_api(api: toml::Value) -> bool {
377    let mut error_count = 0;
378    if let Some(api_map) = api[ROUTE.as_ref()].as_table() {
379        let methods = vec!["GET", "POST"];
380        api_map.values().for_each(|entry| {
381            if let Some(paths) = entry[PATH.as_ref()].as_array() {
382                let first_segment = get_first_segment(vs(&paths[0]));
383
384                // Check the method is GET or PUT.
385                let method = vk(entry, METHOD.as_ref());
386                if !methods.contains(&method.as_str()) {
387                    error!(
388                        "Route: /{}: Unsupported method: {}. Expected one of: {:?}",
389                        &first_segment, &method, &methods
390                    );
391                    error_count += 1;
392                }
393
394                // Check for DOC string.
395                if entry.get(DOC.as_ref()).is_none() || entry[DOC.as_ref()].as_str().is_none() {
396                    error!("Route: /{}: Missing DOC string.", &first_segment);
397                    error_count += 1;
398                }
399
400                // Every URL segment pattern must have a valid type. For example,
401                // if a segment `:amount` might have type UrlSegment::Integer
402                // indicated by
403                //    ":amount" = "Integer"
404                // in the TOML.
405                let paths = entry[PATH.as_ref()]
406                    .as_array()
407                    .expect("Expecting TOML array.");
408                for path in paths {
409                    if path.is_str() {
410                        for segment in path.as_str().unwrap().split('/') {
411                            if let Some(parameter) = segment.strip_prefix(':') {
412                                let stype = vk(entry, segment);
413                                if UrlSegment::from_str(&stype).is_err() {
414                                    error!(
415                                        "Route /{}: Unrecognized type {} for pattern {}.",
416                                        &first_segment, stype, &parameter
417                                    );
418                                    error_count += 1;
419                                }
420                            }
421                        }
422                    } else {
423                        error!(
424                            "Route /{}: Found path '{:?}' but expecting a string.",
425                            &first_segment, path
426                        );
427                    }
428                }
429            } else {
430                error!("Expecting TOML array for {:?}.", &entry[PATH.as_ref()]);
431                error_count += 1;
432            }
433        })
434    }
435    error_count == 0
436}
437
438/// Load the web API or panic
439pub fn load_api(path: &Path) -> toml::Value {
440    let messages = read_to_string(path).unwrap_or_else(|_| panic!("Unable to read {:?}.", &path));
441    let api: toml::Value =
442        toml::from_str(&messages).unwrap_or_else(|_| panic!("Unable to parse {:?}.", &path));
443    if !check_api(api.clone()) {
444        panic!("API specification has errors.",);
445    }
446    api
447}
448
449/// Add routes from api.toml to the routefinder instance in tide-disco
450pub fn configure_router(api: &toml::Value) -> Arc<Router<usize>> {
451    let mut router = Router::new();
452    if let Some(api_map) = api[ROUTE.as_ref()].as_table() {
453        let mut index = 0usize;
454        api_map.values().for_each(|entry| {
455            let paths = entry[PATH.as_ref()]
456                .as_array()
457                .expect("Expecting TOML array.");
458            for path in paths {
459                trace!("adding path: {:?}", path);
460                index += 1;
461                router
462                    .add(path.as_str().expect("Expecting a path string."), index)
463                    .unwrap();
464            }
465        })
466    }
467    Arc::new(router)
468}
469
470/// Return a JSON expression with status 200 indicating the server
471/// is up and running. The JSON expression is normally
472///    {"status": "Available"}
473/// When the server is running but unable to process requests
474/// normally, a response with status 503 and payload {"status":
475/// "unavailable"} should be added.
476pub async fn healthcheck(
477    req: tide::Request<AppServerState>,
478) -> Result<tide::Response, tide::Error> {
479    let status = req.state().health_status.read().await;
480    Ok(tide::Response::builder(StatusCode::OK)
481        .content_type(mime::JSON)
482        .body(tide::prelude::json!({"status": status.as_ref() }))
483        .build())
484}
485
486// Get a string from a toml::Value or panic.
487fn vs(v: &Value) -> &str {
488    v.as_str().unwrap_or_else(|| {
489        panic!(
490            "Expecting TOML string, but found type {}: {:?}",
491            v.type_str(),
492            v
493        )
494    })
495}
496
497// Get a string from an array toml::Value or panic.
498fn vk(v: &Value, key: &str) -> String {
499    if let Some(vv) = v.get(key) {
500        vv.as_str()
501            .unwrap_or_else(|| {
502                panic!(
503                    "Expecting TOML string for {}, but found type {}",
504                    key,
505                    v[key].type_str()
506                )
507            })
508            .to_string()
509    } else {
510        error!("No value for key {}", key);
511        "<missing>".to_string()
512    }
513}
514
515// Given a string delimited by slashes, get the first non-empty
516// segment.
517//
518// For example,
519// - get_first_segment("/foo/bar") -> "foo"
520// - get_first_segment("first/second") -> "first"
521fn get_first_segment(s: &str) -> String {
522    let first_path = s.strip_prefix('/').unwrap_or(s);
523    first_path
524        .split_once('/')
525        .unwrap_or((first_path, ""))
526        .0
527        .to_string()
528}
529
530#[derive(Clone, Debug, EnumString)]
531pub enum UrlSegment {
532    Boolean(Option<bool>),
533    Hexadecimal(Option<u128>),
534    Integer(Option<u128>),
535    TaggedBase64(Option<TaggedBase64>),
536    Literal(Option<String>),
537}
538
539impl UrlSegment {
540    pub fn new(value: &str, vtype: UrlSegment) -> UrlSegment {
541        match vtype {
542            UrlSegment::Boolean(_) => UrlSegment::Boolean(value.parse::<bool>().ok()),
543            UrlSegment::Hexadecimal(_) => {
544                UrlSegment::Hexadecimal(u128::from_str_radix(value, 16).ok())
545            }
546            UrlSegment::Integer(_) => UrlSegment::Integer(value.parse().ok()),
547            UrlSegment::TaggedBase64(_) => {
548                UrlSegment::TaggedBase64(TaggedBase64::parse(value).ok())
549            }
550            UrlSegment::Literal(_) => UrlSegment::Literal(Some(value.to_string())),
551        }
552    }
553    pub fn is_bound(&self) -> bool {
554        match self {
555            UrlSegment::Boolean(v) => v.is_some(),
556            UrlSegment::Hexadecimal(v) => v.is_some(),
557            UrlSegment::Integer(v) => v.is_some(),
558            UrlSegment::TaggedBase64(v) => v.is_some(),
559            UrlSegment::Literal(v) => v.is_some(),
560        }
561    }
562}
563
564/// Get the path to `api.toml`
565pub fn get_api_path(api_toml: &str) -> PathBuf {
566    [env::current_dir().unwrap(), api_toml.into()]
567        .iter()
568        .collect::<PathBuf>()
569}
570
571/// Convert the command line arguments for the config-rs crate
572fn get_cmd_line_map<Args: CommandFactory>() -> config::Environment {
573    config::Environment::default().source(Some({
574        let mut cla = HashMap::new();
575        let matches = Args::command().get_matches();
576        for arg in Args::command().get_arguments() {
577            if let Some(value) = matches.get_one::<String>(arg.get_id().as_str()) {
578                let key = arg.get_id().as_str().replace('-', "_");
579                cla.insert(key, value.to_owned());
580            }
581        }
582        cla
583    }))
584}
585
586/// Compose the path to the application's configuration file
587pub fn compose_config_path(org_dir_name: &str, app_name: &str) -> PathBuf {
588    let mut app_config_path = org_data_path(org_dir_name);
589    app_config_path = app_config_path.join(app_name).join(app_name);
590    app_config_path.set_extension("toml");
591    app_config_path
592}
593
594/// Get the application configuration
595///
596/// Build the configuration from
597/// - Defaults in the tide-disco source
598/// - Defaults passed from the app
599/// - A configuration file from the app
600/// - Command line arguments
601/// - Environment variables
602///
603/// Last one wins.
604///
605/// Environment variables have a prefix of the given app_name in upper case with hyphens converted
606/// to underscores. Hyphens are illegal in environment variables in bash, et.al..
607pub fn compose_settings<Args: CommandFactory>(
608    org_name: &str,
609    app_name: &str,
610    app_defaults: &[(&str, &str)],
611) -> Result<Config, ConfigError> {
612    let app_config_file = &compose_config_path(org_name, app_name);
613    {
614        let app_config = OpenOptions::new()
615            .write(true)
616            .create_new(true)
617            .open(app_config_file);
618        if let Ok(mut app_config) = app_config {
619            write!(
620                app_config,
621                "# {app_name} configuration\n\n\
622                 # Note: keys must be lower case.\n\n"
623            )
624            .map_err(|e| ConfigError::Foreign(e.into()))?;
625            for (k, v) in app_defaults {
626                writeln!(app_config, "{k} = \"{v}\"")
627                    .map_err(|e| ConfigError::Foreign(e.into()))?;
628            }
629        }
630        // app_config file handle gets closed exiting this scope so
631        // Config can read it.
632    }
633    let env_var_prefix = &app_name.replace('-', "_");
634    let org_config_file = org_data_path(org_name).join("org.toml");
635    // In the config-rs crate, environment variable names are converted to lower case, but keys in
636    // files are not, so if we want environment variables to override file value, we must make file
637    // keys lower case. This is a config-rs bug. See https://github.com/mehcode/config-rs/issues/340
638    let mut builder = Config::builder()
639        .set_default(DiscoKey::base_url.as_ref(), "http://localhost:65535")?
640        .set_default(DiscoKey::disco_toml.as_ref(), "disco.toml")?
641        .set_default(
642            DiscoKey::app_toml.as_ref(),
643            app_api_path(org_name, app_name)
644                .to_str()
645                .expect("Invalid api path"),
646        )?
647        .set_default(DiscoKey::ansi_color.as_ref(), false)?
648        .add_source(config::File::with_name("config/default.toml"))
649        .add_source(config::File::with_name(
650            org_config_file
651                .to_str()
652                .expect("Invalid organization configuration file path"),
653        ))
654        .add_source(config::File::with_name(
655            app_config_file
656                .to_str()
657                .expect("Invalid application configuration file path"),
658        ))
659        .add_source(get_cmd_line_map::<Args>())
660        .add_source(config::Environment::with_prefix(env_var_prefix)); // No hyphens allowed
661    for (k, v) in app_defaults {
662        builder = builder.set_default(*k, *v).expect("Failed to set default");
663    }
664    builder.build()
665}
666
667pub fn init_logging(want_color: bool) {
668    tracing_subscriber::fmt()
669        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
670        .with_ansi(want_color)
671        .init();
672}
673
674pub fn org_data_path(org_name: &str) -> PathBuf {
675    dirs::data_local_dir()
676        .unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from("./")))
677        .join(org_name)
678}
679
680pub fn app_api_path(org_name: &str, app_name: &str) -> PathBuf {
681    org_data_path(org_name).join(app_name).join("api.toml")
682}
683
684/// Wait for the server to respond to a connection request
685///
686/// This is useful for tests for which it doesn't make sense to send requests until the server has
687/// started.
688pub async fn wait_for_server(url: &Url, retries: u64, sleep_ms: u64) {
689    let dur = Duration::from_millis(sleep_ms);
690    for _ in 0..retries {
691        if reqwest::Client::new()
692            .head(url.clone())
693            .send()
694            .await
695            .is_ok()
696        {
697            return;
698        }
699        sleep(dur).await;
700    }
701    panic!(
702        "Server did not start in {:?} milliseconds",
703        sleep_ms * SERVER_STARTUP_RETRIES
704    );
705}