Skip to content
Clone

refactor(apphost): switch to clap-based CLI with serve and openapi subcommands

zhenyi committed
parent 2360a4a commit 74ba1ef Browse files

5 files changed

+220 −79

File tree 5
Added app/apihost/src/cli.rs View app/apihost/src/cli.rs
@@ -0,0 +1,65 @@
1use clap::{Args, Parser, Subcommand, ValueEnum};
2use std::path::PathBuf;
3
4pub const DEFAULT_BIND: &str = "0.0.0.0";
5pub const DEFAULT_PORT: u16 = 8080;
6pub const DEFAULT_LOG_FILTER: &str = "info,webapi=debug,apihost=debug";
7
8/// Untitled HTTP API host.
9///
10/// Without a subcommand (and without `--openapi`), the server is launched with
11/// the provided options. Pass a subcommand to run a one-shot task instead.
12#[derive(Debug, Parser)]
13#[command(name = "apihost", version, about, long_about = None)]
14pub struct Cli {
15 #[command(flatten)]
16 pub serve: ServeArgs,
17
18 #[command(subcommand)]
19 pub command: Option<Command>,
20}
21
22#[derive(Debug, Subcommand)]
23pub enum Command {
24 /// Run the HTTP API server with the provided options.
25 Serve(ServeArgs),
26 /// Emit the OpenAPI 3 specification for the webapi surface.
27 Openapi(OpenapiArgs),
28}
29
30#[derive(Debug, Args, Clone)]
31pub struct ServeArgs {
32 /// Path to a config file to load.
33 #[arg(long, value_name = "PATH")]
34 pub config: Option<String>,
35
36 /// Address to bind on.
37 #[arg(long, value_name = "ADDR", default_value = DEFAULT_BIND)]
38 pub bind: String,
39
40 /// Port to listen on.
41 #[arg(long, value_name = "PORT", default_value_t = DEFAULT_PORT)]
42 pub port: u16,
43}
44
45#[derive(Debug, Args, Clone)]
46pub struct OpenapiArgs {
47 /// Output format for the spec.
48 #[arg(long, value_enum, default_value_t = OutputFormat::Json)]
49 pub format: OutputFormat,
50
51 /// Pretty-print JSON output. Ignored for `--format yaml`.
52 #[arg(long)]
53 pub pretty: bool,
54
55 /// Destination file path. When omitted, defaults to `openapi.json` in the
56 /// current working directory.
57 #[arg(long, short = 'o', value_name = "PATH")]
58 pub output: Option<PathBuf>,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
62pub enum OutputFormat {
63 Json,
64 Yaml,
65}
Added app/apihost/src/cmd_openapi.rs View app/apihost/src/cmd_openapi.rs
@@ -0,0 +1,69 @@
1use std::io::Write;
2
3use thiserror::Error;
4use webapi::openapi::openapi as build_openapi;
5
6use crate::cli::{self, OpenapiArgs};
7
8#[derive(Debug, Error)]
9pub enum OpenapiError {
10 #[error("serialization failed: {0}")]
11 Serialize(String),
12 #[error("io error: {0}")]
13 Io(#[from] std::io::Error),
14 #[error("openapi worker panicked: {0}")]
15 WorkerPanic(String),
16}
17
18const OPENAPI_STACK_BYTES: usize = 64 * 1024 * 1024;
19
20pub fn run(args: OpenapiArgs) -> Result<(), OpenapiError> {
21 let format = args.format;
22 let pretty = args.pretty;
23 let output = args.output;
24
25 // The merged OpenApi struct can be extremely deep; constructing it on the
26 // tokio reactor's main-thread stack overflows on some Linux defaults
27 // (8 MiB). Run it on a worker thread with a generous stack instead.
28 let join = std::thread::Builder::new()
29 .name("apihost-openapi".into())
30 .stack_size(OPENAPI_STACK_BYTES)
31 .spawn(move || -> Result<Vec<u8>, OpenapiError> {
32 let doc = build_openapi();
33 let bytes = match (format, pretty) {
34 (cli::OutputFormat::Json, true) => doc
35 .to_pretty_json()
36 .map_err(|e| OpenapiError::Serialize(e.to_string()))?
37 .into_bytes(),
38 (cli::OutputFormat::Json, false) => doc
39 .to_json()
40 .map_err(|e| OpenapiError::Serialize(e.to_string()))?
41 .into_bytes(),
42 (cli::OutputFormat::Yaml, _) => doc
43 .to_yaml()
44 .map_err(|e| OpenapiError::Serialize(e.to_string()))?
45 .into_bytes(),
46 };
47 Ok(bytes)
48 })
49 .map_err(|e| OpenapiError::WorkerPanic(e.to_string()))?;
50
51 let bytes = join
52 .join()
53 .map_err(|_| OpenapiError::WorkerPanic("worker exited without result".into()))??;
54
55 match output {
56 Some(path) => {
57 std::fs::write(&path, &bytes)?;
58 tracing::info!(
59 path = %path.display(),
60 bytes = bytes.len(),
61 "wrote openapi spec"
62 );
63 }
64 None => {
65 std::io::stdout().write_all(&bytes)?;
66 }
67 }
68 Ok(())
69}
Added app/apihost/src/cmd_serve.rs View app/apihost/src/cmd_serve.rs
@@ -0,0 +1,65 @@
1use std::net::{IpAddr, SocketAddr};
2use std::str::FromStr;
3
4use axum::serve;
5use config::ConfigLoader;
6use tokio::net::TcpListener;
7use tracing::info;
8use webapi::{AppServices, router};
9
10use crate::cli::{ServeArgs, DEFAULT_LOG_FILTER};
11
12pub async fn run(args: ServeArgs) -> Result<(), Box<dyn std::error::Error>> {
13 let mut loader = ConfigLoader::new();
14 if let Some(path) = args.config {
15 loader = loader.config_file(path);
16 }
17
18 let bind = IpAddr::from_str(&args.bind)
19 .map_err(|_| format!("invalid --bind address: {}", args.bind))?;
20 let port = args.port;
21
22 let mut app = loader.load()?;
23 app.obs = app.obs.with_app_defaults("apihost", DEFAULT_LOG_FILTER);
24 let _obs = obs::init(&app.obs)?;
25 let addr = SocketAddr::new(bind, port);
26
27 let services = AppServices::build(&app).await?;
28 let listener = TcpListener::bind(addr).await?;
29 info!(%addr, "webapi listening");
30
31 serve(listener, router(services).into_make_service_with_connect_info::<SocketAddr>())
32 .with_graceful_shutdown(shutdown_signal())
33 .await?;
34 info!("apihost shut down cleanly");
35 Ok(())
36}
37
38async fn shutdown_signal() {
39 let ctrl_c = async {
40 if let Err(e) = tokio::signal::ctrl_c().await {
41 tracing::warn!(error = %e, "failed to install ctrl-c handler");
42 }
43 };
44
45 #[cfg(unix)]
46 let term = async {
47 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
48 Ok(mut s) => {
49 s.recv().await;
50 }
51 Err(e) => {
52 tracing::warn!(error = %e, "failed to install SIGTERM handler");
53 std::future::pending::<()>().await;
54 }
55 }
56 };
57
58 #[cfg(not(unix))]
59 let term = std::future::pending::<()>();
60
61 tokio::select! {
62 _ = ctrl_c => tracing::info!("ctrl-c received, beginning shutdown"),
63 _ = term => tracing::info!("SIGTERM received, beginning shutdown"),
64 }
65}
Modified app/apihost/src/main.rs View app/apihost/src/main.rs
@@ -1,15 +1,12 @@
1use std::net::{IpAddr, SocketAddr};
2use std::str::FromStr;
1use std::path::PathBuf;
2
3use clap::Parser;
34
4use axum::serve;
5use config::ConfigLoader;
6use tokio::net::TcpListener;
7use tracing::info;
8use webapi::{AppServices, router};
5mod cli;
6mod cmd_openapi;
7mod cmd_serve;
98
10const DEFAULT_BIND: &str = "0.0.0.0";
11const DEFAULT_PORT: u16 = 8080;
12const DEFAULT_LOG_FILTER: &str = "info,webapi=debug,apihost=debug";
9use cli::{Cli, Command, OpenapiArgs};
1310
1411#[tokio::main]
1512async fn main() {
@@ -20,78 +17,21 @@
2017}
2118
2219async fn run() -> Result<(), Box<dyn std::error::Error>> {
23 let mut loader = ConfigLoader::new();
24 let mut bind = IpAddr::from_str(DEFAULT_BIND)?;
25 let mut port = DEFAULT_PORT;
20 let cli = Cli::parse();
2621
27 let mut args = std::env::args().skip(1);
28 while let Some(arg) = args.next() {
29 match arg.as_str() {
30 "--config" => {
31 let path = args.next().ok_or("--config requires a path argument")?;
32 loader = loader.config_file(path);
33 }
34 "--bind" => {
35 let addr = args.next().ok_or("--bind requires an address argument")?;
36 bind = IpAddr::from_str(&addr)
37 .map_err(|_| format!("invalid --bind address: {addr}"))?;
38 }
39 "--port" => {
40 let value = args.next().ok_or("--port requires a port argument")?;
41 port = value
42 .parse::<u16>()
43 .map_err(|_| format!("invalid --port value: {value}"))?;
44 }
45 other => {
46 return Err(format!(
47 "unknown argument: {other} (usage: apihost [--config <path>] [--bind <addr>] [--port <port>])"
48 )
49 .into());
50 }
22 match cli.command {
23 None => cmd_serve::run(cli.serve).await,
24 Some(Command::Serve(args)) => cmd_serve::run(args).await,
25 Some(Command::Openapi(args)) => {
26 let args = normalize_openapi(args);
27 cmd_openapi::run(args).map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
5128 }
5229 }
53
54 let mut app = loader.load()?;
55 app.obs = app.obs.with_app_defaults("apihost", DEFAULT_LOG_FILTER);
56 let _obs = obs::init(&app.obs)?;
57 let addr = SocketAddr::new(bind, port);
58
59 let services = AppServices::build(&app).await?;
60 let listener = TcpListener::bind(addr).await?;
61 info!(%addr, "webapi listening");
62
63 serve(listener, router(services).into_make_service_with_connect_info::<SocketAddr>())
64 .with_graceful_shutdown(shutdown_signal())
65 .await?;
66 info!("apihost shut down cleanly");
67 Ok(())
6830}
6931
70async fn shutdown_signal() {
71 let ctrl_c = async {
72 if let Err(e) = tokio::signal::ctrl_c().await {
73 tracing::warn!(error = %e, "failed to install ctrl-c handler");
74 }
75 };
76
77 #[cfg(unix)]
78 let term = async {
79 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
80 Ok(mut s) => {
81 s.recv().await;
82 }
83 Err(e) => {
84 tracing::warn!(error = %e, "failed to install SIGTERM handler");
85 std::future::pending::<()>().await;
86 }
87 }
88 };
89
90 #[cfg(not(unix))]
91 let term = std::future::pending::<()>();
92
93 tokio::select! {
94 _ = ctrl_c => tracing::info!("ctrl-c received, beginning shutdown"),
95 _ = term => tracing::info!("SIGTERM received, beginning shutdown"),
32fn normalize_openapi(mut args: OpenapiArgs) -> OpenapiArgs {
33 if args.output.is_none() {
34 args.output = Some(PathBuf::from("openapi.json"));
9635 }
36 args
9737}
Modified app/apihost/Cargo.toml View app/apihost/Cargo.toml
@@ -14,4 +14,6 @@
1414obs.workspace = true
1515axum.workspace = true
1616tokio.workspace = true
17tracing.workspace = true
No newline at end of file
17tracing.workspace = true
18clap.workspace = true
19thiserror.workspace = true
No newline at end of file