apsis-book/src/main.rs

269 lines
9.2 KiB
Rust

//! The `apsis-web` service binary.
//!
//! The process binds an HTTP/1.1 listener and answers requests from three
//! sources of content that are all embedded in the binary:
//!
//! - the concept documentation and the knowledge graph data, read from the
//! `content/` directory at compile time;
//! - the interface documentation of the `apsis-rs` orbit layer, extracted
//! from its source doc comments and embedded as `content/api.json`;
//! - the frontend build output under `frontend/dist/`, embedded by
//! `build.rs`.
//!
//! Because every asset is embedded, the binary is self-contained: it serves
//! the same content from a copy that has no source tree next to it.
//!
//! The `--host` and `--port` options bind the listener, and the language of
//! the user interface is selected in the browser and defaults to Chinese.
mod api;
mod docs;
mod embedded;
mod graph;
mod http;
mod i18n;
mod json;
mod state;
mod tools;
use std::net::SocketAddr;
use std::process::ExitCode;
use std::sync::Arc;
use crate::api::Router;
use crate::http::{Server, ServerConfig};
use crate::state::AppState;
/// Default bind host, which accepts connections from the local machine
/// only.
const DEFAULT_HOST: &str = "127.0.0.1";
/// Default bind port.
const DEFAULT_PORT: u16 = 8080;
/// Entry point.
fn main() -> ExitCode {
let options = match Options::from_environment() {
Ok(options) => options,
Err(message) => {
eprintln!("apsis-web: {message}");
return ExitCode::FAILURE;
}
};
let state = Arc::new(AppState::new());
let router = Arc::new(Router::new(state));
let config = ServerConfig {
workers: options.workers,
..ServerConfig::default()
};
let server = match Server::bind_address(options.address, config) {
Ok(server) => server,
Err(error) => {
eprintln!("apsis-web: cannot bind {}: {error}", options.address);
return ExitCode::FAILURE;
}
};
let bound = server
.local_address()
.map(|address| address.to_string())
.unwrap_or_else(|_| options.address.to_string());
println!("apsis-web listening on http://{bound}");
if !embedded::is_populated() {
println!("apsis-web: no frontend build is embedded; serving the placeholder page");
}
match server.run(router) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("apsis-web: server stopped: {error}");
ExitCode::FAILURE
}
}
}
/// Command line options, read from arguments and then from the environment.
struct Options {
/// Address to bind.
address: SocketAddr,
/// Number of worker threads.
workers: usize,
}
impl Options {
/// Builds the options from the process arguments.
///
/// Arguments override the environment variables `APSIS_WEB_HOST`,
/// `APSIS_WEB_PORT`, `APSIS_WEB_ADDRESS`, and `APSIS_WEB_WORKERS`.
/// `--help` and `--version` print and exit.
///
/// The host and the port may be given separately with `--host` and
/// `--port`, or together with `--address`. Giving both `--address` and
/// either of the two separate options is an error, because the order in
/// which they would apply is not defined.
fn from_environment() -> Result<Self, String> {
let mut host = std::env::var("APSIS_WEB_HOST").unwrap_or_else(|_| DEFAULT_HOST.to_string());
let mut port = match std::env::var("APSIS_WEB_PORT") {
Ok(value) => parse_port(&value).map_err(|error| format!("APSIS_WEB_PORT: {error}"))?,
Err(_) => DEFAULT_PORT,
};
let mut combined: Option<SocketAddr> = match std::env::var("APSIS_WEB_ADDRESS") {
Ok(value) => {
Some(parse_address(&value).map_err(|error| format!("APSIS_WEB_ADDRESS: {error}"))?)
}
Err(_) => None,
};
let mut workers = match std::env::var("APSIS_WEB_WORKERS") {
Ok(value) => value
.parse()
.map_err(|_| "APSIS_WEB_WORKERS must be a positive integer".to_string())?,
Err(_) => 4,
};
if workers == 0 {
return Err("APSIS_WEB_WORKERS must be greater than zero".to_string());
}
let mut host_given = false;
let mut port_given = false;
let mut arguments = std::env::args().skip(1);
while let Some(argument) = arguments.next() {
match argument.as_str() {
"--host" => {
host = arguments
.next()
.ok_or_else(|| "--host needs a value".to_string())?;
host_given = true;
}
"--port" | "-p" => {
let value = arguments
.next()
.ok_or_else(|| "--port needs a value".to_string())?;
port = parse_port(&value).map_err(|error| format!("--port: {error}"))?;
port_given = true;
}
"--address" | "-a" => {
let value = arguments
.next()
.ok_or_else(|| "--address needs a value".to_string())?;
combined =
Some(parse_address(&value).map_err(|error| format!("--address: {error}"))?);
}
"--workers" | "-w" => {
let value = arguments
.next()
.ok_or_else(|| "--workers needs a value".to_string())?;
workers = value
.parse()
.map_err(|_| "--workers needs a positive integer".to_string())?;
}
"--help" | "-h" => {
println!("{}", USAGE);
std::process::exit(0);
}
"--version" | "-V" => {
println!("apsis-web {}", env!("CARGO_PKG_VERSION"));
std::process::exit(0);
}
other => return Err(format!("unknown argument {other:?}; try --help")),
}
}
if workers == 0 {
return Err("--workers must be greater than zero".to_string());
}
let address = if host_given || port_given {
if combined.is_some() {
return Err(
"--address cannot be combined with --host or --port; use one form".to_string(),
);
}
resolve(&host, port).map_err(|error| format!("--host: {error}"))?
} else if let Some(address) = combined {
address
} else {
resolve(&host, port).map_err(|error| format!("APSIS_WEB_HOST: {error}"))?
};
Ok(Self { address, workers })
}
}
/// Parses a port number.
fn parse_port(value: &str) -> Result<u16, String> {
value
.trim()
.parse::<u16>()
.map_err(|_| format!("{value:?} is not a port number"))
}
/// Parses an address written as `host:port`.
fn parse_address(value: &str) -> Result<SocketAddr, String> {
value
.trim()
.parse::<SocketAddr>()
.map_err(|_| format!("{value:?} is not a host and port"))
}
/// Resolves a host and a port into a socket address.
///
/// A host may be a literal address, a bracketed or bare IPv6 literal, or a
/// name that the operating system resolves. A name that resolves to several
/// addresses uses the first one, which is the order the resolver reports and
/// therefore the preferred one.
fn resolve(host: &str, port: u16) -> Result<SocketAddr, String> {
let host = host.trim();
if host.is_empty() {
return Err("the host is empty".to_string());
}
// A literal address, including a bracketed IPv6 literal, needs no name
// resolution.
let literal = host.trim_start_matches('[').trim_end_matches(']');
if let Ok(ip) = literal.parse::<std::net::IpAddr>() {
return Ok(SocketAddr::new(ip, port));
}
// A name that already carries a port, such as `localhost:9000`, is
// accepted so that a value written in the combined form still works.
if let Ok(address) = host.parse::<SocketAddr>() {
return Ok(address);
}
use std::net::ToSocketAddrs;
match (host, port).to_socket_addrs() {
Ok(mut addresses) => addresses
.next()
.ok_or_else(|| format!("{host:?} does not resolve to an address")),
Err(error) => Err(format!("{host:?} does not resolve: {error}")),
}
}
/// Usage text.
const USAGE: &str = "\
apsis-web: web service for the ApsisRS computational kernels
USAGE:
apsis-web [OPTIONS]
OPTIONS:
-a, --address <HOST:PORT> address to bind (default 127.0.0.1:8080)
--host <HOST> interface to bind (default 127.0.0.1)
-p, --port <PORT> port to bind (default 8080)
-w, --workers <N> worker threads (default 4)
-h, --help print this help
-V, --version print the version
ENVIRONMENT:
APSIS_WEB_ADDRESS bind address, as HOST:PORT
APSIS_WEB_HOST interface to bind
APSIS_WEB_PORT port to bind
APSIS_WEB_WORKERS worker threads
EXAMPLES:
apsis-web --host 0.0.0.0 --port 8080
apsis-web --address 127.0.0.1:9000";