forked from coracle/caravel
Refactor error handling
This commit is contained in:
+16
-9
@@ -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<Tenant, ApiError> {
|
||||
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<Relay, ApiError> {
|
||||
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<String, ApiError> {
|
||||
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<String> {
|
||||
|
||||
@@ -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<Arc<Api>>,
|
||||
AuthedPubkey(pubkey): AuthedPubkey,
|
||||
) -> Response {
|
||||
) -> ApiResult {
|
||||
let is_admin = api.is_admin(&pubkey);
|
||||
ok(StatusCode::OK, IdentityResponse { pubkey, is_admin })
|
||||
ok(IdentityResponse { pubkey, is_admin })
|
||||
}
|
||||
|
||||
@@ -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<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> 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<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> 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<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Arc<Api>>) -> Response {
|
||||
ok(StatusCode::OK, api.query.list_plans())
|
||||
pub async fn list_plans(State(api): State<Arc<Api>>) -> ApiResult {
|
||||
ok(api.query.list_plans())
|
||||
}
|
||||
|
||||
pub async fn get_plan(State(api): State<Arc<Api>>, Path(id): Path<String>) -> Response {
|
||||
pub async fn get_plan(State(api): State<Arc<Api>>, Path(id): Path<String>) -> 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")),
|
||||
}
|
||||
}
|
||||
|
||||
+68
-209
@@ -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<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> 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<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
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<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
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<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
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<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Json(payload): Json<CreateRelayRequest>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> 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<String>,
|
||||
Json(payload): Json<UpdateRelayRequest>,
|
||||
) -> Result<Response, ApiError> {
|
||||
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<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
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<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
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<Relay, RelayValidationEr
|
||||
Ok(relay)
|
||||
}
|
||||
|
||||
fn validation_error(error: RelayValidationError) -> 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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
QueryParams(params): QueryParams<StripeSessionParams>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> 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<Arc<Api>>,
|
||||
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(())
|
||||
}
|
||||
|
||||
+61
-107
@@ -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<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> ApiResult {
|
||||
api.require_admin(&auth)?;
|
||||
|
||||
match api.query.list_tenants().await {
|
||||
Ok(tenants) => Ok(ok(
|
||||
StatusCode::OK,
|
||||
tenants
|
||||
.into_iter()
|
||||
.map(TenantResponse::from)
|
||||
.collect::<Vec<_>>(),
|
||||
)),
|
||||
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::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// 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<Arc<Api>>,
|
||||
AuthedPubkey(pubkey): AuthedPubkey,
|
||||
) -> Result<Response, ApiError> {
|
||||
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<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> 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<String>,
|
||||
Json(payload): Json<UpdateTenantRequest>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> 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<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> 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)
|
||||
}
|
||||
|
||||
+65
-31
@@ -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<Response>);
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
*self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Response> for ApiError {
|
||||
fn from(r: Response) -> Self {
|
||||
Self(Box::new(r))
|
||||
}
|
||||
}
|
||||
|
||||
pub type ApiResult = Result<Response, ApiError>;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct OkResponse<T: Serialize> {
|
||||
pub struct DataResponse<T: Serialize> {
|
||||
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<T: Serialize>(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<T: Serialize>(data: T) -> ApiResult {
|
||||
res(StatusCode::OK, data)
|
||||
}
|
||||
|
||||
pub fn ok<T: Serialize>(status: StatusCode, data: T) -> Response {
|
||||
(status, Json(OkResponse { data, code: "ok" })).into_response()
|
||||
pub fn created<T: Serialize>(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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user