From 7a1b58d84ccdc0773a0a5f1697f15e4c7e2c7669 Mon Sep 17 00:00:00 2001 From: Andre Smith Date: Tue, 9 Jun 2026 14:03:44 -0700 Subject: [PATCH 1/5] refactor(type-registry): adopt SDK canonical-projection pattern Signed-off-by: Andre Smith --- Cargo.lock | 6 + gears/credstore/credstore/Cargo.toml | 2 + gears/credstore/credstore/src/domain/error.rs | 9 +- .../credstore/src/domain/error_tests.rs | 8 +- .../credstore/src/domain/service_tests.rs | 13 +- .../mini-chat/src/gts/permissions.rs | 6 +- .../src/domain/bootstrap/service.rs | 5 +- .../src/domain/bootstrap/service_tests.rs | 31 +- .../src/domain/gts_validation.rs | 18 +- .../src/domain/gts_validation_tests.rs | 51 +- .../src/domain/tenant/service/mod.rs | 5 +- .../domain/tenant/service/service_tests.rs | 86 ++-- .../account-management/src/gear.rs | 5 +- .../account-management/src/gear_tests.rs | 35 +- .../src/infra/types_registry/checker.rs | 21 +- .../src/infra/types_registry/checker_tests.rs | 41 +- .../metadata_schema_registry.rs | 11 +- .../src/infra/types_registry/test_helpers.rs | 30 +- .../account-management/src/tr_plugin/tests.rs | 37 +- .../plugins/static-idp-plugin/Cargo.toml | 2 + .../plugins/static-idp-plugin/src/gear.rs | 5 +- .../authn-resolver/authn-resolver/Cargo.toml | 2 + .../authn-resolver/src/domain/error.rs | 6 +- .../authz-resolver/authz-resolver/Cargo.toml | 2 + .../authz-resolver/src/domain/error.rs | 6 +- .../oagw/oagw/src/infra/type_provisioning.rs | 15 +- .../resource-group/resource-group/Cargo.toml | 7 + .../resource-group/src/domain/validation.rs | 4 +- .../resource-group/tests/common/mod.rs | 53 +- .../tenant-resolver/Cargo.toml | 2 + .../tenant-resolver/src/domain/error.rs | 6 +- .../types-registry-sdk/Cargo.toml | 9 + .../types-registry-sdk/src/api.rs | 96 ++-- .../types-registry-sdk/src/error.rs | 473 ++++++++++-------- .../types-registry-sdk/src/error_tests.rs | 399 +++++++++++---- .../types-registry-sdk/src/field.rs | 193 +++++++ .../types-registry-sdk/src/gts.rs | 27 + .../types-registry-sdk/src/lib.rs | 21 +- .../types-registry-sdk/src/models.rs | 72 ++- .../types-registry-sdk/src/models_tests.rs | 29 +- .../types-registry-sdk/src/precondition.rs | 29 ++ .../types-registry-sdk/src/testing.rs | 89 ++-- .../types-registry/src/api/rest/error.rs | 28 +- .../types-registry/src/domain/error.rs | 165 +----- .../types-registry/src/domain/local_client.rs | 147 +++--- .../src/domain/local_client_tests.rs | 66 ++- .../types-registry/src/domain/service.rs | 21 +- 47 files changed, 1499 insertions(+), 895 deletions(-) create mode 100644 gears/system/types-registry/types-registry-sdk/src/field.rs create mode 100644 gears/system/types-registry/types-registry-sdk/src/gts.rs create mode 100644 gears/system/types-registry/types-registry-sdk/src/precondition.rs diff --git a/Cargo.lock b/Cargo.lock index ae86f368d..9f026134b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1419,6 +1419,7 @@ dependencies = [ "async-trait", "cf-gears-authn-resolver-sdk", "cf-gears-toolkit", + "cf-gears-toolkit-canonical-errors", "cf-gears-toolkit-macros", "cf-gears-toolkit-security", "cf-gears-types-registry-sdk", @@ -1457,6 +1458,7 @@ dependencies = [ "async-trait", "cf-gears-authz-resolver-sdk", "cf-gears-toolkit", + "cf-gears-toolkit-canonical-errors", "cf-gears-toolkit-macros", "cf-gears-toolkit-security", "cf-gears-types-registry-sdk", @@ -1498,6 +1500,7 @@ dependencies = [ "async-trait", "cf-gears-credstore-sdk", "cf-gears-toolkit", + "cf-gears-toolkit-canonical-errors", "cf-gears-toolkit-macros", "cf-gears-toolkit-security", "cf-gears-types-registry-sdk", @@ -2127,6 +2130,7 @@ dependencies = [ "async-trait", "cf-gears-account-management-sdk", "cf-gears-toolkit", + "cf-gears-toolkit-canonical-errors", "cf-gears-toolkit-macros", "cf-gears-toolkit-odata", "cf-gears-toolkit-security", @@ -2191,6 +2195,7 @@ dependencies = [ "async-trait", "cf-gears-tenant-resolver-sdk", "cf-gears-toolkit", + "cf-gears-toolkit-canonical-errors", "cf-gears-toolkit-macros", "cf-gears-toolkit-odata", "cf-gears-toolkit-security", @@ -2651,6 +2656,7 @@ name = "cf-gears-types-registry-sdk" version = "0.2.3" dependencies = [ "async-trait", + "cf-gears-toolkit-canonical-errors", "gts", "serde_json", "thiserror 2.0.18", diff --git a/gears/credstore/credstore/Cargo.toml b/gears/credstore/credstore/Cargo.toml index eea059c4a..d88fef619 100644 --- a/gears/credstore/credstore/Cargo.toml +++ b/gears/credstore/credstore/Cargo.toml @@ -19,6 +19,8 @@ name = "credstore" workspace = true [dependencies] +# Trait boundaries of consumed SDKs are `CanonicalError` (ADR 0005). +toolkit-canonical-errors = { workspace = true } credstore-sdk = { workspace = true } types-registry-sdk = { workspace = true } diff --git a/gears/credstore/credstore/src/domain/error.rs b/gears/credstore/credstore/src/domain/error.rs index 8f12f5138..c4e547551 100644 --- a/gears/credstore/credstore/src/domain/error.rs +++ b/gears/credstore/credstore/src/domain/error.rs @@ -32,9 +32,12 @@ pub enum DomainError { // introduce typed variants) so `.source()` returns the original error, then // remove these allows. #[allow(unknown_lints, de1302_error_from_to_string)] -impl From for DomainError { - fn from(e: types_registry_sdk::TypesRegistryError) -> Self { - Self::Internal(e.to_string()) +impl From for DomainError { + fn from(e: toolkit_canonical_errors::CanonicalError) -> Self { + // Prefer the in-process diagnostic for `Internal`/`Unknown` (the wire + // strips it); other categories carry their message in `detail()` via + // `to_string()`. + Self::Internal(e.diagnostic().map_or_else(|| e.to_string(), str::to_owned)) } } diff --git a/gears/credstore/credstore/src/domain/error_tests.rs b/gears/credstore/credstore/src/domain/error_tests.rs index 414a6badd..05df8d295 100644 --- a/gears/credstore/credstore/src/domain/error_tests.rs +++ b/gears/credstore/credstore/src/domain/error_tests.rs @@ -3,11 +3,13 @@ use toolkit::plugins::ChoosePluginError; use super::*; -// ── From ───────────────────────────────────────────── +// ── From ───────────────────────────────────────────────── #[test] -fn from_types_registry_error_becomes_internal() { - let src = types_registry_sdk::TypesRegistryError::internal("oops"); +fn from_canonical_error_becomes_internal() { + // The types-registry trait boundary is now `CanonicalError` (ADR 0005); + // credstore folds any such error into its opaque `Internal`. + let src = types_registry_sdk::testing::internal("oops"); let dst = DomainError::from(src); assert!(matches!(dst, DomainError::Internal(_))); } diff --git a/gears/credstore/credstore/src/domain/service_tests.rs b/gears/credstore/credstore/src/domain/service_tests.rs index ed89b95d2..d13532a7f 100644 --- a/gears/credstore/credstore/src/domain/service_tests.rs +++ b/gears/credstore/credstore/src/domain/service_tests.rs @@ -3,8 +3,7 @@ use std::sync::Arc; use credstore_sdk::{OwnerId, SecretMetadata, SecretValue, SharingMode, TenantId}; use toolkit::client_hub::{ClientHub, ClientScope}; -use types_registry_sdk::TypesRegistryError; -use types_registry_sdk::testing::{MockTypesRegistryClient, make_test_instance}; +use types_registry_sdk::testing::{self, MockTypesRegistryClient, make_test_instance}; use super::*; use crate::domain::test_support::{MockPlugin, test_ctx}; @@ -80,9 +79,8 @@ async fn get_retries_resolution_on_each_call_when_registry_absent() { // Use a failing registry (not an empty hub) so list() is actually invoked and // we can assert the call count proves no caching. let hub = Arc::new(ClientHub::default()); - let registry = Arc::new( - MockTypesRegistryClient::new().with_list_error(TypesRegistryError::internal("unavailable")), - ); + let registry = + Arc::new(MockTypesRegistryClient::new().with_list_error(testing::internal("unavailable"))); hub.register::(registry.clone() as Arc); let svc = Service::new(hub, "constructorfabric".into()); let key = SecretRef::new("my-key").unwrap(); @@ -147,9 +145,8 @@ async fn resolve_plugin_returns_invalid_when_content_malformed() { #[tokio::test] async fn resolve_plugin_returns_internal_when_registry_list_fails() { let hub = Arc::new(ClientHub::default()); - let registry: Arc = Arc::new( - MockTypesRegistryClient::new().with_list_error(TypesRegistryError::internal("db down")), - ); + let registry: Arc = + Arc::new(MockTypesRegistryClient::new().with_list_error(testing::internal("db down"))); hub.register::(registry); let svc = Service::new(hub, "constructorfabric".into()); diff --git a/gears/mini-chat/mini-chat/src/gts/permissions.rs b/gears/mini-chat/mini-chat/src/gts/permissions.rs index 03efbf809..d7d71cf8d 100644 --- a/gears/mini-chat/mini-chat/src/gts/permissions.rs +++ b/gears/mini-chat/mini-chat/src/gts/permissions.rs @@ -480,7 +480,11 @@ mod tests { .get_instance("gts.cf.toolkit.authz.permission.v1~cf.mini_chat._.nonexistent.v1") .await; assert!( - result.is_err() && result.unwrap_err().is_not_found(), + result.is_err() + && matches!( + result.unwrap_err(), + toolkit_canonical_errors::CanonicalError::NotFound { .. } + ), "unknown permission id must produce NotFound" ); } diff --git a/gears/system/account-management/account-management/src/domain/bootstrap/service.rs b/gears/system/account-management/account-management/src/domain/bootstrap/service.rs index 811daffd5..e54ac20d5 100644 --- a/gears/system/account-management/account-management/src/domain/bootstrap/service.rs +++ b/gears/system/account-management/account-management/src/domain/bootstrap/service.rs @@ -847,7 +847,10 @@ impl BootstrapService { { Ok(Ok(entity)) => entity, Ok(Err(err)) => { - if err.is_not_found() { + if matches!( + err, + toolkit_canonical_errors::CanonicalError::NotFound { .. } + ) { emit_metric( AM_BOOTSTRAP_LIFECYCLE, MetricKind::Counter, diff --git a/gears/system/account-management/account-management/src/domain/bootstrap/service_tests.rs b/gears/system/account-management/account-management/src/domain/bootstrap/service_tests.rs index 6bdbb02f1..fb05da093 100644 --- a/gears/system/account-management/account-management/src/domain/bootstrap/service_tests.rs +++ b/gears/system/account-management/account-management/src/domain/bootstrap/service_tests.rs @@ -30,9 +30,10 @@ use async_trait::async_trait; use std::collections::HashMap; use std::sync::Arc; use time::OffsetDateTime; +use toolkit_canonical_errors::CanonicalError; use types_registry_sdk::{ GtsInstance, GtsTypeId, GtsTypeSchema, InstanceQuery, RegisterResult, TypeSchemaQuery, - TypesRegistryClient, TypesRegistryError, + TypesRegistryClient, }; use uuid::Uuid; @@ -127,7 +128,7 @@ impl StubTypesRegistry { /// [`crate::domain::gts_validation::validate_tenant_name_via_gts`] /// — called from `insert_root_provisioning` — has a registered /// schema to validate `cfg.root_name` against. Without this the - /// helper would short-circuit on `GtsTypeSchemaNotFound` and the + /// helper would short-circuit on `CanonicalError::NotFound` and the /// bounds would not gate the saga in tests. fn canned_tenant_schema() -> GtsTypeSchema { GtsTypeSchema::try_new( @@ -153,16 +154,16 @@ impl TypesRegistryClient for StubTypesRegistry { async fn register( &self, _entities: Vec, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { unreachable!("not exercised by bootstrap") } async fn register_type_schemas( &self, _type_schemas: Vec, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { unreachable!("not exercised by bootstrap") } - async fn get_type_schema(&self, type_id: &str) -> Result { + async fn get_type_schema(&self, type_id: &str) -> Result { // Dispatch by the two ids bootstrap consults: // * `ROOT_TENANT_TYPE` — preflight tenant-type eligibility // (`preflight_root_tenant_type`). @@ -182,55 +183,55 @@ impl TypesRegistryClient for StubTypesRegistry { async fn get_type_schema_by_uuid( &self, _type_uuid: Uuid, - ) -> Result { + ) -> Result { unreachable!("not exercised by bootstrap") } async fn get_type_schemas( &self, _type_ids: Vec, - ) -> HashMap> { + ) -> HashMap> { unreachable!("not exercised by bootstrap") } async fn get_type_schemas_by_uuid( &self, _type_uuids: Vec, - ) -> HashMap> { + ) -> HashMap> { unreachable!("not exercised by bootstrap") } async fn list_type_schemas( &self, _query: TypeSchemaQuery, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { unreachable!("not exercised by bootstrap") } async fn register_instances( &self, _instances: Vec, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { unreachable!("not exercised by bootstrap") } - async fn get_instance(&self, _id: &str) -> Result { + async fn get_instance(&self, _id: &str) -> Result { unreachable!("not exercised by bootstrap") } - async fn get_instance_by_uuid(&self, _uuid: Uuid) -> Result { + async fn get_instance_by_uuid(&self, _uuid: Uuid) -> Result { unreachable!("not exercised by bootstrap") } async fn get_instances( &self, _ids: Vec, - ) -> HashMap> { + ) -> HashMap> { unreachable!("not exercised by bootstrap") } async fn get_instances_by_uuid( &self, _uuids: Vec, - ) -> HashMap> { + ) -> HashMap> { unreachable!("not exercised by bootstrap") } async fn list_instances( &self, _query: InstanceQuery, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { unreachable!("not exercised by bootstrap") } } diff --git a/gears/system/account-management/account-management/src/domain/gts_validation.rs b/gears/system/account-management/account-management/src/domain/gts_validation.rs index eb8cb7a86..e7f9978e2 100644 --- a/gears/system/account-management/account-management/src/domain/gts_validation.rs +++ b/gears/system/account-management/account-management/src/domain/gts_validation.rs @@ -8,7 +8,7 @@ //! against the supplied field values — adapting the //! `cf-resource-group::domain::validation::validate_metadata_via_gts` //! pattern to AM's needs with one deliberate divergence: AM maps a -//! non-not-found `TypesRegistryError` to +//! non-not-found `CanonicalError` to //! [`DomainError::ServiceUnavailable`] (HTTP 503) rather than //! `DomainError::validation` (HTTP 400). A registry outage is an //! infrastructure event, not a payload problem, and the 503 envelope @@ -55,14 +55,13 @@ //! 1 AND 255)` constraint is the last-line guard and bootstrap //! must succeed before the catalog is seeded. //! -//! Failures fall into four categories: -//! * `validate_new_user_payload_via_gts` + -//! `TypesRegistryError::GtsTypeSchemaNotFound` → +//! Failures fall into four categories (the types-registry client returns +//! [`CanonicalError`](toolkit_canonical_errors::CanonicalError) per ADR 0005): +//! * `validate_new_user_payload_via_gts` + `CanonicalError::NotFound` → //! [`DomainError::ServiceUnavailable`] (fail-closed, see above). -//! * `validate_tenant_name_via_gts` + -//! `TypesRegistryError::GtsTypeSchemaNotFound` → `Ok(())` +//! * `validate_tenant_name_via_gts` + `CanonicalError::NotFound` → `Ok(())` //! (DB CHECK + bootstrap-before-catalog ordering). -//! * Other `TypesRegistryError` (transport / availability) → +//! * Any other `CanonicalError` (transport / availability) → //! [`DomainError::ServiceUnavailable`] for both helpers. //! * Schema returned but `jsonschema::validator_for` rejects it → //! [`DomainError::Internal`] (catalog drift — operator action @@ -80,7 +79,8 @@ use account_management_sdk::IdpNewUser; use serde_json::Value; -use types_registry_sdk::{TypesRegistryClient, TypesRegistryError}; +use toolkit_canonical_errors::CanonicalError; +use types_registry_sdk::TypesRegistryClient; use crate::domain::error::DomainError; @@ -199,7 +199,7 @@ async fn lookup_effective_properties( ) -> Result>, DomainError> { match types_registry.get_type_schema(type_id).await { Ok(schema) => Ok(Some(schema.effective_properties())), - Err(TypesRegistryError::GtsTypeSchemaNotFound(_)) => Ok(None), + Err(CanonicalError::NotFound { .. }) => Ok(None), Err(err) => Err(DomainError::service_unavailable(format!( "GTS type schema lookup failed for `{type_id}`: {err}" ))), diff --git a/gears/system/account-management/account-management/src/domain/gts_validation_tests.rs b/gears/system/account-management/account-management/src/domain/gts_validation_tests.rs index f96043800..a57f9d266 100644 --- a/gears/system/account-management/account-management/src/domain/gts_validation_tests.rs +++ b/gears/system/account-management/account-management/src/domain/gts_validation_tests.rs @@ -4,14 +4,14 @@ //! Pin the documented failure-mode arms (mirrors the production //! gear doc on `gts_validation.rs`): //! -//! 1. Schema not registered (`TypesRegistryError::GtsTypeSchemaNotFound`): +//! 1. Schema not registered (`CanonicalError::NotFound`): //! * `validate_new_user_payload_via_gts` → //! `DomainError::ServiceUnavailable` (fail-closed — no //! AM-side storage gate for users, so the boundary helper //! MUST be authoritative on `format` / `pattern` rules). //! * `validate_tenant_name_via_gts` → `Ok(())` (DB CHECK + //! bootstrap-before-catalog ordering remain authoritative). -//! 2. Other `TypesRegistryError` (transport / availability) → +//! 2. Other `CanonicalError` (transport / availability) → //! `DomainError::ServiceUnavailable`. //! 3. Schema returned but `jsonschema::validator_for` rejects it //! (catalog drift) → `DomainError::Internal` — schema published @@ -38,9 +38,10 @@ use account_management_sdk::IdpNewUser; use async_trait::async_trait; use serde_json::{Value, json}; +use toolkit_canonical_errors::CanonicalError; use types_registry_sdk::testing::MockTypesRegistryClient; use types_registry_sdk::{ - GtsInstance, GtsTypeId, GtsTypeSchema, RegisterResult, TypesRegistryClient, TypesRegistryError, + GtsInstance, GtsTypeId, GtsTypeSchema, RegisterResult, TypesRegistryClient, }; use uuid::Uuid; @@ -94,7 +95,7 @@ fn tenant_schema_with_name_max(max_chars: usize) -> GtsTypeSchema { /// Registry stub: every read returns `ServiceUnavailable`. Used to /// pin the transport-error arm of the GTS-validation helper without /// reaching for the heavier `MockTypesRegistryClient` (which always -/// returns `GtsTypeSchemaNotFound` for unknown ids and offers no +/// returns `CanonicalError::NotFound` for unknown ids and offers no /// inject-error seam). /// /// Methods that the helper does not exercise return empty / not-found @@ -103,82 +104,82 @@ fn tenant_schema_with_name_max(max_chars: usize) -> GtsTypeSchema { #[derive(Debug, Default)] struct UnavailableRegistry; -fn unavailable() -> TypesRegistryError { - TypesRegistryError::ServiceUnavailable { - message: "registry transport down (test stub)".to_owned(), - retry_after: std::time::Duration::from_secs(0), - } +fn unavailable() -> CanonicalError { + // Model the scenario under test directly: a temporarily unavailable + // registry. The production helper short-circuits only `NotFound` and + // routes every other canonical category (this one included) to its + // `ServiceUnavailable` arm. + CanonicalError::service_unavailable() + .with_detail("registry transport down (test stub)") + .create() } #[async_trait] impl TypesRegistryClient for UnavailableRegistry { - async fn register( - &self, - _entities: Vec, - ) -> Result, TypesRegistryError> { + async fn register(&self, _entities: Vec) -> Result, CanonicalError> { Err(unavailable()) } async fn register_type_schemas( &self, _schemas: Vec, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { Err(unavailable()) } - async fn get_type_schema(&self, _type_id: &str) -> Result { + async fn get_type_schema(&self, _type_id: &str) -> Result { Err(unavailable()) } async fn get_type_schema_by_uuid( &self, _type_uuid: Uuid, - ) -> Result { + ) -> Result { Err(unavailable()) } async fn get_type_schemas( &self, _ids: Vec, - ) -> std::collections::HashMap> { + ) -> std::collections::HashMap> { std::collections::HashMap::new() } async fn get_type_schemas_by_uuid( &self, _ids: Vec, - ) -> std::collections::HashMap> { + ) -> std::collections::HashMap> { std::collections::HashMap::new() } async fn list_type_schemas( &self, _query: types_registry_sdk::TypeSchemaQuery, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { Ok(Vec::new()) } async fn register_instances( &self, _instances: Vec, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { Err(unavailable()) } - async fn get_instance(&self, _id: &str) -> Result { + async fn get_instance(&self, _id: &str) -> Result { Err(unavailable()) } - async fn get_instance_by_uuid(&self, _uuid: Uuid) -> Result { + async fn get_instance_by_uuid(&self, _uuid: Uuid) -> Result { Err(unavailable()) } async fn get_instances( &self, _ids: Vec, - ) -> std::collections::HashMap> { + ) -> std::collections::HashMap> { std::collections::HashMap::new() } async fn get_instances_by_uuid( &self, _ids: Vec, - ) -> std::collections::HashMap> { + ) -> std::collections::HashMap> { std::collections::HashMap::new() } async fn list_instances( &self, _query: types_registry_sdk::InstanceQuery, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { Ok(Vec::new()) } } diff --git a/gears/system/account-management/account-management/src/domain/tenant/service/mod.rs b/gears/system/account-management/account-management/src/domain/tenant/service/mod.rs index 64ce64f73..d7ba06fb5 100644 --- a/gears/system/account-management/account-management/src/domain/tenant/service/mod.rs +++ b/gears/system/account-management/account-management/src/domain/tenant/service/mod.rs @@ -75,8 +75,9 @@ use account_management_sdk::{ }; use serde_json::Value; use tenant_resolver_sdk::TenantId; +use toolkit_canonical_errors::CanonicalError; use toolkit_odata::{ODataQuery, Page}; -use types_registry_sdk::{TypesRegistryClient, TypesRegistryError}; +use types_registry_sdk::TypesRegistryClient; use crate::config::AccountManagementConfig; use crate::domain::error::DomainError; @@ -299,7 +300,7 @@ impl TenantService { // counter plus the dedicated `am.tenant.service` warn // event below; recovery is a registry restore or a // backfill of the missing schema, not a silent fake. - Err(TypesRegistryError::GtsTypeSchemaNotFound(_)) => { + Err(CanonicalError::NotFound { .. }) => { tracing::warn!( target: "am.tenant.service", tenant_id = %tenant.id, diff --git a/gears/system/account-management/account-management/src/domain/tenant/service/service_tests.rs b/gears/system/account-management/account-management/src/domain/tenant/service/service_tests.rs index 356f846db..b7cc596da 100644 --- a/gears/system/account-management/account-management/src/domain/tenant/service/service_tests.rs +++ b/gears/system/account-management/account-management/src/domain/tenant/service/service_tests.rs @@ -73,21 +73,21 @@ impl types_registry_sdk::TypesRegistryClient for ConstantTypesRegistry { async fn register( &self, _entities: Vec, - ) -> Result, types_registry_sdk::TypesRegistryError> + ) -> Result, toolkit_canonical_errors::CanonicalError> { Ok(Vec::new()) } async fn register_type_schemas( &self, _schemas: Vec, - ) -> Result, types_registry_sdk::TypesRegistryError> + ) -> Result, toolkit_canonical_errors::CanonicalError> { Ok(Vec::new()) } async fn get_type_schema( &self, type_id: &str, - ) -> Result { + ) -> Result { // Surface NOT_FOUND for AM-owned resource schemas // (`gts.cf.core.am.tenant.v1~` / `.user.v1~`) so callers like // `validate_tenant_name_via_gts` short-circuit to `Ok(())` @@ -97,12 +97,12 @@ impl types_registry_sdk::TypesRegistryClient for ConstantTypesRegistry { // synthetic schema because `load_tenant_context` treats // missing entries as a hard failure (`ServiceUnavailable`) // after the IdP-metadata isolation refactor. - Err(types_registry_sdk::TypesRegistryError::gts_type_schema_not_found(type_id)) + Err(types_registry_sdk::testing::not_found(type_id)) } async fn get_type_schema_by_uuid( &self, _type_uuid: Uuid, - ) -> Result { + ) -> Result { Ok(synth_type_schema( "gts.cf.core.am.tenant_type.v1~cf.core.am.customer.v1~", )) @@ -112,7 +112,7 @@ impl types_registry_sdk::TypesRegistryClient for ConstantTypesRegistry { _ids: Vec, ) -> std::collections::HashMap< String, - Result, + Result, > { std::collections::HashMap::new() } @@ -121,38 +121,38 @@ impl types_registry_sdk::TypesRegistryClient for ConstantTypesRegistry { _ids: Vec, ) -> std::collections::HashMap< Uuid, - Result, + Result, > { std::collections::HashMap::new() } async fn list_type_schemas( &self, _query: types_registry_sdk::TypeSchemaQuery, - ) -> Result, types_registry_sdk::TypesRegistryError> + ) -> Result, toolkit_canonical_errors::CanonicalError> { Ok(Vec::new()) } async fn register_instances( &self, _instances: Vec, - ) -> Result, types_registry_sdk::TypesRegistryError> + ) -> Result, toolkit_canonical_errors::CanonicalError> { Ok(Vec::new()) } async fn get_instance( &self, _id: &str, - ) -> Result { - Err(types_registry_sdk::TypesRegistryError::Internal( - "not implemented for constant test fake".into(), + ) -> Result { + Err(types_registry_sdk::testing::internal( + "not implemented for constant test fake", )) } async fn get_instance_by_uuid( &self, _uuid: Uuid, - ) -> Result { - Err(types_registry_sdk::TypesRegistryError::Internal( - "not implemented for constant test fake".into(), + ) -> Result { + Err(types_registry_sdk::testing::internal( + "not implemented for constant test fake", )) } async fn get_instances( @@ -160,7 +160,7 @@ impl types_registry_sdk::TypesRegistryClient for ConstantTypesRegistry { _ids: Vec, ) -> std::collections::HashMap< String, - Result, + Result, > { std::collections::HashMap::new() } @@ -169,20 +169,21 @@ impl types_registry_sdk::TypesRegistryClient for ConstantTypesRegistry { _ids: Vec, ) -> std::collections::HashMap< Uuid, - Result, + Result, > { std::collections::HashMap::new() } async fn list_instances( &self, _query: types_registry_sdk::InstanceQuery, - ) -> Result, types_registry_sdk::TypesRegistryError> { + ) -> Result, toolkit_canonical_errors::CanonicalError> + { Ok(Vec::new()) } } /// Stub registry where `get_type_schema_by_uuid` always returns -/// `GtsTypeSchemaNotFound`. Drives the H1 fix's catalog-drift +/// `CanonicalError::NotFound`. Drives the H1 fix's catalog-drift /// fallback path on `load_tenant_context`. struct UuidNotFoundTypesRegistry; @@ -191,39 +192,37 @@ impl types_registry_sdk::TypesRegistryClient for UuidNotFoundTypesRegistry { async fn register( &self, _entities: Vec, - ) -> Result, types_registry_sdk::TypesRegistryError> + ) -> Result, toolkit_canonical_errors::CanonicalError> { Ok(Vec::new()) } async fn register_type_schemas( &self, _schemas: Vec, - ) -> Result, types_registry_sdk::TypesRegistryError> + ) -> Result, toolkit_canonical_errors::CanonicalError> { Ok(Vec::new()) } async fn get_type_schema( &self, type_id: &str, - ) -> Result { - Err(types_registry_sdk::TypesRegistryError::gts_type_schema_not_found(type_id)) + ) -> Result { + Err(types_registry_sdk::testing::not_found(type_id)) } async fn get_type_schema_by_uuid( &self, type_uuid: Uuid, - ) -> Result { - Err( - types_registry_sdk::TypesRegistryError::gts_type_schema_not_found( - type_uuid.as_simple().to_string(), - ), - ) + ) -> Result { + Err(types_registry_sdk::testing::not_found( + type_uuid.as_simple().to_string(), + )) } async fn get_type_schemas( &self, _ids: Vec, ) -> std::collections::HashMap< String, - Result, + Result, > { std::collections::HashMap::new() } @@ -232,38 +231,38 @@ impl types_registry_sdk::TypesRegistryClient for UuidNotFoundTypesRegistry { _ids: Vec, ) -> std::collections::HashMap< Uuid, - Result, + Result, > { std::collections::HashMap::new() } async fn list_type_schemas( &self, _query: types_registry_sdk::TypeSchemaQuery, - ) -> Result, types_registry_sdk::TypesRegistryError> + ) -> Result, toolkit_canonical_errors::CanonicalError> { Ok(Vec::new()) } async fn register_instances( &self, _instances: Vec, - ) -> Result, types_registry_sdk::TypesRegistryError> + ) -> Result, toolkit_canonical_errors::CanonicalError> { Ok(Vec::new()) } async fn get_instance( &self, _id: &str, - ) -> Result { - Err(types_registry_sdk::TypesRegistryError::Internal( - "not implemented for uuid-not-found fake".into(), + ) -> Result { + Err(types_registry_sdk::testing::internal( + "not implemented for uuid-not-found fake", )) } async fn get_instance_by_uuid( &self, _uuid: Uuid, - ) -> Result { - Err(types_registry_sdk::TypesRegistryError::Internal( - "not implemented for uuid-not-found fake".into(), + ) -> Result { + Err(types_registry_sdk::testing::internal( + "not implemented for uuid-not-found fake", )) } async fn get_instances( @@ -271,7 +270,7 @@ impl types_registry_sdk::TypesRegistryClient for UuidNotFoundTypesRegistry { _ids: Vec, ) -> std::collections::HashMap< String, - Result, + Result, > { std::collections::HashMap::new() } @@ -280,14 +279,15 @@ impl types_registry_sdk::TypesRegistryClient for UuidNotFoundTypesRegistry { _ids: Vec, ) -> std::collections::HashMap< Uuid, - Result, + Result, > { std::collections::HashMap::new() } async fn list_instances( &self, _query: types_registry_sdk::InstanceQuery, - ) -> Result, types_registry_sdk::TypesRegistryError> { + ) -> Result, toolkit_canonical_errors::CanonicalError> + { Ok(Vec::new()) } } @@ -4271,7 +4271,7 @@ async fn hard_delete_batch_redacts_vendor_detail_on_terminal_failure() { // ---- catalog-drift on load_tenant_context surfaces as ServiceUnavailable --- // -// `GtsTypeSchemaNotFound` on `get_type_schema_by_uuid` means the row's +// `CanonicalError::NotFound` on `get_type_schema_by_uuid` means the row's // `tenant_type_uuid` no longer resolves through the Types Registry. // The SDK contract on `IdpTenantContext::tenant_type` requires a // *resolved* chained `GtsTypeId`, so the helper surfaces diff --git a/gears/system/account-management/account-management/src/gear.rs b/gears/system/account-management/account-management/src/gear.rs index 1f8406ad7..68b798a0f 100644 --- a/gears/system/account-management/account-management/src/gear.rs +++ b/gears/system/account-management/account-management/src/gear.rs @@ -967,7 +967,10 @@ impl Gear for AccountManagementGear { // the same ID surfaces immediately. for result in &tr_results { if let RegisterResult::Err { error, .. } = result { - if error.is_already_exists() { + if matches!( + error, + toolkit_canonical_errors::CanonicalError::AlreadyExists { .. } + ) { let existing = types_registry .get_instance(tr_instance_id.as_ref()) .await diff --git a/gears/system/account-management/account-management/src/gear_tests.rs b/gears/system/account-management/account-management/src/gear_tests.rs index f401797bc..2273ef319 100644 --- a/gears/system/account-management/account-management/src/gear_tests.rs +++ b/gears/system/account-management/account-management/src/gear_tests.rs @@ -380,9 +380,10 @@ fn seed_root_at_status(repo: &FakeTenantRepo, status: TenantStatus) { fn stub_types_registry() -> Arc { use async_trait::async_trait; use std::collections::HashMap; + use toolkit_canonical_errors::CanonicalError; use types_registry_sdk::{ GtsInstance, GtsTypeId, GtsTypeSchema, InstanceQuery, RegisterResult, TypeSchemaQuery, - TypesRegistryClient, TypesRegistryError, + TypesRegistryClient, }; struct Stub; @@ -392,19 +393,16 @@ fn stub_types_registry() -> Arc { async fn register( &self, _entities: Vec, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { unreachable!() } async fn register_type_schemas( &self, _type_schemas: Vec, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { unreachable!() } - async fn get_type_schema( - &self, - type_id: &str, - ) -> Result { + async fn get_type_schema(&self, type_id: &str) -> Result { // Route per-id so the bootstrap-time GTS validation seam // does not short-circuit before the IdP retry-loop branch // these tests are designed to exercise. The AM tenant @@ -431,58 +429,55 @@ fn stub_types_registry() -> Arc { async fn get_type_schema_by_uuid( &self, _type_uuid: Uuid, - ) -> Result { + ) -> Result { unreachable!() } async fn get_type_schemas( &self, _type_ids: Vec, - ) -> HashMap> { + ) -> HashMap> { unreachable!() } async fn get_type_schemas_by_uuid( &self, _type_uuids: Vec, - ) -> HashMap> { + ) -> HashMap> { unreachable!() } async fn list_type_schemas( &self, _query: TypeSchemaQuery, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { unreachable!() } async fn register_instances( &self, _instances: Vec, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { unreachable!() } - async fn get_instance(&self, _id: &str) -> Result { + async fn get_instance(&self, _id: &str) -> Result { unreachable!() } - async fn get_instance_by_uuid( - &self, - _uuid: Uuid, - ) -> Result { + async fn get_instance_by_uuid(&self, _uuid: Uuid) -> Result { unreachable!() } async fn get_instances( &self, _ids: Vec, - ) -> HashMap> { + ) -> HashMap> { unreachable!() } async fn get_instances_by_uuid( &self, _uuids: Vec, - ) -> HashMap> { + ) -> HashMap> { unreachable!() } async fn list_instances( &self, _query: InstanceQuery, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { unreachable!() } } diff --git a/gears/system/account-management/account-management/src/infra/types_registry/checker.rs b/gears/system/account-management/account-management/src/infra/types_registry/checker.rs index c1a34380a..6c204cb4f 100644 --- a/gears/system/account-management/account-management/src/infra/types_registry/checker.rs +++ b/gears/system/account-management/account-management/src/infra/types_registry/checker.rs @@ -37,7 +37,8 @@ use std::time::Duration; use async_trait::async_trait; use serde_json::Value; -use types_registry_sdk::{GtsTypeSchema, TypesRegistryClient, TypesRegistryError}; +use toolkit_canonical_errors::CanonicalError; +use types_registry_sdk::{GtsTypeSchema, TypesRegistryClient}; use uuid::Uuid; use crate::domain::error::DomainError; @@ -167,11 +168,11 @@ impl TenantTypeChecker for GtsTenantTypeChecker { // batch return that nonetheless contains a per-entry // transport-flavored error (or a missing required entry — // a broken-client signal) is an `error` for dashboard - // purposes; per-entry `GtsTypeSchemaNotFound` is a domain + // purposes; per-entry `CanonicalError::NotFound` is a domain // condition (`InvalidTenantType`), not a health signal. let had_transport_error = requested.iter().any(|u| match map.get(u) { None => true, - Some(Err(err)) => !matches!(err, TypesRegistryError::GtsTypeSchemaNotFound(_)), + Some(Err(err)) => !matches!(err, CanonicalError::NotFound { .. }), Some(Ok(_)) => false, }); emit_metric( @@ -278,10 +279,10 @@ impl TenantTypeChecker for GtsTenantTypeChecker { /// * missing entry → `ServiceUnavailable` (the SDK contract guarantees /// one entry per requested UUID; a missing entry indicates a broken /// client implementation, not a domain condition) -/// * `GtsTypeSchemaNotFound` → `InvalidTenantType` +/// * `CanonicalError::NotFound` → `InvalidTenantType` /// * any other transport/registry error → `ServiceUnavailable` /// -/// `TypesRegistryError` Display is forwarded into the detail unredacted +/// `CanonicalError` Display is forwarded into the detail unredacted /// (no `redact_provider_detail` here). This is intentional: the GTS /// Types Registry is a CF-internal sibling gear whose error surface /// is curated and safe to expose to the caller, in contrast to the `IdP` @@ -289,7 +290,7 @@ impl TenantTypeChecker for GtsTenantTypeChecker { /// from third-party vendor SDKs and must be redacted before crossing /// the AM boundary. fn resolve_schema<'a>( - entry: Option<&'a Result>, + entry: Option<&'a Result>, uuid: Uuid, role: &'static str, ) -> Result<&'a GtsTypeSchema, DomainError> { @@ -298,11 +299,9 @@ fn resolve_schema<'a>( "types-registry: {role} uuid {uuid} missing from response" ))), Some(Ok(schema)) => Ok(schema), - Some(Err(TypesRegistryError::GtsTypeSchemaNotFound(_))) => { - Err(DomainError::InvalidTenantType { - detail: format!("{role} tenant type {uuid} not registered in GTS"), - }) - } + Some(Err(CanonicalError::NotFound { .. })) => Err(DomainError::InvalidTenantType { + detail: format!("{role} tenant type {uuid} not registered in GTS"), + }), Some(Err(other)) => Err(DomainError::service_unavailable(format!( "types-registry: {other}" ))), diff --git a/gears/system/account-management/account-management/src/infra/types_registry/checker_tests.rs b/gears/system/account-management/account-management/src/infra/types_registry/checker_tests.rs index 482368f3c..051d99142 100644 --- a/gears/system/account-management/account-management/src/infra/types_registry/checker_tests.rs +++ b/gears/system/account-management/account-management/src/infra/types_registry/checker_tests.rs @@ -64,13 +64,13 @@ fn envelope() -> Arc { /// `get_type_schemas_by_uuid` and (transitively, via timeout) /// nothing else; the rest are `unreachable!()`. struct FakeRegistry { - schemas: Mutex>>, + schemas: Mutex>>, delay: Mutex>, calls: Mutex, } impl FakeRegistry { - fn new(entries: Vec<(Uuid, Result)>) -> Self { + fn new(entries: Vec<(Uuid, Result)>) -> Self { Self { schemas: Mutex::new(entries.into_iter().collect()), delay: Mutex::new(None), @@ -89,34 +89,34 @@ impl TypesRegistryClient for FakeRegistry { async fn register( &self, _entities: Vec, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { unreachable!() } async fn register_type_schemas( &self, _type_schemas: Vec, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { unreachable!() } - async fn get_type_schema(&self, _type_id: &str) -> Result { + async fn get_type_schema(&self, _type_id: &str) -> Result { unreachable!() } async fn get_type_schema_by_uuid( &self, _type_uuid: Uuid, - ) -> Result { + ) -> Result { unreachable!("checker uses the batch variant") } async fn get_type_schemas( &self, _type_ids: Vec, - ) -> HashMap> { + ) -> HashMap> { unreachable!() } async fn get_type_schemas_by_uuid( &self, type_uuids: Vec, - ) -> HashMap> { + ) -> HashMap> { *self.calls.lock().expect("lock") += 1; let delay = *self.delay.lock().expect("lock"); if let Some(d) = delay { @@ -125,9 +125,10 @@ impl TypesRegistryClient for FakeRegistry { let map = self.schemas.lock().expect("lock"); let mut out = HashMap::new(); for u in type_uuids { - let entry = map.get(&u).cloned().unwrap_or_else(|| { - Err(TypesRegistryError::gts_type_schema_not_found(u.to_string())) - }); + let entry = map + .get(&u) + .cloned() + .unwrap_or_else(|| Err(types_registry_sdk::testing::not_found(u.to_string()))); out.insert(u, entry); } out @@ -135,37 +136,37 @@ impl TypesRegistryClient for FakeRegistry { async fn list_type_schemas( &self, _query: TypeSchemaQuery, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { unreachable!() } async fn register_instances( &self, _instances: Vec, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { unreachable!() } - async fn get_instance(&self, _id: &str) -> Result { + async fn get_instance(&self, _id: &str) -> Result { unreachable!() } - async fn get_instance_by_uuid(&self, _uuid: Uuid) -> Result { + async fn get_instance_by_uuid(&self, _uuid: Uuid) -> Result { unreachable!() } async fn get_instances( &self, _ids: Vec, - ) -> HashMap> { + ) -> HashMap> { unreachable!() } async fn get_instances_by_uuid( &self, _uuids: Vec, - ) -> HashMap> { + ) -> HashMap> { unreachable!() } async fn list_instances( &self, _query: InstanceQuery, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { unreachable!() } } @@ -503,7 +504,7 @@ async fn rejects_when_allowed_parent_types_is_not_an_array() { async fn rejects_when_registry_returns_unrecognised_error_as_service_unavailable() { let registry = Arc::new(FakeRegistry::new(vec![( Uuid::from_u128(0x1), - Err(TypesRegistryError::internal("registry exploded")), + Err(types_registry_sdk::testing::internal("registry exploded")), )])); let checker = GtsTenantTypeChecker::new(registry); let err = checker @@ -520,7 +521,7 @@ async fn rejects_when_registry_probe_times_out() { let registry = Arc::new( FakeRegistry::new(vec![( Uuid::from_u128(0x1), - Err(TypesRegistryError::internal("never reaches us")), + Err(types_registry_sdk::testing::internal("never reaches us")), )]) .with_delay(Duration::from_millis(50)), ); diff --git a/gears/system/account-management/account-management/src/infra/types_registry/metadata_schema_registry.rs b/gears/system/account-management/account-management/src/infra/types_registry/metadata_schema_registry.rs index d772477ac..674aa2d53 100644 --- a/gears/system/account-management/account-management/src/infra/types_registry/metadata_schema_registry.rs +++ b/gears/system/account-management/account-management/src/infra/types_registry/metadata_schema_registry.rs @@ -30,7 +30,8 @@ use std::sync::Arc; use async_trait::async_trait; use gts::{GtsID, GtsTypeId}; use serde_json::Value; -use types_registry_sdk::{TypesRegistryClient, TypesRegistryError}; +use toolkit_canonical_errors::CanonicalError; +use types_registry_sdk::TypesRegistryClient; use uuid::Uuid; use crate::domain::error::DomainError; @@ -65,16 +66,16 @@ impl GtsMetadataSchemaRegistry { } } -/// Map a `TypesRegistryError` onto the appropriate `DomainError` for +/// Map a `CanonicalError` onto the appropriate `DomainError` for /// the schema-registry seam: /// -/// * `GtsTypeSchemaNotFound` → [`DomainError::MetadataEntryNotFound`] +/// * `CanonicalError::NotFound` → [`DomainError::MetadataEntryNotFound`] /// (HTTP 404 with the unified `resource_type = /// gts.cf.core.am.tenant_metadata.v1~`). /// * any other transport / registry error → `ServiceUnavailable`. -fn map_registry_err(err: TypesRegistryError, schema_token: &str) -> DomainError { +fn map_registry_err(err: CanonicalError, schema_token: &str) -> DomainError { match err { - TypesRegistryError::GtsTypeSchemaNotFound(_) => DomainError::MetadataEntryNotFound { + CanonicalError::NotFound { .. } => DomainError::MetadataEntryNotFound { detail: format!("schema {schema_token} is not registered in the types registry"), entry: schema_token.to_owned(), }, diff --git a/gears/system/account-management/account-management/src/infra/types_registry/test_helpers.rs b/gears/system/account-management/account-management/src/infra/types_registry/test_helpers.rs index 57e05251e..66ef51f6a 100644 --- a/gears/system/account-management/account-management/src/infra/types_registry/test_helpers.rs +++ b/gears/system/account-management/account-management/src/infra/types_registry/test_helpers.rs @@ -12,9 +12,9 @@ use std::collections::HashMap; use std::time::Duration; use async_trait::async_trait; +use toolkit_canonical_errors::CanonicalError; use types_registry_sdk::{ - GtsInstance, GtsTypeSchema, InstanceQuery, RegisterResult, TypeSchemaQuery, - TypesRegistryClient, TypesRegistryError, + GtsInstance, GtsTypeSchema, InstanceQuery, RegisterResult, TypeSchemaQuery, TypesRegistryClient, }; use uuid::Uuid; @@ -39,71 +39,71 @@ impl TypesRegistryClient for SlowRegistry { async fn register( &self, _entities: Vec, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { unreachable!() } async fn register_type_schemas( &self, _type_schemas: Vec, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { unreachable!() } - async fn get_type_schema(&self, _type_id: &str) -> Result { + async fn get_type_schema(&self, _type_id: &str) -> Result { unreachable!() } async fn get_type_schema_by_uuid( &self, _type_uuid: Uuid, - ) -> Result { + ) -> Result { unreachable!() } async fn get_type_schemas( &self, _type_ids: Vec, - ) -> HashMap> { + ) -> HashMap> { unreachable!() } async fn get_type_schemas_by_uuid( &self, _type_uuids: Vec, - ) -> HashMap> { + ) -> HashMap> { tokio::time::sleep(self.delay).await; HashMap::new() } async fn list_type_schemas( &self, _query: TypeSchemaQuery, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { unreachable!() } async fn register_instances( &self, _instances: Vec, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { unreachable!() } - async fn get_instance(&self, _id: &str) -> Result { + async fn get_instance(&self, _id: &str) -> Result { unreachable!() } - async fn get_instance_by_uuid(&self, _uuid: Uuid) -> Result { + async fn get_instance_by_uuid(&self, _uuid: Uuid) -> Result { unreachable!() } async fn get_instances( &self, _ids: Vec, - ) -> HashMap> { + ) -> HashMap> { unreachable!() } async fn get_instances_by_uuid( &self, _uuids: Vec, - ) -> HashMap> { + ) -> HashMap> { unreachable!() } async fn list_instances( &self, _query: InstanceQuery, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { unreachable!() } } diff --git a/gears/system/account-management/account-management/src/tr_plugin/tests.rs b/gears/system/account-management/account-management/src/tr_plugin/tests.rs index 9904fae56..b1ac90eb2 100644 --- a/gears/system/account-management/account-management/src/tr_plugin/tests.rs +++ b/gears/system/account-management/account-management/src/tr_plugin/tests.rs @@ -26,9 +26,9 @@ use tenant_resolver_sdk::{ TenantId, TenantResolverError, TenantResolverPluginClient, TenantStatus as SdkStatus, }; use time::OffsetDateTime; +use toolkit_canonical_errors::CanonicalError; use toolkit_security::SecurityContext; use types_registry_sdk::TypesRegistryClient; -use types_registry_sdk::error::TypesRegistryError; use types_registry_sdk::models::{ GtsInstance, GtsTypeSchema, InstanceQuery, RegisterResult, TypeSchemaQuery, }; @@ -54,7 +54,7 @@ const DELETED: TenantStatus = TenantStatus::Deleted; const TEST_TYPE_ID: &str = "gts.cf.core.test.tenant.v1~"; /// Dual-mode stub: when `fail=false` it returns a fixed `GtsTypeSchema` for -/// any UUID; when `fail=true` every lookup returns `GtsTypeSchemaNotFound`. +/// any UUID; when `fail=true` every lookup returns `CanonicalError::NotFound`. struct TestRegistry { fail: bool, } @@ -74,9 +74,9 @@ impl TypesRegistryClient for TestRegistry { async fn get_type_schema_by_uuid( &self, _uuid: Uuid, - ) -> std::result::Result { + ) -> std::result::Result { if self.fail { - Err(TypesRegistryError::gts_type_schema_not_found("test")) + Err(types_registry_sdk::testing::not_found("test")) } else { Ok(make_test_type_schema(TEST_TYPE_ID)) } @@ -85,12 +85,12 @@ impl TypesRegistryClient for TestRegistry { async fn get_type_schemas_by_uuid( &self, uuids: Vec, - ) -> HashMap> { + ) -> HashMap> { uuids .into_iter() .map(|u| { let res = if self.fail { - Err(TypesRegistryError::gts_type_schema_not_found("test")) + Err(types_registry_sdk::testing::not_found("test")) } else { Ok(make_test_type_schema(TEST_TYPE_ID)) }; @@ -102,74 +102,71 @@ impl TypesRegistryClient for TestRegistry { async fn register( &self, _: Vec, - ) -> std::result::Result, TypesRegistryError> { + ) -> std::result::Result, CanonicalError> { unimplemented!("tr_plugin does not call register") } async fn register_type_schemas( &self, _: Vec, - ) -> std::result::Result, TypesRegistryError> { + ) -> std::result::Result, CanonicalError> { unimplemented!("tr_plugin does not call register_type_schemas") } - async fn get_type_schema( - &self, - _: &str, - ) -> std::result::Result { + async fn get_type_schema(&self, _: &str) -> std::result::Result { unimplemented!("tr_plugin does not call get_type_schema by string id") } async fn get_type_schemas( &self, _: Vec, - ) -> HashMap> { + ) -> HashMap> { unimplemented!("tr_plugin does not call get_type_schemas by string ids") } async fn list_type_schemas( &self, _: TypeSchemaQuery, - ) -> std::result::Result, TypesRegistryError> { + ) -> std::result::Result, CanonicalError> { unimplemented!("tr_plugin does not call list_type_schemas") } async fn register_instances( &self, _: Vec, - ) -> std::result::Result, TypesRegistryError> { + ) -> std::result::Result, CanonicalError> { unimplemented!("tr_plugin does not call register_instances") } - async fn get_instance(&self, _: &str) -> std::result::Result { + async fn get_instance(&self, _: &str) -> std::result::Result { unimplemented!("tr_plugin does not call get_instance") } async fn get_instance_by_uuid( &self, _: Uuid, - ) -> std::result::Result { + ) -> std::result::Result { unimplemented!("tr_plugin does not call get_instance_by_uuid") } async fn get_instances( &self, _: Vec, - ) -> HashMap> { + ) -> HashMap> { unimplemented!("tr_plugin does not call get_instances") } async fn get_instances_by_uuid( &self, _: Vec, - ) -> HashMap> { + ) -> HashMap> { unimplemented!("tr_plugin does not call get_instances_by_uuid") } async fn list_instances( &self, _: InstanceQuery, - ) -> std::result::Result, TypesRegistryError> { + ) -> std::result::Result, CanonicalError> { unimplemented!("tr_plugin does not call list_instances") } } diff --git a/gears/system/account-management/plugins/static-idp-plugin/Cargo.toml b/gears/system/account-management/plugins/static-idp-plugin/Cargo.toml index 3cfb4ca12..ee94a988c 100644 --- a/gears/system/account-management/plugins/static-idp-plugin/Cargo.toml +++ b/gears/system/account-management/plugins/static-idp-plugin/Cargo.toml @@ -17,6 +17,8 @@ name = "static_idp_plugin" workspace = true [dependencies] +# Consumed SDK trait boundaries are `CanonicalError` (ADR 0005). +toolkit-canonical-errors = { workspace = true } # Local dependencies account-management-sdk = { package = "cf-gears-account-management-sdk", version = "0.1.3", path = "../../account-management-sdk" } types-registry-sdk = { package = "cf-gears-types-registry-sdk", version = "0.2.3", path = "../../../types-registry/types-registry-sdk" } diff --git a/gears/system/account-management/plugins/static-idp-plugin/src/gear.rs b/gears/system/account-management/plugins/static-idp-plugin/src/gear.rs index 5a744c216..552f31e47 100644 --- a/gears/system/account-management/plugins/static-idp-plugin/src/gear.rs +++ b/gears/system/account-management/plugins/static-idp-plugin/src/gear.rs @@ -83,7 +83,10 @@ impl Gear for StaticIdpPlugin { // owned TR plugin. for result in &results { if let RegisterResult::Err { error, .. } = result { - if error.is_already_exists() { + if matches!( + error, + toolkit_canonical_errors::CanonicalError::AlreadyExists { .. } + ) { let existing = registry .get_instance(instance_id.as_ref()) diff --git a/gears/system/authn-resolver/authn-resolver/Cargo.toml b/gears/system/authn-resolver/authn-resolver/Cargo.toml index 7f38ac3a3..d12dc2fd5 100644 --- a/gears/system/authn-resolver/authn-resolver/Cargo.toml +++ b/gears/system/authn-resolver/authn-resolver/Cargo.toml @@ -18,6 +18,8 @@ name = "authn_resolver" workspace = true [dependencies] +# Trait boundaries of consumed SDKs are `CanonicalError` (ADR 0005). +toolkit-canonical-errors = { workspace = true } # Local dependencies authn-resolver-sdk = { package = "cf-gears-authn-resolver-sdk", version = "0.3.17", path = "../authn-resolver-sdk" } types-registry-sdk = { package = "cf-gears-types-registry-sdk", version = "0.2.3", path = "../../types-registry/types-registry-sdk" } diff --git a/gears/system/authn-resolver/authn-resolver/src/domain/error.rs b/gears/system/authn-resolver/authn-resolver/src/domain/error.rs index ce6dd624c..70e2dff36 100644 --- a/gears/system/authn-resolver/authn-resolver/src/domain/error.rs +++ b/gears/system/authn-resolver/authn-resolver/src/domain/error.rs @@ -37,9 +37,9 @@ pub enum DomainError { mod error_froms { use super::DomainError; - impl From for DomainError { - fn from(e: types_registry_sdk::TypesRegistryError) -> Self { - Self::Internal(e.to_string()) + impl From for DomainError { + fn from(e: toolkit_canonical_errors::CanonicalError) -> Self { + Self::Internal(e.diagnostic().map_or_else(|| e.to_string(), str::to_owned)) } } diff --git a/gears/system/authz-resolver/authz-resolver/Cargo.toml b/gears/system/authz-resolver/authz-resolver/Cargo.toml index 9fe98ed44..a7d7b84ff 100644 --- a/gears/system/authz-resolver/authz-resolver/Cargo.toml +++ b/gears/system/authz-resolver/authz-resolver/Cargo.toml @@ -18,6 +18,8 @@ name = "authz_resolver" workspace = true [dependencies] +# Trait boundaries of consumed SDKs are `CanonicalError` (ADR 0005). +toolkit-canonical-errors = { workspace = true } # Local dependencies authz-resolver-sdk = { package = "cf-gears-authz-resolver-sdk", version = "0.3.8", path = "../authz-resolver-sdk" } types-registry-sdk = { package = "cf-gears-types-registry-sdk", version = "0.2.3", path = "../../types-registry/types-registry-sdk" } diff --git a/gears/system/authz-resolver/authz-resolver/src/domain/error.rs b/gears/system/authz-resolver/authz-resolver/src/domain/error.rs index c7310f7f3..65516f15e 100644 --- a/gears/system/authz-resolver/authz-resolver/src/domain/error.rs +++ b/gears/system/authz-resolver/authz-resolver/src/domain/error.rs @@ -28,9 +28,9 @@ pub enum DomainError { // introduce typed variants) so `.source()` returns the original error, then // remove these allows. #[allow(unknown_lints, de1302_error_from_to_string)] -impl From for DomainError { - fn from(e: types_registry_sdk::TypesRegistryError) -> Self { - Self::Internal(e.to_string()) +impl From for DomainError { + fn from(e: toolkit_canonical_errors::CanonicalError) -> Self { + Self::Internal(e.diagnostic().map_or_else(|| e.to_string(), str::to_owned)) } } diff --git a/gears/system/oagw/oagw/src/infra/type_provisioning.rs b/gears/system/oagw/oagw/src/infra/type_provisioning.rs index bb4d7f404..44f6f3480 100644 --- a/gears/system/oagw/oagw/src/infra/type_provisioning.rs +++ b/gears/system/oagw/oagw/src/infra/type_provisioning.rs @@ -777,8 +777,8 @@ impl TypeProvisioningService for TypeProvisioningServiceImpl { #[cfg(test)] mod tests { use types_registry_sdk::{ - GtsInstance, TypesRegistryError, - testing::{MockTypesRegistryClient, make_test_instance}, + GtsInstance, + testing::{MockTypesRegistryClient, internal, make_test_instance}, }; use super::*; @@ -890,10 +890,8 @@ mod tests { #[tokio::test] async fn list_upstreams_propagates_registry_error() { - let registry = Arc::new( - MockTypesRegistryClient::new() - .with_list_error(TypesRegistryError::internal("connection lost")), - ); + let registry = + Arc::new(MockTypesRegistryClient::new().with_list_error(internal("connection lost"))); let svc = TypeProvisioningServiceImpl::new(registry); let result = svc.list_upstreams().await; @@ -963,9 +961,8 @@ mod tests { #[tokio::test] async fn list_routes_propagates_registry_error() { - let registry = Arc::new( - MockTypesRegistryClient::new().with_list_error(TypesRegistryError::internal("timeout")), - ); + let registry = + Arc::new(MockTypesRegistryClient::new().with_list_error(internal("timeout"))); let svc = TypeProvisioningServiceImpl::new(registry); let result = svc.list_routes().await; diff --git a/gears/system/resource-group/resource-group/Cargo.toml b/gears/system/resource-group/resource-group/Cargo.toml index ba0fd7d7a..22ff009af 100644 --- a/gears/system/resource-group/resource-group/Cargo.toml +++ b/gears/system/resource-group/resource-group/Cargo.toml @@ -74,3 +74,10 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } toolkit-db = { workspace = true, features = ["sqlite"] } tower = { workspace = true } http-body-util = { workspace = true } +# Enable the SDK `test-util` feature so integration-test fakes can build the +# canonical-error envelopes via `types_registry_sdk::testing`. +types-registry-sdk = { workspace = true, features = ["test-util"] } +# Declared directly so the integration-test fakes that name +# `toolkit_canonical_errors::CanonicalError` don't rely on it being pulled in +# transitively via the normal (`[dependencies]`) build graph. +toolkit-canonical-errors = { workspace = true } diff --git a/gears/system/resource-group/resource-group/src/domain/validation.rs b/gears/system/resource-group/resource-group/src/domain/validation.rs index 6ef1c4a1c..19434a9bf 100644 --- a/gears/system/resource-group/resource-group/src/domain/validation.rs +++ b/gears/system/resource-group/resource-group/src/domain/validation.rs @@ -156,7 +156,9 @@ pub async fn validate_metadata_via_gts( let schema = match types_registry.get_type_schema(type_code).await { Ok(schema) => schema, // No registered schema for this type -- skip metadata validation. - Err(types_registry_sdk::TypesRegistryError::GtsTypeSchemaNotFound(_)) => return Ok(()), + // The trait boundary is `CanonicalError` (ADR 0005); a missing schema + // surfaces as `NotFound` regardless of entity kind. + Err(toolkit_canonical_errors::CanonicalError::NotFound { .. }) => return Ok(()), Err(e) => { return Err(DomainError::validation(format!( "Failed to resolve GTS type '{type_code}' for metadata validation: {e}" diff --git a/gears/system/resource-group/resource-group/tests/common/mod.rs b/gears/system/resource-group/resource-group/tests/common/mod.rs index afd0b8c55..bec7d12d4 100644 --- a/gears/system/resource-group/resource-group/tests/common/mod.rs +++ b/gears/system/resource-group/resource-group/tests/common/mod.rs @@ -43,7 +43,7 @@ impl types_registry_sdk::TypesRegistryClient for StubTypesRegistry { async fn register( &self, _entities: Vec, - ) -> Result, types_registry_sdk::TypesRegistryError> + ) -> Result, toolkit_canonical_errors::CanonicalError> { Ok(vec![]) } @@ -51,7 +51,7 @@ impl types_registry_sdk::TypesRegistryClient for StubTypesRegistry { async fn register_type_schemas( &self, _type_schemas: Vec, - ) -> Result, types_registry_sdk::TypesRegistryError> + ) -> Result, toolkit_canonical_errors::CanonicalError> { Ok(vec![]) } @@ -59,19 +59,17 @@ impl types_registry_sdk::TypesRegistryClient for StubTypesRegistry { async fn get_type_schema( &self, type_id: &str, - ) -> Result { - Err(types_registry_sdk::TypesRegistryError::gts_type_schema_not_found(type_id)) + ) -> Result { + Err(types_registry_sdk::testing::not_found(type_id)) } async fn get_type_schema_by_uuid( &self, type_uuid: Uuid, - ) -> Result { - Err( - types_registry_sdk::TypesRegistryError::gts_type_schema_not_found( - type_uuid.to_string(), - ), - ) + ) -> Result { + Err(types_registry_sdk::testing::not_found( + type_uuid.to_string(), + )) } async fn get_type_schemas( @@ -79,12 +77,12 @@ impl types_registry_sdk::TypesRegistryClient for StubTypesRegistry { type_ids: Vec, ) -> std::collections::HashMap< String, - Result, + Result, > { type_ids .into_iter() .map(|id| { - let err = types_registry_sdk::TypesRegistryError::gts_type_schema_not_found(&id); + let err = types_registry_sdk::testing::not_found(&id); (id, Err(err)) }) .collect() @@ -95,14 +93,12 @@ impl types_registry_sdk::TypesRegistryClient for StubTypesRegistry { type_uuids: Vec, ) -> std::collections::HashMap< Uuid, - Result, + Result, > { type_uuids .into_iter() .map(|uuid| { - let err = types_registry_sdk::TypesRegistryError::gts_type_schema_not_found( - uuid.to_string(), - ); + let err = types_registry_sdk::testing::not_found(uuid.to_string()); (uuid, Err(err)) }) .collect() @@ -111,7 +107,7 @@ impl types_registry_sdk::TypesRegistryClient for StubTypesRegistry { async fn list_type_schemas( &self, _query: types_registry_sdk::TypeSchemaQuery, - ) -> Result, types_registry_sdk::TypesRegistryError> + ) -> Result, toolkit_canonical_errors::CanonicalError> { Ok(vec![]) } @@ -119,7 +115,7 @@ impl types_registry_sdk::TypesRegistryClient for StubTypesRegistry { async fn register_instances( &self, _instances: Vec, - ) -> Result, types_registry_sdk::TypesRegistryError> + ) -> Result, toolkit_canonical_errors::CanonicalError> { Ok(vec![]) } @@ -127,15 +123,15 @@ impl types_registry_sdk::TypesRegistryClient for StubTypesRegistry { async fn get_instance( &self, id: &str, - ) -> Result { - Err(types_registry_sdk::TypesRegistryError::gts_instance_not_found(id)) + ) -> Result { + Err(types_registry_sdk::testing::not_found(id)) } async fn get_instance_by_uuid( &self, uuid: Uuid, - ) -> Result { - Err(types_registry_sdk::TypesRegistryError::gts_instance_not_found(uuid.to_string())) + ) -> Result { + Err(types_registry_sdk::testing::not_found(uuid.to_string())) } async fn get_instances( @@ -143,11 +139,11 @@ impl types_registry_sdk::TypesRegistryClient for StubTypesRegistry { ids: Vec, ) -> std::collections::HashMap< String, - Result, + Result, > { ids.into_iter() .map(|id| { - let err = types_registry_sdk::TypesRegistryError::gts_instance_not_found(&id); + let err = types_registry_sdk::testing::not_found(&id); (id, Err(err)) }) .collect() @@ -158,14 +154,12 @@ impl types_registry_sdk::TypesRegistryClient for StubTypesRegistry { uuids: Vec, ) -> std::collections::HashMap< Uuid, - Result, + Result, > { uuids .into_iter() .map(|uuid| { - let err = types_registry_sdk::TypesRegistryError::gts_instance_not_found( - uuid.to_string(), - ); + let err = types_registry_sdk::testing::not_found(uuid.to_string()); (uuid, Err(err)) }) .collect() @@ -174,7 +168,8 @@ impl types_registry_sdk::TypesRegistryClient for StubTypesRegistry { async fn list_instances( &self, _query: types_registry_sdk::InstanceQuery, - ) -> Result, types_registry_sdk::TypesRegistryError> { + ) -> Result, toolkit_canonical_errors::CanonicalError> + { Ok(vec![]) } } diff --git a/gears/system/tenant-resolver/tenant-resolver/Cargo.toml b/gears/system/tenant-resolver/tenant-resolver/Cargo.toml index 08e7fb187..2628aaed2 100644 --- a/gears/system/tenant-resolver/tenant-resolver/Cargo.toml +++ b/gears/system/tenant-resolver/tenant-resolver/Cargo.toml @@ -18,6 +18,8 @@ name = "tenant_resolver" workspace = true [dependencies] +# Trait boundaries of consumed SDKs are `CanonicalError` (ADR 0005). +toolkit-canonical-errors = { workspace = true } # Local dependencies tenant-resolver-sdk = { package = "cf-gears-tenant-resolver-sdk", version = "0.3.8", path = "../tenant-resolver-sdk" } types-registry-sdk = { package = "cf-gears-types-registry-sdk", version = "0.2.3", path = "../../types-registry/types-registry-sdk" } diff --git a/gears/system/tenant-resolver/tenant-resolver/src/domain/error.rs b/gears/system/tenant-resolver/tenant-resolver/src/domain/error.rs index a56d1dda4..a503c6a47 100644 --- a/gears/system/tenant-resolver/tenant-resolver/src/domain/error.rs +++ b/gears/system/tenant-resolver/tenant-resolver/src/domain/error.rs @@ -36,9 +36,9 @@ pub enum DomainError { // introduce typed variants) so `.source()` returns the original error, then // remove these allows. #[allow(unknown_lints, de1302_error_from_to_string)] -impl From for DomainError { - fn from(e: types_registry_sdk::TypesRegistryError) -> Self { - Self::Internal(e.to_string()) +impl From for DomainError { + fn from(e: toolkit_canonical_errors::CanonicalError) -> Self { + Self::Internal(e.diagnostic().map_or_else(|| e.to_string(), str::to_owned)) } } diff --git a/gears/system/types-registry/types-registry-sdk/Cargo.toml b/gears/system/types-registry/types-registry-sdk/Cargo.toml index acafa265b..9a1488892 100644 --- a/gears/system/types-registry/types-registry-sdk/Cargo.toml +++ b/gears/system/types-registry/types-registry-sdk/Cargo.toml @@ -26,6 +26,15 @@ default = [] test-util = [] [dependencies] +# Per ADR 0005 the `TypesRegistryClient` trait boundary is `CanonicalError`; +# the SDK ships `TypesRegistryError` as an opt-in `From` +# projection (see src/error.rs). The single AIP-193 ladder +# (`From for CanonicalError`) lives in the impl crate's +# `api::rest::error`; this SDK depends on the canonical-errors crate to +# project that envelope into the typed view (and to construct the in-process +# `InvalidArgument` errors its client-side `try_new` constructors emit). +toolkit-canonical-errors = { workspace = true } + # Core dependencies for API trait async-trait = { workspace = true } thiserror = { workspace = true } diff --git a/gears/system/types-registry/types-registry-sdk/src/api.rs b/gears/system/types-registry/types-registry-sdk/src/api.rs index f81035fc3..47e50a6bf 100644 --- a/gears/system/types-registry/types-registry-sdk/src/api.rs +++ b/gears/system/types-registry/types-registry-sdk/src/api.rs @@ -7,9 +7,9 @@ use std::collections::HashMap; use async_trait::async_trait; +use toolkit_canonical_errors::CanonicalError; use uuid::Uuid; -use crate::error::TypesRegistryError; use crate::models::{GtsInstance, GtsTypeSchema, InstanceQuery, RegisterResult, TypeSchemaQuery}; /// Public API trait for the `types-registry` gear. @@ -22,6 +22,20 @@ use crate::models::{GtsInstance, GtsTypeSchema, InstanceQuery, RegisterResult, T /// /// GTS type-schemas and instances are global resources (not tenant-scoped), /// so no security context is required for these operations. +/// +/// # Error envelope +/// +/// Per [ADR 0005][adr] every fallible method returns +/// `Result<_, CanonicalError>`, and every per-item `Result` returned inside a +/// map or [`RegisterResult`] carries `CanonicalError` too. The single +/// authoritative AIP-193 ladder (`From for CanonicalError`) lives +/// in the impl crate's `api::rest::error`; this trait surfaces that envelope +/// unchanged. Consumers may propagate it, or opt into the typed +/// [`TypesRegistryError`](crate::TypesRegistryError) projection +/// (`From`) for flat dispatch — see its gear docs for the +/// dispatch table and the three integration patterns. +/// +/// [adr]: https://github.com/constructorfabric/gears-rust/blob/main/docs/arch/errors/ADR/0005-cpt-cf-adr-sdk-canonical-projection.md #[async_trait] pub trait TypesRegistryClient: Send + Sync { // ------------------------------------------------------------------ @@ -46,7 +60,7 @@ pub trait TypesRegistryClient: Send + Sync { async fn register( &self, entities: Vec, - ) -> Result, TypesRegistryError>; + ) -> Result, CanonicalError>; // ------------------------------------------------------------------ // Type-schema operations (internal — no tenant scoping). @@ -56,10 +70,13 @@ pub trait TypesRegistryClient: Send + Sync { /// /// Each input value must have a GTS id ending with `~`. Inputs whose /// identifier does not match the type-schema kind are returned as - /// per-item `RegisterResult::Err` with `InvalidGtsTypeId`. In ready - /// phase, items whose chain parent is not yet registered fail with - /// `ParentTypeSchemaNotRegistered` (callers may register the parent - /// then retry the failed item). + /// per-item `RegisterResult::Err` carrying an `InvalidArgument` + /// canonical error (project to + /// [`TypesRegistryError::Validation`](crate::TypesRegistryError::Validation)). + /// In ready phase, items whose chain parent is not yet registered fail with + /// a `FailedPrecondition` (project to + /// [`TypesRegistryError::ParentNotRegistered`](crate::TypesRegistryError::ParentNotRegistered) + /// — callers may register the parent then retry the failed item). /// /// # Errors /// @@ -67,40 +84,39 @@ pub trait TypesRegistryClient: Send + Sync { async fn register_type_schemas( &self, type_schemas: Vec, - ) -> Result, TypesRegistryError>; + ) -> Result, CanonicalError>; /// Retrieve a registered GTS type-schema by its type id. /// /// # Errors /// - /// * `GtsTypeSchemaNotFound` — no type-schema with this id is registered. - /// * `InvalidGtsTypeId` — id format is invalid, kind-mismatched, or - /// resolves to a non-type-schema entity. - async fn get_type_schema(&self, type_id: &str) -> Result; + /// * `NotFound` — no type-schema with this id is registered. + /// * `InvalidArgument` (reason `INVALID_GTS_ID`) — id format is invalid, + /// kind-mismatched, or resolves to a non-type-schema entity. + async fn get_type_schema(&self, type_id: &str) -> Result; /// Retrieve a registered GTS type-schema by its deterministic UUID v5. /// /// # Errors /// - /// * `GtsTypeSchemaNotFound` — no type-schema is registered with this UUID - /// (also returned when the UUID exists but points to an instance). + /// * `NotFound` — no type-schema is registered with this UUID (also + /// returned when the UUID exists but points to an instance). async fn get_type_schema_by_uuid( &self, type_uuid: Uuid, - ) -> Result; + ) -> Result; /// Retrieve multiple type-schemas by id in one call. /// /// Returns a map keyed by the input ids; each value is a per-item - /// `Result` carrying either the resolved schema or the per-item error - /// ([`GtsTypeSchemaNotFound`](TypesRegistryError::GtsTypeSchemaNotFound), - /// [`InvalidGtsTypeId`](TypesRegistryError::InvalidGtsTypeId), …). - /// Duplicate ids in the input collapse to a single entry. The map - /// always has a value for every distinct input id. + /// `Result` carrying either the resolved schema or the per-item + /// `CanonicalError` (`NotFound`, or `InvalidArgument` with reason + /// `INVALID_GTS_ID`, …). Duplicate ids in the input collapse to a single + /// entry. The map always has a value for every distinct input id. async fn get_type_schemas( &self, type_ids: Vec, - ) -> HashMap>; + ) -> HashMap>; /// Retrieve multiple type-schemas by deterministic UUID v5 in one call. /// @@ -110,13 +126,13 @@ pub trait TypesRegistryClient: Send + Sync { async fn get_type_schemas_by_uuid( &self, type_uuids: Vec, - ) -> HashMap>; + ) -> HashMap>; /// List registered GTS type-schemas matching the query. async fn list_type_schemas( &self, query: TypeSchemaQuery, - ) -> Result, TypesRegistryError>; + ) -> Result, CanonicalError>; // ------------------------------------------------------------------ // Instance operations (internal — no tenant scoping). @@ -126,9 +142,11 @@ pub trait TypesRegistryClient: Send + Sync { /// /// Each input value must have a GTS id that does NOT end with `~`. Inputs /// whose identifier does not match the instance kind are returned as - /// per-item `RegisterResult::Err` with `InvalidGtsInstanceId`. In ready - /// phase, items whose declaring type-schema is not yet registered fail - /// with `ParentTypeSchemaNotRegistered`. + /// per-item `RegisterResult::Err` carrying an `InvalidArgument` canonical + /// error (reason `INVALID_GTS_ID`). In ready phase, items whose declaring + /// type-schema is not yet registered fail with a `FailedPrecondition` + /// (project to + /// [`TypesRegistryError::ParentNotRegistered`](crate::TypesRegistryError::ParentNotRegistered)). /// /// # Errors /// @@ -136,36 +154,36 @@ pub trait TypesRegistryClient: Send + Sync { async fn register_instances( &self, instances: Vec, - ) -> Result, TypesRegistryError>; + ) -> Result, CanonicalError>; /// Retrieve a registered GTS instance by its instance id. /// /// # Errors /// - /// * `GtsInstanceNotFound` — no instance with this id is registered. - /// * `InvalidGtsInstanceId` — id format is invalid, kind-mismatched, or - /// resolves to a non-instance entity. - async fn get_instance(&self, id: &str) -> Result; + /// * `NotFound` — no instance with this id is registered. + /// * `InvalidArgument` (reason `INVALID_GTS_ID`) — id format is invalid, + /// kind-mismatched, or resolves to a non-instance entity. + async fn get_instance(&self, id: &str) -> Result; /// Retrieve a registered GTS instance by its deterministic UUID v5. /// /// # Errors /// - /// * `GtsInstanceNotFound` — no instance is registered with this UUID - /// (also returned when the UUID exists but points to a type-schema). - async fn get_instance_by_uuid(&self, uuid: Uuid) -> Result; + /// * `NotFound` — no instance is registered with this UUID (also returned + /// when the UUID exists but points to a type-schema). + async fn get_instance_by_uuid(&self, uuid: Uuid) -> Result; /// Retrieve multiple instances by id in one call. /// /// Returns a map keyed by the input ids; each value is a per-item /// `Result` carrying either the resolved instance or the per-item - /// error ([`GtsInstanceNotFound`](TypesRegistryError::GtsInstanceNotFound), - /// [`InvalidGtsInstanceId`](TypesRegistryError::InvalidGtsInstanceId), - /// …). Duplicate ids in the input collapse to a single entry. + /// `CanonicalError` (`NotFound`, or `InvalidArgument` with reason + /// `INVALID_GTS_ID`, …). Duplicate ids in the input collapse to a single + /// entry. async fn get_instances( &self, ids: Vec, - ) -> HashMap>; + ) -> HashMap>; /// Retrieve multiple instances by deterministic UUID v5 in one call. /// @@ -175,11 +193,11 @@ pub trait TypesRegistryClient: Send + Sync { async fn get_instances_by_uuid( &self, uuids: Vec, - ) -> HashMap>; + ) -> HashMap>; /// List registered GTS instances matching the query. async fn list_instances( &self, query: InstanceQuery, - ) -> Result, TypesRegistryError>; + ) -> Result, CanonicalError>; } diff --git a/gears/system/types-registry/types-registry-sdk/src/error.rs b/gears/system/types-registry/types-registry-sdk/src/error.rs index 6d3e64efb..cdc1c4e83 100644 --- a/gears/system/types-registry/types-registry-sdk/src/error.rs +++ b/gears/system/types-registry/types-registry-sdk/src/error.rs @@ -1,219 +1,300 @@ -//! Public error types for the `types-registry` gear. +//! Types Registry SDK error surface — typed projection of [`CanonicalError`]. //! -//! These errors are safe to expose to other gears and consumers. The -//! taxonomy is symmetric across kinds: every kind-specific failure gets a -//! kind-specific variant (`*GtsTypeSchema*` vs `*GtsInstance*`) so callers -//! can match on the variant they care about without parsing messages. - -use std::time::Duration; +//! # Opt-in convenience, not the contract +//! +//! Per [ADR 0005][adr] the [`TypesRegistryClient`] trait boundary is +//! `Result<_, CanonicalError>` (and every per-item `Result` it returns inside a +//! map or [`RegisterResult`](crate::RegisterResult) carries `CanonicalError` +//! too). [`TypesRegistryError`] is an **opt-in** typed view over that envelope, +//! shipped for consumers that want flat dispatch on the categories +//! types-registry emits. It is *not* part of the trait contract: adding a +//! variant is non-breaking, and the single authoritative AIP-193 classification +//! lives in the impl crate's one `From for CanonicalError` ladder +//! (`api::rest::error`) — this projection only reads the finished +//! `CanonicalError`. +//! +//! The conversion is infallible (`From`). Canonical categories +//! types-registry does not emit fall through to [`TypesRegistryError::Other`], +//! which preserves the full [`CanonicalError`] for inspection / forward-compatible +//! dispatch on the inner variant. +//! +//! # What types-registry emits — consumer dispatch reference +//! +//! | Disposition | Match arm | HTTP | +//! |---|---|---| +//! | invalid GTS id / query / entity content (inspect [`FieldIssue::reason`]) | [`TypesRegistryError::Validation`] | 400 | +//! | type-schema / instance missing | [`TypesRegistryError::NotFound`] | 404 | +//! | duplicate-on-register | [`TypesRegistryError::AlreadyExists`] | 409 | +//! | batch register: required parent type-schema absent | [`TypesRegistryError::ParentNotRegistered`] | — (in-process batch outcome) | +//! | registry still initializing | [`TypesRegistryError::Unavailable`] | 503 | +//! | internal failure | [`TypesRegistryError::Internal`] | 500 | +//! | anything else (forward-compat) | [`TypesRegistryError::Other`] | — | +//! +//! Resource-scoped variants ([`TypesRegistryError::NotFound`] / +//! [`TypesRegistryError::AlreadyExists`]) carry the raw `resource_type`; match it +//! against [`crate::gts::TYPE_RESOURCE_TYPE`]. The type-schema-vs-instance +//! distinction the legacy error enum carried is intentionally **not** modeled: +//! it is redundant with the method the caller invoked (`get_type_schema` vs +//! `get_instance`) and with the `~` suffix of the `gts_id`, and the canonical +//! boundary classifies both kinds identically (ADR 0005 single-classification). +//! +//! [`TypesRegistryError::ParentNotRegistered`] is the one structured +//! batch-registration outcome; it is reconstructed losslessly from a +//! `FailedPrecondition` whose `violations[].type` is +//! [`precondition::PARENT_NOT_REGISTERED`](crate::precondition::PARENT_NOT_REGISTERED) +//! (`subject` ⇒ parent id, `resource_name` ⇒ dependent id). +//! +//! # Consumer integration — three patterns +//! +//! **Pattern 1 — pure propagation (no projection):** +//! +//! ```ignore +//! let schema = tr_client.get_type_schema(type_id).await?; // ? propagates CanonicalError +//! ``` +//! +//! **Pattern 2 — explicit projection at the call site:** +//! +//! ```ignore +//! use types_registry_sdk::TypesRegistryError; +//! +//! let res = tr_client.get_type_schema(type_id).await +//! .map_err(TypesRegistryError::from); +//! match res { +//! Err(TypesRegistryError::NotFound { .. }) => /* unregistered type */, +//! Err(TypesRegistryError::Unavailable { .. }) => /* retry: still initializing */, +//! _ => /* … */, +//! } +//! ``` +//! +//! **Pattern 3 — transparent chaining via `From for OwnError`:** +//! +//! ```ignore +//! impl From for OwnConsumerError { +//! fn from(err: CanonicalError) -> Self { +//! TypesRegistryError::from(err).into() // route through the typed view +//! } +//! } +//! // then every call site stays plain `?`. +//! ``` +//! +//! Out-of-process consumers reconstruct the canonical error from the wire via +//! `TryFrom for CanonicalError` first, then project: +//! `Problem JSON → Problem → CanonicalError → TypesRegistryError`. +//! +//! [`TypesRegistryClient`]: crate::TypesRegistryClient +//! [adr]: https://github.com/constructorfabric/gears-rust/blob/main/docs/arch/errors/ADR/0005-cpt-cf-adr-sdk-canonical-projection.md use thiserror::Error; +use toolkit_canonical_errors::{CanonicalError, InvalidArgument}; + +use crate::field::ValidationReason; +use crate::precondition::PARENT_NOT_REGISTERED; + +/// A single field-violation projected from a canonical +/// `InvalidArgument.field_violations[]` entry. +/// +/// `reason` is the typed [`ValidationReason`] discriminator consumers dispatch +/// on; `field` is the raw attribution identifier (not a discriminator); +/// `description` is the human-readable message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FieldIssue { + /// The request field the violation is attributed to (e.g. + /// [`field::GTS_ID_FIELD`](crate::field::GTS_ID_FIELD)). + pub field: String, + /// The typed reason discriminator. + pub reason: ValidationReason, + /// Human-readable description of the violation. + pub description: String, +} -/// Errors that can be returned by the `TypesRegistryClient`. +/// Typed projection of [`CanonicalError`] for Types Registry consumers. +/// +/// The impl crate's `From for CanonicalError` is the single +/// authoritative AIP-193 mapping; this enum is a forward-compatible, flat view +/// over the categories types-registry emits, plus the mandatory catch-all +/// [`Self::Other`]. See the [gear docs](self) for the dispatch table and +/// consumer patterns. #[derive(Error, Debug, Clone)] +#[non_exhaustive] pub enum TypesRegistryError { - /// The string is not a valid GTS type-schema identifier. - /// - /// Covers parse failures, kind mismatches (an instance id was passed - /// where a type-schema id was expected), and lookups that resolved to - /// a non-type-schema entity. - #[error("Invalid GTS type-schema id: {0}")] - InvalidGtsTypeId(String), - - /// The string is not a valid GTS instance identifier. - /// - /// Covers parse failures, kind mismatches, missing chain prefix, and - /// chain-prefix mismatches against a passed type-schema. - #[error("Invalid GTS instance id: {0}")] - InvalidGtsInstanceId(String), - - /// No GTS type-schema is registered under the given id or UUID. - #[error("GTS type-schema not found: {0}")] - GtsTypeSchemaNotFound(String), - - /// No GTS instance is registered under the given id or UUID. - #[error("GTS instance not found: {0}")] - GtsInstanceNotFound(String), - - /// Cannot register an entity because its required parent type-schema is - /// not yet registered. The client should register the parent first and - /// retry the failed entity. - #[error( - "Cannot register {dependent_id}: required type-schema {parent_type_id} is not registered" - )] - ParentTypeSchemaNotRegistered { - /// The parent type-schema id that must be registered first. - parent_type_id: String, - /// The id of the entity whose registration failed. - dependent_id: String, + /// Request-shape validation failure (invalid GTS id, query, or entity + /// content). Each [`FieldIssue`] carries a typed + /// [`ValidationReason`](crate::field::ValidationReason); types-registry + /// emits exactly one issue per error today, but the `Vec` mirrors the + /// canonical `field_violations` carrier. + #[error("validation failed: {} issue(s)", issues.len())] + Validation { + /// The projected field violations. + issues: Vec, }, - /// An entity with the same GTS ID already exists. - #[error("Entity already exists: {0}")] - AlreadyExists(String), - - /// The list/query parameters are syntactically invalid (e.g., a pattern - /// that doesn't follow GTS wildcard rules — section 10 of the GTS spec - /// requires a single trailing `*` anchored at a segment boundary). - #[error("Invalid query: {0}")] - InvalidQuery(String), - - /// Validation of the entity content failed. - #[error("Validation failed: {0}")] - ValidationFailed(String), - - /// The service is not currently available (e.g., still initializing). - /// `retry_after` is a hint for how long the caller should wait before - /// retrying; `message` carries a human-readable reason. - #[error("Service unavailable: {message} (retry after {retry_after:?})")] - ServiceUnavailable { - /// Human-readable reason the service is not available. - message: String, - /// Suggested delay before retrying. - retry_after: Duration, + /// No type-schema or instance is registered under the requested id / UUID. + /// `resource_type` is the canonical GTS type — match it against + /// [`crate::gts::TYPE_RESOURCE_TYPE`]; `name` is the raw identifier the + /// caller supplied. + #[error("not found [{resource_type}]: {name}")] + NotFound { + resource_type: String, + name: String, + detail: String, }, - /// An internal error occurred. - #[error("Internal error: {0}")] - Internal(String), -} - -impl TypesRegistryError { - /// Creates an `InvalidGtsTypeId` error. - #[must_use] - pub fn invalid_gts_type_id(message: impl Into) -> Self { - Self::InvalidGtsTypeId(message.into()) - } - - /// Creates an `InvalidGtsInstanceId` error. - #[must_use] - pub fn invalid_gts_instance_id(message: impl Into) -> Self { - Self::InvalidGtsInstanceId(message.into()) - } - - /// Creates a `GtsTypeSchemaNotFound` error. - #[must_use] - pub fn gts_type_schema_not_found(id_or_uuid: impl Into) -> Self { - Self::GtsTypeSchemaNotFound(id_or_uuid.into()) - } - - /// Creates a `GtsInstanceNotFound` error. - #[must_use] - pub fn gts_instance_not_found(id_or_uuid: impl Into) -> Self { - Self::GtsInstanceNotFound(id_or_uuid.into()) - } + /// An entity with the same GTS id already exists (duplicate-on-register). + #[error("already exists [{resource_type}]: {name}")] + AlreadyExists { + resource_type: String, + name: String, + detail: String, + }, - /// Creates a `ParentTypeSchemaNotRegistered` error. - #[must_use] - pub fn parent_type_schema_not_registered( - parent_type_id: impl Into, - dependent_id: impl Into, - ) -> Self { - Self::ParentTypeSchemaNotRegistered { - parent_type_id: parent_type_id.into(), - dependent_id: dependent_id.into(), - } - } + /// Batch register: an entity could not be registered because its required + /// parent type-schema is not yet registered. Register `parent_type_id` + /// first, then retry `dependent_id`. Reconstructed losslessly from a + /// `FailedPrecondition` whose `violations[].type` is + /// [`precondition::PARENT_NOT_REGISTERED`](crate::precondition::PARENT_NOT_REGISTERED). + #[error( + "cannot register {dependent_id}: required type-schema {parent_type_id} is not registered" + )] + ParentNotRegistered { + parent_type_id: String, + dependent_id: String, + detail: String, + }, - /// Creates an `AlreadyExists` error. - #[must_use] - pub fn already_exists(gts_id: impl Into) -> Self { - Self::AlreadyExists(gts_id.into()) - } + /// The registry is not currently available (e.g. still initializing). + #[error("service unavailable: {detail}")] + Unavailable { detail: String }, - /// Creates an `InvalidQuery` error. - #[must_use] - pub fn invalid_query(message: impl Into) -> Self { - Self::InvalidQuery(message.into()) - } + /// Unclassified internal failure (HTTP 500). `detail` is already redacted + /// at the canonical boundary — it never carries the server-side diagnostic. + #[error("internal error: {detail}")] + Internal { detail: String }, - /// Creates a `ValidationFailed` error. - #[must_use] - pub fn validation_failed(message: impl Into) -> Self { - Self::ValidationFailed(message.into()) - } + /// Catch-all for canonical categories types-registry does not model — + /// preserves the full [`CanonicalError`] so consumers stay + /// forward-compatible if the impl crate ever emits a new category. + #[error("[{}] {}", canonical.gts_type(), canonical.detail())] + Other { canonical: CanonicalError }, +} - /// Creates a `ServiceUnavailable` error with a human-readable `message` - /// and a `retry_after` hint. - #[must_use] - pub fn service_unavailable(message: impl Into, retry_after: Duration) -> Self { - Self::ServiceUnavailable { - message: message.into(), - retry_after, +// ───────────────────────────────────────────────────────────────────── +// CanonicalError → TypesRegistryError projection. +// +// The typed sub-enum (`field::ValidationReason`) lives next to its wire-string +// constants in `crate::field`; the precondition `type` discriminator stays a +// plain const in `crate::precondition`. This file owns only the top-level enum +// and the dispatch from `CanonicalError`. +// ───────────────────────────────────────────────────────────────────── + +impl From for TypesRegistryError { + fn from(err: CanonicalError) -> Self { + // Borrow the canonical detail before consuming `err`; the borrow ends + // here so each arm below can move its fields out (no clones). + let detail = err.detail().to_owned(); + match err { + CanonicalError::InvalidArgument { ctx, .. } => Self::Validation { + issues: project_field_issues(ctx), + }, + + // A modeled `NotFound`/`AlreadyExists` always carries its + // `resource_type` (callers dispatch on it via `TYPE_RESOURCE_TYPE`). + // A canonical envelope missing that metadata is malformed for our + // purposes — fall through to `Other` (the `_` arm) so the empty + // string never masquerades as a typed resource type, and the full + // `CanonicalError` is preserved for inspection. + CanonicalError::NotFound { + resource_type: Some(resource_type), + resource_name, + .. + } => Self::NotFound { + resource_type, + name: resource_name.unwrap_or_default(), + detail, + }, + + CanonicalError::AlreadyExists { + resource_type: Some(resource_type), + resource_name, + .. + } => Self::AlreadyExists { + resource_type, + name: resource_name.unwrap_or_default(), + detail, + }, + + // The only `FailedPrecondition` types-registry emits is + // parent-type-schema-not-registered, discriminated by the + // `PARENT_NOT_REGISTERED` violation type. The dependent id rides in + // `resource_name`; the parent id and message ride in that violation + // (`subject` / `description`). Any other precondition shape is + // unmodeled and falls through to `Other` (the `_` arm below). + CanonicalError::FailedPrecondition { + ctx, resource_name, .. + } if ctx + .violations + .iter() + .any(|v| v.type_ == PARENT_NOT_REGISTERED) => + { + let dependent_id = resource_name.unwrap_or_default(); + match ctx + .violations + .into_iter() + .find(|v| v.type_ == PARENT_NOT_REGISTERED) + { + Some(v) => Self::ParentNotRegistered { + parent_type_id: v.subject, + dependent_id, + detail: v.description, + }, + // Unreachable: the guard guaranteed a matching violation. + // Kept total rather than panicking. + None => Self::ParentNotRegistered { + parent_type_id: String::new(), + dependent_id, + detail, + }, + } + } + + CanonicalError::ServiceUnavailable { .. } => Self::Unavailable { detail }, + + CanonicalError::Internal { .. } => Self::Internal { detail }, + + other => Self::Other { canonical: other }, } } +} - /// Returns `true` if this is a `ServiceUnavailable` error. - #[must_use] - pub const fn is_service_unavailable(&self) -> bool { - matches!(self, Self::ServiceUnavailable { .. }) - } - - /// Creates an `Internal` error. - #[must_use] - pub fn internal(message: impl Into) -> Self { - Self::Internal(message.into()) - } - - /// Returns `true` if this is an `InvalidGtsTypeId` error. - #[must_use] - pub const fn is_invalid_gts_type_id(&self) -> bool { - matches!(self, Self::InvalidGtsTypeId(_)) - } - - /// Returns `true` if this is an `InvalidGtsInstanceId` error. - #[must_use] - pub const fn is_invalid_gts_instance_id(&self) -> bool { - matches!(self, Self::InvalidGtsInstanceId(_)) - } - - /// Returns `true` if this is a `GtsTypeSchemaNotFound` error. - #[must_use] - pub const fn is_gts_type_schema_not_found(&self) -> bool { - matches!(self, Self::GtsTypeSchemaNotFound(_)) - } - - /// Returns `true` if this is a `GtsInstanceNotFound` error. - #[must_use] - pub const fn is_gts_instance_not_found(&self) -> bool { - matches!(self, Self::GtsInstanceNotFound(_)) - } - - /// Returns `true` if this is any kind of not-found error - /// (`GtsTypeSchemaNotFound` or `GtsInstanceNotFound`). - #[must_use] - pub const fn is_not_found(&self) -> bool { - matches!( - self, - Self::GtsTypeSchemaNotFound(_) | Self::GtsInstanceNotFound(_) - ) - } - - /// Returns `true` if this is a `ParentTypeSchemaNotRegistered` error. - #[must_use] - pub const fn is_parent_type_schema_not_registered(&self) -> bool { - matches!(self, Self::ParentTypeSchemaNotRegistered { .. }) - } - - /// Returns `true` if this is an already-exists error. - #[must_use] - pub const fn is_already_exists(&self) -> bool { - matches!(self, Self::AlreadyExists(_)) - } - - /// Returns `true` if this is an `InvalidQuery` error. - #[must_use] - pub const fn is_invalid_query(&self) -> bool { - matches!(self, Self::InvalidQuery(_)) - } - - /// Returns `true` if this is a validation error. - #[must_use] - pub const fn is_validation_failed(&self) -> bool { - matches!(self, Self::ValidationFailed(_)) +/// Project a canonical `InvalidArgument` context into the typed field issues. +/// +/// Types-registry only ever emits the `FieldViolations` shape; the `Format` / +/// `Constraint` shapes are mapped into a single field-less issue tagged with a +/// distinct synthetic sentinel ([`ValidationReason::Format`] / +/// [`ValidationReason::Constraint`]) so the discriminator is unambiguous and +/// the message is preserved, even though the impl never produces them today. +fn project_field_issues(ctx: InvalidArgument) -> Vec { + match ctx { + InvalidArgument::FieldViolations { field_violations } => field_violations + .into_iter() + .map(|v| FieldIssue { + field: v.field, + reason: ValidationReason::from_wire(&v.reason), + description: v.description, + }) + .collect(), + InvalidArgument::Format { format } => vec![FieldIssue { + field: String::new(), + reason: ValidationReason::Format, + description: format, + }], + InvalidArgument::Constraint { constraint } => vec![FieldIssue { + field: String::new(), + reason: ValidationReason::Constraint, + description: constraint, + }], } } #[cfg(test)] #[path = "error_tests.rs"] -mod tests; +mod error_tests; diff --git a/gears/system/types-registry/types-registry-sdk/src/error_tests.rs b/gears/system/types-registry/types-registry-sdk/src/error_tests.rs index df401c6cc..71ace4152 100644 --- a/gears/system/types-registry/types-registry-sdk/src/error_tests.rs +++ b/gears/system/types-registry/types-registry-sdk/src/error_tests.rs @@ -1,106 +1,327 @@ -//! Unit tests for [`TypesRegistryError`](super::TypesRegistryError). +//! Tests for the [`TypesRegistryError`](super::TypesRegistryError) projection. //! -//! Kept in a sibling `_tests.rs` file per the `de1101_tests_in_separate_files` -//! repo lint. Linked into `error.rs` via `#[path = "error_tests.rs"] mod tests;`, -//! so the gear sees `error.rs` as `super`. - -use std::time::Duration; +//! Two suites: +//! +//! * `wire_vocabulary_round_trip` — pins every wire-string constant the +//! projection introduces ([`crate::field`], [`crate::precondition`], +//! [`crate::gts`]) to its `Problem` JSON path. A drift between an SDK constant +//! and the wire trips here. +//! * `projection_tests` — exercises `From`, verifying each +//! canonical category lands on the expected typed variant and that unmodeled +//! categories preserve the canonical in `Other`. use super::TypesRegistryError; -#[test] -fn test_invalid_query_constructor() { - let err = TypesRegistryError::invalid_query("bad pattern: too many wildcards"); - assert!(err.is_invalid_query()); - assert!(err.to_string().contains("bad pattern")); -} +// ───────────────────────────────────────────────────────────────────── +// Wire-vocabulary round-trip +// ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod wire_vocabulary_round_trip { + use crate::gts::{self, TypeResource}; + use crate::{field, precondition}; + use toolkit_canonical_errors::{CanonicalError, Problem}; + + fn problem(err: CanonicalError) -> serde_json::Value { + serde_json::to_value(Problem::from(err)).expect("Problem serializes") + } + + #[test] + fn gts_resource_type_round_trips_to_context_resource_type() { + // Also pins the SDK `TypeResource` marker literal == the const. + let err = TypeResource::not_found("x").with_resource("x").create(); + let json = problem(err); + assert_eq!( + json["context"]["resource_type"], + gts::TYPE_RESOURCE_TYPE, + "resource type must round-trip into context.resource_type", + ); + } + + #[test] + fn field_reason_constants_round_trip_to_field_violations() { + for (field_name, reason) in [ + (field::GTS_ID_FIELD, field::INVALID_GTS_ID), + (field::QUERY_FIELD, field::INVALID_QUERY), + (field::ENTITY_FIELD, field::VALIDATION_FAILED), + ] { + let err = TypeResource::invalid_argument() + .with_field_violation(field_name, "bad", reason) + .create(); + let json = problem(err); + assert_eq!( + json["context"]["field_violations"][0]["reason"], reason, + "reason {reason} must round-trip into field_violations[].reason", + ); + assert_eq!( + json["context"]["field_violations"][0]["field"], field_name, + "field {field_name} must round-trip into field_violations[].field", + ); + } + } -#[test] -fn test_invalid_query_distinct_from_invalid_gts_ids() { - let err = TypesRegistryError::invalid_query("foo"); - assert!(!err.is_invalid_gts_type_id()); - assert!(!err.is_invalid_gts_instance_id()); + #[test] + fn parent_not_registered_type_round_trips_to_violations() { + let err = TypeResource::failed_precondition() + .with_resource("gts.acme.core.events.base.v1~acme.x.derived.v1.0~") + .with_precondition_violation( + "gts.acme.core.events.base.v1~", + "required type-schema is not registered", + precondition::PARENT_NOT_REGISTERED, + ) + .create(); + let json = problem(err); + assert_eq!( + json["context"]["violations"][0]["type"], + precondition::PARENT_NOT_REGISTERED, + "type must round-trip into violations[].type", + ); + assert_eq!( + json["context"]["violations"][0]["subject"], "gts.acme.core.events.base.v1~", + "parent id must round-trip into violations[].subject", + ); + } } -#[test] -fn test_error_constructors() { - let err = TypesRegistryError::invalid_gts_type_id("missing vendor"); - assert!(err.is_invalid_gts_type_id()); - assert!(err.to_string().contains("missing vendor")); +// ───────────────────────────────────────────────────────────────────── +// Projection: From for TypesRegistryError +// ───────────────────────────────────────────────────────────────────── - let err = TypesRegistryError::invalid_gts_instance_id("no chain prefix"); - assert!(err.is_invalid_gts_instance_id()); +#[cfg(test)] +mod projection_tests { + use super::TypesRegistryError; + use crate::field::{self, ValidationReason}; + use crate::gts::{self, TypeResource}; + use crate::precondition; + use toolkit_canonical_errors::{CanonicalError, Problem}; - let err = TypesRegistryError::gts_type_schema_not_found("gts.acme.core.events.test.v1~"); - assert!(err.is_gts_type_schema_not_found()); - assert!(err.is_not_found()); + #[test] + fn invalid_argument_projects_typed_validation_reason() { + let canonical = TypeResource::invalid_argument() + .with_field_violation(field::GTS_ID_FIELD, "missing vendor", field::INVALID_GTS_ID) + .create(); + match TypesRegistryError::from(canonical) { + TypesRegistryError::Validation { issues } => { + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].field, field::GTS_ID_FIELD); + assert_eq!(issues[0].reason, ValidationReason::InvalidGtsId); + assert_eq!(issues[0].description, "missing vendor"); + } + other => panic!("expected Validation, got {other:?}"), + } + } - let err = TypesRegistryError::gts_instance_not_found( - "gts.acme.core.events.test.v1~acme.core.instances.u1.v1", - ); - assert!(err.is_gts_instance_not_found()); - assert!(err.is_not_found()); + #[test] + fn not_found_projects_resource_type_and_name() { + let canonical = TypeResource::not_found("type schema not found") + .with_resource("gts.acme.core.events.test.v1~") + .create(); + match TypesRegistryError::from(canonical) { + TypesRegistryError::NotFound { + resource_type, + name, + .. + } => { + assert_eq!(resource_type, gts::TYPE_RESOURCE_TYPE); + assert_eq!(name, "gts.acme.core.events.test.v1~"); + } + other => panic!("expected NotFound, got {other:?}"), + } + } - let err = TypesRegistryError::parent_type_schema_not_registered( - "gts.acme.core.events.base.v1~", - "gts.acme.core.events.base.v1~acme.core.events.derived.v1.0~", - ); - assert!(err.is_parent_type_schema_not_registered()); + #[test] + fn already_exists_projects_resource_type_and_name() { + let canonical = TypeResource::already_exists("entity exists") + .with_resource("gts.acme.core.events.test.v1~") + .create(); + match TypesRegistryError::from(canonical) { + TypesRegistryError::AlreadyExists { + resource_type, + name, + .. + } => { + assert_eq!(resource_type, gts::TYPE_RESOURCE_TYPE); + assert_eq!(name, "gts.acme.core.events.test.v1~"); + } + other => panic!("expected AlreadyExists, got {other:?}"), + } + } - let err = TypesRegistryError::already_exists("gts.acme.core.events.test.v1~"); - assert!(err.is_already_exists()); + /// A `NotFound` envelope that reaches us without `resource_type` metadata + /// (e.g. a foreign / malformed canonical error) is not a modeled + /// types-registry `NotFound` — callers dispatch on `TYPE_RESOURCE_TYPE`, so + /// projecting it to `NotFound { resource_type: "" }` would be a silent lie. + /// It must fall through to `Other`, preserving the full canonical error. + #[test] + fn not_found_without_resource_type_falls_through_to_other() { + let canonical = malformed_without_resource_type( + TypeResource::not_found("missing") + .with_resource("gts.acme.core.events.test.v1~") + .create(), + ); + assert!( + matches!( + canonical, + CanonicalError::NotFound { + resource_type: None, + .. + } + ), + "precondition: the test envelope must lack a resource_type", + ); + match TypesRegistryError::from(canonical) { + TypesRegistryError::Other { .. } => {} + other => panic!("expected Other, got {other:?}"), + } + } - let err = TypesRegistryError::validation_failed("schema invalid"); - assert!(err.is_validation_failed()); + /// Mirror of [`not_found_without_resource_type_falls_through_to_other`] for + /// the symmetric `AlreadyExists` arm. + #[test] + fn already_exists_without_resource_type_falls_through_to_other() { + let canonical = malformed_without_resource_type( + TypeResource::already_exists("entity exists") + .with_resource("gts.acme.core.events.test.v1~") + .create(), + ); + assert!( + matches!( + canonical, + CanonicalError::AlreadyExists { + resource_type: None, + .. + } + ), + "precondition: the test envelope must lack a resource_type", + ); + match TypesRegistryError::from(canonical) { + TypesRegistryError::Other { .. } => {} + other => panic!("expected Other, got {other:?}"), + } + } - let err = - TypesRegistryError::service_unavailable("registry is initializing", Duration::from_secs(1)); - assert!(err.is_service_unavailable()); + /// Strips `resource_type` from a canonical error by round-tripping through + /// its wire `Problem` (the only public path to a resource-type-less + /// envelope, since the variants are `#[non_exhaustive]`). + fn malformed_without_resource_type(err: CanonicalError) -> CanonicalError { + let mut problem = Problem::from(err); + problem + .context + .as_object_mut() + .expect("canonical context serializes to a JSON object") + .remove("resource_type"); + CanonicalError::try_from(problem).expect("problem without resource_type reconstructs") + } - let err = TypesRegistryError::internal("database error"); - assert!(matches!(err, TypesRegistryError::Internal(_))); -} + #[test] + fn failed_precondition_projects_parent_not_registered() { + let canonical = TypeResource::failed_precondition() + .with_resource("gts.acme.core.events.base.v1~acme.x.derived.v1.0~") + .with_precondition_violation( + "gts.acme.core.events.base.v1~", + "required type-schema is not registered", + precondition::PARENT_NOT_REGISTERED, + ) + .create(); + match TypesRegistryError::from(canonical) { + TypesRegistryError::ParentNotRegistered { + parent_type_id, + dependent_id, + detail, + } => { + assert_eq!(parent_type_id, "gts.acme.core.events.base.v1~"); + assert_eq!( + dependent_id, + "gts.acme.core.events.base.v1~acme.x.derived.v1.0~" + ); + assert_eq!(detail, "required type-schema is not registered"); + } + other => panic!("expected ParentNotRegistered, got {other:?}"), + } + } + + #[test] + fn unmodeled_failed_precondition_falls_through_to_other() { + // A FailedPrecondition whose violation type is NOT + // PARENT_NOT_REGISTERED is unmodeled — it must preserve the canonical + // in `Other`, not be mislabeled as ParentNotRegistered. + let canonical = TypeResource::failed_precondition() + .with_precondition_violation("some_subject", "future precondition", "SOME_OTHER_TYPE") + .create(); + match TypesRegistryError::from(canonical) { + TypesRegistryError::Other { + canonical: CanonicalError::FailedPrecondition { .. }, + } => {} + other => panic!("expected Other::FailedPrecondition, got {other:?}"), + } + } + + #[test] + fn service_unavailable_projects_unavailable() { + let canonical = CanonicalError::service_unavailable().create(); + assert!(matches!( + TypesRegistryError::from(canonical), + TypesRegistryError::Unavailable { .. } + )); + } + + #[test] + fn internal_projects_internal() { + let canonical = CanonicalError::internal("boom").create(); + assert!(matches!( + TypesRegistryError::from(canonical), + TypesRegistryError::Internal { .. } + )); + } + + #[test] + fn unmodeled_category_falls_through_to_other() { + // Types-registry never emits Unauthenticated; it must land in Other + // with the canonical preserved for inspection. + let canonical = CanonicalError::unauthenticated() + .with_reason("SOME_REASON") + .create(); + match TypesRegistryError::from(canonical) { + TypesRegistryError::Other { + canonical: CanonicalError::Unauthenticated { .. }, + } => {} + other => panic!("expected Other::Unauthenticated, got {other:?}"), + } + } + + #[test] + fn parent_not_registered_survives_full_problem_round_trip() { + // Out-of-process chain: canonical → Problem JSON → Problem → + // CanonicalError → TypesRegistryError. Pins that an HTTP consumer + // projecting from the wire reconstructs the same structured ids as an + // in-process ClientHub caller — exercised on the lossless + // parent-not-registered encoding. + let canonical = TypeResource::failed_precondition() + .with_resource("gts.acme.core.events.base.v1~acme.x.derived.v1.0~") + .with_precondition_violation( + "gts.acme.core.events.base.v1~", + "required type-schema is not registered", + precondition::PARENT_NOT_REGISTERED, + ) + .create(); + + let bytes = serde_json::to_vec(&Problem::from(canonical)).expect("serialize"); + let restored: Problem = serde_json::from_slice(&bytes).expect("deserialize"); + let restored_canonical = CanonicalError::try_from(restored).expect("reconstruct"); -#[test] -fn test_error_display() { - let err = TypesRegistryError::InvalidGtsTypeId("bad format".to_owned()); - assert_eq!(err.to_string(), "Invalid GTS type-schema id: bad format"); - - let err = TypesRegistryError::InvalidGtsInstanceId("bad format".to_owned()); - assert_eq!(err.to_string(), "Invalid GTS instance id: bad format"); - - let err = TypesRegistryError::GtsTypeSchemaNotFound("gts.cf.core.events.test.v1~".to_owned()); - assert_eq!( - err.to_string(), - "GTS type-schema not found: gts.cf.core.events.test.v1~" - ); - - let err = TypesRegistryError::GtsInstanceNotFound( - "gts.cf.core.events.test.v1~cf.core.instances.u1.v1".to_owned(), - ); - assert_eq!( - err.to_string(), - "GTS instance not found: gts.cf.core.events.test.v1~cf.core.instances.u1.v1" - ); - - let err = TypesRegistryError::AlreadyExists("gts.cf.core.events.test.v1~".to_owned()); - assert_eq!( - err.to_string(), - "Entity already exists: gts.cf.core.events.test.v1~" - ); - - let err = TypesRegistryError::ValidationFailed("missing required field".to_owned()); - assert_eq!(err.to_string(), "Validation failed: missing required field"); - - let err = TypesRegistryError::ServiceUnavailable { - message: "registry is initializing".to_owned(), - retry_after: Duration::from_secs(2), - }; - assert_eq!( - err.to_string(), - "Service unavailable: registry is initializing (retry after 2s)" - ); - - let err = TypesRegistryError::Internal("unexpected".to_owned()); - assert_eq!(err.to_string(), "Internal error: unexpected"); + match TypesRegistryError::from(restored_canonical) { + TypesRegistryError::ParentNotRegistered { + parent_type_id, + dependent_id, + .. + } => { + assert_eq!(parent_type_id, "gts.acme.core.events.base.v1~"); + assert_eq!( + dependent_id, + "gts.acme.core.events.base.v1~acme.x.derived.v1.0~" + ); + } + other => panic!("expected ParentNotRegistered after round-trip, got {other:?}"), + } + } } diff --git a/gears/system/types-registry/types-registry-sdk/src/field.rs b/gears/system/types-registry/types-registry-sdk/src/field.rs new file mode 100644 index 000000000..81d7499d2 --- /dev/null +++ b/gears/system/types-registry/types-registry-sdk/src/field.rs @@ -0,0 +1,193 @@ +//! Wire `field` / `reason` vocabulary for field violations under +//! [`CanonicalError::InvalidArgument`]. +//! +//! Types-registry emits exactly three `InvalidArgument` field-violation +//! `reason` codes, each attributed to a fixed request `field`: +//! +//! | `reason` | `field` | source | +//! |---|---|---| +//! | [`INVALID_GTS_ID`] | [`GTS_ID_FIELD`] | a GTS id failed to parse / kind-mismatched | +//! | [`INVALID_QUERY`] | [`QUERY_FIELD`] | a list/query pattern was out-of-spec | +//! | [`VALIDATION_FAILED`] | [`ENTITY_FIELD`] | entity content failed schema validation | +//! +//! The `reason` slot is the dispatch discriminator, so it is fanned into the +//! typed [`ValidationReason`] sub-enum (consumers match the variant rather than +//! the wire string). The `field` slot stays a free `String` on the projection — +//! it is an attribution identifier, not a discriminator. +//! +//! The impl crate's single `From for CanonicalError` ladder +//! references the same constants at construction time so the SDK vocabulary and +//! the wire can never drift — the round-trip tests in [`crate::error`] pin every +//! constant to its `Problem` JSON path. +//! +//! [`CanonicalError::InvalidArgument`]: toolkit_canonical_errors::CanonicalError::InvalidArgument + +// --------------------------------------------------------------------------- +// `field_violations[].field` attribution keys. +// +// Identify *which* request field failed. Extracted to consts (ADR 0005 Rule 6) +// so the impl ladder and the SDK vocabulary cannot drift; pinned by the +// round-trip tests. +// --------------------------------------------------------------------------- + +/// The GTS identifier field (carries [`INVALID_GTS_ID`]). +pub const GTS_ID_FIELD: &str = "gts_id"; + +/// The list/query pattern field (carries [`INVALID_QUERY`]). +pub const QUERY_FIELD: &str = "query"; + +/// The entity-content field (carries [`VALIDATION_FAILED`]). +pub const ENTITY_FIELD: &str = "entity"; + +// --------------------------------------------------------------------------- +// `field_violations[].reason` codes. +// --------------------------------------------------------------------------- + +/// The string is not a valid GTS identifier (parse failure or kind mismatch). +pub const INVALID_GTS_ID: &str = "INVALID_GTS_ID"; + +/// The list/query parameters are syntactically invalid (out-of-spec wildcard). +pub const INVALID_QUERY: &str = "INVALID_QUERY"; + +/// The entity content failed schema validation. +pub const VALIDATION_FAILED: &str = "VALIDATION_FAILED"; + +// --------------------------------------------------------------------------- +// Synthetic shape sentinels (projection-side, NOT wire `reason` codes). +// +// types-registry never emits these — it only produces the `FieldViolations` +// shape. They tag the *other* `InvalidArgument` shapes (`Format` / `Constraint`) +// so the projection keeps a distinct, non-empty discriminator instead of +// collapsing both into an ambiguous `Unknown("")`. Rendered by +// [`ValidationReason::as_wire`] for diagnostics; never returned by +// [`ValidationReason::from_wire`] (no `field_violations[].reason` carries them). +// --------------------------------------------------------------------------- + +/// Diagnostic token for [`ValidationReason::Format`]. +pub const FORMAT_SHAPE: &str = "FORMAT"; + +/// Diagnostic token for [`ValidationReason::Constraint`]. +pub const CONSTRAINT_SHAPE: &str = "CONSTRAINT"; + +// --------------------------------------------------------------------------- +// Typed view of the `field_violations[].reason` codes. +// --------------------------------------------------------------------------- + +/// Typed view of the types-registry `InvalidArgument` `reason` strings +/// declared above. +/// +/// Carried by each [`crate::error::FieldIssue::reason`]. +/// [`from_wire`](Self::from_wire) returns `Self` (not `Option`) with an +/// [`Self::Unknown`] catch-all because every `reason` types-registry emits is +/// one of the modeled values — the catch-all only fires for a future reason, +/// keeping the projection forward-compatible. +/// +/// [`Self::Format`] and [`Self::Constraint`] are synthetic projection-side +/// sentinels for the non-field-violation `InvalidArgument` shapes; they are +/// produced by the projection, never by [`from_wire`](Self::from_wire). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ValidationReason { + /// See [`INVALID_GTS_ID`]. + InvalidGtsId, + /// See [`INVALID_QUERY`]. + InvalidQuery, + /// See [`VALIDATION_FAILED`]. + ValidationFailed, + /// Synthetic sentinel for a canonical `InvalidArgument` carried as the + /// `Format` shape (a single format descriptor) rather than per-field + /// violations. Renders the reserved token [`FORMAT_SHAPE`]. Not produced + /// by [`Self::from_wire`]; types-registry does not emit this shape today. + Format, + /// Synthetic sentinel for a canonical `InvalidArgument` carried as the + /// `Constraint` shape. Renders the reserved token [`CONSTRAINT_SHAPE`]. + /// Same rationale as [`Self::Format`]. + Constraint, + /// Unmodeled / future reason — preserves the raw wire string. + Unknown(String), +} + +impl ValidationReason { + /// Project a wire `field_violations[].reason` string into the typed + /// discriminator. Any unmodeled value is preserved in [`Self::Unknown`]. + #[must_use] + pub fn from_wire(s: &str) -> Self { + match s { + INVALID_GTS_ID => Self::InvalidGtsId, + INVALID_QUERY => Self::InvalidQuery, + VALIDATION_FAILED => Self::ValidationFailed, + other => Self::Unknown(other.to_owned()), + } + } + + /// Render the discriminator to its wire `reason` string. Inverse of + /// [`Self::from_wire`] for the wire-backed variants; the synthetic + /// [`Self::Format`] / [`Self::Constraint`] sentinels render their reserved + /// diagnostic tokens ([`FORMAT_SHAPE`] / [`CONSTRAINT_SHAPE`]). + #[must_use] + pub fn as_wire(&self) -> &str { + match self { + Self::InvalidGtsId => INVALID_GTS_ID, + Self::InvalidQuery => INVALID_QUERY, + Self::ValidationFailed => VALIDATION_FAILED, + Self::Format => FORMAT_SHAPE, + Self::Constraint => CONSTRAINT_SHAPE, + Self::Unknown(s) => s.as_str(), + } + } +} + +impl core::fmt::Display for ValidationReason { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(self.as_wire()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validation_reason_round_trips_each_constant() { + for (wire, expected) in [ + (INVALID_GTS_ID, ValidationReason::InvalidGtsId), + (INVALID_QUERY, ValidationReason::InvalidQuery), + (VALIDATION_FAILED, ValidationReason::ValidationFailed), + ] { + assert_eq!(ValidationReason::from_wire(wire), expected); + assert_eq!(expected.as_wire(), wire); + } + } + + #[test] + fn validation_reason_preserves_unknown_wire_string() { + let raw = "FUTURE_REASON"; + let r = ValidationReason::from_wire(raw); + assert_eq!(r, ValidationReason::Unknown(raw.to_owned())); + assert_eq!(r.as_wire(), raw); + } + + #[test] + fn shape_sentinels_render_distinct_non_empty_tokens() { + assert_eq!(ValidationReason::Format.as_wire(), FORMAT_SHAPE); + assert_eq!(ValidationReason::Constraint.as_wire(), CONSTRAINT_SHAPE); + // The whole point of the fix: the two shapes are no longer the same + // ambiguous `Unknown("")` — they are distinct, non-empty discriminators. + assert_ne!(ValidationReason::Format, ValidationReason::Constraint); + assert!(!ValidationReason::Format.as_wire().is_empty()); + assert!(!ValidationReason::Constraint.as_wire().is_empty()); + } + + #[test] + fn from_wire_never_yields_synthetic_shape_sentinels() { + // The shape sentinels are projection-side only; feeding their tokens + // back through `from_wire` yields `Unknown`, not the sentinel variant. + assert_eq!( + ValidationReason::from_wire(FORMAT_SHAPE), + ValidationReason::Unknown(FORMAT_SHAPE.to_owned()) + ); + assert_eq!( + ValidationReason::from_wire(CONSTRAINT_SHAPE), + ValidationReason::Unknown(CONSTRAINT_SHAPE.to_owned()) + ); + } +} diff --git a/gears/system/types-registry/types-registry-sdk/src/gts.rs b/gears/system/types-registry/types-registry-sdk/src/gts.rs new file mode 100644 index 000000000..c578d06c9 --- /dev/null +++ b/gears/system/types-registry/types-registry-sdk/src/gts.rs @@ -0,0 +1,27 @@ +//! GTS resource-type vocabulary for the types-registry canonical surface. +//! +//! [`TYPE_RESOURCE_TYPE`] is the canonical GTS resource type that tags every +//! types-registry `NotFound` / `AlreadyExists` / `InvalidArgument` / +//! `FailedPrecondition` error — it MUST equal the literal in the impl crate's +//! `#[resource_error("…")]` marker (`api::rest::error`) and the SDK-internal +//! [`TypeResource`] marker below. The round-trip tests in [`crate::error`] pin +//! the equality (the proc-macro cannot reference the const directly). +//! +//! [`TypeResource`] is the SDK-internal `#[resource_error]` marker used by the +//! client-side `try_new` constructors in [`crate::models`] to build the +//! in-process `InvalidArgument` errors they emit (those constructors never cross +//! a wire boundary — see ADR 0005 "Non-Canonical Methods" — but emitting them as +//! `CanonicalError` keeps the SDK on a single error type end-to-end). + +use toolkit_canonical_errors::resource_error; + +/// The canonical GTS resource type for types-registry entities. Lands in +/// `CanonicalError` `resource_type` / the wire `context.resource_type`. +pub const TYPE_RESOURCE_TYPE: &str = "gts.cf.types_registry.registry.type.v1~"; + +/// SDK-internal canonical-error scope. Mirrors the impl crate's +/// `#[resource_error]` marker so the SDK's `try_new` constructors emit the same +/// `resource_type` the REST ladder does. Its literal MUST equal +/// [`TYPE_RESOURCE_TYPE`] — pinned by `gts_resource_type_round_trips`. +#[resource_error("gts.cf.types_registry.registry.type.v1~")] +pub(crate) struct TypeResource; diff --git a/gears/system/types-registry/types-registry-sdk/src/lib.rs b/gears/system/types-registry/types-registry-sdk/src/lib.rs index 3d3a91810..8b6518db8 100644 --- a/gears/system/types-registry/types-registry-sdk/src/lib.rs +++ b/gears/system/types-registry/types-registry-sdk/src/lib.rs @@ -1,11 +1,15 @@ //! Types Registry SDK //! //! This crate provides the public API for the `types-registry` gear: -//! - `TypesRegistryClient` trait for inter-gear communication +//! - `TypesRegistryClient` trait for inter-gear communication. Per +//! [ADR 0005][adr] every fallible method (and every per-item `Result` it +//! returns) carries [`toolkit_canonical_errors::CanonicalError`]. //! - `GtsTypeSchema` / `GtsInstance` typed entity models //! - `TypeSchemaQuery` / `InstanceQuery` for filtering //! - `GtsTypeId` / `GtsInstanceId` typed identifiers -//! - `TypesRegistryError` for error handling +//! - [`TypesRegistryError`] — opt-in `From` projection (see +//! [`error`]) plus its co-located wire vocabulary ([`field`], +//! [`precondition`], [`gts`]) //! //! ## Usage //! @@ -20,24 +24,31 @@ //! .list_type_schemas(TypeSchemaQuery::default().with_pattern("gts.acme.*")) //! .await?; //! ``` +//! +//! [adr]: https://github.com/constructorfabric/gears-rust/blob/main/docs/arch/errors/ADR/0005-cpt-cf-adr-sdk-canonical-projection.md #![forbid(unsafe_code)] #![deny(rust_2018_idioms)] pub mod api; pub mod error; +pub mod field; +pub mod gts; pub mod models; +pub mod precondition; #[cfg(feature = "test-util")] pub mod testing; pub use api::TypesRegistryClient; -pub use error::TypesRegistryError; +pub use error::{FieldIssue, TypesRegistryError}; +pub use gts::TYPE_RESOURCE_TYPE; pub use models::{ GtsInstance, GtsTypeId, GtsTypeSchema, InstanceQuery, RegisterResult, RegisterSummary, TypeSchemaQuery, is_type_schema_id, }; // Re-export the underlying gts identifier types so consumers don't need a -// direct dependency on `gts` for typed IDs. -pub use gts::GtsInstanceId; +// direct dependency on `gts` for typed IDs. Leading `::` selects the external +// `gts` crate over this crate's local `gts` gear (the canonical-error vocab). +pub use ::gts::GtsInstanceId; diff --git a/gears/system/types-registry/types-registry-sdk/src/models.rs b/gears/system/types-registry/types-registry-sdk/src/models.rs index e2a456371..eb842e14c 100644 --- a/gears/system/types-registry/types-registry-sdk/src/models.rs +++ b/gears/system/types-registry/types-registry-sdk/src/models.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use gts::{GtsID, GtsIdSegment, GtsInstanceId}; use serde_json::{Map, Value}; +use toolkit_canonical_errors::CanonicalError; /// SDK-facing GTS type-schema identifier, re-exported from the `gts` crate. /// @@ -16,7 +17,24 @@ use serde_json::{Map, Value}; pub use gts::GtsTypeId; use uuid::Uuid; -use crate::error::TypesRegistryError; +use crate::field; +use crate::gts::TypeResource; + +/// Build the in-process `InvalidArgument` canonical error the client-side +/// `try_new` constructors emit for a malformed / kind-mismatched GTS id. +/// +/// Mirrors the impl crate's `From for CanonicalError` mapping of +/// `InvalidGtsId` (field [`field::GTS_ID_FIELD`], reason +/// [`field::INVALID_GTS_ID`]) so the in-process and REST classifications match. +/// These constructors never cross a wire boundary (ADR 0005 "Non-Canonical +/// Methods"); emitting `CanonicalError` keeps the SDK on a single error type +/// end-to-end (it projects to +/// [`TypesRegistryError::Validation`](crate::TypesRegistryError::Validation)). +fn invalid_gts_id_error(message: impl Into) -> CanonicalError { + TypeResource::invalid_argument() + .with_field_violation(field::GTS_ID_FIELD, message, field::INVALID_GTS_ID) + .create() +} /// Returns `true` if `s` is shaped like a type-schema GTS id (ends with `~`). /// @@ -100,8 +118,8 @@ impl GtsTypeSchema { /// /// # Errors /// - /// Returns [`InvalidGtsTypeId`](TypesRegistryError::InvalidGtsTypeId) - /// in any of these cases: + /// Returns an `InvalidArgument` [`CanonicalError`] (reason + /// [`field::INVALID_GTS_ID`]) in any of these cases: /// - `type_id` does not end with `~` (looks like an instance id); /// - `type_id` does not parse as a valid GTS identifier; /// - `parent` is `Some(_)` but its `type_id` does not match the chain @@ -115,9 +133,9 @@ impl GtsTypeSchema { raw_schema: Value, description: Option, parent: Option>, - ) -> Result { + ) -> Result { if !is_type_schema_id(type_id.as_ref()) { - return Err(TypesRegistryError::invalid_gts_type_id(format!( + return Err(invalid_gts_id_error(format!( "{type_id} does not end with `~`", ))); } @@ -126,18 +144,18 @@ impl GtsTypeSchema { Self::derive_parent_type_id(type_id.as_ref()), ) { (Some(parent_schema), Some(expected)) if expected != parent_schema.type_id => { - return Err(TypesRegistryError::invalid_gts_type_id(format!( + return Err(invalid_gts_id_error(format!( "type-schema {type_id} expects parent {expected}, got {}", parent_schema.type_id, ))); } (Some(_), None) => { - return Err(TypesRegistryError::invalid_gts_type_id(format!( + return Err(invalid_gts_id_error(format!( "root type-schema {type_id} cannot have a parent", ))); } (None, Some(expected)) => { - return Err(TypesRegistryError::invalid_gts_type_id(format!( + return Err(invalid_gts_id_error(format!( "derived type-schema {type_id} requires parent {expected}, got None", ))); } @@ -145,8 +163,8 @@ impl GtsTypeSchema { // (None, None) — root with no parent → ok _ => {} } - let parsed = GtsID::new(type_id.as_ref()) - .map_err(|e| TypesRegistryError::invalid_gts_type_id(format!("{e}")))?; + let parsed = + GtsID::new(type_id.as_ref()).map_err(|e| invalid_gts_id_error(format!("{e}")))?; let type_uuid = parsed.to_uuid(); let segments = parsed.gts_id_segments; let traits = Self::extract_traits(&raw_schema); @@ -496,8 +514,8 @@ impl GtsInstance { /// /// # Errors /// - /// Returns [`InvalidGtsInstanceId`](TypesRegistryError::InvalidGtsInstanceId) - /// in any of these cases: + /// Returns an `InvalidArgument` [`CanonicalError`] (reason + /// [`field::INVALID_GTS_ID`]) in any of these cases: /// - `id` ends with `~` (looks like a type-schema id); /// - `id` contains no `~` at all (no type-schema chain prefix); /// - `id` does not parse as a valid GTS identifier; @@ -507,25 +525,24 @@ impl GtsInstance { object: Value, description: Option, type_schema: Arc, - ) -> Result { + ) -> Result { if is_type_schema_id(id.as_ref()) { - return Err(TypesRegistryError::invalid_gts_instance_id(format!( + return Err(invalid_gts_id_error(format!( "{id} ends with `~` (looks like a type-schema id)", ))); } let derived = Self::derive_type_id(id.as_ref()).ok_or_else(|| { - TypesRegistryError::invalid_gts_instance_id(format!( + invalid_gts_id_error(format!( "instance id {id} has no type-schema chain (no `~`)" )) })?; if derived != type_schema.type_id { - return Err(TypesRegistryError::invalid_gts_instance_id(format!( + return Err(invalid_gts_id_error(format!( "instance id {id} chain prefix {derived} does not match type-schema {0}", type_schema.type_id ))); } - let parsed = GtsID::new(id.as_ref()) - .map_err(|e| TypesRegistryError::invalid_gts_instance_id(format!("{e}")))?; + let parsed = GtsID::new(id.as_ref()).map_err(|e| invalid_gts_id_error(format!("{e}")))?; let uuid = parsed.to_uuid(); let segments = parsed.gts_id_segments; Ok(Self { @@ -585,8 +602,9 @@ pub enum RegisterResult { Err { /// The GTS ID that was attempted, if it could be extracted from the input. gts_id: Option, - /// The error that occurred during registration. - error: TypesRegistryError, + /// The error that occurred during registration. Project to + /// [`TypesRegistryError`](crate::TypesRegistryError) for typed dispatch. + error: CanonicalError, }, } @@ -603,26 +621,26 @@ impl RegisterResult { matches!(self, Self::Err { .. }) } - /// Converts to `Result<&str, &TypesRegistryError>` — the success arm + /// Converts to `Result<&str, &CanonicalError>` — the success arm /// borrows the canonical `gts_id`. /// /// # Errors /// /// Returns `Err` with a reference to the error if this is a failed registration. - pub fn as_result(&self) -> Result<&str, &TypesRegistryError> { + pub fn as_result(&self) -> Result<&str, &CanonicalError> { match self { Self::Ok { gts_id } => Ok(gts_id), Self::Err { error, .. } => Err(error), } } - /// Converts into `Result` — the success arm + /// Converts into `Result` — the success arm /// owns the canonical `gts_id`. /// /// # Errors /// /// Returns `Err` with the error if this is a failed registration. - pub fn into_result(self) -> Result { + pub fn into_result(self) -> Result { match self { Self::Ok { gts_id } => Ok(gts_id), Self::Err { error, .. } => Err(error), @@ -640,7 +658,7 @@ impl RegisterResult { /// Returns the error if failed, `None` otherwise. #[must_use] - pub fn err(self) -> Option { + pub fn err(self) -> Option { match self { Self::Ok { .. } => None, Self::Err { error, .. } => Some(error), @@ -651,8 +669,8 @@ impl RegisterResult { /// /// # Errors /// - /// Returns the first `TypesRegistryError` encountered in `results`. - pub fn ensure_all_ok(results: &[Self]) -> Result<(), TypesRegistryError> { + /// Returns the first `CanonicalError` encountered in `results`. + pub fn ensure_all_ok(results: &[Self]) -> Result<(), CanonicalError> { for result in results { if let Self::Err { error, .. } = result { return Err(error.clone()); diff --git a/gears/system/types-registry/types-registry-sdk/src/models_tests.rs b/gears/system/types-registry/types-registry-sdk/src/models_tests.rs index 296e7c929..209eb930f 100644 --- a/gears/system/types-registry/types-registry-sdk/src/models_tests.rs +++ b/gears/system/types-registry/types-registry-sdk/src/models_tests.rs @@ -11,6 +11,23 @@ use super::*; use serde_json::json; +use toolkit_canonical_errors::{CanonicalError, InvalidArgument}; + +/// `try_new` now returns `CanonicalError`; the legacy `is_invalid_gts_*` +/// predicates are gone. The kind distinction collapsed at the canonical +/// boundary (ADR 0005), so both former variants are one `InvalidArgument` +/// with reason `INVALID_GTS_ID`. +fn is_invalid_gts_id(err: &CanonicalError) -> bool { + matches!( + err, + CanonicalError::InvalidArgument { + ctx: InvalidArgument::FieldViolations { field_violations }, + .. + } if field_violations + .iter() + .any(|v| v.reason == crate::field::INVALID_GTS_ID) + ) +} fn make_type_schema( type_id: &str, @@ -70,7 +87,7 @@ fn test_type_schema_rejects_instance_id() { None, ) .unwrap_err(); - assert!(err.is_invalid_gts_type_id()); + assert!(is_invalid_gts_id(&err)); } #[test] @@ -90,7 +107,7 @@ fn test_type_schema_rejects_mismatched_parent() { Some(wrong_parent), ) .unwrap_err(); - assert!(err.is_invalid_gts_type_id()); + assert!(is_invalid_gts_id(&err)); } #[test] @@ -99,7 +116,7 @@ fn test_type_schema_rejects_root_with_parent() { let stray_parent = Arc::new(make_type_schema(BASE_ID, json!({}), None)); let err = GtsTypeSchema::try_new(GtsTypeId::new(BASE_ID), json!({}), None, Some(stray_parent)) .unwrap_err(); - assert!(err.is_invalid_gts_type_id()); + assert!(is_invalid_gts_id(&err)); } #[test] @@ -109,7 +126,7 @@ fn test_type_schema_rejects_derived_without_parent() { // reject this and force the caller to pass the chain. let err = GtsTypeSchema::try_new(GtsTypeId::new(DERIVED_ID), json!({}), None, None).unwrap_err(); - assert!(err.is_invalid_gts_type_id()); + assert!(is_invalid_gts_id(&err)); } #[test] @@ -724,7 +741,7 @@ fn test_instance_try_new_rejects_mismatched_type_schema() { type_schema, ) .unwrap_err(); - assert!(err.is_invalid_gts_instance_id()); + assert!(is_invalid_gts_id(&err)); } #[test] @@ -741,7 +758,7 @@ fn test_instance_rejects_type_id() { type_schema, ) .unwrap_err(); - assert!(err.is_invalid_gts_instance_id()); + assert!(is_invalid_gts_id(&err)); } #[test] diff --git a/gears/system/types-registry/types-registry-sdk/src/precondition.rs b/gears/system/types-registry/types-registry-sdk/src/precondition.rs new file mode 100644 index 000000000..6664ee57a --- /dev/null +++ b/gears/system/types-registry/types-registry-sdk/src/precondition.rs @@ -0,0 +1,29 @@ +//! Wire `type` vocabulary for precondition violations under +//! [`CanonicalError::FailedPrecondition`]. +//! +//! Types-registry emits exactly one `FailedPrecondition` shape, produced by the +//! local client's batch-registration parent pre-check: an entity cannot be +//! registered because its required parent type-schema is not yet registered. +//! That disposition has **no `DomainError` / REST-ladder arm** — it is an +//! adapter-only, in-process concept — so it is constructed directly on the +//! in-process boundary and carried losslessly in canonical's typed slots: +//! +//! * `violations[].type` = [`PARENT_NOT_REGISTERED`] (the discriminator), +//! * `violations[].subject` = the missing **parent** type-schema id, +//! * `resource_name` = the **dependent** entity id that failed, +//! * `violations[].description` = the human message. +//! +//! The projection ([`crate::error::TypesRegistryError::ParentNotRegistered`]) +//! reconstructs `{ parent_type_id, dependent_id }` from exactly those slots, so +//! the structured batch-registration outcome survives the canonical round-trip. +//! The round-trip tests in [`crate::error`] pin the constant to its `Problem` +//! JSON path. +//! +//! [`CanonicalError::FailedPrecondition`]: toolkit_canonical_errors::CanonicalError::FailedPrecondition + +/// The `violations[].type` token types-registry emits for a +/// parent-type-schema-not-registered precondition failure. +/// +/// It is the discriminator the projection keys on to reconstruct +/// [`crate::error::TypesRegistryError::ParentNotRegistered`]. +pub const PARENT_NOT_REGISTERED: &str = "PARENT_NOT_REGISTERED"; diff --git a/gears/system/types-registry/types-registry-sdk/src/testing.rs b/gears/system/types-registry/types-registry-sdk/src/testing.rs index c31b183d6..ab37c7a86 100644 --- a/gears/system/types-registry/types-registry-sdk/src/testing.rs +++ b/gears/system/types-registry/types-registry-sdk/src/testing.rs @@ -21,15 +21,51 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use serde_json::Value; +use toolkit_canonical_errors::CanonicalError; use uuid::Uuid; use crate::api::TypesRegistryClient; -use crate::error::TypesRegistryError; +use crate::field; +use crate::gts::TypeResource; use crate::models::{ GtsInstance, GtsTypeId, GtsTypeSchema, InstanceQuery, RegisterResult, TypeSchemaQuery, is_type_schema_id, }; -use gts::GtsInstanceId; +use gts::{GtsID, GtsInstanceId}; + +/// Builds the `InvalidArgument` canonical error the registry returns for a +/// malformed / kind-mismatched GTS id (reason [`field::INVALID_GTS_ID`]), +/// matching the real client's classification. +/// +/// Exposed so consumer test fakes that implement [`TypesRegistryClient`] can +/// synthesize the same canonical envelopes the real client emits. +#[must_use] +pub fn invalid_gts_id(message: impl Into) -> CanonicalError { + TypeResource::invalid_argument() + .with_field_violation(field::GTS_ID_FIELD, message, field::INVALID_GTS_ID) + .create() +} + +/// Builds the `NotFound` canonical error the registry returns for an +/// unregistered id / UUID, tagged with the types-registry resource type. +/// +/// Exposed for consumer test fakes (see [`invalid_gts_id`]). +#[must_use] +pub fn not_found(id: impl Into) -> CanonicalError { + let id = id.into(); + TypeResource::not_found(format!("no entity registered: {id}")) + .with_resource(id) + .create() +} + +/// Builds the opaque `Internal` canonical error the registry returns for an +/// infrastructure failure. +/// +/// Exposed for consumer test fakes (see [`invalid_gts_id`]). +#[must_use] +pub fn internal(message: impl Into) -> CanonicalError { + CanonicalError::internal(message).create() +} /// Stateful in-memory implementation of [`TypesRegistryClient`] for tests. /// @@ -49,7 +85,7 @@ use gts::GtsInstanceId; pub struct MockTypesRegistryClient { type_schemas: Vec, instances: Vec, - list_error: Option, + list_error: Option, received_type_schema_queries: Mutex>, received_instance_queries: Mutex>, } @@ -78,7 +114,7 @@ impl MockTypesRegistryClient { /// Configures the registry so that every `list_*` call fails with the /// given error. Useful for testing error-propagation paths. #[must_use] - pub fn with_list_error(mut self, err: TypesRegistryError) -> Self { + pub fn with_list_error(mut self, err: CanonicalError) -> Self { self.list_error = Some(err); self } @@ -125,10 +161,7 @@ impl MockTypesRegistryClient { #[async_trait] impl TypesRegistryClient for MockTypesRegistryClient { - async fn register( - &self, - entities: Vec, - ) -> Result, TypesRegistryError> { + async fn register(&self, entities: Vec) -> Result, CanonicalError> { assert!( entities.is_empty(), "MockTypesRegistryClient::register is not implemented; \ @@ -140,7 +173,7 @@ impl TypesRegistryClient for MockTypesRegistryClient { async fn register_type_schemas( &self, type_schemas: Vec, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { assert!( type_schemas.is_empty(), "MockTypesRegistryClient::register_type_schemas is not implemented; \ @@ -149,34 +182,33 @@ impl TypesRegistryClient for MockTypesRegistryClient { Ok(vec![]) } - async fn get_type_schema(&self, type_id: &str) -> Result { + async fn get_type_schema(&self, type_id: &str) -> Result { if !is_type_schema_id(type_id) { - return Err(TypesRegistryError::invalid_gts_type_id(format!( - "{type_id} does not end with `~`", - ))); + return Err(invalid_gts_id(format!("{type_id} does not end with `~`"))); } + GtsID::new(type_id).map_err(|e| invalid_gts_id(format!("{e}")))?; self.type_schemas .iter() .find(|s| s.type_id == type_id) .cloned() - .ok_or_else(|| TypesRegistryError::gts_type_schema_not_found(type_id)) + .ok_or_else(|| not_found(type_id)) } async fn get_type_schema_by_uuid( &self, type_uuid: Uuid, - ) -> Result { + ) -> Result { self.type_schemas .iter() .find(|s| s.type_uuid == type_uuid) .cloned() - .ok_or_else(|| TypesRegistryError::gts_type_schema_not_found(type_uuid.to_string())) + .ok_or_else(|| not_found(type_uuid.to_string())) } async fn get_type_schemas( &self, type_ids: Vec, - ) -> HashMap> { + ) -> HashMap> { let mut out = HashMap::with_capacity(type_ids.len()); for id in type_ids { let res = self.get_type_schema(&id).await; @@ -188,7 +220,7 @@ impl TypesRegistryClient for MockTypesRegistryClient { async fn get_type_schemas_by_uuid( &self, type_uuids: Vec, - ) -> HashMap> { + ) -> HashMap> { let mut out = HashMap::with_capacity(type_uuids.len()); for uuid in type_uuids { let res = self.get_type_schema_by_uuid(uuid).await; @@ -200,7 +232,7 @@ impl TypesRegistryClient for MockTypesRegistryClient { async fn list_type_schemas( &self, query: TypeSchemaQuery, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { self.received_type_schema_queries .lock() .expect("MockTypesRegistryClient: type-schema query log poisoned") @@ -214,7 +246,7 @@ impl TypesRegistryClient for MockTypesRegistryClient { async fn register_instances( &self, instances: Vec, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { assert!( instances.is_empty(), "MockTypesRegistryClient::register_instances is not implemented; \ @@ -223,31 +255,32 @@ impl TypesRegistryClient for MockTypesRegistryClient { Ok(vec![]) } - async fn get_instance(&self, id: &str) -> Result { + async fn get_instance(&self, id: &str) -> Result { if is_type_schema_id(id) { - return Err(TypesRegistryError::invalid_gts_instance_id(format!( + return Err(invalid_gts_id(format!( "{id} ends with `~` (looks like a type-schema id)", ))); } + GtsID::new(id).map_err(|e| invalid_gts_id(format!("{e}")))?; self.instances .iter() .find(|e| e.id == id) .cloned() - .ok_or_else(|| TypesRegistryError::gts_instance_not_found(id)) + .ok_or_else(|| not_found(id)) } - async fn get_instance_by_uuid(&self, uuid: Uuid) -> Result { + async fn get_instance_by_uuid(&self, uuid: Uuid) -> Result { self.instances .iter() .find(|e| e.uuid == uuid) .cloned() - .ok_or_else(|| TypesRegistryError::gts_instance_not_found(uuid.to_string())) + .ok_or_else(|| not_found(uuid.to_string())) } async fn get_instances( &self, ids: Vec, - ) -> HashMap> { + ) -> HashMap> { let mut out = HashMap::with_capacity(ids.len()); for id in ids { let res = self.get_instance(&id).await; @@ -259,7 +292,7 @@ impl TypesRegistryClient for MockTypesRegistryClient { async fn get_instances_by_uuid( &self, uuids: Vec, - ) -> HashMap> { + ) -> HashMap> { let mut out = HashMap::with_capacity(uuids.len()); for uuid in uuids { let res = self.get_instance_by_uuid(uuid).await; @@ -271,7 +304,7 @@ impl TypesRegistryClient for MockTypesRegistryClient { async fn list_instances( &self, query: InstanceQuery, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { self.received_instance_queries .lock() .expect("MockTypesRegistryClient: instance query log poisoned") diff --git a/gears/system/types-registry/types-registry/src/api/rest/error.rs b/gears/system/types-registry/types-registry/src/api/rest/error.rs index f7cf256e9..00879a5d4 100644 --- a/gears/system/types-registry/types-registry/src/api/rest/error.rs +++ b/gears/system/types-registry/types-registry/src/api/rest/error.rs @@ -1,6 +1,7 @@ //! REST error mapping for the Types Registry gear. use toolkit_canonical_errors::{CanonicalError, resource_error}; +use types_registry_sdk::{field, precondition}; use crate::domain::error::DomainError; @@ -11,7 +12,7 @@ impl From for CanonicalError { fn from(e: DomainError) -> Self { match e { DomainError::InvalidGtsId(msg) => TypeRegistryError::invalid_argument() - .with_field_violation("gts_id", msg, "INVALID_GTS_ID") + .with_field_violation(field::GTS_ID_FIELD, msg, field::INVALID_GTS_ID) .create(), DomainError::NotFound { kind, target } => { TypeRegistryError::not_found(format!("No entity with {kind}: {target}")) @@ -23,11 +24,32 @@ impl From for CanonicalError { )) .with_resource(id) .create(), + // Adapter-only (in-process) batch-register disposition — never + // reaches a REST handler. Carries `parent_type_id` / `dependent_id` + // losslessly so the SDK projects it back to + // `TypesRegistryError::ParentNotRegistered`: dependent → resource, + // parent → violation subject, message → violation description. + DomainError::ParentTypeSchemaNotRegistered { + parent_type_id, + dependent_id, + } => { + let detail = format!( + "Cannot register {dependent_id}: required type-schema {parent_type_id} is not registered" + ); + TypeRegistryError::failed_precondition() + .with_resource(dependent_id) + .with_precondition_violation( + parent_type_id, + detail, + precondition::PARENT_NOT_REGISTERED, + ) + .create() + } DomainError::InvalidQuery(msg) => TypeRegistryError::invalid_argument() - .with_field_violation("query", msg, "INVALID_QUERY") + .with_field_violation(field::QUERY_FIELD, msg, field::INVALID_QUERY) .create(), DomainError::ValidationFailed(msg) => TypeRegistryError::invalid_argument() - .with_field_violation("entity", msg, "VALIDATION_FAILED") + .with_field_violation(field::ENTITY_FIELD, msg, field::VALIDATION_FAILED) .create(), DomainError::NotInReadyMode => CanonicalError::service_unavailable().create(), DomainError::ReadyCommitFailed(errors) => { diff --git a/gears/system/types-registry/types-registry/src/domain/error.rs b/gears/system/types-registry/types-registry/src/domain/error.rs index 7aea32384..4ef17b2a7 100644 --- a/gears/system/types-registry/types-registry/src/domain/error.rs +++ b/gears/system/types-registry/types-registry/src/domain/error.rs @@ -3,7 +3,6 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use toolkit_macros::domain_model; -use types_registry_sdk::TypesRegistryError; /// A structured validation error with typed fields. #[domain_model] @@ -46,10 +45,14 @@ impl std::fmt::Display for ValidationError { /// /// This enum is intentionally **kind-agnostic** — the storage layer doesn't /// know whether a string identifies a type-schema or an instance, it just -/// stores and retrieves entities by their GTS ID. The kind context is added -/// at the SDK boundary by the local client, which knows what kind the caller -/// asked for and converts via [`Self::into_sdk_for_type_schema`] / -/// [`Self::into_sdk_for_instance`]. +/// stores and retrieves entities by their GTS ID. Per [ADR 0005][adr] this +/// enum is mapped to the platform [`CanonicalError`] by the single +/// `From for CanonicalError` ladder in `crate::api::rest::error`; +/// both the REST boundary and the in-process `TypesRegistryLocalClient` route +/// through that one ladder. +/// +/// [`CanonicalError`]: toolkit_canonical_errors::CanonicalError +/// [adr]: https://github.com/constructorfabric/gears-rust/blob/main/docs/arch/errors/ADR/0005-cpt-cf-adr-sdk-canonical-projection.md #[domain_model] #[derive(Error, Debug)] pub enum DomainError { @@ -68,6 +71,24 @@ pub enum DomainError { #[error("Entity already exists: {0}")] AlreadyExists(String), + /// A batch-register item could not be registered because its required + /// parent type-schema is not yet registered. Produced only by the + /// in-process `TypesRegistryLocalClient` parent pre-check (never by the + /// kind-agnostic service), and surfaced only as a per-item + /// `RegisterResult::Err` — it is unreachable from REST handlers. Maps to a + /// `FailedPrecondition` (wire `type` `PARENT_NOT_REGISTERED`) that carries + /// `parent_type_id` / `dependent_id` losslessly; see + /// `crate::api::rest::error`. + #[error( + "Cannot register {dependent_id}: required type-schema {parent_type_id} is not registered" + )] + ParentTypeSchemaNotRegistered { + /// The parent type-schema id that must be registered first. + parent_type_id: String, + /// The id of the entity whose registration failed. + dependent_id: String, + }, + /// The list/query parameters are syntactically invalid (e.g. an /// out-of-spec wildcard pattern). Distinct from `InvalidGtsId`, which /// covers id-shaped inputs. @@ -91,18 +112,6 @@ pub enum DomainError { Internal(#[from] anyhow::Error), } -/// Indicates which kind of entity the caller was looking up, so kind-agnostic -/// `DomainError`s can be lifted into kind-specific [`TypesRegistryError`] -/// variants at the SDK boundary. -#[domain_model] -#[derive(Debug, Clone, Copy)] -pub enum SdkErrorKind { - /// The caller asked for a type-schema. - TypeSchema, - /// The caller asked for an instance. - Instance, -} - /// Identifies which surface a `NotFound` lookup used. Carried inside /// [`DomainError::NotFound`] so renderers (REST, logs) can produce /// accurate "No entity with X" messages. @@ -175,62 +184,6 @@ impl DomainError { _ => None, } } - - /// Converts to [`TypesRegistryError`] under the assumption that the caller - /// was looking up a **type-schema**. - /// - /// `NotFound` becomes `GtsTypeSchemaNotFound`; `InvalidGtsId` becomes - /// `InvalidGtsTypeId`; the rest map straight. - #[must_use] - pub fn into_sdk_for_type_schema(self) -> TypesRegistryError { - self.into_sdk(SdkErrorKind::TypeSchema) - } - - /// Converts to [`TypesRegistryError`] under the assumption that the caller - /// was looking up an **instance**. - /// - /// `NotFound` becomes `GtsInstanceNotFound`; `InvalidGtsId` becomes - /// `InvalidGtsInstanceId`; the rest map straight. - #[must_use] - pub fn into_sdk_for_instance(self) -> TypesRegistryError { - self.into_sdk(SdkErrorKind::Instance) - } - - fn into_sdk(self, kind: SdkErrorKind) -> TypesRegistryError { - match (self, kind) { - (Self::InvalidGtsId(msg), SdkErrorKind::TypeSchema) => { - TypesRegistryError::invalid_gts_type_id(msg) - } - (Self::InvalidGtsId(msg), SdkErrorKind::Instance) => { - TypesRegistryError::invalid_gts_instance_id(msg) - } - (Self::NotFound { target, .. }, SdkErrorKind::TypeSchema) => { - TypesRegistryError::gts_type_schema_not_found(target) - } - (Self::NotFound { target, .. }, SdkErrorKind::Instance) => { - TypesRegistryError::gts_instance_not_found(target) - } - (Self::AlreadyExists(id), _) => TypesRegistryError::already_exists(id), - (Self::InvalidQuery(msg), _) => TypesRegistryError::invalid_query(msg), - (Self::ValidationFailed(msg), _) => TypesRegistryError::validation_failed(msg), - (Self::NotInReadyMode, _) => TypesRegistryError::service_unavailable( - "types registry is still initializing", - std::time::Duration::from_secs(1), - ), - (Self::ReadyCommitFailed(errors), _) => { - let error_strings: Vec = errors - .iter() - .map(std::string::ToString::to_string) - .collect(); - TypesRegistryError::validation_failed(format!( - "Ready commit failed with {} errors: {}", - errors.len(), - error_strings.join("; ") - )) - } - (Self::Internal(e), _) => TypesRegistryError::internal(e.to_string()), - } - } } #[cfg(test)] @@ -267,72 +220,6 @@ mod tests { assert!(matches!(err, DomainError::ValidationFailed(_))); } - #[test] - fn test_domain_to_sdk_error_conversion_for_type_schema() { - let sdk_err = - DomainError::not_found_by_id("gts.cf.core.events.test.v1~").into_sdk_for_type_schema(); - assert!(sdk_err.is_gts_type_schema_not_found()); - - // UUID-keyed not-found also surfaces as `GtsTypeSchemaNotFound` — - // the SDK error doesn't model the lookup kind, only the kind of - // the entity the caller was after. The lookup-kind distinction - // matters only for REST rendering. - let sdk_err = DomainError::not_found_by_uuid(uuid::Uuid::nil()).into_sdk_for_type_schema(); - assert!(sdk_err.is_gts_type_schema_not_found()); - - let sdk_err = DomainError::invalid_gts_id("bad format").into_sdk_for_type_schema(); - assert!(sdk_err.is_invalid_gts_type_id()); - - let sdk_err = - DomainError::already_exists("gts.cf.core.events.test.v1~").into_sdk_for_type_schema(); - assert!(sdk_err.is_already_exists()); - - let sdk_err = DomainError::validation_failed("bad schema").into_sdk_for_type_schema(); - assert!(sdk_err.is_validation_failed()); - } - - #[test] - fn test_domain_to_sdk_error_conversion_for_instance() { - let sdk_err = - DomainError::not_found_by_id("gts.cf.core.events.test.v1~cf.core.instances.u1.v1") - .into_sdk_for_instance(); - assert!(sdk_err.is_gts_instance_not_found()); - - let sdk_err = DomainError::invalid_gts_id("no chain prefix").into_sdk_for_instance(); - assert!(sdk_err.is_invalid_gts_instance_id()); - } - - #[test] - fn test_domain_to_sdk_error_not_in_ready_mode() { - let sdk_err = DomainError::NotInReadyMode.into_sdk_for_type_schema(); - assert!(sdk_err.is_service_unavailable()); - } - - #[test] - fn test_domain_to_sdk_error_ready_commit_failed() { - let errors = vec![ - ValidationError::new("gts.test1~", "error1"), - ValidationError::new("gts.test2~", "error2"), - ]; - let sdk_err = DomainError::ReadyCommitFailed(errors).into_sdk_for_type_schema(); - assert!(sdk_err.is_validation_failed()); - } - - #[test] - fn test_domain_to_sdk_error_internal() { - let sdk_err = - DomainError::Internal(anyhow::anyhow!("test error")).into_sdk_for_type_schema(); - assert!(matches!(sdk_err, TypesRegistryError::Internal(_))); - } - - #[test] - fn test_domain_to_sdk_error_invalid_query() { - let sdk_err = DomainError::invalid_query("bad pattern").into_sdk_for_type_schema(); - assert!(sdk_err.is_invalid_query()); - let sdk_err = DomainError::invalid_query("bad pattern").into_sdk_for_instance(); - assert!(sdk_err.is_invalid_query()); - } - #[test] fn test_error_display() { let err = DomainError::InvalidGtsId("bad format".to_owned()); diff --git a/gears/system/types-registry/types-registry/src/domain/local_client.rs b/gears/system/types-registry/types-registry/src/domain/local_client.rs index 09fe84001..133614ab2 100644 --- a/gears/system/types-registry/types-registry/src/domain/local_client.rs +++ b/gears/system/types-registry/types-registry/src/domain/local_client.rs @@ -14,10 +14,11 @@ use std::collections::HashMap; use std::sync::Arc; use async_trait::async_trait; +use toolkit_canonical_errors::CanonicalError; use toolkit_macros::domain_model; use types_registry_sdk::{ GtsInstance, GtsInstanceId, GtsTypeId, GtsTypeSchema, InstanceQuery, RegisterResult, - TypeSchemaQuery, TypesRegistryClient, TypesRegistryError, is_type_schema_id, + TypeSchemaQuery, TypesRegistryClient, is_type_schema_id, }; use uuid::Uuid; @@ -26,6 +27,16 @@ use crate::domain::model::{GtsEntity, ListQuery}; use crate::domain::service::TypesRegistryService; use crate::infra::cache::{CacheConfig, InMemoryCache, InstanceCache, TypeSchemaCache}; +/// Build the canonical `InvalidArgument` error this adapter emits for a +/// malformed / kind-mismatched GTS id, routed through the single +/// `From for CanonicalError` ladder (field `gts_id`, reason +/// `INVALID_GTS_ID`). The type-schema-vs-instance distinction the legacy SDK +/// error carried collapses here per ADR 0005 — the canonical boundary +/// classifies both kinds identically. +fn invalid_gts_id_err(message: impl Into) -> CanonicalError { + CanonicalError::from(DomainError::invalid_gts_id(message)) +} + /// Local client for the Types Registry gear. /// /// Implements the public [`TypesRegistryClient`] trait by wrapping @@ -112,25 +123,19 @@ impl TypesRegistryLocalClient { /// Returns the type-schema as a fully-resolved `Arc` (with /// all ancestors recursively populated). Caches the result. /// - /// `type_id` must end with `~` — instance ids are rejected with - /// `InvalidGtsTypeId` before any storage lookup, since type-schema and - /// instance ids are lexically distinct. - fn resolve_type_schema_arc( - &self, - type_id: &str, - ) -> Result, TypesRegistryError> { + /// `type_id` must end with `~` — instance ids are rejected with an + /// `InvalidArgument` canonical error (reason `INVALID_GTS_ID`) before any + /// storage lookup, since type-schema and instance ids are lexically distinct. + fn resolve_type_schema_arc(&self, type_id: &str) -> Result, CanonicalError> { if !is_type_schema_id(type_id) { - return Err(TypesRegistryError::invalid_gts_type_id(format!( + return Err(invalid_gts_id_err(format!( "{type_id} does not end with `~`", ))); } if let Some(cached) = self.type_schemas.get(type_id) { return Ok(cached); } - let entity = self - .service - .get(type_id) - .map_err(DomainError::into_sdk_for_type_schema)?; + let entity = self.service.get(type_id).map_err(CanonicalError::from)?; let arc = self.build_type_schema_arc(entity)?; self.type_schemas .put(arc.type_id.to_string(), Arc::clone(&arc)); @@ -147,17 +152,17 @@ impl TypesRegistryLocalClient { async fn fetch_type_schema_by_uuid_uncached( &self, type_uuid: Uuid, - ) -> Result { + ) -> Result { let entity = self .service .get_by_uuid(type_uuid) - .map_err(DomainError::into_sdk_for_type_schema)?; + .map_err(CanonicalError::from)?; if !entity.is_type_schema { // The UUID exists but points to an instance — from the // type-schema namespace's perspective, it's not registered. - return Err(TypesRegistryError::gts_type_schema_not_found( - type_uuid.to_string(), - )); + return Err(CanonicalError::from(DomainError::not_found_by_uuid( + type_uuid, + ))); } let gts_id = entity.gts_id.clone(); self.get_type_schema(>s_id).await @@ -168,15 +173,15 @@ impl TypesRegistryLocalClient { async fn fetch_instance_by_uuid_uncached( &self, uuid: Uuid, - ) -> Result { + ) -> Result { let entity = self .service .get_by_uuid(uuid) - .map_err(DomainError::into_sdk_for_instance)?; + .map_err(CanonicalError::from)?; if entity.is_type_schema { // The UUID exists but points to a type-schema — from the // instance namespace's perspective, it's not registered. - return Err(TypesRegistryError::gts_instance_not_found(uuid.to_string())); + return Err(CanonicalError::from(DomainError::not_found_by_uuid(uuid))); } let gts_id = entity.gts_id.clone(); self.get_instance(>s_id).await @@ -185,21 +190,19 @@ impl TypesRegistryLocalClient { /// Returns the instance as a fully-resolved `Arc`. Caches /// the result. Type-schema reference is resolved via the type-schema cache. /// - /// `id` must NOT end with `~` — type-schema ids are rejected with - /// `InvalidGtsInstanceId` before any storage lookup. - fn resolve_instance_arc(&self, id: &str) -> Result, TypesRegistryError> { + /// `id` must NOT end with `~` — type-schema ids are rejected with an + /// `InvalidArgument` canonical error (reason `INVALID_GTS_ID`) before any + /// storage lookup. + fn resolve_instance_arc(&self, id: &str) -> Result, CanonicalError> { if is_type_schema_id(id) { - return Err(TypesRegistryError::invalid_gts_instance_id(format!( + return Err(invalid_gts_id_err(format!( "{id} ends with `~` (looks like a type-schema id)", ))); } if let Some(cached) = self.instances.get(id) { return Ok(cached); } - let entity = self - .service - .get(id) - .map_err(DomainError::into_sdk_for_instance)?; + let entity = self.service.get(id).map_err(CanonicalError::from)?; let inst = self.build_instance(entity)?; let arc = Arc::new(inst); self.instances.put(arc.id.to_string(), Arc::clone(&arc)); @@ -222,13 +225,13 @@ impl TypesRegistryLocalClient { fn build_type_schema_arc( &self, entity: GtsEntity, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { let parent = if let Some(parent_id) = GtsTypeSchema::derive_parent_type_id(&entity.gts_id) { Some( self.resolve_type_schema_arc(parent_id.as_ref()) .map_err(|e| { - if e.is_gts_type_schema_not_found() { - TypesRegistryError::invalid_gts_type_id(format!( + if matches!(e, CanonicalError::NotFound { .. }) { + invalid_gts_id_err(format!( "type-schema {} references missing parent {parent_id}", entity.gts_id )) @@ -252,7 +255,7 @@ impl TypesRegistryLocalClient { /// /// For type-schemas, the parent is the chain prefix (`derive_parent_type_id`). /// For instances, the parent is the declaring type-schema (`derive_type_id`). - fn parent_pre_check(&self, gts_id: Option<&str>) -> Option { + fn parent_pre_check(&self, gts_id: Option<&str>) -> Option { let id = gts_id?; let parent_type_id = if is_type_schema_id(id) { // Type-schema: parent only exists for chained (non-root) ids. @@ -264,24 +267,26 @@ impl TypesRegistryLocalClient { if self.service.exists(parent_type_id.as_ref()) { None } else { - Some(TypesRegistryError::parent_type_schema_not_registered( - parent_type_id.into_string(), - id, + Some(CanonicalError::from( + DomainError::ParentTypeSchemaNotRegistered { + parent_type_id: parent_type_id.into_string(), + dependent_id: id.to_owned(), + }, )) } } /// Builds a `GtsInstance` from an internal entity by resolving its /// type-schema through the cache. - fn build_instance(&self, entity: GtsEntity) -> Result { + fn build_instance(&self, entity: GtsEntity) -> Result { if entity.is_type_schema { - return Err(TypesRegistryError::invalid_gts_instance_id(format!( + return Err(invalid_gts_id_err(format!( "{} is a type-schema, not an instance", entity.gts_id, ))); } let type_id = GtsInstance::derive_type_id(&entity.gts_id).ok_or_else(|| { - TypesRegistryError::invalid_gts_instance_id(format!( + invalid_gts_id_err(format!( "instance gts_id {} has no type-schema chain (no `~`)", entity.gts_id )) @@ -310,7 +315,7 @@ impl TypesRegistryClient for TypesRegistryLocalClient { async fn register( &self, entities: Vec, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { // Sort by extracted gts_id so parents register before children within // the same batch (items without an extractable id go to the end — // they'll fail in service.register with InvalidGtsId), but keep the @@ -368,7 +373,7 @@ impl TypesRegistryClient for TypesRegistryLocalClient { async fn register_type_schemas( &self, type_schemas: Vec, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { // See `register` for the sort-then-write-back-by-original-index pattern: // sort lets parents register before children in the batch, but the // returned vec must still line up with the caller's input order. @@ -387,9 +392,7 @@ impl TypesRegistryClient for TypesRegistryLocalClient { let Some(ref id) = gts_id else { slots[orig_idx] = Some(RegisterResult::Err { gts_id: None, - error: TypesRegistryError::invalid_gts_type_id( - "no GTS id field found in entity", - ), + error: invalid_gts_id_err("no GTS id field found in entity"), }); continue; }; @@ -397,9 +400,7 @@ impl TypesRegistryClient for TypesRegistryLocalClient { if !is_type_schema_id(id) { slots[orig_idx] = Some(RegisterResult::Err { gts_id: gts_id.clone(), - error: TypesRegistryError::invalid_gts_type_id(format!( - "{id} does not end with `~`", - )), + error: invalid_gts_id_err(format!("{id} does not end with `~`")), }); continue; } @@ -431,7 +432,7 @@ impl TypesRegistryClient for TypesRegistryLocalClient { Ok(slots.into_iter().map(Option::unwrap).collect()) } - async fn get_type_schema(&self, type_id: &str) -> Result { + async fn get_type_schema(&self, type_id: &str) -> Result { let arc = self.resolve_type_schema_arc(type_id)?; Ok((*arc).clone()) } @@ -439,7 +440,7 @@ impl TypesRegistryClient for TypesRegistryLocalClient { async fn get_type_schema_by_uuid( &self, type_uuid: Uuid, - ) -> Result { + ) -> Result { // Fast path: full cache hit by UUID. (Cache puts populate the // reverse uuid → gts_id index atomically, so anything previously // resolved on the type-schema side is reachable from here.) @@ -452,7 +453,7 @@ impl TypesRegistryClient for TypesRegistryLocalClient { async fn get_type_schemas( &self, type_ids: Vec, - ) -> HashMap> { + ) -> HashMap> { let mut out = HashMap::with_capacity(type_ids.len()); // Phase 1: format check + dedup. Format-rejected ids land directly @@ -467,9 +468,7 @@ impl TypesRegistryClient for TypesRegistryLocalClient { } else { out.insert( id.clone(), - Err(TypesRegistryError::invalid_gts_type_id(format!( - "{id} does not end with `~`", - ))), + Err(invalid_gts_id_err(format!("{id} does not end with `~`"))), ); } } @@ -493,11 +492,7 @@ impl TypesRegistryClient for TypesRegistryLocalClient { // Phase 3: storage round-trip for misses, batched put back. let mut to_put: Vec<(String, Arc)> = Vec::new(); for id in to_build { - let result = match self - .service - .get(&id) - .map_err(DomainError::into_sdk_for_type_schema) - { + let result = match self.service.get(&id).map_err(CanonicalError::from) { Ok(entity) => match self.build_type_schema_arc(entity) { Ok(arc) => { to_put.push((arc.type_id.to_string(), Arc::clone(&arc))); @@ -519,7 +514,7 @@ impl TypesRegistryClient for TypesRegistryLocalClient { async fn get_type_schemas_by_uuid( &self, type_uuids: Vec, - ) -> HashMap> { + ) -> HashMap> { // Phase 1: single-lock fast path — fully cached hits come back as // values; misses (uuid never observed, or value evicted) come back // as `None`. @@ -545,15 +540,15 @@ impl TypesRegistryClient for TypesRegistryLocalClient { async fn list_type_schemas( &self, query: TypeSchemaQuery, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { let entities = self .service .list(&ListQuery::from_type_schema_query(query)) - .map_err(DomainError::into_sdk_for_type_schema)?; + .map_err(CanonicalError::from)?; let mut out = Vec::with_capacity(entities.len()); for e in entities { if !e.is_type_schema { - return Err(TypesRegistryError::invalid_gts_type_id(format!( + return Err(invalid_gts_id_err(format!( "{} is not a type-schema", e.gts_id, ))); @@ -577,7 +572,7 @@ impl TypesRegistryClient for TypesRegistryLocalClient { async fn register_instances( &self, instances: Vec, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { // See `register` for the sort-then-write-back-by-original-index pattern. let mut indexed: Vec<(usize, Option, serde_json::Value)> = instances .into_iter() @@ -595,9 +590,7 @@ impl TypesRegistryClient for TypesRegistryLocalClient { let Some(ref id) = gts_id else { slots[orig_idx] = Some(RegisterResult::Err { gts_id: None, - error: TypesRegistryError::invalid_gts_instance_id( - "no GTS id field found in entity", - ), + error: invalid_gts_id_err("no GTS id field found in entity"), }); continue; }; @@ -605,7 +598,7 @@ impl TypesRegistryClient for TypesRegistryLocalClient { if is_type_schema_id(id) { slots[orig_idx] = Some(RegisterResult::Err { gts_id: gts_id.clone(), - error: TypesRegistryError::invalid_gts_instance_id(format!( + error: invalid_gts_id_err(format!( "{id} ends with `~` (looks like a type-schema id)", )), }); @@ -639,12 +632,12 @@ impl TypesRegistryClient for TypesRegistryLocalClient { Ok(slots.into_iter().map(Option::unwrap).collect()) } - async fn get_instance(&self, id: &str) -> Result { + async fn get_instance(&self, id: &str) -> Result { let arc = self.resolve_instance_arc(id)?; Ok((*arc).clone()) } - async fn get_instance_by_uuid(&self, uuid: Uuid) -> Result { + async fn get_instance_by_uuid(&self, uuid: Uuid) -> Result { // Fast path: full cache hit by UUID. if let Some(arc) = self.instances.get_by_uuid(uuid) { return Ok((*arc).clone()); @@ -655,7 +648,7 @@ impl TypesRegistryClient for TypesRegistryLocalClient { async fn get_instances( &self, ids: Vec, - ) -> HashMap> { + ) -> HashMap> { let mut out = HashMap::with_capacity(ids.len()); // Phase 1: format check + dedup. @@ -667,7 +660,7 @@ impl TypesRegistryClient for TypesRegistryLocalClient { if is_type_schema_id(&id) { out.insert( id.clone(), - Err(TypesRegistryError::invalid_gts_instance_id(format!( + Err(invalid_gts_id_err(format!( "{id} ends with `~` (looks like a type-schema id)", ))), ); @@ -695,11 +688,7 @@ impl TypesRegistryClient for TypesRegistryLocalClient { // Phase 3: storage round-trip for misses, batched put back. let mut to_put: Vec<(String, Arc)> = Vec::new(); for id in to_build { - let result = match self - .service - .get(&id) - .map_err(DomainError::into_sdk_for_instance) - { + let result = match self.service.get(&id).map_err(CanonicalError::from) { Ok(entity) => match self.build_instance(entity) { Ok(inst) => { let arc = Arc::new(inst); @@ -722,7 +711,7 @@ impl TypesRegistryClient for TypesRegistryLocalClient { async fn get_instances_by_uuid( &self, uuids: Vec, - ) -> HashMap> { + ) -> HashMap> { // Phase 1: single-lock fast path. Hits come back as values; misses // (uuid never observed, or value evicted) come back as `None`. let cached = self.instances.get_many_by_uuid(&uuids); @@ -747,11 +736,11 @@ impl TypesRegistryClient for TypesRegistryLocalClient { async fn list_instances( &self, query: InstanceQuery, - ) -> Result, TypesRegistryError> { + ) -> Result, CanonicalError> { let entities = self .service .list(&ListQuery::from_instance_query(query)) - .map_err(DomainError::into_sdk_for_instance)?; + .map_err(CanonicalError::from)?; let mut out = Vec::with_capacity(entities.len()); for e in entities { // Cache puts populate the uuid → gts_id index automatically. diff --git a/gears/system/types-registry/types-registry/src/domain/local_client_tests.rs b/gears/system/types-registry/types-registry/src/domain/local_client_tests.rs index 7713f3bc4..57a7f9f22 100644 --- a/gears/system/types-registry/types-registry/src/domain/local_client_tests.rs +++ b/gears/system/types-registry/types-registry/src/domain/local_client_tests.rs @@ -10,9 +10,39 @@ use crate::infra::InMemoryGtsRepository; use gts::GtsConfig; use serde_json::json; use std::time::Duration; +use toolkit_canonical_errors::InvalidArgument; const JSON_SCHEMA_DRAFT_07: &str = "https://json-schema.org/draft-07/schema#"; +// The client trait now returns `CanonicalError`; the legacy `is_*` predicates +// on the SDK error enum are gone. These helpers assert the canonical category +// the adapter routes each former SDK-error variant to (ADR 0005). +fn is_invalid_gts_id(err: &CanonicalError) -> bool { + matches!( + err, + CanonicalError::InvalidArgument { + ctx: InvalidArgument::FieldViolations { field_violations }, + .. + } if field_violations + .iter() + .any(|v| v.reason == types_registry_sdk::field::INVALID_GTS_ID) + ) +} + +fn is_not_found(err: &CanonicalError) -> bool { + matches!(err, CanonicalError::NotFound { .. }) +} + +fn is_parent_not_registered(err: &CanonicalError) -> bool { + matches!( + err, + CanonicalError::FailedPrecondition { ctx, .. } + if ctx.violations.iter().any(|v| { + v.type_ == types_registry_sdk::precondition::PARENT_NOT_REGISTERED + }) + ) +} + fn default_config() -> GtsConfig { crate::config::TypesRegistryConfig::default().to_gts_config() } @@ -303,7 +333,7 @@ async fn test_get_type_schema_rejects_instance() { .get_type_schema("gts.acme.core.events.user.v1~acme.core.instances.u1.v1") .await .unwrap_err(); - assert!(err.is_invalid_gts_type_id()); + assert!(is_invalid_gts_id(&err)); } #[tokio::test] @@ -316,7 +346,7 @@ async fn test_register_type_schemas_rejects_instance_input() { let results = client.register_type_schemas(vec![instance]).await.unwrap(); assert_eq!(results.len(), 1); match &results[0] { - RegisterResult::Err { error, .. } => assert!(error.is_invalid_gts_type_id()), + RegisterResult::Err { error, .. } => assert!(is_invalid_gts_id(error)), RegisterResult::Ok { .. } => panic!("expected Err for instance input"), } } @@ -367,7 +397,7 @@ async fn test_register_type_schemas_orphan_derived_in_ready_fails() { assert_eq!(results.len(), 1); match &results[0] { RegisterResult::Err { error, .. } => { - assert!(error.is_parent_type_schema_not_registered()); + assert!(is_parent_not_registered(error)); } RegisterResult::Ok { .. } => panic!("expected Err for orphan derived in ready"), } @@ -388,7 +418,7 @@ async fn test_register_instances_orphan_in_ready_fails() { assert_eq!(results.len(), 1); match &results[0] { RegisterResult::Err { error, .. } => { - assert!(error.is_parent_type_schema_not_registered()); + assert!(is_parent_not_registered(error)); } RegisterResult::Ok { .. } => panic!("expected Err for orphan instance in ready"), } @@ -447,7 +477,7 @@ async fn test_get_type_schema_by_uuid() { .get_type_schema_by_uuid(Uuid::nil()) .await .unwrap_err(); - assert!(unknown.is_gts_type_schema_not_found()); + assert!(is_not_found(&unknown)); } #[tokio::test] @@ -588,36 +618,29 @@ async fn test_get_type_schemas_partial_failures() { .get_type_schemas(vec![alpha.to_owned(), missing.to_owned(), gamma.to_owned()]) .await; assert!(results.get(alpha).expect("present").is_ok()); - assert!( + assert!(is_not_found( results .get(missing) .expect("present") .as_ref() .err() .unwrap() - .is_gts_type_schema_not_found() - ); + )); assert!(results.get(gamma).expect("present").is_ok()); } #[tokio::test] async fn test_get_type_schemas_kind_mismatch() { // An instance-shaped id (no trailing `~`) passed to get_type_schemas - // must surface as InvalidGtsTypeId for that single item, not fail - // the whole batch. + // must surface as an invalid-GTS-id validation error for that single + // item, not fail the whole batch. let client = create_client(); let bad = "gts.acme.core.events.user.v1~acme.core.instances.u1.v1"; let results = client.get_type_schemas(vec![bad.to_owned()]).await; assert_eq!(results.len(), 1); - assert!( - results - .get(bad) - .expect("present") - .as_ref() - .err() - .unwrap() - .is_invalid_gts_type_id() - ); + assert!(is_invalid_gts_id( + results.get(bad).expect("present").as_ref().err().unwrap() + )); } #[tokio::test] @@ -705,15 +728,14 @@ async fn test_get_instances_keyed_with_partial_failures() { results.get(u2).expect("present").as_ref().expect("ok").id, u2 ); - assert!( + assert!(is_not_found( results .get(missing) .expect("present") .as_ref() .err() .unwrap() - .is_gts_instance_not_found() - ); + )); assert_eq!( results.get(u1).expect("present").as_ref().expect("ok").id, u1 diff --git a/gears/system/types-registry/types-registry/src/domain/service.rs b/gears/system/types-registry/types-registry/src/domain/service.rs index c610ee548..14adffdc9 100644 --- a/gears/system/types-registry/types-registry/src/domain/service.rs +++ b/gears/system/types-registry/types-registry/src/domain/service.rs @@ -6,6 +6,7 @@ use std::sync::Arc; +use toolkit_canonical_errors::CanonicalError; use toolkit_macros::domain_model; use types_registry_sdk::RegisterResult; use uuid::Uuid; @@ -87,17 +88,15 @@ impl TypesRegistryService { Ok(registered) => RegisterResult::Ok { gts_id: registered.gts_id, }, - Err(e) => { - // Best-effort kind detection from the extracted gts_id so - // the SDK error variant matches the input shape. Unknown - // gts_id falls back to the type-schema variant — the kind- - // agnostic `register()` consumer doesn't distinguish them. - let error = match gts_id.as_deref() { - Some(s) if !s.ends_with('~') => e.into_sdk_for_instance(), - _ => e.into_sdk_for_type_schema(), - }; - RegisterResult::Err { gts_id, error } - } + // Per ADR 0005 the per-item error is the canonical envelope; + // the former type-schema-vs-instance variant split collapses to + // one classification (the kind stays recoverable from the + // `gts_id` suffix). Routed through the single + // `From for CanonicalError` ladder. + Err(e) => RegisterResult::Err { + gts_id, + error: CanonicalError::from(e), + }, }; results.push(result); } From facd1cc145c167bac0218b77fb00386dfca35311 Mon Sep 17 00:00:00 2001 From: Fluid Date: Thu, 11 Jun 2026 20:41:33 +0300 Subject: [PATCH 2/5] chore: bump the Rust version to 1.96.0 Signed-off-by: Fluid --- .github/workflows/ci.yml | 4 +- libs/rustls-fips-shim/Cargo.toml | 1 + .../ui/fail/unscoped_query_execution.stderr | 3 +- .../ui/fail/string_op_on_int_field.stderr | 3 +- rust-toolchain.toml | 2 +- tools/dylint_lints/Cargo.lock | 77 +++--- tools/dylint_lints/Cargo.toml | 8 +- .../de0101_no_serde_in_contract/src/lib.rs | 35 ++- .../de0102_no_toschema_in_contract/src/lib.rs | 14 +- .../src/lib.rs | 22 +- .../de0104_no_api_dto_in_contract/src/lib.rs | 16 +- .../src/lib.rs | 30 +-- .../de0201_dtos_only_in_api_rest/src/lib.rs | 16 +- .../src/lib.rs | 20 +- .../de0203_dtos_must_use_api_dto/src/lib.rs | 14 +- .../src/lib.rs | 16 +- .../de0205_operation_builder/src/lib.rs | 70 ++++-- .../de0301_no_infra_in_domain/src/lib.rs | 77 +++--- .../de0308_no_http_in_domain/src/lib.rs | 72 +++--- .../de0309_must_have_domain_model/src/lib.rs | 22 +- .../de0503_plugin_client_suffix/src/lib.rs | 22 +- .../de0504_client_versioning/src/lib.rs | 20 +- .../de0706_no_direct_sqlx/src/lib.rs | 74 +++--- .../de0707_drop_zeroize/src/lib.rs | 70 +++--- .../de0801_api_endpoint_version/src/lib.rs | 18 +- .../de0802_use_odata_ext/src/lib.rs | 24 +- .../de0803_api_snake_case/src/lib.rs | 61 +++-- .../de0901_gts_string_pattern/src/lib.rs | 228 +++++++++++------- .../src/lib.rs | 30 +-- .../de1101_tests_in_separate_files/src/lib.rs | 60 +++-- .../de1201_docs_rs_all_features/src/lib.rs | 40 ++- .../de1301_no_print_macros/src/lib.rs | 15 +- .../de1302_error_from_to_string/src/lib.rs | 42 ++-- .../de1303_no_primitive_type_alias/src/lib.rs | 24 +- tools/dylint_lints/lint_utils/Cargo.toml | 2 +- tools/dylint_lints/lint_utils/src/lib.rs | 2 +- tools/dylint_lints/rust-toolchain.toml | 2 +- 37 files changed, 750 insertions(+), 506 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd0e43b33..ba12cd245 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -343,7 +343,7 @@ jobs: contents: read id-token: write env: - RUSTUP_TOOLCHAIN: nightly-2026-01-22 + RUSTUP_TOOLCHAIN: nightly-2026-04-16 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -393,7 +393,7 @@ jobs: name: Dylint Tests runs-on: ubuntu-latest env: - RUSTUP_TOOLCHAIN: nightly-2026-01-22 + RUSTUP_TOOLCHAIN: nightly-2026-04-16 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/libs/rustls-fips-shim/Cargo.toml b/libs/rustls-fips-shim/Cargo.toml index 759227be5..188de9eb7 100644 --- a/libs/rustls-fips-shim/Cargo.toml +++ b/libs/rustls-fips-shim/Cargo.toml @@ -9,6 +9,7 @@ rust-version.workspace = true description = "Workspace-feature-unification shim: pulls rustls + hyper-rustls with the `fips` feature on every target where the AWS-LC FIPS backend is the intended TLS provider (i.e. everywhere except macOS, where Apple corecrypto is used, and Windows, where CNG is used instead)." keywords = ["constructorfabric", "cf-gears"] publish = true +metadata.docs.rs.all-features = true [lib] name = "rustls_fips_shim" diff --git a/libs/toolkit-db/tests/ui/fail/unscoped_query_execution.stderr b/libs/toolkit-db/tests/ui/fail/unscoped_query_execution.stderr index 20fe2b7b2..c8913c0e2 100644 --- a/libs/toolkit-db/tests/ui/fail/unscoped_query_execution.stderr +++ b/libs/toolkit-db/tests/ui/fail/unscoped_query_execution.stderr @@ -4,5 +4,4 @@ error[E0599]: no method named `all` found for struct `SecureSelect` | - = note: the method was found for - - `SecureSelect` + = note: the method was found for `SecureSelect` diff --git a/libs/toolkit-odata-macros/tests/ui/fail/string_op_on_int_field.stderr b/libs/toolkit-odata-macros/tests/ui/fail/string_op_on_int_field.stderr index c6c988a91..6d354c1b6 100644 --- a/libs/toolkit-odata-macros/tests/ui/fail/string_op_on_int_field.stderr +++ b/libs/toolkit-odata-macros/tests/ui/fail/string_op_on_int_field.stderr @@ -4,5 +4,4 @@ error[E0599]: no method named `contains` found for struct `FieldRef` | - = note: the method was found for - - `FieldRef` + = note: the method was found for `FieldRef` diff --git a/rust-toolchain.toml b/rust-toolchain.toml index f423e05f4..28ff4fc46 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.95.0" +channel = "1.96.0" components = ["rustfmt", "clippy"] diff --git a/tools/dylint_lints/Cargo.lock b/tools/dylint_lints/Cargo.lock index 15a0be1c7..ee2146d73 100644 --- a/tools/dylint_lints/Cargo.lock +++ b/tools/dylint_lints/Cargo.lock @@ -537,7 +537,7 @@ dependencies = [ [[package]] name = "cf-gears-system-sdk-directory" -version = "0.1.35" +version = "0.1.36" dependencies = [ "anyhow", "async-trait", @@ -545,14 +545,14 @@ dependencies = [ [[package]] name = "cf-gears-system-sdks" -version = "0.1.35" +version = "0.1.36" dependencies = [ "cf-gears-system-sdk-directory", ] [[package]] name = "cf-gears-toolkit" -version = "0.6.10" +version = "0.6.12" dependencies = [ "anyhow", "arc-swap", @@ -599,7 +599,7 @@ dependencies = [ [[package]] name = "cf-gears-toolkit-canonical-errors" -version = "0.7.3" +version = "0.7.4" dependencies = [ "axum 0.8.9", "cf-gears-toolkit-canonical-errors-macro", @@ -623,7 +623,7 @@ dependencies = [ [[package]] name = "cf-gears-toolkit-db" -version = "0.8.2" +version = "0.8.3" dependencies = [ "anyhow", "async-trait", @@ -664,7 +664,7 @@ dependencies = [ [[package]] name = "cf-gears-toolkit-gts" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "cf-gears-toolkit-gts-macros", @@ -678,7 +678,7 @@ dependencies = [ [[package]] name = "cf-gears-toolkit-gts-macros" -version = "0.1.0" +version = "0.1.1" dependencies = [ "gts-macros", "proc-macro-crate", @@ -700,7 +700,7 @@ dependencies = [ [[package]] name = "cf-gears-toolkit-odata" -version = "0.8.0" +version = "0.8.1" dependencies = [ "base64", "bigdecimal", @@ -720,7 +720,7 @@ dependencies = [ [[package]] name = "cf-gears-toolkit-sdk" -version = "0.6.5" +version = "0.6.6" dependencies = [ "cf-gears-toolkit-odata", "cf-gears-toolkit-security", @@ -730,7 +730,7 @@ dependencies = [ [[package]] name = "cf-gears-toolkit-security" -version = "0.7.0" +version = "0.7.1" dependencies = [ "anyhow", "postcard", @@ -787,8 +787,8 @@ dependencies = [ [[package]] name = "clippy_utils" -version = "0.1.95" -source = "git+https://github.com/rust-lang/rust-clippy?rev=54482290b5f32e6c6b57cc9e0a17153f432b0036#54482290b5f32e6c6b57cc9e0a17153f432b0036" +version = "0.1.97" +source = "git+https://github.com/rust-lang/rust-clippy?rev=f6d310692116e9a527ce6d0b3526c965d9c5d7b9#f6d310692116e9a527ce6d0b3526c965d9c5d7b9" dependencies = [ "arrayvec", "itertools 0.12.1", @@ -1413,9 +1413,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "dylint" -version = "5.0.0" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a937bab540c0c8cdcbd650a572e6899ef2b6ffbc277d61bd2ae8d17c0edce" +checksum = "c738fb72ea7d248df2995a31b3698beb63d0f1f9ca5a5dc0188c4b64cd0e86e4" dependencies = [ "anstyle", "anyhow", @@ -1431,9 +1431,9 @@ dependencies = [ [[package]] name = "dylint_internal" -version = "5.0.0" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e11f358a59510be7fa5c4f412729fabbe31a3587a342e4241a6a72020a2a0c5" +checksum = "9796d3441d7894cbaf4992640799efffc1661978f0cc1266ec788c32254fdfb3" dependencies = [ "anstyle", "anyhow", @@ -1441,10 +1441,8 @@ dependencies = [ "cargo_metadata", "git2", "home", - "if_chain", "log", "regex", - "rustversion", "serde", "tar", "thiserror 2.0.18", @@ -1453,9 +1451,9 @@ dependencies = [ [[package]] name = "dylint_linting" -version = "5.0.0" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4588b33aafbd472a6468ad1521d74094faa2bbdb53d534c2d24320300ea94135" +checksum = "c213635b3eaa84f43b9b497377f17d9a8d4feb413bab1d82e03ad1c73e4f6d8c" dependencies = [ "cargo_metadata", "dylint_internal", @@ -1468,9 +1466,9 @@ dependencies = [ [[package]] name = "dylint_testing" -version = "5.0.0" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdc27f344ddb5488eb16b6e0f8aec889a30fb7e4d135060d336cfa60d1fd671c" +checksum = "4f5f50ee06be9ebfb5b9538ba0d7bb08adc33e8f31c901e7d398de2a9b1ae290" dependencies = [ "anyhow", "cargo_metadata", @@ -2304,12 +2302,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "if_chain" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" - [[package]] name = "indexmap" version = "2.14.0" @@ -4725,26 +4717,17 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.12+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime 0.7.5+spec-1.1.0", + "toml_datetime", "toml_parser", "toml_writer", - "winnow 0.7.15", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", + "winnow", ] [[package]] @@ -4763,9 +4746,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime", "toml_parser", - "winnow 1.0.1", + "winnow", ] [[package]] @@ -4774,7 +4757,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.1", + "winnow", ] [[package]] @@ -5559,12 +5542,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - [[package]] name = "winnow" version = "1.0.1" diff --git a/tools/dylint_lints/Cargo.toml b/tools/dylint_lints/Cargo.toml index 5611acbaa..c8dabaaaa 100644 --- a/tools/dylint_lints/Cargo.toml +++ b/tools/dylint_lints/Cargo.toml @@ -1,5 +1,3 @@ -# Updated: 2026-04-07 by Constructor Tech -# Updated: 2026-03-13 by Constructor Tech [workspace] members = [ "lint_utils", @@ -53,9 +51,9 @@ unsafe_code = "forbid" unused_extern_crates = "warn" [workspace.dependencies] -clippy_utils = { git = "https://github.com/rust-lang/rust-clippy", rev = "54482290b5f32e6c6b57cc9e0a17153f432b0036" } -dylint_linting = "=5.0.0" -dylint_testing = "=5.0.0" +clippy_utils = { git = "https://github.com/rust-lang/rust-clippy", rev = "f6d310692116e9a527ce6d0b3526c965d9c5d7b9" } +dylint_linting = "6" +dylint_testing = "6" lint_utils = { path = "lint_utils" } tokio = { version = "1.44", features = ["macros", "rt"] } # NOTE: Keep gts version in sync with /Cargo.toml workspace dependencies diff --git a/tools/dylint_lints/de01_contract_layer/de0101_no_serde_in_contract/src/lib.rs b/tools/dylint_lints/de01_contract_layer/de0101_no_serde_in_contract/src/lib.rs index 036571e5d..1957e96e3 100644 --- a/tools/dylint_lints/de01_contract_layer/de0101_no_serde_in_contract/src/lib.rs +++ b/tools/dylint_lints/de01_contract_layer/de0101_no_serde_in_contract/src/lib.rs @@ -3,6 +3,7 @@ extern crate rustc_ast; +use clippy_utils::diagnostics::span_lint_and_then; use rustc_ast::{Item, ItemKind}; use rustc_lint::{EarlyContext, EarlyLintPass, LintContext}; @@ -70,19 +71,29 @@ impl EarlyLintPass for De0101NoSerdeInContract { let is_deserialize = lint_utils::is_serde_trait(&segments, "Deserialize"); if is_serialize { - cx.span_lint(DE0101_NO_SERDE_IN_CONTRACT, attr.span, |diag| { - diag.primary_message("contract type should not derive `Serialize` (DE0101)"); - diag.help( - "remove serde derives from contract models; use DTOs in the API layer", - ); - }); + span_lint_and_then( + cx, + DE0101_NO_SERDE_IN_CONTRACT, + attr.span, + "contract type should not derive `Serialize` (DE0101)", + |diag| { + diag.help( + "remove serde derives from contract models; use DTOs in the API layer", + ); + }, + ); } else if is_deserialize { - cx.span_lint(DE0101_NO_SERDE_IN_CONTRACT, attr.span, |diag| { - diag.primary_message("contract type should not derive `Deserialize` (DE0101)"); - diag.help( - "remove serde derives from contract models; use DTOs in the API layer", - ); - }); + span_lint_and_then( + cx, + DE0101_NO_SERDE_IN_CONTRACT, + attr.span, + "contract type should not derive `Deserialize` (DE0101)", + |diag| { + diag.help( + "remove serde derives from contract models; use DTOs in the API layer", + ); + }, + ); } }); } diff --git a/tools/dylint_lints/de01_contract_layer/de0102_no_toschema_in_contract/src/lib.rs b/tools/dylint_lints/de01_contract_layer/de0102_no_toschema_in_contract/src/lib.rs index 2fd8ec716..bca1cec1f 100644 --- a/tools/dylint_lints/de01_contract_layer/de0102_no_toschema_in_contract/src/lib.rs +++ b/tools/dylint_lints/de01_contract_layer/de0102_no_toschema_in_contract/src/lib.rs @@ -3,6 +3,7 @@ extern crate rustc_ast; +use clippy_utils::diagnostics::span_lint_and_then; use rustc_ast::{Item, ItemKind}; use rustc_lint::{EarlyContext, EarlyLintPass, LintContext}; @@ -68,10 +69,15 @@ impl EarlyLintPass for De0102NoToschemaInContract { // Check if this is a utoipa ToSchema // Handles: ToSchema, utoipa::ToSchema, ::utoipa::ToSchema if lint_utils::is_utoipa_trait(&segments, "ToSchema") { - cx.span_lint(DE0102_NO_TOSCHEMA_IN_CONTRACT, attr.span, |diag| { - diag.primary_message("contract type should not derive `ToSchema` (DE0102)"); - diag.help("ToSchema is an OpenAPI concern; use DTOs in api/rest/ instead"); - }); + span_lint_and_then( + cx, + DE0102_NO_TOSCHEMA_IN_CONTRACT, + attr.span, + "contract type should not derive `ToSchema` (DE0102)", + |diag| { + diag.help("ToSchema is an OpenAPI concern; use DTOs in api/rest/ instead"); + }, + ); } }); } diff --git a/tools/dylint_lints/de01_contract_layer/de0103_no_http_types_in_contract/src/lib.rs b/tools/dylint_lints/de01_contract_layer/de0103_no_http_types_in_contract/src/lib.rs index 82b0281f3..ea52ac237 100644 --- a/tools/dylint_lints/de01_contract_layer/de0103_no_http_types_in_contract/src/lib.rs +++ b/tools/dylint_lints/de01_contract_layer/de0103_no_http_types_in_contract/src/lib.rs @@ -3,8 +3,9 @@ extern crate rustc_ast; +use clippy_utils::diagnostics::span_lint_and_then; use rustc_ast::{Item, ItemKind, UseTree, UseTreeKind}; -use rustc_lint::{EarlyLintPass, LintContext}; +use rustc_lint::EarlyLintPass; use lint_utils::is_in_contract_module_ast; @@ -74,7 +75,7 @@ const HTTP_TYPE_PATTERNS: &[&str] = &[ fn use_tree_to_string(tree: &UseTree) -> String { match &tree.kind { - UseTreeKind::Simple(..) | UseTreeKind::Glob => tree + UseTreeKind::Simple(..) | UseTreeKind::Glob(_) => tree .prefix .segments .iter() @@ -109,12 +110,17 @@ fn check_use_in_contract(cx: &rustc_lint::EarlyContext<'_>, item: &Item) { let path_str = use_tree_to_string(use_tree); for pattern in HTTP_TYPE_PATTERNS { if path_str.contains(pattern) { - cx.span_lint(DE0103_NO_HTTP_TYPES_IN_CONTRACT, item.span, |diag| { - diag.primary_message("contract module imports HTTP type (DE0103)"); - diag.help( - "contract gears should be transport-agnostic; move HTTP types to api/rest/", - ); - }); + span_lint_and_then( + cx, + DE0103_NO_HTTP_TYPES_IN_CONTRACT, + item.span, + "contract module imports HTTP type (DE0103)", + |diag| { + diag.help( + "contract gears should be transport-agnostic; move HTTP types to api/rest/", + ); + }, + ); break; } } diff --git a/tools/dylint_lints/de01_contract_layer/de0104_no_api_dto_in_contract/src/lib.rs b/tools/dylint_lints/de01_contract_layer/de0104_no_api_dto_in_contract/src/lib.rs index 0cd7be13a..840a35b3f 100644 --- a/tools/dylint_lints/de01_contract_layer/de0104_no_api_dto_in_contract/src/lib.rs +++ b/tools/dylint_lints/de01_contract_layer/de0104_no_api_dto_in_contract/src/lib.rs @@ -3,8 +3,9 @@ extern crate rustc_ast; +use clippy_utils::diagnostics::span_lint_and_then; use rustc_ast::{Item, ItemKind}; -use rustc_lint::{EarlyContext, EarlyLintPass, LintContext}; +use rustc_lint::{EarlyContext, EarlyLintPass}; use lint_utils::is_in_contract_module_ast; @@ -77,10 +78,15 @@ impl EarlyLintPass for De0104NoApiDtoInContract { ); if is_api_dto { - cx.span_lint(DE0104_NO_API_DTO_IN_CONTRACT, attr.span, |diag| { - diag.primary_message("contract type should not use `api_dto` macro (DE0104)"); - diag.help("api_dto is for API DTOs; use plain structs in contract/ and create DTOs in api/rest/"); - }); + span_lint_and_then( + cx, + DE0104_NO_API_DTO_IN_CONTRACT, + attr.span, + "contract type should not use `api_dto` macro (DE0104)", + |diag| { + diag.help("api_dto is for API DTOs; use plain structs in contract/ and create DTOs in api/rest/"); + }, + ); } } } diff --git a/tools/dylint_lints/de01_contract_layer/de0110_no_schema_for_on_gts_structs/src/lib.rs b/tools/dylint_lints/de01_contract_layer/de0110_no_schema_for_on_gts_structs/src/lib.rs index 4632c80d3..ab708f2c4 100644 --- a/tools/dylint_lints/de01_contract_layer/de0110_no_schema_for_on_gts_structs/src/lib.rs +++ b/tools/dylint_lints/de01_contract_layer/de0110_no_schema_for_on_gts_structs/src/lib.rs @@ -5,6 +5,7 @@ extern crate rustc_hir; extern crate rustc_middle; extern crate rustc_span; +use clippy_utils::diagnostics::span_lint_and_then; use rustc_hir::{Expr, ExprKind, GenericArg}; use rustc_lint::{LateContext, LateLintPass, LintContext}; use rustc_middle::ty::{self, Ty}; @@ -113,20 +114,21 @@ impl<'tcx> LateLintPass<'tcx> for De0110NoSchemaForOnGtsStructs { && is_gts_type(cx, ty) { let type_name = get_type_name(ty); - cx.span_lint( - DE0110_NO_SCHEMA_FOR_ON_GTS_STRUCTS, - callsite_span, - |diag| { - diag.primary_message(format!( - "do not use `schema_for!({})` on GTS-wrapped struct (DE0110)", - type_name - )); - diag.help(format!( - "use `{}::gts_json_schema_with_refs()` instead for proper `$id` and `$ref` handling", - type_name - )); - }, - ); + span_lint_and_then( + cx, + DE0110_NO_SCHEMA_FOR_ON_GTS_STRUCTS, + callsite_span, + format!( + "do not use `schema_for!({})` on GTS-wrapped struct (DE0110)", + type_name + ), + |diag| { + diag.help(format!( + "use `{}::gts_json_schema_with_refs()` instead for proper `$id` and `$ref` handling", + type_name + )); + }, + ); return; } } diff --git a/tools/dylint_lints/de02_api_layer/de0201_dtos_only_in_api_rest/src/lib.rs b/tools/dylint_lints/de02_api_layer/de0201_dtos_only_in_api_rest/src/lib.rs index 6bfeae376..ee35863c0 100644 --- a/tools/dylint_lints/de02_api_layer/de0201_dtos_only_in_api_rest/src/lib.rs +++ b/tools/dylint_lints/de02_api_layer/de0201_dtos_only_in_api_rest/src/lib.rs @@ -3,6 +3,7 @@ extern crate rustc_ast; +use clippy_utils::diagnostics::span_lint_and_then; use rustc_ast::ItemKind; use rustc_lint::{EarlyLintPass, LintContext}; @@ -41,13 +42,18 @@ impl EarlyLintPass for De0201DtosOnlyInApiRest { // Check if the file is in api/rest folder (supports simulated_dir for tests) if !lint_utils::is_in_api_rest_folder(cx.sess().source_map(), item.span) { - cx.span_lint(DE0201_DTOS_ONLY_IN_API_REST, span, |diag| { - diag.primary_message(format!( + span_lint_and_then( + cx, + DE0201_DTOS_ONLY_IN_API_REST, + span, + format!( "DTO type `{}` is defined outside of api/rest folder (DE0201)", item_name - )); - diag.help("move DTO types to src/api/rest/dto.rs"); - }); + ), + |diag| { + diag.help("move DTO types to src/api/rest/dto.rs"); + }, + ); } } } diff --git a/tools/dylint_lints/de02_api_layer/de0202_dtos_not_referenced_outside_api/src/lib.rs b/tools/dylint_lints/de02_api_layer/de0202_dtos_not_referenced_outside_api/src/lib.rs index ec347fc31..911280494 100644 --- a/tools/dylint_lints/de02_api_layer/de0202_dtos_not_referenced_outside_api/src/lib.rs +++ b/tools/dylint_lints/de02_api_layer/de0202_dtos_not_referenced_outside_api/src/lib.rs @@ -3,6 +3,7 @@ extern crate rustc_hir; +use clippy_utils::diagnostics::span_lint_and_then; use rustc_hir::{Item, ItemKind}; use rustc_lint::{LateContext, LateLintPass, LintContext}; @@ -59,15 +60,20 @@ impl<'tcx> LateLintPass<'tcx> for De0202DtosNotReferencedOutsideApi { "infra" }; - cx.span_lint(DE0202_DTOS_NOT_REFERENCED_OUTSIDE_API, item.span, |diag| { - diag.primary_message(format!( + span_lint_and_then( + cx, + DE0202_DTOS_NOT_REFERENCED_OUTSIDE_API, + item.span, + format!( "{} module imports DTO type `{}` from api layer (DE0202)", module_type, last - )); - diag.help( - "DTOs are API layer details; use contract models or domain types instead", - ); - }); + ), + |diag| { + diag.help( + "DTOs are API layer details; use contract models or domain types instead", + ); + }, + ); } } } diff --git a/tools/dylint_lints/de02_api_layer/de0203_dtos_must_use_api_dto/src/lib.rs b/tools/dylint_lints/de02_api_layer/de0203_dtos_must_use_api_dto/src/lib.rs index a066cec1a..e7f500155 100644 --- a/tools/dylint_lints/de02_api_layer/de0203_dtos_must_use_api_dto/src/lib.rs +++ b/tools/dylint_lints/de02_api_layer/de0203_dtos_must_use_api_dto/src/lib.rs @@ -3,6 +3,7 @@ extern crate rustc_ast; +use clippy_utils::diagnostics::span_lint_and_then; use rustc_ast::{Item, ItemKind}; use rustc_lint::{EarlyContext, EarlyLintPass, LintContext}; @@ -73,10 +74,15 @@ fn check_dto_uses_api_dto(cx: &EarlyContext<'_>, item: &Item) { } // Report missing api_dto macro - cx.span_lint(DE0203_DTOS_MUST_USE_API_DTO, item.span, |diag| { - diag.primary_message("api/rest DTO type must use the api_dto macro (DE0203)"); - diag.help("Use #[toolkit_macros::api_dto(request)] for request DTOs, #[toolkit_macros::api_dto(response)] for response DTOs, or #[toolkit_macros::api_dto(request, response)] for both"); - }); + span_lint_and_then( + cx, + DE0203_DTOS_MUST_USE_API_DTO, + item.span, + "api/rest DTO type must use the api_dto macro (DE0203)", + |diag| { + diag.help("Use #[toolkit_macros::api_dto(request)] for request DTOs, #[toolkit_macros::api_dto(response)] for response DTOs, or #[toolkit_macros::api_dto(request, response)] for both"); + }, + ); } #[cfg(test)] diff --git a/tools/dylint_lints/de02_api_layer/de0204_dtos_must_have_toschema_derive/src/lib.rs b/tools/dylint_lints/de02_api_layer/de0204_dtos_must_have_toschema_derive/src/lib.rs index 6c91b8f6c..e85c755a5 100644 --- a/tools/dylint_lints/de02_api_layer/de0204_dtos_must_have_toschema_derive/src/lib.rs +++ b/tools/dylint_lints/de02_api_layer/de0204_dtos_must_have_toschema_derive/src/lib.rs @@ -3,8 +3,9 @@ extern crate rustc_ast; +use clippy_utils::diagnostics::span_lint_and_then; use rustc_ast::{Item, ItemKind}; -use rustc_lint::{EarlyContext, EarlyLintPass, LintContext}; +use rustc_lint::{EarlyContext, EarlyLintPass}; dylint_linting::declare_pre_expansion_lint! { /// DE0204: DTOs Must Have ToSchema Derive @@ -81,10 +82,15 @@ fn check_dto_toschema_derive(cx: &EarlyContext<'_>, item: &Item) { // Report missing derive if !has_toschema { - cx.span_lint(DE0204_DTOS_MUST_HAVE_TOSCHEMA_DERIVE, item.span, |diag| { - diag.primary_message("api/rest type is missing required ToSchema derive (DE0204)"); - diag.help("DTOs in api/rest must derive ToSchema for OpenAPI documentation"); - }); + span_lint_and_then( + cx, + DE0204_DTOS_MUST_HAVE_TOSCHEMA_DERIVE, + item.span, + "api/rest type is missing required ToSchema derive (DE0204)", + |diag| { + diag.help("DTOs in api/rest must derive ToSchema for OpenAPI documentation"); + }, + ); } } diff --git a/tools/dylint_lints/de02_api_layer/de0205_operation_builder/src/lib.rs b/tools/dylint_lints/de02_api_layer/de0205_operation_builder/src/lib.rs index 9117ebb7d..159424c3c 100644 --- a/tools/dylint_lints/de02_api_layer/de0205_operation_builder/src/lib.rs +++ b/tools/dylint_lints/de02_api_layer/de0205_operation_builder/src/lib.rs @@ -6,7 +6,8 @@ extern crate rustc_hir; extern crate rustc_span; use clippy_utils::consts::{ConstEvalCtxt, Constant}; -use rustc_lint::{LateContext, LateLintPass, LintContext}; +use clippy_utils::diagnostics::span_lint_and_then; +use rustc_lint::{LateContext, LateLintPass}; use rustc_span::Span; dylint_linting::declare_late_lint! { @@ -71,17 +72,27 @@ impl<'tcx> LateLintPass<'tcx> for De0205OperationBuilder { if let Some(tag_arg) = args.first() { if let Some(tag_string) = extract_tag_value(cx, tag_arg) { if !is_valid_tag_format(&tag_string) { - cx.span_lint(DE0205_OPERATION_BUILDER, tag_arg.span, |diag| { - diag.primary_message("tag format is invalid"); + span_lint_and_then( + cx, + DE0205_OPERATION_BUILDER, + tag_arg.span, + "tag format is invalid", + |diag| { diag.help("tags must contain whitespace-separated words, each starting with a capital letter"); diag.note("example: \"User Management\", \"Simple Resource Registry\""); - }); + }, + ); } } else { - cx.span_lint(DE0205_OPERATION_BUILDER, tag_arg.span, |diag| { - diag.primary_message("tag must be a string literal or const string"); + span_lint_and_then( + cx, + DE0205_OPERATION_BUILDER, + tag_arg.span, + "tag must be a string literal or const string", + |diag| { diag.help("use a string literal like `.tag(\"Your Tag\")` or a const string"); - }); + }, + ); } } } @@ -89,16 +100,26 @@ impl<'tcx> LateLintPass<'tcx> for De0205OperationBuilder { if let Some(summary_arg) = args.first() { if let Some(summary_string) = extract_tag_value(cx, summary_arg) { if summary_string.trim().is_empty() { - cx.span_lint(DE0205_OPERATION_BUILDER, summary_arg.span, |diag| { - diag.primary_message("summary cannot be empty"); - diag.help("provide a meaningful summary for the operation"); - }); + span_lint_and_then( + cx, + DE0205_OPERATION_BUILDER, + summary_arg.span, + "summary cannot be empty", + |diag| { + diag.help("provide a meaningful summary for the operation"); + }, + ); } } else { - cx.span_lint(DE0205_OPERATION_BUILDER, summary_arg.span, |diag| { - diag.primary_message("summary must be a string literal or const string"); + span_lint_and_then( + cx, + DE0205_OPERATION_BUILDER, + summary_arg.span, + "summary must be a string literal or const string", + |diag| { diag.help("use a string literal like `.summary(\"Your summary\")` or a const string"); - }); + }, + ); } } } @@ -120,26 +141,33 @@ fn check_complete_builder_chain(cx: &LateContext<'_>, expr: &rustc_hir::Expr<'_> // Report missing calls let builder_span = get_builder_constructor_span(expr); if !has_tag || !has_summary { - cx.span_lint(DE0205_OPERATION_BUILDER, builder_span, |diag| { - match (has_tag, has_summary) { + let msg = match (has_tag, has_summary) { + (false, false) => "operation builder missing .tag() and .summary() calls", + (false, true) => "operation builder missing .tag() call", + (true, false) => "operation builder missing .summary() call", + (true, true) => "", + }; + span_lint_and_then( + cx, + DE0205_OPERATION_BUILDER, + builder_span, + msg, + |diag| match (has_tag, has_summary) { (false, false) => { - diag.primary_message("operation builder missing .tag() and .summary() calls"); diag.help("add .tag(\"Your Tag\") with properly formatted tag"); diag.note("tags must contain whitespace-separated words, each starting with a capital letter"); diag.help("add .summary(\"Your summary\") with a meaningful description"); } (false, true) => { - diag.primary_message("operation builder missing .tag() call"); diag.help("add .tag(\"Your Tag\") with properly formatted tag"); diag.note("tags must contain whitespace-separated words, each starting with a capital letter"); } (true, false) => { - diag.primary_message("operation builder missing .summary() call"); diag.help("add .summary(\"Your summary\") with a meaningful description"); } (true, true) => {} - } - }); + }, + ); } } } diff --git a/tools/dylint_lints/de03_domain_layer/de0301_no_infra_in_domain/src/lib.rs b/tools/dylint_lints/de03_domain_layer/de0301_no_infra_in_domain/src/lib.rs index 7a1d9cc29..b7cacd414 100644 --- a/tools/dylint_lints/de03_domain_layer/de0301_no_infra_in_domain/src/lib.rs +++ b/tools/dylint_lints/de03_domain_layer/de0301_no_infra_in_domain/src/lib.rs @@ -3,6 +3,7 @@ extern crate rustc_ast; +use clippy_utils::diagnostics::span_lint_and_then; use lint_utils::{is_in_contract_module_ast, is_in_domain_path, use_tree_to_strings}; use rustc_ast::{Item, ItemKind, Ty, TyKind}; use rustc_lint::{EarlyLintPass, LintContext}; @@ -101,14 +102,17 @@ fn check_use_in_domain(cx: &rustc_lint::EarlyContext<'_>, item: &Item) { for path_str in use_tree_to_strings(use_tree) { if let Some(pattern) = matches_infra_pattern(&path_str) { - cx.span_lint(DE0301_NO_INFRA_IN_DOMAIN, item.span, |diag| { - diag.primary_message(format!( - "domain module imports infrastructure dependency `{pattern}` (DE0301)" - )); - diag.help( - "domain should depend only on abstractions; move infrastructure code to infra/ layer", - ); - }); + span_lint_and_then( + cx, + DE0301_NO_INFRA_IN_DOMAIN, + item.span, + format!("domain module imports infrastructure dependency `{pattern}` (DE0301)"), + |diag| { + diag.help( + "domain should depend only on abstractions; move infrastructure code to infra/ layer", + ); + }, + ); return; } } @@ -126,14 +130,17 @@ fn check_type_in_domain(cx: &rustc_lint::EarlyContext<'_>, ty: &Ty) { .join("::"); if matches_infra_pattern(&path_str).is_some() { - cx.span_lint(DE0301_NO_INFRA_IN_DOMAIN, ty.span, |diag| { - diag.primary_message(format!( - "domain module uses infrastructure type `{path_str}` (DE0301)" - )); - diag.help( - "domain should depend only on abstractions; move infrastructure code to infra/ layer", - ); - }); + span_lint_and_then( + cx, + DE0301_NO_INFRA_IN_DOMAIN, + ty.span, + format!("domain module uses infrastructure type `{path_str}` (DE0301)"), + |diag| { + diag.help( + "domain should depend only on abstractions; move infrastructure code to infra/ layer", + ); + }, + ); return; } @@ -189,14 +196,19 @@ fn check_type_in_domain(cx: &rustc_lint::EarlyContext<'_>, ty: &Ty) { .join("::"); if matches_infra_pattern(&path_str).is_some() { - cx.span_lint(DE0301_NO_INFRA_IN_DOMAIN, ty.span, |diag| { - diag.primary_message(format!( + span_lint_and_then( + cx, + DE0301_NO_INFRA_IN_DOMAIN, + ty.span, + format!( "domain module uses infrastructure trait `{path_str}` (DE0301)" - )); - diag.help( - "domain should depend only on abstractions; move infrastructure code to infra/ layer", - ); - }); + ), + |diag| { + diag.help( + "domain should depend only on abstractions; move infrastructure code to infra/ layer", + ); + }, + ); return; } } @@ -216,14 +228,19 @@ fn check_type_in_domain(cx: &rustc_lint::EarlyContext<'_>, ty: &Ty) { .join("::"); if matches_infra_pattern(&path_str).is_some() { - cx.span_lint(DE0301_NO_INFRA_IN_DOMAIN, ty.span, |diag| { - diag.primary_message(format!( + span_lint_and_then( + cx, + DE0301_NO_INFRA_IN_DOMAIN, + ty.span, + format!( "domain module uses infrastructure trait `{path_str}` (DE0301)" - )); - diag.help( - "domain should depend only on abstractions; move infrastructure code to infra/ layer", - ); - }); + ), + |diag| { + diag.help( + "domain should depend only on abstractions; move infrastructure code to infra/ layer", + ); + }, + ); return; } } diff --git a/tools/dylint_lints/de03_domain_layer/de0308_no_http_in_domain/src/lib.rs b/tools/dylint_lints/de03_domain_layer/de0308_no_http_in_domain/src/lib.rs index 3f3065f25..aae7f98bd 100644 --- a/tools/dylint_lints/de03_domain_layer/de0308_no_http_in_domain/src/lib.rs +++ b/tools/dylint_lints/de03_domain_layer/de0308_no_http_in_domain/src/lib.rs @@ -3,6 +3,7 @@ extern crate rustc_ast; +use clippy_utils::diagnostics::span_lint_and_then; use lint_utils::{is_in_contract_module_ast, is_in_domain_path, use_tree_to_strings}; use rustc_ast::{Item, ItemKind, Ty, TyKind}; use rustc_lint::{EarlyContext, EarlyLintPass, LintContext}; @@ -67,12 +68,15 @@ fn matches_http_pattern(path: &str) -> Option<&'static str> { fn check_use_item(cx: &EarlyContext<'_>, item: &Item, tree: &rustc_ast::UseTree) { for path_str in use_tree_to_strings(tree) { if let Some(pattern) = matches_http_pattern(&path_str) { - cx.span_lint(DE0308_NO_HTTP_IN_DOMAIN, item.span, |diag| { - diag.primary_message(format!( - "domain module imports HTTP type `{pattern}` (DE0308)" - )); - diag.help("domain should be transport-agnostic; handle HTTP in api/ layer"); - }); + span_lint_and_then( + cx, + DE0308_NO_HTTP_IN_DOMAIN, + item.span, + format!("domain module imports HTTP type `{pattern}` (DE0308)"), + |diag| { + diag.help("domain should be transport-agnostic; handle HTTP in api/ layer"); + }, + ); return; } } @@ -90,13 +94,15 @@ fn check_type_in_domain(cx: &rustc_lint::EarlyContext<'_>, ty: &Ty) { .join("::"); if matches_http_pattern(&path_str).is_some() { - cx.span_lint(DE0308_NO_HTTP_IN_DOMAIN, ty.span, |diag| { - diag.primary_message(format!( - "domain module uses HTTP type `{}` (DE0308)", - path_str - )); - diag.help("domain should be transport-agnostic; handle HTTP in api/ layer"); - }); + span_lint_and_then( + cx, + DE0308_NO_HTTP_IN_DOMAIN, + ty.span, + format!("domain module uses HTTP type `{}` (DE0308)", path_str), + |diag| { + diag.help("domain should be transport-agnostic; handle HTTP in api/ layer"); + }, + ); return; } @@ -152,15 +158,17 @@ fn check_type_in_domain(cx: &rustc_lint::EarlyContext<'_>, ty: &Ty) { .join("::"); if matches_http_pattern(&path_str).is_some() { - cx.span_lint(DE0308_NO_HTTP_IN_DOMAIN, ty.span, |diag| { - diag.primary_message(format!( - "domain module uses HTTP trait `{}` (DE0308)", - path_str - )); - diag.help( - "domain should be transport-agnostic; handle HTTP in api/ layer", - ); - }); + span_lint_and_then( + cx, + DE0308_NO_HTTP_IN_DOMAIN, + ty.span, + format!("domain module uses HTTP trait `{}` (DE0308)", path_str), + |diag| { + diag.help( + "domain should be transport-agnostic; handle HTTP in api/ layer", + ); + }, + ); return; } } @@ -180,15 +188,17 @@ fn check_type_in_domain(cx: &rustc_lint::EarlyContext<'_>, ty: &Ty) { .join("::"); if matches_http_pattern(&path_str).is_some() { - cx.span_lint(DE0308_NO_HTTP_IN_DOMAIN, ty.span, |diag| { - diag.primary_message(format!( - "domain module uses HTTP trait `{}` (DE0308)", - path_str - )); - diag.help( - "domain should be transport-agnostic; handle HTTP in api/ layer", - ); - }); + span_lint_and_then( + cx, + DE0308_NO_HTTP_IN_DOMAIN, + ty.span, + format!("domain module uses HTTP trait `{}` (DE0308)", path_str), + |diag| { + diag.help( + "domain should be transport-agnostic; handle HTTP in api/ layer", + ); + }, + ); return; } } diff --git a/tools/dylint_lints/de03_domain_layer/de0309_must_have_domain_model/src/lib.rs b/tools/dylint_lints/de03_domain_layer/de0309_must_have_domain_model/src/lib.rs index c92b36041..7c8031dbf 100644 --- a/tools/dylint_lints/de03_domain_layer/de0309_must_have_domain_model/src/lib.rs +++ b/tools/dylint_lints/de03_domain_layer/de0309_must_have_domain_model/src/lib.rs @@ -3,6 +3,7 @@ extern crate rustc_ast; +use clippy_utils::diagnostics::span_lint_and_then; use lint_utils::is_in_domain_path; use rustc_ast::{Item, ItemKind}; use rustc_lint::{EarlyContext, EarlyLintPass, LintContext}; @@ -76,15 +77,18 @@ fn check_domain_model_attribute(cx: &EarlyContext<'_>, item: &Item) { _ => return, }; - cx.span_lint(DE0309_MUST_HAVE_DOMAIN_MODEL, item.span, |diag| { - diag.primary_message(format!( - "domain type `{item_name}` is missing required #[domain_model] attribute (DE0309)" - )); - diag.help(format!( - "add #[domain_model] attribute to enforce DDD boundaries at compile time: \ - use toolkit_macros::domain_model; #[domain_model] pub {item_keyword} ..." - )); - }); + span_lint_and_then( + cx, + DE0309_MUST_HAVE_DOMAIN_MODEL, + item.span, + format!("domain type `{item_name}` is missing required #[domain_model] attribute (DE0309)"), + |diag| { + diag.help(format!( + "add #[domain_model] attribute to enforce DDD boundaries at compile time: \ + use toolkit_macros::domain_model; #[domain_model] pub {item_keyword} ..." + )); + }, + ); } /// Check if an item has the `#[domain_model]` or `#[toolkit::domain_model]` attribute. diff --git a/tools/dylint_lints/de05_client_layer/de0503_plugin_client_suffix/src/lib.rs b/tools/dylint_lints/de05_client_layer/de0503_plugin_client_suffix/src/lib.rs index 53680970e..10cf14170 100644 --- a/tools/dylint_lints/de05_client_layer/de0503_plugin_client_suffix/src/lib.rs +++ b/tools/dylint_lints/de05_client_layer/de0503_plugin_client_suffix/src/lib.rs @@ -4,8 +4,9 @@ extern crate rustc_ast; extern crate rustc_span; +use clippy_utils::diagnostics::span_lint_and_then; use rustc_ast::{Item, ItemKind}; -use rustc_lint::{EarlyContext, EarlyLintPass, LintContext}; +use rustc_lint::{EarlyContext, EarlyLintPass}; use rustc_span::Span; dylint_linting::declare_early_lint! { @@ -116,14 +117,19 @@ fn emit_lint( format!("{trait_name}Client") }; - cx.span_lint(DE0503_PLUGIN_CLIENT_SUFFIX, span, |diag| { - diag.primary_message(format!( + span_lint_and_then( + cx, + DE0503_PLUGIN_CLIENT_SUFFIX, + span, + format!( "plugin client trait `{trait_name}` should use `*{suggested_suffix}` suffix, not `*{wrong_suffix}` (DE0503)" - )); - diag.help(format!( - "rename trait to `{suggestion}` to follow plugin client naming conventions" - )); - }); + ), + |diag| { + diag.help(format!( + "rename trait to `{suggestion}` to follow plugin client naming conventions" + )); + }, + ); } #[cfg(test)] diff --git a/tools/dylint_lints/de05_client_layer/de0504_client_versioning/src/lib.rs b/tools/dylint_lints/de05_client_layer/de0504_client_versioning/src/lib.rs index af440c0ee..89dbdfa11 100644 --- a/tools/dylint_lints/de05_client_layer/de0504_client_versioning/src/lib.rs +++ b/tools/dylint_lints/de05_client_layer/de0504_client_versioning/src/lib.rs @@ -4,6 +4,7 @@ extern crate rustc_ast; extern crate rustc_span; +use clippy_utils::diagnostics::span_lint_and_then; use rustc_ast::{Item, ItemKind}; use rustc_lint::{EarlyContext, EarlyLintPass, LintContext}; use rustc_span::Span; @@ -116,14 +117,19 @@ fn emit_lint( format!("{}V1", version.base) }; - cx.span_lint(DE0504_CLIENT_VERSIONING, span, |diag| { - diag.primary_message(format!( + span_lint_and_then( + cx, + DE0504_CLIENT_VERSIONING, + span, + format!( "Client trait `{trait_name}` in non-system gear must have a version suffix (DE0504)" - )); - diag.help(format!( - "rename trait to `{suggestion}` to indicate API version" - )); - }); + ), + |diag| { + diag.help(format!( + "rename trait to `{suggestion}` to indicate API version" + )); + }, + ); } #[cfg(test)] diff --git a/tools/dylint_lints/de07_security/de0706_no_direct_sqlx/src/lib.rs b/tools/dylint_lints/de07_security/de0706_no_direct_sqlx/src/lib.rs index 8d6ced475..02fc5c66a 100644 --- a/tools/dylint_lints/de07_security/de0706_no_direct_sqlx/src/lib.rs +++ b/tools/dylint_lints/de07_security/de0706_no_direct_sqlx/src/lib.rs @@ -3,6 +3,7 @@ extern crate rustc_ast; +use clippy_utils::diagnostics::span_lint_and_then; use lint_utils::{ is_in_cf_gears_server_path, is_in_contract_module_ast, is_in_toolkit_db_path, use_tree_to_strings, @@ -79,13 +80,16 @@ fn check_type_for_sqlx(cx: &rustc_lint::EarlyContext<'_>, ty: &Ty) { .join("::"); if is_sqlx_path(&path_str) { - cx.span_lint(DE0706_NO_DIRECT_SQLX, ty.span, |diag| { - diag.primary_message(format!( - "direct sqlx type usage detected: `{path_str}` (DE0706)" - )); - diag.help("use Sea-ORM EntityTrait or SecORM abstractions instead"); - diag.note("sqlx bypasses security enforcement and architectural patterns"); - }); + span_lint_and_then( + cx, + DE0706_NO_DIRECT_SQLX, + ty.span, + format!("direct sqlx type usage detected: `{path_str}` (DE0706)"), + |diag| { + diag.help("use Sea-ORM EntityTrait or SecORM abstractions instead"); + diag.note("sqlx bypasses security enforcement and architectural patterns"); + }, + ); return; } @@ -131,15 +135,18 @@ fn check_type_for_sqlx(cx: &rustc_lint::EarlyContext<'_>, ty: &Ty) { .join("::"); if is_sqlx_path(&path_str) { - cx.span_lint(DE0706_NO_DIRECT_SQLX, ty.span, |diag| { - diag.primary_message(format!( - "direct sqlx trait usage detected: `{path_str}` (DE0706)" - )); - diag.help("use Sea-ORM EntityTrait or SecORM abstractions instead"); - diag.note( - "sqlx bypasses security enforcement and architectural patterns", - ); - }); + span_lint_and_then( + cx, + DE0706_NO_DIRECT_SQLX, + ty.span, + format!("direct sqlx trait usage detected: `{path_str}` (DE0706)"), + |diag| { + diag.help("use Sea-ORM EntityTrait or SecORM abstractions instead"); + diag.note( + "sqlx bypasses security enforcement and architectural patterns", + ); + }, + ); return; } } @@ -155,14 +162,16 @@ fn check_use_for_sqlx(cx: &rustc_lint::EarlyContext<'_>, item: &Item) { }; if let Some(path_str) = find_sqlx_path(use_tree) { - cx.span_lint(DE0706_NO_DIRECT_SQLX, item.span, |diag| { - diag.primary_message(format!( - "direct sqlx import detected: `{}` (DE0706)", - path_str - )); - diag.help("use Sea-ORM EntityTrait or SecORM abstractions instead"); - diag.note("sqlx bypasses security enforcement and architectural patterns"); - }); + span_lint_and_then( + cx, + DE0706_NO_DIRECT_SQLX, + item.span, + format!("direct sqlx import detected: `{}` (DE0706)", path_str), + |diag| { + diag.help("use Sea-ORM EntityTrait or SecORM abstractions instead"); + diag.note("sqlx bypasses security enforcement and architectural patterns"); + }, + ); } } @@ -197,11 +206,18 @@ impl EarlyLintPass for De0706NoDirectSqlx { }; if is_sqlx { - cx.span_lint(DE0706_NO_DIRECT_SQLX, item.span, |diag| { - diag.primary_message("extern crate sqlx is prohibited (DE0706)"); - diag.help("use Sea-ORM EntityTrait or SecORM abstractions instead"); - diag.note("sqlx bypasses security enforcement and architectural patterns"); - }); + span_lint_and_then( + cx, + DE0706_NO_DIRECT_SQLX, + item.span, + "extern crate sqlx is prohibited (DE0706)", + |diag| { + diag.help("use Sea-ORM EntityTrait or SecORM abstractions instead"); + diag.note( + "sqlx bypasses security enforcement and architectural patterns", + ); + }, + ); } } // Check struct fields for sqlx types diff --git a/tools/dylint_lints/de07_security/de0707_drop_zeroize/src/lib.rs b/tools/dylint_lints/de07_security/de0707_drop_zeroize/src/lib.rs index e479f9f7d..51749ab82 100644 --- a/tools/dylint_lints/de07_security/de0707_drop_zeroize/src/lib.rs +++ b/tools/dylint_lints/de07_security/de0707_drop_zeroize/src/lib.rs @@ -8,9 +8,10 @@ extern crate rustc_hir; extern crate rustc_middle; extern crate rustc_span; +use clippy_utils::diagnostics::span_lint_and_then; use rustc_hir::def_id::DefId; use rustc_hir::{self as hir, Expr, ExprKind, ImplItemKind, ItemKind}; -use rustc_lint::{LateContext, LateLintPass, LintContext}; +use rustc_lint::{LateContext, LateLintPass}; use rustc_middle::ty::{Ty, TypeckResults}; use rustc_span::symbol::Symbol; @@ -158,17 +159,20 @@ impl<'tcx> hir::intravisit::Visitor<'tcx> for ZeroingVisitor<'tcx, '_> { { let inner_ty = self.typeck.expr_ty(inner); if is_u8_ptr_or_ref(inner_ty) { - self.cx.span_lint(DE0707_DROP_ZEROIZE, expr.span, |diag| { - diag.primary_message( - "manual byte-zeroing in `Drop::drop` may be eliminated by the optimizer (DE0707)", - ); + span_lint_and_then( + self.cx, + DE0707_DROP_ZEROIZE, + expr.span, + "manual byte-zeroing in `Drop::drop` may be eliminated by the optimizer (DE0707)", + |diag| { diag.help( "use `secrecy::SecretBox` or `zeroize`: `.zeroize()` / `#[derive(ZeroizeOnDrop)]`", ); diag.note( "LLVM dead-store elimination can legally remove writes that are never read; `zeroize` uses a compiler fence to prevent this", ); - }); + }, + ); } } } @@ -190,17 +194,20 @@ impl<'tcx> hir::intravisit::Visitor<'tcx> for ZeroingVisitor<'tcx, '_> { // Use adjusted type so Vec auto-derefs to [u8] let recv_ty = self.typeck.expr_ty_adjusted(recv); if method_in_std && has_u8_element(recv_ty) { - self.cx.span_lint(DE0707_DROP_ZEROIZE, expr.span, |diag| { - diag.primary_message( - "manual byte-zeroing in `Drop::drop` may be eliminated by the optimizer (DE0707)", - ); - diag.help( - "use `secrecy::SecretBox` or `zeroize`: `.zeroize()` / `#[derive(ZeroizeOnDrop)]`", - ); - diag.note( - "LLVM dead-store elimination can legally remove writes that are never read; `zeroize` uses a compiler fence to prevent this", - ); - }); + span_lint_and_then( + self.cx, + DE0707_DROP_ZEROIZE, + expr.span, + "manual byte-zeroing in `Drop::drop` may be eliminated by the optimizer (DE0707)", + |diag| { + diag.help( + "use `secrecy::SecretBox` or `zeroize`: `.zeroize()` / `#[derive(ZeroizeOnDrop)]`", + ); + diag.note( + "LLVM dead-store elimination can legally remove writes that are never read; `zeroize` uses a compiler fence to prevent this", + ); + }, + ); } } } @@ -215,21 +222,20 @@ impl<'tcx> hir::intravisit::Visitor<'tcx> for ZeroingVisitor<'tcx, '_> { && let Some(def_id) = self.cx.qpath_res(qpath, func.hir_id).opt_def_id() && is_ptr_write_bytes(self.cx, def_id) { - self.cx.span_lint( - DE0707_DROP_ZEROIZE, - expr.span, - |diag| { - diag.primary_message( - "manual byte-zeroing in `Drop::drop` may be eliminated by the optimizer (DE0707)", - ); - diag.help( - "use `secrecy::SecretBox` or `zeroize`: `.zeroize()` / `#[derive(ZeroizeOnDrop)]`", - ); - diag.note( - "LLVM dead-store elimination can legally remove writes that are never read; `zeroize` uses a compiler fence to prevent this", - ); - }, - ); + span_lint_and_then( + self.cx, + DE0707_DROP_ZEROIZE, + expr.span, + "manual byte-zeroing in `Drop::drop` may be eliminated by the optimizer (DE0707)", + |diag| { + diag.help( + "use `secrecy::SecretBox` or `zeroize`: `.zeroize()` / `#[derive(ZeroizeOnDrop)]`", + ); + diag.note( + "LLVM dead-store elimination can legally remove writes that are never read; `zeroize` uses a compiler fence to prevent this", + ); + }, + ); } } _ => {} diff --git a/tools/dylint_lints/de08_rest_api_conventions/de0801_api_endpoint_version/src/lib.rs b/tools/dylint_lints/de08_rest_api_conventions/de0801_api_endpoint_version/src/lib.rs index 4f90c0934..fddda28a1 100644 --- a/tools/dylint_lints/de08_rest_api_conventions/de0801_api_endpoint_version/src/lib.rs +++ b/tools/dylint_lints/de08_rest_api_conventions/de0801_api_endpoint_version/src/lib.rs @@ -4,8 +4,9 @@ extern crate rustc_ast; extern crate rustc_hir; +use clippy_utils::diagnostics::span_lint_and_then; use rustc_hir::{Expr, ExprKind}; -use rustc_lint::{LateContext, LateLintPass, LintContext}; +use rustc_lint::{LateContext, LateLintPass}; dylint_linting::declare_late_lint! { /// ### What it does @@ -273,11 +274,16 @@ fn check_path_argument<'tcx>(cx: &LateContext<'tcx>, path_arg: &'tcx Expr<'tcx>) ), }; - cx.span_lint(DE0801_API_ENDPOINT_VERSION, path_arg.span, |diag| { - diag.primary_message(message); - diag.help(help); - diag.note(note); - }); + span_lint_and_then( + cx, + DE0801_API_ENDPOINT_VERSION, + path_arg.span, + message, + |diag| { + diag.help(help); + diag.note(note); + }, + ); } } } diff --git a/tools/dylint_lints/de08_rest_api_conventions/de0802_use_odata_ext/src/lib.rs b/tools/dylint_lints/de08_rest_api_conventions/de0802_use_odata_ext/src/lib.rs index a13faed32..68a95446c 100644 --- a/tools/dylint_lints/de08_rest_api_conventions/de0802_use_odata_ext/src/lib.rs +++ b/tools/dylint_lints/de08_rest_api_conventions/de0802_use_odata_ext/src/lib.rs @@ -4,8 +4,9 @@ extern crate rustc_ast; extern crate rustc_hir; +use clippy_utils::diagnostics::span_lint_and_then; use rustc_hir::{Expr, ExprKind}; -use rustc_lint::{LateContext, LateLintPass, LintContext}; +use rustc_lint::{LateContext, LateLintPass}; dylint_linting::declare_late_lint! { /// ### What it does @@ -133,16 +134,21 @@ fn check_odata_param<'tcx>(cx: &LateContext<'tcx>, arg: &'tcx Expr<'tcx>, method if ODATA_PARAMS.contains(¶m_name) { let recommended = get_recommended_method(param_name); - cx.span_lint(DE0802_USE_ODATA_EXT, arg.span, |diag| { - diag.primary_message(format!( + span_lint_and_then( + cx, + DE0802_USE_ODATA_EXT, + arg.span, + format!( "use OperationBuilderODataExt instead of .{}() for OData parameter `{}` (DE0802)", method_name, param_name - )); - diag.help(format!("use {} instead", recommended)); - diag.note( - "type-safe OData methods provide compile-time validation and automatic OpenAPI schema generation", - ); - }); + ), + |diag| { + diag.help(format!("use {} instead", recommended)); + diag.note( + "type-safe OData methods provide compile-time validation and automatic OpenAPI schema generation", + ); + }, + ); } } } diff --git a/tools/dylint_lints/de08_rest_api_conventions/de0803_api_snake_case/src/lib.rs b/tools/dylint_lints/de08_rest_api_conventions/de0803_api_snake_case/src/lib.rs index ea5d89492..9c3b0a54a 100644 --- a/tools/dylint_lints/de08_rest_api_conventions/de0803_api_snake_case/src/lib.rs +++ b/tools/dylint_lints/de08_rest_api_conventions/de0803_api_snake_case/src/lib.rs @@ -4,6 +4,7 @@ extern crate rustc_ast; extern crate rustc_span; +use clippy_utils::diagnostics::span_lint_and_then; use rustc_ast::{Attribute, FieldDef, Item, ItemKind, VariantData}; use rustc_lint::{EarlyContext, EarlyLintPass, LintContext}; @@ -104,14 +105,17 @@ fn find_serde_attribute_value( fn check_type_rename_all(cx: &EarlyContext<'_>, attrs: &[Attribute]) { for (span, value) in find_serde_attribute_value(attrs, "rename_all") { if value != "snake_case" { - cx.span_lint(DE0803_API_SNAKE_CASE, span, |diag| { - diag.primary_message( - "DTOs must not use non-snake_case in serde rename_all (DE0803)", - ); - diag.help( - "DTOs in api/rest must use snake_case (or default) to match API standards", - ); - }); + span_lint_and_then( + cx, + DE0803_API_SNAKE_CASE, + span, + "DTOs must not use non-snake_case in serde rename_all (DE0803)", + |diag| { + diag.help( + "DTOs in api/rest must use snake_case (or default) to match API standards", + ); + }, + ); } } } @@ -120,12 +124,17 @@ fn check_type_rename_all(cx: &EarlyContext<'_>, attrs: &[Attribute]) { fn check_variant_rename(cx: &EarlyContext<'_>, attrs: &[Attribute]) { for (span, value) in find_serde_attribute_value(attrs, "rename") { if !is_snake_case(&value) { - cx.span_lint(DE0803_API_SNAKE_CASE, span, |diag| { - diag.primary_message( - "Enum variants must not use non-snake_case in serde rename (DE0803)", - ); - diag.help("Enum variants in api/rest must use snake_case to match API standards"); - }); + span_lint_and_then( + cx, + DE0803_API_SNAKE_CASE, + span, + "Enum variants must not use non-snake_case in serde rename (DE0803)", + |diag| { + diag.help( + "Enum variants in api/rest must use snake_case to match API standards", + ); + }, + ); } } } @@ -157,13 +166,12 @@ fn check_field_snake_case(cx: &EarlyContext<'_>, field: &FieldDef) { if rename_values.is_empty() { // No field-level serde rename - field name must be snake_case if !is_snake_case(&field_name) { - cx.span_lint( + span_lint_and_then( + cx, DE0803_API_SNAKE_CASE, field.ident.unwrap().span, + "DTO field name must be snake_case or have a serde rename to snake_case (DE0803)", |diag| { - diag.primary_message( - "DTO field name must be snake_case or have a serde rename to snake_case (DE0803)" - ); diag.help(format!( "rename field to snake_case or add #[serde(rename = \"{}\")]", to_snake_case(&field_name) @@ -175,12 +183,17 @@ fn check_field_snake_case(cx: &EarlyContext<'_>, field: &FieldDef) { // Has field-level serde rename - the rename value must be snake_case for (span, value) in rename_values { if !is_snake_case(&value) { - cx.span_lint(DE0803_API_SNAKE_CASE, span, |diag| { - diag.primary_message( - "DTO fields must not use non-snake_case in serde rename (DE0803)", - ); - diag.help("DTO fields in api/rest must use snake_case to match API standards"); - }); + span_lint_and_then( + cx, + DE0803_API_SNAKE_CASE, + span, + "DTO fields must not use non-snake_case in serde rename (DE0803)", + |diag| { + diag.help( + "DTO fields in api/rest must use snake_case to match API standards", + ); + }, + ); } } } diff --git a/tools/dylint_lints/de09_gts_layer/de0901_gts_string_pattern/src/lib.rs b/tools/dylint_lints/de09_gts_layer/de0901_gts_string_pattern/src/lib.rs index 4846314c4..d1e4a0e05 100644 --- a/tools/dylint_lints/de09_gts_layer/de0901_gts_string_pattern/src/lib.rs +++ b/tools/dylint_lints/de09_gts_layer/de0901_gts_string_pattern/src/lib.rs @@ -4,6 +4,7 @@ extern crate rustc_ast; extern crate rustc_span; +use clippy_utils::diagnostics::span_lint_and_then; use gts::{GtsIdSegment, GtsOps}; use lint_utils::{filename_str, is_temp_path}; use rustc_ast::token::LitKind; @@ -71,10 +72,7 @@ impl EarlyLintPass for De0901GtsStringPattern { // Extract both the item name and the initializer expression from const/static items. // Note: `Item` has no top-level `ident`; it lives inside `ConstItem` / `StaticItem`. let (item_name, init_expr): (&str, Option<&Expr>) = match &item.kind { - ItemKind::Const(ci) => ( - ci.ident.name.as_str(), - ci.rhs.as_ref().map(|rhs| rhs.expr()), - ), + ItemKind::Const(ci) => (ci.ident.name.as_str(), ci.rhs_kind.expr()), ItemKind::Static(si) => (si.ident.name.as_str(), si.expr.as_deref()), _ => return, }; @@ -91,13 +89,16 @@ impl EarlyLintPass for De0901GtsStringPattern { // Validate the wildcard GTS pattern itself before skip-listing. let result = GtsOps::parse_id(s); if !result.ok { - cx.span_lint(DE0901_GTS_STRING_PATTERN, item.span, |diag| { - diag.primary_message(format!( - "invalid GTS wildcard pattern in `{item_name}`: '{s}' (DE0901)" - )); - diag.note(result.error); - diag.help("Example: gts.cf.core.srr.resource.v1~*"); - }); + span_lint_and_then( + cx, + DE0901_GTS_STRING_PATTERN, + item.span, + format!("invalid GTS wildcard pattern in `{item_name}`: '{s}' (DE0901)"), + |diag| { + diag.note(result.error); + diag.help("Example: gts.cf.core.srr.resource.v1~*"); + }, + ); // Still skip-list so check_expr doesn't double-report the literal. SKIP_SPANS.with(|spans| { collect_nested_spans(init, &mut spans.borrow_mut()); @@ -108,17 +109,22 @@ impl EarlyLintPass for De0901GtsStringPattern { self.check_vendors_in_parse_result(cx, item.span, s, &result); if !item_name.ends_with("_WILDCARD") { - cx.span_lint(DE0901_GTS_STRING_PATTERN, item.span, |diag| { - diag.primary_message(format!( + span_lint_and_then( + cx, + DE0901_GTS_STRING_PATTERN, + item.span, + format!( "GTS wildcard string in `const`/`static` `{item_name}` must have a name ending with `_WILDCARD` (DE0901)" - )); - diag.note(format!( - "found wildcard GTS pattern `{s}` stored in `{item_name}`" - )); - diag.help(format!( - "rename to `{item_name}_WILDCARD` or use a non-wildcard value" - )); - }); + ), + |diag| { + diag.note(format!( + "found wildcard GTS pattern `{s}` stored in `{item_name}`" + )); + diag.help(format!( + "rename to `{item_name}_WILDCARD` or use a non-wildcard value" + )); + }, + ); } // Skip-list the span so check_expr doesn't re-flag (or double-report) the literal. @@ -503,11 +509,16 @@ impl De0901GtsStringPattern { // Wildcards are NOT allowed in schema_id if s.contains('*') { - cx.span_lint(DE0901_GTS_STRING_PATTERN, span, |diag| { - diag.primary_message(format!("wildcards are not allowed in schema_id: '{}' (DE0901)", s)); - diag.note("Wildcards (*) are only allowed in permission strings, not in schema_id attributes"); - diag.help("Use concrete type names in schema_id"); - }); + span_lint_and_then( + cx, + DE0901_GTS_STRING_PATTERN, + span, + format!("wildcards are not allowed in schema_id: '{}' (DE0901)", s), + |diag| { + diag.note("Wildcards (*) are only allowed in permission strings, not in schema_id attributes"); + diag.help("Use concrete type names in schema_id"); + }, + ); return; } @@ -515,24 +526,34 @@ impl De0901GtsStringPattern { let result = GtsOps::parse_id(s); if !result.ok { - cx.span_lint(DE0901_GTS_STRING_PATTERN, span, |diag| { - diag.primary_message(format!("invalid GTS schema_id: '{}' (DE0901)", s)); - diag.note(result.error); - diag.help("Example: gts.cf.core.events.type.v1~"); - }); + span_lint_and_then( + cx, + DE0901_GTS_STRING_PATTERN, + span, + format!("invalid GTS schema_id: '{}' (DE0901)", s), + |diag| { + diag.note(result.error); + diag.help("Example: gts.cf.core.events.type.v1~"); + }, + ); return; } // Ensure it's actually a schema (type), not an instance if result.is_type != Some(true) { - cx.span_lint(DE0901_GTS_STRING_PATTERN, span, |diag| { - diag.primary_message(format!( + span_lint_and_then( + cx, + DE0901_GTS_STRING_PATTERN, + span, + format!( "schema_id must be a type schema, not an instance: '{}' (DE0901)", s - )); - diag.note("schema_id must end with '~' to indicate it's a type schema"); - diag.help("Example: gts.cf.core.events.type.v1~"); - }); + ), + |diag| { + diag.note("schema_id must end with '~' to indicate it's a type schema"); + diag.help("Example: gts.cf.core.events.type.v1~"); + }, + ); } else { self.check_vendors_in_parse_result(cx, span, s, &result); } @@ -546,46 +567,63 @@ impl De0901GtsStringPattern { // If the input contains delimiters for chained ids / permission strings, // it is not a single segment. if s.contains('~') || s.contains(':') { - cx.span_lint(DE0901_GTS_STRING_PATTERN, span, |diag| { - diag.primary_message(format!( + span_lint_and_then( + cx, + DE0901_GTS_STRING_PATTERN, + span, + format!( "gts_make_instance_id expects a single GTS segment, got: '{}' (DE0901)", s - )); - diag.help("Example: vendor.package.sku.abc.v1"); - }); + ), + |diag| { + diag.help("Example: vendor.package.sku.abc.v1"); + }, + ); return; } if s.contains('*') { - cx.span_lint(DE0901_GTS_STRING_PATTERN, span, |diag| { - diag.primary_message(format!( + span_lint_and_then( + cx, + DE0901_GTS_STRING_PATTERN, + span, + format!( "wildcards are not allowed in instance id segments: '{}' (DE0901)", s - )); - diag.help("Example: vendor.package.sku.abc.v1"); - }); + ), + |diag| { + diag.help("Example: vendor.package.sku.abc.v1"); + }, + ); return; } match GtsIdSegment::new(0, 0, s) { Err(e) => { - cx.span_lint(DE0901_GTS_STRING_PATTERN, span, |diag| { - diag.primary_message(format!("invalid GTS segment: '{}' (DE0901)", s)); - diag.note(e.to_string()); - diag.help("Example: vendor.package.sku.abc.v1"); - }); + span_lint_and_then( + cx, + DE0901_GTS_STRING_PATTERN, + span, + format!("invalid GTS segment: '{}' (DE0901)", s), + |diag| { + diag.note(e.to_string()); + diag.help("Example: vendor.package.sku.abc.v1"); + }, + ); } Ok(seg) if !allowed_vendors(cx, span).contains(&seg.vendor.as_str()) => { - cx.span_lint(DE0901_GTS_STRING_PATTERN, span, |diag| { - diag.primary_message(format!( - "invalid GTS vendor in segment: '{}' (DE0901)", - s - )); - diag.note(format!( - "found vendor '{}', allowed vendors are 'cf' and 'example'", - seg.vendor - )); - }); + span_lint_and_then( + cx, + DE0901_GTS_STRING_PATTERN, + span, + format!("invalid GTS vendor in segment: '{}' (DE0901)", s), + |diag| { + diag.note(format!( + "found vendor '{}', allowed vendors are 'cf' and 'example'", + seg.vendor + )); + }, + ); } Ok(_) => {} } @@ -604,16 +642,18 @@ impl De0901GtsStringPattern { { continue; } - cx.span_lint(DE0901_GTS_STRING_PATTERN, span, |diag| { - diag.primary_message(format!( - "invalid GTS vendor in segment #{idx}: '{}' (DE0901)", - s - )); - diag.note(format!( - "found vendor '{}', allowed vendors are 'cf' and 'example'", - seg.vendor - )); - }); + span_lint_and_then( + cx, + DE0901_GTS_STRING_PATTERN, + span, + format!("invalid GTS vendor in segment #{idx}: '{}' (DE0901)", s), + |diag| { + diag.note(format!( + "found vendor '{}', allowed vendors are 'cf' and 'example'", + seg.vendor + )); + }, + ); break; } } @@ -623,21 +663,34 @@ impl De0901GtsStringPattern { // Wildcards are NOT allowed in regular GTS strings (only in permission strings) if s.contains('*') { - cx.span_lint(DE0901_GTS_STRING_PATTERN, span, |diag| { - diag.primary_message(format!("invalid GTS string (wildcards not allowed): '{}' (DE0901)", s)); - diag.note("Wildcards (*) are only allowed in permission strings, not in regular GTS identifiers"); - diag.help("Use concrete type names"); - }); + span_lint_and_then( + cx, + DE0901_GTS_STRING_PATTERN, + span, + format!( + "invalid GTS string (wildcards not allowed): '{}' (DE0901)", + s + ), + |diag| { + diag.note("Wildcards (*) are only allowed in permission strings, not in regular GTS identifiers"); + diag.help("Use concrete type names"); + }, + ); return; } let result = GtsOps::parse_id(s); if !result.ok { - cx.span_lint(DE0901_GTS_STRING_PATTERN, span, |diag| { - diag.primary_message(format!("invalid GTS string: '{}' (DE0901)", s)); - diag.note(result.error); - }); + span_lint_and_then( + cx, + DE0901_GTS_STRING_PATTERN, + span, + format!("invalid GTS string: '{}' (DE0901)", s), + |diag| { + diag.note(result.error); + }, + ); } else { self.check_vendors_in_parse_result(cx, span, s, &result); } @@ -655,10 +708,15 @@ impl De0901GtsStringPattern { let result = GtsOps::parse_id(s); if !result.ok { - cx.span_lint(DE0901_GTS_STRING_PATTERN, span, |diag| { - diag.primary_message(format!("invalid GTS string: '{}' (DE0901)", s)); - diag.note(result.error); - }); + span_lint_and_then( + cx, + DE0901_GTS_STRING_PATTERN, + span, + format!("invalid GTS string: '{}' (DE0901)", s), + |diag| { + diag.note(result.error); + }, + ); } else { self.check_vendors_in_parse_result(cx, span, s, &result); } diff --git a/tools/dylint_lints/de09_gts_layer/de0902_no_schema_for_on_gts_structs/src/lib.rs b/tools/dylint_lints/de09_gts_layer/de0902_no_schema_for_on_gts_structs/src/lib.rs index 21300ec3b..d35d36af3 100644 --- a/tools/dylint_lints/de09_gts_layer/de0902_no_schema_for_on_gts_structs/src/lib.rs +++ b/tools/dylint_lints/de09_gts_layer/de0902_no_schema_for_on_gts_structs/src/lib.rs @@ -5,6 +5,7 @@ extern crate rustc_hir; extern crate rustc_middle; extern crate rustc_span; +use clippy_utils::diagnostics::span_lint_and_then; use rustc_hir::{Expr, ExprKind, GenericArg}; use rustc_lint::{LateContext, LateLintPass, LintContext}; use rustc_middle::ty::{self, Ty}; @@ -113,20 +114,21 @@ impl<'tcx> LateLintPass<'tcx> for De0902NoSchemaForOnGtsStructs { && is_gts_type(cx, ty) { let type_name = get_type_name(ty); - cx.span_lint( - DE0902_NO_SCHEMA_FOR_ON_GTS_STRUCTS, - callsite_span, - |diag| { - diag.primary_message(format!( - "do not use `schema_for!({})` on GTS-wrapped struct (DE0902)", - type_name - )); - diag.help(format!( - "use `{}::gts_schema_with_refs_as_string()` instead for proper `$id` and `$ref` handling", - type_name - )); - }, - ); + span_lint_and_then( + cx, + DE0902_NO_SCHEMA_FOR_ON_GTS_STRUCTS, + callsite_span, + format!( + "do not use `schema_for!({})` on GTS-wrapped struct (DE0902)", + type_name + ), + |diag| { + diag.help(format!( + "use `{}::gts_schema_with_refs_as_string()` instead for proper `$id` and `$ref` handling", + type_name + )); + }, + ); return; } } diff --git a/tools/dylint_lints/de11_testing/de1101_tests_in_separate_files/src/lib.rs b/tools/dylint_lints/de11_testing/de1101_tests_in_separate_files/src/lib.rs index 3f73217a8..dd86714f3 100644 --- a/tools/dylint_lints/de11_testing/de1101_tests_in_separate_files/src/lib.rs +++ b/tools/dylint_lints/de11_testing/de1101_tests_in_separate_files/src/lib.rs @@ -5,6 +5,7 @@ extern crate rustc_ast; +use clippy_utils::diagnostics::span_lint_and_then; use rustc_ast::Item; use rustc_lint::{EarlyContext, EarlyLintPass, LintContext}; use std::cell::RefCell; @@ -159,35 +160,46 @@ impl EarlyLintPass for De1101TestsInSeparateFiles { for violation in violations { match violation { TestViolation::InlineTestCode => { - cx.span_lint(DE1101_TESTS_IN_SEPARATE_FILES, item.span, |diag| { - diag.primary_message( - "test code must be moved to a separate test file (DE1101)", - ); - diag.help(format!( - "move the test into `tests/*.rs` or an out-of-line `*_tests.rs` module (inline test block exceeds {} lines)", - self.max_inline_test_lines, - )); - }); + span_lint_and_then( + cx, + DE1101_TESTS_IN_SEPARATE_FILES, + item.span, + "test code must be moved to a separate test file (DE1101)", + |diag| { + diag.help(format!( + "move the test into `tests/*.rs` or an out-of-line `*_tests.rs` module (inline test block exceeds {} lines)", + self.max_inline_test_lines, + )); + }, + ); } TestViolation::InlineTestCodeWithCompanion => { - cx.span_lint(DE1101_TESTS_IN_SEPARATE_FILES, item.span, |diag| { - diag.primary_message( - "test code must not be added back to a file that already has a companion test file (DE1101)", - ); - diag.help( - "a `*_tests.rs` companion file already exists; add tests there instead", - ); - }); + span_lint_and_then( + cx, + DE1101_TESTS_IN_SEPARATE_FILES, + item.span, + "test code must not be added back to a file that already has a companion test file (DE1101)", + |diag| { + diag.help( + "a `*_tests.rs` companion file already exists; add tests there instead", + ); + }, + ); } TestViolation::WrongPathAttr { expected, actual } => { - cx.span_lint(DE1101_TESTS_IN_SEPARATE_FILES, item.span, |diag| { - diag.primary_message(format!( + span_lint_and_then( + cx, + DE1101_TESTS_IN_SEPARATE_FILES, + item.span, + format!( "test module path `{actual}.rs` must reference `{expected}.rs` to match the source file (DE1101)", - )); - diag.help(format!( - "use `#[path = \"{expected}.rs\"]` or remove `#[path]`" - )); - }); + ), + |diag| { + diag.help(format!( + "use `#[path = \"{expected}.rs\"]` or remove `#[path]`" + )); + }, + ); } } } diff --git a/tools/dylint_lints/de12_documentation/de1201_docs_rs_all_features/src/lib.rs b/tools/dylint_lints/de12_documentation/de1201_docs_rs_all_features/src/lib.rs index 71965c810..e1b089404 100644 --- a/tools/dylint_lints/de12_documentation/de1201_docs_rs_all_features/src/lib.rs +++ b/tools/dylint_lints/de12_documentation/de1201_docs_rs_all_features/src/lib.rs @@ -1,9 +1,11 @@ #![feature(rustc_private)] #![warn(unused_extern_crates)] +extern crate rustc_errors; extern crate rustc_span; use cargo_metadata::{Metadata, MetadataCommand, Package}; +use rustc_errors::DiagDecorator; use rustc_lint::{LateContext, LateLintPass, LintContext}; use rustc_span::DUMMY_SP; use serde_json::Value; @@ -75,22 +77,30 @@ impl LateLintPass<'_> for De1201DocsRsAllFeatures { { Ok(metadata) => metadata, Err(error) => { - cx.span_lint(DE1201_DOCS_RS_ALL_FEATURES, DUMMY_SP, |diag| { - diag.primary_message(format!( - "could not read Cargo metadata for docs.rs configuration check: {error}" - )); - }); + cx.emit_span_lint( + DE1201_DOCS_RS_ALL_FEATURES, + DUMMY_SP, + DiagDecorator(|diag| { + diag.primary_message(format!( + "could not read Cargo metadata for docs.rs configuration check: {error}" + )); + }), + ); return; } }; let Some(package) = find_current_package(&metadata, &manifest_path) else { - cx.span_lint(DE1201_DOCS_RS_ALL_FEATURES, DUMMY_SP, |diag| { - diag.primary_message(format!( - "could not find current package in Cargo metadata for `{}`", - manifest_path.display() - )); - }); + cx.emit_span_lint( + DE1201_DOCS_RS_ALL_FEATURES, + DUMMY_SP, + DiagDecorator(|diag| { + diag.primary_message(format!( + "could not find current package in Cargo metadata for `{}`", + manifest_path.display() + )); + }), + ); return; }; @@ -103,7 +113,10 @@ impl LateLintPass<'_> for De1201DocsRsAllFeatures { return; }; - cx.span_lint(DE1201_DOCS_RS_ALL_FEATURES, DUMMY_SP, |diag| { + cx.emit_span_lint( + DE1201_DOCS_RS_ALL_FEATURES, + DUMMY_SP, + DiagDecorator(|diag| { diag.primary_message(format!( "publishable crate `{}` must set `package.metadata.docs.rs.all-features = true` (DE1201)", package.name @@ -114,7 +127,8 @@ impl LateLintPass<'_> for De1201DocsRsAllFeatures { package.manifest_path, package.name, )); - }); + }), + ); } } diff --git a/tools/dylint_lints/de13_common_patterns/de1301_no_print_macros/src/lib.rs b/tools/dylint_lints/de13_common_patterns/de1301_no_print_macros/src/lib.rs index 8825ee5a0..5cf8c84ea 100644 --- a/tools/dylint_lints/de13_common_patterns/de1301_no_print_macros/src/lib.rs +++ b/tools/dylint_lints/de13_common_patterns/de1301_no_print_macros/src/lib.rs @@ -4,6 +4,7 @@ extern crate rustc_ast; extern crate rustc_span; +use clippy_utils::diagnostics::span_lint_and_then; use rustc_ast::{ AttrKind, Attribute, ExprKind, Item, ItemKind, MacCall, VisibilityKind, visit, visit::Visitor, }; @@ -132,15 +133,17 @@ impl<'a, 'cx> ForbiddenMacroVisitor<'a, 'cx> { return; } - self.cx - .span_lint(DE1301_NO_PRINT_MACROS, mac_call.span(), |diag| { - diag.primary_message(format!( - "macro `{name}!` is forbidden in production code (DE1301)" - )); + span_lint_and_then( + self.cx, + DE1301_NO_PRINT_MACROS, + mac_call.span(), + format!("macro `{name}!` is forbidden in production code (DE1301)"), + |diag| { diag.help( "use `tracing`/`log` for observability, or return the value and handle it at the boundary", ); - }); + }, + ); } } diff --git a/tools/dylint_lints/de13_common_patterns/de1302_error_from_to_string/src/lib.rs b/tools/dylint_lints/de13_common_patterns/de1302_error_from_to_string/src/lib.rs index 4424a34c8..5041ea7f1 100644 --- a/tools/dylint_lints/de13_common_patterns/de1302_error_from_to_string/src/lib.rs +++ b/tools/dylint_lints/de13_common_patterns/de1302_error_from_to_string/src/lib.rs @@ -7,11 +7,12 @@ extern crate rustc_hir; extern crate rustc_middle; extern crate rustc_span; +use clippy_utils::diagnostics::span_lint_and_then; use clippy_utils::ty::implements_trait; use rustc_hir::def::{DefKind, Res}; use rustc_hir::def_id::DefId; use rustc_hir::{self as hir, Expr, ExprKind, ImplItemKind, ItemKind, QPath}; -use rustc_lint::{LateContext, LateLintPass, LintContext}; +use rustc_lint::{LateContext, LateLintPass}; use rustc_middle::ty::{Ty, TypeckResults}; use rustc_span::hygiene::{ExpnKind, MacroKind}; @@ -88,7 +89,7 @@ dylint_linting::declare_late_lint! { /// Uses the `rustc_diagnostic_item = "Error"` marker and `clippy_utils::ty::implements_trait` /// for proper trait resolution. Handles ADTs, type aliases, and generic params with bounds. fn implements_error<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> bool { - let Some(error_did) = cx.tcx.get_diagnostic_item(rustc_span::sym::Error) else { + let Some(error_did) = cx.tcx.get_diagnostic_item(clippy_utils::sym::Error) else { return false; }; implements_trait(cx, ty, error_did, &[]) @@ -114,17 +115,20 @@ struct ToStringVisitor<'tcx, 'cx> { impl<'tcx> ToStringVisitor<'tcx, '_> { /// Emit the DE1302 diagnostic at `span`. fn emit(&self, span: rustc_span::Span) { - self.cx.span_lint(DE1302_ERROR_FROM_TO_STRING, span, |diag| { - diag.primary_message( - "`.to_string()` in `From`/`TryFrom` impl destroys the error chain (DE1302)", - ); - diag.help( - "store the source error directly, use an enum variant, or use `#[from]` with thiserror", - ); - diag.note( - "`.to_string()` discards the original error type: `.source()` returns None and the error cannot be downcast", - ); - }); + span_lint_and_then( + self.cx, + DE1302_ERROR_FROM_TO_STRING, + span, + "`.to_string()` in `From`/`TryFrom` impl destroys the error chain (DE1302)", + |diag| { + diag.help( + "store the source error directly, use an enum variant, or use `#[from]` with thiserror", + ); + diag.note( + "`.to_string()` discards the original error type: `.source()` returns None and the error cannot be downcast", + ); + }, + ); } /// Returns true if `ty` (after peeling references) is a type whose @@ -166,7 +170,7 @@ impl<'tcx> ToStringVisitor<'tcx, '_> { /// both paths verify they're actually hitting the trait method, not a bare /// inherent method named `to_string`. fn is_to_string_def<'tcx>(cx: &LateContext<'tcx>, def_id: DefId) -> bool { - let Some(to_string_trait) = cx.tcx.get_diagnostic_item(rustc_span::sym::ToString) else { + let Some(to_string_trait) = cx.tcx.get_diagnostic_item(clippy_utils::sym::ToString) else { return false; }; cx.tcx.trait_of_assoc(def_id) == Some(to_string_trait) @@ -209,12 +213,10 @@ impl<'tcx> hir::intravisit::Visitor<'tcx> for ToStringVisitor<'tcx, '_> { } } // UFCS form: `ToString::to_string(&e)` or `::to_string(&e)`. - ExprKind::Call(callee, [arg]) if !hidden => { - if is_to_string_path(self.cx, callee) { - let arg_ty = self.typeck.expr_ty(arg); - if self.is_relevant_receiver(arg_ty) { - self.emit(expr.span); - } + ExprKind::Call(callee, [arg]) if !hidden && is_to_string_path(self.cx, callee) => { + let arg_ty = self.typeck.expr_ty(arg); + if self.is_relevant_receiver(arg_ty) { + self.emit(expr.span); } } // Closures have their own typeck tables; delegate to a helper diff --git a/tools/dylint_lints/de13_common_patterns/de1303_no_primitive_type_alias/src/lib.rs b/tools/dylint_lints/de13_common_patterns/de1303_no_primitive_type_alias/src/lib.rs index a3095e3e9..56e298ad9 100644 --- a/tools/dylint_lints/de13_common_patterns/de1303_no_primitive_type_alias/src/lib.rs +++ b/tools/dylint_lints/de13_common_patterns/de1303_no_primitive_type_alias/src/lib.rs @@ -5,9 +5,10 @@ extern crate rustc_ast; +use clippy_utils::diagnostics::span_lint_and_then; use lint_utils::is_in_contract_module_ast; use rustc_ast::{Item, ItemKind, TyKind, VisibilityKind}; -use rustc_lint::{EarlyLintPass, LintContext}; +use rustc_lint::EarlyLintPass; dylint_linting::declare_early_lint! { /// ### What it does @@ -103,15 +104,20 @@ impl EarlyLintPass for De1303NoPrimitiveTypeAlias { return; } - cx.span_lint(DE1303_NO_PRIMITIVE_TYPE_ALIAS, item.span, |diag| { - diag.primary_message(format!( + span_lint_and_then( + cx, + DE1303_NO_PRIMITIVE_TYPE_ALIAS, + item.span, + format!( "`pub type {name} = {backing}` is a transparent alias with no type safety (DE1303)" - )); - diag.help(format!( - "wrap {backing} in a newtype: `pub struct {name}(pub {backing});` or `pub struct {name}({backing});`" - )); - diag.note("transparent aliases provide no compile-time separation; use a newtype for distinct semantic types"); - }); + ), + |diag| { + diag.help(format!( + "wrap {backing} in a newtype: `pub struct {name}(pub {backing});` or `pub struct {name}({backing});`" + )); + diag.note("transparent aliases provide no compile-time separation; use a newtype for distinct semantic types"); + }, + ); } } diff --git a/tools/dylint_lints/lint_utils/Cargo.toml b/tools/dylint_lints/lint_utils/Cargo.toml index b1e8d3bd4..02b9ebf21 100644 --- a/tools/dylint_lints/lint_utils/Cargo.toml +++ b/tools/dylint_lints/lint_utils/Cargo.toml @@ -8,7 +8,7 @@ publish = false test = false [dependencies] -clippy_utils = { git = "https://github.com/rust-lang/rust-clippy", rev = "54482290b5f32e6c6b57cc9e0a17153f432b0036" } +clippy_utils.workspace = true dylint_linting.workspace = true [package.metadata.rust-analyzer] diff --git a/tools/dylint_lints/lint_utils/src/lib.rs b/tools/dylint_lints/lint_utils/src/lib.rs index 6d5d9c6f4..996b7a125 100644 --- a/tools/dylint_lints/lint_utils/src/lib.rs +++ b/tools/dylint_lints/lint_utils/src/lib.rs @@ -465,7 +465,7 @@ pub fn is_utoipa_trait(segments: &[&str], trait_name: &str) -> bool { /// - `use foo::*` -> `["foo"]` pub fn use_tree_to_strings(tree: &UseTree) -> Vec { match &tree.kind { - UseTreeKind::Simple(..) | UseTreeKind::Glob => { + UseTreeKind::Simple(..) | UseTreeKind::Glob(_) => { vec![ tree.prefix .segments diff --git a/tools/dylint_lints/rust-toolchain.toml b/tools/dylint_lints/rust-toolchain.toml index 8bd50e76a..992f96651 100644 --- a/tools/dylint_lints/rust-toolchain.toml +++ b/tools/dylint_lints/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "nightly-2026-01-22" +channel = "nightly-2026-04-16" components = ["llvm-tools-preview", "rustc-dev"] From d27ef95035edf8c5af90fa9f942165cd17ada4fb Mon Sep 17 00:00:00 2001 From: Andre Smith Date: Tue, 9 Jun 2026 14:03:43 -0700 Subject: [PATCH 3/5] refactor(resource-group): adopt SDK canonical-projection pattern Signed-off-by: Andre Smith --- Cargo.lock | 2 + .../src/domain/user/service.rs | 8 +- .../src/domain/user/service_tests.rs | 64 +-- .../src/domain/user_groups/cascade.rs | 8 +- .../src/domain/user_groups/cascade_tests.rs | 74 ++-- .../src/domain/user_groups/registration.rs | 24 +- .../domain/user_groups/registration_tests.rs | 134 +++--- .../src/infra/rg/checker_tests.rs | 48 +- .../src/infra/rg/test_helpers.rs | 41 +- .../resource-group-sdk/Cargo.toml | 8 + .../resource-group-sdk/src/api.rs | 78 ++-- .../resource-group-sdk/src/error.rs | 412 ++++++++++++------ .../resource-group-sdk/src/error_tests.rs | 411 +++++++++++++++++ .../resource-group-sdk/src/field.rs | 38 ++ .../resource-group-sdk/src/gts.rs | 13 + .../resource-group-sdk/src/lib.rs | 15 +- .../resource-group-sdk/src/precondition.rs | 152 +++++++ .../resource-group-sdk/src/reason.rs | 37 ++ .../resource-group/src/api/rest/error.rs | 49 ++- .../resource-group/src/domain/error.rs | 44 +- .../resource-group/src/domain/read_service.rs | 22 +- .../resource-group/src/domain/rg_service.rs | 74 ++-- .../resource-group/tests/domain_unit_test.rs | 106 +++-- .../plugins/rg-tr-plugin/Cargo.toml | 4 + .../rg-tr-plugin/src/domain/service.rs | 8 +- .../rg-tr-plugin/src/domain/service_tests.rs | 32 +- 26 files changed, 1403 insertions(+), 503 deletions(-) create mode 100644 gears/system/resource-group/resource-group-sdk/src/error_tests.rs create mode 100644 gears/system/resource-group/resource-group-sdk/src/field.rs create mode 100644 gears/system/resource-group/resource-group-sdk/src/precondition.rs create mode 100644 gears/system/resource-group/resource-group-sdk/src/reason.rs diff --git a/Cargo.lock b/Cargo.lock index ae86f368d..c1b9e86e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1932,6 +1932,7 @@ version = "0.1.5" dependencies = [ "async-trait", "cf-gears-toolkit", + "cf-gears-toolkit-canonical-errors", "cf-gears-toolkit-gts", "cf-gears-toolkit-odata", "cf-gears-toolkit-odata-macros", @@ -1955,6 +1956,7 @@ dependencies = [ "cf-gears-resource-group-sdk", "cf-gears-tenant-resolver-sdk", "cf-gears-toolkit", + "cf-gears-toolkit-canonical-errors", "cf-gears-toolkit-macros", "cf-gears-toolkit-odata", "cf-gears-toolkit-security", diff --git a/gears/system/account-management/account-management/src/domain/user/service.rs b/gears/system/account-management/account-management/src/domain/user/service.rs index 6a9aca16f..c8dc44579 100644 --- a/gears/system/account-management/account-management/src/domain/user/service.rs +++ b/gears/system/account-management/account-management/src/domain/user/service.rs @@ -1008,12 +1008,16 @@ async fn cleanup_inner( let mut removed: usize = 0; let mut already_gone: usize = 0; for group_id in &groups { - match tokio::time::timeout( + // The trait boundary is `CanonicalError` (ADR 0005); project the + // inner result into the typed SDK view so the `NotFound` + // idempotent arm dispatches as before. + let outcome = tokio::time::timeout( RG_CLEANUP_TIMEOUT, rg.remove_membership(sys_ctx, *group_id, USER_RG_TYPE_CODE, &user_id_str), ) .await - { + .map(|r| r.map_err(ResourceGroupError::from)); + match outcome { Err(_elapsed) => { emit_metric( AM_DEPENDENCY_HEALTH, diff --git a/gears/system/account-management/account-management/src/domain/user/service_tests.rs b/gears/system/account-management/account-management/src/domain/user/service_tests.rs index 0160c50e4..035b18989 100644 --- a/gears/system/account-management/account-management/src/domain/user/service_tests.rs +++ b/gears/system/account-management/account-management/src/domain/user/service_tests.rs @@ -1149,26 +1149,40 @@ mod cleanup { use async_trait::async_trait; use resource_group_sdk::{ CreateGroupRequest, CreateTypeRequest, GroupHierarchy, ResourceGroup, ResourceGroupClient, - ResourceGroupError, ResourceGroupMembership, ResourceGroupType, ResourceGroupWithDepth, - UpdateGroupRequest, UpdateTypeRequest, + ResourceGroupMembership, ResourceGroupType, ResourceGroupWithDepth, UpdateGroupRequest, + UpdateTypeRequest, }; use std::sync::Mutex; + use toolkit_canonical_errors::{CanonicalError, resource_error}; use toolkit_odata::{ODataQuery, Page, PageInfo}; use toolkit_security::SecurityContext; use crate::domain::user::test_support::FakeUserOutcome; + // The RG trait boundary is `CanonicalError` (ADR 0005); synthesize the + // canonical `NotFound` the real RG ladder emits so the user-cleanup + // path's `.map_err(ResourceGroupError::from)` idempotent dispatch is + // exercised as in prod. + #[resource_error("gts.cf.core.resource_group.group.v1~")] + struct RgErr; + + fn rg_not_found(code: &str) -> CanonicalError { + RgErr::not_found(format!("'{code}' not found")) + .with_resource(code) + .create() + } + // -- minimal RG fake focused on list_groups + remove_membership -- enum ListBehaviour { Pages(Vec>), - Error(ResourceGroupError), + Error(CanonicalError), } enum RemoveBehaviour { Ok, NotFound, - Error(ResourceGroupError), + Error(CanonicalError), } struct FakeMembershipRgClient { @@ -1217,7 +1231,7 @@ mod cleanup { self } - fn with_list_error(error: ResourceGroupError) -> Self { + fn with_list_error(error: CanonicalError) -> Self { Self { list_behaviour: Mutex::new(ListBehaviour::Error(error)), remove_behaviour: RemoveBehaviour::Ok, @@ -1241,7 +1255,7 @@ mod cleanup { &self, _ctx: &SecurityContext, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { *self.list_calls.lock().expect("lock") += 1; let mut behaviour = self.list_behaviour.lock().expect("lock"); match &mut *behaviour { @@ -1261,7 +1275,7 @@ mod cleanup { group_id: Uuid, resource_type: &str, resource_id: &str, - ) -> Result<(), ResourceGroupError> { + ) -> Result<(), CanonicalError> { self.removed.lock().expect("lock").push(( group_id, resource_type.to_owned(), @@ -1269,7 +1283,7 @@ mod cleanup { )); match &self.remove_behaviour { RemoveBehaviour::Ok => Ok(()), - RemoveBehaviour::NotFound => Err(ResourceGroupError::not_found("membership")), + RemoveBehaviour::NotFound => Err(rg_not_found("membership")), RemoveBehaviour::Error(e) => Err(e.clone()), } } @@ -1280,7 +1294,7 @@ mod cleanup { &self, _ctx: &SecurityContext, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!( "cleanup uses list_groups(tenant) + per-group remove, never list_memberships" ) @@ -1289,21 +1303,21 @@ mod cleanup { &self, _ctx: &SecurityContext, _request: CreateTypeRequest, - ) -> Result { + ) -> Result { unreachable!() } async fn get_type( &self, _ctx: &SecurityContext, _code: &str, - ) -> Result { + ) -> Result { unreachable!() } async fn list_types( &self, _ctx: &SecurityContext, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!() } async fn update_type( @@ -1311,28 +1325,28 @@ mod cleanup { _ctx: &SecurityContext, _code: &str, _request: UpdateTypeRequest, - ) -> Result { + ) -> Result { unreachable!() } async fn delete_type( &self, _ctx: &SecurityContext, _code: &str, - ) -> Result<(), ResourceGroupError> { + ) -> Result<(), CanonicalError> { unreachable!() } async fn create_group( &self, _ctx: &SecurityContext, _request: CreateGroupRequest, - ) -> Result { + ) -> Result { unreachable!() } async fn get_group( &self, _ctx: &SecurityContext, _id: Uuid, - ) -> Result { + ) -> Result { unreachable!() } async fn update_group( @@ -1340,14 +1354,14 @@ mod cleanup { _ctx: &SecurityContext, _id: Uuid, _request: UpdateGroupRequest, - ) -> Result { + ) -> Result { unreachable!() } async fn delete_group( &self, _ctx: &SecurityContext, _id: Uuid, - ) -> Result<(), ResourceGroupError> { + ) -> Result<(), CanonicalError> { unreachable!() } async fn get_group_descendants( @@ -1355,7 +1369,7 @@ mod cleanup { _ctx: &SecurityContext, _group_id: Uuid, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!() } async fn get_group_ancestors( @@ -1363,7 +1377,7 @@ mod cleanup { _ctx: &SecurityContext, _group_id: Uuid, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!() } async fn add_membership( @@ -1372,7 +1386,7 @@ mod cleanup { _group_id: Uuid, _resource_type: &str, _resource_id: &str, - ) -> Result { + ) -> Result { unreachable!() } } @@ -1506,9 +1520,7 @@ mod cleanup { let idp = Arc::new(FakeIdpUserProvisioner::new()); idp.set_delete_outcome(FakeUserOutcome::Ok); let rg = Arc::new(FakeMembershipRgClient::with_list_error( - ResourceGroupError::ServiceUnavailable { - message: "connection refused".to_owned(), - }, + CanonicalError::internal("connection refused").create(), )); let svc = make_service_with_cleanup(tenants, idp, Arc::clone(&rg)); @@ -1606,9 +1618,7 @@ mod cleanup { let rg = Arc::new( FakeMembershipRgClient::with_groups(vec![user_group(group_a, tenant_id)]) .with_remove_behaviour(RemoveBehaviour::Error( - ResourceGroupError::ServiceUnavailable { - message: "connection refused".to_owned(), - }, + CanonicalError::internal("connection refused").create(), )), ); let svc = make_service_with_cleanup(tenants, idp, Arc::clone(&rg)); diff --git a/gears/system/account-management/account-management/src/domain/user_groups/cascade.rs b/gears/system/account-management/account-management/src/domain/user_groups/cascade.rs index f8d0bda90..418e5e72a 100644 --- a/gears/system/account-management/account-management/src/domain/user_groups/cascade.rs +++ b/gears/system/account-management/account-management/src/domain/user_groups/cascade.rs @@ -186,7 +186,13 @@ async fn cascade_one( tenant_id: Uuid, group_id: Uuid, ) -> Result { - match tokio::time::timeout(CASCADE_TIMEOUT, client.delete_group_cascade(ctx, group_id)).await { + // The trait boundary is `CanonicalError` (ADR 0005); project the + // inner result into the typed SDK view so the `NotFound` idempotent + // arm below dispatches as before. + let outcome = tokio::time::timeout(CASCADE_TIMEOUT, client.delete_group_cascade(ctx, group_id)) + .await + .map(|r| r.map_err(ResourceGroupError::from)); + match outcome { Err(_elapsed) => { emit_metric( AM_DEPENDENCY_HEALTH, diff --git a/gears/system/account-management/account-management/src/domain/user_groups/cascade_tests.rs b/gears/system/account-management/account-management/src/domain/user_groups/cascade_tests.rs index b7eabafd1..04be9b2e7 100644 --- a/gears/system/account-management/account-management/src/domain/user_groups/cascade_tests.rs +++ b/gears/system/account-management/account-management/src/domain/user_groups/cascade_tests.rs @@ -26,9 +26,10 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use async_trait::async_trait; use resource_group_sdk::{ CreateGroupRequest, CreateTypeRequest, GroupHierarchy, ResourceGroup, ResourceGroupClient, - ResourceGroupError, ResourceGroupMembership, ResourceGroupType, ResourceGroupWithDepth, - UpdateGroupRequest, UpdateTypeRequest, + ResourceGroupMembership, ResourceGroupType, ResourceGroupWithDepth, UpdateGroupRequest, + UpdateTypeRequest, }; +use toolkit_canonical_errors::{CanonicalError, resource_error}; use toolkit_odata::page::PageInfo; use toolkit_odata::{ODataQuery, Page}; use toolkit_security::SecurityContext; @@ -38,10 +39,23 @@ use super::USER_GROUP_TYPE_CODE; use super::cascade::build_cascade_cleanup_hook; use crate::domain::tenant::hooks::HookError; +// The RG trait boundary is `CanonicalError` (ADR 0005); synthesize the +// canonical `NotFound` the real RG ladder emits so the cascade hook's +// `.map_err(ResourceGroupError::from)` idempotent-NotFound dispatch is +// exercised as in prod. +#[resource_error("gts.cf.core.resource_group.group.v1~")] +struct RgErr; + +fn rg_not_found(code: &str) -> CanonicalError { + RgErr::not_found(format!("'{code}' not found")) + .with_resource(code) + .create() +} + // ---- fake client --------------------------------------------------- -type ListGroupsFn = Box Result, ResourceGroupError> + Send + Sync>; -type DeleteCascadeFn = Box Result<(), ResourceGroupError> + Send + Sync>; +type ListGroupsFn = Box Result, CanonicalError> + Send + Sync>; +type DeleteCascadeFn = Box Result<(), CanonicalError> + Send + Sync>; struct FakeCascadeRgClient { list_groups_fn: ListGroupsFn, @@ -65,7 +79,7 @@ impl ResourceGroupClient for FakeCascadeRgClient { &self, _ctx: &SecurityContext, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { (self.list_groups_fn)() } @@ -73,7 +87,7 @@ impl ResourceGroupClient for FakeCascadeRgClient { &self, _ctx: &SecurityContext, id: Uuid, - ) -> Result<(), ResourceGroupError> { + ) -> Result<(), CanonicalError> { self.cascade_calls.fetch_add(1, Ordering::SeqCst); (self.delete_cascade_fn)(id) } @@ -84,7 +98,7 @@ impl ResourceGroupClient for FakeCascadeRgClient { &self, _ctx: &SecurityContext, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!("cascade hook no longer drains memberships -- RG cascade handles them") } async fn remove_membership( @@ -93,35 +107,31 @@ impl ResourceGroupClient for FakeCascadeRgClient { _group_id: Uuid, _resource_type: &str, _resource_id: &str, - ) -> Result<(), ResourceGroupError> { + ) -> Result<(), CanonicalError> { unreachable!("cascade hook no longer removes memberships -- RG cascade handles them") } - async fn delete_group( - &self, - _ctx: &SecurityContext, - _id: Uuid, - ) -> Result<(), ResourceGroupError> { + async fn delete_group(&self, _ctx: &SecurityContext, _id: Uuid) -> Result<(), CanonicalError> { unreachable!("cascade hook only calls delete_group_cascade, not delete_group") } async fn create_type( &self, _ctx: &SecurityContext, _request: CreateTypeRequest, - ) -> Result { + ) -> Result { unreachable!() } async fn get_type( &self, _ctx: &SecurityContext, _code: &str, - ) -> Result { + ) -> Result { unreachable!() } async fn list_types( &self, _ctx: &SecurityContext, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!() } async fn update_type( @@ -129,28 +139,24 @@ impl ResourceGroupClient for FakeCascadeRgClient { _ctx: &SecurityContext, _code: &str, _request: UpdateTypeRequest, - ) -> Result { + ) -> Result { unreachable!() } - async fn delete_type( - &self, - _ctx: &SecurityContext, - _code: &str, - ) -> Result<(), ResourceGroupError> { + async fn delete_type(&self, _ctx: &SecurityContext, _code: &str) -> Result<(), CanonicalError> { unreachable!() } async fn create_group( &self, _ctx: &SecurityContext, _request: CreateGroupRequest, - ) -> Result { + ) -> Result { unreachable!() } async fn get_group( &self, _ctx: &SecurityContext, _id: Uuid, - ) -> Result { + ) -> Result { unreachable!() } async fn update_group( @@ -158,7 +164,7 @@ impl ResourceGroupClient for FakeCascadeRgClient { _ctx: &SecurityContext, _id: Uuid, _request: UpdateGroupRequest, - ) -> Result { + ) -> Result { unreachable!() } async fn get_group_descendants( @@ -166,7 +172,7 @@ impl ResourceGroupClient for FakeCascadeRgClient { _ctx: &SecurityContext, _group_id: Uuid, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!() } async fn get_group_ancestors( @@ -174,7 +180,7 @@ impl ResourceGroupClient for FakeCascadeRgClient { _ctx: &SecurityContext, _group_id: Uuid, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!() } async fn add_membership( @@ -183,7 +189,7 @@ impl ResourceGroupClient for FakeCascadeRgClient { _group_id: Uuid, _resource_type: &str, _resource_id: &str, - ) -> Result { + ) -> Result { unreachable!() } } @@ -319,7 +325,7 @@ async fn group_not_found_during_cascade_treated_as_already_deleted() { let g = g.clone(); Box::new(move || Ok(groups_page(vec![g.clone()]))) }, - delete_cascade_fn: Box::new(|id| Err(ResourceGroupError::not_found(id.to_string()))), + delete_cascade_fn: Box::new(|id| Err(rg_not_found(&id.to_string()))), cascade_calls: AtomicUsize::new(0), }; let client: Arc = Arc::new(fake); @@ -331,11 +337,7 @@ async fn group_not_found_during_cascade_treated_as_already_deleted() { #[tokio::test] async fn list_groups_unavailable_returns_retryable() { let fake = FakeCascadeRgClient { - list_groups_fn: Box::new(|| { - Err(ResourceGroupError::service_unavailable( - "connection refused", - )) - }), + list_groups_fn: Box::new(|| Err(CanonicalError::internal("connection refused").create())), delete_cascade_fn: Box::new(|_| Ok(())), cascade_calls: AtomicUsize::new(0), }; @@ -353,7 +355,9 @@ async fn cascade_error_returns_retryable() { let g = g.clone(); Box::new(move || Ok(groups_page(vec![g.clone()]))) }, - delete_cascade_fn: Box::new(|_| Err(ResourceGroupError::internal())), + delete_cascade_fn: Box::new( + |_| Err(CanonicalError::internal("rg internal error").create()), + ), cascade_calls: AtomicUsize::new(0), }; let client: Arc = Arc::new(fake); diff --git a/gears/system/account-management/account-management/src/domain/user_groups/registration.rs b/gears/system/account-management/account-management/src/domain/user_groups/registration.rs index 6974313ed..39346282d 100644 --- a/gears/system/account-management/account-management/src/domain/user_groups/registration.rs +++ b/gears/system/account-management/account-management/src/domain/user_groups/registration.rs @@ -173,7 +173,7 @@ impl TypeSpec { /// /// Each step runs the full idempotent algorithm independently /// ([`register_one`]): `get_type` → classify-or-create → on -/// `TypeAlreadyExists` race re-read and classify. A step that lands +/// `AlreadyExists` race re-read and classify. A step that lands /// on `DivergentSchema` aborts the pair; the caller (`gear init`) /// surfaces the error and does NOT signal ready. /// @@ -247,7 +247,7 @@ pub async fn register_user_group_types( /// /// * `get_type` returns the row → classify it. /// * `get_type` returns `NotFound` → `create_type` → on success done, -/// on `TypeAlreadyExists` re-read and classify the peer's row. +/// on `AlreadyExists` re-read and classify the peer's row. /// * `get_type` / `create_type` returns transport failure → /// `ServiceUnavailable`. #[allow( @@ -259,8 +259,14 @@ async fn register_one( ctx: &SecurityContext, spec: &TypeSpec, ) -> Result { - // Step 1: query existing type definition. - let existing = match client.get_type(ctx, spec.code).await { + // Step 1: query existing type definition. The trait boundary is + // `CanonicalError` (ADR 0005); project into the typed SDK view to + // dispatch on `NotFound` vs. any transport failure. + let existing = match client + .get_type(ctx, spec.code) + .await + .map_err(ResourceGroupError::from) + { Ok(t) => Some(t), Err(ResourceGroupError::NotFound { .. }) => None, Err(e) => { @@ -284,7 +290,11 @@ async fn register_one( } // Type is absent -- register it. - match client.create_type(ctx, spec.to_create_request()).await { + match client + .create_type(ctx, spec.to_create_request()) + .await + .map_err(ResourceGroupError::from) + { Ok(_) => { emit_metric( AM_DEPENDENCY_HEALTH, @@ -311,7 +321,7 @@ async fn register_one( // surface that as `ServiceUnavailable` so the operator can // retry init; do NOT default to `AlreadyPresent` because the // traits-equivalence invariant is unverified. - Err(ResourceGroupError::TypeAlreadyExists { .. }) => { + Err(ResourceGroupError::AlreadyExists { .. }) => { match client.get_type(ctx, spec.code).await { Ok(racy) => { info!( @@ -360,7 +370,7 @@ async fn register_one( /// Equivalence predicate: every trait `spec` declares MUST be honoured /// by `existing`. Shared by the step-1 "type already exists" path and -/// the `TypeAlreadyExists` race-loser path so both honour the same +/// the `AlreadyExists` race-loser path so both honour the same /// "AM MUST NOT auto-repair divergent traits" invariant. /// /// The check is **inclusive-equivalence**: RG is allowed to seed diff --git a/gears/system/account-management/account-management/src/domain/user_groups/registration_tests.rs b/gears/system/account-management/account-management/src/domain/user_groups/registration_tests.rs index 2cfb74f7d..7038bf77b 100644 --- a/gears/system/account-management/account-management/src/domain/user_groups/registration_tests.rs +++ b/gears/system/account-management/account-management/src/domain/user_groups/registration_tests.rs @@ -15,7 +15,7 @@ //! call (caller never proceeds past a failing step-1). //! * Container divergent / missing-membership-type surfaces with the //! tightened `classify_existing` equivalence check. -//! * `TypeAlreadyExists` race is re-read per-type via `get_type` and +//! * `AlreadyExists` race is re-read per-type via `get_type` and //! classified against the same spec (no silent swallow). //! * Transport errors from `get_type` / `create_type` collapse to //! `ServiceUnavailable` on the first failing step. @@ -34,10 +34,11 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use async_trait::async_trait; use resource_group_sdk::{ - CreateGroupRequest, CreateTypeRequest, ResourceGroup, ResourceGroupClient, ResourceGroupError, + CreateGroupRequest, CreateTypeRequest, ResourceGroup, ResourceGroupClient, ResourceGroupMembership, ResourceGroupType, ResourceGroupWithDepth, UpdateGroupRequest, UpdateTypeRequest, }; +use toolkit_canonical_errors::{CanonicalError, resource_error}; use toolkit_odata::{ODataQuery, Page}; use toolkit_security::SecurityContext; use uuid::Uuid; @@ -45,14 +46,33 @@ use uuid::Uuid; use super::registration::{RegistrationError, RegistrationOutcome, register_user_group_types}; use super::{USER_GROUP_TYPE_CODE, USER_MEMBERSHIP_TYPE}; +// The RG trait boundary is `CanonicalError` (ADR 0005). These helpers +// synthesize the canonical errors the real RG ladder emits, so the +// production code's `.map_err(ResourceGroupError::from)` dispatch +// (NotFound vs. transport failure) is exercised exactly as in prod. +#[resource_error("gts.cf.core.resource_group.group.v1~")] +struct RgErr; + +fn rg_not_found(code: &str) -> CanonicalError { + RgErr::not_found(format!("'{code}' not found")) + .with_resource(code) + .create() +} + +fn rg_already_exists(code: &str) -> CanonicalError { + RgErr::already_exists(format!("'{code}' already exists")) + .with_resource(code) + .create() +} + // ---- fake client --------------------------------------------------- enum GetTypeBehaviour { Return(ResourceGroupType), NotFound, - Error(ResourceGroupError), + Error(CanonicalError), /// Sequence: every call advances; out-of-range calls reuse the - /// last entry. Used by the `TypeAlreadyExists` race-path tests + /// last entry. Used by the `AlreadyExists` race-path tests /// where the first `get_type` returns `NotFound` (we believe /// the type is absent) and a second call after a peer's race /// returns whatever the peer wrote (equivalent or divergent). @@ -63,7 +83,7 @@ enum GetTypeBehaviour { enum CreateTypeBehaviour { Ok, AlreadyExists, - Error(ResourceGroupError), + Error(CanonicalError), } struct TypeState { @@ -103,9 +123,9 @@ impl TypeState { fn get_type_unavailable() -> Self { Self { - get_type: GetTypeBehaviour::Error(ResourceGroupError::ServiceUnavailable { - message: "connection refused".to_owned(), - }), + get_type: GetTypeBehaviour::Error( + CanonicalError::internal("connection refused").create(), + ), create_type: CreateTypeBehaviour::Ok, } } @@ -113,9 +133,9 @@ impl TypeState { fn create_type_unavailable() -> Self { Self { get_type: GetTypeBehaviour::NotFound, - create_type: CreateTypeBehaviour::Error(ResourceGroupError::ServiceUnavailable { - message: "connection refused".to_owned(), - }), + create_type: CreateTypeBehaviour::Error( + CanonicalError::internal("connection refused").create(), + ), } } } @@ -224,11 +244,8 @@ impl ResourceGroupClient for FakeRgClient { &self, _ctx: &SecurityContext, code: &str, - ) -> Result { - fn dispatch( - b: &GetTypeBehaviour, - code: &str, - ) -> Result { + ) -> Result { + fn dispatch(b: &GetTypeBehaviour, code: &str) -> Result { match b { GetTypeBehaviour::Return(t) => Ok(t.clone()), GetTypeBehaviour::Error(e) => Err(e.clone()), @@ -237,21 +254,18 @@ impl ResourceGroupClient for FakeRgClient { // collapsing to `NotFound` keeps the fake observable // rather than panicking inside a test fixture. GetTypeBehaviour::NotFound | GetTypeBehaviour::Sequence(_, _) => { - Err(ResourceGroupError::not_found(code)) + Err(rg_not_found(code)) } } } - let state = self - .states - .get(code) - .ok_or_else(|| ResourceGroupError::not_found(code))?; + let state = self.states.get(code).ok_or_else(|| rg_not_found(code))?; match &state.get_type { GetTypeBehaviour::Sequence(steps, idx) => { let i = idx.fetch_add(1, Ordering::SeqCst); let step = steps .get(i) .or_else(|| steps.last()) - .ok_or_else(|| ResourceGroupError::not_found(code))?; + .ok_or_else(|| rg_not_found(code))?; dispatch(step, code) } other => dispatch(other, code), @@ -262,7 +276,7 @@ impl ResourceGroupClient for FakeRgClient { &self, _ctx: &SecurityContext, request: CreateTypeRequest, - ) -> Result { + ) -> Result { // Best-effort observability: out-of-band record the order of // `create_type` calls. Lock-poison failure aborts the test // with a clear cause rather than silently corrupting state. @@ -273,7 +287,7 @@ impl ResourceGroupClient for FakeRgClient { let state = self .states .get(request.code.as_str()) - .ok_or_else(|| ResourceGroupError::not_found(&request.code))?; + .ok_or_else(|| rg_not_found(&request.code))?; match &state.create_type { CreateTypeBehaviour::Ok => Ok(ResourceGroupType { code: request.code, @@ -282,9 +296,7 @@ impl ResourceGroupClient for FakeRgClient { allowed_membership_types: request.allowed_membership_types, metadata_schema: request.metadata_schema, }), - CreateTypeBehaviour::AlreadyExists => { - Err(ResourceGroupError::type_already_exists(&request.code)) - } + CreateTypeBehaviour::AlreadyExists => Err(rg_already_exists(&request.code)), CreateTypeBehaviour::Error(e) => Err(e.clone()), } } @@ -293,7 +305,7 @@ impl ResourceGroupClient for FakeRgClient { &self, _ctx: &SecurityContext, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!() } async fn update_type( @@ -301,7 +313,7 @@ impl ResourceGroupClient for FakeRgClient { _ctx: &SecurityContext, code: &str, request: UpdateTypeRequest, - ) -> Result { + ) -> Result { self.update_calls .lock() .expect("update_calls lock not poisoned") @@ -314,32 +326,28 @@ impl ResourceGroupClient for FakeRgClient { metadata_schema: request.metadata_schema, }) } - async fn delete_type( - &self, - _ctx: &SecurityContext, - _code: &str, - ) -> Result<(), ResourceGroupError> { + async fn delete_type(&self, _ctx: &SecurityContext, _code: &str) -> Result<(), CanonicalError> { unreachable!() } async fn create_group( &self, _ctx: &SecurityContext, _request: CreateGroupRequest, - ) -> Result { + ) -> Result { unreachable!() } async fn get_group( &self, _ctx: &SecurityContext, _id: Uuid, - ) -> Result { + ) -> Result { unreachable!() } async fn list_groups( &self, _ctx: &SecurityContext, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!() } async fn update_group( @@ -347,14 +355,10 @@ impl ResourceGroupClient for FakeRgClient { _ctx: &SecurityContext, _id: Uuid, _request: UpdateGroupRequest, - ) -> Result { + ) -> Result { unreachable!() } - async fn delete_group( - &self, - _ctx: &SecurityContext, - _id: Uuid, - ) -> Result<(), ResourceGroupError> { + async fn delete_group(&self, _ctx: &SecurityContext, _id: Uuid) -> Result<(), CanonicalError> { unreachable!() } async fn get_group_descendants( @@ -362,7 +366,7 @@ impl ResourceGroupClient for FakeRgClient { _ctx: &SecurityContext, _group_id: Uuid, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!() } async fn get_group_ancestors( @@ -370,7 +374,7 @@ impl ResourceGroupClient for FakeRgClient { _ctx: &SecurityContext, _group_id: Uuid, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!() } async fn add_membership( @@ -379,7 +383,7 @@ impl ResourceGroupClient for FakeRgClient { _group_id: Uuid, _resource_type: &str, _resource_id: &str, - ) -> Result { + ) -> Result { unreachable!() } async fn remove_membership( @@ -388,14 +392,14 @@ impl ResourceGroupClient for FakeRgClient { _group_id: Uuid, _resource_type: &str, _resource_id: &str, - ) -> Result<(), ResourceGroupError> { + ) -> Result<(), CanonicalError> { unreachable!() } async fn list_memberships( &self, _ctx: &SecurityContext, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!() } } @@ -668,21 +672,21 @@ async fn container_update_type_transport_error_returns_service_unavailable() { &self, ctx: &SecurityContext, code: &str, - ) -> Result { + ) -> Result { self.delegate.get_type(ctx, code).await } async fn create_type( &self, ctx: &SecurityContext, request: CreateTypeRequest, - ) -> Result { + ) -> Result { self.delegate.create_type(ctx, request).await } async fn list_types( &self, ctx: &SecurityContext, query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { self.delegate.list_types(ctx, query).await } async fn update_type( @@ -690,37 +694,35 @@ async fn container_update_type_transport_error_returns_service_unavailable() { _ctx: &SecurityContext, _code: &str, _request: UpdateTypeRequest, - ) -> Result { - Err(ResourceGroupError::ServiceUnavailable { - message: "connection refused".to_owned(), - }) + ) -> Result { + Err(CanonicalError::internal("connection refused").create()) } async fn delete_type( &self, ctx: &SecurityContext, code: &str, - ) -> Result<(), ResourceGroupError> { + ) -> Result<(), CanonicalError> { self.delegate.delete_type(ctx, code).await } async fn create_group( &self, ctx: &SecurityContext, request: CreateGroupRequest, - ) -> Result { + ) -> Result { self.delegate.create_group(ctx, request).await } async fn get_group( &self, ctx: &SecurityContext, id: Uuid, - ) -> Result { + ) -> Result { self.delegate.get_group(ctx, id).await } async fn list_groups( &self, ctx: &SecurityContext, query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { self.delegate.list_groups(ctx, query).await } async fn update_group( @@ -728,14 +730,14 @@ async fn container_update_type_transport_error_returns_service_unavailable() { ctx: &SecurityContext, id: Uuid, request: UpdateGroupRequest, - ) -> Result { + ) -> Result { self.delegate.update_group(ctx, id, request).await } async fn delete_group( &self, ctx: &SecurityContext, id: Uuid, - ) -> Result<(), ResourceGroupError> { + ) -> Result<(), CanonicalError> { self.delegate.delete_group(ctx, id).await } async fn get_group_descendants( @@ -743,7 +745,7 @@ async fn container_update_type_transport_error_returns_service_unavailable() { ctx: &SecurityContext, id: Uuid, query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { self.delegate.get_group_descendants(ctx, id, query).await } async fn get_group_ancestors( @@ -751,7 +753,7 @@ async fn container_update_type_transport_error_returns_service_unavailable() { ctx: &SecurityContext, id: Uuid, query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { self.delegate.get_group_ancestors(ctx, id, query).await } async fn add_membership( @@ -760,7 +762,7 @@ async fn container_update_type_transport_error_returns_service_unavailable() { id: Uuid, ty: &str, rid: &str, - ) -> Result { + ) -> Result { self.delegate.add_membership(ctx, id, ty, rid).await } async fn remove_membership( @@ -769,14 +771,14 @@ async fn container_update_type_transport_error_returns_service_unavailable() { id: Uuid, ty: &str, rid: &str, - ) -> Result<(), ResourceGroupError> { + ) -> Result<(), CanonicalError> { self.delegate.remove_membership(ctx, id, ty, rid).await } async fn list_memberships( &self, ctx: &SecurityContext, query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { self.delegate.list_memberships(ctx, query).await } } diff --git a/gears/system/account-management/account-management/src/infra/rg/checker_tests.rs b/gears/system/account-management/account-management/src/infra/rg/checker_tests.rs index 3f49511f3..9ce20149e 100644 --- a/gears/system/account-management/account-management/src/infra/rg/checker_tests.rs +++ b/gears/system/account-management/account-management/src/infra/rg/checker_tests.rs @@ -8,11 +8,11 @@ use super::*; use resource_group_sdk::{ - CreateGroupRequest, CreateTypeRequest, GroupHierarchy, ResourceGroup, ResourceGroupError, - ResourceGroupMembership, ResourceGroupType, ResourceGroupWithDepth, UpdateGroupRequest, - UpdateTypeRequest, + CreateGroupRequest, CreateTypeRequest, GroupHierarchy, ResourceGroup, ResourceGroupMembership, + ResourceGroupType, ResourceGroupWithDepth, UpdateGroupRequest, UpdateTypeRequest, }; use std::sync::Mutex; +use toolkit_canonical_errors::CanonicalError; use toolkit_odata::Page; #[allow( @@ -86,21 +86,21 @@ impl ResourceGroupClient for FakeRgClient { &self, _ctx: &SecurityContext, _request: CreateTypeRequest, - ) -> Result { + ) -> Result { unreachable!("not used by RgResourceOwnershipChecker") } async fn get_type( &self, _ctx: &SecurityContext, _code: &str, - ) -> Result { + ) -> Result { unreachable!() } async fn list_types( &self, _ctx: &SecurityContext, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!() } async fn update_type( @@ -108,35 +108,31 @@ impl ResourceGroupClient for FakeRgClient { _ctx: &SecurityContext, _code: &str, _request: UpdateTypeRequest, - ) -> Result { + ) -> Result { unreachable!() } - async fn delete_type( - &self, - _ctx: &SecurityContext, - _code: &str, - ) -> Result<(), ResourceGroupError> { + async fn delete_type(&self, _ctx: &SecurityContext, _code: &str) -> Result<(), CanonicalError> { unreachable!() } async fn create_group( &self, _ctx: &SecurityContext, _request: CreateGroupRequest, - ) -> Result { + ) -> Result { unreachable!() } async fn get_group( &self, _ctx: &SecurityContext, _id: Uuid, - ) -> Result { + ) -> Result { unreachable!() } async fn list_groups( &self, _ctx: &SecurityContext, query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { *self.list_calls.lock().expect("lock") += 1; *self.last_filter.lock().expect("lock") = query.filter().cloned(); let behaviour = self.behaviour.lock().expect("lock").clone(); @@ -150,9 +146,7 @@ impl ResourceGroupClient for FakeRgClient { limit: 1, }, )), - FakeBehaviour::ListErr => { - Err(ResourceGroupError::service_unavailable("rg backend down")) - } + FakeBehaviour::ListErr => Err(CanonicalError::internal("rg backend down").create()), FakeBehaviour::ListDelay(delay) => { tokio::time::sleep(delay).await; Ok(Page::empty(1)) @@ -164,14 +158,10 @@ impl ResourceGroupClient for FakeRgClient { _ctx: &SecurityContext, _id: Uuid, _request: UpdateGroupRequest, - ) -> Result { + ) -> Result { unreachable!() } - async fn delete_group( - &self, - _ctx: &SecurityContext, - _id: Uuid, - ) -> Result<(), ResourceGroupError> { + async fn delete_group(&self, _ctx: &SecurityContext, _id: Uuid) -> Result<(), CanonicalError> { unreachable!() } async fn get_group_descendants( @@ -179,7 +169,7 @@ impl ResourceGroupClient for FakeRgClient { _ctx: &SecurityContext, _group_id: Uuid, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!() } async fn get_group_ancestors( @@ -187,7 +177,7 @@ impl ResourceGroupClient for FakeRgClient { _ctx: &SecurityContext, _group_id: Uuid, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!() } async fn add_membership( @@ -196,7 +186,7 @@ impl ResourceGroupClient for FakeRgClient { _group_id: Uuid, _resource_type: &str, _resource_id: &str, - ) -> Result { + ) -> Result { unreachable!() } async fn remove_membership( @@ -205,14 +195,14 @@ impl ResourceGroupClient for FakeRgClient { _group_id: Uuid, _resource_type: &str, _resource_id: &str, - ) -> Result<(), ResourceGroupError> { + ) -> Result<(), CanonicalError> { unreachable!() } async fn list_memberships( &self, _ctx: &SecurityContext, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!("not used by RgResourceOwnershipChecker") } } diff --git a/gears/system/account-management/account-management/src/infra/rg/test_helpers.rs b/gears/system/account-management/account-management/src/infra/rg/test_helpers.rs index 37f6f242c..faa0f06c1 100644 --- a/gears/system/account-management/account-management/src/infra/rg/test_helpers.rs +++ b/gears/system/account-management/account-management/src/infra/rg/test_helpers.rs @@ -14,10 +14,11 @@ use std::time::Duration; use async_trait::async_trait; use resource_group_sdk::{ - CreateGroupRequest, CreateTypeRequest, ResourceGroup, ResourceGroupClient, ResourceGroupError, + CreateGroupRequest, CreateTypeRequest, ResourceGroup, ResourceGroupClient, ResourceGroupMembership, ResourceGroupType, ResourceGroupWithDepth, UpdateGroupRequest, UpdateTypeRequest, }; +use toolkit_canonical_errors::CanonicalError; use toolkit_odata::{ODataQuery, Page}; use toolkit_security::SecurityContext; use uuid::Uuid; @@ -44,21 +45,21 @@ impl ResourceGroupClient for SlowRgClient { &self, _ctx: &SecurityContext, _request: CreateTypeRequest, - ) -> Result { + ) -> Result { unreachable!() } async fn get_type( &self, _ctx: &SecurityContext, _code: &str, - ) -> Result { + ) -> Result { unreachable!() } async fn list_types( &self, _ctx: &SecurityContext, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!() } async fn update_type( @@ -66,35 +67,31 @@ impl ResourceGroupClient for SlowRgClient { _ctx: &SecurityContext, _code: &str, _request: UpdateTypeRequest, - ) -> Result { + ) -> Result { unreachable!() } - async fn delete_type( - &self, - _ctx: &SecurityContext, - _code: &str, - ) -> Result<(), ResourceGroupError> { + async fn delete_type(&self, _ctx: &SecurityContext, _code: &str) -> Result<(), CanonicalError> { unreachable!() } async fn create_group( &self, _ctx: &SecurityContext, _request: CreateGroupRequest, - ) -> Result { + ) -> Result { unreachable!() } async fn get_group( &self, _ctx: &SecurityContext, _id: Uuid, - ) -> Result { + ) -> Result { unreachable!() } async fn list_groups( &self, _ctx: &SecurityContext, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { tokio::time::sleep(self.delay).await; Ok(Page::empty(1)) } @@ -103,14 +100,10 @@ impl ResourceGroupClient for SlowRgClient { _ctx: &SecurityContext, _id: Uuid, _request: UpdateGroupRequest, - ) -> Result { + ) -> Result { unreachable!() } - async fn delete_group( - &self, - _ctx: &SecurityContext, - _id: Uuid, - ) -> Result<(), ResourceGroupError> { + async fn delete_group(&self, _ctx: &SecurityContext, _id: Uuid) -> Result<(), CanonicalError> { unreachable!() } async fn get_group_descendants( @@ -118,7 +111,7 @@ impl ResourceGroupClient for SlowRgClient { _ctx: &SecurityContext, _group_id: Uuid, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!() } async fn get_group_ancestors( @@ -126,7 +119,7 @@ impl ResourceGroupClient for SlowRgClient { _ctx: &SecurityContext, _group_id: Uuid, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!() } async fn add_membership( @@ -135,7 +128,7 @@ impl ResourceGroupClient for SlowRgClient { _group_id: Uuid, _resource_type: &str, _resource_id: &str, - ) -> Result { + ) -> Result { unreachable!() } async fn remove_membership( @@ -144,14 +137,14 @@ impl ResourceGroupClient for SlowRgClient { _group_id: Uuid, _resource_type: &str, _resource_id: &str, - ) -> Result<(), ResourceGroupError> { + ) -> Result<(), CanonicalError> { unreachable!() } async fn list_memberships( &self, _ctx: &SecurityContext, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unreachable!() } } diff --git a/gears/system/resource-group/resource-group-sdk/Cargo.toml b/gears/system/resource-group/resource-group-sdk/Cargo.toml index 980419a67..fc7d00e0b 100644 --- a/gears/system/resource-group/resource-group-sdk/Cargo.toml +++ b/gears/system/resource-group/resource-group-sdk/Cargo.toml @@ -23,6 +23,14 @@ odata = [ ] [dependencies] +# Per ADR 0005 the trait boundary is `CanonicalError`; the SDK ships +# `ResourceGroupError` as an opt-in `From` projection +# (see src/error.rs). The single AIP-193 ladder +# (`From for CanonicalError`) lives in the impl crate's +# `api::rest::error`; this SDK depends on the canonical-errors crate to +# project that envelope into the typed view. +toolkit-canonical-errors = { workspace = true } + # Core dependencies for API trait thiserror = { workspace = true } serde = { workspace = true } diff --git a/gears/system/resource-group/resource-group-sdk/src/api.rs b/gears/system/resource-group/resource-group-sdk/src/api.rs index 8ff7b78be..16ad11f08 100644 --- a/gears/system/resource-group/resource-group-sdk/src/api.rs +++ b/gears/system/resource-group/resource-group-sdk/src/api.rs @@ -8,7 +8,8 @@ use toolkit_security::SecurityContext; use toolkit_odata::{ODataQuery, Page}; use uuid::Uuid; -use crate::error::ResourceGroupError; +use toolkit_canonical_errors::CanonicalError; + use crate::models::{ CreateGroupRequest, CreateTypeRequest, ResourceGroup, ResourceGroupMembership, ResourceGroupType, ResourceGroupWithDepth, UpdateGroupRequest, UpdateTypeRequest, @@ -21,6 +22,19 @@ use crate::models::{ /// let client = hub.get::()?; /// let rg_type = client.get_type(&ctx, "gts.cf.core.rg.type.v1~...").await?; /// ``` +/// +/// # Error envelope +/// +/// Per [ADR 0005][adr] every fallible method returns +/// `Result<_, CanonicalError>`. The single authoritative AIP-193 ladder +/// (`From for CanonicalError`) lives in the impl crate's +/// `api::rest::error`; this trait surfaces that envelope unchanged. +/// Consumers may propagate it, or opt into the typed +/// [`ResourceGroupError`](crate::ResourceGroupError) projection +/// (`From`) for flat dispatch — see its gear docs for +/// the dispatch table and the three integration patterns. +/// +/// [adr]: https://github.com/constructorfabric/gears-rust/blob/main/docs/arch/errors/ADR/0005-cpt-cf-adr-sdk-canonical-projection.md #[async_trait] pub trait ResourceGroupClient: Send + Sync { // -- Type lifecycle -- @@ -30,21 +44,21 @@ pub trait ResourceGroupClient: Send + Sync { &self, ctx: &SecurityContext, request: CreateTypeRequest, - ) -> Result; + ) -> Result; /// Get a GTS type definition by its code (GTS type path). async fn get_type( &self, ctx: &SecurityContext, code: &str, - ) -> Result; + ) -> Result; /// List GTS type definitions with `OData` filtering and cursor-based pagination. async fn list_types( &self, ctx: &SecurityContext, query: &ODataQuery, - ) -> Result, ResourceGroupError>; + ) -> Result, CanonicalError>; /// Update a GTS type definition (full replacement). async fn update_type( @@ -52,14 +66,10 @@ pub trait ResourceGroupClient: Send + Sync { ctx: &SecurityContext, code: &str, request: UpdateTypeRequest, - ) -> Result; + ) -> Result; /// Delete a GTS type definition. Fails if groups of this type exist. - async fn delete_type( - &self, - ctx: &SecurityContext, - code: &str, - ) -> Result<(), ResourceGroupError>; + async fn delete_type(&self, ctx: &SecurityContext, code: &str) -> Result<(), CanonicalError>; // -- Group lifecycle -- @@ -68,21 +78,21 @@ pub trait ResourceGroupClient: Send + Sync { &self, ctx: &SecurityContext, request: CreateGroupRequest, - ) -> Result; + ) -> Result; /// Get a resource group by ID. async fn get_group( &self, ctx: &SecurityContext, id: Uuid, - ) -> Result; + ) -> Result; /// List resource groups with `OData` filtering and cursor-based pagination. async fn list_groups( &self, ctx: &SecurityContext, query: &ODataQuery, - ) -> Result, ResourceGroupError>; + ) -> Result, CanonicalError>; /// Update a resource group (full replacement). async fn update_group( @@ -90,15 +100,15 @@ pub trait ResourceGroupClient: Send + Sync { ctx: &SecurityContext, id: Uuid, request: UpdateGroupRequest, - ) -> Result; + ) -> Result; /// Delete a resource group (non-cascade). /// - /// The call fails with `ConflictActiveReferences` if the group has child + /// The call fails with `FailedPrecondition` (`Subject::ActiveReferences`) + /// if the group has child /// groups or active memberships. For force-cascade behaviour use /// [`Self::delete_group_cascade`]. - async fn delete_group(&self, ctx: &SecurityContext, id: Uuid) - -> Result<(), ResourceGroupError>; + async fn delete_group(&self, ctx: &SecurityContext, id: Uuid) -> Result<(), CanonicalError>; /// Force-delete a resource group, cascading into the entire subtree: /// every descendant group, every membership row for those groups, and @@ -109,21 +119,21 @@ pub trait ResourceGroupClient: Send + Sync { /// tenant-hard-delete cascade hook that tears down all user-group /// state for a tenant before the `tenants` row is removed. Most /// consumers want [`Self::delete_group`] (the non-cascade variant) - /// and surface `ConflictActiveReferences` to the caller as 409. + /// and surface `FailedPrecondition` (`Subject::ActiveReferences`) to the caller as 409. /// /// Default impl delegates to the non-cascade variant so existing /// implementers (production `RgService`, test fakes) compile without /// breakage; implementations that genuinely support cascade SHOULD /// override this to call into their REST-side `force=true` path. /// Implementations that cannot cascade (e.g. inert test fakes) are - /// expected to return `ConflictActiveReferences` from the default + /// expected to return `FailedPrecondition` (`Subject::ActiveReferences`) from the default /// fallback when the group has children / memberships, mirroring the /// non-cascade contract. async fn delete_group_cascade( &self, ctx: &SecurityContext, id: Uuid, - ) -> Result<(), ResourceGroupError> { + ) -> Result<(), CanonicalError> { self.delete_group(ctx, id).await } @@ -133,7 +143,7 @@ pub trait ResourceGroupClient: Send + Sync { ctx: &SecurityContext, group_id: Uuid, query: &ODataQuery, - ) -> Result, ResourceGroupError>; + ) -> Result, CanonicalError>; /// Get ancestors of a reference group (depth <= 0). async fn get_group_ancestors( @@ -141,7 +151,7 @@ pub trait ResourceGroupClient: Send + Sync { ctx: &SecurityContext, group_id: Uuid, query: &ODataQuery, - ) -> Result, ResourceGroupError>; + ) -> Result, CanonicalError>; // -- Membership lifecycle -- @@ -152,7 +162,7 @@ pub trait ResourceGroupClient: Send + Sync { group_id: Uuid, resource_type: &str, resource_id: &str, - ) -> Result; + ) -> Result; /// Remove a membership link. async fn remove_membership( @@ -161,14 +171,14 @@ pub trait ResourceGroupClient: Send + Sync { group_id: Uuid, resource_type: &str, resource_id: &str, - ) -> Result<(), ResourceGroupError>; + ) -> Result<(), CanonicalError>; /// List memberships with `OData` filtering and cursor-based pagination. async fn list_memberships( &self, ctx: &SecurityContext, query: &ODataQuery, - ) -> Result, ResourceGroupError>; + ) -> Result, CanonicalError>; } // @cpt-dod:cpt-cf-resource-group-dod-integration-auth-read-service:p1 @@ -193,6 +203,14 @@ pub trait ResourceGroupClient: Send + Sync { /// PDP and recurse. Implementations therefore resolve them unscoped (no tenant /// `AccessScope`); the caller supplies any subject/tenant `OData` filter and /// owns tenant scoping. +/// +/// # Error envelope +/// +/// Like [`ResourceGroupClient`], every fallible method returns +/// `Result<_, CanonicalError>` per [ADR 0005]; consumers may project it +/// into the typed [`ResourceGroupError`](crate::ResourceGroupError) view. +/// +/// [ADR 0005]: https://github.com/constructorfabric/gears-rust/blob/main/docs/arch/errors/ADR/0005-cpt-cf-adr-sdk-canonical-projection.md #[async_trait] pub trait ResourceGroupReadHierarchy: Send + Sync { /// Get descendants of a reference group (depth >= 0). @@ -201,7 +219,7 @@ pub trait ResourceGroupReadHierarchy: Send + Sync { ctx: &SecurityContext, group_id: Uuid, query: &ODataQuery, - ) -> Result, ResourceGroupError>; + ) -> Result, CanonicalError>; /// Get ancestors of a reference group (depth <= 0). async fn get_group_ancestors( @@ -209,7 +227,7 @@ pub trait ResourceGroupReadHierarchy: Send + Sync { ctx: &SecurityContext, group_id: Uuid, query: &ODataQuery, - ) -> Result, ResourceGroupError>; + ) -> Result, CanonicalError>; /// List resource groups with `OData` filtering and cursor-based pagination. /// @@ -221,7 +239,7 @@ pub trait ResourceGroupReadHierarchy: Send + Sync { &self, ctx: &SecurityContext, query: &ODataQuery, - ) -> Result, ResourceGroupError>; + ) -> Result, CanonicalError>; /// Get a single resource group by ID (existence + tenant-ownership check). /// @@ -232,7 +250,7 @@ pub trait ResourceGroupReadHierarchy: Send + Sync { &self, ctx: &SecurityContext, id: Uuid, - ) -> Result; + ) -> Result; /// List memberships with `OData` filtering and cursor-based pagination. /// @@ -243,5 +261,5 @@ pub trait ResourceGroupReadHierarchy: Send + Sync { &self, ctx: &SecurityContext, query: &ODataQuery, - ) -> Result, ResourceGroupError>; + ) -> Result, CanonicalError>; } diff --git a/gears/system/resource-group/resource-group-sdk/src/error.rs b/gears/system/resource-group/resource-group-sdk/src/error.rs index 19114c2f0..6728a7eef 100644 --- a/gears/system/resource-group/resource-group-sdk/src/error.rs +++ b/gears/system/resource-group/resource-group-sdk/src/error.rs @@ -1,158 +1,302 @@ // Created: 2026-04-16 by Constructor Tech // @cpt-dod:cpt-cf-resource-group-dod-sdk-foundation-sdk-errors:p1 -//! Public error types for the resource-group gear. +//! Resource Group SDK error surface — typed projection of +//! [`CanonicalError`]. //! -//! These errors are safe to expose to other gears and consumers. +//! # Opt-in convenience, not the contract +//! +//! Per [ADR 0005][adr] the [`ResourceGroupClient`] / +//! [`ResourceGroupReadHierarchy`] trait boundaries are +//! `Result<_, CanonicalError>`. [`ResourceGroupError`] is an **opt-in** +//! typed view over that envelope, shipped for consumers that want flat +//! dispatch on the categories resource-group emits. It is *not* part of +//! the trait contract: adding a variant is non-breaking, and the single +//! authoritative AIP-193 classification lives in the impl crate's one +//! `From for CanonicalError` ladder (`api::rest::error`) — +//! this projection only reads the finished `CanonicalError`. +//! +//! The conversion is infallible (`From`). Canonical +//! categories resource-group does not emit fall through to +//! [`ResourceGroupError::Other`], which preserves the full +//! [`CanonicalError`] for inspection / forward-compatible dispatch on the +//! inner variant. +//! +//! **Malformed envelopes also fall through to [`ResourceGroupError::Other`].** +//! The in-process builder's type-state guarantees a well-formed envelope +//! (a resource-scoped category always carries `resource_type`; a +//! `FailedPrecondition` always carries ≥1 violation), but the documented +//! wire path (`Problem JSON → CanonicalError`) does not re-validate. So a +//! `NotFound`/`AlreadyExists` missing `resource_type`, or a +//! `FailedPrecondition` with an empty violation list, projects to `Other` +//! rather than to a typed variant with empty fields — a missing dispatch +//! key surfaces as "unmodeled" instead of silently mis-dispatching. +//! +//! # What resource-group emits — consumer dispatch reference +//! +//! | Disposition | Match arm | HTTP | +//! |---|---|---| +//! | type / group / membership missing | [`ResourceGroupError::NotFound`] | 404 | +//! | duplicate-on-create (type, membership, tenant root) | [`ResourceGroupError::AlreadyExists`] | 409 | +//! | request-shape validation (inspect `reason` against [`field`]) | [`ResourceGroupError::InvalidArgument`] | 400 | +//! | state precondition — inspect [`precondition::Subject`] | [`ResourceGroupError::FailedPrecondition`] | 400 | +//! | generic concurrency / state conflict — inspect `reason` ([`reason::aborted`]) | [`ResourceGroupError::Aborted`] | 409 | +//! | PDP denial — inspect `reason` ([`reason::permission`]) | [`ResourceGroupError::PermissionDenied`] | 403 | +//! | internal error (DB / infra) | [`ResourceGroupError::Internal`] | 500 | +//! | anything else (forward-compat) | [`ResourceGroupError::Other`] | — | +//! +//! Resource-scoped variants ([`ResourceGroupError::NotFound`] / +//! [`ResourceGroupError::AlreadyExists`]) carry the raw `resource_type`; +//! match it against [`crate::gts::GROUP_RESOURCE_TYPE`]. +//! +//! [`FailedPrecondition`](ResourceGroupError::FailedPrecondition) flattens +//! several domain families that share the canonical category; the +//! discriminator is the typed [`precondition::Subject`] (e.g. +//! `Subject::Hierarchy` ⇒ a detected cycle, `Subject::Limit` ⇒ a +//! configured-limit violation), **not** the wire `type` (uniformly +//! [`precondition::STATE_TYPE`]). +//! +//! # Consumer integration — three patterns +//! +//! **Pattern 1 — pure propagation (no projection):** +//! +//! ```ignore +//! let group = rg_client.get_group(&ctx, id).await?; // ? propagates CanonicalError +//! ``` +//! +//! **Pattern 2 — explicit projection at the call site:** +//! +//! ```ignore +//! use resource_group_sdk::{ResourceGroupError, precondition::Subject}; +//! +//! let res = rg_client.create_group(&ctx, req).await +//! .map_err(ResourceGroupError::from); +//! match res { +//! Err(ResourceGroupError::FailedPrecondition { subject: Subject::Hierarchy, .. }) => +//! /* would create a cycle */, +//! Err(ResourceGroupError::NotFound { .. }) => /* parent gone */, +//! _ => /* … */, +//! } +//! ``` +//! +//! **Pattern 3 — transparent chaining via `From for OwnError`:** +//! +//! ```ignore +//! impl From for OwnConsumerError { +//! fn from(err: CanonicalError) -> Self { +//! ResourceGroupError::from(err).into() // route through the typed view +//! } +//! } +//! // then every call site stays plain `?`. +//! ``` +//! +//! Out-of-process consumers reconstruct the canonical error from the wire +//! via `TryFrom for CanonicalError` first, then project: +//! `Problem JSON → Problem → CanonicalError → ResourceGroupError`. +//! +//! [`ResourceGroupClient`]: crate::ResourceGroupClient +//! [`ResourceGroupReadHierarchy`]: crate::ResourceGroupReadHierarchy +//! [adr]: https://github.com/constructorfabric/gears-rust/blob/main/docs/arch/errors/ADR/0005-cpt-cf-adr-sdk-canonical-projection.md use thiserror::Error; +use toolkit_canonical_errors::{CanonicalError, InvalidArgument}; + +use crate::precondition::Subject; -/// Errors that can be returned by the `ResourceGroupClient`. +/// Typed projection of [`CanonicalError`] for Resource Group consumers. +/// +/// The impl crate's `From for CanonicalError` is the single +/// authoritative AIP-193 mapping; this enum is a forward-compatible, flat +/// view over the seven categories resource-group emits, plus the +/// mandatory catch-all [`Self::Other`]. See the [gear docs](self) for +/// the dispatch table and consumer patterns. #[derive(Error, Debug, Clone)] pub enum ResourceGroupError { - /// Resource with the specified identifier was not found. - #[error("Resource not found: {code}")] - NotFound { code: String }, - - /// A resource with the specified code already exists. - #[error("Resource already exists: {code}")] - TypeAlreadyExists { code: String }, - - /// Validation error with the provided data. - #[error("Validation error: {message}")] - Validation { message: String }, - - /// Removing allowed parents or disabling root placement would break - /// existing group hierarchy relationships. - #[error("Allowed parents violation: {message}")] - AllowedParentTypesViolation { message: String }, - - /// Cannot delete a type because groups of this type still exist. - #[error("Active references exist: {message}")] - ConflictActiveReferences { message: String }, - - /// A generic conflict (e.g. a concurrency or state conflict not related to references). - #[error("Conflict: {message}")] - Conflict { message: String }, - - /// Parent type is not allowed by the type's `allowed_parent_types` configuration. - #[error("Invalid parent type: {message}")] - InvalidParentType { message: String }, - - /// A cycle would be created in the group hierarchy. - #[error("Cycle detected: {message}")] - CycleDetected { message: String }, - - /// A configured limit (depth, width, etc.) would be exceeded. - #[error("Limit violation: {message}")] - LimitViolation { message: String }, - - /// Cross-tenant link would be created. - /// - /// Each resource (identified by the pair `(resource_type, resource_id)`) - /// belongs to groups in exactly one tenant. Returned by - /// `ResourceGroupClient::add_membership` when the target group's tenant - /// differs from the tenant of any existing membership for the same - /// resource. The resource continues to exist — only the cross-tenant link - /// is rejected. - #[error("Tenant incompatibility: {message}")] - TenantIncompatibility { message: String }, - - /// Service is temporarily unavailable. - #[error("Service unavailable: {message}")] - ServiceUnavailable { message: String }, - - /// Access was denied by the authorization policy (PDP denial). - #[error("Access denied")] - AccessDenied, - - /// An internal error occurred. - #[error("Internal error")] - Internal, -} + /// Resource not found (type, group, or membership). `resource_type` + /// is the canonical GTS type — match it against + /// [`crate::gts::GROUP_RESOURCE_TYPE`]; `name` is the raw identifier + /// the caller supplied. + #[error("not found [{resource_type}]: {name}")] + NotFound { + resource_type: String, + name: String, + detail: String, + }, -impl ResourceGroupError { - /// Create a `NotFound` error. - pub fn not_found(code: impl Into) -> Self { - Self::NotFound { code: code.into() } - } + /// Duplicate-on-create conflict (type code clash, duplicate + /// membership, second tenant-type root). + #[error("already exists [{resource_type}]: {name}")] + AlreadyExists { + resource_type: String, + name: String, + detail: String, + }, - /// Create a `TypeAlreadyExists` error. - pub fn type_already_exists(code: impl Into) -> Self { - Self::TypeAlreadyExists { code: code.into() } - } + /// Request-shape validation failure. `reason` is the wire reason code + /// (match against [`field::INVALID_PARENT_TYPE`](crate::field::INVALID_PARENT_TYPE); + /// empty for the generic `Format` validation shape); `field` is the + /// attributed request field, if any. + #[error("invalid argument [{field}/{reason}]: {detail}")] + InvalidArgument { + field: String, + reason: String, + detail: String, + }, - /// Create a `Validation` error. - pub fn validation(message: impl Into) -> Self { - Self::Validation { - message: message.into(), - } - } + /// State precondition violation. `subject` is the typed + /// [`precondition::Subject`](crate::precondition::Subject) consumers + /// dispatch on (e.g. `Subject::Hierarchy` ⇒ cycle, `Subject::Limit` ⇒ + /// limit-violation); `type_` is the uniform wire token + /// ([`precondition::STATE_TYPE`](crate::precondition::STATE_TYPE)). + #[error("failed precondition [{subject}/{type_}]: {detail}")] + FailedPrecondition { + subject: Subject, + type_: String, + detail: String, + }, - /// Create an `AllowedParentTypesViolation` error. - pub fn allowed_parent_types_violation(message: impl Into) -> Self { - Self::AllowedParentTypesViolation { - message: message.into(), - } - } + /// Generic concurrency / state conflict (HTTP 409). `reason` is the + /// [`reason::aborted::CONFLICT`](crate::reason::aborted::CONFLICT) + /// constant. + #[error("aborted [{reason}]: {detail}")] + Aborted { reason: String, detail: String }, - /// Create a `ConflictActiveReferences` error. - pub fn conflict_active_references(message: impl Into) -> Self { - Self::ConflictActiveReferences { - message: message.into(), - } - } + /// Authorization denial (HTTP 403). `reason` is the + /// [`reason::permission::ACCESS_DENIED`](crate::reason::permission::ACCESS_DENIED) + /// constant. + #[error("permission denied [{reason}]: {detail}")] + PermissionDenied { reason: String, detail: String }, - /// Create a generic `Conflict` error. - pub fn conflict(message: impl Into) -> Self { - Self::Conflict { - message: message.into(), - } - } + /// Unclassified internal failure (HTTP 500). `detail` is already + /// redacted at the canonical boundary — it never carries the + /// server-side diagnostic. + #[error("internal error: {detail}")] + Internal { detail: String }, - /// Create an `InvalidParentType` error. - pub fn invalid_parent_type(message: impl Into) -> Self { - Self::InvalidParentType { - message: message.into(), - } - } + /// Catch-all for canonical categories resource-group does not model — + /// preserves the full [`CanonicalError`] so consumers stay + /// forward-compatible if the impl crate ever emits a new category. + #[error("[{}] {}", canonical.gts_type(), canonical.detail())] + Other { canonical: CanonicalError }, +} - /// Create a `CycleDetected` error. - pub fn cycle_detected(message: impl Into) -> Self { - Self::CycleDetected { - message: message.into(), - } - } +// ───────────────────────────────────────────────────────────────────── +// CanonicalError → ResourceGroupError projection. +// +// The typed sub-enum (`precondition::Subject`) lives next to its +// wire-string constants in `crate::precondition`; the single-valued +// reasons stay plain consts in `crate::field` / `crate::reason`. This +// file owns only the top-level enum and the dispatch from +// `CanonicalError`. +// ───────────────────────────────────────────────────────────────────── - /// Create a `LimitViolation` error. - pub fn limit_violation(message: impl Into) -> Self { - Self::LimitViolation { - message: message.into(), - } - } +impl From for ResourceGroupError { + fn from(err: CanonicalError) -> Self { + // Borrow the canonical detail before consuming `err`; the borrow + // ends here so each arm below can move its fields out (no clones). + let detail = err.detail().to_owned(); + match err { + // A resource-scoped category MUST carry its `resource_type` + // (the dispatch key consumers match against + // `gts::GROUP_RESOURCE_TYPE`). Resource-group always sets it; + // a `None` here means a malformed / foreign envelope reached us + // via the wire path, so we let it fall through to `Other` + // rather than forge a `NotFound { resource_type: "" }` that + // would silently mis-dispatch. + CanonicalError::NotFound { + resource_type: Some(resource_type), + resource_name, + .. + } => Self::NotFound { + resource_type, + name: resource_name.unwrap_or_default(), + detail, + }, - /// Create a `TenantIncompatibility` error. - pub fn tenant_incompatibility(message: impl Into) -> Self { - Self::TenantIncompatibility { - message: message.into(), - } - } + CanonicalError::AlreadyExists { + resource_type: Some(resource_type), + resource_name, + .. + } => Self::AlreadyExists { + resource_type, + name: resource_name.unwrap_or_default(), + detail, + }, - /// Create a `ServiceUnavailable` error. - pub fn service_unavailable(message: impl Into) -> Self { - Self::ServiceUnavailable { - message: message.into(), - } - } + CanonicalError::InvalidArgument { ctx, .. } => project_invalid_argument(ctx, detail), + + // Resource-group emits exactly one PreconditionViolation per + // FailedPrecondition error; the meaningful message lives in + // the violation `description`, so surface it as `detail`. An + // empty violation list is a malformed / foreign envelope (the + // builder's type-state forbids it in-process) — the guard lets + // it fall through to `Other` rather than forge a + // `FailedPrecondition` with empty `subject`/`type_` that would + // look like a real domain precondition. + CanonicalError::FailedPrecondition { ctx, .. } if !ctx.violations.is_empty() => { + ctx.violations.into_iter().next().map_or_else( + // Unreachable: the guard rejects empty violation lists. + // Kept as a total, non-panicking fallback so the + // conversion stays infallible. + || Self::Internal { detail }, + |v| Self::FailedPrecondition { + subject: Subject::from_wire(&v.subject), + type_: v.type_, + detail: v.description, + }, + ) + } + + CanonicalError::Aborted { ctx, .. } => Self::Aborted { + reason: ctx.reason, + detail, + }, - /// Create an `AccessDenied` error. - #[must_use] - pub fn access_denied() -> Self { - Self::AccessDenied + CanonicalError::PermissionDenied { ctx, .. } => Self::PermissionDenied { + reason: ctx.reason, + detail, + }, + + CanonicalError::Internal { .. } => Self::Internal { detail }, + + other => Self::Other { canonical: other }, + } } +} - /// Create an `Internal` error. - #[must_use] - pub fn internal() -> Self { - Self::Internal +fn project_invalid_argument(ctx: InvalidArgument, detail: String) -> ResourceGroupError { + // Resource-group emits two `InvalidArgument` shapes today: a single + // `FieldViolations` (parent-type rejects) and a `Format` (generic + // validation). `Constraint` carries a constraint identifier rather than + // a field, so it projects with an empty `field` and the constraint name + // in `reason`; the empty case falls back to the canonical detail. + match ctx { + InvalidArgument::FieldViolations { field_violations } => { + field_violations.into_iter().next().map_or_else( + || ResourceGroupError::InvalidArgument { + field: String::new(), + reason: String::new(), + detail, + }, + |v| ResourceGroupError::InvalidArgument { + field: v.field, + reason: v.reason, + detail: v.description, + }, + ) + } + InvalidArgument::Constraint { constraint } => ResourceGroupError::InvalidArgument { + field: String::new(), + reason: constraint, + detail, + }, + InvalidArgument::Format { .. } => ResourceGroupError::InvalidArgument { + field: String::new(), + reason: String::new(), + detail, + }, } } + +#[cfg(test)] +#[path = "error_tests.rs"] +mod error_tests; diff --git a/gears/system/resource-group/resource-group-sdk/src/error_tests.rs b/gears/system/resource-group/resource-group-sdk/src/error_tests.rs new file mode 100644 index 000000000..9bebcddb7 --- /dev/null +++ b/gears/system/resource-group/resource-group-sdk/src/error_tests.rs @@ -0,0 +1,411 @@ +//! Tests for the [`ResourceGroupError`] projection. +//! +//! Two suites: +//! +//! * `wire_vocabulary_round_trip` — pins every wire-string constant the +//! projection introduces ([`crate::field`], [`crate::precondition`], +//! [`crate::reason`], [`crate::gts`]) to its `Problem` JSON path. A +//! drift between an SDK constant and the wire trips here. +//! * `projection_tests` — exercises `From`, verifying +//! each canonical category lands on the expected typed variant and that +//! unmodeled categories preserve the canonical in `Other`. + +use super::ResourceGroupError; + +// ───────────────────────────────────────────────────────────────────── +// Wire-vocabulary round-trip +// ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod wire_vocabulary_round_trip { + use crate::{field, gts, precondition, reason}; + use toolkit_canonical_errors::{CanonicalError, Problem, resource_error}; + + // Test scope mirroring the impl crate's `#[resource_error]` marker. + // Its literal MUST equal `gts::GROUP_RESOURCE_TYPE` — the + // `gts_resource_type_round_trip` test asserts that equality (the + // proc-macro cannot reference the const directly). + #[resource_error("gts.cf.core.resource_group.group.v1~")] + struct RgScope; + + fn problem(err: CanonicalError) -> serde_json::Value { + serde_json::to_value(Problem::from(err)).expect("Problem serializes") + } + + #[test] + fn gts_resource_type_round_trips_to_context_resource_type() { + let err = RgScope::not_found("x").with_resource("x").create(); + let json = problem(err); + assert_eq!( + json["context"]["resource_type"], + gts::GROUP_RESOURCE_TYPE, + "resource type must round-trip into context.resource_type", + ); + } + + #[test] + fn field_reason_constant_round_trips_to_field_violations() { + let err = RgScope::invalid_argument() + .with_field_violation( + field::PARENT_TYPE_FIELD, + "bad parent", + field::INVALID_PARENT_TYPE, + ) + .create(); + let json = problem(err); + assert_eq!( + json["context"]["field_violations"][0]["reason"], + field::INVALID_PARENT_TYPE, + "field reason must round-trip into field_violations[].reason", + ); + assert_eq!( + json["context"]["field_violations"][0]["field"], + field::PARENT_TYPE_FIELD, + "field name must round-trip into field_violations[].field", + ); + } + + #[test] + fn precondition_subject_constants_round_trip_to_violations() { + for subject in [ + precondition::ALLOWED_PARENTS_SUBJECT, + precondition::HIERARCHY_SUBJECT, + precondition::ACTIVE_REFERENCES_SUBJECT, + precondition::LIMIT_SUBJECT, + precondition::TENANT_SUBJECT, + ] { + let err = RgScope::failed_precondition() + .with_precondition_violation(subject, "test description", precondition::STATE_TYPE) + .create(); + let json = problem(err); + assert_eq!( + json["context"]["violations"][0]["subject"], subject, + "subject {subject} must round-trip into violations[].subject", + ); + } + } + + #[test] + fn precondition_type_constant_round_trips_to_violations() { + let err = RgScope::failed_precondition() + .with_precondition_violation( + precondition::HIERARCHY_SUBJECT, + "test description", + precondition::STATE_TYPE, + ) + .create(); + let json = problem(err); + assert_eq!( + json["context"]["violations"][0]["type"], + precondition::STATE_TYPE, + "type must round-trip into violations[].type", + ); + } + + #[test] + fn aborted_reason_constant_round_trips_to_context_reason() { + let err = RgScope::aborted("conflict") + .with_reason(reason::aborted::CONFLICT) + .create(); + let json = problem(err); + assert_eq!(json["context"]["reason"], reason::aborted::CONFLICT); + } + + #[test] + fn permission_reason_constant_round_trips_to_context_reason() { + let err = RgScope::permission_denied() + .with_reason(reason::permission::ACCESS_DENIED) + .create(); + let json = problem(err); + assert_eq!(json["context"]["reason"], reason::permission::ACCESS_DENIED); + } +} + +// ───────────────────────────────────────────────────────────────────── +// Projection: From for ResourceGroupError +// ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod projection_tests { + use super::ResourceGroupError; + use crate::precondition::Subject; + use crate::{field, gts, precondition, reason}; + use toolkit_canonical_errors::{CanonicalError, Problem, resource_error}; + + #[resource_error("gts.cf.core.resource_group.group.v1~")] + struct RgScope; + + #[test] + fn not_found_projects_resource_type_and_name() { + let canonical = RgScope::not_found("group 7 not found") + .with_resource("7") + .create(); + match ResourceGroupError::from(canonical) { + ResourceGroupError::NotFound { + resource_type, + name, + .. + } => { + assert_eq!(resource_type, gts::GROUP_RESOURCE_TYPE); + assert_eq!(name, "7"); + } + other => panic!("expected NotFound, got {other:?}"), + } + } + + #[test] + fn already_exists_projects_resource_type_and_name() { + let canonical = RgScope::already_exists("type exists") + .with_resource("my.code") + .create(); + match ResourceGroupError::from(canonical) { + ResourceGroupError::AlreadyExists { + resource_type, + name, + .. + } => { + assert_eq!(resource_type, gts::GROUP_RESOURCE_TYPE); + assert_eq!(name, "my.code"); + } + other => panic!("expected AlreadyExists, got {other:?}"), + } + } + + #[test] + fn invalid_argument_field_violation_projects_field_and_reason() { + let canonical = RgScope::invalid_argument() + .with_field_violation( + field::PARENT_TYPE_FIELD, + "bad parent", + field::INVALID_PARENT_TYPE, + ) + .create(); + match ResourceGroupError::from(canonical) { + ResourceGroupError::InvalidArgument { + field, + reason, + detail, + } => { + assert_eq!(field, field::PARENT_TYPE_FIELD); + assert_eq!(reason, field::INVALID_PARENT_TYPE); + assert_eq!(detail, "bad parent"); + } + other => panic!("expected InvalidArgument, got {other:?}"), + } + } + + #[test] + fn invalid_argument_format_variant_projects_empty_field() { + let canonical = RgScope::invalid_argument() + .with_format("group name must be 1-63 chars") + .create(); + match ResourceGroupError::from(canonical) { + ResourceGroupError::InvalidArgument { field, reason, .. } => { + assert!(field.is_empty()); + assert!(reason.is_empty()); + } + other => panic!("expected InvalidArgument, got {other:?}"), + } + } + + #[test] + fn invalid_argument_constraint_variant_projects_constraint_into_reason() { + let canonical = RgScope::invalid_argument() + .with_constraint("parent_depth_limit") + .create(); + match ResourceGroupError::from(canonical) { + ResourceGroupError::InvalidArgument { + field, + reason, + detail, + } => { + assert!(field.is_empty()); + assert_eq!(reason, "parent_depth_limit"); + assert!(!detail.is_empty()); + } + other => panic!("expected InvalidArgument, got {other:?}"), + } + } + + #[test] + fn failed_precondition_projects_typed_subject() { + let cases = [ + ( + precondition::ALLOWED_PARENTS_SUBJECT, + Subject::AllowedParents, + ), + (precondition::HIERARCHY_SUBJECT, Subject::Hierarchy), + ( + precondition::ACTIVE_REFERENCES_SUBJECT, + Subject::ActiveReferences, + ), + (precondition::LIMIT_SUBJECT, Subject::Limit), + (precondition::TENANT_SUBJECT, Subject::Tenant), + ]; + for (wire, expected) in cases { + let canonical = RgScope::failed_precondition() + .with_precondition_violation(wire, "guard message", precondition::STATE_TYPE) + .create(); + match ResourceGroupError::from(canonical) { + ResourceGroupError::FailedPrecondition { + subject, + type_, + detail, + } => { + assert_eq!(subject, expected); + assert_eq!(type_, precondition::STATE_TYPE); + assert_eq!(detail, "guard message"); + } + other => panic!("expected FailedPrecondition for {wire}, got {other:?}"), + } + } + } + + #[test] + fn aborted_projects_reason() { + let canonical = RgScope::aborted("write conflict") + .with_reason(reason::aborted::CONFLICT) + .create(); + match ResourceGroupError::from(canonical) { + ResourceGroupError::Aborted { reason, detail } => { + assert_eq!(reason, reason::aborted::CONFLICT); + assert_eq!(detail, "write conflict"); + } + other => panic!("expected Aborted, got {other:?}"), + } + } + + #[test] + fn permission_denied_projects_reason() { + let canonical = RgScope::permission_denied() + .with_reason(reason::permission::ACCESS_DENIED) + .create(); + match ResourceGroupError::from(canonical) { + ResourceGroupError::PermissionDenied { reason, .. } => { + assert_eq!(reason, reason::permission::ACCESS_DENIED); + } + other => panic!("expected PermissionDenied, got {other:?}"), + } + } + + #[test] + fn internal_projects() { + let canonical = CanonicalError::internal("boom").create(); + assert!(matches!( + ResourceGroupError::from(canonical), + ResourceGroupError::Internal { .. } + )); + } + + #[test] + fn unmodeled_category_falls_through_to_other() { + // Resource-group never emits Unauthenticated; it must land in + // Other with the canonical preserved for inspection. + let canonical = CanonicalError::unauthenticated() + .with_reason("SOME_REASON") + .create(); + match ResourceGroupError::from(canonical) { + ResourceGroupError::Other { + canonical: CanonicalError::Unauthenticated { .. }, + } => {} + other => panic!("expected Other::Unauthenticated, got {other:?}"), + } + } + + /// Reconstruct a `CanonicalError` from a `Problem` JSON after mutating + /// it, exercising the documented wire path the builder's type-state + /// cannot reach in-process. + fn canonical_from_mutated_problem( + canonical: CanonicalError, + mutate: impl FnOnce(&mut serde_json::Value), + ) -> CanonicalError { + let mut value = serde_json::to_value(Problem::from(canonical)).expect("Problem serializes"); + mutate(&mut value); + let problem: Problem = serde_json::from_value(value).expect("Problem deserializes"); + CanonicalError::try_from(problem).expect("reconstruct") + } + + #[test] + fn not_found_without_resource_type_falls_through_to_other() { + // A malformed / foreign NotFound envelope (no resource_type) must + // NOT project to NotFound { resource_type: "" } — it would silently + // mis-dispatch consumers matching on gts::GROUP_RESOURCE_TYPE. + let canonical = RgScope::not_found("missing").with_resource("7").create(); + let malformed = canonical_from_mutated_problem(canonical, |v| { + v["context"] + .as_object_mut() + .expect("context object") + .remove("resource_type"); + }); + match ResourceGroupError::from(malformed) { + ResourceGroupError::Other { .. } => {} + other => panic!("expected Other for resource_type-less NotFound, got {other:?}"), + } + } + + #[test] + fn already_exists_without_resource_type_falls_through_to_other() { + let canonical = RgScope::already_exists("dupe") + .with_resource("my.code") + .create(); + let malformed = canonical_from_mutated_problem(canonical, |v| { + v["context"] + .as_object_mut() + .expect("context object") + .remove("resource_type"); + }); + match ResourceGroupError::from(malformed) { + ResourceGroupError::Other { .. } => {} + other => panic!("expected Other for resource_type-less AlreadyExists, got {other:?}"), + } + } + + #[test] + fn failed_precondition_without_violations_falls_through_to_other() { + // An empty violation list is a malformed envelope; projecting it to + // FailedPrecondition with empty subject/type_ would masquerade as a + // real domain precondition. + let canonical = RgScope::failed_precondition() + .with_precondition_violation( + precondition::HIERARCHY_SUBJECT, + "would create a cycle", + precondition::STATE_TYPE, + ) + .create(); + let malformed = canonical_from_mutated_problem(canonical, |v| { + v["context"]["violations"] = serde_json::json!([]); + }); + match ResourceGroupError::from(malformed) { + ResourceGroupError::Other { .. } => {} + other => panic!("expected Other for violation-less FailedPrecondition, got {other:?}"), + } + } + + #[test] + fn projection_survives_full_problem_round_trip() { + // Out-of-process chain: canonical → Problem JSON → Problem → + // CanonicalError → ResourceGroupError. Pins that an HTTP consumer + // projecting from the wire gets the same typed variant as an + // in-process ClientHub caller — exercised on the cycle (hierarchy) + // dispatch the projection narrows. + let canonical = RgScope::failed_precondition() + .with_precondition_violation( + precondition::HIERARCHY_SUBJECT, + "would create a cycle", + precondition::STATE_TYPE, + ) + .create(); + + let bytes = serde_json::to_vec(&Problem::from(canonical)).expect("serialize"); + let restored: Problem = serde_json::from_slice(&bytes).expect("deserialize"); + let restored_canonical = CanonicalError::try_from(restored).expect("reconstruct"); + + match ResourceGroupError::from(restored_canonical) { + ResourceGroupError::FailedPrecondition { subject, type_, .. } => { + assert_eq!(subject, Subject::Hierarchy); + assert_eq!(type_, precondition::STATE_TYPE); + } + other => panic!("expected FailedPrecondition after round-trip, got {other:?}"), + } + } +} diff --git a/gears/system/resource-group/resource-group-sdk/src/field.rs b/gears/system/resource-group/resource-group-sdk/src/field.rs new file mode 100644 index 000000000..f693075eb --- /dev/null +++ b/gears/system/resource-group/resource-group-sdk/src/field.rs @@ -0,0 +1,38 @@ +//! Wire `field` / `reason` vocabulary for field violations under +//! [`CanonicalError::InvalidArgument`]. +//! +//! Resource-group emits exactly one field-violation `reason` +//! ([`INVALID_PARENT_TYPE`]) on the [`crate::ResourceGroupError::InvalidArgument`] +//! variant, attributed to the [`PARENT_TYPE_FIELD`] field. Because there +//! is a single reason, it stays a **plain constant** with no typed +//! sub-enum (ADR 0005: single-value reasons stay consts) — the +//! projection surfaces `reason` as a raw `String` consumers match against +//! this constant. The other `InvalidArgument` shape resource-group emits +//! (generic `Validation`, mapped via `with_format`) carries no field +//! attribution. +//! +//! The impl crate's single `From for CanonicalError` ladder +//! references the same constants at construction time so the SDK +//! vocabulary and the wire can never drift — the round-trip tests in +//! [`crate::error`] pin every constant to its `Problem` JSON path. +//! +//! [`CanonicalError::InvalidArgument`]: toolkit_canonical_errors::CanonicalError::InvalidArgument + +// --------------------------------------------------------------------------- +// `field_violations[].reason` code. +// --------------------------------------------------------------------------- + +/// The requested parent type is not permitted by the type's +/// `allowed_parent_types` configuration. +pub const INVALID_PARENT_TYPE: &str = "INVALID_PARENT_TYPE"; + +// --------------------------------------------------------------------------- +// `field_violations[].field` attribution key. +// +// Identifies *which* request field failed. Extracted to a const +// (ADR 0005 Rule 6) so the impl ladder and the SDK vocabulary cannot +// drift; pinned by the round-trip tests. +// --------------------------------------------------------------------------- + +/// `parent_type` reference field (carries [`INVALID_PARENT_TYPE`]). +pub const PARENT_TYPE_FIELD: &str = "parent_type"; diff --git a/gears/system/resource-group/resource-group-sdk/src/gts.rs b/gears/system/resource-group/resource-group-sdk/src/gts.rs index 5203a87ed..5fb53b5dd 100644 --- a/gears/system/resource-group/resource-group-sdk/src/gts.rs +++ b/gears/system/resource-group/resource-group-sdk/src/gts.rs @@ -51,6 +51,19 @@ pub struct ResourceGroupTypeV1 { pub allowed_membership_types: Vec, } +/// Canonical GTS resource type for a resource **group** as a resource. +/// +/// Lands in `CanonicalError::{NotFound,AlreadyExists}.ctx.resource_type` +/// for every group-attributable error and backs the impl crate's +/// `#[resource_error("…")]` REST marker. The macro literal there cannot +/// reference this const (proc-macros can't resolve consts), so the +/// round-trip tests in [`crate::error`] assert the two stay equal. +/// +/// Match it against the resource-scoped projection variants +/// ([`crate::ResourceGroupError::NotFound`] / +/// [`crate::ResourceGroupError::AlreadyExists`]). +pub const GROUP_RESOURCE_TYPE: &str = "gts.cf.core.resource_group.group.v1~"; + /// GTS type path for the tenant resource-group type. /// /// Any RG type whose code **starts with** this path is considered a tenant diff --git a/gears/system/resource-group/resource-group-sdk/src/lib.rs b/gears/system/resource-group/resource-group-sdk/src/lib.rs index 53270103d..7a83e346f 100644 --- a/gears/system/resource-group/resource-group-sdk/src/lib.rs +++ b/gears/system/resource-group/resource-group-sdk/src/lib.rs @@ -3,18 +3,27 @@ //! Resource Group SDK //! //! This crate provides the public API for the `resource-group` gear: -//! - `ResourceGroupClient` trait +//! - `ResourceGroupClient` / `ResourceGroupReadHierarchy` traits +//! (boundary returns [`toolkit_canonical_errors::CanonicalError`] per +//! [ADR 0005][adr]) //! - Model types for GTS types, groups, memberships -//! - Error type (`ResourceGroupError`) +//! - [`ResourceGroupError`] — opt-in `From` projection +//! (see [`error`]) plus its co-located wire vocabulary ([`field`], +//! [`precondition`], [`reason`], [`gts`]) //! - `OData` filter field definitions (behind `odata` feature) +//! +//! [adr]: https://github.com/constructorfabric/gears-rust/blob/main/docs/arch/errors/ADR/0005-cpt-cf-adr-sdk-canonical-projection.md #![forbid(unsafe_code)] #![deny(rust_2018_idioms)] pub mod api; pub mod error; +pub mod field; pub mod gts; pub mod models; +pub mod precondition; +pub mod reason; // OData filter field definitions (feature-gated) #[cfg(feature = "odata")] @@ -23,7 +32,7 @@ pub mod odata; // Re-export main types at crate root for convenience pub use api::{ResourceGroupClient, ResourceGroupReadHierarchy}; pub use error::ResourceGroupError; -pub use gts::TENANT_RG_TYPE_PATH; +pub use gts::{GROUP_RESOURCE_TYPE, TENANT_RG_TYPE_PATH}; pub use models::{ CreateGroupRequest, CreateTypeRequest, GroupHierarchy, GroupHierarchyWithDepth, GtsTypePath, ResourceGroup, ResourceGroupMembership, ResourceGroupType, ResourceGroupWithDepth, diff --git a/gears/system/resource-group/resource-group-sdk/src/precondition.rs b/gears/system/resource-group/resource-group-sdk/src/precondition.rs new file mode 100644 index 000000000..d6a47c0d3 --- /dev/null +++ b/gears/system/resource-group/resource-group-sdk/src/precondition.rs @@ -0,0 +1,152 @@ +//! Wire `subject` / `type` vocabulary for precondition violations under +//! [`CanonicalError::FailedPrecondition`]. +//! +//! Each `*_SUBJECT` constant is the stable `subject` discriminator that +//! lands in `CanonicalError::FailedPrecondition.ctx.violations[].subject` +//! — the field consumers dispatch on. Resource-group emits several +//! distinct precondition families that **all** share the +//! `FailedPrecondition` category and the single `STATE` wire `type` +//! ([`STATE_TYPE`]); the **subject** is therefore the discriminator, not +//! the type. Two subjects carry domain meaning consumers care about: +//! +//! * [`Subject::Hierarchy`] (`"hierarchy"`) ⇒ the write would create a +//! **cycle** in the group hierarchy. +//! * [`Subject::Limit`] (`"limit"`) ⇒ a configured **limit** (depth, +//! width, …) would be exceeded. +//! +//! The impl crate's single `From for CanonicalError` ladder +//! references these constants; the round-trip tests in [`crate::error`] +//! pin each to its `Problem` JSON path. +//! +//! [`CanonicalError::FailedPrecondition`]: toolkit_canonical_errors::CanonicalError::FailedPrecondition + +use core::fmt; + +// --------------------------------------------------------------------------- +// `violations[].subject` discriminators. +// --------------------------------------------------------------------------- + +/// Removing allowed parents / disabling root placement would break +/// existing group-hierarchy relationships. +pub const ALLOWED_PARENTS_SUBJECT: &str = "allowed_parents"; + +/// The write would introduce a cycle in the group hierarchy +/// (cycle-detected). +pub const HIERARCHY_SUBJECT: &str = "hierarchy"; + +/// A type still has groups of this type (active references) and cannot +/// be deleted. +pub const ACTIVE_REFERENCES_SUBJECT: &str = "active_references"; + +/// A configured limit (depth, width, …) would be exceeded +/// (limit-violation). +pub const LIMIT_SUBJECT: &str = "limit"; + +/// A cross-tenant link would be created — a resource may belong to +/// groups of a single tenant only. +pub const TENANT_SUBJECT: &str = "tenant"; + +// --------------------------------------------------------------------------- +// `violations[].type` token. +// +// Resource-group uses a single, uniform `type` across every precondition +// family — the dispatch discriminator is the subject above, not the type. +// Extracted to a const (ADR 0005 Rule 6) so the impl ladder and SDK +// vocabulary cannot drift; pinned by the round-trip tests. +// --------------------------------------------------------------------------- + +/// The uniform `violations[].type` token resource-group emits for every +/// precondition family. +pub const STATE_TYPE: &str = "STATE"; + +// --------------------------------------------------------------------------- +// Typed view of the `violations[].subject` discriminators. +// --------------------------------------------------------------------------- + +/// Typed view of the resource-group `FailedPrecondition` `subject` +/// strings declared above. +/// +/// Carried by [`crate::ResourceGroupError::FailedPrecondition::subject`]. +/// `from_wire` returns `Self` (not `Option`) with an [`Self::Unknown`] +/// catch-all because every `subject` resource-group emits under +/// `FailedPrecondition` is one of the modeled values — the catch-all only +/// fires for a future subject, keeping the projection forward-compatible. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Subject { + /// See [`ALLOWED_PARENTS_SUBJECT`]. + AllowedParents, + /// See [`HIERARCHY_SUBJECT`] — denotes a detected cycle. + Hierarchy, + /// See [`ACTIVE_REFERENCES_SUBJECT`]. + ActiveReferences, + /// See [`LIMIT_SUBJECT`] — denotes a configured-limit violation. + Limit, + /// See [`TENANT_SUBJECT`]. + Tenant, + /// Unmodeled / future subject — preserves the raw wire string. + Unknown(String), +} + +impl Subject { + /// Project a wire `violations[].subject` string into the typed + /// discriminator. Any unmodeled value is preserved in + /// [`Self::Unknown`]. + #[must_use] + pub fn from_wire(s: &str) -> Self { + match s { + ALLOWED_PARENTS_SUBJECT => Self::AllowedParents, + HIERARCHY_SUBJECT => Self::Hierarchy, + ACTIVE_REFERENCES_SUBJECT => Self::ActiveReferences, + LIMIT_SUBJECT => Self::Limit, + TENANT_SUBJECT => Self::Tenant, + other => Self::Unknown(other.to_owned()), + } + } + + /// Render the discriminator back to its wire `subject` string. + /// Inverse of [`Self::from_wire`] for the modeled variants. + #[must_use] + pub fn as_wire(&self) -> &str { + match self { + Self::AllowedParents => ALLOWED_PARENTS_SUBJECT, + Self::Hierarchy => HIERARCHY_SUBJECT, + Self::ActiveReferences => ACTIVE_REFERENCES_SUBJECT, + Self::Limit => LIMIT_SUBJECT, + Self::Tenant => TENANT_SUBJECT, + Self::Unknown(s) => s.as_str(), + } + } +} + +impl fmt::Display for Subject { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_wire()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn subject_round_trips_each_constant() { + for (wire, expected) in [ + (ALLOWED_PARENTS_SUBJECT, Subject::AllowedParents), + (HIERARCHY_SUBJECT, Subject::Hierarchy), + (ACTIVE_REFERENCES_SUBJECT, Subject::ActiveReferences), + (LIMIT_SUBJECT, Subject::Limit), + (TENANT_SUBJECT, Subject::Tenant), + ] { + assert_eq!(Subject::from_wire(wire), expected); + assert_eq!(expected.as_wire(), wire); + } + } + + #[test] + fn subject_preserves_unknown_wire_string() { + let raw = "future_subject"; + let s = Subject::from_wire(raw); + assert_eq!(s, Subject::Unknown(raw.to_owned())); + assert_eq!(s.as_wire(), raw); + } +} diff --git a/gears/system/resource-group/resource-group-sdk/src/reason.rs b/gears/system/resource-group/resource-group-sdk/src/reason.rs new file mode 100644 index 000000000..b945887e5 --- /dev/null +++ b/gears/system/resource-group/resource-group-sdk/src/reason.rs @@ -0,0 +1,37 @@ +//! Wire `reason` vocabulary for the single-valued resource-group +//! canonical categories — [`CanonicalError::Aborted`] and +//! [`CanonicalError::PermissionDenied`]. +//! +//! These categories each carry a `ctx.reason` discriminator, but +//! resource-group emits only a single value per category, so — unlike the +//! multi-valued [`crate::precondition`] family — they stay **plain +//! constants** with no typed sub-enum (ADR 0005: single-value reasons +//! stay consts). Consumers match the projection's `reason: String` field +//! against these constants. +//! +//! The impl crate's single `From for CanonicalError` ladder +//! references the same constants; the round-trip tests in +//! [`crate::error`] pin each to its `Problem` JSON path. +//! +//! [`CanonicalError::Aborted`]: toolkit_canonical_errors::CanonicalError::Aborted +//! [`CanonicalError::PermissionDenied`]: toolkit_canonical_errors::CanonicalError::PermissionDenied + +/// Wire `reason` value for [`CanonicalError::Aborted`] (HTTP 409). +/// +/// [`CanonicalError::Aborted`]: toolkit_canonical_errors::CanonicalError::Aborted +pub mod aborted { + /// Generic concurrency / state conflict not tied to a structural + /// resource id (e.g. a write lost a race). The single abort reason + /// resource-group emits. + pub const CONFLICT: &str = "CONFLICT"; +} + +/// Wire `reason` value for [`CanonicalError::PermissionDenied`] +/// (HTTP 403). +/// +/// [`CanonicalError::PermissionDenied`]: toolkit_canonical_errors::CanonicalError::PermissionDenied +pub mod permission { + /// The authorization policy (PDP) denied the operation. The single + /// denial reason resource-group emits. + pub const ACCESS_DENIED: &str = "ACCESS_DENIED"; +} diff --git a/gears/system/resource-group/resource-group/src/api/rest/error.rs b/gears/system/resource-group/resource-group/src/api/rest/error.rs index d972a54d5..4de5eba36 100644 --- a/gears/system/resource-group/resource-group/src/api/rest/error.rs +++ b/gears/system/resource-group/resource-group/src/api/rest/error.rs @@ -8,11 +8,16 @@ //! (`toolkit::api::canonical_error_middleware`) converts the `CanonicalError` //! to a wire `Problem` and fills `instance` / `trace_id` post-response. +use resource_group_sdk::{field, precondition, reason}; use toolkit_canonical_errors::{CanonicalError, resource_error}; use crate::domain::error::DomainError; /// Errors attributable to a resource group as a resource. +/// +/// The macro literal mirrors [`resource_group_sdk::gts::GROUP_RESOURCE_TYPE`] +/// (proc-macros cannot resolve a const); the SDK round-trip tests pin the +/// two equal. #[resource_error("gts.cf.core.resource_group.group.v1~")] pub struct RgError; @@ -59,37 +64,61 @@ impl From for CanonicalError { // @cpt-end:cpt-cf-resource-group-algo-sdk-foundation-map-domain-error:p1:inst-err-map-2c // @cpt-begin:cpt-cf-resource-group-algo-sdk-foundation-map-domain-error:p1:inst-err-map-2d DomainError::InvalidParentType { message } => RgError::invalid_argument() - .with_field_violation("parent_type", message, "INVALID_PARENT_TYPE") + .with_field_violation( + field::PARENT_TYPE_FIELD, + message, + field::INVALID_PARENT_TYPE, + ) .create(), // @cpt-end:cpt-cf-resource-group-algo-sdk-foundation-map-domain-error:p1:inst-err-map-2d // @cpt-begin:cpt-cf-resource-group-algo-sdk-foundation-map-domain-error:p1:inst-err-map-2e // ⚠ wire change accepted in the migration plan: 409 → 400. DomainError::AllowedParentTypesViolation { message } => RgError::failed_precondition() - .with_precondition_violation("allowed_parents", message, "STATE") + .with_precondition_violation( + precondition::ALLOWED_PARENTS_SUBJECT, + message, + precondition::STATE_TYPE, + ) .create(), // @cpt-end:cpt-cf-resource-group-algo-sdk-foundation-map-domain-error:p1:inst-err-map-2e // @cpt-begin:cpt-cf-resource-group-algo-sdk-foundation-map-domain-error:p1:inst-err-map-2f // ⚠ wire change accepted in the migration plan: 409 → 400. DomainError::CycleDetected { message } => RgError::failed_precondition() - .with_precondition_violation("hierarchy", message, "STATE") + .with_precondition_violation( + precondition::HIERARCHY_SUBJECT, + message, + precondition::STATE_TYPE, + ) .create(), // @cpt-end:cpt-cf-resource-group-algo-sdk-foundation-map-domain-error:p1:inst-err-map-2f // @cpt-begin:cpt-cf-resource-group-algo-sdk-foundation-map-domain-error:p1:inst-err-map-2g // ⚠ wire change accepted in the migration plan: 409 → 400. DomainError::ConflictActiveReferences { message } => RgError::failed_precondition() - .with_precondition_violation("active_references", message, "STATE") + .with_precondition_violation( + precondition::ACTIVE_REFERENCES_SUBJECT, + message, + precondition::STATE_TYPE, + ) .create(), // @cpt-end:cpt-cf-resource-group-algo-sdk-foundation-map-domain-error:p1:inst-err-map-2g // @cpt-begin:cpt-cf-resource-group-algo-sdk-foundation-map-domain-error:p1:inst-err-map-2h // ⚠ wire change accepted in the migration plan: 409 → 400. DomainError::LimitViolation { message } => RgError::failed_precondition() - .with_precondition_violation("limit", message, "STATE") + .with_precondition_violation( + precondition::LIMIT_SUBJECT, + message, + precondition::STATE_TYPE, + ) .create(), // @cpt-end:cpt-cf-resource-group-algo-sdk-foundation-map-domain-error:p1:inst-err-map-2h // @cpt-begin:cpt-cf-resource-group-algo-sdk-foundation-map-domain-error:p1:inst-err-map-2i // ⚠ wire change accepted in the migration plan: 409 → 400. DomainError::TenantIncompatibility { message } => RgError::failed_precondition() - .with_precondition_violation("tenant", message, "STATE") + .with_precondition_violation( + precondition::TENANT_SUBJECT, + message, + precondition::STATE_TYPE, + ) .create(), // @cpt-end:cpt-cf-resource-group-algo-sdk-foundation-map-domain-error:p1:inst-err-map-2i // Duplicate-on-create variants route through `already_exists` @@ -107,13 +136,13 @@ impl From for CanonicalError { .create(), // Generic conflict carries no structural resource id — route // through `aborted` with a stable reason discriminator. - DomainError::Conflict { message } => { - RgError::aborted(message).with_reason("CONFLICT").create() - } + DomainError::Conflict { message } => RgError::aborted(message) + .with_reason(reason::aborted::CONFLICT) + .create(), DomainError::AccessDenied { message } => { tracing::debug!(reason = %message, "resource-group access denied"); RgError::permission_denied() - .with_reason("ACCESS_DENIED") + .with_reason(reason::permission::ACCESS_DENIED) .create() } // @cpt-begin:cpt-cf-resource-group-algo-sdk-foundation-map-domain-error:p1:inst-err-map-2j diff --git a/gears/system/resource-group/resource-group/src/domain/error.rs b/gears/system/resource-group/resource-group/src/domain/error.rs index 955b262b0..26983e143 100644 --- a/gears/system/resource-group/resource-group/src/domain/error.rs +++ b/gears/system/resource-group/resource-group/src/domain/error.rs @@ -4,7 +4,6 @@ //! Domain error types for the resource-group gear. use authz_resolver_sdk::pep::EnforcerError; -use resource_group_sdk::ResourceGroupError; use thiserror::Error; /// Domain-specific errors for the resource-group gear. @@ -205,43 +204,12 @@ impl DomainError { } } -/// Convert domain errors to SDK errors for public API consumption. -impl From for ResourceGroupError { - fn from(e: DomainError) -> Self { - match e { - DomainError::TypeNotFound { code } => ResourceGroupError::not_found(code), - DomainError::TypeAlreadyExists { code } => { - ResourceGroupError::type_already_exists(code) - } - DomainError::Validation { message } => ResourceGroupError::validation(message), - DomainError::InvalidParentType { message } => { - ResourceGroupError::invalid_parent_type(message) - } - DomainError::CycleDetected { message } => ResourceGroupError::cycle_detected(message), - DomainError::LimitViolation { message } => ResourceGroupError::limit_violation(message), - DomainError::AllowedParentTypesViolation { message } => { - ResourceGroupError::allowed_parent_types_violation(message) - } - DomainError::ConflictActiveReferences { message } => { - ResourceGroupError::conflict_active_references(message) - } - DomainError::Conflict { message } - | DomainError::DuplicateMembership { message, .. } => { - ResourceGroupError::conflict(message) - } - DomainError::TenantRootAlreadyExists { detail, .. } => { - ResourceGroupError::conflict(detail) - } - DomainError::GroupNotFound { id } => ResourceGroupError::not_found(id.to_string()), - DomainError::MembershipNotFound { key } => ResourceGroupError::not_found(key), - DomainError::TenantIncompatibility { message } => { - ResourceGroupError::tenant_incompatibility(message) - } - DomainError::AccessDenied { .. } => ResourceGroupError::access_denied(), - DomainError::Database(_) | DomainError::InternalError => ResourceGroupError::internal(), - } - } -} +// The SDK-facing `From for ResourceGroupError` ladder was +// removed per ADR 0005: the SDK trait boundary is now `CanonicalError`, +// and `ResourceGroupError` is an opt-in `From` projection +// in the SDK crate. The single authoritative AIP-193 classification is +// the `From for CanonicalError` ladder in +// `crate::api::rest::error`. impl From for DomainError { fn from(e: sea_orm::DbErr) -> Self { diff --git a/gears/system/resource-group/resource-group/src/domain/read_service.rs b/gears/system/resource-group/resource-group/src/domain/read_service.rs index 24448feac..de676211f 100644 --- a/gears/system/resource-group/resource-group/src/domain/read_service.rs +++ b/gears/system/resource-group/resource-group/src/domain/read_service.rs @@ -17,8 +17,8 @@ use std::sync::Arc; use async_trait::async_trait; use resource_group_sdk::ResourceGroupReadHierarchy; -use resource_group_sdk::error::ResourceGroupError; use resource_group_sdk::models::{ResourceGroup, ResourceGroupMembership, ResourceGroupWithDepth}; +use toolkit_canonical_errors::CanonicalError; use toolkit_odata::{ODataQuery, Page}; use toolkit_security::SecurityContext; use uuid::Uuid; @@ -94,7 +94,7 @@ impl Result, ResourceGroupError> { + ) -> Result, CanonicalError> { // @cpt-begin:cpt-cf-resource-group-flow-integration-auth-plugin-routing:p1:inst-plugin-3a // @cpt-begin:cpt-cf-resource-group-flow-integration-auth-plugin-routing:p1:inst-plugin-5 // @cpt-begin:cpt-cf-resource-group-flow-integration-auth-plugin-read:p1:inst-plugin-read-2 @@ -109,7 +109,7 @@ impl Result, ResourceGroupError> { + ) -> Result, CanonicalError> { // Bypass AuthZ — use unscoped method (AccessScope::allow_all). // Tenant-resolver plugin needs full ancestor visibility regardless // of caller's tenant scope. Confirmed: TR plugins ignore SecurityContext @@ -131,14 +131,14 @@ impl Result, ResourceGroupError> { + ) -> Result, CanonicalError> { // Bypass AuthZ — same rationale as the hierarchy reads above. // Used by the tenant-resolver RG plugin's batch `get_tenants` path, // which queries `id in (…)` over tenant-typed groups regardless of @@ -146,35 +146,35 @@ impl Result { + ) -> Result { // Bypass AuthZ — an in-process PDP consumes this for scope-existence // checks while acting as the PDP, so it cannot re-enter the enforcer. // The consumer reads the group and compares `tenant_id` itself. self.group_service .get_group_unscoped(id) .await - .map_err(ResourceGroupError::from) + .map_err(CanonicalError::from) } async fn list_memberships( &self, _ctx: &SecurityContext, query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { // Bypass AuthZ — an in-process PDP resolves a subject's group // memberships while acting as the PDP; re-entering the enforcer would // recurse. The caller supplies the subject/tenant OData filter. self.membership_service .list_memberships_unscoped(query) .await - .map_err(ResourceGroupError::from) + .map_err(CanonicalError::from) } } // @cpt-end:cpt-cf-resource-group-dod-integration-auth-read-service:p1:inst-full diff --git a/gears/system/resource-group/resource-group/src/domain/rg_service.rs b/gears/system/resource-group/resource-group/src/domain/rg_service.rs index 2777f075e..1097d26bd 100644 --- a/gears/system/resource-group/resource-group/src/domain/rg_service.rs +++ b/gears/system/resource-group/resource-group/src/domain/rg_service.rs @@ -9,11 +9,11 @@ use std::sync::Arc; use async_trait::async_trait; use resource_group_sdk::ResourceGroupClient; -use resource_group_sdk::error::ResourceGroupError; use resource_group_sdk::models::{ CreateGroupRequest, CreateTypeRequest, ResourceGroup, ResourceGroupMembership, ResourceGroupType, ResourceGroupWithDepth, UpdateGroupRequest, UpdateTypeRequest, }; +use toolkit_canonical_errors::CanonicalError; use toolkit_odata::{ODataQuery, Page}; use toolkit_security::SecurityContext; use uuid::Uuid; @@ -64,33 +64,33 @@ impl Result { + ) -> Result { self.type_service .create_type(request) .await - .map_err(ResourceGroupError::from) + .map_err(CanonicalError::from) } async fn get_type( &self, _ctx: &SecurityContext, code: &str, - ) -> Result { + ) -> Result { self.type_service .get_type(code) .await - .map_err(ResourceGroupError::from) + .map_err(CanonicalError::from) } async fn list_types( &self, _ctx: &SecurityContext, query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { self.type_service .list_types(query) .await - .map_err(ResourceGroupError::from) + .map_err(CanonicalError::from) } async fn update_type( @@ -98,22 +98,18 @@ impl Result { + ) -> Result { self.type_service .update_type(code, request) .await - .map_err(ResourceGroupError::from) + .map_err(CanonicalError::from) } - async fn delete_type( - &self, - _ctx: &SecurityContext, - code: &str, - ) -> Result<(), ResourceGroupError> { + async fn delete_type(&self, _ctx: &SecurityContext, code: &str) -> Result<(), CanonicalError> { self.type_service .delete_type(code) .await - .map_err(ResourceGroupError::from) + .map_err(CanonicalError::from) } // -- Group lifecycle -- @@ -122,34 +118,34 @@ impl Result { + ) -> Result { let tenant_id = ctx.subject_tenant_id(); self.group_service .create_group(ctx, request, tenant_id) .await - .map_err(ResourceGroupError::from) + .map_err(CanonicalError::from) } async fn get_group( &self, ctx: &SecurityContext, id: Uuid, - ) -> Result { + ) -> Result { self.group_service .get_group(ctx, id) .await - .map_err(ResourceGroupError::from) + .map_err(CanonicalError::from) } async fn list_groups( &self, ctx: &SecurityContext, query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { self.group_service .list_groups(ctx, query) .await - .map_err(ResourceGroupError::from) + .map_err(CanonicalError::from) } async fn update_group( @@ -157,31 +153,27 @@ impl Result { + ) -> Result { self.group_service .update_group(ctx, id, request) .await - .map_err(ResourceGroupError::from) + .map_err(CanonicalError::from) } - async fn delete_group( - &self, - ctx: &SecurityContext, - id: Uuid, - ) -> Result<(), ResourceGroupError> { + async fn delete_group(&self, ctx: &SecurityContext, id: Uuid) -> Result<(), CanonicalError> { // Non-cascade variant: surface `ConflictActiveReferences` to the // caller; cascade goes through `delete_group_cascade` below. self.group_service .delete_group(ctx, id, false) .await - .map_err(ResourceGroupError::from) + .map_err(CanonicalError::from) } async fn delete_group_cascade( &self, ctx: &SecurityContext, id: Uuid, - ) -> Result<(), ResourceGroupError> { + ) -> Result<(), CanonicalError> { // Cascade variant: forwards to `delete_group_inner` with // `force=true`, which atomically removes the entire subtree, // membership rows, and closure rows under a SERIALIZABLE @@ -189,7 +181,7 @@ impl Result, ResourceGroupError> { + ) -> Result, CanonicalError> { self.group_service .get_group_descendants(ctx, group_id, query) .await - .map_err(ResourceGroupError::from) + .map_err(CanonicalError::from) } async fn get_group_ancestors( @@ -209,11 +201,11 @@ impl Result, ResourceGroupError> { + ) -> Result, CanonicalError> { self.group_service .get_group_ancestors(ctx, group_id, query) .await - .map_err(ResourceGroupError::from) + .map_err(CanonicalError::from) } // -- Membership lifecycle -- @@ -224,11 +216,11 @@ impl Result { + ) -> Result { self.membership_service .add_membership(ctx, group_id, resource_type, resource_id) .await - .map_err(ResourceGroupError::from) + .map_err(CanonicalError::from) } async fn remove_membership( @@ -237,21 +229,21 @@ impl Result<(), ResourceGroupError> { + ) -> Result<(), CanonicalError> { self.membership_service .remove_membership(ctx, group_id, resource_type, resource_id) .await - .map_err(ResourceGroupError::from) + .map_err(CanonicalError::from) } async fn list_memberships( &self, ctx: &SecurityContext, query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { self.membership_service .list_memberships(ctx, query) .await - .map_err(ResourceGroupError::from) + .map_err(CanonicalError::from) } } diff --git a/gears/system/resource-group/resource-group/tests/domain_unit_test.rs b/gears/system/resource-group/resource-group/tests/domain_unit_test.rs index 3b1d5ba93..0667dbaca 100644 --- a/gears/system/resource-group/resource-group/tests/domain_unit_test.rs +++ b/gears/system/resource-group/resource-group/tests/domain_unit_test.rs @@ -575,64 +575,110 @@ fn is_serialization_failure_false_for_non_db_errors() { assert!(!err.is_serialization_failure()); } -// ── DomainError -> ResourceGroupError mapping ─────────────────────────── +// ── DomainError -> CanonicalError -> ResourceGroupError projection ────── +// +// The SDK trait boundary is `CanonicalError` (ADR 0005); `ResourceGroupError` +// is the opt-in `From` projection. These tests exercise the +// full domain → canonical → projection path a ClientHub consumer sees. + +/// Project a domain error the way a consumer would: through the single +/// canonical ladder, then into the typed SDK view. +fn project(err: DomainError) -> resource_group_sdk::ResourceGroupError { + resource_group_sdk::ResourceGroupError::from(CanonicalError::from(err)) +} #[test] fn domain_to_sdk_type_not_found() { use resource_group_sdk::ResourceGroupError; - let domain = DomainError::type_not_found("code"); - let sdk: ResourceGroupError = domain.into(); - assert!(sdk.to_string().contains("code")); + match project(DomainError::type_not_found("code")) { + ResourceGroupError::NotFound { name, .. } => assert_eq!(name, "code"), + other => panic!("expected NotFound, got {other:?}"), + } } #[test] fn domain_to_sdk_type_already_exists() { use resource_group_sdk::ResourceGroupError; - let domain = DomainError::type_already_exists("code"); - let sdk: ResourceGroupError = domain.into(); - assert!(sdk.to_string().contains("code")); + match project(DomainError::type_already_exists("code")) { + ResourceGroupError::AlreadyExists { name, .. } => assert_eq!(name, "code"), + other => panic!("expected AlreadyExists, got {other:?}"), + } } #[test] fn domain_to_sdk_validation() { use resource_group_sdk::ResourceGroupError; - let domain = DomainError::validation("msg"); - let sdk: ResourceGroupError = domain.into(); - assert!(sdk.to_string().contains("msg") || !sdk.to_string().is_empty()); + // Generic validation maps to a `Format` InvalidArgument — no field + // attribution, message preserved in `detail`. + match project(DomainError::validation("msg")) { + ResourceGroupError::InvalidArgument { detail, .. } => assert!(detail.contains("msg")), + other => panic!("expected InvalidArgument, got {other:?}"), + } +} + +#[test] +fn domain_to_sdk_invalid_parent_type() { + use resource_group_sdk::{ResourceGroupError, field}; + match project(DomainError::invalid_parent_type("bad parent")) { + ResourceGroupError::InvalidArgument { field, reason, .. } => { + assert_eq!(field, field::PARENT_TYPE_FIELD); + assert_eq!(reason, field::INVALID_PARENT_TYPE); + } + other => panic!("expected InvalidArgument, got {other:?}"), + } } #[test] fn domain_to_sdk_group_not_found() { use resource_group_sdk::ResourceGroupError; let id = uuid::Uuid::now_v7(); - let domain = DomainError::group_not_found(id); - let sdk: ResourceGroupError = domain.into(); - assert!(sdk.to_string().contains(&id.to_string())); + match project(DomainError::group_not_found(id)) { + ResourceGroupError::NotFound { name, .. } => assert_eq!(name, id.to_string()), + other => panic!("expected NotFound, got {other:?}"), + } } #[test] fn domain_to_sdk_cycle_detected() { - use resource_group_sdk::ResourceGroupError; - let domain = DomainError::cycle_detected("cycle"); - let sdk: ResourceGroupError = domain.into(); - assert!(!sdk.to_string().is_empty()); + use resource_group_sdk::{ResourceGroupError, precondition::Subject}; + // Cycle flattens to FailedPrecondition; the `hierarchy` subject is the + // discriminator. + match project(DomainError::cycle_detected("cycle")) { + ResourceGroupError::FailedPrecondition { subject, .. } => { + assert_eq!(subject, Subject::Hierarchy); + } + other => panic!("expected FailedPrecondition, got {other:?}"), + } +} + +#[test] +fn domain_to_sdk_limit_violation() { + use resource_group_sdk::{ResourceGroupError, precondition::Subject}; + match project(DomainError::limit_violation("limit")) { + ResourceGroupError::FailedPrecondition { subject, .. } => { + assert_eq!(subject, Subject::Limit); + } + other => panic!("expected FailedPrecondition, got {other:?}"), + } } #[test] fn domain_to_sdk_database_maps_to_internal() { use resource_group_sdk::ResourceGroupError; - let domain = DomainError::database("db error"); - let sdk: ResourceGroupError = domain.into(); - // Database errors map to internal (no sensitive info leaked) - assert!(sdk.to_string().to_lowercase().contains("internal")); + // Database errors map to internal (no sensitive info leaked). + assert!(matches!( + project(DomainError::database("db error")), + ResourceGroupError::Internal { .. } + )); } #[test] fn domain_to_sdk_membership_not_found() { use resource_group_sdk::ResourceGroupError; - let domain = DomainError::membership_not_found("key"); - let sdk: ResourceGroupError = domain.into(); - assert!(sdk.to_string().contains("key")); + match project(DomainError::membership_not_found("key")) { + ResourceGroupError::NotFound { name, .. } => assert_eq!(name, "key"), + other => panic!("expected NotFound, got {other:?}"), + } } #[test] @@ -646,13 +692,17 @@ fn domain_to_problem_membership_not_found_is_404() { } #[test] -fn domain_to_sdk_access_denied_maps_to_internal() { - use resource_group_sdk::ResourceGroupError; +fn domain_to_sdk_access_denied_maps_to_permission_denied() { + use resource_group_sdk::{ResourceGroupError, reason}; let domain = DomainError::AccessDenied { message: "denied".to_owned(), }; - let sdk: ResourceGroupError = domain.into(); - assert!(sdk.to_string().to_lowercase().contains("denied")); + match project(domain) { + ResourceGroupError::PermissionDenied { reason, .. } => { + assert_eq!(reason, reason::permission::ACCESS_DENIED); + } + other => panic!("expected PermissionDenied, got {other:?}"), + } } // ── DomainError -> Problem (RFC 9457) mapping ─────────────────────────── diff --git a/gears/system/tenant-resolver/plugins/rg-tr-plugin/Cargo.toml b/gears/system/tenant-resolver/plugins/rg-tr-plugin/Cargo.toml index 4844d6469..9305b7ce4 100644 --- a/gears/system/tenant-resolver/plugins/rg-tr-plugin/Cargo.toml +++ b/gears/system/tenant-resolver/plugins/rg-tr-plugin/Cargo.toml @@ -51,3 +51,7 @@ inventory = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["rt", "macros"] } +# Test fakes synthesize the `CanonicalError` envelope the RG trait now +# returns (ADR 0005). Production code only projects it via the SDK's +# `ResourceGroupError::from`, so this is dev-only. +toolkit-canonical-errors = { workspace = true } diff --git a/gears/system/tenant-resolver/plugins/rg-tr-plugin/src/domain/service.rs b/gears/system/tenant-resolver/plugins/rg-tr-plugin/src/domain/service.rs index c42e89580..5693a5039 100644 --- a/gears/system/tenant-resolver/plugins/rg-tr-plugin/src/domain/service.rs +++ b/gears/system/tenant-resolver/plugins/rg-tr-plugin/src/domain/service.rs @@ -297,7 +297,13 @@ impl Service { self.rg.get_group_descendants(ctx, group_id, &query).await } } - .map_err(|e| match e { + // Project the raw canonical error through `ResourceGroupError` + // so we can match on semantic variants at the error boundary: + // a missing group (`NotFound`) becomes `TenantNotFound`, while + // every other variant collapses to `Internal`. We match on the + // projected `ResourceGroupError` rather than the raw error + // because the projection preserves the semantic classification. + .map_err(|e| match ResourceGroupError::from(e) { ResourceGroupError::NotFound { .. } => TenantResolverError::TenantNotFound { tenant_id: TenantId(group_id), }, diff --git a/gears/system/tenant-resolver/plugins/rg-tr-plugin/src/domain/service_tests.rs b/gears/system/tenant-resolver/plugins/rg-tr-plugin/src/domain/service_tests.rs index b3101ed29..5bb3af893 100644 --- a/gears/system/tenant-resolver/plugins/rg-tr-plugin/src/domain/service_tests.rs +++ b/gears/system/tenant-resolver/plugins/rg-tr-plugin/src/domain/service_tests.rs @@ -6,7 +6,6 @@ use std::sync::Arc; use async_trait::async_trait; use resource_group_sdk::TENANT_RG_TYPE_PATH; use resource_group_sdk::api::ResourceGroupReadHierarchy; -use resource_group_sdk::error::ResourceGroupError; use resource_group_sdk::models::{ GroupHierarchy, GroupHierarchyWithDepth, ResourceGroup, ResourceGroupMembership, ResourceGroupWithDepth, @@ -15,6 +14,7 @@ use tenant_resolver_sdk::{ BarrierMode, GetAncestorsOptions, GetDescendantsOptions, GetTenantsOptions, IsAncestorOptions, TenantId, TenantResolverError, TenantResolverPluginClient, TenantStatus, }; +use toolkit_canonical_errors::CanonicalError; use toolkit_odata::ast::{CompareOperator, Expr, Value}; use toolkit_odata::{ODataQuery, Page, PageInfo}; use toolkit_security::SecurityContext; @@ -76,7 +76,7 @@ impl ResourceGroupReadHierarchy for MockRgHierarchy { _ctx: &SecurityContext, group_id: Uuid, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { // Precedence: if per-id dispatch is configured, honor it strictly — // unknown ids return an empty page (simulates "group not found"). // Otherwise fall back to the flat list. Real RG returns the subtree @@ -104,7 +104,7 @@ impl ResourceGroupReadHierarchy for MockRgHierarchy { _ctx: &SecurityContext, group_id: Uuid, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { // See `get_group_descendants` for precedence rules. let items = if self.ancestors_by_id.is_empty() { self.ancestors.clone() @@ -128,7 +128,7 @@ impl ResourceGroupReadHierarchy for MockRgHierarchy { &self, _ctx: &SecurityContext, query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { // Flatten every `ResourceGroupWithDepth` known to the mock (ancestors // + descendants + per-id dispatches) into `ResourceGroup` entries, // deduplicated by id, then apply the `OData $filter` predicate so @@ -180,7 +180,7 @@ impl ResourceGroupReadHierarchy for MockRgHierarchy { &self, _ctx: &SecurityContext, _id: Uuid, - ) -> Result { + ) -> Result { unimplemented!("MockRgHierarchy models hierarchy reads only") } @@ -188,7 +188,7 @@ impl ResourceGroupReadHierarchy for MockRgHierarchy { &self, _ctx: &SecurityContext, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { + ) -> Result, CanonicalError> { unimplemented!("MockRgHierarchy models hierarchy reads only") } } @@ -774,8 +774,8 @@ async fn rg_error_propagates() { _ctx: &SecurityContext, _group_id: Uuid, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { - Err(ResourceGroupError::internal()) + ) -> Result, CanonicalError> { + Err(CanonicalError::internal("rg backend error").create()) } async fn get_group_ancestors( @@ -783,32 +783,32 @@ async fn rg_error_propagates() { _ctx: &SecurityContext, _group_id: Uuid, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { - Err(ResourceGroupError::internal()) + ) -> Result, CanonicalError> { + Err(CanonicalError::internal("rg backend error").create()) } async fn list_groups( &self, _ctx: &SecurityContext, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { - Err(ResourceGroupError::internal()) + ) -> Result, CanonicalError> { + Err(CanonicalError::internal("rg backend error").create()) } async fn get_group( &self, _ctx: &SecurityContext, _id: Uuid, - ) -> Result { - Err(ResourceGroupError::internal()) + ) -> Result { + Err(CanonicalError::internal("rg backend error").create()) } async fn list_memberships( &self, _ctx: &SecurityContext, _query: &ODataQuery, - ) -> Result, ResourceGroupError> { - Err(ResourceGroupError::internal()) + ) -> Result, CanonicalError> { + Err(CanonicalError::internal("rg backend error").create()) } } From b03eb70d830625ef6d00e589fc1cbba9c1313a06 Mon Sep 17 00:00:00 2001 From: Diffora Date: Sat, 13 Jun 2026 13:48:28 +0200 Subject: [PATCH 4/5] oidc-authn-plugin: per-issuer typ/aud/clock-skew-leeway validation Add optional per-issuer expected_audience, jose_typ, and clock_skew_leeway_secs overrides on TrustedIssuerInput; enforce them in the JWT validator (typ checked case-insensitively, per-issuer audience replaces the global list when set, per-issuer leeway overrides global). Issuers without overrides keep existing behavior. Signed-off-by: Diffora --- .../benches/jwt_validation.rs | 3 + .../oidc-authn-plugin/benches/s2s_exchange.rs | 3 + .../plugins/oidc-authn-plugin/src/config.rs | 345 ++++++++++++++++- .../oidc-authn-plugin/src/domain/error.rs | 7 +- .../src/domain/error_tests.rs | 10 + .../oidc-authn-plugin/src/domain/metrics.rs | 4 +- .../src/domain/metrics/definitions.rs | 2 + .../oidc-authn-plugin/src/domain/validator.rs | 100 +++-- .../src/domain/validator_tests.rs | 362 +++++++++++++++++- .../src/infra/token_client_tests.rs | 3 + .../src/test_support/test_fixtures.rs | 25 ++ .../oidc-authn-plugin/tests/common/mod.rs | 3 + 12 files changed, 830 insertions(+), 37 deletions(-) diff --git a/gears/system/authn-resolver/plugins/oidc-authn-plugin/benches/jwt_validation.rs b/gears/system/authn-resolver/plugins/oidc-authn-plugin/benches/jwt_validation.rs index 17c536944..b1012eeaa 100644 --- a/gears/system/authn-resolver/plugins/oidc-authn-plugin/benches/jwt_validation.rs +++ b/gears/system/authn-resolver/plugins/oidc-authn-plugin/benches/jwt_validation.rs @@ -44,6 +44,9 @@ fn exact_issuer_trust(issuer: String) -> anyhow::Result { IssuerTrustConfig::from_inputs_allowing_insecure_http_for_tests([TrustedIssuerInput { entry: TrustedIssuerEntry::Issuer(issuer), discovery_url: None, + expected_audience: Vec::new(), + jose_typ: None, + clock_skew_leeway_secs: None, }]) .map_err(anyhow::Error::msg) } diff --git a/gears/system/authn-resolver/plugins/oidc-authn-plugin/benches/s2s_exchange.rs b/gears/system/authn-resolver/plugins/oidc-authn-plugin/benches/s2s_exchange.rs index bdccc1c97..cf8ec84a7 100644 --- a/gears/system/authn-resolver/plugins/oidc-authn-plugin/benches/s2s_exchange.rs +++ b/gears/system/authn-resolver/plugins/oidc-authn-plugin/benches/s2s_exchange.rs @@ -132,6 +132,9 @@ fn exact_issuer_trust(issuer: String) -> anyhow::Result { IssuerTrustConfig::from_inputs_allowing_insecure_http_for_tests([TrustedIssuerInput { entry: TrustedIssuerEntry::Issuer(issuer), discovery_url: None, + expected_audience: Vec::new(), + jose_typ: None, + clock_skew_leeway_secs: None, }]) .map_err(anyhow::Error::msg) } diff --git a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/config.rs b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/config.rs index 137c2466c..4e23a07eb 100644 --- a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/config.rs +++ b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/config.rs @@ -305,6 +305,13 @@ struct IssuerRuleCompiled { matcher: MatcherCompiled, discovery_url: Option, url_policy: UrlSecurityPolicy, + /// Per-issuer compiled audience matchers (empty ⇒ fall back to global). + expected_audience: Vec, + /// Required JOSE `typ` header, normalized to lowercase for case-insensitive + /// comparison (`None` ⇒ `typ` not checked). + jose_typ: Option, + /// Per-issuer clock-skew leeway in seconds (`None` ⇒ use global leeway). + clock_skew_leeway_secs: Option, } #[derive(Debug, Clone)] @@ -364,11 +371,45 @@ impl MatcherCompiled { } } +/// Resolved per-issuer validation context for the first matching trusted-issuer +/// rule. +/// +/// Carries the discovery base used to fetch JWKS plus the per-issuer validation +/// overrides (audience, JOSE `typ`, clock-skew leeway). Empty/`None` overrides +/// signal that the validator should fall back to the global configuration. +#[derive(Debug, Clone)] +pub struct ResolvedIssuer { + /// Discovery base URL used to fetch this issuer's JWKS. + pub discovery_base: Url, + /// Per-issuer audience matchers (empty ⇒ use global `expected_audience`). + pub expected_audience: Vec, + /// Required JOSE `typ` header, lowercased (`None` ⇒ `typ` not checked). + pub jose_typ: Option, + /// Per-issuer clock-skew leeway in seconds (`None` ⇒ use global leeway). + pub clock_skew_leeway_secs: Option, +} + +/// Outcome of evaluating one trusted-issuer rule against a token `iss`. +/// +/// A matched rule is **terminal** (first match wins): the caller must not fall +/// through to later rules when a rule matched but its discovery URL is invalid. +enum IssuerMatch { + /// The rule's matcher did not match `iss`. + NoMatch, + /// The matcher matched, but the discovery URL failed to resolve/validate. + MatchedInvalid, + /// The matcher matched and produced a usable per-issuer context. + Matched(ResolvedIssuer), +} + impl IssuerRuleCompiled { fn from_input( TrustedIssuerInput { entry, discovery_url, + expected_audience, + jose_typ, + clock_skew_leeway_secs, }: TrustedIssuerInput, index: usize, url_policy: UrlSecurityPolicy, @@ -430,19 +471,53 @@ impl IssuerRuleCompiled { }) .transpose()?; + let expected_audience = expected_audience + .iter() + .enumerate() + .map(|(audience_index, pattern)| { + MatcherCompiled::from_wildcard_pattern(pattern, audience_index) + }) + .collect::>>() + .map_err(|e| { + anyhow!( + "invalid `expected_audience` in `trusted_issuers` entry at index {index}: {e}" + ) + })?; + + let jose_typ = jose_typ + .map(|value| { + let value = value.trim(); + if value.is_empty() { + return Err(anyhow!( + "`trusted_issuers` entry at index {index} has an empty `jose_typ`" + )); + } + Ok(value.to_ascii_lowercase()) + }) + .transpose()?; + + if let Some(leeway) = clock_skew_leeway_secs + && leeway > 300 + { + return Err(anyhow!( + "`trusted_issuers` entry at index {index} `clock_skew_leeway_secs` must be <= 300" + )); + } + Ok(Self { matcher, discovery_url, url_policy, + expected_audience, + jose_typ, + clock_skew_leeway_secs, }) } - /// Resolve the discovery URL for `issuer` when this rule matches. - fn resolve(&self, issuer: &str) -> Option { - if !self.matcher.is_match(issuer) { - return None; - } - + /// Resolve the discovery base URL for an already-matched rule. Returns + /// `None` when the configured override (or the issuer itself) fails URL + /// validation. + fn resolve_discovery_base(&self, issuer: &str) -> Option { match &self.discovery_url { Some(DiscoveryUrlOverride::Static(url)) => Some(url.clone()), Some(DiscoveryUrlOverride::Template(value)) => { @@ -457,6 +532,24 @@ impl IssuerRuleCompiled { .ok(), } } + + /// Evaluate this rule against `issuer`. A matched rule is terminal: when its + /// discovery URL fails validation the result is [`IssuerMatch::MatchedInvalid`] + /// so the caller stops instead of falling through to later rules. + fn resolve(&self, issuer: &str) -> IssuerMatch { + if !self.matcher.is_match(issuer) { + return IssuerMatch::NoMatch; + } + match self.resolve_discovery_base(issuer) { + Some(discovery_base) => IssuerMatch::Matched(ResolvedIssuer { + discovery_base, + expected_audience: self.expected_audience.clone(), + jose_typ: self.jose_typ.clone(), + clock_skew_leeway_secs: self.clock_skew_leeway_secs, + }), + None => IssuerMatch::MatchedInvalid, + } + } } #[derive(Clone)] @@ -529,15 +622,37 @@ impl IssuerTrustConfig { Self::from_inputs(issuers.into_iter().map(|issuer| TrustedIssuerInput { entry: TrustedIssuerEntry::Issuer(issuer), discovery_url: None, + expected_audience: Vec::new(), + jose_typ: None, + clock_skew_leeway_secs: None, })) } + /// Resolve the per-issuer validation context for the first trusted-issuer + /// rule matching `issuer`. + /// + /// Returns the discovery base plus any per-issuer overrides (audience, JOSE + /// `typ`, clock-skew leeway). + #[must_use] + pub fn resolve_issuer(&self, issuer: &str) -> Option { + // First match wins and is terminal: a rule that matches `issuer` but whose + // discovery URL is invalid fails closed (returns `None`) rather than + // falling through to a later — possibly more permissive — rule. + for rule in std::iter::once(&self.rule).chain(&self.remaining_rules) { + match rule.resolve(issuer) { + IssuerMatch::NoMatch => {} + IssuerMatch::MatchedInvalid => return None, + IssuerMatch::Matched(resolved) => return Some(resolved), + } + } + None + } + /// Resolve the discovery URL for the first trusted-issuer rule matching `issuer`. #[must_use] pub fn resolve(&self, issuer: &str) -> Option { - std::iter::once(&self.rule) - .chain(&self.remaining_rules) - .find_map(|rule| rule.resolve(issuer)) + self.resolve_issuer(issuer) + .map(|resolved| resolved.discovery_base) } fn rule_count(&self) -> usize { @@ -547,7 +662,7 @@ impl IssuerTrustConfig { /// Returns `true` if the given issuer string is trusted. #[must_use] pub fn is_trusted(&self, issuer: &str) -> bool { - self.resolve(issuer).is_some() + self.resolve_issuer(issuer).is_some() } } @@ -724,6 +839,26 @@ pub struct TrustedIssuerInput { /// When provided, `"{issuer}"` placeholder is replaced with the matched issuer. #[serde(default)] pub discovery_url: Option, + /// Per-issuer audience override applied instead of the global + /// `jwt.expected_audience` when non-empty. + /// + /// When empty, the global `jwt.expected_audience` behavior is used. + /// Supports `*` as a substring wildcard (same syntax as the global list). + #[serde(default)] + pub expected_audience: Vec, + /// Required JOSE `typ` header value for tokens from this issuer. + /// + /// Matched case-insensitively (e.g. `"obo+jwt"`). When `None`, the `typ` + /// header is not inspected, preserving default behavior for issuers that do + /// not pin a token type. + #[serde(default)] + pub jose_typ: Option, + /// Per-issuer clock-skew leeway in seconds applied instead of the global + /// `jwt.clock_skew_leeway` when present. + /// + /// When `None`, the global leeway is used. + #[serde(default)] + pub clock_skew_leeway_secs: Option, } /// Issuer matcher kind for a trusted-issuer entry. @@ -1033,6 +1168,9 @@ mod issuer_trust_config_tests { TrustedIssuerInput { entry: TrustedIssuerEntry::Issuer(issuer.to_owned()), discovery_url: None, + expected_audience: Vec::new(), + jose_typ: None, + clock_skew_leeway_secs: None, } } @@ -1040,6 +1178,9 @@ mod issuer_trust_config_tests { TrustedIssuerInput { entry: TrustedIssuerEntry::IssuerPattern(pattern.to_owned()), discovery_url: None, + expected_audience: Vec::new(), + jose_typ: None, + clock_skew_leeway_secs: None, } } @@ -1123,6 +1264,9 @@ mod issuer_trust_config_tests { r"https://idp\.example\.com/realms/[^/]+".to_owned(), ), discovery_url: Some("{issuer}".to_owned()), + expected_audience: Vec::new(), + jose_typ: None, + clock_skew_leeway_secs: None, }]) .unwrap(); @@ -1140,6 +1284,9 @@ mod issuer_trust_config_tests { let trust = IssuerTrustConfig::from_inputs(vec![TrustedIssuerInput { entry: TrustedIssuerEntry::Issuer("https://issuer.example.com".to_owned()), discovery_url: Some(" https://discovery.example.com ".to_owned()), + expected_audience: Vec::new(), + jose_typ: None, + clock_skew_leeway_secs: None, }]) .unwrap(); @@ -1157,6 +1304,9 @@ mod issuer_trust_config_tests { let error = IssuerTrustConfig::from_inputs(vec![TrustedIssuerInput { entry: TrustedIssuerEntry::Issuer("https://issuer.example.com".to_owned()), discovery_url: Some(" ".to_owned()), + expected_audience: Vec::new(), + jose_typ: None, + clock_skew_leeway_secs: None, }]) .expect_err("blank discovery URL override should fail"); @@ -1173,6 +1323,9 @@ mod issuer_trust_config_tests { let error = IssuerTrustConfig::from_inputs(vec![TrustedIssuerInput { entry: TrustedIssuerEntry::Issuer("http://issuer.example.com".to_owned()), discovery_url: None, + expected_audience: Vec::new(), + jose_typ: None, + clock_skew_leeway_secs: None, }]) .expect_err("HTTP issuer should fail under the default URL policy"); @@ -1182,12 +1335,137 @@ mod issuer_trust_config_tests { ); } + #[test] + fn resolve_issuer_carries_per_issuer_overrides() { + let trust = IssuerTrustConfig::from_inputs(vec![TrustedIssuerInput { + entry: TrustedIssuerEntry::Issuer("https://issuer.example.com".to_owned()), + discovery_url: None, + expected_audience: vec!["public-api".to_owned(), "https://*.api".to_owned()], + jose_typ: Some("OBO+JWT".to_owned()), + clock_skew_leeway_secs: Some(30), + }]) + .unwrap(); + + let resolved = trust + .resolve_issuer("https://issuer.example.com") + .expect("issuer should resolve"); + + assert_eq!(resolved.clock_skew_leeway_secs, Some(30)); + // jose_typ is normalized to lowercase for case-insensitive comparison. + assert_eq!(resolved.jose_typ.as_deref(), Some("obo+jwt")); + assert_eq!(resolved.expected_audience.len(), 2); + assert!(resolved.expected_audience[0].is_match("public-api")); + assert!(resolved.expected_audience[1].is_match("https://tenant.api")); + } + + #[test] + fn resolve_issuer_first_match_is_terminal_when_discovery_invalid() { + // Rule 0 matches the issuer but its discovery template resolves to a URL + // with a query string (invalid). A later catch-all rule that *would* + // resolve must NOT be consulted — first match wins, so resolution fails + // closed instead of silently adopting the later rule's policy. + let trust = IssuerTrustConfig::from_inputs(vec![ + TrustedIssuerInput { + entry: TrustedIssuerEntry::Issuer("https://issuer.example.com".to_owned()), + discovery_url: Some("{issuer}?leak=1".to_owned()), + expected_audience: Vec::new(), + jose_typ: None, + clock_skew_leeway_secs: None, + }, + TrustedIssuerInput { + entry: TrustedIssuerEntry::IssuerPattern("^https://.*$".to_owned()), + discovery_url: None, + expected_audience: Vec::new(), + jose_typ: None, + clock_skew_leeway_secs: None, + }, + ]) + .expect("config builds"); + + assert!( + trust.resolve_issuer("https://issuer.example.com").is_none(), + "a matched-but-invalid rule must be terminal, not fall through to later rules" + ); + } + + #[test] + fn resolve_issuer_defaults_overrides_to_empty_and_none() { + let trust = IssuerTrustConfig::from_inputs(vec![exact_input("https://issuer.example.com")]) + .unwrap(); + + let resolved = trust + .resolve_issuer("https://issuer.example.com") + .expect("issuer should resolve"); + + assert!(resolved.expected_audience.is_empty()); + assert!(resolved.jose_typ.is_none()); + assert!(resolved.clock_skew_leeway_secs.is_none()); + } + + #[test] + fn blank_jose_typ_override_fails_fast() { + let error = IssuerTrustConfig::from_inputs(vec![TrustedIssuerInput { + entry: TrustedIssuerEntry::Issuer("https://issuer.example.com".to_owned()), + discovery_url: None, + expected_audience: Vec::new(), + jose_typ: Some(" ".to_owned()), + clock_skew_leeway_secs: None, + }]) + .expect_err("blank jose_typ override should fail"); + + assert!( + error + .to_string() + .contains("`trusted_issuers` entry at index 0 has an empty `jose_typ`"), + "error should identify blank jose_typ override: {error}" + ); + } + + #[test] + fn excessive_per_issuer_leeway_fails_fast() { + let error = IssuerTrustConfig::from_inputs(vec![TrustedIssuerInput { + entry: TrustedIssuerEntry::Issuer("https://issuer.example.com".to_owned()), + discovery_url: None, + expected_audience: Vec::new(), + jose_typ: None, + clock_skew_leeway_secs: Some(301), + }]) + .expect_err("per-issuer leeway over 300s should fail"); + + assert!( + error + .to_string() + .contains("`clock_skew_leeway_secs` must be <= 300"), + "error should identify per-issuer leeway bound: {error}" + ); + } + + #[test] + fn invalid_per_issuer_audience_pattern_fails_fast() { + let error = IssuerTrustConfig::from_inputs(vec![TrustedIssuerInput { + entry: TrustedIssuerEntry::Issuer("https://issuer.example.com".to_owned()), + discovery_url: None, + expected_audience: vec!["api-**".to_owned()], + jose_typ: None, + clock_skew_leeway_secs: None, + }]) + .expect_err("invalid per-issuer audience pattern should fail"); + + assert!( + error.to_string().contains("invalid `expected_audience`"), + "error should identify invalid per-issuer audience: {error}" + ); + } + #[test] fn hidden_test_trusted_issuer_builder_allows_http_url() { let trust = IssuerTrustConfig::from_inputs_allowing_insecure_http_for_tests(vec![ TrustedIssuerInput { entry: TrustedIssuerEntry::Issuer("http://issuer.example.com".to_owned()), discovery_url: None, + expected_audience: Vec::new(), + jose_typ: None, + clock_skew_leeway_secs: None, }, ]) .expect("hidden test builder should allow HTTP issuer URLs"); @@ -1293,6 +1571,53 @@ mod gear_input_tests { ); } + #[test] + fn gear_config_deserializes_per_issuer_overrides() { + let json = r#"{ + "jwt": { + "expected_audience": ["global-api"], + "trusted_issuers": [ + { + "issuer": "https://obo.example.com", + "expected_audience": ["public-api"], + "jose_typ": "obo+jwt", + "clock_skew_leeway_secs": 30 + }, + { "issuer": "https://kc.example.com/realms/platform" } + ], + "claim_mapping": { "subject_tenant_id": "tenant_id", "subject_type": "sub_type" } + }, + "s2s_oauth": { + "discovery_url": "https://obo.example.com", + "default_subject_type": "gts.cf.core.security.subject_user.v1~" + } + }"#; + let config: OidcAuthNGearConfig = + serde_json::from_str(json).expect("per-issuer override config should deserialize"); + let resolved = config + .resolve() + .expect("per-issuer override config should resolve"); + + // OBO issuer carries its own overrides. + let obo = resolved + .issuer_trust + .resolve_issuer("https://obo.example.com") + .expect("OBO issuer should resolve"); + assert_eq!(obo.jose_typ.as_deref(), Some("obo+jwt")); + assert_eq!(obo.clock_skew_leeway_secs, Some(30)); + assert_eq!(obo.expected_audience.len(), 1); + assert!(obo.expected_audience[0].is_match("public-api")); + + // KC issuer has no overrides (preserves global behavior). + let kc = resolved + .issuer_trust + .resolve_issuer("https://kc.example.com/realms/platform") + .expect("KC issuer should resolve"); + assert!(kc.jose_typ.is_none()); + assert!(kc.clock_skew_leeway_secs.is_none()); + assert!(kc.expected_audience.is_empty()); + } + #[test] fn gear_config_requires_s2s_default_subject_type() { let json = r#"{ diff --git a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/error.rs b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/error.rs index 3ee339896..6eb06897e 100644 --- a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/error.rs +++ b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/error.rs @@ -52,6 +52,10 @@ pub enum AuthNError { #[error("invalid audience")] InvalidAudience, + /// The JOSE `typ` header does not match the issuer's required token type. + #[error("invalid token type")] + InvalidTokenType, + /// Oidc (or OIDC Discovery / JWKS endpoint) is unreachable. #[error("identity provider unreachable")] IdpUnreachable, @@ -91,7 +95,8 @@ impl From for AuthNResolverError { | AuthNError::InvalidSubject | AuthNError::KidNotFound | AuthNError::UnsupportedAlgorithm - | AuthNError::InvalidAudience => AuthNResolverError::Unauthorized(format!("{value}")), + | AuthNError::InvalidAudience + | AuthNError::InvalidTokenType => AuthNResolverError::Unauthorized(format!("{value}")), AuthNError::IdpUnreachable => { AuthNResolverError::ServiceUnavailable(format!("{value}")) } diff --git a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/error_tests.rs b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/error_tests.rs index 7a87b12b8..3a60e7012 100644 --- a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/error_tests.rs +++ b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/error_tests.rs @@ -81,6 +81,15 @@ fn invalid_audience_maps_to_unauthorized() { )); } +#[test] +fn invalid_token_type_maps_to_unauthorized() { + let mapped: AuthNResolverError = AuthNError::InvalidTokenType.into(); + assert!(matches!( + mapped, + AuthNResolverError::Unauthorized(msg) if msg == "invalid token type" + )); +} + #[test] fn idp_unreachable_maps_to_service_unavailable() { let mapped: AuthNResolverError = AuthNError::IdpUnreachable.into(); @@ -106,6 +115,7 @@ fn non_idp_errors_are_not_idp_failure() { assert!(!AuthNError::KidNotFound.is_idp_failure()); assert!(!AuthNError::UnsupportedAlgorithm.is_idp_failure()); assert!(!AuthNError::InvalidAudience.is_idp_failure()); + assert!(!AuthNError::InvalidTokenType.is_idp_failure()); assert!(!AuthNError::TokenEndpointUnsuccessfulStatus(401).is_idp_failure()); assert!(!AuthNError::TokenResponseParseFailed.is_idp_failure()); assert!(!AuthNError::TokenEndpointNotConfigured.is_idp_failure()); diff --git a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/metrics.rs b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/metrics.rs index 86a510dbf..f575fd6aa 100644 --- a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/metrics.rs +++ b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/metrics.rs @@ -23,8 +23,8 @@ pub(crate) use definitions::{ AUTHN_TOKEN_REJECTED_TOTAL, TOKEN_REJECTION_REASON_EXPIRED, TOKEN_REJECTION_REASON_INVALID_AUDIENCE, TOKEN_REJECTION_REASON_INVALID_IAT, TOKEN_REJECTION_REASON_INVALID_SIG, TOKEN_REJECTION_REASON_INVALID_TENANT, - TOKEN_REJECTION_REASON_MISSING_AUDIENCE, TOKEN_REJECTION_REASON_MISSING_TENANT, - TOKEN_REJECTION_REASON_UNTRUSTED_ISSUER, + TOKEN_REJECTION_REASON_INVALID_TYP, TOKEN_REJECTION_REASON_MISSING_AUDIENCE, + TOKEN_REJECTION_REASON_MISSING_TENANT, TOKEN_REJECTION_REASON_UNTRUSTED_ISSUER, }; /// OpenTelemetry-backed metrics handle shared across plugin components. diff --git a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/metrics/definitions.rs b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/metrics/definitions.rs index 2885f6da4..25c632924 100644 --- a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/metrics/definitions.rs +++ b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/metrics/definitions.rs @@ -62,6 +62,8 @@ pub const TOKEN_REJECTION_REASON_INVALID_IAT: &str = "invalid_iat"; pub const TOKEN_REJECTION_REASON_INVALID_SIG: &str = "invalid_sig"; /// Token rejection reason: tenant claim is malformed. pub const TOKEN_REJECTION_REASON_INVALID_TENANT: &str = "invalid_tenant"; +/// Token rejection reason: JOSE `typ` header does not match the issuer's required type. +pub const TOKEN_REJECTION_REASON_INVALID_TYP: &str = "invalid_typ"; /// Token rejection reason: required audience is missing. pub const TOKEN_REJECTION_REASON_MISSING_AUDIENCE: &str = "missing_audience"; /// Token rejection reason: tenant claim is missing. diff --git a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/validator.rs b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/validator.rs index ef49356a2..aaa8a2340 100644 --- a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/validator.rs +++ b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/validator.rs @@ -4,16 +4,21 @@ //! enabled subset of the plugin-supported RS256 and ES256 algorithms. //! //! Validation order: -//! 1. Decode and parse the JWT header (extract `kid` and `alg`). +//! 1. Decode and parse the JWT header (extract `kid`, `alg`, and `typ`). //! 2. Reject unsupported/disallowed algorithms. -//! 3. Verify `iss` against the `trusted_issuers` list. -//! 4. Look up the signing key by `kid` in the JWKS cache. +//! 3. Verify `iss` against the `trusted_issuers` list and resolve the matched +//! issuer's per-issuer overrides (audience, JOSE `typ`, clock-skew leeway). +//! 4. When the matched issuer pins a JOSE `typ`, require the header `typ` to +//! match it (case-insensitive). +//! 5. Look up the signing key by `kid` in the JWKS cache. //! - On miss: force-refresh from Oidc. //! - If still missing after refresh: return `Unauthorized`. -//! 5. Verify the JWT signature and decode claims. -//! 6. Validate `exp` (rejected if expired). -//! 7. Validate optional `iat` (rejected if issued too far in the future). -//! 8. Optionally validate `aud`. +//! 6. Verify the JWT signature and decode claims (leeway = per-issuer override +//! when present, else global). +//! 7. Validate `exp` (rejected if expired). +//! 8. Validate optional `iat` (rejected if issued too far in the future). +//! 9. Validate `aud` against the per-issuer audience when set, else the global +//! audience. use std::sync::Arc; use std::time::{Instant, SystemTime, UNIX_EPOCH}; @@ -27,12 +32,13 @@ use toolkit_macros::domain_model; use tracing::{debug, warn}; use url::Url; -use crate::config::{IssuerTrustConfig, JwtValidationConfig, MatcherCompiled}; +use crate::config::{IssuerTrustConfig, JwtValidationConfig, MatcherCompiled, ResolvedIssuer}; use crate::domain::error::AuthNError; use crate::domain::metrics::{ AuthNMetrics, TOKEN_REJECTION_REASON_EXPIRED, TOKEN_REJECTION_REASON_INVALID_AUDIENCE, TOKEN_REJECTION_REASON_INVALID_IAT, TOKEN_REJECTION_REASON_INVALID_SIG, - TOKEN_REJECTION_REASON_MISSING_AUDIENCE, TOKEN_REJECTION_REASON_UNTRUSTED_ISSUER, + TOKEN_REJECTION_REASON_INVALID_TYP, TOKEN_REJECTION_REASON_MISSING_AUDIENCE, + TOKEN_REJECTION_REASON_UNTRUSTED_ISSUER, }; use crate::domain::ports::JwksProvider; @@ -104,6 +110,7 @@ impl JwtValidator { /// - [`AuthNError::SignatureInvalid`]: cannot decode/verify token /// - [`AuthNError::UnsupportedAlgorithm`]: `alg` not enabled by configuration /// - [`AuthNError::UntrustedIssuer`]: `iss` not in `trusted_issuers` + /// - [`AuthNError::InvalidTokenType`]: JOSE `typ` does not match the issuer's pinned type /// - [`AuthNError::KidNotFound`]: key not in JWKS after force-refresh /// - [`AuthNError::SignatureInvalid`]: signature verification failed /// - [`AuthNError::TokenExpired`]: `exp` is in the past @@ -139,31 +146,49 @@ impl JwtValidator { // validated post-signature via `Validation::set_issuer` (defense-in-depth). let issuer = Self::peek_issuer(token)?; - // Step 4: Check issuer is trusted (uses IssuerTrustConfig for exact or regex matching) - let discovery_base = self.validate_issuer(&issuer, issuer_trust)?; + // Step 4: Check issuer is trusted (uses IssuerTrustConfig for exact or + // regex matching) and resolve its per-issuer validation overrides. + let resolved = self.validate_issuer(&issuer, issuer_trust)?; + + // Step 4b: Enforce the issuer's JOSE `typ` requirement, if pinned. + self.validate_jose_typ(&header, &resolved)?; + + // Effective per-issuer overrides (fall back to global where unset). + let leeway = resolved + .clock_skew_leeway_secs + .unwrap_or(config.clock_skew_leeway_secs); + let expected_audience = if resolved.expected_audience.is_empty() { + config.expected_audience.as_slice() + } else { + resolved.expected_audience.as_slice() + }; // Step 5: Get the kid (may be absent for single-key issuers) let kid = header.kid.clone(); // Step 6: Resolve the signing key, with force-refresh on miss let decoding_key = self - .resolve_decoding_key(&issuer, &discovery_base, kid.as_deref(), alg) + .resolve_decoding_key(&issuer, &resolved.discovery_base, kid.as_deref(), alg) .await?; // Step 7: Build validation rules and decode + verify the token let mut validation = Validation::new(alg); validation.validate_nbf = true; - validation.leeway = config.clock_skew_leeway_secs; + validation.leeway = leeway; // Issuer validation (defense-in-depth: re-verified post-signature) validation.set_issuer(&[issuer.as_str()]); // Audience matching is handled after signature verification so `*` // patterns can be matched with the compiled config regexes. Missing - // `aud` is controlled separately by `require_audience`. + // `aud` presence is enforced here. validation.validate_aud = false; - if config.require_audience { + // Require an `aud` claim when globally mandated OR when this issuer pins + // its own audience: a per-issuer `expected_audience` must fail closed on + // a missing `aud` (it binds tokens to a specific audience, e.g. an + // adapter GTS-id) rather than silently depend on the global flag. + if config.require_audience || !resolved.expected_audience.is_empty() { validation.set_required_spec_claims(&["aud"]); } @@ -199,7 +224,7 @@ impl JwtValidator { let claims = token_data.claims; if let Some(iat) = claims.iat - && iat > current_unix_timestamp().saturating_add(config.clock_skew_leeway_secs) + && iat > current_unix_timestamp().saturating_add(leeway) { self.metrics .increment_token_rejected(TOKEN_REJECTION_REASON_INVALID_IAT); @@ -208,8 +233,8 @@ impl JwtValidator { } if let Some(aud) = &claims.aud - && !config.expected_audience.is_empty() - && !audience_matches(&config.expected_audience, aud) + && !expected_audience.is_empty() + && !audience_matches(expected_audience, aud) { self.metrics .increment_token_rejected(TOKEN_REJECTION_REASON_INVALID_AUDIENCE); @@ -268,15 +293,16 @@ impl JwtValidator { /// Validate the issuer against the trusted issuers configuration. /// - /// Delegates to [`IssuerTrustConfig::is_trusted`] which handles both - /// exact (literal string) and regex (auto-anchored) matching modes. + /// Delegates to [`IssuerTrustConfig::resolve_issuer`] which handles both + /// exact (literal string) and regex (auto-anchored) matching modes, and + /// returns the matched issuer's per-issuer validation overrides. fn validate_issuer( &self, issuer: &str, issuer_trust: &IssuerTrustConfig, - ) -> Result { - if let Some(discovery_base) = issuer_trust.resolve(issuer) { - Ok(discovery_base) + ) -> Result { + if let Some(resolved) = issuer_trust.resolve_issuer(issuer) { + Ok(resolved) } else { self.metrics .increment_token_rejected(TOKEN_REJECTION_REASON_UNTRUSTED_ISSUER); @@ -284,6 +310,34 @@ impl JwtValidator { } } + /// Enforce the matched issuer's JOSE `typ` requirement, if any. + /// + /// When the issuer pins a `jose_typ`, the token's `typ` header must match it + /// case-insensitively. Issuers without an override do not inspect `typ`. + fn validate_jose_typ( + &self, + header: &Header, + resolved: &ResolvedIssuer, + ) -> Result<(), AuthNError> { + let Some(expected_typ) = resolved.jose_typ.as_deref() else { + return Ok(()); + }; + + // `expected_typ` is stored lowercased; compare the header case-insensitively. + let matches = header + .typ + .as_deref() + .is_some_and(|typ| typ.eq_ignore_ascii_case(expected_typ)); + + if matches { + Ok(()) + } else { + self.metrics + .increment_token_rejected(TOKEN_REJECTION_REASON_INVALID_TYP); + Err(AuthNError::InvalidTokenType) + } + } + /// Resolve the [`DecodingKey`] for the given issuer and optional `kid`. /// /// Tries the cached JWKS first, force-refreshes on miss or ambiguous diff --git a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/validator_tests.rs b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/validator_tests.rs index 4a9b15f12..2e5c8c3f0 100644 --- a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/validator_tests.rs +++ b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/validator_tests.rs @@ -23,6 +23,9 @@ fn make_regex_trust(patterns: &[&str]) -> IssuerTrustConfig { .map(|pattern| TrustedIssuerInput { entry: TrustedIssuerEntry::IssuerPattern((*pattern).to_owned()), discovery_url: None, + expected_audience: Vec::new(), + jose_typ: None, + clock_skew_leeway_secs: None, }) .collect::>(); IssuerTrustConfig::from_inputs(inputs).unwrap() @@ -163,7 +166,8 @@ fn test_peek_issuer_too_few_segments() { } use crate::test_support::test_fixtures::{ - TEST_ISSUER, TEST_KID, future_exp, past_exp, sign_jwt, test_jwk_json, + TEST_ISSUER, TEST_KID, future_exp, past_exp, sign_jwt, sign_jwt_with_typ, sign_jwt_without_typ, + test_jwk_json, }; struct TestJwksProvider { @@ -691,3 +695,359 @@ async fn metrics_increment_for_jwks_refresh_failure() { ); assert!(after >= 1, "JWKS refresh failure counter should increase"); } + +// ─── Per-issuer overrides (typ / aud / clock-skew leeway) ─────────────────── + +/// Second trusted issuer used to verify per-issuer overrides apply only to the +/// matched issuer. The test JWKS provider ignores the issuer, so tokens for this +/// issuer verify against the same signing key. +const OTHER_ISSUER: &str = "https://other.example.com/realms/platform"; + +/// Build a single-issuer `IssuerTrustConfig` carrying per-issuer overrides. +fn issuer_input_with_overrides( + issuer: &str, + expected_audience: &[&str], + jose_typ: Option<&str>, + clock_skew_leeway_secs: Option, +) -> TrustedIssuerInput { + TrustedIssuerInput { + entry: TrustedIssuerEntry::Issuer(issuer.to_owned()), + discovery_url: None, + expected_audience: expected_audience + .iter() + .map(|aud| (*aud).to_owned()) + .collect(), + jose_typ: jose_typ.map(str::to_owned), + clock_skew_leeway_secs, + } +} + +// (a) Per-issuer `jose_typ` enforcement. + +#[tokio::test] +async fn test_per_issuer_jose_typ_rejects_mismatched_typ() { + let (validator, _) = make_validator_with_test_key(); + let trust = IssuerTrustConfig::from_inputs(vec![issuer_input_with_overrides( + TEST_ISSUER, + &[], + Some("obo+jwt"), + None, + )]) + .unwrap(); + + let claims = serde_json::json!({ + "sub": "550e8400-e29b-41d4-a716-446655440000", + "iss": TEST_ISSUER, + "exp": future_exp(), + }); + // Issuer pins `obo+jwt`; this token carries `cap+jwt`. + let token = sign_jwt_with_typ(&claims, Some(TEST_KID), "cap+jwt"); + + let result = validator + .validate(&token, &base_jwt_validation_config(), &trust) + .await; + assert!( + matches!(result, Err(AuthNError::InvalidTokenType)), + "mismatched JOSE typ should be rejected: {result:?}" + ); +} + +#[tokio::test] +async fn test_per_issuer_jose_typ_accepts_matching_typ_case_insensitively() { + let (validator, _) = make_validator_with_test_key(); + let trust = IssuerTrustConfig::from_inputs(vec![issuer_input_with_overrides( + TEST_ISSUER, + &[], + Some("obo+jwt"), + None, + )]) + .unwrap(); + + let claims = serde_json::json!({ + "sub": "550e8400-e29b-41d4-a716-446655440000", + "iss": TEST_ISSUER, + "exp": future_exp(), + }); + // Case-insensitive match against the configured `obo+jwt`. + let token = sign_jwt_with_typ(&claims, Some(TEST_KID), "OBO+JWT"); + + let result = validator + .validate(&token, &base_jwt_validation_config(), &trust) + .await; + assert!( + result.is_ok(), + "matching JOSE typ (case-insensitive) should be accepted: {result:?}" + ); +} + +#[tokio::test] +async fn test_no_per_issuer_jose_typ_ignores_typ_header() { + // KC-style issuer without a `jose_typ` override: any `typ` is accepted. + let (validator, _) = make_validator_with_test_key(); + let trust = make_trust(&[TEST_ISSUER]); + + let claims = serde_json::json!({ + "sub": "550e8400-e29b-41d4-a716-446655440000", + "iss": TEST_ISSUER, + "exp": future_exp(), + }); + let token = sign_jwt_with_typ(&claims, Some(TEST_KID), "anything+jwt"); + + let result = validator + .validate(&token, &base_jwt_validation_config(), &trust) + .await; + assert!( + result.is_ok(), + "issuer without jose_typ override must not inspect typ: {result:?}" + ); +} + +#[tokio::test] +async fn test_per_issuer_jose_typ_rejects_missing_typ_header() { + let (validator, _) = make_validator_with_test_key(); + let trust = IssuerTrustConfig::from_inputs(vec![issuer_input_with_overrides( + TEST_ISSUER, + &[], + Some("obo+jwt"), + None, + )]) + .unwrap(); + + let claims = serde_json::json!({ + "sub": "550e8400-e29b-41d4-a716-446655440000", + "iss": TEST_ISSUER, + "exp": future_exp(), + }); + // Issuer pins `obo+jwt`; this token omits the `typ` header entirely. + let token = sign_jwt_without_typ(&claims, Some(TEST_KID)); + + let result = validator + .validate(&token, &base_jwt_validation_config(), &trust) + .await; + assert!( + matches!(result, Err(AuthNError::InvalidTokenType)), + "a pinned issuer must reject a token with no typ header: {result:?}" + ); +} + +// (b) Per-issuer audience override vs global fallback. + +#[tokio::test] +async fn test_per_issuer_audience_override_rejects_global_audience() { + let (validator, _) = make_validator_with_test_key(); + // Global expects `global-api`; the matched issuer overrides to `public-api`. + let mut config = jwt_validation_config_with_audience(&["global-api"], true); + config.require_audience = true; + let trust = IssuerTrustConfig::from_inputs(vec![issuer_input_with_overrides( + TEST_ISSUER, + &["public-api"], + None, + None, + )]) + .unwrap(); + + let claims = serde_json::json!({ + "sub": "550e8400-e29b-41d4-a716-446655440000", + "iss": TEST_ISSUER, + "exp": future_exp(), + "aud": "global-api", + }); + let token = sign_jwt(&claims, Some(TEST_KID)); + + let result = validator.validate(&token, &config, &trust).await; + assert!( + matches!(result, Err(AuthNError::InvalidAudience)), + "per-issuer audience override should reject the global audience: {result:?}" + ); +} + +#[tokio::test] +async fn test_per_issuer_audience_override_accepts_its_own_audience() { + let (validator, _) = make_validator_with_test_key(); + let config = jwt_validation_config_with_audience(&["global-api"], true); + let trust = IssuerTrustConfig::from_inputs(vec![issuer_input_with_overrides( + TEST_ISSUER, + &["public-api"], + None, + None, + )]) + .unwrap(); + + let claims = serde_json::json!({ + "sub": "550e8400-e29b-41d4-a716-446655440000", + "iss": TEST_ISSUER, + "exp": future_exp(), + "aud": "public-api", + }); + let token = sign_jwt(&claims, Some(TEST_KID)); + + let result = validator.validate(&token, &config, &trust).await; + assert!( + result.is_ok(), + "per-issuer audience override should accept its own audience: {result:?}" + ); +} + +#[tokio::test] +async fn test_issuer_without_audience_override_uses_global_audience() { + let (validator, _) = make_validator_with_test_key(); + let config = jwt_validation_config_with_audience(&["global-api"], true); + // First issuer overrides audience; second issuer has no override and must + // fall back to the global `global-api`. + let trust = IssuerTrustConfig::from_inputs(vec![ + issuer_input_with_overrides(TEST_ISSUER, &["public-api"], None, None), + issuer_input_with_overrides(OTHER_ISSUER, &[], None, None), + ]) + .unwrap(); + + let claims = serde_json::json!({ + "sub": "550e8400-e29b-41d4-a716-446655440000", + "iss": OTHER_ISSUER, + "exp": future_exp(), + "aud": "global-api", + }); + let token = sign_jwt(&claims, Some(TEST_KID)); + + let result = validator.validate(&token, &config, &trust).await; + assert!( + result.is_ok(), + "issuer without override should accept the global audience: {result:?}" + ); + + // And the same issuer rejects the *other* issuer's per-issuer audience. + let claims = serde_json::json!({ + "sub": "550e8400-e29b-41d4-a716-446655440000", + "iss": OTHER_ISSUER, + "exp": future_exp(), + "aud": "public-api", + }); + let token = sign_jwt(&claims, Some(TEST_KID)); + + let result = validator.validate(&token, &config, &trust).await; + assert!( + matches!(result, Err(AuthNError::InvalidAudience)), + "issuer without override should reject another issuer's audience: {result:?}" + ); +} + +#[tokio::test] +async fn test_per_issuer_audience_requires_aud_even_when_global_flag_off() { + let (validator, _) = make_validator_with_test_key(); + // Global `require_audience` is OFF; the issuer pins its own audience. A token + // omitting `aud` must still be rejected (the per-issuer audience binds the + // token and must fail closed without relying on the global flag). + let config = jwt_validation_config_with_audience(&["public-api"], false); + assert!(!config.require_audience); + let trust = IssuerTrustConfig::from_inputs(vec![issuer_input_with_overrides( + TEST_ISSUER, + &["public-api"], + None, + None, + )]) + .unwrap(); + + let claims = serde_json::json!({ + "sub": "550e8400-e29b-41d4-a716-446655440000", + "iss": TEST_ISSUER, + "exp": future_exp(), + // No `aud` claim. + }); + let token = sign_jwt(&claims, Some(TEST_KID)); + + let result = validator.validate(&token, &config, &trust).await; + assert!( + matches!(result, Err(AuthNError::MissingClaim(ref c)) if c == "aud"), + "per-issuer audience must reject a missing aud even with global require_audience off: {result:?}" + ); +} + +#[tokio::test] +async fn test_per_issuer_audience_accepts_array_aud_member() { + let (validator, _) = make_validator_with_test_key(); + let config = jwt_validation_config_with_audience(&["global-api"], false); + let trust = IssuerTrustConfig::from_inputs(vec![issuer_input_with_overrides( + TEST_ISSUER, + &["public-api"], + None, + None, + )]) + .unwrap(); + + let claims = serde_json::json!({ + "sub": "550e8400-e29b-41d4-a716-446655440000", + "iss": TEST_ISSUER, + "exp": future_exp(), + // `aud` as an array: one member matches the per-issuer audience. + "aud": ["something-else", "public-api"], + }); + let token = sign_jwt(&claims, Some(TEST_KID)); + + let result = validator.validate(&token, &config, &trust).await; + assert!( + result.is_ok(), + "per-issuer audience should accept an array aud whose member matches: {result:?}" + ); +} + +// (c) Per-issuer clock-skew leeway. + +#[tokio::test] +async fn test_per_issuer_leeway_wider_than_global_accepts_future_iat() { + let (validator, _) = make_validator_with_test_key(); + // Global leeway is 60s; per-issuer leeway is widened to 120s. + let config = base_jwt_validation_config(); + assert_eq!(config.clock_skew_leeway_secs, 60); + let trust = IssuerTrustConfig::from_inputs(vec![issuer_input_with_overrides( + TEST_ISSUER, + &[], + None, + Some(120), + )]) + .unwrap(); + let now = current_unix_timestamp(); + + let claims = serde_json::json!({ + "sub": "550e8400-e29b-41d4-a716-446655440000", + "iss": TEST_ISSUER, + "exp": now + 7200, + // Outside the global 60s leeway but inside the per-issuer 120s leeway. + "iat": now + 90, + }); + let token = sign_jwt(&claims, Some(TEST_KID)); + + let result = validator.validate(&token, &config, &trust).await; + assert!( + result.is_ok(), + "iat within per-issuer (wider) leeway should be accepted: {result:?}" + ); +} + +#[tokio::test] +async fn test_per_issuer_leeway_narrower_than_global_rejects_future_iat() { + let (validator, _) = make_validator_with_test_key(); + // Global leeway is 60s; per-issuer leeway is tightened to 10s. + let config = base_jwt_validation_config(); + let trust = IssuerTrustConfig::from_inputs(vec![issuer_input_with_overrides( + TEST_ISSUER, + &[], + None, + Some(10), + )]) + .unwrap(); + let now = current_unix_timestamp(); + + let claims = serde_json::json!({ + "sub": "550e8400-e29b-41d4-a716-446655440000", + "iss": TEST_ISSUER, + "exp": now + 7200, + // Inside the global 60s leeway but outside the per-issuer 10s leeway. + "iat": now + 30, + }); + let token = sign_jwt(&claims, Some(TEST_KID)); + + let result = validator.validate(&token, &config, &trust).await; + assert!( + matches!(result, Err(AuthNError::SignatureInvalid)), + "iat outside per-issuer (narrower) leeway should be rejected: {result:?}" + ); +} diff --git a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/infra/token_client_tests.rs b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/infra/token_client_tests.rs index 6e4e52d00..6cc3bf96e 100644 --- a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/infra/token_client_tests.rs +++ b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/infra/token_client_tests.rs @@ -24,6 +24,9 @@ fn make_issuer_trust(issuer: String) -> IssuerTrustConfig { IssuerTrustConfig::from_inputs_allowing_insecure_http_for_tests([TrustedIssuerInput { entry: TrustedIssuerEntry::Issuer(issuer), discovery_url: None, + expected_audience: Vec::new(), + jose_typ: None, + clock_skew_leeway_secs: None, }]) .expect("test issuer trust should build") } diff --git a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/test_support/test_fixtures.rs b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/test_support/test_fixtures.rs index a44472b70..4413fb191 100644 --- a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/test_support/test_fixtures.rs +++ b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/test_support/test_fixtures.rs @@ -76,6 +76,31 @@ pub fn sign_jwt(claims: &serde_json::Value, kid: Option<&str>) -> String { encode(&header, claims, &key_material().encoding_key).unwrap_or_default() } +/// Sign a JWT with the test RS256 private key, setting the JOSE `typ` header. +/// +/// Used to exercise per-issuer `typ` enforcement (e.g. `obo+jwt` vs `cap+jwt`). +#[must_use] +pub fn sign_jwt_with_typ(claims: &serde_json::Value, kid: Option<&str>, typ: &str) -> String { + use jsonwebtoken::{Header, encode}; + let mut header = Header::new(Algorithm::RS256); + header.kid = kid.map(str::to_owned); + header.typ = Some(typ.to_owned()); + encode(&header, claims, &key_material().encoding_key).unwrap_or_default() +} + +/// Sign a JWT with the test RS256 private key and NO JOSE `typ` header. +/// +/// Used to exercise per-issuer `typ` enforcement against a token that omits the +/// `typ` header entirely (must fail closed when the issuer pins a `typ`). +#[must_use] +pub fn sign_jwt_without_typ(claims: &serde_json::Value, kid: Option<&str>) -> String { + use jsonwebtoken::{Header, encode}; + let mut header = Header::new(Algorithm::RS256); + header.kid = kid.map(str::to_owned); + header.typ = None; + encode(&header, claims, &key_material().encoding_key).unwrap_or_default() +} + /// Build a [`Claims`] map from a JSON object. /// /// Common helper shared across unit and integration tests to avoid duplicating diff --git a/gears/system/authn-resolver/plugins/oidc-authn-plugin/tests/common/mod.rs b/gears/system/authn-resolver/plugins/oidc-authn-plugin/tests/common/mod.rs index 4655861ad..7a76a55f9 100644 --- a/gears/system/authn-resolver/plugins/oidc-authn-plugin/tests/common/mod.rs +++ b/gears/system/authn-resolver/plugins/oidc-authn-plugin/tests/common/mod.rs @@ -105,6 +105,9 @@ pub fn exact_issuer_trust(issuer: String) -> anyhow::Result { IssuerTrustConfig::from_inputs_allowing_insecure_http_for_tests([TrustedIssuerInput { entry: TrustedIssuerEntry::Issuer(issuer), discovery_url: None, + expected_audience: Vec::new(), + jose_typ: None, + clock_skew_leeway_secs: None, }]) .map_err(anyhow::Error::msg) } From e07f8d127af6219904fb9420fb827dfbc1b89d08 Mon Sep 17 00:00:00 2001 From: Diffora Date: Sat, 13 Jun 2026 21:57:03 +0200 Subject: [PATCH 5/5] oidc-authn-plugin: preserve non-standard JWT claims + dylint de0708 migration Add #[serde(flatten)] extra to JwtClaims so claim mapping can read non-standard claims (e.g. cap tokens' subject_tenant/scopes) by name instead of dropping them at deserialize. Signed-off-by: Diffora --- .../src/domain/authenticate_tests.rs | 38 +++++++++++++++++++ .../oidc-authn-plugin/src/domain/validator.rs | 5 +++ .../de0708_no_non_fips_hasher/src/lib.rs | 23 ++++++----- 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/authenticate_tests.rs b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/authenticate_tests.rs index 140c6a7c3..d17b3257b 100644 --- a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/authenticate_tests.rs +++ b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/authenticate_tests.rs @@ -244,6 +244,7 @@ fn jwt_claims_to_map_includes_all_populated_fields() { tenant_id: Some("tenant-val".to_owned()), user_type: Some("type-val".to_owned()), scope: Some("scope-val".to_owned()), + extra: serde_json::Map::new(), }; let map = super::jwt_claims_to_map(jwt_claims); assert_eq!( @@ -267,6 +268,7 @@ fn jwt_claims_to_map_produces_correct_entries() { tenant_id: Some("tenant-abc".to_owned()), user_type: None, scope: Some("read write".to_owned()), + extra: serde_json::Map::new(), }; let map = super::jwt_claims_to_map(jwt_claims); assert_eq!( @@ -304,6 +306,42 @@ fn jwt_claims_to_map_produces_correct_entries() { ); } +#[test] +fn non_standard_claims_survive_into_map() { + // Cap tokens carry claims under names the struct does not declare + // (`subject_tenant`, `scopes`, `context_tenant`). They must be preserved via + // `extra` so claim mapping can be configured to read them. + let payload = serde_json::json!({ + "sub": "550e8400-e29b-41d4-a716-446655440000", + "iss": "https://core.example/issuers/cap", + "exp": 9_999_999_999u64, + "subject_tenant": "11111111-1111-1111-1111-111111111111", + "scopes": "rms.read rms.write", + "context_tenant": "22222222-2222-2222-2222-222222222222" + }); + let jwt_claims: JwtClaims = + serde_json::from_value(payload).expect("cap claims should deserialize"); + let map = super::jwt_claims_to_map(jwt_claims); + assert_eq!( + map.get("subject_tenant").and_then(|v| v.as_str()), + Some("11111111-1111-1111-1111-111111111111"), + "non-standard claim must survive the JwtClaims round-trip" + ); + assert_eq!( + map.get("scopes").and_then(|v| v.as_str()), + Some("rms.read rms.write") + ); + assert_eq!( + map.get("context_tenant").and_then(|v| v.as_str()), + Some("22222222-2222-2222-2222-222222222222") + ); + // Standard fields still decode into their own slots, not `extra`. + assert_eq!( + map.get("exp").and_then(serde_json::Value::as_u64), + Some(9_999_999_999) + ); +} + #[tokio::test] async fn concurrent_authenticate_calls_all_succeed() { let (plugin, _) = jwt_plugin_with_jwks_provider(StaticJwksProvider::available()); diff --git a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/validator.rs b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/validator.rs index aaa8a2340..eb47c362d 100644 --- a/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/validator.rs +++ b/gears/system/authn-resolver/plugins/oidc-authn-plugin/src/domain/validator.rs @@ -73,6 +73,11 @@ pub struct JwtClaims { /// Scopes (space-separated). #[serde(skip_serializing_if = "Option::is_none")] pub scope: Option, + /// Any non-standard claims, preserved verbatim so claim mapping can be + /// configured to read them by name (e.g. cap tokens' `subject_tenant`, + /// `scopes`). An empty map serializes to nothing. + #[serde(flatten)] + pub extra: serde_json::Map, } /// JWT validator backed by a domain JWKS provider port. diff --git a/tools/dylint_lints/de07_security/de0708_no_non_fips_hasher/src/lib.rs b/tools/dylint_lints/de07_security/de0708_no_non_fips_hasher/src/lib.rs index d849c4b45..75ab3e636 100644 --- a/tools/dylint_lints/de07_security/de0708_no_non_fips_hasher/src/lib.rs +++ b/tools/dylint_lints/de07_security/de0708_no_non_fips_hasher/src/lib.rs @@ -3,6 +3,7 @@ extern crate rustc_ast; +use clippy_utils::diagnostics::span_lint_and_then; use lint_utils::{is_in_hasher_allow_list, use_tree_to_strings}; use rustc_ast::{Item, ItemKind}; use rustc_lint::{EarlyLintPass, LintContext}; @@ -70,16 +71,18 @@ impl EarlyLintPass for De0708NoNonFipsHasher { }; if let Some(path_str) = find_banned_path(use_tree) { - cx.span_lint(DE0708_NO_NON_FIPS_HASHER, item.span, |diag| { - diag.primary_message(format!( - "non-FIPS-validated hasher import detected: `{}` (DE0708)", - path_str - )); - diag.help( - "these crates use pure-Rust RustCrypto; add to the DE0708 allow-list if usage is non-cryptographic", - ); - diag.note("see docs/security/SECURITY.md §9 for the FIPS dependency policy"); - }); + span_lint_and_then( + cx, + DE0708_NO_NON_FIPS_HASHER, + item.span, + format!("non-FIPS-validated hasher import detected: `{path_str}` (DE0708)"), + |diag| { + diag.help( + "these crates use pure-Rust RustCrypto; add to the DE0708 allow-list if usage is non-cryptographic", + ); + diag.note("see docs/security/SECURITY.md §9 for the FIPS dependency policy"); + }, + ); } } }