From 3bda215c367902860d1d0637463b88ba67464188 Mon Sep 17 00:00:00 2001 From: Stefano Cunego <93382903+kukkok3@users.noreply.github.com> Date: Fri, 22 Mar 2024 10:16:46 +0100 Subject: [PATCH 01/35] feat: bump ci to 2.9.9 (#327) --- catalyst-gateway/Earthfile | 2 +- catalyst-gateway/event-db/Earthfile | 10 +++++----- docs/Earthfile | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/catalyst-gateway/Earthfile b/catalyst-gateway/Earthfile index 10dc76bae..4a1390609 100644 --- a/catalyst-gateway/Earthfile +++ b/catalyst-gateway/Earthfile @@ -4,7 +4,7 @@ VERSION --try --global-cache 0.7 # Set up our target toolchains, and copy our files. builder: - DO github.com/input-output-hk/catalyst-ci/earthly/rust:v2.9.8+SETUP + DO github.com/input-output-hk/catalyst-ci/earthly/rust:v2.9.9+SETUP COPY --dir .cargo .config Cargo.* clippy.toml deny.toml rustfmt.toml bin crates tests . COPY --dir ./event-db/queries ./event-db/queries diff --git a/catalyst-gateway/event-db/Earthfile b/catalyst-gateway/event-db/Earthfile index 208ca7c2d..709374025 100644 --- a/catalyst-gateway/event-db/Earthfile +++ b/catalyst-gateway/event-db/Earthfile @@ -9,7 +9,7 @@ VERSION 0.7 # Internal: builder is our Event db builder target. Prepares all necessary artifacts. # CI target : dependency builder: - DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.9.8+BUILDER \ + DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.9.9+BUILDER \ --sqlfluff_cfg=./../../+repo-config/repo/.sqlfluff COPY ./../../+repo-config/repo/.sqlfluff . @@ -24,7 +24,7 @@ builder: check: FROM +builder - DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.9.8+CHECK + DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.9.9+CHECK # format all SQL files in the current project. Local developers tool. @@ -32,15 +32,15 @@ check: format: LOCALLY - DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.9.8+FORMAT --src=$(echo ${PWD}/../../) + DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.9.9+FORMAT --src=$(echo ${PWD}/../../) # build - an event db docker image. # CI target : true build: FROM +builder - DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.9.8+BUILD --image_name=event-db - DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.9.8+DOCS --image_name=event-db + DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.9.9+BUILD --image_name=event-db + DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.9.9+DOCS --image_name=event-db # test the event db database schema # CI target : true diff --git a/docs/Earthfile b/docs/Earthfile index 8e8f5ec78..746da7448 100644 --- a/docs/Earthfile +++ b/docs/Earthfile @@ -6,7 +6,7 @@ VERSION 0.7 # Copy all the source we need to build the docs src: # Common src setup - DO github.com/input-output-hk/catalyst-ci/earthly/docs:v2.9.8+SRC + DO github.com/input-output-hk/catalyst-ci/earthly/docs:v2.9.9+SRC # Now copy into that any artifacts we pull from the builds. COPY --dir ../+repo-docs/repo /docs/includes @@ -21,12 +21,12 @@ src: docs: FROM +src - DO github.com/input-output-hk/catalyst-ci/earthly/docs:v2.9.8+BUILD + DO github.com/input-output-hk/catalyst-ci/earthly/docs:v2.9.9+BUILD # Make a locally runable container that can serve the docs. local: # Build a self contained service to show built docs locally. - DO github.com/input-output-hk/catalyst-ci/earthly/docs:v2.9.8+PACKAGE + DO github.com/input-output-hk/catalyst-ci/earthly/docs:v2.9.9+PACKAGE # Copy the static pages into the container COPY +docs/ /usr/share/nginx/html From 9c134d78692a555bcf669dc7045900b46e7adda4 Mon Sep 17 00:00:00 2001 From: Lucio Baglione Date: Fri, 22 Mar 2024 14:05:29 +0100 Subject: [PATCH 02/35] feat: Add ResponsiveBuilder widget. (#331) * feat: Add `ResponsiveBuilder` widget. * test: Add tests for `Responsive Builder`. * chore: Remove unused comment. * docs: Specify the `Object` constrain. --- .../responsive_breakpoint_key.dart | 16 +++ .../responsive_builder.dart | 96 +++++++++++++++++ .../test/src/responsive_builder_test.dart | 101 ++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive_builder/responsive_breakpoint_key.dart create mode 100644 catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive_builder/responsive_builder.dart create mode 100644 catalyst_voices/packages/catalyst_voices_shared/test/src/responsive_builder_test.dart diff --git a/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive_builder/responsive_breakpoint_key.dart b/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive_builder/responsive_breakpoint_key.dart new file mode 100644 index 000000000..54f2b18a7 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive_builder/responsive_breakpoint_key.dart @@ -0,0 +1,16 @@ +/// [ResponsiveBreakpointKey] is enum representing the responsive breakpoints. +/// +/// The responsive breakpoints are used to define different screen sizes +/// for responsive design. The available keys are: +/// - `xs`: Extra small screens: 0 - 599 +/// - `sm`: Small screens: 600 - 959 +/// - `md`: Medium screens: 1240 - 1439 +/// - `lg`: Large screens: 1440 - 2048 +/// - `other`: Other screen sizes not covered by the above keys. +enum ResponsiveBreakpointKey { + xs, + sm, + md, + lg, + other, +} diff --git a/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive_builder/responsive_builder.dart b/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive_builder/responsive_builder.dart new file mode 100644 index 000000000..8aad594e3 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive_builder/responsive_builder.dart @@ -0,0 +1,96 @@ +import 'package:catalyst_voices_shared/src/responsive_builder/responsive_breakpoint_key.dart'; +import 'package:flutter/widgets.dart'; + +// A [ResponsiveBuilder] is a StatelessWidget that is aware about the current +// device breakpoint. +// +// This is an abstract widget that has a required argument [builder] that can +// consume breakpoint-specific data automatically based on breakpoint that is +// detected. +// +// The breakpoint is identified by using the screen width exposed by MediaQuery +// of the context. +// +// The widget accepts an argument for each breakpoint defined in +// [ResponsiveBreakpointKey]. The breakpoint specific [data] is selected when: +// - the breakpoint is detected +// - the breakpoint-specific data argument is present +// As a fallback the widget uses the [other] argument. +// The type of the breakpoint-specific data is generic but constrained to Object +// in order to prevent the use of `dynamic` that can cause run-time crashes. +// +// Example to render a specific string based on the breakpoints: +// +// ```dart +// ResponsiveBuilder( +// xs: 'Extra small device', +// sm: 'Small device', +// md: 'Medium device', +// lg: 'Large device', +// other: 'Fallback device', +// builder: (context, title) => Title(title!), +// ); +// +// or to have a specific padding: +// +// ```dart +// ResponsiveBuilder( +// xs: EdgeInsets.all(4.0), +// other: EdgeInsets.all(10.0), +// builder: (context, padding) => Padding( +// padding: padding, +// child: Text('This is an example.') +// ), +// ); +// ``` + +class ResponsiveBuilder extends StatelessWidget { + final Widget Function(BuildContext context, T? data) builder; + final Map _responsiveData; + + ResponsiveBuilder({ + super.key, + required this.builder, + T? xs, + T? sm, + T? md, + T? lg, + required T other, + }) : _responsiveData = { + ResponsiveBreakpointKey.xs: xs, + ResponsiveBreakpointKey.sm: sm, + ResponsiveBreakpointKey.md: md, + ResponsiveBreakpointKey.lg: lg, + ResponsiveBreakpointKey.other: other, + }; + + @override + Widget build(BuildContext context) { + return builder(context, _getResponsiveBreakpoint(context)); + } + + T _getResponsiveBreakpoint(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + + final breakpointKey = _breakpoints.entries + .firstWhere( + (entry) => ( + screenWidth >= entry.value.min && + screenWidth <= entry.value.max && + _responsiveData[entry.key] != null + ), + orElse: () => const MapEntry( + ResponsiveBreakpointKey.other, (min: 0, max: 0), + ), + ) + .key; + return _responsiveData[breakpointKey]!; + } + + final Map _breakpoints = { + ResponsiveBreakpointKey.xs: (min: 0, max: 599), + ResponsiveBreakpointKey.sm: (min: 600, max: 1239), + ResponsiveBreakpointKey.md: (min: 1240, max: 1439), + ResponsiveBreakpointKey.lg: (min: 1440, max: 2048), + }; +} diff --git a/catalyst_voices/packages/catalyst_voices_shared/test/src/responsive_builder_test.dart b/catalyst_voices/packages/catalyst_voices_shared/test/src/responsive_builder_test.dart new file mode 100644 index 000000000..89ce59542 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_shared/test/src/responsive_builder_test.dart @@ -0,0 +1,101 @@ +import 'package:catalyst_voices_shared/src/responsive_builder/responsive_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildApp( + Size size, + Widget child, + ) => MediaQuery( + data: MediaQueryData(size: size), + child: MaterialApp( + home: Scaffold( + body: child, + ), + ), + ); + + group('Test screen sizes with Text child', () { + final sizesToTest = { + 280.0: 'Xs device', + 620.0: 'Small device', + 1280.0: 'Medium device', + 1600.0: 'Large device', + 3000.0: 'Other device', + }; + + for (final entry in sizesToTest.entries) { + testWidgets( + 'ResponsiveBuilder adapts to screen of width $entry.key', + (tester) async { + await tester.pumpWidget( + buildApp( + Size.fromWidth(entry.key), + ResponsiveBuilder( + xs: 'Xs device', + sm: 'Small device', + md: 'Medium device', + lg: 'Large device', + other: 'Other device', + builder: (context, data) => Text(data!), + ), + ), + ); + + final testedElement = find.byType(Text); + // Verify the Widget renders properly + expect(testedElement, findsOneWidget); + // Verify the proper text is rendered + expect(find.text(entry.value), findsOneWidget); + } + ); + } + }); + + group('Test screen sizes with specific Padding', () { + final sizesToTest = { + 280.0: const EdgeInsets.all(2), + 620.0: const EdgeInsets.all(4), + 1280.0: const EdgeInsets.all(8), + 1600.0: const EdgeInsets.all(16), + 3000.0: const EdgeInsets.all(32), + }; + + for (final entry in sizesToTest.entries) { + testWidgets( + 'ResponsiveBuilder adapts to screen of width $entry.key', + (tester) async { + await tester.pumpWidget( + buildApp( + Size.fromWidth(entry.key), + ResponsiveBuilder( + xs: const EdgeInsets.all(2), + sm: const EdgeInsets.all(4), + md: const EdgeInsets.all(8), + lg: const EdgeInsets.all(16), + other: const EdgeInsets.all(32), + builder: (context, padding) => Padding( + padding: padding!, + child: const Text('Test') + ), + ), + ), + ); + + final testedElement = find.byType(Text); + // Verify the Widget renders properly + expect(testedElement, findsOneWidget); + + final paddingWidget = tester.widget( + find.ancestor( + of: testedElement, + matching: find.byType(Padding), + ), + ); + // Check that the padding corresponds + expect(paddingWidget.padding, entry.value); + } + ); + } + }); +} From 428fdc9c7597a2b72b2112f121c3a51d33866a91 Mon Sep 17 00:00:00 2001 From: Alex Pozhylenkov Date: Fri, 22 Mar 2024 16:08:48 +0200 Subject: [PATCH 03/35] feat: Total utxo staked ada amount (#325) * add select_total_utxo_amount.sql query * add total_utxo_amount query * refactor * wip * fix * fix * add /staked_ada/:stake_address impl * add cardano address type * wip * add network type validation * wip * filter out orphan txs * fix * fix spelling * fix * fix * fix * add regex pattern * fix * fix * fix spelling * add prefix checks * deprecate endpoint to pass the Schemathesis tests * wip * fix * disable some lints --- .config/dictionaries/project.dic | 1 + catalyst-gateway/Cargo.toml | 14 -- catalyst-gateway/bin/src/event_db/follower.rs | 19 +- catalyst-gateway/bin/src/event_db/utxo.rs | 52 ++++- .../bin/src/event_db/voter_registration.rs | 27 +-- catalyst-gateway/bin/src/follower.rs | 7 +- catalyst-gateway/bin/src/service/api/mod.rs | 5 +- .../bin/src/service/api/utxo/mod.rs | 69 ++++++ .../src/service/api/utxo/staked_ada_get.rs | 109 ++++++++++ .../service/common/objects/cardano_address.rs | 83 +++++++ .../bin/src/service/common/objects/mod.rs | 3 + .../bin/src/service/common/objects/network.rs | 27 +++ .../service/common/objects/stake_amount.rs | 37 ++++ .../src/service/common/responses/resp_4xx.rs | 7 + .../bin/src/service/common/tags.rs | 2 + catalyst-gateway/bin/src/state/mod.rs | 1 - .../event-db/migrations/V6__registration.sql | 2 +- .../insert_txn_index.sql} | 0 .../insert_utxo.sql} | 0 .../queries/utxo/select_total_utxo_amount.sql | 26 +++ catalyst-gateway/tests/.spectral.yml | 202 +++++++++--------- catalyst-gateway/tests/Earthfile | 2 +- .../tests/schema-mismatch/Earthfile | 2 +- 23 files changed, 544 insertions(+), 153 deletions(-) create mode 100644 catalyst-gateway/bin/src/service/api/utxo/mod.rs create mode 100644 catalyst-gateway/bin/src/service/api/utxo/staked_ada_get.rs create mode 100644 catalyst-gateway/bin/src/service/common/objects/cardano_address.rs create mode 100644 catalyst-gateway/bin/src/service/common/objects/network.rs create mode 100644 catalyst-gateway/bin/src/service/common/objects/stake_amount.rs rename catalyst-gateway/event-db/queries/{follower/utxo_txn_index.sql => utxo/insert_txn_index.sql} (100%) rename catalyst-gateway/event-db/queries/{follower/utxo_index_utxo_query.sql => utxo/insert_utxo.sql} (100%) create mode 100644 catalyst-gateway/event-db/queries/utxo/select_total_utxo_amount.sql diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index 98fba837c..6f0c9e4a5 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -9,6 +9,7 @@ Arbritrary asyncio asyncpg auditability +bech bkioshn bluefireteam BROTLI diff --git a/catalyst-gateway/Cargo.toml b/catalyst-gateway/Cargo.toml index 4f43e1300..6b88e2807 100644 --- a/catalyst-gateway/Cargo.toml +++ b/catalyst-gateway/Cargo.toml @@ -16,19 +16,14 @@ repository = "https://github.com/input-output-hk/catalyst-voices" license = "MIT OR Apache-2.0" [workspace.dependencies] - clap = "4" - tracing = "0.1.37" tracing-subscriber = "0.3.16" - serde = "1.0" serde_json = "1.0" - poem = "2.0.0" poem-openapi = "4.0.0" poem-extensions = "0.8.0" - prometheus = "0.13.0" cryptoxide = "0.4.4" uuid = "1" @@ -37,25 +32,16 @@ panic-message = "0.3" cpu-time = "1.0" ulid = "1.0.1" rust-embed = "8" - url = "2.4.1" - thiserror = "1.0" - chrono = "0.4" - async-trait = "0.1.64" - rust_decimal = "1.29" - bb8 = "0.8.1" bb8-postgres = "0.8.1" tokio-postgres = "0.7.10" - tokio = "1" - dotenvy = "0.15" - local-ip-address = "0.5.7" gethostname = "0.4.3" diff --git a/catalyst-gateway/bin/src/event_db/follower.rs b/catalyst-gateway/bin/src/event_db/follower.rs index e5eb51efb..26ceb9730 100644 --- a/catalyst-gateway/bin/src/event_db/follower.rs +++ b/catalyst-gateway/bin/src/event_db/follower.rs @@ -1,12 +1,11 @@ //! Follower Queries use cardano_chain_follower::Network; -use chrono::TimeZone; use crate::event_db::{Error, EventDB}; /// Block time -pub type BlockTime = i64; +pub type BlockTime = chrono::DateTime; /// Slot pub type SlotNumber = i64; /// Epoch @@ -15,8 +14,6 @@ pub type EpochNumber = i64; pub type BlockHash = String; /// Unique follower id pub type MachineId = String; -/// Time when a follower last indexed -pub type LastUpdate = chrono::DateTime; impl EventDB { /// Index follower block stream @@ -26,8 +23,6 @@ impl EventDB { ) -> Result<(), Error> { let conn = self.pool.get().await?; - let timestamp: chrono::DateTime = chrono::Utc.timestamp_nanos(block_time); - let network = match network { Network::Mainnet => "mainnet".to_string(), Network::Preview => "preview".to_string(), @@ -42,7 +37,7 @@ impl EventDB { &slot_no, &network, &epoch_no, - ×tamp, + &block_time, &hex::decode(block_hash).map_err(|e| Error::DecodeHex(e.to_string()))?, ], ) @@ -55,7 +50,7 @@ impl EventDB { /// Start follower from where previous follower left off. pub(crate) async fn last_updated_metadata( &self, network: String, - ) -> Result<(SlotNumber, BlockHash, LastUpdate), Error> { + ) -> Result<(SlotNumber, BlockHash, BlockTime), Error> { let conn = self.pool.get().await?; let rows = conn @@ -72,16 +67,16 @@ impl EventDB { return Err(Error::NoLastUpdateMetadata("No metadata".to_string())); }; - let slot_no: SlotNumber = match row.try_get("slot_no") { + let slot_no = match row.try_get("slot_no") { Ok(slot) => slot, Err(e) => return Err(Error::NoLastUpdateMetadata(e.to_string())), }; - let block_hash: BlockHash = match row.try_get::<_, Vec>("block_hash") { + let block_hash = match row.try_get::<_, Vec>("block_hash") { Ok(block_hash) => hex::encode(block_hash), Err(e) => return Err(Error::NoLastUpdateMetadata(e.to_string())), }; - let last_updated: LastUpdate = match row.try_get("ended") { + let last_updated = match row.try_get("ended") { Ok(last_updated) => last_updated, Err(e) => return Err(Error::NoLastUpdateMetadata(e.to_string())), }; @@ -92,7 +87,7 @@ impl EventDB { /// Mark point in time where the last follower finished indexing in order for future /// followers to pick up from this point pub(crate) async fn refresh_last_updated( - &self, last_updated: LastUpdate, slot_no: SlotNumber, block_hash: BlockHash, + &self, last_updated: BlockTime, slot_no: SlotNumber, block_hash: BlockHash, network: Network, machine_id: &MachineId, ) -> Result<(), Error> { let conn = self.pool.get().await?; diff --git a/catalyst-gateway/bin/src/event_db/utxo.rs b/catalyst-gateway/bin/src/event_db/utxo.rs index c059b405e..93a46f728 100644 --- a/catalyst-gateway/bin/src/event_db/utxo.rs +++ b/catalyst-gateway/bin/src/event_db/utxo.rs @@ -3,7 +3,10 @@ use cardano_chain_follower::Network; use pallas::ledger::traverse::MultiEraTx; -use super::follower::SlotNumber; +use super::{ + follower::{BlockTime, SlotNumber}, + voter_registration::StakeCredential, +}; use crate::{ event_db::{ Error::{self, SqlTypeConversionFailure}, @@ -15,9 +18,12 @@ use crate::{ }, }; +/// Stake amount. +pub(crate) type StakeAmount = i64; + impl EventDB { /// Index utxo data - pub async fn index_utxo_data( + pub(crate) async fn index_utxo_data( &self, txs: Vec>, slot_no: SlotNumber, network: Network, ) -> Result<(), Error> { let conn = self.pool.get().await?; @@ -57,9 +63,7 @@ impl EventDB { let _rows = conn .query( - include_str!( - "../../../event-db/queries/follower/utxo_index_utxo_query.sql" - ), + include_str!("../../../event-db/queries/utxo/insert_utxo.sql"), &[ &i32::try_from(index).map_err(|e| { Error::NotFound( @@ -90,7 +94,7 @@ impl EventDB { } /// Index txn metadata - pub async fn index_txn_data( + pub(crate) async fn index_txn_data( &self, tx_id: &[u8], slot_no: SlotNumber, network: Network, ) -> Result<(), Error> { let conn = self.pool.get().await?; @@ -104,11 +108,45 @@ impl EventDB { let _rows = conn .query( - include_str!("../../../event-db/queries/follower/utxo_txn_index.sql"), + include_str!("../../../event-db/queries/utxo/insert_txn_index.sql"), &[&tx_id, &slot_no, &network], ) .await?; Ok(()) } + + /// Get total utxo amount + #[allow(dead_code)] + pub(crate) async fn total_utxo_amount( + &self, stake_credential: StakeCredential<'_>, network: Network, date_time: BlockTime, + ) -> Result<(StakeAmount, SlotNumber, BlockTime), Error> { + let conn = self.pool.get().await?; + + let network = match network { + Network::Mainnet => "mainnet".to_string(), + Network::Preview => "preview".to_string(), + Network::Preprod => "preprod".to_string(), + Network::Testnet => "testnet".to_string(), + }; + + let row = conn + .query_one( + include_str!("../../../event-db/queries/utxo/select_total_utxo_amount.sql"), + &[&stake_credential, &network, &date_time], + ) + .await?; + + // Aggregate functions as SUM and MAX return NULL if there are no rows, so we need to + // check for it. + // https://www.postgresql.org/docs/8.2/functions-aggregate.html + if let Some(amount) = row.try_get("total_utxo_amount")? { + let slot_number = row.try_get("slot_no")?; + let block_time = row.try_get("block_time")?; + + Ok((amount, slot_number, block_time)) + } else { + Err(Error::NotFound("Cannot find total utxo amount".to_string())) + } + } } diff --git a/catalyst-gateway/bin/src/event_db/voter_registration.rs b/catalyst-gateway/bin/src/event_db/voter_registration.rs index 3d0b169c0..b472af8b7 100644 --- a/catalyst-gateway/bin/src/event_db/voter_registration.rs +++ b/catalyst-gateway/bin/src/event_db/voter_registration.rs @@ -5,17 +5,17 @@ use super::{Error, EventDB}; /// Transaction id pub(crate) type TxId = String; /// Stake credential -pub(crate) type StakeCredential = Box<[u8]>; +pub(crate) type StakeCredential<'a> = &'a [u8]; /// Public voting key -pub(crate) type PublicVotingKey = Box<[u8]>; +pub(crate) type PublicVotingKey<'a> = &'a [u8]; /// Payment address -pub(crate) type PaymentAddress = Box<[u8]>; +pub(crate) type PaymentAddress<'a> = &'a [u8]; /// Nonce pub(crate) type Nonce = i64; /// Metadata 61284 -pub(crate) type Metadata61284 = Box<[u8]>; +pub(crate) type Metadata61284<'a> = &'a [u8]; /// Metadata 61285 -pub(crate) type Metadata61285 = Box<[u8]>; +pub(crate) type Metadata61285<'a> = &'a [u8]; /// Stats pub(crate) type Stats = Option; @@ -23,9 +23,10 @@ impl EventDB { /// Inserts voter registration data, replacing any existing data. #[allow(dead_code, clippy::too_many_arguments)] async fn insert_voter_registration( - &self, tx_id: TxId, stake_credential: StakeCredential, public_voting_key: PublicVotingKey, - payment_address: PaymentAddress, nonce: Nonce, metadata_61284: Metadata61284, - metadata_61285: Metadata61285, valid: bool, stats: Stats, + &self, tx_id: TxId, stake_credential: StakeCredential<'_>, + public_voting_key: PublicVotingKey<'_>, payment_address: PaymentAddress<'_>, nonce: Nonce, + metadata_61284: Metadata61284<'_>, metadata_61285: Metadata61285<'_>, valid: bool, + stats: Stats, ) -> Result<(), Error> { let conn = self.pool.get().await?; @@ -36,12 +37,12 @@ impl EventDB { ), &[ &hex::decode(tx_id).map_err(|e| Error::DecodeHex(e.to_string()))?, - &stake_credential.as_ref(), - &public_voting_key.as_ref(), - &payment_address.as_ref(), + &stake_credential, + &public_voting_key, + &payment_address, &nonce, - &metadata_61284.as_ref(), - &metadata_61285.as_ref(), + &metadata_61284, + &metadata_61285, &valid, &stats, ], diff --git a/catalyst-gateway/bin/src/follower.rs b/catalyst-gateway/bin/src/follower.rs index c08ebaaab..e668760c1 100644 --- a/catalyst-gateway/bin/src/follower.rs +++ b/catalyst-gateway/bin/src/follower.rs @@ -8,13 +8,14 @@ use async_recursion::async_recursion; use cardano_chain_follower::{ network_genesis_values, ChainUpdate, Follower, FollowerConfigBuilder, Network, Point, }; +use chrono::TimeZone; use tokio::{task::JoinHandle, time}; use tracing::{error, info}; use crate::{ event_db::{ config::{FollowerMeta, NetworkMeta}, - follower::{BlockHash, LastUpdate, MachineId, SlotNumber}, + follower::{BlockHash, BlockTime, MachineId, SlotNumber}, EventDB, }, util::valid_era, @@ -150,7 +151,7 @@ async fn spawn_followers( /// it left off. If there was no previous follower, start indexing from genesis point. async fn find_last_update_point( db: Arc, network: &String, -) -> Result<(Option, Option, Option), Box> { +) -> Result<(Option, Option, Option), Box> { let (slot_no, block_hash, last_updated) = match db.last_updated_metadata(network.to_string()).await { Ok((slot_no, block_hash, last_updated)) => { @@ -214,7 +215,7 @@ async fn init_follower( }; let wallclock = match block.wallclock(&genesis_values).try_into() { - Ok(time) => time, + Ok(time) => chrono::Utc.timestamp_nanos(time), Err(err) => { error!("Cannot parse wall time from block {:?} - skip..", err); continue; diff --git a/catalyst-gateway/bin/src/service/api/mod.rs b/catalyst-gateway/bin/src/service/api/mod.rs index b99aa95db..53875cba7 100644 --- a/catalyst-gateway/bin/src/service/api/mod.rs +++ b/catalyst-gateway/bin/src/service/api/mod.rs @@ -11,11 +11,13 @@ use local_ip_address::list_afinet_netifas; use poem_openapi::{ContactObject, LicenseObject, OpenApiService, ServerObject}; use test_endpoints::TestApi; +use self::utxo::UTXOApi; use crate::settings::{DocsSettings, API_URL_PREFIX}; mod health; mod legacy; mod test_endpoints; +mod utxo; /// The name of the API const API_TITLE: &str = "Catalyst Gateway"; @@ -58,11 +60,12 @@ const TERMS_OF_SERVICE: &str = /// Create the `OpenAPI` definition pub(crate) fn mk_api( hosts: Vec, settings: &DocsSettings, -) -> OpenApiService<(TestApi, HealthApi, LegacyApi), ()> { +) -> OpenApiService<(TestApi, HealthApi, UTXOApi, LegacyApi), ()> { let mut service = OpenApiService::new( ( TestApi, HealthApi, + UTXOApi, (legacy::RegistrationApi, legacy::V0Api, legacy::V1Api), ), API_TITLE, diff --git a/catalyst-gateway/bin/src/service/api/utxo/mod.rs b/catalyst-gateway/bin/src/service/api/utxo/mod.rs new file mode 100644 index 000000000..e6bf510bd --- /dev/null +++ b/catalyst-gateway/bin/src/service/api/utxo/mod.rs @@ -0,0 +1,69 @@ +//! Cardano UTXO endpoints + +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use poem::web::Data; +use poem_openapi::{ + param::{Path, Query}, + OpenApi, +}; + +use crate::{ + service::{ + common::{ + objects::{cardano_address::CardanoStakeAddress, network::Network}, + tags::ApiTags, + }, + utilities::middleware::schema_validation::schema_version_validation, + }, + state::State, +}; + +mod staked_ada_get; + +/// Cardano UTXO API Endpoints +pub(crate) struct UTXOApi; + +#[OpenApi(prefix_path = "/utxo", tag = "ApiTags::Utxo")] +impl UTXOApi { + #[oai( + path = "/staked_ada/:stake_address", + method = "get", + operation_id = "stakedAdaAmountGet", + transform = "schema_version_validation", + // TODO: https://github.com/input-output-hk/catalyst-voices/issues/330 + deprecated = true + )] + /// Get staked ada amount. + /// + /// This endpoint returns the total Cardano's staked ada amount to the corresponded + /// user's stake address. + /// + /// ## Responses + /// * 200 OK - Returns the staked ada amount. + /// * 400 Bad Request. + /// * 404 Not Found. + /// * 500 Server Error - If anything within this function fails unexpectedly. + /// * 503 Service Unavailable - Service is not ready, requests to other + /// endpoints should not be sent until the service becomes ready. + async fn staked_ada_get( + &self, data: Data<&Arc>, + /// The stake address of the user. + /// Should a valid Bech32 encoded address followed by the https://cips.cardano.org/cip/CIP-19/#stake-addresses. + stake_address: Path, + /// Cardano network type. + /// If omitted network type is identified from the stake address. + /// If specified it must be correspondent to the network type encoded in the stake + /// address. + /// As `preprod` and `preview` network types in the stake address encoded as a + /// `testnet`, to specify `preprod` or `preview` network type use this + /// query parameter. + network: Query>, + /// Date time at which the staked ada amount should be calculated. + /// If omitted current date time is used. + date_time: Query>>, + ) -> staked_ada_get::AllResponses { + staked_ada_get::endpoint(&data, stake_address.0, network.0, date_time.0).await + } +} diff --git a/catalyst-gateway/bin/src/service/api/utxo/staked_ada_get.rs b/catalyst-gateway/bin/src/service/api/utxo/staked_ada_get.rs new file mode 100644 index 000000000..097149a8b --- /dev/null +++ b/catalyst-gateway/bin/src/service/api/utxo/staked_ada_get.rs @@ -0,0 +1,109 @@ +//! Implementation of the GET `/utxo/staked_ada` endpoint + +use chrono::{DateTime, Utc}; +use poem_extensions::{ + response, + UniResponse::{T200, T400, T404, T500, T503}, +}; +use poem_openapi::{payload::Json, types::ToJSON}; + +use crate::{ + cli::Error, + event_db::error::Error as DBError, + service::common::{ + objects::{ + cardano_address::CardanoStakeAddress, network::Network, stake_amount::StakeInfo, + }, + responses::{ + resp_2xx::OK, + resp_4xx::{ApiValidationError, NotFound}, + resp_5xx::{server_error, ServerError, ServiceUnavailable}, + }, + }, + state::{SchemaVersionStatus, State}, +}; + +/// # All Responses +pub(crate) type AllResponses = response! { + 200: OK>, + 400: ApiValidationError, + 404: NotFound, + 500: ServerError, + 503: ServiceUnavailable, +}; + +/// # GET `/utxo/staked_ada` +#[allow(clippy::unused_async)] +pub(crate) async fn endpoint( + state: &State, stake_address: CardanoStakeAddress, provided_network: Option, + date_time: Option>, +) -> AllResponses { + match state.event_db() { + Ok(event_db) => { + let date_time = date_time.unwrap_or_else(Utc::now); + let stake_credential = stake_address.payload().as_hash().as_ref(); + + // check the provided network type with the encoded inside the stake address + let network = match stake_address.network() { + pallas::ledger::addresses::Network::Mainnet => { + if let Some(network) = provided_network { + if !matches!(&network, Network::Mainnet) { + return T400(ApiValidationError::new(format!( + "Provided network type {} does not match stake address network type Mainnet", network.to_json_string() + ))); + } + } + Network::Mainnet + }, + pallas::ledger::addresses::Network::Testnet => { + // the preprod and preview network types are encoded as `testnet` in the stake + // address, so here we are checking if the `provided_network` type matches the + // one, and if not - we return an error. + // if the `provided_network` omitted - we return the `testnet` network type + if let Some(network) = provided_network { + if !matches!( + network, + Network::Testnet | Network::Preprod | Network::Preview + ) { + return T400(ApiValidationError::new(format!( + "Provided network type {} does not match stake address network type Testnet", network.to_json_string() + ))); + } + network + } else { + Network::Testnet + } + }, + pallas::ledger::addresses::Network::Other(x) => { + return T400(ApiValidationError::new(format!("Unknown network type {x}"))); + }, + }; + + // get the total utxo amount from the database + match event_db + .total_utxo_amount(stake_credential, network.into(), date_time) + .await + { + Ok((amount, slot_number, block_time)) => { + T200(OK(Json(StakeInfo { + amount, + slot_number, + block_time, + }))) + }, + Err(DBError::NotFound(_)) => T404(NotFound), + Err(err) => T500(server_error!("{}", err.to_string())), + } + }, + Err(Error::EventDb(DBError::MismatchedSchema { was, expected })) => { + tracing::error!( + expected = expected, + current = was, + "DB schema version status mismatch" + ); + state.set_schema_version_status(SchemaVersionStatus::Mismatch); + T503(ServiceUnavailable) + }, + Err(err) => T500(server_error!("{}", err.to_string())), + } +} diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano_address.rs b/catalyst-gateway/bin/src/service/common/objects/cardano_address.rs new file mode 100644 index 000000000..131bd3b77 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/objects/cardano_address.rs @@ -0,0 +1,83 @@ +//! Defines API schemas of Cardano address types. + +use std::ops::Deref; + +use pallas::ledger::addresses::{Address, StakeAddress}; +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef, Registry}, + types::{ParseError, ParseFromParameter, ParseResult, Type}, +}; + +/// Cardano stake address of the user. +/// Should a valid Bech32 encoded stake address followed by the `https://cips.cardano.org/cip/CIP-19/#stake-addresses.` +#[derive(Debug)] +pub(crate) struct CardanoStakeAddress(StakeAddress); + +impl CardanoStakeAddress { + /// Creates a `CardanoStakeAddress` schema definition. + fn schema() -> MetaSchema { + let mut schema = MetaSchema::new("string"); + schema.title = Some("CardanoStakeAddress".to_string()); + schema.description = Some("The stake address of the user. Should a valid Bech32 encoded address followed by the https://cips.cardano.org/cip/CIP-19/#stake-addresses."); + schema.example = Some(serde_json::Value::String( + // cspell: disable + "stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw".to_string(), + // cspell: enable + )); + schema.max_length = Some(64); + schema.pattern = Some("(stake|stake_test)1[a,c-h,j-n,p-z,0,2-9]{53}".to_string()); + schema + } +} + +impl Deref for CardanoStakeAddress { + type Target = StakeAddress; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Type for CardanoStakeAddress { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "CardanoStakeAddress".into() + } + + fn schema_ref() -> MetaSchemaRef { + MetaSchemaRef::Reference(Self::name().to_string()) + } + + fn register(registry: &mut Registry) { + registry.create_schema::(Self::name().to_string(), |_| Self::schema()); + } + + fn as_raw_value(&self) -> Option<&Self::RawValueType> { + Some(self) + } + + fn raw_element_iter<'a>( + &'a self, + ) -> Box + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for CardanoStakeAddress { + fn parse_from_parameter(param: &str) -> ParseResult { + // prefix checks + if !param.starts_with("stake") && !param.starts_with("stake_test") { + return Err(ParseError::custom("Invalid Cardano stake address")); + } + let address = Address::from_bech32(param).map_err(|e| ParseError::custom(e.to_string()))?; + if let Address::Stake(stake_address) = address { + Ok(Self(stake_address)) + } else { + Err(ParseError::custom("Invalid Cardano stake address")) + } + } +} diff --git a/catalyst-gateway/bin/src/service/common/objects/mod.rs b/catalyst-gateway/bin/src/service/common/objects/mod.rs index 6eebf8923..f967ccb7e 100644 --- a/catalyst-gateway/bin/src/service/common/objects/mod.rs +++ b/catalyst-gateway/bin/src/service/common/objects/mod.rs @@ -1,3 +1,6 @@ //! This module contains common and re-usable objects. +pub(crate) mod cardano_address; pub(crate) mod legacy; +pub(crate) mod network; +pub(crate) mod stake_amount; diff --git a/catalyst-gateway/bin/src/service/common/objects/network.rs b/catalyst-gateway/bin/src/service/common/objects/network.rs new file mode 100644 index 000000000..fc0ea577c --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/objects/network.rs @@ -0,0 +1,27 @@ +//! Defines API schemas of Cardano network types. + +use poem_openapi::Enum; + +/// Cardano network type. +#[derive(Enum, Debug)] +pub(crate) enum Network { + /// Cardano mainnet. + Mainnet, + /// Cardano testnet. + Testnet, + /// Cardano preprod. + Preprod, + /// Cardano preview. + Preview, +} + +impl From for cardano_chain_follower::Network { + fn from(value: Network) -> Self { + match value { + Network::Mainnet => Self::Mainnet, + Network::Testnet => Self::Testnet, + Network::Preprod => Self::Preprod, + Network::Preview => Self::Preview, + } + } +} diff --git a/catalyst-gateway/bin/src/service/common/objects/stake_amount.rs b/catalyst-gateway/bin/src/service/common/objects/stake_amount.rs new file mode 100644 index 000000000..94298e305 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/objects/stake_amount.rs @@ -0,0 +1,37 @@ +//! Defines API schemas of stake amount type. + +use chrono::Utc; +use poem_openapi::{types::Example, Object}; + +use crate::event_db::{ + follower::{BlockTime, SlotNumber}, + utxo::StakeAmount, +}; + +/// User's cardano stake info. +#[derive(Object)] +#[oai(example = true)] +pub(crate) struct StakeInfo { + /// Stake amount. + // TODO(bkioshn): https://github.com/input-output-hk/catalyst-voices/issues/239 + #[oai(validator(minimum(value = "0"), maximum(value = "4294967295")))] + pub(crate) amount: StakeAmount, + + /// Slot number. + // TODO(bkioshn): https://github.com/input-output-hk/catalyst-voices/issues/239 + #[oai(validator(minimum(value = "0"), maximum(value = "4294967295")))] + pub(crate) slot_number: SlotNumber, + + /// Block date time. + pub(crate) block_time: BlockTime, +} + +impl Example for StakeInfo { + fn example() -> Self { + Self { + amount: 1, + slot_number: 5, + block_time: Utc::now(), + } + } +} diff --git a/catalyst-gateway/bin/src/service/common/responses/resp_4xx.rs b/catalyst-gateway/bin/src/service/common/responses/resp_4xx.rs index 2e9e2e027..c2317d79c 100644 --- a/catalyst-gateway/bin/src/service/common/responses/resp_4xx.rs +++ b/catalyst-gateway/bin/src/service/common/responses/resp_4xx.rs @@ -15,6 +15,13 @@ pub(crate) struct BadRequest(T); /// It has failed to pass validation, as specified by the `OpenAPI` schema. pub(crate) struct ApiValidationError(PlainText); +impl ApiValidationError { + /// Create new `ApiValidationError` + pub(crate) fn new(error: String) -> Self { + Self(PlainText(error)) + } +} + #[derive(OneResponse)] #[oai(status = 401)] /// ## Unauthorized diff --git a/catalyst-gateway/bin/src/service/common/tags.rs b/catalyst-gateway/bin/src/service/common/tags.rs index 0e4f13398..5075d0c1e 100644 --- a/catalyst-gateway/bin/src/service/common/tags.rs +++ b/catalyst-gateway/bin/src/service/common/tags.rs @@ -8,6 +8,8 @@ pub(crate) enum ApiTags { Fragments, /// Health Endpoints Health, + /// UTXO Endpoints + Utxo, /// Information relating to Voter Registration, Delegations and Calculated Voting /// Power. Registration, diff --git a/catalyst-gateway/bin/src/state/mod.rs b/catalyst-gateway/bin/src/state/mod.rs index 4b1aab075..c0e073dc2 100644 --- a/catalyst-gateway/bin/src/state/mod.rs +++ b/catalyst-gateway/bin/src/state/mod.rs @@ -52,7 +52,6 @@ impl State { } /// Get the reference to the database connection pool for `EventDB`. - #[allow(dead_code)] pub(crate) fn event_db(&self) -> Result, Error> { let guard = self.schema_version_status_lock(); match *guard { diff --git a/catalyst-gateway/event-db/migrations/V6__registration.sql b/catalyst-gateway/event-db/migrations/V6__registration.sql index 2e86a8a91..6dbce906c 100644 --- a/catalyst-gateway/event-db/migrations/V6__registration.sql +++ b/catalyst-gateway/event-db/migrations/V6__registration.sql @@ -3,7 +3,7 @@ -- Title : Role Registration Data --- cspell: words utxo stxo +-- cspell: words utxo -- Configuration Tables -- ------------------------------------------------------------------------------------------------- diff --git a/catalyst-gateway/event-db/queries/follower/utxo_txn_index.sql b/catalyst-gateway/event-db/queries/utxo/insert_txn_index.sql similarity index 100% rename from catalyst-gateway/event-db/queries/follower/utxo_txn_index.sql rename to catalyst-gateway/event-db/queries/utxo/insert_txn_index.sql diff --git a/catalyst-gateway/event-db/queries/follower/utxo_index_utxo_query.sql b/catalyst-gateway/event-db/queries/utxo/insert_utxo.sql similarity index 100% rename from catalyst-gateway/event-db/queries/follower/utxo_index_utxo_query.sql rename to catalyst-gateway/event-db/queries/utxo/insert_utxo.sql diff --git a/catalyst-gateway/event-db/queries/utxo/select_total_utxo_amount.sql b/catalyst-gateway/event-db/queries/utxo/select_total_utxo_amount.sql new file mode 100644 index 000000000..d962f8f25 --- /dev/null +++ b/catalyst-gateway/event-db/queries/utxo/select_total_utxo_amount.sql @@ -0,0 +1,26 @@ +-- Select total UTXO's corresponded to the provided stake credential from the given network that have occurred before the given time. +SELECT + SUM(cardano_utxo.value)::BIGINT AS total_utxo_amount, + MAX(cardano_slot_index.slot_no) AS slot_no, + MAX(cardano_slot_index.block_time) AS block_time + +FROM cardano_utxo + +INNER JOIN cardano_txn_index + ON cardano_utxo.tx_id = cardano_txn_index.id + +-- filter out orphaned transactions +INNER JOIN cardano_update_state + ON + cardano_txn_index.slot_no <= cardano_update_state.slot_no + AND cardano_txn_index.network = cardano_update_state.network + +INNER JOIN cardano_slot_index + ON + cardano_txn_index.slot_no = cardano_slot_index.slot_no + AND cardano_txn_index.network = cardano_slot_index.network + +WHERE + cardano_utxo.stake_credential = $1 + AND cardano_txn_index.network = $2 + AND cardano_slot_index.block_time <= $3; diff --git a/catalyst-gateway/tests/.spectral.yml b/catalyst-gateway/tests/.spectral.yml index 8a8c366e8..5d03e95b9 100644 --- a/catalyst-gateway/tests/.spectral.yml +++ b/catalyst-gateway/tests/.spectral.yml @@ -1,4 +1,4 @@ -# References to the rules +# References to the rules # OpenAPI: https://docs.stoplight.io/docs/spectral/4dec24461f3af-open-api-rules#openapi-rules # OWASP Top 10: https://github.com/stoplightio/spectral-owasp-ruleset/blob/v1.4.3/src/ruleset.ts # Documentations: https://github.com/stoplightio/spectral-documentation/blob/v1.3.1/src/ruleset.ts @@ -7,116 +7,120 @@ # Use CDN hosted version for spectral-documentation and spectral-owasp extends: -- 'spectral:oas' -- 'https://unpkg.com/@stoplight/spectral-documentation@1.3.1/dist/ruleset.mjs' -- 'https://unpkg.com/@stoplight/spectral-owasp-ruleset@1.4.3/dist/ruleset.mjs' + - "spectral:oas" + - "https://unpkg.com/@stoplight/spectral-documentation@1.3.1/dist/ruleset.mjs" + - "https://unpkg.com/@stoplight/spectral-owasp-ruleset@1.4.3/dist/ruleset.mjs" aliases: PathItem: - - $.paths[*] + - $.paths[*] OperationObject: - - $.paths[*][get,put,post,delete,options,head,patch,trace] + - $.paths[*][get,put,post,delete,options,head,patch,trace] DescribableObjects: - - $.info - - $.tags[*] - - '#OperationObject' - - '#OperationObject.responses[*]' - - '#PathItem.parameters[?(@ && @.in)]' - - '#OperationObject.parameters[?(@ && @.in)]' + - $.info + - $.tags[*] + - "#OperationObject" + - "#OperationObject.responses[*]" + - "#PathItem.parameters[?(@ && @.in)]" + - "#OperationObject.parameters[?(@ && @.in)]" overrides: -- files: ['*'] - rules: - # Override document description rule - # - No limitations on the characters that can start or end a sentence. - # - Length should be >= 20 characters - # Ref: https://github.com/stoplightio/spectral-documentation/blob/a34ca1b49cbd1ac5a75cfcb93c69d1d77bde341e/src/ruleset.ts#L173 - docs-description: - given: '#DescribableObjects' - then: - - field: 'description' - function: 'truthy' - - field: 'description' - function: 'length' - functionOptions: - min: 20 - - field: 'description' - function: 'pattern' - functionOptions: - # Matches any character that is #, *, uppercase or lowercase letters from A to Z, or digits from 0 to 9 at the beginning of the string. - # with zero or more occurrences of any character except newline. - match: '^[#*A-Za-z0-9].*' + - files: ["*"] + rules: + # Override document description rule + # - No limitations on the characters that can start or end a sentence. + # - Length should be >= 20 characters + # Ref: https://github.com/stoplightio/spectral-documentation/blob/a34ca1b49cbd1ac5a75cfcb93c69d1d77bde341e/src/ruleset.ts#L173 + docs-description: + given: "#DescribableObjects" + then: + - field: "description" + function: "truthy" + - field: "description" + function: "length" + functionOptions: + min: 20 + - field: "description" + function: "pattern" + functionOptions: + # Matches any character that is #, *, uppercase or lowercase letters from A to Z, or digits from 0 to 9 at the beginning of the string. + # with zero or more occurrences of any character except newline. + match: "^[#*A-Za-z0-9].*" - # Severity - # warn: Should be implemented, but is blocked by a technical issue. - # info: Good to be implemented. + # Severity + # warn: Should be implemented, but is blocked by a technical issue. + # info: Good to be implemented. - # Rate limit - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L436 - owasp:api4:2019-rate-limit: warn - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L484 - owasp:api4:2019-rate-limit-responses-429: warn - # Public API - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L305 - owasp:api2:2019-protection-global-safe: info - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L269 - owasp:api2:2019-protection-global-unsafe: info - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L287 - owasp:api2:2019-protection-global-unsafe-strict: info - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L376 - owasp:api3:2019-define-error-responses-401: warn + # Rate limit + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L436 + owasp:api4:2019-rate-limit: warn + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L484 + owasp:api4:2019-rate-limit-responses-429: warn + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L551 + owasp:api4:2019-string-restricted: warn + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L521 + owasp:api4:2019-string-limit: warn + # Public API + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L305 + owasp:api2:2019-protection-global-safe: info + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L269 + owasp:api2:2019-protection-global-unsafe: info + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L287 + owasp:api2:2019-protection-global-unsafe-strict: info + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L376 + owasp:api3:2019-define-error-responses-401: warn - # UUID rules for name containing "id" is ignored - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L102 - owasp:api1:2019-no-numeric-ids: off + # UUID rules for name containing "id" is ignored + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L102 + owasp:api1:2019-no-numeric-ids: off -- files: - - '**#/paths/~1api~1health~1live/get/responses' - - '**#/paths/~1api~1health~1started/get/responses' - - '**#/paths/~1api~1health~1ready/get/responses' - - '**#/paths/~1api~1test~1test~1%7Bid%7D~1test~1%7Baction%7D/post/responses' - - '**#/paths/~1api~1test~1test~1%7Bid%7D~1test~1%7Baction%7D/get/responses' - # Recheck this, already apply validator but does not work "FragmentId" - - '**#/paths/~1api~1v1~1fragments~1statuses/get/parameters/0/schema' - # Recheck this, already apply validator but does not work "AccountId" - - '**#/paths/~1api~1v1~1votes~1plan~1account-votes~1%7Baccount_id%7D/get/parameters/0/schema' - rules: - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L551 - owasp:api4:2019-string-restricted: off + - files: + - "**#/paths/~1api~1health~1live/get/responses" + - "**#/paths/~1api~1health~1started/get/responses" + - "**#/paths/~1api~1health~1ready/get/responses" + - "**#/paths/~1api~1test~1test~1%7Bid%7D~1test~1%7Baction%7D/post/responses" + - "**#/paths/~1api~1test~1test~1%7Bid%7D~1test~1%7Baction%7D/get/responses" + # Recheck this, already apply validator but does not work "FragmentId" + - "**#/paths/~1api~1v1~1fragments~1statuses/get/parameters/0/schema" + # Recheck this, already apply validator but does not work "AccountId" + - "**#/paths/~1api~1v1~1votes~1plan~1account-votes~1%7Baccount_id%7D/get/parameters/0/schema" + rules: + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L551 + owasp:api4:2019-string-restricted: off -- files: - - '**#/paths/~1api~1health~1live/get/responses' - - '**#/paths/~1api~1health~1started/get/responses' - - '**#/paths/~1api~1health~1ready/get/responses' - - '**#/paths/~1api~1v0~1message/post/requestBody/content' - - '**#/paths/~1api~1test~1test~1%7Bid%7D~1test~1%7Baction%7D/post/responses' - - '**#/paths/~1api~1test~1test~1%7Bid%7D~1test~1%7Baction%7D/get/responses' - - '**#/components/schemas/ServerErrorPayload/properties/id' - - '**#/components/schemas/VoterRegistration/properties/as_at' - - '**#/components/schemas/VoterRegistration/properties/last_updated' - # Recheck this, already apply validator but does not work "FragmentId" - - '**#/paths/~1api~1v1~1fragments~1statuses/get/parameters/0/schema' - # Recheck this, already apply validator but does not work "AccountId" - - '**#/paths/~1api~1v1~1votes~1plan~1account-votes~1%7Baccount_id%7D/get/parameters/0/schema' - rules: - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L521 - owasp:api4:2019-string-limit: off + - files: + - "**#/paths/~1api~1health~1live/get/responses" + - "**#/paths/~1api~1health~1started/get/responses" + - "**#/paths/~1api~1health~1ready/get/responses" + - "**#/paths/~1api~1v0~1message/post/requestBody/content" + - "**#/paths/~1api~1test~1test~1%7Bid%7D~1test~1%7Baction%7D/post/responses" + - "**#/paths/~1api~1test~1test~1%7Bid%7D~1test~1%7Baction%7D/get/responses" + - "**#/components/schemas/ServerErrorPayload/properties/id" + - "**#/components/schemas/VoterRegistration/properties/as_at" + - "**#/components/schemas/VoterRegistration/properties/last_updated" + # Recheck this, already apply validator but does not work "FragmentId" + - "**#/paths/~1api~1v1~1fragments~1statuses/get/parameters/0/schema" + # Recheck this, already apply validator but does not work "AccountId" + - "**#/paths/~1api~1v1~1votes~1plan~1account-votes~1%7Baccount_id%7D/get/parameters/0/schema" + rules: + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L521 + owasp:api4:2019-string-limit: off -- files: - - '**#/paths/~1api~1v0~1vote~1active~1plans/get/responses' - - '**#/paths/~1api~1v1~1votes~1plan~1account-votes~1%7Baccount_id%7D/get/responses' - rules: - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L506 - owasp:api4:2019-array-limit: off + - files: + - "**#/paths/~1api~1v0~1vote~1active~1plans/get/responses" + - "**#/paths/~1api~1v1~1votes~1plan~1account-votes~1%7Baccount_id%7D/get/responses" + rules: + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L506 + owasp:api4:2019-array-limit: off -- files: - - '**#/components/schemas/FragmentStatus' - rules: - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L678 - owasp:api6:2019-no-additionalProperties: off + - files: + - "**#/components/schemas/FragmentStatus" + rules: + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L678 + owasp:api6:2019-no-additionalProperties: off -- files: - - '**#/paths/~1api~1v1~1fragments~1statuses/get/responses' - rules: - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L698 - owasp:api6:2019-constrained-additionalProperties: off + - files: + - "**#/paths/~1api~1v1~1fragments~1statuses/get/responses" + rules: + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L698 + owasp:api6:2019-constrained-additionalProperties: off diff --git a/catalyst-gateway/tests/Earthfile b/catalyst-gateway/tests/Earthfile index 65a9dd061..c68f3ac46 100644 --- a/catalyst-gateway/tests/Earthfile +++ b/catalyst-gateway/tests/Earthfile @@ -27,7 +27,7 @@ test-fuzzer-api: WITH DOCKER \ --compose schemathesis-docker-compose.yml \ --load schemathesis:latest=(+package-schemathesis --openapi_spec="http://127.0.0.1:3030/docs/cat-gateway.json") \ - --load event-db:latest=(../event-db+build --with_historic_data=false) \ + --load event-db:latest=(../event-db+build) \ --load cat-gateway:latest=(../+package-cat-gateway --address="127.0.0.1:3030" \ --db_url="postgres://catalyst-event-dev:CHANGE_ME@localhost/CatalystEventDev") \ --service event-db \ diff --git a/catalyst-gateway/tests/schema-mismatch/Earthfile b/catalyst-gateway/tests/schema-mismatch/Earthfile index 3c70ff7f6..d2fdfb956 100644 --- a/catalyst-gateway/tests/schema-mismatch/Earthfile +++ b/catalyst-gateway/tests/schema-mismatch/Earthfile @@ -25,7 +25,7 @@ test: WITH DOCKER \ --compose docker-compose.yml \ - --load event-db:latest=(../../event-db+build --with_historic_data=false) \ + --load event-db:latest=(../../event-db+build) \ --load cat-gateway:latest=(../../+package-cat-gateway --address=$CAT_ADDRESS --db_url=$DB_URL) \ --load test:latest=(+package-tester) \ --service event-db \ From 7f08eb1fbdad39f15a6eae3a55b3997d39bbe6c0 Mon Sep 17 00:00:00 2001 From: Stefano Cunego <93382903+kukkok3@users.noreply.github.com> Date: Mon, 25 Mar 2024 11:01:08 +0100 Subject: [PATCH 04/35] feat: bump ci to 3.0.0 (#340) --- catalyst-gateway/Earthfile | 2 +- catalyst-gateway/event-db/Earthfile | 10 +++++----- docs/Earthfile | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/catalyst-gateway/Earthfile b/catalyst-gateway/Earthfile index 4a1390609..a61739981 100644 --- a/catalyst-gateway/Earthfile +++ b/catalyst-gateway/Earthfile @@ -4,7 +4,7 @@ VERSION --try --global-cache 0.7 # Set up our target toolchains, and copy our files. builder: - DO github.com/input-output-hk/catalyst-ci/earthly/rust:v2.9.9+SETUP + DO github.com/input-output-hk/catalyst-ci/earthly/rust:v3.0.0+SETUP COPY --dir .cargo .config Cargo.* clippy.toml deny.toml rustfmt.toml bin crates tests . COPY --dir ./event-db/queries ./event-db/queries diff --git a/catalyst-gateway/event-db/Earthfile b/catalyst-gateway/event-db/Earthfile index 709374025..fda6ba288 100644 --- a/catalyst-gateway/event-db/Earthfile +++ b/catalyst-gateway/event-db/Earthfile @@ -9,7 +9,7 @@ VERSION 0.7 # Internal: builder is our Event db builder target. Prepares all necessary artifacts. # CI target : dependency builder: - DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.9.9+BUILDER \ + DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v3.0.0+BUILDER \ --sqlfluff_cfg=./../../+repo-config/repo/.sqlfluff COPY ./../../+repo-config/repo/.sqlfluff . @@ -24,7 +24,7 @@ builder: check: FROM +builder - DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.9.9+CHECK + DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v3.0.0+CHECK # format all SQL files in the current project. Local developers tool. @@ -32,15 +32,15 @@ check: format: LOCALLY - DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.9.9+FORMAT --src=$(echo ${PWD}/../../) + DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v3.0.0+FORMAT --src=$(echo ${PWD}/../../) # build - an event db docker image. # CI target : true build: FROM +builder - DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.9.9+BUILD --image_name=event-db - DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.9.9+DOCS --image_name=event-db + DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v3.0.0+BUILD --image_name=event-db + DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v3.0.0+DOCS --image_name=event-db # test the event db database schema # CI target : true diff --git a/docs/Earthfile b/docs/Earthfile index 746da7448..135fcbfb7 100644 --- a/docs/Earthfile +++ b/docs/Earthfile @@ -6,7 +6,7 @@ VERSION 0.7 # Copy all the source we need to build the docs src: # Common src setup - DO github.com/input-output-hk/catalyst-ci/earthly/docs:v2.9.9+SRC + DO github.com/input-output-hk/catalyst-ci/earthly/docs:v3.0.0+SRC # Now copy into that any artifacts we pull from the builds. COPY --dir ../+repo-docs/repo /docs/includes @@ -21,12 +21,12 @@ src: docs: FROM +src - DO github.com/input-output-hk/catalyst-ci/earthly/docs:v2.9.9+BUILD + DO github.com/input-output-hk/catalyst-ci/earthly/docs:v3.0.0+BUILD # Make a locally runable container that can serve the docs. local: # Build a self contained service to show built docs locally. - DO github.com/input-output-hk/catalyst-ci/earthly/docs:v2.9.9+PACKAGE + DO github.com/input-output-hk/catalyst-ci/earthly/docs:v3.0.0+PACKAGE # Copy the static pages into the container COPY +docs/ /usr/share/nginx/html From 1708cbd2b179fb90a357178e0f5c14bd2c1a034b Mon Sep 17 00:00:00 2001 From: Alex Pozhylenkov Date: Mon, 25 Mar 2024 12:16:35 +0200 Subject: [PATCH 05/35] refactor: Add `anyhow` error handling (#339) * update rust dependecies * add anyhow, refactor some functions * wip --- catalyst-gateway/Cargo.lock | 1 + catalyst-gateway/Cargo.toml | 5 +++++ catalyst-gateway/bin/Cargo.toml | 26 +++++++------------------ catalyst-gateway/bin/src/cli.rs | 2 +- catalyst-gateway/bin/src/follower.rs | 29 +++++++++++++++++----------- catalyst-gateway/bin/src/logger.rs | 7 ++++--- catalyst-gateway/bin/src/util.rs | 12 +++++------- 7 files changed, 41 insertions(+), 41 deletions(-) diff --git a/catalyst-gateway/Cargo.lock b/catalyst-gateway/Cargo.lock index ac9e44547..ef2a5c892 100644 --- a/catalyst-gateway/Cargo.lock +++ b/catalyst-gateway/Cargo.lock @@ -457,6 +457,7 @@ dependencies = [ name = "cat-gateway" version = "0.0.1" dependencies = [ + "anyhow", "async-recursion", "bb8", "bb8-postgres", diff --git a/catalyst-gateway/Cargo.toml b/catalyst-gateway/Cargo.toml index 6b88e2807..9c13b1322 100644 --- a/catalyst-gateway/Cargo.toml +++ b/catalyst-gateway/Cargo.toml @@ -44,6 +44,11 @@ tokio = "1" dotenvy = "0.15" local-ip-address = "0.5.7" gethostname = "0.4.3" +hex = "0.4.3" +async-recursion = "1.0.5" +pallas = "0.23.0" +anyhow = "1.0.71" +cardano-chain-follower= { git = "https://github.com/input-output-hk/hermes.git", version="0.0.1" } [workspace.lints.rust] warnings = "deny" diff --git a/catalyst-gateway/bin/Cargo.toml b/catalyst-gateway/bin/Cargo.toml index 7c7cdd5b7..467a8cec8 100644 --- a/catalyst-gateway/bin/Cargo.toml +++ b/catalyst-gateway/bin/Cargo.toml @@ -15,7 +15,6 @@ repository.workspace = true workspace = true [dependencies] - bb8 = { workspace = true } bb8-postgres = { workspace = true } tokio-postgres = { workspace = true, features = [ @@ -23,24 +22,18 @@ tokio-postgres = { workspace = true, features = [ "with-serde_json-1", "with-time-0_3", ] } - clap = { workspace = true, features = ["derive", "env"] } tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["fmt", "json", "time"] } - serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } - tokio = { workspace = true, features = ["rt", "macros", "rt-multi-thread"] } thiserror = { workspace = true } - rust_decimal = { workspace = true, features = [ "serde-with-float", "db-tokio-postgres", ] } - chrono = { workspace = true } - poem = { workspace = true, features = [ "embed", "prometheus", @@ -56,8 +49,6 @@ poem-openapi = { workspace = true, features = [ "chrono", ] } poem-extensions = { workspace = true } - -# Metrics - Poem prometheus = { workspace = true } cryptoxide = { workspace = true } uuid = { workspace = true, features = ["v4", "serde"] } @@ -68,13 +59,10 @@ panic-message = { workspace = true } cpu-time = { workspace = true } ulid = { workspace = true, features = ["serde", "uuid"] } rust-embed = { workspace = true } -local-ip-address.workspace = true -gethostname.workspace = true - -hex = "0.4.3" -async-recursion = "1.0.5" - - -cardano-chain-follower= { git = "https://github.com/input-output-hk/hermes.git", version="0.0.1"} - -pallas = { version = "0.23.0" } +local-ip-address = { workspace = true } +gethostname = { workspace = true } +hex = { workspace = true } +async-recursion = { workspace = true } +pallas = { workspace = true } +cardano-chain-follower= { workspace = true } +anyhow = { workspace = true } diff --git a/catalyst-gateway/bin/src/cli.rs b/catalyst-gateway/bin/src/cli.rs index 9cae6c28f..d935256eb 100644 --- a/catalyst-gateway/bin/src/cli.rs +++ b/catalyst-gateway/bin/src/cli.rs @@ -46,7 +46,7 @@ impl Cli { /// - Failed to initialize the logger with the specified log level. /// - Failed to create a new `State` with the provided database URL. /// - Failed to run the service on the specified address. - pub(crate) async fn exec(self) -> Result<(), Box> { + pub(crate) async fn exec(self) -> anyhow::Result<()> { match self { Self::Run(settings) => { logger::init(settings.log_level)?; diff --git a/catalyst-gateway/bin/src/follower.rs b/catalyst-gateway/bin/src/follower.rs index e668760c1..9551f6b98 100644 --- a/catalyst-gateway/bin/src/follower.rs +++ b/catalyst-gateway/bin/src/follower.rs @@ -1,5 +1,5 @@ //! Logic for orchestrating followers -use std::{error::Error, path::PathBuf, str::FromStr, sync::Arc}; +use std::{path::PathBuf, str::FromStr, sync::Arc}; /// Handler for follower tasks, allows for control over spawned follower threads pub type ManageTasks = JoinHandle<()>; @@ -30,7 +30,7 @@ const DATA_NOT_STALE: i64 = 1; pub(crate) async fn start_followers( configs: (Vec, FollowerMeta), db: Arc, data_refresh_tick: u64, check_config_tick: u64, machine_id: String, -) -> Result<(), Box> { +) -> anyhow::Result<()> { // spawn followers and obtain thread handlers for control and future cancellation let follower_tasks = spawn_followers( configs.clone(), @@ -76,7 +76,7 @@ pub(crate) async fn start_followers( ) .await?; }, - None => return Err("Config has been deleted...".into()), + None => return Err(anyhow::anyhow!("Config has been deleted...")), } Ok(()) @@ -86,7 +86,7 @@ pub(crate) async fn start_followers( async fn spawn_followers( configs: (Vec, FollowerMeta), db: Arc, data_refresh_tick: u64, machine_id: String, -) -> Result, Box> { +) -> anyhow::Result> { let snapshot_path = configs.1.mithril_snapshot_path; let mut follower_tasks = Vec::new(); @@ -151,7 +151,7 @@ async fn spawn_followers( /// it left off. If there was no previous follower, start indexing from genesis point. async fn find_last_update_point( db: Arc, network: &String, -) -> Result<(Option, Option, Option), Box> { +) -> anyhow::Result<(Option, Option, Option)> { let (slot_no, block_hash, last_updated) = match db.last_updated_metadata(network.to_string()).await { Ok((slot_no, block_hash, last_updated)) => { @@ -176,11 +176,11 @@ async fn find_last_update_point( async fn init_follower( network: Network, relay: &str, start_from: (Option, Option), db: Arc, machine_id: MachineId, snapshot: &str, -) -> Result> { +) -> anyhow::Result { let mut follower = follower_connection(start_from, snapshot, network, relay).await?; - let genesis_values = - network_genesis_values(&network).ok_or("Obtaining genesis values failed")?; + let genesis_values = network_genesis_values(&network) + .ok_or(anyhow::anyhow!("Obtaining genesis values failed"))?; let task = tokio::spawn(async move { loop { @@ -310,7 +310,7 @@ async fn init_follower( async fn follower_connection( start_from: (Option, Option), snapshot: &str, network: Network, relay: &str, -) -> Result> { +) -> anyhow::Result { let mut follower_cfg = if start_from.0.is_none() || start_from.1.is_none() { // start from genesis, no previous followers, hence no starting points. FollowerConfigBuilder::default() @@ -320,8 +320,15 @@ async fn follower_connection( // start from given point FollowerConfigBuilder::default() .follow_from(Point::new( - start_from.0.ok_or("Slot number not present")?.try_into()?, - hex::decode(start_from.1.ok_or("Block Hash not present")?)?, + start_from + .0 + .ok_or(anyhow::anyhow!("Slot number not present"))? + .try_into()?, + hex::decode( + start_from + .1 + .ok_or(anyhow::anyhow!("Block Hash not present"))?, + )?, )) .mithril_snapshot_path(PathBuf::from(snapshot)) .build() diff --git a/catalyst-gateway/bin/src/logger.rs b/catalyst-gateway/bin/src/logger.rs index bb5ac8c30..dde8bbea1 100644 --- a/catalyst-gateway/bin/src/logger.rs +++ b/catalyst-gateway/bin/src/logger.rs @@ -1,6 +1,6 @@ //! Setup for logging for the service. use clap::ValueEnum; -use tracing::{level_filters::LevelFilter, subscriber::SetGlobalDefaultError}; +use tracing::level_filters::LevelFilter; use tracing_subscriber::{ fmt::{format::FmtSpan, time}, FmtSubscriber, @@ -45,7 +45,7 @@ impl From for tracing::log::LevelFilter { } /// Initialize the tracing subscriber -pub(crate) fn init(log_level: LogLevel) -> Result<(), SetGlobalDefaultError> { +pub(crate) fn init(log_level: LogLevel) -> anyhow::Result<()> { let subscriber = FmtSubscriber::builder() .json() .with_max_level(LevelFilter::from_level(log_level.into())) @@ -64,5 +64,6 @@ pub(crate) fn init(log_level: LogLevel) -> Result<(), SetGlobalDefaultError> { // Logging is globally disabled by default, so globally enable it to the required level. tracing::log::set_max_level(log_level.into()); - tracing::subscriber::set_global_default(subscriber) + tracing::subscriber::set_global_default(subscriber)?; + Ok(()) } diff --git a/catalyst-gateway/bin/src/util.rs b/catalyst-gateway/bin/src/util.rs index 5fefd23f4..69be0ef63 100644 --- a/catalyst-gateway/bin/src/util.rs +++ b/catalyst-gateway/bin/src/util.rs @@ -1,7 +1,5 @@ //! Block stream parsing and filtering utils -use std::error::Error; - use cryptoxide::{blake2b::Blake2b, digest::Digest}; use pallas::ledger::{ primitives::conway::{StakeCredential, VKeyWitness}, @@ -120,7 +118,7 @@ pub fn extract_stake_credentials_from_certs( /// except for credentials (i.e. keys or scripts) which are 28-byte long (or 224 bits) pub fn extract_hashed_witnesses( witnesses: &[VKeyWitness], -) -> Result, Box> { +) -> anyhow::Result> { let mut hashed_witnesses = Vec::new(); for witness in witnesses { let pub_key_bytes: [u8; 32] = witness.vkey.as_slice().try_into()?; @@ -141,7 +139,7 @@ pub fn extract_hashed_witnesses( /// to identify the correct stake credential key. pub fn find_matching_stake_credential( witnesses: &[(WitnessPubKey, WitnessHash)], stake_credentials: &[String], -) -> Result<(StakeCredentialKey, StakeCredentialHash), Box> { +) -> anyhow::Result<(StakeCredentialKey, StakeCredentialHash)> { stake_credentials .iter() .zip(witnesses.iter()) @@ -152,7 +150,7 @@ pub fn find_matching_stake_credential( None } }) - .ok_or( - "No stake credential from the certificates matches any of the witness pub keys".into(), - ) + .ok_or(anyhow::anyhow!( + "No stake credential from the certificates matches any of the witness pub keys" + )) } From 433ef1803846e3871cd0b346093c1117b7c4d84d Mon Sep 17 00:00:00 2001 From: Lucio Baglione Date: Mon, 25 Mar 2024 12:17:14 +0100 Subject: [PATCH 06/35] feat: add DynamicPadding widget. (#324) * feat: Add `DynamicPadding` widget. * refactor: Adjust sizes to material design standards. * docs: DynamicPadding usage. * test: Add tests for `OCDynamicPadding` widget. * refactor: Use `ResponsiveBuilder` to calculate padding values. * chore: Rename `DynamicPadding` to `ResponsivePadding`. --- .../lib/src/catalyst_voices_shared.dart | 1 + .../responsive_padding.dart | 63 +++++++++++++++++++ .../test/src/catalyst_voices_shared_test.dart | 1 - .../platform_aware_builder_test.dart | 0 .../test/src/responsive_padding_test.dart | 54 ++++++++++++++++ 5 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive_padding/responsive_padding.dart delete mode 100644 catalyst_voices/packages/catalyst_voices_shared/test/src/catalyst_voices_shared_test.dart rename catalyst_voices/packages/catalyst_voices_shared/test/src/{platform_aware_builder => }/platform_aware_builder_test.dart (100%) create mode 100644 catalyst_voices/packages/catalyst_voices_shared/test/src/responsive_padding_test.dart diff --git a/catalyst_voices/packages/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart b/catalyst_voices/packages/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart index 1a8d7ac99..33e111668 100644 --- a/catalyst_voices/packages/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart +++ b/catalyst_voices/packages/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart @@ -1,2 +1,3 @@ export 'platform/catalyst_platform.dart'; export 'platform_aware_builder/platform_aware_builder.dart'; +export 'responsive_padding/responsive_padding.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive_padding/responsive_padding.dart b/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive_padding/responsive_padding.dart new file mode 100644 index 000000000..5a26ee55c --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive_padding/responsive_padding.dart @@ -0,0 +1,63 @@ +import 'package:catalyst_voices_shared/src/responsive_builder/responsive_breakpoint_key.dart'; +import 'package:catalyst_voices_shared/src/responsive_builder/responsive_builder.dart'; +import 'package:flutter/widgets.dart'; + +// A [ResponsivePadding] is a StatelessWidget that applies a padding based on +// the current screen size. +// +// The widget wraps its [child] in a ResponsiveBuilder that calculates the +// proper padding value based on the screen size and wraps it again in a Padding +// to display the selected padding value. +// +// The possible arguments are [xs], [sm], [md], [lg], [other] following the +// Material design standards and the ResponsiveBuilder arguments. +// Each screen size has a default value to simplify widget usage. +// +// Example usage: +// +// ```dart +// ResponsivePadding( +// xs: const EdgeInsets.all(4.0), +// sm: const EdgeInsets.all(6.0), +// md: const EdgeInsets.only(top: 6.0), +// lg: const EdgeInsets.symmetric(vertical: 15.0), +// child: Text('This is an example text with dynamic padding.') +// ); +// ``` + +class ResponsivePadding extends StatelessWidget { + final Widget child; + final Map _paddings; + + ResponsivePadding({ + super.key, + required this.child, + EdgeInsets xs = const EdgeInsets.all(4), + EdgeInsets sm = const EdgeInsets.all(8), + EdgeInsets md = const EdgeInsets.all(12), + EdgeInsets lg = const EdgeInsets.all(16), + EdgeInsets other = const EdgeInsets.all(8), + }) : _paddings = { + ResponsiveBreakpointKey.xs: xs, + ResponsiveBreakpointKey.sm: sm, + ResponsiveBreakpointKey.md: md, + ResponsiveBreakpointKey.lg: lg, + ResponsiveBreakpointKey.other: other, + }; + + @override + Widget build(BuildContext context) { + return ResponsiveBuilder( + builder: (context, padding) => Padding( + padding: padding!, + child: child, + ), + xs: _paddings[ResponsiveBreakpointKey.xs], + sm: _paddings[ResponsiveBreakpointKey.sm], + md: _paddings[ResponsiveBreakpointKey.md], + lg: _paddings[ResponsiveBreakpointKey.lg], + other: _paddings[ResponsiveBreakpointKey.other]!, + ); + } + +} diff --git a/catalyst_voices/packages/catalyst_voices_shared/test/src/catalyst_voices_shared_test.dart b/catalyst_voices/packages/catalyst_voices_shared/test/src/catalyst_voices_shared_test.dart deleted file mode 100644 index 7430153ff..000000000 --- a/catalyst_voices/packages/catalyst_voices_shared/test/src/catalyst_voices_shared_test.dart +++ /dev/null @@ -1 +0,0 @@ -export 'platform_aware_builder/platform_aware_builder_test.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_shared/test/src/platform_aware_builder/platform_aware_builder_test.dart b/catalyst_voices/packages/catalyst_voices_shared/test/src/platform_aware_builder_test.dart similarity index 100% rename from catalyst_voices/packages/catalyst_voices_shared/test/src/platform_aware_builder/platform_aware_builder_test.dart rename to catalyst_voices/packages/catalyst_voices_shared/test/src/platform_aware_builder_test.dart diff --git a/catalyst_voices/packages/catalyst_voices_shared/test/src/responsive_padding_test.dart b/catalyst_voices/packages/catalyst_voices_shared/test/src/responsive_padding_test.dart new file mode 100644 index 000000000..972173b82 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_shared/test/src/responsive_padding_test.dart @@ -0,0 +1,54 @@ +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + + Widget buildApp(Size size) => MediaQuery( + data: MediaQueryData(size: size), + child: MaterialApp( + home: Scaffold( + body: ResponsivePadding( + xs: const EdgeInsets.all(2), + sm: const EdgeInsets.symmetric(vertical: 3), + md: const EdgeInsets.symmetric(horizontal: 4), + lg: const EdgeInsets.only(top: 5), + child: const Text('Test data!'), + ), + ), + ), + ); + group('Test screen sizes', () { + + final sizesToTest = { + 280.0: const EdgeInsets.all(2), + 620.0: const EdgeInsets.symmetric(vertical: 3), + 1280.0: const EdgeInsets.symmetric(horizontal: 4), + 1600.0: const EdgeInsets.only(top: 5), + }; + + for (final entry in sizesToTest.entries) { + testWidgets( + 'ResponsivePadding adapts to screen of width $entry.key', + (tester) async { + await tester.pumpWidget( + buildApp(Size.fromWidth(entry.key)), + ); + + final testedElement = find.byType(Text); + // Verify the Widget renders properly + expect(testedElement, findsOneWidget); + final paddingWidget = tester.widget( + find.ancestor( + of: testedElement, + matching: find.byType(Padding), + ), + ); + // Check that the padding corresponds + expect(paddingWidget.padding, entry.value); + } + ); + } + + }); +} From 5e144cc25329a9af3e6f69fe4dc89ac36bc1849d Mon Sep 17 00:00:00 2001 From: Alex Pozhylenkov Date: Mon, 25 Mar 2024 17:32:08 +0200 Subject: [PATCH 07/35] feat: Add mithril snapshot loader (#341) * add mithril snapshot loader * wip * fix spelling * bump CI version * wip * wip * wip * wip --- catalyst-gateway/Earthfile | 2 +- catalyst-gateway/event-db/Earthfile | 10 +-- .../mithril_snapshot_loader/Earthfile | 16 +++++ .../mithril_snapshot_loader/loader.py | 68 +++++++++++++++++++ .../tests/schema-mismatch/Earthfile | 6 +- .../schema-mismatch/{README.md => Readme.md} | 0 docs/Earthfile | 6 +- 7 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 catalyst-gateway/mithril_snapshot_loader/Earthfile create mode 100755 catalyst-gateway/mithril_snapshot_loader/loader.py rename catalyst-gateway/tests/schema-mismatch/{README.md => Readme.md} (100%) diff --git a/catalyst-gateway/Earthfile b/catalyst-gateway/Earthfile index a61739981..b0664024f 100644 --- a/catalyst-gateway/Earthfile +++ b/catalyst-gateway/Earthfile @@ -4,7 +4,7 @@ VERSION --try --global-cache 0.7 # Set up our target toolchains, and copy our files. builder: - DO github.com/input-output-hk/catalyst-ci/earthly/rust:v3.0.0+SETUP + DO github.com/input-output-hk/catalyst-ci/earthly/rust:v2.10.0+SETUP COPY --dir .cargo .config Cargo.* clippy.toml deny.toml rustfmt.toml bin crates tests . COPY --dir ./event-db/queries ./event-db/queries diff --git a/catalyst-gateway/event-db/Earthfile b/catalyst-gateway/event-db/Earthfile index fda6ba288..eb32b91bb 100644 --- a/catalyst-gateway/event-db/Earthfile +++ b/catalyst-gateway/event-db/Earthfile @@ -9,7 +9,7 @@ VERSION 0.7 # Internal: builder is our Event db builder target. Prepares all necessary artifacts. # CI target : dependency builder: - DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v3.0.0+BUILDER \ + DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.10.0+BUILDER \ --sqlfluff_cfg=./../../+repo-config/repo/.sqlfluff COPY ./../../+repo-config/repo/.sqlfluff . @@ -24,7 +24,7 @@ builder: check: FROM +builder - DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v3.0.0+CHECK + DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.10.0+CHECK # format all SQL files in the current project. Local developers tool. @@ -32,15 +32,15 @@ check: format: LOCALLY - DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v3.0.0+FORMAT --src=$(echo ${PWD}/../../) + DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.10.0+FORMAT --src=$(echo ${PWD}/../../) # build - an event db docker image. # CI target : true build: FROM +builder - DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v3.0.0+BUILD --image_name=event-db - DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v3.0.0+DOCS --image_name=event-db + DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.10.0+BUILD --image_name=event-db + DO github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.10.0+DOCS --image_name=event-db # test the event db database schema # CI target : true diff --git a/catalyst-gateway/mithril_snapshot_loader/Earthfile b/catalyst-gateway/mithril_snapshot_loader/Earthfile new file mode 100644 index 000000000..00552b1e3 --- /dev/null +++ b/catalyst-gateway/mithril_snapshot_loader/Earthfile @@ -0,0 +1,16 @@ +VERSION 0.7 + +# cspell: words tqdm + +load-mithril-snapshot: + ARG network="preprod" + + FROM github.com/input-output-hk/catalyst-ci/earthly/python:v2.10.0+python-base + + RUN pip3 install requests + RUN pip3 install tqdm + + COPY loader.py /scripts + + RUN /scripts/loader.py --network=$network --out="snapshot" + SAVE ARTIFACT ./snapshot snapshot \ No newline at end of file diff --git a/catalyst-gateway/mithril_snapshot_loader/loader.py b/catalyst-gateway/mithril_snapshot_loader/loader.py new file mode 100755 index 000000000..e75790c7c --- /dev/null +++ b/catalyst-gateway/mithril_snapshot_loader/loader.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 + +# cspell: words tqdm + +import argparse +import rich +from rich import print +import os +import enum +import requests +from tqdm import tqdm + +# This script loads latest mithril snapshot archive + + +class NetworkType(enum.Enum): + Mainnet = "mainnet" + Testnet = "testnet" + Preprod = "preprod" + Preview = "preview" + + def get_aggregator_url(self): + match self: + case NetworkType.Mainnet: + return "https://aggregator.release-mainnet.api.mithril.network/aggregator/artifact/snapshots" + case NetworkType.Testnet: + return "https://aggregator.testing-preview.api.mithril.network/aggregator/artifact/snapshots" + case NetworkType.Preprod: + return "https://aggregator.release-preprod.api.mithril.network/aggregator/artifact/snapshots" + case NetworkType.Preview: + return "https://aggregator.pre-release-preview.api.mithril.network/aggregator/artifact/snapshots" + + +def load_snapshot(network_type: NetworkType, out: str): + resp = requests.get(network_type.get_aggregator_url()) + # getting the latest snapshot from the list, it's always on the first position + snapshot_info = resp.json()[0] + + location = snapshot_info["locations"][0] + # load archive + resp = requests.get(location, stream=True) + content_size = int(resp.headers.get("Content-length")) + with open(out, "wb") as file: + with tqdm(total=content_size) as t: + chunk_size = 1024 + for chunk in resp.iter_content(chunk_size=chunk_size): + file.write(chunk) + t.update(chunk_size) + + +def main(): + # Force color output in CI + rich.reconfigure(color_system="256") + + parser = argparse.ArgumentParser(description="Mithril snapshot loading.") + parser.add_argument( + "--network", + type=NetworkType, + help="Cardano network type, available options: ['mainnet', 'testnet', 'preprod', 'preview']", + ) + parser.add_argument("--out", help="Out file name of the snapshot archive") + args = parser.parse_args() + + load_snapshot(args.network, args.out) + + +if __name__ == "__main__": + main() diff --git a/catalyst-gateway/tests/schema-mismatch/Earthfile b/catalyst-gateway/tests/schema-mismatch/Earthfile index d2fdfb956..830deaf28 100644 --- a/catalyst-gateway/tests/schema-mismatch/Earthfile +++ b/catalyst-gateway/tests/schema-mismatch/Earthfile @@ -1,10 +1,10 @@ VERSION --global-cache 0.7 builder: - FROM github.com/input-output-hk/catalyst-ci/earthly/python:v2.9.2+python-base + FROM github.com/input-output-hk/catalyst-ci/earthly/python:v2.10.0+python-base - COPY --dir ./schema_mismatch README.md . - DO github.com/input-output-hk/catalyst-ci/earthly/python:v2.9.2+BUILDER + COPY --dir ./schema_mismatch . + DO github.com/input-output-hk/catalyst-ci/earthly/python:v2.10.0+BUILDER package-tester: FROM +builder diff --git a/catalyst-gateway/tests/schema-mismatch/README.md b/catalyst-gateway/tests/schema-mismatch/Readme.md similarity index 100% rename from catalyst-gateway/tests/schema-mismatch/README.md rename to catalyst-gateway/tests/schema-mismatch/Readme.md diff --git a/docs/Earthfile b/docs/Earthfile index 135fcbfb7..3ece828fe 100644 --- a/docs/Earthfile +++ b/docs/Earthfile @@ -6,7 +6,7 @@ VERSION 0.7 # Copy all the source we need to build the docs src: # Common src setup - DO github.com/input-output-hk/catalyst-ci/earthly/docs:v3.0.0+SRC + DO github.com/input-output-hk/catalyst-ci/earthly/docs:v2.10.0+SRC # Now copy into that any artifacts we pull from the builds. COPY --dir ../+repo-docs/repo /docs/includes @@ -21,12 +21,12 @@ src: docs: FROM +src - DO github.com/input-output-hk/catalyst-ci/earthly/docs:v3.0.0+BUILD + DO github.com/input-output-hk/catalyst-ci/earthly/docs:v2.10.0+BUILD # Make a locally runable container that can serve the docs. local: # Build a self contained service to show built docs locally. - DO github.com/input-output-hk/catalyst-ci/earthly/docs:v3.0.0+PACKAGE + DO github.com/input-output-hk/catalyst-ci/earthly/docs:v2.10.0+PACKAGE # Copy the static pages into the container COPY +docs/ /usr/share/nginx/html From 6f0d27ba4c84538cfbbdd01a1a7beb7b985b8a27 Mon Sep 17 00:00:00 2001 From: Stefano Cunego <93382903+kukkok3@users.noreply.github.com> Date: Mon, 25 Mar 2024 16:47:41 +0100 Subject: [PATCH 08/35] feat: adds flutter unit tests and report (#343) * feat: adds flutter unit tests and report * fix: cspell * feat: update ci version * feat: adds tojson lib * feat: adds lib output * fix: remove test report leftover * fix: cspell --- .config/dictionaries/project.dic | 10 ++++++---- .github/workflows/generate-allure-report.yml | 14 +++++++++++++- catalyst_voices/Earthfile | 13 +++++++++++-- catalyst_voices/test_driver/Earthfile | 2 +- ...ntegration_test.dart => integration_tests.dart} | 0 5 files changed, 31 insertions(+), 8 deletions(-) rename catalyst_voices/test_driver/{integration_test.dart => integration_tests.dart} (100%) diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index 6f0c9e4a5..3fd135d47 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -28,6 +28,7 @@ coti cryptoxide Cunego Cupertino +dalek dbsync delegators DIND @@ -65,6 +66,8 @@ Joaquín jorm jormungandr Jörmungandr +junitreport +Keyhash lcov Leshiy lintfix @@ -114,6 +117,7 @@ rxdart saibatizoku schemathesis Schemathesis +Scripthash seckey sendfile slotno @@ -128,11 +132,13 @@ testunit thiserror thollander timelike +tojunit Traceback TXNZD unmanaged UTXO vitss +vkey voteplan voteplans wallclock @@ -144,7 +150,3 @@ xctest xctestrun xcworkspace yoroi -dalek -Keyhash -Scripthash -vkey \ No newline at end of file diff --git a/.github/workflows/generate-allure-report.yml b/.github/workflows/generate-allure-report.yml index e7b27b6e8..d77d61110 100644 --- a/.github/workflows/generate-allure-report.yml +++ b/.github/workflows/generate-allure-report.yml @@ -36,7 +36,7 @@ jobs: aws_region: ${{ env.AWS_REGION }} earthly_runner_secret: ${{ secrets.EARTHLY_RUNNER_SECRET }} - - name: Get unit test report + - name: Get catalyst gateway unit test report uses: input-output-hk/catalyst-ci/actions/run@master if: always() continue-on-error: true @@ -60,6 +60,18 @@ jobs: runner_address: ${{ secrets.EARTHLY_SATELLITE_ADDRESS }} artifact: "false" + - name: Get flutter unit test report + uses: input-output-hk/catalyst-ci/actions/run@master + if: always() + continue-on-error: true + with: + earthfile: ./catalyst_voices/ + flags: + targets: test-unit + target_flags: + runner_address: ${{ secrets.EARTHLY_SATELLITE_ADDRESS }} + artifact: "false" + - name: Collect and upload test reports uses: actions/upload-artifact@v4 if: always() diff --git a/catalyst_voices/Earthfile b/catalyst_voices/Earthfile index d8319153a..414ecb509 100644 --- a/catalyst_voices/Earthfile +++ b/catalyst_voices/Earthfile @@ -12,13 +12,13 @@ deps: WORKDIR /frontend GIT CLONE https://github.com/flutter/flutter.git /usr/local/flutter - ENV PATH="/usr/local/flutter/bin:/usr/local/flutter/bin/cache/dart-sdk/bin:${PATH}" - + ENV PATH="/usr/local/flutter/bin:/usr/local/flutter/bin/cache/dart-sdk/bin:$HOME/.pub-cache/bin:${PATH}" RUN flutter channel stable RUN flutter upgrade RUN flutter --version RUN flutter doctor -v RUN flutter config --enable-web + RUN dart pub global activate junitreport src: FROM +deps @@ -35,6 +35,15 @@ build: WORKDIR /frontend/build SAVE ARTIFACT web /web AS LOCAL web +test-unit: + FROM +build + WORKDIR /frontend + TRY + RUN flutter test --reporter expanded . --machine | tojunit --output flutter.junit-report.xml + FINALLY + SAVE ARTIFACT flutter.junit-report.xml AS LOCAL flutter-unit-tests.junit-report.xml + END + package: FROM nginx:alpine3.18 ARG tag='latest' diff --git a/catalyst_voices/test_driver/Earthfile b/catalyst_voices/test_driver/Earthfile index 65f6bec0a..6b240d5da 100644 --- a/catalyst_voices/test_driver/Earthfile +++ b/catalyst_voices/test_driver/Earthfile @@ -19,7 +19,7 @@ integration-test-web: # LET driver = "msedgedriver" #END RUN ($driver --port=$driver_port > $driver.log &) && \ - flutter drive --driver=test_driver/integration_test.dart \ + flutter drive --driver=test_driver/integration_tests.dart \ --target=integration_test/main.dart \ --flavor development -d web-server --profile \ --browser-name=$browser --driver-port=$driver_port || echo fail > fail diff --git a/catalyst_voices/test_driver/integration_test.dart b/catalyst_voices/test_driver/integration_tests.dart similarity index 100% rename from catalyst_voices/test_driver/integration_test.dart rename to catalyst_voices/test_driver/integration_tests.dart From de790280fdfe4795152bc197dfcf05e604966fca Mon Sep 17 00:00:00 2001 From: Lucio Baglione Date: Tue, 26 Mar 2024 12:00:35 +0100 Subject: [PATCH 09/35] feat: Add `ResponsiveChild`. (#342) * feat: Add ResponsiveDynamicCgild widget. * chore: Rename `ResponsiveChild` widget. * test: Add test cases for `ResponsiveChild`. * docs: Add description for `ResponsiveChild`. * refactor: `ResponsiveChild` manages `WidgetBuilder` instead of `Widget`. * chore: Reorder folders. --------- Co-authored-by: Oleksandr Prokhorenko --- .../lib/src/catalyst_voices_shared.dart | 4 +- .../responsive_breakpoint_key.dart | 0 .../responsive_builder.dart | 2 +- .../lib/src/responsive/responsive_child.dart | 64 +++++++++++++ .../responsive_padding.dart | 4 +- .../test/src/platform_aware_builder_test.dart | 2 +- .../test/src/responsive_builder_test.dart | 2 +- .../test/src/responsive_child_test.dart | 92 +++++++++++++++++++ 8 files changed, 164 insertions(+), 6 deletions(-) rename catalyst_voices/packages/catalyst_voices_shared/lib/src/{responsive_builder => responsive}/responsive_breakpoint_key.dart (100%) rename catalyst_voices/packages/catalyst_voices_shared/lib/src/{responsive_builder => responsive}/responsive_builder.dart (96%) create mode 100644 catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive/responsive_child.dart rename catalyst_voices/packages/catalyst_voices_shared/lib/src/{responsive_padding => responsive}/responsive_padding.dart (91%) create mode 100644 catalyst_voices/packages/catalyst_voices_shared/test/src/responsive_child_test.dart diff --git a/catalyst_voices/packages/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart b/catalyst_voices/packages/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart index 33e111668..de77e2138 100644 --- a/catalyst_voices/packages/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart +++ b/catalyst_voices/packages/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart @@ -1,3 +1,5 @@ export 'platform/catalyst_platform.dart'; export 'platform_aware_builder/platform_aware_builder.dart'; -export 'responsive_padding/responsive_padding.dart'; +export 'responsive/responsive_builder.dart'; +export 'responsive/responsive_child.dart'; +export 'responsive/responsive_padding.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive_builder/responsive_breakpoint_key.dart b/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive/responsive_breakpoint_key.dart similarity index 100% rename from catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive_builder/responsive_breakpoint_key.dart rename to catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive/responsive_breakpoint_key.dart diff --git a/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive_builder/responsive_builder.dart b/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive/responsive_builder.dart similarity index 96% rename from catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive_builder/responsive_builder.dart rename to catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive/responsive_builder.dart index 8aad594e3..15a7b45f1 100644 --- a/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive_builder/responsive_builder.dart +++ b/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive/responsive_builder.dart @@ -1,4 +1,4 @@ -import 'package:catalyst_voices_shared/src/responsive_builder/responsive_breakpoint_key.dart'; +import 'package:catalyst_voices_shared/src/responsive/responsive_breakpoint_key.dart'; import 'package:flutter/widgets.dart'; // A [ResponsiveBuilder] is a StatelessWidget that is aware about the current diff --git a/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive/responsive_child.dart b/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive/responsive_child.dart new file mode 100644 index 000000000..c7330ce7a --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive/responsive_child.dart @@ -0,0 +1,64 @@ +import 'package:catalyst_voices_shared/src/responsive/responsive_breakpoint_key.dart'; +import 'package:catalyst_voices_shared/src/responsive/responsive_builder.dart'; +import 'package:flutter/material.dart'; + +// A [ResponsiveChild] is a StatelessWidget that selects a WidgetBuilder based +// on the current screen size and execute it. +// This is a simple wrapper around ResponsiveBuilder to simplify development and +// make it explicit for a reader. +// +// The possible arguments are [xs], [sm], [md], [lg], [other] following the +// the ResponsiveBuilder arguments. +// [other] is required and acts as fallback. +// +// Example usage: +// +// ```dart +// ResponsiveChild( +// xs: (context) => const Text('Simple text for extra small screens.'), +// sm: (context) => const Padding( +// padding: EdgeInsets.all(50), +// child: Text('Text with padding for small screens.'), +// ), +// md: (context) => const Column( +// children: [ +// Text('This is'), +// Text('a set'), +// Text('of Texts'), +// Text('for medium screens.'), +// ], +// ), +// other: (context) => const Text('The fallback widget.'), +// ); +// ``` + +class ResponsiveChild extends StatelessWidget { + final Map _widgets; + + ResponsiveChild({ + super.key, + WidgetBuilder? xs, + WidgetBuilder? sm, + WidgetBuilder? md, + WidgetBuilder? lg, + required WidgetBuilder other, + }) : _widgets = { + ResponsiveBreakpointKey.xs: xs, + ResponsiveBreakpointKey.sm: sm, + ResponsiveBreakpointKey.md: md, + ResponsiveBreakpointKey.lg: lg, + ResponsiveBreakpointKey.other: other, + }; + + @override + Widget build(BuildContext context) { + return ResponsiveBuilder( + builder: (context, childBuilder) => childBuilder!(context), + xs: _widgets[ResponsiveBreakpointKey.xs], + sm: _widgets[ResponsiveBreakpointKey.sm], + md: _widgets[ResponsiveBreakpointKey.md], + lg: _widgets[ResponsiveBreakpointKey.lg], + other: _widgets[ResponsiveBreakpointKey.other]!, + ); + } +} diff --git a/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive_padding/responsive_padding.dart b/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive/responsive_padding.dart similarity index 91% rename from catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive_padding/responsive_padding.dart rename to catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive/responsive_padding.dart index 5a26ee55c..2007d5029 100644 --- a/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive_padding/responsive_padding.dart +++ b/catalyst_voices/packages/catalyst_voices_shared/lib/src/responsive/responsive_padding.dart @@ -1,5 +1,5 @@ -import 'package:catalyst_voices_shared/src/responsive_builder/responsive_breakpoint_key.dart'; -import 'package:catalyst_voices_shared/src/responsive_builder/responsive_builder.dart'; +import 'package:catalyst_voices_shared/src/responsive/responsive_breakpoint_key.dart'; +import 'package:catalyst_voices_shared/src/responsive/responsive_builder.dart'; import 'package:flutter/widgets.dart'; // A [ResponsivePadding] is a StatelessWidget that applies a padding based on diff --git a/catalyst_voices/packages/catalyst_voices_shared/test/src/platform_aware_builder_test.dart b/catalyst_voices/packages/catalyst_voices_shared/test/src/platform_aware_builder_test.dart index fac277aba..0cec3272b 100644 --- a/catalyst_voices/packages/catalyst_voices_shared/test/src/platform_aware_builder_test.dart +++ b/catalyst_voices/packages/catalyst_voices_shared/test/src/platform_aware_builder_test.dart @@ -1,4 +1,4 @@ -import 'package:catalyst_voices_shared/src/catalyst_voices_shared.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_shared/test/src/responsive_builder_test.dart b/catalyst_voices/packages/catalyst_voices_shared/test/src/responsive_builder_test.dart index 89ce59542..4cf620ce0 100644 --- a/catalyst_voices/packages/catalyst_voices_shared/test/src/responsive_builder_test.dart +++ b/catalyst_voices/packages/catalyst_voices_shared/test/src/responsive_builder_test.dart @@ -1,4 +1,4 @@ -import 'package:catalyst_voices_shared/src/responsive_builder/responsive_builder.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_shared/test/src/responsive_child_test.dart b/catalyst_voices/packages/catalyst_voices_shared/test/src/responsive_child_test.dart new file mode 100644 index 000000000..eeb1cc5f5 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_shared/test/src/responsive_child_test.dart @@ -0,0 +1,92 @@ +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildApp(Size size) => MediaQuery( + data: MediaQueryData(size: size), + child: MaterialApp( + home: Scaffold( + body: ResponsiveChild( + xs: (context) => const Text('Simple text for extra small screens.'), + sm: (context) => const Padding( + padding: EdgeInsets.all(50), + child: Text('Text with padding for small screens.'), + ), + md: (context) => const Column( + children: [ + Text('This is'), + Text('a set'), + Text('of Texts'), + Text('for medium screens.'), + ], + ), + other: (context) => const Text('The fallback widget.'), + ), + ), + ), + ); + + group('Test screen sizes', () { + testWidgets( + 'ResponsiveChild outputs a text for extra-small screens', + (tester) async { + await tester.pumpWidget( + buildApp(const Size.fromWidth(280)), + ); + final testedElement = find.byType(Text); + expect(testedElement, findsOneWidget); + expect( + find.text('Simple text for extra small screens.'), + findsOneWidget, + ); + } + ); + testWidgets( + 'ResponsiveChild outputs a text with padding for small screens', + (tester) async { + await tester.pumpWidget( + buildApp(const Size.fromWidth(620)), + ); + final testedElement = find.byType(Text); + expect(testedElement, findsOneWidget); + expect( + find.text('Text with padding for small screens.'), + findsOneWidget, + ); + final paddingWidget = tester.widget( + find.ancestor( + of: testedElement, + matching: find.byType(Padding), + ), + ); + expect(paddingWidget.padding, const EdgeInsets.all(50)); + } + ); + testWidgets( + 'ResponsiveChild outputs four texts for medium screens', + (tester) async { + await tester.pumpWidget( + buildApp(const Size.fromWidth(1280)), + ); + final testedElements = find.byType(Text); + expect(testedElements, findsExactly(4)); + expect(find.text('This is'), findsOneWidget); + expect(find.text('a set'), findsOneWidget); + expect(find.text('of Texts'), findsOneWidget); + expect(find.text('for medium screens.'), findsOneWidget); + } + ); + testWidgets( + 'ResponsiveChild fallback to other for large screens', + (tester) async { + await tester.pumpWidget( + buildApp(const Size.fromWidth(1600)), + ); + final testedElements = find.byType(Text); + expect(testedElements, findsOneWidget); + expect(find.text('The fallback widget.'), findsOneWidget); + } + ); + }); +} From e1423b8333b86a30f0d7e7a7cf6c0545f12b31c1 Mon Sep 17 00:00:00 2001 From: Alex Pozhylenkov Date: Tue, 26 Mar 2024 15:31:37 +0200 Subject: [PATCH 10/35] feat: Update cat-gateway config handling, preparation for integration tests (#344) * add mithril snapshot loader * wip * fix spelling * bump CI version * wip * wip * rename schema_mismatch dir to api_tests * wip * wip * refactor * wip * fix * wip * wip * add test_utxo.py * update config table * fix CI * wip * wip --- catalyst-gateway/bin/src/cli.rs | 2 +- catalyst-gateway/bin/src/event_db/config.rs | 89 ++++++++++--------- catalyst-gateway/bin/src/follower.rs | 20 ++--- .../event-db/migrations/V1__config_tables.sql | 41 ++++++--- .../event-db/queries/config/select_config.sql | 12 ++- .../{schema-mismatch => api_tests}/Earthfile | 16 +--- .../{schema-mismatch => api_tests}/Readme.md | 0 .../tests/api_tests/api_tests/__init__.py | 37 ++++++++ .../test_schema_mismatch_behavior.py | 45 +++++++--- .../tests/api_tests/api_tests/test_utxo.py | 5 ++ .../docker-compose.yml | 0 .../poetry.lock | 0 .../pyproject.toml | 4 +- .../schema_mismatch/__init__.py | 45 ---------- 14 files changed, 176 insertions(+), 140 deletions(-) rename catalyst-gateway/tests/{schema-mismatch => api_tests}/Earthfile (59%) rename catalyst-gateway/tests/{schema-mismatch => api_tests}/Readme.md (100%) create mode 100644 catalyst-gateway/tests/api_tests/api_tests/__init__.py rename catalyst-gateway/tests/{schema-mismatch/schema_mismatch => api_tests/api_tests}/test_schema_mismatch_behavior.py (55%) create mode 100644 catalyst-gateway/tests/api_tests/api_tests/test_utxo.py rename catalyst-gateway/tests/{schema-mismatch => api_tests}/docker-compose.yml (100%) rename catalyst-gateway/tests/{schema-mismatch => api_tests}/poetry.lock (100%) rename catalyst-gateway/tests/{schema-mismatch => api_tests}/pyproject.toml (88%) delete mode 100644 catalyst-gateway/tests/schema-mismatch/schema_mismatch/__init__.py diff --git a/catalyst-gateway/bin/src/cli.rs b/catalyst-gateway/bin/src/cli.rs index d935256eb..2e9664ddd 100644 --- a/catalyst-gateway/bin/src/cli.rs +++ b/catalyst-gateway/bin/src/cli.rs @@ -75,7 +75,7 @@ impl Cli { let config = loop { interval.tick().await; - match event_db.get_config().await { + match event_db.get_follower_config().await { Ok(config) => break config, Err(err) => error!("no config {:?}", err), } diff --git a/catalyst-gateway/bin/src/event_db/config.rs b/catalyst-gateway/bin/src/event_db/config.rs index 47014b0ae..cd2930e86 100644 --- a/catalyst-gateway/bin/src/event_db/config.rs +++ b/catalyst-gateway/bin/src/event_db/config.rs @@ -1,72 +1,81 @@ //! Config Queries use serde::{Deserialize, Serialize}; -use tracing::error; -use crate::event_db::{Error, Error::JsonParseIssue, EventDB}; +use crate::event_db::{Error, EventDB}; #[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Clone)] /// Network config metadata -pub(crate) struct NetworkMeta { +pub(crate) struct FollowerConfig { /// Mainnet, preview, preprod - pub network: String, + pub(crate) network: String, /// Cardano relay address - pub relay: String, + pub(crate) relay: String, + /// Mithril snapshot info + pub(crate) mithril_snapshot: MithrilSnapshotConfig, } -#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +#[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Clone)] /// Follower metadata -pub(crate) struct FollowerMeta { +pub(crate) struct MithrilSnapshotConfig { /// Path to snapshot file for bootstrap - pub mithril_snapshot_path: String, + pub(crate) path: String, /// Defines when data is stale or not - pub timing_pattern: u8, + pub(crate) timing_pattern: u8, } impl EventDB { /// Config query - pub(crate) async fn get_config(&self) -> Result<(Vec, FollowerMeta), Error> { + pub(crate) async fn get_follower_config(&self) -> Result, Error> { let conn = self.pool.get().await?; + let id = "cardano"; + let id2 = "follower"; + let rows = conn .query( include_str!("../../../event-db/queries/config/select_config.sql"), - &[], + &[&id, &id2], ) .await?; - let Some(row) = rows.first() else { - return Err(Error::NoConfig); - }; - - let mut networks: Vec = Vec::new(); - - let follower_meta: String = row.try_get("follower")?; - let follower_metadata: FollowerMeta = - serde_json::from_str(&follower_meta).map_err(|e| { - Error::NotFound(JsonParseIssue(format!("issue parsing db json {e}")).to_string()) - })?; + let mut follower_configs = Vec::new(); + for row in rows { + let network = row.try_get("id3")?; + let config: serde_json::Value = row.try_get("value")?; - row.try_get("cardano") - .map(|network| networks.push(network)) - .ok(); + let relay = config + .get("relay") + .ok_or(Error::JsonParseIssue( + "Cardano follower config does not have `relay` property".to_string(), + ))? + .as_str() + .ok_or(Error::JsonParseIssue( + "Cardano follower config `relay` not a string type".to_string(), + ))? + .to_string(); - row.try_get("preview") - .map(|network| networks.push(network)) - .ok(); - - let mut parse_errors = vec![]; - - let network_metadata: Vec = networks - .iter() - .map(|meta| serde_json::from_str(meta)) - .filter_map(|r| r.map_err(|e| parse_errors.push(e)).ok()) - .collect(); + let mithril_snapshot = serde_json::from_value( + config + .get("mithril_snapshot") + .ok_or(Error::JsonParseIssue( + "Cardano follower config does not have `mithril_snapshot` property" + .to_string(), + ))? + .clone(), + ) + .map_err(|e| Error::JsonParseIssue(e.to_string()))?; - if !parse_errors.is_empty() { - error!("Parsing errors {:?}", parse_errors); - return Err(Error::JsonParseIssue("Unable to parse config".to_string())); + follower_configs.push(FollowerConfig { + network, + relay, + mithril_snapshot, + }); } - Ok((network_metadata, follower_metadata)) + if follower_configs.is_empty() { + Err(Error::NoConfig) + } else { + Ok(follower_configs) + } } } diff --git a/catalyst-gateway/bin/src/follower.rs b/catalyst-gateway/bin/src/follower.rs index 9551f6b98..6d7df957f 100644 --- a/catalyst-gateway/bin/src/follower.rs +++ b/catalyst-gateway/bin/src/follower.rs @@ -14,7 +14,7 @@ use tracing::{error, info}; use crate::{ event_db::{ - config::{FollowerMeta, NetworkMeta}, + config::FollowerConfig, follower::{BlockHash, BlockTime, MachineId, SlotNumber}, EventDB, }, @@ -28,8 +28,8 @@ const DATA_NOT_STALE: i64 = 1; #[async_recursion] /// Start followers as per defined in the config pub(crate) async fn start_followers( - configs: (Vec, FollowerMeta), db: Arc, data_refresh_tick: u64, - check_config_tick: u64, machine_id: String, + configs: Vec, db: Arc, data_refresh_tick: u64, check_config_tick: u64, + machine_id: String, ) -> anyhow::Result<()> { // spawn followers and obtain thread handlers for control and future cancellation let follower_tasks = spawn_followers( @@ -44,7 +44,7 @@ pub(crate) async fn start_followers( let mut interval = time::interval(time::Duration::from_secs(check_config_tick)); let config = loop { interval.tick().await; - match db.get_config().await { + match db.get_follower_config().await { Ok(config) => { if configs != config { info!("Config has changed! restarting"); @@ -84,14 +84,11 @@ pub(crate) async fn start_followers( /// Spawn follower threads and return associated handlers async fn spawn_followers( - configs: (Vec, FollowerMeta), db: Arc, data_refresh_tick: u64, - machine_id: String, + configs: Vec, db: Arc, data_refresh_tick: u64, machine_id: String, ) -> anyhow::Result> { - let snapshot_path = configs.1.mithril_snapshot_path; - let mut follower_tasks = Vec::new(); - for config in &configs.0 { + for config in &configs { info!("starting follower for {:?}", config.network); let network = Network::from_str(&config.network)?; @@ -118,7 +115,8 @@ async fn spawn_followers( }; // Threshold which defines if data is stale and ready to update or not - if chrono::offset::Utc::now().timestamp() - threshold > configs.1.timing_pattern.into() + if chrono::offset::Utc::now().timestamp() - threshold + > config.mithril_snapshot.timing_pattern.into() { info!( "Last update is stale for network {} - ready to update, starting follower now.", @@ -130,7 +128,7 @@ async fn spawn_followers( (slot_no, block_hash), db.clone(), machine_id.clone(), - &snapshot_path, + &config.mithril_snapshot.path, ) .await?; break follower_handler; diff --git a/catalyst-gateway/event-db/migrations/V1__config_tables.sql b/catalyst-gateway/event-db/migrations/V1__config_tables.sql index ae46e46c6..9067ea618 100644 --- a/catalyst-gateway/event-db/migrations/V1__config_tables.sql +++ b/catalyst-gateway/event-db/migrations/V1__config_tables.sql @@ -74,9 +74,9 @@ Must match the `name` component of the $id URI inside the schema.'; -- This table is looked up with three keys, `id`, `id2` and `id3` CREATE TABLE config ( row_id SERIAL PRIMARY KEY, - cardano VARCHAR NOT NULL, - follower VARCHAR NOT NULL, - preview VARCHAR NOT NULL, + id VARCHAR NOT NULL, + id2 VARCHAR NOT NULL, + id3 VARCHAR NOT NULL, value JSONB NULL, value_schema UUID, @@ -84,7 +84,7 @@ CREATE TABLE config ( ); -- cardano+follower+preview must be unique, they are a combined key. -CREATE UNIQUE INDEX config_idx ON config (cardano, follower, preview); +CREATE UNIQUE INDEX config_idx ON config (id, id2, id3); COMMENT ON TABLE config IS 'General JSON Configuration and Data Values. @@ -95,13 +95,13 @@ Defined Data Formats: COMMENT ON COLUMN config.row_id IS 'Synthetic unique key. Always lookup using `cardano.follower.preview`'; -COMMENT ON COLUMN config.cardano IS +COMMENT ON COLUMN config.id IS 'The name/id of the general config value/variable'; -COMMENT ON COLUMN config.follower IS +COMMENT ON COLUMN config.id2 IS '2nd ID of the general config value. Must be defined, use "" if not required.'; -COMMENT ON COLUMN config.preview IS +COMMENT ON COLUMN config.id3 IS '3rd ID of the general config value. Must be defined, use "" if not required.'; COMMENT ON COLUMN config.value IS @@ -115,12 +115,31 @@ COMMENT ON INDEX config_idx IS at the app level to allow for querying groups of data.'; -INSERT INTO config (cardano, follower, preview) +INSERT INTO config (id, id2, id3, value) VALUES ( - '{"network": "mainnet", "relay": "relays-new.cardano-mainnet.iohk.io:3001"}', - '{ "mithril_snapshot_path": "/tmp/immutable","timing_pattern": 25 }', '{ "network": "preview", "relay": - "preview-node.play.dev.cardano.org:3001"}' + 'cardano', + 'follower', + 'mainnet', + '{ + "relay": "relays-new.cardano-mainnet.iohk.io:3001", + "mithril_snapshot": { + "path": "/tmp/mainnet/immutable", + "timing_pattern": 25 + } + }' +), +( + 'cardano', + 'follower', + 'preview', + '{ + "relay": "preview-node.play.dev.cardano.org:3001", + "mithril_snapshot": { + "path": "/tmp/preview/immutable", + "timing_pattern": 25 + } + }' ); diff --git a/catalyst-gateway/event-db/queries/config/select_config.sql b/catalyst-gateway/event-db/queries/config/select_config.sql index c54c68fa4..07075938e 100644 --- a/catalyst-gateway/event-db/queries/config/select_config.sql +++ b/catalyst-gateway/event-db/queries/config/select_config.sql @@ -1,5 +1,9 @@ SELECT - cardano, - follower, - preview -FROM config; + id3, + value + +FROM config + +WHERE + id = $1 + AND id2 = $2; diff --git a/catalyst-gateway/tests/schema-mismatch/Earthfile b/catalyst-gateway/tests/api_tests/Earthfile similarity index 59% rename from catalyst-gateway/tests/schema-mismatch/Earthfile rename to catalyst-gateway/tests/api_tests/Earthfile index 830deaf28..72008745c 100644 --- a/catalyst-gateway/tests/schema-mismatch/Earthfile +++ b/catalyst-gateway/tests/api_tests/Earthfile @@ -3,33 +3,23 @@ VERSION --global-cache 0.7 builder: FROM github.com/input-output-hk/catalyst-ci/earthly/python:v2.10.0+python-base - COPY --dir ./schema_mismatch . + COPY --dir ./api_tests . DO github.com/input-output-hk/catalyst-ci/earthly/python:v2.10.0+BUILDER -package-tester: - FROM +builder - - CMD poetry run pytest - # The following is useful for debugging the tests - # CMD poetry run pytest -vvvv --capture tee-sys --show-capture=stderr - test: - FROM earthly/dind:alpine-3.19 - RUN apk update && apk add iptables-legacy # workaround for https://github.com/earthly/earthly/issues/3784 + FROM +builder ARG DB_URL="postgres://catalyst-event-dev:CHANGE_ME@event-db/CatalystEventDev" ARG CAT_ADDRESS="0.0.0.0:3030" - WORKDIR /default COPY ./docker-compose.yml . WITH DOCKER \ --compose docker-compose.yml \ --load event-db:latest=(../../event-db+build) \ --load cat-gateway:latest=(../../+package-cat-gateway --address=$CAT_ADDRESS --db_url=$DB_URL) \ - --load test:latest=(+package-tester) \ --service event-db \ --service cat-gateway \ --allow-privileged - RUN docker run --network=default_default test + RUN poetry run pytest END diff --git a/catalyst-gateway/tests/schema-mismatch/Readme.md b/catalyst-gateway/tests/api_tests/Readme.md similarity index 100% rename from catalyst-gateway/tests/schema-mismatch/Readme.md rename to catalyst-gateway/tests/api_tests/Readme.md diff --git a/catalyst-gateway/tests/api_tests/api_tests/__init__.py b/catalyst-gateway/tests/api_tests/api_tests/__init__.py new file mode 100644 index 000000000..eb04a7bd9 --- /dev/null +++ b/catalyst-gateway/tests/api_tests/api_tests/__init__.py @@ -0,0 +1,37 @@ +"""Utilities for testing schema mismatch behavior.""" + +from loguru import logger +import http.client + +DB_URL = "postgres://catalyst-event-dev:CHANGE_ME@localhost/CatalystEventDev" +DEFAULT_TIMEOUT = 10 +CAT_GATEWAY_HOST = "127.0.0.1" +CAT_GATEWAY_PORT = 3030 + + +def call_api_url(method: str, endpoint: str): + client = http.client.HTTPConnection( + CAT_GATEWAY_HOST, CAT_GATEWAY_PORT, timeout=DEFAULT_TIMEOUT + ) + client.request(method, endpoint) + resp = client.getresponse() + client.close() + return resp + + +def check_is_live(): + resp = call_api_url("GET", "/api/health/live") + assert resp.status == 204 + logger.info("cat-gateway service is LIVE.") + + +def check_is_ready(): + resp = call_api_url("GET", "/api/health/ready") + assert resp.status == 204 + logger.info("cat-gateway service is READY.") + + +def check_is_not_ready(): + resp = call_api_url("GET", "/api/health/ready") + assert resp.status == 503 + logger.info("cat-gateway service is NOT READY.") diff --git a/catalyst-gateway/tests/schema-mismatch/schema_mismatch/test_schema_mismatch_behavior.py b/catalyst-gateway/tests/api_tests/api_tests/test_schema_mismatch_behavior.py similarity index 55% rename from catalyst-gateway/tests/schema-mismatch/schema_mismatch/test_schema_mismatch_behavior.py rename to catalyst-gateway/tests/api_tests/api_tests/test_schema_mismatch_behavior.py index 00815b4d4..19503091f 100644 --- a/catalyst-gateway/tests/schema-mismatch/schema_mismatch/test_schema_mismatch_behavior.py +++ b/catalyst-gateway/tests/api_tests/api_tests/test_schema_mismatch_behavior.py @@ -1,22 +1,41 @@ """Test the `catalyst-gateway` service when a DB schema mismatch occurs.""" + from loguru import logger +import asyncio +import asyncpg + +from api_tests import DB_URL, check_is_live, check_is_ready, check_is_not_ready + +GET_VERSION_QUERY = "SELECT MAX(version) FROM refinery_schema_history" +UPDATE_QUERY = "UPDATE refinery_schema_history SET version=$1 WHERE version=$2" + + +def fetch_schema_version(): + async def get_current_version(): + conn = await asyncpg.connect(DB_URL) + if conn is None: + raise Exception("no db connection found") + + version = await conn.fetchval(GET_VERSION_QUERY) + if version is None: + raise Exception("failed to fetch version from db") + return version + + return asyncio.run(get_current_version()) + -from schema_mismatch import call_api_url, fetch_schema_version, change_version +def change_version(from_value: int, change_to: int): + async def change_schema_version(): + conn = await asyncpg.connect(DB_URL) + if conn is None: + raise Exception("no db connection found for") -def check_is_live(): - resp = call_api_url("GET", "/api/health/live") - assert resp.status == 204 - logger.info("cat-gateway service is LIVE.") + update = await conn.execute(UPDATE_QUERY, change_to, from_value) + if update is None: + raise Exception("failed to fetch version from db") -def check_is_ready(): - resp = call_api_url("GET", "/api/health/ready") - assert resp.status == 204 - logger.info("cat-gateway service is READY.") + return asyncio.run(change_schema_version()) -def check_is_not_ready(): - resp = call_api_url("GET", "/api/health/ready") - assert resp.status == 503 - logger.info("cat-gateway service is NOT READY.") def test_schema_version_mismatch_changes_cat_gateway_behavior(): # Check that the `live` endpoint is OK diff --git a/catalyst-gateway/tests/api_tests/api_tests/test_utxo.py b/catalyst-gateway/tests/api_tests/api_tests/test_utxo.py new file mode 100644 index 000000000..16e17ea9d --- /dev/null +++ b/catalyst-gateway/tests/api_tests/api_tests/test_utxo.py @@ -0,0 +1,5 @@ +from api_tests import check_is_live + + +def test_staked_ada_endpoint(): + check_is_live() diff --git a/catalyst-gateway/tests/schema-mismatch/docker-compose.yml b/catalyst-gateway/tests/api_tests/docker-compose.yml similarity index 100% rename from catalyst-gateway/tests/schema-mismatch/docker-compose.yml rename to catalyst-gateway/tests/api_tests/docker-compose.yml diff --git a/catalyst-gateway/tests/schema-mismatch/poetry.lock b/catalyst-gateway/tests/api_tests/poetry.lock similarity index 100% rename from catalyst-gateway/tests/schema-mismatch/poetry.lock rename to catalyst-gateway/tests/api_tests/poetry.lock diff --git a/catalyst-gateway/tests/schema-mismatch/pyproject.toml b/catalyst-gateway/tests/api_tests/pyproject.toml similarity index 88% rename from catalyst-gateway/tests/schema-mismatch/pyproject.toml rename to catalyst-gateway/tests/api_tests/pyproject.toml index 8b7c2f819..97e5398c8 100644 --- a/catalyst-gateway/tests/schema-mismatch/pyproject.toml +++ b/catalyst-gateway/tests/api_tests/pyproject.toml @@ -1,9 +1,9 @@ [tool.poetry] -name = "schema-mismatch" +name = "api_tests" version = "0.1.0" description = "" authors = ["Joaquín Rosales "] -readme = "README.md" +readme = "Readme.md" license = "MIT or Apache-2.0" [tool.poetry.dependencies] diff --git a/catalyst-gateway/tests/schema-mismatch/schema_mismatch/__init__.py b/catalyst-gateway/tests/schema-mismatch/schema_mismatch/__init__.py deleted file mode 100644 index 0924f6b63..000000000 --- a/catalyst-gateway/tests/schema-mismatch/schema_mismatch/__init__.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Utilities for testing schema mismatch behavior.""" - -import asyncio -import asyncpg -import http.client - -GET_VERSION_QUERY = "SELECT MAX(version) FROM refinery_schema_history" -UPDATE_QUERY = "UPDATE refinery_schema_history SET version=$1 WHERE version=$2" - -DB_URL="postgres://catalyst-event-dev:CHANGE_ME@event-db/CatalystEventDev" -DEFAULT_TIMEOUT: int = 10 -HOST = "gateway" -PORT = 3030 - -def call_api_url(method, endpoint, *args, **kwargs): - client = http.client.HTTPConnection(HOST, PORT, timeout=DEFAULT_TIMEOUT) - client.request(method, endpoint) - resp = client.getresponse() - client.close() - return resp - -def fetch_schema_version(): - async def get_current_version(): - conn = await asyncpg.connect(DB_URL) - if conn is None: - raise Exception("no db connection found") - - version = await conn.fetchval(GET_VERSION_QUERY) - if version is None: - raise Exception("failed to fetch version from db") - return version - - return asyncio.run(get_current_version()) - -def change_version(from_value: int, change_to: int): - async def change_schema_version(): - conn = await asyncpg.connect(DB_URL) - if conn is None: - raise Exception("no db connection found for") - - update = await conn.execute(UPDATE_QUERY, change_to, from_value) - if update is None: - raise Exception("failed to fetch version from db") - - return asyncio.run(change_schema_version()) From 0f17b562b7b7f9b8423974ebc9506a079781b321 Mon Sep 17 00:00:00 2001 From: Lucio Baglione Date: Wed, 27 Mar 2024 16:38:26 +0100 Subject: [PATCH 11/35] feat: Add coming soon page (#346) * feat: Draft ComingSoon page structure. * chore: Update assets for coming soon page. * chore: Update assets for coming soon page. * feat: Add Coming Soon page with text animations. * chore: Adjust routes to display Coming Soon page. * chore: Remove guard from home route. * chore: Adjust spelling and linting. --- .config/dictionaries/project.dic | 1 + catalyst_voices/lib/app/view/app_content.dart | 2 +- .../lib/pages/coming_soon/coming_soon.dart | 53 +++++++++++++++ .../lib/pages/coming_soon/description.dart | 36 ++++++++++ .../lib/pages/coming_soon/logo.dart | 32 +++++++++ .../lib/pages/coming_soon/title.dart | 62 ++++++++++++++++++ catalyst_voices/lib/routes/app_router.dart | 14 +--- .../lib/routes/home_page_route.dart | 6 +- .../lib/routes/home_page_route.g.dart | 4 +- .../assets/colors/colors.xml | 2 + .../assets/images/coming_soon_bkg.webp | Bin 0 -> 99418 bytes .../assets/images/logo.webp | Bin 0 -> 3694 bytes .../lib/generated/assets.gen.dart | 9 ++- .../lib/generated/colors.gen.dart | 6 ++ .../catalyst_voices_localizations.dart | 24 +++++++ .../catalyst_voices_localizations_en.dart | 12 ++++ .../catalyst_voices_localizations_es.dart | 12 ++++ .../lib/l10n/intl_en.arb | 16 +++++ .../test/src/responsive_builder_test.dart | 2 +- catalyst_voices/pubspec.yaml | 2 + 20 files changed, 275 insertions(+), 20 deletions(-) create mode 100644 catalyst_voices/lib/pages/coming_soon/coming_soon.dart create mode 100644 catalyst_voices/lib/pages/coming_soon/description.dart create mode 100644 catalyst_voices/lib/pages/coming_soon/logo.dart create mode 100644 catalyst_voices/lib/pages/coming_soon/title.dart create mode 100644 catalyst_voices/packages/catalyst_voices_assets/assets/images/coming_soon_bkg.webp create mode 100644 catalyst_voices/packages/catalyst_voices_assets/assets/images/logo.webp diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index 3fd135d47..ec491f756 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -135,6 +135,7 @@ timelike tojunit Traceback TXNZD +Typer unmanaged UTXO vitss diff --git a/catalyst_voices/lib/app/view/app_content.dart b/catalyst_voices/lib/app/view/app_content.dart index e50e0a73f..671fd1004 100644 --- a/catalyst_voices/lib/app/view/app_content.dart +++ b/catalyst_voices/lib/app/view/app_content.dart @@ -29,7 +29,7 @@ final class AppContent extends StatelessWidget { localeListResolutionCallback: basicLocaleListResolution, routerConfig: _routeConfig(context), theme: ThemeData( - brightness: Brightness.dark, + //brightness: Brightness.dark, bottomNavigationBarTheme: const BottomNavigationBarThemeData( type: BottomNavigationBarType.fixed, ), diff --git a/catalyst_voices/lib/pages/coming_soon/coming_soon.dart b/catalyst_voices/lib/pages/coming_soon/coming_soon.dart new file mode 100644 index 000000000..79e7ad0b4 --- /dev/null +++ b/catalyst_voices/lib/pages/coming_soon/coming_soon.dart @@ -0,0 +1,53 @@ +import 'package:catalyst_voices/pages/coming_soon/description.dart'; +import 'package:catalyst_voices/pages/coming_soon/logo.dart'; +import 'package:catalyst_voices/pages/coming_soon/title.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/material.dart'; + +final class ComingSoonPage extends StatelessWidget { + static const comingSoonPageKey = Key('ComingSoon'); + + const ComingSoonPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + key: comingSoonPageKey, + body: Container( + constraints: const BoxConstraints.expand(), + decoration: BoxDecoration( + image: DecorationImage( + image: CatalystImage.asset( + VoicesAssets.images.comingSoonBkg.path, + ).image, + fit: BoxFit.cover, + ), + ), + child: ResponsivePadding( + xs: const EdgeInsets.only(left: 17), + sm: const EdgeInsets.only(left: 119), + md: const EdgeInsets.only(left: 150), + lg: const EdgeInsets.only(left: 150), + other: const EdgeInsets.only(left: 150), + child: Align( + alignment: Alignment.centerLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 356), + child: const Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ComingSoonLogo(), + ComingSoonTitle(), + ComingSoonDescription(), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/catalyst_voices/lib/pages/coming_soon/description.dart b/catalyst_voices/lib/pages/coming_soon/description.dart new file mode 100644 index 000000000..90214c098 --- /dev/null +++ b/catalyst_voices/lib/pages/coming_soon/description.dart @@ -0,0 +1,36 @@ +import 'package:animated_text_kit/animated_text_kit.dart'; +import 'package:catalyst_voices_assets/generated/colors.gen.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class ComingSoonDescription extends StatelessWidget { + const ComingSoonDescription({super.key}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 66, + child: DefaultTextStyle( + style: GoogleFonts.notoSans( + textStyle: const TextStyle(color: VoicesColors.blueText), + fontSize: 16, + fontWeight: FontWeight.w400, + ), + child: AnimatedTextKit( + pause: const Duration(milliseconds: 2000), + animatedTexts: [ + // We need an empty initial text to trigger an initial + // pause + TyperAnimatedText('', speed: Duration.zero), + TyperAnimatedText( + context.l10n.comingSoonDescription, + speed: const Duration(milliseconds: 30), + ), + ], + totalRepeatCount: 1, + ), + ), + ); + } +} diff --git a/catalyst_voices/lib/pages/coming_soon/logo.dart b/catalyst_voices/lib/pages/coming_soon/logo.dart new file mode 100644 index 000000000..0547e27a5 --- /dev/null +++ b/catalyst_voices/lib/pages/coming_soon/logo.dart @@ -0,0 +1,32 @@ +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class ComingSoonLogo extends StatelessWidget { + const ComingSoonLogo({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + CatalystImage.asset( + VoicesAssets.images.logo.path, + ), + Container( + margin: const EdgeInsets.only(left: 13, bottom: 6), + child: Text( + l10n.comingSoonSubtitle, + style: GoogleFonts.notoSans( + textStyle: const TextStyle(color: VoicesColors.blue), + fontSize: 19, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ); + } +} diff --git a/catalyst_voices/lib/pages/coming_soon/title.dart b/catalyst_voices/lib/pages/coming_soon/title.dart new file mode 100644 index 000000000..e2230b295 --- /dev/null +++ b/catalyst_voices/lib/pages/coming_soon/title.dart @@ -0,0 +1,62 @@ +import 'package:animated_text_kit/animated_text_kit.dart'; +import 'package:catalyst_voices_assets/generated/colors.gen.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class ComingSoonTitle extends StatelessWidget { + const ComingSoonTitle({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Container( + height: 122, + margin: const EdgeInsets.symmetric(vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DefaultTextStyle( + style: GoogleFonts.poppins( + textStyle: const TextStyle(color: VoicesColors.blue), + fontSize: 53, + height: 1.15, + fontWeight: FontWeight.w700, + ), + child: AnimatedTextKit( + animatedTexts: [ + TyperAnimatedText( + l10n.comingSoonTitle1, + speed: const Duration(milliseconds: 200), + ), + ], + totalRepeatCount: 1, + ), + ), + DefaultTextStyle( + style: GoogleFonts.poppins( + textStyle: const TextStyle(color: VoicesColors.blue), + fontSize: 53, + height: 1.15, + fontWeight: FontWeight.w700, + ), + child: AnimatedTextKit( + pause: const Duration(milliseconds: 1200), + animatedTexts: [ + TyperAnimatedText( + '', + speed: Duration.zero, + ), + TyperAnimatedText( + l10n.comingSoonTitle2, + speed: const Duration(milliseconds: 200), + ), + ], + totalRepeatCount: 1, + ), + ), + ], + ), + ); + } +} diff --git a/catalyst_voices/lib/routes/app_router.dart b/catalyst_voices/lib/routes/app_router.dart index 608056d2a..1cf6facea 100644 --- a/catalyst_voices/lib/routes/app_router.dart +++ b/catalyst_voices/lib/routes/app_router.dart @@ -28,18 +28,8 @@ final class AppRouter { AuthenticationBloc authenticationBloc, GoRouterState state, ) { - final isAuthenticated = authenticationBloc.isAuthenticated; - final signingIn = state.matchedLocation == login_route.loginPath; - - if (!isAuthenticated) { - return login_route.loginPath; - } - - if (signingIn) { - return home_route.homePath; - } - - return null; + // Always return home route that defaults to coming soon page. + return home_route.homePath; } static String? _isWeb() { diff --git a/catalyst_voices/lib/routes/home_page_route.dart b/catalyst_voices/lib/routes/home_page_route.dart index 2efb81d20..c69d7aa56 100644 --- a/catalyst_voices/lib/routes/home_page_route.dart +++ b/catalyst_voices/lib/routes/home_page_route.dart @@ -1,15 +1,15 @@ -import 'package:catalyst_voices/pages/home/home_page.dart'; +import 'package:catalyst_voices/pages/coming_soon/coming_soon.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; part 'home_page_route.g.dart'; -const homePath = '/home'; +const homePath = '/'; @TypedGoRoute(path: homePath) final class HomeRoute extends GoRouteData { @override Widget build(BuildContext context, GoRouterState state) { - return const HomePage(); + return const ComingSoonPage(); } } diff --git a/catalyst_voices/lib/routes/home_page_route.g.dart b/catalyst_voices/lib/routes/home_page_route.g.dart index 60410b890..3ec8ca4cf 100644 --- a/catalyst_voices/lib/routes/home_page_route.g.dart +++ b/catalyst_voices/lib/routes/home_page_route.g.dart @@ -11,7 +11,7 @@ List get $appRoutes => [ ]; RouteBase get $homeRoute => GoRouteData.$route( - path: '/home', + path: '/', factory: $HomeRouteExtension._fromState, ); @@ -19,7 +19,7 @@ extension $HomeRouteExtension on HomeRoute { static HomeRoute _fromState(GoRouterState state) => HomeRoute(); String get location => GoRouteData.$location( - '/home', + '/', ); void go(BuildContext context) => context.go(location); diff --git a/catalyst_voices/packages/catalyst_voices_assets/assets/colors/colors.xml b/catalyst_voices/packages/catalyst_voices_assets/assets/colors/colors.xml index cb790c64d..0a9f7a30d 100644 --- a/catalyst_voices/packages/catalyst_voices_assets/assets/colors/colors.xml +++ b/catalyst_voices/packages/catalyst_voices_assets/assets/colors/colors.xml @@ -9,4 +9,6 @@ #7CAE7A #B02E0C #565656 + #1235C7 + #506288 diff --git a/catalyst_voices/packages/catalyst_voices_assets/assets/images/coming_soon_bkg.webp b/catalyst_voices/packages/catalyst_voices_assets/assets/images/coming_soon_bkg.webp new file mode 100644 index 0000000000000000000000000000000000000000..c61fc9f3fb6bfeeda5dcba743e1f8d9fe1561e64 GIT binary patch literal 99418 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