use anyhow::{Context, Result};
use axum::Router;
use axum::extract::State;
use axum::http::StatusCode;
use axum::http::header;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{Html, IntoResponse, Response};
use axum::routing::get;
use notify::RecursiveMode;
use notify_debouncer_mini::{Debouncer, new_debouncer};
use std::convert::Infallible;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, mpsc};
use std::thread;
use std::time::Duration;
use tokio::sync::broadcast;
use tokio_stream::StreamExt;
use tokio_stream::wrappers::BroadcastStream;
use crate::read_and_parse;
use crate::versioned::{self, SpecVersion};
#[derive(Copy, Clone, Debug, clap::ValueEnum)]
pub(crate) enum Renderer {
Redoc,
#[value(name = "swagger-ui")]
SwaggerUi,
}
#[derive(clap::Args)]
pub struct PreviewArgs {
pub(crate) file: PathBuf,
#[arg(long, value_enum)]
pub(crate) from: Option<SpecVersion>,
#[arg(long)]
pub(crate) no_open: bool,
#[arg(long)]
pub(crate) watch: bool,
#[arg(long, value_enum, default_value = "redoc")]
pub(crate) renderer: Renderer,
}
#[derive(Clone, Debug)]
struct SpecSource {
file: PathBuf,
from: Option<SpecVersion>,
}
impl SpecSource {
fn from_args(args: &PreviewArgs) -> Self {
Self {
file: args.file.clone(),
from: args.from,
}
}
fn build_spec_json(&self) -> Result<String> {
let value = read_and_parse(&self.file)?;
let detected = versioned::detect_or_use(self.from, value)?;
let version = detected.version();
serde_json::to_string(&detected.convert_to(version)?)
.context("serializing spec for the viewer")
}
}
const REDOC_HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<title>roas — OpenAPI viewer</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>body { margin: 0; padding: 0; }</style>
</head>
<body>
<redoc spec-url='/spec'></redoc>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
</body>
</html>
"#;
const SWAGGER_UI_HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<title>roas — OpenAPI viewer</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@latest/swagger-ui.css" />
<style>body { margin: 0; padding: 0; }</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@latest/swagger-ui-bundle.js"></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({ url: '/spec', dom_id: '#swagger-ui' });
};
</script>
</body>
</html>
"#;
const RELOAD_SCRIPT: &str = r#"
<script>
(function () {
const es = new EventSource('/reload');
es.onmessage = function () { window.location.reload(); };
})();
</script>"#;
fn render_html(renderer: Renderer, watch: bool) -> String {
let base = match renderer {
Renderer::Redoc => REDOC_HTML,
Renderer::SwaggerUi => SWAGGER_UI_HTML,
};
if watch {
base.replace("</body>", &format!("{RELOAD_SCRIPT}\n </body>"))
} else {
base.to_string()
}
}
fn spawn_file_watcher(
source: SpecSource,
spec_json: Arc<Mutex<String>>,
reload_tx: broadcast::Sender<()>,
) -> Result<Debouncer<notify::RecommendedWatcher>> {
let (event_tx, event_rx) = mpsc::channel::<()>();
let mut debouncer = new_debouncer(
Duration::from_millis(150),
move |res: notify_debouncer_mini::DebounceEventResult| {
if res.is_ok() {
let _ = event_tx.send(());
}
},
)
.context("starting filesystem watcher")?;
debouncer
.watcher()
.watch(&source.file, RecursiveMode::NonRecursive)
.with_context(|| format!("watching {}", source.file.display()))?;
let display_path = source.file.display().to_string();
thread::spawn(move || {
while event_rx.recv().is_ok() {
match source.build_spec_json() {
Ok(new_json) => {
*spec_json.lock().unwrap() = new_json;
let _ = reload_tx.send(());
eprintln!("preview: reloaded {display_path}");
}
Err(e) => {
eprintln!(
"preview: failed to reload {display_path}, keeping previous version: {e}",
);
}
}
}
});
Ok(debouncer)
}
#[derive(Clone)]
struct AppState {
html: Arc<String>,
spec_json: Arc<Mutex<String>>,
reload_tx: Option<broadcast::Sender<()>>,
}
struct PreparedPreview {
listener: tokio::net::TcpListener,
url: String,
renderer_label: &'static str,
state: AppState,
_debouncer: Option<Debouncer<notify::RecommendedWatcher>>,
}
async fn prepare_preview(args: &PreviewArgs) -> Result<PreparedPreview> {
let source = SpecSource::from_args(args);
let initial_json = source.build_spec_json()?;
let spec_json = Arc::new(Mutex::new(initial_json));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.context("binding preview server listener")?;
let addr = listener
.local_addr()
.context("reading preview server local addr")?;
let url = format!("http://{addr}/");
let renderer_label = match args.renderer {
Renderer::Redoc => "Redoc",
Renderer::SwaggerUi => "Swagger UI",
};
let html = Arc::new(render_html(args.renderer, args.watch));
let (reload_tx, debouncer) = if args.watch {
let (tx, _initial_rx) = broadcast::channel::<()>(16);
let debouncer = spawn_file_watcher(source, Arc::clone(&spec_json), tx.clone())?;
(Some(tx), Some(debouncer))
} else {
(None, None)
};
let state = AppState {
html,
spec_json,
reload_tx,
};
Ok(PreparedPreview {
listener,
url,
renderer_label,
state,
_debouncer: debouncer,
})
}
pub fn run_preview(args: PreviewArgs) -> Result<()> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("building tokio runtime for preview server")?;
runtime.block_on(run_preview_async(args))
}
async fn run_preview_async(args: PreviewArgs) -> Result<()> {
let prepared = prepare_preview(&args).await?;
drive_prepared_preview(&args, prepared).await
}
async fn drive_prepared_preview(args: &PreviewArgs, prepared: PreparedPreview) -> Result<()> {
eprintln!(
"Serving {} via {} at {}",
args.file.display(),
prepared.renderer_label,
prepared.url,
);
if args.watch {
eprintln!("Watching {} for changes.", args.file.display());
}
eprintln!("Press Ctrl+C to stop.");
if !args.no_open {
let _ = webbrowser::open(&prepared.url);
}
let app = preview_router(prepared.state);
axum::serve(prepared.listener, app)
.await
.context("serving preview HTTP requests")
}
fn preview_router(state: AppState) -> Router {
Router::new()
.route("/", get(handler_index))
.route("/index.html", get(handler_index))
.route("/spec", get(handler_spec))
.route("/spec.json", get(handler_spec))
.route("/reload", get(handler_reload))
.with_state(state)
}
async fn handler_index(State(state): State<AppState>) -> Html<String> {
Html((*state.html).clone())
}
async fn handler_spec(State(state): State<AppState>) -> Response {
let body = state.spec_json.lock().unwrap().clone();
([(header::CONTENT_TYPE, "application/json")], body).into_response()
}
async fn handler_reload(State(state): State<AppState>) -> Response {
let Some(tx) = state.reload_tx.as_ref() else {
return (StatusCode::NOT_FOUND, "not found").into_response();
};
let rx = tx.subscribe();
let stream = BroadcastStream::new(rx).filter_map(|res| match res {
Ok(()) => Some(Ok::<_, Infallible>(Event::default().data("reload"))),
Err(_) => None,
});
Sse::new(stream)
.keep_alive(KeepAlive::default())
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read as IoRead, Write};
use std::net::{SocketAddr, TcpStream};
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_path(suffix: &str) -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"roas-cli-preview-test-{}-{}-{suffix}",
std::process::id(),
n,
))
}
fn write_minimal_v3_2_spec() -> PathBuf {
let path = temp_path("ok.json");
std::fs::write(
&path,
br#"{"openapi":"3.2.0","info":{"title":"x","version":"1"},"paths":{}}"#,
)
.expect("write temp spec");
path
}
async fn spawn_axum_for_prepared(
prepared: PreparedPreview,
) -> (SocketAddr, AppState, ServerHandle) {
let addr = prepared
.listener
.local_addr()
.expect("local_addr on prepared listener");
let state = prepared.state.clone();
let app = preview_router(prepared.state);
let handle = tokio::spawn(async move {
let _ = axum::serve(prepared.listener, app).await;
drop(prepared._debouncer);
});
tokio::time::sleep(Duration::from_millis(20)).await;
(addr, state, ServerHandle(Some(handle)))
}
struct ServerHandle(Option<tokio::task::JoinHandle<()>>);
impl Drop for ServerHandle {
fn drop(&mut self) {
if let Some(h) = self.0.take() {
h.abort();
}
}
}
fn send_request_sync(addr: SocketAddr, path: &str) -> TcpStream {
let mut stream = TcpStream::connect(addr).expect("connect");
let req = format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
stream.write_all(req.as_bytes()).expect("write request");
stream
}
fn parse_status_and_body(stream: &mut TcpStream) -> (u16, String, String) {
let mut raw = Vec::new();
stream.read_to_end(&mut raw).expect("read response");
let text = String::from_utf8_lossy(&raw).to_string();
let (head, body) = text
.split_once("\r\n\r\n")
.map(|(h, b)| (h.to_string(), b.to_string()))
.unwrap_or_else(|| (text.clone(), String::new()));
let status: u16 = head
.lines()
.next()
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|c| c.parse().ok())
.unwrap_or(0);
let content_type = head
.lines()
.find(|l| l.to_ascii_lowercase().starts_with("content-type:"))
.map(|l| {
l.split_once(':')
.map(|x| x.1)
.unwrap_or("")
.trim()
.to_string()
})
.unwrap_or_default();
(status, content_type, body)
}
async fn get(addr: SocketAddr, path: &str) -> (u16, String, String) {
let path = path.to_string();
tokio::task::spawn_blocking(move || {
let mut s = send_request_sync(addr, &path);
parse_status_and_body(&mut s)
})
.await
.expect("blocking task")
}
#[test]
fn redoc_html_constant_references_spec_url_and_redoc_cdn() {
assert!(
REDOC_HTML.contains("spec-url='/spec'"),
"REDOC_HTML must point at /spec",
);
assert!(
REDOC_HTML.contains("cdn.redoc.ly"),
"REDOC_HTML must load Redoc from its CDN",
);
}
#[test]
fn swagger_ui_html_constant_references_spec_url_and_swagger_ui_bundle() {
assert!(
SWAGGER_UI_HTML.contains("url: '/spec'"),
"SWAGGER_UI_HTML must point SwaggerUIBundle at /spec",
);
assert!(
SWAGGER_UI_HTML.contains("swagger-ui-dist"),
"SWAGGER_UI_HTML must load swagger-ui-dist from the CDN",
);
assert!(
SWAGGER_UI_HTML.contains("swagger-ui.css"),
"SWAGGER_UI_HTML must include the swagger-ui stylesheet",
);
}
#[test]
fn render_html_selects_the_right_shell_per_variant() {
assert!(render_html(Renderer::Redoc, false).contains("cdn.redoc.ly"));
assert!(render_html(Renderer::SwaggerUi, false).contains("swagger-ui-dist"));
}
#[test]
fn render_html_injects_reload_script_when_watch_is_on() {
let off = render_html(Renderer::Redoc, false);
let on = render_html(Renderer::Redoc, true);
assert!(!off.contains("EventSource"));
assert!(on.contains("new EventSource('/reload')"));
assert!(on.contains("window.location.reload()"));
assert!(on.contains("cdn.redoc.ly"));
}
#[test]
fn run_preview_missing_file_errors_with_reading_context() {
let args = PreviewArgs {
file: temp_path("missing.json"),
from: None,
no_open: true,
renderer: Renderer::Redoc,
watch: false,
};
let err = run_preview(args).expect_err("missing file must error before server starts");
assert!(
err.to_string().contains("reading"),
"expected `reading` context, got: {err}",
);
}
#[tokio::test]
async fn prepare_preview_with_redoc_returns_bound_listener_and_redoc_assets() {
let path = write_minimal_v3_2_spec();
let args = PreviewArgs {
file: path.clone(),
from: None,
no_open: true,
renderer: Renderer::Redoc,
watch: false,
};
let prepared = prepare_preview(&args).await.expect("prepare ok");
let addr = prepared.listener.local_addr().expect("local_addr");
assert!(addr.ip().is_loopback(), "must bind to loopback");
assert!(addr.port() > 0, "must allocate an ephemeral port");
assert_eq!(prepared.url, format!("http://127.0.0.1:{}/", addr.port()));
assert_eq!(prepared.renderer_label, "Redoc");
assert!(prepared.state.html.contains("cdn.redoc.ly"));
let parsed: serde_json::Value =
serde_json::from_str(&prepared.state.spec_json.lock().unwrap()).unwrap();
assert_eq!(parsed["openapi"], "3.2.0");
drop(prepared);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn prepare_preview_with_swagger_ui_switches_renderer_fields() {
let path = write_minimal_v3_2_spec();
let args = PreviewArgs {
file: path.clone(),
from: None,
no_open: true,
renderer: Renderer::SwaggerUi,
watch: false,
};
let prepared = prepare_preview(&args).await.expect("prepare ok");
assert_eq!(prepared.renderer_label, "Swagger UI");
assert!(prepared.state.html.contains("swagger-ui-dist"));
drop(prepared);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn prepare_preview_with_forced_version_round_trips_through_parse_as() {
let path = temp_path("forced.json");
std::fs::write(
&path,
br#"{"openapi":"3.1.0","info":{"title":"x","version":"1"},"paths":{}}"#,
)
.expect("write temp spec");
let args = PreviewArgs {
file: path.clone(),
from: Some(SpecVersion::V3_1),
no_open: true,
renderer: Renderer::Redoc,
watch: false,
};
let prepared = prepare_preview(&args).await.expect("prepare ok");
let parsed: serde_json::Value =
serde_json::from_str(&prepared.state.spec_json.lock().unwrap()).unwrap();
let openapi = parsed["openapi"].as_str().unwrap();
assert!(openapi.starts_with("3.1"), "got openapi = {openapi}");
drop(prepared);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn axum_router_serves_html_spec_and_404s_unknown_routes() {
let path = write_minimal_v3_2_spec();
let args = PreviewArgs {
file: path.clone(),
from: None,
no_open: true,
renderer: Renderer::Redoc,
watch: false,
};
let prepared = prepare_preview(&args).await.expect("prepare ok");
let (addr, _state, _server) = spawn_axum_for_prepared(prepared).await;
let (code, ctype, body) = get(addr, "/").await;
assert_eq!(code, 200);
assert!(ctype.contains("text/html"));
assert!(body.contains("cdn.redoc.ly"));
let (code, _ctype, body) = get(addr, "/index.html").await;
assert_eq!(code, 200);
assert!(body.contains("cdn.redoc.ly"));
let (code, ctype, body) = get(addr, "/spec").await;
assert_eq!(code, 200);
assert!(ctype.contains("application/json"));
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed["openapi"], "3.2.0");
let (code, _ctype, body) = get(addr, "/spec.json").await;
assert_eq!(code, 200);
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed["openapi"], "3.2.0");
let (code, _ctype, _body) = get(addr, "/nope").await;
assert_eq!(code, 404);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn reload_route_returns_404_when_watch_is_off() {
let path = write_minimal_v3_2_spec();
let args = PreviewArgs {
file: path.clone(),
from: None,
no_open: true,
renderer: Renderer::Redoc,
watch: false,
};
let prepared = prepare_preview(&args).await.expect("prepare ok");
let (addr, _state, _server) = spawn_axum_for_prepared(prepared).await;
let (code, _ctype, _body) = get(addr, "/reload").await;
assert_eq!(code, 404, "/reload must 404 when --watch is off");
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn reload_route_emits_sse_frame_when_broadcast_fires() {
let path = write_minimal_v3_2_spec();
let args = PreviewArgs {
file: path.clone(),
from: None,
no_open: true,
renderer: Renderer::Redoc,
watch: true,
};
let prepared = prepare_preview(&args).await.expect("prepare ok");
let (addr, state, _server) = spawn_axum_for_prepared(prepared).await;
let tx = state
.reload_tx
.clone()
.expect("--watch must allocate a broadcast sender");
let client = tokio::task::spawn_blocking(move || {
let mut stream = TcpStream::connect(addr).expect("connect");
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.expect("set read timeout");
let req =
"GET /reload HTTP/1.1\r\nHost: 127.0.0.1\r\nAccept: text/event-stream\r\n\r\n";
stream.write_all(req.as_bytes()).expect("write");
let mut buf = [0u8; 1024];
let mut acc = Vec::new();
loop {
match stream.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
acc.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&acc).contains("data: reload") {
break;
}
}
Err(_) => break,
}
}
String::from_utf8_lossy(&acc).to_string()
});
tokio::time::sleep(Duration::from_millis(100)).await;
let _ = tx.send(());
let body = client.await.expect("client task");
assert!(
body.contains("text/event-stream"),
"expected SSE content-type, body was: {body:?}",
);
assert!(
body.contains("data: reload"),
"expected SSE reload frame, body was: {body:?}",
);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn file_watcher_broadcasts_reload_and_refreshes_spec_json_on_change() {
let path = write_minimal_v3_2_spec();
let args = PreviewArgs {
file: path.clone(),
from: None,
no_open: true,
watch: true,
renderer: Renderer::Redoc,
};
let prepared = prepare_preview(&args).await.expect("prepare ok");
let tx = prepared
.state
.reload_tx
.clone()
.expect("--watch must allocate a broadcast sender");
let mut rx = tx.subscribe();
let spec_json = Arc::clone(&prepared.state.spec_json);
tokio::time::sleep(Duration::from_millis(50)).await;
std::fs::write(
&path,
br#"{"openapi":"3.2.0","info":{"title":"changed","version":"1"},"paths":{}}"#,
)
.expect("rewrite spec");
let received = tokio::time::timeout(Duration::from_secs(3), rx.recv()).await;
assert!(
matches!(received, Ok(Ok(()))),
"watcher must broadcast reload within 3s of a real file change, got {received:?}",
);
let updated = spec_json.lock().unwrap().clone();
let parsed: serde_json::Value = serde_json::from_str(&updated).unwrap();
assert_eq!(
parsed["info"]["title"], "changed",
"spec_json must reflect the rewritten file",
);
drop(prepared);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn file_watcher_keeps_previous_json_when_reparse_fails() {
let path = write_minimal_v3_2_spec();
let args = PreviewArgs {
file: path.clone(),
from: None,
no_open: true,
watch: true,
renderer: Renderer::Redoc,
};
let prepared = prepare_preview(&args).await.expect("prepare ok");
let spec_json = Arc::clone(&prepared.state.spec_json);
let before = spec_json.lock().unwrap().clone();
tokio::time::sleep(Duration::from_millis(50)).await;
std::fs::write(&path, b"%%% not parseable %%%").expect("write garbage");
tokio::time::sleep(Duration::from_millis(500)).await;
let after = spec_json.lock().unwrap().clone();
assert_eq!(
before, after,
"spec_json must be untouched when the new file fails to parse",
);
drop(prepared);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn prepare_preview_with_watch_wires_up_broadcast_sender_and_html_injection() {
let path = write_minimal_v3_2_spec();
let args = PreviewArgs {
file: path.clone(),
from: None,
no_open: true,
watch: true,
renderer: Renderer::Redoc,
};
let prepared = prepare_preview(&args).await.expect("prepare ok");
assert!(
prepared.state.reload_tx.is_some(),
"--watch must allocate a broadcast sender",
);
assert!(
prepared.state.html.contains("new EventSource('/reload')"),
"--watch must inject the SSE-subscriber script",
);
drop(prepared);
let _ = std::fs::remove_file(&path);
}
}