From fae701626185deddc72e540809e24a4b071a426d Mon Sep 17 00:00:00 2001 From: Jeb Bearer Date: Wed, 17 Aug 2022 17:06:14 -0700 Subject: [PATCH] Hook up documentation generation to dispatch. Closes #54 Closes #55 Closes #71 --- Cargo.toml | 1 + examples/hello-world/api.toml | 8 + examples/hello-world/main.rs | 9 +- src/api.rs | 281 +++++++++++++++++++++++++++++++--- src/app.rs | 48 ++++-- src/lib.rs | 279 +-------------------------------- src/main.rs | 83 ---------- src/route.rs | 35 ++++- 8 files changed, 350 insertions(+), 394 deletions(-) delete mode 100644 src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 2fbb4175..28b20713 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ jf-utils = { features = ["std"], git = "https://github.com/EspressoSystems/jelly lazy_static = "1.4.0" libc = "0.2.126" markdown = "0.3" +maud = { version = "0.23", features = ["tide"] } parking_lot = "0.12.0" routefinder = "0.5.0" semver = "1.0" diff --git a/examples/hello-world/api.toml b/examples/hello-world/api.toml index bfec5898..fb48e902 100644 --- a/examples/hello-world/api.toml +++ b/examples/hello-world/api.toml @@ -1,11 +1,19 @@ [meta] +NAME = "hello-world" +DESCRIPTION = "An example of a simple Tide Disco module" FORMAT_VERSION = "0.1.0" [route.greeting] PATH = ["greeting/:name"] ":name" = "Literal" +DOC = """ +Return a greeting personalized for `name`. +""" [route.setgreeting] PATH = ["greeting/:greeting"] METHOD = "POST" ":greeting" = "Literal" +DOC = """ +Set the personalized greeting to return from [greeting](#greeting). +""" diff --git a/examples/hello-world/main.rs b/examples/hello-world/main.rs index 6572dd3f..73c8ba37 100644 --- a/examples/hello-world/main.rs +++ b/examples/hello-world/main.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; use snafu::Snafu; use std::fs; use std::io; +use std::path::PathBuf; +use std::str::FromStr; use tide_disco::{http::StatusCode, Api, App, Error, RequestError}; use tracing::info; @@ -41,7 +43,12 @@ async fn serve(port: u16) -> io::Result<()> { "examples/hello-world/api.toml", )?)?) .unwrap(); - api.with_version(env!("CARGO_PKG_VERSION").parse().unwrap()); + api.with_version(env!("CARGO_PKG_VERSION").parse().unwrap()) + .with_public( + PathBuf::from_str(env!("CARGO_MANIFEST_DIR")) + .unwrap() + .join("public/media"), + ); // Can invoke by browsing // `http://0.0.0.0:8080/hello/greeting/dude` diff --git a/src/api.rs b/src/api.rs index 86206d7b..c84ba11d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -16,7 +16,7 @@ use crate::{ method::{Method, ReadState, WriteState}, request::RequestParams, route::{self, *}, - socket, + socket, Html, }; use async_trait::async_trait; use derive_more::From; @@ -24,6 +24,7 @@ use futures::{ future::{BoxFuture, Future}, stream::BoxStream, }; +use maud::{html, PreEscaped}; use semver::Version; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_with::{serde_as, DisplayFromStr}; @@ -31,19 +32,20 @@ use snafu::{OptionExt, ResultExt, Snafu}; use std::collections::hash_map::{Entry, HashMap, IntoValues, Values}; use std::fmt::Display; use std::ops::Index; +use std::path::PathBuf; use tide::http::content::Accept; /// An error encountered when parsing or constructing an [Api]. #[derive(Clone, Debug, Snafu)] pub enum ApiError { Route { source: RouteParseError }, + ApiMustBeTable, MissingRoutesTable, RoutesMustBeTable, UndefinedRoute, HandlerAlreadyRegistered, IncorrectMethod { expected: Method, actual: Method }, - MetaMustBeTable, - MissingMetaTable, + InvalidMetaTable { source: toml::de::Error }, MissingFormatVersion, InvalidFormatVersion, AmbiguousRoutes { route1: String, route2: String }, @@ -62,16 +64,202 @@ pub struct ApiVersion { pub spec_version: Version, } +/// Metadata used for describing and documenting an API. +/// +/// [ApiMetadata] contains version information about the API, as well as optional HTML fragments to +/// customize the formatting of automatically generated API documentation. Each of the supported +/// HTML fragments is optional and will be filled in with a reasonable default if not provided. Some +/// of the HTML fragments may contain "placeholders", which are identifiers enclosed in `{{ }}`, +/// like `{{SOME_PLACEHOLDER}}`. These will be replaced by contextual information when the +/// documentation is generated. The placeholders supported by each HTML fragment are documented +/// below. +#[serde_as] +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub struct ApiMetadata { + /// The name of this API. + /// + /// Note that the name of the API may be overridden if the API is registered with an app using + /// a different name. + #[serde(default = "meta_defaults::name")] + pub name: String, + + /// A description of this API. + #[serde(default = "meta_defaults::description")] + pub description: String, + + /// The version of the Tide Disco API specification format. + /// + /// If not specified, the version of this crate will be used. + #[serde_as(as = "DisplayFromStr")] + #[serde(default = "meta_defaults::format_version")] + pub format_version: Version, + + /// HTML to be prepended to automatically generated documentation. + /// + /// # Placeholders + /// + /// * `NAME`: the name of the API + /// * `DESCRIPTION`: the description provided in `Cargo.toml` + /// * `VERSION`: the version of the API + /// * `FORMAT_VERSION`: the `FORMAT_VERSION` of the API + /// * `PUBLIC`: the URL where the public directory for this API is being served + #[serde(default = "meta_defaults::html_top")] + pub html_top: String, + + /// HTML to be appended to automatically generated documentation. + #[serde(default = "meta_defaults::html_bottom")] + pub html_bottom: String, + + /// The heading for documentation of a route. + /// + /// # Placeholders + /// + /// * `METHOD`: the method of the route + /// * `NAME`: the name of the route + #[serde(default = "meta_defaults::heading_entry")] + pub heading_entry: String, + + /// The heading preceding documentation of all routes in this API. + #[serde(default = "meta_defaults::heading_routes")] + pub heading_routes: String, + + /// The heading preceding documentation of route parameters. + #[serde(default = "meta_defaults::heading_parameters")] + pub heading_parameters: String, + + /// The heading preceding documentation of a route description. + #[serde(default = "meta_defaults::heading_description")] + pub heading_description: String, + + /// HTML formatting the path of a route. + /// + /// # Placeholders + /// + /// * `PATH`: the path being formatted + #[serde(default = "meta_defaults::route_path")] + pub route_path: String, + + /// HTML preceding the contents of a table documenting the parameters of a route. + #[serde(default = "meta_defaults::parameter_table_open")] + pub parameter_table_open: String, + + /// HTML closing a table documenting the parameters of a route. + #[serde(default = "meta_defaults::parameter_table_close")] + pub parameter_table_close: String, + + /// HTML formatting an entry in a table documenting the parameters of a route. + /// + /// # Placeholders + /// + /// * `NAME`: the parameter being documented + /// * `TYPE`: the type of the parameter being documented + #[serde(default = "meta_defaults::parameter_row")] + pub parameter_row: String, + + /// Documentation to insert in the parameters section of a route with no parameters. + #[serde(default = "meta_defaults::parameter_none")] + pub parameter_none: String, +} + +impl Default for ApiMetadata { + fn default() -> Self { + // Deserialize an empty table, using the `serde` defaults for every field. + toml::Value::Table(Default::default()).try_into().unwrap() + } +} + +mod meta_defaults { + use super::Version; + + pub fn name() -> String { + "default-tide-disco-api".to_string() + } + + pub fn description() -> String { + "Default Tide Disco API".to_string() + } + + pub fn format_version() -> Version { + "0.1.0".parse().unwrap() + } + + pub fn html_top() -> String { + " + + + + + {{NAME}} Reference + + + + + +
Espresso Systems Logo
+

{{NAME}} API {{VERSION}} Reference

+

{{DESCRIPTION}}

+ " + .to_string() + } + + pub fn html_bottom() -> String { + " +

 

+

Copyright © 2022 Espresso Systems. All rights reserved.

+ + + " + .to_string() + } + + pub fn heading_entry() -> String { + "

{{METHOD}} {{NAME}}

\n".to_string() + } + + pub fn heading_routes() -> String { + "

Routes

\n".to_string() + } + pub fn heading_parameters() -> String { + "

Parameters

\n".to_string() + } + pub fn heading_description() -> String { + "

Description

\n".to_string() + } + + pub fn route_path() -> String { + "

{{PATH}}

\n".to_string() + } + + pub fn parameter_table_open() -> String { + "\n".to_string() + } + pub fn parameter_table_close() -> String { + "
\n\n".to_string() + } + pub fn parameter_row() -> String { + "{{NAME}}{{TYPE}}\n".to_string() + } + pub fn parameter_none() -> String { + "
None
".to_string() + } +} + /// 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 { + meta: ApiMetadata, routes: HashMap>, routes_by_path: HashMap>, health_check: Option>, - version: ApiVersion, + api_version: Option, + public: Option, } impl<'a, State, Error> IntoIterator for &'a Api { @@ -120,15 +308,20 @@ impl<'a, State, Error> Iterator for RoutesWithPath<'a, State, Error> { impl Api { /// Parse an API from a TOML specification. - pub fn new(api: toml::Value) -> Result { + pub fn new(mut api: toml::Value) -> Result { + let meta = match api + .as_table_mut() + .context(ApiMustBeTableSnafu)? + .remove("meta") + { + Some(meta) => toml::Value::try_into(meta) + .map_err(|source| ApiError::InvalidMetaTable { source })?, + None => ApiMetadata::default(), + }; let routes = match api.get("route") { Some(routes) => routes.as_table().context(RoutesMustBeTableSnafu)?, None => return Err(ApiError::MissingRoutesTable), }; - let meta = match api.get("meta") { - Some(meta) => meta.as_table().context(MetaMustBeTableSnafu)?, - None => return Err(ApiError::MissingMetaTable), - }; // Collect routes into a [HashMap] indexed by route name. let routes = routes .into_iter() @@ -162,19 +355,12 @@ impl Api { } } Ok(Self { + meta, routes, routes_by_path, health_check: None, - version: ApiVersion { - api_version: None, - spec_version: meta - .get("FORMAT_VERSION") - .context(MissingFormatVersionSnafu)? - .as_str() - .context(InvalidFormatVersionSnafu)? - .parse() - .map_err(|_| ApiError::InvalidFormatVersion)?, - }, + api_version: None, + public: None, }) } @@ -212,7 +398,13 @@ impl Api { /// # } /// ``` pub fn with_version(&mut self, version: Version) -> &mut Self { - self.version.api_version = Some(version); + self.api_version = Some(version); + self + } + + /// Serve the contents of `dir` at the URL `/public/{{NAME}}`. + pub fn with_public(&mut self, dir: PathBuf) -> &mut Self { + self.public = Some(dir); self } @@ -824,7 +1016,14 @@ impl Api { /// Get the version of this API. pub fn version(&self) -> ApiVersion { - self.version.clone() + ApiVersion { + api_version: self.api_version.clone(), + spec_version: self.meta.format_version.clone(), + } + } + + pub(crate) fn public(&self) -> Option<&PathBuf> { + self.public.as_ref() } /// Create a new [Api] which is just like this one, except has a transformed `Error` type. @@ -838,6 +1037,7 @@ impl Api { State: 'static + Send + Sync, { Api { + meta: self.meta, routes: self .routes .into_iter() @@ -845,9 +1045,38 @@ impl Api { .collect(), routes_by_path: self.routes_by_path, health_check: self.health_check, - version: self.version, + api_version: self.api_version, + public: self.public, } } + + pub(crate) fn set_name(&mut self, name: String) { + self.meta.name = name; + } + + /// Compose an HTML page documenting all the routes in this API. + pub fn documentation(&self) -> Html { + html! { + (PreEscaped(self.meta.html_top + .replace("{{NAME}}", &self.meta.name) + .replace("{{DESCRIPTION}}", &self.meta.description) + .replace("{{VERSION}}", &match &self.api_version { + Some(version) => version.to_string(), + None => "(no version)".to_string(), + }) + .replace("{{FORMAT_VERSION}}", &self.meta.format_version.to_string()) + .replace("{{PUBLIC}}", &format!("/public/{}", self.meta.name)))) + @for route in self.routes.values() { + (route.documentation(&self.meta)) + } + (PreEscaped(&self.meta.html_bottom)) + } + } + + /// The description of this API from the specification. + pub fn description(&self) -> &str { + &self.meta.description + } } // `ReadHandler { handler }` essentially represents a handler function @@ -927,7 +1156,7 @@ mod test { async_std::connect_async, tungstenite::{ client::IntoClientRequest, http::header::*, protocol::frame::coding::CloseCode, - protocol::Message, Error as WsError, + protocol::Message, }, WebSocketStream, }; @@ -936,9 +1165,13 @@ mod test { AsyncRead, AsyncWrite, FutureExt, SinkExt, StreamExt, }; use portpicker::pick_unused_port; - use std::io::ErrorKind; use toml::toml; + #[cfg(windows)] + use async_tungstenite::tungstenite::Error as WsError; + #[cfg(windows)] + use std::io::ErrorKind; + async fn check_stream_closed(mut conn: WebSocketStream) where S: AsyncRead + AsyncWrite + Unpin, diff --git a/src/app.rs b/src/app.rs index 0d1e5dc8..cd514776 100644 --- a/src/app.rs +++ b/src/app.rs @@ -19,9 +19,11 @@ use crate::{ request::{RequestParam, RequestParams}, route::{self, health_check_response, respond_with, Handler, Route, RouteError}, socket::SocketError, + Html, }; use async_std::sync::Arc; use futures::future::BoxFuture; +use maud::html; use semver::Version; use serde::{Deserialize, Serialize}; use serde_with::{serde_as, DisplayFromStr}; @@ -105,7 +107,9 @@ impl App { return Err(AppError::ModuleAlreadyExists); } Entry::Vacant(e) => { - e.insert(api.map_err(Error::from)); + let mut api = api.map_err(Error::from); + api.set_name(base_url.to_string()); + e.insert(api); } } @@ -197,6 +201,11 @@ impl App>>(self, listener: L) -> io::Result<()> { let state = Arc::new(self); let mut server = tide::Server::with_state(state.clone()); + for (name, api) in &state.apis { + if let Some(path) = api.public() { + server.at("/public").at(name).serve_dir(path)?; + } + } server.with(add_error_body::<_, Error>); server.with( CorsMiddleware::new() @@ -283,30 +292,51 @@ impl App>| async move { - Ok(format!("help /\n{:?}", req.url())) + Ok(html! { + "This is a Tide Disco app composed of the following modules:" + (req.state().list_apis()) + }) }); } { server .at("/*") .all(move |req: tide::Request>| async move { - Ok(format!("help /*\n{:?}", req.url())) + let api_name = req.url().path_segments().unwrap().next().unwrap(); + let state = req.state(); + if let Some(api) = state.apis.get(api_name) { + Ok(api.documentation()) + } else { + Ok(html! { + "No valid route begins with \"" (api_name) "\". Try routes beginning + with one of the following API identifiers:" + (state.list_apis()) + }) + } }); } - // TODO https://github.com/EspressoSystems/tide-disco/issues/55 - { - // This path is not found for address-book - //server.at("/public").serve_dir("public/media/")?; - } server.listen(listener).await } + fn list_apis(&self) -> Html { + html! { + ul { + @for (name, api) in &self.apis { + li { + a href=(format!("/{}", name)) {(name)} + " " + (api.description()) + } + } + } + } + } + fn register_route( api: String, endpoint: &mut tide::Route>, diff --git a/src/lib.rs b/src/lib.rs index 3e289d89..4a00908b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -237,8 +237,6 @@ use crate::ApiKey::*; use async_std::sync::{Arc, RwLock}; use async_std::task::sleep; -use async_std::task::spawn; -use async_std::task::JoinHandle; use clap::{CommandFactory, Parser}; use config::{Config, ConfigError}; use routefinder::Router; @@ -254,12 +252,7 @@ use std::{ }; use strum_macros::{AsRefStr, EnumString}; use tagged_base64::TaggedBase64; -use tide::{ - http::headers::HeaderValue, - http::mime, - security::{CorsMiddleware, Origin}, - Request, Response, -}; +use tide::http::mime; use toml::value::Value; use tracing::{error, trace}; use url::Url; @@ -280,6 +273,8 @@ pub use method::Method; pub use request::{RequestError, RequestParam, RequestParamType, RequestParamValue, RequestParams}; pub use tide::http::{self, StatusCode}; +pub type Html = maud::Markup; + /// Number of times to poll before failing pub const SERVER_STARTUP_RETRIES: u64 = 255; @@ -336,23 +331,8 @@ pub type AppServerState = ServerState; #[derive(AsRefStr, Debug)] enum ApiKey { DOC, - FORMAT_VERSION, - HEADING_DESCRIPTION, - HEADING_ENTRY, - HEADING_PARAMETERS, - HEADING_ROUTES, - HTML_BOTTOM, - HTML_TOP, - #[strum(serialize = "meta")] - META, METHOD, - MINIMAL_HTML, - PARAMETER_NONE, - PARAMETER_ROW, - PARAMETER_TABLE_CLOSE, - PARAMETER_TABLE_OPEN, PATH, - ROUTE_PATH, #[strum(serialize = "route")] ROUTE, } @@ -517,76 +497,6 @@ fn get_first_segment(s: &str) -> String { .to_string() } -/// Compose an HTML fragment documenting all the variations on -/// a single route -pub fn document_route(meta: &toml::Value, entry: &toml::Value) -> String { - let mut help: String = "".into(); - let paths = entry[PATH.as_ref()] - .as_array() - .expect("Expecting TOML array."); - let first_segment = get_first_segment(vs(&paths[0])); - help += &vk(meta, HEADING_ENTRY.as_ref()) - .replace("{{METHOD}}", &vk(entry, METHOD.as_ref())) - .replace("{{NAME}}", &first_segment); - help += &vk(meta, HEADING_ROUTES.as_ref()); - for path in paths.iter() { - help += &vk(meta, ROUTE_PATH.as_ref()).replace("{{PATH}}", vs(path)); - } - help += &vk(meta, HEADING_PARAMETERS.as_ref()); - help += &vk(meta, PARAMETER_TABLE_OPEN.as_ref()); - let mut has_parameters = false; - for (parameter, ptype) in entry - .as_table() - .expect("Route definitions must be tables in api.toml") - .iter() - { - if let Some(parameter) = parameter.strip_prefix(':') { - has_parameters = true; - help += &vk(meta, PARAMETER_ROW.as_ref()) - .to_owned() - .replace("{{NAME}}", parameter) - .replace("{{TYPE}}", vs(ptype)); - } - } - if !has_parameters { - help += &vk(meta, PARAMETER_NONE.as_ref()); - } - help += &vk(meta, PARAMETER_TABLE_CLOSE.as_ref()); - help += &vk(meta, HEADING_DESCRIPTION.as_ref()); - help += &markdown::to_html(vk(entry, DOC.as_ref()).trim()); - help -} - -/// Compose `api.toml` into HTML. -/// -/// This function iterates over the routes, adding headers and HTML -/// class attributes to make a documentation page for the web API. -/// -/// The results of this could be precomputed and cached. -pub async fn compose_reference_documentation( - req: tide::Request, -) -> Result { - let package_name = env!("CARGO_PKG_NAME"); - let package_description = env!("CARGO_PKG_DESCRIPTION"); - let api = &req.state().app_state; - let meta = &api["meta"]; - let version = vk(meta, FORMAT_VERSION.as_ref()); - let mut help = vk(meta, HTML_TOP.as_ref()) - .replace("{{NAME}}", package_name) - .replace("{{FORMAT_VERSION}}", &version) - .replace("{{DESCRIPTION}}", package_description); - if let Some(api_map) = api[ROUTE.as_ref()].as_table() { - api_map.values().for_each(|entry| { - help += &document_route(meta, entry); - }); - } - help = format!("{}{}\n", help, &vk(meta, HTML_BOTTOM.as_ref())); - Ok(tide::Response::builder(200) - .content_type(tide::http::mime::HTML) - .body(help) - .build()) -} - #[derive(Clone, Debug, EnumString)] pub enum UrlSegment { Boolean(Option), @@ -621,189 +531,6 @@ impl UrlSegment { } } -// TODO https://github.com/EspressoSystems/tide-disco/issues/54 -pub async fn disco_dispatch( - req: Request, - bindings: HashMap, -) -> tide::Result { - let title = "Valid response here"; - let body = &format!("
Bindings: {:?}
", bindings); - Ok(Response::builder(StatusCode::Ok) - .body( - vk(&req.state().app_state[META.as_ref()], MINIMAL_HTML.as_ref()) - .replace("{{TITLE}}", title) - .replace("{{BODY}}", body), - ) - .content_type(mime::HTML) - .build()) -} - -/// Parse URL parameters -/// -/// We might have a valid match or there may be type errors in the -/// captures. Type check the captures and report any failures. Return -/// a tuple indicating success, error messages, and bindings. -pub fn parse_parameters( - api: &Value, - first_segment: &str, - route_match: &routefinder::Match, -) -> (bool, String, HashMap) { - let mut bindings = HashMap::::new(); - let mut parse_error = false; - let mut errors = String::new(); - for capture in route_match.captures().iter() { - let cname = ":".to_owned() + capture.name(); - // The unwrap is safe thanks to check_api(). - let vtype = api[ROUTE.as_ref()][&first_segment][cname].as_str().unwrap(); - // The unwrap is safe thanks to check_api(). - let stype = UrlSegment::from_str(vtype).unwrap(); - let binding = UrlSegment::new(capture.value(), stype); - if !&binding.is_bound() { - parse_error = true; - errors = format!( - "{}\n

Expecting {} for {}.

\n", - errors, - vtype, - capture.name(), - ); - } - bindings.insert(capture.name().to_string(), binding); - } - (parse_error, errors, bindings) -} - -// Report invalid URL literal segments -// -// First segment matches, but no route matches. The code below -// generates suggestions for any literal segments that are close to a -// literal from a path pattern. This function does not check URL -// parameters. -// -// Currently, no suggestions are offered for -// - Incorrect order of literal segments -// - Missing literal segment -// - Extra literal segment -pub fn check_literals(url: &Url, api: &Value, first_segment: &str) -> String { - let mut typos = String::new(); - let meta = &api["meta"]; - let api_map = api[ROUTE.as_ref()].as_table().unwrap(); - api_map[first_segment][PATH.as_ref()] - .as_array() - .unwrap() - .iter() - .for_each(|path| { - for pseg in path.as_str().unwrap().split('/') { - if !pseg.is_empty() && !pseg.starts_with(':') { - url.path_segments().unwrap().for_each(|useg| { - let d = edit_distance::edit_distance(pseg, useg); - if 0 < d && d <= pseg.len() / 2 { - typos = format!( - "{}

Found '{}'. Did you mean '{}'?

\n", - typos, useg, pseg - ); - } - }); - } - } - }); - format!( - "

Invalid arguments for /{}.

\n{}\n{}", - &first_segment, - typos, - document_route(meta, &api[ROUTE.as_ref()][&first_segment]) - ) -} - -pub async fn disco_web_handler(req: Request) -> tide::Result { - let router = &req.state().router; - let path = req.url().path(); - let route_match = router.best_match(path); - let first_segment = get_first_segment(path); - let mut body: String = "

Something went wrong.

".into(); - let mut best: String = "".into(); - let mut distance = usize::MAX; - let api = &req.state().app_state; - if let Some(route_match) = route_match { - let (parse_error, errors, bindings) = parse_parameters(api, &first_segment, &route_match); - if !parse_error { - disco_dispatch(req, bindings).await - } else { - let meta = &api["meta"]; - let template = vk(&req.state().app_state[META.as_ref()], MINIMAL_HTML.as_ref()); - let entry = &api[ROUTE.as_ref()].as_table().unwrap()[&first_segment]; - let content = format!("{}{}", errors, &document_route(meta, entry)); - body = template - .replace("{{TITLE}}", "Route syntax error") - .replace("{{BODY}}", &content); - Ok(Response::builder(StatusCode::NotFound) - .body(body) - .content_type(mime::HTML) - .build()) - } - } else { - // No pattern matched. Note, no wildcards were added, so now - // we fuzzy match and give closest help - // - Does the first segment match? - // - Is the first segment spelled incorrectly? - let meta = &api["meta"]; - if let Some(api_map) = api[ROUTE.as_ref()].as_table() { - api_map.keys().for_each(|entry| { - let d = edit_distance::edit_distance(&first_segment, entry); - if d < distance { - (best, distance) = (entry.into(), d); - } - }); - body = if 0 < distance { - format!( - "

No exact match for /{}. Closet match is /{}.

\n{}", - &first_segment, - &best, - document_route(meta, &api[ROUTE.as_ref()][&best]) - ) - } else { - // First segment matches, but no pattern is satisfied. - // Suggest corrections. - check_literals(req.url(), api, &first_segment) - }; - } - Ok(Response::builder(StatusCode::NotFound) - .body( - vk(&req.state().app_state[META.as_ref()], MINIMAL_HTML.as_ref()) - .replace("{{TITLE}}", "Route not found") - .replace("{{BODY}}", &body), - ) - .content_type(mime::HTML) - .build()) - } -} - -pub async fn init_web_server( - base_url: &str, - state: AppServerState, -) -> std::io::Result>> { - let base_url = Url::parse(base_url).unwrap(); - let mut web_server = tide::with_state(state); - web_server.with( - CorsMiddleware::new() - .allow_methods("GET, POST".parse::().unwrap()) - .allow_headers("*".parse::().unwrap()) - .allow_origin(Origin::from("*")) - .allow_credentials(true), - ); - - // TODO https://github.com/EspressoSystems/tide-disco/issues/58 - web_server.at("/help").get(compose_reference_documentation); - web_server.at("/help/").get(compose_reference_documentation); - web_server.at("/healthcheck").get(healthcheck); - web_server.at("/healthcheck/").get(healthcheck); - - web_server.at("/").all(disco_web_handler); - web_server.at("/*").all(disco_web_handler); - web_server.at("/public").serve_dir("public/media/")?; - - Ok(spawn(web_server.listen(base_url.to_string()))) -} - /// Get the path to `api.toml` pub fn get_api_path(api_toml: &str) -> PathBuf { [env::current_dir().unwrap(), api_toml.into()] diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 8e77db87..00000000 --- a/src/main.rs +++ /dev/null @@ -1,83 +0,0 @@ -use async_std::sync::{Arc, RwLock}; -use config::ConfigError; -#[cfg(not(windows))] -use signal::InterruptHandle; -#[cfg(not(windows))] -use signal_hook::consts::{SIGINT, SIGTERM, SIGUSR1}; -use std::env::current_dir; -use tide_disco::{ - app_api_path, compose_settings, configure_router, get_api_path, init_web_server, load_api, - AppServerState, DiscoArgs, DiscoKey, HealthStatus::*, -}; -use tracing::info; - -mod signal; - -// This demonstrates the older way of configuring the web server. What's valuable here is that it -// shows the bare bones of discoverability from a TOML file. -#[async_std::main] -async fn main() -> Result<(), ConfigError> { - let api_path = current_dir().unwrap().join("api").join("api.toml"); - let api_path_str = api_path.to_str().unwrap(); - - // Combine settings from multiple sources. - let settings = compose_settings::( - "acme", - "rocket-sleds", - &[(DiscoKey::api_toml.as_ref(), api_path_str)], - )?; - - // Colorful logs upon request. - let want_color = settings - .get_bool(DiscoKey::ansi_color.as_ref()) - .unwrap_or(false); - - // Configure logs with timestamps, no color, and settings from - // the RUST_LOG environment variable. - tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .with_ansi(want_color) - .try_init() - .unwrap(); - - info!("Settings: {:?}", settings); - info!("api_path: {:?}", api_path_str); - info!("app_api_path: {:?}", app_api_path("acme", "rocket-sleds")); - - // Fetch the configuration values before any slow operations. - let api_toml = &settings.get_string(DiscoKey::api_toml.as_ref())?; - let base_url = &settings.get_string(DiscoKey::base_url.as_ref())?; - - // Load a TOML file and display something from it. - info!("api_toml: {:?}", api_toml); - info!("base_url: {:?}", base_url); - info!("get_api_path: {:?}", &get_api_path(api_toml)); - let api = load_api(&get_api_path(api_toml)); - let router = configure_router(&api); - - let web_state = AppServerState { - health_status: Arc::new(RwLock::new(Starting)), - app_state: api, - router, - }; - - // Demonstrate that we can read and write the web server state. - info!("Health Status: {}", *web_state.health_status.read().await); - *web_state.health_status.write().await = Available; - - // Activate the handler for ^C, etc. - #[cfg(not(windows))] - let mut interrupt_handler = InterruptHandle::new(&[SIGINT, SIGTERM, SIGUSR1]); - init_web_server(base_url, web_state) - .await - .unwrap_or_else(|err| { - panic!("Web server exited with an error: {}", err); - }) - .await - .unwrap(); - - #[cfg(not(windows))] - interrupt_handler.finalize().await; - - Ok(()) -} diff --git a/src/route.rs b/src/route.rs index 23010ab8..3f5172c3 100644 --- a/src/route.rs +++ b/src/route.rs @@ -1,12 +1,15 @@ use crate::{ + api::ApiMetadata, healthcheck::HealthCheck, method::Method, request::{best_response_type, RequestError, RequestParam, RequestParamType, RequestParams}, socket::{self, SocketError}, + Html, }; use async_trait::async_trait; use derive_more::From; use futures::future::{BoxFuture, Future, FutureExt}; +use maud::{html, PreEscaped}; use serde::Serialize; use snafu::{OptionExt, Snafu}; use std::collections::HashMap; @@ -223,6 +226,7 @@ pub enum RouteParseError { MissingPath, IncorrectPathType, IncorrectParamType, + IncorrectDocType, RouteMustBeTable, } @@ -310,7 +314,10 @@ impl Route { }) .collect::>()?, handler, - doc: String::new(), + doc: match spec.get("DOC") { + Some(doc) => markdown::to_html(doc.as_str().context(IncorrectDocTypeSnafu)?), + None => String::new(), + }, }) } @@ -366,6 +373,32 @@ impl Route { doc: self.doc, } } + + /// Compose an HTML fragment documenting all the variations on this route. + pub fn documentation(&self, meta: &ApiMetadata) -> Html { + html! { + (PreEscaped(meta.heading_entry + .replace("{{METHOD}}", &self.method().to_string()) + .replace("{{NAME}}", &self.name()))) + (PreEscaped(&meta.heading_routes)) + @for path in self.patterns() { + (PreEscaped(meta.route_path.replace("{{PATH}}", path))) + } + (PreEscaped(&meta.heading_parameters)) + (PreEscaped(&meta.parameter_table_open)) + @for param in self.params() { + (PreEscaped(meta.parameter_row + .replace("{{NAME}}", ¶m.name) + .replace("{{TYPE}}", ¶m.param_type.to_string()))) + } + @if self.params().is_empty() { + (PreEscaped(&meta.parameter_none)) + } + (PreEscaped(&meta.parameter_table_close)) + (PreEscaped(&meta.heading_description)) + (PreEscaped(&self.doc)) + } + } } impl Route {