use axum::body::Bytes;
use axum::extract::{Path, State};
use axum::http::{Method, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use axum::routing::{any, get};
use axum::{Json, Router};
use nomoreide_core::config::ProviderConnectionDef;
use nomoreide_core::providers::registry::{
host_cli_missing, host_cli_session, host_cli_status, host_provider_manifests,
public_provider_connection, require_host_actions, require_host_context, require_host_provider,
HostClient,
};
use nomoreide_core::ssh_servers::invalidate_host_ssh_targets;
use serde_json::{json, Map, Value};
use crate::server::app::AppState;
use crate::server::body::{decode_uri_component, parse_form};
use crate::server::errors::{error, method_not_allowed};
pub(crate) fn routes() -> Router<AppState> {
Router::new()
.route("/api/hosts", get(providers))
.route("/api/hosts/:provider/status", any(status))
.route("/api/hosts/:provider/connect", any(connect))
.route("/api/hosts/:provider/instances", any(instances))
.route(
"/api/hosts/:provider/instances/:instance/:action",
any(run_action),
)
.route("/api/hosts/:provider/instances/:instance", any(instance))
}
fn ok() -> Response {
Json(json!({ "ok": true })).into_response()
}
async fn providers() -> Response {
Json(json!({ "ok": true, "providers": host_provider_manifests() })).into_response()
}
async fn status(State(state): State<AppState>, Path(provider): Path<String>) -> Response {
let manifest = match require_host_provider(&provider) {
Ok(manifest) => manifest,
Err(message) => return error(StatusCode::NOT_FOUND, &message),
};
let config = match state.config_store.load().await {
Ok(config) => config,
Err(failure) => return error(StatusCode::NOT_FOUND, &failure.to_string()),
};
let cli = host_cli_status(&provider);
let connection = public_provider_connection(&config, &provider);
let mut base = Map::new();
base.insert("ok".into(), Value::Bool(true));
base.insert("provider".into(), manifest);
if let Some(connection) = connection.clone() {
base.insert("connection".into(), connection);
}
base.insert("cliAvailable".into(), Value::Bool(cli.available));
if let Some(cli_error) = cli.error.as_ref() {
base.insert("cliError".into(), Value::String(cli_error.clone()));
}
if connection.is_none() && !cli.available {
base.insert("status".into(), Value::String("not_configured".into()));
return Json(Value::Object(base)).into_response();
}
let client = match require_host_context(&provider, &state.config_store, &config) {
Ok(client) => client,
Err(message) => return unreachable_provider(base, false, message),
};
match client.account().await {
Ok(account) => connected_panel(base, connection.is_none(), account),
Err(failure) => unreachable_provider(base, failure.is_auth(), failure.message),
}
}
fn connected_panel(
mut base: Map<String, Value>,
ambient: bool,
account: nomoreide_core::providers::registry::HostAccount,
) -> Response {
if ambient {
base.insert("connection".into(), json!({ "source": "cli" }));
}
base.insert("status".into(), Value::String("connected".into()));
let mut user = Map::new();
if let Some(username) = account.username {
user.insert("username".into(), username);
}
if let Some(avatar) = account.avatar {
user.insert("avatar".into(), avatar);
}
base.insert("user".into(), Value::Object(user));
Json(Value::Object(base)).into_response()
}
fn unreachable_provider(mut base: Map<String, Value>, auth: bool, message: String) -> Response {
base.insert(
"status".into(),
Value::String(
if auth {
"auth_error"
} else {
"connection_error"
}
.into(),
),
);
base.insert("error".into(), Value::String(message));
Json(Value::Object(base)).into_response()
}
async fn connect(
State(state): State<AppState>,
Path(provider): Path<String>,
method: Method,
body: Bytes,
) -> Response {
if let Err(message) = require_host_provider(&provider) {
return error(StatusCode::BAD_REQUEST, &message);
}
if method == Method::DELETE {
return match state.config_store.remove_connection(&provider).await {
Ok(_) => {
invalidate_host_ssh_targets();
ok()
}
Err(failure) => error(StatusCode::BAD_REQUEST, &failure.to_string()),
};
}
if method != Method::POST {
return method_not_allowed().await;
}
let form = parse_form(&body);
let connection = if form.get("source").map(|source| source.trim()) == Some("cli") {
let Some(session) = host_cli_session(&provider) else {
return error(
StatusCode::BAD_REQUEST,
host_cli_missing(&provider).unwrap_or_default(),
);
};
ProviderConnectionDef {
source: "cli".into(),
scope_id: session.current_scope,
..ProviderConnectionDef::default()
}
} else {
let token = form
.get("token")
.map(|token| token.trim())
.filter(|token| !token.is_empty());
let Some(token) = token else {
return error(StatusCode::BAD_REQUEST, "token is required");
};
ProviderConnectionDef {
source: "stored".into(),
token: Some(token.to_string()),
..ProviderConnectionDef::default()
}
};
match state
.config_store
.set_connection(&provider, connection)
.await
{
Ok(_) => {
invalidate_host_ssh_targets();
ok()
}
Err(failure) => error(StatusCode::BAD_REQUEST, &failure.to_string()),
}
}
async fn instances(
State(state): State<AppState>,
Path(provider): Path<String>,
method: Method,
) -> Response {
if method != Method::GET {
return method_not_allowed().await;
}
let client = match host_client(&state, &provider).await {
Ok(client) => client,
Err(response) => return response,
};
match client.list_instances().await {
Ok(instances) => Json(json!({ "ok": true, "instances": instances })).into_response(),
Err(failure) => error(StatusCode::INTERNAL_SERVER_ERROR, &failure.message),
}
}
async fn instance(
State(state): State<AppState>,
Path((provider, _instance)): Path<(String, String)>,
method: Method,
uri: Uri,
) -> Response {
if method != Method::GET {
return method_not_allowed().await;
}
let client = match host_client(&state, &provider).await {
Ok(client) => client,
Err(response) => return response,
};
let Some(id) = decode_uri_component(raw_segment(&uri, 5)) else {
return error(StatusCode::INTERNAL_SERVER_ERROR, "URI malformed");
};
match client.get_instance(&id).await {
Ok(instance) => Json(json!({ "ok": true, "instance": instance })).into_response(),
Err(failure) => error(StatusCode::INTERNAL_SERVER_ERROR, &failure.message),
}
}
async fn run_action(
State(state): State<AppState>,
Path((provider, _instance, _action)): Path<(String, String, String)>,
method: Method,
uri: Uri,
) -> Response {
if method != Method::POST {
return method_not_allowed().await;
}
let refusal = StatusCode::BAD_REQUEST;
let manifest = match require_host_provider(&provider) {
Ok(manifest) => manifest,
Err(message) => return error(refusal, &message),
};
let action = raw_segment(&uri, 6);
if !declares_action(&manifest, action) {
let name = manifest
.get("name")
.and_then(Value::as_str)
.unwrap_or(&provider);
return error(
StatusCode::NOT_FOUND,
&format!("{name} has no action \"{action}\"."),
);
}
let config = match state.config_store.load().await {
Ok(config) => config,
Err(failure) => return error(refusal, &failure.to_string()),
};
let actions = match require_host_actions(&provider, &state.config_store, &config) {
Ok(actions) => actions,
Err(message) => return error(refusal, &message),
};
let Some(instance_id) = decode_uri_component(raw_segment(&uri, 5)) else {
return error(refusal, "URI malformed");
};
match actions.run(action, &instance_id).await {
Ok(()) => {
invalidate_host_ssh_targets();
ok()
}
Err(failure) => error(refusal, &failure.message),
}
}
async fn host_client(state: &AppState, provider: &str) -> Result<HostClient, Response> {
let refusal = StatusCode::INTERNAL_SERVER_ERROR;
let config = state
.config_store
.load()
.await
.map_err(|failure| error(refusal, &failure.to_string()))?;
require_host_context(provider, &state.config_store, &config)
.map_err(|message| error(refusal, &message))
}
fn declares_action(manifest: &Value, action: &str) -> bool {
manifest
.get("actions")
.and_then(Value::as_array)
.is_some_and(|actions| actions.iter().any(|name| name == action))
}
fn raw_segment(uri: &Uri, index: usize) -> &str {
uri.path().split('/').nth(index).unwrap_or_default()
}