diff --git a/Cargo.toml b/Cargo.toml index 13534f7b..801d515b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ bincode = "1.3.3" clap = { version = "3.2.5", features = ["derive"] } config = "0.13.1" derive_more = "0.99" +dirs = "4.0.0" edit-distance = "2.1.0" futures = "0.3.21" futures-util = "0.3.8" diff --git a/src/api.rs b/src/api.rs index 1c04e040..b64fc22a 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,3 +1,16 @@ +// COPYRIGHT100 (c) 2022 Espresso Systems (espressosys.com) +// +// This program is free software: you can redistribute it and/or modify it under the terms of the +// GNU General Public License as published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without +// even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with this program. If +// not, see . + use crate::{ healthcheck::{HealthCheck, HealthStatus}, method::{method_is_mutable, ReadState, WriteState}, diff --git a/src/app.rs b/src/app.rs index 7910c5ad..b0f3c194 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,16 @@ +// COPYRIGHT100 (c) 2022 Espresso Systems (espressosys.com) +// +// This program is free software: you can redistribute it and/or modify it under the terms of the +// GNU General Public License as published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without +// even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with this program. If +// not, see . + use crate::{ api::{Api, ApiVersion}, healthcheck::{HealthCheck, HealthStatus}, @@ -248,6 +261,29 @@ impl App(err).into_tide_error()) }); + // Register catch-all routes for discoverability + { + server + .at("/") + .get(move |req: tide::Request>| async move { + // TODO invoke disco_web_handler with the URL, etc. + Ok(format!("help /\n{:?}", req.url())) + }); + } + { + server + .at("/*") + .get(move |req: tide::Request>| async move { + // TODO invoke disco_web_handler with the URL, etc. + Ok(format!("help /*\n{:?}", req.url())) + }); + } + // TODO add a call to serve_dir + { + // TODO This path is not found for address-book + //server.at("/public").serve_dir("public/media/")?; + } + server.listen(listener).await } } @@ -304,10 +340,10 @@ pub struct AppVersion { /// body of the response. /// /// If the response does not contain an error, it is passed through unchanged. -fn add_error_body<'a, T: Clone + Send + Sync + 'static, E: crate::Error>( +fn add_error_body( req: tide::Request, - next: tide::Next<'a, T>, -) -> BoxFuture<'a, tide::Result> { + next: tide::Next, +) -> BoxFuture { Box::pin(async { let mut accept = Accept::from_headers(&req)?; let mut res = next.run(req).await; diff --git a/src/lib.rs b/src/lib.rs index 59d92601..49cb7e75 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ -//! Web server framework with built-in discoverability. +//! _Tide Disco is a web server framework with built-in discoverability support for +//! [Tide](https://github.com/http-rs/tide)_ //! //! # Overview //! @@ -235,14 +236,17 @@ 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; +use clap::{CommandFactory, Parser}; use config::{Config, ConfigError}; use routefinder::Router; use serde::Deserialize; -use std::fs::read_to_string; +use std::fs::{read_to_string, OpenOptions}; +use std::io::Write; use std::str::FromStr; +use std::time::Duration; use std::{ collections::HashMap, env, @@ -257,7 +261,7 @@ use tide::{ Request, Response, }; use toml::value::Value; -use tracing::error; +use tracing::{error, trace}; use url::Url; pub mod api; @@ -274,14 +278,35 @@ pub use error::Error; pub use request::{RequestError, RequestParam, RequestParamType, RequestParamValue, RequestParams}; pub use tide::http::{self, StatusCode}; +/// Number of times to poll before failing +const STARTUP_RETRIES: u32 = 255; + +#[derive(Parser, Debug)] +#[clap(author, version, about, long_about = None)] +pub struct DiscoArgs { + #[clap(long)] + /// Server address + pub base_url: Option, + #[clap(long)] + /// HTTP routes + pub api_toml: Option, + /// If true, log in color. Otherwise, no color. + #[clap(long)] + pub ansi_color: Option, +} + +/// Configuration keys for Tide Disco settings +/// +/// The application is expected to define additional keys. Note, string literals could be used +/// directly, but defining an enum allows the compiler to catch typos. #[derive(AsRefStr, Debug)] #[allow(non_camel_case_types)] -pub enum ConfigKey { +pub enum DiscoKey { + ansi_color, + api_toml, + app_toml, base_url, disco_toml, - brand_toml, - api_toml, - ansi_color, } #[derive(AsRefStr, Clone, Debug, Deserialize, strum_macros::Display)] @@ -416,6 +441,7 @@ pub fn configure_router(api: &toml::Value) -> Arc> { .as_array() .expect("Expecting TOML array."); for path in paths { + trace!("adding path: {:?}", path); index += 1; router .add(path.as_str().expect("Expecting a path string."), index) @@ -549,7 +575,7 @@ pub async fn compose_reference_documentation( help += &document_route(meta, entry); }); } - help += &format!("{}\n", &vk(meta, HTML_BOTTOM.as_ref())); + help = format!("{}{}\n", help, &vk(meta, HTML_BOTTOM.as_ref())); Ok(tide::Response::builder(200) .content_type(tide::http::mime::HTML) .body(help) @@ -666,8 +692,10 @@ pub fn check_literals(url: &Url, api: &Value, first_segment: &str) -> String { 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", useg, pseg); + typos = format!( + "{}

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

\n", + typos, useg, pseg + ); } }); } @@ -744,7 +772,6 @@ pub async fn disco_web_handler(req: Request) -> tide::Result { } } -// TODO The routes should come from api.toml. pub async fn init_web_server( base_url: &str, state: AppServerState, @@ -794,30 +821,118 @@ fn get_cmd_line_map() -> config::Environment { })) } +/// Compose the path to the application's configuration file +pub fn compose_config_path(org_dir_name: &str, app_name: &str) -> PathBuf { + let mut app_config_path = org_data_path(org_dir_name); + app_config_path = app_config_path.join(app_name).join(app_name); + app_config_path.set_extension("toml"); + app_config_path +} + /// Get the application configuration /// -/// Gets the configuration from -/// - Defaults in the source -/// - A configuration file config/app.toml +/// Build the configuration from +/// - Defaults in the tide-disco source +/// - Defaults passed from the app +/// - A configuration file from the app /// - Command line arguments /// - Environment variables -/// Last one wins. Additional file sources can be added. -pub fn get_settings() -> Result { - // In the config-rs crate, environment variable names are - // converted to lower case, but keys in files are not, so if we - // want environment variables to override file value, we must make - // file keys lower case. This is a config-rs bug. See - // https://github.com/mehcode/config-rs/issues/340 - Config::builder() - .set_default(ConfigKey::base_url.as_ref(), "http://localhost:65535")? - .set_default(ConfigKey::disco_toml.as_ref(), "api/disco.toml")? - .set_default(ConfigKey::brand_toml.as_ref(), "api/brand.toml")? - .set_default(ConfigKey::api_toml.as_ref(), "api/api.toml")? - .set_default(ConfigKey::ansi_color.as_ref(), false)? +/// Last one wins. +/// +/// Environment variables have a prefix of the given app_name in upper case with hyphens converted +/// to underscores. Hyphens are illegal in environment variables in bash, et.al.. +pub fn compose_settings( + org_name: &str, + app_name: &str, + app_defaults: &[(&str, &str)], +) -> Result { + let app_config_file = &compose_config_path(org_name, app_name); + { + let app_config = OpenOptions::new() + .write(true) + .create_new(true) + .open(app_config_file); + if let Ok(mut app_config) = app_config { + write!( + app_config, + "# {app_name} configuration\n\n\ + # Note: keys must be lower case.\n\n" + ) + .map_err(|e| ConfigError::Foreign(e.into()))?; + for (k, v) in app_defaults { + writeln!(app_config, "{k} = \"{v}\"") + .map_err(|e| ConfigError::Foreign(e.into()))?; + } + } + // app_config file handle gets closed exiting this scope so + // Config can read it. + } + let env_var_prefix = &app_name.replace('-', "_"); + let org_config_file = org_data_path(org_name).join("org.toml"); + // In the config-rs crate, environment variable names are converted to lower case, but keys in + // files are not, so if we want environment variables to override file value, we must make file + // keys lower case. This is a config-rs bug. See https://github.com/mehcode/config-rs/issues/340 + let mut builder = Config::builder() + .set_default(DiscoKey::base_url.as_ref(), "http://localhost:65535")? + .set_default(DiscoKey::disco_toml.as_ref(), "disco.toml")? // TODO path to share config + .set_default( + DiscoKey::app_toml.as_ref(), + app_api_path(org_name, app_name) + .to_str() + .expect("Invalid api path"), + )? + .set_default(DiscoKey::ansi_color.as_ref(), false)? .add_source(config::File::with_name("config/default.toml")) - .add_source(config::File::with_name("config/org.toml")) - .add_source(config::File::with_name("config/app.toml")) + .add_source(config::File::with_name( + org_config_file + .to_str() + .expect("Invalid organization configuration file path"), + )) + .add_source(config::File::with_name( + app_config_file + .to_str() + .expect("Invalid application configuration file path"), + )) .add_source(get_cmd_line_map::()) - .add_source(config::Environment::with_prefix("APP")) - .build() + .add_source(config::Environment::with_prefix(env_var_prefix)); // No hyphens allowed + for (k, v) in app_defaults { + builder = builder.set_default(*k, *v).expect("Failed to set default"); + } + builder.build() +} + +pub fn init_logging(want_color: bool) { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_ansi(want_color) + .init(); +} + +pub fn org_data_path(org_name: &str) -> PathBuf { + dirs::data_local_dir() + .unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from("./"))) + .join(org_name) +} + +pub fn app_api_path(org_name: &str, app_name: &str) -> PathBuf { + org_data_path(org_name).join(app_name).join("api.toml") +} + +/// Wait for the server to respond to a connection request +/// +/// This is useful for tests for which it doesn't make sense to send requests until the server has +/// started. +pub async fn wait_for_server(base_url: &str) { + // Wait for the server to come up and start serving. + let pause_ms = Duration::from_millis(100); + for _ in 0..STARTUP_RETRIES { + if surf::connect(base_url).send().await.is_ok() { + return; + } + sleep(pause_ms).await; + } + panic!( + "Address Book did not start in {:?} milliseconds", + pause_ms * STARTUP_RETRIES + ); } diff --git a/src/main.rs b/src/main.rs index 58965bad..31f12822 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,33 +1,18 @@ use crate::signal::Interrupt; use async_std::sync::{Arc, RwLock}; -use clap::Parser; use config::ConfigError; use signal::InterruptHandle; use signal_hook::consts::{SIGINT, SIGTERM, SIGUSR1}; -use std::{path::PathBuf, process}; +use std::env::current_dir; +use std::process; use tide_disco::{ - configure_router, get_api_path, get_settings, init_web_server, load_api, AppServerState, - ConfigKey, HealthStatus::*, + app_api_path, compose_settings, configure_router, get_api_path, init_web_server, load_api, + AppServerState, DiscoArgs, DiscoKey, HealthStatus::*, }; use tracing::info; -use url::Url; mod signal; -#[derive(Parser, Debug)] -#[clap(author, version, about, long_about = None)] -struct Args { - #[clap(long)] - /// Server address - base_url: Option, - #[clap(long)] - /// HTTP routes - api_toml: Option, - /// If true, log in color. Otherwise, no color. - #[clap(long)] - ansi_color: Option, -} - impl Interrupt for InterruptHandle { fn signal_action(signal: i32) { // TOOD modify web_state based on the signal. @@ -36,13 +21,25 @@ impl Interrupt for InterruptHandle { } } +// 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. +// TODO integrate discoverability into the new method of wrapping Tide. #[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 = get_settings::()?; + 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("ansi_color").unwrap_or(false); + 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. @@ -52,13 +49,18 @@ async fn main() -> Result<(), ConfigError> { .try_init() .unwrap(); - info!("{:?}", settings); + 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(ConfigKey::api_toml.as_ref())?; - let base_url = &settings.get_string(ConfigKey::base_url.as_ref())?; + 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); diff --git a/src/request.rs b/src/request.rs index 20ee8863..24e6d6c8 100644 --- a/src/request.rs +++ b/src/request.rs @@ -28,6 +28,9 @@ pub enum RequestError { name: String, expected: String, }, + + #[snafu(display("Unable to compose JSON"))] + JsonSnafu, } /// Parameters passed to a route handler. @@ -232,6 +235,13 @@ impl RequestParams { pub fn body_bytes(&self) -> Vec { self.post_data.clone() } + + pub fn body_json(&self) -> Result + where + T: serde::de::DeserializeOwned, + { + serde_json::from_slice(&self.post_data.clone()).map_err(|_| RequestError::JsonSnafu {}) + } } #[derive(Clone, Debug)]