From 3bf0ccf6cbc38359e888e53cb24ada753b0f2ebc Mon Sep 17 00:00:00 2001 From: Steven Johnson Date: Mon, 25 Nov 2024 23:03:34 +0700 Subject: [PATCH] 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 --- .config/dictionaries/project.dic | 3 +- 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 ++++++ .../api/cat-gateway/stoplight_template.html | 7 +- 55 files changed, 2238 insertions(+), 248 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 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/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, _asat: Option, + _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>, + asat: Query>, + page: Query>, + limit: Query>, + /// 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, // 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, + /// 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, + /// 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), + /// No valid registration. + #[oai(status = 404)] + NotFound, +} + +/// All responses to a cip36 registration query +pub(crate) type AllRegistration = common::responses::WithErrorResponses; + +/// 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, + /// List of latest invalid registrations that were found, for the requested filter. + #[oai(skip_serializing_if_is_empty, validator(max_items = "10"))] + invalid: Vec, + /// 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, +} + +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, + /// Voting Public Key (Ed25519 Public key). + #[oai(skip_serializing_if_is_none)] + pub vote_pub_key: + Option, + #[allow(clippy::missing_docs_in_private_items)] // Type is pre documented. + #[oai(skip_serializing_if_is_none)] + pub nonce: Option, + #[allow(clippy::missing_docs_in_private_items)] // Type is pre documented. + #[oai(skip_serializing_if_is_none)] + pub txn: Option, + /// Cardano Cip-19 Formatted Shelley Payment Address. + #[oai(skip_serializing_if_is_none)] + pub payment_address: Option, + /// 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::::new", + skip_serializing_if_is_empty, + validator(max_items = "10") + )] + pub errors: Vec, +} + +/// 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::::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, - /// 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>, - /// 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>, - /// 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, // 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, - /// 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, - /// 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 { 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 { - 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) -> 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, +} + +impl UnprocessableContent { + /// Create a new `ContentErrorDetail` Response Payload. + pub(crate) fn new(errors: Vec) -> 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>, + #[oai(validator(max_items = 100))] + loc: Option>, /// The error message. #[oai(validator(max_length = "1000", pattern = "^[0-9a-zA-Z].*$"))] - msg: Option, + msg: Option, /// The type of error #[oai( rename = "type", validator(max_length = "1000", pattern = "^[0-9a-zA-Z].*$") )] - err_type: Option, + err_type: Option, } 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, -} - -impl UnprocessableContent { - /// Create a new `ContentErrorDetail` Response Payload. - pub(crate) fn new(errors: Vec) -> 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: +/// This is NOT `i128::MIN`. const MINIMUM: i128 = -9_223_372_036_854_775_808; /// Maximum. /// From: +/// 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 = 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 = 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 for Cip19ShelleyAddress { + type Error = anyhow::Error; + + fn try_from(addr: ShelleyAddress) -> Result { + let addr_str = addr + .to_bech32() + .map_err(|e| anyhow::anyhow!(format!("Invalid payment address {e}")))?; + Ok(Self(addr_str)) + } +} + +impl TryInto for Cip19ShelleyAddress { + type Error = anyhow::Error; + + fn try_into(self) -> Result { + 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 = 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 { + value.to_string().try_into() } +} - /// Convert a `StakeAddress` string to a `StakeAddress`. - pub fn to_stake_address(&self) -> anyhow::Result { - 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 for Cip19StakeAddress { + type Error = anyhow::Error; + + fn try_from(value: String) -> Result { + 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 { +impl TryFrom for Cip19StakeAddress { + type Error = anyhow::Error; + + fn try_from(addr: StakeAddress) -> Result { let addr_str = addr .to_bech32() .map_err(|e| anyhow::anyhow!(format!("Invalid stake address {e}")))?; @@ -117,6 +132,19 @@ impl Cip19StakeAddress { } } +impl TryInto for Cip19StakeAddress { + type Error = anyhow::Error; + + fn try_into(self) -> Result { + 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> 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> for HexEncodedHash28 { // All creation of this type should come only from one of the deserialization methods. impl From for Vec { 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 = 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 + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for Nonce { + fn parse_from_parameter(value: &str) -> ParseResult { + let nonce: u64 = value.parse()?; + Ok(Self(nonce)) + } +} + +impl ParseFromJSON for Nonce { + fn parse_from_json(value: Option) -> ParseResult { + 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 { + Some(self.0.into()) + } +} + +impl From for Nonce { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl Nonce { + /// Generic conversion of `Option` to `Option`. + #[allow(dead_code)] + pub(crate) fn into_option>(value: Option) -> Option { + 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 = 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 = + LazyLock::new(|| min(TIME_DISCRIMINATOR.len(), SLOT_DISCRIMINATOR.len()) + ":0".len()); +/// Maximum parameter length +static MAX_LENGTH: LazyLock = 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 = 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::() else { + bail!( + "Not a valid `as_at` parameter. Invalid {} specified.", + whence + ); + }; + Ok((whence.to_owned(), when)) +} + +/// Schema. +static SCHEMA: LazyLock = 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 + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for AsAt { + fn parse_from_parameter(value: &str) -> ParseResult { + 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 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 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 { + /// Regex to parse the parameter + #[allow(clippy::unwrap_used)] // Safe because the Regex is constant. Can never panic in prod. + static RE: LazyLock = 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 = 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 = 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 = 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 { + 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 + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for StakeOrVoter { + fn parse_from_parameter(value: &str) -> ParseResult { + Ok(Self((value.to_string(), value.try_into()?))) + } +} + +impl StakeOrVoter { + /// Is this for ALL results? + pub(crate) fn is_all(&self, headers: &HeaderMap) -> Result { + match self.0 .1 { + StakeAddressOrPublicKey::All => { + check_api_key(headers)?; + Ok(true) + }, + _ => Ok(false), + } + } +} + +impl TryInto for StakeOrVoter { + type Error = anyhow::Error; + + fn try_into( + self, + ) -> Result { + match self.0 .1 { + StakeAddressOrPublicKey::Address(addr) => Ok(addr), + _ => bail!("Not a Stake Address"), + } + } +} + +impl TryInto + for StakeOrVoter +{ + type Error = anyhow::Error; + + fn try_into( + self, + ) -> Result + { + 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 = 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 + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for SlotNo { + fn parse_from_parameter(value: &str) -> ParseResult { + let slot: u64 = value.parse()?; + Ok(Self(slot)) + } +} + +impl ParseFromJSON for SlotNo { + fn parse_from_json(value: Option) -> ParseResult { + 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 { + Some(self.0.into()) + } +} + +impl TryFrom for SlotNo { + type Error = anyhow::Error; + + fn try_from(value: i64) -> Result { + let value: u64 = value.try_into()?; + if !is_valid(value) { + bail!("Invalid Slot Number"); + } + Ok(Self(value)) + } +} + +impl From for SlotNo { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl SlotNo { + /// Generic conversion of `Option` to `Option`. + pub(crate) fn into_option>(value: Option) -> Option { + 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 = 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 + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for TxnIndex { + fn parse_from_parameter(value: &str) -> ParseResult { + let idx: u16 = value.parse()?; + Ok(Self(idx)) + } +} + +impl ParseFromJSON for TxnIndex { + fn parse_from_json(value: Option) -> ParseResult { + 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 { + Some(self.0.into()) + } +} + +impl TryFrom for TxnIndex { + type Error = anyhow::Error; + + fn try_from(value: u64) -> Result { + let value: u16 = value.try_into()?; + if !is_valid(value) { + bail!(INVALID_MSG); + } + Ok(Self(value)) + } +} + +impl TryFrom for TxnIndex { + type Error = anyhow::Error; + + fn try_from(value: i16) -> Result { + let value: u16 = value.try_into()?; + if !is_valid(value) { + bail!(INVALID_MSG); + } + Ok(Self(value)) + } +} + +impl From for TxnIndex { + fn from(value: u16) -> Self { + Self(value) + } +} + +impl TxnIndex { + /// Generic conversion of `Option` to `Option`. + #[allow(dead_code)] + pub(crate) fn into_option>(value: Option) -> Option { + 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 = 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 { + value.to_string().try_into() + } +} + +impl TryFrom for Ed25519HexEncodedPublicKey { + type Error = anyhow::Error; + + fn try_from(value: String) -> Result { + if !is_valid(&value) { + bail!("Invalid Ed25519 Public key") + } + Ok(Self(value)) + } +} + impl TryFrom> for Ed25519HexEncodedPublicKey { type Error = anyhow::Error; fn try_from(value: Vec) -> Result { 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 = 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 = 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 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>, +// #[doc = common::types::generic::query::pagination::LIMIT_DESCRIPTION] +// limit: Query> +// ``` 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 = 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 + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for Page { + fn parse_from_parameter(value: &str) -> ParseResult { + let page: u64 = value.parse()?; + Ok(Page(page)) + } +} + +impl ParseFromJSON for Page { + fn parse_from_json(value: Option) -> ParseResult { + 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 { + Some(self.0.into()) + } +} + +impl From 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 = 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 + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for Limit { + fn parse_from_parameter(value: &str) -> ParseResult { + let limit: u64 = value.parse()?; + Ok(Limit(limit)) + } +} + +impl ParseFromJSON for Limit { + fn parse_from_json(value: Option) -> ParseResult { + 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 { + Some(self.0.into()) + } +} + +impl From 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 = 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 + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromJSON for Remaining { + fn parse_from_json(value: Option) -> ParseResult { + 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 { + Some(self.0.into()) + } +} + +impl From 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 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 { 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> { + #[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/*" 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/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 @@ Catalyst Gateway Rust Docs - - + +