From 2b4ca4eae9b694c2b597f384327f50eb768b4310 Mon Sep 17 00:00:00 2001 From: curtis lee fulton Date: Fri, 21 Nov 2025 13:40:55 -0800 Subject: [PATCH] feat: paging for Offer Service #46 --- README.md | 3 + ROADMAP.md | 4 +- doc/offer-service-openapi.yaml | 28 +++ server/config/memory-basic-no-tls.yaml | 1 + server/config/memory-basic.yaml | 1 + server/config/mixed-persistence.yaml | 3 +- server/config/offers-standalone.yaml | 3 +- server/config/sqlite-persistent.yaml | 3 +- server/src/commands/offer/metadata.rs | 12 +- server/src/commands/offer/record.rs | 10 +- server/src/config.rs | 1 + server/src/di/delegates.rs | 8 +- .../src/di/inject/injectors/service/offer.rs | 11 +- server/src/main.rs | 8 + ...e.feature => cli-discovery-manage.feature} | 0 ...anage.feature => cli-offer-manage.feature} | 50 +++- ...very_manage.rs => cli_discovery_manage.rs} | 0 .../{offer_manage.rs => cli_offer_manage.rs} | 229 ++++++++++++++++-- .../tests/features/common/step_functions.rs | 88 ++++--- server/tests/features/main.rs | 4 +- service/src/api/offer.rs | 14 +- service/src/axum/crud/error.rs | 7 + service/src/components/discovery/error.rs | 10 + service/src/components/discovery/http.rs | 124 +++++----- service/src/components/offer/db.rs | 25 +- service/src/components/offer/http.rs | 186 +++++++------- service/src/components/offer/memory.rs | 113 +++++++-- service/src/offer/handler.rs | 28 ++- service/src/offer/service.rs | 171 ++++++++++--- service/src/offer/state.rs | 13 +- service/tests/common/offer.rs | 97 +++++--- service/tests/common/service.rs | 3 +- switchgear/README.md | 29 ++- 33 files changed, 946 insertions(+), 341 deletions(-) rename server/tests/features/{discovery-manage.feature => cli-discovery-manage.feature} (100%) rename server/tests/features/{offer-manage.feature => cli-offer-manage.feature} (84%) rename server/tests/features/{discovery_manage.rs => cli_discovery_manage.rs} (100%) rename server/tests/features/{offer_manage.rs => cli_offer_manage.rs} (85%) diff --git a/README.md b/README.md index 66167da..4b67870 100644 --- a/README.md +++ b/README.md @@ -499,6 +499,9 @@ offer-service: cert-path: "/etc/ssl/certs/offer-cert.pem" # Path to TLS private key file key-path: "/etc/ssl/certs/offer-key.pem" + + # max page size for get all queries + max-page-size: 100 ``` #### Authentication Setup diff --git a/ROADMAP.md b/ROADMAP.md index 86eb181..b647a3f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,9 +18,9 @@ Mitigate overload and certain DOS attacks with rate limiting: * per-session (ip-based) rate limit per endpoint * configurable individual max and session limits for each endpoint -#### Paging for Offer GET Endpoint +#### ~~Paging for Offer GET Endpoint~~ DONE -GET endpoints for Discovery and Offer Services that return a list need paging. For REST: `?page={page}` query parameter. The CLI will need a --page parameter. +~~GET endpoints for Discovery and Offer Services that return a list need paging. For REST: `?page={page}` query parameter. The CLI will need a --page parameter.~~ #### ~~PATCH Method for Discovery~~ DONE diff --git a/doc/offer-service-openapi.yaml b/doc/offer-service-openapi.yaml index a80a33a..59b3ce6 100644 --- a/doc/offer-service-openapi.yaml +++ b/doc/offer-service-openapi.yaml @@ -45,6 +45,20 @@ paths: required: true schema: type: string + - name: start + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + description: Offset for pagination (starting index) + - name: count + in: query + required: false + schema: + type: integer + description: Maximum number of offers to return (page size) responses: '200': description: List of offers @@ -188,6 +202,20 @@ paths: required: true schema: type: string + - name: start + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + description: Offset for pagination (starting index) + - name: count + in: query + required: false + schema: + type: integer + description: Maximum number of metadata entries to return (page size) responses: '200': description: List of metadata diff --git a/server/config/memory-basic-no-tls.yaml b/server/config/memory-basic-no-tls.yaml index 83d8db6..706aea6 100644 --- a/server/config/memory-basic-no-tls.yaml +++ b/server/config/memory-basic-no-tls.yaml @@ -27,6 +27,7 @@ discovery-service: offer-service: auth-authority: "${OFFER_SERVICE_AUTH_AUTHORITY_PATH:-/etc/ssl/certs/offer-auth-authority.pem}" address: "${OFFER_SERVICE_ADDRESS:-127.0.0.1:8082}" + max-page-size: 100 store: offer: diff --git a/server/config/memory-basic.yaml b/server/config/memory-basic.yaml index c3043a0..003939b 100644 --- a/server/config/memory-basic.yaml +++ b/server/config/memory-basic.yaml @@ -35,6 +35,7 @@ offer-service: tls: cert-path: "${OFFER_SERVICE_TLS_CERT_PATH:-/etc/ssl/certs/offer-cert.pem}" key-path: "${OFFER_SERVICE_TLS_KEY_PATH:-/etc/ssl/certs/offer-key.pem}" + max-page-size: 100 store: offer: diff --git a/server/config/mixed-persistence.yaml b/server/config/mixed-persistence.yaml index 87cc395..d1f0d6e 100644 --- a/server/config/mixed-persistence.yaml +++ b/server/config/mixed-persistence.yaml @@ -36,7 +36,8 @@ offer-service: tls: cert-path: "${OFFER_SERVICE_TLS_CERT_PATH:-/etc/ssl/certs/offer-cert.pem}" key-path: "${OFFER_SERVICE_TLS_KEY_PATH:-/etc/ssl/certs/offer-key.pem}" - + max-page-size: 100 + store: offer: type: "database" diff --git a/server/config/offers-standalone.yaml b/server/config/offers-standalone.yaml index 01fd8c8..8ccf4c4 100644 --- a/server/config/offers-standalone.yaml +++ b/server/config/offers-standalone.yaml @@ -8,7 +8,8 @@ offer-service: tls: cert-path: "${OFFER_SERVICE_TLS_CERT_PATH:-/etc/ssl/certs/offer-cert.pem}" key-path: "${OFFER_SERVICE_TLS_KEY_PATH:-/etc/ssl/certs/offer-key.pem}" - + max-page-size: 100 + # Storage configuration store: offer: diff --git a/server/config/sqlite-persistent.yaml b/server/config/sqlite-persistent.yaml index f26dfcc..ec09541 100644 --- a/server/config/sqlite-persistent.yaml +++ b/server/config/sqlite-persistent.yaml @@ -36,7 +36,8 @@ offer-service: tls: cert-path: "${OFFER_SERVICE_TLS_CERT_PATH:-/etc/ssl/certs/offer-cert.pem}" key-path: "${OFFER_SERVICE_TLS_KEY_PATH:-/etc/ssl/certs/offer-key.pem}" - + max-page-size: 100 + store: offer: type: "database" diff --git a/server/src/commands/offer/metadata.rs b/server/src/commands/offer/metadata.rs index a5e4e35..10f9f1a 100644 --- a/server/src/commands/offer/metadata.rs +++ b/server/src/commands/offer/metadata.rs @@ -30,8 +30,14 @@ pub enum OfferMetadataManagementCommands { Get { /// Partition name partition: String, - /// Optional offer metadata uuid, default returns all offers for partition + /// Optional offer metadata uuid, default returns all metadata for partition id: Option, + /// Start position when returning multiple metadata + #[arg(short, long, conflicts_with = "id", default_value_t = 0)] + start: usize, + /// Count position when returning multiple metadata + #[arg(short, long, conflicts_with = "id", default_value_t = 100)] + count: usize, /// Optional output metadata path, defaults to stdout #[arg(short, long)] output: Option, @@ -103,6 +109,8 @@ pub fn new_metadata(partition: &str, text: &str, output: Option<&Path>) -> anyho pub async fn get_metadata( partition: &str, id: Option<&Uuid>, + start: usize, + count: usize, output: Option<&Path>, client_configuration: &OfferManagementClientConfig, ) -> anyhow::Result<()> { @@ -126,7 +134,7 @@ pub async fn get_metadata( bail!("Metadata {id} not found"); } } else { - let metadata = client.get_all_metadata(partition).await?; + let metadata = client.get_all_metadata(partition, start, count).await?; let metadata = metadata .into_iter() .map(|metadata| OfferMetadataRest { diff --git a/server/src/commands/offer/record.rs b/server/src/commands/offer/record.rs index 383e37c..ba31f6d 100644 --- a/server/src/commands/offer/record.rs +++ b/server/src/commands/offer/record.rs @@ -31,6 +31,12 @@ pub enum OfferRecordManagementCommands { partition: String, /// Optional offer uuid, default returns all offers for partition id: Option, + /// Start position when returning multiple offers + #[arg(short, long, conflicts_with = "id", default_value_t = 0)] + start: usize, + /// Count when returning multiple offers + #[arg(short, long, conflicts_with = "id", default_value_t = 100)] + count: usize, /// Optional output path, defaults to stdout #[arg(short, long)] output: Option, @@ -107,6 +113,8 @@ pub fn new_offer(partition: &str, metadata_id: &Uuid, output: Option<&Path>) -> pub async fn get_offer( partition: &str, id: Option<&Uuid>, + start: usize, + count: usize, output: Option<&Path>, client_configuration: &OfferManagementClientConfig, ) -> anyhow::Result<()> { @@ -130,7 +138,7 @@ pub async fn get_offer( bail!("Offer {id} not found"); } } else { - let offers = client.get_offers(partition).await?; + let offers = client.get_offers(partition, start, count).await?; let offers = offers .into_iter() .map(|offer| OfferRecordRest { diff --git a/server/src/config.rs b/server/src/config.rs index 8b3f5db..b36d7e0 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -46,6 +46,7 @@ pub struct OfferServiceConfig { pub address: SocketAddr, pub auth_authority: PathBuf, pub tls: Option, + pub max_page_size: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/server/src/di/delegates.rs b/server/src/di/delegates.rs index 3175755..69537cc 100644 --- a/server/src/di/delegates.rs +++ b/server/src/di/delegates.rs @@ -112,8 +112,10 @@ impl OfferStore for OfferStoreDelegate { async fn get_offers( &self, partition: &str, + start: usize, + count: usize, ) -> Result, Self::Error> { - delegate_to_offer_store_variants!(self, get_offers, partition).await + delegate_to_offer_store_variants!(self, get_offers, partition, start, count).await } async fn post_offer( @@ -150,8 +152,10 @@ impl OfferMetadataStore for OfferStoreDelegate { async fn get_all_metadata( &self, partition: &str, + start: usize, + count: usize, ) -> Result, Self::Error> { - delegate_to_offer_store_variants!(self, get_all_metadata, partition).await + delegate_to_offer_store_variants!(self, get_all_metadata, partition, start, count).await } async fn post_metadata( diff --git a/server/src/di/inject/injectors/service/offer.rs b/server/src/di/inject/injectors/service/offer.rs index 1dc5ad5..ee268fc 100644 --- a/server/src/di/inject/injectors/service/offer.rs +++ b/server/src/di/inject/injectors/service/offer.rs @@ -89,9 +89,14 @@ impl OfferServiceInjector { ) })?; - let router = OfferService::router(OfferState::new(store.clone(), store, auth_authority)) - .layer(ClfLogger::new("offer")) - .into_make_service_with_connect_info::(); + let router = OfferService::router(OfferState::new( + store.clone(), + store, + auth_authority, + service_config.max_page_size, + )) + .layer(ClfLogger::new("offer")) + .into_make_service_with_connect_info::(); let f = async move { match acceptor { diff --git a/server/src/main.rs b/server/src/main.rs index 3dec893..77f39a7 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -132,12 +132,16 @@ async fn _main(args: CliArgs) -> anyhow::Result<()> { OfferRecordManagementCommands::Get { partition, id, + start, + count, output, client, } => { commands::offer::record::get_offer( &partition, id.as_ref(), + start, + count, output.as_deref(), &client, ) @@ -175,12 +179,16 @@ async fn _main(args: CliArgs) -> anyhow::Result<()> { OfferMetadataManagementCommands::Get { partition, id, + start, + count, output, client, } => { commands::offer::metadata::get_metadata( &partition, id.as_ref(), + start, + count, output.as_deref(), &client, ) diff --git a/server/tests/features/discovery-manage.feature b/server/tests/features/cli-discovery-manage.feature similarity index 100% rename from server/tests/features/discovery-manage.feature rename to server/tests/features/cli-discovery-manage.feature diff --git a/server/tests/features/offer-manage.feature b/server/tests/features/cli-offer-manage.feature similarity index 84% rename from server/tests/features/offer-manage.feature rename to server/tests/features/cli-offer-manage.feature index 9de8fe2..a5e790f 100644 --- a/server/tests/features/offer-manage.feature +++ b/server/tests/features/cli-offer-manage.feature @@ -44,17 +44,36 @@ Feature: Offer CLI management And offer details should be output @offer-get-all - Scenario: Get all offers + Scenario Outline: Get all offers with parameters Given the lnurl server is ready to start When I start the lnurl server with the configuration Then the server should start successfully Given a valid offer JSON exists - When I run "swgr offer post" with offer JSON + When I run "swgr offer post" 10 times with offer JSON Then the command should succeed - When I run "swgr offer get" + When I run "swgr offer get" with parameters "" Then the command should succeed And all offers should be output + Examples: + | parameters | + | | + | --start 1 | + | --count 5 | + | --start 1 --count 1 | + + @offer-get-all-bounds-error + Scenario: Get all offers with count exceeding limit + Given the lnurl server is ready to start + When I start the lnurl server with the configuration + Then the server should start successfully + Given a valid offer JSON exists + When I run "swgr offer post" 10 times with offer JSON + Then the command should succeed + When I run "swgr offer get --count 101" + Then the command should fail + And a user error message should be shown + @offer-put Scenario: Update an offer Given the lnurl server is ready to start @@ -118,17 +137,36 @@ Feature: Offer CLI management And offer metadata details should be output @offer-metadata-get-all - Scenario: Get all offer metadata + Scenario Outline: Get all offer metadata with parameters Given the lnurl server is ready to start When I start the lnurl server with the configuration Then the server should start successfully Given a valid offer metadata JSON exists - When I run "swgr offer metadata post" with metadata JSON + When I run "swgr offer metadata post" 10 times with metadata JSON Then the command should succeed - When I run "swgr offer metadata get" + When I run "swgr offer metadata get" with parameters "" Then the command should succeed And all offer metadata should be output + Examples: + | parameters | + | | + | --start 1 | + | --count 5 | + | --start 1 --count 1 | + + @offer-metadata-get-all-bounds-error + Scenario: Get all offer metadata with count exceeding limit + Given the lnurl server is ready to start + When I start the lnurl server with the configuration + Then the server should start successfully + Given a valid offer metadata JSON exists + When I run "swgr offer metadata post" 10 times with metadata JSON + Then the command should succeed + When I run "swgr offer metadata get --count 101" + Then the command should fail + And a user error message should be shown + @offer-metadata-put Scenario: Update offer metadata Given the lnurl server is ready to start diff --git a/server/tests/features/discovery_manage.rs b/server/tests/features/cli_discovery_manage.rs similarity index 100% rename from server/tests/features/discovery_manage.rs rename to server/tests/features/cli_discovery_manage.rs diff --git a/server/tests/features/offer_manage.rs b/server/tests/features/cli_offer_manage.rs similarity index 85% rename from server/tests/features/offer_manage.rs rename to server/tests/features/cli_offer_manage.rs index fb217a1..22780a5 100644 --- a/server/tests/features/offer_manage.rs +++ b/server/tests/features/cli_offer_manage.rs @@ -180,7 +180,7 @@ async fn test_offer_get() { } /// Feature: Offer CLI management -/// Scenario: Get all offers +/// Scenario Outline: Get all offers with parameters #[tokio::test] async fn test_offer_get_all() { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -223,26 +223,122 @@ async fn test_offer_get_all() { .await .expect("assert"); - // Setup - load offer - step_given_a_valid_offer_json_exists(&mut ctx, &mut cli_ctx) + let mut expected_offers = vec![]; + for _ in 0..10 { + step_given_a_valid_offer_json_exists(&mut ctx, &mut cli_ctx) + .await + .expect("assert"); + + let offer = extract_offer(&cli_ctx).await.expect("assert"); + expected_offers.push(offer); + + step_when_i_run_swgr_offer_post(&mut ctx, &mut cli_ctx) + .await + .expect("assert"); + step_then_the_command_should_succeed(&mut cli_ctx) + .await + .expect("assert"); + + // make sure timestamps are different + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + let parameter_combinations = vec![ + (None, None), + (Some(1), None), + (None, Some(5)), + (Some(1), Some(1)), + ]; + + for (start, count) in parameter_combinations { + let start_index = start.unwrap_or(0); + let end_index = count + .map(|c| start_index + c) + .unwrap_or(expected_offers.len()); + cli_ctx.reset(); + step_when_i_run_swgr_offer_get_all(&mut ctx, &mut cli_ctx, start, count) + .await + .expect("assert"); + step_then_the_command_should_succeed(&mut cli_ctx) + .await + .expect("assert"); + + step_then_all_offers_should_be_output( + &mut cli_ctx, + &expected_offers[start_index..end_index], + ) .await .expect("assert"); - let offer_id = extract_offer_id(&cli_ctx).await.expect("assert"); - step_when_i_run_swgr_offer_post(&mut ctx, &mut cli_ctx) + } + + ctx.stop_all_servers().expect("assert"); +} + +/// Feature: Offer CLI management +/// Scenario: Get all offers with count exceeding limit +#[tokio::test] +async fn test_offer_get_all_bounds_error() { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let feature_test_config_path = manifest_dir.join(FEATURE_TEST_CONFIG_PATH); + let mut ctx = match GlobalContext::create(&feature_test_config_path).expect("assert") { + Some(ctx) => ctx, + None => return, + }; + + let server1 = "server1"; + let config_path = manifest_dir.join("config/memory-basic.yaml"); + ctx.add_server( + server1, + config_path, + Protocol::Https, + Protocol::Https, + Protocol::Https, + ) + .expect("assert"); + ctx.activate_server(server1); + + let mut cli_ctx = CliContext::create().expect("assert"); + + // Background + step_given_the_swgr_cli_is_available(&mut cli_ctx) .await .expect("assert"); - step_then_the_command_should_succeed(&mut cli_ctx) + + // Start server + step_given_the_lnurl_server_is_ready_to_start(&mut ctx) + .await + .expect("assert"); + step_when_i_start_the_lnurl_server_with_the_configuration(&mut ctx) + .await + .expect("assert"); + step_then_the_server_should_start_successfully(&mut ctx) + .await + .expect("assert"); + step_and_all_services_should_be_listening_on_their_configured_ports(&mut ctx) .await .expect("assert"); + // Setup - load 10 offers + for _ in 0..10 { + step_given_a_valid_offer_json_exists(&mut ctx, &mut cli_ctx) + .await + .expect("assert"); + step_when_i_run_swgr_offer_post(&mut ctx, &mut cli_ctx) + .await + .expect("assert"); + step_then_the_command_should_succeed(&mut cli_ctx) + .await + .expect("assert"); + } + // Scenario steps - step_when_i_run_swgr_offer_get_all(&mut ctx, &mut cli_ctx) + step_when_i_run_swgr_offer_get_all(&mut ctx, &mut cli_ctx, None, Some(101)) .await .expect("assert"); - step_then_the_command_should_succeed(&mut cli_ctx) + step_then_the_command_should_fail(&mut cli_ctx) .await .expect("assert"); - step_then_all_offers_should_be_output(&mut cli_ctx, &offer_id) + step_then_a_user_error_message_should_be_shown(&mut cli_ctx) .await .expect("assert"); @@ -579,7 +675,7 @@ async fn test_offer_metadata_get() { } /// Feature: Offer CLI management -/// Scenario: Get all offer metadata +/// Scenario Outline: Get all offer metadata with parameters #[tokio::test] async fn test_offer_metadata_get_all() { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -622,26 +718,123 @@ async fn test_offer_metadata_get_all() { .await .expect("assert"); - // Setup - load metadata - step_given_a_valid_offer_metadata_json_exists(&mut ctx, &mut cli_ctx) + // Setup - load 10 metadata + let mut expected_metadata = vec![]; + for _ in 0..10 { + step_given_a_valid_offer_metadata_json_exists(&mut ctx, &mut cli_ctx) + .await + .expect("assert"); + + let metadata = extract_metadata(&cli_ctx).await.expect("assert"); + expected_metadata.push(metadata); + + step_when_i_run_swgr_offer_metadata_post(&mut ctx, &mut cli_ctx) + .await + .expect("assert"); + step_then_the_command_should_succeed(&mut cli_ctx) + .await + .expect("assert"); + + // make sure timestamps are different + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + let parameter_combinations = vec![ + (None, None), + (Some(1), None), + (None, Some(5)), + (Some(1), Some(1)), + ]; + + for (start, count) in parameter_combinations { + let start_index = start.unwrap_or(0); + let end_index = count + .map(|c| start_index + c) + .unwrap_or(expected_metadata.len()); + cli_ctx.reset(); + step_when_i_run_swgr_offer_metadata_get_all(&mut ctx, &mut cli_ctx, start, count) + .await + .expect("assert"); + step_then_the_command_should_succeed(&mut cli_ctx) + .await + .expect("assert"); + + step_then_all_offer_metadata_should_be_output( + &mut cli_ctx, + &expected_metadata[start_index..end_index], + ) + .await + .expect("assert"); + } + + ctx.stop_all_servers().expect("assert"); +} + +/// Feature: Offer CLI management +/// Scenario: Get all offer metadata with count exceeding limit +#[tokio::test] +async fn test_offer_metadata_get_all_bounds_error() { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let feature_test_config_path = manifest_dir.join(FEATURE_TEST_CONFIG_PATH); + let mut ctx = match GlobalContext::create(&feature_test_config_path).expect("assert") { + Some(ctx) => ctx, + None => return, + }; + + let server1 = "server1"; + let config_path = manifest_dir.join("config/memory-basic.yaml"); + ctx.add_server( + server1, + config_path, + Protocol::Https, + Protocol::Https, + Protocol::Https, + ) + .expect("assert"); + ctx.activate_server(server1); + + let mut cli_ctx = CliContext::create().expect("assert"); + + // Background + step_given_the_swgr_cli_is_available(&mut cli_ctx) .await .expect("assert"); - let metadata_id = extract_metadata_id(&cli_ctx).await.expect("assert"); - step_when_i_run_swgr_offer_metadata_post(&mut ctx, &mut cli_ctx) + + // Start server + step_given_the_lnurl_server_is_ready_to_start(&mut ctx) .await .expect("assert"); - step_then_the_command_should_succeed(&mut cli_ctx) + step_when_i_start_the_lnurl_server_with_the_configuration(&mut ctx) .await .expect("assert"); + step_then_the_server_should_start_successfully(&mut ctx) + .await + .expect("assert"); + step_and_all_services_should_be_listening_on_their_configured_ports(&mut ctx) + .await + .expect("assert"); + + // Setup - load 10 metadata + for _ in 0..10 { + step_given_a_valid_offer_metadata_json_exists(&mut ctx, &mut cli_ctx) + .await + .expect("assert"); + step_when_i_run_swgr_offer_metadata_post(&mut ctx, &mut cli_ctx) + .await + .expect("assert"); + step_then_the_command_should_succeed(&mut cli_ctx) + .await + .expect("assert"); + } // Scenario steps - step_when_i_run_swgr_offer_metadata_get_all(&mut ctx, &mut cli_ctx) + step_when_i_run_swgr_offer_metadata_get_all(&mut ctx, &mut cli_ctx, None, Some(101)) .await .expect("assert"); - step_then_the_command_should_succeed(&mut cli_ctx) + step_then_the_command_should_fail(&mut cli_ctx) .await .expect("assert"); - step_then_all_offer_metadata_should_be_output(&mut cli_ctx, &metadata_id) + step_then_a_user_error_message_should_be_shown(&mut cli_ctx) .await .expect("assert"); diff --git a/server/tests/features/common/step_functions.rs b/server/tests/features/common/step_functions.rs index 67277cb..1b2bea6 100644 --- a/server/tests/features/common/step_functions.rs +++ b/server/tests/features/common/step_functions.rs @@ -2381,15 +2381,15 @@ pub async fn step_then_a_conflict_message_should_be_shown(ctx: &mut CliContext) /// Step: "And a user error message should be shown" /// Verifies that a user error message (not a system error) was output -/// Checks for specific user error patterns: "invalid input" or "bad request" +/// Checks for specific user error patterns: "invalid input" pub async fn step_then_a_user_error_message_should_be_shown(ctx: &mut CliContext) -> Result<()> { let stderr = ctx.stderr_buffer().join("\n"); let stderr_lower = stderr.to_lowercase(); // Check for user error patterns - if !stderr_lower.contains("invalid input") && !stderr_lower.contains("bad request") { + if !stderr_lower.contains("invalid input") { bail_log!( - "Expected user error message ('invalid input' or 'bad request') but got: {}", + "Expected user error message ('invalid input') but got: {}", stderr ); } @@ -3691,11 +3691,13 @@ pub async fn step_when_i_run_swgr_offer_get( Ok(()) } -/// Step: "When I run swgr offer get" -/// Runs the offer get command without ID to get all offers +/// Step: "When I run swgr offer get" or "When I run swgr offer get with parameters" +/// Runs the offer get command without ID to get all offers, with optional parameters pub async fn step_when_i_run_swgr_offer_get_all( ctx: &mut GlobalContext, cli_ctx: &mut CliContext, + start: Option, + count: Option, ) -> Result<()> { let service_profile = ctx.get_active_offer_service_profile()?; let protocol = service_profile.protocol; @@ -3709,7 +3711,7 @@ pub async fn step_when_i_run_swgr_offer_get_all( let authorization = ctx.get_active_offer_authorization()?; let authorization_str = authorization.to_str().unwrap().to_string(); - let args = vec![ + let mut args = vec![ "offer", "get", "default", @@ -3721,6 +3723,22 @@ pub async fn step_when_i_run_swgr_offer_get_all( &ca_bundle_str, ]; + // Add start parameter if provided + let start_str; + if let Some(s) = start { + args.push("--start"); + start_str = s.to_string(); + args.push(&start_str); + } + + // Add count parameter if provided + let count_str; + if let Some(c) = count { + args.push("--count"); + count_str = c.to_string(); + args.push(&count_str); + } + let empty_env: Vec<(&str, &str)> = vec![]; cli_ctx.command(empty_env, args)?; Ok(()) @@ -3790,7 +3808,7 @@ pub async fn step_then_offer_details_should_be_output( /// Verifies that all offers were output (as a JSON array) and contains the expected offer pub async fn step_then_all_offers_should_be_output( cli_ctx: &mut CliContext, - expected_offer_id: &Uuid, + expected_offers: &[OfferRecord], ) -> Result<()> { let stdout = cli_ctx.stdout_buffer().join("\n"); @@ -3810,16 +3828,7 @@ pub async fn step_then_all_offers_should_be_output( } }; - // Verify the expected offer is in the array - let found = offers.iter().any(|offer| offer.id == *expected_offer_id); - - if !found { - bail_log!( - "Expected offer with ID {} not found in output. Got {} offers", - expected_offer_id, - offers.len() - ); - } + assert_eq!(expected_offers, offers.as_slice()); Ok(()) } @@ -4152,6 +4161,8 @@ pub async fn step_when_i_run_swgr_offer_metadata_get( pub async fn step_when_i_run_swgr_offer_metadata_get_all( ctx: &mut GlobalContext, cli_ctx: &mut CliContext, + start: Option, + count: Option, ) -> Result<()> { let service_profile = ctx.get_active_offer_service_profile()?; let protocol = service_profile.protocol; @@ -4165,7 +4176,7 @@ pub async fn step_when_i_run_swgr_offer_metadata_get_all( let authorization = ctx.get_active_offer_authorization()?; let authorization_str = authorization.to_str().unwrap().to_string(); - let args = vec![ + let mut args = vec![ "offer", "metadata", "get", @@ -4178,6 +4189,22 @@ pub async fn step_when_i_run_swgr_offer_metadata_get_all( &ca_bundle_str, ]; + // Add start parameter if provided + let start_str; + if let Some(s) = start { + args.push("--start"); + start_str = s.to_string(); + args.push(&start_str); + } + + // Add count parameter if provided + let count_str; + if let Some(c) = count { + args.push("--count"); + count_str = c.to_string(); + args.push(&count_str); + } + let empty_env: Vec<(&str, &str)> = vec![]; cli_ctx.command(empty_env, args)?; Ok(()) @@ -4236,41 +4263,30 @@ pub async fn step_then_offer_metadata_details_should_be_output( } /// Step: "And all offer metadata should be output" -/// Verifies that all offer metadata were output (as a JSON array) and contains the expected metadata +/// Verifies that all offer metadata were output (as a JSON array) and matches the expected metadata pub async fn step_then_all_offer_metadata_should_be_output( cli_ctx: &mut CliContext, - expected_metadata_id: &Uuid, + expected_metadata: &[OfferMetadata], ) -> Result<()> { let stdout = cli_ctx.stdout_buffer().join("\n"); if stdout.trim().is_empty() { - bail_log!("All offer metadata output is empty"); + bail_log!("All offers output is empty"); } - // Parse as JSON array of OfferMetadata - let metadata_list: Vec = match serde_json::from_str(&stdout) { + // Parse as JSON array of OfferRecord + let metadata: Vec = match serde_json::from_str(&stdout) { Ok(metadata) => metadata, Err(e) => { bail_log!( - "Failed to parse offer metadata JSON array: {}. Output: {}", + "Failed to parse metadata JSON array: {}. Output: {}", e, stdout ); } }; - // Verify the expected metadata is in the array - let found = metadata_list - .iter() - .any(|metadata| metadata.id == *expected_metadata_id); - - if !found { - bail_log!( - "Expected offer metadata with ID {} not found in output. Got {} metadata entries", - expected_metadata_id, - metadata_list.len() - ); - } + assert_eq!(expected_metadata, metadata.as_slice()); Ok(()) } diff --git a/server/tests/features/main.rs b/server/tests/features/main.rs index 8eb50e0..50bcad2 100644 --- a/server/tests/features/main.rs +++ b/server/tests/features/main.rs @@ -5,13 +5,13 @@ pub const FEATURE_TEST_CONFIG_PATH: &str = "tests/features/feature-test-config.t mod backend_create_delete; mod backend_enable_disable; +mod cli_discovery_manage; +mod cli_offer_manage; mod cli_token; -mod discovery_manage; mod http_remote_stores; mod invalid_configuration_rejection; mod lnurl_pay_invoice_generation; mod lnurl_pay_multi_backend_invoice_generation; -mod offer_manage; mod server_lifecycle_with_graceful_shutdown; mod server_persistence; mod service_enablement; diff --git a/service/src/api/offer.rs b/service/src/api/offer.rs index b3cdcd9..3ca65b9 100644 --- a/service/src/api/offer.rs +++ b/service/src/api/offer.rs @@ -15,7 +15,12 @@ pub trait OfferStore { id: &Uuid, ) -> Result, Self::Error>; - async fn get_offers(&self, partition: &str) -> Result, Self::Error>; + async fn get_offers( + &self, + partition: &str, + start: usize, + count: usize, + ) -> Result, Self::Error>; async fn post_offer(&self, offer: OfferRecord) -> Result, Self::Error>; @@ -34,7 +39,12 @@ pub trait OfferMetadataStore { id: &Uuid, ) -> Result, Self::Error>; - async fn get_all_metadata(&self, partition: &str) -> Result, Self::Error>; + async fn get_all_metadata( + &self, + partition: &str, + start: usize, + count: usize, + ) -> Result, Self::Error>; async fn post_metadata(&self, offer: OfferMetadata) -> Result, Self::Error>; diff --git a/service/src/axum/crud/error.rs b/service/src/axum/crud/error.rs index 627e705..fe29cb6 100644 --- a/service/src/axum/crud/error.rs +++ b/service/src/axum/crud/error.rs @@ -45,6 +45,13 @@ impl CrudError { } } + pub fn bad() -> Self { + Self { + status: StatusCode::BAD_REQUEST, + headers: Default::default(), + } + } + pub fn conflict(location: HeaderValue) -> Self { Self { status: StatusCode::CONFLICT, diff --git a/service/src/components/discovery/error.rs b/service/src/components/discovery/error.rs index 19dee90..0a09ba9 100644 --- a/service/src/components/discovery/error.rs +++ b/service/src/components/discovery/error.rs @@ -40,6 +40,8 @@ pub enum DiscoveryBackendStoreErrorSource { JsonSerialization(#[from] serde_json::Error), #[error("internal error: {0}")] Internal(String), + #[error("Invalid Input error: {0}")] + InvalidInput(String), } impl DiscoveryBackendStoreError { @@ -151,6 +153,14 @@ impl DiscoveryBackendStoreError { ) } + pub fn invalid_input_error>>(context: C, message: String) -> Self { + Self::new( + DiscoveryBackendStoreErrorSource::InvalidInput(message), + ServiceErrorSource::Downstream, + context, + ) + } + pub fn context(&self) -> &str { self.context.as_ref() } diff --git a/service/src/components/discovery/http.rs b/service/src/components/discovery/http.rs index ef86b11..ef74ec7 100644 --- a/service/src/components/discovery/http.rs +++ b/service/src/components/discovery/http.rs @@ -89,6 +89,27 @@ impl HttpDiscoveryBackendStore { fn discovery_address_url(&self, addr: &DiscoveryBackendAddress) -> String { format!("{}/{}", self.discovery_url, addr.encoded()) } + + fn general_error(status: StatusCode, context: &str) -> DiscoveryBackendStoreError { + if status.is_success() { + return DiscoveryBackendStoreError::internal_error( + ServiceErrorSource::Upstream, + context.to_string(), + format!("unexpected http status {status}"), + ); + } + if status.is_client_error() { + return DiscoveryBackendStoreError::invalid_input_error( + context.to_string(), + format!("invalid input, http status: {status}"), + ); + } + DiscoveryBackendStoreError::http_status_error( + ServiceErrorSource::Upstream, + context.to_string(), + status.as_u16(), + ) + } } #[async_trait] @@ -101,10 +122,10 @@ impl DiscoveryBackendStore for HttpDiscoveryBackendStore { ) -> Result, Self::Error> { let url = self.discovery_address_url(addr); - let response = self.client.get(url).send().await.map_err(|e| { + let response = self.client.get(&url).send().await.map_err(|e| { DiscoveryBackendStoreError::http_error( ServiceErrorSource::Upstream, - format!("getting discovery backend for address {}", addr.encoded()), + format!("get backend {url}"), e, ) })?; @@ -114,18 +135,14 @@ impl DiscoveryBackendStore for HttpDiscoveryBackendStore { let backend: DiscoveryBackend = response.json().await.map_err(|e| { DiscoveryBackendStoreError::deserialization_error( ServiceErrorSource::Upstream, - format!("reading discovery backend for address {}", addr.encoded()), + format!("parse backend {url}"), e, ) })?; Ok(Some(backend)) } StatusCode::NOT_FOUND => Ok(None), - status => Err(DiscoveryBackendStoreError::http_status_error( - ServiceErrorSource::Upstream, - format!("getting discovery backend for address {}", addr.encoded()), - status.as_u16(), - )), + status => Err(Self::general_error(status, &format!("get backend {url}"))), } } @@ -134,7 +151,7 @@ impl DiscoveryBackendStore for HttpDiscoveryBackendStore { let response = self.client.get(url).send().await.map_err(|e| { DiscoveryBackendStoreError::http_error( ServiceErrorSource::Upstream, - "retrieving all discovery backends", + format!("get all backends {url}"), e, ) })?; @@ -145,16 +162,15 @@ impl DiscoveryBackendStore for HttpDiscoveryBackendStore { response.json().await.map_err(|e| { DiscoveryBackendStoreError::deserialization_error( ServiceErrorSource::Upstream, - "parsing discovery backends list", + format!("parse all backends {url}"), e, ) })?; Ok(backends_response) } - status => Err(DiscoveryBackendStoreError::http_status_error( - ServiceErrorSource::Upstream, - "retrieving all discovery backends", - status.as_u16(), + status => Err(Self::general_error( + status, + &format!("get all backends {url}"), )), } } @@ -172,24 +188,23 @@ impl DiscoveryBackendStore for HttpDiscoveryBackendStore { .map_err(|e| { DiscoveryBackendStoreError::http_error( ServiceErrorSource::Upstream, - format!("registering discovery backend {backend:?}",), + format!( + "post backend: {}, url: {}", + backend.address, &self.discovery_url + ), e, ) })?; match response.status() { - StatusCode::CREATED => { - // Successfully created - Ok(Some(backend.address)) - } - StatusCode::CONFLICT => { - // Backend already exists - Ok(None) - } - status => Err(DiscoveryBackendStoreError::http_status_error( - ServiceErrorSource::Upstream, - format!("registering discovery backend {backend:?}",), - status.as_u16(), + StatusCode::CREATED => Ok(Some(backend.address)), + StatusCode::CONFLICT => Ok(None), + status => Err(Self::general_error( + status, + &format!( + "post backend: {}, url: {}", + backend.address, &self.discovery_url + ), )), } } @@ -199,38 +214,22 @@ impl DiscoveryBackendStore for HttpDiscoveryBackendStore { let response = self .client - .put(url) - .json(&backend.backend) // PUT expects DiscoveryBackendSparse + .put(&url) + .json(&backend.backend) .send() .await .map_err(|e| { DiscoveryBackendStoreError::http_error( ServiceErrorSource::Upstream, - format!( - "updating discovery backend at address {}", - backend.address.encoded() - ), + format!("put backend {url}"), e, ) })?; match response.status() { - StatusCode::NO_CONTENT => { - // Updated existing backend - Ok(false) - } - StatusCode::CREATED => { - // Created new backend - Ok(true) - } - status => Err(DiscoveryBackendStoreError::http_status_error( - ServiceErrorSource::Upstream, - format!( - "updating discovery backend at address {}", - backend.address.encoded() - ), - status.as_u16(), - )), + StatusCode::NO_CONTENT => Ok(false), + StatusCode::CREATED => Ok(true), + status => Err(Self::general_error(status, &format!("put backend {url}"))), } } @@ -239,17 +238,14 @@ impl DiscoveryBackendStore for HttpDiscoveryBackendStore { let response = self .client - .patch(url) + .patch(&url) .json(&backend.backend) .send() .await .map_err(|e| { DiscoveryBackendStoreError::http_error( ServiceErrorSource::Upstream, - format!( - "updating discovery backend at address {}", - backend.address.encoded() - ), + format!("patch backend {url}"), e, ) })?; @@ -257,24 +253,17 @@ impl DiscoveryBackendStore for HttpDiscoveryBackendStore { match response.status() { StatusCode::NO_CONTENT => Ok(true), StatusCode::NOT_FOUND => Ok(false), - status => Err(DiscoveryBackendStoreError::http_status_error( - ServiceErrorSource::Upstream, - format!( - "patching discovery backend at address {}", - backend.address.encoded() - ), - status.as_u16(), - )), + status => Err(Self::general_error(status, &format!("patch backend {url}"))), } } async fn delete(&self, addr: &DiscoveryBackendAddress) -> Result { let url = self.discovery_address_url(addr); - let response = self.client.delete(url).send().await.map_err(|e| { + let response = self.client.delete(&url).send().await.map_err(|e| { DiscoveryBackendStoreError::http_error( ServiceErrorSource::Upstream, - format!("removing discovery backend at address {}", addr.encoded()), + format!("delete backend {url}"), e, ) })?; @@ -282,10 +271,9 @@ impl DiscoveryBackendStore for HttpDiscoveryBackendStore { match response.status() { StatusCode::NO_CONTENT => Ok(true), StatusCode::NOT_FOUND => Ok(false), - status => Err(DiscoveryBackendStoreError::http_status_error( - ServiceErrorSource::Upstream, - format!("removing discovery backend at address {}", addr.encoded()), - status.as_u16(), + status => Err(Self::general_error( + status, + &format!("delete backend: {url}"), )), } } diff --git a/service/src/components/offer/db.rs b/service/src/components/offer/db.rs index 441fb25..42d047a 100644 --- a/service/src/components/offer/db.rs +++ b/service/src/components/offer/db.rs @@ -9,7 +9,8 @@ use crate::components::offer::error::OfferStoreError; use async_trait::async_trait; use chrono::Utc; use sea_orm::{ - ColumnTrait, Database, DatabaseConnection, EntityTrait, QueryFilter, QuerySelect, Set, + ColumnTrait, Database, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect, + Set, }; use sha2::{Digest, Sha256}; use switchgear_migration::OnConflict; @@ -95,9 +96,18 @@ impl OfferStore for DbOfferStore { } } - async fn get_offers(&self, partition: &str) -> Result, Self::Error> { + async fn get_offers( + &self, + partition: &str, + start: usize, + count: usize, + ) -> Result, Self::Error> { let models = OfferRecordTable::find() .filter(offer_record_table::Column::Partition.eq(partition)) + .order_by_asc(offer_record_table::Column::CreatedAt) + .order_by_asc(offer_record_table::Column::Id) + .offset(start as u64) + .limit(count as u64) .all(&self.db) .await .map_err(|e| { @@ -344,9 +354,18 @@ impl OfferMetadataStore for DbOfferStore { } } - async fn get_all_metadata(&self, partition: &str) -> Result, Self::Error> { + async fn get_all_metadata( + &self, + partition: &str, + start: usize, + count: usize, + ) -> Result, Self::Error> { let models = OfferMetadataTable::find() .filter(offer_metadata_table::Column::Partition.eq(partition)) + .order_by_asc(offer_metadata_table::Column::CreatedAt) + .order_by_asc(offer_metadata_table::Column::Id) + .offset(start as u64) + .limit(count as u64) .all(&self.db) .await .map_err(|e| { diff --git a/service/src/components/offer/http.rs b/service/src/components/offer/http.rs index 21b2fd7..6893705 100644 --- a/service/src/components/offer/http.rs +++ b/service/src/components/offer/http.rs @@ -115,6 +115,27 @@ impl HttpOfferStore { fn metadata_partition_id_url(&self, partition: &str, id: &Uuid) -> String { format!("{}/{}", self.metadata_partition_url(partition), id) } + + fn general_error(status: StatusCode, context: &str) -> OfferStoreError { + if status.is_success() { + return OfferStoreError::internal_error( + ServiceErrorSource::Upstream, + context.to_string(), + format!("unexpected http status {status}"), + ); + } + if status.is_client_error() { + return OfferStoreError::invalid_input_error( + context.to_string(), + format!("invalid input, http status: {status}"), + ); + } + OfferStoreError::http_status_error( + ServiceErrorSource::Upstream, + context.to_string(), + status.as_u16(), + ) + } } #[async_trait] @@ -127,12 +148,8 @@ impl OfferStore for HttpOfferStore { id: &Uuid, ) -> Result, Self::Error> { let url = self.offers_partition_id_url(partition, id); - let response = self.client.get(url).send().await.map_err(|e| { - OfferStoreError::http_error( - ServiceErrorSource::Upstream, - format!("retrieving offer {id}"), - e, - ) + let response = self.client.get(&url).send().await.map_err(|e| { + OfferStoreError::http_error(ServiceErrorSource::Upstream, format!("get offer {url}"), e) })?; match response.status() { @@ -140,25 +157,31 @@ impl OfferStore for HttpOfferStore { let offer = response.json::().await.map_err(|e| { OfferStoreError::deserialization_error( ServiceErrorSource::Upstream, - format!("reading offer {id}"), + format!("parsing offer {id}"), e, ) })?; Ok(Some(offer)) } StatusCode::NOT_FOUND => Ok(None), - status => Err(OfferStoreError::http_status_error( - ServiceErrorSource::Upstream, - format!("retrieving offer {id}"), - status.as_u16(), - )), + status => Err(Self::general_error(status, &format!("get offer {url}"))), } } - async fn get_offers(&self, partition: &str) -> Result, Self::Error> { + async fn get_offers( + &self, + partition: &str, + start: usize, + count: usize, + ) -> Result, Self::Error> { let url = self.offers_partition_url(partition); - let response = self.client.get(url).send().await.map_err(|e| { - OfferStoreError::http_error(ServiceErrorSource::Upstream, "listing all offers", e) + let url = format!("{url}?start={start}&count={count}"); + let response = self.client.get(&url).send().await.map_err(|e| { + OfferStoreError::http_error( + ServiceErrorSource::Upstream, + format!("get all offers {url}"), + e, + ) })?; match response.status() { @@ -166,16 +189,15 @@ impl OfferStore for HttpOfferStore { let offer_records = response.json::>().await.map_err(|e| { OfferStoreError::deserialization_error( ServiceErrorSource::Upstream, - "parsing offers list", + format!("parsing all offers for {url}"), e, ) })?; Ok(offer_records) } - status => Err(OfferStoreError::http_status_error( - ServiceErrorSource::Upstream, - "listing all offers", - status.as_u16(), + status => Err(Self::general_error( + status, + &format!("get all offers {url}"), )), } } @@ -190,22 +212,17 @@ impl OfferStore for HttpOfferStore { .map_err(|e| { OfferStoreError::http_error( ServiceErrorSource::Upstream, - format!("creating offer {}", offer.id), + format!("post offer: {}, url: {}", offer.id, &self.offer_url), e, ) })?; match response.status() { StatusCode::CREATED => Ok(Some(offer.id)), - StatusCode::CONFLICT => Ok(None), // Already exists - StatusCode::BAD_REQUEST => Err(OfferStoreError::invalid_input_error( - format!("post offer {offer:?}"), - format!("invalid input for offer {}", offer.id), - )), - status => Err(OfferStoreError::http_status_error( - ServiceErrorSource::Upstream, - format!("creating offer {}", offer.id), - status.as_u16(), + StatusCode::CONFLICT => Ok(None), + status => Err(Self::general_error( + status, + &format!("post offer: {}, url: {}", offer.id, &self.offer_url), )), } } @@ -214,39 +231,31 @@ impl OfferStore for HttpOfferStore { let url = self.offers_partition_id_url(&offer.partition, &offer.id); let response = self .client - .put(url) + .put(&url) .json(&offer) .send() .await .map_err(|e| { OfferStoreError::http_error( ServiceErrorSource::Upstream, - format!("updating offer {}", offer.id), + format!("put offer {url}"), e, ) })?; match response.status() { - StatusCode::CREATED => Ok(true), // New resource created - StatusCode::NO_CONTENT => Ok(false), // Existing resource updated - StatusCode::BAD_REQUEST => Err(OfferStoreError::invalid_input_error( - format!("put offer {offer:?}"), - format!("invalid input for offer {}", offer.id), - )), - status => Err(OfferStoreError::http_status_error( - ServiceErrorSource::Upstream, - format!("updating offer {}", offer.id), - status.as_u16(), - )), + StatusCode::CREATED => Ok(true), + StatusCode::NO_CONTENT => Ok(false), + status => Err(Self::general_error(status, &format!("put offer {url}"))), } } async fn delete_offer(&self, partition: &str, id: &Uuid) -> Result { let url = self.offers_partition_id_url(partition, id); - let response = self.client.delete(url).send().await.map_err(|e| { + let response = self.client.delete(&url).send().await.map_err(|e| { OfferStoreError::http_error( ServiceErrorSource::Upstream, - format!("removing offer {id}"), + format!("delete offer {url}"), e, ) })?; @@ -254,11 +263,7 @@ impl OfferStore for HttpOfferStore { match response.status() { StatusCode::NO_CONTENT => Ok(true), StatusCode::NOT_FOUND => Ok(false), - status => Err(OfferStoreError::http_status_error( - ServiceErrorSource::Upstream, - format!("removing offer {id}"), - status.as_u16(), - )), + status => Err(Self::general_error(status, &format!("delete offer {url}"))), } } } @@ -273,10 +278,10 @@ impl OfferMetadataStore for HttpOfferStore { id: &Uuid, ) -> Result, Self::Error> { let url = self.metadata_partition_id_url(partition, id); - let response = self.client.get(url).send().await.map_err(|e| { + let response = self.client.get(&url).send().await.map_err(|e| { OfferStoreError::http_error( ServiceErrorSource::Upstream, - format!("retrieving offer metadata {id}"), + format!("get offer metadata {url}"), e, ) })?; @@ -286,27 +291,32 @@ impl OfferMetadataStore for HttpOfferStore { let metadata = response.json::().await.map_err(|e| { OfferStoreError::deserialization_error( ServiceErrorSource::Upstream, - format!("reading offer metadata {id}"), + format!("parse offer metadata {url}"), e, ) })?; Ok(Some(metadata)) } StatusCode::NOT_FOUND => Ok(None), - status => Err(OfferStoreError::http_status_error( - ServiceErrorSource::Upstream, - format!("retrieving offer metadata {id}"), - status.as_u16(), + status => Err(Self::general_error( + status, + &format!("get offer metadata {url}"), )), } } - async fn get_all_metadata(&self, partition: &str) -> Result, Self::Error> { + async fn get_all_metadata( + &self, + partition: &str, + start: usize, + count: usize, + ) -> Result, Self::Error> { let url = self.metadata_partition_url(partition); - let response = self.client.get(url).send().await.map_err(|e| { + let url = format!("{url}?start={start}&count={count}"); + let response = self.client.get(&url).send().await.map_err(|e| { OfferStoreError::http_error( ServiceErrorSource::Upstream, - "listing all offer metadata", + format!("get all metadata {url}"), e, ) })?; @@ -316,16 +326,15 @@ impl OfferMetadataStore for HttpOfferStore { let metadata_all = response.json::>().await.map_err(|e| { OfferStoreError::deserialization_error( ServiceErrorSource::Upstream, - "parsing offer metadata list", + format!("parse all metadata {url}"), e, ) })?; Ok(metadata_all) } - status => Err(OfferStoreError::http_status_error( - ServiceErrorSource::Upstream, - "listing all offer metadata", - status.as_u16(), + status => Err(Self::general_error( + status, + &format!("get all metadata {url}"), )), } } @@ -340,18 +349,23 @@ impl OfferMetadataStore for HttpOfferStore { .map_err(|e| { OfferStoreError::http_error( ServiceErrorSource::Upstream, - format!("creating offer metadata {}", metadata.id), + format!( + "post offer metadata {}, url: {}", + metadata.id, &self.metadata_url + ), e, ) })?; match response.status() { StatusCode::CREATED => Ok(Some(metadata.id)), - StatusCode::CONFLICT => Ok(None), // Already exists - status => Err(OfferStoreError::http_status_error( - ServiceErrorSource::Upstream, - format!("creating offer metadata {}", metadata.id), - status.as_u16(), + StatusCode::CONFLICT => Ok(None), + status => Err(Self::general_error( + status, + &format!( + "post offer metadata {}, url: {}", + metadata.id, &self.metadata_url + ), )), } } @@ -360,35 +374,34 @@ impl OfferMetadataStore for HttpOfferStore { let url = self.metadata_partition_id_url(&metadata.partition, &metadata.id); let response = self .client - .put(url) + .put(&url) .json(&metadata) .send() .await .map_err(|e| { OfferStoreError::http_error( ServiceErrorSource::Upstream, - format!("updating offer metadata {}", metadata.id), + format!("put offer metadata {url}"), e, ) })?; match response.status() { - StatusCode::CREATED => Ok(true), // New resource created - StatusCode::NO_CONTENT => Ok(false), // Existing resource updated - status => Err(OfferStoreError::http_status_error( - ServiceErrorSource::Upstream, - format!("updating offer metadata {}", metadata.id), - status.as_u16(), + StatusCode::CREATED => Ok(true), + StatusCode::NO_CONTENT => Ok(false), + status => Err(Self::general_error( + status, + &format!("put offer metadata {url}"), )), } } async fn delete_metadata(&self, partition: &str, id: &Uuid) -> Result { let url = self.metadata_partition_id_url(partition, id); - let response = self.client.delete(url).send().await.map_err(|e| { + let response = self.client.delete(&url).send().await.map_err(|e| { OfferStoreError::http_error( ServiceErrorSource::Upstream, - format!("removing offer metadata {id}"), + format!("delete offer metadata {url}"), e, ) })?; @@ -396,14 +409,9 @@ impl OfferMetadataStore for HttpOfferStore { match response.status() { StatusCode::NO_CONTENT => Ok(true), StatusCode::NOT_FOUND => Ok(false), - StatusCode::BAD_REQUEST => Err(OfferStoreError::invalid_input_error( - format!("delete metadata {partition}/{id}"), - "bad request".to_string(), - )), - status => Err(OfferStoreError::http_status_error( - ServiceErrorSource::Upstream, - format!("removing offer metadata {id}"), - status.as_u16(), + status => Err(Self::general_error( + status, + &format!("delete offer metadata {url}"), )), } } diff --git a/service/src/components/offer/memory.rs b/service/src/components/offer/memory.rs index 79766db..3473ec4 100644 --- a/service/src/components/offer/memory.rs +++ b/service/src/components/offer/memory.rs @@ -11,10 +11,22 @@ use std::sync::Arc; use tokio::sync::Mutex; use uuid::Uuid; +#[derive(Clone, Debug)] +struct OfferRecordTimestamped { + created: chrono::DateTime, + offer: OfferRecord, +} + +#[derive(Clone, Debug)] +struct OfferMetadataTimestamped { + created: chrono::DateTime, + metadata: OfferMetadata, +} + #[derive(Clone, Debug)] pub struct MemoryOfferStore { - offer: Arc>>, - metadata: Arc>>, + offer: Arc>>, + metadata: Arc>>, } impl MemoryOfferStore { @@ -42,16 +54,37 @@ impl OfferStore for MemoryOfferStore { id: &Uuid, ) -> Result, Self::Error> { let store = self.offer.lock().await; - Ok(store.get(&(partition.to_string(), *id)).cloned()) + Ok(store + .get(&(partition.to_string(), *id)) + .map(|o| o.offer.clone())) } - async fn get_offers(&self, partition: &str) -> Result, Self::Error> { + async fn get_offers( + &self, + partition: &str, + start: usize, + count: usize, + ) -> Result, Self::Error> { let store = self.offer.lock().await; - let offers: Vec = store + let mut offers: Vec = store .iter() .filter(|((p, _), _)| p == partition) .map(|(_, offer)| offer.clone()) .collect(); + + offers.sort_by(|a, b| { + a.created + .cmp(&b.created) + .then_with(|| a.offer.id.cmp(&b.offer.id)) + }); + + let offers = offers + .into_iter() + .skip(start) + .take(count) + .map(|o| o.offer) + .collect(); + Ok(offers) } @@ -72,7 +105,10 @@ impl OfferStore for MemoryOfferStore { if let std::collections::hash_map::Entry::Vacant(e) = store.entry((offer.partition.to_string(), offer.id)) { - e.insert(offer.clone()); + e.insert(OfferRecordTimestamped { + created: chrono::Utc::now(), + offer: offer.clone(), + }); Ok(Some(offer.id)) } else { Ok(None) @@ -94,7 +130,13 @@ impl OfferStore for MemoryOfferStore { } let was_new = store - .insert((offer.partition.to_string(), offer.id), offer) + .insert( + (offer.partition.to_string(), offer.id), + OfferRecordTimestamped { + created: chrono::Utc::now(), + offer, + }, + ) .is_none(); Ok(was_new) } @@ -175,35 +217,66 @@ impl OfferMetadataStore for MemoryOfferStore { id: &Uuid, ) -> Result, Self::Error> { let store = self.metadata.lock().await; - Ok(store.get(&(partition.to_string(), *id)).cloned()) + Ok(store + .get(&(partition.to_string(), *id)) + .map(|o| o.metadata.clone())) } - async fn get_all_metadata(&self, partition: &str) -> Result, Self::Error> { + async fn get_all_metadata( + &self, + partition: &str, + start: usize, + count: usize, + ) -> Result, Self::Error> { let store = self.metadata.lock().await; - let offers: Vec = store + let mut metadata: Vec = store .iter() .filter(|((p, _), _)| p == partition) .map(|(_, metadata)| metadata.clone()) .collect(); - Ok(offers) + + metadata.sort_by(|a, b| { + a.created + .cmp(&b.created) + .then_with(|| a.metadata.id.cmp(&b.metadata.id)) + }); + + let metadata = metadata + .into_iter() + .skip(start) + .take(count) + .map(|o| o.metadata) + .collect(); + + Ok(metadata) } - async fn post_metadata(&self, offer: OfferMetadata) -> Result, Self::Error> { + async fn post_metadata(&self, metadata: OfferMetadata) -> Result, Self::Error> { let mut store = self.metadata.lock().await; if let std::collections::hash_map::Entry::Vacant(e) = - store.entry((offer.partition.to_string(), offer.id)) + store.entry((metadata.partition.to_string(), metadata.id)) { - e.insert(offer.clone()); - Ok(Some(offer.id)) + e.insert(OfferMetadataTimestamped { + created: chrono::Utc::now(), + metadata: metadata.clone(), + }); + + Ok(Some(metadata.id)) } else { Ok(None) } } - async fn put_metadata(&self, offer: OfferMetadata) -> Result { + async fn put_metadata(&self, metadata: OfferMetadata) -> Result { let mut store = self.metadata.lock().await; let was_new = store - .insert((offer.partition.to_string(), offer.id), offer) + .insert( + (metadata.partition.to_string(), metadata.id), + OfferMetadataTimestamped { + created: chrono::Utc::now(), + metadata, + }, + ) .is_none(); Ok(was_new) } @@ -212,9 +285,9 @@ impl OfferMetadataStore for MemoryOfferStore { let offer_store = self.offer.lock().await; let mut metadata_store = self.metadata.lock().await; - let metadata_in_use = offer_store - .values() - .any(|offer| offer.partition == partition && offer.offer.metadata_id == *id); + let metadata_in_use = offer_store.values().any(|offer| { + offer.offer.partition == partition && offer.offer.offer.metadata_id == *id + }); if metadata_in_use { return Err(OfferStoreError::invalid_input_error( diff --git a/service/src/offer/handler.rs b/service/src/offer/handler.rs index d3ce480..7a7e3b2 100644 --- a/service/src/offer/handler.rs +++ b/service/src/offer/handler.rs @@ -7,8 +7,22 @@ use crate::axum::crud::response::JsonCrudResponse; use crate::axum::extract::uuid::UuidParam; use crate::axum::header::no_cache_headers; use crate::offer::state::OfferState; +use axum::extract::Query; use axum::http::HeaderValue; use axum::{extract::State, Json}; +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +pub struct GetAllOffersQueryParameters { + pub start: Option, + pub count: Option, +} + +#[derive(Deserialize, Debug)] +pub struct GetAllMetadataQueryParameters { + pub start: Option, + pub count: Option, +} pub struct OfferHandlers; @@ -40,15 +54,20 @@ impl OfferHandlers { pub async fn get_offers( axum::extract::Path(partition): axum::extract::Path, + Query(params): Query, State(state): State>, ) -> Result>, CrudError> where S: OfferStore, M: OfferMetadataStore, { + let count = params.count.unwrap_or(state.max_page_size()); + if count > state.max_page_size() { + return Err(CrudError::bad()); + } let offers = state .offer_store() - .get_offers(&partition) + .get_offers(&partition, params.start.unwrap_or(0), count) .await .map_err(|e| crate::crud_error_from_service!(e))?; @@ -163,15 +182,20 @@ impl OfferHandlers { pub async fn get_all_metadata( axum::extract::Path(partition): axum::extract::Path, + Query(params): Query, State(state): State>, ) -> Result>, CrudError> where S: OfferStore, M: OfferMetadataStore, { + let count = params.count.unwrap_or(state.max_page_size()); + if count > state.max_page_size() { + return Err(CrudError::bad()); + } let metadata = state .metadata_store() - .get_all_metadata(&partition) + .get_all_metadata(&partition, params.start.unwrap_or(0), count) .await .map_err(|e| crate::crud_error_from_service!(e))?; diff --git a/service/src/offer/service.rs b/service/src/offer/service.rs index baf50a9..b10d467 100644 --- a/service/src/offer/service.rs +++ b/service/src/offer/service.rs @@ -59,7 +59,7 @@ impl OfferService { mod tests { use crate::api::offer::{ OfferMetadata, OfferMetadataIdentifier, OfferMetadataImage, OfferMetadataSparse, - OfferMetadataStore, OfferRecord, OfferRecordSparse, OfferStore, + OfferMetadataStore, OfferRecord, OfferRecordRest, OfferRecordSparse, OfferStore, }; use crate::components::offer::memory::MemoryOfferStore; use crate::offer::service::OfferService; @@ -76,10 +76,6 @@ mod tests { use std::time::{SystemTime, UNIX_EPOCH}; use uuid::Uuid; - fn create_test_offer() -> OfferRecord { - create_test_offer_with_metadata_id(Uuid::new_v4()) - } - fn create_test_offer_with_metadata_id(metadata_id: Uuid) -> OfferRecord { OfferRecord { partition: "default".to_string(), @@ -109,7 +105,10 @@ mod tests { } } - async fn create_test_server_with_offer(offer: OfferRecord) -> TestServerWithAuthorization { + async fn create_test_server_with_offer( + offers: Vec, + metadata: Vec, + ) -> TestServerWithAuthorization { let mut rng = thread_rng(); let private_key = SigningKey::random(&mut rng); let public_key = *private_key.verifying_key(); @@ -136,19 +135,16 @@ mod tests { let authorization = encode(&header, &claims, &encoding_key).unwrap(); let store = MemoryOfferStore::default(); - let metadata = OfferMetadata { - id: offer.offer.metadata_id, - partition: offer.partition.clone(), - metadata: OfferMetadataSparse { - text: "Test offer".to_string(), - long_text: Some("This is a test offer description".to_string()), - image: None, - identifier: None, - }, - }; - store.put_metadata(metadata).await.unwrap(); - store.put_offer(offer).await.unwrap(); - let state = OfferState::new(store.clone(), store, decoding_key); + + for m in metadata { + store.put_metadata(m).await.unwrap(); + } + + for o in offers { + store.put_offer(o).await.unwrap(); + } + + let state = OfferState::new(store.clone(), store, decoding_key, 100); let app = OfferService::router(state); TestServerWithAuthorization { @@ -187,7 +183,7 @@ mod tests { let store = MemoryOfferStore::default(); store.put_metadata(metadata).await.unwrap(); - let state = OfferState::new(store.clone(), store, decoding_key); + let state = OfferState::new(store.clone(), store, decoding_key, 100); let app = OfferService::router(state); TestServerWithAuthorization { @@ -223,7 +219,7 @@ mod tests { let authorization = encode(&header, &claims, &encoding_key).unwrap(); let store = MemoryOfferStore::default(); - let state = OfferState::new(store.clone(), store, decoding_key); + let state = OfferState::new(store.clone(), store, decoding_key, 100); let app = OfferService::router(state); TestServerWithAuthorization { @@ -251,9 +247,10 @@ mod tests { #[tokio::test] async fn delete_offer_when_exists_then_removes_and_second_delete_not_found() { - let test_offer = create_test_offer(); + let test_metadata = create_test_metadata(); + let test_offer = create_test_offer_with_metadata_id(test_metadata.id); let offer_id = test_offer.id; - let server = create_test_server_with_offer(test_offer).await; + let server = create_test_server_with_offer(vec![test_offer], vec![test_metadata]).await; let response = server .server .delete(&format!("/offers/default/{offer_id}")) @@ -286,9 +283,12 @@ mod tests { #[tokio::test] async fn get_offer_when_exists_then_returns_resource() { - let test_offer = create_test_offer(); + let test_metadata = create_test_metadata(); + let test_offer = create_test_offer_with_metadata_id(test_metadata.id); let offer_id = test_offer.id; - let server = create_test_server_with_offer(test_offer.clone()).await; + + let server = + create_test_server_with_offer(vec![test_offer.clone()], vec![test_metadata]).await; let response = server .server .get(&format!("/offers/default/{offer_id}")) @@ -345,17 +345,65 @@ mod tests { #[tokio::test] async fn get_offers_when_exists_then_returns_list() { - let test_offer = create_test_offer(); - let server = create_test_server_with_offer(test_offer).await; + let test_metadata = create_test_metadata(); + let metadata_id = test_metadata.id; + + let mut expected_offers = Vec::new(); + for i in 0..10 { + let mut offer = create_test_offer_with_metadata_id(metadata_id); + offer.id = Uuid::from_u128(i as u128); + expected_offers.push(offer); + } + + let server = + create_test_server_with_offer(expected_offers.clone(), vec![test_metadata]).await; + let response = server .server .get("/offers/default") .authorization_bearer(server.authorization.clone()) .await; + assert_eq!(response.status_code(), StatusCode::OK); + let all_offers: Vec = response.json(); + let all_offers: Vec = all_offers.into_iter().map(|r| r.offer).collect(); + assert_eq!(all_offers.as_slice(), expected_offers.as_slice()); + let response = server + .server + .get("/offers/default?start=1") + .authorization_bearer(server.authorization.clone()) + .await; assert_eq!(response.status_code(), StatusCode::OK); - let offers: Vec = response.json(); - assert_eq!(offers.len(), 1); + let next_nine: Vec = response.json(); + let next_nine: Vec = next_nine.into_iter().map(|r| r.offer).collect(); + assert_eq!(next_nine.as_slice(), &expected_offers[1..]); + + let response = server + .server + .get("/offers/default?count=1") + .authorization_bearer(server.authorization.clone()) + .await; + assert_eq!(response.status_code(), StatusCode::OK); + let first: Vec = response.json(); + let first: Vec = first.into_iter().map(|r| r.offer).collect(); + assert_eq!(first.as_slice(), &expected_offers[0..1]); + + let response = server + .server + .get("/offers/default?start=3&count=4") + .authorization_bearer(server.authorization.clone()) + .await; + assert_eq!(response.status_code(), StatusCode::OK); + let middle_offers: Vec = response.json(); + let middle_offers: Vec = middle_offers.into_iter().map(|r| r.offer).collect(); + assert_eq!(middle_offers.as_slice(), &expected_offers[3..7]); + + let response = server + .server + .get("/offers/default?count=101") + .authorization_bearer(server.authorization.clone()) + .await; + assert_eq!(response.status_code(), StatusCode::BAD_REQUEST); } #[tokio::test] @@ -417,9 +465,11 @@ mod tests { #[tokio::test] async fn put_offer_when_exists_then_updates_no_content() { - let test_offer = create_test_offer(); + let test_metadata = create_test_metadata(); + let test_offer = create_test_offer_with_metadata_id(test_metadata.id); let offer_id = test_offer.id; - let server = create_test_server_with_offer(test_offer.clone()).await; + let server = + create_test_server_with_offer(vec![test_offer.clone()], vec![test_metadata]).await; let mut updated_offer = test_offer.offer.clone(); updated_offer.max_sendable = 2000000; @@ -644,17 +694,65 @@ mod tests { #[tokio::test] async fn get_all_metadata_when_exists_then_returns_list() { - let test_metadata = create_test_metadata(); - let server = create_test_server_with_metadata(test_metadata).await; + use crate::api::offer::OfferMetadataRest; + + let mut expected_metadata = Vec::new(); + for i in 0..10 { + let mut metadata = create_test_metadata(); + metadata.id = Uuid::from_u128(i as u128); + expected_metadata.push(metadata); + } + + let server = create_test_server_with_offer(vec![], expected_metadata.clone()).await; + let response = server .server .get("/metadata/default") .authorization_bearer(server.authorization.clone()) .await; + assert_eq!(response.status_code(), StatusCode::OK); + let all_metadata: Vec = response.json(); + let all_metadata: Vec = + all_metadata.into_iter().map(|r| r.metadata).collect(); + assert_eq!(all_metadata.as_slice(), expected_metadata.as_slice()); + let response = server + .server + .get("/metadata/default?start=1") + .authorization_bearer(server.authorization.clone()) + .await; assert_eq!(response.status_code(), StatusCode::OK); - let metadata: Vec = response.json(); - assert_eq!(metadata.len(), 1); + let next_nine: Vec = response.json(); + let next_nine: Vec = next_nine.into_iter().map(|r| r.metadata).collect(); + assert_eq!(next_nine.as_slice(), &expected_metadata[1..]); + + let response = server + .server + .get("/metadata/default?count=1") + .authorization_bearer(server.authorization.clone()) + .await; + assert_eq!(response.status_code(), StatusCode::OK); + let first: Vec = response.json(); + let first: Vec = first.into_iter().map(|r| r.metadata).collect(); + assert_eq!(first.as_slice(), &expected_metadata[0..1]); + + let response = server + .server + .get("/metadata/default?start=3&count=4") + .authorization_bearer(server.authorization.clone()) + .await; + assert_eq!(response.status_code(), StatusCode::OK); + let middle_metadata: Vec = response.json(); + let middle_metadata: Vec = + middle_metadata.into_iter().map(|r| r.metadata).collect(); + assert_eq!(middle_metadata.as_slice(), &expected_metadata[3..7]); + + let response = server + .server + .get("/metadata/default?count=101") + .authorization_bearer(server.authorization.clone()) + .await; + assert_eq!(response.status_code(), StatusCode::BAD_REQUEST); } #[tokio::test] @@ -749,7 +847,8 @@ mod tests { #[tokio::test] async fn unauthorized() { let server = create_empty_test_server(); - let test_offer = create_test_offer(); + let test_metadata = create_test_metadata(); + let test_offer = create_test_offer_with_metadata_id(test_metadata.id); let response = server.server.post("/offers").json(&test_offer).await; diff --git a/service/src/offer/state.rs b/service/src/offer/state.rs index 93425e3..2d879e2 100644 --- a/service/src/offer/state.rs +++ b/service/src/offer/state.rs @@ -6,6 +6,7 @@ pub struct OfferState { offer_store: S, metadata_store: M, auth_authority: DecodingKey, + max_page_size: usize, } impl OfferState @@ -13,11 +14,17 @@ where S: OfferStore, M: OfferMetadataStore, { - pub fn new(offer_store: S, metadata_store: M, auth_authority: DecodingKey) -> Self { + pub fn new( + offer_store: S, + metadata_store: M, + auth_authority: DecodingKey, + max_page_size: usize, + ) -> Self { Self { offer_store, metadata_store, auth_authority, + max_page_size, } } @@ -32,4 +39,8 @@ where pub fn auth_authority(&self) -> &DecodingKey { &self.auth_authority } + + pub fn max_page_size(&self) -> usize { + self.max_page_size + } } diff --git a/service/tests/common/offer.rs b/service/tests/common/offer.rs index ccc1e1e..350e602 100644 --- a/service/tests/common/offer.rs +++ b/service/tests/common/offer.rs @@ -1,6 +1,5 @@ -use chrono::Utc; +use chrono::{Timelike, Utc}; use sha2::Digest; -use std::collections::HashSet; use switchgear_service::api::lnurl::LnUrlOfferMetadata; use switchgear_service::api::offer::{ OfferMetadata, OfferMetadataIdentifier, OfferMetadataImage, OfferMetadataSparse, @@ -12,6 +11,12 @@ use uuid::Uuid; // Test data generators pub fn create_test_offer_with_existing_metadata(id: Uuid, metadata_id: Uuid) -> OfferRecord { + // Truncate timestamps to second precision to match MySQL's TIMESTAMP behavior + let now = Utc::now().with_nanosecond(0).unwrap(); + let expires = (now + chrono::Duration::seconds(3600)) + .with_nanosecond(0) + .unwrap(); + OfferRecord { partition: "default".to_string(), id, @@ -19,8 +24,8 @@ pub fn create_test_offer_with_existing_metadata(id: Uuid, metadata_id: Uuid) -> max_sendable: 1000, min_sendable: 100, metadata_id, - timestamp: Utc::now(), - expires: Some(Utc::now() + chrono::Duration::seconds(3600)), + timestamp: now, + expires: Some(expires), }, } } @@ -191,25 +196,38 @@ where ::Error: std::fmt::Debug, ::Error: std::fmt::Debug, { - let offer1_id = Uuid::new_v4(); - let offer2_id = Uuid::new_v4(); - let offer3_id = Uuid::new_v4(); + let mut expected_offers = Vec::new(); + + for i in 0..10 { + let offer_id = Uuid::from_u128(i as u128); + let (offer, _metadata) = create_test_offer_with_metadata(&store, offer_id).await; + store.put_offer(offer.clone()).await.unwrap(); + expected_offers.push(offer); + } + + let all_offers = store.get_offers("default", 0, 100).await.unwrap(); + assert_eq!(all_offers.as_slice(), expected_offers.as_slice()); + + let first_five = store.get_offers("default", 0, 5).await.unwrap(); + assert_eq!(first_five.as_slice(), &expected_offers[0..5]); - let (offer1, _metadata1) = create_test_offer_with_metadata(&store, offer1_id).await; - let (offer2, _metadata2) = create_test_offer_with_metadata(&store, offer2_id).await; - let (offer3, _metadata3) = create_test_offer_with_metadata(&store, offer3_id).await; + let next_five = store.get_offers("default", 5, 5).await.unwrap(); + assert_eq!(next_five.as_slice(), &expected_offers[5..10]); - store.put_offer(offer1.clone()).await.unwrap(); - store.put_offer(offer2.clone()).await.unwrap(); - store.put_offer(offer3.clone()).await.unwrap(); + let middle_offers = store.get_offers("default", 3, 4).await.unwrap(); + assert_eq!(middle_offers.as_slice(), &expected_offers[3..7]); - let all_offers = store.get_offers("default").await.unwrap(); - assert_eq!(all_offers.len(), 3); + let last_offers = store.get_offers("default", 8, 10).await.unwrap(); + assert_eq!(last_offers.as_slice(), &expected_offers[8..10]); - let ids: HashSet = all_offers.iter().map(|o| o.id).collect(); - assert!(ids.contains(&offer1.id)); - assert!(ids.contains(&offer2.id)); - assert!(ids.contains(&offer3.id)); + let beyond_offers = store.get_offers("default", 15, 5).await.unwrap(); + assert_eq!(beyond_offers.len(), 0); + + let zero_count = store.get_offers("default", 0, 0).await.unwrap(); + assert_eq!(zero_count.len(), 0); + + let single_offer = store.get_offers("default", 5, 1).await.unwrap(); + assert_eq!(single_offer.as_slice(), &expected_offers[5..6]); } // OfferMetadataStore tests @@ -324,21 +342,38 @@ where S: OfferMetadataStore, ::Error: std::fmt::Debug, { - let metadata1 = create_test_offer_metadata(Uuid::new_v4()); - let metadata2 = create_test_offer_metadata(Uuid::new_v4()); - let metadata3 = create_test_offer_metadata(Uuid::new_v4()); + let mut expected_metadata = Vec::new(); + + for i in 0..10 { + let metadata_id = Uuid::from_u128(i as u128); + let metadata = create_test_offer_metadata(metadata_id); + store.put_metadata(metadata.clone()).await.unwrap(); + expected_metadata.push(metadata); + } + + let all_metadata = store.get_all_metadata("default", 0, 100).await.unwrap(); + assert_eq!(all_metadata.as_slice(), expected_metadata.as_slice()); + + let first_five = store.get_all_metadata("default", 0, 5).await.unwrap(); + assert_eq!(first_five.as_slice(), &expected_metadata[0..5]); + + let next_five = store.get_all_metadata("default", 5, 5).await.unwrap(); + assert_eq!(next_five.as_slice(), &expected_metadata[5..10]); + + let middle_metadata = store.get_all_metadata("default", 3, 4).await.unwrap(); + assert_eq!(middle_metadata.as_slice(), &expected_metadata[3..7]); + + let last_metadata = store.get_all_metadata("default", 8, 10).await.unwrap(); + assert_eq!(last_metadata.as_slice(), &expected_metadata[8..10]); - store.put_metadata(metadata1.clone()).await.unwrap(); - store.put_metadata(metadata2.clone()).await.unwrap(); - store.put_metadata(metadata3.clone()).await.unwrap(); + let beyond_metadata = store.get_all_metadata("default", 15, 5).await.unwrap(); + assert_eq!(beyond_metadata.len(), 0); - let all_metadata = store.get_all_metadata("default").await.unwrap(); - assert_eq!(all_metadata.len(), 3); + let zero_count = store.get_all_metadata("default", 0, 0).await.unwrap(); + assert_eq!(zero_count.len(), 0); - let ids: HashSet = all_metadata.iter().map(|m| m.id).collect(); - assert!(ids.contains(&metadata1.id)); - assert!(ids.contains(&metadata2.id)); - assert!(ids.contains(&metadata3.id)); + let single_metadata = store.get_all_metadata("default", 5, 1).await.unwrap(); + assert_eq!(single_metadata.as_slice(), &expected_metadata[5..6]); } // OfferProvider tests diff --git a/service/tests/common/service.rs b/service/tests/common/service.rs index e5b5329..9707218 100644 --- a/service/tests/common/service.rs +++ b/service/tests/common/service.rs @@ -80,7 +80,8 @@ impl TestService { // Create OfferState with MemoryOfferStore for both stores let offer_store = MemoryOfferStore::default(); - let offer_state = OfferState::new(offer_store.clone(), offer_store, offer_decoding_key); + let offer_state = + OfferState::new(offer_store.clone(), offer_store, offer_decoding_key, 100); // Create listeners let discovery_listener = diff --git a/switchgear/README.md b/switchgear/README.md index e214f45..68f4fde 100644 --- a/switchgear/README.md +++ b/switchgear/README.md @@ -40,7 +40,7 @@ Switchgear is in **ALPHA** status: * Integration tests are complete * APIs may change without warning -See [ROADMAP.md](./ROADMAP.md) for the Switchgear release roadmap. +See [ROADMAP.md](https://github.com/bitshock-src/switchgear/blob/HEAD/ROADMAP.md) for the Switchgear release roadmap. ## Why Bitcoin Lightning Payments Fail @@ -171,7 +171,7 @@ docker run bitshock/switchgear {cli-options} All service configuration is controlled by a yaml file passed to the server at startup. -See [server/config](./server/config) directory for more configuration examples. +See [server/config](https://github.com/bitshock-src/switchgear/blob/HEAD/server/config) directory for more configuration examples. Each service has a root entry the configuration file: @@ -302,7 +302,7 @@ Switchgear partitions have predictable URLs. Use partitions and a global Applica ## LNURL Service -The OpenAPI LNURL Service specification: [doc/lnurl-service-openapi.yaml](./doc/lnurl-service-openapi.yaml). +The OpenAPI LNURL Service specification: [doc/lnurl-service-openapi.yaml](https://github.com/bitshock-src/switchgear/blob/HEAD/doc/lnurl-service-openapi.yaml). The LNURL Service is public facing, and implements the [LNURL LUD-06 specification.](https://github.com/lnurl/luds/blob/luds/06.md) @@ -341,7 +341,7 @@ The QR image is in PNG format. ### LNURL Service Configuration -See [server/config](./server/config) directory for more configuration examples. +See [server/config](https://github.com/bitshock-src/switchgear/blob/HEAD/server/config) directory for more configuration examples. ```yaml lnurl-service: @@ -426,7 +426,7 @@ Consistent uses the optional LNURL `comment` query parameter as a hash key, whic ## Discovery Service -The OpenAPI Discovery Service specification: [doc/discovery-service-openapi.yaml](./doc/discovery-service-openapi.yaml). +The OpenAPI Discovery Service specification: [doc/discovery-service-openapi.yaml](https://github.com/bitshock-src/switchgear/blob/HEAD/doc/discovery-service-openapi.yaml). The Discovery Service is an administrative service used to manage connections to individual Lightning Nodes. @@ -436,7 +436,7 @@ See the [Manage Lightning Node Backends with Discovery Service](#manage-lightnin ### Discovery Service Configuration -See [server/config](./server/config) directory for more configuration examples. +See [server/config](https://github.com/bitshock-src/switchgear/blob/HEAD/server/config) directory for more configuration examples. ```yaml discovery-service: @@ -472,7 +472,7 @@ swgr discovery token mint --key discovery-private.pem --output discovery.token ## Offer Service -The OpenAPI Offer Service specification: [doc/offer-service-openapi.yaml](./doc/offer-service-openapi.yaml). +The OpenAPI Offer Service specification: [doc/offer-service-openapi.yaml](https://github.com/bitshock-src/switchgear/blob/HEAD/doc/offer-service-openapi.yaml). The Offer Service is an administrative service used to manage Offers, which are used to generate LNURLs. @@ -482,7 +482,7 @@ See the [Manage LNURLs with Offer Service](#manage-lnurls-with-offer-service) se ### Offer Service Configuration -See [server/config](./server/config) directory for more configuration examples. +See [server/config](https://github.com/bitshock-src/switchgear/blob/HEAD/server/config) directory for more configuration examples. ```yaml offer-service: @@ -499,6 +499,9 @@ offer-service: cert-path: "/etc/ssl/certs/offer-cert.pem" # Path to TLS private key file key-path: "/etc/ssl/certs/offer-key.pem" + + # max page size for get all queries + max-page-size: 100 ``` #### Authentication Setup @@ -859,7 +862,7 @@ swgr discovery delete pk/0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f28 ### Discovery Data Model -Discovery OpenAPI schema: [doc/discovery-service-openapi.yaml](./doc/discovery-service-openapi.yaml). +Discovery OpenAPI schema: [doc/discovery-service-openapi.yaml](https://github.com/bitshock-src/switchgear/blob/HEAD/doc/discovery-service-openapi.yaml). Example CLN backend configuration: ```json @@ -1142,7 +1145,7 @@ swgr offer metadata delete default 88deff7e-ca45-4144-8fca-286a5a18fb1a ### Offer Data Model -Offer OpenAPI schema: [doc/offer-service-openapi.yaml](./doc/offer-service-openapi.yaml). +Offer OpenAPI schema: [doc/offer-service-openapi.yaml](https://github.com/bitshock-src/switchgear/blob/HEAD/doc/offer-service-openapi.yaml). Example offer configuration: ```json @@ -1189,21 +1192,21 @@ Metadata identifiers can be: ### Service -The [switchgear-service](./service) crate defines all services and their trait dependencies. See the `api` module for trait definitions and data models: [service/src/api](./service/src/api) +The [switchgear-service](https://github.com/bitshock-src/switchgear/blob/HEAD/service) crate defines all services and their trait dependencies. See the `api` module for trait definitions and data models: [service/src/api](https://github.com/bitshock-src/switchgear/blob/HEAD/service/src/api) ![image](https://raw.githubusercontent.com/bitshock-src/switchgear/main/doc/service_traits_component_diagram-Service_Layer_Trait_Relationships.png) ### Pingora -`PingoraLnBalancer` is the default `LnBalancer` implementation. The [switchgear-pingora](./pingora) crate holds the complete implementation, plus trait definitions it uses for itself. +`PingoraLnBalancer` is the default `LnBalancer` implementation. The [switchgear-pingora](https://github.com/bitshock-src/switchgear/blob/HEAD/pingora) crate holds the complete implementation, plus trait definitions it uses for itself. ![image](https://raw.githubusercontent.com/bitshock-src/switchgear/main/doc/pingora_traits_component_diagram-PingoraLnBalancer_Trait_Dependencies.png) ### Components -The `components` module in [switchgear-service](./service/src/components) is a collection self-defined traits and implementations useful for implementing a complete `LnBalancer`. The module also holds different implementations of `DiscoveryBackendStore`, `OfferStore` and `OfferMetadataStore`. +The `components` module in [switchgear-service](https://github.com/bitshock-src/switchgear/blob/HEAD/service/src/components) is a collection self-defined traits and implementations useful for implementing a complete `LnBalancer`. The module also holds different implementations of `DiscoveryBackendStore`, `OfferStore` and `OfferMetadataStore`. #### Service Components