From 3373e00dfcb3d4cf3355f3727cb70c00af3efb23 Mon Sep 17 00:00:00 2001 From: Priec Date: Wed, 6 May 2026 19:50:09 +0200 Subject: [PATCH] validation core as a dependency2 --- common/build.rs | 64 ++ common/proto/table_validation.proto | 99 +++ common/src/proto/descriptor.bin | Bin 66544 -> 72490 bytes common/src/proto/komp_ac.table_validation.rs | 739 ++++++++++++++++++ server | 2 +- validation-core/Cargo.toml | 2 +- .../docs/architecture/validation.md | 37 +- validation-core/src/config.rs | 81 ++ validation-core/src/lib.rs | 10 +- validation-core/src/recipe.rs | 63 -- validation-core/src/rules/character_limits.rs | 20 + validation-core/src/rules/display_mask.rs | 5 + validation-core/src/set.rs | 118 +++ 13 files changed, 1152 insertions(+), 88 deletions(-) delete mode 100644 validation-core/src/recipe.rs create mode 100644 validation-core/src/set.rs diff --git a/common/build.rs b/common/build.rs index da17cd4..09acd4d 100644 --- a/common/build.rs +++ b/common/build.rs @@ -64,6 +64,70 @@ fn main() -> Result<(), Box> { ".komp_ac.table_validation.ReplaceTableValidationResponse", "#[derive(serde::Serialize, serde::Deserialize)]", ) + .type_attribute( + ".komp_ac.table_validation.ValidationRuleDefinition", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) + .type_attribute( + ".komp_ac.table_validation.ValidationSetDefinition", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) + .type_attribute( + ".komp_ac.table_validation.UpsertValidationRuleRequest", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) + .type_attribute( + ".komp_ac.table_validation.UpsertValidationRuleResponse", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) + .type_attribute( + ".komp_ac.table_validation.ListValidationRulesRequest", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) + .type_attribute( + ".komp_ac.table_validation.ListValidationRulesResponse", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) + .type_attribute( + ".komp_ac.table_validation.DeleteValidationRuleRequest", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) + .type_attribute( + ".komp_ac.table_validation.DeleteValidationRuleResponse", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) + .type_attribute( + ".komp_ac.table_validation.UpsertValidationSetRequest", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) + .type_attribute( + ".komp_ac.table_validation.UpsertValidationSetResponse", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) + .type_attribute( + ".komp_ac.table_validation.ListValidationSetsRequest", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) + .type_attribute( + ".komp_ac.table_validation.ListValidationSetsResponse", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) + .type_attribute( + ".komp_ac.table_validation.DeleteValidationSetRequest", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) + .type_attribute( + ".komp_ac.table_validation.DeleteValidationSetResponse", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) + .type_attribute( + ".komp_ac.table_validation.ApplyValidationSetRequest", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) + .type_attribute( + ".komp_ac.table_validation.ApplyValidationSetResponse", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) // Enum -> readable strings in JSON ("BYTES", "DISPLAY_WIDTH") .type_attribute( ".komp_ac.table_validation.CountMode", diff --git a/common/proto/table_validation.proto b/common/proto/table_validation.proto index ed13448..093027f 100644 --- a/common/proto/table_validation.proto +++ b/common/proto/table_validation.proto @@ -178,6 +178,19 @@ service TableValidationService { // Replace the full validation definition set for a table in one transaction. rpc ReplaceTableValidation(ReplaceTableValidationRequest) returns (ReplaceTableValidationResponse); + + // Reusable named rule fragments. + rpc UpsertValidationRule(UpsertValidationRuleRequest) returns (UpsertValidationRuleResponse); + rpc ListValidationRules(ListValidationRulesRequest) returns (ListValidationRulesResponse); + rpc DeleteValidationRule(DeleteValidationRuleRequest) returns (DeleteValidationRuleResponse); + + // Reusable named sets composed from rules. + rpc UpsertValidationSet(UpsertValidationSetRequest) returns (UpsertValidationSetResponse); + rpc ListValidationSets(ListValidationSetsRequest) returns (ListValidationSetsResponse); + rpc DeleteValidationSet(DeleteValidationSetRequest) returns (DeleteValidationSetResponse); + + // Snapshot a reusable set onto a concrete table field. + rpc ApplyValidationSet(ApplyValidationSetRequest) returns (ApplyValidationSetResponse); } message UpdateFieldValidationRequest { @@ -204,3 +217,89 @@ message ReplaceTableValidationResponse { bool success = 1; string message = 2; } + +message ValidationRuleDefinition { + string name = 1; + optional string description = 2; + + // Reusable rule fragment. dataKey is ignored by the server for reusable rules. + FieldValidation validation = 3; +} + +message ValidationSetDefinition { + string name = 1; + optional string description = 2; + repeated string ruleNames = 3; + + // Server-resolved snapshot of all rules in ruleNames order. + FieldValidation resolvedValidation = 4; +} + +message UpsertValidationRuleRequest { + string profileName = 1; + ValidationRuleDefinition rule = 2; +} + +message UpsertValidationRuleResponse { + bool success = 1; + string message = 2; +} + +message ListValidationRulesRequest { + string profileName = 1; +} + +message ListValidationRulesResponse { + repeated ValidationRuleDefinition rules = 1; +} + +message DeleteValidationRuleRequest { + string profileName = 1; + string name = 2; +} + +message DeleteValidationRuleResponse { + bool success = 1; + string message = 2; +} + +message UpsertValidationSetRequest { + string profileName = 1; + ValidationSetDefinition set = 2; +} + +message UpsertValidationSetResponse { + bool success = 1; + string message = 2; +} + +message ListValidationSetsRequest { + string profileName = 1; +} + +message ListValidationSetsResponse { + repeated ValidationSetDefinition sets = 1; +} + +message DeleteValidationSetRequest { + string profileName = 1; + string name = 2; +} + +message DeleteValidationSetResponse { + bool success = 1; + string message = 2; +} + +message ApplyValidationSetRequest { + string profileName = 1; + string tableName = 2; + string dataKey = 3; + string setName = 4; +} + +message ApplyValidationSetResponse { + bool success = 1; + string message = 2; + FieldValidation validation = 3; +} diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index 73620a79cc6c70998321cc16b5e2921f5b67b653..f76e8e5e8a27b5d49623d715ba8936fe5e101c90 100644 GIT binary patch delta 5548 zcmZ{neT-Dq8OHB9d-pPPm;E}s%g)a1?p%hg0xGdUw>5~3N}F05O_!9$MoSj<0^2Mz z+nIrczb;sjh*WH5TWNttY~;g=kZdX>KtdxzqclQ712#fJBaqMtRBWJ;hCc5(=iS+l z>3{rqKhE<$@4e^F-rVTE{hoVZ?&hj<8^v(<<`%yxb3pis#k~XleZ9s0T()QRKv??O z>~!f~>AnW_l<%(@2_>pZ$A;!*o~ZLDFX;;l%kuq$RFSzL{MK@VMPZTaA5iUnJdcJ? z_pS;HiK?J#dc3FnAL?0O<-s!sZP!G`0W$hm2N!pSZn4nKly|H!nkt^ zrDuj_Je*nKrz*sH!opxKTL{(n{Av;V`dvHtx$x972lKLg}+HjdDJ>kz+hlOIYn=-m9XV}N>1>rZ1-2?S5sJT?yULnH0 z_37Oe6Krb|KHkVt!YhTixe;DDW0dgmlVPh?c!db}*23phOt7sZUyYp$eG6(1IWq3m zr2zNUj?JA|IP&0<)#FDhE3OdX&PH*?gtFp^513Y*N=|rMxh)A!byWzPrr#f4>`zkF z!Z{#IGv-ene)4z2ZQ{?vw};n@XqNAKj;vDddqOTr20>4_y3o6HAPnfH=?mxr3zp}5 zSF8%N#lkG#ul1`bUr>RUWR!%dZbsei@#p=?y1A>|gF-%MgDy%B3a`yzBwf@V6v??9 zM(siI?Vs4%YULh+FdM8!?V<9q)hInwKDHXAhXjuOmOj=~?qM98oH>4`0#+R?%dHyB z6~d?w)8$s_Im_dDIOp()M(N?4!)1vc&N*C`I7zujgnZUYoP^RN!b>os^oVGi&4|(? zV*ZcqbTJjVN6Xg4PvS$XdZ=PyF| zHGY*l8HMkQSQm}rPL_^xj|-`{N`$=uV>C7)a*vC6Gb6el7p)1DMpTRF2ST>ms6Z`{ zYlV)+6-QC~foPdxkA>V5LblkTL@AIwTM9ZUB01GYtMI&>6ta;K zwI>CByqF29bapyGt#oUZ_n~n9YK^Ex=7%EoHQlzBjQB{%CS3=DBA~~J+>gYhI>xGq zxKY_!L{ITFwUUegnrVPKYNr80(J3>HbnBG&v2gxo71bg0V^&m0ica%tK^^N&J&kmpO$I?0(3lKF z?s+!3UPd(1c{aJ8&isT;2B8*cMgr=nH5rJ~PuS!J<$Wrge^^Bg$oy2q=!tOM22ylE zNb{6}&=sh=MifQz1re{+dk3QJ1<}xGYZ1ICWX1*s?n|JttU(LBC~C}e3Dn7|gOkze zq6nt?&uJC#{F0DUlTVlLynHnJ3ZE6=Cw}k3aBa|E2>MrKb9r3yOV=p=a0nHA8$i2*F0nzp{o7G5XUSYF9FbinR0;1CuHVa6}k+5!o(dr7hMLTPxC-kb2 zfwmHaRzPk=cgfXqD?0b8P$^DEr>mkf?LVbEfvyQTBRMZvq)mK?|E38F*~s3`x#dCc zz<~ME1pQh45B*0`kjwXld2=O06ZpBdfhhfw-`eI#dAEi0A1kRDskcQeqfKfiMR)ja1fea^v;`vf4toSd+dIZ1 z>9#2EE8+atc5OlCS8RO?b-gR(ENxIrBpp0iVDn@l3OnzLnrSAiPq{0)W-#KEyW+uz zw4GCwcTYGkNb|{^f>ZACDO2c_b<%!vLC_8~+JVSjC+U+LVf|pVT_^Q7*99WF9?!o% z6NFkI*OHy1?)&63Bd6+WjU-QM8Fo z0-`O@v;`u0lQsz+0nv67dz2tWuSk0>gRnKA`Go`2(WJSDfhc`N26VGXw@rDkO6ONr zQPhUiSEYR~+ep$DHVFhpK;sb*xm(yHAlhzWkJ?DlYwQsSr9d+bP)DstK$N~l9z`T6 zsl1YOer+Wsky?^5a}Op-(GX7tLR+9|3q)yBQ_6c?I={7DQ^2!v9gQ3TXcI}8w| o@A5F+X>ww}w0~)U@aO~02Ofyp{nGxW0YvS7nGS|e9qL;6KTsp>tpET3 delta 827 zcmXw%%}x|S6ovcTu4!g!`UeU|1WeRqiNO6wHs z8_`O-hP;eongv`_r1cu^S;RUG)z4qhE2ePS4U(TRh=Qdz=)VeW3m?P`vL1{iPDIQU zSv$i@smQJ~ths2Vc&p14;)5WDWoknXa@>YU?`^k@n9^l;NPfkMN><+KM5Ui-uBRcf zEwRgp$lAG{hRCjSJx%EYT=>)M`Lhz~C9Bs@v_L!!ReBfxH1GH$O5thR&qs^*bqld! zVZJft7Q<~tHfXWa-GC|^v>5v&#Z)f4_usJlZ51o;(ccY8Req(VhD_pE#4cnaYnK{w jB?o?|rKVi@ogOr061~K3C1Oxx$V6Hn7;=;S9>4hy_~BhC diff --git a/common/src/proto/komp_ac.table_validation.rs b/common/src/proto/komp_ac.table_validation.rs index bbf3bce..14213a2 100644 --- a/common/src/proto/komp_ac.table_validation.rs +++ b/common/src/proto/komp_ac.table_validation.rs @@ -206,6 +206,140 @@ pub struct ReplaceTableValidationResponse { #[prost(string, tag = "2")] pub message: ::prost::alloc::string::String, } +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ValidationRuleDefinition { + #[prost(string, tag = "1")] + pub name: ::prost::alloc::string::String, + #[prost(string, optional, tag = "2")] + pub description: ::core::option::Option<::prost::alloc::string::String>, + /// Reusable rule fragment. dataKey is ignored by the server for reusable rules. + #[prost(message, optional, tag = "3")] + pub validation: ::core::option::Option, +} +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ValidationSetDefinition { + #[prost(string, tag = "1")] + pub name: ::prost::alloc::string::String, + #[prost(string, optional, tag = "2")] + pub description: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, repeated, tag = "3")] + pub rule_names: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + /// Server-resolved snapshot of all rules in ruleNames order. + #[prost(message, optional, tag = "4")] + pub resolved_validation: ::core::option::Option, +} +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct UpsertValidationRuleRequest { + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, + #[prost(message, optional, tag = "2")] + pub rule: ::core::option::Option, +} +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct UpsertValidationRuleResponse { + #[prost(bool, tag = "1")] + pub success: bool, + #[prost(string, tag = "2")] + pub message: ::prost::alloc::string::String, +} +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListValidationRulesRequest { + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, +} +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListValidationRulesResponse { + #[prost(message, repeated, tag = "1")] + pub rules: ::prost::alloc::vec::Vec, +} +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DeleteValidationRuleRequest { + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub name: ::prost::alloc::string::String, +} +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DeleteValidationRuleResponse { + #[prost(bool, tag = "1")] + pub success: bool, + #[prost(string, tag = "2")] + pub message: ::prost::alloc::string::String, +} +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct UpsertValidationSetRequest { + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, + #[prost(message, optional, tag = "2")] + pub set: ::core::option::Option, +} +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct UpsertValidationSetResponse { + #[prost(bool, tag = "1")] + pub success: bool, + #[prost(string, tag = "2")] + pub message: ::prost::alloc::string::String, +} +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListValidationSetsRequest { + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, +} +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListValidationSetsResponse { + #[prost(message, repeated, tag = "1")] + pub sets: ::prost::alloc::vec::Vec, +} +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DeleteValidationSetRequest { + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub name: ::prost::alloc::string::String, +} +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DeleteValidationSetResponse { + #[prost(bool, tag = "1")] + pub success: bool, + #[prost(string, tag = "2")] + pub message: ::prost::alloc::string::String, +} +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ApplyValidationSetRequest { + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub table_name: ::prost::alloc::string::String, + #[prost(string, tag = "3")] + pub data_key: ::prost::alloc::string::String, + #[prost(string, tag = "4")] + pub set_name: ::prost::alloc::string::String, +} +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ApplyValidationSetResponse { + #[prost(bool, tag = "1")] + pub success: bool, + #[prost(string, tag = "2")] + pub message: ::prost::alloc::string::String, + #[prost(message, optional, tag = "3")] + pub validation: ::core::option::Option, +} /// Character length counting mode #[derive(serde::Serialize, serde::Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] @@ -509,6 +643,212 @@ pub mod table_validation_service_client { ); self.inner.unary(req, path, codec).await } + /// Reusable named rule fragments. + pub async fn upsert_validation_rule( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/komp_ac.table_validation.TableValidationService/UpsertValidationRule", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "komp_ac.table_validation.TableValidationService", + "UpsertValidationRule", + ), + ); + self.inner.unary(req, path, codec).await + } + pub async fn list_validation_rules( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/komp_ac.table_validation.TableValidationService/ListValidationRules", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "komp_ac.table_validation.TableValidationService", + "ListValidationRules", + ), + ); + self.inner.unary(req, path, codec).await + } + pub async fn delete_validation_rule( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/komp_ac.table_validation.TableValidationService/DeleteValidationRule", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "komp_ac.table_validation.TableValidationService", + "DeleteValidationRule", + ), + ); + self.inner.unary(req, path, codec).await + } + /// Reusable named sets composed from rules. + pub async fn upsert_validation_set( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/komp_ac.table_validation.TableValidationService/UpsertValidationSet", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "komp_ac.table_validation.TableValidationService", + "UpsertValidationSet", + ), + ); + self.inner.unary(req, path, codec).await + } + pub async fn list_validation_sets( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/komp_ac.table_validation.TableValidationService/ListValidationSets", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "komp_ac.table_validation.TableValidationService", + "ListValidationSets", + ), + ); + self.inner.unary(req, path, codec).await + } + pub async fn delete_validation_set( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/komp_ac.table_validation.TableValidationService/DeleteValidationSet", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "komp_ac.table_validation.TableValidationService", + "DeleteValidationSet", + ), + ); + self.inner.unary(req, path, codec).await + } + /// Snapshot a reusable set onto a concrete table field. + pub async fn apply_validation_set( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/komp_ac.table_validation.TableValidationService/ApplyValidationSet", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "komp_ac.table_validation.TableValidationService", + "ApplyValidationSet", + ), + ); + self.inner.unary(req, path, codec).await + } } } /// Generated server implementations. @@ -547,6 +887,58 @@ pub mod table_validation_service_server { tonic::Response, tonic::Status, >; + /// Reusable named rule fragments. + async fn upsert_validation_rule( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn list_validation_rules( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn delete_validation_rule( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + /// Reusable named sets composed from rules. + async fn upsert_validation_set( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn list_validation_sets( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn delete_validation_set( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + /// Snapshot a reusable set onto a concrete table field. + async fn apply_validation_set( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; } /// Service for storing and fetching field-validation definitions. #[derive(Debug)] @@ -777,6 +1169,353 @@ pub mod table_validation_service_server { }; Box::pin(fut) } + "/komp_ac.table_validation.TableValidationService/UpsertValidationRule" => { + #[allow(non_camel_case_types)] + struct UpsertValidationRuleSvc( + pub Arc, + ); + impl< + T: TableValidationService, + > tonic::server::UnaryService + for UpsertValidationRuleSvc { + type Response = super::UpsertValidationRuleResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::upsert_validation_rule( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = UpsertValidationRuleSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/komp_ac.table_validation.TableValidationService/ListValidationRules" => { + #[allow(non_camel_case_types)] + struct ListValidationRulesSvc(pub Arc); + impl< + T: TableValidationService, + > tonic::server::UnaryService + for ListValidationRulesSvc { + type Response = super::ListValidationRulesResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::list_validation_rules( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = ListValidationRulesSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/komp_ac.table_validation.TableValidationService/DeleteValidationRule" => { + #[allow(non_camel_case_types)] + struct DeleteValidationRuleSvc( + pub Arc, + ); + impl< + T: TableValidationService, + > tonic::server::UnaryService + for DeleteValidationRuleSvc { + type Response = super::DeleteValidationRuleResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::delete_validation_rule( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = DeleteValidationRuleSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/komp_ac.table_validation.TableValidationService/UpsertValidationSet" => { + #[allow(non_camel_case_types)] + struct UpsertValidationSetSvc(pub Arc); + impl< + T: TableValidationService, + > tonic::server::UnaryService + for UpsertValidationSetSvc { + type Response = super::UpsertValidationSetResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::upsert_validation_set( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = UpsertValidationSetSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/komp_ac.table_validation.TableValidationService/ListValidationSets" => { + #[allow(non_camel_case_types)] + struct ListValidationSetsSvc(pub Arc); + impl< + T: TableValidationService, + > tonic::server::UnaryService + for ListValidationSetsSvc { + type Response = super::ListValidationSetsResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::list_validation_sets( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = ListValidationSetsSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/komp_ac.table_validation.TableValidationService/DeleteValidationSet" => { + #[allow(non_camel_case_types)] + struct DeleteValidationSetSvc(pub Arc); + impl< + T: TableValidationService, + > tonic::server::UnaryService + for DeleteValidationSetSvc { + type Response = super::DeleteValidationSetResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::delete_validation_set( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = DeleteValidationSetSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/komp_ac.table_validation.TableValidationService/ApplyValidationSet" => { + #[allow(non_camel_case_types)] + struct ApplyValidationSetSvc(pub Arc); + impl< + T: TableValidationService, + > tonic::server::UnaryService + for ApplyValidationSetSvc { + type Response = super::ApplyValidationSetResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::apply_validation_set( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = ApplyValidationSetSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } _ => { Box::pin(async move { let mut response = http::Response::new( diff --git a/server b/server index f828b76..93ceaf4 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit f828b7688aab3ce32a5976819660d2454ea579e2 +Subproject commit 93ceaf42677e7c42be22c077fa9e22457c38c9a6 diff --git a/validation-core/Cargo.toml b/validation-core/Cargo.toml index d11d19b..e6570ee 100644 --- a/validation-core/Cargo.toml +++ b/validation-core/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true -description = "Shared validation primitives, recipes, and package metadata." +description = "Shared validation primitives, rules, and sets." repository.workspace = true [dependencies] diff --git a/validation-core/docs/architecture/validation.md b/validation-core/docs/architecture/validation.md index e2f0a39..88ba62f 100644 --- a/validation-core/docs/architecture/validation.md +++ b/validation-core/docs/architecture/validation.md @@ -5,7 +5,7 @@ Validation is split into three ownership layers. ```mermaid flowchart LR Server[server
stores simple settings
binds table fields
enforces writes] - Core[validation-core
owns meaning
resolves recipes
runs pure validation] + Core[validation-core
owns meaning
resolves sets
runs pure validation] Canvas[canvas
editor integration
masking while typing
UI feedback] Common[common/proto
wire format] @@ -23,13 +23,14 @@ settings mean. `canvas` uses the resolved result for editing behavior. ```mermaid flowchart TD Settings[ValidationSettings
serializable data] - Recipe[ValidationRecipe
named reusable settings] - Package[ValidationPackage
distributable recipes] + Rule[ValidationRule
named reusable fragment] + Set[ValidationSet
ordered rules] Config[ValidationConfig
resolved runtime config] Result[ValidationResult] - Package --> Recipe - Recipe --> Settings + Rule --> Settings + Set --> Rule + Set --> Settings Settings --> Config Config --> Result ``` @@ -50,30 +51,32 @@ sequenceDiagram Client->>Client: canvas editing, masks, errors ``` -## Future Package Flow +## Set Flow ```mermaid flowchart LR - Registry[validation package registry] - Package[phone package] - Recipe[phone.e164 recipe] + RuleA[digits-only rule] + RuleB[phone-length rule] + RuleC[phone-mask rule] + Set[phone set] Assignment[column assignment] - Stored[server stored settings
recipe ref + resolved config] + Stored[server stored settings
set name + resolved config] Runtime[server/canvas runtime] - Registry --> Package - Package --> Recipe - Recipe --> Assignment + RuleA --> Set + RuleB --> Set + RuleC --> Set + Set --> Assignment Assignment --> Stored Stored --> Runtime ``` -The server may store both the recipe reference and the resolved settings: +The server stores reusable rules and sets, and field application stores a +resolved snapshot: ```text -field customer_phone uses phone.e164@1.0.0 +field customer_phone uses set phone resolved settings = {...} ``` -That keeps package imports inspectable and versioned while preserving stable -backend enforcement even if a package changes later. +That keeps backend enforcement stable even if the reusable set changes later. diff --git a/validation-core/src/config.rs b/validation-core/src/config.rs index c6d1fe5..78222ad 100644 --- a/validation-core/src/config.rs +++ b/validation-core/src/config.rs @@ -2,6 +2,8 @@ use crate::rules::{ CharacterFilter, CharacterLimits, DisplayMask, PatternFilters, PositionFilter, PositionRange, }; use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use thiserror::Error; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AllowedValues { @@ -60,6 +62,7 @@ pub enum CharacterFilterSettings { Alphanumeric, Exact(char), OneOf(Vec), + Regex(String), } impl CharacterFilterSettings { @@ -70,6 +73,22 @@ impl CharacterFilterSettings { Self::Alphanumeric => CharacterFilter::Alphanumeric, Self::Exact(ch) => CharacterFilter::Exact(*ch), Self::OneOf(chars) => CharacterFilter::OneOf(chars.clone()), + Self::Regex(pattern) => { + #[cfg(feature = "regex")] + { + match regex::Regex::new(pattern) { + Ok(regex) => CharacterFilter::Custom(Arc::new(move |ch| { + regex.is_match(&ch.to_string()) + })), + Err(_) => CharacterFilter::Custom(Arc::new(|_| false)), + } + } + #[cfg(not(feature = "regex"))] + { + let _ = pattern; + CharacterFilter::Custom(Arc::new(|_| false)) + } + } } } } @@ -126,6 +145,68 @@ impl ValidationSettings { external_validation_enabled: self.external_validation_enabled, } } + + pub fn merge_rules<'a>( + rules: impl IntoIterator, + ) -> Result { + let mut merged = ValidationSettings::default(); + + for rule in rules { + merged.merge_rule(rule)?; + } + + Ok(merged) + } + + pub fn merge_rule(&mut self, rule: &ValidationSettings) -> Result<(), ValidationMergeError> { + self.required |= rule.required; + self.external_validation_enabled |= rule.external_validation_enabled; + + merge_singleton( + "character_limits", + &mut self.character_limits, + &rule.character_limits, + )?; + merge_singleton("allowed_values", &mut self.allowed_values, &rule.allowed_values)?; + merge_singleton("display_mask", &mut self.display_mask, &rule.display_mask)?; + merge_singleton("formatter", &mut self.formatter, &rule.formatter)?; + + if let Some(pattern) = &rule.pattern { + match &mut self.pattern { + Some(existing) => { + existing.filters.extend(pattern.filters.clone()); + if existing.description.is_none() { + existing.description = pattern.description.clone(); + } + } + None => self.pattern = Some(pattern.clone()), + } + } + + Ok(()) + } +} + +fn merge_singleton( + field_name: &'static str, + target: &mut Option, + source: &Option, +) -> Result<(), ValidationMergeError> { + if let Some(source) = source { + if target.is_some() { + return Err(ValidationMergeError::DuplicateSingleton { field_name }); + } + + *target = Some(source.clone()); + } + + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ValidationMergeError { + #[error("validation set contains more than one rule configuring {field_name}")] + DuplicateSingleton { field_name: &'static str }, } #[derive(Debug, Clone, Default)] diff --git a/validation-core/src/lib.rs b/validation-core/src/lib.rs index 8b3e469..07c6f87 100644 --- a/validation-core/src/lib.rs +++ b/validation-core/src/lib.rs @@ -1,16 +1,14 @@ pub mod config; -pub mod recipe; pub mod rules; +pub mod set; pub use config::{ AllowedValues, CharacterFilterSettings, FormatterOption, FormatterSettings, PatternSettings, - PositionFilterSettings, ValidationConfig, ValidationResult, ValidationSettings, -}; -pub use recipe::{ - AppliedValidation, PackageId, PackageRequirement, RecipeId, RecipeReference, - ValidationPackage, ValidationRecipe, + PositionFilterSettings, ValidationConfig, ValidationMergeError, ValidationResult, + ValidationSettings, }; pub use rules::{ count_text, CharacterFilter, CharacterLimits, CountMode, DisplayMask, LimitCheckResult, MaskDisplayMode, PatternFilters, PositionFilter, PositionRange, }; +pub use set::{AppliedValidation, ValidationRule, ValidationSet}; diff --git a/validation-core/src/recipe.rs b/validation-core/src/recipe.rs deleted file mode 100644 index 3231e43..0000000 --- a/validation-core/src/recipe.rs +++ /dev/null @@ -1,63 +0,0 @@ -use crate::{ValidationConfig, ValidationSettings}; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct PackageId(pub String); - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct RecipeId(pub String); - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ValidationRecipe { - pub id: RecipeId, - pub name: String, - pub description: Option, - pub settings: ValidationSettings, -} - -impl ValidationRecipe { - pub fn resolve(&self) -> ValidationConfig { - self.settings.resolve() - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ValidationPackage { - pub id: PackageId, - pub name: String, - pub version: String, - pub description: Option, - pub recipes: Vec, - pub dependencies: Vec, -} - -impl ValidationPackage { - pub fn recipe(&self, id: &RecipeId) -> Option<&ValidationRecipe> { - self.recipes.iter().find(|recipe| &recipe.id == id) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PackageRequirement { - pub package_id: PackageId, - pub version_requirement: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RecipeReference { - pub package_id: PackageId, - pub recipe_id: RecipeId, - pub version: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AppliedValidation { - pub source: Option, - pub settings: ValidationSettings, -} - -impl AppliedValidation { - pub fn resolve(&self) -> ValidationConfig { - self.settings.resolve() - } -} diff --git a/validation-core/src/rules/character_limits.rs b/validation-core/src/rules/character_limits.rs index 5aa931b..f65950b 100644 --- a/validation-core/src/rules/character_limits.rs +++ b/validation-core/src/rules/character_limits.rs @@ -73,6 +73,26 @@ impl CharacterLimits { count_mode: CountMode::default(), } } + + /// Create new character limits with just minimum length + pub fn new_min(min_length: usize) -> Self { + Self { + max_length: None, + min_length: Some(min_length), + warning_threshold: None, + count_mode: CountMode::default(), + } + } + + /// Create new character limits with only a warning threshold. + pub fn new_warning(threshold: usize) -> Self { + Self { + max_length: None, + min_length: None, + warning_threshold: Some(threshold), + count_mode: CountMode::default(), + } + } /// Set warning threshold (when to show warning before hitting limit) pub fn with_warning_threshold(mut self, threshold: usize) -> Self { diff --git a/validation-core/src/rules/display_mask.rs b/validation-core/src/rules/display_mask.rs index 3db82c1..c6870ee 100644 --- a/validation-core/src/rules/display_mask.rs +++ b/validation-core/src/rules/display_mask.rs @@ -237,6 +237,11 @@ impl DisplayMask { &self.pattern } + /// Get the input placeholder character + pub fn input_char(&self) -> char { + self.input_char + } + /// Get the position of the first input character in the pattern pub fn first_input_position(&self) -> usize { for (pos, ch) in self.pattern.chars().enumerate() { diff --git a/validation-core/src/set.rs b/validation-core/src/set.rs new file mode 100644 index 0000000..a8cb289 --- /dev/null +++ b/validation-core/src/set.rs @@ -0,0 +1,118 @@ +use crate::{ValidationConfig, ValidationMergeError, ValidationSettings}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidationRule { + pub name: String, + pub description: Option, + pub settings: ValidationSettings, +} + +impl ValidationRule { + pub fn resolve(&self) -> ValidationConfig { + self.settings.resolve() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidationSet { + pub name: String, + pub description: Option, + pub rules: Vec, +} + +impl ValidationSet { + pub fn resolve_settings(&self) -> Result { + ValidationSettings::merge_rules(self.rules.iter().map(|rule| &rule.settings)) + } + + pub fn resolve(&self) -> Result { + Ok(self.resolve_settings()?.resolve()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppliedValidation { + pub set_name: Option, + pub settings: ValidationSettings, +} + +impl AppliedValidation { + pub fn resolve(&self) -> ValidationConfig { + self.settings.resolve() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + CharacterFilterSettings, CharacterLimits, PatternSettings, PositionFilterSettings, + PositionRange, + }; + + #[test] + fn validation_set_merges_rule_fragments() { + let set = ValidationSet { + name: "phone".to_string(), + description: None, + rules: vec![ + ValidationRule { + name: "phone-length".to_string(), + description: None, + settings: ValidationSettings { + character_limits: Some(CharacterLimits::new_range(10, 15)), + ..ValidationSettings::default() + }, + }, + ValidationRule { + name: "digits-only".to_string(), + description: None, + settings: ValidationSettings { + pattern: Some(PatternSettings { + filters: vec![PositionFilterSettings { + positions: PositionRange::From(0), + filter: CharacterFilterSettings::Numeric, + }], + description: None, + }), + ..ValidationSettings::default() + }, + }, + ], + }; + + let settings = set.resolve_settings().expect("set should resolve"); + + assert!(settings.character_limits.is_some()); + assert_eq!(settings.pattern.expect("pattern").filters.len(), 1); + } + + #[test] + fn validation_set_rejects_duplicate_singleton_rules() { + let set = ValidationSet { + name: "conflict".to_string(), + description: None, + rules: vec![ + ValidationRule { + name: "short".to_string(), + description: None, + settings: ValidationSettings { + character_limits: Some(CharacterLimits::new(10)), + ..ValidationSettings::default() + }, + }, + ValidationRule { + name: "long".to_string(), + description: None, + settings: ValidationSettings { + character_limits: Some(CharacterLimits::new(20)), + ..ValidationSettings::default() + }, + }, + ], + }; + + assert!(set.resolve_settings().is_err()); + } +}