things3_cloud/commands/
webserver.rs1use std::process::Command as ProcessCommand;
2
3use anyhow::{Context, Result};
4use clap::Args;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
8
9use crate::{app::Cli, commands::Command};
10
11#[derive(Debug, Args)]
12pub struct WebserverArgs {
13 #[arg(long, default_value = "127.0.0.1")]
15 pub host: String,
16 #[arg(long, default_value_t = 8765)]
18 pub port: u16,
19}
20
21#[derive(Debug, Deserialize)]
22struct CommandRequest {
23 args: Option<Vec<String>>,
24}
25
26#[derive(Debug, Serialize)]
27#[serde(tag = "status", rename_all = "snake_case")]
28enum CommandResponse {
29 Success {
30 data: Value,
31 },
32 Error {
33 error: String,
34 #[serde(flatten)]
35 details: ErrorDetails,
36 },
37}
38
39#[derive(Debug, Serialize, Default)]
40struct ErrorDetails {
41 #[serde(skip_serializing_if = "Option::is_none")]
42 returncode: Option<i32>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 stderr: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 stdout: Option<String>,
47}
48
49#[derive(Debug)]
50struct RequestError {
51 status: StatusCode,
52 message: String,
53}
54
55impl RequestError {
56 fn new(status: StatusCode, message: impl Into<String>) -> Self {
57 Self {
58 status,
59 message: message.into(),
60 }
61 }
62}
63
64impl Command for WebserverArgs {
65 fn run_with_ctx(
66 &self,
67 _cli: &Cli,
68 _out: &mut dyn std::io::Write,
69 _ctx: &mut dyn crate::cmd_ctx::CmdCtx,
70 ) -> Result<()> {
71 let addr = format!("{}:{}", self.host, self.port);
72 let server =
73 Server::http(&addr).map_err(|err| anyhow::anyhow!("failed to bind {addr}: {err}"))?;
74 eprintln!("things3 webserver listening on http://{addr}");
75
76 for request in server.incoming_requests() {
77 if let Err(err) = handle_request(request) {
78 eprintln!("webserver request error: {err}");
79 }
80 }
81
82 Ok(())
83 }
84}
85
86fn handle_request(mut request: Request) -> Result<()> {
87 let (status, payload) = match process_request(&mut request) {
88 Ok(response) => (StatusCode(200), response),
89 Err(err) => (
90 err.status,
91 CommandResponse::Error {
92 error: err.message,
93 details: Default::default(),
94 },
95 ),
96 };
97
98 send_json(request, status, &payload)
99}
100
101fn process_request(request: &mut Request) -> std::result::Result<CommandResponse, RequestError> {
102 if request.method() != &Method::Post {
103 return Err(RequestError::new(StatusCode(405), "method not allowed"));
104 }
105
106 if request.url() != "/" {
107 return Err(RequestError::new(StatusCode(404), "not found"));
108 }
109
110 let mut body = String::new();
111 request
112 .as_reader()
113 .read_to_string(&mut body)
114 .map_err(|_| RequestError::new(StatusCode(400), "failed reading request body"))?;
115
116 let parsed = if body.trim().is_empty() {
117 CommandRequest { args: None }
118 } else {
119 serde_json::from_str::<CommandRequest>(&body).map_err(|err| {
120 RequestError::new(StatusCode(400), format!("invalid JSON payload: {err}"))
121 })?
122 };
123
124 let args = normalize_args(parsed.args);
125
126 execute_command(&args).map_err(|err| {
127 RequestError::new(StatusCode(500), format!("failed to execute command: {err}"))
128 })
129}
130
131fn normalize_args(args: Option<Vec<String>>) -> Vec<String> {
132 let mut out = args
133 .unwrap_or_default()
134 .into_iter()
135 .map(|s| s.trim().to_string())
136 .filter(|s| !s.is_empty())
137 .collect::<Vec<_>>();
138
139 if out.is_empty() {
140 out.push("today".to_string());
141 }
142
143 out
144}
145
146fn execute_command(args: &[String]) -> Result<CommandResponse> {
147 let exe = std::env::current_exe().context("failed to locate current executable")?;
148
149 let mut argv = args.to_vec();
150 if !argv.iter().any(|v| v == "--json") {
151 argv.insert(0, "--json".to_string());
152 }
153
154 let output = ProcessCommand::new(exe)
155 .args(&argv)
156 .output()
157 .context("failed to execute command")?;
158
159 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
160 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
161
162 if !output.status.success() {
163 return Ok(command_error_response(
164 "command failed",
165 output.status.code(),
166 stdout,
167 stderr,
168 ));
169 }
170
171 match serde_json::from_str::<Value>(&stdout) {
172 Ok(data) => Ok(CommandResponse::Success { data }),
173 Err(_) => Ok(command_error_response(
174 "command did not return JSON output",
175 output.status.code(),
176 stdout,
177 stderr,
178 )),
179 }
180}
181
182fn command_error_response(
183 message: &str,
184 returncode: Option<i32>,
185 stdout: String,
186 stderr: String,
187) -> CommandResponse {
188 CommandResponse::Error {
189 error: message.to_string(),
190 details: ErrorDetails {
191 returncode,
192 stderr: (!stderr.is_empty()).then_some(stderr),
193 stdout: (!stdout.is_empty()).then_some(stdout),
194 },
195 }
196}
197
198fn send_json<T: Serialize>(request: Request, status: StatusCode, payload: &T) -> Result<()> {
199 let body = serde_json::to_string(payload)?;
200 let content_type = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
201 .expect("static content-type header should always be valid");
202
203 let response = Response::from_string(body)
204 .with_status_code(status)
205 .with_header(content_type);
206 request.respond(response)?;
207 Ok(())
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn normalize_args_defaults_to_today() {
216 assert_eq!(normalize_args(None), vec!["today"]);
217 assert_eq!(
218 normalize_args(Some(vec!["".to_string(), " ".to_string()])),
219 vec!["today"]
220 );
221 }
222
223 #[test]
224 fn normalize_args_trims_entries() {
225 assert_eq!(
226 normalize_args(Some(vec![" today ".to_string(), " ".to_string()])),
227 vec!["today"]
228 );
229 }
230}