From 32ef6868ff95cf4d29b2ca667b2f1e99a47fea9c Mon Sep 17 00:00:00 2001 From: bkioshn <35752733+bkioshn@users.noreply.github.com> Date: Mon, 28 Oct 2024 21:09:11 +0700 Subject: [PATCH] fix: API cleanup (#1036) * 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 --------- Signed-off-by: bkioshn Co-authored-by: Steven Johnson Co-authored-by: Steven Johnson --- .config/dictionaries/project.dic | 2 + catalyst-gateway/Earthfile | 2 +- catalyst-gateway/Justfile | 2 +- catalyst-gateway/README.md | 5 +- .../db/event/config/jsonschema/frontend.json | 30 - .../bin/src/db/event/config/key.rs | 90 ++- .../bin/src/service/api/auth/endpoint.rs | 128 ---- .../bin/src/service/api/auth/mod.rs | 4 - .../bin/src/service/api/auth/token.rs | 145 ----- .../bin/src/service/api/cardano/cip36.rs | 218 +++---- .../bin/src/service/api/cardano/mod.rs | 97 +-- .../service/api/cardano/registration_get.rs | 53 +- .../src/service/api/cardano/staked_ada_get.rs | 10 +- .../bin/src/service/api/config/mod.rs | 154 +++-- .../src/service/api/health/inspection_get.rs | 4 +- .../bin/src/service/api/health/mod.rs | 45 +- .../bin/src/service/api/health/ready_get.rs | 5 +- .../service/api/legacy/registration/mod.rs | 23 +- .../bin/src/service/api/legacy/v0/mod.rs | 3 +- .../service/api/legacy/v1/fragments_post.rs | 2 +- .../bin/src/service/api/legacy/v1/mod.rs | 17 +- catalyst-gateway/bin/src/service/api/mod.rs | 23 +- .../bin/src/service/common/auth/api_key.rs | 31 + .../bin/src/service/common/auth/mod.rs | 6 + .../bin/src/service/common/auth/none.rs | 42 ++ .../src/service/common/auth/none_or_rbac.rs | 15 + .../bin/src/service/common/auth/rbac/mod.rs | 6 + .../src/service/common/auth/rbac/scheme.rs | 167 +++++ .../bin/src/service/common/auth/rbac/token.rs | 238 +++++++ .../bin/src/service/common/mod.rs | 3 + .../service/common/objects/cardano/cip36.rs | 140 ++++ .../src/service/common/objects/cardano/mod.rs | 2 +- .../objects/cardano/registration_info.rs | 9 +- .../common/objects/cardano/stake_address.rs | 93 --- .../common/objects/cardano/stake_info.rs | 2 + .../common/objects/config/frontend_config.rs | 44 ++ .../src/service/common/objects/config/mod.rs | 36 ++ .../objects/legacy/delegate_public_key.rs | 4 +- .../service/common/objects/legacy/event_id.rs | 2 +- .../legacy/fragments_processing_summary.rs | 1 + .../common/objects/legacy/vote_plan.rs | 2 +- .../common/objects/legacy/voter_group_id.rs | 2 +- .../common/objects/legacy/voter_info.rs | 2 +- .../bin/src/service/common/objects/mod.rs | 3 +- .../common/objects/validation_error.rs | 19 - .../common/responses/code_401_unauthorized.rs | 35 + .../common/responses/code_403_forbidden.rs | 48 ++ .../code_422_unprocessable_content.rs | 78 +++ .../responses/code_429_too_many_requests.rs | 35 + .../code_500_internal_server_error.rs} | 14 +- .../responses/code_503_service_unavailable.rs | 36 ++ .../bin/src/service/common/responses/mod.rs | 190 +++++- .../bin/src/service/common/tags.rs | 17 +- .../service/common/types/cardano/address.rs | 82 +++ .../src/service/common/types/cardano/mod.rs | 3 + .../headers/access_control_allow_origin.rs | 60 ++ .../src/service/common/types/headers/mod.rs | 19 + .../service/common/types/headers/ratelimit.rs | 54 ++ .../common/types/headers/retry_after.rs | 88 +++ .../bin/src/service/common/types/mod.rs | 13 + .../src/service/common/types/string_types.rs | 132 ++++ catalyst-gateway/bin/src/service/mod.rs | 11 + .../bin/src/service/utilities/catch_panic.rs | 4 +- catalyst-gateway/bin/src/settings/mod.rs | 14 +- .../bin/src/utils/blake2b_hash.rs | 26 + catalyst-gateway/bin/src/utils/mod.rs | 1 + catalyst-gateway/bin/src/utils/schema.rs | 242 +++++++ catalyst-gateway/deny.toml | 2 +- catalyst-gateway/tests/.oapi-v3.spectral.yml | 240 +++++++ catalyst-gateway/tests/.spectral.yml | 34 + catalyst-gateway/tests/Earthfile | 8 +- .../src/catalyst_data_gateway_repository.dart | 17 +- ...catalyst_data_gateway_repository_test.dart | 13 +- .../cat_gateway_api.models.swagger.dart | 599 +++++++++++++++--- .../cat_gateway_api.models.swagger.g.dart | 147 ++++- .../cat_gateway_api.swagger.chopper.dart | 71 ++- .../cat_gateway_api.swagger.dart | 202 +++--- 77 files changed, 3366 insertions(+), 1100 deletions(-) delete mode 100644 catalyst-gateway/bin/src/db/event/config/jsonschema/frontend.json delete mode 100644 catalyst-gateway/bin/src/service/api/auth/endpoint.rs delete mode 100644 catalyst-gateway/bin/src/service/api/auth/mod.rs delete mode 100644 catalyst-gateway/bin/src/service/api/auth/token.rs create mode 100644 catalyst-gateway/bin/src/service/common/auth/api_key.rs create mode 100644 catalyst-gateway/bin/src/service/common/auth/mod.rs create mode 100644 catalyst-gateway/bin/src/service/common/auth/none.rs create mode 100644 catalyst-gateway/bin/src/service/common/auth/none_or_rbac.rs create mode 100644 catalyst-gateway/bin/src/service/common/auth/rbac/mod.rs create mode 100644 catalyst-gateway/bin/src/service/common/auth/rbac/scheme.rs create mode 100644 catalyst-gateway/bin/src/service/common/auth/rbac/token.rs create mode 100644 catalyst-gateway/bin/src/service/common/objects/cardano/cip36.rs delete mode 100644 catalyst-gateway/bin/src/service/common/objects/cardano/stake_address.rs create mode 100644 catalyst-gateway/bin/src/service/common/objects/config/frontend_config.rs create mode 100644 catalyst-gateway/bin/src/service/common/objects/config/mod.rs delete mode 100644 catalyst-gateway/bin/src/service/common/objects/validation_error.rs create mode 100644 catalyst-gateway/bin/src/service/common/responses/code_401_unauthorized.rs create mode 100644 catalyst-gateway/bin/src/service/common/responses/code_403_forbidden.rs create mode 100644 catalyst-gateway/bin/src/service/common/responses/code_422_unprocessable_content.rs create mode 100644 catalyst-gateway/bin/src/service/common/responses/code_429_too_many_requests.rs rename catalyst-gateway/bin/src/service/common/{objects/server_error.rs => responses/code_500_internal_server_error.rs} (75%) create mode 100644 catalyst-gateway/bin/src/service/common/responses/code_503_service_unavailable.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/cardano/address.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/cardano/mod.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/headers/access_control_allow_origin.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/headers/mod.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/headers/ratelimit.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/headers/retry_after.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/mod.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/string_types.rs create mode 100644 catalyst-gateway/bin/src/utils/schema.rs create mode 100644 catalyst-gateway/tests/.oapi-v3.spectral.yml diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index 4ce208d453..794e098f61 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -181,6 +181,7 @@ oneshot openapi opentelemetry overprovisioned +Pbkdf2 pbxproj Pdart permissionless @@ -205,6 +206,7 @@ pubspec pytest qrcode rapidoc +ratelimit redoc reloadable Replayability diff --git a/catalyst-gateway/Earthfile b/catalyst-gateway/Earthfile index 3dd4f8c7e7..95eb929359 100644 --- a/catalyst-gateway/Earthfile +++ b/catalyst-gateway/Earthfile @@ -81,7 +81,7 @@ build: all-hosts-build: BUILD --platform=linux/amd64 --platform=linux/arm64 +build -# package-cat-gateway : Create a deployable container for catalyst-gateway +# package : Create a deployable container for catalyst-gateway package: FROM debian:12.7-slim WORKDIR /cat-gateway diff --git a/catalyst-gateway/Justfile b/catalyst-gateway/Justfile index 4ed94238f2..008bc970da 100644 --- a/catalyst-gateway/Justfile +++ b/catalyst-gateway/Justfile @@ -34,7 +34,7 @@ pre-push: sync-cfg code-format code-lint license-check # Make sure we can actually build inside Earthly which needs to happen in CI. cd .. && earthly ./catalyst-gateway+check cd .. && earthly ./catalyst-gateway+build - cd .. && earthly ./catalyst-gateway+package-cat-gateway + cd .. && earthly ./catalyst-gateway+package cd .. && earthly ./catalyst-gateway/tests+test-lint-openapi # Build Local release build of catalyst gateway diff --git a/catalyst-gateway/README.md b/catalyst-gateway/README.md index 501e9d9643..576a2fa870 100644 --- a/catalyst-gateway/README.md +++ b/catalyst-gateway/README.md @@ -40,13 +40,12 @@ or you can build a docker image and run everything with the `docker-compose`. To build and run docker images follow these steps: -1. Run `earthly +package-cat-gateway` or `earthly +package-cat-gateway-with-preprod-snapshot` - to build a cat-gateway docker image without `preprod-snapshot` or with it. +1. Run `earthly +package` to build a cat-gateway docker image. 2. Run `earthly ./event-db+build` to build an event-db docker image. 3. Run `docker-compose up cat-gateway` to spin up cat-gateway with event-db from already built images. Note that every time when you are building an image it obsoletes an old image but does not remove it, -so dont forget to cleanup dangling images of the event-db and cat-gateway in your docker environment. +so don't forget to clean up dangling images of the event-db and cat-gateway in your docker environment. ### Rust binary diff --git a/catalyst-gateway/bin/src/db/event/config/jsonschema/frontend.json b/catalyst-gateway/bin/src/db/event/config/jsonschema/frontend.json deleted file mode 100644 index 674c23dd09..0000000000 --- a/catalyst-gateway/bin/src/db/event/config/jsonschema/frontend.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Frontend JSON schema", - "type": "object", - "sentry": { - "type": "object", - "description": "Configuration for Sentry.", - "properties": { - "dsn": { - "$ref": "#/definitions/httpsUrl", - "description": "The Data Source Name (DSN) for Sentry." - }, - "release": { - "type": "string", - "description": "A version of the code deployed to an environment" - }, - "environment": { - "type": "string", - "description": "The environment in which the application is running, e.g., 'dev', 'qa'." - } - } - }, - "definitions": { - "httpsUrl": { - "type": "string", - "format": "uri", - "pattern": "^https?://" - } - } -} diff --git a/catalyst-gateway/bin/src/db/event/config/key.rs b/catalyst-gateway/bin/src/db/event/config/key.rs index 2d02be110b..1ab612b7df 100644 --- a/catalyst-gateway/bin/src/db/event/config/key.rs +++ b/catalyst-gateway/bin/src/db/event/config/key.rs @@ -6,6 +6,8 @@ use jsonschema::{BasicOutput, Validator}; use serde_json::{json, Value}; use tracing::error; +use crate::utils::schema::{extract_json_schema_for, SCHEMA_VERSION}; + /// Configuration key #[derive(Debug, Clone, PartialEq)] pub(crate) enum ConfigKey { @@ -24,9 +26,9 @@ impl Display for ConfigKey { } } -/// Frontend schema. -static FRONTEND_SCHEMA: LazyLock = - LazyLock::new(|| load_json_lazy(include_str!("jsonschema/frontend.json"))); +/// Frontend schema from API specification. +pub(crate) static FRONTEND_SCHEMA: LazyLock = + LazyLock::new(|| extract_json_schema_for("FrontendConfig")); /// Frontend schema validator. static FRONTEND_SCHEMA_VALIDATOR: LazyLock = @@ -43,28 +45,35 @@ static FRONTEND_IP_DEFAULT: LazyLock = /// Helper function to create a JSON validator from a JSON schema. /// If the schema is invalid, a default JSON validator is created. fn schema_validator(schema: &Value) -> Validator { - jsonschema::validator_for(schema).unwrap_or_else(|err| { - error!( - id = "schema_validator", - error=?err, - "Error creating JSON validator" - ); - - // Create a default JSON validator as a fallback - // This should not fail since it is hard coded - #[allow(clippy::expect_used)] - Validator::new(&json!({ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object" - })) - .expect("Failed to create default JSON validator") - }) + Validator::options() + .with_draft(jsonschema::Draft::Draft202012) + .build(schema) + .unwrap_or_else(|err| { + error!( + id="schema_validator", + error=?err, + "Error creating JSON validator" + ); + + default_validator() + }) +} + +/// Create a default JSON validator as a fallback +/// This should not fail since it is hard coded +fn default_validator() -> Validator { + #[allow(clippy::expect_used)] + Validator::new(&json!({ + "$schema": SCHEMA_VERSION, + "type": "object" + })) + .expect("Failed to create default JSON validator") } /// Helper function to convert a JSON string to a JSON value. fn load_json_lazy(data: &str) -> Value { serde_json::from_str(data).unwrap_or_else(|err| { - error!(id = "load_json_lazy", error=?err, "Error parsing JSON"); + error!(id="load_json_lazy", error=?err, "Error parsing JSON"); json!({}) }) } @@ -115,15 +124,43 @@ mod tests { use super::*; #[test] - fn test_valid_validate() { + fn test_schema_for_schema() { + // Invalid schema + let invalid_schema = json!({ + "title": "Invalid Example Schema", + "type": "object", + + "properties": { + "invalidProperty": { + "type": "unknownType" + } + }, + + }); + // This should not fail + schema_validator(&invalid_schema); + } + + #[test] + fn test_valid_validate_1() { let value = json!({ - "test": "test" + "sentry": { + "dsn": "https://test.com" + } }); let result = ConfigKey::Frontend.validate(&value); assert!(result.is_valid()); println!("{:?}", serde_json::to_value(result).unwrap()); } + #[test] + fn test_valid_validate_2() { + let value = json!({}); + let result = ConfigKey::Frontend.validate(&value); + assert!(result.is_valid()); + println!("{:?}", serde_json::to_value(result).unwrap()); + } + #[test] fn test_invalid_validate() { let value = json!([]); @@ -137,4 +174,13 @@ mod tests { let result = ConfigKey::Frontend.default(); assert!(result.is_object()); } + + #[test] + fn test_default_validator() { + let result = std::panic::catch_unwind(|| { + default_validator(); + }); + // Assert that no panic occurred + assert!(result.is_ok(), "default_validator panicked"); + } } diff --git a/catalyst-gateway/bin/src/service/api/auth/endpoint.rs b/catalyst-gateway/bin/src/service/api/auth/endpoint.rs deleted file mode 100644 index 54bf7e4622..0000000000 --- a/catalyst-gateway/bin/src/service/api/auth/endpoint.rs +++ /dev/null @@ -1,128 +0,0 @@ -use std::{sync::LazyLock, time::Duration}; - -use dashmap::DashMap; -use ed25519_dalek::{Signature, VerifyingKey, PUBLIC_KEY_LENGTH}; -use moka::future::Cache; -use poem::{error::ResponseError, http::StatusCode, Request}; -use poem_openapi::{auth::Bearer, SecurityScheme}; -use tracing::error; - -use super::token::{Kid, SignatureEd25519, UlidBytes}; -use crate::service::api::auth::token::decode_auth_token_ed25519; - -/// Decoded token consists of a Kid, Ulid and Signature -pub type DecodedAuthToken = (Kid, UlidBytes, SignatureEd25519); - -/// Auth token in the form of catv1.. -pub type EncodedAuthToken = String; - -/// Cached auth tokens -static CACHE: LazyLock> = LazyLock::new(|| { - Cache::builder() - // Time to live (TTL): 30 minutes - .time_to_live(Duration::from_secs(30 * 60)) - // Time to idle (TTI): 5 minutes - .time_to_idle(Duration::from_secs(5 * 60)) - // Create the cache. - .build() -}); - -/// Mocked Valid certificates -/// TODO: the following is temporary state for POC until RBAC database is complete. -static CERTS: LazyLock> = LazyLock::new(|| { - /// Mock KID - const KID: &str = "0467de6bd945b9207bfa09d846b77ef5"; - - let public_key_bytes: [u8; PUBLIC_KEY_LENGTH] = [ - 180, 91, 130, 149, 226, 112, 29, 45, 188, 141, 64, 147, 250, 233, 75, 151, 151, 53, 248, - 197, 225, 122, 24, 67, 207, 100, 162, 152, 232, 102, 89, 162, - ]; - - let cert_map = DashMap::new(); - cert_map.insert(KID.to_string(), public_key_bytes); - cert_map -}); - -#[derive(SecurityScheme)] -#[oai( - rename = "CatalystSecurityScheme", - ty = "bearer", - key_in = "header", - key_name = "Bearer", - checker = "checker_api_catalyst_auth" -)] -#[allow(dead_code)] -/// Auth token security scheme -/// Add to endpoint params e.g async fn endpoint(&self, auth: `CatalystSecurityScheme`) -pub struct CatalystSecurityScheme(pub DecodedAuthToken); - -#[derive(Debug, thiserror::Error)] -#[error("Corrupt Auth Token")] -pub struct AuthTokenError; - -impl ResponseError for AuthTokenError { - fn status(&self) -> StatusCode { - StatusCode::FORBIDDEN - } -} - -/// When added to an endpoint, this hook is called per request to verify the bearer token -/// is valid. -async fn checker_api_catalyst_auth( - _req: &Request, bearer: Bearer, -) -> poem::Result { - if CACHE.contains_key(&bearer.token) { - // This get() will extend the entry life for another 5 minutes. - // Even though we keep calling get(), the entry will expire - // after 30 minutes (TTL) from the origin insert(). - if let Some((kid, ulid, sig)) = CACHE.get(&bearer.token).await { - Ok((kid, ulid, sig)) - } else { - error!("Auth token is not in the cache: {:?}", bearer.token); - Err(AuthTokenError)? - } - } else { - // Decode bearer token - let (kid, ulid, sig, msg) = match decode_auth_token_ed25519(&bearer.token.clone()) { - Ok((kid, ulid, sig, msg)) => (kid, ulid, sig, msg), - Err(err) => { - error!("Corrupt auth token: {:?}", err); - Err(AuthTokenError)? - }, - }; - - // Get pub key from CERTS state given decoded KID from decoded bearer token - let pub_key_bytes = if let Some(cert) = CERTS.get(&hex::encode(kid.0)) { - *cert - } else { - error!("Invalid KID {:?}", kid); - Err(AuthTokenError)? - }; - - let public_key = match VerifyingKey::from_bytes(&pub_key_bytes) { - Ok(pub_key) => pub_key, - Err(err) => { - error!("Invalid public key: {:?}", err); - Err(AuthTokenError)? - }, - }; - - // Strictly verify a signature on a message with this key-pair public key. - if public_key - .verify_strict(&msg, &Signature::from_bytes(&sig.0)) - .is_err() - { - error!( - "Message {:?} was not signed by this key-pair {:?}", - hex::encode(msg), - public_key, - ); - Err(AuthTokenError)?; - } - - // This entry will expire after 5 minutes (TTI) if there is no get(). - CACHE.insert(bearer.token, (kid, ulid, sig.clone())).await; - - Ok((kid, ulid, sig)) - } -} diff --git a/catalyst-gateway/bin/src/service/api/auth/mod.rs b/catalyst-gateway/bin/src/service/api/auth/mod.rs deleted file mode 100644 index d54d3fd16c..0000000000 --- a/catalyst-gateway/bin/src/service/api/auth/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -/// Cat security scheme -pub mod endpoint; -/// Token encoding decoding logic -mod token; diff --git a/catalyst-gateway/bin/src/service/api/auth/token.rs b/catalyst-gateway/bin/src/service/api/auth/token.rs deleted file mode 100644 index 7684f3fc3b..0000000000 --- a/catalyst-gateway/bin/src/service/api/auth/token.rs +++ /dev/null @@ -1,145 +0,0 @@ -use anyhow::Ok; -use base64::{prelude::BASE64_STANDARD, Engine}; -use ed25519_dalek::{Signer, SigningKey, SECRET_KEY_LENGTH, SIGNATURE_LENGTH}; -use pallas::codec::minicbor; - -/// Key ID - Blake2b-128 hash of the Role 0 Certificate defining the Session public key. -/// BLAKE2b-128 produces digest side of 16 bytes. -#[derive(Debug, Clone, Copy)] -pub struct Kid(pub [u8; 16]); - -/// Identifier for this token, encodes both the time the token was issued and a random -/// nonce. -#[derive(Debug, Clone, Copy)] -pub struct UlidBytes(pub [u8; 16]); - -/// Ed25519 signatures are (64 bytes) -#[derive(Debug, Clone)] -pub struct SignatureEd25519(pub [u8; 64]); - -/// The Encoded Binary Auth Token is a [CBOR sequence] that consists of 3 fields [ kid, -/// ulid, signature ]. ED25519 Signature over the preceding two fields - sig(cbor(kid), -/// cbor(ulid)) -#[allow(dead_code)] -pub fn encode_auth_token_ed25519( - kid: &Kid, ulid: &UlidBytes, secret_key_bytes: [u8; SECRET_KEY_LENGTH], -) -> anyhow::Result { - /// Auth token prefix as per spec - const AUTH_TOKEN_PREFIX: &str = "catv1"; - - let sk: SigningKey = SigningKey::from_bytes(&secret_key_bytes); - - let out: Vec = Vec::new(); - let mut encoder = minicbor::Encoder::new(out); - - encoder.bytes(&kid.0)?; - encoder.bytes(&ulid.0)?; - - let signature: [u8; SIGNATURE_LENGTH] = sk.sign(encoder.writer()).to_bytes(); - - encoder.bytes(&signature)?; - - Ok(format!( - "{}.{}", - AUTH_TOKEN_PREFIX, - BASE64_STANDARD.encode(encoder.writer()) - )) -} - -/// Decode base64 cbor encoded auth token into constituent parts of (kid, ulid, signature) -/// e.g catv1.UAARIjNEVWZ3iJmqu8zd7v9QAZEs7HHPLEwUpV1VhdlNe1hAAAAAAAAAAAAA... -#[allow(dead_code)] -pub fn decode_auth_token_ed25519( - auth_token: &str, -) -> anyhow::Result<(Kid, UlidBytes, SignatureEd25519, Vec)> { - /// The message is a Cbor sequence (cbor(kid) + cbor(ulid)): - /// kid + ulid are 16 bytes a piece, with 1 byte extra due to cbor encoding, - /// The two fields include their encoding resulting in 17 bytes each. - const KID_ULID_CBOR_ENCODED_BYTES: u8 = 34; - /// Auth token prefix - const AUTH_TOKEN_PREFIX: &str = "catv1"; - - let token = auth_token.split('.').collect::>(); - - let prefix = token.first().ok_or(anyhow::anyhow!("No valid prefix"))?; - if *prefix != AUTH_TOKEN_PREFIX { - return Err(anyhow::anyhow!("Corrupt token, invalid prefix")); - } - let token_base64 = token.get(1).ok_or(anyhow::anyhow!("No valid token"))?; - let token_cbor_encoded = BASE64_STANDARD.decode(token_base64)?; - - // We verify the signature on the message which corresponds to a Cbor sequence (cbor(kid) - // + cbor(ulid)): - let message_cbor_encoded = &token_cbor_encoded - .get(0..KID_ULID_CBOR_ENCODED_BYTES.into()) - .ok_or(anyhow::anyhow!("No valid token"))?; - - // Decode cbor to bytes - let mut cbor_decoder = minicbor::Decoder::new(&token_cbor_encoded); - - // Raw kid bytes - let kid = Kid(cbor_decoder - .bytes() - .map_err(|e| anyhow::anyhow!(format!("Invalid cbor for kid : {e}")))? - .try_into()?); - - // Raw ulid bytes - let ulid = UlidBytes( - cbor_decoder - .bytes() - .map_err(|e| anyhow::anyhow!(format!("Invalid cbor for ulid : {e}")))? - .try_into()?, - ); - - // Raw signature - let signature = SignatureEd25519( - cbor_decoder - .bytes() - .map_err(|e| anyhow::anyhow!(format!("Invalid cbor for sig : {e}")))? - .try_into()?, - ); - - Ok((kid, ulid, signature, message_cbor_encoded.to_vec())) -} - -#[cfg(test)] -mod tests { - - use ed25519_dalek::{Signature, SigningKey, Verifier, SECRET_KEY_LENGTH}; - use rand::rngs::OsRng; - - use super::{encode_auth_token_ed25519, Kid, UlidBytes}; - use crate::service::api::auth::token::decode_auth_token_ed25519; - - #[test] - fn test_token_generation_and_decoding() { - let kid: [u8; 16] = hex::decode("00112233445566778899aabbccddeeff") - .unwrap() - .try_into() - .unwrap(); - let ulid: [u8; 16] = hex::decode("01912cec71cf2c4c14a55d5585d94d7b") - .unwrap() - .try_into() - .unwrap(); - - let mut random_seed = OsRng; - let signing_key: SigningKey = SigningKey::generate(&mut random_seed); - - let verifying_key = signing_key.verifying_key(); - - let secret_key_bytes: [u8; SECRET_KEY_LENGTH] = *signing_key.as_bytes(); - - let auth_token = - encode_auth_token_ed25519(&Kid(kid), &UlidBytes(ulid), secret_key_bytes).unwrap(); - - let (decoded_kid, decoded_ulid, decoded_sig, message) = - decode_auth_token_ed25519(&auth_token).unwrap(); - - assert_eq!(decoded_kid.0, kid); - assert_eq!(decoded_ulid.0, ulid); - - verifying_key - .verify(&message, &Signature::from(&decoded_sig.0)) - .unwrap(); - } -} diff --git a/catalyst-gateway/bin/src/service/api/cardano/cip36.rs b/catalyst-gateway/bin/src/service/api/cardano/cip36.rs index 93a1a46f00..a417237094 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/cip36.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/cip36.rs @@ -1,9 +1,9 @@ -//! Implementation of the GET `/registration/cip36` endpoint +//! Implementation of the GET `/cardano/cip36` endpoint use std::{cmp::Reverse, sync::Arc}; use futures::StreamExt; -use poem_openapi::{payload::Json, ApiResponse, Object}; +use poem_openapi::{payload::Json, ApiResponse}; use tracing::error; use crate::{ @@ -16,98 +16,36 @@ use crate::{ }, session::CassandraSession, }, - service::common::responses::WithErrorResponses, + service::common::{ + objects::cardano::cip36::{ + Cip36Info, Cip36Reporting, Cip36ReportingList, InvalidRegistrationsReport, + }, + responses::WithErrorResponses, + }, }; -/// Endpoint responses +/// Endpoint responses. #[derive(ApiResponse)] pub(crate) enum ResponseSingleRegistration { - /// Cip36 registration + /// A CIP36 registration report. #[oai(status = 200)] Ok(Json), - /// No valid registration + /// No valid registration. #[oai(status = 404)] NotFound, } -/// Endpoint responses +/// Endpoint responses. #[derive(ApiResponse)] pub(crate) enum ResponseMultipleRegistrations { - /// Cip36 registration + /// All CIP36 registrations associated with the same Voting Key. #[oai(status = 200)] Ok(Json), - /// No valid registration + /// No valid registration. #[oai(status = 404)] NotFound, } -/// Cip36 info list -#[derive(Object, Default)] -pub(crate) struct Cip36ReportingList { - /// List of registrations - #[oai(validator(max_items = "100000"))] - cip36: Vec, -} - -/// Cip36 info + invalid reporting -#[derive(Object, Default)] -pub(crate) struct Cip36Reporting { - /// List of registrations - #[oai(validator(max_items = "100000"))] - cip36: Vec, - /// Invalid registration reporting - #[oai(validator(max_items = "100000"))] - invalids: Vec, -} - -/// Cip36 info -#[derive(Object, Default)] -pub(crate) struct Cip36Info { - /// Full Stake Address (not hashed, 32 byte ED25519 Public key). - #[oai(validator(max_length = 66, min_length = 66, pattern = "0x[0-9a-f]{64}"))] - pub stake_address: String, - /// Nonce value after normalization. - #[oai(validator(minimum(value = "0"), maximum(value = "9223372036854775807")))] - pub nonce: u64, - /// Slot Number the cert is in. - #[oai(validator(minimum(value = "0"), maximum(value = "9223372036854775807")))] - pub slot_no: u64, - /// Transaction Index. - #[oai(validator(minimum(value = "0"), maximum(value = "9223372036854775807")))] - pub txn: i16, - /// Voting Public Key - #[oai(validator(max_length = 66, min_length = 66, pattern = "0x[0-9a-f]{64}"))] - pub vote_key: String, - /// Full Payment Address (not hashed, 32 byte ED25519 Public key). - #[oai(validator(max_length = 66, min_length = 66, pattern = "0x[0-9a-f]{64}"))] - pub payment_address: String, - /// Is the stake address a script or not. - pub is_payable: bool, - /// Is the Registration CIP36 format, or CIP15 - pub cip36: bool, -} - -/// Invalid registration error reporting -#[derive(Object, Default)] -pub(crate) struct InvalidRegistrationsReport { - /// Error report - #[oai(validator(max_items = "100000"))] - pub error_report: Vec, - /// Full Stake Address (not hashed, 32 byte ED25519 Public key). - #[oai(validator(max_length = 66, min_length = 66, pattern = "0x[0-9a-f]{64}"))] - pub stake_address: String, - /// Voting Public Key - #[oai(validator(max_length = 66, min_length = 0, pattern = "[0-9a-f]"))] - pub vote_key: String, - /// Full Payment Address (not hashed, 32 byte ED25519 Public key). - #[oai(validator(max_length = 66, min_length = 0, pattern = "[0-9a-f]"))] - pub payment_address: String, - /// Is the stake address a script or not. - pub is_payable: bool, - /// Is the Registration CIP36 format, or CIP15 - pub cip36: bool, -} - /// Single registration response pub(crate) type SingleRegistrationResponse = WithErrorResponses; /// All responses voting key @@ -120,13 +58,16 @@ pub(crate) async fn get_latest_registration_from_stake_addr( let stake_addr = match hex::decode(stake_addr) { Ok(stake_addr) => stake_addr, Err(err) => { - error!("Failed to decode stake addr {:?}", err); + error!(id="get_latest_registration_from_stake_addr", error=?err, "Failed to decode stake addr"); return ResponseSingleRegistration::NotFound.into(); }, }; let Some(session) = CassandraSession::get(persistent) else { - error!("Failed to acquire db session"); + error!( + id = "get_latest_registration_from_stake_addr", + "Failed to acquire db session" + ); return ResponseSingleRegistration::NotFound.into(); }; @@ -135,9 +76,9 @@ pub(crate) async fn get_latest_registration_from_stake_addr( Ok(registrations) => registrations, Err(err) => { error!( - "Failed to obtain registrations for given stake addr:{:?} {:?}", - hex::encode(stake_addr), - err + id="get_latest_registration_from_stake_addr", + error=?err, + "Failed to obtain registrations for given stake addr", ); return ResponseSingleRegistration::NotFound.into(); }, @@ -153,18 +94,15 @@ pub(crate) async fn get_latest_registration_from_stake_addr( Ok(invalids) => invalids, Err(err) => { error!( - "Failed to obtain invalid registrations for given stake addr:{:?} {:?}", - hex::encode(stake_addr), - err + id="get_latest_registration_from_stake_addr", + error=?err, + "Failed to obtain invalid registrations for given stake addr", ); return ResponseSingleRegistration::NotFound.into(); }, }; - let report = Cip36Reporting { - cip36: vec![registration], - invalids: invalids_report, - }; + let report = Cip36Reporting::new(vec![registration], invalids_report); ResponseSingleRegistration::Ok(Json(report)).into() } @@ -268,13 +206,16 @@ pub(crate) async fn get_latest_registration_from_stake_key_hash( let stake_hash = match hex::decode(stake_hash) { Ok(stake_hash) => stake_hash, Err(err) => { - error!("Failed to decode stake addr {:?}", err); + error!(id="get_latest_registration_from_stake_key_hash_stake_hash",error=?err, "Failed to decode stake hash"); return ResponseSingleRegistration::NotFound.into(); }, }; let Some(session) = CassandraSession::get(persistent) else { - error!("Failed to acquire db session"); + error!( + id = "get_latest_registration_from_stake_key_hash_db_session", + "Failed to acquire db session" + ); return ResponseSingleRegistration::NotFound.into(); }; @@ -283,7 +224,11 @@ pub(crate) async fn get_latest_registration_from_stake_key_hash( match GetStakeAddrQuery::execute(&session, GetStakeAddrParams::new(stake_hash)).await { Ok(latest) => latest, Err(err) => { - error!("Failed to query stake addr from stake hash {:?}", err); + error!( + id="get_latest_registration_from_stake_key_hash_query_stake_addr", + error=?err, + "Failed to query stake addr from stake hash" + ); return ResponseSingleRegistration::NotFound.into(); }, }; @@ -292,25 +237,31 @@ pub(crate) async fn get_latest_registration_from_stake_key_hash( let row = match row_stake_addr { Ok(r) => r, Err(err) => { - error!("Failed to get latest registration {:?}", err); + error!( + id="get_latest_registration_from_stake_key_hash_latest_registration", + error=?err, + "Failed to get latest registration" + ); return ResponseSingleRegistration::NotFound.into(); }, }; - let registration = - match latest_registration_from_stake_addr(row.stake_address.clone(), session.clone()) - .await - { - Ok(registration) => registration, - Err(err) => { - error!( - "Failed to obtain registration for given stake addr:{:?} {:?}", - hex::encode(row.stake_address), - err - ); - return ResponseSingleRegistration::NotFound.into(); - }, - }; + let registration = match latest_registration_from_stake_addr( + row.stake_address.clone(), + session.clone(), + ) + .await + { + Ok(registration) => registration, + Err(err) => { + error!( + id="get_latest_registration_from_stake_key_hash_registration_for_stake_addr", + error=?err, + "Failed to obtain registration for given stake addr", + ); + return ResponseSingleRegistration::NotFound.into(); + }, + }; // include any erroneous registrations which occur AFTER the slot# of the last valid // registration @@ -324,18 +275,15 @@ pub(crate) async fn get_latest_registration_from_stake_key_hash( Ok(invalids) => invalids, Err(err) => { error!( - "Failed to obtain invalid registrations for given stake addr:{:?} {:?}", - hex::encode(registration.stake_address.clone()), - err + id="get_latest_registration_from_stake_key_hash_invalid_registrations_for_stake_addr", + error=?err, + "Failed to obtain invalid registrations for given stake addr", ); return ResponseSingleRegistration::NotFound.into(); }, }; - let report = Cip36Reporting { - cip36: vec![registration], - invalids: invalids_report, - }; + let report = Cip36Reporting::new(vec![registration], invalids_report); return ResponseSingleRegistration::Ok(Json(report)).into(); } @@ -352,13 +300,20 @@ pub(crate) async fn get_associated_vote_key_registrations( let vote_key = match hex::decode(vote_key) { Ok(vote_key) => vote_key, Err(err) => { - error!("Failed to decode vote key {:?}", err); + error!( + id="get_associated_vote_key_registrations_vote_key", + error=?err, + "Failed to decode vote key" + ); return ResponseMultipleRegistrations::NotFound.into(); }, }; let Some(session) = CassandraSession::get(persistent) else { - error!("Failed to acquire db session"); + error!( + id = "get_associated_vote_key_registrations_db_session", + "Failed to acquire db session" + ); return ResponseMultipleRegistrations::NotFound.into(); }; @@ -370,7 +325,11 @@ pub(crate) async fn get_associated_vote_key_registrations( { Ok(latest) => latest, Err(err) => { - error!("Failed to query stake addr from vote key {:?}", err); + error!( + id="get_associated_vote_key_registrations_query_stake_addr_from_vote_key", + error=?err, + "Failed to query stake addr from vote key" + ); return ResponseMultipleRegistrations::NotFound.into(); }, }; @@ -379,7 +338,11 @@ pub(crate) async fn get_associated_vote_key_registrations( let row = match row_stake_addr { Ok(r) => r, Err(err) => { - error!("Failed to get latest registration {:?}", err); + error!( + id="get_associated_vote_key_registrations_latest_registration", + error=?err, + "Failed to get latest registration" + ); return ResponseMultipleRegistrations::NotFound.into(); }, }; @@ -393,9 +356,9 @@ pub(crate) async fn get_associated_vote_key_registrations( Ok(registration) => registration, Err(err) => { error!( - "Failed to obtain registrations for given stake addr:{:?} {:?}", - hex::encode(row.stake_address), - err + id="get_associated_vote_key_registrations_get_registrations_for_stake_addr", + error=?err, + "Failed to obtain registrations for given stake addr", ); return ResponseMultipleRegistrations::NotFound.into(); }, @@ -408,7 +371,7 @@ pub(crate) async fn get_associated_vote_key_registrations( // Report includes registration info and any erroneous registrations which occur AFTER // the slot# of the last valid registration - let mut reports = Cip36ReportingList { cip36: Vec::new() }; + let mut reports = Cip36ReportingList::new(); for registration in redacted_registrations { let invalids_report = match get_invalid_registrations( @@ -421,20 +384,15 @@ pub(crate) async fn get_associated_vote_key_registrations( Ok(invalids) => invalids, Err(err) => { error!( - "Failed to obtain invalid registrations for given stake addr:{:?} {:?}", - hex::encode(registration.stake_address.clone()), - err + id="get_associated_vote_key_registrations_invalid_registrations_for_stake_addr", + error=?err, + "Failed to obtain invalid registrations for given stake addr", ); continue; }, }; - let report = Cip36Reporting { - cip36: vec![registration], - invalids: invalids_report, - }; - - reports.cip36.push(report); + reports.add(Cip36Reporting::new(vec![registration], invalids_report)); } return ResponseMultipleRegistrations::Ok(Json(reports)).into(); diff --git a/catalyst-gateway/bin/src/service/api/cardano/mod.rs b/catalyst-gateway/bin/src/service/api/cardano/mod.rs index 27247ea646..9f7efd5284 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/mod.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/mod.rs @@ -7,8 +7,8 @@ use types::{DateTime, SlotNumber}; use crate::service::{ common::{ - objects::cardano::{network::Network, stake_address::StakeAddress}, - tags::ApiTags, + auth::none_or_rbac::NoneOrRBAC, objects::cardano::network::Network, tags::ApiTags, + types::cardano::address::Cip19StakeAddress, }, utilities::middleware::schema_validation::schema_version_validation, }; @@ -23,23 +23,23 @@ pub(crate) mod types; /// Cardano Follower API Endpoints pub(crate) struct CardanoApi; -#[OpenApi(prefix_path = "/cardano", tag = "ApiTags::Cardano")] +#[OpenApi(tag = "ApiTags::Cardano")] impl CardanoApi { + /// Get staked ADA amount. + /// + /// This endpoint returns the total Cardano's staked ADA amount to the corresponded + /// user's stake address. #[oai( - path = "/staked_ada/:stake_address", + path = "/draft/cardano/staked_ada/:stake_address", method = "get", operation_id = "stakedAdaAmountGet", transform = "schema_version_validation" )] - /// Get staked ada amount. - /// - /// This endpoint returns the total Cardano's staked ada amount to the corresponded - /// user's stake address. async fn staked_ada_get( &self, /// The stake address of the user. - /// Should a valid Bech32 encoded address followed by the https://cips.cardano.org/cip/CIP-19/#stake-addresses. - stake_address: Path, + /// 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 @@ -48,30 +48,32 @@ impl CardanoApi { /// `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. + /// 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, ) -> staked_ada_get::AllResponses { staked_ada_get::endpoint(stake_address.0, network.0, slot_number.0).await } + /// 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 = "/registration/:stake_address", + path = "/draft/cardano/registration/:stake_address", method = "get", operation_id = "registrationGet", transform = "schema_version_validation" )] - /// 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. async fn registration_get( &self, /// The stake address of the user. - /// Should a valid Bech32 encoded address followed by the https://cips.cardano.org/cip/CIP-19/#stake-addresses. - stake_address: Path, + /// 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 @@ -80,7 +82,7 @@ impl CardanoApi { /// `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. + /// 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")))] @@ -89,15 +91,15 @@ impl CardanoApi { 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. #[oai( - path = "/sync_state", + path = "/draft/cardano/sync_state", method = "get", operation_id = "syncStateGet", transform = "schema_version_validation" )] - /// Get Cardano follower's sync state. - /// - /// This endpoint returns the current cardano follower's sync state info. async fn sync_state_get( &self, /// Cardano network type. @@ -110,16 +112,16 @@ impl CardanoApi { sync_state_get::endpoint(network.0).await } + /// Get Cardano slot info to the provided date-time. + /// + /// This endpoint returns the closest cardano slot info to the provided + /// date-time. #[oai( - path = "/date_time_to_slot_number", + path = "/draft/cardano/date_time_to_slot_number", method = "get", operation_id = "dateTimeToSlotNumberGet", transform = "schema_version_validation" )] - /// Get Cardano slot info to the provided date-time. - /// - /// This endpoint returns the closest cardano slot info to the provided - /// date-time. async fn date_time_to_slot_number_get( &self, /// The date-time for which the slot number should be calculated. @@ -135,53 +137,54 @@ impl CardanoApi { 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 = "/cip36/latest_registration/stake_addr", + path = "/draft/cardano/cip36/latest_registration/stake_addr", method = "get", operation_id = "latestRegistrationGivenStakeAddr" )] - /// Cip36 registrations - /// - /// This endpoint gets the latest registration given a stake addr async fn latest_registration_cip36_given_stake_addr( &self, - #[oai(validator(max_length = 66, min_length = 0, pattern = "[0-9a-f]"))] stake_addr: Query< - String, - >, + /// Stake Address to find the latest registration for. + #[oai(validator(max_length = 66, min_length = 0, pattern = "[0-9a-f]"))] + stake_addr: Query, ) -> cip36::SingleRegistrationResponse { cip36::get_latest_registration_from_stake_addr(stake_addr.0, true).await } + /// Get latest CIP36 registrations from a stake key hash. + /// + /// This endpoint gets the latest registration given a stake key hash. #[oai( - path = "/cip36/latest_registration/stake_key_hash", + path = "/draft/cardano/cip36/latest_registration/stake_key_hash", method = "get", operation_id = "latestRegistrationGivenStakeHash" )] - /// Cip36 registrations - /// - /// This endpoint gets the latest registration given a stake key hash 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, ) -> 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 = "/cip36/latest_registration/vote_key", + path = "/draft/cardano/cip36/latest_registration/vote_key", method = "get", operation_id = "latestRegistrationGivenVoteKey" )] - /// Cip36 registrations - /// - /// This endpoint returns the list of stake address registrations currently associated - /// with a given voting key. async fn latest_registration_cip36_given_vote_key( &self, - #[oai(validator(max_length = 66, min_length = 0, pattern = "[0-9a-f]"))] vote_key: Query< - String, - >, + /// Voting Key to find CIP36 registrations for. + #[oai(validator(max_length = 66, min_length = 0, pattern = "[0-9a-f]"))] + vote_key: Query, ) -> cip36::MultipleRegistrationResponse { cip36::get_associated_vote_key_registrations(vote_key.0, true).await } diff --git a/catalyst-gateway/bin/src/service/api/cardano/registration_get.rs b/catalyst-gateway/bin/src/service/api/cardano/registration_get.rs index 00c4e89d1b..027bf3b763 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/registration_get.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/registration_get.rs @@ -5,10 +5,9 @@ use poem_openapi::{payload::Json, ApiResponse}; use super::types::SlotNumber; use crate::service::{ common::{ - objects::cardano::{ - network::Network, registration_info::RegistrationInfo, stake_address::StakeAddress, - }, + objects::cardano::{network::Network, registration_info::RegistrationInfo}, responses::WithErrorResponses, + types::cardano::address::Cip19StakeAddress, }, utilities::check_network, }; @@ -16,7 +15,7 @@ use crate::service::{ /// Endpoint responses #[derive(ApiResponse)] #[allow(dead_code)] -pub(crate) enum Responses { +pub(super) enum Responses { /// The registration information for the stake address queried. #[oai(status = 200)] Ok(Json), @@ -27,36 +26,40 @@ pub(crate) enum Responses { } /// All responses -pub(crate) type AllResponses = WithErrorResponses; +pub(super) type AllResponses = WithErrorResponses; /// # GET `/registration` #[allow(clippy::unused_async, clippy::no_effect_underscore_binding)] pub(crate) async fn endpoint( - stake_address: StakeAddress, provided_network: Option, slot_num: Option, + stake_address: Cip19StakeAddress, provided_network: Option, + slot_num: Option, ) -> AllResponses { let _date_time = slot_num.unwrap_or(SlotNumber::MAX); - let _stake_credential = stake_address.payload().as_hash().to_vec(); - let _network = match check_network(stake_address.network(), provided_network) { - Ok(network) => network, - Err(err) => return AllResponses::handle_error(&err), + // TODO - handle appropriate response + let Ok(address) = stake_address.to_stake_address() else { + return Responses::NotFound.into(); }; + let _stake_credential = address.payload().as_hash().to_vec(); - let _unused = " - // get the total utxo amount from the database - match EventDB::get_registration_info(stake_credential, network.into(), date_time).await { - Ok((tx_id, payment_address, voting_info, nonce)) => { - Responses::Ok(Json(RegistrationInfo::new( - tx_id, - &payment_address, - voting_info, - nonce, - ))) - .into() - }, - Err(err) if err.is::() => Responses::NotFound.into(), - Err(err) => AllResponses::handle_error(&err), + // If the network is not valid, just say NotFound. + if let Ok(_network) = check_network(address.network(), provided_network) { + let _unused = " + // get the total utxo amount from the database + match EventDB::get_registration_info(stake_credential, network.into(), date_time).await { + Ok((tx_id, payment_address, voting_info, nonce)) => { + Responses::Ok(Json(RegistrationInfo::new( + tx_id, + &payment_address, + voting_info, + nonce, + ))) + .into() + }, + Err(err) if err.is::() => Responses::NotFound.into(), + Err(err) => AllResponses::handle_error(&err), + } + "; } - "; Responses::NotFound.into() } diff --git a/catalyst-gateway/bin/src/service/api/cardano/staked_ada_get.rs b/catalyst-gateway/bin/src/service/api/cardano/staked_ada_get.rs index 39a0b573fa..50112d3be9 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/staked_ada_get.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/staked_ada_get.rs @@ -23,10 +23,10 @@ use crate::{ service::common::{ objects::cardano::{ network::Network, - stake_address::StakeAddress, stake_info::{FullStakeInfo, StakeInfo, StakedNativeTokenInfo}, }, responses::WithErrorResponses, + types::cardano::address::Cip19StakeAddress, }, }; @@ -47,7 +47,8 @@ pub(crate) type AllResponses = WithErrorResponses; /// # GET `/staked_ada` #[allow(clippy::unused_async, clippy::no_effect_underscore_binding)] pub(crate) async fn endpoint( - stake_address: StakeAddress, _provided_network: Option, slot_num: Option, + stake_address: Cip19StakeAddress, _provided_network: Option, + slot_num: Option, ) -> AllResponses { let persistent_res = calculate_stake_info(true, stake_address.clone(), slot_num).await; let persistent_stake_info = match persistent_res { @@ -105,13 +106,14 @@ struct TxoInfo { /// This function also updates the spent column if it detects that a TXO was spent /// between lookups. async fn calculate_stake_info( - persistent: bool, stake_address: StakeAddress, slot_num: Option, + persistent: bool, stake_address: Cip19StakeAddress, slot_num: Option, ) -> anyhow::Result> { let Some(session) = CassandraSession::get(persistent) else { anyhow::bail!("Failed to acquire db session"); }; - let stake_address_bytes = stake_address.payload().as_hash().to_vec(); + let address = stake_address.to_stake_address()?; + 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?; if txos_by_txn.is_empty() { diff --git a/catalyst-gateway/bin/src/service/api/config/mod.rs b/catalyst-gateway/bin/src/service/api/config/mod.rs index 421b1de9ed..7b1028d77c 100644 --- a/catalyst-gateway/bin/src/service/api/config/mod.rs +++ b/catalyst-gateway/bin/src/service/api/config/mod.rs @@ -1,57 +1,74 @@ //! Configuration Endpoints -use std::{net::IpAddr, str::FromStr}; +use std::net::IpAddr; use jsonschema::BasicOutput; use poem::web::RealIp; -use poem_openapi::{param::Query, payload::Json, ApiResponse, Object, OpenApi}; -use serde_json::{json, Value}; +use poem_openapi::{param::Query, payload::Json, types::ToJSON, ApiResponse, OpenApi}; +use serde_json::Value; use tracing::error; use crate::{ db::event::config::{key::ConfigKey, Config}, - service::common::{responses::WithErrorResponses, tags::ApiTags}, + service::common::{ + auth::{none_or_rbac::NoneOrRBAC, rbac::scheme::CatalystRBACSecurityScheme}, + objects::config::{frontend_config::FrontendConfig, ConfigBadRequest}, + responses::WithErrorResponses, + tags::ApiTags, + }, }; /// Configuration API struct pub(crate) struct ConfigApi; -/// Endpoint responses +/// Get configuration endpoint responses. #[derive(ApiResponse)] -enum Responses { - /// Configuration result +enum GetConfigResponses { + /// Configuration result. #[oai(status = 200)] - Ok(Json), - /// Bad request - #[oai(status = 400)] - BadRequest(Json), + Ok(Json), } +/// Get configuration all responses. +type GetConfigAllResponses = WithErrorResponses; -/// Bad request errors -#[derive(Object, Default)] -struct BadRequestError { - /// Error messages. - #[oai(validator(max_length = "100", pattern = "^[0-9a-zA-Z].*$"))] - error: String, - /// Optional schema validation errors. - #[oai(validator(max_items = "1000", max_length = "9999", pattern = "^[0-9a-zA-Z].*$"))] - schema_validation_errors: Option>, +/// Get configuration schema endpoint responses. +#[derive(ApiResponse)] +enum GetConfigSchemaResponses { + /// Configuration result. + #[oai(status = 200)] + Ok(Json), } +/// Get configuration schema all responses. +type GetConfigSchemaAllResponses = WithErrorResponses; -/// All responses. -type AllResponses = WithErrorResponses; +/// Set configuration endpoint responses. +#[derive(ApiResponse)] +enum SetConfigResponse { + /// Configuration Update Successful. + #[oai(status = 204)] + Ok, + /// Set configuration bad request. + #[oai(status = 400)] + BadRequest(Json), +} +/// Set configuration all responses. +type SetConfigAllResponses = WithErrorResponses; #[OpenApi(tag = "ApiTags::Config")] impl ConfigApi { /// Get the configuration for the frontend. /// - /// Retrieving IP from X-Real-IP, Forwarded, X-Forwarded-For or Remote Address. + /// Get the frontend configuration for the requesting client. + /// + /// ### Security + /// + /// Does not require any Catalyst RBAC Token to access. #[oai( path = "/draft/config/frontend", method = "get", operation_id = "get_config_frontend" )] - async fn get_frontend(&self, ip_address: RealIp) -> AllResponses { + async fn get_frontend(&self, ip_address: RealIp, _auth: NoneOrRBAC) -> GetConfigAllResponses { // Fetch the general configuration let general_config = Config::get(ConfigKey::Frontend).await; @@ -60,8 +77,8 @@ impl ConfigApi { match Config::get(ConfigKey::FrontendForIp(ip)).await { Ok(value) => Some(value), Err(err) => { - error!(id="get_config_frontend_ip", error=?err, "Failed to get frontend configuration for IP"); - return AllResponses::handle_error(&err); + error!(id="get_frontend_config_ip", error=?err, "Failed to get frontend configuration for IP"); + return GetConfigAllResponses::handle_error(&err); }, } } else { @@ -79,52 +96,77 @@ impl ConfigApi { general }; - Responses::Ok(Json(response_config)).into() + // Convert the merged Value to FrontendConfig + let frontend_config: FrontendConfig = + serde_json::from_value(response_config).unwrap_or_default(); // Handle error as needed + + GetConfigResponses::Ok(Json(frontend_config)).into() }, Err(err) => { - error!(id="get_config_frontend_general", error=?err, "Failed to get general frontend configuration"); - AllResponses::handle_error(&err) + error!(id="get_frontend_config_general", error=?err, "Failed to get general frontend configuration"); + GetConfigAllResponses::handle_error(&err) }, } } - /// Get the frontend JSON schema. + /// Get the frontend configuration JSON schema. + /// + /// Returns the JSON schema which defines the data which can be read or written for + /// the frontend configuration. + /// + /// ### Security + /// + /// Does not require any Catalyst RBAC Token to access. #[oai( path = "/draft/config/frontend/schema", method = "get", operation_id = "get_config_frontend_schema" )] #[allow(clippy::unused_async)] - async fn get_frontend_schema(&self) -> AllResponses { + async fn get_frontend_schema(&self, _auth: NoneOrRBAC) -> GetConfigSchemaAllResponses { // Schema for both IP specific and general are identical - Responses::Ok(Json(ConfigKey::Frontend.schema().clone())).into() + let schema_value = ConfigKey::Frontend.schema().clone(); + let frontend_config: FrontendConfig = + serde_json::from_value(schema_value).unwrap_or_default(); + GetConfigSchemaResponses::Ok(Json(frontend_config)).into() } /// Set the frontend configuration. + /// + /// Store the given config as either global front end configuration, or configuration + /// for a client at a specific IP address. + /// + /// ### Security + /// + /// Requires Admin Authoritative RBAC Token. #[oai( path = "/draft/config/frontend", method = "put", operation_id = "put_config_frontend" )] async fn put_frontend( - &self, #[oai(name = "IP")] ip_query: Query>, body: Json, - ) -> AllResponses { - let body_value = body.0; - - match ip_query.0 { - Some(ip) => { - match IpAddr::from_str(&ip) { - Ok(parsed_ip) => set(ConfigKey::FrontendForIp(parsed_ip), body_value).await, - Err(err) => { - Responses::BadRequest(Json(BadRequestError { - error: format!("Invalid IP address: {err}"), - schema_validation_errors: None, - })) - .into() - }, + &self, + /// *OPTIONAL* The IP Address to set the configuration for. + #[oai(name = "IP")] + ip_query: Query>, + _auth: CatalystRBACSecurityScheme, body: Json, + ) -> SetConfigAllResponses { + let body_value = body.0.to_json(); + + match body_value { + Some(value) => { + match ip_query.0 { + Some(ip) => set(ConfigKey::FrontendForIp(ip), value).await, + None => set(ConfigKey::Frontend, value).await, } }, - None => set(ConfigKey::Frontend, body_value).await, + None => { + SetConfigResponse::BadRequest(Json(ConfigBadRequest::new( + "Invalid JSON data".to_string(), + None, + ))) + .into() + }, } } } @@ -149,27 +191,27 @@ fn merge_configs(general: &Value, ip_specific: &Value) -> Value { } /// Helper function to handle set. -async fn set(key: ConfigKey, value: Value) -> AllResponses { +async fn set(key: ConfigKey, value: Value) -> SetConfigAllResponses { match Config::set(key, value).await { Ok(validate) => { match validate { - BasicOutput::Valid(_) => Responses::Ok(Json(json!(null))).into(), + BasicOutput::Valid(_) => SetConfigResponse::Ok.into(), BasicOutput::Invalid(errors) => { let schema_errors: Vec = errors .iter() .map(|error| error.error_description().clone().into_inner()) .collect(); - Responses::BadRequest(Json(BadRequestError { - error: "Invalid JSON data validating against JSON schema".to_string(), - schema_validation_errors: Some(schema_errors), - })) + SetConfigResponse::BadRequest(Json(ConfigBadRequest::new( + "Invalid JSON data validating against JSON schema".to_string(), + Some(schema_errors), + ))) .into() }, } }, Err(err) => { - error!(id="put_config_frontend", error=?err, "Failed to set frontend configuration"); - AllResponses::handle_error(&err) + error!(id="set_config_frontend", error=?err, "Failed to set frontend configuration"); + SetConfigAllResponses::handle_error(&err) }, } } diff --git a/catalyst-gateway/bin/src/service/api/health/inspection_get.rs b/catalyst-gateway/bin/src/service/api/health/inspection_get.rs index 56850e491f..5510bca4e5 100644 --- a/catalyst-gateway/bin/src/service/api/health/inspection_get.rs +++ b/catalyst-gateway/bin/src/service/api/health/inspection_get.rs @@ -4,7 +4,7 @@ use tracing::debug; use crate::{db::event::EventDB, logger, service::common::responses::WithErrorResponses}; -/// `LogLevel` Open API definition. +/// Set of all log levels which can be selected. #[derive(Debug, Clone, Copy, Enum)] #[oai(rename_all = "lowercase")] pub(crate) enum LogLevel { @@ -29,7 +29,7 @@ impl From for logger::LogLevel { } } -/// `DeepQueryInspectionFlag` Open API definition. +/// Enable or Disable Deep Database Query Inspection. #[derive(Debug, Clone, Copy, Enum)] #[oai(rename_all = "lowercase")] pub(crate) enum DeepQueryInspectionFlag { diff --git a/catalyst-gateway/bin/src/service/api/health/mod.rs b/catalyst-gateway/bin/src/service/api/health/mod.rs index 24c9b20d96..0f100b02ac 100644 --- a/catalyst-gateway/bin/src/service/api/health/mod.rs +++ b/catalyst-gateway/bin/src/service/api/health/mod.rs @@ -1,7 +1,7 @@ //! Health Endpoints use poem_openapi::{param::Query, OpenApi}; -use crate::service::common::tags::ApiTags; +use crate::service::common::{auth::api_key::InternalApiKeyAuthorization, tags::ApiTags}; mod inspection_get; mod live_get; @@ -12,9 +12,8 @@ pub(crate) use started_get::started; /// Health API Endpoints pub(crate) struct HealthApi; -#[OpenApi(prefix_path = "/health", tag = "ApiTags::Health")] +#[OpenApi(tag = "ApiTags::Health")] impl HealthApi { - #[oai(path = "/started", method = "get", operation_id = "healthStarted")] /// Service Started /// /// This endpoint is used to determine if the service has started properly @@ -24,11 +23,15 @@ impl HealthApi { /// /// *This endpoint is for internal use of the service deployment infrastructure. /// It may not be exposed publicly.* - async fn started_get(&self) -> started_get::AllResponses { + #[oai( + path = "/v1/health/started", + method = "get", + operation_id = "healthStarted" + )] + async fn started_get(&self, _auth: InternalApiKeyAuthorization) -> started_get::AllResponses { started_get::endpoint().await } - #[oai(path = "/ready", method = "get", operation_id = "healthReady")] /// Service Ready /// /// This endpoint is used to determine if the service is ready and able to serve @@ -38,11 +41,15 @@ impl HealthApi { /// /// *This endpoint is for internal use of the service deployment infrastructure. /// It may not be exposed publicly.* - async fn ready_get(&self) -> ready_get::AllResponses { + #[oai( + path = "/v1/health/ready", + method = "get", + operation_id = "healthReady" + )] + async fn ready_get(&self, _auth: InternalApiKeyAuthorization) -> ready_get::AllResponses { ready_get::endpoint().await } - #[oai(path = "/live", method = "get", operation_id = "healthLive")] /// Service Live /// /// This endpoint is used to determine if the service is live. @@ -51,19 +58,33 @@ impl HealthApi { /// /// *This endpoint is for internal use of the service deployment infrastructure. /// It may not be exposed publicly. Refer to []* - async fn live_get(&self) -> live_get::AllResponses { + #[oai(path = "/v1/health/live", method = "get", operation_id = "healthLive")] + async fn live_get(&self, _auth: InternalApiKeyAuthorization) -> live_get::AllResponses { live_get::endpoint().await } + /// Service Inspection Control. + /// + /// This endpoint is used to control internal service inspection features. + /// + /// ## Note + /// + /// *This endpoint is for internal use of the service deployment infrastructure. + /// It may not be exposed publicly.* + // TODO: Make the parameters to this a JSON Body, not query parameters. #[oai( - path = "/inspection", - method = "get", + path = "/v1/health/inspection", + method = "put", operation_id = "healthInspection" )] - /// Options for service inspection. async fn inspection( - &self, log_level: Query>, + &self, + /// The log level to use for the service. Controls what detail gets logged. + log_level: Query>, + /// Enable or disable Verbose Query inspection in the logs. Used to find query + /// performance issues. query_inspection: Query>, + _auth: InternalApiKeyAuthorization, ) -> inspection_get::AllResponses { inspection_get::endpoint(log_level.0, query_inspection.0).await } diff --git a/catalyst-gateway/bin/src/service/api/health/ready_get.rs b/catalyst-gateway/bin/src/service/api/health/ready_get.rs index df5db8db55..5dd021f7e2 100644 --- a/catalyst-gateway/bin/src/service/api/health/ready_get.rs +++ b/catalyst-gateway/bin/src/service/api/health/ready_get.rs @@ -1,5 +1,6 @@ //! Implementation of the GET /health/ready endpoint use poem_openapi::ApiResponse; +use tracing::{debug, error}; use crate::{ db::event::{schema_check::MismatchedSchemaError, EventDB}, @@ -40,11 +41,11 @@ pub(crate) type AllResponses = WithErrorResponses; pub(crate) async fn endpoint() -> AllResponses { match EventDB::schema_version_check().await { Ok(_) => { - tracing::debug!("DB schema version status ok"); + debug!("DB schema version status ok"); Responses::NoContent.into() }, Err(err) if err.is::() => { - tracing::error!("{err}"); + error!(id="health_ready_mismatch_schema", error=?err, "DB schema version mismatch"); Responses::ServiceUnavailable.into() }, Err(err) => AllResponses::handle_error(&err), diff --git a/catalyst-gateway/bin/src/service/api/legacy/registration/mod.rs b/catalyst-gateway/bin/src/service/api/legacy/registration/mod.rs index 239fa942e1..60b115a62e 100644 --- a/catalyst-gateway/bin/src/service/api/legacy/registration/mod.rs +++ b/catalyst-gateway/bin/src/service/api/legacy/registration/mod.rs @@ -24,7 +24,7 @@ pub(crate) struct RegistrationApi; /// Endpoint responses #[derive(ApiResponse)] enum Responses { - /// Voter's registration info + /// Voter's registration info. #[oai(status = 200)] Ok(Json), } @@ -32,15 +32,8 @@ enum Responses { /// All responses type AllResponses = WithErrorResponses; -#[OpenApi(prefix_path = "/registration", tag = "ApiTags::Registration")] +#[OpenApi(tag = "ApiTags::Registration")] impl RegistrationApi { - #[oai( - path = "/voter/:voting_key", - method = "get", - operation_id = "getVoterInfo", - transform = "schema_version_validation", - deprecated = true - )] /// Voter's info /// /// Get the voter's registration and voting power by their Public Voting Key. @@ -51,18 +44,24 @@ impl RegistrationApi { /// `delegator_addresses` field of `VoterInfo` type does not provided. #[allow(clippy::unused_async)] #[allow(unused_variables)] + #[oai( + path = "/v1/registration/voter/:voting_key", + method = "get", + operation_id = "getVoterInfo", + transform = "schema_version_validation", + deprecated = true + )] async fn get_voter_info( &self, /// A Voters Public ED25519 Key (as registered in their most recent valid /// [CIP-15](https://cips.cardano.org/cips/cip15) or [CIP-36](https://cips.cardano.org/cips/cip36) registration). #[oai(validator(max_length = 66, min_length = 66, pattern = "0x[0-9a-f]{64}"))] voting_key: Path, - /// The Event ID to return results for. + /// The Event Index to return results for. /// See [GET Events](Link to events endpoint) for details on retrieving all valid /// event IDs. - // TODO(bkioshn): https://github.com/input-output-hk/catalyst-voices/issues/239 #[oai(validator(minimum(value = "0"), maximum(value = "2147483647")))] - event_id: Query>, + event_index: Query>, /// If this optional flag is set, the response will include the delegator's list /// in the response. Otherwise, it will be omitted. #[oai(default)] diff --git a/catalyst-gateway/bin/src/service/api/legacy/v0/mod.rs b/catalyst-gateway/bin/src/service/api/legacy/v0/mod.rs index 1f7cadcfe1..513030258c 100644 --- a/catalyst-gateway/bin/src/service/api/legacy/v0/mod.rs +++ b/catalyst-gateway/bin/src/service/api/legacy/v0/mod.rs @@ -4,14 +4,13 @@ use poem_openapi::{payload::Binary, OpenApi}; use crate::service::{ common::tags::ApiTags, utilities::middleware::schema_validation::schema_version_validation, }; - mod message_post; mod plans_get; /// `v0` API Endpoints pub(crate) struct V0Api; -#[OpenApi(prefix_path = "/v0", tag = "ApiTags::V0")] +#[OpenApi(prefix_path = "/v0", tag = "ApiTags::Legacy")] impl V0Api { /// Posts a signed transaction. /// diff --git a/catalyst-gateway/bin/src/service/api/legacy/v1/fragments_post.rs b/catalyst-gateway/bin/src/service/api/legacy/v1/fragments_post.rs index 50b8041c6d..558b0e8f7e 100644 --- a/catalyst-gateway/bin/src/service/api/legacy/v1/fragments_post.rs +++ b/catalyst-gateway/bin/src/service/api/legacy/v1/fragments_post.rs @@ -12,7 +12,7 @@ use crate::service::common::{ /// Endpoint responses #[derive(ApiResponse)] pub(crate) enum Responses { - /// Fragments processing summary + /// Fragments processing summary. #[oai(status = 200)] Ok(Json), } diff --git a/catalyst-gateway/bin/src/service/api/legacy/v1/mod.rs b/catalyst-gateway/bin/src/service/api/legacy/v1/mod.rs index 2fa186e035..dea5f54f07 100644 --- a/catalyst-gateway/bin/src/service/api/legacy/v1/mod.rs +++ b/catalyst-gateway/bin/src/service/api/legacy/v1/mod.rs @@ -23,8 +23,12 @@ mod fragments_statuses; /// V1 API Endpoints pub(crate) struct V1Api; -#[OpenApi(prefix_path = "/v1", tag = "ApiTags::V1")] +#[OpenApi(prefix_path = "/v1", tag = "ApiTags::Legacy")] impl V1Api { + /// Get Account Votes + /// + /// Get from all active vote plans, the index of the voted proposals + /// by the given account ID. #[oai( path = "/votes/plan/account-votes/:account_id", method = "get", @@ -32,13 +36,10 @@ impl V1Api { transform = "schema_version_validation", deprecated = true )] - - /// Get Account Votes - /// - /// Get from all active vote plans, the index of the voted proposals - /// by the given account ID. async fn get_account_votes( - &self, /// A account ID to get the votes for. + &self, + /// A account ID to get the votes for. + #[oai(validator(max_length = "100", pattern = "^0x[a-f0-9]+$"))] account_id: Path, ) -> account_votes_get::AllResponses { account_votes_get::endpoint(account_id).await @@ -51,7 +52,6 @@ impl V1Api { path = "/fragments", method = "post", operation_id = "fragments", - tag = "ApiTags::Fragments", transform = "schema_version_validation", deprecated = true )] @@ -70,7 +70,6 @@ impl V1Api { path = "/fragments/statuses", method = "get", operation_id = "fragmentsStatuses", - tag = "ApiTags::Fragments", transform = "schema_version_validation", deprecated = true )] diff --git a/catalyst-gateway/bin/src/service/api/mod.rs b/catalyst-gateway/bin/src/service/api/mod.rs index 409e289093..9f5a6f5dc0 100644 --- a/catalyst-gateway/bin/src/service/api/mod.rs +++ b/catalyst-gateway/bin/src/service/api/mod.rs @@ -13,8 +13,7 @@ use poem_openapi::{ContactObject, LicenseObject, OpenApiService, ServerObject}; use self::cardano::CardanoApi; use crate::settings::Settings; -/// Auth -mod auth; + pub(crate) mod cardano; mod config; mod health; @@ -39,17 +38,7 @@ fn get_api_contact() -> ContactObject { /// A long description of the API. Markdown is supported const API_DESCRIPTION: &str = "# Catalyst Gateway API. -The Catalyst Gateway API provides realtime data for all prior, current and future Catalyst voting events. - -TODO: - -* Implement Permissionless Auth. -* Implement Replacement Functionality for GVC. -* Implement representative registration on main-chain, distinct from voter registration. -* Implement Voting API abstracting the Jormungandr API from public exposure. -* Implement Audit API's (Retrieve voting blockchain records, registration/voting power audit and private tally audit. -* Implement API's needed to support posting Ideas/Proposals etc.Catalyst Gateway -"; +The Catalyst Gateway API provides realtime data for all prior, current and future Catalyst Voices voting events."; /// Get the license details for the API fn get_api_license() -> LicenseObject { @@ -86,7 +75,7 @@ pub(crate) fn mk_api() -> OpenApiService<(HealthApi, CardanoApi, ConfigApi, Lega // Add server name if it is set if let Some(name) = Settings::server_name() { - service = service.server(ServerObject::new(name).description("Server at server name")); + service = service.server(ServerObject::new(name).description("Server at server name.")); } let port = Settings::bound_address().port(); @@ -95,7 +84,7 @@ pub(crate) fn mk_api() -> OpenApiService<(HealthApi, CardanoApi, ConfigApi, Lega if let Ok(hostname) = gethostname().into_string() { let hostname_address = format!("http://{hostname}:{port}",); service = service - .server(ServerObject::new(hostname_address).description("Server at localhost name")); + .server(ServerObject::new(hostname_address).description("Server at localhost name.")); } // Get local IP address v4 and v6 @@ -106,13 +95,13 @@ pub(crate) fn mk_api() -> OpenApiService<(HealthApi, CardanoApi, ConfigApi, Lega IpAddr::V4(_) => { ( format!("http://{ip}:{port}"), - "Server at local IPv4 address", + "Server at local IPv4 address.", ) }, IpAddr::V6(_) => { ( format!("http://[{ip}]:{port}"), - "Server at local IPv6 address", + "Server at local IPv6 address.", ) }, }; diff --git a/catalyst-gateway/bin/src/service/common/auth/api_key.rs b/catalyst-gateway/bin/src/service/common/auth/api_key.rs new file mode 100644 index 0000000000..7264e7906a --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/auth/api_key.rs @@ -0,0 +1,31 @@ +//! API Key authorization scheme is used ONLY by internal endpoints. +//! +//! Its purpose is to prevent their use externally, if they were accidentally exposed. +//! +//! It is NOT to be used on any endpoint intended to be publicly facing. + +use poem::Request; +use poem_openapi::{auth::ApiKey, SecurityScheme}; + +use crate::settings::Settings; + +/// `ApiKey` authorization for Internal Endpoints +#[derive(SecurityScheme)] +#[oai( + ty = "api_key", + key_name = "X-API-Key", + key_in = "header", + checker = "api_checker" +)] +#[allow(dead_code)] +pub(crate) struct InternalApiKeyAuthorization(String); + +/// Check the provided API Key matches the API Key defined by for the deployment. +#[allow(clippy::unused_async)] +async fn api_checker(_req: &Request, api_key: ApiKey) -> Option { + if Settings::check_internal_api_key(&api_key.key) { + Some(api_key.key) + } else { + None + } +} diff --git a/catalyst-gateway/bin/src/service/common/auth/mod.rs b/catalyst-gateway/bin/src/service/common/auth/mod.rs new file mode 100644 index 0000000000..a45315bf83 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/auth/mod.rs @@ -0,0 +1,6 @@ +//! Catalyst RBAC Token Authentication + +pub(crate) mod api_key; +pub(crate) mod none; +pub(crate) mod none_or_rbac; +pub(crate) mod rbac; diff --git a/catalyst-gateway/bin/src/service/common/auth/none.rs b/catalyst-gateway/bin/src/service/common/auth/none.rs new file mode 100644 index 0000000000..9e308ad2ef --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/auth/none.rs @@ -0,0 +1,42 @@ +//! None authorization scheme. +//! +//! Means the API Endpoint does not need to use any Auth. + +/// Endpoint can be used without any authorization. +#[allow(dead_code)] +pub(crate) struct NoAuthorization(); + +impl<'a> poem_openapi::ApiExtractor<'a> for NoAuthorization { + type ParamRawType = (); + type ParamType = (); + + const TYPES: &'static [poem_openapi::ApiExtractorType] = + &[poem_openapi::ApiExtractorType::SecurityScheme]; + + fn register(registry: &mut poem_openapi::registry::Registry) { + registry.create_security_scheme( + "NoAuthorization", + poem_openapi::registry::MetaSecurityScheme { + ty: "http", + description: Some("Endpoint can be used without any authorization."), + name: None, + key_in: None, + scheme: None, + bearer_format: None, + flows: None, + openid_connect_url: None, + }, + ); + } + + fn security_schemes() -> Vec<&'static str> { + vec!["NoAuthorization"] + } + + async fn from_request( + _req: &'a poem::Request, _body: &mut poem::RequestBody, + _param_opts: poem_openapi::ExtractParamOptions, + ) -> poem::Result { + Ok(Self()) + } +} diff --git a/catalyst-gateway/bin/src/service/common/auth/none_or_rbac.rs b/catalyst-gateway/bin/src/service/common/auth/none_or_rbac.rs new file mode 100644 index 0000000000..9c62c71d4d --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/auth/none_or_rbac.rs @@ -0,0 +1,15 @@ +//! Either has No Authorization, or RBAC Token. + +use poem_openapi::SecurityScheme; + +use super::{none::NoAuthorization, rbac::scheme::CatalystRBACSecurityScheme}; + +#[derive(SecurityScheme)] +#[allow(dead_code, clippy::upper_case_acronyms)] +/// Endpoint allows Authorization with or without RBAC Token. +pub(crate) enum NoneOrRBAC { + /// Has RBAC Token. + RBAC(CatalystRBACSecurityScheme), + /// Has No Authorization. + None(NoAuthorization), +} diff --git a/catalyst-gateway/bin/src/service/common/auth/rbac/mod.rs b/catalyst-gateway/bin/src/service/common/auth/rbac/mod.rs new file mode 100644 index 0000000000..6a89dcc7d4 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/auth/rbac/mod.rs @@ -0,0 +1,6 @@ +//! Catalyst RBAC Authorization + +/// Catalyst RBAC Security Scheme definition +pub(crate) mod scheme; +/// Catalyst RBAC Token utility functions +pub(crate) mod token; diff --git a/catalyst-gateway/bin/src/service/common/auth/rbac/scheme.rs b/catalyst-gateway/bin/src/service/common/auth/rbac/scheme.rs new file mode 100644 index 0000000000..2e06ddf2cc --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/auth/rbac/scheme.rs @@ -0,0 +1,167 @@ +//! Catalyst RBAC Security Scheme +use std::{error::Error, sync::LazyLock, time::Duration}; + +use dashmap::DashMap; +use ed25519_dalek::{VerifyingKey, PUBLIC_KEY_LENGTH}; +use moka::future::Cache; +use poem::{error::ResponseError, http::StatusCode, IntoResponse, Request}; +use poem_openapi::{auth::Bearer, SecurityScheme}; +use tracing::error; + +use super::token::CatalystRBACTokenV1; +use crate::service::common::responses::ErrorResponses; + +/// Auth token in the form of catv1.. +pub type EncodedAuthToken = String; + +/// Cached auth tokens +static CACHE: LazyLock> = LazyLock::new(|| { + Cache::builder() + // Time to live (TTL): 30 minutes + .time_to_live(Duration::from_secs(30 * 60)) + // Time to idle (TTI): 5 minutes + .time_to_idle(Duration::from_secs(5 * 60)) + // Create the cache. + .build() +}); + +/// Mocked Valid certificates +/// TODO: the following is temporary state for POC until RBAC database is complete. +static CERTS: LazyLock> = LazyLock::new(|| { + /// Mock KID + const KID: &str = "0467de6bd945b9207bfa09d846b77ef5"; + + let public_key_bytes: [u8; PUBLIC_KEY_LENGTH] = [ + 180, 91, 130, 149, 226, 112, 29, 45, 188, 141, 64, 147, 250, 233, 75, 151, 151, 53, 248, + 197, 225, 122, 24, 67, 207, 100, 162, 152, 232, 102, 89, 162, + ]; + + let cert_map = DashMap::new(); + cert_map.insert(KID.to_string(), public_key_bytes); + cert_map +}); + +/// Catalyst RBAC Access Token +#[derive(SecurityScheme)] +#[oai( + ty = "bearer", + bearer_format = "catalyst-rbac-token", + checker = "checker_api_catalyst_auth" +)] +#[allow(clippy::module_name_repetitions)] +#[allow(dead_code)] +pub struct CatalystRBACSecurityScheme(pub CatalystRBACTokenV1); + +/// Error with the Authorization Token +/// +/// We can not parse it, so its a 401 response. +#[derive(Debug, thiserror::Error)] +#[error("Invalid Catalyst RBAC Auth Token")] +pub struct AuthTokenError; + +impl ResponseError for AuthTokenError { + fn status(&self) -> StatusCode { + StatusCode::UNAUTHORIZED + } + + /// Convert this error to a HTTP response. + fn as_response(&self) -> poem::Response + where Self: Error + Send + Sync + 'static { + ErrorResponses::unauthorized().into_response() + } +} + +/// Token does not have required access rights +/// +/// Not enough access rights, so its a 403 response. +#[derive(Debug, thiserror::Error)] +#[error("Insufficient Permission for Catalyst RBAC Token")] +pub struct AuthTokenAccessViolation(Vec); + +impl ResponseError for AuthTokenAccessViolation { + fn status(&self) -> StatusCode { + StatusCode::FORBIDDEN + } + + /// Convert this error to a HTTP response. + fn as_response(&self) -> poem::Response + where Self: Error + Send + Sync + 'static { + // TODO: Actually check permissions needed for an endpoint. + ErrorResponses::forbidden(Some(self.0.clone())).into_response() + } +} + +/// Time in the past the Token can be valid for. +const MAX_TOKEN_AGE: Duration = Duration::from_secs(60 * 60); // 1 hour. + +/// Time in the future the Token can be valid for. +const MAX_TOKEN_SKEW: Duration = Duration::from_secs(5 * 60); // 5 minutes + +/// When added to an endpoint, this hook is called per request to verify the bearer token +/// is valid. +async fn checker_api_catalyst_auth( + _req: &Request, bearer: Bearer, +) -> poem::Result { + // First check the token can be deserialized. + let token = match CatalystRBACTokenV1::decode(&bearer.token) { + Ok(token) => token, + Err(err) => { + // Corrupted Authorisation Token received + error!("Corrupt auth token: {:?}", err); + Err(AuthTokenError)? + }, + }; + + // Check if the token is young enough. + if !token.young(MAX_TOKEN_AGE, MAX_TOKEN_SKEW) { + // Token is too old or too far in the future. + error!("Auth token expired: {:?}", token); + Err(AuthTokenAccessViolation(vec!["EXPIRED".to_string()]))?; + } + + // Its valid and young enough, check if its in the auth cache. + // This get() will extend the entry life for another 5 minutes. + // Even though we keep calling get(), the entry will expire + // after 30 minutes (TTL) from the origin insert(). + // This is an optimization which saves us constantly looking up registrations we have + // already validated. + if let Some(token) = CACHE.get(&bearer.token).await { + return Ok(token); + } + + // Ok, so its validly decoded, but we haven't seen it before. + // Check that the token is able to be authorized. + + // Get pub key from CERTS state given decoded KID from decoded bearer token + // TODO: Look up certs from the Kid based on RBAC Registrations. + let pub_key_bytes = if let Some(cert) = CERTS.get(&hex::encode(token.kid.0)) { + *cert + } else { + error!("Invalid KID {:?}", token.kid); + Err(AuthTokenAccessViolation(vec!["UNREGISTERED".to_string()]))? + }; + + // Verify the token signature using the public key. + let public_key = match VerifyingKey::from_bytes(&pub_key_bytes) { + Ok(pub_key) => pub_key, + Err(err) => { + // In theory this should never happen. + error!("Invalid public key: {:?}", err); + Err(AuthTokenAccessViolation(vec![ + "INVALID PUBLIC KEY".to_string() + ]))? + }, + }; + + if let Err(error) = token.verify(&public_key) { + error!(error=%error, "Token Invalidly Signed"); + Err(AuthTokenAccessViolation(vec![ + "INVALID SIGNATURE".to_string() + ]))?; + } + + // This entry will expire after 5 minutes (TTI) if there is no more (). + CACHE.insert(bearer.token, token.clone()).await; + + Ok(token) +} diff --git a/catalyst-gateway/bin/src/service/common/auth/rbac/token.rs b/catalyst-gateway/bin/src/service/common/auth/rbac/token.rs new file mode 100644 index 0000000000..fce464a27f --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/auth/rbac/token.rs @@ -0,0 +1,238 @@ +//! Catalyst RBAC Token utility functions. +use std::{ + fmt::{Display, Formatter}, + time::{Duration, SystemTime}, +}; + +use anyhow::{bail, Ok}; +use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine}; +use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; +use pallas::codec::minicbor; +use tracing::error; +use ulid::Ulid; + +use crate::utils::blake2b_hash::blake2b_128; + +/// Key ID - Blake2b-128 hash of the Role 0 Certificate defining the Session public key. +/// BLAKE2b-128 produces digest side of 16 bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Kid(pub [u8; 16]); + +impl From<&VerifyingKey> for Kid { + fn from(vk: &VerifyingKey) -> Self { + Self(blake2b_128(vk.as_bytes())) + } +} + +impl PartialEq for Kid { + fn eq(&self, other: &VerifyingKey) -> bool { + self == &Kid::from(other) + } +} + +/// Identifier for this token, encodes both the time the token was issued and a random +/// nonce. +#[derive(Debug, Clone, Copy)] +struct UlidBytes(pub [u8; 16]); + +/// Ed25519 signatures are (64 bytes) +#[derive(Debug, Clone)] +pub struct SignatureEd25519(pub [u8; 64]); + +/// A Catalyst RBAC Authorization Token. +#[derive(Debug, Clone)] +pub(crate) struct CatalystRBACTokenV1 { + /// Token Key Identifier + pub(crate) kid: Kid, + /// Tokens ULID (Time and Random Nonce) + pub(crate) ulid: Ulid, + /// Ed25519 Signature of the Token + pub(crate) sig: SignatureEd25519, + /// Raw bytes of the token + raw: Vec, +} + +impl CatalystRBACTokenV1 { + /// Bearer Token prefix for this token. + const AUTH_TOKEN_PREFIX: &str = "catv1"; + /// The message is a Cbor sequence (cbor(kid) + cbor(ulid)): + /// kid + ulid are 16 bytes a piece, with 1 byte extra due to cbor encoding, + /// The two fields include their encoding resulting in 17 bytes each. + const KID_ULID_CBOR_ENCODED_BYTES: u8 = 34; + + /// The Encoded Binary Auth Token is a [CBOR sequence] that consists of 3 fields [ + /// kid, ulid, signature ]. ED25519 Signature over the preceding two fields - + /// sig(cbor(kid), cbor(ulid)) + #[allow(dead_code, clippy::expect_used)] + pub(crate) fn new(sk: &SigningKey) -> Self { + // Calculate the `kid` from the PublicKey. + let vk: ed25519_dalek::VerifyingKey = sk.verifying_key(); + + // Generate the Kid from the Signing Verify Key + let kid = Kid::from(&vk); + + // Create a enw ulid for this token. + let ulid = Ulid::new(); + + let out: Vec = Vec::new(); + let mut encoder = minicbor::Encoder::new(out); + + // It is safe to use expect here, because the calls are infallible + encoder.bytes(&kid.0).expect("This should never fail."); + encoder + .bytes(&ulid.to_bytes()) + .expect("This should never fail"); + + let sig = SignatureEd25519(sk.sign(encoder.writer()).to_bytes()); + + encoder.bytes(&sig.0).expect("This should never fail"); + + Self { + kid, + ulid, + sig, + raw: encoder.writer().clone(), + } + } + + /// Decode base64 cbor encoded auth token into constituent parts of (kid, ulid, + /// signature) + /// e.g catv1.UAARIjNEVWZ3iJmqu8zd7v9QAZEs7HHPLEwUpV1VhdlNe1hAAAAAAAAAAAAA... + pub(crate) fn decode(auth_token: &str) -> anyhow::Result { + let token = auth_token.split('.').collect::>(); + + let prefix = token.first().ok_or(anyhow::anyhow!("No valid prefix"))?; + if *prefix != Self::AUTH_TOKEN_PREFIX { + return Err(anyhow::anyhow!("Corrupt token, invalid prefix")); + } + let token_base64 = token.get(1).ok_or(anyhow::anyhow!("No valid token"))?; + let token_cbor_encoded = BASE64_URL_SAFE_NO_PAD.decode(token_base64)?; + + // Decode cbor to bytes + let mut cbor_decoder = minicbor::Decoder::new(&token_cbor_encoded); + + // Raw kid bytes + // TODO: Check if the KID is not the right length it gets an error. + let kid = Kid(cbor_decoder + .bytes() + .map_err(|e| anyhow::anyhow!(format!("Invalid cbor for kid : {e}")))? + .try_into()?); + + // TODO: Check what happens if the ULID is NOT 28 bytes long + let ulid_raw: UlidBytes = UlidBytes( + cbor_decoder + .bytes() + .map_err(|e| anyhow::anyhow!(format!("Invalid cbor for ulid : {e}")))? + .try_into()?, + ); + let ulid = Ulid::from_bytes(ulid_raw.0); + + // Raw signature + let signature = SignatureEd25519( + cbor_decoder + .bytes() + .map_err(|e| anyhow::anyhow!(format!("Invalid cbor for signature : {e}")))? + .try_into()?, + ); + + Ok(CatalystRBACTokenV1 { + kid, + ulid, + sig: signature, + raw: token_cbor_encoded, + }) + } + + /// Given the `PublicKey`, verify the token was correctly signed. + pub(crate) fn verify(&self, public_key: &VerifyingKey) -> anyhow::Result<()> { + // Verify the Kid of the Token matches the PublicKey. + if self.kid != *public_key { + error!(token=%self, public_key=?public_key, + "Tokens Kid did not match verifying Public Key", + ); + bail!("Kid does not match PublicKey.") + } + + // We verify the signature on the message which corresponds to a Cbor sequence (cbor(kid) + // + cbor(ulid)): + let message_cbor_encoded = self + .raw + .get(0..Self::KID_ULID_CBOR_ENCODED_BYTES.into()) + .ok_or(anyhow::anyhow!("No valid token"))?; + + if let Err(error) = + public_key.verify_strict(message_cbor_encoded, &Signature::from_bytes(&self.sig.0)) + { + error!(error=%error, token=%self, public_key=?public_key, + "Token was not signed by the expected Public Key", + ); + bail!("Token Not Validated"); + } + + Ok(()) + } + + /// Check if the token is young enough. + /// Old tokens are no longer valid. + pub(crate) fn young(&self, max_age: Duration, max_skew: Duration) -> bool { + // We check that the token is not too old or too skewed. + let now = SystemTime::now(); + let token_age = self.ulid.datetime(); + + // The token is considered old if it was issued more than max_age ago. + // Or newer than an allowed clock skew value + // This is a safety measure to avoid replay attacks. + ((now - max_age) > token_age) && ((now + max_skew) < token_age) + } +} + +impl Display for CatalystRBACTokenV1 { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}.{}", + CatalystRBACTokenV1::AUTH_TOKEN_PREFIX, + BASE64_URL_SAFE_NO_PAD.encode(self.raw.clone()) + ) + } +} + +#[cfg(test)] +mod tests { + + use ed25519_dalek::SigningKey; + use rand::rngs::OsRng; + + use crate::service::common::auth::rbac::token::{CatalystRBACTokenV1, Kid}; + + #[test] + fn test_token_generation_and_decoding() { + let mut random_seed = OsRng; + let signing_key: SigningKey = SigningKey::generate(&mut random_seed); + let verifying_key = signing_key.verifying_key(); + + let signing_key2: SigningKey = SigningKey::generate(&mut random_seed); + let verifying_key2 = signing_key2.verifying_key(); + + // Generate a Kid and then check it verifies properly against itself. + // And doesn't against a different verifying key. + let kid = Kid::from(&verifying_key); + assert!(kid == verifying_key); + assert!(kid != verifying_key2); + + // Create a new Catalyst V1 Token + let token = CatalystRBACTokenV1::new(&signing_key); + // Check its signed properly against its own key, and not another. + assert!(token.verify(&verifying_key).is_ok()); + assert!(token.verify(&verifying_key2).is_err()); + + let decoded_token = format!("{token}"); + + let re_encoded_token = CatalystRBACTokenV1::decode(&decoded_token) + .expect("Failed to decode a token we encoded."); + + // Check its still signed properly against its own key, and not another. + assert!(re_encoded_token.verify(&verifying_key).is_ok()); + assert!(re_encoded_token.verify(&verifying_key2).is_err()); + } +} diff --git a/catalyst-gateway/bin/src/service/common/mod.rs b/catalyst-gateway/bin/src/service/common/mod.rs index e43a6e2602..b7d9cc35b9 100644 --- a/catalyst-gateway/bin/src/service/common/mod.rs +++ b/catalyst-gateway/bin/src/service/common/mod.rs @@ -1,5 +1,8 @@ //! Define common and reusable api components here. //! these components should be structured into their own sub modules. + +pub(crate) mod auth; pub(crate) mod objects; pub(crate) mod responses; pub(crate) mod tags; +pub(crate) mod types; diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/cip36.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/cip36.rs new file mode 100644 index 0000000000..8dc50056f3 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/cip36.rs @@ -0,0 +1,140 @@ +//! CIP36 object + +use poem_openapi::{types::Example, Object}; + +/// List of CIP36 Registration Data as found on-chain. +#[derive(Object, Default)] +#[oai(example = true)] +pub(crate) struct Cip36ReportingList { + /// List of registrations associated with the same Voting Key + #[oai(validator(max_items = "100000"))] + cip36: Vec, +} + +impl Cip36ReportingList { + /// Create a new instance of `Cip36ReportingList`. + pub(crate) fn new() -> Self { + Self { cip36: vec![] } + } + + /// Add a new `Cip36Reporting` to the list. + pub(crate) fn add(&mut self, cip36: Cip36Reporting) { + self.cip36.push(cip36); + } +} + +impl Example for Cip36ReportingList { + fn example() -> Self { + Self { + cip36: vec![Cip36Reporting::example()], + } + } +} + +/// CIP36 info + invalid reporting. +#[derive(Object, Default)] +#[oai(example = true)] +pub(crate) struct Cip36Reporting { + /// List of registrations. + #[oai(validator(max_items = "100000"))] + cip36: Vec, + /// Invalid registration reporting. + #[oai(validator(max_items = "100000"))] + invalids: Vec, +} + +impl Cip36Reporting { + /// Create a new instance of `Cip36Reporting`. + pub(crate) fn new(cip36: Vec, invalids: Vec) -> Self { + Self { cip36, invalids } + } +} + +impl Example for Cip36Reporting { + fn example() -> Self { + Self { + cip36: vec![Cip36Info::example()], + invalids: vec![InvalidRegistrationsReport::example()], + } + } +} + +/// CIP36 Registration Data as found on-chain. +#[derive(Object, Default)] +#[oai(example = true)] +pub(crate) struct Cip36Info { + /// Full Stake Address (not hashed, 32 byte ED25519 Public key). + #[oai(validator(max_length = 66, min_length = 66, pattern = "0x[0-9a-f]{64}"))] + pub stake_address: String, + /// Nonce value after normalization. + #[oai(validator(minimum(value = "0"), maximum(value = "9223372036854775807")))] + pub nonce: u64, + /// Slot Number the cert is in. + #[oai(validator(minimum(value = "0"), maximum(value = "9223372036854775807")))] + pub slot_no: u64, + /// Transaction Index. + #[oai(validator(minimum(value = "0"), maximum(value = "9223372036854775807")))] + pub txn: i16, + /// Voting Public Key + #[oai(validator(max_length = 66, min_length = 66, pattern = "0x[0-9a-f]{64}"))] + pub vote_key: String, + /// Full Payment Address (not hashed, 32 byte ED25519 Public key). + #[oai(validator(max_length = 116, min_length = 66, pattern = "0x[0-9a-f]{64}"))] + pub payment_address: String, + /// Is the stake address a script or not. + pub is_payable: bool, + /// Is the Registration CIP36 format, or CIP15 + pub cip36: bool, +} + +impl Example for Cip36Info { + fn example() -> Self { + Self { + stake_address: "0xad4b948699193634a39dd56f779a2951a24779ad52aa7916f6912b8ec4702cee" + .to_string(), + nonce: 0, + slot_no: 12345, + txn: 0, + vote_key: "0xa6a3c0447aeb9cc54cf6422ba32b294e5e1c3ef6d782f2acff4a70694c4d1663" + .to_string(), + payment_address: "0x00588e8e1d18cba576a4d35758069fe94e53f638b6faf7c07b8abd2bc5c5cdee47b60edc7772855324c85033c638364214cbfc6627889f81c4".to_string(), + is_payable: false, + cip36: true, + } + } +} + +/// Invalid registration error reporting. +#[derive(Object, Default)] +#[oai(example = true)] +pub(crate) struct InvalidRegistrationsReport { + /// Error report + #[oai(validator(max_items = "100000", max_length = "100", pattern = ".*"))] + pub error_report: Vec, + /// Full Stake Address (not hashed, 32 byte ED25519 Public key). + #[oai(validator(max_length = 66, min_length = 66, pattern = "0x[0-9a-f]{64}"))] + pub stake_address: String, + /// Voting Public Key + #[oai(validator(max_length = 66, min_length = 0, pattern = "[0-9a-f]"))] + pub vote_key: String, + /// Full Payment Address (not hashed, 32 byte ED25519 Public key). + #[oai(validator(max_length = 116, min_length = 0, pattern = "[0-9a-f]"))] + pub payment_address: String, + /// Is the stake address a script or not. + pub is_payable: bool, + /// Is the Registration CIP36 format, or CIP15 + pub cip36: bool, +} + +impl Example for InvalidRegistrationsReport { + fn example() -> Self { + Self { + error_report: vec!["Invalid registration".to_string()], + stake_address: "0xad4b948699193634a39dd56f779a2951a24779ad52aa7916f6912b8ec4702cee".to_string(), + vote_key: "0xa6a3c0447aeb9cc54cf6422ba32b294e5e1c3ef6d782f2acff4a70694c4d1663".to_string(), + payment_address: "0x00588e8e1d18cba576a4d35758069fe94e53f638b6faf7c07b8abd2bc5c5cdee47b60edc7772855324c85033c638364214cbfc6627889f81c4".to_string(), + is_payable: false, + cip36: true, + } + } +} 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 d6ca60e4dc..63159b676c 100644 --- a/catalyst-gateway/bin/src/service/common/objects/cardano/mod.rs +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/mod.rs @@ -1,9 +1,9 @@ //! Defines API schemas of Cardano types. +pub(crate) mod cip36; pub(crate) mod hash; pub(crate) mod network; pub(crate) mod registration_info; pub(crate) mod slot_info; -pub(crate) mod stake_address; pub(crate) mod stake_info; pub(crate) mod sync_state; 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 6a855cc799..cc23ffb0f6 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 @@ -8,7 +8,7 @@ use crate::service::{ utilities::to_hex_with_prefix, }; -/// Delegation type +/// The Voting power and voting key of a Delegated voter. #[derive(Object)] struct Delegation { /// Voting key. @@ -16,12 +16,11 @@ struct Delegation { voting_key: String, /// Delegation power assigned to the voting key. - // TODO(bkioshn): https://github.com/input-output-hk/catalyst-voices/issues/239 #[oai(validator(minimum(value = "0"), maximum(value = "9223372036854775807")))] power: i64, } -/// Represents a list of delegations +/// Represents a list of delegations. #[derive(Object)] struct Delegations { /// A list of delegations. @@ -29,7 +28,7 @@ struct Delegations { delegations: Vec, } -/// Direct voter type +/// Voting `Key` for a direct voter (not delegated). #[derive(Object)] struct DirectVoter { /// Voting key. @@ -37,7 +36,7 @@ struct DirectVoter { voting_key: String, } -/// Voting key type +/// The type of the Voting Key. #[derive(Union)] #[oai(discriminator_name = "type", one_of = true)] enum VotingInfo { diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/stake_address.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/stake_address.rs deleted file mode 100644 index e1428c13fa..0000000000 --- a/catalyst-gateway/bin/src/service/common/objects/cardano/stake_address.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! Defines API schemas of Cardano address types. - -use std::ops::Deref; - -use pallas::ledger::addresses::{Address, StakeAddress as StakeAddressPallas}; -use poem_openapi::{ - registry::{MetaSchema, MetaSchemaRef, Registry}, - types::{ParseError, ParseFromParameter, ParseResult, Type}, -}; - -/// Cardano stake address of the user. -/// Should a valid Bech32 encoded stake address followed by the `https://cips.cardano.org/cip/CIP-19/#stake-addresses.` -#[derive(Debug, Clone)] -pub(crate) struct StakeAddress(StakeAddressPallas); - -impl StakeAddress { - /// Creates a `CardanoStakeAddress` schema definition. - fn schema() -> MetaSchema { - let mut schema = MetaSchema::new("string"); - schema.title = Some(Self::name().to_string()); - schema.description = Some("The stake address of the user. Should a valid Bech32 encoded address followed by the https://cips.cardano.org/cip/CIP-19/#stake-addresses."); - schema.example = Some(serde_json::Value::String( - // cspell: disable - "stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw".to_string(), - // cspell: enable - )); - schema.max_length = Some(64); - schema.pattern = Some("(stake|stake_test)1[a,c-h,j-n,p-z,0,2-9]{53}".to_string()); - schema - } -} - -impl Deref for StakeAddress { - type Target = StakeAddressPallas; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl Type for StakeAddress { - type RawElementValueType = Self; - type RawValueType = Self; - - const IS_REQUIRED: bool = true; - - fn name() -> std::borrow::Cow<'static, str> { - "CardanoStakeAddress".into() - } - - fn schema_ref() -> MetaSchemaRef { - MetaSchemaRef::Reference(Self::name().to_string()) - } - - fn register(registry: &mut Registry) { - registry.create_schema::(Self::name().to_string(), |_| Self::schema()); - } - - fn as_raw_value(&self) -> Option<&Self::RawValueType> { - Some(self) - } - - fn raw_element_iter<'a>( - &'a self, - ) -> Box + 'a> { - Box::new(self.as_raw_value().into_iter()) - } -} - -impl ParseFromParameter for StakeAddress { - fn parse_from_parameter(param: &str) -> ParseResult { - // prefix checks - if !param.starts_with("stake") && !param.starts_with("stake_test") { - return Err(ParseError::custom( - "Invalid Cardano stake address. Should start with 'stake' or 'stake_test' prefix.", - )); - } - let address = Address::from_bech32(param).map_err(|e| ParseError::custom(e.to_string()))?; - match address { - Address::Stake(stake_address) => Ok(Self(stake_address)), - Address::Shelley(_) => { - Err(ParseError::custom( - "Invalid Cardano stake address. Provided a Shelley address.", - )) - }, - Address::Byron(_) => { - Err(ParseError::custom( - "Invalid Cardano stake address. Provided a Byron address.", - )) - }, - } - } -} diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/stake_info.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/stake_info.rs index 01343d7fe0..816d485d02 100644 --- a/catalyst-gateway/bin/src/service/common/objects/cardano/stake_info.rs +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/stake_info.rs @@ -8,8 +8,10 @@ use crate::service::api::cardano::types::{SlotNumber, StakeAmount}; #[derive(Object)] pub(crate) struct StakedNativeTokenInfo { /// Token policy hash. + #[oai(validator(max_length = "256", pattern = "^0x[a-f0-9]+$"))] pub(crate) policy_hash: String, /// Token policy name. + #[oai(validator(max_length = "256", pattern = ".*"))] pub(crate) policy_name: String, /// Token amount. #[oai(validator(minimum(value = "0"), maximum(value = "9223372036854775807")))] diff --git a/catalyst-gateway/bin/src/service/common/objects/config/frontend_config.rs b/catalyst-gateway/bin/src/service/common/objects/config/frontend_config.rs new file mode 100644 index 0000000000..b7a449f7ff --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/objects/config/frontend_config.rs @@ -0,0 +1,44 @@ +//! Frontend configuration objects. + +use poem_openapi::{types::Example, Object}; + +/// Frontend JSON schema. +#[derive(Object, Default, serde::Deserialize)] +#[oai(example = true)] +pub(crate) struct FrontendConfig { + /// Sentry properties. + sentry: Option, +} + +impl Example for FrontendConfig { + fn example() -> Self { + FrontendConfig { + sentry: Some(Sentry::example()), + } + } +} + +/// Frontend configuration for Sentry. +#[derive(Object, Default, serde::Deserialize)] +#[oai(example = true)] +pub(crate) struct Sentry { + /// The Data Source Name (DSN) for Sentry. + #[oai(validator(max_length = "100", pattern = "^https?://"))] + dsn: String, + /// A version of the code deployed to an environment. + #[oai(validator(max_length = "100", pattern = "^[0-9a-zA-Z].*$"))] + release: Option, + /// The environment in which the application is running, e.g., 'dev', 'qa'. + #[oai(validator(max_length = "100", pattern = "^[0-9a-zA-Z].*$"))] + environment: Option, +} + +impl Example for Sentry { + fn example() -> Self { + Sentry { + dsn: "https://example.com".to_string(), + release: Some("1.0.0".to_string()), + environment: Some("dev".to_string()), + } + } +} diff --git a/catalyst-gateway/bin/src/service/common/objects/config/mod.rs b/catalyst-gateway/bin/src/service/common/objects/config/mod.rs new file mode 100644 index 0000000000..136883aa02 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/objects/config/mod.rs @@ -0,0 +1,36 @@ +//! Config object definitions. + +pub(crate) mod frontend_config; + +use poem_openapi::{types::Example, Object}; + +/// Configuration Data Validation Error. +#[derive(Object, Default)] +#[oai(example = true)] +pub(crate) struct ConfigBadRequest { + /// Error messages. + #[oai(validator(max_length = "100", pattern = "^[0-9a-zA-Z].*$"))] + error: String, + /// Optional schema validation errors. + #[oai(validator(max_items = "1000", max_length = "9999", pattern = "^[0-9a-zA-Z].*$"))] + schema_validation_errors: Option>, +} + +impl ConfigBadRequest { + /// Create a new instance of `ConfigBadRequest`. + pub(crate) fn new(error: String, schema_validation_errors: Option>) -> Self { + Self { + error, + schema_validation_errors, + } + } +} + +impl Example for ConfigBadRequest { + fn example() -> Self { + ConfigBadRequest { + error: "Invalid Data".to_string(), + schema_validation_errors: Some(vec!["Error message".to_string()]), + } + } +} diff --git a/catalyst-gateway/bin/src/service/common/objects/legacy/delegate_public_key.rs b/catalyst-gateway/bin/src/service/common/objects/legacy/delegate_public_key.rs index ad4bce2708..6861038ddd 100644 --- a/catalyst-gateway/bin/src/service/common/objects/legacy/delegate_public_key.rs +++ b/catalyst-gateway/bin/src/service/common/objects/legacy/delegate_public_key.rs @@ -3,10 +3,10 @@ use poem_openapi::{types::Example, Object}; #[derive(Object)] #[oai(example = true)] -/// Delegate Public Key +/// Delegated Voting Public Key. pub(crate) struct DelegatePublicKey { #[oai(validator(pattern = "0x[0-9a-f]{64}", min_length = "66", max_length = "66"))] - /// Delegate Public Key in hex format + /// Delegated Voting Public Key in hex format. address: String, } diff --git a/catalyst-gateway/bin/src/service/common/objects/legacy/event_id.rs b/catalyst-gateway/bin/src/service/common/objects/legacy/event_id.rs index ba393ac3fa..e9f4d0141b 100644 --- a/catalyst-gateway/bin/src/service/common/objects/legacy/event_id.rs +++ b/catalyst-gateway/bin/src/service/common/objects/legacy/event_id.rs @@ -2,7 +2,7 @@ use poem_openapi::{types::Example, NewType}; use serde::Deserialize; -/// The Numeric ID of a Voting Event +/// The Numeric Index of a Voting Event #[derive(NewType, Deserialize)] #[oai(example = true)] pub(crate) struct EventId(pub(crate) i32); diff --git a/catalyst-gateway/bin/src/service/common/objects/legacy/fragments_processing_summary.rs b/catalyst-gateway/bin/src/service/common/objects/legacy/fragments_processing_summary.rs index 2bac1f4c17..66a59e38c3 100644 --- a/catalyst-gateway/bin/src/service/common/objects/legacy/fragments_processing_summary.rs +++ b/catalyst-gateway/bin/src/service/common/objects/legacy/fragments_processing_summary.rs @@ -15,6 +15,7 @@ impl Example for FragmentId { Self("0x7db6f91f3c92c0aef7b3dd497e9ea275229d2ab4dba6a1b30ce6b32db9c9c3b2".into()) } } + #[derive(Enum)] /// The reason for which a fragment was rejected. pub(crate) enum ReasonRejected { diff --git a/catalyst-gateway/bin/src/service/common/objects/legacy/vote_plan.rs b/catalyst-gateway/bin/src/service/common/objects/legacy/vote_plan.rs index a5428f0746..4bb4ac331b 100644 --- a/catalyst-gateway/bin/src/service/common/objects/legacy/vote_plan.rs +++ b/catalyst-gateway/bin/src/service/common/objects/legacy/vote_plan.rs @@ -2,7 +2,7 @@ use poem_openapi::{types::Example, Object}; -/// Voting Plan +/// Voting Plan Information. #[derive(Object)] #[oai(example = true)] pub(crate) struct VotePlan { diff --git a/catalyst-gateway/bin/src/service/common/objects/legacy/voter_group_id.rs b/catalyst-gateway/bin/src/service/common/objects/legacy/voter_group_id.rs index 1a7d1a4b90..6bace34a78 100644 --- a/catalyst-gateway/bin/src/service/common/objects/legacy/voter_group_id.rs +++ b/catalyst-gateway/bin/src/service/common/objects/legacy/voter_group_id.rs @@ -1,7 +1,7 @@ //! Defines the allowable groups for a Voter use poem_openapi::{types::Example, Enum}; -/// Voter Group ID. +/// The kind of voter group foes the voter belong. #[derive(Enum)] pub(crate) enum VoterGroupId { /// Delegated Representative. diff --git a/catalyst-gateway/bin/src/service/common/objects/legacy/voter_info.rs b/catalyst-gateway/bin/src/service/common/objects/legacy/voter_info.rs index ff6afdc24a..1cea0ae330 100644 --- a/catalyst-gateway/bin/src/service/common/objects/legacy/voter_info.rs +++ b/catalyst-gateway/bin/src/service/common/objects/legacy/voter_info.rs @@ -3,7 +3,7 @@ use poem_openapi::{types::Example, Object}; use super::{delegate_public_key::DelegatePublicKey, voter_group_id::VoterGroupId}; -/// Voter Info +/// An individual voters information. #[derive(Object)] #[oai(example = true)] pub(crate) struct VoterInfo { diff --git a/catalyst-gateway/bin/src/service/common/objects/mod.rs b/catalyst-gateway/bin/src/service/common/objects/mod.rs index 8a52583b35..68c270166c 100644 --- a/catalyst-gateway/bin/src/service/common/objects/mod.rs +++ b/catalyst-gateway/bin/src/service/common/objects/mod.rs @@ -1,6 +1,5 @@ //! This module contains common and re-usable objects. pub(crate) mod cardano; +pub(crate) mod config; pub(crate) mod legacy; -pub(crate) mod server_error; -pub(crate) mod validation_error; diff --git a/catalyst-gateway/bin/src/service/common/objects/validation_error.rs b/catalyst-gateway/bin/src/service/common/objects/validation_error.rs deleted file mode 100644 index 9d6932f0c5..0000000000 --- a/catalyst-gateway/bin/src/service/common/objects/validation_error.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! Define `ValidationError` type. - -use poem_openapi::Object; - -/// Common error message type. -/// It has failed to pass validation, as specified by the `OpenAPI` schema. -#[derive(Object)] -pub(crate) struct ValidationError { - /// Error message - #[oai(validator(max_length = "1000"))] - message: String, -} - -impl ValidationError { - /// Create a new `ValidationError` - pub(crate) fn new(message: String) -> Self { - Self { message } - } -} 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 new file mode 100644 index 0000000000..962db861df --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/responses/code_401_unauthorized.rs @@ -0,0 +1,35 @@ +//! Define `Unauthorized` response type. + +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. +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, +} + +impl Unauthorized { + /// Create a new Server Error Response 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 } + } +} + +impl Example for Unauthorized { + /// Example for the Too Many Requests Payload. + 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 new file mode 100644 index 0000000000..b087e2350a --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/responses/code_403_forbidden.rs @@ -0,0 +1,48 @@ +//! Define `Forbidden` response type. + +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. +pub(crate) struct Forbidden { + /// 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, + /// List or Roles required to access the resource. + // TODO: This should be a Vector of defined Roles/Grants. + // When those are defined, use that type instead of "String" + // It should look like an enum. + #[oai(validator(max_items = 100, max_length = "100", pattern = "^[0-9a-zA-Z].*$"))] + required: Option>, +} + +impl Forbidden { + /// Create a new Server Error Response Payload. + pub(crate) fn new(msg: Option, roles: Option>) -> Self { + let msg = msg.unwrap_or( + "Your request was not successful because your authentication credentials do not have the required roles for the requested resource.".to_string(), + ); + let id = Uuid::new_v4(); + + Self { + id, + msg, + required: roles, + } + } +} + +impl Example for Forbidden { + /// Example for the Too Many Requests Payload. + fn example() -> Self { + Self::new( + None, + Some(vec!["VOTER".to_string(), "PROPOSER".to_string()]), + ) + } +} 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 new file mode 100644 index 0000000000..b37eb9cc9b --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/responses/code_422_unprocessable_content.rs @@ -0,0 +1,78 @@ +//! Define `Unprocessable Content` response type. + +use poem_openapi::{types::Example, Object}; + +#[derive(Debug, Object)] +#[oai(example, skip_serializing_if_is_none)] +/// 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>, + /// The error message. + #[oai(validator(max_length = "1000", pattern = "^[0-9a-zA-Z].*$"))] + msg: Option, + /// The type of error + #[oai( + rename = "type", + validator(max_length = "1000", pattern = "^[0-9a-zA-Z].*$") + )] + 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()), + } + } +} + +impl ContentErrorDetail { + /// Create a new `ContentErrorDetail` Response Payload. + pub(crate) fn new(error: &poem::Error) -> Self { + // TODO: See if we can get more info from the error than this. + Self { + loc: None, + msg: Some(error.to_string()), + 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 new file mode 100644 index 0000000000..195f40a09d --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/responses/code_429_too_many_requests.rs @@ -0,0 +1,35 @@ +//! Define `TooManyRequests` response type. + +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. +pub(crate) struct TooManyRequests { + /// 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 = "100", pattern = "^[0-9a-zA-Z].*$"))] + msg: String, +} + +impl TooManyRequests { + /// Create a new Server Error Response Payload. + pub(crate) fn new(msg: Option) -> Self { + let msg = msg.unwrap_or( + "Too Many Requests. You have exceeded the rate limit for this endpoint.".to_string(), + ); + let id = Uuid::new_v4(); + + Self { id, msg } + } +} + +impl Example for TooManyRequests { + /// Example for the Too Many Requests Payload. + fn example() -> Self { + Self::new(None) + } +} diff --git a/catalyst-gateway/bin/src/service/common/objects/server_error.rs b/catalyst-gateway/bin/src/service/common/responses/code_500_internal_server_error.rs similarity index 75% rename from catalyst-gateway/bin/src/service/common/objects/server_error.rs rename to catalyst-gateway/bin/src/service/common/responses/code_500_internal_server_error.rs index b8499ea30c..d3ad5189c4 100644 --- a/catalyst-gateway/bin/src/service/common/objects/server_error.rs +++ b/catalyst-gateway/bin/src/service/common/responses/code_500_internal_server_error.rs @@ -11,21 +11,19 @@ use crate::settings::Settings; #[derive(Debug, Object)] #[oai(example, skip_serializing_if_is_none)] /// Server Error response to a Bad request. -pub(crate) struct ServerError { +pub(crate) struct InternalServerError { /// Unique ID of this Server Error so that it can be located easily for debugging. id: Uuid, - /// *Optional* SHORT Error message. - /// Will not contain sensitive information, internal details or backtraces. - // TODO(bkioshn): https://github.com/input-output-hk/catalyst-voices/issues/239 + /// Error message. + // Will not contain sensitive information, internal details or backtraces. #[oai(validator(max_length = "100", pattern = "^[0-9a-zA-Z].*$"))] msg: String, /// A URL to report an issue. - // TODO(bkioshn): https://github.com/input-output-hk/catalyst-voices/issues/239 #[oai(validator(max_length = "1000"))] issue: Option, } -impl ServerError { +impl InternalServerError { /// Create a new Server Error Response Payload. pub(crate) fn new(msg: Option) -> Self { let msg = msg.unwrap_or( @@ -44,9 +42,9 @@ impl ServerError { } } -impl Example for ServerError { +impl Example for InternalServerError { /// Example for the Server Error Payload. fn example() -> Self { - Self::new(Some("Server Error".to_string())) + Self::new(None) } } 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 new file mode 100644 index 0000000000..9c9451b37c --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/responses/code_503_service_unavailable.rs @@ -0,0 +1,36 @@ +//! Define `Service Unavailable` Response Body. + +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. +pub(crate) struct ServiceUnavailable { + /// 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 = "100", pattern = "^[0-9a-zA-Z].*$"))] + msg: String, +} + +impl ServiceUnavailable { + /// Create a new Server Error Response Payload. + pub(crate) fn new(msg: Option) -> Self { + let msg = msg.unwrap_or( + "Service Unavailable. Indicates that the server is not ready to handle the request." + .to_string(), + ); + let id = Uuid::new_v4(); + + Self { id, msg } + } +} + +impl Example for ServiceUnavailable { + /// Example for the Service Unavailable Payload. + fn example() -> Self { + Self::new(None) + } +} diff --git a/catalyst-gateway/bin/src/service/common/responses/mod.rs b/catalyst-gateway/bin/src/service/common/responses/mod.rs index 21b8ef7223..e0f8ef2f1a 100644 --- a/catalyst-gateway/bin/src/service/common/responses/mod.rs +++ b/catalyst-gateway/bin/src/service/common/responses/mod.rs @@ -5,32 +5,73 @@ use std::{ hash::{Hash, Hasher}, }; +use code_401_unauthorized::Unauthorized; +use code_403_forbidden::Forbidden; +use code_422_unprocessable_content::UnprocessableContent; +use code_429_too_many_requests::TooManyRequests; +use code_503_service_unavailable::ServiceUnavailable; use poem::IntoResponse; use poem_openapi::{ payload::Json, - registry::{MetaResponse, MetaResponses, Registry}, + registry::{MetaHeader, MetaResponse, MetaResponses, Registry}, ApiResponse, }; +use tracing::error; -use super::objects::{server_error::ServerError, validation_error::ValidationError}; -use crate::service::utilities::NetworkValidationError; +mod code_401_unauthorized; +mod code_403_forbidden; +mod code_422_unprocessable_content; +mod code_429_too_many_requests; +pub(crate) mod code_500_internal_server_error; +mod code_503_service_unavailable; + +use code_500_internal_server_error::InternalServerError; + +use super::types::headers::{ + access_control_allow_origin::AccessControlAllowOriginHeader, ratelimit::RateLimitHeader, + retry_after::RetryAfterHeader, +}; /// Default error responses #[derive(ApiResponse)] pub(crate) enum ErrorResponses { - /// ## Content validation error. + /// ## Unauthorized + /// + /// The client has not sent valid authentication credentials for the requested + /// resource. + #[oai(status = 401)] + Unauthorized(Json), + + /// ## Forbidden + /// + /// The client has not sent valid authentication credentials for the requested + /// resource. + #[oai(status = 403)] + Forbidden(Json), + + /// ## Unprocessable Content + /// + /// The client has not sent valid data in its request, headers, parameters or body. + #[oai(status = 422)] + UnprocessableContent(Json), + + /// ## Too Many Requests /// - /// This error means that the request was malformed. - /// It has failed to pass validation, as specified by the `OpenAPI` schema. - #[oai(status = 400)] - BadRequest(Json), + /// The client has sent too many requests in a given amount of time. + #[oai(status = 429)] + TooManyRequests( + Json, + #[oai(header = "Retry-After")] RetryAfterHeader, + ), + /// ## Internal Server Error. /// /// An internal server error occurred. /// /// *The contents of this response should be reported to the projects issue tracker.* #[oai(status = 500)] - ServerError(Json), + ServerError(Json), + /// ## Service Unavailable /// /// The service is not available, do not send other requests. @@ -38,7 +79,32 @@ pub(crate) enum ErrorResponses { /// *This is returned when the service either has not started, /// or has become unavailable.* #[oai(status = 503)] - ServiceUnavailable, + ServiceUnavailable( + Json, + #[oai(header = "Retry-After")] Option, + ), +} + +impl ErrorResponses { + /// Handle a 401 unauthorized response. + /// + /// Returns a 401 Unauthorized response. + /// Its OK if we actually never call this. Required for the API. + /// May be generated by the ingress. + pub(crate) fn unauthorized() -> Self { + let error = Unauthorized::new(None); + ErrorResponses::Unauthorized(Json(error)) + } + + /// Handle a 403 forbidden response. + /// + /// Returns a 403 Forbidden response. + /// Its OK if we actually never call this. Required for the API. + /// May be generated by the ingress. + pub(crate) fn forbidden(roles: Option>) -> Self { + let error = Forbidden::new(None, roles); + ErrorResponses::Forbidden(Json(error)) + } } /// Combine provided responses type with the default responses under one type. @@ -50,26 +116,64 @@ pub(crate) enum WithErrorResponses { } impl WithErrorResponses { - /// Handle a 5xx or 4xx response. - /// Returns a Server Error, a Bad Request or a Service Unavailable response. + /// Handle a 5xx response. + /// Returns a Server Error or a Service Unavailable response. pub(crate) fn handle_error(err: &anyhow::Error) -> Self { match err { - err if err.is::() => { - WithErrorResponses::Error(ErrorResponses::BadRequest(Json(ValidationError::new( - err.to_string(), - )))) - }, err if err.is::>() => { - WithErrorResponses::Error(ErrorResponses::ServiceUnavailable) + let error = ServiceUnavailable::new(None); + WithErrorResponses::Error(ErrorResponses::ServiceUnavailable( + Json(error), + Some(RetryAfterHeader::default()), + )) }, err => { - let error = crate::service::common::objects::server_error::ServerError::new(None); - let id = error.id(); - tracing::error!(id = format!("{id}"), "{}", err); + let error = InternalServerError::new(None); + error!(id=%error.id(), error=?err); WithErrorResponses::Error(ErrorResponses::ServerError(Json(error))) }, } } + + /// Handle a 401 unauthorized response. + /// + /// Returns a 401 Unauthorized response. + /// Its OK if we actually never call this. Required for the API. + /// May be generated by the ingress. + #[allow(dead_code)] + pub(crate) fn unauthorized() -> Self { + WithErrorResponses::Error(ErrorResponses::unauthorized()) + } + + /// Handle a 403 forbidden response. + /// + /// Returns a 403 Forbidden response. + /// Its OK if we actually never call this. Required for the API. + /// May be generated by the ingress. + #[allow(dead_code)] + pub(crate) fn forbidden(roles: Option>) -> Self { + WithErrorResponses::Error(ErrorResponses::forbidden(roles)) + } + + /// Handle a 422 unprocessable content response. + /// + /// Returns a 422 unprocessable content response. + pub(crate) fn unprocessable_content(errors: Vec) -> Self { + let error = UnprocessableContent::new(errors); + WithErrorResponses::Error(ErrorResponses::UnprocessableContent(Json(error))) + } + + /// Handle a 429 rate limiting response. + /// + /// Returns a 429 Rate limit response. + /// Its OK if we actually never call this. Required for the API. + /// May be generated by the ingress. + #[allow(dead_code)] + pub(crate) fn rate_limit(retry_after: Option) -> Self { + let retry_after = retry_after.unwrap_or_default(); + let error = TooManyRequests::new(None); + WithErrorResponses::Error(ErrorResponses::TooManyRequests(Json(error), retry_after)) + } } impl From for WithErrorResponses { @@ -99,7 +203,43 @@ impl ApiResponse for WithErrorResponses { .map(FilteredByStatusCodeResponse), ); - let responses = responses.into_iter().map(|val| val.0).collect(); + let responses = + responses + .into_iter() + .map(|val| { + let mut response = val.0; + // Make modifications to the responses to set common headers + if let Some(status) = response.status { + // Only 2xx and 4xx responses get RateLimit Headers. + if (200..300).contains(&status) || (400..500).contains(&status) { + response.headers.insert(0usize, MetaHeader { + name: "RateLimit".to_string(), + description: Some("RateLimit Header.".to_string()), + required: false, + deprecated: false, + schema: ::schema_ref(), + }); + } + + // All responses get Access-Control-Allow-Origin headers + response.headers.insert(0usize, MetaHeader { + name: "Access-Control-Allow-Origin".to_string(), + description: Some("Access-Control-Allow-Origin Header.".to_string()), + required: false, + deprecated: false, + schema: ::schema_ref(), + }); + } + response + }) + .collect(); + + // Add Rate limiting headers to ALL 2xx and 4xx responses + // for response in responses.iter_mut() { + // response. + // debug!(response = response); + //} + MetaResponses { responses } } @@ -108,10 +248,8 @@ impl ApiResponse for WithErrorResponses { T::register(registry); } - fn from_parse_request_error(err: poem::Error) -> Self { - Self::Error(ErrorResponses::BadRequest(Json(ValidationError::new( - err.to_string(), - )))) + fn from_parse_request_error(err: poem_openapi::__private::poem::Error) -> Self { + WithErrorResponses::unprocessable_content(vec![err]) } } diff --git a/catalyst-gateway/bin/src/service/common/tags.rs b/catalyst-gateway/bin/src/service/common/tags.rs index c83e040340..4b34ed1642 100644 --- a/catalyst-gateway/bin/src/service/common/tags.rs +++ b/catalyst-gateway/bin/src/service/common/tags.rs @@ -4,19 +4,14 @@ use poem_openapi::Tags; /// `OpenAPI` Tags #[derive(Tags)] pub(crate) enum ApiTags { - /// Fragment endpoints - Fragments, - /// Health Endpoints + /// Service Health and Readiness. Health, - /// Cardano Endpoints + /// General Cardano Blockchain Information. Cardano, - /// Information relating to Voter Registration, Delegations and Calculated Voting - /// Power. + /// Registration and Role Based Access Control (RBAC) Operations. Registration, - /// API Version 0 Endpoints - V0, - /// API Version 1 Endpoints - V1, - /// Config + /// Service Configuration and Status. Config, + /// Legacy Mobile App Support. + Legacy, } diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/address.rs b/catalyst-gateway/bin/src/service/common/types/cardano/address.rs new file mode 100644 index 0000000000..e96fadcbb1 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/address.rs @@ -0,0 +1,82 @@ +//! 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 pallas::ledger::addresses::{Address, StakeAddress}; +use poem_openapi::{ + registry::{MetaExternalDocument, MetaSchema, MetaSchemaRef}, + types::{ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use serde_json::Value; + +use crate::service::common::types::string_types::impl_string_types; + +/// Stake address title. +const STAKE_TITLE: &str = "Cardano stake address"; +/// Stake address description. +const STAKE_DESCRIPTION: &str = "Cardano stake address, also known as a reward address."; +/// Stake address example. +// cSpell:disable +const STAKE_EXAMPLE: &str = "stake_test1uqehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gssrtvn"; +// cSpell:enable + +/// 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 for `StakeAddress`. +static STAKE_SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(STAKE_TITLE.to_owned()), + description: Some(STAKE_DESCRIPTION), + example: Some(Value::String(STAKE_EXAMPLE.to_string())), + external_docs: Some(EXTERNAL_DOCS.clone()), + max_length: Some(64), + pattern: Some("(stake|stake_test)1[a,c-h,j-n,p-z,0,2-9]{53}".to_string()), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +impl_string_types!( + Cip19StakeAddress, + "string", + "cardano:cip19-address", + Some(STAKE_SCHEMA.clone()) +); + +impl Cip19StakeAddress { + /// Create a new `StakeAddress`. + #[allow(dead_code)] + pub fn new(address: String) -> Self { + Cip19StakeAddress(address) + } + + /// 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")), + } + } + + /// Convert a `StakeAddress` to a `StakeAddress` string. + #[allow(dead_code)] + pub fn from_stake_address(addr: &StakeAddress) -> anyhow::Result { + let addr_str = addr + .to_bech32() + .map_err(|e| anyhow::anyhow!(format!("Invalid stake address {e}")))?; + Ok(Self(addr_str)) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/mod.rs b/catalyst-gateway/bin/src/service/common/types/cardano/mod.rs new file mode 100644 index 0000000000..966baa783d --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/mod.rs @@ -0,0 +1,3 @@ +//! Cardano Types + +pub(crate) mod address; diff --git a/catalyst-gateway/bin/src/service/common/types/headers/access_control_allow_origin.rs b/catalyst-gateway/bin/src/service/common/types/headers/access_control_allow_origin.rs new file mode 100644 index 0000000000..4848947973 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/headers/access_control_allow_origin.rs @@ -0,0 +1,60 @@ +//! Access-Control-Allow-Origin Header type. +//! +//! This is a passive type, produced automatically by the CORS middleware. + +use std::{ + borrow::Cow, + ops::{Deref, DerefMut}, + sync::LazyLock, +}; + +use poem_openapi::{ + registry::{MetaExternalDocument, MetaSchema, MetaSchemaRef}, + types::{ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use serde_json::Value; + +use crate::service::common::types::string_types::impl_string_types; + +/// Tite for the header in documentation. +const TITLE: &str = "Access-Control-Allow-Origin header."; + +/// Description for the header in documentation. +const DESCRIPTION: &str = "Valid formats: + +* `Access-Control-Allow-Origin: *` +* `Access-Control-Allow-Origin: ` +* `Access-Control-Allow-Origin: null` +"; + +/// Example for the header in documentation. +const EXAMPLE: &str = "*"; + +/// External documentation for the header +static EXTERNAL_DOCS: LazyLock = LazyLock::new(|| { + MetaExternalDocument { + url: + "https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin" + .to_owned(), + description: Some("MDB Web Docs - Access-Control-Allow-Origin".to_owned()), + } +}); + +/// `OpenAPI` schema for the header in documentation. +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()), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +// Access-Control-Allow-Origin Header String Type +impl_string_types!( + AccessControlAllowOriginHeader, + "string", + "origin", + Some(SCHEMA.clone()) +); diff --git a/catalyst-gateway/bin/src/service/common/types/headers/mod.rs b/catalyst-gateway/bin/src/service/common/types/headers/mod.rs new file mode 100644 index 0000000000..8933274e9b --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/headers/mod.rs @@ -0,0 +1,19 @@ +//! Header Types +//! +//! These are types used to define the values of the contents of header fields. +//! +//! There are two kinds of header fields: +//! +//! ## Passive header fields +//! +//! These headers are not created or updated by the responder, or are only read in a +//! request. They could be produced by middleware. +//! +//! ## Active header fields +//! +//! These are produced as part of a response, and it's the responsibility of the responder +//! to set them. + +pub(crate) mod access_control_allow_origin; +pub(crate) mod ratelimit; +pub(crate) mod retry_after; diff --git a/catalyst-gateway/bin/src/service/common/types/headers/ratelimit.rs b/catalyst-gateway/bin/src/service/common/types/headers/ratelimit.rs new file mode 100644 index 0000000000..33c37e3a55 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/headers/ratelimit.rs @@ -0,0 +1,54 @@ +//! `RateLimit` Header type. +//! +//! This is a passive type, produced automatically by the CORS middleware. + +use std::{ + borrow::Cow, + ops::{Deref, DerefMut}, + sync::LazyLock, +}; + +use poem_openapi::{ + registry::{MetaExternalDocument, MetaSchema, MetaSchemaRef}, + types::{ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use serde_json::Value; + +use crate::service::common::types::string_types::impl_string_types; + +/// Tite for the header in documentation. +const TITLE: &str = "RateLimit HTTP header."; + +/// Description for the header in documentation. +const DESCRIPTION: &str = "Allows this server to advertise its quota policies and the current +service limits, thereby allowing clients to avoid being throttled."; + +/// Example for the header in documentation. +const EXAMPLE: &str = r#""default";q=100;w=10"#; + +/// External documentation for the header +static EXTERNAL_DOCS: LazyLock = LazyLock::new(|| { + MetaExternalDocument { + url: "https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/".to_owned(), + description: Some("IETF Draft - RateLimit header fields for HTTP".to_owned()), + } +}); + +/// `OpenAPI` schema for the header in documentation. +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()), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +// Access-Control-Allow-Origin Header String Type +impl_string_types!( + RateLimitHeader, + "string", + "rate-limit", + Some(SCHEMA.clone()) +); diff --git a/catalyst-gateway/bin/src/service/common/types/headers/retry_after.rs b/catalyst-gateway/bin/src/service/common/types/headers/retry_after.rs new file mode 100644 index 0000000000..0d3a63ebf7 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/headers/retry_after.rs @@ -0,0 +1,88 @@ +//! Retry After header type +//! +//! This is an active header which expects to be provided in a response. + +use std::{borrow::Cow, fmt::Display}; + +use chrono::{DateTime, Utc}; +use poem::http::HeaderValue; +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{ToHeader, Type}, +}; +use serde_json::Value; + +/// Parameter which describes the possible choices for a Retry-After header field. +#[allow(dead_code)] // Its OK if all these variants are not used. +pub(crate) enum RetryAfterHeader { + /// Http Date + Date(DateTime), + /// Interval in seconds. + Seconds(u64), +} + +impl Display for RetryAfterHeader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RetryAfterHeader::Date(date_time) => { + let http_date = date_time.format("%a, %d %b %Y %T GMT").to_string(); + write!(f, "{http_date}") + }, + RetryAfterHeader::Seconds(secs) => write!(f, "{secs}"), + } + } +} + +impl Default for RetryAfterHeader { + fn default() -> Self { + Self::Seconds(300) + } +} + +impl Type for RetryAfterHeader { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> Cow<'static, str> { + "string(http-date || integer)".into() + } + + fn schema_ref() -> MetaSchemaRef { + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format( + "string", + "http-date || integer", + ))) + .merge(MetaSchema { + title: Some("Retry-After Header".to_owned()), + description: Some( + "Http Date or Interval in seconds. +Valid formats: + +* `Retry-After: ` +* `Retry-After: ` + +See: ", + ), + example: Some(Value::String("300".to_string())), + ..poem_openapi::registry::MetaSchema::ANY + }) + } + + 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 ToHeader for RetryAfterHeader { + fn to_header(&self) -> Option { + HeaderValue::from_str(&self.to_string()).ok() + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/mod.rs b/catalyst-gateway/bin/src/service/common/types/mod.rs new file mode 100644 index 0000000000..8e3a05061c --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/mod.rs @@ -0,0 +1,13 @@ +//! Common types +//! +//! These should be simple types, not objects. +//! For example, types derived from strings or integers and vectors of simple types only. +//! +//! Objects are objects, and not types. +//! +//! Simple types can be enums, if the intended underlying type is simple, such as a string +//! or integer. + +pub(crate) mod cardano; +pub(crate) mod headers; +pub(crate) mod string_types; diff --git a/catalyst-gateway/bin/src/service/common/types/string_types.rs b/catalyst-gateway/bin/src/service/common/types/string_types.rs new file mode 100644 index 0000000000..1503cc4591 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/string_types.rs @@ -0,0 +1,132 @@ +//! Simple string types. +//! +//! This code comes from Poem, but it is not exported by Poem, so replicated here. +//! +//! Original Source: + +/// Macro to make creating validated and documented string types much easier. +/// +/// ## Parameters +/// +/// * `$ty` - The Type name to create. Example `MyNewType`. +/// * `$type_name` - The `OpenAPI` name for the type. Almost always going to be `string`. +/// * `$format` - The `OpenAPI` format for the type. Where possible use a defined +/// `OpenAPI` or `JsonSchema` format. +/// * `$schema` - A Poem `MetaSchema` which defines all the schema parameters for the +/// type. +/// * `$validation` - *OPTIONAL* Validation function to apply to the string value. +/// +/// +/// ## Example +/// +/// ```ignore +/// impl_string_types!(MyNewType, "string", "date", MyNewTypeSchema, SomeValidationFunction); +/// ``` +/// +/// Is the equivalent of: +/// +/// ```ignore +/// #[derive(Debug, Clone, Eq, PartialEq, Hash)] +/// pub(crate) struct MyNewType(pub String); +/// +/// impl for MyNewType { ... } +/// ``` +macro_rules! impl_string_types { + ($(#[$docs:meta])* $ty:ident, $type_name:literal, $format:literal, $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])* + #[derive(Debug, Clone, Eq, PartialEq, Hash)] + pub(crate) struct $ty(pub String); + + impl Deref for $ty { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + impl DerefMut for $ty { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } + } + + impl AsRef for $ty { + fn as_ref(&self) -> &str { + &self.0 + } + } + + impl Type for $ty { + const IS_REQUIRED: bool = true; + + type RawValueType = Self; + + type RawElementValueType = Self; + + fn name() -> Cow<'static, str> { + concat!($type_name, "(", $format, ")").into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format($type_name, $format))); + if let Some(schema) = $schema { + schema_ref.merge(schema) + } else { + schema_ref + } + } + + 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()) + } + + #[inline] + fn is_empty(&self) -> bool { + self.0.is_empty() + } + } + + impl ParseFromJSON for $ty { + fn parse_from_json(value: Option) -> ParseResult { + let value = value.unwrap_or_default(); + if let Value::String(value) = value { + let validator = $validator; + if !validator(&value) { + return Err(concat!("invalid ", $format).into()); + } + Ok(Self(value)) + } else { + Err(ParseError::expected_type(value)) + } + } + } + + impl ParseFromParameter for $ty { + fn parse_from_parameter(value: &str) -> ParseResult { + let validator = $validator; + if !validator(value) { + return Err(concat!("invalid ", $format).into()); + } + Ok(Self(value.to_string())) + } + } + + impl ToJSON for $ty { + fn to_json(&self) -> Option { + Some(Value::String(self.0.clone())) + } + } + }; +} +pub(crate) use impl_string_types; diff --git a/catalyst-gateway/bin/src/service/mod.rs b/catalyst-gateway/bin/src/service/mod.rs index 3a46f96aab..f7571d3fdb 100644 --- a/catalyst-gateway/bin/src/service/mod.rs +++ b/catalyst-gateway/bin/src/service/mod.rs @@ -9,8 +9,11 @@ mod common; mod poem_service; pub(crate) mod utilities; +use api::mk_api; pub(crate) use api::started; pub(crate) use poem_service::get_app_docs; +use serde_json::{json, Value}; +use tracing::error; /// # Run Catalyst Gateway Service. /// @@ -29,3 +32,11 @@ pub(crate) use poem_service::get_app_docs; pub(crate) async fn run() -> anyhow::Result<()> { poem_service::run().await } + +/// Retrieve the API specification in JSON format. +pub(crate) fn api_spec() -> Value { + serde_json::from_str(&mk_api().spec()).unwrap_or_else(|err| { + error!(id="api_spec", error=?err, "Failed to parse API spec"); + json!({}) + }) +} diff --git a/catalyst-gateway/bin/src/service/utilities/catch_panic.rs b/catalyst-gateway/bin/src/service/utilities/catch_panic.rs index 08f32fc9e8..e6fab44059 100644 --- a/catalyst-gateway/bin/src/service/utilities/catch_panic.rs +++ b/catalyst-gateway/bin/src/service/utilities/catch_panic.rs @@ -7,7 +7,7 @@ use poem::{http::StatusCode, middleware::PanicHandler, IntoResponse}; use poem_openapi::payload::Json; use serde_json::json; -use crate::service::common::objects::server_error::ServerError; +use crate::service::common::responses::code_500_internal_server_error::InternalServerError; /// Customized Panic handler. /// Catches all panics, and turns them into 500. @@ -49,7 +49,7 @@ impl PanicHandler for ServicePanicHandler { /// Handle a panic. /// Log the panic and respond with a 500 with appropriate data. fn get_response(&self, err: Box) -> Self::Response { - let server_err = ServerError::new(None); + let server_err = InternalServerError::new(None); // Get the unique identifier for this panic, so we can find it in the logs. let panic_identifier = server_err.id().to_string(); diff --git a/catalyst-gateway/bin/src/settings/mod.rs b/catalyst-gateway/bin/src/settings/mod.rs index 6d86cddefc..2021501d8d 100644 --- a/catalyst-gateway/bin/src/settings/mod.rs +++ b/catalyst-gateway/bin/src/settings/mod.rs @@ -141,6 +141,9 @@ struct EnvVars { /// The Chain Follower configuration chain_follower: chain_follower::EnvVars, + /// Internal API Access API Key + internal_api_key: Option, + /// Tick every N seconds until config exists in db #[allow(unused)] check_config_tick: Duration, @@ -193,6 +196,7 @@ static ENV_VARS: LazyLock = LazyLock::new(|| { cassandra_db::VOLATILE_NAMESPACE_DEFAULT, ), chain_follower: chain_follower::EnvVars::new(), + internal_api_key: StringEnvVar::new_optional("INTERNAL_API_KEY", true), check_config_tick, } }); @@ -277,7 +281,6 @@ impl Settings { } /// The Service UUID - #[allow(unused)] pub(crate) fn service_id() -> &'static str { ENV_VARS.service_id.as_str() } @@ -355,6 +358,15 @@ impl Settings { }, } } + + /// Check a given key matches the internal API Key + pub(crate) fn check_internal_api_key(value: &str) -> bool { + if let Some(required_key) = ENV_VARS.internal_api_key.as_ref().map(StringEnvVar::as_str) { + value == required_key + } else { + false + } + } } /// Transform a string list of host names into a vec of host names. diff --git a/catalyst-gateway/bin/src/utils/blake2b_hash.rs b/catalyst-gateway/bin/src/utils/blake2b_hash.rs index de8485c625..824df7aba2 100644 --- a/catalyst-gateway/bin/src/utils/blake2b_hash.rs +++ b/catalyst-gateway/bin/src/utils/blake2b_hash.rs @@ -92,6 +92,32 @@ pub(crate) fn blake2b_256(input_bytes: &[u8]) -> Blake2b256 { bytes } +/// 128 Byte Blake2b Hash +pub(crate) type Blake2b128 = [u8; 16]; + +/// Computes a BLAKE2b-128 hash of the input bytes. +/// +/// # Arguments +/// - `input_bytes`: A slice of bytes to be hashed. +/// +/// # Returns +/// An array containing the BLAKE2b-128 hash of the input bytes. +pub(crate) fn blake2b_128(input_bytes: &[u8]) -> Blake2b128 { + // Where we will actually store the result. + let mut bytes: Blake2b128 = Blake2b128::default(); + + // Generate a unique hash of the data. + let mut hasher = Params::new().hash_length(bytes.len()).to_state(); + + hasher.update(input_bytes); + let hash = hasher.finalize(); + + // Create a new array containing the first 16 elements from the original array + bytes.copy_from_slice(hash.as_bytes()); + + bytes +} + #[cfg(test)] mod tests { use super::*; diff --git a/catalyst-gateway/bin/src/utils/mod.rs b/catalyst-gateway/bin/src/utils/mod.rs index dd92d18e20..4887e4bbb1 100644 --- a/catalyst-gateway/bin/src/utils/mod.rs +++ b/catalyst-gateway/bin/src/utils/mod.rs @@ -1,3 +1,4 @@ //! General Purpose utility functions pub(crate) mod blake2b_hash; +pub(crate) mod schema; diff --git a/catalyst-gateway/bin/src/utils/schema.rs b/catalyst-gateway/bin/src/utils/schema.rs new file mode 100644 index 0000000000..e8c2dd5a28 --- /dev/null +++ b/catalyst-gateway/bin/src/utils/schema.rs @@ -0,0 +1,242 @@ +//! Utility functions for JSON schema processing + +use std::sync::LazyLock; + +use serde_json::{json, Value}; + +use crate::service::api_spec; + +/// JSON schema version +pub(crate) const SCHEMA_VERSION: &str = "https://json-schema.org/draft/2020-12/schema"; + +/// Get the `OpenAPI` specification +pub(crate) static OPENAPI_SPEC: LazyLock = LazyLock::new(api_spec); + +/// Extract a JSON schema from `schema_name` +pub(crate) fn extract_json_schema_for(schema_name: &str) -> Value { + let schema = OPENAPI_SPEC + .get("components") + .and_then(|components| components.get("schemas")) + .and_then(|schemas| schemas.get(schema_name)) + .cloned() + .unwrap_or_default(); + + // JSON schema not found, return an empty JSON object + if schema.is_null() { + return json!({}); + } + update_refs(&schema, &OPENAPI_SPEC) +} + +/// Function to resolve a `$ref` in the JSON schema +pub(crate) fn update_refs(example: &Value, base: &Value) -> Value { + /// Return the new JSON with modified $refs. + /// and the original values of the $refs + fn traverse_and_update(example: &Value) -> (Value, Vec) { + if let Value::Object(map) = example { + let mut new_map = serde_json::Map::new(); + let mut original_refs = Vec::new(); + + for (key, value) in map { + match key.as_str() { + "allOf" | "anyOf" | "oneOf" => { + // Iterate over the array and update each item + if let Value::Array(arr) = value { + let new_array: Vec = arr + .iter() + .map(|item| { + let (updated_item, refs) = traverse_and_update(item); + original_refs.extend(refs); + updated_item + }) + .collect(); + new_map.insert(key.to_string(), Value::Array(new_array)); + } + }, + "$ref" => { + // Modify the ref value to a new path, which is + // "#/definitions/{schema_name}" + if let Value::String(ref ref_str) = value { + let original_ref = ref_str.clone(); + let parts: Vec<&str> = ref_str.split('/').collect(); + if let Some(schema_name) = parts.last() { + let new_ref = format!("#/definitions/{schema_name}"); + new_map.insert(key.to_string(), json!(new_ref)); + original_refs.push(original_ref); + } + } + }, + _ => { + let (updated_value, refs) = traverse_and_update(value); + new_map.insert(key.to_string(), updated_value); + original_refs.extend(refs); + }, + } + } + + (Value::Object(new_map), original_refs) + } else { + (example.clone(), Vec::new()) + } + } + + let (updated_schema, references) = traverse_and_update(example); + // Create new JSON to hold the definitions + let mut definitions = json!({"definitions": {}}); + + // Traverse the references and retrieve the values + for r in references { + let path = extract_ref(&r); + if let Some(value) = get_nested_value(base, &path) { + if let Some(obj) = value.as_object() { + for (key, val) in obj { + if let Some(definitions_obj) = definitions + .get_mut("definitions") + .and_then(|v| v.as_object_mut()) + { + // Insert the key-value pair into the definitions object + definitions_obj.insert(key.clone(), val.clone()); + } + } + } + } + } + + // Add schema version + let j = merge_json(&updated_schema, &json!( { "$schema": SCHEMA_VERSION } )); + // Merge the definitions with the updated schema + json!(merge_json(&j, &definitions)) +} + +/// Merge 2 JSON objects. +fn merge_json(json1: &Value, json2: &Value) -> Value { + let mut merged = json1.as_object().cloned().unwrap_or_default(); + + if let Some(obj2) = json2.as_object() { + for (key, value) in obj2 { + // Insert or overwrite the definitions + merged.insert(key.clone(), value.clone()); + } + } + + Value::Object(merged) +} + +/// Get the nested value from a JSON object. +fn get_nested_value(base: &Value, path: &[String]) -> Option { + let mut current_value = base; + + for segment in path { + current_value = match current_value { + Value::Object(ref obj) => { + // If this is the last segment, return the key-value as a JSON object + if segment == path.last().unwrap_or(&String::new()) { + return obj.get(segment).map(|v| json!({ segment: v })); + } + // Move to the next nested value + obj.get(segment)? + }, + _ => return None, + }; + } + + None +} + +/// Extract the reference parts from a $ref string +fn extract_ref(ref_str: &str) -> Vec { + ref_str + .split('/') + .filter_map(|part| { + match part.trim() { + "" | "#" => None, + trimmed => Some(trimmed.to_string()), + } + }) + .collect() +} + +#[cfg(test)] +mod test { + use serde_json::{json, Value}; + + use crate::utils::schema::{extract_json_schema_for, update_refs}; + + #[test] + fn test_update_refs() { + let base_json: Value = json!({ + "components": { + "schemas": { + "Example": { + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/Props" + } + ] + } + }, + "required": ["data"], + "description": "Example schema" + }, + "Props": { + "type": "object", + "properties": { + "prop1": { + "type": "string", + "description": "Property 1" + }, + "prop2": { + "type": "string", + "description": "Property 2" + }, + "prop3": { + "type": "string", + "description": "Property 3" + } + }, + "required": ["prop1"] + } + } + } + }); + + let example_json: Value = json!({ + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/Props" + } + ] + } + }, + "required": ["data"], + "description": "Example schema" + + }); + + let schema = update_refs(&example_json, &base_json); + assert!(schema.get("definitions").unwrap().get("Props").is_some()); + } + + #[test] + fn test_extract_json_schema_for_frontend_config() { + let schema = extract_json_schema_for("FrontendConfig"); + println!("{schema}"); + assert!(schema.get("type").is_some()); + assert!(schema.get("properties").is_some()); + assert!(schema.get("description").is_some()); + assert!(schema.get("definitions").is_some()); + assert!(schema.get("$schema").is_some()); + } + + #[test] + fn test_extract_json_schema_for_frontend_config_no_data() { + let schema = extract_json_schema_for("test"); + assert!(schema.is_object()); + } +} diff --git a/catalyst-gateway/deny.toml b/catalyst-gateway/deny.toml index 8f5e39e813..77f0259f18 100644 --- a/catalyst-gateway/deny.toml +++ b/catalyst-gateway/deny.toml @@ -121,4 +121,4 @@ license-files = [{ path = "LICENSE", hash = 0xbd0eed23 }] #[[licenses.clarify]] #crate = "rustls-webpki" #expression = "ISC" -#license-files = [{ path = "LICENSE", hash = 0x001c7e6c }] \ No newline at end of file +#license-files = [{ path = "LICENSE", hash = 0x001c7e6c }] diff --git a/catalyst-gateway/tests/.oapi-v3.spectral.yml b/catalyst-gateway/tests/.oapi-v3.spectral.yml new file mode 100644 index 0000000000..a117908106 --- /dev/null +++ b/catalyst-gateway/tests/.oapi-v3.spectral.yml @@ -0,0 +1,240 @@ +# References to the rules +# OpenAPI: https://docs.stoplight.io/docs/spectral/4dec24461f3af-open-api-rules#openapi-rules +# OWASP Top 10: https://github.com/stoplightio/spectral-owasp-ruleset/blob/v1.4.3/src/ruleset.ts +# Documentations: https://github.com/stoplightio/spectral-documentation/blob/v2.0.1/src/ruleset.ts + +# cspell: words OWASP owasp Baction Baccount Bvoting unioned + +# Use CDN hosted version for spectral-documentation and spectral-owasp +extends: + - ["spectral:oas", all] + - "https://unpkg.com/@stoplight/spectral-documentation@1.3.1/dist/ruleset.mjs" + - "https://unpkg.com/@stoplight/spectral-owasp-ruleset@2.0.1/dist/ruleset.mjs" + +formats: ["oas3"] + +aliases: + # From: https://github.com/stoplightio/spectral-owasp-ruleset/blob/26819e80e5ac4571b6271834fc97f0a1b66110bd/src/ruleset.ts#L60 + StringProperties: + description: "String Properties" + targets: + - formats: + - oas2 + - oas3_0 + given: + - $..[?(@ && @.type=="string")] + - formats: + - oas3_1 + given: + - $..[?(@ && @.type=="string")] + - $..[?(@ && @.type && @.type.constructor.name === "Array" && @.type.includes("string"))] + # From: https://github.com/stoplightio/spectral-documentation/blob/a34ca1b49cbd1ac5a75cfcb93c69d1d77bde341e/src/ruleset.ts#L19C1-L20C1 + PathItem: + - "$.paths[*]" + # From: https://github.com/stoplightio/spectral-documentation/blob/a34ca1b49cbd1ac5a75cfcb93c69d1d77bde341e/src/ruleset.ts#L20C1-L23C1 + OperationObject: + - "#PathItem[get,put,post,delete,options,head,patch,trace]" + # From: https://github.com/stoplightio/spectral-documentation/blob/a34ca1b49cbd1ac5a75cfcb93c69d1d77bde341e/src/ruleset.ts#L26 + DescribableObjects: + description: "All objects that should be described." + targets: + - formats: + - oas2 + given: + - "$.info" + - "$.tags[*]" + - "#OperationObject" + - "#OperationObject.responses[*]" + - "#PathItem.parameters[?(@ && @.in)]" + - "#OperationObject.parameters[?(@ && @.in)]" + - "$.definitions[*]" + - formats: + - oas3 + given: + - "$.info" + - "$.tags[*]" + - "#OperationObject" + - "#OperationObject.responses[*]" + - "#PathItem.parameters[?(@ && @.in)]" + - "#OperationObject.parameters[?(@ && @.in)]" + - "$.components.schemas[*]" + - "$.servers[*]" + +overrides: + - files: ["*"] + rules: + # Severity + # warn: Should be implemented, but is blocked by a technical issue. + # info: Good to be implemented. + # error: Must be implemented. + # off: Not applicable - check comment to see if its replaced with a custom rule. + + docs-description: error + owasp:api4:2023-string-restricted: error + owasp:api3:2023-no-additionalProperties: error + owasp:api3:2023-constrained-additionalProperties: error + # 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. + owasp:api9:2023-inventory-access: off + + + # Legacy Endpoints that have strange unioned properties. + # These can not be documented + # ONLY Legacy endpoints may be added to this override. + - files: + - "**#/components/schemas/VotingInfo_Delegations" + - "**#/components/schemas/VotingInfo_DirectVoter" + rules: + docs-description: off + + # Legacy Endpoints that use additionalProperties other than setting to False. + # ONLY Legacy endpoints may be added to this override. + - files: + - "**#/components/schemas/FragmentStatus" + rules: + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L678 + owasp:api3:2023-no-additionalProperties: off + + # Legacy Endpoints that have direct json arrays in response, which Poem can not annotate with validation constraints. + # ONLY Legacy endpoints may be added to this override. + - files: + - "**#/paths/~1api~1v0~1vote~1active~1plans/get/responses" + - "**#/paths/~1api~1v1~1votes~1plan~1account-votes~1%7Baccount_id%7D/get/responses" + rules: + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L506 + owasp:api4:2023-array-limit: off + + # Legacy Endpoints that use additionalProperties other than setting to False. + # ONLY Legacy endpoints may be added to this override. + - files: + - "**#/paths/~1api~1v1~1fragments~1statuses/get/responses" + rules: + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L678 + owasp:api3:2023-constrained-additionalProperties: off + + # Can't put limits or validation on array query parameters. See: https://github.com/poem-web/poem/issues/907 + # This should be removed if these limits can be applied, or this endpoint is eliminated. + # To be used for Legacy endpoints only. Array query parameters not permitted in new endpoints. + - files: + - "**#/paths/~1api~1v1~1fragments~1statuses/get/parameters/0/schema/items" + rules: + owasp:api4:2023-string-limit: off + owasp:api4:2023-string-restricted: off + + # Disable security details from legacy endpoints + # This override can ONLY be used by legacy endpoints. + - files: + - "**#/paths/~1api~1v1~1registration~1voter~1%7Bvoting_key%7D/get" + - "**#/paths/~1api~1v0~1message" + - "**#/paths/~1api~1v0~1vote~1active~1plans" + - "**#/paths/~1api~1v1~1votes~1plan~1account-votes~1%7Baccount_id%7D/get" + - "**#/paths/~1api~1v1~1fragments~1statuses" + - "**#/paths/~1api~1v1~1fragments/post" + rules: + owasp:api2:2023-read-restricted: off + owasp:api8:2023-define-error-responses-401: off + owasp:api8:2023-define-error-validation: off + owasp:api2:2023-write-restricted: off + +rules: + # A version of owasp:api4:2019-string-limit which accepts `format` without length as defining a string limit. + owasp:api4:2023-string-limit: + message: "Schema of type string must specify format, maxLength, enum, or const." + description: "String size should be limited to mitigate resource exhaustion attacks. This can be done using `format`, `maxLength`, `enum` or `const`." + severity: error + given: "#StringProperties" + then: + function: schema + functionOptions: + schema: + type: "object" + oneOf: + - anyOf: + - required: ["format"] + - required: ["maxLength"] + - required: ["enum"] + - required: ["const"] + + # A version of owasp:api4:2019-rate-limit-retry-after which accepts the header name in upper case. + owasp:api4:2023-rate-limit-retry-after: + message: "A 429 response should define a Retry-After header." + description: "Define proper rate limiting to avoid attackers overloading the API. Part of that involves setting a Retry-After header so well meaning consumers are not polling and potentially exacerbating problems." + severity: error + given: "$..responses[429].headers" + then: + field: "RETRY-AFTER" + function: defined + + # Override document description rule + # - No limitations on the characters that can start or end a sentence. + # - Length should be >= 20 characters + # Ref: https://github.com/stoplightio/spectral-documentation/blob/a34ca1b49cbd1ac5a75cfcb93c69d1d77bde341e/src/ruleset.ts#L173 + docs-description: + description: "Documentation tools use description to provide more context to users of the API who are not as familiar with the concepts as the API designers are." + message: "{{error}}." + 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: "[\\.\\*]$" + + api-path: + message: "Invalid API path - should be /api/draft/* or /api/v/*" + given: "$.paths" + severity: error + then: + field: "@key" + function: pattern + functionOptions: + # Match paths that start with /api/draft/* or /api/v/* + match: "^/api/(draft|v\\d+)/.*$" + api-content-type: + message: "Content type must not be text/plain" + given: "$.paths[*][*].responses[*].content" + severity: error + then: + field: "@key" + function: pattern + functionOptions: + # Match content type that is not text/plain + match: "^(?!text/plain).*" + api-content-type-json-object: + message: "Content type application/json must be object" + given: "$.paths[*][*].responses[*].content['application/json'].schema.type" + severity: error + then: + field: "@value" + function: pattern + functionOptions: + # Match content type application/json should be object + match: "object" + api-example-schema: + message: "Schema should contain example" + given: "$..schema" + severity: warn + then: + field: "example" + function: truthy + api-example-properties: + message: "Property should contain example" + given: "$..properties.*" + severity: warn + then: + field: "example" + function: truthy diff --git a/catalyst-gateway/tests/.spectral.yml b/catalyst-gateway/tests/.spectral.yml index b5eefe860c..fcfe46baea 100644 --- a/catalyst-gateway/tests/.spectral.yml +++ b/catalyst-gateway/tests/.spectral.yml @@ -11,6 +11,8 @@ extends: - "https://unpkg.com/@stoplight/spectral-documentation@1.3.1/dist/ruleset.mjs" - "https://unpkg.com/@stoplight/spectral-owasp-ruleset@1.4.3/dist/ruleset.mjs" +formats: ["oas3", "json-schema-2020-12"] + aliases: PathItem: - $.paths[*] @@ -126,3 +128,35 @@ overrides: rules: # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L698 owasp:api6:2019-constrained-additionalProperties: off + +rules: + api-path: + message: "Invalid API path - should be /api/draft/* or /api/v/*" + given: "$.paths" + severity: error + then: + field: "@key" + function: pattern + functionOptions: + # Match paths that start with /api/draft/* or /api/v/* + match: "^/api/(draft|v\\d+)/.*$" + api-content-type: + message: "Content type must not be text/plain" + given: "$.paths[*][*].responses[*].content" + severity: error + then: + field: "@key" + function: pattern + functionOptions: + # Match content type that is not text/plain + match: "^(?!text/plain).*" + api-content-type-json-object: + message: "Content type application/json must be object" + given: "$.paths[*][*].responses[*].content['application/json'].schema.type" + severity: error + then: + field: "@value" + function: pattern + functionOptions: + # Match content type application/json should be object + match: "object" \ No newline at end of file diff --git a/catalyst-gateway/tests/Earthfile b/catalyst-gateway/tests/Earthfile index bc13dce307..eba6f09af1 100644 --- a/catalyst-gateway/tests/Earthfile +++ b/catalyst-gateway/tests/Earthfile @@ -1,11 +1,13 @@ VERSION 0.8 -IMPORT github.com/input-output-hk/catalyst-ci/earthly/spectral:v3.2.18 AS spectral-ci +IMPORT github.com/input-output-hk/catalyst-ci/earthly/spectral:fix/spectral-linter AS spectral-ci # test-lint-openapi - OpenAPI linting from an artifact # testing whether the OpenAPI generated during build stage follows good practice. test-lint-openapi: FROM spectral-ci+spectral-base # Copy the doc artifact. - COPY ../+build/doc ./doc + COPY --dir ../+build/doc . + # Copy the spectral configuration file. + COPY ./.oapi-v3.spectral.yml .spectral.yml # Scan the doc directory where type of file is JSON. - DO spectral-ci+BUILD_SPECTRAL --dir=./doc --file_type="json" \ No newline at end of file + DO spectral-ci+LINT --dir=./doc \ No newline at end of file diff --git a/catalyst_voices/packages/catalyst_voices_repositories/lib/src/catalyst_data_gateway_repository.dart b/catalyst_voices/packages/catalyst_voices_repositories/lib/src/catalyst_data_gateway_repository.dart index d22ae32e64..1047af383f 100644 --- a/catalyst_voices/packages/catalyst_voices_repositories/lib/src/catalyst_data_gateway_repository.dart +++ b/catalyst_voices/packages/catalyst_voices_repositories/lib/src/catalyst_data_gateway_repository.dart @@ -51,12 +51,14 @@ final class CatalystDataGatewayRepository { CatalystDataGatewayRepository( Uri baseUrl, { CatGatewayApi? catGatewayApiInstance, - }) : _catGatewayApi = - catGatewayApiInstance ?? CatGatewayApi.create(baseUrl: baseUrl); + }) : _catGatewayApi = catGatewayApiInstance ?? + CatGatewayApi.create( + baseUrl: baseUrl, + ); Future> getHealthStarted() async { try { - final heathStarted = await _catGatewayApi.apiHealthStartedGet(); + final heathStarted = await _catGatewayApi.apiV1HealthStartedGet(); return _emptyBodyOrThrow(heathStarted); } on ChopperHttpException catch (error) { return Failure(_getNetworkError(error.response.statusCode)); @@ -65,7 +67,7 @@ final class CatalystDataGatewayRepository { Future> getHealthReady() async { try { - final heathReady = await _catGatewayApi.apiHealthReadyGet(); + final heathReady = await _catGatewayApi.apiV1HealthReadyGet(); return _emptyBodyOrThrow(heathReady); } on ChopperHttpException catch (error) { return Failure(_getNetworkError(error.response.statusCode)); @@ -74,7 +76,7 @@ final class CatalystDataGatewayRepository { Future> getHealthLive() async { try { - final healthLive = await _catGatewayApi.apiHealthLiveGet(); + final healthLive = await _catGatewayApi.apiV1HealthLiveGet(); return _emptyBodyOrThrow(healthLive); } on ChopperHttpException catch (error) { return Failure(_getNetworkError(error.response.statusCode)); @@ -87,7 +89,8 @@ final class CatalystDataGatewayRepository { int? slotNumber, }) async { try { - final stakeInfo = await _catGatewayApi.apiCardanoStakedAdaStakeAddressGet( + final stakeInfo = + await _catGatewayApi.apiDraftCardanoStakedAdaStakeAddressGet( stakeAddress: stakeAddress, network: network, slotNumber: slotNumber, @@ -102,7 +105,7 @@ final class CatalystDataGatewayRepository { enums.Network network = enums.Network.mainnet, }) async { try { - final syncState = await _catGatewayApi.apiCardanoSyncStateGet( + final syncState = await _catGatewayApi.apiDraftCardanoSyncStateGet( network: network, ); return Success(syncState.bodyOrThrow); diff --git a/catalyst_voices/packages/catalyst_voices_repositories/test/src/catalyst_data_gateway_repository/catalyst_data_gateway_repository_test.dart b/catalyst_voices/packages/catalyst_voices_repositories/test/src/catalyst_data_gateway_repository/catalyst_data_gateway_repository_test.dart index b30cbaba6e..9795abced9 100644 --- a/catalyst_voices/packages/catalyst_voices_repositories/test/src/catalyst_data_gateway_repository/catalyst_data_gateway_repository_test.dart +++ b/catalyst_voices/packages/catalyst_voices_repositories/test/src/catalyst_data_gateway_repository/catalyst_data_gateway_repository_test.dart @@ -21,24 +21,25 @@ class FakeCatGatewayApi extends Fake implements CatGatewayApi { FakeCatGatewayApi(this.response); @override - Future> apiHealthStartedGet() async => response; + Future> apiV1HealthStartedGet() async => response; @override - Future> apiHealthReadyGet() async => response; + Future> apiV1HealthReadyGet() async => response; @override - Future> apiHealthLiveGet() async => response; + Future> apiV1HealthLiveGet() async => response; @override - Future> apiCardanoStakedAdaStakeAddressGet({ + Future> + apiDraftCardanoStakedAdaStakeAddressGet({ required String? stakeAddress, enums.Network? network, int? slotNumber, }) async => - response as chopper.Response; + response as chopper.Response; @override - Future> apiCardanoSyncStateGet({ + Future> apiDraftCardanoSyncStateGet({ enums.Network? network, }) async => response as chopper.Response; diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.models.swagger.dart b/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.models.swagger.dart index 760d1fe957..78b908c4e7 100644 --- a/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.models.swagger.dart +++ b/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.models.swagger.dart @@ -62,66 +62,6 @@ extension $AccountVoteExtension on AccountVote { } } -@JsonSerializable(explicitToJson: true) -class BadRequestError { - const BadRequestError({ - required this.error, - this.schemaValidationErrors, - }); - - factory BadRequestError.fromJson(Map json) => - _$BadRequestErrorFromJson(json); - - static const toJsonFactory = _$BadRequestErrorToJson; - Map toJson() => _$BadRequestErrorToJson(this); - - @JsonKey(name: 'error') - final String error; - @JsonKey(name: 'schema_validation_errors', defaultValue: []) - final List? schemaValidationErrors; - static const fromJsonFactory = _$BadRequestErrorFromJson; - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other is BadRequestError && - (identical(other.error, error) || - const DeepCollectionEquality().equals(other.error, error)) && - (identical(other.schemaValidationErrors, schemaValidationErrors) || - const DeepCollectionEquality().equals( - other.schemaValidationErrors, schemaValidationErrors))); - } - - @override - String toString() => jsonEncode(this); - - @override - int get hashCode => - const DeepCollectionEquality().hash(error) ^ - const DeepCollectionEquality().hash(schemaValidationErrors) ^ - runtimeType.hashCode; -} - -extension $BadRequestErrorExtension on BadRequestError { - BadRequestError copyWith( - {String? error, List? schemaValidationErrors}) { - return BadRequestError( - error: error ?? this.error, - schemaValidationErrors: - schemaValidationErrors ?? this.schemaValidationErrors); - } - - BadRequestError copyWithWrapped( - {Wrapped? error, - Wrapped?>? schemaValidationErrors}) { - return BadRequestError( - error: (error != null ? error.value : this.error), - schemaValidationErrors: (schemaValidationErrors != null - ? schemaValidationErrors.value - : this.schemaValidationErrors)); - } -} - @JsonSerializable(explicitToJson: true) class BlockDate { const BlockDate({ @@ -396,6 +336,128 @@ extension $Cip36ReportingListExtension on Cip36ReportingList { } } +@JsonSerializable(explicitToJson: true) +class ConfigBadRequest { + const ConfigBadRequest({ + required this.error, + this.schemaValidationErrors, + }); + + factory ConfigBadRequest.fromJson(Map json) => + _$ConfigBadRequestFromJson(json); + + static const toJsonFactory = _$ConfigBadRequestToJson; + Map toJson() => _$ConfigBadRequestToJson(this); + + @JsonKey(name: 'error') + final String error; + @JsonKey(name: 'schema_validation_errors', defaultValue: []) + final List? schemaValidationErrors; + static const fromJsonFactory = _$ConfigBadRequestFromJson; + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other is ConfigBadRequest && + (identical(other.error, error) || + const DeepCollectionEquality().equals(other.error, error)) && + (identical(other.schemaValidationErrors, schemaValidationErrors) || + const DeepCollectionEquality().equals( + other.schemaValidationErrors, schemaValidationErrors))); + } + + @override + String toString() => jsonEncode(this); + + @override + int get hashCode => + const DeepCollectionEquality().hash(error) ^ + const DeepCollectionEquality().hash(schemaValidationErrors) ^ + runtimeType.hashCode; +} + +extension $ConfigBadRequestExtension on ConfigBadRequest { + ConfigBadRequest copyWith( + {String? error, List? schemaValidationErrors}) { + return ConfigBadRequest( + error: error ?? this.error, + schemaValidationErrors: + schemaValidationErrors ?? this.schemaValidationErrors); + } + + ConfigBadRequest copyWithWrapped( + {Wrapped? error, + Wrapped?>? schemaValidationErrors}) { + return ConfigBadRequest( + error: (error != null ? error.value : this.error), + schemaValidationErrors: (schemaValidationErrors != null + ? schemaValidationErrors.value + : this.schemaValidationErrors)); + } +} + +@JsonSerializable(explicitToJson: true) +class ContentErrorDetail { + const ContentErrorDetail({ + this.loc, + this.msg, + this.type, + }); + + factory ContentErrorDetail.fromJson(Map json) => + _$ContentErrorDetailFromJson(json); + + static const toJsonFactory = _$ContentErrorDetailToJson; + Map toJson() => _$ContentErrorDetailToJson(this); + + @JsonKey(name: 'loc', defaultValue: []) + final List? loc; + @JsonKey(name: 'msg') + final String? msg; + @JsonKey(name: 'type') + final String? type; + static const fromJsonFactory = _$ContentErrorDetailFromJson; + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other is ContentErrorDetail && + (identical(other.loc, loc) || + const DeepCollectionEquality().equals(other.loc, loc)) && + (identical(other.msg, msg) || + const DeepCollectionEquality().equals(other.msg, msg)) && + (identical(other.type, type) || + const DeepCollectionEquality().equals(other.type, type))); + } + + @override + String toString() => jsonEncode(this); + + @override + int get hashCode => + const DeepCollectionEquality().hash(loc) ^ + const DeepCollectionEquality().hash(msg) ^ + const DeepCollectionEquality().hash(type) ^ + runtimeType.hashCode; +} + +extension $ContentErrorDetailExtension on ContentErrorDetail { + ContentErrorDetail copyWith({List? loc, String? msg, String? type}) { + return ContentErrorDetail( + loc: loc ?? this.loc, msg: msg ?? this.msg, type: type ?? this.type); + } + + ContentErrorDetail copyWithWrapped( + {Wrapped?>? loc, + Wrapped? msg, + Wrapped? type}) { + return ContentErrorDetail( + loc: (loc != null ? loc.value : this.loc), + msg: (msg != null ? msg.value : this.msg), + type: (type != null ? type.value : this.type)); + } +} + @JsonSerializable(explicitToJson: true) class DelegatePublicKey { const DelegatePublicKey({ @@ -582,6 +644,71 @@ extension $DirectVoterExtension on DirectVoter { } } +@JsonSerializable(explicitToJson: true) +class Forbidden { + const Forbidden({ + required this.id, + required this.msg, + this.required, + }); + + factory Forbidden.fromJson(Map json) => + _$ForbiddenFromJson(json); + + static const toJsonFactory = _$ForbiddenToJson; + Map toJson() => _$ForbiddenToJson(this); + + @JsonKey(name: 'id') + final String id; + @JsonKey(name: 'msg') + final String msg; + @JsonKey(name: 'required', defaultValue: []) + final List? required; + static const fromJsonFactory = _$ForbiddenFromJson; + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other is Forbidden && + (identical(other.id, id) || + const DeepCollectionEquality().equals(other.id, id)) && + (identical(other.msg, msg) || + const DeepCollectionEquality().equals(other.msg, msg)) && + (identical(other.required, required) || + const DeepCollectionEquality() + .equals(other.required, required))); + } + + @override + String toString() => jsonEncode(this); + + @override + int get hashCode => + const DeepCollectionEquality().hash(id) ^ + const DeepCollectionEquality().hash(msg) ^ + const DeepCollectionEquality().hash(required) ^ + runtimeType.hashCode; +} + +extension $ForbiddenExtension on Forbidden { + Forbidden copyWith({String? id, String? msg, List? required}) { + return Forbidden( + id: id ?? this.id, + msg: msg ?? this.msg, + required: required ?? this.required); + } + + Forbidden copyWithWrapped( + {Wrapped? id, + Wrapped? msg, + Wrapped?>? required}) { + return Forbidden( + id: (id != null ? id.value : this.id), + msg: (msg != null ? msg.value : this.msg), + required: (required != null ? required.value : this.required)); + } +} + @JsonSerializable(explicitToJson: true) class FragmentStatus { const FragmentStatus(); @@ -715,6 +842,49 @@ extension $FragmentsProcessingSummaryExtension on FragmentsProcessingSummary { } } +@JsonSerializable(explicitToJson: true) +class FrontendConfig { + const FrontendConfig({ + this.sentry, + }); + + factory FrontendConfig.fromJson(Map json) => + _$FrontendConfigFromJson(json); + + static const toJsonFactory = _$FrontendConfigToJson; + Map toJson() => _$FrontendConfigToJson(this); + + @JsonKey(name: 'sentry') + final Sentry? sentry; + static const fromJsonFactory = _$FrontendConfigFromJson; + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other is FrontendConfig && + (identical(other.sentry, sentry) || + const DeepCollectionEquality().equals(other.sentry, sentry))); + } + + @override + String toString() => jsonEncode(this); + + @override + int get hashCode => + const DeepCollectionEquality().hash(sentry) ^ runtimeType.hashCode; +} + +extension $FrontendConfigExtension on FrontendConfig { + FrontendConfig copyWith({Sentry? sentry}) { + return FrontendConfig(sentry: sentry ?? this.sentry); + } + + FrontendConfig copyWithWrapped({Wrapped? sentry}) { + return FrontendConfig( + sentry: (sentry != null ? sentry.value : this.sentry)); + } +} + @JsonSerializable(explicitToJson: true) class FullStakeInfo { const FullStakeInfo({ @@ -812,6 +982,66 @@ extension $HashExtension on Hash { } } +@JsonSerializable(explicitToJson: true) +class InternalServerError { + const InternalServerError({ + required this.id, + required this.msg, + this.issue, + }); + + factory InternalServerError.fromJson(Map json) => + _$InternalServerErrorFromJson(json); + + static const toJsonFactory = _$InternalServerErrorToJson; + Map toJson() => _$InternalServerErrorToJson(this); + + @JsonKey(name: 'id') + final String id; + @JsonKey(name: 'msg') + final String msg; + @JsonKey(name: 'issue') + final String? issue; + static const fromJsonFactory = _$InternalServerErrorFromJson; + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other is InternalServerError && + (identical(other.id, id) || + const DeepCollectionEquality().equals(other.id, id)) && + (identical(other.msg, msg) || + const DeepCollectionEquality().equals(other.msg, msg)) && + (identical(other.issue, issue) || + const DeepCollectionEquality().equals(other.issue, issue))); + } + + @override + String toString() => jsonEncode(this); + + @override + int get hashCode => + const DeepCollectionEquality().hash(id) ^ + const DeepCollectionEquality().hash(msg) ^ + const DeepCollectionEquality().hash(issue) ^ + runtimeType.hashCode; +} + +extension $InternalServerErrorExtension on InternalServerError { + InternalServerError copyWith({String? id, String? msg, String? issue}) { + return InternalServerError( + id: id ?? this.id, msg: msg ?? this.msg, issue: issue ?? this.issue); + } + + InternalServerError copyWithWrapped( + {Wrapped? id, Wrapped? msg, Wrapped? issue}) { + return InternalServerError( + id: (id != null ? id.value : this.id), + msg: (msg != null ? msg.value : this.msg), + issue: (issue != null ? issue.value : this.issue)); + } +} + @JsonSerializable(explicitToJson: true) class InvalidRegistrationsReport { const InvalidRegistrationsReport({ @@ -1070,37 +1300,98 @@ extension $RejectedFragmentExtension on RejectedFragment { } @JsonSerializable(explicitToJson: true) -class ServerError { - const ServerError({ +class Sentry { + const Sentry({ + required this.dsn, + this.release, + this.environment, + }); + + factory Sentry.fromJson(Map json) => _$SentryFromJson(json); + + static const toJsonFactory = _$SentryToJson; + Map toJson() => _$SentryToJson(this); + + @JsonKey(name: 'dsn') + final String dsn; + @JsonKey(name: 'release') + final String? release; + @JsonKey(name: 'environment') + final String? environment; + static const fromJsonFactory = _$SentryFromJson; + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other is Sentry && + (identical(other.dsn, dsn) || + const DeepCollectionEquality().equals(other.dsn, dsn)) && + (identical(other.release, release) || + const DeepCollectionEquality() + .equals(other.release, release)) && + (identical(other.environment, environment) || + const DeepCollectionEquality() + .equals(other.environment, environment))); + } + + @override + String toString() => jsonEncode(this); + + @override + int get hashCode => + const DeepCollectionEquality().hash(dsn) ^ + const DeepCollectionEquality().hash(release) ^ + const DeepCollectionEquality().hash(environment) ^ + runtimeType.hashCode; +} + +extension $SentryExtension on Sentry { + Sentry copyWith({String? dsn, String? release, String? environment}) { + return Sentry( + dsn: dsn ?? this.dsn, + release: release ?? this.release, + environment: environment ?? this.environment); + } + + Sentry copyWithWrapped( + {Wrapped? dsn, + Wrapped? release, + Wrapped? environment}) { + return Sentry( + dsn: (dsn != null ? dsn.value : this.dsn), + release: (release != null ? release.value : this.release), + environment: + (environment != null ? environment.value : this.environment)); + } +} + +@JsonSerializable(explicitToJson: true) +class ServiceUnavailable { + const ServiceUnavailable({ required this.id, required this.msg, - this.issue, }); - factory ServerError.fromJson(Map json) => - _$ServerErrorFromJson(json); + factory ServiceUnavailable.fromJson(Map json) => + _$ServiceUnavailableFromJson(json); - static const toJsonFactory = _$ServerErrorToJson; - Map toJson() => _$ServerErrorToJson(this); + static const toJsonFactory = _$ServiceUnavailableToJson; + Map toJson() => _$ServiceUnavailableToJson(this); @JsonKey(name: 'id') final String id; @JsonKey(name: 'msg') final String msg; - @JsonKey(name: 'issue') - final String? issue; - static const fromJsonFactory = _$ServerErrorFromJson; + static const fromJsonFactory = _$ServiceUnavailableFromJson; @override bool operator ==(Object other) { return identical(this, other) || - (other is ServerError && + (other is ServiceUnavailable && (identical(other.id, id) || const DeepCollectionEquality().equals(other.id, id)) && (identical(other.msg, msg) || - const DeepCollectionEquality().equals(other.msg, msg)) && - (identical(other.issue, issue) || - const DeepCollectionEquality().equals(other.issue, issue))); + const DeepCollectionEquality().equals(other.msg, msg))); } @override @@ -1110,22 +1401,19 @@ class ServerError { int get hashCode => const DeepCollectionEquality().hash(id) ^ const DeepCollectionEquality().hash(msg) ^ - const DeepCollectionEquality().hash(issue) ^ runtimeType.hashCode; } -extension $ServerErrorExtension on ServerError { - ServerError copyWith({String? id, String? msg, String? issue}) { - return ServerError( - id: id ?? this.id, msg: msg ?? this.msg, issue: issue ?? this.issue); +extension $ServiceUnavailableExtension on ServiceUnavailable { + ServiceUnavailable copyWith({String? id, String? msg}) { + return ServiceUnavailable(id: id ?? this.id, msg: msg ?? this.msg); } - ServerError copyWithWrapped( - {Wrapped? id, Wrapped? msg, Wrapped? issue}) { - return ServerError( + ServiceUnavailable copyWithWrapped( + {Wrapped? id, Wrapped? msg}) { + return ServiceUnavailable( id: (id != null ? id.value : this.id), - msg: (msg != null ? msg.value : this.msg), - issue: (issue != null ? issue.value : this.issue)); + msg: (msg != null ? msg.value : this.msg)); } } @@ -1583,27 +1871,129 @@ extension $SyncStateExtension on SyncState { } @JsonSerializable(explicitToJson: true) -class ValidationError { - const ValidationError({ - required this.message, +class TooManyRequests { + const TooManyRequests({ + required this.id, + required this.msg, + }); + + factory TooManyRequests.fromJson(Map json) => + _$TooManyRequestsFromJson(json); + + static const toJsonFactory = _$TooManyRequestsToJson; + Map toJson() => _$TooManyRequestsToJson(this); + + @JsonKey(name: 'id') + final String id; + @JsonKey(name: 'msg') + final String msg; + static const fromJsonFactory = _$TooManyRequestsFromJson; + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other is TooManyRequests && + (identical(other.id, id) || + const DeepCollectionEquality().equals(other.id, id)) && + (identical(other.msg, msg) || + const DeepCollectionEquality().equals(other.msg, msg))); + } + + @override + String toString() => jsonEncode(this); + + @override + int get hashCode => + const DeepCollectionEquality().hash(id) ^ + const DeepCollectionEquality().hash(msg) ^ + runtimeType.hashCode; +} + +extension $TooManyRequestsExtension on TooManyRequests { + TooManyRequests copyWith({String? id, String? msg}) { + return TooManyRequests(id: id ?? this.id, msg: msg ?? this.msg); + } + + TooManyRequests copyWithWrapped({Wrapped? id, Wrapped? msg}) { + return TooManyRequests( + id: (id != null ? id.value : this.id), + msg: (msg != null ? msg.value : this.msg)); + } +} + +@JsonSerializable(explicitToJson: true) +class Unauthorized { + const Unauthorized({ + required this.id, + required this.msg, + }); + + factory Unauthorized.fromJson(Map json) => + _$UnauthorizedFromJson(json); + + static const toJsonFactory = _$UnauthorizedToJson; + Map toJson() => _$UnauthorizedToJson(this); + + @JsonKey(name: 'id') + final String id; + @JsonKey(name: 'msg') + final String msg; + static const fromJsonFactory = _$UnauthorizedFromJson; + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other is Unauthorized && + (identical(other.id, id) || + const DeepCollectionEquality().equals(other.id, id)) && + (identical(other.msg, msg) || + const DeepCollectionEquality().equals(other.msg, msg))); + } + + @override + String toString() => jsonEncode(this); + + @override + int get hashCode => + const DeepCollectionEquality().hash(id) ^ + const DeepCollectionEquality().hash(msg) ^ + runtimeType.hashCode; +} + +extension $UnauthorizedExtension on Unauthorized { + Unauthorized copyWith({String? id, String? msg}) { + return Unauthorized(id: id ?? this.id, msg: msg ?? this.msg); + } + + Unauthorized copyWithWrapped({Wrapped? id, Wrapped? msg}) { + return Unauthorized( + id: (id != null ? id.value : this.id), + msg: (msg != null ? msg.value : this.msg)); + } +} + +@JsonSerializable(explicitToJson: true) +class UnprocessableContent { + const UnprocessableContent({ + required this.detail, }); - factory ValidationError.fromJson(Map json) => - _$ValidationErrorFromJson(json); + factory UnprocessableContent.fromJson(Map json) => + _$UnprocessableContentFromJson(json); - static const toJsonFactory = _$ValidationErrorToJson; - Map toJson() => _$ValidationErrorToJson(this); + static const toJsonFactory = _$UnprocessableContentToJson; + Map toJson() => _$UnprocessableContentToJson(this); - @JsonKey(name: 'message') - final String message; - static const fromJsonFactory = _$ValidationErrorFromJson; + @JsonKey(name: 'detail', defaultValue: []) + final List detail; + static const fromJsonFactory = _$UnprocessableContentFromJson; @override bool operator ==(Object other) { return identical(this, other) || - (other is ValidationError && - (identical(other.message, message) || - const DeepCollectionEquality().equals(other.message, message))); + (other is UnprocessableContent && + (identical(other.detail, detail) || + const DeepCollectionEquality().equals(other.detail, detail))); } @override @@ -1611,17 +2001,18 @@ class ValidationError { @override int get hashCode => - const DeepCollectionEquality().hash(message) ^ runtimeType.hashCode; + const DeepCollectionEquality().hash(detail) ^ runtimeType.hashCode; } -extension $ValidationErrorExtension on ValidationError { - ValidationError copyWith({String? message}) { - return ValidationError(message: message ?? this.message); +extension $UnprocessableContentExtension on UnprocessableContent { + UnprocessableContent copyWith({List? detail}) { + return UnprocessableContent(detail: detail ?? this.detail); } - ValidationError copyWithWrapped({Wrapped? message}) { - return ValidationError( - message: (message != null ? message.value : this.message)); + UnprocessableContent copyWithWrapped( + {Wrapped>? detail}) { + return UnprocessableContent( + detail: (detail != null ? detail.value : this.detail)); } } diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.models.swagger.g.dart b/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.models.swagger.g.dart index 3672b3cf35..1a365de356 100644 --- a/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.models.swagger.g.dart +++ b/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.models.swagger.g.dart @@ -20,22 +20,6 @@ Map _$AccountVoteToJson(AccountVote instance) => 'votes': instance.votes, }; -BadRequestError _$BadRequestErrorFromJson(Map json) => - BadRequestError( - error: json['error'] as String, - schemaValidationErrors: - (json['schema_validation_errors'] as List?) - ?.map((e) => e as String) - .toList() ?? - [], - ); - -Map _$BadRequestErrorToJson(BadRequestError instance) => - { - 'error': instance.error, - 'schema_validation_errors': instance.schemaValidationErrors, - }; - BlockDate _$BlockDateFromJson(Map json) => BlockDate( epoch: (json['epoch'] as num).toInt(), slotId: (json['slot_id'] as num).toInt(), @@ -100,6 +84,37 @@ Map _$Cip36ReportingListToJson(Cip36ReportingList instance) => 'cip36': instance.cip36.map((e) => e.toJson()).toList(), }; +ConfigBadRequest _$ConfigBadRequestFromJson(Map json) => + ConfigBadRequest( + error: json['error'] as String, + schemaValidationErrors: + (json['schema_validation_errors'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + ); + +Map _$ConfigBadRequestToJson(ConfigBadRequest instance) => + { + 'error': instance.error, + 'schema_validation_errors': instance.schemaValidationErrors, + }; + +ContentErrorDetail _$ContentErrorDetailFromJson(Map json) => + ContentErrorDetail( + loc: (json['loc'] as List?)?.map((e) => e as String).toList() ?? + [], + msg: json['msg'] as String?, + type: json['type'] as String?, + ); + +Map _$ContentErrorDetailToJson(ContentErrorDetail instance) => + { + 'loc': instance.loc, + 'msg': instance.msg, + 'type': instance.type, + }; + DelegatePublicKey _$DelegatePublicKeyFromJson(Map json) => DelegatePublicKey( address: json['address'] as String, @@ -142,6 +157,21 @@ Map _$DirectVoterToJson(DirectVoter instance) => 'voting_key': instance.votingKey, }; +Forbidden _$ForbiddenFromJson(Map json) => Forbidden( + id: json['id'] as String, + msg: json['msg'] as String, + required: (json['required'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + ); + +Map _$ForbiddenToJson(Forbidden instance) => { + 'id': instance.id, + 'msg': instance.msg, + 'required': instance.required, + }; + FragmentStatus _$FragmentStatusFromJson(Map json) => FragmentStatus(); @@ -183,6 +213,18 @@ Map _$FragmentsProcessingSummaryToJson( 'rejected': instance.rejected.map((e) => e.toJson()).toList(), }; +FrontendConfig _$FrontendConfigFromJson(Map json) => + FrontendConfig( + sentry: json['sentry'] == null + ? null + : Sentry.fromJson(json['sentry'] as Map), + ); + +Map _$FrontendConfigToJson(FrontendConfig instance) => + { + 'sentry': instance.sentry?.toJson(), + }; + FullStakeInfo _$FullStakeInfoFromJson(Map json) => FullStakeInfo( volatile: StakeInfo.fromJson(json['volatile'] as Map), @@ -204,6 +246,21 @@ Map _$HashToJson(Hash instance) => { 'hash': instance.hash, }; +InternalServerError _$InternalServerErrorFromJson(Map json) => + InternalServerError( + id: json['id'] as String, + msg: json['msg'] as String, + issue: json['issue'] as String?, + ); + +Map _$InternalServerErrorToJson( + InternalServerError instance) => + { + 'id': instance.id, + 'msg': instance.msg, + 'issue': instance.issue, + }; + InvalidRegistrationsReport _$InvalidRegistrationsReportFromJson( Map json) => InvalidRegistrationsReport( @@ -260,17 +317,28 @@ Map _$RejectedFragmentToJson(RejectedFragment instance) => 'reason': reasonRejectedToJson(instance.reason), }; -ServerError _$ServerErrorFromJson(Map json) => ServerError( +Sentry _$SentryFromJson(Map json) => Sentry( + dsn: json['dsn'] as String, + release: json['release'] as String?, + environment: json['environment'] as String?, + ); + +Map _$SentryToJson(Sentry instance) => { + 'dsn': instance.dsn, + 'release': instance.release, + 'environment': instance.environment, + }; + +ServiceUnavailable _$ServiceUnavailableFromJson(Map json) => + ServiceUnavailable( id: json['id'] as String, msg: json['msg'] as String, - issue: json['issue'] as String?, ); -Map _$ServerErrorToJson(ServerError instance) => +Map _$ServiceUnavailableToJson(ServiceUnavailable instance) => { 'id': instance.id, 'msg': instance.msg, - 'issue': instance.issue, }; Slot _$SlotFromJson(Map json) => Slot( @@ -375,14 +443,43 @@ Map _$SyncStateToJson(SyncState instance) => { 'last_updated': instance.lastUpdated.toIso8601String(), }; -ValidationError _$ValidationErrorFromJson(Map json) => - ValidationError( - message: json['message'] as String, +TooManyRequests _$TooManyRequestsFromJson(Map json) => + TooManyRequests( + id: json['id'] as String, + msg: json['msg'] as String, + ); + +Map _$TooManyRequestsToJson(TooManyRequests instance) => + { + 'id': instance.id, + 'msg': instance.msg, + }; + +Unauthorized _$UnauthorizedFromJson(Map json) => Unauthorized( + id: json['id'] as String, + msg: json['msg'] as String, + ); + +Map _$UnauthorizedToJson(Unauthorized instance) => + { + 'id': instance.id, + 'msg': instance.msg, + }; + +UnprocessableContent _$UnprocessableContentFromJson( + Map json) => + UnprocessableContent( + detail: (json['detail'] as List?) + ?.map( + (e) => ContentErrorDetail.fromJson(e as Map)) + .toList() ?? + [], ); -Map _$ValidationErrorToJson(ValidationError instance) => +Map _$UnprocessableContentToJson( + UnprocessableContent instance) => { - 'message': instance.message, + 'detail': instance.detail.map((e) => e.toJson()).toList(), }; VotePlan _$VotePlanFromJson(Map json) => VotePlan( diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.swagger.chopper.dart b/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.swagger.chopper.dart index 2b3298773b..d73ee5f573 100644 --- a/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.swagger.chopper.dart +++ b/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.swagger.chopper.dart @@ -18,8 +18,8 @@ final class _$CatGatewayApi extends CatGatewayApi { final Type definitionType = CatGatewayApi; @override - Future> _apiHealthStartedGet() { - final Uri $url = Uri.parse('/api/health/started'); + Future> _apiV1HealthStartedGet() { + final Uri $url = Uri.parse('/api/v1/health/started'); final Request $request = Request( 'GET', $url, @@ -29,8 +29,8 @@ final class _$CatGatewayApi extends CatGatewayApi { } @override - Future> _apiHealthReadyGet() { - final Uri $url = Uri.parse('/api/health/ready'); + Future> _apiV1HealthReadyGet() { + final Uri $url = Uri.parse('/api/v1/health/ready'); final Request $request = Request( 'GET', $url, @@ -40,8 +40,8 @@ final class _$CatGatewayApi extends CatGatewayApi { } @override - Future> _apiHealthLiveGet() { - final Uri $url = Uri.parse('/api/health/live'); + Future> _apiV1HealthLiveGet() { + final Uri $url = Uri.parse('/api/v1/health/live'); final Request $request = Request( 'GET', $url, @@ -51,17 +51,17 @@ final class _$CatGatewayApi extends CatGatewayApi { } @override - Future> _apiHealthInspectionGet({ + Future> _apiV1HealthInspectionPut({ String? logLevel, String? queryInspection, }) { - final Uri $url = Uri.parse('/api/health/inspection'); + final Uri $url = Uri.parse('/api/v1/health/inspection'); final Map $params = { 'log_level': logLevel, 'query_inspection': queryInspection, }; final Request $request = Request( - 'GET', + 'PUT', $url, client.baseUrl, parameters: $params, @@ -70,12 +70,12 @@ final class _$CatGatewayApi extends CatGatewayApi { } @override - Future> _apiCardanoStakedAdaStakeAddressGet({ + Future> _apiDraftCardanoStakedAdaStakeAddressGet({ required String? stakeAddress, String? network, int? slotNumber, }) { - final Uri $url = Uri.parse('/api/cardano/staked_ada/${stakeAddress}'); + final Uri $url = Uri.parse('/api/draft/cardano/staked_ada/${stakeAddress}'); final Map $params = { 'network': network, 'slot_number': slotNumber, @@ -90,12 +90,14 @@ final class _$CatGatewayApi extends CatGatewayApi { } @override - Future> _apiCardanoRegistrationStakeAddressGet({ + Future> + _apiDraftCardanoRegistrationStakeAddressGet({ required String? stakeAddress, String? network, int? slotNumber, }) { - final Uri $url = Uri.parse('/api/cardano/registration/${stakeAddress}'); + final Uri $url = + Uri.parse('/api/draft/cardano/registration/${stakeAddress}'); final Map $params = { 'network': network, 'slot_number': slotNumber, @@ -110,8 +112,8 @@ final class _$CatGatewayApi extends CatGatewayApi { } @override - Future> _apiCardanoSyncStateGet({String? network}) { - final Uri $url = Uri.parse('/api/cardano/sync_state'); + Future> _apiDraftCardanoSyncStateGet({String? network}) { + final Uri $url = Uri.parse('/api/draft/cardano/sync_state'); final Map $params = {'network': network}; final Request $request = Request( 'GET', @@ -123,11 +125,11 @@ final class _$CatGatewayApi extends CatGatewayApi { } @override - Future> _apiCardanoDateTimeToSlotNumberGet({ + Future> _apiDraftCardanoDateTimeToSlotNumberGet({ DateTime? dateTime, String? network, }) { - final Uri $url = Uri.parse('/api/cardano/date_time_to_slot_number'); + final Uri $url = Uri.parse('/api/draft/cardano/date_time_to_slot_number'); final Map $params = { 'date_time': dateTime, 'network': network, @@ -143,10 +145,10 @@ final class _$CatGatewayApi extends CatGatewayApi { @override Future> - _apiCardanoCip36LatestRegistrationStakeAddrGet( + _apiDraftCardanoCip36LatestRegistrationStakeAddrGet( {required String? stakeAddr}) { final Uri $url = - Uri.parse('/api/cardano/cip36/latest_registration/stake_addr'); + Uri.parse('/api/draft/cardano/cip36/latest_registration/stake_addr'); final Map $params = { 'stake_addr': stakeAddr }; @@ -161,10 +163,10 @@ final class _$CatGatewayApi extends CatGatewayApi { @override Future> - _apiCardanoCip36LatestRegistrationStakeKeyHashGet( + _apiDraftCardanoCip36LatestRegistrationStakeKeyHashGet( {required String? stakeKeyHash}) { - final Uri $url = - Uri.parse('/api/cardano/cip36/latest_registration/stake_key_hash'); + final Uri $url = Uri.parse( + '/api/draft/cardano/cip36/latest_registration/stake_key_hash'); final Map $params = { 'stake_key_hash': stakeKeyHash }; @@ -179,9 +181,10 @@ final class _$CatGatewayApi extends CatGatewayApi { @override Future> - _apiCardanoCip36LatestRegistrationVoteKeyGet({required String? voteKey}) { + _apiDraftCardanoCip36LatestRegistrationVoteKeyGet( + {required String? voteKey}) { final Uri $url = - Uri.parse('/api/cardano/cip36/latest_registration/vote_key'); + Uri.parse('/api/draft/cardano/cip36/latest_registration/vote_key'); final Map $params = {'vote_key': voteKey}; final Request $request = Request( 'GET', @@ -193,20 +196,20 @@ final class _$CatGatewayApi extends CatGatewayApi { } @override - Future> _apiDraftConfigFrontendGet() { + Future> _apiDraftConfigFrontendGet() { final Uri $url = Uri.parse('/api/draft/config/frontend'); final Request $request = Request( 'GET', $url, client.baseUrl, ); - return client.send($request); + return client.send($request); } @override Future> _apiDraftConfigFrontendPut({ - String? ip, - required Object? body, + Object? ip, + required FrontendConfig? body, }) { final Uri $url = Uri.parse('/api/draft/config/frontend'); final Map $params = {'IP': ip}; @@ -222,25 +225,25 @@ final class _$CatGatewayApi extends CatGatewayApi { } @override - Future> _apiDraftConfigFrontendSchemaGet() { + Future> _apiDraftConfigFrontendSchemaGet() { final Uri $url = Uri.parse('/api/draft/config/frontend/schema'); final Request $request = Request( 'GET', $url, client.baseUrl, ); - return client.send($request); + return client.send($request); } @override - Future> _apiRegistrationVoterVotingKeyGet({ + Future> _apiV1RegistrationVoterVotingKeyGet({ required String? votingKey, - int? eventId, + int? eventIndex, bool? withDelegators, }) { - final Uri $url = Uri.parse('/api/registration/voter/${votingKey}'); + final Uri $url = Uri.parse('/api/v1/registration/voter/${votingKey}'); final Map $params = { - 'event_id': eventId, + 'event_index': eventIndex, 'with_delegators': withDelegators, }; final Request $request = Request( diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.swagger.dart b/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.swagger.dart index da1eb94042..cef66d8404 100644 --- a/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.swagger.dart +++ b/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.swagger.dart @@ -49,58 +49,62 @@ abstract class CatGatewayApi extends ChopperService { } ///Service Started - Future apiHealthStartedGet() { - return _apiHealthStartedGet(); + Future apiV1HealthStartedGet() { + return _apiV1HealthStartedGet(); } ///Service Started - @Get(path: '/api/health/started') - Future _apiHealthStartedGet(); + @Get(path: '/api/v1/health/started') + Future _apiV1HealthStartedGet(); ///Service Ready - Future apiHealthReadyGet() { - return _apiHealthReadyGet(); + Future apiV1HealthReadyGet() { + return _apiV1HealthReadyGet(); } ///Service Ready - @Get(path: '/api/health/ready') - Future _apiHealthReadyGet(); + @Get(path: '/api/v1/health/ready') + Future _apiV1HealthReadyGet(); ///Service Live - Future apiHealthLiveGet() { - return _apiHealthLiveGet(); + Future apiV1HealthLiveGet() { + return _apiV1HealthLiveGet(); } ///Service Live - @Get(path: '/api/health/live') - Future _apiHealthLiveGet(); + @Get(path: '/api/v1/health/live') + Future _apiV1HealthLiveGet(); - ///Options for service inspection. - ///@param log_level - ///@param query_inspection - Future apiHealthInspectionGet({ + ///Service Inspection Control. + ///@param log_level The log level to use for the service. Controls what detail gets logged. + ///@param query_inspection Enable or disable Verbose Query inspection in the logs. Used to find query performance issues. + Future apiV1HealthInspectionPut({ enums.LogLevel? logLevel, enums.DeepQueryInspectionFlag? queryInspection, }) { - return _apiHealthInspectionGet( + return _apiV1HealthInspectionPut( logLevel: logLevel?.value?.toString(), queryInspection: queryInspection?.value?.toString()); } - ///Options for service inspection. - ///@param log_level - ///@param query_inspection - @Get(path: '/api/health/inspection') - Future _apiHealthInspectionGet({ + ///Service Inspection Control. + ///@param log_level The log level to use for the service. Controls what detail gets logged. + ///@param query_inspection Enable or disable Verbose Query inspection in the logs. Used to find query performance issues. + @Put( + path: '/api/v1/health/inspection', + optionalBody: true, + ) + Future _apiV1HealthInspectionPut({ @Query('log_level') String? logLevel, @Query('query_inspection') String? queryInspection, }); - ///Get staked ada amount. - ///@param stake_address The stake address of the user. Should a valid Bech32 encoded address followed by the https://cips.cardano.org/cip/CIP-19/#stake-addresses. + ///Get staked ADA amount. + ///@param stake_address 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. ///@param network 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. - ///@param slot_number Slot number at which the staked ada amount should be calculated. If omitted latest slot number is used. - Future> apiCardanoStakedAdaStakeAddressGet({ + ///@param slot_number Slot number at which the staked ADA amount should be calculated. If omitted latest slot number is used. + Future> + apiDraftCardanoStakedAdaStakeAddressGet({ required String? stakeAddress, enums.Network? network, int? slotNumber, @@ -108,29 +112,30 @@ abstract class CatGatewayApi extends ChopperService { generatedMapping.putIfAbsent( FullStakeInfo, () => FullStakeInfo.fromJsonFactory); - return _apiCardanoStakedAdaStakeAddressGet( + return _apiDraftCardanoStakedAdaStakeAddressGet( stakeAddress: stakeAddress, network: network?.value?.toString(), slotNumber: slotNumber); } - ///Get staked ada amount. - ///@param stake_address The stake address of the user. Should a valid Bech32 encoded address followed by the https://cips.cardano.org/cip/CIP-19/#stake-addresses. + ///Get staked ADA amount. + ///@param stake_address 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. ///@param network 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. - ///@param slot_number Slot number at which the staked ada amount should be calculated. If omitted latest slot number is used. - @Get(path: '/api/cardano/staked_ada/{stake_address}') - Future> _apiCardanoStakedAdaStakeAddressGet({ + ///@param slot_number Slot number at which the staked ADA amount should be calculated. If omitted latest slot number is used. + @Get(path: '/api/draft/cardano/staked_ada/{stake_address}') + Future> + _apiDraftCardanoStakedAdaStakeAddressGet({ @Path('stake_address') required String? stakeAddress, @Query('network') String? network, @Query('slot_number') int? slotNumber, }); ///Get registration info. - ///@param stake_address The stake address of the user. Should a valid Bech32 encoded address followed by the https://cips.cardano.org/cip/CIP-19/#stake-addresses. + ///@param stake_address 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. ///@param network 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. - ///@param slot_number Slot number at which the staked ada amount should be calculated. If omitted latest slot number is used. + ///@param slot_number Slot number at which the staked ADA amount should be calculated. If omitted latest slot number is used. Future> - apiCardanoRegistrationStakeAddressGet({ + apiDraftCardanoRegistrationStakeAddressGet({ required String? stakeAddress, enums.Network? network, int? slotNumber, @@ -138,19 +143,19 @@ abstract class CatGatewayApi extends ChopperService { generatedMapping.putIfAbsent( RegistrationInfo, () => RegistrationInfo.fromJsonFactory); - return _apiCardanoRegistrationStakeAddressGet( + return _apiDraftCardanoRegistrationStakeAddressGet( stakeAddress: stakeAddress, network: network?.value?.toString(), slotNumber: slotNumber); } ///Get registration info. - ///@param stake_address The stake address of the user. Should a valid Bech32 encoded address followed by the https://cips.cardano.org/cip/CIP-19/#stake-addresses. + ///@param stake_address 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. ///@param network 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. - ///@param slot_number Slot number at which the staked ada amount should be calculated. If omitted latest slot number is used. - @Get(path: '/api/cardano/registration/{stake_address}') + ///@param slot_number Slot number at which the staked ADA amount should be calculated. If omitted latest slot number is used. + @Get(path: '/api/draft/cardano/registration/{stake_address}') Future> - _apiCardanoRegistrationStakeAddressGet({ + _apiDraftCardanoRegistrationStakeAddressGet({ @Path('stake_address') required String? stakeAddress, @Query('network') String? network, @Query('slot_number') int? slotNumber, @@ -158,160 +163,171 @@ abstract class CatGatewayApi extends ChopperService { ///Get Cardano follower's sync state. ///@param network Cardano network type. If omitted `mainnet` network type is defined. 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. - Future> apiCardanoSyncStateGet( + Future> apiDraftCardanoSyncStateGet( {enums.Network? network}) { generatedMapping.putIfAbsent(SyncState, () => SyncState.fromJsonFactory); - return _apiCardanoSyncStateGet(network: network?.value?.toString()); + return _apiDraftCardanoSyncStateGet(network: network?.value?.toString()); } ///Get Cardano follower's sync state. ///@param network Cardano network type. If omitted `mainnet` network type is defined. 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. - @Get(path: '/api/cardano/sync_state') - Future> _apiCardanoSyncStateGet( + @Get(path: '/api/draft/cardano/sync_state') + Future> _apiDraftCardanoSyncStateGet( {@Query('network') String? network}); ///Get Cardano slot info to the provided date-time. ///@param date_time The date-time for which the slot number should be calculated. If omitted current date time is used. ///@param network Cardano network type. If omitted `mainnet` network type is defined. 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. - Future> apiCardanoDateTimeToSlotNumberGet({ + Future> apiDraftCardanoDateTimeToSlotNumberGet({ DateTime? dateTime, enums.Network? network, }) { generatedMapping.putIfAbsent(SlotInfo, () => SlotInfo.fromJsonFactory); - return _apiCardanoDateTimeToSlotNumberGet( + return _apiDraftCardanoDateTimeToSlotNumberGet( dateTime: dateTime, network: network?.value?.toString()); } ///Get Cardano slot info to the provided date-time. ///@param date_time The date-time for which the slot number should be calculated. If omitted current date time is used. ///@param network Cardano network type. If omitted `mainnet` network type is defined. 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. - @Get(path: '/api/cardano/date_time_to_slot_number') - Future> _apiCardanoDateTimeToSlotNumberGet({ + @Get(path: '/api/draft/cardano/date_time_to_slot_number') + Future> _apiDraftCardanoDateTimeToSlotNumberGet({ @Query('date_time') DateTime? dateTime, @Query('network') String? network, }); - ///Cip36 registrations - ///@param stake_addr + ///Get latest CIP36 registrations from stake address. + ///@param stake_addr Stake Address to find the latest registration for. Future> - apiCardanoCip36LatestRegistrationStakeAddrGet( + apiDraftCardanoCip36LatestRegistrationStakeAddrGet( {required String? stakeAddr}) { generatedMapping.putIfAbsent( Cip36Reporting, () => Cip36Reporting.fromJsonFactory); - return _apiCardanoCip36LatestRegistrationStakeAddrGet(stakeAddr: stakeAddr); + return _apiDraftCardanoCip36LatestRegistrationStakeAddrGet( + stakeAddr: stakeAddr); } - ///Cip36 registrations - ///@param stake_addr - @Get(path: '/api/cardano/cip36/latest_registration/stake_addr') + ///Get latest CIP36 registrations from stake address. + ///@param stake_addr Stake Address to find the latest registration for. + @Get(path: '/api/draft/cardano/cip36/latest_registration/stake_addr') Future> - _apiCardanoCip36LatestRegistrationStakeAddrGet( + _apiDraftCardanoCip36LatestRegistrationStakeAddrGet( {@Query('stake_addr') required String? stakeAddr}); - ///Cip36 registrations - ///@param stake_key_hash + ///Get latest CIP36 registrations from a stake key hash. + ///@param stake_key_hash Stake Key Hash to find the latest registration for. Future> - apiCardanoCip36LatestRegistrationStakeKeyHashGet( + apiDraftCardanoCip36LatestRegistrationStakeKeyHashGet( {required String? stakeKeyHash}) { generatedMapping.putIfAbsent( Cip36Reporting, () => Cip36Reporting.fromJsonFactory); - return _apiCardanoCip36LatestRegistrationStakeKeyHashGet( + return _apiDraftCardanoCip36LatestRegistrationStakeKeyHashGet( stakeKeyHash: stakeKeyHash); } - ///Cip36 registrations - ///@param stake_key_hash - @Get(path: '/api/cardano/cip36/latest_registration/stake_key_hash') + ///Get latest CIP36 registrations from a stake key hash. + ///@param stake_key_hash Stake Key Hash to find the latest registration for. + @Get(path: '/api/draft/cardano/cip36/latest_registration/stake_key_hash') Future> - _apiCardanoCip36LatestRegistrationStakeKeyHashGet( + _apiDraftCardanoCip36LatestRegistrationStakeKeyHashGet( {@Query('stake_key_hash') required String? stakeKeyHash}); - ///Cip36 registrations - ///@param vote_key + ///Get latest CIP36 registrations from voting key. + ///@param vote_key Voting Key to find CIP36 registrations for. Future> - apiCardanoCip36LatestRegistrationVoteKeyGet({required String? voteKey}) { + apiDraftCardanoCip36LatestRegistrationVoteKeyGet( + {required String? voteKey}) { generatedMapping.putIfAbsent( Cip36ReportingList, () => Cip36ReportingList.fromJsonFactory); - return _apiCardanoCip36LatestRegistrationVoteKeyGet(voteKey: voteKey); + return _apiDraftCardanoCip36LatestRegistrationVoteKeyGet(voteKey: voteKey); } - ///Cip36 registrations - ///@param vote_key - @Get(path: '/api/cardano/cip36/latest_registration/vote_key') + ///Get latest CIP36 registrations from voting key. + ///@param vote_key Voting Key to find CIP36 registrations for. + @Get(path: '/api/draft/cardano/cip36/latest_registration/vote_key') Future> - _apiCardanoCip36LatestRegistrationVoteKeyGet( + _apiDraftCardanoCip36LatestRegistrationVoteKeyGet( {@Query('vote_key') required String? voteKey}); ///Get the configuration for the frontend. - Future apiDraftConfigFrontendGet() { + Future> apiDraftConfigFrontendGet() { + generatedMapping.putIfAbsent( + FrontendConfig, () => FrontendConfig.fromJsonFactory); + return _apiDraftConfigFrontendGet(); } ///Get the configuration for the frontend. @Get(path: '/api/draft/config/frontend') - Future _apiDraftConfigFrontendGet(); + Future> _apiDraftConfigFrontendGet(); ///Set the frontend configuration. - ///@param IP + ///@param IP *OPTIONAL* The IP Address to set the configuration for. Future apiDraftConfigFrontendPut({ - String? ip, - required Object? body, + Object? ip, + required FrontendConfig? body, }) { return _apiDraftConfigFrontendPut(ip: ip, body: body); } ///Set the frontend configuration. - ///@param IP + ///@param IP *OPTIONAL* The IP Address to set the configuration for. @Put( path: '/api/draft/config/frontend', optionalBody: true, ) Future _apiDraftConfigFrontendPut({ - @Query('IP') String? ip, - @Body() required Object? body, + @Query('IP') Object? ip, + @Body() required FrontendConfig? body, }); - ///Get the frontend JSON schema. - Future apiDraftConfigFrontendSchemaGet() { + ///Get the frontend configuration JSON schema. + Future> apiDraftConfigFrontendSchemaGet() { + generatedMapping.putIfAbsent( + FrontendConfig, () => FrontendConfig.fromJsonFactory); + return _apiDraftConfigFrontendSchemaGet(); } - ///Get the frontend JSON schema. + ///Get the frontend configuration JSON schema. @Get(path: '/api/draft/config/frontend/schema') - Future _apiDraftConfigFrontendSchemaGet(); + Future> _apiDraftConfigFrontendSchemaGet(); ///Voter's info ///@param voting_key A Voters Public ED25519 Key (as registered in their most recent valid [CIP-15](https://cips.cardano.org/cips/cip15) or [CIP-36](https://cips.cardano.org/cips/cip36) registration). - ///@param event_id The Event ID to return results for. See [GET Events](Link to events endpoint) for details on retrieving all valid event IDs. + ///@param event_index The Event Index to return results for. See [GET Events](Link to events endpoint) for details on retrieving all valid event IDs. ///@param with_delegators If this optional flag is set, the response will include the delegator's list in the response. Otherwise, it will be omitted. @deprecated - Future> apiRegistrationVoterVotingKeyGet({ + Future> + apiV1RegistrationVoterVotingKeyGet({ required String? votingKey, - int? eventId, + int? eventIndex, bool? withDelegators, }) { generatedMapping.putIfAbsent( VoterRegistration, () => VoterRegistration.fromJsonFactory); - return _apiRegistrationVoterVotingKeyGet( - votingKey: votingKey, eventId: eventId, withDelegators: withDelegators); + return _apiV1RegistrationVoterVotingKeyGet( + votingKey: votingKey, + eventIndex: eventIndex, + withDelegators: withDelegators); } ///Voter's info ///@param voting_key A Voters Public ED25519 Key (as registered in their most recent valid [CIP-15](https://cips.cardano.org/cips/cip15) or [CIP-36](https://cips.cardano.org/cips/cip36) registration). - ///@param event_id The Event ID to return results for. See [GET Events](Link to events endpoint) for details on retrieving all valid event IDs. + ///@param event_index The Event Index to return results for. See [GET Events](Link to events endpoint) for details on retrieving all valid event IDs. ///@param with_delegators If this optional flag is set, the response will include the delegator's list in the response. Otherwise, it will be omitted. @deprecated - @Get(path: '/api/registration/voter/{voting_key}') + @Get(path: '/api/v1/registration/voter/{voting_key}') Future> - _apiRegistrationVoterVotingKeyGet({ + _apiV1RegistrationVoterVotingKeyGet({ @Path('voting_key') required String? votingKey, - @Query('event_id') int? eventId, + @Query('event_index') int? eventIndex, @Query('with_delegators') bool? withDelegators, });