diff --git a/backend/src/api.rs b/backend/src/api.rs index c52fd32..5c62304 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -31,7 +31,7 @@ use crate::billing::Billing; use crate::command::Command; use crate::env::Env; use crate::infra::Infra; -use crate::models::Tenant; +use crate::models::{Relay, Tenant}; use crate::query::Query; use crate::routes::identity::get_identity; use crate::routes::invoices::{get_invoice, get_invoice_bolt11, list_tenant_invoices}; @@ -44,7 +44,7 @@ use crate::routes::stripe::{create_stripe_session, stripe_webhook}; use crate::routes::tenants::{ create_tenant, get_tenant, list_tenant_relays, list_tenants, update_tenant, }; -use crate::web::ApiError; +use crate::web::{ApiError, forbidden, internal, not_found, unauthorized}; #[derive(Clone)] pub struct Api { @@ -106,7 +106,7 @@ impl Api { if self.is_admin(authorized_pubkey) { Ok(()) } else { - Err(ApiError::Forbidden("admin required")) + Err(forbidden("admin required")) } } @@ -118,15 +118,23 @@ impl Api { if self.is_admin(authorized_pubkey) || authorized_pubkey == tenant_pubkey { Ok(()) } else { - Err(ApiError::Forbidden("not authorized")) + Err(forbidden("not authorized")) } } pub async fn get_tenant_or_404(&self, pubkey: &str) -> Result { match self.query.get_tenant(pubkey).await { Ok(Some(t)) => Ok(t), - Ok(None) => Err(ApiError::NotFound("tenant not found")), - Err(e) => Err(ApiError::Internal(e.to_string())), + Ok(None) => Err(not_found("tenant not found")), + Err(e) => Err(internal(e)), + } + } + + pub async fn get_relay_or_404(&self, id: &str) -> Result { + match self.query.get_relay(id).await { + Ok(Some(r)) => Ok(r), + Ok(None) => Err(not_found("relay not found")), + Err(e) => Err(internal(e)), } } @@ -137,10 +145,9 @@ impl Api { /// This is the intentional session-style variant of NIP-98 used by the /// Caravel API: it validates signer identity plus host affinity, and does /// not bind to exact request URL/method or maintain replay state. Any - /// failure surfaces as `ApiError::Unauthorized`, which renders as 401. + /// failure surfaces as a 401 response. fn extract_auth_pubkey(&self, headers: &HeaderMap) -> Result { - self.decode_nip98_pubkey(headers) - .map_err(ApiError::Unauthorized) + self.decode_nip98_pubkey(headers).map_err(unauthorized) } fn decode_nip98_pubkey(&self, headers: &HeaderMap) -> Result { diff --git a/backend/src/routes/identity.rs b/backend/src/routes/identity.rs index 92af03a..4ac8d45 100644 --- a/backend/src/routes/identity.rs +++ b/backend/src/routes/identity.rs @@ -1,10 +1,10 @@ use std::sync::Arc; -use axum::{extract::State, http::StatusCode, response::Response}; +use axum::extract::State; use serde::Serialize; use crate::api::{Api, AuthedPubkey}; -use crate::web::ok; +use crate::web::{ApiResult, ok}; #[derive(Serialize)] struct IdentityResponse { @@ -15,7 +15,7 @@ struct IdentityResponse { pub async fn get_identity( State(api): State>, AuthedPubkey(pubkey): AuthedPubkey, -) -> Response { +) -> ApiResult { let is_admin = api.is_admin(&pubkey); - ok(StatusCode::OK, IdentityResponse { pubkey, is_admin }) + ok(IdentityResponse { pubkey, is_admin }) } diff --git a/backend/src/routes/invoices.rs b/backend/src/routes/invoices.rs index e46ccff..0df1c6b 100644 --- a/backend/src/routes/invoices.rs +++ b/backend/src/routes/invoices.rs @@ -1,42 +1,33 @@ use std::sync::Arc; -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::Response, -}; +use axum::extract::{Path, State}; +use reqwest::StatusCode; use crate::api::{Api, AuthedPubkey}; use crate::stripe::InvoiceLookupError; -use crate::web::{ApiError, err, ok}; +use crate::web::{ApiError, ApiResult, bad_request, internal, not_found, ok}; pub async fn list_tenant_invoices( State(api): State>, AuthedPubkey(auth): AuthedPubkey, Path(pubkey): Path, -) -> Result { +) -> ApiResult { api.require_admin_or_tenant(&auth, &pubkey)?; let tenant = api.get_tenant_or_404(&pubkey).await?; - match api + let invoices = api .billing .stripe_list_invoices(&tenant.stripe_customer_id) .await - { - Ok(invoices) => Ok(ok(StatusCode::OK, invoices)), - Err(e) => Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )), - } + .map_err(internal)?; + ok(invoices) } pub async fn get_invoice( State(api): State>, AuthedPubkey(auth): AuthedPubkey, Path(id): Path, -) -> Result { +) -> ApiResult { let (invoice, tenant) = api .billing .get_invoice_with_tenant(&id) @@ -50,14 +41,14 @@ pub async fn get_invoice( .await .map_err(map_invoice_lookup_error)?; - Ok(ok(StatusCode::OK, invoice)) + ok(invoice) } pub async fn get_invoice_bolt11( State(api): State>, AuthedPubkey(auth): AuthedPubkey, Path(id): Path, -) -> Result { +) -> ApiResult { let (invoice, tenant) = api .billing .get_invoice_with_tenant(&id) @@ -73,46 +64,29 @@ pub async fn get_invoice_bolt11( let status = invoice["status"].as_str().unwrap_or_default(); if status != "open" { - return Ok(err( - StatusCode::BAD_REQUEST, - "invoice-not-open", - "invoice is not open", - )); + return Err(bad_request("invoice-not-open", "invoice is not open")); } let amount_due = invoice["amount_due"].as_i64().unwrap_or(0); let currency = invoice["currency"].as_str().unwrap_or("usd"); - match api + let bolt11 = api .billing .get_or_create_manual_lightning_bolt11(&id, &tenant.pubkey, amount_due, currency) .await - { - Ok(bolt11) => Ok(ok(StatusCode::OK, serde_json::json!({ "bolt11": bolt11 }))), - Err(e) => Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )), - } + .map_err(internal)?; + ok(serde_json::json!({ "bolt11": bolt11 })) } fn map_invoice_lookup_error(error: InvoiceLookupError) -> ApiError { match error { - InvoiceLookupError::StripeClient { status } => { - let status = StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_REQUEST); - match status { - StatusCode::NOT_FOUND => ApiError::NotFound("invoice not found"), - StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { - ApiError::Forbidden("invoice access denied") - } - _ => ApiError::Client { - status, - code: "invoice-request-rejected", - message: "invoice request rejected", - }, + InvoiceLookupError::StripeClient { status } => match status { + StatusCode::NOT_FOUND => not_found("invoice not found"), + _ => { + tracing::warn!(%status, "stripe invoice request returned unexpected status"); + internal("invoice request rejected") } - } - InvoiceLookupError::Internal(error) => ApiError::Internal(error.to_string()), + }, + InvoiceLookupError::Internal(error) => internal(error), } } diff --git a/backend/src/routes/plans.rs b/backend/src/routes/plans.rs index a1af2e7..05e507d 100644 --- a/backend/src/routes/plans.rs +++ b/backend/src/routes/plans.rs @@ -1,21 +1,17 @@ use std::sync::Arc; -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::Response, -}; +use axum::extract::{Path, State}; use crate::api::Api; -use crate::web::{err, ok}; +use crate::web::{ApiResult, not_found, ok}; -pub async fn list_plans(State(api): State>) -> Response { - ok(StatusCode::OK, api.query.list_plans()) +pub async fn list_plans(State(api): State>) -> ApiResult { + ok(api.query.list_plans()) } -pub async fn get_plan(State(api): State>, Path(id): Path) -> Response { +pub async fn get_plan(State(api): State>, Path(id): Path) -> ApiResult { match api.query.get_plan(&id) { - Some(plan) => ok(StatusCode::OK, plan), - None => err(StatusCode::NOT_FOUND, "not-found", "plan not found"), + Some(plan) => ok(plan), + None => Err(not_found("plan not found")), } } diff --git a/backend/src/routes/relays.rs b/backend/src/routes/relays.rs index da3f665..0bba3f4 100644 --- a/backend/src/routes/relays.rs +++ b/backend/src/routes/relays.rs @@ -4,8 +4,6 @@ use anyhow::Result; use axum::{ Json, extract::{Path, State}, - http::StatusCode, - response::Response, }; use serde::Deserialize; @@ -13,7 +11,10 @@ use crate::api::{Api, AuthedPubkey}; use crate::models::{ RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, }; -use crate::web::{ApiError, err, map_unique_error, ok, parse_bool_default}; +use crate::web::{ + ApiError, ApiResult, bad_request, created, internal, map_unique_error, ok, + parse_bool_default, unprocessable, +}; #[derive(Deserialize)] pub struct CreateRelayRequest { @@ -51,106 +52,56 @@ pub struct UpdateRelayRequest { pub async fn list_relays( State(api): State>, AuthedPubkey(auth): AuthedPubkey, -) -> Result { +) -> ApiResult { api.require_admin(&auth)?; - match api.query.list_relays().await { - Ok(relays) => Ok(ok(StatusCode::OK, relays)), - Err(e) => Err(ApiError::Internal(e.to_string())), - } + let relays = api.query.list_relays().await.map_err(internal)?; + ok(relays) } pub async fn get_relay( State(api): State>, AuthedPubkey(auth): AuthedPubkey, Path(id): Path, -) -> Result { - let relay = match api.query.get_relay(&id).await { - Ok(Some(r)) => r, - Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")), - Err(e) => { - return Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )); - } - }; - +) -> ApiResult { + let relay = api.get_relay_or_404(&id).await?; api.require_admin_or_tenant(&auth, &relay.tenant)?; - - Ok(ok(StatusCode::OK, relay)) + ok(relay) } pub async fn list_relay_activity( State(api): State>, AuthedPubkey(auth): AuthedPubkey, Path(id): Path, -) -> Result { - let relay = match api.query.get_relay(&id).await { - Ok(Some(r)) => r, - Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")), - Err(e) => { - return Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )); - } - }; - +) -> ApiResult { + let relay = api.get_relay_or_404(&id).await?; api.require_admin_or_tenant(&auth, &relay.tenant)?; - match api.query.list_activity_for_relay(&id).await { - Ok(activity) => Ok(ok( - StatusCode::OK, - serde_json::json!({ "activity": activity }), - )), - Err(e) => Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )), - } + let activity = api + .query + .list_activity_for_relay(&id) + .await + .map_err(internal)?; + ok(serde_json::json!({ "activity": activity })) } pub async fn list_relay_members( State(api): State>, AuthedPubkey(auth): AuthedPubkey, Path(id): Path, -) -> Result { - let relay = match api.query.get_relay(&id).await { - Ok(Some(r)) => r, - Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")), - Err(e) => { - return Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )); - } - }; - +) -> ApiResult { + let relay = api.get_relay_or_404(&id).await?; api.require_admin_or_tenant(&auth, &relay.tenant)?; - match fetch_relay_members(&api, &relay).await { - Ok(members) => Ok(ok( - StatusCode::OK, - serde_json::json!({ "members": members }), - )), - Err(e) => Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )), - } + let members = fetch_relay_members(&api, &relay).await.map_err(internal)?; + ok(serde_json::json!({ "members": members })) } pub async fn create_relay( State(api): State>, AuthedPubkey(auth): AuthedPubkey, Json(payload): Json, -) -> Result { +) -> ApiResult { api.require_admin_or_tenant(&auth, &payload.tenant)?; let relay_id = format!( @@ -159,7 +110,7 @@ pub async fn create_relay( &uuid::Uuid::new_v4().simple().to_string()[..8] ); - let mut relay = Relay { + let relay = Relay { id: relay_id.clone(), tenant: payload.tenant, schema: relay_id.clone(), @@ -181,31 +132,13 @@ pub async fn create_relay( synced: 0, }; - relay = match prepare_relay(&api, relay) { - Ok(r) => r, - Err(e) => { - return Ok(relay_validation_error_response(e)); - } - }; + let relay = prepare_relay(&api, relay).map_err(validation_error)?; - match api.command.create_relay(&relay).await { - Ok(()) => Ok(ok(StatusCode::CREATED, relay)), - Err(e) => { - if matches!(map_unique_error(&e), Some("subdomain-exists")) { - Ok(err( - StatusCode::UNPROCESSABLE_ENTITY, - "subdomain-exists", - "subdomain already exists", - )) - } else { - Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )) - } - } - } + api.command + .create_relay(&relay) + .await + .map_err(map_relay_write_error)?; + created(relay) } pub async fn update_relay( @@ -213,19 +146,8 @@ pub async fn update_relay( AuthedPubkey(auth): AuthedPubkey, Path(id): Path, Json(payload): Json, -) -> Result { - let mut relay = match api.query.get_relay(&id).await { - Ok(Some(r)) => r, - Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")), - Err(e) => { - return Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )); - } - }; - +) -> ApiResult { + let mut relay = api.get_relay_or_404(&id).await?; api.require_admin_or_tenant(&auth, &relay.tenant)?; let current_plan = relay.plan.clone(); @@ -268,12 +190,7 @@ pub async fn update_relay( relay.push_enabled = v; } - relay = match prepare_relay(&api, relay) { - Ok(r) => r, - Err(e) => { - return Ok(relay_validation_error_response(e)); - } - }; + let relay = prepare_relay(&api, relay).map_err(validation_error)?; let plan_changed = requested_plan .as_deref() @@ -285,123 +202,61 @@ pub async fn update_relay( .get_plan(&relay.plan) .expect("validated plan must exist"); if let Some(limit) = selected_plan.members { - let current_members = match fetch_relay_members(&api, &relay).await { - Ok(members) => members.len() as i64, - Err(e) => { - return Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )); - } - }; + let current_members = fetch_relay_members(&api, &relay) + .await + .map_err(internal)? + .len() as i64; if current_members > limit { let message = format!( "relay has {current_members} members, which exceeds the {} plan limit of {limit}", selected_plan.name.to_lowercase() ); - return Ok(err( - StatusCode::UNPROCESSABLE_ENTITY, - "member-limit-exceeded", - &message, - )); + return Err(unprocessable("member-limit-exceeded", &message)); } } } - match api.command.update_relay(&relay).await { - Ok(()) => Ok(ok(StatusCode::OK, relay)), - Err(e) => { - if matches!(map_unique_error(&e), Some("subdomain-exists")) { - Ok(err( - StatusCode::UNPROCESSABLE_ENTITY, - "subdomain-exists", - "subdomain already exists", - )) - } else { - Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )) - } - } - } + api.command + .update_relay(&relay) + .await + .map_err(map_relay_write_error)?; + ok(relay) } pub async fn deactivate_relay( State(api): State>, AuthedPubkey(auth): AuthedPubkey, Path(id): Path, -) -> Result { - let relay = match api.query.get_relay(&id).await { - Ok(Some(r)) => r, - Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")), - Err(e) => { - return Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )); - } - }; - +) -> ApiResult { + let relay = api.get_relay_or_404(&id).await?; api.require_admin_or_tenant(&auth, &relay.tenant)?; if relay.status == RELAY_STATUS_INACTIVE || relay.status == RELAY_STATUS_DELINQUENT { - return Ok(err( - StatusCode::BAD_REQUEST, - "relay-is-inactive", - "relay is already inactive", - )); + return Err(bad_request("relay-is-inactive", "relay is already inactive")); } - match api.command.deactivate_relay(&relay).await { - Ok(()) => Ok(ok(StatusCode::OK, ())), - Err(e) => Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )), - } + api.command + .deactivate_relay(&relay) + .await + .map_err(internal)?; + ok(()) } pub async fn reactivate_relay( State(api): State>, AuthedPubkey(auth): AuthedPubkey, Path(id): Path, -) -> Result { - let relay = match api.query.get_relay(&id).await { - Ok(Some(r)) => r, - Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")), - Err(e) => { - return Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )); - } - }; - +) -> ApiResult { + let relay = api.get_relay_or_404(&id).await?; api.require_admin_or_tenant(&auth, &relay.tenant)?; if relay.status == RELAY_STATUS_ACTIVE { - return Ok(err( - StatusCode::BAD_REQUEST, - "relay-is-active", - "relay is already active", - )); + return Err(bad_request("relay-is-active", "relay is already active")); } - match api.command.activate_relay(&relay).await { - Ok(()) => Ok(ok(StatusCode::OK, ())), - Err(e) => Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )), - } + api.command.activate_relay(&relay).await.map_err(internal)?; + ok(()) } // --- helpers ---------------------------------------------------------------- @@ -447,6 +302,18 @@ fn prepare_relay(api: &Api, mut relay: Relay) -> Result ApiError { + unprocessable(error.code(), error.message()) +} + +fn map_relay_write_error(e: anyhow::Error) -> ApiError { + if matches!(map_unique_error(&e), Some("subdomain-exists")) { + unprocessable("subdomain-exists", "subdomain already exists") + } else { + internal(e) + } +} + const SUBDOMAIN_LABEL_MAX_LEN: usize = 63; const RESERVED_SUBDOMAIN_LABELS: [&str; 2] = ["api", "admin"]; @@ -536,11 +403,3 @@ fn validate_subdomain_label(subdomain: &str) -> Result<(), SubdomainValidationEr Ok(()) } - -fn relay_validation_error_response(error: RelayValidationError) -> Response { - err( - StatusCode::UNPROCESSABLE_ENTITY, - error.code(), - error.message(), - ) -} diff --git a/backend/src/routes/stripe.rs b/backend/src/routes/stripe.rs index c67f057..cc886ef 100644 --- a/backend/src/routes/stripe.rs +++ b/backend/src/routes/stripe.rs @@ -3,13 +3,12 @@ use std::sync::Arc; use axum::{ body::Bytes, extract::{Path, Query as QueryParams, State}, - http::{HeaderMap, StatusCode}, - response::Response, + http::HeaderMap, }; use serde::Deserialize; use crate::api::{Api, AuthedPubkey}; -use crate::web::{ApiError, err, ok}; +use crate::web::{ApiResult, bad_request, internal, ok}; #[derive(Deserialize)] pub struct StripeSessionParams { @@ -21,22 +20,16 @@ pub async fn create_stripe_session( AuthedPubkey(auth): AuthedPubkey, Path(pubkey): Path, QueryParams(params): QueryParams, -) -> Result { +) -> ApiResult { api.require_admin_or_tenant(&auth, &pubkey)?; let tenant = api.get_tenant_or_404(&pubkey).await?; - match api + let url = api .billing .stripe_create_portal_session(&tenant.stripe_customer_id, params.return_url.as_deref()) .await - { - Ok(url) => Ok(ok(StatusCode::OK, serde_json::json!({ "url": url }))), - Err(e) => Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )), - } + .map_err(internal)?; + ok(serde_json::json!({ "url": url })) } /// Stripe webhook endpoint. Authenticated via `Stripe-Signature` verification @@ -45,19 +38,18 @@ pub async fn stripe_webhook( State(api): State>, headers: HeaderMap, body: Bytes, -) -> Response { +) -> ApiResult { let signature = headers .get("Stripe-Signature") .and_then(|v| v.to_str().ok()) .unwrap_or(""); - let payload = match std::str::from_utf8(&body) { - Ok(s) => s, - Err(_) => return err(StatusCode::BAD_REQUEST, "bad-request", "invalid payload"), - }; + let payload = std::str::from_utf8(&body) + .map_err(|_| bad_request("bad-request", "invalid payload"))?; - match api.billing.handle_webhook(payload, signature).await { - Ok(()) => ok(StatusCode::OK, ()), - Err(e) => err(StatusCode::BAD_REQUEST, "webhook-error", &e.to_string()), - } + api.billing + .handle_webhook(payload, signature) + .await + .map_err(|e| bad_request("webhook-error", &e.to_string()))?; + ok(()) } diff --git a/backend/src/routes/tenants.rs b/backend/src/routes/tenants.rs index 0e71f3d..ac7c208 100644 --- a/backend/src/routes/tenants.rs +++ b/backend/src/routes/tenants.rs @@ -3,15 +3,13 @@ use std::sync::Arc; use axum::{ Json, extract::{Path, State}, - http::StatusCode, - response::Response, }; -use serde::{Deserialize, Serialize}; use chrono::Utc; +use serde::{Deserialize, Serialize}; use crate::api::{Api, AuthedPubkey}; use crate::models::Tenant; -use crate::web::{ApiError, err, map_unique_error, ok}; +use crate::web::{ApiResult, internal, map_unique_error, ok}; #[derive(Serialize)] pub struct TenantResponse { @@ -46,23 +44,14 @@ pub struct UpdateTenantRequest { pub async fn list_tenants( State(api): State>, AuthedPubkey(auth): AuthedPubkey, -) -> Result { +) -> ApiResult { api.require_admin(&auth)?; - match api.query.list_tenants().await { - Ok(tenants) => Ok(ok( - StatusCode::OK, - tenants - .into_iter() - .map(TenantResponse::from) - .collect::>(), - )), - Err(e) => Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )), - } + let tenants = api.query.list_tenants().await.map_err(internal)?; + ok(tenants + .into_iter() + .map(TenantResponse::from) + .collect::>()) } /// Creates the tenant row for the calling pubkey. Idempotent: if the tenant @@ -71,60 +60,37 @@ pub async fn list_tenants( pub async fn create_tenant( State(api): State>, AuthedPubkey(pubkey): AuthedPubkey, -) -> Result { - match api.query.get_tenant(&pubkey).await { - Ok(Some(t)) => Ok(ok(StatusCode::OK, TenantResponse::from(t))), - Ok(None) => { - let stripe_customer_id = match api.billing.stripe_create_customer(&pubkey).await { - Ok(id) => id, - Err(e) => { - return Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "stripe-customer-create-failed", - &e.to_string(), - )); - } - }; +) -> ApiResult { + if let Some(t) = api.query.get_tenant(&pubkey).await.map_err(internal)? { + return ok(TenantResponse::from(t)); + } - let tenant = Tenant { - pubkey: pubkey.clone(), - nwc_url: String::new(), - nwc_error: None, - created_at: Utc::now().timestamp(), - stripe_customer_id, - stripe_subscription_id: None, - past_due_at: None, - }; + let stripe_customer_id = api + .billing + .stripe_create_customer(&pubkey) + .await + .map_err(internal)?; - match api.command.create_tenant(&tenant).await { - Ok(()) => Ok(ok(StatusCode::OK, TenantResponse::from(tenant))), - Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => { - match api.query.get_tenant(&pubkey).await { - Ok(Some(t)) => Ok(ok(StatusCode::OK, TenantResponse::from(t))), - Ok(None) => Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - "tenant row missing after unique-constraint race", - )), - Err(e) => Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )), - } - } - Err(e) => Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )), + let tenant = Tenant { + pubkey: pubkey.clone(), + nwc_url: String::new(), + nwc_error: None, + created_at: Utc::now().timestamp(), + stripe_customer_id, + stripe_subscription_id: None, + past_due_at: None, + }; + + match api.command.create_tenant(&tenant).await { + Ok(()) => ok(TenantResponse::from(tenant)), + Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => { + match api.query.get_tenant(&pubkey).await { + Ok(Some(t)) => ok(TenantResponse::from(t)), + Ok(None) => Err(internal("tenant row missing after unique-constraint race")), + Err(e) => Err(internal(e)), } } - Err(e) => Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )), + Err(e) => Err(internal(e)), } } @@ -132,10 +98,10 @@ pub async fn get_tenant( State(api): State>, AuthedPubkey(auth): AuthedPubkey, Path(pubkey): Path, -) -> Result { +) -> ApiResult { api.require_admin_or_tenant(&auth, &pubkey)?; let tenant = api.get_tenant_or_404(&pubkey).await?; - Ok(ok(StatusCode::OK, TenantResponse::from(tenant))) + ok(TenantResponse::from(tenant)) } pub async fn update_tenant( @@ -143,7 +109,7 @@ pub async fn update_tenant( AuthedPubkey(auth): AuthedPubkey, Path(pubkey): Path, Json(payload): Json, -) -> Result { +) -> ApiResult { api.require_admin_or_tenant(&auth, &pubkey)?; let mut tenant = api.get_tenant_or_404(&pubkey).await?; @@ -152,52 +118,40 @@ pub async fn update_tenant( if nwc_url.is_empty() { tenant.nwc_url = String::new(); } else { - tenant.nwc_url = api - .env - .encrypt(&nwc_url) - .map_err(|e| ApiError::Internal(e.to_string()))?; + tenant.nwc_url = api.env.encrypt(&nwc_url).map_err(internal)?; } } - match api.command.update_tenant(&tenant).await { - Ok(()) => { - // When NWC is first connected, attempt to pay any outstanding open invoices. - if nwc_previously_empty && !tenant.nwc_url.is_empty() { - let billing = api.billing.clone(); - let tenant_clone = tenant.clone(); - tokio::spawn(async move { - if let Err(e) = billing.pay_outstanding_nwc_invoices(&tenant_clone).await { - tracing::error!( - error = %e, - pubkey = %tenant_clone.pubkey, - "pay_outstanding_nwc_invoices failed after NWC setup" - ); - } - }); + api.command.update_tenant(&tenant).await.map_err(internal)?; + + // When NWC is first connected, attempt to pay any outstanding open invoices. + if nwc_previously_empty && !tenant.nwc_url.is_empty() { + let billing = api.billing.clone(); + let tenant_clone = tenant.clone(); + tokio::spawn(async move { + if let Err(e) = billing.pay_outstanding_nwc_invoices(&tenant_clone).await { + tracing::error!( + error = %e, + pubkey = %tenant_clone.pubkey, + "pay_outstanding_nwc_invoices failed after NWC setup" + ); } - Ok(ok(StatusCode::OK, TenantResponse::from(tenant))) - } - Err(e) => Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )), + }); } + ok(TenantResponse::from(tenant)) } pub async fn list_tenant_relays( State(api): State>, AuthedPubkey(auth): AuthedPubkey, Path(pubkey): Path, -) -> Result { +) -> ApiResult { api.require_admin_or_tenant(&auth, &pubkey)?; - match api.query.list_relays_for_tenant(&pubkey).await { - Ok(relays) => Ok(ok(StatusCode::OK, relays)), - Err(e) => Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )), - } + let relays = api + .query + .list_relays_for_tenant(&pubkey) + .await + .map_err(internal)?; + ok(relays) } diff --git a/backend/src/web.rs b/backend/src/web.rs index ea5cd4a..494810a 100644 --- a/backend/src/web.rs +++ b/backend/src/web.rs @@ -1,7 +1,11 @@ //! General-purpose HTTP helpers shared across route handlers. //! -//! This module owns the wire response envelopes (`ok` / `err`), the -//! `ApiError` type that route handlers return, and a few stateless utilities. +//! Success builders (`res`, `ok`, `created`) return [`ApiResult`] so they +//! can sit at the end of a handler without an `Ok(..)` wrap. Error builders +//! (`err`, `not_found`, `forbidden`, …) return [`ApiError`] so they compose +//! with `.map_err(...)` and with explicit `Err(...)` returns. + +use std::fmt::Display; use axum::{ Json, @@ -10,8 +14,24 @@ use axum::{ }; use serde::Serialize; +pub struct ApiError(pub Box); + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + *self.0 + } +} + +impl From for ApiError { + fn from(r: Response) -> Self { + Self(Box::new(r)) + } +} + +pub type ApiResult = Result; + #[derive(Serialize)] -pub struct OkResponse { +pub struct DataResponse { pub data: T, pub code: &'static str, } @@ -22,40 +42,23 @@ pub struct ErrorResponse { pub code: String, } -#[derive(Debug)] -pub enum ApiError { - Unauthorized(anyhow::Error), - Forbidden(&'static str), - NotFound(&'static str), - Client { - status: StatusCode, - code: &'static str, - message: &'static str, - }, - Internal(String), +// --- success builders (return ApiResult) ------------------------------------ + +pub fn res(status: StatusCode, data: T) -> ApiResult { + Ok((status, Json(DataResponse { data, code: "ok" })).into_response()) } -impl IntoResponse for ApiError { - fn into_response(self) -> Response { - match self { - Self::Unauthorized(e) => err(StatusCode::UNAUTHORIZED, "unauthorized", &e.to_string()), - Self::Forbidden(message) => err(StatusCode::FORBIDDEN, "forbidden", message), - Self::NotFound(message) => err(StatusCode::NOT_FOUND, "not-found", message), - Self::Client { - status, - code, - message, - } => err(status, code, message), - Self::Internal(message) => err(StatusCode::INTERNAL_SERVER_ERROR, "internal", &message), - } - } +pub fn ok(data: T) -> ApiResult { + res(StatusCode::OK, data) } -pub fn ok(status: StatusCode, data: T) -> Response { - (status, Json(OkResponse { data, code: "ok" })).into_response() +pub fn created(data: T) -> ApiResult { + res(StatusCode::CREATED, data) } -pub fn err(status: StatusCode, code: &str, message: &str) -> Response { +// --- error builders (return ApiError) --------------------------------------- + +pub fn err(status: StatusCode, code: &str, message: &str) -> ApiError { ( status, Json(ErrorResponse { @@ -64,8 +67,39 @@ pub fn err(status: StatusCode, code: &str, message: &str) -> Response { }), ) .into_response() + .into() } +pub fn unauthorized(reason: impl Display) -> ApiError { + err(StatusCode::UNAUTHORIZED, "unauthorized", &reason.to_string()) +} + +pub fn forbidden(message: &str) -> ApiError { + err(StatusCode::FORBIDDEN, "forbidden", message) +} + +pub fn not_found(message: &str) -> ApiError { + err(StatusCode::NOT_FOUND, "not-found", message) +} + +pub fn bad_request(code: &str, message: &str) -> ApiError { + err(StatusCode::BAD_REQUEST, code, message) +} + +pub fn unprocessable(code: &str, message: &str) -> ApiError { + err(StatusCode::UNPROCESSABLE_ENTITY, code, message) +} + +pub fn internal(reason: impl Display) -> ApiError { + err( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + &reason.to_string(), + ) +} + +// --- misc utilities --------------------------------------------------------- + pub fn parse_bool_default(value: i64, default: i64) -> i64 { if value == 0 || value == 1 { value diff --git a/todo.md b/todo.md deleted file mode 100644 index 8803008..0000000 --- a/todo.md +++ /dev/null @@ -1,3 +0,0 @@ -- [ ] Split web utilities and controllers. Use decorators for implementing auth -- [ ] Fix billing by using stripe as a backend to do proration, then mark invoices paid manually when using bitcoin. -- [ ] Send a payment link instead of an invoice so we can generate/pay on the fly