diff --git a/Cargo.lock b/Cargo.lock index 6242505..a9ce9df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5029,6 +5029,8 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "utoipa", + "utoipa-scalar", "uuid", ] @@ -5772,6 +5774,44 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23" +dependencies = [ + "indexmap 2.2.6", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bf0e16c02bc4bf5322ab65f10ab1149bdbcaa782cba66dc7057370a3f8190be" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "syn 2.0.68", + "uuid", +] + +[[package]] +name = "utoipa-scalar" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ab4b7269d14d93626b0bfedf212f1b0995cb7d13d35daba21d579511e7fae8" +dependencies = [ + "axum 0.7.5", + "serde", + "serde_json", + "utoipa", +] + [[package]] name = "uuid" version = "1.9.1" diff --git a/Cargo.toml b/Cargo.toml index a678c7b..d9de9c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,8 @@ humantime = "2.1.0" rust_socketio = { version = "0.6.0", features = ["async"] } bcrypt = "0.15.1" migration = { path = "migration" } +utoipa = { version = "4.2.3", features = ["axum_extras", "chrono", "uuid"] } +utoipa-scalar = { version = "0.1.0", features = ["axum"] } [build-dependencies] tonic-build = "0.11.0" diff --git a/config/development.yml b/config/development.yml index 4b2b88f..66236f2 100644 --- a/config/development.yml +++ b/config/development.yml @@ -1,4 +1,4 @@ -domain: "swissknife" +domain: "numeraire.tech" ln_provider: breez auth_provider: bypass diff --git a/migration/src/m20240420_194334_ln_address_table.rs b/migration/src/m20240420_194334_ln_address_table.rs index 5ff2c6f..61f517d 100644 --- a/migration/src/m20240420_194334_ln_address_table.rs +++ b/migration/src/m20240420_194334_ln_address_table.rs @@ -20,8 +20,7 @@ impl MigrationTrait for Migration { username varchar(255) unique NOT NULL, active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT current_timestamp, - updated_at timestamptz, - deleted_at timestamptz + updated_at timestamptz ); CREATE TRIGGER update_timestamp BEFORE UPDATE ON ln_address FOR EACH ROW EXECUTE PROCEDURE update_timestamp();", diff --git a/migration/src/m20240420_195225_invoice_table.rs b/migration/src/m20240420_195225_invoice_table.rs index 2e0ca03..3d8cafe 100644 --- a/migration/src/m20240420_195225_invoice_table.rs +++ b/migration/src/m20240420_195225_invoice_table.rs @@ -18,7 +18,7 @@ impl MigrationTrait for Migration { id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id varchar(255) NOT NULL, payment_hash varchar(255), - ln_address uuid, + ln_address_id uuid, bolt11 varchar, ledger varchar(255) NOT NULL, currency varchar(255) NOT NULL, @@ -35,7 +35,7 @@ impl MigrationTrait for Migration { created_at timestamptz NOT NULL DEFAULT current_timestamp, updated_at timestamptz, expires_at timestamptz, - CONSTRAINT fk_ln_address FOREIGN KEY (ln_address) + CONSTRAINT fk_ln_address FOREIGN KEY (ln_address_id) REFERENCES ln_address (id) ON DELETE SET NULL ); diff --git a/src/application/docs/mod.rs b/src/application/docs/mod.rs new file mode 100644 index 0000000..5fca8c3 --- /dev/null +++ b/src/application/docs/mod.rs @@ -0,0 +1,3 @@ +mod openapi; + +pub use openapi::*; diff --git a/src/application/docs/openapi.rs b/src/application/docs/openapi.rs new file mode 100644 index 0000000..752e802 --- /dev/null +++ b/src/application/docs/openapi.rs @@ -0,0 +1,105 @@ +use crate::{ + application::{ + dtos::ErrorResponse, + entities::{Currency, Ledger, OrderDirection}, + }, + domains::{ + invoices::api::InvoiceHandler, + lightning::api::{BreezNodeHandler, LnAddressHandler, LnURLpHandler}, + payments::api::PaymentHandler, + system::api::SystemHandler, + users::api::AuthHandler, + wallet::api::WalletHandler, + }, +}; +use utoipa::{ + openapi::{ + security::{Http, HttpAuthScheme, SecurityScheme}, + Components, OpenApi, + }, + Modify, OpenApi as OpenApiDoc, +}; + +#[derive(OpenApiDoc)] +#[openapi( + info( + title = "Numeraire SwissKnife REST API", + description = "This API is available to anyone with a Numeraire account. The `Wallet` endpoints are the main access point for most users.", + ), + components(schemas(OrderDirection, Ledger, Currency), responses(ErrorResponse)), + modifiers(&SecurityAddon), +)] +struct ApiDoc; + +pub fn merged_openapi() -> OpenApi { + let mut openapi = ApiDoc::openapi(); + openapi.merge(AuthHandler::openapi()); + openapi.merge(WalletHandler::openapi()); + openapi.merge(InvoiceHandler::openapi()); + openapi.merge(PaymentHandler::openapi()); + openapi.merge(LnAddressHandler::openapi()); + openapi.merge(LnURLpHandler::openapi()); + openapi.merge(BreezNodeHandler::openapi()); + openapi.merge(SystemHandler::openapi()); + openapi +} + +pub const BAD_REQUEST_EXAMPLE: &str = r#" +{ + "status": "400 Bad Request", + "reason": "Missing required parameter in request" +} +"#; + +pub const UNAUTHORIZED_EXAMPLE: &str = r#" +{ + "status": "401 Unauthorized", + "reason": "Invalid credentials" +} +"#; + +pub const FORBIDDEN_EXAMPLE: &str = r#" +{ + "status": "403 Forbidden", + "reason": "Missing permissions" +} +"#; + +pub const NOT_FOUND_EXAMPLE: &str = r#" +{ + "status": "404 Not Found", + "reason": "Resouce not found" +} +"#; + +pub const UNSUPPORTED_EXAMPLE: &str = r#" +{ + "status": "405 Method Not Allowed", + "reason": "Sign in not allowed (not needed) for oauth2 provider" +} +"#; + +pub const UNPROCESSABLE_EXAMPLE: &str = r#" +{ + "status": "422 Unprocessable Entity", + "reason": "Validation failed: ..." +} +"#; + +pub const INTERNAL_EXAMPLE: &str = r#" +{ + "status": "500 Internal Server Error", + "reason": "Internal server error, Please contact your administrator or try later" +} +"#; + +struct SecurityAddon; +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut OpenApi) { + let components: &mut Components = openapi.components.as_mut().unwrap(); // we can unwrap safely since there already is components registered. + components.add_security_scheme( + "jwt", + SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)), + ); + } +} diff --git a/src/application/dtos/auth.rs b/src/application/dtos/auth.rs new file mode 100644 index 0000000..53d8db6 --- /dev/null +++ b/src/application/dtos/auth.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Sign In Request +#[derive(Debug, Deserialize, ToSchema)] +pub struct SignInRequest { + /// User password + #[schema(example = "password_from_config_file")] + pub password: String, +} + +/// Sign In Response +#[derive(Debug, Serialize, ToSchema)] +pub struct SignInResponse { + /// JWT token + #[schema(example = "eyJ0eXAiOiJKV1QiLCJhbGciOiJ...")] + pub token: String, +} diff --git a/src/application/dtos/error.rs b/src/application/dtos/error.rs new file mode 100644 index 0000000..a22ae5b --- /dev/null +++ b/src/application/dtos/error.rs @@ -0,0 +1,14 @@ +use serde::Serialize; +use utoipa::{ToResponse, ToSchema}; + +/// Application Error Response +#[derive(Serialize, ToResponse, ToSchema)] +pub struct ErrorResponse { + /// Error status + #[schema(example = "401 Unauthorized")] + pub status: String, + + /// Error reason + #[schema(example = "error message")] + pub reason: String, +} diff --git a/src/application/dtos/invoices.rs b/src/application/dtos/invoices.rs new file mode 100644 index 0000000..5d2aa76 --- /dev/null +++ b/src/application/dtos/invoices.rs @@ -0,0 +1,15 @@ +use serde::Deserialize; +use utoipa::ToSchema; + +/// New Invoice Request +#[derive(Debug, Deserialize, ToSchema)] +pub struct NewInvoiceRequest { + /// User ID. Will be populated with your own ID by default + pub user_id: Option, + /// Amount in millisatoshis + pub amount_msat: u64, + /// Description of the invoice. Visible by the payer + pub description: Option, + /// Expiration time in seconds + pub expiry: Option, +} diff --git a/src/application/dtos/lightning.rs b/src/application/dtos/lightning.rs index f6133f2..74d47cf 100644 --- a/src/application/dtos/lightning.rs +++ b/src/application/dtos/lightning.rs @@ -1,54 +1,69 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; -#[derive(Debug, Deserialize)] -pub struct NewInvoiceRequest { - pub user_id: Option, - pub amount_msat: u64, - pub description: Option, - pub expiry: Option, -} - -#[derive(Debug, Deserialize)] -pub struct SendPaymentRequest { - pub user_id: Option, - pub input: String, - pub amount_msat: Option, - pub comment: Option, -} +#[derive(Debug, Deserialize, ToSchema)] +pub struct RedeemOnchainRequest { + /// Recipient BTC address + pub to_address: String, -// Part of the lightning types because this is the payload to send from the node with a swap service -#[derive(Debug, Deserialize)] -pub struct SendOnchainPaymentRequest { - pub amount_msat: u64, - pub recipient_address: String, + /// Fee rate in sats/vb + #[schema(example = "8")] pub feerate: u32, } -#[derive(Debug, Deserialize)] -pub struct RedeemOnchainRequest { - pub to_address: String, - pub feerate: u32, +#[derive(Debug, Serialize, ToSchema)] +pub struct RedeemOnchainResponse { + /// Transaction ID + #[schema(example = "ceb662f7e470e6...")] + pub txid: String, } -#[derive(Debug, Deserialize)] -pub struct RegisterLightningAddressRequest { +#[derive(Debug, Deserialize, ToSchema)] +pub struct RegisterLnAddressRequest { + /// User ID. Will be populated with your own ID by default pub user_id: Option, + /// Username such as `username@domain` pub username: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct ConnectLSPRequest { + /// LSP ID + #[schema(example = "3e8822d5-00de-4fa3-a30e-c2d31f5454e8")] pub lsp_id: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct SignMessageRequest { + /// Message + #[schema(example = "my message...")] pub message: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, ToSchema)] +pub struct SignMessageResponse { + /// zbase encoded signature + #[schema(example = "d7norubk1xweo96ompcgqg4g4gyy...")] + pub signature: String, +} + +#[derive(Debug, Deserialize, ToSchema)] pub struct CheckMessageRequest { + /// Original message + #[schema(example = "my message...")] pub message: String, + + /// zbase encoded signature + #[schema(example = "d7norubk1xweo96ompcgqg4g4gyy...")] pub signature: String, + + /// Node public key + #[schema(example = "021e15c10d72f86a79323d1e3a42...")] pub pubkey: String, } + +#[derive(Debug, Serialize, ToSchema)] +pub struct CheckMessageResponse { + /// Signature validity + pub is_valid: bool, +} diff --git a/src/application/dtos/lnurl.rs b/src/application/dtos/lnurl.rs index a92c580..fb6d6dc 100644 --- a/src/application/dtos/lnurl.rs +++ b/src/application/dtos/lnurl.rs @@ -1,7 +1,10 @@ use serde::Deserialize; +use utoipa::IntoParams; -#[derive(Deserialize)] +#[derive(Debug, Deserialize, IntoParams)] pub struct LNUrlpInvoiceQueryParams { + /// Amount in millisatoshis pub amount: u64, + /// Optional comment for the recipient pub comment: Option, } diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index 0290fd0..2db5d12 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -1,9 +1,15 @@ +mod auth; mod config; +mod error; +mod invoices; mod lightning; mod lnurl; -mod user; +mod payments; +pub use auth::*; pub use config::*; +pub use error::*; +pub use invoices::*; pub use lightning::*; pub use lnurl::*; -pub use user::*; +pub use payments::*; diff --git a/src/application/dtos/payments.rs b/src/application/dtos/payments.rs new file mode 100644 index 0000000..054e885 --- /dev/null +++ b/src/application/dtos/payments.rs @@ -0,0 +1,34 @@ +use serde::Deserialize; +use utoipa::ToSchema; + +/// Send Payment Request +#[derive(Debug, Deserialize, ToSchema)] +pub struct SendPaymentRequest { + /// User ID. Will be populated with your own ID by default + pub user_id: Option, + + /// Recipient. Can be a Bolt11 invoice, LNURL or LN Address. Keysend and On-chain payments not yet supported + #[schema(example = "hello@numeraire.tech")] + pub input: String, + + /// Amount in millisatoshis. Only necessary if the input does not specify an amount (empty Bolt11, LNURL or LN Address) + pub amount_msat: Option, + /// Comment of the payment. Visible by the recipient for LNURL payments + pub comment: Option, +} + +/// Send On-chain Payment Request +#[derive(Debug, Deserialize, ToSchema)] +pub struct SendOnchainPaymentRequest { + /// Amount in millisatoshis + #[schema(example = 100000000)] + pub amount_msat: u64, + + /// Recipient Bitcoin address + #[schema(example = "bc1q7jys2n3jjf9t25r6ut369taap8v38pgqekq8v4")] + pub recipient_address: String, + + /// Fee rate in sats/vb + #[schema(example = "8")] + pub feerate: u32, +} diff --git a/src/application/dtos/user.rs b/src/application/dtos/user.rs deleted file mode 100644 index 76f2c1c..0000000 --- a/src/application/dtos/user.rs +++ /dev/null @@ -1,6 +0,0 @@ -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -pub struct LoginRequest { - pub password: String, -} diff --git a/src/application/entities/mod.rs b/src/application/entities/mod.rs index f5ecf3c..b1d0a36 100644 --- a/src/application/entities/mod.rs +++ b/src/application/entities/mod.rs @@ -1,11 +1,9 @@ mod ordering; -mod pagination; mod services; mod store; mod transaction; pub use ordering::*; -pub use pagination::*; pub use services::*; pub use store::AppStore; pub use transaction::*; diff --git a/src/application/entities/ordering.rs b/src/application/entities/ordering.rs index 215654e..970473c 100644 --- a/src/application/entities/ordering.rs +++ b/src/application/entities/ordering.rs @@ -1,8 +1,11 @@ use sea_orm::Order; use serde::{Deserialize, Serialize}; use strum_macros::{Display, EnumString}; +use utoipa::ToSchema; -#[derive(Clone, Debug, EnumString, Deserialize, Serialize, Display, PartialEq, Eq, Default)] +#[derive( + Clone, Debug, EnumString, Deserialize, Serialize, Display, PartialEq, Eq, Default, ToSchema, +)] pub enum OrderDirection { #[default] Desc, diff --git a/src/application/entities/pagination.rs b/src/application/entities/pagination.rs deleted file mode 100644 index dbe4730..0000000 --- a/src/application/entities/pagination.rs +++ /dev/null @@ -1,11 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_with::{serde_as, DisplayFromStr}; - -#[serde_as] -#[derive(Clone, Debug, Deserialize, Serialize, Default)] -pub struct PaginationFilter { - #[serde_as(as = "Option")] - pub limit: Option, - #[serde_as(as = "Option")] - pub offset: Option, -} diff --git a/src/application/entities/transaction.rs b/src/application/entities/transaction.rs index 9aa94c4..8783d91 100644 --- a/src/application/entities/transaction.rs +++ b/src/application/entities/transaction.rs @@ -1,7 +1,10 @@ use serde::{Deserialize, Serialize}; use strum_macros::{Display, EnumString}; +use utoipa::ToSchema; -#[derive(Clone, Debug, EnumString, Deserialize, Serialize, Display, PartialEq, Eq, Default)] +#[derive( + Clone, Debug, EnumString, Deserialize, Serialize, Display, PartialEq, Eq, Default, ToSchema, +)] pub enum Ledger { #[default] Lightning, @@ -9,7 +12,9 @@ pub enum Ledger { Onchain, } -#[derive(Clone, Debug, EnumString, Deserialize, Serialize, Display, PartialEq, Eq, Default)] +#[derive( + Clone, Debug, EnumString, Deserialize, Serialize, Display, PartialEq, Eq, Default, ToSchema, +)] pub enum Currency { #[default] Bitcoin, diff --git a/src/application/errors/application_error.rs b/src/application/errors/application_error.rs index b1b70ce..21a2766 100644 --- a/src/application/errors/application_error.rs +++ b/src/application/errors/application_error.rs @@ -1,11 +1,12 @@ use thiserror::Error; +use utoipa::ToSchema; use super::{ AuthenticationError, AuthorizationError, ConfigError, DataError, DatabaseError, LightningError, WebServerError, }; -#[derive(Debug, Error)] +#[derive(Debug, Error, ToSchema)] pub enum ApplicationError { #[error(transparent)] Config(#[from] ConfigError), diff --git a/src/application/mod.rs b/src/application/mod.rs index cc5c380..323346e 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -1,3 +1,4 @@ +pub mod docs; pub mod dtos; pub mod entities; pub mod errors; diff --git a/src/domains/invoices/adapters/invoice_model.rs b/src/domains/invoices/adapters/invoice_model.rs index 0fc8176..c56308d 100644 --- a/src/domains/invoices/adapters/invoice_model.rs +++ b/src/domains/invoices/adapters/invoice_model.rs @@ -16,7 +16,7 @@ pub struct Model { pub currency: String, pub ledger: String, pub payment_hash: Option, - pub ln_address: Option, + pub ln_address_id: Option, pub bolt11: Option, pub payee_pubkey: Option, pub description: Option, @@ -37,7 +37,7 @@ pub struct Model { pub enum Relation { #[sea_orm( belongs_to = "crate::domains::lightning::adapters::ln_address_model::Entity", - from = "Column::LnAddress", + from = "Column::LnAddressId", to = "crate::domains::lightning::adapters::ln_address_model::Column::Id", on_update = "NoAction", on_delete = "SetNull" @@ -83,7 +83,7 @@ impl From for Invoice { Invoice { id: model.id, user_id: model.user_id, - ln_address: model.ln_address, + ln_address_id: model.ln_address_id, description: model.description, amount_msat: model.amount_msat.map(|v| v as u64), timestamp: model.timestamp, diff --git a/src/domains/invoices/adapters/invoice_repository.rs b/src/domains/invoices/adapters/invoice_repository.rs index 028b16d..ccecf60 100644 --- a/src/domains/invoices/adapters/invoice_repository.rs +++ b/src/domains/invoices/adapters/invoice_repository.rs @@ -74,8 +74,8 @@ impl InvoiceRepository for SeaOrmInvoiceRepository { }) .order_by(Column::PaymentTime, order_direction.clone()) .order_by(Column::Timestamp, order_direction) - .offset(filter.pagination.offset) - .limit(filter.pagination.limit) + .offset(filter.offset) + .limit(filter.limit) .all(&self.db) .await .map_err(|e| DatabaseError::FindMany(e.to_string()))?; @@ -90,7 +90,7 @@ impl InvoiceRepository for SeaOrmInvoiceRepository { ) -> Result { let mut model = ActiveModel { user_id: Set(invoice.user_id), - ln_address: Set(invoice.ln_address), + ln_address_id: Set(invoice.ln_address_id), description: Set(invoice.description), amount_msat: Set(invoice.amount_msat.map(|v| v as i64)), timestamp: Set(invoice.timestamp), diff --git a/src/domains/invoices/api/invoice_handler.rs b/src/domains/invoices/api/invoice_handler.rs index 3943295..ec59d37 100644 --- a/src/domains/invoices/api/invoice_handler.rs +++ b/src/domains/invoices/api/invoice_handler.rs @@ -6,93 +6,195 @@ use axum::{ Json, Router, }; use axum_extra::extract::Query; +use utoipa::OpenApi; use uuid::Uuid; use crate::{ - application::{dtos::NewInvoiceRequest, errors::ApplicationError}, + application::{ + docs::{ + BAD_REQUEST_EXAMPLE, FORBIDDEN_EXAMPLE, INTERNAL_EXAMPLE, NOT_FOUND_EXAMPLE, + UNAUTHORIZED_EXAMPLE, UNPROCESSABLE_EXAMPLE, + }, + dtos::NewInvoiceRequest, + errors::ApplicationError, + }, domains::{ - invoices::entities::{Invoice, InvoiceFilter}, + invoices::entities::{Invoice, InvoiceFilter, InvoiceStatus, LnInvoice}, users::entities::{AuthUser, Permission}, }, infra::app::AppState, }; +#[derive(OpenApi)] +#[openapi( + paths(generate, list, get_one, delete_one, delete_many), + components(schemas(Invoice, NewInvoiceRequest, InvoiceStatus, LnInvoice)), + tags( + (name = "Invoices", description = "Invoice management endpoints. Require authorization.") + ), + security(("jwt" = ["read:transactions", "write:transactions"])) +)] pub struct InvoiceHandler; +pub const CONTEXT_PATH: &str = "/api/invoices"; + +pub fn router() -> Router> { + Router::new() + .route("/", post(generate)) + .route("/", get(list)) + .route("/:id", get(get_one)) + .route("/:id", delete(delete_one)) + .route("/", delete(delete_many)) +} + +/// Generate a new invoice +/// +/// Returns the generated invoice for the given user +#[utoipa::path( + post, + path = "", + tag = "Invoices", + context_path = CONTEXT_PATH, + request_body = NewInvoiceRequest, + responses( + (status = 200, description = "Invoice Created", body = Invoice), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 422, description = "Unprocessable Entity", body = ErrorResponse, example = json!(UNPROCESSABLE_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn generate( + State(app_state): State>, + user: AuthUser, + Json(payload): Json, +) -> Result, ApplicationError> { + user.check_permission(Permission::WriteLnTransaction)?; + + let invoice = app_state + .services + .invoice + .invoice( + payload.user_id.unwrap_or(user.sub), + payload.amount_msat, + payload.description, + payload.expiry, + ) + .await?; + Ok(invoice.into()) +} + +/// Find an invoice +/// +/// Returns the invoice by its ID. +#[utoipa::path( + get, + path = "/{id}", + tag = "Invoices", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "Found", body = Invoice), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 404, description = "Not Found", body = ErrorResponse, example = json!(NOT_FOUND_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn get_one( + State(app_state): State>, + user: AuthUser, + Path(id): Path, +) -> Result, ApplicationError> { + user.check_permission(Permission::ReadLnTransaction)?; + + let invoice = app_state.services.invoice.get(id).await?; + Ok(invoice.into()) +} + +/// List invoices +/// +/// Returns all the invoices given a filter +#[utoipa::path( + get, + path = "", + tag = "Invoices", + context_path = CONTEXT_PATH, + params(InvoiceFilter), + responses( + (status = 200, description = "Success", body = Vec), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn list( + State(app_state): State>, + user: AuthUser, + Query(filter): Query, +) -> Result>, ApplicationError> { + user.check_permission(Permission::ReadLnTransaction)?; + + let lightning_invoices = app_state.services.invoice.list(filter).await?; + + let response: Vec = lightning_invoices.into_iter().map(Into::into).collect(); + + Ok(response.into()) +} + +/// Delete an invoice +/// +/// Deletes an invoice by ID. Returns an empty body. Deleting an invoice has an effect on the user balance +#[utoipa::path( + delete, + path = "/{id}", + tag = "Invoices", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "Deleted"), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 404, description = "Not Found", body = ErrorResponse, example = json!(NOT_FOUND_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn delete_one( + State(app_state): State>, + user: AuthUser, + Path(id): Path, +) -> Result<(), ApplicationError> { + user.check_permission(Permission::WriteLnTransaction)?; + + app_state.services.invoice.delete(id).await?; + Ok(()) +} -impl InvoiceHandler { - pub fn routes() -> Router> { - Router::new() - .route("/", post(Self::generate)) - .route("/", get(Self::list)) - .route("/:id", get(Self::get)) - .route("/:id", delete(Self::delete)) - .route("/", delete(Self::delete_many)) - } - - async fn generate( - State(app_state): State>, - user: AuthUser, - Json(payload): Json, - ) -> Result, ApplicationError> { - user.check_permission(Permission::WriteLnTransaction)?; - - let invoice = app_state - .services - .invoice - .invoice( - payload.user_id.unwrap_or(user.sub), - payload.amount_msat, - payload.description, - payload.expiry, - ) - .await?; - Ok(invoice.into()) - } - - async fn get( - State(app_state): State>, - user: AuthUser, - Path(id): Path, - ) -> Result, ApplicationError> { - user.check_permission(Permission::ReadLnTransaction)?; - - let invoice = app_state.services.invoice.get(id).await?; - Ok(invoice.into()) - } - - async fn list( - State(app_state): State>, - user: AuthUser, - Query(query_params): Query, - ) -> Result>, ApplicationError> { - user.check_permission(Permission::ReadLnTransaction)?; - - let lightning_invoices = app_state.services.invoice.list(query_params).await?; - - let response: Vec = lightning_invoices.into_iter().map(Into::into).collect(); - - Ok(response.into()) - } - - async fn delete( - State(app_state): State>, - user: AuthUser, - Path(id): Path, - ) -> Result<(), ApplicationError> { - user.check_permission(Permission::WriteLnTransaction)?; - - app_state.services.invoice.delete(id).await?; - Ok(()) - } - - async fn delete_many( - State(app_state): State>, - user: AuthUser, - Query(query_params): Query, - ) -> Result, ApplicationError> { - user.check_permission(Permission::WriteLnTransaction)?; - - let n_deleted = app_state.services.invoice.delete_many(query_params).await?; - Ok(n_deleted.into()) - } +/// Delete invoices +/// +/// Deletes all the invoices given a filter. Returns the number of deleted invoices. Deleting an invoice can have an effect on the user balance +#[utoipa::path( + delete, + path = "", + tag = "Invoices", + context_path = CONTEXT_PATH, + params(InvoiceFilter), + responses( + (status = 200, description = "Success", body = u64), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn delete_many( + State(app_state): State>, + user: AuthUser, + Query(query_params): Query, +) -> Result, ApplicationError> { + user.check_permission(Permission::WriteLnTransaction)?; + + let n_deleted = app_state.services.invoice.delete_many(query_params).await?; + Ok(n_deleted.into()) } diff --git a/src/domains/invoices/api/mod.rs b/src/domains/invoices/api/mod.rs index 4b58874..dfaa7c8 100644 --- a/src/domains/invoices/api/mod.rs +++ b/src/domains/invoices/api/mod.rs @@ -1,2 +1,2 @@ mod invoice_handler; -pub use invoice_handler::InvoiceHandler; +pub use invoice_handler::*; diff --git a/src/domains/invoices/entities/invoice.rs b/src/domains/invoices/entities/invoice.rs index ea65a45..f522744 100644 --- a/src/domains/invoices/entities/invoice.rs +++ b/src/domains/invoices/entities/invoice.rs @@ -2,54 +2,93 @@ use std::time::Duration; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use serde_with::{serde_as, DurationSeconds}; +use serde_with::{serde_as, DisplayFromStr, DurationSeconds}; use strum_macros::{Display, EnumString}; +use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use crate::application::entities::OrderDirection; -use crate::application::entities::{Currency, Ledger, PaginationFilter}; +use crate::application::entities::{Currency, Ledger}; #[serde_as] -#[derive(Clone, Debug, Default, Serialize)] +#[derive(Clone, Debug, Default, Serialize, ToSchema)] pub struct Invoice { + /// Internal ID pub id: Uuid, + /// User ID pub user_id: String, + /// Lightning Address. Populated when invoice is generated as part of the LNURL protocol #[serde(skip_serializing_if = "Option::is_none")] - pub ln_address: Option, + pub ln_address_id: Option, + /// Description pub description: Option, + /// Currency. Different networks use different currencies such as testnet pub currency: Currency, + /// Amount in millisatoshis. pub amount_msat: Option, + /// Date of creation on the LN node pub timestamp: DateTime, + /// Status pub status: InvoiceStatus, + /// Ledger pub ledger: Ledger, #[serde(skip_serializing_if = "Option::is_none")] + /// Fees paid. Populated when a new channel is opened to receive the funds. pub fee_msat: Option, #[serde(skip_serializing_if = "Option::is_none")] + /// Payment time pub payment_time: Option>, + /// Date of creation in database pub created_at: DateTime, #[serde(skip_serializing_if = "Option::is_none")] + /// Date of update in database pub updated_at: Option>, + #[serde(flatten)] #[serde(skip_serializing_if = "Option::is_none")] + /// Lightning details of the invoice pub lightning: Option, } #[serde_as] -#[derive(Clone, Debug, Default, Serialize)] +#[derive(Clone, Debug, Default, Serialize, ToSchema)] pub struct LnInvoice { + /// Payment hash + #[schema(example = "b587c7f76339e3fb87ad2b...")] pub payment_hash: String, + + /// Bolt11 + #[schema(example = "lnbcrt1m1png24kasp5...")] pub bolt11: String, + + /// Description hash #[serde(skip_serializing_if = "Option::is_none")] pub description_hash: Option, + + /// Public key of the node receiving the funds + #[schema(example = "02086a3f5b67ac4c43...")] pub payee_pubkey: String, + + /// The minimum number of blocks the final hop in the route should wait before allowing the payment to be claimed. This is a security measure to ensure that the payment can be settled properly + #[schema(example = 10)] pub min_final_cltv_expiry_delta: u64, + + /// A secret value included in the payment request to mitigate certain types of attacks. The payment secret must be provided by the payer when making the payment + #[schema(example = "019a32e03bb375a42bc...")] pub payment_secret: String, + + /// Duration of expiry in seconds since creation #[serde_as(as = "DurationSeconds")] + #[schema(example = 3600)] pub expiry: Duration, + + /// Date of expiry pub expires_at: DateTime, } -#[derive(Clone, Debug, EnumString, Deserialize, Serialize, Display, PartialEq, Eq, Default)] +#[derive( + Clone, Debug, EnumString, Deserialize, Serialize, Display, PartialEq, Eq, Default, ToSchema, +)] pub enum InvoiceStatus { #[default] Pending, @@ -57,14 +96,24 @@ pub enum InvoiceStatus { Expired, } -#[derive(Clone, Debug, Deserialize, Serialize, Default)] +#[serde_as] +#[derive(Clone, Debug, Deserialize, Serialize, Default, IntoParams)] pub struct InvoiceFilter { - #[serde(flatten)] - pub pagination: PaginationFilter, + /// Total amount of results to return + #[serde_as(as = "Option")] + pub limit: Option, + /// Offset where to start returning results + #[serde_as(as = "Option")] + pub offset: Option, + /// List of IDs pub ids: Option>, + /// User ID. Automatically populated with your user ID pub user_id: Option, + /// Status pub status: Option, + /// Ledger pub ledger: Option, #[serde(default)] + /// Direction of the ordering of results pub order_direction: OrderDirection, } diff --git a/src/domains/lightning/adapters/ln_address_model.rs b/src/domains/lightning/adapters/ln_address_model.rs index afd7f4c..9b7930c 100644 --- a/src/domains/lightning/adapters/ln_address_model.rs +++ b/src/domains/lightning/adapters/ln_address_model.rs @@ -16,7 +16,6 @@ pub struct Model { pub active: bool, pub created_at: DateTimeUtc, pub updated_at: Option, - pub deleted_at: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] @@ -42,7 +41,6 @@ impl From for LnAddress { active: model.active, created_at: model.created_at, updated_at: model.updated_at, - deleted_at: model.deleted_at, } } } diff --git a/src/domains/lightning/adapters/ln_address_repository.rs b/src/domains/lightning/adapters/ln_address_repository.rs index 331ee73..f28df90 100644 --- a/src/domains/lightning/adapters/ln_address_repository.rs +++ b/src/domains/lightning/adapters/ln_address_repository.rs @@ -61,8 +61,8 @@ impl LnAddressRepository for SeaOrmLnAddressRepository { }) .apply_if(filter.ids, |q, ids| q.filter(Column::Id.is_in(ids))) .order_by_desc(Column::CreatedAt) - .offset(filter.pagination.offset) - .limit(filter.pagination.limit) + .offset(filter.offset) + .limit(filter.limit) .all(&self.db) .await .map_err(|e| DatabaseError::FindMany(e.to_string()))?; diff --git a/src/domains/lightning/api/breez_node_handler.rs b/src/domains/lightning/api/breez_node_handler.rs index ec3d065..08b5923 100644 --- a/src/domains/lightning/api/breez_node_handler.rs +++ b/src/domains/lightning/api/breez_node_handler.rs @@ -9,12 +9,18 @@ use axum::{ Json, Router, }; use breez_sdk_core::{LspInformation, NodeState, ReverseSwapInfo}; +use utoipa::OpenApi; use crate::{ application::{ + docs::{ + BAD_REQUEST_EXAMPLE, FORBIDDEN_EXAMPLE, INTERNAL_EXAMPLE, NOT_FOUND_EXAMPLE, + UNAUTHORIZED_EXAMPLE, + }, dtos::{ - CheckMessageRequest, ConnectLSPRequest, RedeemOnchainRequest, - SendOnchainPaymentRequest, SignMessageRequest, + CheckMessageRequest, CheckMessageResponse, ConnectLSPRequest, RedeemOnchainRequest, + RedeemOnchainResponse, SendOnchainPaymentRequest, SignMessageRequest, + SignMessageResponse, }, errors::{ApplicationError, LightningError}, }, @@ -22,185 +28,368 @@ use crate::{ infra::{app::AppState, lightning::LnClient}, }; +#[derive(OpenApi)] +#[openapi( + paths(node_info, lsp_info, list_lsps, close_lsp_channels, connect_lsp, swap, redeem, sign_message, check_message, sync, backup), + components(schemas(ConnectLSPRequest, SendOnchainPaymentRequest, RedeemOnchainRequest, SignMessageRequest, CheckMessageRequest, SignMessageResponse, CheckMessageResponse)), + tags( + (name = "Lightning Node", description = "LN Node management endpoints. Currently only available for `breez` Lightning provider. Require authorization.") + ), + security(("jwt" = ["read:ln_node", "write:ln_node"])) +)] pub struct BreezNodeHandler; +pub const CONTEXT_PATH: &str = "/api/lightning/node"; + +pub fn breez_node_router() -> Router> { + Router::new() + .route("/info", get(node_info)) + .route("/lsp-info", get(lsp_info)) + .route("/lsps", get(list_lsps)) + .route("/close-channels", post(close_lsp_channels)) + .route("/connect-lsp", post(connect_lsp)) + .route("/swap", post(swap)) + .route("/redeem", post(redeem)) + .route("/sign-message", post(sign_message)) + .route("/check-message", post(check_message)) + .route("/sync", post(sync)) + .route("/backup", get(backup)) +} -impl BreezNodeHandler { - pub fn routes() -> Router> { - Router::new() - .route("/info", get(Self::node_info)) - .route("/lsp-info", get(Self::lsp_info)) - .route("/lsps", get(Self::list_lsps)) - .route("/close-channels", post(Self::close_lsp_channels)) - .route("/connect-lsp", post(Self::connect_lsp)) - .route("/swap", post(Self::swap)) - .route("/redeem", post(Self::redeem)) - .route("/sign-message", post(Self::sign_message)) - .route("/check-message", post(Self::check_message)) - .route("/sync", post(Self::sync)) - .route("/backup", get(Self::backup)) - } - - async fn node_info( - State(app_state): State>, - user: AuthUser, - ) -> Result, ApplicationError> { - user.check_permission(Permission::ReadLnNode)?; - - let client = app_state.ln_node_client.as_breez_client()?; - let node_info = client.node_info()?; - - Ok(node_info.into()) - } - - async fn lsp_info( - State(app_state): State>, - user: AuthUser, - ) -> Result, ApplicationError> { - user.check_permission(Permission::ReadLnNode)?; - - let client = app_state.ln_node_client.as_breez_client()?; - let lsp_info = client.lsp_info().await?; - - Ok(lsp_info.into()) - } - - async fn list_lsps( - State(app_state): State>, - user: AuthUser, - ) -> Result>, ApplicationError> { - user.check_permission(Permission::ReadLnNode)?; - - let client = app_state.ln_node_client.as_breez_client()?; - let lsps = client.list_lsps().await?; - - Ok(lsps.into()) - } - - async fn close_lsp_channels( - State(app_state): State>, - user: AuthUser, - ) -> Result>, ApplicationError> { - user.check_permission(Permission::WriteLnNode)?; - - let client = app_state.ln_node_client.as_breez_client()?; - let tx_ids = client.close_lsp_channels().await?; - - Ok(tx_ids.into()) - } - - async fn connect_lsp( - State(app_state): State>, - user: AuthUser, - Json(payload): Json, - ) -> Result<(), ApplicationError> { - user.check_permission(Permission::WriteLnNode)?; - - let client = app_state.ln_node_client.as_breez_client()?; - client.connect_lsp(payload.lsp_id).await?; - - Ok(()) - } - - // TODO: Move to pay and parse the input to check if it's a BTC address instead of own endpoint - async fn swap( - State(app_state): State>, - user: AuthUser, - Json(payload): Json, - ) -> Result, ApplicationError> { - user.check_permission(Permission::WriteLnNode)?; - - let client = app_state.ln_node_client.as_breez_client()?; - let payment_info = client - .pay_onchain( - payload.amount_msat, - payload.recipient_address, - payload.feerate, - ) - .await?; - - Ok(payment_info.into()) - } - - async fn redeem( - State(app_state): State>, - user: AuthUser, - Json(payload): Json, - ) -> Result { - user.check_permission(Permission::WriteLnNode)?; - - let client = app_state.ln_node_client.as_breez_client()?; - let txid = client - .redeem_onchain(payload.to_address, payload.feerate) - .await?; - - Ok(txid) - } +/// Get node info +/// +/// Returns the Core Lightning node info hosted on [Greenlight (Blockstream)](https://blockstream.com/lightning/greenlight/) infrastructure +#[utoipa::path( + get, + path = "/info", + tag = "Lightning Node", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "Found", body = NodeState), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn node_info( + State(app_state): State>, + user: AuthUser, +) -> Result, ApplicationError> { + user.check_permission(Permission::ReadLnNode)?; + + let client = app_state.ln_node_client.as_breez_client()?; + let node_info = client.node_info()?; + + Ok(node_info.into()) +} - async fn sign_message( - State(app_state): State>, - user: AuthUser, - Json(payload): Json, - ) -> Result { - user.check_permission(Permission::WriteLnNode)?; +/// Get LSP info +/// +/// Returns the info of the current Breez partner LSP connected to the Core Lightning node. +#[utoipa::path( + get, + path = "/lsp-info", + tag = "Lightning Node", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "Found", body = LspInformation), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn lsp_info( + State(app_state): State>, + user: AuthUser, +) -> Result, ApplicationError> { + user.check_permission(Permission::ReadLnNode)?; + + let client = app_state.ln_node_client.as_breez_client()?; + let lsp_info = client.lsp_info().await?; + + Ok(lsp_info.into()) +} - let client = app_state.ln_node_client.as_breez_client()?; - let signature = client.sign_message(payload.message).await?; +/// List LSPs +/// +/// Returns the list of available LSPs for the node. +#[utoipa::path( + get, + path = "/lsps", + tag = "Lightning Node", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "Success", body = Vec), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn list_lsps( + State(app_state): State>, + user: AuthUser, +) -> Result>, ApplicationError> { + user.check_permission(Permission::ReadLnNode)?; + + let client = app_state.ln_node_client.as_breez_client()?; + let lsps = client.list_lsps().await?; + + Ok(lsps.into()) +} - Ok(signature) - } +/// Close LSP channels +/// +/// Returns the list of transaction IDs for the lightning channel closures. The funds are deposited in your on-chain addresses and can be redeemed +#[utoipa::path( + post, + path = "/close-channels", + tag = "Lightning Node", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "Channels Closed", body = Vec), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn close_lsp_channels( + State(app_state): State>, + user: AuthUser, +) -> Result>, ApplicationError> { + user.check_permission(Permission::WriteLnNode)?; + + let client = app_state.ln_node_client.as_breez_client()?; + let tx_ids = client.close_lsp_channels().await?; + + Ok(tx_ids.into()) +} - async fn check_message( - State(app_state): State>, - user: AuthUser, - Json(payload): Json, - ) -> Result { - user.check_permission(Permission::WriteLnNode)?; +/// Connect LSP +/// +/// Connects to an LSP from the list of available LSPs by its ID. Returns an empty body +#[utoipa::path( + post, + path = "/connect-lsp", + tag = "Lightning Node", + context_path = CONTEXT_PATH, + request_body = ConnectLSPRequest, + responses( + (status = 200, description = "LSP Connected"), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 404, description = "Not Found", body = ErrorResponse, example = json!(NOT_FOUND_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn connect_lsp( + State(app_state): State>, + user: AuthUser, + Json(payload): Json, +) -> Result<(), ApplicationError> { + user.check_permission(Permission::WriteLnNode)?; + + let client = app_state.ln_node_client.as_breez_client()?; + client.connect_lsp(payload.lsp_id).await?; + + Ok(()) +} - let client = app_state.ln_node_client.as_breez_client()?; - let is_valid = client - .check_message(payload.message, payload.pubkey, payload.signature) - .await?; +/// Swap BTC +/// +/// Pays BTC on-chain via Swap service. Meaning that the funds are sent through Lightning and swaps to the recipient on-chain address +#[utoipa::path( + post, + path = "/swap", + tag = "Lightning Node", + context_path = CONTEXT_PATH, + request_body = SendOnchainPaymentRequest, + responses( + (status = 200, description = "Swap Success", body = ReverseSwapInfo), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn swap( + State(app_state): State>, + user: AuthUser, + Json(payload): Json, +) -> Result, ApplicationError> { + user.check_permission(Permission::WriteLnNode)?; + + let client = app_state.ln_node_client.as_breez_client()?; + let payment_info = client + .pay_onchain( + payload.amount_msat, + payload.recipient_address, + payload.feerate, + ) + .await?; + + Ok(payment_info.into()) +} - Ok(is_valid.to_string()) - } +/// Redeem BTC +/// +/// Redeems your whole on-chain BTC balance to an address of your choice. Returns the transaction ID. +#[utoipa::path( + post, + path = "/redeem", + tag = "Lightning Node", + context_path = CONTEXT_PATH, + request_body = RedeemOnchainRequest, + responses( + (status = 200, description = "Redeem Success", body = RedeemOnchainResponse), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn redeem( + State(app_state): State>, + user: AuthUser, + Json(payload): Json, +) -> Result, ApplicationError> { + user.check_permission(Permission::WriteLnNode)?; + + let client = app_state.ln_node_client.as_breez_client()?; + let txid = client + .redeem_onchain(payload.to_address, payload.feerate) + .await?; + + Ok(RedeemOnchainResponse { txid }.into()) +} - async fn sync( - State(app_state): State>, - user: AuthUser, - ) -> Result<(), ApplicationError> { - user.check_permission(Permission::WriteLnNode)?; +/// Sign message +/// +/// Signs a message using the node's key. Returns a zbase encoded signature +#[utoipa::path( + post, + path = "/sign-message", + tag = "Lightning Node", + context_path = CONTEXT_PATH, + request_body = SignMessageRequest, + responses( + (status = 200, description = "Message Signed", body = SignMessageResponse), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn sign_message( + State(app_state): State>, + user: AuthUser, + Json(payload): Json, +) -> Result, ApplicationError> { + user.check_permission(Permission::WriteLnNode)?; + + let client = app_state.ln_node_client.as_breez_client()?; + let signature = client.sign_message(payload.message).await?; + + Ok(SignMessageResponse { signature }.into()) +} - let client = app_state.ln_node_client.as_breez_client()?; - client.sync().await?; +/// Verify Signature +/// +/// Verifies the validity of a signature against a node's public key. Returns `true` if valid. +#[utoipa::path( + post, + path = "/check-message", + tag = "Lightning Node", + context_path = CONTEXT_PATH, + request_body = CheckMessageRequest, + responses( + (status = 200, description = "Message Verified", body = CheckMessageResponse), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn check_message( + State(app_state): State>, + user: AuthUser, + Json(payload): Json, +) -> Result, ApplicationError> { + user.check_permission(Permission::WriteLnNode)?; + + let client = app_state.ln_node_client.as_breez_client()?; + let is_valid = client + .check_message(payload.message, payload.pubkey, payload.signature) + .await?; + + Ok(CheckMessageResponse { is_valid }.into()) +} - Ok(()) - } +/// Sync node +/// +/// Syncs the local state with the remote node state. +#[utoipa::path( + post, + path = "/sync", + tag = "Lightning Node", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "Node Synced"), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn sync( + State(app_state): State>, + user: AuthUser, +) -> Result<(), ApplicationError> { + user.check_permission(Permission::WriteLnNode)?; + + let client = app_state.ln_node_client.as_breez_client()?; + client.sync().await?; + + Ok(()) +} - async fn backup( - State(app_state): State>, - user: AuthUser, - ) -> Result { - user.check_permission(Permission::ReadLnNode)?; - - let client = app_state.ln_node_client.as_breez_client()?; - let data = client.backup()?; - - match data { - Some(data) => { - let filename = "channels_backup.txt"; - let body = Body::from(data.join("\n").into_bytes()); - - let headers = [ - (header::CONTENT_TYPE, "text/plain"), - ( - header::CONTENT_DISPOSITION, - &format!("attachment; filename=\"{}\"", filename), - ), - ]; - - Ok((headers, body).into_response()) - } - None => Err(LightningError::Backup("No backup data found".to_string()))?, +/// Backup node channels +/// +/// Returns the static channel backup file contaning the channel information needed to recover funds for a Core Lightning node. See [the documentation](https://docs.corelightning.org/docs/backup#static-channel-backup) +#[utoipa::path( + get, + path = "/backup", + tag = "Lightning Node", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "Backup Downloaded", content_type = "text/plain"), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn backup( + State(app_state): State>, + user: AuthUser, +) -> Result { + user.check_permission(Permission::ReadLnNode)?; + + let client = app_state.ln_node_client.as_breez_client()?; + let data = client.backup()?; + + match data { + Some(data) => { + let filename = "channels_backup.txt"; + let body = Body::from(data.join("\n").into_bytes()); + + let headers = [ + (header::CONTENT_TYPE, "text/plain"), + ( + header::CONTENT_DISPOSITION, + &format!("attachment; filename=\"{}\"", filename), + ), + ]; + + Ok((headers, body).into_response()) } + None => Err(LightningError::Backup("No backup data found".to_string()))?, } } diff --git a/src/domains/lightning/api/ln_address_handler.rs b/src/domains/lightning/api/ln_address_handler.rs index 4536e66..f9ddebc 100644 --- a/src/domains/lightning/api/ln_address_handler.rs +++ b/src/domains/lightning/api/ln_address_handler.rs @@ -6,10 +6,18 @@ use axum::{ Json, Router, }; use axum_extra::extract::Query; +use utoipa::OpenApi; use uuid::Uuid; use crate::{ - application::{dtos::RegisterLightningAddressRequest, errors::ApplicationError}, + application::{ + docs::{ + BAD_REQUEST_EXAMPLE, FORBIDDEN_EXAMPLE, INTERNAL_EXAMPLE, NOT_FOUND_EXAMPLE, + UNAUTHORIZED_EXAMPLE, UNPROCESSABLE_EXAMPLE, + }, + dtos::RegisterLnAddressRequest, + errors::ApplicationError, + }, domains::{ lightning::entities::{LnAddress, LnAddressFilter}, users::entities::{AuthUser, Permission}, @@ -17,77 +25,171 @@ use crate::{ infra::app::AppState, }; +#[derive(OpenApi)] +#[openapi( + paths(register, get_one, list, delete_one, delete_many), + components(schemas(LnAddress, RegisterLnAddressRequest)), + tags( + (name = "Lightning Addresses", description = "LN Address management endpoints as defined in the [protocol specification](https://lightningaddress.com/). Require authorization.") + ), + security(("jwt" = ["read:ln_address", "write:ln_address"])) +)] pub struct LnAddressHandler; +pub const CONTEXT_PATH: &str = "/api/lightning/addresses"; + +pub fn ln_address_router() -> Router> { + Router::new() + .route("/", get(list)) + .route("/", post(register)) + .route("/:id", get(get_one)) + .route("/:id", delete(delete_one)) + .route("/", delete(delete_many)) +} + +/// Register a new LN Address +/// +/// Registers an address. Returns the address details. LN Addresses are ready to receive funds through the LNURL protocol upon registration. +#[utoipa::path( + post, + path = "", + tag = "Lightning Addresses", + context_path = CONTEXT_PATH, + request_body = RegisterLnAddressRequest, + responses( + (status = 200, description = "LN Address Registered", body = LnAddress), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 422, description = "Unprocessable Entity", body = ErrorResponse, example = json!(UNPROCESSABLE_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn register( + State(app_state): State>, + user: AuthUser, + Json(payload): Json, +) -> Result, ApplicationError> { + user.check_permission(Permission::WriteLnAddress)?; + + let ln_address = app_state + .services + .lnurl + .register(payload.user_id.unwrap_or(user.sub), payload.username) + .await?; + Ok(ln_address.into()) +} + +/// Find a LN Address +/// +/// Returns the address by its ID. +#[utoipa::path( + get, + path = "/{id}", + tag = "Lightning Addresses", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "Found", body = LnAddress), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 404, description = "Not Found", body = ErrorResponse, example = json!(NOT_FOUND_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn get_one( + State(app_state): State>, + user: AuthUser, + Path(id): Path, +) -> Result, ApplicationError> { + user.check_permission(Permission::ReadLnAddress)?; + + let ln_address = app_state.services.lnurl.get(id).await?; + Ok(ln_address.into()) +} + +/// List LN Addresses +/// +/// Returns all the addresses given a filter +#[utoipa::path( + get, + path = "", + tag = "Lightning Addresses", + context_path = CONTEXT_PATH, + params(LnAddressFilter), + responses( + (status = 200, description = "Success", body = Vec), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn list( + State(app_state): State>, + user: AuthUser, + Query(query_params): Query, +) -> Result>, ApplicationError> { + user.check_permission(Permission::ReadLnAddress)?; + + let ln_addresses = app_state.services.lnurl.list(query_params).await?; + + let response: Vec = ln_addresses.into_iter().map(Into::into).collect(); + + Ok(response.into()) +} + +/// Delete a LN Address +/// +/// Deletes an address by ID. Returns an empty body +#[utoipa::path( + delete, + path = "/{id}", + tag = "Lightning Addresses", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "Deleted"), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 404, description = "Not Found", body = ErrorResponse, example = json!(NOT_FOUND_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn delete_one( + State(app_state): State>, + user: AuthUser, + Path(id): Path, +) -> Result<(), ApplicationError> { + user.check_permission(Permission::WriteLnAddress)?; + + app_state.services.lnurl.delete(id).await?; + Ok(()) +} -impl LnAddressHandler { - pub fn routes() -> Router> { - Router::new() - .route("/", get(Self::list)) - .route("/", post(Self::register)) - .route("/:id", get(Self::get)) - .route("/:id", delete(Self::delete)) - .route("/", delete(Self::delete_many)) - } - - async fn register( - State(app_state): State>, - user: AuthUser, - Json(payload): Json, - ) -> Result, ApplicationError> { - user.check_permission(Permission::WriteLnAddress)?; - - let ln_address = app_state - .services - .lnurl - .register(payload.user_id.unwrap_or(user.sub), payload.username) - .await?; - Ok(ln_address.into()) - } - - async fn get( - State(app_state): State>, - user: AuthUser, - Path(id): Path, - ) -> Result, ApplicationError> { - user.check_permission(Permission::ReadLnAddress)?; - - let ln_address = app_state.services.lnurl.get(id).await?; - Ok(ln_address.into()) - } - - async fn list( - State(app_state): State>, - user: AuthUser, - Query(query_params): Query, - ) -> Result>, ApplicationError> { - user.check_permission(Permission::ReadLnAddress)?; - - let ln_addresses = app_state.services.lnurl.list(query_params).await?; - - let response: Vec = ln_addresses.into_iter().map(Into::into).collect(); - - Ok(response.into()) - } - - async fn delete( - State(app_state): State>, - user: AuthUser, - Path(id): Path, - ) -> Result<(), ApplicationError> { - user.check_permission(Permission::WriteLnAddress)?; - - app_state.services.lnurl.delete(id).await?; - Ok(()) - } - - async fn delete_many( - State(app_state): State>, - user: AuthUser, - Query(query_params): Query, - ) -> Result, ApplicationError> { - user.check_permission(Permission::WriteLnAddress)?; - - let n_deleted = app_state.services.lnurl.delete_many(query_params).await?; - Ok(n_deleted.into()) - } +/// Delete LN Addresses +/// +/// Deletes all the addresses given a filter. Returns the number of deleted addresses +#[utoipa::path( + delete, + path = "", + tag = "Lightning Addresses", + context_path = CONTEXT_PATH, + params(LnAddressFilter), + responses( + (status = 200, description = "Success", body = u64), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn delete_many( + State(app_state): State>, + user: AuthUser, + Query(query_params): Query, +) -> Result, ApplicationError> { + user.check_permission(Permission::WriteLnAddress)?; + + let n_deleted = app_state.services.lnurl.delete_many(query_params).await?; + Ok(n_deleted.into()) } diff --git a/src/domains/lightning/api/lnurlp_handler.rs b/src/domains/lightning/api/lnurlp_handler.rs index 3013821..1e170bb 100644 --- a/src/domains/lightning/api/lnurlp_handler.rs +++ b/src/domains/lightning/api/lnurlp_handler.rs @@ -5,42 +5,84 @@ use axum::{ routing::get, Json, Router, }; +use utoipa::OpenApi; use crate::{ - application::{dtos::LNUrlpInvoiceQueryParams, errors::ApplicationError}, + application::{ + docs::{BAD_REQUEST_EXAMPLE, INTERNAL_EXAMPLE, NOT_FOUND_EXAMPLE, UNPROCESSABLE_EXAMPLE}, + dtos::LNUrlpInvoiceQueryParams, + errors::ApplicationError, + }, domains::lightning::entities::{LnURLPayRequest, LnUrlCallbackResponse}, infra::app::AppState, }; +#[derive(OpenApi)] +#[openapi( + paths(well_known_lnurlp, callback), + components(schemas(LnURLPayRequest, LnUrlCallbackResponse)), + tags( + (name = "LNURL", description = "LNURL endpoints as defined in the [protocol specification](https://github.com/lnurl/luds). Allows any active Lightning Address to receive payments") + ), +)] pub struct LnURLpHandler; -impl LnURLpHandler { - pub fn well_known_route() -> Router> { - Router::new().route("/:username", get(Self::well_known_lnurlp)) - } +pub fn well_known_router() -> Router> { + Router::new().route("/:username", get(well_known_lnurlp)) +} - pub fn callback_route() -> Router> { - Router::new().route("/:username/callback", get(Self::callback)) - } +pub fn callback_router() -> Router> { + Router::new().route("/:username/callback", get(callback)) +} - async fn well_known_lnurlp( - Path(username): Path, - State(app_state): State>, - ) -> Result, ApplicationError> { - let lnurlp = app_state.services.lnurl.lnurlp(username).await?; - Ok(lnurlp.into()) - } +/// Well-known endpoint +/// +/// Returns the LNURL payRequest for this LN Address (username). The returned payload contains information allowing the payer to generate an invoice. See [LUDS-06](https://github.com/lnurl/luds/blob/luds/06.md) +#[utoipa::path( + get, + path = "/{username}", + tag = "LNURL", + context_path = "/.well-known/lnurlp", + responses( + (status = 200, description = "Found", body = LnURLPayRequest), + (status = 404, description = "Not Found", body = ErrorResponse, example = json!(NOT_FOUND_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn well_known_lnurlp( + Path(username): Path, + State(app_state): State>, +) -> Result, ApplicationError> { + let lnurlp = app_state.services.lnurl.lnurlp(username).await?; + Ok(lnurlp.into()) +} - async fn callback( - Path(username): Path, - Query(query_params): Query, - State(app_state): State>, - ) -> Result, ApplicationError> { - let callback = app_state - .services - .lnurl - .lnurlp_callback(username, query_params.amount, query_params.comment) - .await?; - Ok(callback.into()) - } +/// LNURL callback endpoint +/// +/// Returns the callback response for this LN Address (username). Containing an invoice and information on how to behave upon success. See [LUDS-06](https://github.com/lnurl/luds/blob/luds/06.md) +#[utoipa::path( + get, + path = "/{username}/callback", + tag = "LNURL", + context_path = "/api/lnurlp", + params(LNUrlpInvoiceQueryParams), + responses( + (status = 200, description = "Found", body = LnUrlCallbackResponse), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 404, description = "Not Found", body = ErrorResponse, example = json!(NOT_FOUND_EXAMPLE)), + (status = 422, description = "Unprocessable Entity", body = ErrorResponse, example = json!(UNPROCESSABLE_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn callback( + Path(username): Path, + Query(query_params): Query, + State(app_state): State>, +) -> Result, ApplicationError> { + let callback = app_state + .services + .lnurl + .lnurlp_callback(username, query_params.amount, query_params.comment) + .await?; + Ok(callback.into()) } diff --git a/src/domains/lightning/api/mod.rs b/src/domains/lightning/api/mod.rs index f220c22..36b9f4e 100644 --- a/src/domains/lightning/api/mod.rs +++ b/src/domains/lightning/api/mod.rs @@ -2,6 +2,6 @@ mod breez_node_handler; mod ln_address_handler; mod lnurlp_handler; -pub use breez_node_handler::BreezNodeHandler; -pub use ln_address_handler::LnAddressHandler; -pub use lnurlp_handler::LnURLpHandler; +pub use breez_node_handler::*; +pub use ln_address_handler::*; +pub use lnurlp_handler::*; diff --git a/src/domains/lightning/entities/ln_address.rs b/src/domains/lightning/entities/ln_address.rs index 02630ae..fe85c25 100644 --- a/src/domains/lightning/entities/ln_address.rs +++ b/src/domains/lightning/entities/ln_address.rs @@ -1,27 +1,40 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, DisplayFromStr}; +use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; -use crate::application::entities::PaginationFilter; - -#[derive(Clone, Debug, Serialize)] +/// Lightning Address +#[derive(Clone, Debug, Serialize, ToSchema)] pub struct LnAddress { + /// Internal ID pub id: Uuid, + /// User ID pub user_id: String, + /// Username pub username: String, + /// Active status. Inactive addresses cannot receive funds pub active: bool, + /// Date of creation in database pub created_at: DateTime, #[serde(skip_serializing_if = "Option::is_none")] + /// Date of update in database pub updated_at: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub deleted_at: Option>, } -#[derive(Clone, Debug, Deserialize, Serialize, Default)] +#[serde_as] +#[derive(Clone, Debug, Deserialize, Serialize, Default, IntoParams)] pub struct LnAddressFilter { - #[serde(flatten)] - pub pagination: PaginationFilter, + /// Total amount of results to return + #[serde_as(as = "Option")] + pub limit: Option, + /// Offset where to start returning results + #[serde_as(as = "Option")] + pub offset: Option, + /// List of IDs pub ids: Option>, + /// User ID. Automatically populated with your user ID pub user_id: Option, + /// Status pub username: Option, } diff --git a/src/domains/lightning/entities/lnurl.rs b/src/domains/lightning/entities/lnurl.rs index 87216c7..17aa3a7 100644 --- a/src/domains/lightning/entities/lnurl.rs +++ b/src/domains/lightning/entities/lnurl.rs @@ -1,16 +1,35 @@ use breez_sdk_core::SuccessActionProcessed; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; -/// See -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct LnURLPayRequest { + /// The URL from LN SERVICE to accept the pay request + #[schema(example = "https://numeraire.tech/lnurlp/dario_nakamoto/callback")] pub callback: String, - pub max_sendable: u64, // Max amount in milli-satoshis LN SERVICE is willing to receive - pub min_sendable: u64, // Min amount in milli-satoshis LN SERVICE is willing to receive, can not be less than 1 or more than `maxSendable` - pub metadata: String, // Metadata json which must be presented as raw string here, this is required to pass signature verification at a later step - pub comment_allowed: u16, // Optional number of characters accepted for the `comment` query parameter on subsequent callback, defaults to 0 if not provided. (no comment allowed). See - pub tag: String, // Type of LNURL + + /// Max amount in milli-satoshis LN SERVICE is willing to receive + #[schema(example = 1000000000)] + pub max_sendable: u64, + + /// Min amount in milli-satoshis LN SERVICE is willing to receive, can not be less than 1 or more than `maxSendable` + #[schema(example = 1000)] + pub min_sendable: u64, + + /// Metadata json which must be presented as raw string here, this is required to pass signature verification at a later step + #[schema( + example = "[[\"text/plain\",\"dario_nakamoto never refuses sats\"],[\"text/identifier\",\"dario_nakamoto@numeraire.tech\"]]" + )] + pub metadata: String, + + /// Optional number of characters accepted for the `comment` query parameter on subsequent callback, defaults to 0 if not provided. (no comment allowed). See + #[schema(example = 255)] + pub comment_allowed: u16, + + /// Type of LNURL + #[schema(example = "payRequest")] + pub tag: String, } #[derive(Deserialize, Debug, Serialize)] @@ -18,11 +37,16 @@ pub struct LnUrlErrorData { pub reason: String, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct LnUrlCallbackResponse { - pub pr: String, // bech32-serialized lightning invoice - pub success_action: Option, // An optional action to be executed after successfully paying an invoice - pub disposable: Option, // An optional flag to let a wallet know whether to persist the link from step 1, if null should be interpreted as true - pub routes: Vec, // array with payment routes, should be left empty if no routes are to be provided + /// bech32-serialized Lightning invoice + #[schema(example = "lnbcrt1m1png24kasp5...")] + pub pr: String, + /// An optional action to be executed after successfully paying an invoice + pub success_action: Option, + /// An optional flag to let a wallet know whether to persist the link from step 1, if null should be interpreted as true + pub disposable: Option, + /// array with payment routes, should be left empty if no routes are to be provided + pub routes: Vec, } diff --git a/src/domains/lightning/services/ln_events_service.rs b/src/domains/lightning/services/ln_events_service.rs index c84b036..3fcce9e 100644 --- a/src/domains/lightning/services/ln_events_service.rs +++ b/src/domains/lightning/services/ln_events_service.rs @@ -3,7 +3,7 @@ use tracing::{debug, info}; use crate::{ application::{ - entities::{AppStore, Ledger, PaginationFilter}, + entities::{AppStore, Ledger}, errors::{ApplicationError, DataError}, }, domains::{ @@ -37,10 +37,7 @@ impl LnEventsUseCases for LnEventsService { .find_many(InvoiceFilter { status: Some(InvoiceStatus::Settled), ledger: Some(Ledger::Lightning), - pagination: PaginationFilter { - limit: Some(1), - ..Default::default() - }, + limit: Some(1), ..Default::default() }) .await?; diff --git a/src/domains/lightning/services/lnurl_service.rs b/src/domains/lightning/services/lnurl_service.rs index 88223bf..2468b85 100644 --- a/src/domains/lightning/services/lnurl_service.rs +++ b/src/domains/lightning/services/lnurl_service.rs @@ -105,7 +105,7 @@ impl LnUrlUseCases for LnUrlService { ) .await?; invoice.user_id.clone_from(&ln_address.user_id); - invoice.ln_address = Some(ln_address.id); + invoice.ln_address_id = Some(ln_address.id); // TODO: Get or add more information to make this a LNURLp invoice (like fetching a success action specific to the user) let invoice = self.store.invoice.insert(None, invoice).await?; diff --git a/src/domains/payments/adapters/payment_repository.rs b/src/domains/payments/adapters/payment_repository.rs index b73f171..3883b41 100644 --- a/src/domains/payments/adapters/payment_repository.rs +++ b/src/domains/payments/adapters/payment_repository.rs @@ -58,8 +58,8 @@ impl PaymentRepository for SeaOrmPaymentRepository { q.filter(Column::Status.eq(s.to_string())) }) .order_by_desc(Column::CreatedAt) - .offset(filter.pagination.offset) - .limit(filter.pagination.limit) + .offset(filter.offset) + .limit(filter.limit) .all(&self.db) .await .map_err(|e| DatabaseError::FindMany(e.to_string()))?; diff --git a/src/domains/payments/api/mod.rs b/src/domains/payments/api/mod.rs index 747741b..699fc6a 100644 --- a/src/domains/payments/api/mod.rs +++ b/src/domains/payments/api/mod.rs @@ -1,2 +1,2 @@ mod payment_handler; -pub use payment_handler::PaymentHandler; +pub use payment_handler::*; diff --git a/src/domains/payments/api/payment_handler.rs b/src/domains/payments/api/payment_handler.rs index 4fd8845..d94b09a 100644 --- a/src/domains/payments/api/payment_handler.rs +++ b/src/domains/payments/api/payment_handler.rs @@ -6,84 +6,186 @@ use axum::{ Json, Router, }; use axum_extra::extract::Query; +use utoipa::OpenApi; use uuid::Uuid; use crate::{ - application::{dtos::SendPaymentRequest, errors::ApplicationError}, + application::{ + docs::{ + BAD_REQUEST_EXAMPLE, FORBIDDEN_EXAMPLE, INTERNAL_EXAMPLE, NOT_FOUND_EXAMPLE, + UNAUTHORIZED_EXAMPLE, UNPROCESSABLE_EXAMPLE, + }, + dtos::SendPaymentRequest, + errors::ApplicationError, + }, domains::{ - payments::entities::{Payment, PaymentFilter}, + payments::entities::{Payment, PaymentFilter, PaymentStatus}, users::entities::{AuthUser, Permission}, }, infra::app::AppState, }; +#[derive(OpenApi)] +#[openapi( + paths(pay, get_one, list, delete_one, delete_many), + components(schemas(Payment, SendPaymentRequest, PaymentStatus)), + tags( + (name = "Payments", description = "Payment management endpoints. Require authorization.") + ), + security(("jwt" = ["read:transactions", "write:transactions"])) +)] pub struct PaymentHandler; +pub const CONTEXT_PATH: &str = "/api/payments"; + +pub fn router() -> Router> { + Router::new() + .route("/", post(pay)) + .route("/", get(list)) + .route("/:id", get(get_one)) + .route("/:id", delete(delete_one)) + .route("/", delete(delete_many)) +} + +/// Send a payment +/// +/// Pay for a LN invoice, LNURL, LN Address, On-chain or internally to an other user on the same instance. Returns the payment details. +#[utoipa::path( + post, + path = "", + tag = "Payments", + context_path = CONTEXT_PATH, + request_body = SendPaymentRequest, + responses( + (status = 200, description = "Payment Sent", body = Payment), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 422, description = "Unprocessable Entity", body = ErrorResponse, example = json!(UNPROCESSABLE_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn pay( + State(app_state): State>, + user: AuthUser, + Json(payload): Json, +) -> Result, ApplicationError> { + user.check_permission(Permission::WriteLnTransaction)?; + + let payment = app_state.services.payment.pay(payload).await?; + Ok(payment.into()) +} + +/// Find a payment +/// +/// Returns the payment by its ID. +#[utoipa::path( + get, + path = "/{id}", + tag = "Payments", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "Found", body = Payment), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 404, description = "Not Found", body = ErrorResponse, example = json!(NOT_FOUND_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn get_one( + State(app_state): State>, + user: AuthUser, + Path(id): Path, +) -> Result, ApplicationError> { + user.check_permission(Permission::ReadLnTransaction)?; + + let payment = app_state.services.payment.get(id).await?; + Ok(payment.into()) +} + +/// List payments +/// +/// Returns all the payments given a filter +#[utoipa::path( + get, + path = "", + tag = "Payments", + context_path = CONTEXT_PATH, + params(PaymentFilter), + responses( + (status = 200, description = "Success", body = Vec), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn list( + State(app_state): State>, + user: AuthUser, + Query(query_params): Query, +) -> Result>, ApplicationError> { + user.check_permission(Permission::ReadLnTransaction)?; + + let payments = app_state.services.payment.list(query_params).await?; + + let response: Vec = payments.into_iter().map(Into::into).collect(); + + Ok(response.into()) +} + +/// Delete a payment +/// +/// Deletes a payment by ID. Returns an empty body. Deleting a payment has an effect on the user balance +#[utoipa::path( + delete, + path = "/{id}", + tag = "Payments", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "Deleted"), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 404, description = "Not Found", body = ErrorResponse, example = json!(NOT_FOUND_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn delete_one( + State(app_state): State>, + user: AuthUser, + Path(id): Path, +) -> Result<(), ApplicationError> { + user.check_permission(Permission::WriteLnTransaction)?; + + app_state.services.payment.delete(id).await?; + Ok(()) +} -impl PaymentHandler { - pub fn routes() -> Router> { - Router::new() - .route("/", post(Self::pay)) - .route("/", get(Self::list)) - .route("/:id", get(Self::get)) - .route("/:id", delete(Self::delete)) - .route("/", delete(Self::delete_many)) - } - - async fn pay( - State(app_state): State>, - user: AuthUser, - Json(payload): Json, - ) -> Result, ApplicationError> { - user.check_permission(Permission::WriteLnTransaction)?; - - let payment = app_state.services.payment.pay(payload).await?; - Ok(payment.into()) - } - - async fn get( - State(app_state): State>, - user: AuthUser, - Path(id): Path, - ) -> Result, ApplicationError> { - user.check_permission(Permission::ReadLnTransaction)?; - - let payment = app_state.services.payment.get(id).await?; - Ok(payment.into()) - } - - async fn list( - State(app_state): State>, - user: AuthUser, - Query(query_params): Query, - ) -> Result>, ApplicationError> { - user.check_permission(Permission::ReadLnTransaction)?; - - let payments = app_state.services.payment.list(query_params).await?; - - let response: Vec = payments.into_iter().map(Into::into).collect(); - - Ok(response.into()) - } - - async fn delete( - State(app_state): State>, - user: AuthUser, - Path(id): Path, - ) -> Result<(), ApplicationError> { - user.check_permission(Permission::WriteLnTransaction)?; - - app_state.services.payment.delete(id).await?; - Ok(()) - } - - async fn delete_many( - State(app_state): State>, - user: AuthUser, - Query(query_params): Query, - ) -> Result, ApplicationError> { - user.check_permission(Permission::WriteLnTransaction)?; - - let n_deleted = app_state.services.payment.delete_many(query_params).await?; - Ok(n_deleted.into()) - } +/// Delete payments +/// +/// Deletes all the payments given a filter. Returns the number of deleted payments. Deleting a payment can have an effect on the user balance +#[utoipa::path( + delete, + path = "", + tag = "Payments", + context_path = CONTEXT_PATH, + params(PaymentFilter), + responses( + (status = 200, description = "Success", body = u64), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn delete_many( + State(app_state): State>, + user: AuthUser, + Query(query_params): Query, +) -> Result, ApplicationError> { + user.check_permission(Permission::WriteLnTransaction)?; + + let n_deleted = app_state.services.payment.delete_many(query_params).await?; + Ok(n_deleted.into()) } diff --git a/src/domains/payments/entities/payment.rs b/src/domains/payments/entities/payment.rs index 116a6d6..3fb6fca 100644 --- a/src/domains/payments/entities/payment.rs +++ b/src/domains/payments/entities/payment.rs @@ -1,41 +1,69 @@ use breez_sdk_core::SuccessActionProcessed; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, DisplayFromStr}; use strum_macros::{Display, EnumString}; +use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; -use crate::application::entities::{Ledger, PaginationFilter}; +use crate::application::entities::Ledger; -#[derive(Clone, Debug, Default, Serialize)] +#[derive(Clone, Debug, Default, Serialize, ToSchema)] pub struct Payment { + /// Internal ID pub id: Uuid, + /// User ID pub user_id: String, + + /// Lightning Address. Populated when sending to a LN Address + #[schema(example = "hello@numeraire.tech")] pub ln_address: Option, + + /// Payment hash #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = "b587c7f76339e3fb87ad2b...")] pub payment_hash: Option, + + /// Payment Preimage #[serde(skip_serializing_if = "Option::is_none")] pub payment_preimage: Option, + + /// Error message #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = "failed to pay error message")] pub error: Option, + + /// Amount in millisatoshis. pub amount_msat: u64, #[serde(skip_serializing_if = "Option::is_none")] + /// Fees paid. Populated when a new channel is opened to receive the funds pub fee_msat: Option, + /// Ledger pub ledger: Ledger, + /// Payment time #[serde(skip_serializing_if = "Option::is_none")] pub payment_time: Option>, + /// Status pub status: PaymentStatus, + /// Description #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + /// Metadata #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, + /// Success Action. Populated when sending to a LNURL or LN Address #[serde(skip_serializing_if = "Option::is_none")] pub success_action: Option, + /// Date of creation in database pub created_at: DateTime, + /// Date of update in database #[serde(skip_serializing_if = "Option::is_none")] pub updated_at: Option>, } -#[derive(Clone, Debug, EnumString, Display, Deserialize, Serialize, PartialEq, Eq, Default)] +#[derive( + Clone, Debug, EnumString, Display, Deserialize, Serialize, PartialEq, Eq, Default, ToSchema, +)] pub enum PaymentStatus { #[default] Pending, @@ -43,11 +71,19 @@ pub enum PaymentStatus { Failed, } -#[derive(Clone, Debug, Deserialize, Serialize, Default)] +#[serde_as] +#[derive(Clone, Debug, Deserialize, Serialize, Default, IntoParams)] pub struct PaymentFilter { - #[serde(flatten)] - pub pagination: PaginationFilter, + /// Total amount of results to return + #[serde_as(as = "Option")] + pub limit: Option, + /// Offset where to start returning results + #[serde_as(as = "Option")] + pub offset: Option, + /// List of IDs pub ids: Option>, + /// User ID. Automatically populated with your user ID pub user_id: Option, + /// Status pub status: Option, } diff --git a/src/domains/payments/services/payments_service.rs b/src/domains/payments/services/payments_service.rs index 9790db8..3128415 100644 --- a/src/domains/payments/services/payments_service.rs +++ b/src/domains/payments/services/payments_service.rs @@ -214,7 +214,7 @@ impl PaymentService { Some(&txn), Invoice { user_id: retrieved_address.user_id, - ln_address: Some(retrieved_address.id), + ln_address_id: Some(retrieved_address.id), ledger: Ledger::Internal, description: comment.clone().or( DEFAULT_INTERNAL_INVOICE_DESCRIPTION.to_string().into(), diff --git a/src/domains/system/api/mod.rs b/src/domains/system/api/mod.rs index f552482..ec432a1 100644 --- a/src/domains/system/api/mod.rs +++ b/src/domains/system/api/mod.rs @@ -1,3 +1,3 @@ mod system_handler; -pub use system_handler::SystemHandler; +pub use system_handler::*; diff --git a/src/domains/system/api/system_handler.rs b/src/domains/system/api/system_handler.rs index 002c06b..7def26f 100644 --- a/src/domains/system/api/system_handler.rs +++ b/src/domains/system/api/system_handler.rs @@ -1,38 +1,82 @@ use std::sync::Arc; use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router}; +use utoipa::OpenApi; use crate::{ application::errors::ApplicationError, - domains::system::entities::{HealthCheck, VersionInfo}, + domains::system::entities::{HealthCheck, HealthStatus, VersionInfo}, infra::app::AppState, }; +#[derive(OpenApi)] +#[openapi( + paths(readiness_check, health_check, version_check), + components(schemas(HealthCheck, HealthStatus, VersionInfo)), + tags( + (name = "System", description = "System related endpoints") + ) +)] pub struct SystemHandler; +pub const CONTEXT_PATH: &str = "/api/system"; -impl SystemHandler { - pub fn routes() -> Router> { - Router::new() - .route("/health", get(Self::health_check)) - .route("/ready", get(Self::readiness_check)) - .route("/version", get(Self::version_check)) - } +pub fn router() -> Router> { + Router::new() + .route("/health", get(health_check)) + .route("/ready", get(readiness_check)) + .route("/version", get(version_check)) +} - async fn readiness_check() -> impl IntoResponse { - StatusCode::OK - } +/// Readiness Check +/// +/// Returns successfully if the server is reachable. +#[utoipa::path( + get, + path = "/ready", + tag = "System", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "OK") + ) +)] +async fn readiness_check() -> impl IntoResponse { + StatusCode::OK +} - async fn health_check( - State(app_state): State>, - ) -> Result, ApplicationError> { - let health_check = app_state.services.system.health_check().await; - Ok(health_check.into()) - } +/// Health Check +/// +/// Returns the health of the system fine-grained by dependency. +#[utoipa::path( + get, + path = "/health", + tag = "System", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "OK", body = HealthCheck) + ) +)] +async fn health_check( + State(app_state): State>, +) -> Result, ApplicationError> { + let health_check = app_state.services.system.health_check().await; + Ok(health_check.into()) +} - async fn version_check( - State(app_state): State>, - ) -> Result, ApplicationError> { - let version = app_state.services.system.version(); - Ok(version.into()) - } +/// Version Information +/// +/// Returns the current version and build time of the system. +#[utoipa::path( + get, + path = "/version", + tag = "System", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "OK", body = VersionInfo) + ) +)] +async fn version_check( + State(app_state): State>, +) -> Result, ApplicationError> { + let version = app_state.services.system.version(); + Ok(version.into()) } diff --git a/src/domains/system/entities/health.rs b/src/domains/system/entities/health.rs index 45ab5b9..7515392 100644 --- a/src/domains/system/entities/health.rs +++ b/src/domains/system/entities/health.rs @@ -1,13 +1,17 @@ use serde::Serialize; use strum_macros::{Display, EnumString}; +use utoipa::ToSchema; -#[derive(Debug, Serialize)] +/// App Health Information +#[derive(Debug, Serialize, ToSchema)] pub struct HealthCheck { + /// Health of the database pub database: HealthStatus, + /// Health of the Lightning provider service pub ln_provider: HealthStatus, } -#[derive(Clone, Debug, EnumString, Serialize, Display, PartialEq, Eq)] +#[derive(Clone, Debug, EnumString, Serialize, Display, PartialEq, Eq, ToSchema)] pub enum HealthStatus { Operational, Unavailable, diff --git a/src/domains/system/entities/version.rs b/src/domains/system/entities/version.rs index 90b6cff..6ae7396 100644 --- a/src/domains/system/entities/version.rs +++ b/src/domains/system/entities/version.rs @@ -1,7 +1,14 @@ use serde::Serialize; +use utoipa::ToSchema; -#[derive(Serialize)] +/// App version info +#[derive(Debug, Serialize, ToSchema)] pub struct VersionInfo { + /// Current version of the software + #[schema(example = "0.0.1")] pub version: String, + + /// Build time of the software + #[schema(example = "2024-07-03T18:13:09.093289+00:00")] pub build_time: String, } diff --git a/src/domains/users/api/auth_handler.rs b/src/domains/users/api/auth_handler.rs new file mode 100644 index 0000000..eb49263 --- /dev/null +++ b/src/domains/users/api/auth_handler.rs @@ -0,0 +1,52 @@ +use std::sync::Arc; + +use axum::{extract::State, routing::post, Json, Router}; +use utoipa::OpenApi; + +use crate::{ + application::{ + docs::{BAD_REQUEST_EXAMPLE, UNAUTHORIZED_EXAMPLE, UNSUPPORTED_EXAMPLE}, + dtos::{SignInRequest, SignInResponse}, + errors::ApplicationError, + }, + infra::app::AppState, +}; + +#[derive(OpenApi)] +#[openapi( + paths(sign_in), + components(schemas(SignInRequest, SignInResponse)), + tags( + (name = "Authentication", description = "Some endpoints are public, but some require authentication. We provide all the required endpoints to create an account and authorize yourself.") + ) +)] +pub struct AuthHandler; +pub const CONTEXT_PATH: &str = "/api/auth"; + +pub fn auth_router() -> Router> { + Router::new().route("/sign-in", post(sign_in)) +} + +/// Sign In +/// +/// Returns a JWT token to be used for authentication. The JWT token contains authentication and permissions. Sign in is only required for `JWT` Auth provider. +#[utoipa::path( + post, + path = "/sign-in", + tag = "Authentication", + context_path = CONTEXT_PATH, + request_body = SignInRequest, + responses( + (status = 200, description = "Token Created", body = SignInResponse), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 405, description = "Unsupported", body = ErrorResponse, example = json!(UNSUPPORTED_EXAMPLE)) + ) +)] +async fn sign_in( + State(app_state): State>, + Json(payload): Json, +) -> Result, ApplicationError> { + let token = app_state.services.user.sign_in(payload.password)?; + Ok(SignInResponse { token }.into()) +} diff --git a/src/domains/users/api/mod.rs b/src/domains/users/api/mod.rs index 1b0568f..a20e113 100644 --- a/src/domains/users/api/mod.rs +++ b/src/domains/users/api/mod.rs @@ -1,4 +1,4 @@ +mod auth_handler; mod auth_middleware; -mod user_handler; -pub use user_handler::UserHandler; +pub use auth_handler::*; diff --git a/src/domains/users/api/user_handler.rs b/src/domains/users/api/user_handler.rs deleted file mode 100644 index 4d39cf8..0000000 --- a/src/domains/users/api/user_handler.rs +++ /dev/null @@ -1,24 +0,0 @@ -use std::sync::Arc; - -use axum::{extract::State, routing::post, Json, Router}; - -use crate::{ - application::{dtos::LoginRequest, errors::ApplicationError}, - infra::app::AppState, -}; - -pub struct UserHandler; - -impl UserHandler { - pub fn routes() -> Router> { - Router::new().route("/sign-in", post(Self::sign_in)) - } - - async fn sign_in( - State(app_state): State>, - Json(payload): Json, - ) -> Result { - let token = app_state.services.user.sign_in(payload.password)?; - Ok(token) - } -} diff --git a/src/domains/users/services/user_service.rs b/src/domains/users/services/user_service.rs index be7055e..b552cf3 100644 --- a/src/domains/users/services/user_service.rs +++ b/src/domains/users/services/user_service.rs @@ -38,7 +38,7 @@ impl UserUseCases for UserService { Ok(token) } _ => Err(ApplicationError::UnsupportedOperation(format!( - "login for {} provider", + "Sign in not allowed (not needed) for {} provider", self.provider ))), } diff --git a/src/domains/wallet/api/mod.rs b/src/domains/wallet/api/mod.rs index 5b84775..9678ea0 100644 --- a/src/domains/wallet/api/mod.rs +++ b/src/domains/wallet/api/mod.rs @@ -1,3 +1,3 @@ mod wallet_handler; -pub use wallet_handler::WalletHandler; +pub use wallet_handler::*; diff --git a/src/domains/wallet/api/wallet_handler.rs b/src/domains/wallet/api/wallet_handler.rs index 44f5d0c..6f889af 100644 --- a/src/domains/wallet/api/wallet_handler.rs +++ b/src/domains/wallet/api/wallet_handler.rs @@ -5,11 +5,16 @@ use axum::{ routing::{delete, get, post}, Json, Router, }; +use utoipa::OpenApi; use uuid::Uuid; use crate::{ application::{ - dtos::{NewInvoiceRequest, RegisterLightningAddressRequest, SendPaymentRequest}, + docs::{ + BAD_REQUEST_EXAMPLE, INTERNAL_EXAMPLE, NOT_FOUND_EXAMPLE, UNAUTHORIZED_EXAMPLE, + UNPROCESSABLE_EXAMPLE, + }, + dtos::{NewInvoiceRequest, RegisterLnAddressRequest, SendPaymentRequest}, errors::{ApplicationError, DataError}, }, domains::{ @@ -22,205 +27,398 @@ use crate::{ infra::app::AppState, }; +#[derive(OpenApi)] +#[openapi( + paths(get_wallet, get_balance, get_address, register_address, pay, list_payments, get_payment, delete_failed_payments, list_invoices, get_invoice, new_invoice, delete_expired_invoices), + components(schemas(Wallet, UserBalance)), + tags( + (name = "Wallet", description = "Wallet endpoints. Available to any authenticated user.") + ), +)] pub struct WalletHandler; +pub const CONTEXT_PATH: &str = "/api/wallet"; -impl WalletHandler { - pub fn routes() -> Router> { - Router::new() - .route("/", get(Self::get_wallet)) - .route("/balance", get(Self::get_balance)) - .route("/lightning-address", get(Self::get_address)) - .route("/lightning-address", post(Self::register_address)) - .route("/payments", post(Self::pay)) - .route("/payments", get(Self::list_payments)) - .route("/payments/:id", get(Self::get_payment)) - .route("/payments", delete(Self::delete_failed_payments)) - .route("/invoices", get(Self::list_invoices)) - .route("/invoices/:id", get(Self::get_invoice)) - .route("/invoices", post(Self::new_invoice)) - .route("/invoices", delete(Self::delete_expired_invoices)) - } - - async fn get_wallet( - State(app_state): State>, - user: AuthUser, - ) -> Result, ApplicationError> { - let wallet = app_state.services.wallet.get(user.sub).await?; - Ok(wallet.into()) - } - - async fn pay( - State(app_state): State>, - user: AuthUser, - Json(mut payload): Json, - ) -> Result, ApplicationError> { - payload.user_id = Some(user.sub); - let payment = app_state.services.payment.pay(payload).await?; - Ok(payment.into()) - } - - async fn get_balance( - State(app_state): State>, - user: AuthUser, - ) -> Result, ApplicationError> { - let balance = app_state.services.wallet.get_balance(user.sub).await?; - Ok(balance.into()) - } - - async fn new_invoice( - State(app_state): State>, - user: AuthUser, - Json(payload): Json, - ) -> Result, ApplicationError> { - let invoice = app_state - .services - .invoice - .invoice( - user.sub, - payload.amount_msat, - payload.description, - payload.expiry, - ) - .await?; - - Ok(invoice.into()) - } - - async fn get_address( - State(app_state): State>, - user: AuthUser, - ) -> Result, ApplicationError> { - let ln_addresses = app_state - .services - .lnurl - .list(LnAddressFilter { - user_id: Some(user.sub), - ..Default::default() - }) - .await?; - - let ln_address = ln_addresses - .first() - .cloned() - .ok_or_else(|| DataError::NotFound("Lightning address not found.".to_string()))?; - - Ok(ln_address.into()) - } - - async fn register_address( - State(app_state): State>, - user: AuthUser, - Json(payload): Json, - ) -> Result, ApplicationError> { - let ln_address = app_state - .services - .lnurl - .register(user.sub, payload.username) - .await?; - Ok(ln_address.into()) - } - - async fn list_payments( - State(app_state): State>, - user: AuthUser, - Query(mut query_params): Query, - ) -> Result>, ApplicationError> { - query_params.user_id = Some(user.sub); - let payments = app_state.services.payment.list(query_params).await?; - - let response: Vec = payments.into_iter().map(Into::into).collect(); - - Ok(response.into()) - } - - async fn get_payment( - State(app_state): State>, - user: AuthUser, - Path(id): Path, - ) -> Result, ApplicationError> { - let payments = app_state - .services - .payment - .list(PaymentFilter { - user_id: Some(user.sub), - ids: Some(vec![id]), - ..Default::default() - }) - .await?; - - let lightning_payment = payments - .first() - .cloned() - .ok_or_else(|| DataError::NotFound("Lightning payment not found.".to_string()))?; - - Ok(lightning_payment.into()) - } - - async fn list_invoices( - State(app_state): State>, - user: AuthUser, - Query(mut query_params): Query, - ) -> Result>, ApplicationError> { - query_params.user_id = Some(user.sub); - let invoices = app_state.services.invoice.list(query_params).await?; - - let response: Vec = invoices.into_iter().map(Into::into).collect(); - - Ok(response.into()) - } - - async fn get_invoice( - State(app_state): State>, - user: AuthUser, - Path(id): Path, - ) -> Result, ApplicationError> { - let invoices = app_state - .services - .invoice - .list(InvoiceFilter { - user_id: Some(user.sub), - ids: Some(vec![id]), - ..Default::default() - }) - .await?; - - let lightning_invoice = invoices - .first() - .cloned() - .ok_or_else(|| DataError::NotFound("Lightning invoice not found.".to_string()))?; - - Ok(lightning_invoice.into()) - } - - async fn delete_expired_invoices( - State(app_state): State>, - user: AuthUser, - ) -> Result, ApplicationError> { - let n_deleted = app_state - .services - .invoice - .delete_many(InvoiceFilter { - user_id: Some(user.sub), - status: Some(InvoiceStatus::Expired), - ..Default::default() - }) - .await?; - Ok(n_deleted.into()) - } - - async fn delete_failed_payments( - State(app_state): State>, - user: AuthUser, - ) -> Result, ApplicationError> { - let n_deleted = app_state - .services - .payment - .delete_many(PaymentFilter { - user_id: Some(user.sub), - status: Some(PaymentStatus::Failed), - ..Default::default() - }) - .await?; - Ok(n_deleted.into()) - } +pub fn router() -> Router> { + Router::new() + .route("/", get(get_wallet)) + .route("/balance", get(get_balance)) + .route("/lightning-address", get(get_address)) + .route("/lightning-address", post(register_address)) + .route("/payments", post(pay)) + .route("/payments", get(list_payments)) + .route("/payments/:id", get(get_payment)) + .route("/payments", delete(delete_failed_payments)) + .route("/invoices", get(list_invoices)) + .route("/invoices/:id", get(get_invoice)) + .route("/invoices", post(new_invoice)) + .route("/invoices", delete(delete_expired_invoices)) +} + +/// Gets the user wallet +/// +/// Returns the user wallet. +#[utoipa::path( + get, + path = "", + tag = "Wallet", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "Found", body = Wallet), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn get_wallet( + State(app_state): State>, + user: AuthUser, +) -> Result, ApplicationError> { + let wallet = app_state.services.wallet.get(user.sub).await?; + Ok(wallet.into()) +} + +/// Send a payment +/// +/// Pay for a LN invoice, LNURL, LN Address, On-chain or internally to an other user on the same instance. Returns the payment details. +#[utoipa::path( + post, + path = "/payments", + tag = "Payments", + context_path = CONTEXT_PATH, + request_body = SendPaymentRequest, + responses( + (status = 200, description = "Payment Sent", body = Payment), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 422, description = "Unprocessable Entity", body = ErrorResponse, example = json!(UNPROCESSABLE_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn pay( + State(app_state): State>, + user: AuthUser, + Json(mut payload): Json, +) -> Result, ApplicationError> { + payload.user_id = Some(user.sub); + let payment = app_state.services.payment.pay(payload).await?; + Ok(payment.into()) +} + +/// Gets the user balance +/// +/// Returns the user balance. +#[utoipa::path( + get, + path = "/balance", + tag = "Wallet", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "Found", body = UserBalance), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn get_balance( + State(app_state): State>, + user: AuthUser, +) -> Result, ApplicationError> { + let balance = app_state.services.wallet.get_balance(user.sub).await?; + Ok(balance.into()) +} + +/// Generate a new invoice +/// +/// Returns the generated invoice +#[utoipa::path( + post, + path = "/invoices", + tag = "Wallet", + context_path = CONTEXT_PATH, + request_body = NewInvoiceRequest, + responses( + (status = 200, description = "Invoice Created", body = Invoice), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 422, description = "Unprocessable Entity", body = ErrorResponse, example = json!(UNPROCESSABLE_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn new_invoice( + State(app_state): State>, + user: AuthUser, + Json(payload): Json, +) -> Result, ApplicationError> { + let invoice = app_state + .services + .invoice + .invoice( + user.sub, + payload.amount_msat, + payload.description, + payload.expiry, + ) + .await?; + + Ok(invoice.into()) +} + +/// Get your LN Address +/// +/// Returns the registered address +#[utoipa::path( + get, + path = "/lightning-address", + tag = "Wallet", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "Found", body = LnAddress), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 404, description = "Not Found", body = ErrorResponse, example = json!(NOT_FOUND_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn get_address( + State(app_state): State>, + user: AuthUser, +) -> Result, ApplicationError> { + let ln_addresses = app_state + .services + .lnurl + .list(LnAddressFilter { + user_id: Some(user.sub), + ..Default::default() + }) + .await?; + + let ln_address = ln_addresses + .first() + .cloned() + .ok_or_else(|| DataError::NotFound("LN Address not found.".to_string()))?; + + Ok(ln_address.into()) +} + +/// Register a new LN Address +/// +/// Registers an address. Returns the address details. LN Addresses are ready to receive funds through the LNURL protocol upon registration. +#[utoipa::path( + post, + path = "/lightning-address", + tag = "Wallet", + context_path = CONTEXT_PATH, + request_body = RegisterLnAddressRequest, + responses( + (status = 200, description = "LN Address Registered", body = LnAddress), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 422, description = "Unprocessable Entity", body = ErrorResponse, example = json!(UNPROCESSABLE_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn register_address( + State(app_state): State>, + user: AuthUser, + Json(payload): Json, +) -> Result, ApplicationError> { + let ln_address = app_state + .services + .lnurl + .register(user.sub, payload.username) + .await?; + Ok(ln_address.into()) +} + +/// List payments +/// +/// Returns all the payments given a filter +#[utoipa::path( + get, + path = "/payments", + tag = "Wallet", + context_path = CONTEXT_PATH, + params(PaymentFilter), + responses( + (status = 200, description = "Success", body = Vec), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn list_payments( + State(app_state): State>, + user: AuthUser, + Query(mut query_params): Query, +) -> Result>, ApplicationError> { + query_params.user_id = Some(user.sub); + let payments = app_state.services.payment.list(query_params).await?; + + let response: Vec = payments.into_iter().map(Into::into).collect(); + + Ok(response.into()) +} + +/// Find a payment +/// +/// Returns the payment by its ID +#[utoipa::path( + get, + path = "/payments/{id}", + tag = "Wallet", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "Found", body = Payment), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 404, description = "Not Found", body = ErrorResponse, example = json!(NOT_FOUND_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn get_payment( + State(app_state): State>, + user: AuthUser, + Path(id): Path, +) -> Result, ApplicationError> { + let payments = app_state + .services + .payment + .list(PaymentFilter { + user_id: Some(user.sub), + ids: Some(vec![id]), + ..Default::default() + }) + .await?; + + let payment = payments + .first() + .cloned() + .ok_or_else(|| DataError::NotFound("Payment not found.".to_string()))?; + + Ok(payment.into()) +} + +/// List invoices +/// +/// Returns all the invoices given a filter +#[utoipa::path( + get, + path = "/invoices", + tag = "Wallet", + context_path = CONTEXT_PATH, + params(InvoiceFilter), + responses( + (status = 200, description = "Success", body = Vec), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn list_invoices( + State(app_state): State>, + user: AuthUser, + Query(mut query_params): Query, +) -> Result>, ApplicationError> { + query_params.user_id = Some(user.sub); + let invoices = app_state.services.invoice.list(query_params).await?; + + let response: Vec = invoices.into_iter().map(Into::into).collect(); + + Ok(response.into()) +} + +/// Find an invoice +/// +/// Returns the invoice by its ID +#[utoipa::path( + get, + path = "/invoices/{id}", + tag = "Wallet", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "Found", body = Invoice), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 404, description = "Not Found", body = ErrorResponse, example = json!(NOT_FOUND_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn get_invoice( + State(app_state): State>, + user: AuthUser, + Path(id): Path, +) -> Result, ApplicationError> { + let invoices = app_state + .services + .invoice + .list(InvoiceFilter { + user_id: Some(user.sub), + ids: Some(vec![id]), + ..Default::default() + }) + .await?; + + let invoice = invoices + .first() + .cloned() + .ok_or_else(|| DataError::NotFound("Invoice not found.".to_string()))?; + + Ok(invoice.into()) +} + +/// Delete expired invoices +/// +/// Deletes all the invoices with status `Èxpired`. Returns the number of deleted invoices +#[utoipa::path( + delete, + path = "/invoices", + tag = "Wallet", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "Success", body = u64), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn delete_expired_invoices( + State(app_state): State>, + user: AuthUser, +) -> Result, ApplicationError> { + let n_deleted = app_state + .services + .invoice + .delete_many(InvoiceFilter { + user_id: Some(user.sub), + status: Some(InvoiceStatus::Expired), + ..Default::default() + }) + .await?; + Ok(n_deleted.into()) +} + +/// Delete failed payments +/// +/// Deletes all the payments with `Failed` status. Returns the number of deleted payments +#[utoipa::path( + delete, + path = "/payments", + tag = "Wallet", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "Success", body = u64), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn delete_failed_payments( + State(app_state): State>, + user: AuthUser, +) -> Result, ApplicationError> { + let n_deleted = app_state + .services + .payment + .delete_many(PaymentFilter { + user_id: Some(user.sub), + status: Some(PaymentStatus::Failed), + ..Default::default() + }) + .await?; + Ok(n_deleted.into()) } diff --git a/src/domains/wallet/entities/wallet.rs b/src/domains/wallet/entities/wallet.rs index 719cf06..fee28b0 100644 --- a/src/domains/wallet/entities/wallet.rs +++ b/src/domains/wallet/entities/wallet.rs @@ -1,21 +1,37 @@ use serde::Serialize; +use utoipa::ToSchema; use crate::domains::{ invoices::entities::Invoice, lightning::entities::LnAddress, payments::entities::Payment, }; -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ToSchema)] pub struct Wallet { + /// User Balance pub user_balance: UserBalance, + /// List of payments pub payments: Vec, + /// Lit of Invoices pub invoices: Vec, + /// Lightning Address pub ln_address: Option, } -#[derive(Debug, Clone, Serialize, Default)] +#[derive(Debug, Clone, Serialize, Default, ToSchema)] pub struct UserBalance { + /// Total amount received + #[schema(example = 1000000000)] pub received_msat: u64, + + /// Total amount sent + #[schema(example = 10000000)] pub sent_msat: u64, + + /// Total fees paid pub fees_paid_msat: u64, + #[schema(example = 1000)] + + /// Amount available to spend + #[schema(example = 989999000)] pub available_msat: i64, } diff --git a/src/domains/wallet/services/wallet_service.rs b/src/domains/wallet/services/wallet_service.rs index e81325f..7b41464 100644 --- a/src/domains/wallet/services/wallet_service.rs +++ b/src/domains/wallet/services/wallet_service.rs @@ -1,8 +1,5 @@ use crate::{ - application::{ - entities::{AppStore, PaginationFilter}, - errors::ApplicationError, - }, + application::{entities::AppStore, errors::ApplicationError}, domains::{ invoices::entities::InvoiceFilter, payments::entities::PaymentFilter, @@ -47,10 +44,7 @@ impl WalletUseCases for WalletService { .payment .find_many(PaymentFilter { user_id: Some(user_id.clone()), - pagination: PaginationFilter { - limit: Some(PAYMENTS_LIMIT), - ..Default::default() - }, + limit: Some(PAYMENTS_LIMIT), ..Default::default() }) .await?; @@ -59,10 +53,7 @@ impl WalletUseCases for WalletService { .invoice .find_many(InvoiceFilter { user_id: Some(user_id.clone()), - pagination: PaginationFilter { - limit: Some(INVOICES_LIMIT), - ..Default::default() - }, + limit: Some(INVOICES_LIMIT), ..Default::default() }) .await?; diff --git a/src/infra/app/server.rs b/src/infra/app/server.rs index 2191356..cacc310 100644 --- a/src/infra/app/server.rs +++ b/src/infra/app/server.rs @@ -5,17 +5,11 @@ use std::future::Future; use tokio::net::TcpListener; use tower_http::{cors::CorsLayer, trace::TraceLayer}; use tracing::info; +use utoipa_scalar::{Scalar, Servable as ScalarServable}; use crate::{ - application::{entities::LnNodeClient, errors::WebServerError}, - domains::{ - invoices::api::InvoiceHandler, - lightning::api::{BreezNodeHandler, LnAddressHandler, LnURLpHandler}, - payments::api::PaymentHandler, - system::api::SystemHandler, - users::api::UserHandler, - wallet::api::WalletHandler, - }, + application::{docs::merged_openapi, entities::LnNodeClient, errors::WebServerError}, + domains::{invoices, lightning, payments, system, users, wallet}, infra::app::AppState, }; @@ -26,18 +20,22 @@ pub struct Server { impl Server { pub fn new(state: Arc) -> Self { let router = Router::new() - .nest("/api/system", SystemHandler::routes()) - .nest("/.well-known/lnurlp", LnURLpHandler::well_known_route()) - .nest("/api/lnurlp", LnURLpHandler::callback_route()) - .nest("/api/lightning/addresses", LnAddressHandler::routes()) - .nest("/api/invoices", InvoiceHandler::routes()) - .nest("/api/payments", PaymentHandler::routes()) - .nest("/api/wallet", WalletHandler::routes()) - .nest("/api/users", UserHandler::routes()); + .nest("/api/system", system::api::router()) + .nest("/.well-known/lnurlp", lightning::api::well_known_router()) + .nest("/api/lnurlp", lightning::api::callback_router()) + .nest( + "/api/lightning/addresses", + lightning::api::ln_address_router(), + ) + .nest("/api/invoices", invoices::api::router()) + .nest("/api/payments", payments::api::router()) + .nest("/api/wallet", wallet::api::router()) + .nest("/api/auth", users::api::auth_router()) + .merge(Scalar::with_url("/docs", merged_openapi())); let router = match state.ln_node_client { LnNodeClient::Breez(_) => { - router.nest("/api/lightning/node", BreezNodeHandler::routes()) + router.nest("/api/lightning/node", lightning::api::breez_node_router()) } _ => router, }; diff --git a/src/infra/axum/axum_response.rs b/src/infra/axum/axum_response.rs index 005b505..58e27d4 100644 --- a/src/infra/axum/axum_response.rs +++ b/src/infra/axum/axum_response.rs @@ -3,11 +3,13 @@ use axum::{ response::{IntoResponse, Response}, Json, }; -use serde_json::{json, Value}; use tracing::{debug, error, warn}; -use crate::application::errors::{ - ApplicationError, AuthenticationError, AuthorizationError, DataError, LightningError, +use crate::application::{ + dtos::ErrorResponse, + errors::{ + ApplicationError, AuthenticationError, AuthorizationError, DataError, LightningError, + }, }; const INTERNAL_SERVER_ERROR_MSG: &str = @@ -24,14 +26,14 @@ impl IntoResponse for ApplicationError { debug!("{}", self); let status = StatusCode::METHOD_NOT_ALLOWED; - let body = generate_body(status, self.to_string().as_str()); + let body = generate_body(status, self.to_string()); (status, body).into_response() } _ => { error!("{}", self); let status = StatusCode::INTERNAL_SERVER_ERROR; - let body = generate_body(status, INTERNAL_SERVER_ERROR_MSG); + let body = generate_body(status, INTERNAL_SERVER_ERROR_MSG.to_string()); (status, body).into_response() } // Add additional cases as needed } @@ -49,7 +51,7 @@ impl IntoResponse for AuthorizationError { warn!("{}", self); let status = StatusCode::FORBIDDEN; - let body = generate_body(status, error_message); + let body = generate_body(status, error_message.to_string()); (status, body).into_response() } } @@ -79,7 +81,7 @@ impl IntoResponse for AuthenticationError { warn!("{}", self); let status = StatusCode::UNAUTHORIZED; - let body = generate_body(status, error_message); + let body = generate_body(status, error_message.to_string()); let mut response = (status, body).into_response(); // Add WWW-Authenticate header if needed @@ -114,7 +116,7 @@ impl IntoResponse for DataError { } }; - let body = generate_body(status, error_message.as_str()); + let body = generate_body(status, error_message); (status, body).into_response() } } @@ -140,14 +142,15 @@ impl IntoResponse for LightningError { } }; - let body = generate_body(status, error_message.as_str()); + let body = generate_body(status, error_message); (status, body).into_response() } } -fn generate_body(status: StatusCode, reason: &str) -> Json { - Json(json!({ - "status": status.as_str(), - "reason": reason, - })) +fn generate_body(status: StatusCode, reason: String) -> Json { + ErrorResponse { + status: format!("{}", status), + reason, + } + .into() }