GET /api/v1/admin/apps/{id_or_slug} now returns an `AppRole`-typed
`my_role` alongside the existing app fields, computed server-side from
the Principal: `Owner → app_admin` and `Admin → editor` (both
implicit per blueprint §11.6), `Member → app_members.role` (looked up
via the existing `AuthzRepo::membership` already in `AppsState`).
The dashboard uses this single field to decide whether to render
admin-only surfaces (Members tab, etc.) instead of duplicating the
implicit-grant rules on the client side — keeps API and UI gate logic
identical with one round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
630 lines
20 KiB
Rust
630 lines
20 KiB
Rust
//! `/api/v1/admin/apps/*` — app + domain claim CRUD.
|
|
//!
|
|
//! All endpoints are guarded by `require_admin`. Per-app permissions
|
|
//! are deferred (every authenticated admin can act on every app); the
|
|
//! middleware seam exists for when that lands.
|
|
//!
|
|
//! Slug validation: regex `^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word
|
|
//! list rejected. Slug renames record the old slug in
|
|
//! `app_slug_history` for permanent 301 redirects; reclaiming a
|
|
//! historical slug requires `"force_takeover": true` in the request.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::extract::{Path, Query, State};
|
|
use axum::http::StatusCode;
|
|
use axum::response::{IntoResponse, Json, Response};
|
|
use axum::routing::{delete, get, post};
|
|
use axum::{Extension, Router};
|
|
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
|
|
use picloud_shared::{App, AppDomain, AppId, AppRole, InstanceRole, Principal};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::json;
|
|
use uuid::Uuid;
|
|
|
|
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
|
use crate::app_repo::AppRepository;
|
|
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
|
use crate::repo::ScriptRepositoryError;
|
|
use crate::route_repo::RouteRepository;
|
|
|
|
const SLUG_MIN: usize = 1;
|
|
const SLUG_MAX: usize = 63;
|
|
const RESERVED_SLUGS: &[&str] = &[
|
|
"new", "api", "admin", "admins", "healthz", "version", "login", "logout", "apps",
|
|
];
|
|
|
|
#[derive(Clone)]
|
|
pub struct AppsState {
|
|
pub apps: Arc<dyn AppRepository>,
|
|
pub domains: Arc<dyn AppDomainRepository>,
|
|
pub routes: Arc<dyn RouteRepository>,
|
|
/// Cached host → app_id lookup; replaced after every domain CRUD
|
|
/// operation so the orchestrator sees changes immediately.
|
|
pub domain_table: Arc<AppDomainTable>,
|
|
/// Capability gate — Phase 3.5.
|
|
pub authz: Arc<dyn AuthzRepo>,
|
|
}
|
|
|
|
pub fn apps_router(state: AppsState) -> Router {
|
|
Router::new()
|
|
.route("/apps", get(list_apps).post(create_app))
|
|
.route(
|
|
"/apps/{id_or_slug}",
|
|
get(get_app).patch(patch_app).delete(delete_app),
|
|
)
|
|
.route("/apps/{id_or_slug}/slug:check", post(slug_check))
|
|
.route(
|
|
"/apps/{id_or_slug}/domains",
|
|
get(list_domains).post(create_domain),
|
|
)
|
|
.route(
|
|
"/apps/{id_or_slug}/domains/{domain_id}",
|
|
delete(delete_domain),
|
|
)
|
|
.with_state(state)
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// DTOs
|
|
// ----------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct AppDto {
|
|
#[serde(flatten)]
|
|
pub app: App,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateAppRequest {
|
|
pub slug: String,
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
/// Set to `true` to consume an existing `app_slug_history` row for
|
|
/// the requested slug (breaking old redirects).
|
|
#[serde(default)]
|
|
pub force_takeover: bool,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PatchAppRequest {
|
|
pub name: Option<String>,
|
|
#[serde(default, deserialize_with = "deserialize_optional_optional")]
|
|
#[allow(clippy::option_option)]
|
|
pub description: Option<Option<String>>,
|
|
pub slug: Option<String>,
|
|
#[serde(default)]
|
|
pub force_takeover: bool,
|
|
}
|
|
|
|
#[allow(clippy::option_option)]
|
|
fn deserialize_optional_optional<'de, D>(d: D) -> Result<Option<Option<String>>, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
Option::<String>::deserialize(d).map(Some)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct SlugCheckRequest {
|
|
pub new_slug: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct SlugCheckResponse {
|
|
pub ok: bool,
|
|
pub conflict_kind: Option<&'static str>,
|
|
pub current_app: Option<App>,
|
|
pub reason: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateDomainRequest {
|
|
pub pattern: String,
|
|
}
|
|
|
|
/// Query params for `DELETE /apps/{id_or_slug}`. `force=true` opts into
|
|
/// a cascading delete that also removes every script in the app (and
|
|
/// thereby their routes and execution logs). Without it the request is
|
|
/// rejected when the app still contains scripts.
|
|
#[derive(Debug, Default, Deserialize)]
|
|
pub struct DeleteAppQuery {
|
|
#[serde(default)]
|
|
pub force: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct AppLookupResponse {
|
|
#[serde(flatten)]
|
|
pub app: App,
|
|
/// When the operator hits the API with a retired slug, this points
|
|
/// at the live slug so dashboards can redirect.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub redirect_to: Option<String>,
|
|
/// The caller's role on this app, used by the dashboard to decide
|
|
/// whether to render admin-only surfaces (Members tab, settings).
|
|
/// `Owner` maps to `app_admin`, `Admin` to `editor` (both implicit
|
|
/// per blueprint §11.6); `Member` carries its explicit
|
|
/// `app_members.role`.
|
|
pub my_role: Option<AppRole>,
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Handlers
|
|
// ----------------------------------------------------------------------------
|
|
|
|
async fn list_apps(
|
|
State(s): State<AppsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
) -> Result<Json<Vec<App>>, AppsApiError> {
|
|
// Member callers see only apps they're a member of; owner/admin
|
|
// see everything. Filter at the SQL layer (not just in the
|
|
// dashboard) — that's the strict-isolation guarantee from §11.6.
|
|
let apps = if principal.instance_role == InstanceRole::Member {
|
|
s.apps.list_for_user(principal.user_id).await?
|
|
} else {
|
|
s.apps.list().await?
|
|
};
|
|
Ok(Json(apps))
|
|
}
|
|
|
|
async fn create_app(
|
|
State(s): State<AppsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Json(input): Json<CreateAppRequest>,
|
|
) -> Result<(StatusCode, Json<App>), AppsApiError> {
|
|
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
|
|
validate_slug(&input.slug)?;
|
|
|
|
// Historical-slug check before insert: if the slug is in history
|
|
// and the caller hasn't asked to force takeover, surface a clean
|
|
// 409 so the dashboard can present a "this will break old links"
|
|
// confirmation.
|
|
if !input.force_takeover {
|
|
if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
|
|
return Err(AppsApiError::SlugInHistory(current));
|
|
}
|
|
}
|
|
|
|
let created = if input.force_takeover {
|
|
s.apps
|
|
.create_with_takeover(&input.slug, &input.name, input.description.as_deref())
|
|
.await?
|
|
} else {
|
|
s.apps
|
|
.create(&input.slug, &input.name, input.description.as_deref())
|
|
.await?
|
|
};
|
|
Ok((StatusCode::CREATED, Json(created)))
|
|
}
|
|
|
|
async fn get_app(
|
|
State(s): State<AppsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
) -> Result<Json<AppLookupResponse>, AppsApiError> {
|
|
let lookup = resolve_app(&*s.apps, &id_or_slug).await?;
|
|
require(
|
|
s.authz.as_ref(),
|
|
&principal,
|
|
Capability::AppRead(lookup.app.id),
|
|
)
|
|
.await?;
|
|
let redirect_to = if lookup.redirected {
|
|
Some(lookup.app.slug.clone())
|
|
} else {
|
|
None
|
|
};
|
|
let my_role = compute_my_role(s.authz.as_ref(), &principal, lookup.app.id).await?;
|
|
Ok(Json(AppLookupResponse {
|
|
app: lookup.app,
|
|
redirect_to,
|
|
my_role,
|
|
}))
|
|
}
|
|
|
|
/// Compute the caller's effective `AppRole` on a specific app. Mirrors
|
|
/// the implicit-grant logic in `authz::role_grants` but returns the
|
|
/// role itself (for UI gating) rather than a yes/no decision. `Owner`
|
|
/// is implicit `AppAdmin` everywhere; `Admin` is implicit `Editor`
|
|
/// everywhere; `Member` consults `app_members`.
|
|
async fn compute_my_role(
|
|
authz: &dyn AuthzRepo,
|
|
principal: &Principal,
|
|
app_id: AppId,
|
|
) -> Result<Option<AppRole>, AppsApiError> {
|
|
match principal.instance_role {
|
|
InstanceRole::Owner => Ok(Some(AppRole::AppAdmin)),
|
|
InstanceRole::Admin => Ok(Some(AppRole::Editor)),
|
|
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?),
|
|
}
|
|
}
|
|
|
|
async fn patch_app(
|
|
State(s): State<AppsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
Json(input): Json<PatchAppRequest>,
|
|
) -> Result<Json<App>, AppsApiError> {
|
|
let current = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
|
require(
|
|
s.authz.as_ref(),
|
|
&principal,
|
|
Capability::AppAdmin(current.id),
|
|
)
|
|
.await?;
|
|
|
|
// Edits to name/description go first (separate from rename so we
|
|
// don't conflate the two errors).
|
|
let after_meta = if input.name.is_some() || input.description.is_some() {
|
|
s.apps
|
|
.update(
|
|
current.id,
|
|
input.name.as_deref(),
|
|
input.description.as_ref().map(|d| d.as_deref()),
|
|
)
|
|
.await?
|
|
} else {
|
|
current
|
|
};
|
|
|
|
// Slug rename is a separate operation; the rename method does its
|
|
// own history bookkeeping in a transaction.
|
|
let after_rename = if let Some(new_slug) = input.slug.as_deref() {
|
|
validate_slug(new_slug)?;
|
|
match s
|
|
.apps
|
|
.rename_slug(after_meta.id, new_slug, input.force_takeover)
|
|
.await
|
|
{
|
|
Ok(app) => app,
|
|
Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
|
|
if let Some(current) = s.apps.slug_in_history(new_slug).await? {
|
|
return Err(AppsApiError::SlugInHistory(current));
|
|
}
|
|
return Err(AppsApiError::Conflict(msg));
|
|
}
|
|
Err(e) => return Err(e.into()),
|
|
}
|
|
} else {
|
|
after_meta
|
|
};
|
|
|
|
Ok(Json(after_rename))
|
|
}
|
|
|
|
async fn delete_app(
|
|
State(s): State<AppsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
Query(q): Query<DeleteAppQuery>,
|
|
) -> Result<StatusCode, AppsApiError> {
|
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
|
|
|
if q.force {
|
|
s.apps.delete_cascade(app.id).await?;
|
|
} else {
|
|
// Soft pre-check for a clean error; the DB FK is the real guard
|
|
// (ON DELETE RESTRICT on scripts.app_id).
|
|
let n_scripts = s.apps.count_scripts_in_app(app.id).await?;
|
|
if n_scripts > 0 {
|
|
return Err(AppsApiError::HasScripts(n_scripts));
|
|
}
|
|
s.apps.delete(app.id).await?;
|
|
}
|
|
refresh_domain_cache(&s).await?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
async fn slug_check(
|
|
State(s): State<AppsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
Json(input): Json<SlugCheckRequest>,
|
|
) -> Result<Json<SlugCheckResponse>, AppsApiError> {
|
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
|
match validate_slug(&input.new_slug) {
|
|
Err(AppsApiError::InvalidSlug(reason)) => {
|
|
return Ok(Json(SlugCheckResponse {
|
|
ok: false,
|
|
conflict_kind: Some("invalid"),
|
|
current_app: None,
|
|
reason: Some(reason),
|
|
}));
|
|
}
|
|
Err(other) => return Err(other),
|
|
Ok(()) => {}
|
|
}
|
|
if let Some(app) = s.apps.get_by_slug(&input.new_slug).await? {
|
|
return Ok(Json(SlugCheckResponse {
|
|
ok: false,
|
|
conflict_kind: Some("current"),
|
|
current_app: Some(app),
|
|
reason: Some("another app currently uses this slug".into()),
|
|
}));
|
|
}
|
|
if let Some(app) = s.apps.slug_in_history(&input.new_slug).await? {
|
|
return Ok(Json(SlugCheckResponse {
|
|
ok: false,
|
|
conflict_kind: Some("historical"),
|
|
current_app: Some(app),
|
|
reason: Some("slug is a retired redirect; using it will break old links".into()),
|
|
}));
|
|
}
|
|
Ok(Json(SlugCheckResponse {
|
|
ok: true,
|
|
conflict_kind: None,
|
|
current_app: None,
|
|
reason: None,
|
|
}))
|
|
}
|
|
|
|
async fn list_domains(
|
|
State(s): State<AppsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
) -> Result<Json<Vec<AppDomain>>, AppsApiError> {
|
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
|
require(s.authz.as_ref(), &principal, Capability::AppRead(app.id)).await?;
|
|
Ok(Json(s.domains.list_for_app(app.id).await?))
|
|
}
|
|
|
|
async fn create_domain(
|
|
State(s): State<AppsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
Json(input): Json<CreateDomainRequest>,
|
|
) -> Result<(StatusCode, Json<AppDomain>), AppsApiError> {
|
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
|
require(
|
|
s.authz.as_ref(),
|
|
&principal,
|
|
Capability::AppManageDomains(app.id),
|
|
)
|
|
.await?;
|
|
let parsed = pattern::parse_app_domain(&input.pattern)?;
|
|
let created = s
|
|
.domains
|
|
.create(NewAppDomain {
|
|
app_id: app.id,
|
|
pattern: input.pattern,
|
|
shape: parsed.shape,
|
|
shape_key: parsed.shape_key,
|
|
})
|
|
.await?;
|
|
refresh_domain_cache(&s).await?;
|
|
Ok((StatusCode::CREATED, Json(created)))
|
|
}
|
|
|
|
async fn delete_domain(
|
|
State(s): State<AppsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path((id_or_slug, domain_id)): Path<(String, Uuid)>,
|
|
) -> Result<StatusCode, AppsApiError> {
|
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
|
require(
|
|
s.authz.as_ref(),
|
|
&principal,
|
|
Capability::AppManageDomains(app.id),
|
|
)
|
|
.await?;
|
|
let Some(domain) = s.domains.get(domain_id).await? else {
|
|
return Err(AppsApiError::DomainNotFound(domain_id));
|
|
};
|
|
if domain.app_id != app.id {
|
|
return Err(AppsApiError::DomainNotFound(domain_id));
|
|
}
|
|
|
|
// Guard: routes inside this app may reference this exact host
|
|
// pattern. The host-kind on the route is `strict` or `wildcard`
|
|
// (Any routes don't pin a specific host). We block deletion in
|
|
// either case and let the operator clean up first.
|
|
let strict = s
|
|
.routes
|
|
.count_for_app_host(app.id, picloud_shared::HostKind::Strict, &domain.pattern)
|
|
.await?;
|
|
let wild_suffix = domain
|
|
.pattern
|
|
.split_once('.')
|
|
.map(|(_, s)| s.to_string())
|
|
.unwrap_or_default();
|
|
let wild = if wild_suffix.is_empty() {
|
|
0
|
|
} else {
|
|
s.routes
|
|
.count_for_app_host(app.id, picloud_shared::HostKind::Wildcard, &wild_suffix)
|
|
.await?
|
|
};
|
|
if strict + wild > 0 {
|
|
return Err(AppsApiError::DomainHasRoutes(strict + wild));
|
|
}
|
|
|
|
s.domains.delete(domain_id).await?;
|
|
refresh_domain_cache(&s).await?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Helpers
|
|
// ----------------------------------------------------------------------------
|
|
|
|
async fn resolve_app(
|
|
apps: &dyn AppRepository,
|
|
ident: &str,
|
|
) -> Result<crate::app_repo::AppLookup, AppsApiError> {
|
|
if let Ok(uuid) = ident.parse::<Uuid>() {
|
|
if let Some(app) = apps.get_by_id(AppId::from(uuid)).await? {
|
|
return Ok(crate::app_repo::AppLookup {
|
|
app,
|
|
redirected: false,
|
|
});
|
|
}
|
|
return Err(AppsApiError::AppNotFound(ident.to_string()));
|
|
}
|
|
apps.get_by_slug_or_history(ident)
|
|
.await?
|
|
.ok_or_else(|| AppsApiError::AppNotFound(ident.to_string()))
|
|
}
|
|
|
|
fn validate_slug(slug: &str) -> Result<(), AppsApiError> {
|
|
if slug.len() < SLUG_MIN || slug.len() > SLUG_MAX {
|
|
return Err(AppsApiError::InvalidSlug(format!(
|
|
"slug length must be between {SLUG_MIN} and {SLUG_MAX}"
|
|
)));
|
|
}
|
|
if !slug
|
|
.chars()
|
|
.next()
|
|
.is_some_and(|c| c.is_ascii_alphanumeric())
|
|
{
|
|
return Err(AppsApiError::InvalidSlug(
|
|
"slug must start with [a-z0-9]".into(),
|
|
));
|
|
}
|
|
for c in slug.chars() {
|
|
if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
|
|
return Err(AppsApiError::InvalidSlug(
|
|
"slug may only contain lowercase letters, digits, and '-'".into(),
|
|
));
|
|
}
|
|
}
|
|
if RESERVED_SLUGS.contains(&slug) {
|
|
return Err(AppsApiError::InvalidSlug(format!(
|
|
"slug {slug:?} is reserved for system use"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Rebuild the in-memory host → app_id cache used by the orchestrator.
|
|
/// Called after every domain CRUD operation.
|
|
pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError> {
|
|
let all = state.domains.list_all().await?;
|
|
let compiled = all
|
|
.into_iter()
|
|
.filter_map(|d| {
|
|
// Parse the stored pattern; skip on parse error rather than
|
|
// poisoning the entire cache. The handlers reject bad input,
|
|
// so this is purely defensive against a future migration
|
|
// that loosens the constraints.
|
|
pattern::parse_app_domain(&d.pattern)
|
|
.ok()
|
|
.map(|p| CompiledAppDomain {
|
|
app_id: d.app_id,
|
|
pattern: p.pattern,
|
|
shape_key: p.shape_key,
|
|
})
|
|
})
|
|
.collect();
|
|
state.domain_table.replace(compiled);
|
|
Ok(())
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Errors
|
|
// ----------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum AppsApiError {
|
|
#[error("app not found: {0}")]
|
|
AppNotFound(String),
|
|
|
|
#[error("domain not found: {0}")]
|
|
DomainNotFound(Uuid),
|
|
|
|
#[error("invalid slug: {0}")]
|
|
InvalidSlug(String),
|
|
|
|
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
|
|
SlugInHistory(App),
|
|
|
|
#[error("app still contains {0} script(s); delete or move them first")]
|
|
HasScripts(i64),
|
|
|
|
#[error("domain has {0} route(s) bound to it; delete the routes first")]
|
|
DomainHasRoutes(i64),
|
|
|
|
#[error("invalid pattern: {0}")]
|
|
Pattern(#[from] pattern::ParseError),
|
|
|
|
#[error("conflict: {0}")]
|
|
Conflict(String),
|
|
|
|
#[error("forbidden")]
|
|
Forbidden,
|
|
|
|
#[error("authorization repo error: {0}")]
|
|
AuthzRepo(String),
|
|
|
|
#[error("repository error: {0}")]
|
|
Repo(#[from] ScriptRepositoryError),
|
|
}
|
|
|
|
impl From<AuthzDenied> for AppsApiError {
|
|
fn from(d: AuthzDenied) -> Self {
|
|
match d {
|
|
AuthzDenied::Denied => Self::Forbidden,
|
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<AuthzError> for AppsApiError {
|
|
fn from(e: AuthzError) -> Self {
|
|
Self::AuthzRepo(e.to_string())
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for AppsApiError {
|
|
fn into_response(self) -> Response {
|
|
let (status, body) = match &self {
|
|
Self::AppNotFound(_)
|
|
| Self::DomainNotFound(_)
|
|
| Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
|
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
|
}
|
|
Self::InvalidSlug(_) | Self::Pattern(_) => (
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
json!({ "error": self.to_string() }),
|
|
),
|
|
Self::SlugInHistory(current) => (
|
|
StatusCode::CONFLICT,
|
|
json!({
|
|
"error": self.to_string(),
|
|
"conflict_kind": "historical",
|
|
"current_app": current,
|
|
}),
|
|
),
|
|
Self::HasScripts(n) => (
|
|
StatusCode::CONFLICT,
|
|
json!({ "error": self.to_string(), "script_count": n }),
|
|
),
|
|
Self::DomainHasRoutes(n) => (
|
|
StatusCode::CONFLICT,
|
|
json!({ "error": self.to_string(), "route_count": n }),
|
|
),
|
|
Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => {
|
|
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
|
}
|
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
|
Self::AuthzRepo(e) => {
|
|
tracing::error!(error = %e, "apps authz repo error");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
json!({ "error": "internal error" }),
|
|
)
|
|
}
|
|
Self::Repo(ScriptRepositoryError::Db(e)) => {
|
|
tracing::error!(error = %e, "apps api db error");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
json!({ "error": "internal error" }),
|
|
)
|
|
}
|
|
};
|
|
(status, Json(body)).into_response()
|
|
}
|
|
}
|