use crate::server::app::AppState;
use crate::server::body::{decode_uri_component, parse_form, read_json_object, string_field};
use crate::server::errors::{error, method_not_allowed};
use crate::server::routes::query::query_value;
use axum::body::Bytes;
use axum::extract::{Path, State};
use axum::http::{Method, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use axum::routing::any;
use axum::{Json, Router};
use nomoreide_core::config::{selected_git_repository, ProviderConnectionDef};
use nomoreide_core::providers::deploy::{DeployActionInput, ProviderProject};
use nomoreide_core::providers::registry::{
cli_missing, provider_cli_session, provider_cli_status, public_provider_connection,
require_deploy_provider, require_provider_actions, require_provider_context, DeployActions,
ProviderAccount, ProviderContext,
};
use serde_json::{json, Map, Value};
const MAX_DEPLOYMENTS: u32 = 100;
const MAX_BUILD_LOG_LINES: u32 = 2_000;
const DEFAULT_BUILD_LOG_LINES: u32 = 500;
const MAX_RUNTIME_LOG_LINES: u32 = 1_000;
const DEFAULT_RUNTIME_LOG_LINES: u32 = 200;
const DEFAULT_DEPLOYMENTS: u32 = 20;
pub(crate) mod oauth;
pub(crate) fn routes() -> Router<AppState> {
Router::new()
.merge(oauth::routes())
.route("/api/providers/:provider/status", any(status))
.route("/api/providers/:provider/connect", any(connect))
.route("/api/providers/:provider/scope", any(scope))
.route("/api/providers/:provider/project", any(project))
.route("/api/providers/:provider/env", any(env))
.route("/api/providers/:provider/env/:env/reveal", any(reveal_env))
.route("/api/providers/:provider/env/:env", any(change_env))
.route("/api/providers/:provider/domains", any(domains))
.route("/api/providers/:provider/projects", any(projects))
.route("/api/providers/:provider/deployments", any(deployments))
.route(
"/api/providers/:provider/deployments/:deployment/logs",
any(build_logs),
)
.route(
"/api/providers/:provider/deployments/:deployment/runtime-logs",
any(runtime_logs),
)
.route(
"/api/providers/:provider/deployments/:deployment/:action",
any(run_action),
)
.route(
"/api/providers/:provider/deployments/:deployment",
any(deployment),
)
}
fn ok() -> Response {
Json(json!({ "ok": true })).into_response()
}
async fn status(State(state): State<AppState>, Path(provider): Path<String>) -> Response {
let manifest = match require_deploy_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 = provider_cli_status(&provider).await;
let mut base = Map::new();
base.insert("ok".into(), Value::Bool(true));
base.insert("provider".into(), manifest);
let connection = public_provider_connection(&config, &provider);
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 {
base.insert("cliError".into(), Value::String(cli_error));
}
if let Some(repository) = selected_git_repository(&config) {
base.insert(
"repositoryName".into(),
Value::String(repository.name.clone()),
);
}
if connection.is_none() && !cli.available {
base.insert("status".into(), Value::String("not_configured".into()));
return Json(Value::Object(base)).into_response();
}
let cwd = state.workspace_cwd().await;
let context =
match require_provider_context(&provider, &state.config_store, &config, &cwd).await {
Ok(context) => context,
Err(message) => return unreachable_provider(base, false, message),
};
let (account, scopes) = tokio::join!(context.client.account(), context.client.list_scopes());
match account {
Ok(account) => connected_panel(
base,
connection,
&context,
account,
scopes.unwrap_or_default(),
),
Err(failure) => unreachable_provider(base, failure.is_auth(), failure.message),
}
}
fn connected_panel(
mut base: Map<String, Value>,
connection: Option<Value>,
context: &ProviderContext,
account: ProviderAccount,
scopes: Vec<Value>,
) -> Response {
base.insert(
"connection".into(),
connection.unwrap_or_else(|| json!({ "source": "cli" })),
);
base.insert(
"status".into(),
Value::String(
if context.project.is_some() {
"connected"
} else {
"no_project"
}
.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));
base.insert("scopes".into(), json!(scopes));
if let Some(project) = context.project.as_ref() {
base.insert("project".into(), json!(project));
}
if let Some(scope_id) = context.credential.scope_id.as_ref() {
base.insert("scopeId".into(), Value::String(scope_id.clone()));
}
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_deploy_provider(&provider) {
return error(StatusCode::BAD_REQUEST, &message);
}
if method == Method::DELETE {
return match state.config_store.remove_connection(&provider).await {
Ok(_) => {
state.provider_logins.forget(&provider);
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) = provider_cli_session(&provider).await else {
return error(
StatusCode::BAD_REQUEST,
cli_missing(&provider).unwrap_or_default(),
);
};
ProviderConnectionDef {
source: "cli".into(),
scope_id: session.current_scope,
..empty_connection()
}
} 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()),
..empty_connection()
}
};
match state
.config_store
.set_connection(&provider, connection)
.await
{
Ok(_) => ok(),
Err(failure) => error(StatusCode::BAD_REQUEST, &failure.to_string()),
}
}
fn empty_connection() -> ProviderConnectionDef {
ProviderConnectionDef {
source: String::new(),
token: None,
refresh_token: None,
expires_at: None,
client_id: None,
scope_id: None,
scope_slug: None,
username: None,
legacy_team_id: None,
legacy_team_slug: None,
}
}
async fn scope(
State(state): State<AppState>,
Path(provider): Path<String>,
method: Method,
body: Bytes,
) -> Response {
if method != Method::PUT {
return method_not_allowed().await;
}
if let Err(message) = require_deploy_provider(&provider) {
return error(StatusCode::BAD_REQUEST, &message);
}
let payload = read_json_object(&body);
match state
.config_store
.set_connection_scope(
&provider,
string_field(&payload, "scopeId").map(str::to_string),
string_field(&payload, "scopeSlug").map(str::to_string),
)
.await
{
Ok(_) => ok(),
Err(failure) => error(StatusCode::BAD_REQUEST, &failure.to_string()),
}
}
async fn project(
State(state): State<AppState>,
Path(provider): Path<String>,
method: Method,
body: Bytes,
) -> Response {
let refusal = if method == Method::PUT {
StatusCode::BAD_REQUEST
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
if let Err(message) = require_deploy_provider(&provider) {
return error(refusal, &message);
}
if method == Method::PUT {
let payload = read_json_object(&body);
let config = match state.config_store.load().await {
Ok(config) => config,
Err(failure) => return error(refusal, &failure.to_string()),
};
let Some(repository) = selected_git_repository(&config) else {
return error(refusal, "No Git repository is selected.");
};
let repository = repository.name.clone();
return match state
.config_store
.set_provider_project(
&provider,
&repository,
string_field(&payload, "projectId").map(str::to_string),
)
.await
{
Ok(_) => ok(),
Err(failure) => error(refusal, &failure.to_string()),
};
}
if method != Method::GET {
return method_not_allowed().await;
}
let context = match context(&state, &provider).await {
Ok(context) => context,
Err(response) => return response,
};
let Some(linked) = context
.project
.as_ref()
.and_then(ProviderProject::identifier)
else {
return error(refusal, "No project is linked to this repository.");
};
match context.client.get_project(linked).await {
Ok(project) => Json(json!({ "ok": true, "project": project })).into_response(),
Err(failure) => error(refusal, &failure.message),
}
}
async fn context(state: &AppState, provider: &str) -> Result<ProviderContext, Response> {
provider_context(state, provider)
.await
.map_err(|failure| error(StatusCode::INTERNAL_SERVER_ERROR, &failure))
}
async fn provider_context(state: &AppState, provider: &str) -> Result<ProviderContext, String> {
let config = state
.config_store
.load()
.await
.map_err(|failure| failure.to_string())?;
let cwd = state.workspace_cwd().await;
require_provider_context(provider, &state.config_store, &config, &cwd).await
}
fn require_project(context: &ProviderContext) -> Result<&str, String> {
context
.project
.as_ref()
.and_then(ProviderProject::identifier)
.ok_or_else(|| "No project is linked to this repository.".to_string())
}
async fn actions(state: &AppState, provider: &str) -> Result<DeployActions, String> {
let config = state
.config_store
.load()
.await
.map_err(|failure| failure.to_string())?;
require_provider_actions(provider, &state.config_store, &config).await
}
fn raw_env_segment(uri: &Uri) -> &str {
raw_segment(uri, 5)
}
fn raw_segment(uri: &Uri, index: usize) -> &str {
uri.path().split('/').nth(index).unwrap_or_default()
}
async fn env(
State(state): State<AppState>,
Path(provider): Path<String>,
method: Method,
body: Bytes,
) -> Response {
let refusal = if method == Method::GET {
StatusCode::INTERNAL_SERVER_ERROR
} else {
StatusCode::BAD_REQUEST
};
if let Err(message) = require_deploy_provider(&provider) {
return error(refusal, &message);
}
let context = match provider_context(&state, &provider).await {
Ok(context) => context,
Err(message) => return error(refusal, &message),
};
if method == Method::GET {
let project = match require_project(&context) {
Ok(project) => project,
Err(message) => return error(refusal, &message),
};
return match context.client.list_env(project).await {
Ok(env) => Json(json!({ "ok": true, "env": env })).into_response(),
Err(failure) => error(refusal, &failure.message),
};
}
if method != Method::POST {
return method_not_allowed().await;
}
let payload = read_json_object(&body);
let actions = match actions(&state, &provider).await {
Ok(actions) => actions,
Err(message) => return error(refusal, &message),
};
let project = match require_project(&context) {
Ok(project) => project.to_string(),
Err(message) => return error(refusal, &message),
};
let key = string_field(&payload, "key").unwrap_or_default().trim();
let environments = string_list(&payload, "environments");
if key.is_empty() {
return error(refusal, "A key is required.");
}
if environments.is_empty() {
return error(refusal, "Choose at least one environment.");
}
match actions
.create_env(
&project,
key,
string_field(&payload, "value").unwrap_or_default(),
&environments,
if string_field(&payload, "type") == Some("plain") {
"plain"
} else {
"encrypted"
},
)
.await
{
Ok(env) => Json(json!({ "ok": true, "env": env })).into_response(),
Err(failure) => error(refusal, &failure.message),
}
}
async fn reveal_env(
State(state): State<AppState>,
Path((provider, _env)): Path<(String, String)>,
method: Method,
uri: Uri,
) -> Response {
if method != Method::POST {
return method_not_allowed().await;
}
let refusal = StatusCode::BAD_REQUEST;
if let Err(message) = require_deploy_provider(&provider) {
return error(refusal, &message);
}
let context = match provider_context(&state, &provider).await {
Ok(context) => context,
Err(message) => return error(refusal, &message),
};
let project = match require_project(&context) {
Ok(project) => project,
Err(message) => return error(refusal, &message),
};
let Some(env_id) = decode_uri_component(raw_env_segment(&uri)) else {
return error(refusal, "URI malformed");
};
match context.client.get_env_value(project, &env_id).await {
Ok(value) => Json(json!({ "ok": true, "value": value })).into_response(),
Err(failure) => error(refusal, &failure.message),
}
}
async fn change_env(
State(state): State<AppState>,
Path((provider, _env)): Path<(String, String)>,
method: Method,
uri: Uri,
body: Bytes,
) -> Response {
if method != Method::PATCH && method != Method::DELETE {
return method_not_allowed().await;
}
let refusal = StatusCode::BAD_REQUEST;
if let Err(message) = require_deploy_provider(&provider) {
return error(refusal, &message);
}
let context = match provider_context(&state, &provider).await {
Ok(context) => context,
Err(message) => return error(refusal, &message),
};
let actions = match actions(&state, &provider).await {
Ok(actions) => actions,
Err(message) => return error(refusal, &message),
};
let project = match require_project(&context) {
Ok(project) => project.to_string(),
Err(message) => return error(refusal, &message),
};
let Some(env_id) = decode_uri_component(raw_env_segment(&uri)) else {
return error(refusal, "URI malformed");
};
if method == Method::DELETE {
return match actions.delete_env(&project, &env_id).await {
Ok(()) => ok(),
Err(failure) => error(refusal, &failure.message),
};
}
let payload = read_json_object(&body);
let value = string_field(&payload, "value").filter(|value| !value.is_empty());
let environments = payload
.get("environments")
.and_then(Value::as_array)
.map(|_| string_list(&payload, "environments"));
match actions
.update_env(&project, &env_id, value, environments.as_deref())
.await
{
Ok(env) => Json(json!({ "ok": true, "env": env })).into_response(),
Err(failure) => error(refusal, &failure.message),
}
}
async fn domains(State(state): State<AppState>, Path(provider): Path<String>) -> Response {
let refusal = StatusCode::INTERNAL_SERVER_ERROR;
if let Err(message) = require_deploy_provider(&provider) {
return error(refusal, &message);
}
let context = match provider_context(&state, &provider).await {
Ok(context) => context,
Err(message) => return error(refusal, &message),
};
let project = match require_project(&context) {
Ok(project) => project,
Err(message) => return error(refusal, &message),
};
match context.client.list_domains(project).await {
Ok(domains) => Json(json!({ "ok": true, "domains": domains })).into_response(),
Err(failure) => error(refusal, &failure.message),
}
}
fn string_list(payload: &Value, key: &str) -> Vec<String> {
payload
.get(key)
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
async fn projects(
State(state): State<AppState>,
Path(provider): Path<String>,
uri: Uri,
) -> Response {
let context = match context(&state, &provider).await {
Ok(context) => context,
Err(response) => return response,
};
let search = query_value(&uri, "search")
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
match context.client.list_projects(search.as_deref()).await {
Ok(projects) => {
let mut body = serde_json::Map::new();
body.insert("ok".into(), Value::Bool(true));
body.insert("projects".into(), json!(projects));
if let Some(linked) = context
.project
.as_ref()
.and_then(|project| project.id.clone())
{
body.insert("linkedProjectId".into(), linked);
}
Json(Value::Object(body)).into_response()
}
Err(failure) => error(StatusCode::INTERNAL_SERVER_ERROR, &failure.message),
}
}
async fn deployments(
State(state): State<AppState>,
Path(provider): Path<String>,
uri: Uri,
) -> Response {
let context = match context(&state, &provider).await {
Ok(context) => context,
Err(response) => return response,
};
let Some(project) = context.project.as_ref() else {
return Json(json!({ "ok": true, "deployments": [], "project": Value::Null }))
.into_response();
};
let Some(project_id) = project.identifier() else {
return Json(json!({ "ok": true, "deployments": [], "project": Value::Null }))
.into_response();
};
let target =
query_value(&uri, "target").filter(|value| value == "production" || value == "preview");
let limit = capped_limit(query_value(&uri, "limit").as_deref(), MAX_DEPLOYMENTS);
match context
.client
.list_deployments(
project_id,
target.as_deref(),
limit.unwrap_or(DEFAULT_DEPLOYMENTS),
)
.await
{
Ok(deployments) => Json(json!({
"ok": true,
"project": project,
"deployments": deployments,
}))
.into_response(),
Err(failure) => error(StatusCode::INTERNAL_SERVER_ERROR, &failure.message),
}
}
async fn deployment_read(
state: &AppState,
provider: &str,
method: Method,
uri: &Uri,
) -> Result<(ProviderContext, String), Response> {
if method != Method::GET {
return Err(method_not_allowed().await);
}
let context = context(state, provider).await?;
let id = decode_uri_component(raw_segment(uri, 5))
.ok_or_else(|| error(StatusCode::INTERNAL_SERVER_ERROR, "URI malformed"))?;
Ok((context, id))
}
async fn build_logs(
State(state): State<AppState>,
Path((provider, _deployment)): Path<(String, String)>,
method: Method,
uri: Uri,
) -> Response {
let (context, id) = match deployment_read(&state, &provider, method, &uri).await {
Ok(read) => read,
Err(response) => return response,
};
let limit = capped_limit(query_value(&uri, "limit").as_deref(), MAX_BUILD_LOG_LINES);
match context
.client
.build_logs(
context.linked_project().as_deref(),
&id,
limit.unwrap_or(DEFAULT_BUILD_LOG_LINES),
)
.await
{
Ok(logs) => Json(json!({ "ok": true, "logs": logs })).into_response(),
Err(failure) => error(StatusCode::INTERNAL_SERVER_ERROR, &failure.message),
}
}
async fn runtime_logs(
State(state): State<AppState>,
Path((provider, _deployment)): Path<(String, String)>,
method: Method,
uri: Uri,
) -> Response {
let (context, id) = match deployment_read(&state, &provider, method, &uri).await {
Ok(read) => read,
Err(response) => return response,
};
let limit = capped_limit(query_value(&uri, "limit").as_deref(), MAX_RUNTIME_LOG_LINES);
match context
.client
.runtime_logs(&id, limit.unwrap_or(DEFAULT_RUNTIME_LOG_LINES))
.await
{
Ok(logs) => Json(json!({ "ok": true, "logs": logs })).into_response(),
Err(failure) => error(StatusCode::INTERNAL_SERVER_ERROR, &failure.message),
}
}
async fn deployment(
State(state): State<AppState>,
Path((provider, _deployment)): Path<(String, String)>,
method: Method,
uri: Uri,
) -> Response {
let (context, id) = match deployment_read(&state, &provider, method, &uri).await {
Ok(read) => read,
Err(response) => return response,
};
match context
.client
.get_deployment(context.linked_project().as_deref(), &id)
.await
{
Ok(deployment) => Json(json!({ "ok": true, "deployment": deployment })).into_response(),
Err(failure) => error(StatusCode::INTERNAL_SERVER_ERROR, &failure.message),
}
}
async fn run_action(
State(state): State<AppState>,
Path((provider, _deployment, _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_deploy_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 context = match provider_context(&state, &provider).await {
Ok(context) => context,
Err(message) => return error(refusal, &message),
};
let actions = match actions(&state, &provider).await {
Ok(actions) => actions,
Err(message) => return error(refusal, &message),
};
let Some(deployment_id) = decode_uri_component(raw_segment(&uri, 5)) else {
return error(refusal, "URI malformed");
};
let project = context.linked_project();
let original = context
.client
.get_deployment(project.as_deref(), &deployment_id)
.await
.ok();
let input = DeployActionInput {
deployment_id,
project_id: project,
name: original
.as_ref()
.and_then(|detail| detail.deployment.name.clone()),
target: original
.as_ref()
.map(|detail| detail.deployment.target.clone()),
description: Some(format!("{action} from NoMoreIDE")),
};
match actions.run(action, &input).await {
Ok(created) => {
let mut body = Map::new();
body.insert("ok".into(), Value::Bool(true));
if let Some(deployment) = created {
body.insert("deployment".into(), json!(deployment));
}
Json(Value::Object(body)).into_response()
}
Err(failure) => error(refusal, &failure.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 capped_limit(raw: Option<&str>, cap: u32) -> Option<u32> {
let parsed = parse_int(raw.unwrap_or(""))?;
(parsed > 0).then(|| parsed.min(i64::from(cap)) as u32)
}
fn parse_int(raw: &str) -> Option<i64> {
let text = raw.trim_start();
let (sign, digits) = match text.strip_prefix('-') {
Some(rest) => (-1, rest),
None => (1, text.strip_prefix('+').unwrap_or(text)),
};
let leading: String = digits.chars().take_while(char::is_ascii_digit).collect();
leading
.parse::<i64>()
.ok()
.or((!leading.is_empty()).then_some(i64::MAX))
.map(|value| sign * value)
}