use crate::ApiKey::*;
use async_std::sync::{Arc, RwLock};
use async_std::task::sleep;
use clap::CommandFactory;
use config::{Config, ConfigError};
use routefinder::Router;
use serde::Deserialize;
use std::fs::{OpenOptions, read_to_string};
use std::io::Write;
use std::str::FromStr;
use std::time::Duration;
use std::{
collections::HashMap,
env,
path::{Path, PathBuf},
};
use strum_macros::{AsRefStr, EnumString};
use tagged_base64::TaggedBase64;
use tide::http::mime;
use toml::value::Value;
use tracing::{error, trace};
pub mod api;
pub mod app;
pub mod error;
pub mod healthcheck;
pub mod listener;
pub mod method;
pub mod metrics;
pub mod request;
pub mod socket;
pub mod status;
pub mod testing;
mod dispatch;
mod middleware;
mod route;
pub use api::Api;
pub use app::App;
pub use error::Error;
pub use method::Method;
pub use request::{RequestError, RequestParam, RequestParamType, RequestParamValue, RequestParams};
pub use status::StatusCode;
pub use tide::http;
pub use url::Url;
pub type Html = maud::Markup;
pub const SERVER_STARTUP_RETRIES: u64 = 255;
pub const SERVER_STARTUP_SLEEP_MS: u64 = 100;
#[derive(clap::Args, Debug)]
#[clap(author, version, about, long_about = None)]
pub struct DiscoArgs {
#[clap(long)]
pub base_url: Option<Url>,
#[clap(long)]
pub api_toml: Option<PathBuf>,
#[clap(long)]
pub ansi_color: Option<bool>,
}
#[derive(AsRefStr, Debug)]
#[allow(non_camel_case_types)]
pub enum DiscoKey {
ansi_color,
api_toml,
app_toml,
base_url,
disco_toml,
}
#[derive(AsRefStr, Clone, Debug, Deserialize, strum_macros::Display)]
pub enum HealthStatus {
Starting,
Available,
Stopping,
}
#[derive(Clone)]
pub struct ServerState<AppState> {
pub health_status: Arc<RwLock<HealthStatus>>,
pub app_state: AppState,
pub router: Arc<Router<usize>>,
}
pub type AppState = Value;
pub type AppServerState = ServerState<AppState>;
#[allow(non_camel_case_types, clippy::upper_case_acronyms)]
#[derive(AsRefStr, Debug)]
enum ApiKey {
DOC,
METHOD,
PATH,
#[strum(serialize = "route")]
ROUTE,
}
pub fn check_api(api: toml::Value) -> bool {
let mut error_count = 0;
if let Some(api_map) = api[ROUTE.as_ref()].as_table() {
let methods = vec!["GET", "POST"];
api_map.values().for_each(|entry| {
if let Some(paths) = entry[PATH.as_ref()].as_array() {
let first_segment = get_first_segment(vs(&paths[0]));
let method = vk(entry, METHOD.as_ref());
if !methods.contains(&method.as_str()) {
error!(
"Route: /{}: Unsupported method: {}. Expected one of: {:?}",
&first_segment, &method, &methods
);
error_count += 1;
}
if entry.get(DOC.as_ref()).is_none() || entry[DOC.as_ref()].as_str().is_none() {
error!("Route: /{}: Missing DOC string.", &first_segment);
error_count += 1;
}
let paths = entry[PATH.as_ref()]
.as_array()
.expect("Expecting TOML array.");
for path in paths {
if path.is_str() {
for segment in path.as_str().unwrap().split('/') {
if let Some(parameter) = segment.strip_prefix(':') {
let stype = vk(entry, segment);
if UrlSegment::from_str(&stype).is_err() {
error!(
"Route /{}: Unrecognized type {} for pattern {}.",
&first_segment, stype, ¶meter
);
error_count += 1;
}
}
}
} else {
error!(
"Route /{}: Found path '{:?}' but expecting a string.",
&first_segment, path
);
}
}
} else {
error!("Expecting TOML array for {:?}.", &entry[PATH.as_ref()]);
error_count += 1;
}
})
}
error_count == 0
}
pub fn load_api(path: &Path) -> toml::Value {
let messages = read_to_string(path).unwrap_or_else(|_| panic!("Unable to read {:?}.", &path));
let api: toml::Value =
toml::from_str(&messages).unwrap_or_else(|_| panic!("Unable to parse {:?}.", &path));
if !check_api(api.clone()) {
panic!("API specification has errors.",);
}
api
}
pub fn configure_router(api: &toml::Value) -> Arc<Router<usize>> {
let mut router = Router::new();
if let Some(api_map) = api[ROUTE.as_ref()].as_table() {
let mut index = 0usize;
api_map.values().for_each(|entry| {
let paths = entry[PATH.as_ref()]
.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)
.unwrap();
}
})
}
Arc::new(router)
}
pub async fn healthcheck(
req: tide::Request<AppServerState>,
) -> Result<tide::Response, tide::Error> {
let status = req.state().health_status.read().await;
Ok(tide::Response::builder(StatusCode::OK)
.content_type(mime::JSON)
.body(tide::prelude::json!({"status": status.as_ref() }))
.build())
}
fn vs(v: &Value) -> &str {
v.as_str().unwrap_or_else(|| {
panic!(
"Expecting TOML string, but found type {}: {:?}",
v.type_str(),
v
)
})
}
fn vk(v: &Value, key: &str) -> String {
if let Some(vv) = v.get(key) {
vv.as_str()
.unwrap_or_else(|| {
panic!(
"Expecting TOML string for {}, but found type {}",
key,
v[key].type_str()
)
})
.to_string()
} else {
error!("No value for key {}", key);
"<missing>".to_string()
}
}
fn get_first_segment(s: &str) -> String {
let first_path = s.strip_prefix('/').unwrap_or(s);
first_path
.split_once('/')
.unwrap_or((first_path, ""))
.0
.to_string()
}
#[derive(Clone, Debug, EnumString)]
pub enum UrlSegment {
Boolean(Option<bool>),
Hexadecimal(Option<u128>),
Integer(Option<u128>),
TaggedBase64(Option<TaggedBase64>),
Literal(Option<String>),
}
impl UrlSegment {
pub fn new(value: &str, vtype: UrlSegment) -> UrlSegment {
match vtype {
UrlSegment::Boolean(_) => UrlSegment::Boolean(value.parse::<bool>().ok()),
UrlSegment::Hexadecimal(_) => {
UrlSegment::Hexadecimal(u128::from_str_radix(value, 16).ok())
}
UrlSegment::Integer(_) => UrlSegment::Integer(value.parse().ok()),
UrlSegment::TaggedBase64(_) => {
UrlSegment::TaggedBase64(TaggedBase64::parse(value).ok())
}
UrlSegment::Literal(_) => UrlSegment::Literal(Some(value.to_string())),
}
}
pub fn is_bound(&self) -> bool {
match self {
UrlSegment::Boolean(v) => v.is_some(),
UrlSegment::Hexadecimal(v) => v.is_some(),
UrlSegment::Integer(v) => v.is_some(),
UrlSegment::TaggedBase64(v) => v.is_some(),
UrlSegment::Literal(v) => v.is_some(),
}
}
}
pub fn get_api_path(api_toml: &str) -> PathBuf {
[env::current_dir().unwrap(), api_toml.into()]
.iter()
.collect::<PathBuf>()
}
fn get_cmd_line_map<Args: CommandFactory>() -> config::Environment {
config::Environment::default().source(Some({
let mut cla = HashMap::new();
let matches = Args::command().get_matches();
for arg in Args::command().get_arguments() {
if let Some(value) = matches.get_one::<String>(arg.get_id().as_str()) {
let key = arg.get_id().as_str().replace('-', "_");
cla.insert(key, value.to_owned());
}
}
cla
}))
}
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
}
pub fn compose_settings<Args: CommandFactory>(
org_name: &str,
app_name: &str,
app_defaults: &[(&str, &str)],
) -> Result<Config, ConfigError> {
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()))?;
}
}
}
let env_var_prefix = &app_name.replace('-', "_");
let org_config_file = org_data_path(org_name).join("org.toml");
let mut builder = Config::builder()
.set_default(DiscoKey::base_url.as_ref(), "http://localhost:65535")?
.set_default(DiscoKey::disco_toml.as_ref(), "disco.toml")?
.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(
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::<Args>())
.add_source(config::Environment::with_prefix(env_var_prefix)); 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")
}
pub async fn wait_for_server(url: &Url, retries: u64, sleep_ms: u64) {
let dur = Duration::from_millis(sleep_ms);
for _ in 0..retries {
if reqwest::Client::new()
.head(url.clone())
.send()
.await
.is_ok()
{
return;
}
sleep(dur).await;
}
panic!(
"Server did not start in {:?} milliseconds",
sleep_ms * SERVER_STARTUP_RETRIES
);
}