use axum::body::Bytes;
use axum::extract::State;
use axum::http::{Method, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
use nomoreide_core::agent_profiles::{self, registry_config as config, PublishRequest};
use crate::server::app::AppState;
use crate::server::body::{decode_uri_component, read_json_object};
use crate::server::errors::error;
pub(crate) fn routes() -> Router<AppState> {
Router::new()
.route("/api/agent-env/registry/profiles", get(browse))
.route(
"/api/agent-env/profiles/install-from-registry",
post(install),
)
.route(
"/api/agent-env/profiles/register-github",
post(register_github),
)
.route(
"/api/agent-env/profiles/:name/publish",
post(publish).fallback(publish),
)
}
fn upstream_status(message: &str) -> StatusCode {
let code = message
.split("HTTP ")
.nth(1)
.and_then(|rest| rest.get(..3))
.and_then(|code| code.parse::<u16>().ok());
match code {
Some(code) if (400..500).contains(&code) => {
StatusCode::from_u16(code).unwrap_or(StatusCode::BAD_GATEWAY)
}
_ => StatusCode::BAD_GATEWAY,
}
}
fn install_status(message: &str) -> StatusCode {
if message.contains("already exists") {
return StatusCode::CONFLICT;
}
const UNPROCESSABLE: [&str; 4] = [
"Profile archive contains",
"Archive has",
"Archive is missing",
"Could not extract the archive",
];
if UNPROCESSABLE.iter().any(|start| message.starts_with(start)) {
return StatusCode::UNPROCESSABLE_ENTITY;
}
upstream_status(message)
}
fn failed(status: StatusCode, message: &str) -> Response {
(status, Json(json!({ "ok": false, "error": message }))).into_response()
}
fn signed_in() -> bool {
config::api_token_with_source().is_some()
}
fn required<'a>(body: &'a Value, key: &str) -> Option<&'a str> {
body.get(key)
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
}
fn optional<'a>(body: &'a Value, key: &str) -> Result<Option<&'a str>, ()> {
match body.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(value)) => Ok(Some(value)),
Some(_) => Err(()),
}
}
async fn browse(uri: Uri) -> Response {
let query = crate::server::routes::query::query_value(&uri, "q");
let sort = crate::server::routes::query::query_value(&uri, "sort");
let query = query
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if query
.as_ref()
.is_some_and(|value| value.chars().count() > 100)
{
return failed(StatusCode::BAD_REQUEST, "Invalid registry profile query.");
}
let sort = match sort.as_deref().filter(|value| !value.is_empty()) {
None => "recent".to_string(),
Some(value) if ["recent", "stars", "downloads", "alpha"].contains(&value) => {
value.to_string()
}
Some(_) => return failed(StatusCode::BAD_REQUEST, "Invalid registry profile query."),
};
match agent_profiles::list_public_profiles(query.as_deref(), &sort).await {
Ok(profiles) => Json(json!({ "ok": true, "profiles": profiles })).into_response(),
Err(message) => failed(upstream_status(&message), &message),
}
}
async fn install(State(state): State<AppState>, body: Bytes) -> Response {
let _ = &state;
let payload = read_json_object(&body);
let Some(slug) = required(&payload, "slug") else {
return failed(StatusCode::BAD_REQUEST, "slug is required.");
};
let force = payload
.get("force")
.and_then(Value::as_bool)
.unwrap_or(false);
let rename_to = match payload.get("as") {
None | Some(Value::Null) => None,
Some(Value::String(value)) if !value.is_empty() => Some(value.as_str()),
Some(_) => return failed(StatusCode::BAD_REQUEST, "slug is required."),
};
let supplied: BTreeMap<String, String> = match payload.get("credentials") {
None | Some(Value::Null) => BTreeMap::new(),
Some(Value::Object(map)) => {
let mut supplied = BTreeMap::new();
for (key, value) in map {
let Some(text) = value.as_str() else {
return failed(StatusCode::BAD_REQUEST, "slug is required.");
};
supplied.insert(key.clone(), text.to_string());
}
supplied
}
Some(_) => return failed(StatusCode::BAD_REQUEST, "slug is required."),
};
let token = config::api_token_with_source().map(|(token, _)| token);
match agent_profiles::install(slug, force, rename_to, &supplied, token.as_deref()).await {
Ok(outcome) => merged(&outcome),
Err(message) => failed(install_status(&message), &message),
}
}
async fn register_github(State(state): State<AppState>, body: Bytes) -> Response {
let _ = &state;
let payload = read_json_object(&body);
let (Some(repo_url), Some(slug), Some(title)) = (
required(&payload, "repoUrl"),
required(&payload, "slug"),
required(&payload, "title"),
) else {
return failed(
StatusCode::BAD_REQUEST,
"repoUrl, slug, and title are required.",
);
};
if !signed_in() {
return failed(StatusCode::UNAUTHORIZED, "Sign in to the registry first.");
}
let (Ok(summary), Ok(ref_name), Ok(profile_path)) = (
optional(&payload, "summary"),
optional(&payload, "refName"),
optional(&payload, "profilePath"),
) else {
return failed(
StatusCode::BAD_REQUEST,
"repoUrl, slug, and title are required.",
);
};
match agent_profiles::register_github(repo_url, slug, title, summary, ref_name, profile_path)
.await
{
Ok(result) => Json(json!({ "ok": true, "result": result })).into_response(),
Err(message) => failed(upstream_status(&message), &message),
}
}
async fn publish(State(state): State<AppState>, method: Method, uri: Uri, body: Bytes) -> Response {
if method != Method::POST {
return error(StatusCode::METHOD_NOT_ALLOWED, "Method not allowed");
}
let Some(name) = published_name(&uri) else {
return error(StatusCode::INTERNAL_SERVER_ERROR, "URI malformed");
};
let payload = read_json_object(&body);
let (Some(slug), Some(title)) = (required(&payload, "slug"), required(&payload, "title"))
else {
return failed(StatusCode::BAD_REQUEST, "slug and title are required.");
};
if !signed_in() {
return failed(StatusCode::UNAUTHORIZED, "Sign in to the registry first.");
}
let (Ok(summary), Ok(version), Ok(changelog)) = (
optional(&payload, "summary"),
optional(&payload, "version"),
optional(&payload, "changelog"),
) else {
return failed(StatusCode::BAD_REQUEST, "slug and title are required.");
};
let visibility = match payload.get("visibility") {
None | Some(Value::Null) => None,
Some(Value::String(value)) if value == "public" || value == "private" => {
Some(value.as_str())
}
Some(_) => return failed(StatusCode::BAD_REQUEST, "slug and title are required."),
};
let cwd = state.workspace_cwd().await;
let request = PublishRequest {
name: &name,
slug,
title,
summary,
version,
changelog,
visibility,
};
match agent_profiles::publish(request, std::path::Path::new(&cwd)).await {
Ok(outcome) => merged(&outcome),
Err(message) => {
let status = if message.contains("not found") {
StatusCode::NOT_FOUND
} else {
upstream_status(&message)
};
failed(status, &message)
}
}
}
fn published_name(uri: &Uri) -> Option<String> {
let rest = uri
.path()
.strip_prefix("/api/agent-env/profiles/")?
.strip_suffix("/publish")?;
decode_uri_component(rest)
}
fn merged<T: serde::Serialize>(value: &T) -> Response {
let mut body = Map::new();
body.insert("ok".into(), Value::Bool(true));
if let Ok(Value::Object(fields)) = serde_json::to_value(value) {
body.extend(fields);
}
Json(Value::Object(body)).into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_four_hundred_from_upstream_is_passed_through() {
assert_eq!(
upstream_status("Create profile failed: HTTP 422 — nope"),
StatusCode::UNPROCESSABLE_ENTITY
);
assert_eq!(
upstream_status("Lookup failed: HTTP 401"),
StatusCode::UNAUTHORIZED
);
}
#[test]
fn anything_else_is_a_bad_gateway() {
assert_eq!(
upstream_status("Upload failed: HTTP 500 — boom"),
StatusCode::BAD_GATEWAY
);
assert_eq!(
upstream_status("the network went away"),
StatusCode::BAD_GATEWAY
);
assert_eq!(upstream_status("HTTP 99"), StatusCode::BAD_GATEWAY);
}
#[test]
fn an_install_has_two_rules_of_its_own() {
assert_eq!(
install_status("Profile \"x\" already exists."),
StatusCode::CONFLICT
);
assert_eq!(
install_status("Archive is missing profile.json"),
StatusCode::UNPROCESSABLE_ENTITY
);
assert_eq!(
install_status("Download failed: HTTP 404"),
StatusCode::NOT_FOUND
);
}
#[test]
fn a_name_can_steer_its_own_status() {
assert_eq!(
install_status("Profile \"already exists here\" could not be read."),
StatusCode::CONFLICT
);
}
#[test]
fn a_publish_name_comes_back_decoded() {
assert_eq!(
published_name(&"/api/agent-env/profiles/a%20b/publish".parse().unwrap()).as_deref(),
Some("a b")
);
assert_eq!(
published_name(&"/api/agent-env/profiles/plain/publish".parse().unwrap()).as_deref(),
Some("plain")
);
}
}