use crate::cli::{GatewayCommand, GatewayRouteCommand};
use crate::util::{Result, repo_path, repo_root, run_make, usage_error};
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashSet;
use std::env;
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
pub(crate) fn run_gateway_command(command: GatewayCommand) -> Result<()> {
match command {
GatewayCommand::Generate => {
sync_service_gateway_route_imports()?;
run_make(&repo_root().join("services/core/gateway"), "generate", &[])
}
GatewayCommand::Route { command } => run_gateway_route_command(command),
GatewayCommand::ValidateRoutes { files } => validate_gateway_routes(&files),
}
}
pub(crate) fn sync_service_gateway_route_imports() -> Result<()> {
sync_service_gateway_route_imports_for_root(&repo_root())
}
pub(crate) fn sync_service_gateway_route_imports_for_root(root: &Path) -> Result<()> {
let api_dir = root.join("services/core/gateway/api");
let gateway_routes = api_dir.join("gateway-routes.json");
let text = fs::read_to_string(&gateway_routes)?;
let mut document: Value = serde_json::from_str(&text)?;
let discovered = discover_service_gateway_route_imports(root)?;
let mut imports = document
.get("imports")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
.into_iter()
.filter_map(|value| value.as_str().map(ToString::to_string))
.filter(|import| !is_discovered_service_import(import))
.collect::<Vec<_>>();
imports.extend(discovered);
imports.sort();
imports.dedup();
document["imports"] = Value::Array(imports.into_iter().map(Value::String).collect());
fs::write(&gateway_routes, gateway_routes_json(&document)?)?;
Ok(())
}
fn discover_service_gateway_route_imports(root: &Path) -> Result<Vec<String>> {
let mut route_files = Vec::new();
collect_service_gateway_route_files(&root.join("services"), &mut route_files)?;
route_files.sort();
let api_dir = root.join("services/core/gateway/api");
let mut imports = Vec::new();
for route_file in route_files {
if is_placeholder_route_file(&route_file) {
println!(
"[gateway] skipping placeholder service route file: {}",
route_file.display()
);
continue;
}
if !gateway_route_file_has_routes(&route_file)? {
println!(
"[gateway] skipping empty service route file: {}",
route_file.display()
);
continue;
}
validate_gateway_route_file(&route_file)?;
if gateway_route_file_is_stale(root, &route_file)? {
println!(
"[gateway] skipping stale service route file: {}",
route_file.display()
);
continue;
}
if gateway_route_file_uses_auth(&route_file)? && !gateway_auth_contract_is_ready(root)? {
println!(
"[gateway] skipping auth-protected service route file until auth token contract is ready: {}",
route_file.display()
);
continue;
}
imports.push(relative_import_path(&api_dir, &route_file, root)?);
}
Ok(imports)
}
fn is_placeholder_route_file(route_file: &Path) -> bool {
route_file
.file_name()
.and_then(OsStr::to_str)
.is_some_and(|name| name.contains("__"))
}
fn gateway_route_file_has_routes(route_file: &Path) -> Result<bool> {
let text = fs::read_to_string(route_file)?;
let document: Value = serde_json::from_str(&text)?;
Ok(document
.get("routes")
.and_then(Value::as_array)
.is_some_and(|routes| !routes.is_empty()))
}
fn gateway_route_file_uses_auth(route_file: &Path) -> Result<bool> {
let text = fs::read_to_string(route_file)?;
let document: Value = serde_json::from_str(&text)?;
let Some(routes) = document.get("routes").and_then(Value::as_array) else {
return Ok(false);
};
Ok(routes.iter().any(|route| {
route
.get("public")
.and_then(|public| public.get("auth"))
.and_then(Value::as_str)
.is_some_and(|auth| auth != "public")
|| route
.get("id")
.and_then(Value::as_str)
.is_some_and(|id| matches!(id, "auth-login" | "auth-refresh" | "auth-logout"))
}))
}
fn gateway_auth_contract_is_ready(root: &Path) -> Result<bool> {
let auth_proto = root.join("services/core/auth/api/service.proto");
if !auth_proto.exists() {
return Ok(false);
}
let text = fs::read_to_string(auth_proto)?;
Ok(text.contains("rpc ValidateAccessToken")
&& text.contains("message ValidateAccessTokenRequest")
&& text.contains("message VerifiedIdentity"))
}
fn gateway_route_file_is_stale(root: &Path, route_file: &Path) -> Result<bool> {
let text = fs::read_to_string(route_file)?;
let document: Value = serde_json::from_str(&text)?;
let Some(routes) = document.get("routes").and_then(Value::as_array) else {
return Ok(false);
};
for route in routes {
let Some(proto) = route
.get("target")
.and_then(|target| target.get("proto"))
.and_then(Value::as_str)
else {
continue;
};
if !root.join(proto).exists() {
return Ok(true);
}
}
Ok(false)
}
fn collect_service_gateway_route_files(root: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
if !root.exists() {
return Ok(());
}
for entry in fs::read_dir(root)? {
let path = entry?.path();
if path.is_dir() {
collect_service_gateway_route_files(&path, out)?;
continue;
}
if !path
.file_name()
.and_then(OsStr::to_str)
.is_some_and(|name| name.ends_with("-routes.json"))
{
continue;
}
if path
.parent()
.and_then(Path::file_name)
.and_then(OsStr::to_str)
== Some("routes")
&& path
.parent()
.and_then(Path::parent)
.and_then(Path::file_name)
.and_then(OsStr::to_str)
== Some("gateway")
{
out.push(path);
}
}
Ok(())
}
fn relative_import_path(from_dir: &Path, to_file: &Path, root: &Path) -> Result<String> {
let from = from_dir.strip_prefix(root)?;
let to = to_file.strip_prefix(root)?;
let up_levels = from.components().count();
let mut parts = vec!["..".to_string(); up_levels];
parts.extend(
to.components()
.map(|component| component.as_os_str().to_string_lossy().to_string()),
);
Ok(parts.join("/"))
}
fn is_discovered_service_import(import: &str) -> bool {
import.contains("/services/") || import.starts_with("../../../services/")
}
fn gateway_routes_json(document: &Value) -> Result<String> {
let default_version = Value::from(1);
let empty_array = Value::Array(vec![]);
Ok(format!(
"{{\n \"version\": {},\n \"imports\": {},\n \"routes\": {},\n \"upstreams\": {},\n \"services\": {},\n \"consumers\": {}\n}}\n",
serde_json::to_string(document.get("version").unwrap_or(&default_version))?,
indent_json(document.get("imports").unwrap_or(&empty_array))?,
indent_json(document.get("routes").unwrap_or(&empty_array))?,
indent_json(document.get("upstreams").unwrap_or(&empty_array))?,
indent_json(document.get("services").unwrap_or(&empty_array))?,
indent_json(document.get("consumers").unwrap_or(&empty_array))?,
))
}
fn indent_json(value: &Value) -> Result<String> {
let text = serde_json::to_string_pretty(value)?;
Ok(text.replace('\n', "\n "))
}
fn run_gateway_route_command(command: GatewayRouteCommand) -> Result<()> {
match command {
GatewayRouteCommand::Create(args) => {
run_gateway_route_make("route-file", args.service, None, args.force, args.append)
}
GatewayRouteCommand::FromProto(args) => run_gateway_route_make(
"route-file-from-proto",
args.service,
None,
args.force,
args.append,
),
GatewayRouteCommand::Entry(args) => run_gateway_route_make(
"route-entry",
args.service,
Some(args.rpc),
args.force,
args.append,
),
}
}
fn run_gateway_route_make(
target: &str,
service: String,
rpc: Option<String>,
force: bool,
append: bool,
) -> Result<()> {
let mut vars = vec![format!("SERVICE={service}")];
if let Some(rpc) = rpc {
vars.push(format!("RPC={rpc}"));
}
if force {
vars.push("FORCE=true".into());
}
if append {
vars.push("APPEND=true".into());
}
run_make(&repo_root().join("services/core/gateway"), target, &vars)
}
#[derive(Deserialize)]
struct RouteModule {
version: i64,
routes: Vec<RouteEntry>,
}
#[derive(Deserialize)]
struct RouteEntry {
id: String,
public: PublicRoute,
target: TargetRoute,
}
#[derive(Deserialize)]
struct PublicRoute {
method: String,
path: String,
auth: String,
#[serde(default)]
permission: String,
}
#[derive(Deserialize)]
struct TargetRoute {
protocol: String,
proto: String,
package: String,
service: String,
rpc: String,
endpoint_env: String,
}
fn validate_gateway_routes(args: &[String]) -> Result<()> {
let files: Vec<PathBuf> = if args.is_empty() {
let routes_dir = env::current_dir()?.join("gateway/routes");
fs::read_dir(routes_dir)?
.flatten()
.map(|entry| entry.path())
.filter(|path| {
path.file_name()
.and_then(OsStr::to_str)
.is_some_and(|name| name.ends_with("-routes.json"))
})
.collect()
} else {
args.iter().map(|arg| repo_path(arg)).collect()
};
if files.is_empty() {
println!("No gateway route request files found.");
return Ok(());
}
for file in &files {
validate_gateway_route_file(file)?;
}
println!("OK gateway route request files: {}", files.len());
Ok(())
}
fn validate_gateway_route_file(path: &Path) -> Result<()> {
let text = fs::read_to_string(path)?;
let module: RouteModule = serde_json::from_str(&text)?;
if module.version != 1 {
return usage_error(format!("{}: version must be 1", path.display()));
}
let mut seen = HashSet::new();
for route in module.routes {
if route.id.is_empty() {
return usage_error(format!("{}: route id is required", path.display()));
}
if !seen.insert(route.id.clone()) {
return usage_error(format!(
"{}: duplicate route id: {}",
path.display(),
route.id
));
}
if !["GET", "POST", "PUT", "PATCH", "DELETE"].contains(&route.public.method.as_str()) {
return usage_error(format!(
"{}: {}.public.method is invalid",
path.display(),
route.id
));
}
if !route.public.path.starts_with('/') {
return usage_error(format!(
"{}: {}.public.path must start with /",
path.display(),
route.id
));
}
if !["public", "user", "permission"].contains(&route.public.auth.as_str()) {
return usage_error(format!(
"{}: {}.public.auth must be public, user, or permission",
path.display(),
route.id
));
}
if route.public.auth == "permission" && route.public.permission.is_empty() {
return usage_error(format!(
"{}: {}.public.permission is required for permission auth",
path.display(),
route.id
));
}
if route.target.protocol != "grpc" {
return usage_error(format!(
"{}: {}.target.protocol must be grpc",
path.display(),
route.id
));
}
for (field, value) in [
("proto", route.target.proto),
("package", route.target.package),
("service", route.target.service),
("rpc", route.target.rpc),
("endpoint_env", route.target.endpoint_env),
] {
if value.is_empty() {
return usage_error(format!(
"{}: {}.target.{field} is required",
path.display(),
route.id
));
}
}
}
Ok(())
}