use std::io::Read;
use anyhow::{Context, Result};
use owo_colors::OwoColorize;
use serde_json::{json, Value};
use crate::api::client::ApiClient;
use crate::api::models::{GatewayHeader, GatewayRedirect, GatewayRoute, GatewayRulesPage};
use crate::exit;
use crate::output::Format;
use crate::render::sanitise;
use crate::render::table::{Cell, Table};
use crate::render::theme;
pub struct Trial<'a> {
pub url: &'a str,
pub method: Option<&'a str>,
pub origin: Option<&'a str>,
}
pub enum RedirectChange<'a> {
List,
Add {
from: &'a str,
to: &'a str,
status: u16,
},
Remove {
from: &'a str,
},
}
pub enum HeaderChange<'a> {
List,
Set {
name: &'a str,
value: &'a str,
replaces: bool,
},
Remove {
name: &'a str,
},
Security,
}
pub enum RouteChange<'a> {
List,
Add {
prefix: &'a str,
target: &'a str,
strip: bool,
},
Remove {
prefix: &'a str,
},
}
pub struct CorsRequest<'a> {
pub origins: &'a [String],
pub methods: &'a [String],
pub headers: &'a [String],
pub expose: &'a [String],
pub credentials: bool,
pub max_age: Option<u32>,
pub off: bool,
}
pub struct ErrorPageRequest<'a> {
pub page: &'a str,
pub file: Option<&'a str>,
pub default: bool,
pub off: bool,
}
pub async fn show(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
trial: Option<Trial<'_>>,
format: Format,
) -> Result<()> {
let (_, service) = client.locate(project_reference, service_reference).await?;
if let Some(trial) = trial {
let answer = client
.preview_gateway_rules(&service.id, trial.url, trial.method, trial.origin)
.await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let preview = &answer.body;
println!(
" {} {}{}",
theme::ARROW.style(theme::accent()),
preview
.status
.map(|status| format!("{status} · "))
.unwrap_or_default()
.style(theme::strong()),
sanitise::plain(&preview.summary),
);
for header in &preview.headers {
println!(
" {}",
format!(
"{}: {}",
sanitise::plain(&header.name),
sanitise::plain(&header.value)
)
.style(theme::muted())
);
}
return Ok(());
}
let answer = client.gateway_rules(&service.id).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let rules = &answer.body;
print_redirects(rules);
println!();
print_headers(rules);
println!();
print_cors(rules);
println!();
print_routes(rules);
println!();
print_pages(rules);
Ok(())
}
pub async fn redirects(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
change: RedirectChange<'_>,
format: Format,
) -> Result<()> {
let (_, service) = client.locate(project_reference, service_reference).await?;
let current = client.gateway_rules(&service.id).await?;
let answer = match change {
RedirectChange::List => current,
RedirectChange::Add { from, to, status } => {
let mut kept: Vec<Value> = current
.body
.redirects
.iter()
.filter(|rule| redirect_key(&rule.from) != redirect_key(from))
.map(redirect_json)
.collect();
kept.push(json!({ "from": from, "to": to, "status": status }));
client
.update_gateway_rules(&service.id, &json!({ "redirects": kept }))
.await?
}
RedirectChange::Remove { from } => {
let kept = without_redirect(¤t.body.redirects, from)?;
client
.update_gateway_rules(&service.id, &json!({ "redirects": kept }))
.await?
}
};
if format.answered_in_json(&answer.raw) {
return Ok(());
}
print_redirects(&answer.body);
Ok(())
}
pub async fn headers(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
change: HeaderChange<'_>,
format: Format,
) -> Result<()> {
let (_, service) = client.locate(project_reference, service_reference).await?;
let answer = match change {
HeaderChange::List => client.gateway_rules(&service.id).await?,
HeaderChange::Security => {
client
.update_gateway_rules(&service.id, &json!({ "securityHeaders": true }))
.await?
}
HeaderChange::Set {
name,
value,
replaces,
} => {
let current = client.gateway_rules(&service.id).await?;
let mut kept: Vec<Value> = current
.body
.headers
.iter()
.filter(|header| !header.name.eq_ignore_ascii_case(name.trim()))
.map(header_json)
.collect();
kept.push(json!({ "name": name, "value": value, "override": replaces }));
client
.update_gateway_rules(&service.id, &json!({ "headers": kept }))
.await?
}
HeaderChange::Remove { name } => {
let current = client.gateway_rules(&service.id).await?;
let kept = without_header(¤t.body.headers, name)?;
client
.update_gateway_rules(&service.id, &json!({ "headers": kept }))
.await?
}
};
if format.answered_in_json(&answer.raw) {
return Ok(());
}
print_headers(&answer.body);
Ok(())
}
pub async fn cors(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
request: CorsRequest<'_>,
format: Format,
) -> Result<()> {
let (_, service) = client.locate(project_reference, service_reference).await?;
let answer = client
.update_gateway_rules(&service.id, &cors_change(&request))
.await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
print_cors(&answer.body);
Ok(())
}
pub async fn routes(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
change: RouteChange<'_>,
format: Format,
) -> Result<()> {
let (_, service) = client.locate(project_reference, service_reference).await?;
let current = client.gateway_rules(&service.id).await?;
let answer = match change {
RouteChange::List => current,
RouteChange::Add {
prefix,
target,
strip,
} => {
let mut kept: Vec<Value> = current
.body
.routes
.iter()
.filter(|route| prefix_key(&route.prefix) != prefix_key(prefix))
.map(route_json)
.collect();
kept.push(json!({ "prefix": prefix, "service": target, "stripPrefix": strip }));
client
.update_gateway_rules(&service.id, &json!({ "routes": kept }))
.await?
}
RouteChange::Remove { prefix } => {
let kept = without_route(¤t.body.routes, prefix)?;
client
.update_gateway_rules(&service.id, &json!({ "routes": kept }))
.await?
}
};
if format.answered_in_json(&answer.raw) {
return Ok(());
}
print_routes(&answer.body);
Ok(())
}
pub async fn error_page(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
request: ErrorPageRequest<'_>,
format: Format,
) -> Result<()> {
let html = match request.file {
Some(path) => Some(read_page(path)?),
None => None,
};
let change = error_page_change(request.page, html, request.default, request.off)?;
let (_, service) = client.locate(project_reference, service_reference).await?;
let answer = client.update_gateway_rules(&service.id, &change).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
print_pages(&answer.body);
Ok(())
}
fn read_page(path: &str) -> Result<String> {
if path == "-" {
let mut text = String::new();
std::io::stdin()
.read_to_string(&mut text)
.context("no se pudo leer el HTML de la entrada")?;
return Ok(text);
}
std::fs::read_to_string(path).with_context(|| format!("no se pudo leer {path}"))
}
pub fn redirect_key(from: &str) -> String {
let written = from.trim();
if written.starts_with('/') {
return if written.len() > 1 {
written.trim_end_matches('/').to_owned()
} else {
written.to_owned()
};
}
written
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_end_matches('/')
.to_ascii_lowercase()
}
pub fn prefix_key(prefix: &str) -> String {
prefix.trim().trim_end_matches('/').to_owned()
}
fn redirect_json(rule: &GatewayRedirect) -> Value {
json!({ "from": rule.from, "to": rule.to, "status": rule.status })
}
fn header_json(header: &GatewayHeader) -> Value {
json!({ "name": header.name, "value": header.value, "override": header.replaces })
}
fn route_json(route: &GatewayRoute) -> Value {
json!({ "prefix": route.prefix, "service": route.service_id, "stripPrefix": route.strip_prefix })
}
pub fn without_redirect(rules: &[GatewayRedirect], from: &str) -> Result<Vec<Value>> {
let kept: Vec<Value> = rules
.iter()
.filter(|rule| redirect_key(&rule.from) != redirect_key(from))
.map(redirect_json)
.collect();
if kept.len() == rules.len() {
return Err(exit::Refused::not_found(format!(
"no hay ninguna redirección desde {}",
sanitise::plain(from)
))
.into());
}
Ok(kept)
}
pub fn without_header(headers: &[GatewayHeader], name: &str) -> Result<Vec<Value>> {
let kept: Vec<Value> = headers
.iter()
.filter(|header| !header.name.eq_ignore_ascii_case(name.trim()))
.map(header_json)
.collect();
if kept.len() == headers.len() {
return Err(exit::Refused::not_found(format!(
"no hay ninguna cabecera {}",
sanitise::plain(name)
))
.into());
}
Ok(kept)
}
pub fn without_route(routes: &[GatewayRoute], prefix: &str) -> Result<Vec<Value>> {
let kept: Vec<Value> = routes
.iter()
.filter(|route| prefix_key(&route.prefix) != prefix_key(prefix))
.map(route_json)
.collect();
if kept.len() == routes.len() {
return Err(exit::Refused::not_found(format!(
"no hay ninguna ruta {}",
sanitise::plain(prefix)
))
.into());
}
Ok(kept)
}
pub fn cors_change(request: &CorsRequest<'_>) -> Value {
if request.off {
return json!({ "cors": null });
}
let mut cors = serde_json::Map::new();
cors.insert("origins".into(), json!(request.origins));
for (key, values) in [
("methods", request.methods),
("headers", request.headers),
("exposeHeaders", request.expose),
] {
if !values.is_empty() {
cors.insert(key.into(), json!(values));
}
}
cors.insert("credentials".into(), json!(request.credentials));
if let Some(seconds) = request.max_age {
cors.insert("maxAgeSeconds".into(), json!(seconds));
}
json!({ "cors": Value::Object(cors) })
}
pub fn error_page_change(
page: &str,
html: Option<String>,
default: bool,
off: bool,
) -> Result<Value> {
let written = page.trim().to_ascii_lowercase();
if written == "waiting" || written == "espera" {
return Ok(if off {
json!({ "waitingPage": { "enabled": false } })
} else if default {
json!({ "waitingPage": { "enabled": true, "html": null } })
} else {
match html {
Some(html) => json!({ "waitingPage": { "enabled": true, "html": html } }),
None => json!({ "waitingPage": { "enabled": true } }),
}
});
}
if !["502", "503", "504"].contains(&written.as_str()) {
return Err(exit::Refused::invalid(format!(
"`{}` no es una página: 502, 503, 504 o waiting",
sanitise::plain(page)
))
.into());
}
match (html, default || off) {
(_, true) => Ok(json!({ "errorPages": { written: null } })),
(Some(html), false) => Ok(json!({ "errorPages": { written: html } })),
(None, false) => Err(exit::Refused::invalid(
"di qué HTML: un fichero, - para leerlo de la entrada, o --default para volver a la página de la plataforma",
)
.into()),
}
}
fn print_redirects(rules: &GatewayRulesPage) {
println!(" {}", "Redirecciones".style(theme::strong()));
let mut table = Table::new(&["desde", "hacia", "código"]);
for rule in &rules.redirects {
table.push(vec![
Cell::plain(sanitise::plain(&rule.from)),
Cell::plain(sanitise::plain(&rule.to)),
Cell::styled(rule.status.to_string(), theme::muted()),
]);
}
table.print_or(" Ninguna. fundaia redirects add <proyecto> <servicio> <desde> <hacia>");
}
fn print_headers(rules: &GatewayRulesPage) {
println!(" {}", "Cabeceras de respuesta".style(theme::strong()));
let mut table = Table::new(&["cabecera", "valor", "cuándo"]);
for header in &rules.headers {
table.push(vec![
Cell::plain(sanitise::plain(&header.name)),
Cell::plain(sanitise::plain(&header.value)),
Cell::styled(
if header.replaces {
"siempre"
} else {
"si falta"
},
theme::muted(),
),
]);
}
table.print_or(
" Ninguna. fundaia headers security <proyecto> <servicio> pone las de seguridad",
);
}
fn print_cors(rules: &GatewayRulesPage) {
match &rules.cors {
None => println!(
" {} {}",
"CORS".style(theme::strong()),
"apagado: las respuestas llevan el CORS que ponga el servicio".style(theme::muted())
),
Some(cors) => {
println!(
" {} {}",
"CORS".style(theme::strong()),
sanitise::plain(&cors.origins.join(", "))
);
println!(
" {}",
format!(
"métodos {} · cabeceras {} · {} · {} s",
cors.methods.join(", "),
if cors.headers.is_empty() {
"ninguna".to_owned()
} else {
cors.headers.join(", ")
},
if cors.credentials {
"con credenciales"
} else {
"sin credenciales"
},
cors.max_age_seconds
)
.style(theme::muted())
);
}
}
}
fn print_routes(rules: &GatewayRulesPage) {
println!(" {}", "Rutas a otros servicios".style(theme::strong()));
let mut table = Table::new(&["prefijo", "servicio", "prefijo", "estado"]);
for route in &rules.routes {
table.push(vec![
Cell::plain(sanitise::plain(&route.prefix)),
Cell::plain(sanitise::plain(
route
.service
.as_ref()
.map(|target| target.name.as_str())
.unwrap_or(route.service_id.as_str()),
)),
Cell::styled(
if route.strip_prefix {
"se quita"
} else {
"se mantiene"
},
theme::muted(),
),
match &route.broken {
None => Cell::styled("sirve", theme::success()),
Some(reason) => Cell::styled(
format!("rota: {}", sanitise::plain(reason)),
theme::danger(),
),
},
]);
}
table.print_or(" Ninguna. fundaia routes add <proyecto> <servicio> /api <otro-servicio>");
}
fn print_pages(rules: &GatewayRulesPage) {
let pages = &rules.error_pages;
println!(" {}", "Páginas de error".style(theme::strong()));
for (status, page) in [
("502", &pages.bad_gateway),
("503", &pages.unavailable),
("504", &pages.timeout),
] {
println!(
" {status} {}",
match page {
Some(html) => format!("propia, {} caracteres", html.chars().count()),
None => "la de la plataforma".to_owned(),
}
.style(theme::muted())
);
}
println!(
" espera {}",
match (&rules.waiting_page.enabled, &rules.waiting_page.html) {
(false, _) => "apagada: el navegador espera a que despierte".to_owned(),
(true, None) => "la de la plataforma, se recarga sola".to_owned(),
(true, Some(html)) => format!(
"propia, {} caracteres, se recarga sola",
html.chars().count()
),
}
.style(theme::muted())
);
}
#[cfg(test)]
mod tests {
use super::*;
fn redirect(from: &str) -> GatewayRedirect {
GatewayRedirect {
from: from.to_owned(),
to: "/nuevo".to_owned(),
status: 301,
kind: "path".to_owned(),
}
}
#[test]
fn it_should_read_a_host_the_way_the_server_stores_it() {
assert_eq!(redirect_key("https://WWW.Tienda.com/"), "www.tienda.com");
}
#[test]
fn it_should_read_a_path_without_its_trailing_slash() {
assert_eq!(redirect_key("/viejo/"), "/viejo");
}
#[test]
fn it_should_keep_the_root_as_it_is() {
assert_eq!(redirect_key("/"), "/");
}
#[test]
fn it_should_remove_the_redirect_named() {
let kept = without_redirect(&[redirect("/viejo"), redirect("/otro")], "/viejo/").unwrap();
assert_eq!(
kept,
vec![json!({ "from": "/otro", "to": "/nuevo", "status": 301 })]
);
}
#[test]
fn it_should_say_not_found_for_a_redirect_that_is_not_there() {
let refused = without_redirect(&[redirect("/viejo")], "/nada").unwrap_err();
assert_eq!(exit::classify(&refused).code(), 3);
}
#[test]
fn it_should_remove_a_header_whatever_its_case() {
let headers = [GatewayHeader {
name: "X-Frame-Options".to_owned(),
value: "DENY".to_owned(),
replaces: true,
}];
assert_eq!(
without_header(&headers, "x-frame-options").unwrap(),
Vec::<Value>::new()
);
}
#[test]
fn it_should_send_null_to_turn_cors_off() {
let request = CorsRequest {
origins: &[],
methods: &[],
headers: &[],
expose: &[],
credentials: false,
max_age: None,
off: true,
};
assert_eq!(cors_change(&request), json!({ "cors": null }));
}
#[test]
fn it_should_leave_the_defaults_to_the_server_when_no_methods_are_given() {
let origins = ["https://tienda.com".to_owned()];
let request = CorsRequest {
origins: &origins,
methods: &[],
headers: &[],
expose: &[],
credentials: true,
max_age: Some(60),
off: false,
};
assert_eq!(
cors_change(&request),
json!({ "cors": { "origins": ["https://tienda.com"], "credentials": true, "maxAgeSeconds": 60 } })
);
}
#[test]
fn it_should_set_an_error_page_from_its_html() {
let change =
error_page_change("502", Some("<h1>Caído</h1>".to_owned()), false, false).unwrap();
assert_eq!(change, json!({ "errorPages": { "502": "<h1>Caído</h1>" } }));
}
#[test]
fn it_should_go_back_to_the_platform_page_with_default() {
let change = error_page_change("504", None, true, false).unwrap();
assert_eq!(change, json!({ "errorPages": { "504": null } }));
}
#[test]
fn it_should_ask_for_the_html_of_an_error_page() {
let refused = error_page_change("503", None, false, false).unwrap_err();
assert_eq!(exit::classify(&refused).code(), 4);
}
#[test]
fn it_should_turn_the_waiting_page_off() {
let change = error_page_change("espera", None, false, true).unwrap();
assert_eq!(change, json!({ "waitingPage": { "enabled": false } }));
}
#[test]
fn it_should_turn_the_waiting_page_on_keeping_its_html() {
let change = error_page_change("waiting", None, false, false).unwrap();
assert_eq!(change, json!({ "waitingPage": { "enabled": true } }));
}
#[test]
fn it_should_refuse_a_page_that_does_not_exist() {
let refused = error_page_change("404", None, true, false).unwrap_err();
assert_eq!(exit::classify(&refused).code(), 4);
}
#[test]
fn it_should_read_the_rules_the_server_answers() {
let answer = r#"{"serviceEnvironmentId":"sve_1","serviceId":"svc_1","redirects":[{"from":"tienda.es","to":"tienda.com","status":301,"kind":"host"}],"headers":[{"name":"X-Frame-Options","value":"DENY","override":true}],"cors":{"origins":["*"],"methods":["GET"],"headers":["*"],"exposeHeaders":[],"credentials":false,"maxAgeSeconds":600},"routes":[{"prefix":"/api","serviceId":"svc_2","service":null,"stripPrefix":true,"broken":"Ese servicio ya no está en este entorno"}],"errorPages":{"502":"<h1>502</h1>","503":null,"504":null},"waitingPage":{"enabled":true,"html":null},"hosts":["tienda.com"],"targets":[],"updatedBy":"usr_1","updatedAt":"2026-10-16T10:00:00.000Z"}"#;
let page: GatewayRulesPage = serde_json::from_str(answer).unwrap();
assert_eq!(
page.routes[0].broken.as_deref(),
Some("Ese servicio ya no está en este entorno")
);
}
}