From 720522deec1e587004b3c408c05c750163b7dab4 Mon Sep 17 00:00:00 2001 From: Dominik Toton <166132265+dtscalac@users.noreply.github.com> Date: Wed, 27 Nov 2024 11:28:54 +0100 Subject: [PATCH] chore(general): merge main into mve3 (#1282) * Feat: update testplan template (#1243) * chore: update testplan * fix * fix * fix * fix * fix: testplan template (#1245) * feat(cat-gateway): Finliaze CIP36 Endpoint Cleanup (#1241) * fix: api endpoint draft Signed-off-by: bkioshn * fix: api health endpoint v1 Signed-off-by: bkioshn * fix: remove bad request from errorResponses Signed-off-by: bkioshn * fix: add bad req to get /registration Signed-off-by: bkioshn * fix: error logging Signed-off-by: bkioshn * fix: remove validation error Signed-off-by: bkioshn * fix: registration get error name Signed-off-by: bkioshn * chore:format Signed-off-by: bkioshn * fix: get json schema from openapi spec Signed-off-by: bkioshn * fix: move schema utils Signed-off-by: bkioshn * fix: optional field Signed-off-by: bkioshn * fix: config key Signed-off-by: bkioshn * fix: cat-gateway code gen Signed-off-by: bkioshn * fix: api name in cat-voice Signed-off-by: bkioshn * fix: cat-voice format Signed-off-by: bkioshn * chore: fix spacing Signed-off-by: bkioshn * chore: fix spacing Signed-off-by: bkioshn * chore: change tag config description * test: add test for default validator * fix: add spectral ruleset Signed-off-by: bkioshn * fix(cat-gateway): Sort the spelling words, and use latest deny.toml * fix(cat-gateway): Fix broken pre-push justfile target * docs(cat-gateway): cleanup * docs(cat-gateway): Fix API Groups and document them better * docs(cat-gateway): Add documentation to the health/inspection endpoint * docs(cat-gateway): Add descriptions for cardano/cip36/latest_registration/stake_addr * docs(cat-gateway): Document stake key hash and vote key endpoints for cardano * docs(cat-gateway): add documentation to config/frontend * docs(cat-gateway): Add api docs for frontend schema * docs(cat-gateway): Move legacy registration endpoints into the Legacy TAG. * docs(cat-gateway): Remaining documentable entities documented * fix: update openapi linter Signed-off-by: bkioshn * docs(cat-gateway): Add more constraints to parameters and json bodies * fix: openapi lint FUNCTION name Signed-off-by: bkioshn * fix: CIP36 example and description Signed-off-by: bkioshn * fix(cat-gateway): cleanup error handling, and add a global 429 response to all endpoints. * fix: config endpoint example, desc, and return Signed-off-by: bkioshn * chore: remove todo Signed-off-by: bkioshn * fix: move config object Signed-off-by: bkioshn * fix: move cip36 object Signed-off-by: bkioshn * docs(cat-gateway): Add missing headers to responses * docs(cat-gateway): Cleanup the rest of the documentation in the api * fix(cat-gateway): Fix OpenAPI linting and add autogenerated api file for dart. * refactor(cat-gateway): Better generalize the OpenAPI simple string type creation macro. * fix(cat-gateway): Add APIKey and CatToken auth to some endpoints. Add 401 and 403 common responses. * fix(cat-gateway): Add universal 422 response to all endpoints, and try and make all endpoint validation use it. * fix: add cardano stake address type Signed-off-by: bkioshn * fix(cat-gateway): stake address type Signed-off-by: bkioshn * fix(cat-gateway): Refactor the RBAC Token auth, so it's easier to maintain. * fix(cat-gateway): stake address name Signed-off-by: bkioshn * fix(cat-gateway): Add no auth and no-auth+rbac auth schemes * fix(cat-gateway): format + stake addr example Signed-off-by: bkioshn * fix(cat-gateway): code format * fix(cat-gateway): openapi spectral example rules Signed-off-by: bkioshn * fix(cat-gateway): Move legacy registration endpoint under Legacy Tag * fix(cat-gateway): Add Auth to all endpoints * fix(docs): Remove obsolete lint config file * fix(cat-gateway): Make config.toml match upstream * docs(docs): update project dictionary * feat(cat-gateway): add target to make it quick to check openapi lints locally * fix(cat-gateway): Remove reference to hermes * fix(cat-gateway): Add auth to rbac endpoints * docs(cat-gateway): Add full docs for v1/votes/plan/account-votes * docs(cat-gateway): Add example for ip address query argument * fix(cat-gateway): Define and abstract Ed25519 Public Keys as hex encoded parameters * fix(cat-gateway): Make sure string api types do not directly expose the internal string * fix(cat-gateway): Make conversion from a Ed25519 pub key hex value to a Verifyingkey infallible * fix(cat-gateway): Fix native asset response types * docs(cat-gateway): fix comments * fix(cat-gateway): Autogenerate flutter files * fix(cat-gateway): Exclude legacy endpoints from needing api examples * fix(cat-gateway): WIP improving cip36 endpoint docs * fix(docs): Make targets to re-check the generated schema easy. * fix: spectral ruleset for linting query params description * feat: parameter rule * fix: debug function * docs(cat-gateway): Make schema lint accept description inside a schema in a query parameter * fix(cat-gateway): remove debug logic from api docs lint * fix(cat-gateway): Don't put expanded program into git * Make error response comments consistent * test(cat-gateway): Add local operation to easily expand macros in the service code * fix(cat-gateway): CIP36 Structured endpoint * fix: speling * fix(rust): cleanup/normalize nonce validation * fix(rust): code format * Update catalyst-gateway/bin/src/service/common/types/cardano/cip19_shelley_address.rs Co-authored-by: bkioshn <35752733+bkioshn@users.noreply.github.com> * Update catalyst-gateway/bin/src/service/common/types/cardano/cip19_shelley_address.rs Co-authored-by: bkioshn <35752733+bkioshn@users.noreply.github.com> --------- Signed-off-by: bkioshn Co-authored-by: bkioshn Co-authored-by: bkioshn <35752733+bkioshn@users.noreply.github.com> Co-authored-by: Dominik Toton <166132265+dtscalac@users.noreply.github.com> Co-authored-by: Apisit Ritreungroj * Revert "Merge branch 'mve3' into main" This reverts commit 01db066663ece91c2c5f6ab3150387803f87885c, reversing changes made to 3bf0ccf6cbc38359e888e53cb24ada753b0f2ebc. * fix(cat-voices): equatable lint issue fix (#1280) * fix: resolve equatable lint issue * fix: missing override --------- Signed-off-by: bkioshn Co-authored-by: Stefano Cunego <93382903+kukkok3@users.noreply.github.com> Co-authored-by: Steven Johnson Co-authored-by: bkioshn Co-authored-by: bkioshn <35752733+bkioshn@users.noreply.github.com> Co-authored-by: Apisit Ritreungroj Co-authored-by: Oleksandr Prokhorenko --- .config/dictionaries/project.dic | 3 +- .github/ISSUE_TEMPLATE/test_plan.yml | 52 ++- catalyst-gateway/.gitignore | 3 +- catalyst-gateway/Justfile | 21 +- catalyst-gateway/bin/Cargo.toml | 1 + catalyst-gateway/bin/Justfile | 12 + .../src/service/api/cardano/cip36/endpoint.rs | 32 ++ .../bin/src/service/api/cardano/cip36/mod.rs | 140 +++++++ .../{cip36.rs => cip36/old_endpoint.rs} | 0 .../src/service/api/cardano/cip36/response.rs | 166 ++++++++ .../bin/src/service/api/cardano/mod.rs | 105 +---- .../api/cardano/rbac/chain_root_get.rs | 4 +- .../service/api/cardano/staking/assets_get.rs | 5 +- .../src/service/api/cardano/staking/mod.rs | 2 +- catalyst-gateway/bin/src/service/api/mod.rs | 2 +- .../bin/src/service/common/auth/api_key.rs | 19 +- .../service/common/objects/cardano/cip36.rs | 3 + .../service/common/objects/cardano/hash.rs | 4 +- .../src/service/common/objects/cardano/mod.rs | 8 +- .../objects/cardano/registration_info.rs | 8 +- .../common/objects/cardano/sync_state.rs | 2 +- .../src/service/common/objects/generic/mod.rs | 3 + .../common/objects/generic/pagination.rs | 48 +++ .../bin/src/service/common/objects/mod.rs | 1 + .../common/responses/code_401_unauthorized.rs | 23 +- .../common/responses/code_403_forbidden.rs | 7 +- .../code_422_unprocessable_content.rs | 90 ++--- .../responses/code_429_too_many_requests.rs | 6 +- .../code_500_internal_server_error.rs | 6 +- .../responses/code_503_service_unavailable.rs | 9 +- .../bin/src/service/common/responses/mod.rs | 2 +- .../common/types/cardano/asset_value.rs | 2 + .../types/cardano/cip19_shelley_address.rs | 144 +++++++ .../{address.rs => cip19_stake_address.rs} | 70 +++- .../service/common/types/cardano/hash28.rs | 11 +- .../src/service/common/types/cardano/mod.rs | 7 +- .../src/service/common/types/cardano/nonce.rs | 126 ++++++ .../common/types/cardano/query/as_at.rs | 169 ++++++++ .../service/common/types/cardano/query/mod.rs | 9 + .../types/cardano/query/stake_or_voter.rs | 200 ++++++++++ .../service/common/types/cardano/slot_no.rs | 131 +++++++ .../service/common/types/cardano/txn_index.rs | 147 +++++++ .../types/generic/ed25519_public_key.rs | 60 ++- .../service/common/types/generic/error_msg.rs | 79 ++++ .../src/service/common/types/generic/mod.rs | 2 + .../service/common/types/generic/query/mod.rs | 12 + .../common/types/generic/query/pagination.rs | 360 ++++++++++++++++++ .../src/service/common/types/string_types.rs | 10 +- .../bin/src/service/utilities/mod.rs | 23 +- catalyst-gateway/bin/src/settings/mod.rs | 7 + catalyst-gateway/rustfmt.toml | 2 +- catalyst-gateway/tests/Earthfile | 2 +- .../.spectral.yml} | 26 +- .../openapi-v3.0-lints/functions/debug.js | 28 ++ .../functions/description-required.js | 117 ++++++ .../lib/common/formatters/date_formatter.dart | 10 +- .../pages/account/unlock_keychain_dialog.dart | 1 - .../workspace/workspace_guidance_view.dart | 39 +- .../lib/widgets/empty_state/empty_state.dart | 77 ---- .../widgets/images/voices_image_scheme.dart | 23 -- .../voices/lib/widgets/menu/voices_menu.dart | 34 +- .../lib/widgets/menu/voices_modal_menu.dart | 179 --------- .../campaign/campaign_categories_tile.dart | 165 -------- .../campaign/campaign_details_tile.dart | 219 ----------- .../voices_password_text_field.dart | 5 - .../widgets/text_field/voices_text_field.dart | 5 - .../widgets/tiles/voices_expansion_tile.dart | 107 ------ .../apps/voices/lib/widgets/widgets.dart | 2 - catalyst_voices/apps/voices/pubspec.yaml | 2 +- .../formatters/date_formatter_test.dart | 115 ++---- .../widgets/empty_state/empty_state_test.dart | 131 ------- .../test/widgets/menu/voices_menu_test.dart | 16 +- catalyst_voices/melos.yaml | 2 +- .../assets/images/no_proposal_foreground.svg | 41 -- .../catalyst_voices_blocs/pubspec.yaml | 2 +- .../catalyst_voices_models/pubspec.yaml | 2 +- .../catalyst_voices_services/pubspec.yaml | 2 +- .../lib/src/campaign/campaign_category.dart | 14 - .../lib/src/campaign/campaign_section.dart | 31 -- .../lib/src/catalyst_voices_view_models.dart | 4 - .../lib/src/menu/menu_item.dart | 31 -- .../lib/src/menu/popup_menu_item.dart | 32 -- .../catalyst_voices_view_models/pubspec.yaml | 2 +- .../catalyst_cardano/example/pubspec.yaml | 2 +- .../pubspec.yaml | 2 +- .../pubspec.yaml | 2 +- .../pubspec.yaml | 2 +- .../libs/catalyst_key_derivation/pubspec.yaml | 2 +- .../lib/examples/voices_menu_example.dart | 30 +- .../api/cat-gateway/stoplight_template.html | 7 +- 90 files changed, 2398 insertions(+), 1473 deletions(-) create mode 100644 catalyst-gateway/bin/Justfile create mode 100644 catalyst-gateway/bin/src/service/api/cardano/cip36/endpoint.rs create mode 100644 catalyst-gateway/bin/src/service/api/cardano/cip36/mod.rs rename catalyst-gateway/bin/src/service/api/cardano/{cip36.rs => cip36/old_endpoint.rs} (100%) create mode 100644 catalyst-gateway/bin/src/service/api/cardano/cip36/response.rs create mode 100644 catalyst-gateway/bin/src/service/common/objects/generic/mod.rs create mode 100644 catalyst-gateway/bin/src/service/common/objects/generic/pagination.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/cardano/cip19_shelley_address.rs rename catalyst-gateway/bin/src/service/common/types/cardano/{address.rs => cip19_stake_address.rs} (66%) create mode 100644 catalyst-gateway/bin/src/service/common/types/cardano/nonce.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/cardano/query/as_at.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/cardano/query/mod.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/cardano/query/stake_or_voter.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/cardano/slot_no.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/cardano/txn_index.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/generic/error_msg.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/generic/query/mod.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/generic/query/pagination.rs rename catalyst-gateway/tests/{.oapi-v3.spectral.yml => openapi-v3.0-lints/.spectral.yml} (93%) create mode 100644 catalyst-gateway/tests/openapi-v3.0-lints/functions/debug.js create mode 100644 catalyst-gateway/tests/openapi-v3.0-lints/functions/description-required.js delete mode 100644 catalyst_voices/apps/voices/lib/widgets/empty_state/empty_state.dart delete mode 100644 catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart delete mode 100644 catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart delete mode 100644 catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_categories_tile.dart delete mode 100644 catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_details_tile.dart delete mode 100644 catalyst_voices/apps/voices/lib/widgets/tiles/voices_expansion_tile.dart delete mode 100644 catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_section.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/menu_item.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/popup_menu_item.dart diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index 9288b68c6b9..a61c56ea5a4 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -14,6 +14,7 @@ Arbritrary ARCHS ARGB Arissara +asat asmjs asyncio asyncpg @@ -241,6 +242,7 @@ rustflags rustfmt rustls rxdart +ryszard-schossler saibatizoku Schemathesis Scripthash @@ -286,7 +288,6 @@ trailings TXNZD txos Typer -ryszard-schossler unawaited unchunk Unlogged diff --git a/.github/ISSUE_TEMPLATE/test_plan.yml b/.github/ISSUE_TEMPLATE/test_plan.yml index 889d205f99b..c728dd6d20a 100644 --- a/.github/ISSUE_TEMPLATE/test_plan.yml +++ b/.github/ISSUE_TEMPLATE/test_plan.yml @@ -1,5 +1,5 @@ name: Test Plan -description: Create a test plan +description: Create a test plan. title: "[Test plan]: " labels: ["testplan"] projects: ["/input-output-hk/102"] @@ -11,18 +11,64 @@ body: validations: required: true + - type: textarea + attributes: + label: Stakeholders + description: Insert the relevant stakeholders that need to understand, review and approve the test plan + placeholder: | + | Name | Role | Approval | + |:-: |:-:|:-:| + | | | | + validations: + required: true + - type: input attributes: label: Test plan document placeholder: https://input-output-hk.github.io/catalyst-voices/architecture/10_quality/testplans/template.md - description: A link to the test plan document. + description: A link to the test plan document if it is needed. + validations: + required: false + + - type: textarea + attributes: + label: Requirements + description: Business requirements, insert links to relevant Github or JIRA tickets, list what platforms are supported, what will not be tested, etc + validations: + required: true + + - type: textarea + attributes: + label: Acceptance criteria + description: List the acceptance criteria for this feature + validations: + required: true + + - type: textarea + attributes: + label: Risks + description: Describe what risks can affect the accomplishment of the testplan. For example documentation is missing, not enough resources etc + validations: + required: true + + - type: textarea + attributes: + label: Tools + description: Describe what tools will be needed for the testing, if new tools are needed to be developed + validations: + required: true + + - type: textarea + attributes: + label: Test strategy + description: Describe the strategy for testing validations: required: true - type: textarea attributes: label: Test cases - description: The list of the test cases that are part of the test plan + description: The list of the test cases that are part of the test plan. Use the ACC framework [https://input-output-hk.github.io/catalyst-voices/architecture/10_quality/testplans/template/#acc-framework] to help you define testcases placeholder: | -[] https://github.com/input-output-hk/catalyst-voices/issues/1 -[] https://github.com/input-output-hk/catalyst-voices/issues/1 diff --git a/catalyst-gateway/.gitignore b/catalyst-gateway/.gitignore index fea35a6a809..699900d6cce 100644 --- a/catalyst-gateway/.gitignore +++ b/catalyst-gateway/.gitignore @@ -13,4 +13,5 @@ target/ # Build artifacts cat-gateway.coverage.info cat-gateway.junit-report.xml -cat-gateway-api.* \ No newline at end of file +cat-gateway-api.* +expanded.rs \ No newline at end of file diff --git a/catalyst-gateway/Justfile b/catalyst-gateway/Justfile index 9d16e79ea20..32ae8fa693f 100644 --- a/catalyst-gateway/Justfile +++ b/catalyst-gateway/Justfile @@ -57,7 +57,22 @@ run-cat-gateway-mainnet: build-cat-gateway RUST_LOG="error,cat_gateway=debug,cardano_chain_follower=debug,mithril-client=debug" \ ./target/release/cat-gateway run --log-level debug -# Do the minimal work needed to test the schema generated by cat-gateway -quick-schema-lint: build-cat-gateway +# expand all macros and produce a single unified source file. +expand-macros: + just bin/expand-macros + +# Generate the current openapi schema file locally. +generate-openapi-schema: build-cat-gateway ./target/release/cat-gateway docs cat-gateway-api.json - docker run --rm -it -v $(pwd):/tmp stoplight/spectral:latest lint --ruleset "/tmp/tests/.oapi-v3.spectral.yml" "/tmp/cat-gateway-api.json" \ No newline at end of file + +# Lint an openapi schema that has already been generated +lint-generated-schema: + docker run --rm -it -v $(pwd):/tmp stoplight/spectral:latest lint --ruleset "/tmp/tests/openapi-v3.0-lints/.spectral.yml" "/tmp/cat-gateway-api.json" + +# Lint an openapi schema that has already been generated locally. +# Make sure before running this command, you have installed "spectral" locally. +lint-generated-schema-local: + spectral lint --ruleset "./tests/openapi-v3.0-lints/.spectral.yml" "cat-gateway-api.json" + +# Do the minimal work needed to test the schema generated by cat-gateway +quick-schema-lint: generate-openapi-schema lint-generated-schema diff --git a/catalyst-gateway/bin/Cargo.toml b/catalyst-gateway/bin/Cargo.toml index ca17f018695..2eaedb86a82 100644 --- a/catalyst-gateway/bin/Cargo.toml +++ b/catalyst-gateway/bin/Cargo.toml @@ -92,6 +92,7 @@ der-parser = "9.0.0" jsonschema = "0.26.1" bech32 = "0.11.0" const_format = "0.2.33" +regex = "1.11.1" [dev-dependencies] proptest = "1.5.0" diff --git a/catalyst-gateway/bin/Justfile b/catalyst-gateway/bin/Justfile new file mode 100644 index 00000000000..1c02f5933f6 --- /dev/null +++ b/catalyst-gateway/bin/Justfile @@ -0,0 +1,12 @@ +# use with https://github.com/casey/just +# +# Developer convenience functions + +# cspell: words prereqs, commitlog, rustls, nocapture + +default: + @just --list --unsorted + +# expand all macros and produce a single unified source file. +expand-macros: + cargo expand --release --bin cat-gateway > ../expanded.rs diff --git a/catalyst-gateway/bin/src/service/api/cardano/cip36/endpoint.rs b/catalyst-gateway/bin/src/service/api/cardano/cip36/endpoint.rs new file mode 100644 index 00000000000..cfd5d5f2a51 --- /dev/null +++ b/catalyst-gateway/bin/src/service/api/cardano/cip36/endpoint.rs @@ -0,0 +1,32 @@ +//! Implementation of the GET `/cardano/cip36` endpoint + +use std::time::Duration; + +use poem::http::HeaderMap; +use tokio::time::sleep; + +use super::{ + cardano::{self}, + response, NoneOrRBAC, SlotNo, +}; +use crate::service::common::{self}; + +/// Process the endpoint operation +pub(crate) async fn cip36_registrations( + _lookup: Option<cardano::query::stake_or_voter::StakeOrVoter>, _asat: Option<SlotNo>, + _page: common::types::generic::query::pagination::Page, + _limit: common::types::generic::query::pagination::Limit, _auth: NoneOrRBAC, + _headers: &HeaderMap, +) -> response::AllRegistration { + // Dummy sleep, remove it + sleep(Duration::from_millis(1)).await; + + // Todo: refactor the below into a single operation here. + + // If _asat is None, then get the latest slot number from the chain follower and use that. + // If _for is not defined, use the stake addresses defined for Role0 in the _auth + // parameter. _auth not yet implemented, so put placeholder for that, and return not + // found until _auth is implemented. + + response::Cip36Registration::NotFound.into() +} diff --git a/catalyst-gateway/bin/src/service/api/cardano/cip36/mod.rs b/catalyst-gateway/bin/src/service/api/cardano/cip36/mod.rs new file mode 100644 index 00000000000..549dc776443 --- /dev/null +++ b/catalyst-gateway/bin/src/service/api/cardano/cip36/mod.rs @@ -0,0 +1,140 @@ +//! CIP36 Registration Endpoints + +use ed25519_dalek::VerifyingKey; +use poem::http::{HeaderMap, StatusCode}; +use poem_openapi::{param::Query, OpenApi}; + +use self::cardano::slot_no::SlotNo; +use super::Ed25519HexEncodedPublicKey; +use crate::service::common::{ + self, + auth::none_or_rbac::NoneOrRBAC, + tags::ApiTags, + types::cardano::{self}, +}; + +pub(crate) mod endpoint; +pub(crate) mod old_endpoint; +pub(crate) mod response; + +/// Cardano Staking API Endpoints +pub(crate) struct Api; + +#[OpenApi(tag = "ApiTags::Cardano")] +impl Api { + /// CIP36 registrations. + /// + /// This endpoint gets the latest registration given either the voting key, stake + /// address, stake public key or the auth token. + /// + /// Registration can be the latest to date, or at a particular date-time or slot + /// number. + // Required To be able to look up for: + // 1. Voting Public Key + // 2. Cip-19 stake address + // 3. All - Hidden option and would need a hidden api key header (used to create a snapshot + // replacement.) + // 4. Stake addresses associated with current Role0 registration (if none of the above + // provided). + // If none of the above provided, return not found. + #[oai( + path = "/draft/cardano/registration/cip36", + method = "get", + operation_id = "cardanoRegistrationCip36" + )] + async fn get_registration( + &self, lookup: Query<Option<cardano::query::stake_or_voter::StakeOrVoter>>, + asat: Query<Option<cardano::query::AsAt>>, + page: Query<Option<common::types::generic::query::pagination::Page>>, + limit: Query<Option<common::types::generic::query::pagination::Limit>>, + /// No Authorization required, but Token permitted. + auth: NoneOrRBAC, + /// Headers, used if the query is requesting ALL to determine if the secret API + /// Key is also defined. + headers: &HeaderMap, + ) -> response::AllRegistration { + // Special validation for the `lookup` parameter. + // If the parameter is ALL, BUT we do not have a valid API Key, just report the parameter + // is invalid. + if let Some(lookup) = lookup.0.clone() { + if lookup.is_all(headers).is_err() { + return response::AllRegistration::unprocessable_content(vec![ + poem::Error::from_string( + "Invalid Stake Address or Voter Key", + StatusCode::UNPROCESSABLE_ENTITY, + ), + ]); + } + } + + endpoint::cip36_registrations( + lookup.0, + SlotNo::into_option(asat.0), + page.0.unwrap_or_default(), + limit.0.unwrap_or_default(), + auth, + headers, + ) + .await + } + + /// Get latest CIP36 registrations from stake address. + /// + /// This endpoint gets the latest registration given a stake address. + #[oai( + path = "/draft/cardano/cip36/latest_registration/stake_addr", + method = "get", + operation_id = "latestRegistrationGivenStakeAddr" + )] + async fn latest_registration_cip36_given_stake_addr( + &self, + /// Stake Public Key to find the latest registration for. + stake_pub_key: Query<Ed25519HexEncodedPublicKey>, // Validation provided by type. + /// No Authorization required, but Token permitted. + _auth: NoneOrRBAC, + ) -> old_endpoint::SingleRegistrationResponse { + let hex_key = stake_pub_key.0; + let pub_key: VerifyingKey = hex_key.into(); + + old_endpoint::get_latest_registration_from_stake_addr(&pub_key, true).await + } + + /// Get latest CIP36 registrations from a stake key hash. + /// + /// This endpoint gets the latest registration given a stake key hash. + #[oai( + path = "/draft/cardano/cip36/latest_registration/stake_key_hash", + method = "get", + operation_id = "latestRegistrationGivenStakeHash" + )] + async fn latest_registration_cip36_given_stake_key_hash( + &self, + /// Stake Key Hash to find the latest registration for. + #[oai(validator(max_length = 66, min_length = 0, pattern = "[0-9a-f]"))] + stake_key_hash: Query<String>, + /// No Authorization required, but Token permitted. + _auth: NoneOrRBAC, + ) -> old_endpoint::SingleRegistrationResponse { + old_endpoint::get_latest_registration_from_stake_key_hash(stake_key_hash.0, true).await + } + + /// Get latest CIP36 registrations from voting key. + /// + /// This endpoint returns the list of stake address registrations currently associated + /// with a given voting key. + #[oai( + path = "/draft/cardano/cip36/latest_registration/vote_key", + method = "get", + operation_id = "latestRegistrationGivenVoteKey" + )] + async fn latest_registration_cip36_given_vote_key( + &self, + /// Voting Key to find CIP36 registrations for. + #[oai(validator(max_length = 66, min_length = 66, pattern = "0x[0-9a-f]"))] + vote_key: Query<String>, + /// No Authorization required, but Token permitted. + _auth: NoneOrRBAC, + ) -> old_endpoint::MultipleRegistrationResponse { + old_endpoint::get_associated_vote_key_registrations(vote_key.0, true).await + } +} diff --git a/catalyst-gateway/bin/src/service/api/cardano/cip36.rs b/catalyst-gateway/bin/src/service/api/cardano/cip36/old_endpoint.rs similarity index 100% rename from catalyst-gateway/bin/src/service/api/cardano/cip36.rs rename to catalyst-gateway/bin/src/service/api/cardano/cip36/old_endpoint.rs diff --git a/catalyst-gateway/bin/src/service/api/cardano/cip36/response.rs b/catalyst-gateway/bin/src/service/api/cardano/cip36/response.rs new file mode 100644 index 00000000000..fc9d0b5411a --- /dev/null +++ b/catalyst-gateway/bin/src/service/api/cardano/cip36/response.rs @@ -0,0 +1,166 @@ +//! Cip36 Registration Query Endpoint Response +use poem_openapi::{payload::Json, types::Example, ApiResponse, Object}; + +use crate::service::common; + +// ToDo: The examples of this response should be taken from representative data from a +// response generated on pre-prod. + +/// Endpoint responses. +#[derive(ApiResponse)] +#[allow(dead_code)] // TODO: Remove once endpoint fully implemented +pub(crate) enum Cip36Registration { + /// All CIP36 registrations associated with the same Voting Key. + #[oai(status = 200)] + Ok(Json<Cip36RegistrationList>), + /// No valid registration. + #[oai(status = 404)] + NotFound, +} + +/// All responses to a cip36 registration query +pub(crate) type AllRegistration = common::responses::WithErrorResponses<Cip36Registration>; + +/// List of CIP36 Registration Data as found on-chain. +#[derive(Object)] +#[oai(example = true)] +pub(crate) struct Cip36RegistrationList { + /// The Slot the Registrations are valid up until. + /// + /// Any registrations that occurred after this Slot are not included in the list. + /// Errors are reported only if they fall between the last valid registration and this + /// slot number. + /// Earlier errors are never reported. + slot: common::types::cardano::slot_no::SlotNo, + /// List of registrations associated with the query. + #[oai(validator(max_items = "100"))] + voting_key: Vec<Cip36RegistrationsForVotingPublicKey>, + /// List of latest invalid registrations that were found, for the requested filter. + #[oai(skip_serializing_if_is_empty, validator(max_items = "10"))] + invalid: Vec<Cip36Details>, + /// Current Page + page: common::objects::generic::pagination::CurrentPage, +} + +impl Example for Cip36RegistrationList { + fn example() -> Self { + Self { + slot: (common::types::cardano::slot_no::EXAMPLE + 635).into(), + voting_key: vec![Cip36RegistrationsForVotingPublicKey::example()], + invalid: vec![Cip36Details::invalid_example()], + page: common::objects::generic::pagination::CurrentPage::example(), + } + } +} + +/// List of CIP36 Registration Data for a Voting Key. +#[derive(Object)] +#[oai(example = true)] +pub(crate) struct Cip36RegistrationsForVotingPublicKey { + /// Voting Public Key + pub vote_pub_key: common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey, + /// List of Registrations associated with this Voting Key + #[oai(validator(max_items = "100"))] + pub registrations: Vec<Cip36Details>, +} + +impl Example for Cip36RegistrationsForVotingPublicKey { + fn example() -> Self { + Cip36RegistrationsForVotingPublicKey { + vote_pub_key: + common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey::example(), + registrations: vec![Cip36Details::example()], + } + } +} + +/// CIP36 Registration Data as found on-chain. +#[derive(Object)] +#[oai(example = true)] +pub(crate) struct Cip36Details { + /// Blocks Slot Number that the registration certificate is in. + pub slot_no: common::types::cardano::slot_no::SlotNo, + /// Full Stake Address (not hashed, 32 byte ED25519 Public key). + #[oai(skip_serializing_if_is_none)] + pub stake_pub_key: + Option<common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey>, + /// Voting Public Key (Ed25519 Public key). + #[oai(skip_serializing_if_is_none)] + pub vote_pub_key: + Option<common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey>, + #[allow(clippy::missing_docs_in_private_items)] // Type is pre documented. + #[oai(skip_serializing_if_is_none)] + pub nonce: Option<common::types::cardano::nonce::Nonce>, + #[allow(clippy::missing_docs_in_private_items)] // Type is pre documented. + #[oai(skip_serializing_if_is_none)] + pub txn: Option<common::types::cardano::txn_index::TxnIndex>, + /// Cardano Cip-19 Formatted Shelley Payment Address. + #[oai(skip_serializing_if_is_none)] + pub payment_address: Option<common::types::cardano::cip19_shelley_address::Cip19ShelleyAddress>, + /// If the payment address is a script, then it can not be payed rewards. + #[oai(default = "is_payable_default")] + pub is_payable: bool, + /// If this field is set, then the registration was in CIP15 format. + #[oai(default = "cip15_default")] + pub cip15: bool, + /// If there are errors with this registration, they are listed here. + /// This field is *NEVER* returned for a valid registration. + #[oai( + default = "Vec::<common::types::generic::error_msg::ErrorMessage>::new", + skip_serializing_if_is_empty, + validator(max_items = "10") + )] + pub errors: Vec<common::types::generic::error_msg::ErrorMessage>, +} + +/// Is the payment address payable by catalyst. +fn is_payable_default() -> bool { + true +} + +/// Is the registration using CIP15 format. +fn cip15_default() -> bool { + false +} + +impl Example for Cip36Details { + /// Example of a valid registration + fn example() -> Self { + Self { + slot_no: common::types::cardano::slot_no::SlotNo::example(), + stake_pub_key: Some( + common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey::examples(0), + ), + vote_pub_key: Some( + common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey::example(), + ), + nonce: Some(common::types::cardano::nonce::Nonce::example()), + txn: Some(common::types::cardano::txn_index::TxnIndex::example()), + payment_address: Some( + common::types::cardano::cip19_shelley_address::Cip19ShelleyAddress::example(), + ), + is_payable: true, + cip15: false, + errors: Vec::<common::types::generic::error_msg::ErrorMessage>::new(), + } + } +} + +impl Cip36Details { + /// Example of an invalid registration + fn invalid_example() -> Self { + Self { + slot_no: (common::types::cardano::slot_no::EXAMPLE + 135).into(), + stake_pub_key: None, + vote_pub_key: Some( + common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey::example(), + ), + nonce: Some((common::types::cardano::nonce::EXAMPLE + 97).into()), + txn: Some(common::types::cardano::txn_index::TxnIndex::example()), + payment_address: None, + is_payable: false, + cip15: true, + errors: vec!["Stake Public Key is required".into()], + } + } +} diff --git a/catalyst-gateway/bin/src/service/api/cardano/mod.rs b/catalyst-gateway/bin/src/service/api/cardano/mod.rs index 059611067c0..6866e65b051 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/mod.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/mod.rs @@ -1,10 +1,9 @@ //! Cardano API endpoints -use ed25519_dalek::VerifyingKey; use poem_openapi::{ param::{Path, Query}, OpenApi, }; -use types::{DateTime, SlotNumber}; +use types::DateTime; use crate::service::{ common::{ @@ -12,17 +11,17 @@ use crate::service::{ objects::cardano::network::Network, tags::ApiTags, types::{ - cardano::address::Cip19StakeAddress, + cardano::cip19_stake_address::Cip19StakeAddress, generic::ed25519_public_key::Ed25519HexEncodedPublicKey, }, }, utilities::middleware::schema_validation::schema_version_validation, }; -mod cip36; +pub(crate) mod cip36; mod date_time_to_slot_number_get; mod rbac; -mod registration_get; +// mod registration_get; pub(crate) mod staking; mod sync_state_get; pub(crate) mod types; @@ -32,40 +31,6 @@ pub(crate) struct Api; #[OpenApi(tag = "ApiTags::Cardano")] impl Api { - /// Get registration info. - /// - /// This endpoint returns the registration info followed by the [CIP-36](https://cips.cardano.org/cip/CIP-36/) to the - /// corresponded user's stake address. - #[oai( - path = "/draft/cardano/registration/:stake_address", - method = "get", - operation_id = "registrationGet", - transform = "schema_version_validation" - )] - async fn registration_get( - &self, - /// The stake address of the user. - /// Should be a valid Bech32 encoded address followed by the https://cips.cardano.org/cip/CIP-19/#stake-addresses. - stake_address: Path<Cip19StakeAddress>, - /// 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<Option<Network>>, - /// Slot number at which the staked ADA amount should be calculated. - /// If omitted latest slot number is used. - // TODO(bkioshn): https://github.com/input-output-hk/catalyst-voices/issues/239 - #[oai(validator(minimum(value = "0"), maximum(value = "9223372036854775807")))] - slot_number: Query<Option<SlotNumber>>, - /// No Authorization required, but Token permitted. - _auth: NoneOrRBAC, - ) -> registration_get::AllResponses { - registration_get::endpoint(stake_address.0, network.0, slot_number.0).await - } - /// Get Cardano follower's sync state. /// /// This endpoint returns the current cardano follower's sync state info. @@ -116,66 +81,6 @@ impl Api { date_time_to_slot_number_get::endpoint(date_time.0, network.0).await } - /// Get latest CIP36 registrations from stake address. - /// - /// This endpoint gets the latest registration given a stake address. - #[oai( - path = "/draft/cardano/cip36/latest_registration/stake_addr", - method = "get", - operation_id = "latestRegistrationGivenStakeAddr" - )] - async fn latest_registration_cip36_given_stake_addr( - &self, - /// Stake Public Key to find the latest registration for. - stake_pub_key: Query<Ed25519HexEncodedPublicKey>, // Validation provided by type. - /// No Authorization required, but Token permitted. - _auth: NoneOrRBAC, - ) -> cip36::SingleRegistrationResponse { - let hex_key = stake_pub_key.0; - let pub_key: VerifyingKey = hex_key.into(); - - cip36::get_latest_registration_from_stake_addr(&pub_key, true).await - } - - /// Get latest CIP36 registrations from a stake key hash. - /// - /// This endpoint gets the latest registration given a stake key hash. - #[oai( - path = "/draft/cardano/cip36/latest_registration/stake_key_hash", - method = "get", - operation_id = "latestRegistrationGivenStakeHash" - )] - async fn latest_registration_cip36_given_stake_key_hash( - &self, - /// Stake Key Hash to find the latest registration for. - #[oai(validator(max_length = 66, min_length = 0, pattern = "[0-9a-f]"))] - stake_key_hash: Query<String>, - /// No Authorization required, but Token permitted. - _auth: NoneOrRBAC, - ) -> cip36::SingleRegistrationResponse { - cip36::get_latest_registration_from_stake_key_hash(stake_key_hash.0, true).await - } - - /// Get latest CIP36 registrations from voting key. - /// - /// This endpoint returns the list of stake address registrations currently associated - /// with a given voting key. - #[oai( - path = "/draft/cardano/cip36/latest_registration/vote_key", - method = "get", - operation_id = "latestRegistrationGivenVoteKey" - )] - async fn latest_registration_cip36_given_vote_key( - &self, - /// Voting Key to find CIP36 registrations for. - #[oai(validator(max_length = 66, min_length = 66, pattern = "0x[0-9a-f]"))] - vote_key: Query<String>, - /// No Authorization required, but Token permitted. - _auth: NoneOrRBAC, - ) -> cip36::MultipleRegistrationResponse { - cip36::get_associated_vote_key_registrations(vote_key.0, true).await - } - #[oai( path = "/draft/rbac/chain_root/:stake_address", method = "get", @@ -234,4 +139,4 @@ impl Api { } /// Cardano API Endpoints -pub(crate) type CardanoApi = (Api, staking::Api); +pub(crate) type CardanoApi = (Api, staking::Api, cip36::Api); diff --git a/catalyst-gateway/bin/src/service/api/cardano/rbac/chain_root_get.rs b/catalyst-gateway/bin/src/service/api/cardano/rbac/chain_root_get.rs index 2769b1c2ed7..42e7de091b0 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/rbac/chain_root_get.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/rbac/chain_root_get.rs @@ -12,7 +12,9 @@ use crate::{ }, service::common::{ responses::WithErrorResponses, - types::{cardano::address::Cip19StakeAddress, headers::retry_after::RetryAfterOption}, + types::{ + cardano::cip19_stake_address::Cip19StakeAddress, headers::retry_after::RetryAfterOption, + }, }, }; diff --git a/catalyst-gateway/bin/src/service/api/cardano/staking/assets_get.rs b/catalyst-gateway/bin/src/service/api/cardano/staking/assets_get.rs index d1d2b72126e..cd0d2d04e37 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/staking/assets_get.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/staking/assets_get.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use anyhow::anyhow; use futures::StreamExt; +use pallas::ledger::addresses::StakeAddress; use poem_openapi::{payload::Json, ApiResponse}; use super::SlotNumber; @@ -26,7 +27,7 @@ use crate::{ stake_info::{FullStakeInfo, StakeInfo, StakedNativeTokenInfo}, }, responses::WithErrorResponses, - types::cardano::address::Cip19StakeAddress, + types::cardano::cip19_stake_address::Cip19StakeAddress, }, }; @@ -114,7 +115,7 @@ async fn calculate_stake_info( anyhow::bail!("Failed to acquire db session"); }; - let address = stake_address.to_stake_address()?; + let address: StakeAddress = stake_address.try_into()?; let stake_address_bytes = address.payload().as_hash().to_vec(); let mut txos_by_txn = get_txo_by_txn(&session, stake_address_bytes.clone(), slot_num).await?; diff --git a/catalyst-gateway/bin/src/service/api/cardano/staking/mod.rs b/catalyst-gateway/bin/src/service/api/cardano/staking/mod.rs index 7e3d9daf6ae..704b4d63264 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/staking/mod.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/staking/mod.rs @@ -9,7 +9,7 @@ use super::types::SlotNumber; use crate::service::{ common::{ auth::none_or_rbac::NoneOrRBAC, objects::cardano::network::Network, tags::ApiTags, - types::cardano::address::Cip19StakeAddress, + types::cardano::cip19_stake_address::Cip19StakeAddress, }, utilities::middleware::schema_validation::schema_version_validation, }; diff --git a/catalyst-gateway/bin/src/service/api/mod.rs b/catalyst-gateway/bin/src/service/api/mod.rs index 3d1e75f9314..0df680295f7 100644 --- a/catalyst-gateway/bin/src/service/api/mod.rs +++ b/catalyst-gateway/bin/src/service/api/mod.rs @@ -54,7 +54,7 @@ pub(crate) fn mk_api() -> OpenApiService<(HealthApi, CardanoApi, ConfigApi, Lega let mut service = OpenApiService::new( ( HealthApi, - (cardano::Api, cardano::staking::Api), + (cardano::Api, cardano::staking::Api, cardano::cip36::Api), ConfigApi, (legacy::RegistrationApi, legacy::V0Api, legacy::V1Api), ), diff --git a/catalyst-gateway/bin/src/service/common/auth/api_key.rs b/catalyst-gateway/bin/src/service/common/auth/api_key.rs index 7264e7906aa..4abc18efb98 100644 --- a/catalyst-gateway/bin/src/service/common/auth/api_key.rs +++ b/catalyst-gateway/bin/src/service/common/auth/api_key.rs @@ -4,16 +4,20 @@ //! //! It is NOT to be used on any endpoint intended to be publicly facing. -use poem::Request; +use anyhow::{bail, Result}; +use poem::{http::HeaderMap, Request}; use poem_openapi::{auth::ApiKey, SecurityScheme}; use crate::settings::Settings; +/// The header name that holds the API Key +const API_KEY_HEADER: &str = "X-API-Key"; + /// `ApiKey` authorization for Internal Endpoints #[derive(SecurityScheme)] #[oai( ty = "api_key", - key_name = "X-API-Key", + key_name = "X-API-Key", // MUST match the above constant. key_in = "header", checker = "api_checker" )] @@ -29,3 +33,14 @@ async fn api_checker(_req: &Request, api_key: ApiKey) -> Option<String> { None } } + +/// Check if the API Key is correctly set. +/// Returns an error if it is not. +pub(crate) fn check_api_key(headers: &HeaderMap) -> Result<()> { + if let Some(key) = headers.get(API_KEY_HEADER) { + if Settings::check_internal_api_key(key.to_str()?) { + return Ok(()); + } + } + bail!("Invalid API Key"); +} diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/cip36.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/cip36.rs index 9144a43a623..707d1e5631b 100644 --- a/catalyst-gateway/bin/src/service/common/objects/cardano/cip36.rs +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/cip36.rs @@ -1,5 +1,8 @@ //! CIP36 object +// TODO: This is NOT common, remove it once the rationalized endpoint is implemented. +// Retained to keep the existing code from breaking only. + use poem_openapi::{types::Example, Object}; use crate::service::common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey; diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/hash.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/hash.rs index 295c4409a40..d7cc7daae6f 100644 --- a/catalyst-gateway/bin/src/service/common/objects/cardano/hash.rs +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/hash.rs @@ -5,7 +5,7 @@ use poem_openapi::{ types::{ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, }; -use crate::service::utilities::to_hex_with_prefix; +use crate::service::utilities::as_hex_string; /// Cardano Blake2b256 hash encoded in hex. #[derive(Debug)] @@ -91,6 +91,6 @@ impl ParseFromJSON for Hash { impl ToJSON for Hash { fn to_json(&self) -> Option<serde_json::Value> { - Some(serde_json::Value::String(to_hex_with_prefix(&self.0))) + Some(serde_json::Value::String(as_hex_string(&self.0))) } } diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/mod.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/mod.rs index 63159b676c9..391e5e16e4b 100644 --- a/catalyst-gateway/bin/src/service/common/objects/cardano/mod.rs +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/mod.rs @@ -1,6 +1,10 @@ -//! Defines API schemas of Cardano types. +//! Defines API schemas of Cardano Objects. +//! +//! These Objects MUST be used in multiple places for multiple things to be considered +//! common. They should not be simple types. but actual objects. +//! Simple types belong in `common/types`. -pub(crate) mod cip36; +pub(crate) mod cip36; // TODO: Not common, to be removed once code refactored. pub(crate) mod hash; pub(crate) mod network; pub(crate) mod registration_info; diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/registration_info.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/registration_info.rs index cc23ffb0f68..24eeb2cb09b 100644 --- a/catalyst-gateway/bin/src/service/common/objects/cardano/registration_info.rs +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/registration_info.rs @@ -5,7 +5,7 @@ use poem_openapi::{types::Example, Object, Union}; use crate::service::{ api::cardano::types::{Nonce, PaymentAddress, PublicVotingInfo, TxId}, common::objects::cardano::hash::Hash, - utilities::to_hex_with_prefix, + utilities::as_hex_string, }; /// The Voting power and voting key of a Delegated voter. @@ -76,7 +76,7 @@ impl RegistrationInfo { let voting_info = match voting_info { PublicVotingInfo::Direct(voting_key) => { VotingInfo::Direct(DirectVoter { - voting_key: to_hex_with_prefix(voting_key.bytes()), + voting_key: as_hex_string(voting_key.bytes()), }) }, PublicVotingInfo::Delegated(delegations) => { @@ -85,7 +85,7 @@ impl RegistrationInfo { .into_iter() .map(|(voting_key, power)| { Delegation { - voting_key: to_hex_with_prefix(voting_key.bytes()), + voting_key: as_hex_string(voting_key.bytes()), power, } }) @@ -95,7 +95,7 @@ impl RegistrationInfo { }; Self { tx_hash: tx_hash.into(), - rewards_address: to_hex_with_prefix(rewards_address), + rewards_address: as_hex_string(rewards_address), nonce, voting_info, } diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/sync_state.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/sync_state.rs index 326d1633406..770dd19e68f 100644 --- a/catalyst-gateway/bin/src/service/common/objects/cardano/sync_state.rs +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/sync_state.rs @@ -8,7 +8,7 @@ use crate::service::{ }; /// Cardano follower's sync state info. -#[derive(Debug, Object)] +#[derive(Object)] #[oai(example = true)] pub(crate) struct SyncState { /// Slot number. diff --git a/catalyst-gateway/bin/src/service/common/objects/generic/mod.rs b/catalyst-gateway/bin/src/service/common/objects/generic/mod.rs new file mode 100644 index 00000000000..9f5eec6f164 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/objects/generic/mod.rs @@ -0,0 +1,3 @@ +//! Generic Objects + +pub(crate) mod pagination; diff --git a/catalyst-gateway/bin/src/service/common/objects/generic/pagination.rs b/catalyst-gateway/bin/src/service/common/objects/generic/pagination.rs new file mode 100644 index 00000000000..278fbc65d5d --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/objects/generic/pagination.rs @@ -0,0 +1,48 @@ +//! Pagination response object to be included in every paged response. + +use poem_openapi::{types::Example, Object}; + +use crate::service::common; + +/// Description for the `CurrentPage` object. +#[allow(dead_code)] +pub(crate) const CURRENT_PAGE_DESCRIPTION: &str = + "The Page of results is being returned, and the Limit of results. +The data returned is constrained by this limit. +The limit applies to the total number of records returned. +*Note: The Limit may not be exactly as requested, if it was constrained by the response. +The caller must read this record to ensure the correct data requested was returned.*"; + +#[derive(Object)] +#[oai(example = true)] +/// Current Page of data being returned. +pub(crate) struct CurrentPage { + #[allow(clippy::missing_docs_in_private_items)] // Type is pre documented + pub page: common::types::generic::query::pagination::Page, + #[allow(clippy::missing_docs_in_private_items)] // Type is pre documented + pub limit: common::types::generic::query::pagination::Limit, + #[allow(clippy::missing_docs_in_private_items)] // Type is pre documented + pub remaining: common::types::generic::query::pagination::Remaining, +} + +impl Example for CurrentPage { + fn example() -> Self { + Self { + page: common::types::generic::query::pagination::Page::example(), + limit: common::types::generic::query::pagination::Limit::example(), + remaining: common::types::generic::query::pagination::Remaining::example(), + } + } +} + +impl CurrentPage { + /// Create a new `CurrentPage` object. + #[allow(dead_code)] + fn new(page: u64, limit: u64, remaining: u64) -> Self { + Self { + page: page.into(), + limit: limit.into(), + remaining: remaining.into(), + } + } +} diff --git a/catalyst-gateway/bin/src/service/common/objects/mod.rs b/catalyst-gateway/bin/src/service/common/objects/mod.rs index 68c270166cf..c5e863052f3 100644 --- a/catalyst-gateway/bin/src/service/common/objects/mod.rs +++ b/catalyst-gateway/bin/src/service/common/objects/mod.rs @@ -2,4 +2,5 @@ pub(crate) mod cardano; pub(crate) mod config; +pub(crate) mod generic; pub(crate) mod legacy; diff --git a/catalyst-gateway/bin/src/service/common/responses/code_401_unauthorized.rs b/catalyst-gateway/bin/src/service/common/responses/code_401_unauthorized.rs index 962db861df8..445895f2bdd 100644 --- a/catalyst-gateway/bin/src/service/common/responses/code_401_unauthorized.rs +++ b/catalyst-gateway/bin/src/service/common/responses/code_401_unauthorized.rs @@ -3,32 +3,39 @@ use poem_openapi::{types::Example, Object}; use uuid::Uuid; -#[derive(Debug, Object)] -#[oai(example, skip_serializing_if_is_none)] -/// Server Error response to a Bad request. +use crate::service::common; + +#[derive(Object)] +#[oai(example)] +// Keep this message consistent with the response comment. +/// The client has not sent valid authentication credentials for the requested +/// resource. pub(crate) struct Unauthorized { /// Unique ID of this Server Error so that it can be located easily for debugging. id: Uuid, /// Error message. // Will not contain sensitive information, internal details or backtraces. - #[oai(validator(max_length = "1000", pattern = "^[0-9a-zA-Z].*$"))] - msg: String, + //#[oai(validator(max_length = "1000", pattern = "^[0-9a-zA-Z].*$"))] + msg: common::types::generic::error_msg::ErrorMessage, } impl Unauthorized { - /// Create a new Server Error Response Payload. + /// Create a new Payload. pub(crate) fn new(msg: Option<String>) -> Self { let msg = msg.unwrap_or( "Your request was not successful because it lacks valid authentication credentials for the requested resource.".to_string(), ); let id = Uuid::new_v4(); - Self { id, msg } + Self { + id, + msg: msg.into(), + } } } impl Example for Unauthorized { - /// Example for the Too Many Requests Payload. + /// Example fn example() -> Self { Self::new(None) } diff --git a/catalyst-gateway/bin/src/service/common/responses/code_403_forbidden.rs b/catalyst-gateway/bin/src/service/common/responses/code_403_forbidden.rs index b087e2350a7..8ef89206dc9 100644 --- a/catalyst-gateway/bin/src/service/common/responses/code_403_forbidden.rs +++ b/catalyst-gateway/bin/src/service/common/responses/code_403_forbidden.rs @@ -3,9 +3,10 @@ use poem_openapi::{types::Example, Object}; use uuid::Uuid; -#[derive(Debug, Object)] -#[oai(example, skip_serializing_if_is_none)] -/// Server Error response to a Bad request. +#[derive(Object)] +#[oai(example)] +/// The client has not sent valid authentication credentials for the requested +/// resource. pub(crate) struct Forbidden { /// Unique ID of this Server Error so that it can be located easily for debugging. id: Uuid, diff --git a/catalyst-gateway/bin/src/service/common/responses/code_422_unprocessable_content.rs b/catalyst-gateway/bin/src/service/common/responses/code_422_unprocessable_content.rs index b37eb9cc9b6..e2b8061248e 100644 --- a/catalyst-gateway/bin/src/service/common/responses/code_422_unprocessable_content.rs +++ b/catalyst-gateway/bin/src/service/common/responses/code_422_unprocessable_content.rs @@ -2,32 +2,69 @@ use poem_openapi::{types::Example, Object}; -#[derive(Debug, Object)] -#[oai(example, skip_serializing_if_is_none)] +use crate::service::common; + +#[derive(Object)] +#[oai(example)] +/// The client has not sent valid data in its request, headers, parameters or body. +pub(crate) struct UnprocessableContent { + #[oai(validator(max_items = "1000", min_items = "1"))] + /// Details of each error in the content that was detected. + /// + /// Note: This may not be ALL errors in the content, as validation of content can stop + /// at any point an error is detected. + detail: Vec<ContentErrorDetail>, +} + +impl UnprocessableContent { + /// Create a new `ContentErrorDetail` Response Payload. + pub(crate) fn new(errors: Vec<poem::Error>) -> Self { + let mut detail = vec![]; + for error in errors { + detail.push(ContentErrorDetail::new(&error)); + } + + Self { detail } + } +} + +impl Example for UnprocessableContent { + /// Example for the Too Many Requests Payload. + fn example() -> Self { + Self { + detail: vec![ContentErrorDetail::example()], + } + } +} + +//-------------------------------------------------------------------------------------- + +#[derive(Object)] +#[oai(example)] /// Individual details of a single error that was detected with the content of the /// request. pub(crate) struct ContentErrorDetail { /// The location of the error - #[oai(validator(max_items = 100, max_length = "1000", pattern = "^[0-9a-zA-Z].*$"))] - loc: Option<Vec<String>>, + #[oai(validator(max_items = 100))] + loc: Option<Vec<common::types::generic::error_msg::ErrorMessage>>, /// The error message. #[oai(validator(max_length = "1000", pattern = "^[0-9a-zA-Z].*$"))] - msg: Option<String>, + msg: Option<common::types::generic::error_msg::ErrorMessage>, /// The type of error #[oai( rename = "type", validator(max_length = "1000", pattern = "^[0-9a-zA-Z].*$") )] - err_type: Option<String>, + err_type: Option<common::types::generic::error_msg::ErrorMessage>, } impl Example for ContentErrorDetail { /// Example for the `ContentErrorDetail` Payload. fn example() -> Self { Self { - loc: Some(vec!["body".to_owned()]), - msg: Some("Value is not a valid dict.".to_owned()), - err_type: Some("type_error.dict".to_owned()), + loc: Some(vec!["body".into()]), + msg: Some("Value is not a valid dict.".into()), + err_type: Some("type_error.dict".into()), } } } @@ -38,41 +75,8 @@ impl ContentErrorDetail { // TODO: See if we can get more info from the error than this. Self { loc: None, - msg: Some(error.to_string()), + msg: Some(error.to_string().into()), err_type: None, } } } - -#[derive(Debug, Object)] -#[oai(example, skip_serializing_if_is_none)] -/// Server Error response to a Bad request. -pub(crate) struct UnprocessableContent { - #[oai(validator(max_items = "1000", min_items = "1"))] - /// Details of each error in the content that was detected. - /// - /// Note: This may not be ALL errors in the content, as validation of content can stop - /// at any point an error is detected. - detail: Vec<ContentErrorDetail>, -} - -impl UnprocessableContent { - /// Create a new `ContentErrorDetail` Response Payload. - pub(crate) fn new(errors: Vec<poem::Error>) -> Self { - let mut detail = vec![]; - for error in errors { - detail.push(ContentErrorDetail::new(&error)); - } - - Self { detail } - } -} - -impl Example for UnprocessableContent { - /// Example for the Too Many Requests Payload. - fn example() -> Self { - Self { - detail: vec![ContentErrorDetail::example()], - } - } -} diff --git a/catalyst-gateway/bin/src/service/common/responses/code_429_too_many_requests.rs b/catalyst-gateway/bin/src/service/common/responses/code_429_too_many_requests.rs index 195f40a09d2..d3f07429e52 100644 --- a/catalyst-gateway/bin/src/service/common/responses/code_429_too_many_requests.rs +++ b/catalyst-gateway/bin/src/service/common/responses/code_429_too_many_requests.rs @@ -3,9 +3,9 @@ use poem_openapi::{types::Example, Object}; use uuid::Uuid; -#[derive(Debug, Object)] -#[oai(example, skip_serializing_if_is_none)] -/// Server Error response to a Bad request. +#[derive(Object)] +#[oai(example)] +/// The client has sent too many requests in a given amount of time. pub(crate) struct TooManyRequests { /// Unique ID of this Server Error so that it can be located easily for debugging. id: Uuid, diff --git a/catalyst-gateway/bin/src/service/common/responses/code_500_internal_server_error.rs b/catalyst-gateway/bin/src/service/common/responses/code_500_internal_server_error.rs index d3ad5189c47..f7fc977cd73 100644 --- a/catalyst-gateway/bin/src/service/common/responses/code_500_internal_server_error.rs +++ b/catalyst-gateway/bin/src/service/common/responses/code_500_internal_server_error.rs @@ -8,9 +8,11 @@ use uuid::Uuid; /// probably want to place this in your crate root use crate::settings::Settings; -#[derive(Debug, Object)] +#[derive(Object)] #[oai(example, skip_serializing_if_is_none)] -/// Server Error response to a Bad request. +/// An internal server error occurred. +/// +/// *The contents of this response should be reported to the projects issue tracker.* pub(crate) struct InternalServerError { /// Unique ID of this Server Error so that it can be located easily for debugging. id: Uuid, diff --git a/catalyst-gateway/bin/src/service/common/responses/code_503_service_unavailable.rs b/catalyst-gateway/bin/src/service/common/responses/code_503_service_unavailable.rs index 48a840692e0..b5c67daf8b3 100644 --- a/catalyst-gateway/bin/src/service/common/responses/code_503_service_unavailable.rs +++ b/catalyst-gateway/bin/src/service/common/responses/code_503_service_unavailable.rs @@ -3,9 +3,12 @@ use poem_openapi::{types::Example, Object}; use uuid::Uuid; -#[derive(Debug, Object)] -#[oai(example, skip_serializing_if_is_none)] -/// Server Error response to a Bad request. +#[derive(Object)] +#[oai(example)] +/// The service is not available, try again later. +/// +/// *This is returned when the service either has not started, +/// or has become unavailable.* pub(crate) struct ServiceUnavailable { /// Unique ID of this Server Error so that it can be located easily for debugging. id: Uuid, diff --git a/catalyst-gateway/bin/src/service/common/responses/mod.rs b/catalyst-gateway/bin/src/service/common/responses/mod.rs index 31972cb3c3b..7f86a27c52c 100644 --- a/catalyst-gateway/bin/src/service/common/responses/mod.rs +++ b/catalyst-gateway/bin/src/service/common/responses/mod.rs @@ -75,7 +75,7 @@ pub(crate) enum ErrorResponses { /// ## Service Unavailable /// - /// The service is not available, do not send other requests. + /// The service is not available, try again later. /// /// *This is returned when the service either has not started, /// or has become unavailable.* diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/asset_value.rs b/catalyst-gateway/bin/src/service/common/types/cardano/asset_value.rs index fc858682df1..2fcf64a82fb 100644 --- a/catalyst-gateway/bin/src/service/common/types/cardano/asset_value.rs +++ b/catalyst-gateway/bin/src/service/common/types/cardano/asset_value.rs @@ -17,9 +17,11 @@ const DESCRIPTION: &str = "This is a non-zero signed integer."; const EXAMPLE: i128 = 1_234_567; /// Minimum. /// From: <https://github.com/IntersectMBO/cardano-ledger/blob/78b32d585fd4a0340fb2b184959fb0d46f32c8d2/eras/conway/impl/cddl-files/conway.cddl#L568> +/// This is NOT `i128::MIN`. const MINIMUM: i128 = -9_223_372_036_854_775_808; /// Maximum. /// From: <https://github.com/IntersectMBO/cardano-ledger/blob/78b32d585fd4a0340fb2b184959fb0d46f32c8d2/eras/conway/impl/cddl-files/conway.cddl#L569> +/// This is NOT `i128::MAX`. const MAXIMUM: i128 = 9_223_372_036_854_775_808; /// Schema. diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/cip19_shelley_address.rs b/catalyst-gateway/bin/src/service/common/types/cardano/cip19_shelley_address.rs new file mode 100644 index 00000000000..a6eac96080a --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/cip19_shelley_address.rs @@ -0,0 +1,144 @@ +//! Cardano address types. +//! +//! More information can be found in [CIP-19](https://cips.cardano.org/cip/CIP-19) + +use std::{ + borrow::Cow, + ops::{Deref, DerefMut}, + sync::LazyLock, +}; + +use const_format::concatcp; +use pallas::ledger::addresses::{Address, ShelleyAddress}; +use poem_openapi::{ + registry::{MetaExternalDocument, MetaSchema, MetaSchemaRef}, + types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use serde_json::Value; + +use crate::service::common::types::string_types::impl_string_types; + +/// Title +const TITLE: &str = "Cardano Payment Address"; +/// Description +const DESCRIPTION: &str = "Cardano Shelley Payment Address (CIP-19 Formatted)."; +/// Example +// cSpell:disable +const EXAMPLE: &str = "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgs68faae"; +// cSpell:enable +/// Production Address Identifier +const PROD: &str = "addr"; +/// Test Address Identifier +const TEST: &str = "addr_test"; +/// Bech32 Match Pattern +const BECH32: &str = "[a,c-h,j-n,p-z,0,2-9]"; +/// Length of the encoded address (for type 0 - 3). +const ENCODED_STAKED_ADDR_LEN: usize = 98; +/// Length of the encoded address (for type 6 - 7). +const ENCODED_UNSTAKED_ADDR_LEN: usize = 53; +/// Regex Pattern +const PATTERN: &str = concatcp!( + "(", + PROD, + "|", + TEST, + ")1(", + BECH32, + "{", + ENCODED_UNSTAKED_ADDR_LEN, + "}|", + BECH32, + "{", + ENCODED_STAKED_ADDR_LEN, + "})" +); +/// Length of the decoded address. +const DECODED_UNSTAKED_ADDR_LEN: usize = 28; +/// Length of the decoded address. +const DECODED_STAKED_ADDR_LEN: usize = DECODED_UNSTAKED_ADDR_LEN * 2; +/// Minimum length +const MIN_LENGTH: usize = PROD.len() + 1 + ENCODED_UNSTAKED_ADDR_LEN; +/// Minimum length +const MAX_LENGTH: usize = TEST.len() + 1 + ENCODED_STAKED_ADDR_LEN; + +/// External document for Cardano addresses. +static EXTERNAL_DOCS: LazyLock<MetaExternalDocument> = LazyLock::new(|| { + MetaExternalDocument { + url: "https://cips.cardano.org/cip/CIP-19".to_owned(), + description: Some("CIP-19 - Cardano Addresses".to_owned()), + } +}); + +/// Schema. +static SCHEMA: LazyLock<MetaSchema> = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(Value::String(EXAMPLE.to_string())), + external_docs: Some(EXTERNAL_DOCS.clone()), + min_length: Some(MIN_LENGTH), + max_length: Some(MAX_LENGTH), + pattern: Some(PATTERN.to_string()), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Because ALL the constraints are defined above, we do not ever need to define them in +/// the API. BUT we do need to make a validator. +/// This helps enforce uniform validation. +fn is_valid(addr: &str) -> bool { + // Just check the string can be safely converted into the type. + if let Ok((hrp, addr)) = bech32::decode(addr) { + let hrp = hrp.as_str(); + (addr.len() == DECODED_UNSTAKED_ADDR_LEN || addr.len() == DECODED_STAKED_ADDR_LEN) + && (hrp == PROD || hrp == TEST) + } else { + false + } +} + +impl_string_types!( + Cip19ShelleyAddress, + "string", + "cardano:cip19-address", + Some(SCHEMA.clone()), + is_valid +); + +impl Cip19ShelleyAddress { + /// Create a new `PaymentAddress`. + #[allow(dead_code)] + pub fn new(address: String) -> Self { + Cip19ShelleyAddress(address) + } +} + +impl TryFrom<ShelleyAddress> for Cip19ShelleyAddress { + type Error = anyhow::Error; + + fn try_from(addr: ShelleyAddress) -> Result<Self, Self::Error> { + let addr_str = addr + .to_bech32() + .map_err(|e| anyhow::anyhow!(format!("Invalid payment address {e}")))?; + Ok(Self(addr_str)) + } +} + +impl TryInto<ShelleyAddress> for Cip19ShelleyAddress { + type Error = anyhow::Error; + + fn try_into(self) -> Result<ShelleyAddress, Self::Error> { + let address_str = &self.0; + let address = Address::from_bech32(address_str)?; + match address { + Address::Shelley(address) => Ok(address), + _ => Err(anyhow::anyhow!("Invalid payment address")), + } + } +} + +impl Example for Cip19ShelleyAddress { + fn example() -> Self { + Self(EXAMPLE.to_owned()) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/address.rs b/catalyst-gateway/bin/src/service/common/types/cardano/cip19_stake_address.rs similarity index 66% rename from catalyst-gateway/bin/src/service/common/types/cardano/address.rs rename to catalyst-gateway/bin/src/service/common/types/cardano/cip19_stake_address.rs index 9a3a2e70cc8..3ae12fbc3b1 100644 --- a/catalyst-gateway/bin/src/service/common/types/cardano/address.rs +++ b/catalyst-gateway/bin/src/service/common/types/cardano/cip19_stake_address.rs @@ -8,6 +8,7 @@ use std::{ sync::LazyLock, }; +use anyhow::bail; use const_format::concatcp; use pallas::ledger::addresses::{Address, StakeAddress}; use poem_openapi::{ @@ -24,14 +25,14 @@ const TITLE: &str = "Cardano stake address"; const DESCRIPTION: &str = "Cardano stake address, also known as a reward address."; /// Stake address example. // cSpell:disable -const EXAMPLE: &str = "stake_test1uqehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gssrtvn"; +pub(crate) const EXAMPLE: &str = "stake_test1uqehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gssrtvn"; // cSpell:enable /// Production Stake Address Identifier const PROD_STAKE: &str = "stake"; /// Test Stake Address Identifier const TEST_STAKE: &str = "stake_test"; /// Regex Pattern -const PATTERN: &str = concatcp!( +pub(crate) const PATTERN: &str = concatcp!( "(", PROD_STAKE, "|", @@ -43,9 +44,12 @@ const ENCODED_ADDR_LEN: usize = 53; /// Length of the decoded address. const DECODED_ADDR_LEN: usize = 28; /// Minimum length -const MIN_LENGTH: usize = PROD_STAKE.len() + 1 + ENCODED_ADDR_LEN; +pub(crate) const MIN_LENGTH: usize = PROD_STAKE.len() + 1 + ENCODED_ADDR_LEN; /// Minimum length -const MAX_LENGTH: usize = TEST_STAKE.len() + 1 + ENCODED_ADDR_LEN; +pub(crate) const MAX_LENGTH: usize = TEST_STAKE.len() + 1 + ENCODED_ADDR_LEN; + +/// String Format +pub(crate) const FORMAT: &str = "cardano:cip19-address"; /// External document for Cardano addresses. static EXTERNAL_DOCS: LazyLock<MetaExternalDocument> = LazyLock::new(|| { @@ -85,31 +89,42 @@ fn is_valid(stake_addr: &str) -> bool { impl_string_types!( Cip19StakeAddress, "string", - "cardano:cip19-address", + FORMAT, Some(STAKE_SCHEMA.clone()), is_valid ); -impl Cip19StakeAddress { - /// Create a new `StakeAddress`. - #[allow(dead_code)] - pub fn new(address: String) -> Self { - Cip19StakeAddress(address) +impl TryFrom<&str> for Cip19StakeAddress { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result<Self, Self::Error> { + value.to_string().try_into() } +} - /// Convert a `StakeAddress` string to a `StakeAddress`. - pub fn to_stake_address(&self) -> anyhow::Result<StakeAddress> { - let address_str = &self.0; - let address = Address::from_bech32(address_str)?; - match address { - Address::Stake(stake_address) => Ok(stake_address), - _ => Err(anyhow::anyhow!("Invalid stake address")), - } +impl TryFrom<String> for Cip19StakeAddress { + type Error = anyhow::Error; + + fn try_from(value: String) -> Result<Self, Self::Error> { + match bech32::decode(&value) { + Ok((hrp, addr)) => { + let hrp = hrp.as_str(); + if addr.len() == DECODED_ADDR_LEN && (hrp == PROD_STAKE || hrp == TEST_STAKE) { + return Ok(Cip19StakeAddress(value)); + } + bail!("Invalid CIP-19 formatted Stake Address") + }, + Err(err) => { + bail!("Invalid CIP-19 formatted Stake Address : {err}"); + }, + }; } +} - /// Convert a `StakeAddress` to a `StakeAddress` string. - #[allow(dead_code)] - pub fn from_stake_address(addr: &StakeAddress) -> anyhow::Result<Self> { +impl TryFrom<StakeAddress> for Cip19StakeAddress { + type Error = anyhow::Error; + + fn try_from(addr: StakeAddress) -> Result<Self, Self::Error> { let addr_str = addr .to_bech32() .map_err(|e| anyhow::anyhow!(format!("Invalid stake address {e}")))?; @@ -117,6 +132,19 @@ impl Cip19StakeAddress { } } +impl TryInto<StakeAddress> for Cip19StakeAddress { + type Error = anyhow::Error; + + fn try_into(self) -> Result<StakeAddress, Self::Error> { + let address_str = &self.0; + let address = Address::from_bech32(address_str)?; + match address { + Address::Stake(address) => Ok(address), + _ => Err(anyhow::anyhow!("Invalid stake address")), + } + } +} + impl Example for Cip19StakeAddress { fn example() -> Self { Self(EXAMPLE.to_owned()) diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/hash28.rs b/catalyst-gateway/bin/src/service/common/types/cardano/hash28.rs index 4b3e97cee9a..5c44725d835 100644 --- a/catalyst-gateway/bin/src/service/common/types/cardano/hash28.rs +++ b/catalyst-gateway/bin/src/service/common/types/cardano/hash28.rs @@ -15,7 +15,10 @@ use poem_openapi::{ }; use serde_json::Value; -use crate::service::common::types::string_types::impl_string_types; +use crate::service::{ + common::types::string_types::impl_string_types, + utilities::{as_hex_string, from_hex_string}, +}; /// Title. const TITLE: &str = "28 Byte Hash"; @@ -78,7 +81,7 @@ impl TryFrom<Vec<u8>> for HexEncodedHash28 { if value.len() != HASH_LENGTH { bail!("Hash Length Invalid.") } - Ok(Self(format!("0x{}", hex::encode(value)))) + Ok(Self(as_hex_string(&value))) } } @@ -87,9 +90,7 @@ impl TryFrom<Vec<u8>> for HexEncodedHash28 { // All creation of this type should come only from one of the deserialization methods. impl From<HexEncodedHash28> for Vec<u8> { fn from(val: HexEncodedHash28) -> Self { - #[allow(clippy::string_slice)] // 100% safe due to the way this type can be constructed. - let raw_hex = &val.0[2..]; #[allow(clippy::expect_used)] - hex::decode(raw_hex).expect("This can only fail if the type was invalidly constructed.") + from_hex_string(&val.0).expect("This can only fail if the type was invalidly constructed.") } } diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/mod.rs b/catalyst-gateway/bin/src/service/common/types/cardano/mod.rs index d4c4deac6b9..f50e83f268d 100644 --- a/catalyst-gateway/bin/src/service/common/types/cardano/mod.rs +++ b/catalyst-gateway/bin/src/service/common/types/cardano/mod.rs @@ -1,6 +1,11 @@ //! Cardano Types -pub(crate) mod address; pub(crate) mod asset_name; pub(crate) mod asset_value; +pub(crate) mod cip19_shelley_address; +pub(crate) mod cip19_stake_address; pub(crate) mod hash28; +pub(crate) mod nonce; +pub(crate) mod query; +pub(crate) mod slot_no; +pub(crate) mod txn_index; diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/nonce.rs b/catalyst-gateway/bin/src/service/common/types/cardano/nonce.rs new file mode 100644 index 00000000000..e99679b7d7f --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/nonce.rs @@ -0,0 +1,126 @@ +//! Nonce + +use std::sync::LazyLock; + +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use serde_json::Value; + +use super::slot_no; + +/// Title. +const TITLE: &str = "Nonce"; +/// Description. +const DESCRIPTION: &str = "The current slot at the time a transaction was posted. +Used to ensure out of order inclusion on-chain can be detected. + +*Note: Because a Nonce should never be greater than the slot of the transaction it is found in, +excessively large nonces are capped to the transactions slot number.*"; +/// Example. +pub(crate) const EXAMPLE: u64 = slot_no::EXAMPLE; +/// Minimum. +const MINIMUM: u64 = 0; +/// Maximum. +const MAXIMUM: u64 = u64::MAX; + +/// Schema. +#[allow(clippy::cast_precision_loss)] +static SCHEMA: LazyLock<MetaSchema> = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(EXAMPLE.into()), + maximum: Some(MAXIMUM as f64), + minimum: Some(MINIMUM as f64), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Value of a Nonce. +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct Nonce(u64); + +/// Is the Nonce valid? +fn is_valid(value: u64) -> bool { + (MINIMUM..=MAXIMUM).contains(&value) +} + +impl Type for Nonce { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "integer(u64)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("integer", "u64"))); + schema_ref.merge(SCHEMA.clone()) + } + + fn as_raw_value(&self) -> Option<&Self::RawValueType> { + Some(self) + } + + fn raw_element_iter<'a>( + &'a self, + ) -> Box<dyn Iterator<Item = &'a Self::RawElementValueType> + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for Nonce { + fn parse_from_parameter(value: &str) -> ParseResult<Self> { + let nonce: u64 = value.parse()?; + Ok(Self(nonce)) + } +} + +impl ParseFromJSON for Nonce { + fn parse_from_json(value: Option<Value>) -> ParseResult<Self> { + let value = value.unwrap_or_default(); + match value { + Value::Number(num) => { + let nonce = num + .as_u64() + .ok_or(ParseError::from("nonce must be a positive integer"))?; + if !is_valid(nonce) { + return Err(ParseError::from("nonce out of valid range")); + } + Ok(Self(nonce)) + }, + _ => Err(ParseError::expected_type(value)), + } + } +} + +impl ToJSON for Nonce { + fn to_json(&self) -> Option<Value> { + Some(self.0.into()) + } +} + +impl From<u64> for Nonce { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl Nonce { + /// Generic conversion of `Option<T>` to `Option<Self>`. + #[allow(dead_code)] + pub(crate) fn into_option<T: Into<Self>>(value: Option<T>) -> Option<Self> { + value.map(std::convert::Into::into) + } +} + +impl Example for Nonce { + fn example() -> Self { + Self(EXAMPLE) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/query/as_at.rs b/catalyst-gateway/bin/src/service/common/types/cardano/query/as_at.rs new file mode 100644 index 00000000000..f95fbb607f6 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/query/as_at.rs @@ -0,0 +1,169 @@ +//! Query Parameter that can take either a Blockchain slot Number of Unix Epoch timestamp. +//! +//! Allows better specifying of times that restrict a GET endpoints response. + +//! Hex encoded 28 byte hash. +//! +//! Hex encoded string which represents a 28 byte hash. + +use std::{ + cmp::{max, min}, + fmt::{self, Display}, + sync::LazyLock, +}; + +use anyhow::{bail, Result}; +use chrono::DateTime; +use const_format::concatcp; +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{ParseError, ParseFromParameter, ParseResult, Type}, +}; +use regex::Regex; +use serde_json::Value; + +use crate::{service::common::types::cardano::slot_no::SlotNo, settings::Settings}; + +/// Title. +const TITLE: &str = "As At this Time OR Slot."; +/// Description. +const DESCRIPTION: &str = "Restrict the query to this time. +Time can be represented as either the blockchains slot number, +or the number of seconds since midnight 1970, UTC. + +If this parameter is not defined, the query will retrieve data up to the current time."; +/// Example whence. +const EXAMPLE_WHENCE: &str = TIME_DISCRIMINATOR; +/// Example time. +const EXAMPLE_TIME: u64 = 1_730_861_339; // Date and time (UTC): November 6, 2024 2:48:59 AM +/// Example +static EXAMPLE: LazyLock<String> = LazyLock::new(|| { + // Note, the SlotNumber here is wrong, but its not used for generating the example, so + // thats OK. + let example = AsAt((EXAMPLE_WHENCE.to_owned(), EXAMPLE_TIME, 0.into())); + format!("{example}") +}); +/// Time Discriminator +const TIME_DISCRIMINATOR: &str = "TIME"; +/// Slot Discriminator +const SLOT_DISCRIMINATOR: &str = "SLOT"; +/// Validation Regex Pattern +const PATTERN: &str = concatcp!( + "^(", + SLOT_DISCRIMINATOR, + "|", + TIME_DISCRIMINATOR, + r"):(\d{1,20})$" +); +/// Minimum parameter length +static MIN_LENGTH: LazyLock<usize> = + LazyLock::new(|| min(TIME_DISCRIMINATOR.len(), SLOT_DISCRIMINATOR.len()) + ":0".len()); +/// Maximum parameter length +static MAX_LENGTH: LazyLock<usize> = LazyLock::new(|| { + max(TIME_DISCRIMINATOR.len(), SLOT_DISCRIMINATOR.len()) + ":".len() + u64::MAX.to_string().len() +}); + +/// Parse the `AsAt` parameter from the Query string provided. +fn parse_parameter(param: &str) -> Result<(String, u64)> { + /// Regex to parse the parameter + #[allow(clippy::unwrap_used)] // Safe because the Regex is constant. Can never panic in prod. + static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(PATTERN).unwrap()); + + let Some(results) = RE.captures(param) else { + bail!("Not a valid `as_at` parameter."); + }; + let whence = &results[1]; + let Ok(when) = results[2].parse::<u64>() else { + bail!( + "Not a valid `as_at` parameter. Invalid {} specified.", + whence + ); + }; + Ok((whence.to_owned(), when)) +} + +/// Schema. +static SCHEMA: LazyLock<MetaSchema> = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(Value::String(EXAMPLE.to_string())), + max_length: Some(*MAX_LENGTH), + min_length: Some(*MIN_LENGTH), + pattern: Some(PATTERN.to_string()), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// As at time from query string parameter. +/// Store (Whence, When and decoded `SlotNo`) in a tuple for easier access. +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct AsAt((String, u64, SlotNo)); + +impl Type for AsAt { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "string(slot or time)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format( + "string", + "slot or time", + ))); + schema_ref.merge(SCHEMA.clone()) + } + + fn as_raw_value(&self) -> Option<&Self::RawValueType> { + Some(self) + } + + fn raw_element_iter<'a>( + &'a self, + ) -> Box<dyn Iterator<Item = &'a Self::RawElementValueType> + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for AsAt { + fn parse_from_parameter(value: &str) -> ParseResult<Self> { + let (whence, when) = parse_parameter(value)?; + let slot = if whence == TIME_DISCRIMINATOR { + let network = Settings::cardano_network(); + let Ok(epoch_time) = when.try_into() else { + return Err(ParseError::from(format!( + "time {when} too far in the future" + ))); + }; + let Some(datetime) = DateTime::from_timestamp(epoch_time, 0) else { + return Err(ParseError::from(format!("invalid time {when}"))); + }; + let Some(slot) = network.time_to_slot(datetime) else { + return Err(ParseError::from(format!( + "invalid time {when} for network: {network}" + ))); + }; + slot + } else { + when + }; + let slot_no: SlotNo = slot.into(); + Ok(Self((whence, when, slot_no))) + } +} + +impl From<AsAt> for SlotNo { + fn from(value: AsAt) -> Self { + value.0 .2 + } +} + +impl Display for AsAt { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}", self.0 .0, self.0 .1) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/query/mod.rs b/catalyst-gateway/bin/src/service/common/types/cardano/query/mod.rs new file mode 100644 index 00000000000..2583fded07c --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/query/mod.rs @@ -0,0 +1,9 @@ +//! These types are specifically and only used for Query Parameters +//! +//! They exist due to limitations in the expressiveness of Query parameter constraints in +//! `OpenAPI` + +pub(crate) mod as_at; +pub(crate) mod stake_or_voter; + +pub(crate) use as_at::AsAt; diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/query/stake_or_voter.rs b/catalyst-gateway/bin/src/service/common/types/cardano/query/stake_or_voter.rs new file mode 100644 index 00000000000..63e2aaf7a84 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/query/stake_or_voter.rs @@ -0,0 +1,200 @@ +//! Query Parameter that can take a CIP-19 stake address, or a hex encoded vote public +//! key. +//! +//! Allows us to have one parameter that can represent two things, uniformly. + +use std::{ + cmp::{max, min}, + sync::LazyLock, +}; + +use anyhow::{bail, Result}; +use const_format::concatcp; +use poem::http::HeaderMap; +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{ParseFromParameter, ParseResult, Type}, +}; +use regex::Regex; +use serde_json::Value; + +use crate::service::common::{self, auth::api_key::check_api_key}; + +/// A Query Parameter that can take a CIP-19 stake address, or a public key. +/// Defining these are mutually exclusive, ao a single parameter is required to be used. +#[derive(Clone)] +pub(crate) enum StakeAddressOrPublicKey { + /// A CIP-19 stake address + Address(common::types::cardano::cip19_stake_address::Cip19StakeAddress), + /// A Ed25519 Public Key + PublicKey(common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey), + /// Special value that means we try to fetch all possible results. Must be protected + /// with an `APIKey`. + All, +} + +impl From<StakeOrVoter> for StakeAddressOrPublicKey { + fn from(value: StakeOrVoter) -> Self { + value.0 .1 + } +} + +impl TryFrom<&str> for StakeAddressOrPublicKey { + type Error = anyhow::Error; + + fn try_from(value: &str) -> std::result::Result<Self, Self::Error> { + /// Regex to parse the parameter + #[allow(clippy::unwrap_used)] // Safe because the Regex is constant. Can never panic in prod. + static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(PATTERN).unwrap()); + + // First check it is the special "ALL" parameter. + if value == "ALL" { + return Ok(Self::All); + } + + // Otherwise, work out use the regex to work out what it is, and validate it. + if let Some(results) = RE.captures(value) { + if let Some(stake_addr) = results.get(1) { + return Ok(Self::Address(stake_addr.as_str().try_into()?)); + } else if let Some(public_key) = results.get(2) { + return Ok(Self::PublicKey(public_key.as_str().try_into()?)); + } + } + bail!("Not a valid \"Stake or Public Key\" parameter."); + } +} + +/// Title. +const TITLE: &str = "Stake Address or Voting Key."; +/// Description. +const DESCRIPTION: &str = "Restrict the query to this Stake address, or Voters Public Key. +If neither are defined, the stake address(es) from the auth tokens role0 registration are used."; +/// Example +const EXAMPLE: &str = common::types::cardano::cip19_stake_address::EXAMPLE; +/// Stake Address Pattern +const STAKE_PATTERN: &str = common::types::cardano::cip19_stake_address::PATTERN; +/// Voting Key Pattern +const VOTING_KEY_PATTERN: &str = common::types::generic::ed25519_public_key::PATTERN; +/// Validation Regex Pattern +const PATTERN: &str = concatcp!("^(", STAKE_PATTERN, ")|(", VOTING_KEY_PATTERN, ")$"); +/// Minimum parameter length +static MIN_LENGTH: LazyLock<usize> = LazyLock::new(|| { + min( + common::types::cardano::cip19_stake_address::MIN_LENGTH, + common::types::generic::ed25519_public_key::ENCODED_LENGTH, + ) +}); +/// Maximum parameter length +static MAX_LENGTH: LazyLock<usize> = LazyLock::new(|| { + max( + common::types::cardano::cip19_stake_address::MAX_LENGTH, + common::types::generic::ed25519_public_key::ENCODED_LENGTH, + ) +}); + +/// Format +const FORMAT: &str = concatcp!( + common::types::cardano::cip19_stake_address::FORMAT, + "|", + common::types::generic::ed25519_public_key::FORMAT +); + +/// Schema. +static SCHEMA: LazyLock<MetaSchema> = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(Value::String(EXAMPLE.to_string())), + max_length: Some(*MAX_LENGTH), + min_length: Some(*MIN_LENGTH), + pattern: Some(PATTERN.to_string()), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Either a Stake Address or a ED25519 Public key. +#[derive(Clone)] +pub(crate) struct StakeOrVoter((String, StakeAddressOrPublicKey)); + +impl TryFrom<&str> for StakeOrVoter { + type Error = anyhow::Error; + + fn try_from(value: &str) -> std::result::Result<Self, Self::Error> { + Ok(Self((value.to_string(), value.try_into()?))) + } +} + +impl Type for StakeOrVoter { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + format!("string({FORMAT})").into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("string", FORMAT))); + schema_ref.merge(SCHEMA.clone()) + } + + fn as_raw_value(&self) -> Option<&Self::RawValueType> { + Some(self) + } + + fn raw_element_iter<'a>( + &'a self, + ) -> Box<dyn Iterator<Item = &'a Self::RawElementValueType> + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for StakeOrVoter { + fn parse_from_parameter(value: &str) -> ParseResult<Self> { + Ok(Self((value.to_string(), value.try_into()?))) + } +} + +impl StakeOrVoter { + /// Is this for ALL results? + pub(crate) fn is_all(&self, headers: &HeaderMap) -> Result<bool> { + match self.0 .1 { + StakeAddressOrPublicKey::All => { + check_api_key(headers)?; + Ok(true) + }, + _ => Ok(false), + } + } +} + +impl TryInto<common::types::cardano::cip19_stake_address::Cip19StakeAddress> for StakeOrVoter { + type Error = anyhow::Error; + + fn try_into( + self, + ) -> Result<common::types::cardano::cip19_stake_address::Cip19StakeAddress, Self::Error> { + match self.0 .1 { + StakeAddressOrPublicKey::Address(addr) => Ok(addr), + _ => bail!("Not a Stake Address"), + } + } +} + +impl TryInto<common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey> + for StakeOrVoter +{ + type Error = anyhow::Error; + + fn try_into( + self, + ) -> Result<common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey, Self::Error> + { + match self.0 .1 { + StakeAddressOrPublicKey::PublicKey(key) => Ok(key), + _ => bail!("Not a Stake Address"), + } + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/slot_no.rs b/catalyst-gateway/bin/src/service/common/types/cardano/slot_no.rs new file mode 100644 index 00000000000..f2e2f2d2daa --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/slot_no.rs @@ -0,0 +1,131 @@ +//! Slot Number on the blockchain. + +use std::sync::LazyLock; + +use anyhow::bail; +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use serde_json::Value; + +/// Title. +const TITLE: &str = "Cardano Blockchain Slot Number"; +/// Description. +const DESCRIPTION: &str = "The Slot Number of a Cardano Block on the chain."; +/// Example. +pub(crate) const EXAMPLE: u64 = 1_234_567; +/// Minimum. +const MINIMUM: u64 = 0; +/// Maximum. +const MAXIMUM: u64 = u64::MAX; + +/// Schema. +#[allow(clippy::cast_precision_loss)] +static SCHEMA: LazyLock<MetaSchema> = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(EXAMPLE.into()), + maximum: Some(MAXIMUM as f64), + minimum: Some(MINIMUM as f64), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Slot number +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct SlotNo(u64); + +/// Is the Slot Number valid? +fn is_valid(_value: u64) -> bool { + true +} + +impl Type for SlotNo { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "integer(u64)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("integer", "u64"))); + schema_ref.merge(SCHEMA.clone()) + } + + fn as_raw_value(&self) -> Option<&Self::RawValueType> { + Some(self) + } + + fn raw_element_iter<'a>( + &'a self, + ) -> Box<dyn Iterator<Item = &'a Self::RawElementValueType> + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for SlotNo { + fn parse_from_parameter(value: &str) -> ParseResult<Self> { + let slot: u64 = value.parse()?; + Ok(Self(slot)) + } +} + +impl ParseFromJSON for SlotNo { + fn parse_from_json(value: Option<Value>) -> ParseResult<Self> { + let value = value.unwrap_or_default(); + if let Value::Number(value) = value { + let value = value + .as_u64() + .ok_or(ParseError::from("invalid slot number"))?; + if !is_valid(value) { + return Err("invalid AssetValue".into()); + } + Ok(Self(value)) + } else { + Err(ParseError::expected_type(value)) + } + } +} + +impl ToJSON for SlotNo { + fn to_json(&self) -> Option<Value> { + Some(self.0.into()) + } +} + +impl TryFrom<i64> for SlotNo { + type Error = anyhow::Error; + + fn try_from(value: i64) -> Result<Self, Self::Error> { + let value: u64 = value.try_into()?; + if !is_valid(value) { + bail!("Invalid Slot Number"); + } + Ok(Self(value)) + } +} + +impl From<u64> for SlotNo { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl SlotNo { + /// Generic conversion of `Option<T>` to `Option<SlotNo>`. + pub(crate) fn into_option<T: Into<SlotNo>>(value: Option<T>) -> Option<SlotNo> { + value.map(std::convert::Into::into) + } +} + +impl Example for SlotNo { + fn example() -> Self { + Self(EXAMPLE) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/txn_index.rs b/catalyst-gateway/bin/src/service/common/types/cardano/txn_index.rs new file mode 100644 index 00000000000..7a48bd1c344 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/txn_index.rs @@ -0,0 +1,147 @@ +//! Transaction Index within a block. + +use std::sync::LazyLock; + +use anyhow::bail; +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use serde_json::Value; + +/// Title. +const TITLE: &str = "Transaction Index"; +/// Description. +const DESCRIPTION: &str = "The Index of a transaction within a block."; +/// Example. +const EXAMPLE: u16 = 7; +/// Minimum. +const MINIMUM: u16 = 0; +/// Maximum. +const MAXIMUM: u16 = u16::MAX; +/// Invalid Error Msg. +const INVALID_MSG: &str = "Invalid Transaction Index."; + +/// Schema. +#[allow(clippy::cast_lossless)] +static SCHEMA: LazyLock<MetaSchema> = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(EXAMPLE.into()), + maximum: Some(MAXIMUM as f64), + minimum: Some(MINIMUM as f64), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Transaction Index within a block. +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct TxnIndex(u16); + +/// Is the Slot Number valid? +fn is_valid(_value: u16) -> bool { + true +} + +impl Type for TxnIndex { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "integer(u16)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("integer", "u16"))); + schema_ref.merge(SCHEMA.clone()) + } + + fn as_raw_value(&self) -> Option<&Self::RawValueType> { + Some(self) + } + + fn raw_element_iter<'a>( + &'a self, + ) -> Box<dyn Iterator<Item = &'a Self::RawElementValueType> + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for TxnIndex { + fn parse_from_parameter(value: &str) -> ParseResult<Self> { + let idx: u16 = value.parse()?; + Ok(Self(idx)) + } +} + +impl ParseFromJSON for TxnIndex { + fn parse_from_json(value: Option<Value>) -> ParseResult<Self> { + let value = value.unwrap_or_default(); + if let Value::Number(value) = value { + let value = value + .as_u64() + .ok_or(ParseError::from(INVALID_MSG))? + .try_into()?; + if !is_valid(value) { + return Err(INVALID_MSG.into()); + } + Ok(Self(value)) + } else { + Err(ParseError::expected_type(value)) + } + } +} + +impl ToJSON for TxnIndex { + fn to_json(&self) -> Option<Value> { + Some(self.0.into()) + } +} + +impl TryFrom<u64> for TxnIndex { + type Error = anyhow::Error; + + fn try_from(value: u64) -> Result<Self, Self::Error> { + let value: u16 = value.try_into()?; + if !is_valid(value) { + bail!(INVALID_MSG); + } + Ok(Self(value)) + } +} + +impl TryFrom<i16> for TxnIndex { + type Error = anyhow::Error; + + fn try_from(value: i16) -> Result<Self, Self::Error> { + let value: u16 = value.try_into()?; + if !is_valid(value) { + bail!(INVALID_MSG); + } + Ok(Self(value)) + } +} + +impl From<u16> for TxnIndex { + fn from(value: u16) -> Self { + Self(value) + } +} + +impl TxnIndex { + /// Generic conversion of `Option<T>` to `Option<TxnIndex>`. + #[allow(dead_code)] + pub(crate) fn into_option<T: Into<Self>>(value: Option<T>) -> Option<Self> { + value.map(std::convert::Into::into) + } +} + +impl Example for TxnIndex { + fn example() -> Self { + Self(EXAMPLE) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/generic/ed25519_public_key.rs b/catalyst-gateway/bin/src/service/common/types/generic/ed25519_public_key.rs index 92fbd2c572d..eb0954f4e61 100644 --- a/catalyst-gateway/bin/src/service/common/types/generic/ed25519_public_key.rs +++ b/catalyst-gateway/bin/src/service/common/types/generic/ed25519_public_key.rs @@ -8,24 +8,30 @@ use std::{ sync::LazyLock, }; +use anyhow::bail; use poem_openapi::{ registry::{MetaSchema, MetaSchemaRef}, types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, }; use serde_json::Value; -use crate::{service::common::types::string_types::impl_string_types, utils::ed25519}; +use crate::{ + service::{common::types::string_types::impl_string_types, utilities::as_hex_string}, + utils::ed25519, +}; /// Title. const TITLE: &str = "Ed25519 Public Key"; /// Description. const DESCRIPTION: &str = "This is a 32 Byte Hex encoded Ed25519 Public Key."; /// Example. -const EXAMPLE: &str = "0x98dbd3d884068eee77e5894c22268d5d12e6484ba713e7ddd595abba308d88d3"; +const EXAMPLE: &str = "0x56CDD154355E078A0990F9E633F9553F7D43A68B2FF9BEF78B9F5C71C808A7C8"; /// Length of the hex encoded string -const ENCODED_LENGTH: usize = ed25519::HEX_ENCODED_LENGTH; +pub(crate) const ENCODED_LENGTH: usize = ed25519::HEX_ENCODED_LENGTH; /// Validation Regex Pattern -const PATTERN: &str = "0x[A-Fa-f0-9]{64}"; +pub(crate) const PATTERN: &str = "0x[A-Fa-f0-9]{64}"; +/// Format +pub(crate) const FORMAT: &str = "hex:ed25519-public-key"; /// Schema static SCHEMA: LazyLock<MetaSchema> = LazyLock::new(|| { @@ -52,7 +58,7 @@ fn is_valid(hex_key: &str) -> bool { impl_string_types!( Ed25519HexEncodedPublicKey, "string", - "hex:ed25519-public-key", + FORMAT, Some(SCHEMA.clone()), is_valid ); @@ -64,13 +70,55 @@ impl Example for Ed25519HexEncodedPublicKey { } } +impl Ed25519HexEncodedPublicKey { + /// Extra examples of 32 bytes ED25519 Public Key. + pub(crate) fn examples(index: usize) -> Self { + match index { + 0 => { + Self( + "0xDEF855AE45F3BF9640A5298A38B97617DE75600F796F17579BFB815543624B24".to_owned(), + ) + }, + 1 => { + Self( + "0x83B3B55589797EF953E24F4F0DBEE4D50B6363BCF041D15F6DBD33E014E54711".to_owned(), + ) + }, + _ => { + Self( + "0xA3E52361AFDE840918E2589DBAB9967C8027FB4431E83D36E338748CD6E3F820".to_owned(), + ) + }, + } + } +} + +impl TryFrom<&str> for Ed25519HexEncodedPublicKey { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result<Self, Self::Error> { + value.to_string().try_into() + } +} + +impl TryFrom<String> for Ed25519HexEncodedPublicKey { + type Error = anyhow::Error; + + fn try_from(value: String) -> Result<Self, Self::Error> { + if !is_valid(&value) { + bail!("Invalid Ed25519 Public key") + } + Ok(Self(value)) + } +} + impl TryFrom<Vec<u8>> for Ed25519HexEncodedPublicKey { type Error = anyhow::Error; fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> { let key = ed25519::verifying_key_from_vec(&value)?; - Ok(Self(format!("0x{}", hex::encode(key)))) + Ok(Self(as_hex_string(key.as_ref()))) } } diff --git a/catalyst-gateway/bin/src/service/common/types/generic/error_msg.rs b/catalyst-gateway/bin/src/service/common/types/generic/error_msg.rs new file mode 100644 index 00000000000..f43e82179f2 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/generic/error_msg.rs @@ -0,0 +1,79 @@ +//! Generic Error Messages + +use std::{ + borrow::Cow, + ops::{Deref, DerefMut}, + sync::LazyLock, +}; + +use const_format::concatcp; +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use regex::Regex; +use serde_json::Value; + +use crate::service::common::types::string_types::impl_string_types; + +/// Title. +const TITLE: &str = "Error Message"; +/// Description. +const DESCRIPTION: &str = "This is an error message."; +/// Example. +const EXAMPLE: &str = "An error has occurred, the details of the error are ..."; +/// Max Length +const MAX_LENGTH: usize = 256; +/// Min Length +const MIN_LENGTH: usize = 1; +/// Validation Regex Pattern +const PATTERN: &str = concatcp!("^(.){", MIN_LENGTH, ",", MAX_LENGTH, "}$"); + +/// Schema +static SCHEMA: LazyLock<MetaSchema> = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(Value::String(EXAMPLE.to_string())), + max_length: Some(MAX_LENGTH), + min_length: Some(MIN_LENGTH), + pattern: Some(PATTERN.to_string()), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Check if we match the regex. +fn is_valid(msg: &str) -> bool { + /// Validation pattern + #[allow(clippy::unwrap_used)] // Safe because the Regex is constant. Can never panic in prod. + static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(PATTERN).unwrap()); + + RE.is_match(msg) +} + +impl_string_types!( + ErrorMessage, + "string", + "error", + Some(SCHEMA.clone()), + is_valid +); + +impl Example for ErrorMessage { + /// An example 32 bytes ED25519 Public Key. + fn example() -> Self { + Self(EXAMPLE.to_owned()) + } +} + +impl From<String> for ErrorMessage { + fn from(val: String) -> Self { + Self(val) + } +} + +impl From<&str> for ErrorMessage { + fn from(val: &str) -> Self { + Self(val.to_owned()) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/generic/mod.rs b/catalyst-gateway/bin/src/service/common/types/generic/mod.rs index 27d8845f262..a04a6ed0a59 100644 --- a/catalyst-gateway/bin/src/service/common/types/generic/mod.rs +++ b/catalyst-gateway/bin/src/service/common/types/generic/mod.rs @@ -3,3 +3,5 @@ //! These types may be used in Cardano, but are not specific to Cardano. pub(crate) mod ed25519_public_key; +pub(crate) mod error_msg; +pub(crate) mod query; diff --git a/catalyst-gateway/bin/src/service/common/types/generic/query/mod.rs b/catalyst-gateway/bin/src/service/common/types/generic/query/mod.rs new file mode 100644 index 00000000000..bc560e0ba21 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/generic/query/mod.rs @@ -0,0 +1,12 @@ +//! Generic Query ONLY parameters. + +pub(crate) mod pagination; + +// To add pagination to an endpoint add these two lines to the parameters: +// +// ``` +// #[doc = common::types::generic::query::pagination::PAGE_DESCRIPTION] +// page: Query<Option<common::types::generic::query::pagination::Page>>, +// #[doc = common::types::generic::query::pagination::LIMIT_DESCRIPTION] +// limit: Query<Option<common::types::generic::query::pagination::Limit>> +// ``` diff --git a/catalyst-gateway/bin/src/service/common/types/generic/query/pagination.rs b/catalyst-gateway/bin/src/service/common/types/generic/query/pagination.rs new file mode 100644 index 00000000000..85824bd4a74 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/generic/query/pagination.rs @@ -0,0 +1,360 @@ +//! Consistent Pagination Types +//! +//! These types are paired and must be used together. +//! +//! Page - The Page we wish to request, defaults to 0. +//! Limit - The Limit we wish to request, defaults to 100. + +use std::sync::LazyLock; + +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use serde_json::Value; + +//***** PAGE */ +/// Page Title. +const PAGE_TITLE: &str = "Page"; +/// Description. +macro_rules! page_description { + () => { + "The page number of the data. +The size of each page, and its offset within the complete data set is determined by the `limit` parameter." + }; +} +pub(crate) use page_description; +/// Description +pub(crate) const PAGE_DESCRIPTION: &str = page_description!(); +/// Example. +const PAGE_EXAMPLE: u64 = 5; +/// Default +const PAGE_DEFAULT: u64 = 0; +/// Page Minimum. +const PAGE_MINIMUM: u64 = 0; +/// Page Maximum. +const PAGE_MAXIMUM: u64 = u64::MAX; + +/// Schema. +#[allow(clippy::cast_precision_loss)] +static PAGE_SCHEMA: LazyLock<MetaSchema> = LazyLock::new(|| { + MetaSchema { + title: Some(PAGE_TITLE.to_owned()), + description: Some(PAGE_DESCRIPTION), + example: Some(PAGE_EXAMPLE.into()), + default: Page(PAGE_DEFAULT).to_json(), + maximum: Some(PAGE_MAXIMUM as f64), + minimum: Some(PAGE_MINIMUM as f64), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Page to be returned in the response. +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct Page(u64); + +impl Default for Page { + fn default() -> Self { + Self(PAGE_DEFAULT) + } +} + +/// Is the `Page` valid? +fn is_valid_page(value: u64) -> bool { + (PAGE_MINIMUM..=PAGE_MAXIMUM).contains(&value) +} + +impl Type for Page { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "integer(u64)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("integer", "u64"))); + schema_ref.merge(PAGE_SCHEMA.clone()) + } + + fn as_raw_value(&self) -> Option<&Self::RawValueType> { + Some(self) + } + + fn raw_element_iter<'a>( + &'a self, + ) -> Box<dyn Iterator<Item = &'a Self::RawElementValueType> + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for Page { + fn parse_from_parameter(value: &str) -> ParseResult<Self> { + let page: u64 = value.parse()?; + Ok(Page(page)) + } +} + +impl ParseFromJSON for Page { + fn parse_from_json(value: Option<Value>) -> ParseResult<Self> { + let value = value.unwrap_or_default(); + if let Value::Number(value) = value { + let value = value.as_u64().unwrap_or_default(); + if !is_valid_page(value) { + return Err("invalid Page".into()); + } + Ok(Self(value)) + } else { + Err(ParseError::expected_type(value)) + } + } +} + +impl ToJSON for Page { + fn to_json(&self) -> Option<Value> { + Some(self.0.into()) + } +} + +impl From<u64> for Page { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl Example for Page { + fn example() -> Self { + Self(PAGE_EXAMPLE) + } +} + +//***** LIMIT */ +/// Title. +const LIMIT_TITLE: &str = "Limit"; +/// Description - must be suitable for both the Query and Response docs. +macro_rules! limit_description { + () => { + "The size `limit` of each `page` of results. +Determines the maximum amount of data that can be returned in a valid response. + +This `limit` of records of data will always be returned unless there is less data to return +than allowed for by the `limit` and `page`. + +*Exceeding the `page`/`limit` of all available records will not return `404`, it will return an +empty response.*" + }; +} +pub(crate) use limit_description; +/// Description +pub(crate) const LIMIT_DESCRIPTION: &str = limit_description!(); +/// Example. +const LIMIT_EXAMPLE: u64 = 10; +/// Default Limit (Should be used by paged responses to set the maximum size of the +/// response). +pub(crate) const LIMIT_DEFAULT: u64 = 100; +/// Minimum. +const LIMIT_MINIMUM: u64 = 1; +/// Maximum. +const LIMIT_MAXIMUM: u64 = LIMIT_DEFAULT; + +/// Schema. +#[allow(clippy::cast_precision_loss)] +static LIMIT_SCHEMA: LazyLock<MetaSchema> = LazyLock::new(|| { + MetaSchema { + title: Some(LIMIT_TITLE.to_owned()), + description: Some(LIMIT_DESCRIPTION), + example: Some(LIMIT_EXAMPLE.into()), + default: Page(LIMIT_DEFAULT).to_json(), + maximum: Some(LIMIT_MAXIMUM as f64), + minimum: Some(LIMIT_MINIMUM as f64), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Limit of items to be returned in a page of data. +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct Limit(u64); + +impl Default for Limit { + fn default() -> Self { + Self(LIMIT_DEFAULT) + } +} + +/// Is the `Page` valid? +fn is_valid_limit(value: u64) -> bool { + (LIMIT_MINIMUM..=LIMIT_MAXIMUM).contains(&value) +} + +impl Type for Limit { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "integer(u64)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("integer", "u64"))); + schema_ref.merge(LIMIT_SCHEMA.clone()) + } + + fn as_raw_value(&self) -> Option<&Self::RawValueType> { + Some(self) + } + + fn raw_element_iter<'a>( + &'a self, + ) -> Box<dyn Iterator<Item = &'a Self::RawElementValueType> + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for Limit { + fn parse_from_parameter(value: &str) -> ParseResult<Self> { + let limit: u64 = value.parse()?; + Ok(Limit(limit)) + } +} + +impl ParseFromJSON for Limit { + fn parse_from_json(value: Option<Value>) -> ParseResult<Self> { + let value = value.unwrap_or_default(); + if let Value::Number(value) = value { + let value = value.as_u64().unwrap_or_default(); + if !is_valid_limit(value) { + return Err("invalid Limit".into()); + } + Ok(Self(value)) + } else { + Err(ParseError::expected_type(value)) + } + } +} + +impl ToJSON for Limit { + fn to_json(&self) -> Option<Value> { + Some(self.0.into()) + } +} + +impl From<u64> for Limit { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl Example for Limit { + fn example() -> Self { + Self(LIMIT_EXAMPLE) + } +} + +//***** REMAINING : Not a Query Parameter, but tightly coupled type used in the pagination +//***** response. */ +/// Title. +const REMAINING_TITLE: &str = "Remaining"; +/// Description. +macro_rules! remaining_description { + () => { + "The number of items remaining to be returned after this page. +This is the absolute number of items remaining, and not the number of Pages." + }; +} +pub(crate) use remaining_description; +/// Description +pub(crate) const REMAINING_DESCRIPTION: &str = remaining_description!(); +/// Example. +const REMAINING_EXAMPLE: u64 = 16_384; +/// Minimum. +const REMAINING_MINIMUM: u64 = 0; +/// Maximum. +const REMAINING_MAXIMUM: u64 = u64::MAX; + +/// Schema. +#[allow(clippy::cast_precision_loss)] +static REMAINING_SCHEMA: LazyLock<MetaSchema> = LazyLock::new(|| { + MetaSchema { + title: Some(REMAINING_TITLE.to_owned()), + description: Some(REMAINING_DESCRIPTION), + example: Some(REMAINING_EXAMPLE.into()), + maximum: Some(REMAINING_MAXIMUM as f64), + minimum: Some(REMAINING_MINIMUM as f64), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Limit of items to be returned in a page of data. +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct Remaining(u64); + +/// Is the `Page` valid? +fn is_valid_remaining(value: u64) -> bool { + (REMAINING_MINIMUM..=REMAINING_MAXIMUM).contains(&value) +} + +impl Type for Remaining { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "integer(u64)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("integer", "u64"))); + schema_ref.merge(REMAINING_SCHEMA.clone()) + } + + fn as_raw_value(&self) -> Option<&Self::RawValueType> { + Some(self) + } + + fn raw_element_iter<'a>( + &'a self, + ) -> Box<dyn Iterator<Item = &'a Self::RawElementValueType> + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromJSON for Remaining { + fn parse_from_json(value: Option<Value>) -> ParseResult<Self> { + let value = value.unwrap_or_default(); + if let Value::Number(value) = value { + let value = value.as_u64().unwrap_or_default(); + if !is_valid_remaining(value) { + return Err("invalid Remaining".into()); + } + Ok(Self(value)) + } else { + Err(ParseError::expected_type(value)) + } + } +} + +impl ToJSON for Remaining { + fn to_json(&self) -> Option<Value> { + Some(self.0.into()) + } +} + +impl From<u64> for Remaining { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl Example for Remaining { + fn example() -> Self { + Self(REMAINING_EXAMPLE) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/string_types.rs b/catalyst-gateway/bin/src/service/common/types/string_types.rs index 8f65629697d..5655fe9dd83 100644 --- a/catalyst-gateway/bin/src/service/common/types/string_types.rs +++ b/catalyst-gateway/bin/src/service/common/types/string_types.rs @@ -32,11 +32,11 @@ /// impl <stuff> for MyNewType { ... } /// ``` macro_rules! impl_string_types { - ($(#[$docs:meta])* $ty:ident, $type_name:literal, $format:literal, $schema:expr ) => { + ($(#[$docs:meta])* $ty:ident, $type_name:literal, $format:expr, $schema:expr ) => { impl_string_types!($(#[$docs])* $ty, $type_name, $format, $schema, |_| true); }; - ($(#[$docs:meta])* $ty:ident, $type_name:literal, $format:literal, $schema:expr, $validator:expr) => { + ($(#[$docs:meta])* $ty:ident, $type_name:literal, $format:expr, $schema:expr, $validator:expr) => { $(#[$docs])* #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub(crate) struct $ty(String); @@ -69,7 +69,7 @@ macro_rules! impl_string_types { type RawElementValueType = Self; fn name() -> Cow<'static, str> { - concat!($type_name, "(", $format, ")").into() + format!("{}({})", $type_name, $format).into() } fn schema_ref() -> MetaSchemaRef { @@ -103,7 +103,7 @@ macro_rules! impl_string_types { if let Value::String(value) = value { let validator = $validator; if !validator(&value) { - return Err(concat!("invalid ", $format).into()); + return Err(format!("invalid {}", $format).into()); } Ok(Self(value)) } else { @@ -116,7 +116,7 @@ macro_rules! impl_string_types { fn parse_from_parameter(value: &str) -> ParseResult<Self> { let validator = $validator; if !validator(value) { - return Err(concat!("invalid ", $format).into()); + return Err(format!("invalid {}", $format).into()); } Ok(Self(value.to_string())) } diff --git a/catalyst-gateway/bin/src/service/utilities/mod.rs b/catalyst-gateway/bin/src/service/utilities/mod.rs index 796aca69224..94990a2ec2f 100644 --- a/catalyst-gateway/bin/src/service/utilities/mod.rs +++ b/catalyst-gateway/bin/src/service/utilities/mod.rs @@ -4,16 +4,30 @@ pub(crate) mod convert; pub(crate) mod middleware; pub(crate) mod net; -use pallas::ledger::addresses::Network as PallasNetwork; -use poem_openapi::types::ToJSON; +use anyhow::{bail, Result}; +// use pallas::ledger::addresses::Network as PallasNetwork; +// use poem_openapi::types::ToJSON; -use crate::service::common::objects::cardano::network::Network; +// use crate::service::common::objects::cardano::network::Network; /// Convert bytes to hex string with the `0x` prefix -pub(crate) fn to_hex_with_prefix(bytes: &[u8]) -> String { +pub(crate) fn as_hex_string(bytes: &[u8]) -> String { format!("0x{}", hex::encode(bytes)) } +/// Convert bytes to hex string with the `0x` prefix +pub(crate) fn from_hex_string(hex: &str) -> Result<Vec<u8>> { + #[allow(clippy::string_slice)] // Safe because of size checks. + if hex.len() < 4 || hex.len() % 2 != 0 || &hex[0..2] != "0x" { + bail!("Invalid hex string"); + } + + #[allow(clippy::string_slice)] // Safe due to above checks. + Ok(hex::decode(&hex[2..])?) +} + +/// Unused +const _UNUSED: &str = r#" /// Network validation error #[derive(thiserror::Error, Debug)] pub(crate) enum NetworkValidationError { @@ -63,3 +77,4 @@ pub(crate) fn check_network( PallasNetwork::Other(x) => Err(NetworkValidationError::UnknownNetwork(x).into()), } } +"#; diff --git a/catalyst-gateway/bin/src/settings/mod.rs b/catalyst-gateway/bin/src/settings/mod.rs index 2021501d8d8..28726514680 100644 --- a/catalyst-gateway/bin/src/settings/mod.rs +++ b/catalyst-gateway/bin/src/settings/mod.rs @@ -8,6 +8,7 @@ use std::{ }; use anyhow::anyhow; +use cardano_chain_follower::Network; use clap::Args; use dotenvy::dotenv; use duration_string::DurationString; @@ -270,6 +271,12 @@ impl Settings { ENV_VARS.chain_follower.clone() } + /// Chain Follower network (The Blockchain network we are configured to use). + /// Note: Catalyst Gateway can ONLY follow one network at a time. + pub(crate) fn cardano_network() -> Network { + ENV_VARS.chain_follower.chain + } + /// The API Url prefix pub(crate) fn api_url_prefix() -> &'static str { ENV_VARS.api_url_prefix.as_str() diff --git a/catalyst-gateway/rustfmt.toml b/catalyst-gateway/rustfmt.toml index fa6d8c2e906..905bde2d0bd 100644 --- a/catalyst-gateway/rustfmt.toml +++ b/catalyst-gateway/rustfmt.toml @@ -65,4 +65,4 @@ condense_wildcard_suffixes = true hex_literal_case = "Upper" # Ignored files: -ignore = [] \ No newline at end of file +ignore = [] diff --git a/catalyst-gateway/tests/Earthfile b/catalyst-gateway/tests/Earthfile index 979773202f8..85383a20403 100644 --- a/catalyst-gateway/tests/Earthfile +++ b/catalyst-gateway/tests/Earthfile @@ -9,6 +9,6 @@ test-lint-openapi: # Copy the doc artifact. COPY --dir ../+build/doc . # Copy the spectral configuration file. - COPY ./.oapi-v3.spectral.yml .spectral.yml + COPY --dir ./openapi-v3.0-lints/* . # Scan the doc directory where type of file is JSON. DO spectral-ci+LINT --dir=./doc diff --git a/catalyst-gateway/tests/.oapi-v3.spectral.yml b/catalyst-gateway/tests/openapi-v3.0-lints/.spectral.yml similarity index 93% rename from catalyst-gateway/tests/.oapi-v3.spectral.yml rename to catalyst-gateway/tests/openapi-v3.0-lints/.spectral.yml index 9d8710175cc..3470dbb1204 100644 --- a/catalyst-gateway/tests/.oapi-v3.spectral.yml +++ b/catalyst-gateway/tests/openapi-v3.0-lints/.spectral.yml @@ -13,6 +13,10 @@ extends: formats: ["oas3"] +functions: + - "debug" + - "description-required" + aliases: # From: https://github.com/stoplightio/spectral-owasp-ruleset/blob/26819e80e5ac4571b6271834fc97f0a1b66110bd/src/ruleset.ts#L60 StringProperties: @@ -74,6 +78,8 @@ overrides: owasp:api3:2023-no-additionalProperties: error owasp:api3:2023-constrained-additionalProperties: error owasp:api2:2023-read-restricted: error + # Replaced by custom rule `description-required` + oas3-parameter-description: off # Not enforced at OpenAPI level. Production URL's will always be https. owasp:api8:2023-no-server-http: off # Can't add custom properties to server list. @@ -174,23 +180,9 @@ rules: severity: error 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].*" - - field: "description" - function: pattern - functionOptions: - # Matches against a full stop or a literal `*` at the end of a description. - match: "[\\.\\*]$" + function: description-required + functionOptions: + length: 20 api-path: message: "Invalid API path - should be /api/draft/* or /api/v<number>/*" diff --git a/catalyst-gateway/tests/openapi-v3.0-lints/functions/debug.js b/catalyst-gateway/tests/openapi-v3.0-lints/functions/debug.js new file mode 100644 index 00000000000..d57c01a1e6b --- /dev/null +++ b/catalyst-gateway/tests/openapi-v3.0-lints/functions/debug.js @@ -0,0 +1,28 @@ +// Debug target. +// Always fails, message is all the parameters it received. +import { createRulesetFunction } from "@stoplight/spectral-core"; + +export default createRulesetFunction( + { + input: null, + options: { + type: 'object', + properties: { + context: { + type: 'boolean', + description: 'Debug print the context', + default: false + }, + }, + additionalProperties: true + } + }, + (input, options, context) => { + console.log('------ DEBUG ----------------------------------------------------------------') + console.log('input', input); + console.log('options', options); + if (options.context) { + console.log('context', context); + } + }, +); \ No newline at end of file diff --git a/catalyst-gateway/tests/openapi-v3.0-lints/functions/description-required.js b/catalyst-gateway/tests/openapi-v3.0-lints/functions/description-required.js new file mode 100644 index 00000000000..effabdf9e29 --- /dev/null +++ b/catalyst-gateway/tests/openapi-v3.0-lints/functions/description-required.js @@ -0,0 +1,117 @@ +import { createRulesetFunction } from "@stoplight/spectral-core"; +import { printValue } from '@stoplight/spectral-runtime'; + +// regex in a string like {"match": "/[a-b]+/im"} or {"match": "[a-b]+"} in a json ruleset +// the available flags are "gimsuy" as described here: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp +const REGEXP_PATTERN = /^\/(.+)\/([a-z]*)$/; + +const cache = new Map(); + +function getFromCache(pattern) { + const existingPattern = cache.get(pattern); + if (existingPattern !== void 0) { + existingPattern.lastIndex = 0; + return existingPattern; + } + + const newPattern = createRegex(pattern); + cache.set(pattern, newPattern); + return newPattern; +} + +function createRegex(pattern) { + const splitRegex = REGEXP_PATTERN.exec(pattern); + if (splitRegex !== null) { + // with slashes like /[a-b]+/ and possibly with flags like /[a-b]+/im + return new RegExp(splitRegex[1], splitRegex[2]); + } else { + // without slashes like [a-b]+ + return new RegExp(pattern); + } +} + +export default createRulesetFunction( + { + input: null, + options: { + type: 'object', + properties: { + length: { + type: 'integer', + description: 'The minimum length of a description.', + }, + match: { + type: 'string', + description: 'regex that target must match.', + }, + noMatch: { + type: 'string', + description: 'regex that target must not match.', + }, + }, + additionalProperties: false, + }, + }, + (input, options, context) => { + let results = []; + const { } = options; + + const testDescriptionValidity = (value) => { + if (!value) { + (results ??= []).push({ + message: `Description must exist`, + }); + } + + if ('length' in options) { + if (value.length < options.length) { + (results ??= []).push({ + message: `Description must have length >= ${printValue(options.length)} characters`, + }) + } + } + + if ('match' in options) { + const pattern = getFromCache(options.match); + + if (!pattern.test(value)) { + (results ??= []).push({ + message: `${printValue(value)} must match the pattern ${printValue(options.match)}`, + }) + } + } + + if ('noMatch' in options) { + const pattern = getFromCache(options.noMatch); + + if (pattern.test(value)) { + (results ??= []).push({ + message: `${printValue(value)} must NOT match the pattern ${printValue(options.noMatch)}`, + }) + } + } + }; + + // check if 'description' or 'schema.description' exists in the ParameterObject + if (input.description) { + testDescriptionValidity(input.description); + } else if ("in" in input && input.in === "query") { + if ("schema" in input && "description" in input.schema) { + testDescriptionValidity(input.schema.description); + } else { + (results ??= []).push({ + message: `'description' or 'schema.description' is missing in the Query Parameter.` + }) + } + } + else { + (results ??= []).push({ + message: `'description' is missing.` + }) + } + + if (results.length) { + return results; + } + }, +); diff --git a/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart b/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart index 17e6ba3860c..f54e615fcb3 100644 --- a/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart +++ b/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart @@ -10,14 +10,10 @@ abstract class DateFormatter { /// - Yesterday /// - 2 days ago /// - Other cases: yMMMMd date format. - static String formatRecentDate( - VoicesLocalizations l10n, - DateTime dateTime, { - DateTime? from, - }) { - from ??= DateTimeExt.now(); + static String formatRecentDate(VoicesLocalizations l10n, DateTime dateTime) { + final now = DateTimeExt.now(); - final today = DateTime(from.year, from.month, from.day, 12); + final today = DateTime(now.year, now.month, now.day, 12); if (dateTime.isSameDateAs(today)) return l10n.today; final tomorrow = today.plusDays(1); diff --git a/catalyst_voices/apps/voices/lib/pages/account/unlock_keychain_dialog.dart b/catalyst_voices/apps/voices/lib/pages/account/unlock_keychain_dialog.dart index deb0a32412e..2c3b3385e45 100644 --- a/catalyst_voices/apps/voices/lib/pages/account/unlock_keychain_dialog.dart +++ b/catalyst_voices/apps/voices/lib/pages/account/unlock_keychain_dialog.dart @@ -155,7 +155,6 @@ class _UnlockPassword extends StatelessWidget { Widget build(BuildContext context) { return VoicesPasswordTextField( controller: controller, - autofocus: true, decoration: VoicesTextFieldDecoration( labelText: context.l10n.unlockDialogHint, errorText: error?.message(context), diff --git a/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart b/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart index 279aabae2b2..fd580e3ac84 100644 --- a/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart +++ b/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart @@ -7,7 +7,6 @@ import 'package:flutter/material.dart'; class GuidanceView extends StatefulWidget { final List<Guidance> guidances; - const GuidanceView(this.guidances, {super.key}); @override @@ -19,6 +18,25 @@ class _GuidanceViewState extends State<GuidanceView> { GuidanceType? selectedType; + @override + void initState() { + super.initState(); + filteredGuidances + ..clear() + ..addAll(widget.guidances); + } + + @override + void didUpdateWidget(GuidanceView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.guidances != widget.guidances) { + filteredGuidances + ..clear() + ..addAll(widget.guidances); + _filterGuidances(selectedType); + } + } + @override Widget build(BuildContext context) { return Column( @@ -56,25 +74,6 @@ class _GuidanceViewState extends State<GuidanceView> { ); } - @override - void didUpdateWidget(GuidanceView oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.guidances != widget.guidances) { - filteredGuidances - ..clear() - ..addAll(widget.guidances); - _filterGuidances(selectedType); - } - } - - @override - void initState() { - super.initState(); - filteredGuidances - ..clear() - ..addAll(widget.guidances); - } - void _filterGuidances(GuidanceType? type) { selectedType = type; filteredGuidances diff --git a/catalyst_voices/apps/voices/lib/widgets/empty_state/empty_state.dart b/catalyst_voices/apps/voices/lib/widgets/empty_state/empty_state.dart deleted file mode 100644 index a2bb1e959b1..00000000000 --- a/catalyst_voices/apps/voices/lib/widgets/empty_state/empty_state.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:catalyst_voices/widgets/images/voices_image_scheme.dart'; -import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; -import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; -import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; -import 'package:flutter/material.dart'; - -class EmptyState extends StatelessWidget { - final String? title; - final String? description; - final Widget? image; - final Widget? imageBackground; - - const EmptyState({ - super.key, - this.title, - this.description, - this.image, - this.imageBackground, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final textTheme = theme.textTheme; - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 64), - child: Column( - children: [ - image ?? - VoicesImagesScheme( - image: CatalystSvgPicture.asset( - VoicesAssets.images.noProposalForeground.path, - ), - background: imageBackground ?? - Container( - height: 180, - decoration: BoxDecoration( - color: theme.colors.onSurfaceNeutral08, - shape: BoxShape.circle, - ), - ), - ), - const SizedBox(height: 24), - SizedBox( - width: 430, - child: Column( - children: [ - Text( - _buildTitle(context), - style: textTheme.titleMedium - ?.copyWith(color: theme.colors.textOnPrimaryLevel1), - ), - const SizedBox(height: 8), - Text( - _buildDescription(context), - style: textTheme.bodyMedium - ?.copyWith(color: theme.colors.textOnPrimaryLevel1), - textAlign: TextAlign.center, - ), - ], - ), - ), - ], - ), - ), - ); - } - - String _buildDescription(BuildContext context) { - return description ?? context.l10n.noProposalStateDescription; - } - - String _buildTitle(BuildContext context) { - return title ?? context.l10n.noProposalStateTitle; - } -} diff --git a/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart b/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart deleted file mode 100644 index ddf0bf9bce3..00000000000 --- a/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter/material.dart'; - -class VoicesImagesScheme extends StatelessWidget { - final Widget image; - final Widget? background; - - const VoicesImagesScheme({ - super.key, - required this.image, - required this.background, - }); - - @override - Widget build(BuildContext context) { - return Stack( - alignment: Alignment.center, - children: [ - if (background != null) background!, - image, - ], - ); - } -} diff --git a/catalyst_voices/apps/voices/lib/widgets/menu/voices_menu.dart b/catalyst_voices/apps/voices/lib/widgets/menu/voices_menu.dart index 178e9bbccc8..0a98b997093 100644 --- a/catalyst_voices/apps/voices/lib/widgets/menu/voices_menu.dart +++ b/catalyst_voices/apps/voices/lib/widgets/menu/voices_menu.dart @@ -1,5 +1,4 @@ import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; -import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; /// A menu of the app that @@ -69,7 +68,7 @@ class _MenuButton extends StatelessWidget { final textStyle = textTheme.bodyMedium?.copyWith( color: - menuItem.isEnabled ? textTheme.bodySmall?.color : theme.disabledColor, + menuItem.enabled ? textTheme.bodySmall?.color : theme.disabledColor, ); final children = menuChildren; @@ -86,7 +85,7 @@ class _MenuButton extends StatelessWidget { child: IconTheme( data: IconThemeData( size: 24, - color: menuItem.isEnabled + color: menuItem.enabled ? textTheme.bodySmall?.color : theme.disabledColor, ), @@ -139,29 +138,32 @@ class _MenuButton extends StatelessWidget { } /// Model representing Menu Item -final class MenuItem extends BasicPopupMenuItem { - const MenuItem({ - required super.id, - required super.label, - super.isEnabled = true, - super.icon, - super.showDivider = false, +class MenuItem { + final int id; + final String label; + final Widget? icon; + final bool showDivider; + final bool enabled; + + MenuItem({ + required this.id, + required this.label, + this.icon, + this.showDivider = false, + this.enabled = true, }); } /// Model representing Submenu Item /// and extending from MenuItem -final class SubMenuItem extends MenuItem { - final List<MenuItem> children; +class SubMenuItem extends MenuItem { + List<MenuItem> children; - const SubMenuItem({ + SubMenuItem({ required super.id, required super.label, required this.children, super.icon, super.showDivider, }); - - @override - List<Object?> get props => super.props + [children]; } diff --git a/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart b/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart deleted file mode 100644 index 61b24038779..00000000000 --- a/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart +++ /dev/null @@ -1,179 +0,0 @@ -import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; -import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; -import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; -import 'package:flutter/material.dart'; - -class VoicesModalMenu extends StatelessWidget { - final String? selectedId; - final List<MenuItem> menuItems; - final ValueChanged<String>? onTap; - - const VoicesModalMenu({ - super.key, - this.selectedId, - required this.menuItems, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - final onTap = this.onTap; - - return Column( - mainAxisSize: MainAxisSize.min, - children: menuItems - .map<Widget>( - (item) { - return _VoicesModalMenuItemTile( - key: ValueKey('VoicesModalMenu[${item.id}]Key'), - label: item.label, - isSelected: selectedId == item.id, - isEnabled: item.isEnabled, - onTap: onTap != null ? () => onTap(item.id) : null, - ); - }, - ) - .separatedBy(const SizedBox(height: 8)) - .toList(), - ); - } -} - -class _VoicesModalMenuItemTile extends StatefulWidget { - final String label; - final bool isSelected; - final bool isEnabled; - final VoidCallback? onTap; - - const _VoicesModalMenuItemTile({ - required super.key, - required this.label, - required this.isSelected, - required this.isEnabled, - this.onTap, - }); - - @override - State<_VoicesModalMenuItemTile> createState() { - return _VoicesModalMenuItemTileState(); - } -} - -class _VoicesModalMenuItemTileState extends State<_VoicesModalMenuItemTile> { - late _BackgroundColor _backgroundColor; - late _ForegroundColor _foregroundColor; - late _LabelTextStyle _labelTextStyle; - late _BorderColor _border; - - Set<WidgetState> get _states => { - if (!widget.isEnabled) WidgetState.disabled, - if (widget.isSelected) WidgetState.selected, - }; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - - final theme = Theme.of(context); - - _backgroundColor = _BackgroundColor(theme.colorScheme.brightness); - _foregroundColor = _ForegroundColor(theme.colors); - _labelTextStyle = _LabelTextStyle(theme.textTheme); - _border = _BorderColor(theme.colorScheme.brightness); - } - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: widget.isEnabled ? widget.onTap : null, - borderRadius: BorderRadius.circular(8), - child: AnimatedContainer( - duration: const Duration(milliseconds: 250), - constraints: const BoxConstraints(minWidth: 320), - decoration: BoxDecoration( - color: _backgroundColor.resolve(_states), - border: _border.resolve(_states), - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16) - .add(const EdgeInsets.only(bottom: 2)), - child: Text( - widget.label, - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: _labelTextStyle - .resolve(_states) - .copyWith(color: _foregroundColor.resolve(_states)), - ), - ), - ); - } -} - -class _BackgroundColor extends WidgetStateProperty<Color?> { - final Brightness _brightness; - - _BackgroundColor(this._brightness); - - @override - Color? resolve(Set<WidgetState> states) { - if (states.contains(WidgetState.selected)) { - // TODO(damian-molinski): Those colors are not using properties. - // TODO(damian-molinski): Dark/Transparent/On primary surface P40 016 - // TODO(damian-molinski): Light/Transparent/On surface P40 08 - return switch (_brightness) { - Brightness.dark => const Color(0x29123cd3), - Brightness.light => const Color(0x1f123cd3), - }; - } - - return null; - } -} - -class _ForegroundColor extends WidgetStateProperty<Color?> { - final VoicesColorScheme _colors; - - _ForegroundColor(this._colors); - - @override - Color? resolve(Set<WidgetState> states) { - if (states.contains(WidgetState.disabled)) { - return _colors.textDisabled; - } - - return _colors.textOnPrimaryLevel1; - } -} - -class _LabelTextStyle extends WidgetStateProperty<TextStyle> { - final TextTheme _textTheme; - - _LabelTextStyle(this._textTheme); - - @override - TextStyle resolve(Set<WidgetState> states) { - if (states.contains(WidgetState.selected)) { - return _textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.bold); - } - - return _textTheme.bodyLarge!; - } -} - -class _BorderColor extends WidgetStateProperty<BoxBorder> { - final Brightness _brightness; - - _BorderColor(this._brightness); - - @override - BoxBorder resolve(Set<WidgetState> states) { - // TODO(damian-molinski): Those colors are not using properties. - // TODO(damian-molinski): Elevations/On surface/Neutral/Transparent/on surface N10 08 - // TODO(damian-molinski): Elevations/On surface/Neutral/Transparent/on surface N10 08 - return switch (_brightness) { - Brightness.dark => Border.all(color: const Color(0x1fbfc8d9)), - Brightness.light => Border.all(color: const Color(0x14212a3d)), - }; - } -} diff --git a/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_categories_tile.dart b/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_categories_tile.dart deleted file mode 100644 index 644ffcb9438..00000000000 --- a/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_categories_tile.dart +++ /dev/null @@ -1,165 +0,0 @@ -import 'package:catalyst_voices/widgets/menu/voices_modal_menu.dart'; -import 'package:catalyst_voices/widgets/tiles/voices_expansion_tile.dart'; -import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; -import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; -import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -class CampaignCategoriesTile extends StatefulWidget { - final List<CampaignSection> sections; - - const CampaignCategoriesTile({ - super.key, - required this.sections, - }); - - @override - State<CampaignCategoriesTile> createState() => _CampaignCategoriesTileState(); -} - -class _CampaignCategoriesTileState extends State<CampaignCategoriesTile> { - String? _selectedSectionId; - - @override - void initState() { - super.initState(); - - _selectedSectionId = widget.sections.firstOrNull?.id; - } - - @override - void didUpdateWidget(covariant CampaignCategoriesTile oldWidget) { - super.didUpdateWidget(oldWidget); - - if (!listEquals(widget.sections, oldWidget.sections)) { - if (!widget.sections.any((element) => element.id == _selectedSectionId)) { - _selectedSectionId = widget.sections.firstOrNull?.id; - } - } - } - - @override - Widget build(BuildContext context) { - final selectedSection = widget.sections - .singleWhereOrNull((element) => element.id == _selectedSectionId); - - return VoicesExpansionTile( - initiallyExpanded: true, - title: Text(context.l10n.campaignCategories), - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _Menu( - selectedId: _selectedSectionId, - menuItems: widget.sections, - onTap: _updateSelection, - ), - const SizedBox(width: 32), - Expanded( - child: selectedSection != null - ? _Details(section: selectedSection) - : const SizedBox(), - ), - ], - ), - ], - ); - } - - void _updateSelection(String id) { - setState(() { - _selectedSectionId = id; - }); - } -} - -class _Menu extends StatelessWidget { - final String? selectedId; - final List<MenuItem> menuItems; - final ValueChanged<String> onTap; - - const _Menu({ - this.selectedId, - required this.menuItems, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final colors = theme.colors; - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 16), - Text( - context.l10n.cardanoUseCases, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: textTheme.titleSmall?.copyWith( - color: colors.textOnPrimaryLevel0, - ), - ), - const SizedBox(height: 12), - VoicesModalMenu( - selectedId: selectedId, - menuItems: menuItems, - onTap: onTap, - ), - const SizedBox(height: 16), - ], - ); - } -} - -class _Details extends StatelessWidget { - final CampaignSection section; - - const _Details({ - required this.section, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final colors = theme.colors; - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 48), - Text( - section.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: textTheme.headlineMedium?.copyWith( - color: colors.textOnPrimaryLevel1, - ), - ), - const SizedBox(height: 24), - Text( - section.title, - style: textTheme.titleLarge?.copyWith( - color: colors.textOnPrimaryLevel0, - ), - ), - const SizedBox(height: 16), - Text( - section.body, - style: textTheme.bodyLarge?.copyWith( - color: colors.textOnPrimaryLevel1, - ), - ), - const SizedBox(height: 32), - ], - ); - } -} diff --git a/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_details_tile.dart b/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_details_tile.dart deleted file mode 100644 index f07a02a6e4e..00000000000 --- a/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_details_tile.dart +++ /dev/null @@ -1,219 +0,0 @@ -import 'package:catalyst_voices/common/formatters/date_formatter.dart'; -import 'package:catalyst_voices/widgets/tiles/voices_expansion_tile.dart'; -import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; -import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; -import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; -import 'package:flutter/material.dart'; - -class CampaignDetailsTile extends StatelessWidget { - final String description; - final DateTime publishDate; - final DateTime startDate; - final DateTime endDate; - final int categoriesCount; - final int proposalsCount; - - const CampaignDetailsTile({ - super.key, - required this.description, - required this.publishDate, - required this.startDate, - required this.endDate, - required this.categoriesCount, - required this.proposalsCount, - }); - - @override - Widget build(BuildContext context) { - return VoicesExpansionTile( - initiallyExpanded: true, - title: Text(context.l10n.campaignDetails), - children: [ - _Body( - description: description, - ), - const SizedBox(height: 16 + 24), - _CampaignData( - publishDate: publishDate, - startDate: startDate, - endDate: endDate, - categoriesCount: categoriesCount, - proposalsCount: proposalsCount, - ), - ], - ); - } -} - -class _Body extends StatelessWidget { - final String description; - - const _Body({ - required this.description, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final colors = theme.colors; - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - context.l10n.description, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: textTheme.titleSmall?.copyWith( - color: colors.textOnPrimaryLevel1, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 14), - Text( - description, - style: textTheme.bodyLarge?.copyWith( - color: colors.textOnPrimaryLevel1, - ), - ), - ], - ); - } -} - -class _CampaignData extends StatelessWidget { - final DateTime publishDate; - final DateTime startDate; - final DateTime endDate; - final int categoriesCount; - final int proposalsCount; - - const _CampaignData({ - required this.publishDate, - required this.startDate, - required this.endDate, - required this.categoriesCount, - required this.proposalsCount, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colors; - final l10n = context.l10n; - - return Container( - decoration: BoxDecoration( - color: colors.onSurfacePrimary012, - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - child: Row( - children: [ - _CampaignDataTile( - key: const ValueKey('StartDateTileKey'), - title: l10n.startDate, - subtitle: DateFormatter.formatInDays(l10n, startDate), - value: startDate.day, - valueSuffix: DateFormatter.formatShortMonth(l10n, startDate), - ), - _CampaignDataTile( - key: const ValueKey('EndDateTileKey'), - title: l10n.endDate, - subtitle: DateFormatter.formatInDays(l10n, endDate), - value: endDate.day, - valueSuffix: DateFormatter.formatShortMonth(l10n, endDate), - ), - _CampaignDataTile( - key: const ValueKey('CategoriesTileKey'), - title: l10n.categories, - subtitle: DateFormatter.formatInDays( - l10n, - DateTime.now(), - from: publishDate, - ), - value: categoriesCount, - ), - _CampaignDataTile( - key: const ValueKey('ProposalsTileKey'), - title: l10n.proposals, - subtitle: l10n.totalSubmitted, - value: proposalsCount, - ), - ] - .map<Widget>((e) => Expanded(child: e)) - .separatedBy(const SizedBox(width: 16)) - .toList(), - ), - ); - } -} - -class _CampaignDataTile extends StatelessWidget { - final String title; - final String subtitle; - final int value; - final String? valueSuffix; - - const _CampaignDataTile({ - super.key, - required this.title, - required this.subtitle, - required this.value, - this.valueSuffix, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final colors = theme.colors; - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - title, - style: textTheme.titleSmall?.copyWith( - color: colors.textOnPrimaryLevel1, - ), - ), - Text( - subtitle, - style: textTheme.bodySmall?.copyWith( - // TODO(damian-molinski): This color does not have property. - // Colors/sys color neutral md ref/N60 - color: const Color(0xFF7F90B3), - ), - ), - const SizedBox(height: 16), - Row( - textBaseline: TextBaseline.alphabetic, - crossAxisAlignment: valueSuffix != null - ? CrossAxisAlignment.baseline - : CrossAxisAlignment.end, - children: [ - Text( - '$value', - style: textTheme.headlineLarge?.copyWith( - color: colors.textOnPrimaryLevel1, - ), - ), - if (valueSuffix != null) ...[ - const SizedBox(width: 4), - Text( - valueSuffix!, - style: textTheme.titleMedium?.copyWith( - color: colors.textOnPrimaryLevel1, - ), - ), - ], - ], - ), - ], - ); - } -} diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart index 190bfaa79b4..e7c20a7810b 100644 --- a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart @@ -19,9 +19,6 @@ final class VoicesPasswordTextField extends StatelessWidget { /// Optional decoration. See [VoicesTextField] for more details. final VoicesTextFieldDecoration? decoration; - /// [VoicesTextField.autofocus]. - final bool autofocus; - const VoicesPasswordTextField({ super.key, this.controller, @@ -29,7 +26,6 @@ final class VoicesPasswordTextField extends StatelessWidget { this.onChanged, this.onSubmitted, this.decoration, - this.autofocus = false, }); @override @@ -37,7 +33,6 @@ final class VoicesPasswordTextField extends StatelessWidget { return VoicesTextField( controller: controller, keyboardType: TextInputType.visiblePassword, - autofocus: autofocus, obscureText: true, textInputAction: textInputAction, onChanged: onChanged, diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart index e63a838bf80..31220324918 100644 --- a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart @@ -32,9 +32,6 @@ class VoicesTextField extends StatefulWidget { /// [TextField.style] final TextStyle? style; - /// [TextField.autofocus] - final bool autofocus; - /// [TextField.obscureText] final bool obscureText; @@ -80,7 +77,6 @@ class VoicesTextField extends StatefulWidget { this.textInputAction, this.textCapitalization = TextCapitalization.none, this.style, - this.autofocus = false, this.obscureText = false, this.maxLength, this.maxLines = 1, @@ -180,7 +176,6 @@ class _VoicesTextFieldState extends State<VoicesTextField> { resizableVertically: resizable, child: TextFormField( textAlignVertical: TextAlignVertical.top, - autofocus: widget.autofocus, expands: resizable, controller: _obtainController(), focusNode: widget.focusNode, diff --git a/catalyst_voices/apps/voices/lib/widgets/tiles/voices_expansion_tile.dart b/catalyst_voices/apps/voices/lib/widgets/tiles/voices_expansion_tile.dart deleted file mode 100644 index 46aca16a6a8..00000000000 --- a/catalyst_voices/apps/voices/lib/widgets/tiles/voices_expansion_tile.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:catalyst_voices/widgets/buttons/voices_buttons.dart'; -import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; -import 'package:flutter/material.dart'; - -class VoicesExpansionTile extends StatefulWidget { - final Widget title; - final List<Widget> children; - final bool initiallyExpanded; - - const VoicesExpansionTile({ - super.key, - required this.title, - this.children = const [], - this.initiallyExpanded = false, - }); - - @override - State<VoicesExpansionTile> createState() => _VoicesExpansionTileState(); -} - -class _VoicesExpansionTileState extends State<VoicesExpansionTile> { - final _controller = ExpansionTileController(); - - bool _isExpanded = false; - - @override - void initState() { - super.initState(); - _isExpanded = widget.initiallyExpanded; - } - - @override - Widget build(BuildContext context) { - return _ThemeOverride( - child: Builder( - builder: (context) { - final theme = Theme.of(context); - - return ExpansionTile( - title: DefaultTextStyle( - style: theme.textTheme.titleLarge ?? const TextStyle(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - child: widget.title, - ), - trailing: ChevronExpandButton( - isExpanded: _isExpanded, - onTap: _toggleExpand, - ), - controller: _controller, - initiallyExpanded: _isExpanded, - onExpansionChanged: _updateExpended, - children: widget.children, - ); - }, - ), - ); - } - - void _updateExpended(bool value) { - setState(() { - _isExpanded = value; - }); - } - - void _toggleExpand() { - if (_controller.isExpanded) { - _controller.collapse(); - } else { - _controller.expand(); - } - } -} - -class _ThemeOverride extends StatelessWidget { - final Widget child; - - const _ThemeOverride({ - required this.child, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Theme( - data: theme.copyWith( - // listTileTheme is required here because ExpansionTile does not let - // us set shape or ripple used internally by ListTile. - listTileTheme: const ListTileThemeData(shape: RoundedRectangleBorder()), - expansionTileTheme: ExpansionTileThemeData( - backgroundColor: theme.colors.elevationsOnSurfaceNeutralLv0, - collapsedBackgroundColor: theme.colors.elevationsOnSurfaceNeutralLv0, - tilePadding: const EdgeInsets.fromLTRB(24, 8, 12, 8), - childrenPadding: const EdgeInsets.fromLTRB(24, 16, 24, 24), - textColor: theme.colors.textOnPrimaryLevel1, - collapsedTextColor: theme.colors.textOnPrimaryLevel1, - iconColor: theme.colors.iconsForeground, - collapsedIconColor: theme.colors.iconsForeground, - shape: const RoundedRectangleBorder(), - collapsedShape: const RoundedRectangleBorder(), - ), - ), - child: child, - ); - } -} diff --git a/catalyst_voices/apps/voices/lib/widgets/widgets.dart b/catalyst_voices/apps/voices/lib/widgets/widgets.dart index a43e5437a5a..d3ad43f3ba5 100644 --- a/catalyst_voices/apps/voices/lib/widgets/widgets.dart +++ b/catalyst_voices/apps/voices/lib/widgets/widgets.dart @@ -46,7 +46,6 @@ export 'indicators/voices_status_indicator.dart'; export 'list/bullet_list.dart'; export 'menu/voices_list_tile.dart'; export 'menu/voices_menu.dart'; -export 'menu/voices_modal_menu.dart'; export 'menu/voices_node_menu.dart'; export 'menu/voices_wallet_tile.dart'; export 'modals/voices_alert_dialog.dart'; @@ -72,7 +71,6 @@ export 'text_field/voices_autocomplete.dart'; export 'text_field/voices_email_text_field.dart'; export 'text_field/voices_password_text_field.dart'; export 'text_field/voices_text_field.dart'; -export 'tiles/voices_expansion_tile.dart'; export 'tiles/voices_nav_tile.dart'; export 'toggles/voices_checkbox.dart'; export 'toggles/voices_checkbox_group.dart'; diff --git a/catalyst_voices/apps/voices/pubspec.yaml b/catalyst_voices/apps/voices/pubspec.yaml index 1dd7683242f..27987e9e804 100644 --- a/catalyst_voices/apps/voices/pubspec.yaml +++ b/catalyst_voices/apps/voices/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: path: ../../packages/internal/catalyst_voices_view_models collection: ^1.18.0 dotted_border: ^2.1.0 - equatable: ^2.0.5 + equatable: ^2.0.7 file_picker: ^8.0.7 flutter: sdk: flutter diff --git a/catalyst_voices/apps/voices/test/common/formatters/date_formatter_test.dart b/catalyst_voices/apps/voices/test/common/formatters/date_formatter_test.dart index a07b0dee89f..0b163672d45 100644 --- a/catalyst_voices/apps/voices/test/common/formatters/date_formatter_test.dart +++ b/catalyst_voices/apps/voices/test/common/formatters/date_formatter_test.dart @@ -1,94 +1,53 @@ import 'package:catalyst_voices/common/formatters/date_formatter.dart'; -import 'package:catalyst_voices_localization/generated/catalyst_voices_localizations_en.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:intl/intl.dart'; +class _FakeVoicesLocalizations extends Fake implements VoicesLocalizations { + @override + String get today => 'Today'; + @override + String get tomorrow => 'Tomorrow'; + @override + String get yesterday => 'Yesterday'; + @override + String get twoDaysAgo => '2 days ago'; +} + void main() { group(DateFormatter, () { - final l10n = VoicesLocalizationsEn(); - - group('formatRecentDate', () { - test('should return "Today" for today\'s date', () { - final today = DateTimeExt.now(); - final result = DateFormatter.formatRecentDate(l10n, today); - expect(result, l10n.today); - }); - - test('should return "Tomorrow" for tomorrow\'s date', () { - final tomorrow = DateTimeExt.now().plusDays(1); - final result = DateFormatter.formatRecentDate(l10n, tomorrow); - expect(result, l10n.tomorrow); - }); + final l10n = _FakeVoicesLocalizations(); - test('should return "Yesterday" for yesterday\'s date', () { - final yesterday = DateTimeExt.now().minusDays(1); - final result = DateFormatter.formatRecentDate(l10n, yesterday); - expect(result, l10n.yesterday); - }); - - test('should return "2 days ago" for a date 2 days ago', () { - final twoDaysAgo = DateTimeExt.now().minusDays(2); - final result = DateFormatter.formatRecentDate(l10n, twoDaysAgo); - expect(result, l10n.twoDaysAgo); - }); - - test('should return formatted date for older dates', () { - final pastDate = DateTimeExt.now().minusDays(10); - final result = DateFormatter.formatRecentDate(l10n, pastDate); - final expectedFormat = DateFormat.yMMMMd().format(pastDate); - expect(result, expectedFormat); - }); + test('should return "Today" for today\'s date', () { + final today = DateTimeExt.now(); + final result = DateFormatter.formatRecentDate(l10n, today); + expect(result, l10n.today); }); - group('formatInDays', () { - test('returns 20 days when in comparing to 20 days in future', () { - // Given - final publishDate = DateTime(2024, 11, 20); - final now = DateTime(2024, 11, 0); - - // When - final result = DateFormatter.formatInDays( - l10n, - publishDate, - from: now, - ); - - // Then - expect(result, 'In 20 days'); - }); - - test('returns 0 days when in comparing to past', () { - // Given - final publishDate = DateTime(2024, 2, 10); - final now = DateTime(2024, 11, 0); - - // When - final result = DateFormatter.formatInDays( - l10n, - publishDate, - from: now, - ); - - // Then - expect(result, 'In 0 days'); - }); + test('should return "Tomorrow" for tomorrow\'s date', () { + final tomorrow = DateTimeExt.now().plusDays(1); + final result = DateFormatter.formatRecentDate(l10n, tomorrow); + expect(result, l10n.tomorrow); + }); - test('returns 1 day when in comparing to 1 day in future', () { - // Given - final publishDate = DateTime(2024, 11, 1); - final now = DateTime(2024, 11, 0); + test('should return "Yesterday" for yesterday\'s date', () { + final yesterday = DateTimeExt.now().minusDays(1); + final result = DateFormatter.formatRecentDate(l10n, yesterday); + expect(result, l10n.yesterday); + }); - // When - final result = DateFormatter.formatInDays( - l10n, - publishDate, - from: now, - ); + test('should return "2 days ago" for a date 2 days ago', () { + final twoDaysAgo = DateTimeExt.now().minusDays(2); + final result = DateFormatter.formatRecentDate(l10n, twoDaysAgo); + expect(result, l10n.twoDaysAgo); + }); - // Then - expect(result, 'In 1 day'); - }); + test('should return formatted date for older dates', () { + final pastDate = DateTimeExt.now().minusDays(10); + final result = DateFormatter.formatRecentDate(l10n, pastDate); + final expectedFormat = DateFormat.yMMMMd().format(pastDate); + expect(result, expectedFormat); }); }); } diff --git a/catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart b/catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart deleted file mode 100644 index d19e3df0b8b..00000000000 --- a/catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart +++ /dev/null @@ -1,131 +0,0 @@ -import 'package:catalyst_voices/widgets/empty_state/empty_state.dart'; -import 'package:catalyst_voices/widgets/images/voices_image_scheme.dart'; -import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; -import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../../helpers/helpers.dart'; - -void main() { - group('EmptyState Widget Tests', () { - testWidgets('Renders correctly with default values', (tester) async { - await tester.pumpApp( - const EmptyState(), - ); - await tester.pumpAndSettle(); - - expect(find.byType(CatalystSvgPicture), findsOneWidget); - expect(find.byType(Text), findsNWidgets(2)); - expect(find.text('No draft proposals yet'), findsOneWidget); - expect( - find.text( - // ignore: lines_longer_than_80_chars - 'Discovery space will show draft proposals you can comment on, currently there are no draft proposals.', - ), - findsOneWidget, - ); - }); - - testWidgets('Renders correctly with custom values', (tester) async { - await tester.pumpApp( - const EmptyState( - title: 'Custom Title', - description: 'Custom Description', - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(CatalystSvgPicture), findsOneWidget); - expect(find.text('Custom Title'), findsOneWidget); - expect(find.text('Custom Description'), findsOneWidget); - }); - - testWidgets('Uses correct custom color scheme', (tester) async { - const colors = - VoicesColorScheme.optional(textOnPrimaryLevel1: Colors.red); - await tester.pumpApp( - voicesColors: colors, - const EmptyState( - title: 'Custom Title', - description: 'Custom Description', - ), - ); - await tester.pumpAndSettle(); - - final titleText = tester.widget<Text>( - find.byType(Text).first, - ); - - expect( - titleText.style?.color, - colors.textOnPrimaryLevel1, - ); - - final descriptionText = tester.widget<Text>( - find.byType(Text).last, - ); - - expect( - descriptionText.style?.color, - colors.textOnPrimaryLevel1, - ); - }); - - testWidgets( - 'Proposal image changes depending on theme brightness', - (tester) async { - // Given - const widget = EmptyState(); - - // When - Light theme - await tester.pumpApp( - widget, - theme: ThemeData(brightness: Brightness.light), - voicesColors: const VoicesColorScheme.optional(), - ); - await tester.pumpAndSettle(); - - // Then - Light theme - final lightThemeImage = tester.widget<CatalystSvgPicture>( - find.byType(CatalystSvgPicture), - ); - expect( - lightThemeImage, - isA<CatalystSvgPicture>(), - ); - - // When - Dark theme - await tester.pumpApp( - widget, - theme: ThemeData(brightness: Brightness.dark), - voicesColors: const VoicesColorScheme.optional(), - ); - await tester.pumpAndSettle(); - - // Then - Dark theme - final darkThemeImage = tester.widget<CatalystSvgPicture>( - find.byType(CatalystSvgPicture), - ); - expect( - darkThemeImage, - isA<CatalystSvgPicture>(), - ); - }, - ); - - testWidgets('Renders correctly with custom image', (tester) async { - await tester.pumpApp( - EmptyState( - image: CatalystSvgPicture.asset( - VoicesAssets.images.noProposalForeground.path, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(CatalystSvgPicture), findsOneWidget); - expect(find.byType(VoicesImagesScheme), findsNothing); - }); - }); -} diff --git a/catalyst_voices/apps/voices/test/widgets/menu/voices_menu_test.dart b/catalyst_voices/apps/voices/test/widgets/menu/voices_menu_test.dart index 2e09d8fb64a..4ceb87006f3 100644 --- a/catalyst_voices/apps/voices/test/widgets/menu/voices_menu_test.dart +++ b/catalyst_voices/apps/voices/test/widgets/menu/voices_menu_test.dart @@ -9,34 +9,34 @@ import '../../helpers/helpers.dart'; void main() { final menu = [ MenuItem( - id: '1', + id: 1, label: 'Rename', icon: VoicesAssets.icons.pencil.buildIcon(), ), SubMenuItem( - id: '2', + id: 2, label: 'Move Private Team', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), - children: const [ + children: [ MenuItem( - id: '3', + id: 3, label: 'Team 1: The Vikings', ), MenuItem( - id: '4', + id: 4, label: 'Team 2: Pure Hearts', ), ], ), MenuItem( - id: '5', + id: 5, label: 'Move to public', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), showDivider: true, - isEnabled: false, + enabled: false, ), MenuItem( - id: '6', + id: 6, label: 'Delete', icon: VoicesAssets.icons.trash.buildIcon(), ), diff --git a/catalyst_voices/melos.yaml b/catalyst_voices/melos.yaml index ccc055e9265..14510cecc2d 100644 --- a/catalyst_voices/melos.yaml +++ b/catalyst_voices/melos.yaml @@ -92,7 +92,7 @@ command: cryptography: ^2.7.0 dotted_border: ^2.1.0 ed25519_hd_key: ^2.3.0 - equatable: ^2.0.5 + equatable: ^2.0.7 ffi: ^2.1.0 ffigen: ^11.0.0 file_picker: ^8.0.7 diff --git a/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg b/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg deleted file mode 100644 index df9c9749f62..00000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg +++ /dev/null @@ -1,41 +0,0 @@ -<svg width="180" height="186" viewBox="0 0 180 186" fill="none" xmlns="http://www.w3.org/2000/svg"> -<g filter="url(#filter0_d_28_35)"> -<rect x="33" y="39.5953" width="76.1304" height="107.999" rx="4.4" transform="rotate(-8 33 39.5953)" fill="#F2F4F8"/> -</g> -<g filter="url(#filter1_d_28_35)"> -<g clip-path="url(#clip0_28_35)"> -<rect x="72.0996" y="29.0825" width="76.1304" height="107.999" rx="4.4" transform="rotate(10 72.0996 29.0825)" fill="#F8F9FC"/> -<rect x="76.6892" y="41.0615" width="52.8" height="5.5" rx="1.1" transform="rotate(10 76.6892 41.0615)" fill="#D9D9D9"/> -<rect x="72.4868" y="64.8938" width="62.9304" height="5.5" rx="1.1" transform="rotate(10 72.4868 64.8938)" fill="#D9D9D9"/> -<rect x="70.3857" y="76.81" width="52.8" height="5.5" rx="1.1" transform="rotate(10 70.3857 76.81)" fill="#D9D9D9"/> -<rect x="68.2847" y="88.7262" width="59.4" height="5.5" rx="1.1" transform="rotate(10 68.2847 88.7262)" fill="#D9D9D9"/> -<rect x="66.1836" y="100.642" width="62.7" height="5.5" rx="1.1" transform="rotate(10 66.1836 100.642)" fill="#D9D9D9"/> -<rect x="64.0823" y="112.559" width="49.5" height="5.5" rx="1.1" transform="rotate(10 64.0823 112.559)" fill="#D9D9D9"/> -</g> -</g> -<defs> -<filter id="filter0_d_28_35" x="0" y="0.4" width="156.42" height="183.543" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> -<feFlood flood-opacity="0" result="BackgroundImageFix"/> -<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> -<feOffset dy="4.4"/> -<feGaussianBlur stdDeviation="16.5"/> -<feComposite in2="hardAlpha" operator="out"/> -<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/> -<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_28_35"/> -<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_28_35" result="shape"/> -</filter> -<filter id="filter1_d_28_35" x="20.3457" y="0.48252" width="159.728" height="185.578" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> -<feFlood flood-opacity="0" result="BackgroundImageFix"/> -<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> -<feOffset dy="4.4"/> -<feGaussianBlur stdDeviation="16.5"/> -<feComposite in2="hardAlpha" operator="out"/> -<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/> -<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_28_35"/> -<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_28_35" result="shape"/> -</filter> -<clipPath id="clip0_28_35"> -<rect x="72.0996" y="29.0825" width="76.1304" height="107.999" rx="4.4" transform="rotate(10 72.0996 29.0825)" fill="white"/> -</clipPath> -</defs> -</svg> diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/pubspec.yaml b/catalyst_voices/packages/internal/catalyst_voices_blocs/pubspec.yaml index d8ba90d88d1..f420d231cf7 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/pubspec.yaml +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/pubspec.yaml @@ -26,7 +26,7 @@ dependencies: catalyst_voices_view_models: path: ../catalyst_voices_view_models collection: ^1.18.0 - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter flutter_bloc: ^8.1.5 diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/pubspec.yaml b/catalyst_voices/packages/internal/catalyst_voices_models/pubspec.yaml index 1cf08696dfb..e83c92fefdd 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/pubspec.yaml +++ b/catalyst_voices/packages/internal/catalyst_voices_models/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: catalyst_cardano_web: ^0.3.0 collection: ^1.18.0 convert: ^3.1.1 - equatable: ^2.0.5 + equatable: ^2.0.7 meta: ^1.10.0 password_strength: ^0.2.0 diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/pubspec.yaml b/catalyst_voices/packages/internal/catalyst_voices_services/pubspec.yaml index cad7dc6701c..9d7f0d025ce 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/pubspec.yaml +++ b/catalyst_voices/packages/internal/catalyst_voices_services/pubspec.yaml @@ -20,7 +20,7 @@ dependencies: convert: ^3.1.1 cryptography: ^2.7.0 ed25519_hd_key: ^2.3.0 - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter flutter_secure_storage: ^9.2.2 diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category.dart deleted file mode 100644 index fd2ca82d607..00000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:equatable/equatable.dart'; - -final class CampaignCategory extends Equatable { - final String id; - final String name; - - const CampaignCategory({ - required this.id, - required this.name, - }); - - @override - List<Object?> get props => [id, name]; -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_section.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_section.dart deleted file mode 100644 index 0e8a346e34f..00000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_section.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; -import 'package:equatable/equatable.dart'; - -final class CampaignSection extends Equatable implements MenuItem { - @override - final String id; - final CampaignCategory category; - final String title; - final String body; - - const CampaignSection({ - required this.id, - required this.category, - required this.title, - required this.body, - }); - - @override - String get label => category.name; - - @override - bool get isEnabled => true; - - @override - List<Object?> get props => [ - id, - category, - title, - body, - ]; -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart index 2b86a26c535..f9fc437926d 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart @@ -1,10 +1,6 @@ export 'authentication/authentication.dart'; -export 'campaign/campaign_category.dart'; -export 'campaign/campaign_section.dart'; export 'exception/localized_exception.dart'; export 'exception/localized_unknown_exception.dart'; -export 'menu/menu_item.dart'; -export 'menu/popup_menu_item.dart'; export 'navigation/sections_list_view_item.dart'; export 'navigation/sections_navigation.dart'; export 'proposal/comment.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/menu_item.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/menu_item.dart deleted file mode 100644 index 1481dba245e..00000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/menu_item.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:equatable/equatable.dart'; - -abstract interface class MenuItem { - String get id; - - String get label; - - bool get isEnabled; -} - -base class BasicMenuItem extends Equatable implements MenuItem { - @override - final String id; - @override - final String label; - @override - final bool isEnabled; - - const BasicMenuItem({ - required this.id, - required this.label, - this.isEnabled = true, - }); - - @override - List<Object?> get props => [ - id, - label, - isEnabled, - ]; -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/popup_menu_item.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/popup_menu_item.dart deleted file mode 100644 index 9299e44e0f5..00000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/popup_menu_item.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; -import 'package:flutter/widgets.dart'; - -abstract interface class PopupMenuItem implements MenuItem { - Widget? get icon; - - bool get showDivider; -} - -base class BasicPopupMenuItem extends BasicMenuItem implements PopupMenuItem { - @override - final Widget? icon; - - @override - final bool showDivider; - - const BasicPopupMenuItem({ - required super.id, - required super.label, - super.isEnabled, - this.icon, - this.showDivider = false, - }); - - @override - List<Object?> get props => - super.props + - [ - icon, - showDivider, - ]; -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/pubspec.yaml b/catalyst_voices/packages/internal/catalyst_voices_view_models/pubspec.yaml index c24db4f4ab7..3b8adff3e15 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/pubspec.yaml +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/pubspec.yaml @@ -17,7 +17,7 @@ dependencies: path: ../catalyst_voices_localization catalyst_voices_models: path: ../catalyst_voices_models - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter formz: ^0.7.0 diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/pubspec.yaml b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/pubspec.yaml index eaa42e72f1e..0a30d979fe4 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/pubspec.yaml +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: cbor: ^6.2.0 convert: ^3.1.1 cupertino_icons: ^1.0.6 - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano_platform_interface/pubspec.yaml b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano_platform_interface/pubspec.yaml index 9f97f5edb55..d2c0f8fb23b 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano_platform_interface/pubspec.yaml +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano_platform_interface/pubspec.yaml @@ -11,7 +11,7 @@ environment: dependencies: catalyst_cardano_serialization: ^0.4.0 - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter plugin_platform_interface: ^2.1.7 diff --git a/catalyst_voices/packages/libs/catalyst_cardano_serialization/pubspec.yaml b/catalyst_voices/packages/libs/catalyst_cardano_serialization/pubspec.yaml index 68cfd6fcc5e..5073d1d1d1d 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano_serialization/pubspec.yaml +++ b/catalyst_voices/packages/libs/catalyst_cardano_serialization/pubspec.yaml @@ -19,7 +19,7 @@ dependencies: cbor: ^6.2.0 convert: ^3.1.1 cryptography: ^2.7.0 - equatable: ^2.0.5 + equatable: ^2.0.7 pinenacl: ^0.6.0 ulid: ^2.0.0 diff --git a/catalyst_voices/packages/libs/catalyst_compression/catalyst_compression_platform_interface/pubspec.yaml b/catalyst_voices/packages/libs/catalyst_compression/catalyst_compression_platform_interface/pubspec.yaml index 6f3c9522d43..f9550c6c32a 100644 --- a/catalyst_voices/packages/libs/catalyst_compression/catalyst_compression_platform_interface/pubspec.yaml +++ b/catalyst_voices/packages/libs/catalyst_compression/catalyst_compression_platform_interface/pubspec.yaml @@ -10,7 +10,7 @@ environment: flutter: ">=3.24.1" dependencies: - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter plugin_platform_interface: ^2.1.7 diff --git a/catalyst_voices/packages/libs/catalyst_key_derivation/pubspec.yaml b/catalyst_voices/packages/libs/catalyst_key_derivation/pubspec.yaml index cb2b9ae38d7..d79f3ae8c58 100644 --- a/catalyst_voices/packages/libs/catalyst_key_derivation/pubspec.yaml +++ b/catalyst_voices/packages/libs/catalyst_key_derivation/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: cbor: ^6.2.0 convert: ^3.1.1 cryptography: ^2.7.0 - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter flutter_rust_bridge: 2.5.1 diff --git a/catalyst_voices/utilities/uikit_example/lib/examples/voices_menu_example.dart b/catalyst_voices/utilities/uikit_example/lib/examples/voices_menu_example.dart index b85aa136885..3dcfa8fb0fa 100644 --- a/catalyst_voices/utilities/uikit_example/lib/examples/voices_menu_example.dart +++ b/catalyst_voices/utilities/uikit_example/lib/examples/voices_menu_example.dart @@ -71,28 +71,28 @@ class _MenuExample1 extends StatelessWidget { onTap: (menuItem) => debugPrint('Selected label: ${menuItem.label}'), menuItems: [ MenuItem( - id: '1', + id: 1, label: 'Rename', icon: VoicesAssets.icons.pencil.buildIcon(), ), SubMenuItem( - id: '4', + id: 4, label: 'Move Private Team', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), - children: const [ - MenuItem(id: '5', label: 'Team 1: The Vikings'), - MenuItem(id: '6', label: 'Team 2: Pure Hearts'), + children: [ + MenuItem(id: 5, label: 'Team 1: The Vikings'), + MenuItem(id: 6, label: 'Team 2: Pure Hearts'), ], ), MenuItem( - id: '2', + id: 2, label: 'Move to public', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), showDivider: true, - isEnabled: false, + enabled: false, ), MenuItem( - id: '3', + id: 3, label: 'Delete', icon: VoicesAssets.icons.trash.buildIcon(), ), @@ -118,27 +118,27 @@ class _MenuExample2 extends StatelessWidget { onTap: (menuItem) => debugPrint('Selected label: ${menuItem.label}'), menuItems: [ MenuItem( - id: '1', + id: 1, label: 'Rename', icon: VoicesAssets.icons.pencil.buildIcon(), ), SubMenuItem( - id: '4', + id: 4, label: 'Move Private Team', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), - children: const [ - MenuItem(id: '5', label: 'Team 1: The Vikings'), - MenuItem(id: '6', label: 'Team 2: Pure Hearts'), + children: [ + MenuItem(id: 5, label: 'Team 1: The Vikings'), + MenuItem(id: 6, label: 'Team 2: Pure Hearts'), ], ), MenuItem( - id: '2', + id: 2, label: 'Move to public', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), showDivider: true, ), MenuItem( - id: '3', + id: 3, label: 'Delete', icon: VoicesAssets.icons.trash.buildIcon(), ), diff --git a/docs/src/api/cat-gateway/stoplight_template.html b/docs/src/api/cat-gateway/stoplight_template.html index 582d1db5a62..d0c263bc938 100644 --- a/docs/src/api/cat-gateway/stoplight_template.html +++ b/docs/src/api/cat-gateway/stoplight_template.html @@ -22,9 +22,10 @@ <a href="../cat-gateway/index.html">Catalyst Gateway</a> <a href="../cat-gateway/rust.html">Rust Docs</a> </nav> - <elements-api apiDescriptionUrl="./rust-docs/cat-gateway-api.json" router="memory" - hideInternal="true" style="width: 100%; height: calc(100vh - 50px); position: absolute; top: 30px; left: 0; background: white;" > - + <elements-api apiDescriptionUrl="./rust-docs/cat-gateway-api.json" router="memory" hideInternal="true" + layout="responsive" + style="width: 100%; height: calc(100vh - 50px); position: absolute; top: 30px; left: 0; background: white;"> + </elements-api> </body>