use anyhow::Result;
use axum::{
Json, Router,
body::Body,
extract::{Path, State},
http::Method,
response::Response,
routing::{delete, get, post, put},
};
use serde::Serialize;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer;
use crate::serve::project::ProjectSettings;
#[derive(Serialize)]
pub struct HealthResponse {
pub status: String,
}
pub mod api;
pub mod assets;
pub mod project;
pub struct PlayState {
pub collections_dir: PathBuf,
pub collections_dirs: Vec<PathBuf>,
pub project_root: Option<PathBuf>,
pub project_settings: Option<ProjectSettings>,
pub history_lock: std::sync::Mutex<()>,
}
async fn static_handler(Path(path): Path<String>) -> Response {
assets::handle_embedded(&format!("assets/{}", path)).await
}
async fn index_handler() -> Response {
assets::handle_embedded("").await
}
async fn spa_fallback(Path(path): Path<String>) -> Response {
if !path.is_empty()
&& !path.starts_with("api/")
&& let Some(resp) = assets::try_get_asset(&path)
{
return resp;
}
assets::handle_embedded("").await
}
#[derive(Serialize)]
pub struct VersionResponse {
pub version: String,
}
pub async fn version_handler() -> Json<VersionResponse> {
Json(VersionResponse {
version: env!("CARGO_PKG_VERSION").to_string(),
})
}
async fn health_handler() -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok".into(),
})
}
#[derive(Serialize)]
pub struct InfoResponse {
pub version: String,
pub status: String,
pub project: Option<api::ProjectInfo>,
}
pub async fn info_handler(State(state): State<Arc<PlayState>>) -> Json<InfoResponse> {
let project = if state.project_root.is_some() {
Some(api::project_info_inner(&state))
} else {
None
};
Json(InfoResponse {
version: env!("CARGO_PKG_VERSION").to_string(),
status: "ok".into(),
project,
})
}
fn use_color() -> bool {
std::env::var_os("NO_COLOR").is_none()
}
macro_rules! ansi {
($code:expr) => {{ if use_color() { $code } else { "" } }};
}
const ANSI_GREEN: &str = "\x1b[32m";
const ANSI_YELLOW: &str = "\x1b[33m";
const ANSI_RED: &str = "\x1b[31m";
const ANSI_CYAN: &str = "\x1b[36m";
const ANSI_BOLD: &str = "\x1b[1m";
const ANSI_RESET: &str = "\x1b[0m";
fn status_color(code: u16) -> &'static str {
if !use_color() {
return "";
}
if code < 300 {
ANSI_GREEN
} else if code < 400 {
ANSI_CYAN
} else if code < 500 {
ANSI_YELLOW
} else {
ANSI_RED
}
}
fn fmt_size(bytes: &str) -> String {
if let Ok(b) = bytes.parse::<f64>() {
if b >= 1_000_000.0 {
format!("{:.1}MB", b / 1_000_000.0)
} else if b >= 1_000.0 {
format!("{:.1}KB", b / 1_000.0)
} else {
format!("{}B", b as u64)
}
} else {
bytes.to_string()
}
}
async fn access_log_middleware(
req: axum::http::Request<Body>,
next: axum::middleware::Next,
) -> Response {
let path = req.uri().path().to_string();
if path == "/api/health" {
return next.run(req).await;
}
let method = req.method().clone();
let query = req
.uri()
.query()
.map(|q| format!("?{}", q))
.unwrap_or_default();
let start = Instant::now();
let response = next.run(req).await;
let status = response.status().as_u16();
let duration_s = start.elapsed().as_secs_f64();
let duration = if duration_s >= 1.0 {
format!("{:.2}s", duration_s)
} else {
format!("{:.1}ms", duration_s * 1000.0)
};
let size = response
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.map(fmt_size)
.unwrap_or_else(|| "-".into());
let now = chrono::Local::now().format("%H:%M:%S%.3f");
let full_path = format!("{}{}", path, query);
if path.starts_with("/api/") {
let status_fmt = format!(
"{bold}{color}{status}{reset}",
bold = ansi!(ANSI_BOLD),
color = status_color(status),
status = status,
reset = ansi!(ANSI_RESET)
);
println!(
"{} {} {:>7} {:>7} {} {}",
now, status_fmt, duration, size, method, full_path
);
} else if status >= 400 {
println!("{} {} {} {}", now, status, method, full_path);
}
response
}
pub async fn start_play_server(port: u16, dir: PathBuf) -> Result<()> {
let project_root = project::detect_project(&dir);
let collections_dir = project_root
.as_ref()
.map(|r| r.join("collections"))
.filter(|p| p.is_dir())
.unwrap_or_else(|| dir.clone());
let collections_dirs = if let Some(ref root) = project_root {
let mut dirs = vec![collections_dir.clone()];
if let Ok(settings) = project::load_project_settings(root)
&& let Some(ref extra) = settings.collections
{
for p in extra {
let resolved = root.join(p);
if resolved.is_dir() {
dirs.push(resolved);
}
}
}
dirs
} else {
vec![collections_dir.clone()]
};
let collections_dir_display = collections_dir.display().to_string();
let state = Arc::new(PlayState {
collections_dir,
collections_dirs,
project_root: project_root.clone(),
project_settings: project_root
.as_ref()
.and_then(|r| project::load_project_settings(r).ok()),
history_lock: std::sync::Mutex::new(()),
});
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers(Any);
let base_routes = Router::new()
.route("/", get(index_handler))
.route("/assets/{*path}", get(static_handler))
.route("/api/collections", get(api::list_collections))
.route("/api/collections/{*path}", get(api::get_collection))
.route("/api/save", post(api::save_collection))
.route(
"/api/save-structured",
post(api::save_collection_structured),
)
.route("/api/call", post(api::execute_call))
.route("/api/reflect", post(api::reflect_server))
.route("/api/import-grpcurl", post(api::import_grpcurl))
.route("/api/grpcurl", post(api::generate_grpcurl))
.route("/api/schema-fill", post(api::schema_fill))
.route("/api/proto-upload", post(api::proto_upload))
.route("/api/proto-files", get(api::proto_files))
.route("/api/dir/{*path}", post(api::create_directory))
.route("/api/move", post(api::move_item))
.route("/api/collections/{*path}", delete(api::delete_collection))
.route("/api/version", get(version_handler))
.route("/api/health", get(health_handler))
.route("/api/info", get(info_handler));
let project_routes = Router::new()
.route("/api/project/info", get(api::project_info))
.route("/api/project/settings", get(api::project_get_settings))
.route("/api/project/settings", put(api::project_put_settings))
.route("/api/project/env/list", get(api::project_env_list))
.route("/api/project/env/{name}", get(api::project_env_get))
.route("/api/project/env/{name}", put(api::project_env_put))
.route(
"/api/project/env/{name}/merged",
get(api::project_env_merged),
)
.route(
"/api/project/env/{name}/local",
get(api::project_env_local_get),
)
.route(
"/api/project/env/{name}/local",
put(api::project_env_local_put),
)
.route(
"/api/project/env/{name}/local",
delete(api::project_env_local_delete),
)
.route("/api/project/history", get(api::project_history_get));
let app = base_routes
.merge(project_routes)
.route("/{*path}", get(spa_fallback))
.layer(axum::middleware::from_fn(access_log_middleware))
.layer(TraceLayer::new_for_http())
.layer(cors)
.with_state(state);
let addr = format!("0.0.0.0:{}", port);
let version = env!("CARGO_PKG_VERSION");
println!(
"{bold}grpctestify play v{version}{reset} — http://localhost:{port}",
bold = ansi!(ANSI_BOLD),
version = version,
reset = ansi!(ANSI_RESET),
port = port
);
if let Some(ref root) = project_root {
println!(" project {root}", root = root.display());
if let Ok(envs) = project::list_env_files(root)
&& !envs.is_empty()
{
println!(" envs {envs}", envs = envs.join(", "));
}
}
println!(" dirs {dir}", dir = collections_dir_display);
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app).await?;
Ok(())
}