From a67fb8ef4e2040818d92a73a2d7cf45cc29bd5e3 Mon Sep 17 00:00:00 2001 From: Alex Tymchenko Date: Tue, 13 Jan 2026 17:34:29 +0000 Subject: [PATCH 01/16] Bootstrap the library at version `2.0.0-snapshot.1`. --- .gitignore | 52 + README.md | 307 ++++++ package.json | 17 + packages/example/.gitignore | 22 + packages/example/README.md | 145 +++ packages/example/buf.gen.yaml | 7 + packages/example/buf.yaml | 9 + packages/example/package.json | 23 + packages/example/proto/product.proto | 118 +++ packages/example/proto/spine/options.proto | 958 ++++++++++++++++++ packages/example/proto/user.proto | 53 + packages/example/src/index.ts | 129 +++ packages/example/tsconfig.json | 21 + packages/spine-validation-ts/.gitignore | 34 + .../DESCRIPTOR_API_GUIDE.md | 430 ++++++++ .../spine-validation-ts/QUICK_REFERENCE.md | 202 ++++ packages/spine-validation-ts/README.md | 251 +++++ packages/spine-validation-ts/buf.gen.yaml | 6 + packages/spine-validation-ts/buf.yaml | 9 + packages/spine-validation-ts/jest.config.js | 22 + packages/spine-validation-ts/package.json | 48 + .../proto/spine/base/field_path.proto | 59 ++ .../proto/spine/options.proto | 958 ++++++++++++++++++ .../proto/spine/validate/error_message.proto | 70 ++ .../spine/validate/validation_error.proto | 133 +++ packages/spine-validation-ts/src/index.ts | 26 + .../src/options-registry.ts | 54 + .../src/options/distinct.ts | 170 ++++ .../spine-validation-ts/src/options/goes.ts | 190 ++++ .../src/options/min-max.ts | 331 ++++++ .../src/options/pattern.ts | 160 +++ .../spine-validation-ts/src/options/range.ts | 307 ++++++ .../src/options/required-field.ts | 287 ++++++ .../src/options/required.ts | 137 +++ .../src/options/validate.ts | 258 +++++ .../spine-validation-ts/src/validation.ts | 135 +++ .../tests/basic-validation.test.ts | 24 + .../spine-validation-ts/tests/buf.gen.yaml | 6 + packages/spine-validation-ts/tests/buf.yaml | 9 + .../tests/distinct.test.ts | 371 +++++++ .../spine-validation-ts/tests/goes.test.ts | 524 ++++++++++ .../tests/integration.test.ts | 642 ++++++++++++ .../spine-validation-ts/tests/min-max.test.ts | 457 +++++++++ .../spine-validation-ts/tests/pattern.test.ts | 178 ++++ .../tests/proto/integration-account.proto | 55 + .../tests/proto/integration-product.proto | 124 +++ .../tests/proto/integration-user.proto | 58 ++ .../tests/proto/spine/options.proto | 958 ++++++++++++++++++ .../tests/proto/test-distinct.proto | 90 ++ .../tests/proto/test-goes.proto | 121 +++ .../tests/proto/test-min-max.proto | 99 ++ .../tests/proto/test-pattern.proto | 96 ++ .../tests/proto/test-range.proto | 88 ++ .../tests/proto/test-required-field.proto | 74 ++ .../tests/proto/test-required.proto | 52 + .../tests/proto/test-validate.proto | 150 +++ .../spine-validation-ts/tests/range.test.ts | 417 ++++++++ .../tests/required-field.test.ts | 370 +++++++ .../tests/required.test.ts | 129 +++ .../tests/validate.test.ts | 486 +++++++++ packages/spine-validation-ts/tsconfig.json | 20 + 61 files changed, 11736 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 packages/example/.gitignore create mode 100644 packages/example/README.md create mode 100644 packages/example/buf.gen.yaml create mode 100644 packages/example/buf.yaml create mode 100644 packages/example/package.json create mode 100644 packages/example/proto/product.proto create mode 100644 packages/example/proto/spine/options.proto create mode 100644 packages/example/proto/user.proto create mode 100644 packages/example/src/index.ts create mode 100644 packages/example/tsconfig.json create mode 100644 packages/spine-validation-ts/.gitignore create mode 100644 packages/spine-validation-ts/DESCRIPTOR_API_GUIDE.md create mode 100644 packages/spine-validation-ts/QUICK_REFERENCE.md create mode 100644 packages/spine-validation-ts/README.md create mode 100644 packages/spine-validation-ts/buf.gen.yaml create mode 100644 packages/spine-validation-ts/buf.yaml create mode 100644 packages/spine-validation-ts/jest.config.js create mode 100644 packages/spine-validation-ts/package.json create mode 100644 packages/spine-validation-ts/proto/spine/base/field_path.proto create mode 100644 packages/spine-validation-ts/proto/spine/options.proto create mode 100644 packages/spine-validation-ts/proto/spine/validate/error_message.proto create mode 100644 packages/spine-validation-ts/proto/spine/validate/validation_error.proto create mode 100644 packages/spine-validation-ts/src/index.ts create mode 100644 packages/spine-validation-ts/src/options-registry.ts create mode 100644 packages/spine-validation-ts/src/options/distinct.ts create mode 100644 packages/spine-validation-ts/src/options/goes.ts create mode 100644 packages/spine-validation-ts/src/options/min-max.ts create mode 100644 packages/spine-validation-ts/src/options/pattern.ts create mode 100644 packages/spine-validation-ts/src/options/range.ts create mode 100644 packages/spine-validation-ts/src/options/required-field.ts create mode 100644 packages/spine-validation-ts/src/options/required.ts create mode 100644 packages/spine-validation-ts/src/options/validate.ts create mode 100644 packages/spine-validation-ts/src/validation.ts create mode 100644 packages/spine-validation-ts/tests/basic-validation.test.ts create mode 100644 packages/spine-validation-ts/tests/buf.gen.yaml create mode 100644 packages/spine-validation-ts/tests/buf.yaml create mode 100644 packages/spine-validation-ts/tests/distinct.test.ts create mode 100644 packages/spine-validation-ts/tests/goes.test.ts create mode 100644 packages/spine-validation-ts/tests/integration.test.ts create mode 100644 packages/spine-validation-ts/tests/min-max.test.ts create mode 100644 packages/spine-validation-ts/tests/pattern.test.ts create mode 100644 packages/spine-validation-ts/tests/proto/integration-account.proto create mode 100644 packages/spine-validation-ts/tests/proto/integration-product.proto create mode 100644 packages/spine-validation-ts/tests/proto/integration-user.proto create mode 100644 packages/spine-validation-ts/tests/proto/spine/options.proto create mode 100644 packages/spine-validation-ts/tests/proto/test-distinct.proto create mode 100644 packages/spine-validation-ts/tests/proto/test-goes.proto create mode 100644 packages/spine-validation-ts/tests/proto/test-min-max.proto create mode 100644 packages/spine-validation-ts/tests/proto/test-pattern.proto create mode 100644 packages/spine-validation-ts/tests/proto/test-range.proto create mode 100644 packages/spine-validation-ts/tests/proto/test-required-field.proto create mode 100644 packages/spine-validation-ts/tests/proto/test-required.proto create mode 100644 packages/spine-validation-ts/tests/proto/test-validate.proto create mode 100644 packages/spine-validation-ts/tests/range.test.ts create mode 100644 packages/spine-validation-ts/tests/required-field.test.ts create mode 100644 packages/spine-validation-ts/tests/required.test.ts create mode 100644 packages/spine-validation-ts/tests/validate.test.ts create mode 100644 packages/spine-validation-ts/tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72d4b94 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +*.tsbuildinfo + +# Generated code (Protobuf) +src/generated/ +tests/generated/ + +# Test coverage +coverage/ +*.lcov +.nyc_output/ + +# IDE and editors +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# OS files +Thumbs.db +.AppleDouble +.LSOverride + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +pnpm-debug.log* + +# Temporary files +*.tmp +.cache/ +.temp/ + +# Environment and local config +.env +.env.local +.env.*.local +.claude/settings.local.json + +# Package manager lock files (libraries should not commit lock files) +package-lock.json +yarn.lock +pnpm-lock.yaml diff --git a/README.md b/README.md new file mode 100644 index 0000000..2cca4f0 --- /dev/null +++ b/README.md @@ -0,0 +1,307 @@ +# Spine Validation TypeScript + +> Runtime validation for Protobuf messages with [Spine Event Engine](https://spine.io/) validation constraints. + +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) +[![Protobuf-ES](https://img.shields.io/badge/protobuf--es-v2-green.svg)](https://github.com/bufbuild/protobuf-es) + +A TypeScript validation library for Protobuf messages using Spine validation options, built on [@bufbuild/protobuf](https://github.com/bufbuild/protobuf-es) (Protobuf-ES v2). + +--- + +## πŸ’‘ Why Use This? + +### For Spine Event Engine Users + +**You already have validation rules in your backend.** Now bring them to your TypeScript/JavaScript frontend with zero duplication! + +If you're using [Spine Event Engine](https://spine.io/) with its Validation library on the server side, your Protobuf messages already have validation constraints defined using Spine options like `(required)`, `(pattern)`, `(min)`, `(max)`, etc. + +**This library lets you:** +- βœ… **Reuse the same validation rules** in your frontend that you defined in your backend. +- βœ… **Maintain a single source of truth** - validation logic lives in your `.proto` files. +- βœ… **Keep frontend and backend validation in sync** automatically. +- βœ… **Get type-safe validation** with full TypeScript support. +- βœ… **Display the same error messages** to users that your backend generates. + +**No code duplication. No maintenance burden. Just add this library and validate.** + +### For New Users + +Even if you're not using Spine Event Engine, this library provides a powerful way to add runtime validation to your Protobuf-based TypeScript applications: + +- βœ… **Define validation in `.proto` files** using declarative Spine validation options. +- βœ… **Type-safe, runtime validation** for your Protobuf messages. +- βœ… **Clear, customizable error messages** for better UX. +- βœ… **Works with Protobuf-ES v2** and modern tooling. +- βœ… **Extensible architecture** for custom validation logic. + +--- + +## ✨ Features + +**Comprehensive Validation Support:** + +- βœ… **`(required)`** - Ensure fields have non-default values. +- πŸ”€ **`(pattern)`** - Regex validation for strings. +- πŸ”’ **`(min)` / `(max)`** - Numeric bounds with inclusive/exclusive support. +- πŸ“Š **`(range)`** - Bounded ranges with bracket notation `[min..max]`. +- πŸ” **`(distinct)`** - Enforce uniqueness in repeated fields. +- πŸ—οΈ **`(validate)`** - Recursive nested message validation. +- πŸ”— **`(goes)`** - Field dependency constraints. +- 🎯 **`(required_field)`** - Complex required field combinations with boolean logic. + +**Developer Experience:** + +- πŸš€ Full TypeScript type safety. +- πŸ“ Custom error messages. +- πŸ§ͺ 223+ comprehensive tests. +- πŸ“š Extensive documentation. +- 🎨 Clean, readable error formatting. + +### ⚠️ Known Limitations + +- **`(set_once)`** - Not currently supported. This option requires state tracking across multiple validations, which is outside the scope of single-message validation. If you need this feature, please [open an issue](../../issues). + +--- + +## πŸš€ Quick Start + +### Installation + +```bash +npm install @spine-event-engine/validation-ts @bufbuild/protobuf +``` + +### Basic Usage + +**Step 1: Define validation in your `.proto` file** + +```protobuf +syntax = "proto3"; + +import "spine/options.proto"; + +message User { + string name = 1 [(required) = true]; + + string email = 2 [ + (required) = true, + (pattern).regex = "^[^@]+@[^@]+\\.[^@]+$", + (pattern).error_msg = "Email must be valid. Provided: `{value}`." + ]; + + int32 age = 3 [ + (min).value = "0", + (max).value = "150" + ]; + + repeated string tags = 4 [(distinct) = true]; +} +``` + +**Step 2: Use validation in TypeScript** + +```typescript +import { create } from '@bufbuild/protobuf'; +import { validate, formatViolations } from '@spine-event-engine/validation-ts'; +import { UserSchema } from './generated/user_pb'; + +// Create a message +const user = create(UserSchema, { + name: '', // Missing required field + email: 'invalid-email' // Invalid pattern +}); + +// Validate +const violations = validate(UserSchema, user); + +if (violations.length > 0) { + console.log(formatViolations(violations)); + // Output: + // 1. User.name: A value must be set. + // 2. User.email: Email must be valid. Provided: `invalid-email`. +} +``` + +--- + +## πŸ“¦ What's Included + +This repository is structured as an npm workspace: + +``` +validation-ts/ +β”œβ”€β”€ packages/ +β”‚ β”œβ”€β”€ spine-validation-ts/ # πŸ“¦ Main validation package +β”‚ β”‚ β”œβ”€β”€ src/ # Source code +β”‚ β”‚ β”œβ”€β”€ tests/ # 223 comprehensive tests +β”‚ β”‚ β”œβ”€β”€ proto/ # Spine validation proto definitions +β”‚ β”‚ └── README.md # Full package documentation +β”‚ β”‚ +β”‚ └── example/ # 🎯 Example project +β”‚ β”œβ”€β”€ proto/ # Example proto files +β”‚ β”œβ”€β”€ src/ # Example usage code +β”‚ └── README.md # Example documentation +β”‚ +└── README.md # You are here +``` + +--- + +## πŸŽ“ Documentation + +- **[Package README](packages/spine-validation-ts/README.md)** - Complete API documentation and usage guide. +- **[Descriptor API Guide](packages/spine-validation-ts/DESCRIPTOR_API_GUIDE.md)** - Working with message and field descriptors. +- **[Quick Reference](packages/spine-validation-ts/QUICK_REFERENCE.md)** - Cheat sheet for common operations. + +--- + +## πŸ› οΈ Development + +### Setup + +```bash +# Clone the repository +git clone +cd validation-ts + +# Install dependencies +npm install +``` + +### Build & Test + +```bash +# Build the validation package +npm run build + +# Run all tests (223 tests) +npm test + +# Run the example project +npm run example +``` + +### Workspace Scripts + +| Command | Description | +|---------|-------------| +| `npm run build` | Build the validation package. | +| `npm test` | Run all validation tests. | +| `npm run example` | Run the example project. | + +--- + +## πŸ“‹ Validation Options Reference + +### Field-Level Options + +| Option | Description | Example | +|--------|-------------|---------| +| `(required)` | Field must have a non-default value. | `[(required) = true]` | +| `(if_missing)` | Custom error for missing field. | `[(if_missing).error_msg = "Name is required"]` | +| `(pattern)` | Regex pattern matching. | `[(pattern).regex = "^[A-Z].*"]` | +| `(min)` / `(max)` | Numeric minimum/maximum. | `[(min).value = "0", (max).value = "100"]` | +| `(range)` | Bounded numeric range. | `[(range) = "[0..100]"]` | +| `(distinct)` | Unique repeated elements. | `[(distinct) = true]` | +| `(validate)` | Validate nested messages. | `[(validate) = true]` | +| `(if_invalid)` | Custom error for nested validation. | `[(if_invalid).error_msg = "Invalid address"]` | +| `(goes)` | Field dependency. | `[(goes).with = "other_field"]` | + +### Message-Level Options + +| Option | Description | Example | +|--------|-------------|---------| +| `(required_field)` | Required field combinations. | `option (required_field) = "id \| email";` | + +### Not Supported + +| Option | Status | Notes | +|--------|--------|-------| +| `(set_once)` | ❌ Not supported | Requires state tracking across validations. See [limitations](#-known-limitations). | + +--- + +## βœ… Test Coverage + +The package includes comprehensive test coverage: + +- **223 tests** across 10 test suites. +- **All validation options** thoroughly tested. +- **Integration tests** combining multiple constraints. +- **Edge cases** and real-world scenarios. +- **100% coverage** of validation logic. + +Test suites: +- Basic validation. +- Required fields. +- Pattern matching. +- Min/Max constraints. +- Range validation. +- Distinct elements. +- Nested validation. +- Field dependencies (goes). +- Required field combinations. +- Integration scenarios. + +--- + +## πŸ“ Example Output + +When validation fails, you get clear, actionable error messages: + +``` +Validation failed: +1. User.name: A value must be set. +2. User.email: Email must be valid. Provided: `invalid-email`. +3. User.age: Value must be at least 0. Provided: -5. +4. User.tags: Values must be distinct. Duplicates found: ["test"]. +``` + +--- + +## πŸ—οΈ Architecture + +The validation system is built with extensibility in mind: + +- **`validation.ts`** - Core validation engine using the visitor pattern. +- **`options-registry.ts`** - Dynamic registration of validation options. +- **`options/`** - Modular validators for each Spine option. +- **Proto-first** - Validation rules defined in `.proto` files. +- **Type-safe** - Full TypeScript support with generated types. + +--- + +## 🀝 Contributing + +Contributions are welcome! Please ensure: + +1. All tests pass: `npm test`. +2. Code follows existing patterns. +3. New features include tests. +4. Documentation is updated. + +--- + +## πŸ“„ License + +Apache 2.0. + +--- + +## πŸ”— Related Projects + +- [Spine Event Engine](https://spine.io/) - Event-driven framework for CQRS/ES applications. +- [Protobuf-ES](https://github.com/bufbuild/protobuf-es) - Protocol Buffers for ECMAScript. +- [Buf](https://buf.build/) - Modern Protobuf tooling. + +--- + +
+ +**Made with ❀️ for the Spine Event Engine ecosystem.** + +[Documentation](packages/spine-validation-ts/README.md) · [Examples](packages/example) · [Report Bug](../../issues) + +
diff --git a/package.json b/package.json new file mode 100644 index 0000000..b888c37 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "@spine-event-engine/validation-ts-workspace", + "version": "2.0.0-snapshot.1", + "private": true, + "workspaces": [ + "packages/*" + ], + "scripts": { + "build": "npm run build --workspace=@spine-event-engine/validation-ts", + "test": "npm test --workspace=@spine-event-engine/validation-ts", + "example": "npm start --workspace=@spine-event-engine/example-smoke" + }, + "keywords": [], + "author": "", + "license": "Apache-2.0", + "description": "TypeScript validation library for Protobuf messages with Spine validation options" +} diff --git a/packages/example/.gitignore b/packages/example/.gitignore new file mode 100644 index 0000000..5ee562c --- /dev/null +++ b/packages/example/.gitignore @@ -0,0 +1,22 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Generated files +src/generated/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* diff --git a/packages/example/README.md b/packages/example/README.md new file mode 100644 index 0000000..9927bcf --- /dev/null +++ b/packages/example/README.md @@ -0,0 +1,145 @@ +# Spine Validation TypeScript - Example Project + +A standalone example demonstrating runtime validation of Protobuf messages with Spine validation constraints. + +## What This Example Shows + +- Defining Protobuf messages with Spine validation options. +- Validating messages at runtime. +- Programmatically handling validation violations. +- Various validation scenarios (required fields, patterns, ranges, etc.). + +## Quick Start + +### Install Dependencies + +```bash +npm install +``` + +### Run the Example + +```bash +npm start +``` + +This will: +1. Generate TypeScript code from `.proto` files. +2. Build the TypeScript code. +3. Run the example showing various validation scenarios. + +## Project Structure + +``` +example/ +β”œβ”€β”€ proto/ +β”‚ β”œβ”€β”€ user.proto # User message with validation constraints +β”‚ └── product.proto # Product message with validation examples +β”œβ”€β”€ src/ +β”‚ └── index.ts # Example validation code +β”œβ”€β”€ package.json +└── README.md # This file +``` + +## Expected Output + +When you run the example, you'll see validation results for different scenarios: + +``` +=== Spine Validation Example === + +Example 1: Valid User +--------------------- +Violations: 0 +No violations + +Example 2: Missing Required Email +---------------------------------- +Violations: 1 +1. example.User.email: A value must be set. + +... +``` + +## Key Code Pattern + +The example demonstrates the core validation pattern: + +```typescript +import { create } from '@bufbuild/protobuf'; +import { validate } from '@spine-event-engine/validation-ts'; +import { UserSchema } from './generated/user_pb'; + +// Create a message +const user = create(UserSchema, { + name: 'John Doe', + email: 'john@example.com' +}); + +// Validate the message +const violations = validate(UserSchema, user); + +// Handle violations programmatically +if (violations.length === 0) { + // Valid - proceed with business logic + processUser(user); +} else { + // Invalid - handle errors + violations.forEach(v => { + console.log(`Field: ${v.fieldPath}`); + console.log(`Error: ${v.message}`); + }); +} +``` + +## Validation Options Used + +The example proto files demonstrate these Spine validation options: + +- `(required)` - Field must have a non-default value. +- `(pattern)` - String must match a regex pattern. +- `(min)` / `(max)` - Numeric bounds. +- `(range)` - Numeric ranges with bracket notation. +- `(distinct)` - Unique elements in repeated fields. +- `(validate)` - Nested message validation. +- `(goes)` - Field dependency constraints. +- `(required_field)` - Message-level field combinations. + +## Learn More + +For complete documentation: + +- **[Validation Library README](../spine-validation-ts/README.md)** - Full API documentation. +- **[Root README](../../README.md)** - Project overview and setup. +- **[Spine Event Engine](https://spine.io/)** - Server-side validation framework. + +## Adding Your Own Validation + +1. Create a `.proto` file in the `proto/` directory. +2. Import `spine/options.proto`. +3. Add validation options to your message fields. +4. Run `npm run generate` to generate TypeScript code. +5. Use the generated schemas in your code. + +Example: + +```protobuf +syntax = "proto3"; + +import "spine/options.proto"; + +message Order { + string order_id = 1 [ + (required) = true, + (pattern).regex = "^ORD-[0-9]{6}$" + ]; + + double total = 2 [ + (min).value = "0.01" + ]; +} +``` + +## License + +Apache License 2.0. diff --git a/packages/example/buf.gen.yaml b/packages/example/buf.gen.yaml new file mode 100644 index 0000000..fa2f561 --- /dev/null +++ b/packages/example/buf.gen.yaml @@ -0,0 +1,7 @@ +version: v2 +plugins: + - local: protoc-gen-es + out: src/generated + opt: + - target=ts + - import_extension=js diff --git a/packages/example/buf.yaml b/packages/example/buf.yaml new file mode 100644 index 0000000..eb0c845 --- /dev/null +++ b/packages/example/buf.yaml @@ -0,0 +1,9 @@ +version: v2 +modules: + - path: proto +lint: + use: + - STANDARD +breaking: + use: + - FILE diff --git a/packages/example/package.json b/packages/example/package.json new file mode 100644 index 0000000..849a96b --- /dev/null +++ b/packages/example/package.json @@ -0,0 +1,23 @@ +{ + "name": "@spine-event-engine/example-smoke", + "version": "2.0.0-snapshot.1", + "private": true, + "description": "Example project demonstrating @spine-event-engine/validation-ts usage", + "type": "module", + "scripts": { + "generate": "buf generate", + "build": "npm run generate && tsc", + "start": "npm run build && node dist/index.js", + "clean": "rm -rf dist src/generated" + }, + "dependencies": { + "@bufbuild/protobuf": "^2.10.2", + "@spine-event-engine/validation-ts": "*" + }, + "devDependencies": { + "@bufbuild/buf": "^1.61.0", + "@bufbuild/protoc-gen-es": "^2.10.2", + "@types/node": "^25.0.3", + "typescript": "^5.9.3" + } +} diff --git a/packages/example/proto/product.proto b/packages/example/proto/product.proto new file mode 100644 index 0000000..ab66c68 --- /dev/null +++ b/packages/example/proto/product.proto @@ -0,0 +1,118 @@ +syntax = "proto3"; + +package example; + +import "google/protobuf/timestamp.proto"; +import "spine/options.proto"; + +// Product message representing a product entity with validation constraints +message Product { + // Product ID - required and set once + string id = 1 [(required) = true, + (set_once) = true, + (pattern).regex = "^prod-[0-9]+$", + (pattern).error_msg = "Product ID must follow format 'prod-XXX'. Provided: `{value}`."]; + + // Product name - required + string name = 2 [(required) = true, + (if_missing).error_msg = "Product name is required."]; + + // Product description - optional + string description = 3; + + // Product price - must be positive + double price = 4 [(required) = true, + (min).value = "0.01", + (min).error_msg = "Price must be at least {other}. Provided: {value}."]; + + // Stock quantity - must be non-negative + int32 stock = 5 [(min).value = "0", + (range) = "[0..1000000)"]; + + // Creation timestamp - required + google.protobuf.Timestamp created_at = 6 [(required) = true]; + + // Category - required and validated + Category category = 7 [(required) = true, + (validate) = true, + (if_invalid).error_msg = "Category is invalid."]; + + // Display settings - demonstrates "goes" option + // Text color can only be set when highlight color is set, and vice versa + Color text_color = 8 [(goes).with = "highlight_color"]; + Color highlight_color = 9 [(goes).with = "text_color"]; +} + +// Color message for display settings +message Color { + int32 red = 1 [(range) = "[0..255]"]; + int32 green = 2 [(range) = "[0..255]"]; + int32 blue = 3 [(range) = "[0..255]"]; +} + +// Category message with validation +message Category { + // Category ID - must be positive + int32 id = 1 [(required) = true, + (min).value = "1"]; + + // Category name - required + string name = 2 [(required) = true]; +} + +// Payment method demonstrating is_required oneof option +message PaymentMethod { + oneof method { + option (is_required) = true; + + // Money is provided from a payment card that has this number + PaymentCardNumber payment_card = 1 [(validate) = true]; + + // Money is provided from this bank account + BankAccount bank_account = 2 [(validate) = true]; + } +} + +// Payment card number with validation +message PaymentCardNumber { + string number = 1 [(required) = true, + (pattern).regex = "^[0-9]{13,19}$", + (pattern).error_msg = "Card number must be 13-19 digits."]; + int32 expiry_month = 2 [(required) = true, + (range) = "[1..12]"]; + int32 expiry_year = 3 [(required) = true, + (min).value = "2024"]; +} + +// Bank account with validation +message BankAccount { + string account_number = 1 [(required) = true, + (pattern).regex = "^[0-9]{8,17}$"]; + string routing_number = 2 [(required) = true, + (pattern).regex = "^[0-9]{9}$"]; +} + +// Request message for listing products with pagination +message ListProductsRequest { + // Page number - must be positive + int32 page = 1 [(required) = true, + (min).value = "1", + (if_missing).error_msg = "Page number is required."]; + + // Page size - must be between 1 and 100 + int32 page_size = 2 [(required) = true, + (range) = "[1..100]", + (if_missing).error_msg = "Page size is required."]; + + // Optional search query + string search_query = 3; +} + +// Response message for listing products +message ListProductsResponse { + // List of products with validation enabled + repeated Product products = 1 [(validate) = true]; + + // Total count - must be non-negative + int32 total_count = 2 [(min).value = "0"]; +} diff --git a/packages/example/proto/spine/options.proto b/packages/example/proto/spine/options.proto new file mode 100644 index 0000000..2e05914 --- /dev/null +++ b/packages/example/proto/spine/options.proto @@ -0,0 +1,958 @@ +/* + * Copyright 2022, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +syntax = "proto3"; + +// API Note on Packaging +// --------------------- +// We do not define the package for this file to allow shorter options for user-defined types. +// This allows to write: +// +// option (internal) = true; +// +// instead of: +// +// option (spine.base.internal) = true; +// + +// Custom Type Prefix Option +// ------------------------- +// The custom `type_url_prefix` option allows to define specify custom type URL prefix for messages +// defined in a proto file. This option is declared in this file. Other proto files must import +// `options.proto` to be able to specify custom type URL prefix. +// +// It is recommended that the import statement is provided before the line with `type_url_prefix` +// option to make it obvious that custom option is defined in the imported file. +// +// For example: +// +// syntax = "proto3"; +// +// package my.package; +// +// import "spine/options.proto"; +// +// option (type_url_prefix) = "type.example.org"; +// + +option (type_url_prefix) = "type.spine.io"; +option java_multiple_files = true; +option java_outer_classname = "OptionsProto"; +option java_package = "io.spine.option"; + +import "google/protobuf/descriptor.proto"; + +// +// Reserved Range of Option Field Numbers +// -------------------------------------- +// Spine Options use the range of option field numbers from the internal range reserved for +// individual organizations. For details of custom Protobuf options and this range please see: +// +// https://developers.google.com/protocol-buffers/docs/proto#customoptions +// +// The whole range reserved for individual organizations is 50000-99999. +// The range used by Spine Options is 73812-75000. +// In order to prevent number collision with custom options used by a project based on Spine, +// numbers for custom options defined in this project should be in the range 50000-73811 or +// 75001-99999. +// + +extend google.protobuf.FieldOptions { + + // Field Validation Constraints + //----------------------------- + // For constraints defined via message-based types, please see documentation of corresponding + // message types. + // + + // The option to mark a field as required. + // + // If the field type is a `message`, it must be set to a non-default instance. + // If it is `string` or `bytes`, the value must not be an empty string or an array. + // Other field types are not applicable. + // If the field is repeated, it must have at least one element. + // + // Unlike the `required` keyword used in Protobuf 2, the option does not affect the transfer + // layer. Even if a message content violates the requirement set by the option, it would still + // be a valid message for the Protobuf library. + // + // Example: Using `(required)` field validation constraint. + // + // message MyOuterMessage { + // MyMessage field = 1 [(required) = true]; + // } + // + bool required = 73812; + + // See `IfMissingOption` + IfMissingOption if_missing = 73813; + + // Reserved 73814 and 73815 for deleted options `decimal_max` and `decimal_min`. + + // A higher boundary to the range of values of a number. + MaxOption max = 73816; + + // A lower boundary to the range of values of a number. + MinOption min = 73817; + + // 73818 reserved for the (digits) option. + + // 73819 reserved for the (when) option. + + // See `PatternOption`. + PatternOption pattern = 73820; + + // Turns on validation constraint checking for a value of a message, a map, or a repeated field. + // + // Default value is `false`. + // + // If set to `true`, the outer message declaring the annotated field would be valid if: + // + // 1. A message field value satisfies the validation constraints defined in the corresponding + // message type of the field. + // + // 2. Each value of a map entry satisfies validation constraints. + // + // 3. Each item of a repeated field satisfies validation constraints. + // + bool validate = 73821; + + // See `IfInvalidOption`. + IfInvalidOption if_invalid = 73822; + + // See `GoesOption`. + GoesOption goes = 73823; + + // Indicates that a field can only be set once. + // + // A typical use-case would include a value of an ID, which doesn't change over the course of + // the life of an entity. + // + // Example: Using `(set_once)` field validation constraint. + // + // message User { + // UserId id = 1 [(set_once) = true]; + // } + // + // Once set, the `id` field cannot be changed. + // + bool set_once = 73824; + + // The option to mark a `repeated` field as a collection of unique elements. + // + // Example: Using `(distinct)` constraint for a repeated field. + // + // message Blizzard { + // + // // All snowflakes must be unique in this blizzard. + // // + // // Attempting to add a snowflake that is equal to an existing one would result + // // in constraint violation error. + // // + // repeated Snowflake = 1 [(distinct) = true]; + // } + // + bool distinct = 73825; + + // The option to indicate that a numeric field is required to have a value which belongs + // to the specified bounded range. For unbounded ranges, please use `(min)` and `(max) options. + // + // The range can be open (not including the endpoint) or closed (including the endpoint) on + // each side. Open endpoints are indicated using a parenthesis (`(`, `)`). Closed endpoints are + // indicated using a square bracket (`[`, `]`). + // + // Example: Defining ranges of numeric values. + // + // message NumRanges { + // int32 hour = 1 [(range) = "[0..24)"]; + // uint32 minute = 2 [(range) = "[0..59]"]; + // float degree = 3 [(range) = "[0.0..360.0)"]; + // double angle = 4 [(range) = "(0.0..180.0)"]; + // } + // + // NOTE: That definition of ranges must be consistent with the type they constrain. + // An range for an integer field must be defined with integer endpoints. + // A range for a floating point field must be defined with decimal separator (`.`), + // even if the endpoint value does not have a fractional part. + // + string range = 73826; + + // Reserved 73827 to 73849 for future validation options. + + // API Annotations + //----------------- + + // Indicates a field which is internal to Spine, not part of the public API, and should not be + // used by users of the framework. + // + // If you plan to implement an extension of the framework, which is going to be + // wired into the framework, you may use the internal parts. Please consult with the Spine + // team, as the internal APIs do not have the same stability API guarantee as public ones. + // + bool internal = 73850; + + // Reserved 73851 for the deleted SPI option. + + // Indicates a field that can change at any time, and has no guarantee of API stability and + // backward-compatibility. + // + // Usage guidelines: + // 1. This annotation is used only on public API. Internal interfaces should not use it. + // 2. This annotation can only be added to new API. Adding it to an existing API is considered + // API-breaking. + // 3. Removing this annotation from an API gives it stable status. + // + bool experimental = 73852; + + // Signifies that a public API is subject to incompatible changes, or even removal, in a future + // release. + // + // An API bearing this annotation is exempt from any compatibility guarantees made by its + // containing library. Note that the presence of this annotation implies nothing about the + // quality of the API in question, only the fact that it is not "API-frozen." + // It is generally safe for applications to depend on beta APIs, at the cost of some extra work + // during upgrades. + // + bool beta = 73853; + + // Marks an entity state field as column. + // + // The column fields are stored separately from the entity record and can be specified as + // filtering criteria during entity querying. + // + // The column field should be declared as follows: + // + // message UserProfile { + // ... + // int32 year_of_registration = 8 [(column) = true]; + // } + // + // The `year_of_registration` field value can then be used as query parameter when reading + // entities of `UserProfile` type from the server side. + // + // The value of a column field can be updated in two ways: + // + // 1. In the receptors of the entity, just like any other part of entity state. + // 2. Using the language-specific tools like `EntityWithColumns` interface in Java. + // + // All column fields are considered optional by the framework. + // + // Currently, only entities of projection and process manager type are eligible for having + // columns (see `EntityOption`). For all other message types the column declarations are + // ignored. + // + // The `repeated` and `map` fields cannot be columns. + // + bool column = 73854; + + // Reserved 73855 to 73890 for future options. + + // Reserved 73900 for removed `by` option. +} + +extend google.protobuf.OneofOptions { + + // Marks a `oneof` group, in which one field *must* be set. + // + // Alternative to `(required_field)` with all the field in the group joined with the OR + // operator. + // + bool is_required = 73891; + + // Reserved 73892 to 73899 for future options. +} + +extend google.protobuf.MessageOptions { + + // Validation Constraints + //------------------------ + + // The default format string for validation error message text. + // + // This option extends message types that extend `FieldOptions` + // The number of parameters and their types are determined by the type of field options. + // + // Usage of this value is deprecated. Along with the old `msg_format`s, it exists to support + // the old validation library. The new version of the validation library, which does not lie in + // the `base` repository, constructs the default error messages separately when creating + // language-agnostic validation rules. + // + string default_message = 73901 [deprecated = true]; + + // The constraint to require at least one of the fields or a combination of fields. + // + // Unlike the `required` field constraint which always require corresponding field, + // this message option allows to require alternative fields or a combination of them as + // an alternative. Field names and `oneof` group names are acceptable. + // + // Field names are separated using the pipe (`|`) symbol. The combination of fields is defined + // using the ampersand (`&`) symbol. + // + // Example: Pipe syntax for defining alternative required fields. + // + // message PersonName { + // option (required_field) = "given_name|honorific_prefix & family_name"; + // + // string honorific_prefix = 1; + // string given_name = 2; + // string middle_name = 3; + // string family_name = 4; + // string honorific_suffix = 5; + // } + // + string required_field = 73902; + + // See `EntityOption`. + EntityOption entity = 73903; + + // An external validation constraint for a field. + // + // Allows to re-define validation constraints for a message when its usage as a field of + // another type requires alternative constraints. This includes definition of constraints for + // a message which does not have them defined within the type. + // + // A target field of an external constraint should be specified using a fully-qualified + // field name (e.g. `mypackage.MessageName.field_name`). + // + // Example: Defining external validation constraint. + // + // package io.spine.example; + // + // // Defines a change in a string value. + // // + // // Both of the fields of this message are not `required` to be able to describe + // // a change from empty value to non-empty value, or from a non-empty value to + // // an empty string. + // // + // message StringChange { + // + // // The value of the field that's changing. + // string previous_value = 1; + // + // // The new value of the field. + // string new_value = 2; + // } + // + // // A command to change a name of a task. + // // + // // The task has a non-empty name. A new name cannot be empty. + // // + // message RenameTask { + // + // // The ID of the task to rename. + // string task_id = 1; + // + // // Instruction for changing the name. + // // + // // The value of `change.previous_value` is the current name of the task. + // // It cannot be empty. + // // + // // The value of `change.new_value` is the new name of the task. + // // It cannot be empty either. + // // + // StringChange change = 1 [(validate) = true]; + // } + // + // // External validation constraint for both fields of the `StringChange` message + // // in the scope of the `RenameTask` command. + // // + // message RequireTaskNames { + // option (constraint_for) = "spine.example.RenameTask.change"; + // + // string previous_value = 1 [(required) = true]; + // string new_value = 2 [(required) = true]; + // } + // + // NOTE: A target field for an external validation constraint must be have the option `(validate)` + // set to `true`. See the definition of the `RenameTask.change` field in the example + // above. If there is no such option defined, or it is set to `false`, the external + // constraint will not be applied. + // + // External validation constraints can be applied to fields of several types. + // To do so, separate fully-qualified references to these fields with comma. + // + // Example: External validation constraints for multiple fields. + // + // // External validation constraint for requiring a new value in renaming commands. + // message RequireNewName { + // option (constraint_for) = "spine.example.RenameTask.change," + // "spine.example.RenameProject.change,"; + // "spine.example.UpdateComment.text_change; + // + // string new_value = 1 [(required) = true]; + // } + // + // NOTE: An external validation constraint for a field must be defined only once. + // Spine Model Compiler does not check such an "overwriting". + // See the issue: https://github.com/SpineEventEngine/base/issues/318. + // + string constraint_for = 73904; + + // Reserved 73905 to 73910 for future validation options. + + // API Annotations + //----------------- + + // Indicates a type usage of which is restricted in one of the following ways. + // + // 1. This type is internal to the Spine Event Engine framework. It is not a part of + // the public API, and must not be used by framework users. + // + // 2. The type is internal to a bounded context, artifact of which exposes the type to + // the outside world (presumably for historical reasons). + // + // The type with such an option can be used only inside the bounded context which declares it. + // + // The type must not be used neither for inbound (i.e. being sent to the bounded context + // which declares this type) nor for outbound communication (i.e. being sent by this + // bounded context outside). + // + // An attempt to violate these usage restrictions will result in a runtime error. + // + bool internal_type = 73911; + + // Indicates a file which contains elements of Service Provider Interface (SPI). + bool SPI_type = 73912; + + // Indicates a public API that can change at any time, and has no guarantee of + // API stability and backward-compatibility. + bool experimental_type = 73913; + + // Signifies that a public API is subject to incompatible changes, or even removal, + // in a future release. + bool beta_type = 73914; + + // Specifies a characteristic inherent in the the given message type. + // + // Example: Using `(is)` message option. + // + // message CreateProject { + // option (is).java_type = "ProjectCommand"; + // + // // Remainder omitted. + // } + // + // In the example above, `CreateProject` message is a `ProjectCommand`. + // + // To specify a characteristic for every message in a `.proto` file, use `(every_is)` file + // option. If both `(is)` and `(every_is)` options are found, both are applied. + // + // When targeting Java, specify the name of a Java interface to be implemented by this + // message via `(is).java_type`. + // + IsOption is = 73915; + + // Reserved 73916 to 73921 for future API annotation options. + + // Reserved 73922 for removed `enrichment_for` option. + + // Specifies the natural ordering strategy for this type. + // + // Code generators should generate language-specific comparisons based on the field paths. + // + // Runtime comparators may use the reflection API to compare field values. + // + CompareByOption compare_by = 73923; + + // Reserved 73924 to 73938 for future options. + + // Reserved 73939 and 73940 for the deleted options `events` and `rejections`. +} + +extend google.protobuf.FileOptions { + + // Specifies a type URL prefix for all types within a file. + // + // This type URL will be used when packing messages into `Any`. + // See `any.proto` for more details. + // + string type_url_prefix = 73941; + + // Indicates a file which contains types usage of which is restricted. + // + // For more information on such restrictions please see the documentation of + // the type option called `internal_type`. + // + bool internal_all = 73942; + + // Indicates a file which contains elements of Service Provider Interface (SPI). + bool SPI_all = 73943; + + // Indicates a public API that can change at any time, and has no guarantee of + // API stability and backward-compatibility. + bool experimental_all = 73944; + + // Signifies that a public API is subject to incompatible changes, or even removal, + // in a future release. + bool beta_all = 73945; + + // Specifies a characteristic common for all the message types in the given file. + // + // Example: Marking all the messages using the `(every_is)` file option. + // ``` + // option (every_is).java_type = "ProjectCommand"; + // + // message CreateProject { + // // ... + // + // message WithAssignee { + // // ... + // } + // } + // + // message DeleteProject { /*...*/ } + // ``` + // + // In the example above, `CreateProject`, `CreateProject.WithAssignee`, and `DeleteProject` + // messages are `ProjectCommand`-s. + // + // To specify a characteristic for a single message, use `(is)` message option. If both `(is)` + // and `(every_is)` options are found, both are applied. + // + // When targeting Java, specify the name of a Java interface to be implemented by these + // message types via `(every_is).java_type`. + // + IsOption every_is = 73946; + + // Reserved 73947 to 73970 for future use. +} + +extend google.protobuf.ServiceOptions { + + // Indicates that the service is a part of Service Provider Interface (SPI). + bool SPI_service = 73971; + + // Reserved 73971 to 73980. +} + +// Reserved 73981 to 74000 for other future Spine Options numbers. + +// +// Validation Option Types +//--------------------------- + +// Defines the error handling for `required` field with no value set. +// +// Applies only to the fields marked as `required`. +// Validation error message is composed according to the rules defined by this option. +// +// Example: Using the `(if_missing)` option. +// +// message Holder { +// MyMessage field = 1 [(required) = true, +// (if_missing).error_msg = "This field is required."]; +// } +// +message IfMissingOption { + + // The default error message. + option (default_message) = "A value must be set."; + + // A user-defined validation error format message. + // + // Use `error_msg` instead. + // + string msg_format = 1 [deprecated = true]; + + // A user-defined error message. + string error_msg = 2; +} + +// The field value must be greater than or equal to the given minimum number. +// +// Is applicable only to numbers. +// Repeated fields are supported. +// +// Example: Defining lower boundary for a numeric field. +// +// message KelvinTemperature { +// double value = 1 [(min) = { +// value = "0.0" +// exclusive = true +// error_msg = "Temperature cannot reach {other}K, but provided {value}." +// }]; +// } +// +message MinOption { + + // The default error message format string. + // + // The format parameters are: + // 1) "or equal to " string (if the `exclusive` parameter is false) or an empty string; + // 2) the minimum number. + // + option (default_message) = "The number must be greater than %s%s."; + + // The string representation of the minimum field value. + string value = 1; + + // Specifies if the field should be strictly greater than the specified minimum. + // + // The default value is false, i.e. the bound is inclusive. + // + bool exclusive = 2; + + // A user-defined validation error format message. + string msg_format = 3 [deprecated = true]; + + // A user-defined validation error format message. + // + // May include tokens `{value}`β€”for the actual value of the field, and `{other}`β€”for + // the threshold value. The tokens will be replaced at runtime when the error is constructed. + // + string error_msg = 4; +} + +// The field value must be less than or equal to the given maximum number. +// +// Is applicable only to numbers. +// Repeated fields are supported. +// +// Example: Defining upper boundary for a numeric field. +// +// message Elevation { +// double value = 1 [(max).value = "8848.86"]; +// } +// +message MaxOption { + + // The default error message format string. + // + // The format parameters are: + // 1) "or equal to " string (if the `exclusive` parameter is false) or an empty string; + // 2) the maximum number. + // + option (default_message) = "The number must be less than %s%s."; + + // The string representation of the maximum field value. + string value = 1; + + // Specifies if the field should be strictly less than the specified maximum + // + // The default value is false, i.e. the bound is inclusive. + // + bool exclusive = 2; + + // A user-defined validation error format message. + string msg_format = 3 [deprecated = true]; + + // A user-defined validation error format message. + // + // May include tokens `{value}`β€”for the actual value of the field, and `{other}`β€”for + // the threshold value. The tokens will be replaced at runtime when the error is constructed. + // + string error_msg = 4; +} + +// A string field value must match the given regular expression. +// Is applicable only to strings. +// Repeated fields are supported. +// +// Example: Using the `(pattern)` option. +// +// message CreateAccount { +// string id = 1 [(pattern).regex = "^[A-Za-z0-9+]+$", +// (pattern).error_msg = "ID must be alphanumerical. Provided: `{value}`."]; +// } +// +message PatternOption { + + // The default error message format string. + // + // The format parameter is the regular expression to which the value must match. + // + option (default_message) = "The string must match the regular expression `%s`."; + + // The regular expression to match. + string regex = 1; + + reserved 2; + reserved "flag"; + + // Modifiers for this pattern. + Modifier modifier = 4; + + // A user-defined validation error format message. + string msg_format = 3 [deprecated = true]; + + // A user-defined validation error format message. + // + // May include tokens `{value}`β€”for the actual value of the field, and `{other}`β€”for + // the threshold value. The tokens will be replaced at runtime when the error is constructed. + // + string error_msg = 5; + + // Regular expression modifiers. + // + // These modifiers are specifically selected to be supported in many implementation platforms. + // + message Modifier { + + // Enables the dot (`.`) symbol to match all the characters. + // + // By default, the dot does not match line break characters. + // + // May also be known in some platforms as "single line" mode and be encoded with the `s` + // flag. + // + bool dot_all = 1; + + // Allows to ignore the case of the matched symbols. + // + // For example, this modifier is specified, string `ABC` would be a complete match for + // the regex `[a-z]+`. + // + // On some platforms may be represented by the `i` flag. + // + bool case_insensitive = 2; + + // Enables the `^` (caret) and `$` (dollar) signs to match a start and an end of a line + // instead of a start and an end of the whole expression. + // + // On some platforms may be represented by the `m` flag. + // + bool multiline = 3; + + // Enables matching the whole UTF-8 sequences, + // + // On some platforms may be represented by the `u` flag. + // + bool unicode = 4; + + // Allows the matched strings to contain a full match to the pattern and some other + // characters as well. + // + // By default, a string only matches a pattern if it is a full match, i.e. there are no + // unaccounted for leading and/or trailing characters. + // + // This modifier is usually not represented programming languages, as the control over + // weather to match an entire string or only its part is provided to the user by other + // language means. For example, in Java, this would be the difference between methods + // `matches()` and `find()` of the `java.util.regex.Matcher` class. + // + bool partial_match = 5; + } +} + +// Specifies the message to show if a validated field happens to be invalid. +// Is applicable only to messages. +// Repeated fields are supported. +// +// Example: Using the `(if_invalid)` option. +// +// message Holder { +// MyMessage field = 1 [(validate) = true, +// (if_invalid).error_msg = "The field is invalid."]; +// } +// +message IfInvalidOption { + + // The default error message for the field. + option (default_message) = "The message must have valid properties."; + + // A user-defined validation error format message. + string msg_format = 1 [deprecated = true]; + + // A user-defined validation error format message. + // + // May include the token `{value}` for the actual value of the field. The token will be replaced + // at runtime when the error is constructed. + // + string error_msg = 2; +} + +// Specifies that a message field can be present only if another field is present. +// +// Unlike the `required_field` that handles combination of required fields, this option is useful +// when it is needed to say that an optional field makes sense only when another optional field is +// present. +// +// Example: Requiring mutual presence of optional fields. +// +// message ScheduledItem { +// ... +// spine.time.LocalDate date = 4; +// spine.time.LocalTime time = 5 [(goes).with = "date"]; +// } +// +message GoesOption { + + // The default error message format string. + // + // The first parameter is the name of the field for which we specify the option. + // The second parameter is the name of the field set in the "with" value. + // + option (default_message) = "The field `%s` can only be set when the field `%s` is defined."; + + // A name of the field required for presence of the field for which we set the option. + string with = 1; + + // A user-defined validation error format message. + string msg_format = 2 [deprecated = true]; + + // A user-defined validation error format message. + // + // May include the token `{value}` for the actual value of the field. The token will be replaced + // at runtime when the error is constructed. + // + string error_msg = 3; +} + +// Defines options of a message representing a state of an entity. +message EntityOption { + + // A type of an entity for state of which the message is defined. + enum Kind { + option allow_alias = true; + + // Reserved for errors. + KIND_UNKNOWN = 0; + + // The message is an aggregate state. + AGGREGATE = 1; + + // The message is a state of a projection (same as "view"). + PROJECTION = 2; + + // The message is a state of a view (same as "projection"). + VIEW = 2; + + // The message is a state of a process manager. + PROCESS_MANAGER = 3; + + // The message is a state of an entity, which is not of the type + // defined by other members of this enumeration. + ENTITY = 4; + } + + // The type of the entity. + Kind kind = 1; + + // The level of visibility of the entity to queries. + enum Visibility { + + // Default visibility is different for different types of entities: + // - for projections, "FULL" is default; + // - for aggregates, process managers, and other entities, "NONE" is default. + // + DEFAULT = 0; + + // The entity is not visible to queries. + NONE = 1; + + // Client-side applications can subscribe to updates of entities of this type. + SUBSCRIBE = 2; + + // Client-side applications can query this type of entities. + QUERY = 3; + + // Client-side applications can subscribe and query this type of entity. + FULL = 4; + } + + // The visibility of the entity. + // + // If not defined, the value of this option is `DEFAULT`. + // + Visibility visibility = 2; +} + +// Defines a marker for a given type or a set of types. +// +// The option may be used in two modes: +// - with the marker code generation; +// - without the marker code generation. +// +// When used with the code generation, language-specific markers are generated by the Protobuf +// compiler. Otherwise, it is expected that the user creates such markers manually. +// +message IsOption { + + // Enables the generation of marker interfaces. + // + // The generation is disabled by default. + bool generate = 1; + + // The reference to a Java interface. + // + // May be an fully-qualified or a simple name. In the latter case, the interface should belong + // to the same Java package as the message class which implements this interface. + // + // The framework does not ensure the referenced type exists. + // If the generation is disabled, the Java type is used as-is. Otherwise, a corresponding Java + // interface is generated. + // + // A generated interface has no declared methods and extends `com.google.protobuf.Message`. + // + // The `.java` file is placed alongside with the code generated by the proto-to-java compiler. + // + // If fully-qualified name given, the package of the generated type matches the fully-qualified + // name. When a simple name is set in the option, the package of the interface matches the + // package of the message class. + // + // If both `(is)` and `(every_is)` options specify a Java interface, the message class + // implements both interfaces. + // + string java_type = 2; +} + +// Defines the way to compare two messages of the same type to one another. +// +// Comparisons can be used to sort values. +// +// See the `(compare_by)` option. +// +message CompareByOption { + + // Field paths used for comparisons. + // + // The allowed field types are: + // - any number type; + // - `bool` (false is less than true); + // - `string` (in the order of respective Unicode values); + // - enumerations (following the order of numbers associated with each constant); + // - messages marked with `(compare_by)`. + // + // Other types are not permitted. Neither are repeated and map fields. Such declarations can + // lead to build-time errors. + // + // To refer to nested fields, separate the field names with a dot (`.`). No fields in the path + // can be repeated or maps. + // + // When multiple field paths are specified, comparison is executed in the order of reference. + // For example, specifying ["seconds", "nanos"] makes the comparison mechanism prioritize + // the `seconds` field and refer to `nanos` only when `seconds` are equal. + // + // Note. When comparing message fields, a non-set message is always less than a set message. + // But if a message is set to a default value, the comparison falls back to + // the field-wise comparison, i.e. number values are treated as zeros, `bool` β€” as `false`, + // and so on. + // + repeated string field = 1; + + // If true, the default order is reversed. For example, numbers are ordered from the greater to + // the lower, enums β€” from the last number value to the 0th value, etc. + bool descending = 2; +} diff --git a/packages/example/proto/user.proto b/packages/example/proto/user.proto new file mode 100644 index 0000000..af2e5b1 --- /dev/null +++ b/packages/example/proto/user.proto @@ -0,0 +1,53 @@ +syntax = "proto3"; + +package example; + +import "spine/options.proto"; + +// User message representing a user entity with validation constraints +message User { + option (required_field) = "id | email"; + + // User ID - set once and cannot be changed + int32 id = 1 [(set_once) = true, (min).value = "1"]; + + // User name - required, with pattern validation + string name = 2 [(required) = true, + (pattern).regex = "^[A-Za-z][A-Za-z0-9 ]{1,49}$", + (pattern).error_msg = "Name must start with a letter and be 2-50 characters. Provided: `{value}`."]; + + // User email - required, with pattern validation for email format + string email = 3 [(required) = true, + (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + (pattern).error_msg = "Email must be valid. Provided: `{value}`."]; + + // User role - required + Role role = 4 [(required) = true]; + + // Tags - distinct values only + repeated string tags = 5 [(distinct) = true]; +} + +// Role enumeration +enum Role { + ROLE_UNSPECIFIED = 0; + ROLE_USER = 1; + ROLE_ADMIN = 2; + ROLE_MODERATOR = 3; +} + +// Request message for getting a user +message GetUserRequest { + // User ID must be positive + int32 user_id = 1 [(required) = true, + (min).value = "1", + (if_missing).error_msg = "User ID is required."]; +} + +// Response message for getting a user +message GetUserResponse { + // User data with validation enabled + User user = 1 [(validate) = true, + (if_invalid).error_msg = "User data is invalid."]; + bool found = 2; +} diff --git a/packages/example/src/index.ts b/packages/example/src/index.ts new file mode 100644 index 0000000..2f3f2af --- /dev/null +++ b/packages/example/src/index.ts @@ -0,0 +1,129 @@ +/** + * Example demonstrating the spine-validation-ts package. + * + * This example shows how to validate Protobuf messages with Spine validation constraints. + */ + +import { create } from '@bufbuild/protobuf'; +import { UserSchema, Role } from './generated/user_pb.js'; +import { validate, formatViolations } from '@spine-event-engine/validation-ts'; + +console.log('=== Spine Validation Example ===\n'); + +// Example 1: Valid user - all required fields provided +console.log('Example 1: Valid User'); +console.log('---------------------'); +const validUser = create(UserSchema, { + id: 1, + name: 'John Doe', + email: 'john.doe@example.com', + role: Role.ADMIN, + tags: ['developer', 'typescript'] +}); + +const validUserViolations = validate(UserSchema, validUser); +console.log('Violations:', validUserViolations.length); +console.log(formatViolations(validUserViolations)); +console.log(); + +// Example 2: Invalid user - missing required email +console.log('Example 2: Missing Required Email'); +console.log('----------------------------------'); +const invalidUser1 = create(UserSchema, { + id: 2, + name: 'Jane Smith', + email: '', // Required but empty + role: Role.USER, + tags: [] +}); + +const violations1 = validate(UserSchema, invalidUser1); +console.log('Violations:', violations1.length); +console.log(formatViolations(violations1)); +console.log(); + +// Example 3: Invalid user - missing required name +console.log('Example 3: Missing Required Name'); +console.log('---------------------------------'); +const invalidUser2 = create(UserSchema, { + id: 3, + name: '', // Required but empty + email: 'alice@example.com', + role: Role.USER, + tags: [] +}); + +const violations2 = validate(UserSchema, invalidUser2); +console.log('Violations:', violations2.length); +console.log(formatViolations(violations2)); +console.log(); + +// Example 4: Multiple violations +console.log('Example 4: Multiple Violations'); +console.log('-------------------------------'); +const invalidUser3 = create(UserSchema, { + id: 4, + name: '', // Required but empty + email: '', // Required but empty + role: 0, // ROLE_UNSPECIFIED + tags: [] +}); + +const violations3 = validate(UserSchema, invalidUser3); +console.log('Violations:', violations3.length); +console.log(formatViolations(violations3)); +console.log(); + +// Example 5: Pattern validation - invalid name format +console.log('Example 5: Pattern Validation (Invalid Name)'); +console.log('----------------------------------------------'); +const invalidPattern1 = create(UserSchema, { + id: 5, + name: '123Invalid', // Starts with number, violates pattern + email: 'valid@example.com', + role: Role.USER, + tags: [] +}); + +const violations4 = validate(UserSchema, invalidPattern1); +console.log('Violations:', violations4.length); +console.log(formatViolations(violations4)); +console.log(); + +// Example 6: Pattern validation - invalid email format +console.log('Example 6: Pattern Validation (Invalid Email)'); +console.log('-----------------------------------------------'); +const invalidPattern2 = create(UserSchema, { + id: 6, + name: 'Bob Wilson', + email: 'notanemail', // Invalid email format + role: Role.USER, + tags: [] +}); + +const violations5 = validate(UserSchema, invalidPattern2); +console.log('Violations:', violations5.length); +console.log(formatViolations(violations5)); +console.log(); + +// Example 7: Multiple validation types +console.log('Example 7: Multiple Validation Types'); +console.log('-------------------------------------'); +const multipleInvalid = create(UserSchema, { + id: 7, + name: '', // Required violation + email: 'bad@', // Pattern violation + role: 0, + tags: [] +}); + +const violations6 = validate(UserSchema, multipleInvalid); +console.log('Violations:', violations6.length); +violations6.forEach((v, i) => { + const fieldPath = v.fieldPath?.fieldName.join('.') || 'unknown'; + const message = v.message?.withPlaceholders || 'No message'; + console.log(`${i + 1}. Field "${fieldPath}": ${message}`); +}); +console.log(); + +console.log('=== Example Complete ==='); diff --git a/packages/example/tsconfig.json b/packages/example/tsconfig.json new file mode 100644 index 0000000..8546ebd --- /dev/null +++ b/packages/example/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/packages/spine-validation-ts/.gitignore b/packages/spine-validation-ts/.gitignore new file mode 100644 index 0000000..411d23c --- /dev/null +++ b/packages/spine-validation-ts/.gitignore @@ -0,0 +1,34 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Generated code +src/generated/ +tests/generated/ + +# Test coverage +coverage/ +*.lcov + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Temp files +*.tmp +.cache/ diff --git a/packages/spine-validation-ts/DESCRIPTOR_API_GUIDE.md b/packages/spine-validation-ts/DESCRIPTOR_API_GUIDE.md new file mode 100644 index 0000000..1d89957 --- /dev/null +++ b/packages/spine-validation-ts/DESCRIPTOR_API_GUIDE.md @@ -0,0 +1,430 @@ +# Guide: Accessing Message and Field Descriptors in @bufbuild/protobuf v2 + +This guide explains how to access message descriptors, field descriptors, and their custom extension options in `@bufbuild/protobuf` v2 (protoc-gen-es v2). + +## Overview + +When protoc-gen-es v2 generates TypeScript code from `.proto` files, it creates: +- **Schema objects** (e.g., `UserSchema`) that are message descriptors of type `GenMessage` +- These schemas provide access to message metadata, field descriptors, and custom options + +## Key Concepts + +### 1. Schema Structure + +```typescript +import { UserSchema } from './generated/examples/user_pb'; + +// UserSchema IS the message descriptor (DescMessage) +// It has the following key properties: +UserSchema.kind // "message" +UserSchema.typeName // "example.User" +UserSchema.name // "User" +UserSchema.fields // Array of field descriptors +UserSchema.field // Record of fields by localName +UserSchema.proto // The raw DescriptorProto with options +``` + +### 2. Field Descriptors + +```typescript +// Access field descriptors from the schema +const field = UserSchema.fields.find(f => f.name === 'email'); + +// Field descriptor properties: +field.name // Field name in proto +field.localName // Field name in TypeScript (camelCase) +field.number // Field number +field.fieldKind // "scalar" | "message" | "enum" | "list" | "map" +field.proto // The raw FieldDescriptorProto with options +``` + +## Accessing Custom Extension Options + +There are TWO approaches to access custom field options (like Spine validation options): + +### Approach 1: Using `hasOption` / `getOption` (RECOMMENDED) + +This is the cleanest approach. These helper functions work directly with descriptors. + +```typescript +import { hasOption, getOption } from '@bufbuild/protobuf'; +import { UserSchema } from './generated/examples/user_pb'; +import { required, pattern, min, required_field } from './generated/options_pb'; + +// Access message-level options +if (hasOption(UserSchema, required_field)) { + const value = getOption(UserSchema, required_field); + console.log('Message required_field:', value); // "id | email" +} + +// Access field-level options +const emailField = UserSchema.fields.find(f => f.name === 'email'); +if (emailField) { + if (hasOption(emailField, required)) { + const isRequired = getOption(emailField, required); + console.log('Email is required:', isRequired); // true + } + + if (hasOption(emailField, pattern)) { + const patternOpt = getOption(emailField, pattern); + console.log('Email pattern:', patternOpt.regex); + console.log('Email error msg:', patternOpt.errorMsg); + } +} +``` + +### Approach 2: Using `hasExtension` / `getExtension` with `proto.options` + +This lower-level approach accesses the options from the raw protobuf descriptors. + +```typescript +import { hasExtension, getExtension } from '@bufbuild/protobuf'; +import { UserSchema } from './generated/examples/user_pb'; +import { required, pattern, required_field } from './generated/options_pb'; + +// Access message-level options via proto.options +const messageOptions = UserSchema.proto.options; +if (messageOptions && hasExtension(messageOptions, required_field)) { + const value = getExtension(messageOptions, required_field); + console.log('Message required_field:', value); +} + +// Access field-level options via field.proto.options +const emailField = UserSchema.fields.find(f => f.name === 'email'); +if (emailField?.proto.options) { + const fieldOpts = emailField.proto.options; + + if (hasExtension(fieldOpts, required)) { + const isRequired = getExtension(fieldOpts, required); + console.log('Email is required:', isRequired); + } + + if (hasExtension(fieldOpts, pattern)) { + const patternOpt = getExtension(fieldOpts, pattern); + console.log('Email pattern:', patternOpt.regex); + } +} +``` + +## Complete Working Example + +```typescript +import { UserSchema } from './generated/examples/user_pb'; +import { + required, + min, + max, + pattern, + validate, + set_once, + distinct, + if_missing, + if_invalid, + required_field +} from './generated/options_pb'; +import { hasOption, getOption } from '@bufbuild/protobuf'; + +// 1. Access message-level options +console.log('=== Message Options ==='); +if (hasOption(UserSchema, required_field)) { + const value = getOption(UserSchema, required_field); + console.log(`required_field: "${value}"`); // "id | email" +} + +// 2. Iterate through all fields and their options +console.log('\n=== Field Options ==='); +for (const field of UserSchema.fields) { + console.log(`\nField: ${field.name}`); + + if (hasOption(field, required)) { + console.log(` required: ${getOption(field, required)}`); + } + + if (hasOption(field, set_once)) { + console.log(` set_once: ${getOption(field, set_once)}`); + } + + if (hasOption(field, distinct)) { + console.log(` distinct: ${getOption(field, distinct)}`); + } + + if (hasOption(field, min)) { + const minOpt = getOption(field, min); + console.log(` min.value: "${minOpt.value}"`); + console.log(` min.exclusive: ${minOpt.exclusive}`); + if (minOpt.errorMsg) { + console.log(` min.errorMsg: "${minOpt.errorMsg}"`); + } + } + + if (hasOption(field, max)) { + const maxOpt = getOption(field, max); + console.log(` max.value: "${maxOpt.value}"`); + console.log(` max.exclusive: ${maxOpt.exclusive}`); + if (maxOpt.errorMsg) { + console.log(` max.errorMsg: "${maxOpt.errorMsg}"`); + } + } + + if (hasOption(field, pattern)) { + const patternOpt = getOption(field, pattern); + console.log(` pattern.regex: "${patternOpt.regex}"`); + console.log(` pattern.errorMsg: "${patternOpt.errorMsg}"`); + } + + if (hasOption(field, validate)) { + console.log(` validate: ${getOption(field, validate)}`); + } + + if (hasOption(field, if_missing)) { + const ifMissingOpt = getOption(field, if_missing); + console.log(` if_missing.errorMsg: "${ifMissingOpt.errorMsg}"`); + } + + if (hasOption(field, if_invalid)) { + const ifInvalidOpt = getOption(field, if_invalid); + console.log(` if_invalid.errorMsg: "${ifInvalidOpt.errorMsg}"`); + } +} +``` + +## Utility Function: Extract All Field Options + +Here's a reusable utility function to extract all validation options from a field: + +```typescript +interface FieldValidationOptions { + required?: boolean; + set_once?: boolean; + distinct?: boolean; + validate?: boolean; + min?: { value: string; exclusive: boolean; errorMsg: string }; + max?: { value: string; exclusive: boolean; errorMsg: string }; + pattern?: { regex: string; errorMsg: string }; + if_missing?: { errorMsg: string }; + if_invalid?: { errorMsg: string }; +} + +function getFieldValidationOptions( + schema: GenMessage, + fieldName: string +): FieldValidationOptions | null { + const field = schema.fields.find(f => f.name === fieldName); + if (!field) return null; + + const options: FieldValidationOptions = {}; + + if (hasOption(field, required)) { + options.required = getOption(field, required); + } + + if (hasOption(field, set_once)) { + options.set_once = getOption(field, set_once); + } + + if (hasOption(field, distinct)) { + options.distinct = getOption(field, distinct); + } + + if (hasOption(field, validate)) { + options.validate = getOption(field, validate); + } + + if (hasOption(field, min)) { + const minOpt = getOption(field, min); + options.min = { + value: minOpt.value, + exclusive: minOpt.exclusive, + errorMsg: minOpt.errorMsg + }; + } + + if (hasOption(field, max)) { + const maxOpt = getOption(field, max); + options.max = { + value: maxOpt.value, + exclusive: maxOpt.excessive, + errorMsg: maxOpt.errorMsg + }; + } + + if (hasOption(field, pattern)) { + const patOpt = getOption(field, pattern); + options.pattern = { + regex: patOpt.regex, + errorMsg: patOpt.errorMsg + }; + } + + if (hasOption(field, if_missing)) { + const ifMissingOpt = getOption(field, if_missing); + options.if_missing = { + errorMsg: ifMissingOpt.errorMsg + }; + } + + if (hasOption(field, if_invalid)) { + const ifInvalidOpt = getOption(field, if_invalid); + options.if_invalid = { + errorMsg: ifInvalidOpt.errorMsg + }; + } + + return Object.keys(options).length > 0 ? options : null; +} + +// Usage: +const emailOptions = getFieldValidationOptions(UserSchema, 'email'); +console.log(JSON.stringify(emailOptions, null, 2)); +``` + +## TypeScript Types + +```typescript +import type { GenMessage, DescMessage, DescField, DescExtension } from '@bufbuild/protobuf/codegenv2'; +import type { MessageOptions, FieldOptions } from '@bufbuild/protobuf/wkt'; + +// Schema is a GenMessage which extends DescMessage +const schema: GenMessage = UserSchema; + +// Access the descriptor properties +const messageDescriptor: DescMessage = UserSchema; + +// Field descriptors +const fieldDescriptor: DescField = UserSchema.fields[0]; + +// Extension descriptors (for custom options) +const requiredExt: GenExtension = required; +const patternExt: GenExtension = pattern; +``` + +## Important Notes + +1. **Schema IS the descriptor**: In v2, `UserSchema` is the message descriptor itself. Don't look for `UserSchema.message` - that doesn't exist. + +2. **Use `hasOption`/`getOption` for cleaner code**: These functions handle the descriptor types correctly and are the recommended approach. + +3. **Access field options via descriptor, not proto**: While you can access `field.proto.options`, using `hasOption(field, extension)` is cleaner. + +4. **Field names**: Use `field.name` for the proto name, `field.localName` for the TypeScript property name. + +5. **Type safety**: Extension functions are fully type-safe. The return type matches the extension value type. + +## Common Patterns + +### Pattern 1: Validate a message based on its field options + +```typescript +function validateMessage(schema: GenMessage, data: any): string[] { + const errors: string[] = []; + + for (const field of schema.fields) { + const value = data[field.localName]; + + // Check required + if (hasOption(field, required) && getOption(field, required)) { + if (value === undefined || value === null || value === '') { + errors.push(`Field ${field.name} is required`); + } + } + + // Check pattern + if (hasOption(field, pattern) && typeof value === 'string') { + const patternOpt = getOption(field, pattern); + const regex = new RegExp(patternOpt.regex); + if (!regex.test(value)) { + errors.push(patternOpt.errorMsg || `Field ${field.name} does not match pattern`); + } + } + + // Check min for numbers + if (hasOption(field, min) && typeof value === 'number') { + const minOpt = getOption(field, min); + const minValue = parseFloat(minOpt.value); + if (minOpt.exclusive ? value <= minValue : value < minValue) { + errors.push(minOpt.errorMsg || `Field ${field.name} is below minimum`); + } + } + } + + return errors; +} +``` + +### Pattern 2: Generate validation schema for another library + +```typescript +function toZodSchema(schema: GenMessage) { + const fields: Record = {}; + + for (const field of schema.fields) { + let fieldSchema; + + if (field.fieldKind === 'scalar' && field.scalar === ScalarType.STRING) { + fieldSchema = z.string(); + + if (hasOption(field, pattern)) { + const patternOpt = getOption(field, pattern); + fieldSchema = fieldSchema.regex(new RegExp(patternOpt.regex), { + message: patternOpt.errorMsg + }); + } + } else if (field.fieldKind === 'scalar' && field.scalar === ScalarType.INT32) { + fieldSchema = z.number().int(); + + if (hasOption(field, min)) { + const minOpt = getOption(field, min); + const minValue = parseInt(minOpt.value); + fieldSchema = minOpt.exclusive + ? fieldSchema.gt(minValue) + : fieldSchema.gte(minValue); + } + } + + if (hasOption(field, required) && getOption(field, required)) { + // Field is required (already the default in Zod) + } else { + fieldSchema = fieldSchema.optional(); + } + + fields[field.localName] = fieldSchema; + } + + return z.object(fields); +} +``` + +## Available Spine Validation Extensions + +From `/Users/armiol/development/Spine/validation-ts/src/generated/options_pb.ts`: + +### Field Options: +- `required: GenExtension` - Field is required +- `if_missing: GenExtension` - Custom error for missing field +- `min: GenExtension` - Minimum value constraint +- `max: GenExtension` - Maximum value constraint +- `pattern: GenExtension` - Regex pattern constraint +- `validate: GenExtension` - Enable validation for nested messages +- `if_invalid: GenExtension` - Custom error for invalid field +- `goes: GenExtension` - Field dependency +- `set_once: GenExtension` - Field can only be set once +- `distinct: GenExtension` - Repeated field must have unique values +- `range: GenExtension` - Range constraint for numbers + +### Message Options: +- `required_field: GenExtension` - Required field combinations +- `entity: GenExtension` - Entity metadata + +### Oneof Options: +- `is_required: GenExtension` - Oneof group must have one field set + +## References + +- **Generated files**: `/Users/armiol/development/Spine/validation-ts/src/generated/` + - `examples/user_pb.ts` - Example generated code with schemas + - `options_pb.ts` - Spine validation extension definitions + +- **@bufbuild/protobuf documentation**: https://github.com/bufbuild/protobuf-es + +- **Protobuf descriptor documentation**: https://protobuf.dev/reference/protobuf/google.protobuf/ diff --git a/packages/spine-validation-ts/QUICK_REFERENCE.md b/packages/spine-validation-ts/QUICK_REFERENCE.md new file mode 100644 index 0000000..756b8e5 --- /dev/null +++ b/packages/spine-validation-ts/QUICK_REFERENCE.md @@ -0,0 +1,202 @@ +# Quick Reference: Accessing Descriptors and Field Options in @bufbuild/protobuf v2 + +## TL;DR + +```typescript +import { hasOption, getOption } from '@bufbuild/protobuf'; +import { UserSchema } from './generated/examples/user_pb'; +import { required, pattern, min } from './generated/options_pb'; + +// Access message options +const msgRequiredFields = getOption(UserSchema, required_field); + +// Access field and its options +const emailField = UserSchema.fields.find(f => f.name === 'email'); +if (hasOption(emailField, required)) { + const isRequired = getOption(emailField, required); // true +} +if (hasOption(emailField, pattern)) { + const { regex, errorMsg } = getOption(emailField, pattern); +} +``` + +## Key Points + +1. **Schema IS the descriptor**: `UserSchema` directly provides descriptor properties +2. **Use `hasOption`/`getOption`**: Cleanest API for accessing custom options +3. **Iterate fields**: `schema.fields` gives you all field descriptors +4. **Type-safe**: All extension accesses are fully typed + +## Common Operations + +### Get a field descriptor +```typescript +// By name +const field = UserSchema.fields.find(f => f.name === 'email'); + +// By localName (TypeScript property name) +const field = UserSchema.field['email']; // Using record accessor + +// All fields +for (const field of UserSchema.fields) { + console.log(field.name, field.fieldKind); +} +``` + +### Check if field has an option +```typescript +if (hasOption(field, required)) { + // Field has the required option +} +``` + +### Get option value +```typescript +// Simple boolean option +const isRequired = getOption(field, required); + +// Complex option (returns message type) +const patternOpt = getOption(field, pattern); +console.log(patternOpt.regex); +console.log(patternOpt.errorMsg); + +const minOpt = getOption(field, min); +console.log(minOpt.value); // "1" +console.log(minOpt.exclusive); // false +console.log(minOpt.errorMsg); // custom error message +``` + +### Get message-level options +```typescript +import { required_field } from './generated/options_pb'; + +if (hasOption(UserSchema, required_field)) { + const value = getOption(UserSchema, required_field); + console.log(value); // "id | email" +} +``` + +## Field Descriptor Properties + +```typescript +const field = UserSchema.fields[0]; + +field.name // "email" (proto name) +field.localName // "email" (TypeScript property name) +field.number // 3 (field number) +field.fieldKind // "scalar" | "message" | "enum" | "list" | "map" +field.jsonName // "email" (JSON field name) + +// For scalar fields: +field.scalar // ScalarType.STRING, ScalarType.INT32, etc. + +// For message fields: +field.message // Message descriptor (DescMessage) + +// For enum fields: +field.enum // Enum descriptor (DescEnum) +``` + +## Message Descriptor Properties + +```typescript +UserSchema.kind // "message" +UserSchema.typeName // "example.User" +UserSchema.name // "User" +UserSchema.fields // Array +UserSchema.field // Record - by localName +UserSchema.oneofs // Array +UserSchema.proto // DescriptorProto (raw protobuf descriptor) +``` + +## All Available Spine Validation Extensions + +### Field-level +```typescript +import { + required, // boolean + set_once, // boolean + distinct, // boolean (for repeated fields) + validate, // boolean (for message fields) + min, // MinOption { value, exclusive, errorMsg } + max, // MaxOption { value, exclusive, errorMsg } + pattern, // PatternOption { regex, errorMsg, modifier } + range, // string + if_missing, // IfMissingOption { errorMsg } + if_invalid, // IfInvalidOption { errorMsg } + goes, // GoesOption { with, errorMsg } +} from './generated/options_pb'; +``` + +### Message-level +```typescript +import { + required_field, // string (e.g., "id | email") + entity, // EntityOption { kind, visibility } +} from './generated/options_pb'; +``` + +### Oneof-level +```typescript +import { + is_required, // boolean +} from './generated/options_pb'; +``` + +## Complete Example + +```typescript +import { hasOption, getOption } from '@bufbuild/protobuf'; +import { UserSchema } from './generated/examples/user_pb'; +import { required, pattern, min, set_once, distinct } from './generated/options_pb'; + +// Check all fields for validation options +for (const field of UserSchema.fields) { + console.log(`\nField: ${field.name} (${field.fieldKind})`); + + if (hasOption(field, required)) { + console.log(` βœ“ Required: ${getOption(field, required)}`); + } + + if (hasOption(field, set_once)) { + console.log(` βœ“ Set once: ${getOption(field, set_once)}`); + } + + if (hasOption(field, distinct)) { + console.log(` βœ“ Distinct: ${getOption(field, distinct)}`); + } + + if (hasOption(field, min)) { + const { value, exclusive, errorMsg } = getOption(field, min); + console.log(` βœ“ Min: ${value} (exclusive: ${exclusive})`); + } + + if (hasOption(field, pattern)) { + const { regex, errorMsg } = getOption(field, pattern); + console.log(` βœ“ Pattern: ${regex}`); + if (errorMsg) console.log(` Error: ${errorMsg}`); + } +} +``` + +## Alternative: Using `hasExtension`/`getExtension` + +If you need the lower-level API: + +```typescript +import { hasExtension, getExtension } from '@bufbuild/protobuf'; + +// For message options +const msgOptions = UserSchema.proto.options; +if (msgOptions && hasExtension(msgOptions, required_field)) { + const value = getExtension(msgOptions, required_field); +} + +// For field options +const fieldOptions = emailField.proto.options; +if (fieldOptions && hasExtension(fieldOptions, required)) { + const value = getExtension(fieldOptions, required); +} +``` + +**Note**: `hasOption`/`getOption` is recommended as it's cleaner and works directly with descriptors. diff --git a/packages/spine-validation-ts/README.md b/packages/spine-validation-ts/README.md new file mode 100644 index 0000000..77f2ad7 --- /dev/null +++ b/packages/spine-validation-ts/README.md @@ -0,0 +1,251 @@ +# @spine-event-engine/validation-ts + +TypeScript validation library for Protobuf messages with [Spine Event Engine](https://spine.io/) validation options. + +## Features + +- βœ… Runtime validation of Protobuf messages against Spine validation constraints +- βœ… Support for all major Spine validation options +- βœ… Custom error messages with placeholder substitution +- βœ… Type-safe validation with full TypeScript support +- βœ… Works with [@bufbuild/protobuf](https://github.com/bufbuild/protobuf-es) (Protobuf-ES v2) +- βœ… Comprehensive test coverage (223 tests) + +## Installation + +```bash +npm install @spine-event-engine/validation-ts +``` + +## Prerequisites + +This library requires: +- `@bufbuild/protobuf` v2.10.2 or later +- Protobuf definitions with Spine validation options + +The package includes: +- Spine validation Proto definitions (`spine/options.proto`) +- TypeScript validation implementation +- Pre-configured TypeScript build + +## Quick Start + +```typescript +import { create } from '@bufbuild/protobuf'; +import { validate, formatViolations } from '@spine-event-engine/validation-ts'; +import { UserSchema } from './generated/user_pb'; + +// Create a message with validation constraints. +const user = create(UserSchema, { + name: '', // This field is marked as `(required) = true`. + email: '' // This field is also required. +}); + +// Validate the message. +const violations = validate(UserSchema, user); + +if (violations.length > 0) { + console.log('Validation failed:'); + console.log(formatViolations(violations)); + // Output: + // 1. example.User.name: A value must be set. + // 2. example.User.email: A value must be set. +} +``` + +## API Reference + +### `validate(schema, message)` + +Validates a Protobuf message against its Spine validation constraints. + +**Parameters:** +- `schema`: The message schema (e.g., `UserSchema`) +- `message`: The message instance to validate + +**Returns:** Array of `ConstraintViolation` objects (empty if valid) + +### `formatViolations(violations)` + +Formats validation violations into a human-readable string. + +**Parameters:** +- `violations`: Array of constraint violations + +**Returns:** Formatted string describing all violations + +### `formatTemplateString(template, values)` + +Formats a `TemplateString` by replacing placeholders with provided values. + +**Parameters:** +- `template`: Template string with placeholders (e.g., `{value}`, `{other}`) +- `values`: Object mapping placeholder names to their values + +**Returns:** Formatted string with placeholders replaced + +## Supported Validation Options + +### Field-Level Options + +- βœ… **`(required)`** - Ensures field has a non-default value +- βœ… **`(if_missing)`** - Custom error message for required fields +- βœ… **`(pattern)`** - Regex validation for string fields +- βœ… **`(min)` / `(max)`** - Numeric range validation with inclusive/exclusive bounds +- βœ… **`(range)`** - Bounded numeric ranges using bracket notation `[min..max]` +- βœ… **`(distinct)`** - Ensures unique elements in repeated fields +- βœ… **`(validate)`** - Enables recursive validation of nested messages +- βœ… **`(if_invalid)`** - Custom error message for nested validation failures +- βœ… **`(goes)`** - Field dependency validation (field can only be set if another field is set) + +### Message-Level Options + +- βœ… **`(required_field)`** - Requires specific field combinations using boolean logic + +### Oneof-Level Options + +- βœ… **`(is_required)`** - Requires that one of the oneof fields must be set + +## Example Proto File + +```protobuf +syntax = "proto3"; + +import "spine/options.proto"; + +message User { + option (required_field) = "id | email"; + + int32 id = 1 [ + (set_once) = true, + (min).value = "1" + ]; + + string name = 2 [ + (required) = true, + (pattern).regex = "^[A-Za-z][A-Za-z0-9 ]{1,49}$", + (pattern).error_msg = "Name must start with a letter and be 2-50 characters." + ]; + + string email = 3 [ + (required) = true, + (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + (pattern).error_msg = "Email must be valid." + ]; + + int32 age = 4 [ + (range) = "[13..120]" + ]; + + repeated string tags = 5 [ + (distinct) = true + ]; +} + +message Address { + string street = 1 [(required) = true]; + string city = 2 [(required) = true]; + string zip_code = 3 [ + (pattern).regex = "^[0-9]{5}$" + ]; +} + +message UserProfile { + User user = 1 [ + (required) = true, + (validate) = true, + (if_invalid).error_msg = "User data is invalid." + ]; + + Address address = 2 [ + (validate) = true + ]; +} +``` + +## Validation Behavior + +### Proto3 Field Semantics + +In `proto3`, fields have default values: +- Numeric fields default to `0` +- String fields default to `""` +- Bool fields default to `false` +- Message fields default to `undefined` + +The `(required)` validator considers a field "set" when: +- String fields are non-empty +- Numeric fields are non-zero +- Bool fields are `true` or `false` (both count as set) +- Message fields are not `undefined` +- Repeated fields have at least one element + +### Nested Validation + +Use `(validate) = true` on message fields to recursively validate nested messages: + +```protobuf +message Order { + Product product = 1 [ + (required) = true, + (validate) = true // Validates Product's constraints too. + ]; +} +``` + +### Field Dependencies + +Use `(goes)` to enforce field dependencies: + +```protobuf +message ShippingDetails { + string tracking_number = 1 [ + (goes).with = "carrier", + (goes).error_msg = "Tracking number requires carrier to be set." + ]; + string carrier = 2 [(goes).with = "tracking_number"]; +} +``` + +### Required Field Combinations + +Use `(required_field)` for complex field requirements: + +```protobuf +message ContactInfo { + option (required_field) = "(phone & country_code) | email"; + + string phone = 1; + string country_code = 2; + string email = 3; +} +``` + +## Testing + +The package includes comprehensive test coverage with 223 tests across 10 test suites: + +- `basic-validation.test.ts` - Basic validation and formatting +- `required.test.ts` - `(required)` and `(if_missing)` options +- `pattern.test.ts` - `(pattern)` regex validation +- `required-field.test.ts` - `(required_field)` message-level option +- `min-max.test.ts` - `(min)` and `(max)` numeric validation +- `range.test.ts` - `(range)` bracket notation +- `distinct.test.ts` - `(distinct)` uniqueness validation +- `validate.test.ts` - `(validate)` and `(if_invalid)` nested validation +- `goes.test.ts` - `(goes)` field dependency validation +- `integration.test.ts` - Complex multi-option scenarios + +Run tests with: + +```bash +npm test +``` + +## License + +Apache License 2.0 + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/packages/spine-validation-ts/buf.gen.yaml b/packages/spine-validation-ts/buf.gen.yaml new file mode 100644 index 0000000..c2a80a0 --- /dev/null +++ b/packages/spine-validation-ts/buf.gen.yaml @@ -0,0 +1,6 @@ +version: v2 +plugins: + - local: protoc-gen-es + out: src/generated + opt: + - target=ts diff --git a/packages/spine-validation-ts/buf.yaml b/packages/spine-validation-ts/buf.yaml new file mode 100644 index 0000000..c7e30e3 --- /dev/null +++ b/packages/spine-validation-ts/buf.yaml @@ -0,0 +1,9 @@ +version: v2 +modules: + - path: proto +lint: + use: + - STANDARD +breaking: + use: + - FILE diff --git a/packages/spine-validation-ts/jest.config.js b/packages/spine-validation-ts/jest.config.js new file mode 100644 index 0000000..c44c9c7 --- /dev/null +++ b/packages/spine-validation-ts/jest.config.js @@ -0,0 +1,22 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/tests'], + testMatch: ['**/*.test.ts'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/generated/**', + ], + moduleFileExtensions: ['ts', 'js', 'json'], + coverageDirectory: 'coverage', + verbose: true, + transform: { + '^.+\\.ts$': ['ts-jest', { + tsconfig: { + skipLibCheck: true, + strict: false, + }, + }], + }, +}; diff --git a/packages/spine-validation-ts/package.json b/packages/spine-validation-ts/package.json new file mode 100644 index 0000000..c965da0 --- /dev/null +++ b/packages/spine-validation-ts/package.json @@ -0,0 +1,48 @@ +{ + "name": "@spine-event-engine/validation-ts", + "version": "2.0.0-snapshot.1", + "description": "TypeScript validation library for Protobuf messages with Spine validation options", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "generate": "buf generate", + "generate:tests": "cd tests && buf generate", + "build": "npm run generate && tsc", + "test": "npm run generate && npm run generate:tests && jest", + "test:watch": "npm run generate && npm run generate:tests && jest --watch", + "test:coverage": "npm run generate && npm run generate:tests && jest --coverage", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "protobuf", + "validation", + "spine", + "typescript", + "protobuf-es" + ], + "author": "", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.10.2" + }, + "devDependencies": { + "@bufbuild/buf": "^1.61.0", + "@bufbuild/protobuf": "^2.10.2", + "@bufbuild/protoc-gen-es": "^2.10.2", + "@types/jest": "^30.0.0", + "@types/node": "^25.0.3", + "jest": "^30.2.0", + "ts-jest": "^29.4.6", + "typescript": "^5.9.3" + }, + "files": [ + "dist", + "src", + "proto", + "buf.yaml", + "buf.gen.yaml", + "README.md", + "!src/generated/examples", + "!tests" + ] +} diff --git a/packages/spine-validation-ts/proto/spine/base/field_path.proto b/packages/spine-validation-ts/proto/spine/base/field_path.proto new file mode 100644 index 0000000..6ee1bc7 --- /dev/null +++ b/packages/spine-validation-ts/proto/spine/base/field_path.proto @@ -0,0 +1,59 @@ +/* + * Copyright 2024, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +syntax = "proto3"; + +package spine.base; + +import "spine/options.proto"; + +option (type_url_prefix) = "type.spine.io"; +option java_package = "io.spine.base"; +option java_outer_classname = "FieldPathProto"; +option java_multiple_files = true; + +// Field path provides field names of nested messages. +// +// The first entry in the names list is a name of a field in the root message. +// The second entry is the name of the field in the nested message, and so on. +// +// For example, consider the following message declarations: +// +// message CustomerAccount { +// User user = 1; +// } +// +// message User { +// string name = 1; +// } +// +// The field path for `name` field of the `User` message enclosed into the `CustomerAccount` +// is ["user", "name"]. +// +message FieldPath { + + // Unqualified field names. + repeated string field_name = 1; +} diff --git a/packages/spine-validation-ts/proto/spine/options.proto b/packages/spine-validation-ts/proto/spine/options.proto new file mode 100644 index 0000000..2e05914 --- /dev/null +++ b/packages/spine-validation-ts/proto/spine/options.proto @@ -0,0 +1,958 @@ +/* + * Copyright 2022, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +syntax = "proto3"; + +// API Note on Packaging +// --------------------- +// We do not define the package for this file to allow shorter options for user-defined types. +// This allows to write: +// +// option (internal) = true; +// +// instead of: +// +// option (spine.base.internal) = true; +// + +// Custom Type Prefix Option +// ------------------------- +// The custom `type_url_prefix` option allows to define specify custom type URL prefix for messages +// defined in a proto file. This option is declared in this file. Other proto files must import +// `options.proto` to be able to specify custom type URL prefix. +// +// It is recommended that the import statement is provided before the line with `type_url_prefix` +// option to make it obvious that custom option is defined in the imported file. +// +// For example: +// +// syntax = "proto3"; +// +// package my.package; +// +// import "spine/options.proto"; +// +// option (type_url_prefix) = "type.example.org"; +// + +option (type_url_prefix) = "type.spine.io"; +option java_multiple_files = true; +option java_outer_classname = "OptionsProto"; +option java_package = "io.spine.option"; + +import "google/protobuf/descriptor.proto"; + +// +// Reserved Range of Option Field Numbers +// -------------------------------------- +// Spine Options use the range of option field numbers from the internal range reserved for +// individual organizations. For details of custom Protobuf options and this range please see: +// +// https://developers.google.com/protocol-buffers/docs/proto#customoptions +// +// The whole range reserved for individual organizations is 50000-99999. +// The range used by Spine Options is 73812-75000. +// In order to prevent number collision with custom options used by a project based on Spine, +// numbers for custom options defined in this project should be in the range 50000-73811 or +// 75001-99999. +// + +extend google.protobuf.FieldOptions { + + // Field Validation Constraints + //----------------------------- + // For constraints defined via message-based types, please see documentation of corresponding + // message types. + // + + // The option to mark a field as required. + // + // If the field type is a `message`, it must be set to a non-default instance. + // If it is `string` or `bytes`, the value must not be an empty string or an array. + // Other field types are not applicable. + // If the field is repeated, it must have at least one element. + // + // Unlike the `required` keyword used in Protobuf 2, the option does not affect the transfer + // layer. Even if a message content violates the requirement set by the option, it would still + // be a valid message for the Protobuf library. + // + // Example: Using `(required)` field validation constraint. + // + // message MyOuterMessage { + // MyMessage field = 1 [(required) = true]; + // } + // + bool required = 73812; + + // See `IfMissingOption` + IfMissingOption if_missing = 73813; + + // Reserved 73814 and 73815 for deleted options `decimal_max` and `decimal_min`. + + // A higher boundary to the range of values of a number. + MaxOption max = 73816; + + // A lower boundary to the range of values of a number. + MinOption min = 73817; + + // 73818 reserved for the (digits) option. + + // 73819 reserved for the (when) option. + + // See `PatternOption`. + PatternOption pattern = 73820; + + // Turns on validation constraint checking for a value of a message, a map, or a repeated field. + // + // Default value is `false`. + // + // If set to `true`, the outer message declaring the annotated field would be valid if: + // + // 1. A message field value satisfies the validation constraints defined in the corresponding + // message type of the field. + // + // 2. Each value of a map entry satisfies validation constraints. + // + // 3. Each item of a repeated field satisfies validation constraints. + // + bool validate = 73821; + + // See `IfInvalidOption`. + IfInvalidOption if_invalid = 73822; + + // See `GoesOption`. + GoesOption goes = 73823; + + // Indicates that a field can only be set once. + // + // A typical use-case would include a value of an ID, which doesn't change over the course of + // the life of an entity. + // + // Example: Using `(set_once)` field validation constraint. + // + // message User { + // UserId id = 1 [(set_once) = true]; + // } + // + // Once set, the `id` field cannot be changed. + // + bool set_once = 73824; + + // The option to mark a `repeated` field as a collection of unique elements. + // + // Example: Using `(distinct)` constraint for a repeated field. + // + // message Blizzard { + // + // // All snowflakes must be unique in this blizzard. + // // + // // Attempting to add a snowflake that is equal to an existing one would result + // // in constraint violation error. + // // + // repeated Snowflake = 1 [(distinct) = true]; + // } + // + bool distinct = 73825; + + // The option to indicate that a numeric field is required to have a value which belongs + // to the specified bounded range. For unbounded ranges, please use `(min)` and `(max) options. + // + // The range can be open (not including the endpoint) or closed (including the endpoint) on + // each side. Open endpoints are indicated using a parenthesis (`(`, `)`). Closed endpoints are + // indicated using a square bracket (`[`, `]`). + // + // Example: Defining ranges of numeric values. + // + // message NumRanges { + // int32 hour = 1 [(range) = "[0..24)"]; + // uint32 minute = 2 [(range) = "[0..59]"]; + // float degree = 3 [(range) = "[0.0..360.0)"]; + // double angle = 4 [(range) = "(0.0..180.0)"]; + // } + // + // NOTE: That definition of ranges must be consistent with the type they constrain. + // An range for an integer field must be defined with integer endpoints. + // A range for a floating point field must be defined with decimal separator (`.`), + // even if the endpoint value does not have a fractional part. + // + string range = 73826; + + // Reserved 73827 to 73849 for future validation options. + + // API Annotations + //----------------- + + // Indicates a field which is internal to Spine, not part of the public API, and should not be + // used by users of the framework. + // + // If you plan to implement an extension of the framework, which is going to be + // wired into the framework, you may use the internal parts. Please consult with the Spine + // team, as the internal APIs do not have the same stability API guarantee as public ones. + // + bool internal = 73850; + + // Reserved 73851 for the deleted SPI option. + + // Indicates a field that can change at any time, and has no guarantee of API stability and + // backward-compatibility. + // + // Usage guidelines: + // 1. This annotation is used only on public API. Internal interfaces should not use it. + // 2. This annotation can only be added to new API. Adding it to an existing API is considered + // API-breaking. + // 3. Removing this annotation from an API gives it stable status. + // + bool experimental = 73852; + + // Signifies that a public API is subject to incompatible changes, or even removal, in a future + // release. + // + // An API bearing this annotation is exempt from any compatibility guarantees made by its + // containing library. Note that the presence of this annotation implies nothing about the + // quality of the API in question, only the fact that it is not "API-frozen." + // It is generally safe for applications to depend on beta APIs, at the cost of some extra work + // during upgrades. + // + bool beta = 73853; + + // Marks an entity state field as column. + // + // The column fields are stored separately from the entity record and can be specified as + // filtering criteria during entity querying. + // + // The column field should be declared as follows: + // + // message UserProfile { + // ... + // int32 year_of_registration = 8 [(column) = true]; + // } + // + // The `year_of_registration` field value can then be used as query parameter when reading + // entities of `UserProfile` type from the server side. + // + // The value of a column field can be updated in two ways: + // + // 1. In the receptors of the entity, just like any other part of entity state. + // 2. Using the language-specific tools like `EntityWithColumns` interface in Java. + // + // All column fields are considered optional by the framework. + // + // Currently, only entities of projection and process manager type are eligible for having + // columns (see `EntityOption`). For all other message types the column declarations are + // ignored. + // + // The `repeated` and `map` fields cannot be columns. + // + bool column = 73854; + + // Reserved 73855 to 73890 for future options. + + // Reserved 73900 for removed `by` option. +} + +extend google.protobuf.OneofOptions { + + // Marks a `oneof` group, in which one field *must* be set. + // + // Alternative to `(required_field)` with all the field in the group joined with the OR + // operator. + // + bool is_required = 73891; + + // Reserved 73892 to 73899 for future options. +} + +extend google.protobuf.MessageOptions { + + // Validation Constraints + //------------------------ + + // The default format string for validation error message text. + // + // This option extends message types that extend `FieldOptions` + // The number of parameters and their types are determined by the type of field options. + // + // Usage of this value is deprecated. Along with the old `msg_format`s, it exists to support + // the old validation library. The new version of the validation library, which does not lie in + // the `base` repository, constructs the default error messages separately when creating + // language-agnostic validation rules. + // + string default_message = 73901 [deprecated = true]; + + // The constraint to require at least one of the fields or a combination of fields. + // + // Unlike the `required` field constraint which always require corresponding field, + // this message option allows to require alternative fields or a combination of them as + // an alternative. Field names and `oneof` group names are acceptable. + // + // Field names are separated using the pipe (`|`) symbol. The combination of fields is defined + // using the ampersand (`&`) symbol. + // + // Example: Pipe syntax for defining alternative required fields. + // + // message PersonName { + // option (required_field) = "given_name|honorific_prefix & family_name"; + // + // string honorific_prefix = 1; + // string given_name = 2; + // string middle_name = 3; + // string family_name = 4; + // string honorific_suffix = 5; + // } + // + string required_field = 73902; + + // See `EntityOption`. + EntityOption entity = 73903; + + // An external validation constraint for a field. + // + // Allows to re-define validation constraints for a message when its usage as a field of + // another type requires alternative constraints. This includes definition of constraints for + // a message which does not have them defined within the type. + // + // A target field of an external constraint should be specified using a fully-qualified + // field name (e.g. `mypackage.MessageName.field_name`). + // + // Example: Defining external validation constraint. + // + // package io.spine.example; + // + // // Defines a change in a string value. + // // + // // Both of the fields of this message are not `required` to be able to describe + // // a change from empty value to non-empty value, or from a non-empty value to + // // an empty string. + // // + // message StringChange { + // + // // The value of the field that's changing. + // string previous_value = 1; + // + // // The new value of the field. + // string new_value = 2; + // } + // + // // A command to change a name of a task. + // // + // // The task has a non-empty name. A new name cannot be empty. + // // + // message RenameTask { + // + // // The ID of the task to rename. + // string task_id = 1; + // + // // Instruction for changing the name. + // // + // // The value of `change.previous_value` is the current name of the task. + // // It cannot be empty. + // // + // // The value of `change.new_value` is the new name of the task. + // // It cannot be empty either. + // // + // StringChange change = 1 [(validate) = true]; + // } + // + // // External validation constraint for both fields of the `StringChange` message + // // in the scope of the `RenameTask` command. + // // + // message RequireTaskNames { + // option (constraint_for) = "spine.example.RenameTask.change"; + // + // string previous_value = 1 [(required) = true]; + // string new_value = 2 [(required) = true]; + // } + // + // NOTE: A target field for an external validation constraint must be have the option `(validate)` + // set to `true`. See the definition of the `RenameTask.change` field in the example + // above. If there is no such option defined, or it is set to `false`, the external + // constraint will not be applied. + // + // External validation constraints can be applied to fields of several types. + // To do so, separate fully-qualified references to these fields with comma. + // + // Example: External validation constraints for multiple fields. + // + // // External validation constraint for requiring a new value in renaming commands. + // message RequireNewName { + // option (constraint_for) = "spine.example.RenameTask.change," + // "spine.example.RenameProject.change,"; + // "spine.example.UpdateComment.text_change; + // + // string new_value = 1 [(required) = true]; + // } + // + // NOTE: An external validation constraint for a field must be defined only once. + // Spine Model Compiler does not check such an "overwriting". + // See the issue: https://github.com/SpineEventEngine/base/issues/318. + // + string constraint_for = 73904; + + // Reserved 73905 to 73910 for future validation options. + + // API Annotations + //----------------- + + // Indicates a type usage of which is restricted in one of the following ways. + // + // 1. This type is internal to the Spine Event Engine framework. It is not a part of + // the public API, and must not be used by framework users. + // + // 2. The type is internal to a bounded context, artifact of which exposes the type to + // the outside world (presumably for historical reasons). + // + // The type with such an option can be used only inside the bounded context which declares it. + // + // The type must not be used neither for inbound (i.e. being sent to the bounded context + // which declares this type) nor for outbound communication (i.e. being sent by this + // bounded context outside). + // + // An attempt to violate these usage restrictions will result in a runtime error. + // + bool internal_type = 73911; + + // Indicates a file which contains elements of Service Provider Interface (SPI). + bool SPI_type = 73912; + + // Indicates a public API that can change at any time, and has no guarantee of + // API stability and backward-compatibility. + bool experimental_type = 73913; + + // Signifies that a public API is subject to incompatible changes, or even removal, + // in a future release. + bool beta_type = 73914; + + // Specifies a characteristic inherent in the the given message type. + // + // Example: Using `(is)` message option. + // + // message CreateProject { + // option (is).java_type = "ProjectCommand"; + // + // // Remainder omitted. + // } + // + // In the example above, `CreateProject` message is a `ProjectCommand`. + // + // To specify a characteristic for every message in a `.proto` file, use `(every_is)` file + // option. If both `(is)` and `(every_is)` options are found, both are applied. + // + // When targeting Java, specify the name of a Java interface to be implemented by this + // message via `(is).java_type`. + // + IsOption is = 73915; + + // Reserved 73916 to 73921 for future API annotation options. + + // Reserved 73922 for removed `enrichment_for` option. + + // Specifies the natural ordering strategy for this type. + // + // Code generators should generate language-specific comparisons based on the field paths. + // + // Runtime comparators may use the reflection API to compare field values. + // + CompareByOption compare_by = 73923; + + // Reserved 73924 to 73938 for future options. + + // Reserved 73939 and 73940 for the deleted options `events` and `rejections`. +} + +extend google.protobuf.FileOptions { + + // Specifies a type URL prefix for all types within a file. + // + // This type URL will be used when packing messages into `Any`. + // See `any.proto` for more details. + // + string type_url_prefix = 73941; + + // Indicates a file which contains types usage of which is restricted. + // + // For more information on such restrictions please see the documentation of + // the type option called `internal_type`. + // + bool internal_all = 73942; + + // Indicates a file which contains elements of Service Provider Interface (SPI). + bool SPI_all = 73943; + + // Indicates a public API that can change at any time, and has no guarantee of + // API stability and backward-compatibility. + bool experimental_all = 73944; + + // Signifies that a public API is subject to incompatible changes, or even removal, + // in a future release. + bool beta_all = 73945; + + // Specifies a characteristic common for all the message types in the given file. + // + // Example: Marking all the messages using the `(every_is)` file option. + // ``` + // option (every_is).java_type = "ProjectCommand"; + // + // message CreateProject { + // // ... + // + // message WithAssignee { + // // ... + // } + // } + // + // message DeleteProject { /*...*/ } + // ``` + // + // In the example above, `CreateProject`, `CreateProject.WithAssignee`, and `DeleteProject` + // messages are `ProjectCommand`-s. + // + // To specify a characteristic for a single message, use `(is)` message option. If both `(is)` + // and `(every_is)` options are found, both are applied. + // + // When targeting Java, specify the name of a Java interface to be implemented by these + // message types via `(every_is).java_type`. + // + IsOption every_is = 73946; + + // Reserved 73947 to 73970 for future use. +} + +extend google.protobuf.ServiceOptions { + + // Indicates that the service is a part of Service Provider Interface (SPI). + bool SPI_service = 73971; + + // Reserved 73971 to 73980. +} + +// Reserved 73981 to 74000 for other future Spine Options numbers. + +// +// Validation Option Types +//--------------------------- + +// Defines the error handling for `required` field with no value set. +// +// Applies only to the fields marked as `required`. +// Validation error message is composed according to the rules defined by this option. +// +// Example: Using the `(if_missing)` option. +// +// message Holder { +// MyMessage field = 1 [(required) = true, +// (if_missing).error_msg = "This field is required."]; +// } +// +message IfMissingOption { + + // The default error message. + option (default_message) = "A value must be set."; + + // A user-defined validation error format message. + // + // Use `error_msg` instead. + // + string msg_format = 1 [deprecated = true]; + + // A user-defined error message. + string error_msg = 2; +} + +// The field value must be greater than or equal to the given minimum number. +// +// Is applicable only to numbers. +// Repeated fields are supported. +// +// Example: Defining lower boundary for a numeric field. +// +// message KelvinTemperature { +// double value = 1 [(min) = { +// value = "0.0" +// exclusive = true +// error_msg = "Temperature cannot reach {other}K, but provided {value}." +// }]; +// } +// +message MinOption { + + // The default error message format string. + // + // The format parameters are: + // 1) "or equal to " string (if the `exclusive` parameter is false) or an empty string; + // 2) the minimum number. + // + option (default_message) = "The number must be greater than %s%s."; + + // The string representation of the minimum field value. + string value = 1; + + // Specifies if the field should be strictly greater than the specified minimum. + // + // The default value is false, i.e. the bound is inclusive. + // + bool exclusive = 2; + + // A user-defined validation error format message. + string msg_format = 3 [deprecated = true]; + + // A user-defined validation error format message. + // + // May include tokens `{value}`β€”for the actual value of the field, and `{other}`β€”for + // the threshold value. The tokens will be replaced at runtime when the error is constructed. + // + string error_msg = 4; +} + +// The field value must be less than or equal to the given maximum number. +// +// Is applicable only to numbers. +// Repeated fields are supported. +// +// Example: Defining upper boundary for a numeric field. +// +// message Elevation { +// double value = 1 [(max).value = "8848.86"]; +// } +// +message MaxOption { + + // The default error message format string. + // + // The format parameters are: + // 1) "or equal to " string (if the `exclusive` parameter is false) or an empty string; + // 2) the maximum number. + // + option (default_message) = "The number must be less than %s%s."; + + // The string representation of the maximum field value. + string value = 1; + + // Specifies if the field should be strictly less than the specified maximum + // + // The default value is false, i.e. the bound is inclusive. + // + bool exclusive = 2; + + // A user-defined validation error format message. + string msg_format = 3 [deprecated = true]; + + // A user-defined validation error format message. + // + // May include tokens `{value}`β€”for the actual value of the field, and `{other}`β€”for + // the threshold value. The tokens will be replaced at runtime when the error is constructed. + // + string error_msg = 4; +} + +// A string field value must match the given regular expression. +// Is applicable only to strings. +// Repeated fields are supported. +// +// Example: Using the `(pattern)` option. +// +// message CreateAccount { +// string id = 1 [(pattern).regex = "^[A-Za-z0-9+]+$", +// (pattern).error_msg = "ID must be alphanumerical. Provided: `{value}`."]; +// } +// +message PatternOption { + + // The default error message format string. + // + // The format parameter is the regular expression to which the value must match. + // + option (default_message) = "The string must match the regular expression `%s`."; + + // The regular expression to match. + string regex = 1; + + reserved 2; + reserved "flag"; + + // Modifiers for this pattern. + Modifier modifier = 4; + + // A user-defined validation error format message. + string msg_format = 3 [deprecated = true]; + + // A user-defined validation error format message. + // + // May include tokens `{value}`β€”for the actual value of the field, and `{other}`β€”for + // the threshold value. The tokens will be replaced at runtime when the error is constructed. + // + string error_msg = 5; + + // Regular expression modifiers. + // + // These modifiers are specifically selected to be supported in many implementation platforms. + // + message Modifier { + + // Enables the dot (`.`) symbol to match all the characters. + // + // By default, the dot does not match line break characters. + // + // May also be known in some platforms as "single line" mode and be encoded with the `s` + // flag. + // + bool dot_all = 1; + + // Allows to ignore the case of the matched symbols. + // + // For example, this modifier is specified, string `ABC` would be a complete match for + // the regex `[a-z]+`. + // + // On some platforms may be represented by the `i` flag. + // + bool case_insensitive = 2; + + // Enables the `^` (caret) and `$` (dollar) signs to match a start and an end of a line + // instead of a start and an end of the whole expression. + // + // On some platforms may be represented by the `m` flag. + // + bool multiline = 3; + + // Enables matching the whole UTF-8 sequences, + // + // On some platforms may be represented by the `u` flag. + // + bool unicode = 4; + + // Allows the matched strings to contain a full match to the pattern and some other + // characters as well. + // + // By default, a string only matches a pattern if it is a full match, i.e. there are no + // unaccounted for leading and/or trailing characters. + // + // This modifier is usually not represented programming languages, as the control over + // weather to match an entire string or only its part is provided to the user by other + // language means. For example, in Java, this would be the difference between methods + // `matches()` and `find()` of the `java.util.regex.Matcher` class. + // + bool partial_match = 5; + } +} + +// Specifies the message to show if a validated field happens to be invalid. +// Is applicable only to messages. +// Repeated fields are supported. +// +// Example: Using the `(if_invalid)` option. +// +// message Holder { +// MyMessage field = 1 [(validate) = true, +// (if_invalid).error_msg = "The field is invalid."]; +// } +// +message IfInvalidOption { + + // The default error message for the field. + option (default_message) = "The message must have valid properties."; + + // A user-defined validation error format message. + string msg_format = 1 [deprecated = true]; + + // A user-defined validation error format message. + // + // May include the token `{value}` for the actual value of the field. The token will be replaced + // at runtime when the error is constructed. + // + string error_msg = 2; +} + +// Specifies that a message field can be present only if another field is present. +// +// Unlike the `required_field` that handles combination of required fields, this option is useful +// when it is needed to say that an optional field makes sense only when another optional field is +// present. +// +// Example: Requiring mutual presence of optional fields. +// +// message ScheduledItem { +// ... +// spine.time.LocalDate date = 4; +// spine.time.LocalTime time = 5 [(goes).with = "date"]; +// } +// +message GoesOption { + + // The default error message format string. + // + // The first parameter is the name of the field for which we specify the option. + // The second parameter is the name of the field set in the "with" value. + // + option (default_message) = "The field `%s` can only be set when the field `%s` is defined."; + + // A name of the field required for presence of the field for which we set the option. + string with = 1; + + // A user-defined validation error format message. + string msg_format = 2 [deprecated = true]; + + // A user-defined validation error format message. + // + // May include the token `{value}` for the actual value of the field. The token will be replaced + // at runtime when the error is constructed. + // + string error_msg = 3; +} + +// Defines options of a message representing a state of an entity. +message EntityOption { + + // A type of an entity for state of which the message is defined. + enum Kind { + option allow_alias = true; + + // Reserved for errors. + KIND_UNKNOWN = 0; + + // The message is an aggregate state. + AGGREGATE = 1; + + // The message is a state of a projection (same as "view"). + PROJECTION = 2; + + // The message is a state of a view (same as "projection"). + VIEW = 2; + + // The message is a state of a process manager. + PROCESS_MANAGER = 3; + + // The message is a state of an entity, which is not of the type + // defined by other members of this enumeration. + ENTITY = 4; + } + + // The type of the entity. + Kind kind = 1; + + // The level of visibility of the entity to queries. + enum Visibility { + + // Default visibility is different for different types of entities: + // - for projections, "FULL" is default; + // - for aggregates, process managers, and other entities, "NONE" is default. + // + DEFAULT = 0; + + // The entity is not visible to queries. + NONE = 1; + + // Client-side applications can subscribe to updates of entities of this type. + SUBSCRIBE = 2; + + // Client-side applications can query this type of entities. + QUERY = 3; + + // Client-side applications can subscribe and query this type of entity. + FULL = 4; + } + + // The visibility of the entity. + // + // If not defined, the value of this option is `DEFAULT`. + // + Visibility visibility = 2; +} + +// Defines a marker for a given type or a set of types. +// +// The option may be used in two modes: +// - with the marker code generation; +// - without the marker code generation. +// +// When used with the code generation, language-specific markers are generated by the Protobuf +// compiler. Otherwise, it is expected that the user creates such markers manually. +// +message IsOption { + + // Enables the generation of marker interfaces. + // + // The generation is disabled by default. + bool generate = 1; + + // The reference to a Java interface. + // + // May be an fully-qualified or a simple name. In the latter case, the interface should belong + // to the same Java package as the message class which implements this interface. + // + // The framework does not ensure the referenced type exists. + // If the generation is disabled, the Java type is used as-is. Otherwise, a corresponding Java + // interface is generated. + // + // A generated interface has no declared methods and extends `com.google.protobuf.Message`. + // + // The `.java` file is placed alongside with the code generated by the proto-to-java compiler. + // + // If fully-qualified name given, the package of the generated type matches the fully-qualified + // name. When a simple name is set in the option, the package of the interface matches the + // package of the message class. + // + // If both `(is)` and `(every_is)` options specify a Java interface, the message class + // implements both interfaces. + // + string java_type = 2; +} + +// Defines the way to compare two messages of the same type to one another. +// +// Comparisons can be used to sort values. +// +// See the `(compare_by)` option. +// +message CompareByOption { + + // Field paths used for comparisons. + // + // The allowed field types are: + // - any number type; + // - `bool` (false is less than true); + // - `string` (in the order of respective Unicode values); + // - enumerations (following the order of numbers associated with each constant); + // - messages marked with `(compare_by)`. + // + // Other types are not permitted. Neither are repeated and map fields. Such declarations can + // lead to build-time errors. + // + // To refer to nested fields, separate the field names with a dot (`.`). No fields in the path + // can be repeated or maps. + // + // When multiple field paths are specified, comparison is executed in the order of reference. + // For example, specifying ["seconds", "nanos"] makes the comparison mechanism prioritize + // the `seconds` field and refer to `nanos` only when `seconds` are equal. + // + // Note. When comparing message fields, a non-set message is always less than a set message. + // But if a message is set to a default value, the comparison falls back to + // the field-wise comparison, i.e. number values are treated as zeros, `bool` β€” as `false`, + // and so on. + // + repeated string field = 1; + + // If true, the default order is reversed. For example, numbers are ordered from the greater to + // the lower, enums β€” from the last number value to the 0th value, etc. + bool descending = 2; +} diff --git a/packages/spine-validation-ts/proto/spine/validate/error_message.proto b/packages/spine-validation-ts/proto/spine/validate/error_message.proto new file mode 100644 index 0000000..dd5b5d5 --- /dev/null +++ b/packages/spine-validation-ts/proto/spine/validate/error_message.proto @@ -0,0 +1,70 @@ +/* + * Copyright 2023, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +syntax = "proto3"; + +package spine.validate; + +import "spine/options.proto"; + +option (type_url_prefix) = "type.spine.io"; +option java_multiple_files = true; +option java_outer_classname = "ErrorMessageProto"; +option java_package = "io.spine.validate"; + +// Represents a template string with placeholders and a map for further substituting +// those placeholders with the actual values. +// +// Placeholders are specified using the format `${key}`, where `key` is the identifier +// for the value to be substituted from the `placeholder_value` map. There are no +// expectations about the identifier format. It can be `myKey`, `my.key`, `my_key`, etc. +// +// Example usage: +// template_string = "My dog's name is ${dog.name}." +// placeholder_values = { "dog.name": "Fido" } +// +// After substitution, the final output would be: +// "My dog's name is Fido." +// +// Each placeholder `key` referenced in the template string must have a corresponding entry +// in the `placeholder_value` map. However, the `placeholder_value` map is not restricted to +// containing only the placeholders used in the template. Additional entries in the map +// that do not correspond to placeholders in the template string are permitted. +// +message TemplateString { + + // The template string that may contain one or more placeholders. + string with_placeholders = 1; + + // A map that provides values for placeholders referenced in `with_placeholders`. + // + // The keys in this map should match the placeholder keys inside `with_placeholders` + // excluding the `${}` placeholder markers. + // + // All placeholders present in `with_placeholders` must have corresponding entries + // in this map. Otherwise, the template is considered invalid. + // + map placeholder_value = 2; +} diff --git a/packages/spine-validation-ts/proto/spine/validate/validation_error.proto b/packages/spine-validation-ts/proto/spine/validate/validation_error.proto new file mode 100644 index 0000000..0cf5cc5 --- /dev/null +++ b/packages/spine-validation-ts/proto/spine/validate/validation_error.proto @@ -0,0 +1,133 @@ +/* + * Copyright 2023, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +syntax = "proto3"; + +package spine.validate; + +import "spine/options.proto"; + +option (type_url_prefix) = "type.spine.io"; +option java_multiple_files = true; +option java_outer_classname = "ValidationErrorProto"; +option java_package = "io.spine.validate"; + +import "google/protobuf/any.proto"; + +import "spine/validate/error_message.proto"; +import "spine/base/field_path.proto"; + +// An error indicating that a message did not pass validation. +message ValidationError { + + // Validation constraint violations found by validator. + repeated ConstraintViolation constraint_violation = 1; +} + +// Describes the violation of a validation constraint found in a message. +message ConstraintViolation { + + // Deprecated: use `message` field to access the error message. + string msg_format = 1 [deprecated = true]; + + // Deprecated: use `message` field to access the error message. + repeated string param = 2 [deprecated = true]; + + // An error message for this violation. + // + // The returned message can be formatted using one of the following approaches: + // + // 1. In Kotlin, use the `TemplateString.format()` extension. + // 2. In Java, use the `io.spine.validate.TemplateStrings.format()` static method. + // + spine.validate.TemplateString message = 8; + + // The name of the validated message type. + // + // If the `(validate)` option is applied and a nested field contains an invalid value, + // this property holds the name of the root message, which triggered the validation. + // + // Example: + // + // ``` + // message Student { + // Contacts contacts = 1 [(validate) = true]; + // } + // + // message Contacts { + // Email email = 1 [(validate) = true]; + // } + // + // message Email { + // string value = 1 [(pattern).regex = "^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$"]; + // } + // ``` + // + // When the `Student` message is validated and the `value` field in the nested `Email` + // message is invalid, this property will contain the `Student` type name, not `Email`. + // This is so because this type is an entry point to the nested validation. + // + string type_name = 7; + + // A path to the field containing an invalid value. + // + // If the `(validate)` option is applied and a nested field contains an invalid value, + // this property holds the path to the invalid field. If there is no nesting, the path + // consists only of the field name. + // + // Example: + // + // ``` + // message Student { + // Contacts contacts = 1 [(validate) = true]; + // } + // + // message Contacts { + // Email email = 1 [(validate) = true]; + // } + // + // message Email { + // string value = 1 [(pattern).regex = "^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$"]; + // } + // ``` + // + // When the `Student` message is validated and the `value` field in the nested `Email` message + // is invalid, this property will contain the full path to the invalid field: `contacts.email.value`. + // However, if the `Email` message is validated independently (not as part of `Student`), + // the path will consist only of the field name: `value`. + // + base.FieldPath field_path = 3; + + // The value of the field which violates the validation constraint. + // + // Contains a corresponding wrapper message for primitives + // (google.protobuf.StringValue for "string" field type and so on). + // + google.protobuf.Any field_value = 4; + + // Deprecated: all violations are top-level now. Their nesting is reflected in the value + // of `field_path` field. + repeated ConstraintViolation violation = 5 [deprecated = true]; +} diff --git a/packages/spine-validation-ts/src/index.ts b/packages/spine-validation-ts/src/index.ts new file mode 100644 index 0000000..434e31f --- /dev/null +++ b/packages/spine-validation-ts/src/index.ts @@ -0,0 +1,26 @@ +/** + * Spine Validation for TypeScript. + * + * A validation library for Protobuf messages with Spine validation options. + * + * @packageDocumentation + */ + +export { + validate, + formatTemplateString, + formatViolations +} from './validation'; + +export type { + ConstraintViolation, + ValidationError +} from './generated/spine/validate/validation_error_pb'; + +export type { + TemplateString +} from './generated/spine/validate/error_message_pb'; + +export type { + FieldPath +} from './generated/spine/base/field_path_pb'; diff --git a/packages/spine-validation-ts/src/options-registry.ts b/packages/spine-validation-ts/src/options-registry.ts new file mode 100644 index 0000000..2deeff3 --- /dev/null +++ b/packages/spine-validation-ts/src/options-registry.ts @@ -0,0 +1,54 @@ +/** + * Internal registry for Spine validation option extensions. + * + * Options are automatically imported from the generated Spine options. + */ + +import { + required, + if_missing, + pattern, + required_field, + min, + max, + range, + distinct, + validate, + if_invalid, + goes +} from './generated/spine/options_pb'; + +/** + * Registry storing option extension references. + * + * Currently supported options are automatically registered. + */ +const optionRegistry = { + required, + if_missing, + pattern, + required_field, + min, + max, + range, + distinct, + validate, + if_invalid, + goes, +} as const; + +/** + * Type representing the names of all registered options. + */ +type OptionName = keyof typeof optionRegistry; + +/** + * Gets a registered option extension by name. + * + * @param name The name of the option to retrieve. + * @returns The option extension, or `undefined` if not found. + * @internal + */ +export function getRegisteredOption(name: OptionName): any | undefined { + return optionRegistry[name]; +} diff --git a/packages/spine-validation-ts/src/options/distinct.ts b/packages/spine-validation-ts/src/options/distinct.ts new file mode 100644 index 0000000..5fb0f53 --- /dev/null +++ b/packages/spine-validation-ts/src/options/distinct.ts @@ -0,0 +1,170 @@ +/** + * Validation logic for the `(distinct)` option. + * + * The `(distinct)` option is a field-level constraint that enforces uniqueness + * of elements in repeated fields. + * + * Supported field types: + * - All repeated scalar types (`int32`, `int64`, `uint32`, `uint64`, `sint32`, `sint64`, + * `fixed32`, `fixed64`, `sfixed32`, `sfixed64`, `float`, `double`, `bool`, `string`, `bytes`) + * - Repeated enum fields + * + * Features: + * - Ensures all elements in a repeated field are unique. + * - Detects duplicate values and reports violations with element indices. + * - Works with primitive types (numbers, strings, booleans). + * - Works with enum values. + * + * Examples: + * ```protobuf + * repeated string tags = 1 [(distinct) = true]; + * repeated int32 product_ids = 2 [(distinct) = true]; + * repeated Status statuses = 3 [(distinct) = true]; + * ``` + */ + +import type { Message } from '@bufbuild/protobuf'; +import { getOption, hasOption, create } from '@bufbuild/protobuf'; +import type { GenMessage } from '@bufbuild/protobuf/codegenv2'; +import type { ConstraintViolation } from '../generated/spine/validate/validation_error_pb'; +import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; +import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; +import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; +import { getRegisteredOption } from '../options-registry'; + +/** + * Creates a constraint violation for `(distinct)` validation failures. + * + * @param typeName The fully qualified message type name. + * @param fieldName Array representing the field path (including index). + * @param duplicateValue The duplicate value found. + * @param firstIndex Index of the first occurrence of the value. + * @param duplicateIndex Index of the duplicate occurrence. + * @returns A `ConstraintViolation` object. + */ +function createViolation( + typeName: string, + fieldName: string[], + duplicateValue: any, + firstIndex: number, + duplicateIndex: number +): ConstraintViolation { + return create(ConstraintViolationSchema, { + typeName, + fieldPath: create(FieldPathSchema, { + fieldName + }), + fieldValue: undefined, + message: create(TemplateStringSchema, { + withPlaceholders: `Duplicate value found in repeated field. Value {value} at index {duplicate_index} is a duplicate of the value at index {first_index}.`, + placeholderValue: { + 'value': String(duplicateValue), + 'first_index': String(firstIndex), + 'duplicate_index': String(duplicateIndex) + } + }), + msgFormat: '', + param: [], + violation: [] + }); +} + +/** + * Checks if two values are considered equal for distinctness purposes. + * + * Uses strict equality for primitives (number, string, boolean, bigint). + * + * @param val1 The first value to compare. + * @param val2 The second value to compare. + * @returns `true` if the values are equal, `false` otherwise. + */ +function valuesAreEqual(val1: any, val2: any): boolean { + return val1 === val2; +} + +/** + * Validates `(distinct)` constraint for a single field. + * + * @param schema The message schema containing field descriptors. + * @param message The message instance being validated. + * @param field The field descriptor to validate. + * @param violations Array to collect constraint violations. + */ +function validateFieldDistinct( + schema: GenMessage, + message: any, + field: any, + violations: ConstraintViolation[] +): void { + const distinctOpt = getRegisteredOption('distinct'); + + if (!distinctOpt) { + return; + } + + if (field.fieldKind !== 'list') { + return; + } + + if (!hasOption(field, distinctOpt)) { + return; + } + + const distinctValue = getOption(field, distinctOpt); + if (distinctValue !== true) { + return; + } + + const fieldValue = (message as any)[field.localName]; + + if (!Array.isArray(fieldValue) || fieldValue.length <= 1) { + return; + } + + const seenValues = new Map(); + + fieldValue.forEach((element: any, index: number) => { + let isDuplicate = false; + let firstIndex = -1; + + for (const [seenValue, seenIndex] of seenValues.entries()) { + if (valuesAreEqual(element, seenValue)) { + isDuplicate = true; + firstIndex = seenIndex; + break; + } + } + + if (isDuplicate) { + violations.push(createViolation( + schema.typeName, + [field.name, String(index)], + element, + firstIndex, + index + )); + } else { + seenValues.set(element, index); + } + }); +} + +/** + * Validates the `(distinct)` option for all fields in a message. + * + * This is a field-level constraint that enforces uniqueness of elements + * in repeated fields. Only applies to repeated fields (lists). + * + * @param schema The message schema containing field descriptors. + * @param message The message instance to validate. + * @param violations Array to collect constraint violations. + */ +export function validateDistinctFields( + schema: GenMessage, + message: any, + violations: ConstraintViolation[] +): void { + for (const field of schema.fields) { + validateFieldDistinct(schema, message, field, violations); + } +} diff --git a/packages/spine-validation-ts/src/options/goes.ts b/packages/spine-validation-ts/src/options/goes.ts new file mode 100644 index 0000000..2005f64 --- /dev/null +++ b/packages/spine-validation-ts/src/options/goes.ts @@ -0,0 +1,190 @@ +/** + * Validation logic for the `(goes)` option. + * + * The `(goes)` option is a field-level constraint that enforces field dependency: + * a field can only be set if another specified field is also set. + * + * Semantics: + * - If field A has `(goes).with = "B"`: + * - A is set AND B is NOT set β†’ VIOLATION + * - A is set AND B is set β†’ VALID + * - A is NOT set β†’ VALID (regardless of B) + * + * Examples: + * ```protobuf + * string time = 3 [(goes).with = "date"]; + * // time can only be set when date is also set + * + * string text_color = 1 [(goes).with = "highlight_color"]; + * string highlight_color = 2 [(goes).with = "text_color"]; + * // Mutual dependency: both must be set or both unset + * ``` + */ + +import type { Message } from '@bufbuild/protobuf'; +import { getOption, hasOption, create } from '@bufbuild/protobuf'; +import type { GenMessage } from '@bufbuild/protobuf/codegenv2'; +import type { ConstraintViolation } from '../generated/spine/validate/validation_error_pb'; +import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; +import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; +import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; +import type { GoesOption } from '../generated/spine/options_pb'; +import { getRegisteredOption } from '../options-registry'; + +/** + * Checks if a field has a non-default value (is "set") in proto3. + * + * For proto3 fields: + * - Message fields: non-default instance (not `undefined`/`null`) + * - String fields: non-empty string + * - Numeric fields: non-zero value + * - Bool fields: any value (`true` or `false` both count as "set") + * - Enum fields: non-zero value + * + * @param value The field value to check. + * @returns `true` if the field is considered set, `false` otherwise. + */ +function isFieldSet(value: any): boolean { + if (value === undefined || value === null) { + return false; + } + + if (typeof value === 'string') { + return value !== ''; + } + + if (typeof value === 'number') { + return value !== 0; + } + + if (typeof value === 'boolean') { + return true; + } + + if (typeof value === 'object') { + return true; + } + + return false; +} + +/** + * Creates a constraint violation for `(goes)` validation failures. + * + * @param typeName The fully qualified message type name. + * @param fieldName The name of the field that violated the constraint. + * @param requiredFieldName The name of the field that must be set. + * @param fieldValue The actual value of the violating field. + * @param customErrorMsg Optional custom error message from `(goes).error_msg`. + * @returns A `ConstraintViolation` object. + */ +function createViolation( + typeName: string, + fieldName: string, + requiredFieldName: string, + fieldValue: any, + customErrorMsg?: string +): ConstraintViolation { + const errorMessage = customErrorMsg || + `The field \`${fieldName}\` can only be set when the field \`${requiredFieldName}\` is defined.`; + + return create(ConstraintViolationSchema, { + typeName, + fieldPath: create(FieldPathSchema, { + fieldName: [fieldName] + }), + fieldValue: undefined, + message: create(TemplateStringSchema, { + withPlaceholders: errorMessage, + placeholderValue: { + 'value': fieldValue !== undefined ? String(fieldValue) : '' + } + }), + msgFormat: '', + param: [], + violation: [] + }); +} + +/** + * Validates `(goes)` constraint for a single field. + * + * @param schema The message schema containing field descriptors. + * @param message The message instance being validated. + * @param field The field descriptor to validate. + * @param violations Array to collect constraint violations. + */ +function validateFieldGoes( + schema: GenMessage, + message: any, + field: any, + violations: ConstraintViolation[] +): void { + const goesOpt = getRegisteredOption('goes'); + + if (!goesOpt) { + return; + } + + if (!hasOption(field, goesOpt)) { + return; + } + + const goesOption = getOption(field, goesOpt) as GoesOption; + const requiredFieldName = goesOption.with; + + if (!requiredFieldName) { + return; + } + + const fieldValue = (message as any)[field.localName]; + + if (!isFieldSet(fieldValue)) { + return; + } + + const requiredField = schema.fields.find(f => f.name === requiredFieldName); + + if (!requiredField) { + violations.push(createViolation( + schema.typeName, + field.name, + requiredFieldName, + fieldValue, + `Field \`${field.name}\` references non-existent field \`${requiredFieldName}\` in (goes).with option.` + )); + return; + } + + const requiredFieldValue = (message as any)[requiredField.localName]; + + if (!isFieldSet(requiredFieldValue)) { + violations.push(createViolation( + schema.typeName, + field.name, + requiredFieldName, + fieldValue, + goesOption.errorMsg + )); + } +} + +/** + * Validates the `(goes)` option for all fields in a message. + * + * The `(goes)` option enforces field dependency validation: a field can only + * be set if another specified field is also set. + * + * @param schema The message schema containing field descriptors. + * @param message The message instance to validate. + * @param violations Array to collect constraint violations. + */ +export function validateGoesFields( + schema: GenMessage, + message: any, + violations: ConstraintViolation[] +): void { + for (const field of schema.fields) { + validateFieldGoes(schema, message, field, violations); + } +} diff --git a/packages/spine-validation-ts/src/options/min-max.ts b/packages/spine-validation-ts/src/options/min-max.ts new file mode 100644 index 0000000..48346f6 --- /dev/null +++ b/packages/spine-validation-ts/src/options/min-max.ts @@ -0,0 +1,331 @@ +/** + * Validation logic for the `(min)` and `(max)` options. + * + * The `(min)` and `(max)` options are field-level constraints that enforce + * numeric range validation on scalar numeric fields. + * + * Supported field types: + * - `int32`, `int64`, `uint32`, `uint64`, `sint32`, `sint64` + * - `fixed32`, `fixed64`, `sfixed32`, `sfixed64` + * - `float`, `double` + * + * Features: + * - Inclusive bounds by default (value >= min, value <= max) + * - Exclusive bounds via the `exclusive` flag (value > min, value < max) + * - Custom error messages with token replacement (`{value}`, `{other}`) + * - Validation applies to repeated fields (each element checked independently) + * + * Examples: + * ```protobuf + * int32 age = 1 [(min).value = "0"]; // age >= 0 + * double price = 2 [(min) = {value: "0.0", exclusive: true}]; // price > 0.0 + * int32 percentage = 3 [(max).value = "100"]; // percentage <= 100 + * ``` + */ + +import type { Message } from '@bufbuild/protobuf'; +import { getOption, hasOption, create, ScalarType } from '@bufbuild/protobuf'; +import type { GenMessage } from '@bufbuild/protobuf/codegenv2'; +import type { ConstraintViolation } from '../generated/spine/validate/validation_error_pb'; +import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; +import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; +import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; +import type { MinOption, MaxOption } from '../generated/spine/options_pb'; +import { getRegisteredOption } from '../options-registry'; + +/** + * Creates a constraint violation for `(min)` or `(max)` validation failures. + * + * @param typeName The fully qualified message type name. + * @param fieldName Array representing the field path. + * @param fieldValue The actual value of the field. + * @param errorMessage The error message describing the violation. + * @param thresholdValue The threshold value that was violated. + * @returns A `ConstraintViolation` object. + */ +function createViolation( + typeName: string, + fieldName: string[], + fieldValue: any, + errorMessage: string, + thresholdValue: string +): ConstraintViolation { + return create(ConstraintViolationSchema, { + typeName, + fieldPath: create(FieldPathSchema, { + fieldName + }), + fieldValue: undefined, + message: create(TemplateStringSchema, { + withPlaceholders: errorMessage, + placeholderValue: { + 'value': String(fieldValue), + 'other': thresholdValue + } + }), + msgFormat: '', + param: [], + violation: [] + }); +} + +/** + * Checks if a scalar type is numeric. + * + * @param scalarType The scalar type to check. + * @returns `true` if the type is numeric, `false` otherwise. + */ +function isNumericType(scalarType: ScalarType): boolean { + return scalarType !== ScalarType.STRING && + scalarType !== ScalarType.BYTES && + scalarType !== ScalarType.BOOL; +} + +/** + * Parses a threshold value string based on the field's scalar type. + * + * @param valueStr The threshold value as a string. + * @param scalarType The scalar type of the field. + * @returns The parsed numeric threshold value. + */ +function parseThreshold(valueStr: string, scalarType: ScalarType): number { + if (scalarType === ScalarType.FLOAT || scalarType === ScalarType.DOUBLE) { + return parseFloat(valueStr); + } else { + return parseInt(valueStr, 10); + } +} + +/** + * Validates a single numeric value against `(min)` constraint. + * + * @param value The numeric value to validate. + * @param minOption The `(min)` option configuration. + * @param scalarType The scalar type of the field. + * @returns `true` if the value meets the constraint, `false` otherwise. + */ +function validateMinValue( + value: number, + minOption: MinOption, + scalarType: ScalarType +): boolean { + const threshold = parseThreshold(minOption.value, scalarType); + + if (isNaN(threshold)) { + console.warn(`Invalid min threshold value: "${minOption.value}"`); + return true; + } + + if (minOption.exclusive) { + return value > threshold; + } else { + return value >= threshold; + } +} + +/** + * Validates a single numeric value against `(max)` constraint. + * + * @param value The numeric value to validate. + * @param maxOption The `(max)` option configuration. + * @param scalarType The scalar type of the field. + * @returns `true` if the value meets the constraint, `false` otherwise. + */ +function validateMaxValue( + value: number, + maxOption: MaxOption, + scalarType: ScalarType +): boolean { + const threshold = parseThreshold(maxOption.value, scalarType); + + if (isNaN(threshold)) { + console.warn(`Invalid max threshold value: "${maxOption.value}"`); + return true; + } + + if (maxOption.exclusive) { + return value < threshold; + } else { + return value <= threshold; + } +} + +/** + * Gets the error message for `(min)` constraint violations. + * + * @param minOption The `(min)` option configuration. + * @returns The error message (custom or default). + */ +function getMinErrorMessage(minOption: MinOption): string { + if (minOption.errorMsg) { + return minOption.errorMsg; + } + + const comparator = minOption.exclusive ? 'greater than' : 'at least'; + return `The number must be ${comparator} {other}.`; +} + +/** + * Gets the error message for `(max)` constraint violations. + * + * @param maxOption The `(max)` option configuration. + * @returns The error message (custom or default). + */ +function getMaxErrorMessage(maxOption: MaxOption): string { + if (maxOption.errorMsg) { + return maxOption.errorMsg; + } + + const comparator = maxOption.exclusive ? 'less than' : 'at most'; + return `The number must be ${comparator} {other}.`; +} + +/** + * Validates `(min)` and `(max)` constraints for a single field. + * + * @param schema The message schema containing field descriptors. + * @param message The message instance being validated. + * @param field The field descriptor to validate. + * @param violations Array to collect constraint violations. + */ +function validateFieldMinMax( + schema: GenMessage, + message: any, + field: any, + violations: ConstraintViolation[] +): void { + const minOpt = getRegisteredOption('min'); + const maxOpt = getRegisteredOption('max'); + + if (!minOpt && !maxOpt) { + return; + } + + const fieldValue = (message as any)[field.localName]; + + if (field.fieldKind === 'list') { + if (!field.listKind || field.listKind !== 'scalar' || !field.scalar) { + return; + } + + const scalarType = field.scalar; + if (!isNumericType(scalarType)) { + return; + } + + if (!Array.isArray(fieldValue) || fieldValue.length === 0) { + return; + } + + fieldValue.forEach((element: number, index: number) => { + validateSingleValue( + schema, + field, + element, + [field.name, String(index)], + scalarType, + violations + ); + }); + } else if (field.fieldKind === 'scalar') { + if (!field.scalar) { + return; + } + + const scalarType = field.scalar; + if (!isNumericType(scalarType)) { + return; + } + + if (fieldValue === undefined || fieldValue === null) { + return; + } + + validateSingleValue( + schema, + field, + fieldValue, + [field.name], + scalarType, + violations + ); + } +} + +/** + * Validates a single numeric value against `(min)` and `(max)` constraints. + * + * @param schema The message schema containing field descriptors. + * @param field The field descriptor being validated. + * @param value The numeric value to validate. + * @param fieldPath Array representing the field path. + * @param scalarType The scalar type of the field. + * @param violations Array to collect constraint violations. + */ +function validateSingleValue( + schema: GenMessage, + field: any, + value: number, + fieldPath: string[], + scalarType: ScalarType, + violations: ConstraintViolation[] +): void { + const minOpt = getRegisteredOption('min'); + const maxOpt = getRegisteredOption('max'); + + if (minOpt && hasOption(field, minOpt)) { + const minOption = getOption(field, minOpt) as MinOption; + + if (minOption && minOption.value) { + const isValid = validateMinValue(value, minOption, scalarType); + + if (!isValid) { + violations.push(createViolation( + schema.typeName, + fieldPath, + value, + getMinErrorMessage(minOption), + minOption.value + )); + } + } + } + + if (maxOpt && hasOption(field, maxOpt)) { + const maxOption = getOption(field, maxOpt) as MaxOption; + + if (maxOption && maxOption.value) { + const isValid = validateMaxValue(value, maxOption, scalarType); + + if (!isValid) { + violations.push(createViolation( + schema.typeName, + fieldPath, + value, + getMaxErrorMessage(maxOption), + maxOption.value + )); + } + } + } +} + +/** + * Validates the `(min)` and `(max)` options for all fields in a message. + * + * These are field-level constraints that enforce numeric range validation. + * Only applies to numeric scalar types (integers, floats, doubles). + * + * @param schema The message schema containing field descriptors. + * @param message The message instance to validate. + * @param violations Array to collect constraint violations. + */ +export function validateMinMaxFields( + schema: GenMessage, + message: any, + violations: ConstraintViolation[] +): void { + for (const field of schema.fields) { + validateFieldMinMax(schema, message, field, violations); + } +} diff --git a/packages/spine-validation-ts/src/options/pattern.ts b/packages/spine-validation-ts/src/options/pattern.ts new file mode 100644 index 0000000..8b6dd5f --- /dev/null +++ b/packages/spine-validation-ts/src/options/pattern.ts @@ -0,0 +1,160 @@ +/** + * Validation logic for the `(pattern)` option. + * + * The `(pattern)` option validates that a string field matches a given regular expression. + */ + +import type { Message } from '@bufbuild/protobuf'; +import { hasOption, getOption, create, ScalarType } from '@bufbuild/protobuf'; +import type { GenMessage } from '@bufbuild/protobuf/codegenv2'; +import type { ConstraintViolation } from '../generated/spine/validate/validation_error_pb'; +import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; +import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; +import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; +import { getRegisteredOption } from '../options-registry'; + +/** + * Creates a constraint violation object for `(pattern)` validation failures. + * + * @param typeName The fully qualified message type name. + * @param fieldName The name of the field that violated the constraint. + * @param fieldValue The actual value of the field. + * @param violationMessage The error message describing the violation. + * @returns A `ConstraintViolation` object. + */ +function createViolation( + typeName: string, + fieldName: string, + fieldValue: any, + violationMessage: string +): ConstraintViolation { + return create(ConstraintViolationSchema, { + typeName, + fieldPath: create(FieldPathSchema, { + fieldName: [fieldName] + }), + fieldValue: undefined, + message: create(TemplateStringSchema, { + withPlaceholders: violationMessage, + placeholderValue: { + 'field': fieldName, + 'value': String(fieldValue ?? '') + } + }), + msgFormat: '', + param: [], + violation: [] + }); +} + +/** + * Validates a single string value against a regex pattern with modifiers. + * + * @param value The string value to validate. + * @param regex The regular expression pattern. + * @param patternOption The pattern option object with optional modifiers. + * @returns `true` if the value matches the pattern, `false` otherwise. + */ +function validatePatternValue(value: string, regex: string, patternOption: any): boolean { + if (typeof value !== 'string') { + return false; + } + + try { + let flags = ''; + const modifier = patternOption.modifier; + + if (modifier) { + if (modifier.caseInsensitive) { + flags += 'i'; + } + if (modifier.multiline) { + flags += 'm'; + } + if (modifier.dotAll) { + flags += 's'; + } + if (modifier.unicode) { + flags += 'u'; + } + } + + const pattern = new RegExp(regex, flags); + const partialMatch = modifier?.partialMatch || false; + + if (partialMatch) { + return pattern.test(value); + } else { + return pattern.test(value); + } + } catch (error) { + console.error(`Invalid regex pattern: ${regex}`, error); + return false; + } +} + +/** + * Validates the `(pattern)` option for string fields. + * + * This function checks if string field values match the specified regular expression pattern. + * Supports pattern modifiers like `case_insensitive`, `multiline`, `dot_all`, etc. + * + * @param schema The message schema containing field descriptors. + * @param message The message instance to validate. + * @param violations Array to collect constraint violations. + */ +export function validatePatternFields( + schema: GenMessage, + message: any, + violations: ConstraintViolation[] +): void { + const patternOption = getRegisteredOption('pattern'); + + if (!patternOption) { + return; + } + + for (const field of schema.fields) { + if (!hasOption(field, patternOption)) { + continue; + } + + const patternValue = getOption(field, patternOption); + if (!patternValue || typeof patternValue !== 'object' || !('regex' in patternValue)) { + continue; + } + + const regex = (patternValue as any).regex; + const errorMsg = (patternValue as any).errorMsg || + `The string must match the regular expression \`${regex}\`.`; + + const fieldValue = (message as any)[field.localName]; + + if (field.fieldKind === 'list') { + if (Array.isArray(fieldValue)) { + for (let i = 0; i < fieldValue.length; i++) { + const itemValue = fieldValue[i]; + if (typeof itemValue === 'string' && !validatePatternValue(itemValue, regex, patternValue)) { + violations.push(createViolation( + schema.typeName, + `${field.name}[${i}]`, + itemValue, + errorMsg + )); + } + } + } + } else if (field.fieldKind === 'scalar' && field.scalar === ScalarType.STRING) { + if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') { + if (!validatePatternValue(fieldValue, regex, patternValue)) { + violations.push(createViolation( + schema.typeName, + field.name, + fieldValue, + errorMsg + )); + } + } + } + } +} diff --git a/packages/spine-validation-ts/src/options/range.ts b/packages/spine-validation-ts/src/options/range.ts new file mode 100644 index 0000000..59492fe --- /dev/null +++ b/packages/spine-validation-ts/src/options/range.ts @@ -0,0 +1,307 @@ +/** + * Validation logic for the `(range)` option. + * + * The `(range)` option is a field-level constraint that enforces bounded numeric ranges + * using bracket notation for inclusive/exclusive bounds. + * + * Supported field types: + * - `int32`, `int64`, `uint32`, `uint64`, `sint32`, `sint64` + * - `fixed32`, `fixed64`, `sfixed32`, `sfixed64` + * - `float`, `double` + * + * Features: + * - Inclusive bounds (closed intervals): `[min..max]` + * - Exclusive bounds (open intervals): `(min..max)` + * - Half-open intervals: `[min..max)` or `(min..max]` + * - Validation applies to repeated fields (each element checked independently) + * + * Syntax: + * - `"[0..100]"` β†’ 0 <= value <= 100 + * - `"(0..100)"` β†’ 0 < value < 100 + * - `"[0..100)"` β†’ 0 <= value < 100 + * - `"(0..100]"` β†’ 0 < value <= 100 + * + * Examples: + * ```protobuf + * int32 rgb_value = 1 [(range) = "[0..255]"]; // RGB color value + * int32 hour = 2 [(range) = "[0..24)"]; // Hour (0-23) + * double percentage = 3 [(range) = "(0.0..1.0)"]; // Exclusive percentage + * ``` + */ + +import type { Message } from '@bufbuild/protobuf'; +import { getOption, hasOption, create, ScalarType } from '@bufbuild/protobuf'; +import type { GenMessage } from '@bufbuild/protobuf/codegenv2'; +import type { ConstraintViolation } from '../generated/spine/validate/validation_error_pb'; +import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; +import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; +import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; +import { getRegisteredOption } from '../options-registry'; + +/** + * Represents a parsed range with bounds and inclusivity flags. + */ +interface ParsedRange { + min: number; + max: number; + minInclusive: boolean; + maxInclusive: boolean; +} + +/** + * Creates a constraint violation for `(range)` validation failures. + * + * @param typeName The fully qualified message type name. + * @param fieldName Array representing the field path. + * @param fieldValue The actual value of the field. + * @param rangeStr The range string that was violated. + * @returns A `ConstraintViolation` object. + */ +function createViolation( + typeName: string, + fieldName: string[], + fieldValue: any, + rangeStr: string +): ConstraintViolation { + return create(ConstraintViolationSchema, { + typeName, + fieldPath: create(FieldPathSchema, { + fieldName + }), + fieldValue: undefined, + message: create(TemplateStringSchema, { + withPlaceholders: `The number must be in range ${rangeStr}.`, + placeholderValue: { + 'value': String(fieldValue), + 'range': rangeStr + } + }), + msgFormat: '', + param: [], + violation: [] + }); +} + +/** + * Checks if a scalar type is numeric. + * + * @param scalarType The scalar type to check. + * @returns `true` if the type is numeric, `false` otherwise. + */ +function isNumericType(scalarType: ScalarType): boolean { + return scalarType !== ScalarType.STRING && + scalarType !== ScalarType.BYTES && + scalarType !== ScalarType.BOOL; +} + +/** + * Parses a range string like `"[0..100]"` into a ParsedRange object. + * + * Syntax: + * - `[` or `]` = inclusive bound + * - `(` or `)` = exclusive bound + * - `..` = separator between min and max + * + * @param rangeStr The range string from the proto option. + * @param scalarType The field's scalar type for parsing numbers. + * @returns ParsedRange object or `null` if parsing fails. + */ +function parseRange(rangeStr: string, scalarType: ScalarType): ParsedRange | null { + const trimmed = rangeStr.trim(); + + if (trimmed.length < 5) { + console.warn(`Invalid range format (too short): "${rangeStr}"`); + return null; + } + + const firstChar = trimmed[0]; + const lastChar = trimmed[trimmed.length - 1]; + + if (!['[', '('].includes(firstChar) || ![')',']'].includes(lastChar)) { + console.warn(`Invalid range format (missing brackets): "${rangeStr}"`); + return null; + } + + const minInclusive = firstChar === '['; + const maxInclusive = lastChar === ']'; + + const middle = trimmed.substring(1, trimmed.length - 1); + + const parts = middle.split('..'); + if (parts.length !== 2) { + console.warn(`Invalid range format (missing .. separator): "${rangeStr}"`); + return null; + } + + const [minStr, maxStr] = parts; + + let min: number; + let max: number; + + if (scalarType === ScalarType.FLOAT || scalarType === ScalarType.DOUBLE) { + min = parseFloat(minStr); + max = parseFloat(maxStr); + } else { + min = parseInt(minStr, 10); + max = parseInt(maxStr, 10); + } + + if (isNaN(min) || isNaN(max)) { + console.warn(`Invalid range format (NaN values): "${rangeStr}"`); + return null; + } + + if (min > max) { + console.warn(`Invalid range format (min > max): "${rangeStr}"`); + return null; + } + + return { + min, + max, + minInclusive, + maxInclusive + }; +} + +/** + * Validates a single numeric value against a range constraint. + * + * @param value The numeric value to validate. + * @param range The parsed range object with bounds and inclusivity flags. + * @returns `true` if the value is within the range, `false` otherwise. + */ +function validateRangeValue(value: number, range: ParsedRange): boolean { + if (range.minInclusive) { + if (value < range.min) return false; + } else { + if (value <= range.min) return false; + } + + if (range.maxInclusive) { + if (value > range.max) return false; + } else { + if (value >= range.max) return false; + } + + return true; +} + +/** + * Validates `(range)` constraints for a single field. + * + * @param schema The message schema containing field descriptors. + * @param message The message instance being validated. + * @param field The field descriptor to validate. + * @param violations Array to collect constraint violations. + */ +function validateFieldRange( + schema: GenMessage, + message: any, + field: any, + violations: ConstraintViolation[] +): void { + const rangeOpt = getRegisteredOption('range'); + + if (!rangeOpt) { + return; + } + + const fieldValue = (message as any)[field.localName]; + + if (field.fieldKind === 'list') { + if (!field.listKind || field.listKind !== 'scalar' || !field.scalar) { + return; + } + + const scalarType = field.scalar; + if (!isNumericType(scalarType)) { + return; + } + + if (!hasOption(field, rangeOpt)) { + return; + } + + const rangeStr = getOption(field, rangeOpt); + if (!rangeStr || typeof rangeStr !== 'string') { + return; + } + + const range = parseRange(rangeStr, scalarType); + if (!range) { + return; + } + + if (!Array.isArray(fieldValue) || fieldValue.length === 0) { + return; + } + + fieldValue.forEach((element: number, index: number) => { + if (!validateRangeValue(element, range)) { + violations.push(createViolation( + schema.typeName, + [field.name, String(index)], + element, + rangeStr + )); + } + }); + } else if (field.fieldKind === 'scalar') { + if (!field.scalar) { + return; + } + + const scalarType = field.scalar; + if (!isNumericType(scalarType)) { + return; + } + + if (!hasOption(field, rangeOpt)) { + return; + } + + const rangeStr = getOption(field, rangeOpt); + if (!rangeStr || typeof rangeStr !== 'string') { + return; + } + + const range = parseRange(rangeStr, scalarType); + if (!range) { + return; + } + + if (fieldValue === undefined || fieldValue === null) { + return; + } + + if (!validateRangeValue(fieldValue, range)) { + violations.push(createViolation( + schema.typeName, + [field.name], + fieldValue, + rangeStr + )); + } + } +} + +/** + * Validates the `(range)` option for all fields in a message. + * + * This is a field-level constraint that enforces bounded numeric ranges. + * Only applies to numeric scalar types (integers, floats, doubles). + * + * @param schema The message schema containing field descriptors. + * @param message The message instance to validate. + * @param violations Array to collect constraint violations. + */ +export function validateRangeFields( + schema: GenMessage, + message: any, + violations: ConstraintViolation[] +): void { + for (const field of schema.fields) { + validateFieldRange(schema, message, field, violations); + } +} diff --git a/packages/spine-validation-ts/src/options/required-field.ts b/packages/spine-validation-ts/src/options/required-field.ts new file mode 100644 index 0000000..3261799 --- /dev/null +++ b/packages/spine-validation-ts/src/options/required-field.ts @@ -0,0 +1,287 @@ +/** + * Validation logic for the `(required_field)` option. + * + * The `(required_field)` option is a message-level constraint that requires + * at least one field from a set of alternatives or combinations of fields. + * + * Syntax: + * - `|` (pipe): OR operator - at least one field must be set + * - `&` (ampersand): AND operator - all fields must be set together + * - Parentheses for grouping: `(field1 & field2) | field3` + * + * Examples: + * ```protobuf + * message User { + * option (required_field) = "id | email"; // Either id OR email must be set + * string id = 1; + * string email = 2; + * } + * + * message PhoneNumber { + * option (required_field) = "phone & country_code"; // Both phone AND country_code must be set + * string phone = 1; + * string country_code = 2; + * } + * + * message PersonName { + * option (required_field) = "given_name | (honorific_prefix & family_name)"; + * // Either given_name alone OR both honorific_prefix AND family_name + * string given_name = 1; + * string honorific_prefix = 2; + * string family_name = 3; + * } + * ``` + */ + +import type { Message } from '@bufbuild/protobuf'; +import { hasOption, getOption, create, getExtension, hasExtension, ScalarType } from '@bufbuild/protobuf'; +import type { GenMessage } from '@bufbuild/protobuf/codegenv2'; +import type { ConstraintViolation } from '../generated/spine/validate/validation_error_pb'; +import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; +import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; +import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; +import { getRegisteredOption } from '../options-registry'; + +/** + * Creates a constraint violation for `(required_field)` at the message level. + * + * @param typeName The fully qualified message type name. + * @param expression The required field expression that was not satisfied. + * @param violationMessage The error message describing the violation. + * @returns A `ConstraintViolation` object. + */ +function createViolation( + typeName: string, + expression: string, + violationMessage: string +): ConstraintViolation { + return create(ConstraintViolationSchema, { + typeName, + fieldPath: create(FieldPathSchema, { + fieldName: [] + }), + fieldValue: undefined, + message: create(TemplateStringSchema, { + withPlaceholders: violationMessage, + placeholderValue: { + 'expression': expression + } + }), + msgFormat: '', + param: [], + violation: [] + }); +} + +/** + * Checks if a field is set (has a non-default value). + * + * @param message The message instance to check. + * @param fieldName The name of the field to check. + * @param schema The message schema containing field descriptors. + * @returns `true` if the field is set, `false` otherwise. + */ +function isFieldSet(message: any, fieldName: string, schema: GenMessage): boolean { + const field = schema.fields.find(f => f.name === fieldName); + if (!field) { + console.warn(`Field "${fieldName}" not found in schema ${schema.typeName}`); + return false; + } + + const fieldValue = (message as any)[field.localName]; + + if (field.fieldKind === 'scalar') { + if (field.scalar) { + const scalarType = field.scalar; + if (scalarType === ScalarType.STRING || scalarType === ScalarType.BYTES) { + return fieldValue !== undefined && fieldValue !== null && fieldValue !== ''; + } else if (scalarType === ScalarType.BOOL) { + return fieldValue !== undefined && fieldValue !== null; + } else { + return fieldValue !== undefined && fieldValue !== null && fieldValue !== 0; + } + } + } else if (field.fieldKind === 'message') { + return fieldValue !== undefined && fieldValue !== null; + } else if (field.fieldKind === 'enum') { + return fieldValue !== undefined && fieldValue !== null && fieldValue !== 0; + } else if (field.fieldKind === 'list' || field.fieldKind === 'map') { + return fieldValue !== undefined && fieldValue !== null && + (Array.isArray(fieldValue) ? fieldValue.length > 0 : Object.keys(fieldValue).length > 0); + } + + return false; +} + +/** + * Tokenizes the `(required_field)` expression into tokens. + * + * @param expression The expression string to tokenize. + * @returns Array of tokens (field names, operators, parentheses). + */ +function tokenize(expression: string): string[] { + const tokens: string[] = []; + let current = ''; + + for (let i = 0; i < expression.length; i++) { + const char = expression[i]; + + if (char === '(' || char === ')' || char === '|' || char === '&') { + if (current.trim()) { + tokens.push(current.trim()); + current = ''; + } + tokens.push(char); + } else if (char === ' ' || char === '\t' || char === '\n') { + if (current.trim()) { + tokens.push(current.trim()); + current = ''; + } + } else { + current += char; + } + } + + if (current.trim()) { + tokens.push(current.trim()); + } + + return tokens; +} + +/** + * Parses and evaluates the `(required_field)` expression. + * + * @param expression The expression string to evaluate. + * @param message The message instance to validate. + * @param schema The message schema containing field descriptors. + * @returns `true` if the expression is satisfied, `false` otherwise. + */ +function evaluateExpression( + expression: string, + message: any, + schema: GenMessage +): boolean { + const tokens = tokenize(expression); + + let index = 0; + + function parseOr(): boolean { + let result = parseAnd(); + + while (index < tokens.length && tokens[index] === '|') { + index++; + const right = parseAnd(); + result = result || right; + } + + return result; + } + + function parseAnd(): boolean { + let result = parsePrimary(); + + while (index < tokens.length && tokens[index] === '&') { + index++; + const right = parsePrimary(); + result = result && right; + } + + return result; + } + + function parsePrimary(): boolean { + if (index >= tokens.length) { + return false; + } + + const token = tokens[index]; + + if (token === '(') { + index++; + const result = parseOr(); + if (index < tokens.length && tokens[index] === ')') { + index++; + } + return result; + } else if (token === '|' || token === '&' || token === ')') { + return false; + } else { + index++; + return isFieldSet(message, token, schema); + } + } + + return parseOr(); +} + +/** + * Validates the `(required_field)` option for messages. + * + * This is a message-level constraint that requires specific combinations + * of fields to be set according to the expression. + * + * @param schema The message schema containing field descriptors. + * @param message The message instance to validate. + * @param violations Array to collect constraint violations. + */ +export function validateRequiredFieldOption( + schema: GenMessage, + message: any, + violations: ConstraintViolation[] +): void { + const requiredFieldOption = getRegisteredOption('required_field'); + + if (!requiredFieldOption) { + return; + } + + const options = (schema.proto as any).options; + if (!options) { + return; + } + + const unknownFields = options.$unknown as Array<{ no: number; wireType: number; data: Uint8Array }>; + if (unknownFields) { + const requiredFieldExtension = unknownFields.find((f: any) => f.no === 73902); + if (requiredFieldExtension) { + const dataWithoutLength = requiredFieldExtension.data.slice(1); + const decoder = new TextDecoder(); + const expression = decoder.decode(dataWithoutLength); + + if (expression && typeof expression === 'string') { + const satisfied = evaluateExpression(expression, message, schema); + + if (!satisfied) { + const violationMessage = `At least one of the required field combinations must be satisfied: ${expression}`; + violations.push(createViolation( + schema.typeName, + expression, + violationMessage + )); + } + return; + } + } + } + + if (!hasExtension(options, requiredFieldOption)) { + return; + } + + const expression = getExtension(options, requiredFieldOption); + if (!expression || typeof expression !== 'string') { + return; + } + + const satisfied = evaluateExpression(expression, message, schema); + + if (!satisfied) { + const violationMessage = `At least one of the required field combinations must be satisfied: ${expression}`; + violations.push(createViolation( + schema.typeName, + expression, + violationMessage + )); + } +} diff --git a/packages/spine-validation-ts/src/options/required.ts b/packages/spine-validation-ts/src/options/required.ts new file mode 100644 index 0000000..2e2b911 --- /dev/null +++ b/packages/spine-validation-ts/src/options/required.ts @@ -0,0 +1,137 @@ +/** + * Validation logic for the `(required)` option. + * + * The `(required)` option ensures that a field has a non-default value set. + */ + +import type { Message } from '@bufbuild/protobuf'; +import { hasOption, getOption, create } from '@bufbuild/protobuf'; +import type { GenMessage } from '@bufbuild/protobuf/codegenv2'; +import type { ConstraintViolation } from '../generated/spine/validate/validation_error_pb'; +import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; +import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; +import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; +import { getRegisteredOption } from '../options-registry'; + +/** + * Creates a constraint violation object for `(required)` validation failures. + * + * @param typeName The fully qualified message type name. + * @param fieldName The name of the field that violated the constraint. + * @param fieldValue The actual value of the field. + * @param violationMessage The error message describing the violation. + * @returns A `ConstraintViolation` object. + */ +function createViolation( + typeName: string, + fieldName: string, + fieldValue: any, + violationMessage: string +): ConstraintViolation { + return create(ConstraintViolationSchema, { + typeName, + fieldPath: create(FieldPathSchema, { + fieldName: [fieldName] + }), + fieldValue: undefined, + message: create(TemplateStringSchema, { + withPlaceholders: violationMessage, + placeholderValue: { + 'field': fieldName, + 'value': String(fieldValue ?? '') + } + }), + msgFormat: '', + param: [], + violation: [] + }); +} + +/** + * Validates the `(required)` option for all fields in a message. + * + * This function checks each field with the `(required)` option to ensure it has + * a non-default value. Custom error messages can be provided via the `(if_missing)` option. + * + * @param schema The message schema containing field descriptors. + * @param message The message instance to validate. + * @param violations Array to collect constraint violations. + */ +export function validateRequiredFields( + schema: GenMessage, + message: any, + violations: ConstraintViolation[] +): void { + const requiredOption = getRegisteredOption('required'); + const ifMissingOption = getRegisteredOption('if_missing'); + + for (const field of schema.fields) { + if (!requiredOption || !hasOption(field, requiredOption) || !getOption(field, requiredOption)) { + continue; + } + + let violationMessage = 'A value must be set.'; + + if (ifMissingOption && hasOption(field, ifMissingOption)) { + const ifMissingOpt = getOption(field, ifMissingOption); + if (ifMissingOpt && typeof ifMissingOpt === 'object' && 'errorMsg' in ifMissingOpt) { + violationMessage = (ifMissingOpt as any).errorMsg || violationMessage; + } + } + + const fieldValue = (message as any)[field.localName]; + let isViolated = false; + + if (field.fieldKind === 'scalar') { + if (field.scalar) { + switch (field.scalar.toString()) { + case 'ScalarType.STRING': + isViolated = !fieldValue || fieldValue === ''; + break; + case 'ScalarType.BYTES': + isViolated = !fieldValue || fieldValue.length === 0; + break; + case 'ScalarType.INT32': + case 'ScalarType.INT64': + case 'ScalarType.UINT32': + case 'ScalarType.UINT64': + case 'ScalarType.SINT32': + case 'ScalarType.SINT64': + case 'ScalarType.FIXED32': + case 'ScalarType.FIXED64': + case 'ScalarType.SFIXED32': + case 'ScalarType.SFIXED64': + case 'ScalarType.FLOAT': + case 'ScalarType.DOUBLE': + isViolated = fieldValue === undefined || fieldValue === null; + break; + case 'ScalarType.BOOL': + isViolated = fieldValue === undefined || fieldValue === null; + break; + default: + isViolated = !fieldValue; + } + } + } else if (field.fieldKind === 'message') { + isViolated = !fieldValue; + } else if (field.fieldKind === 'enum') { + isViolated = fieldValue === undefined || fieldValue === null; + } + + if (field.fieldKind === 'list') { + isViolated = !fieldValue || !Array.isArray(fieldValue) || fieldValue.length === 0; + if (isViolated && !violationMessage.includes('at least')) { + violationMessage = 'At least one element must be present.'; + } + } + + if (isViolated) { + violations.push(createViolation( + schema.typeName, + field.name, + fieldValue, + violationMessage + )); + } + } +} diff --git a/packages/spine-validation-ts/src/options/validate.ts b/packages/spine-validation-ts/src/options/validate.ts new file mode 100644 index 0000000..518a14f --- /dev/null +++ b/packages/spine-validation-ts/src/options/validate.ts @@ -0,0 +1,258 @@ +/** + * Validation logic for the `(validate)` and `(if_invalid)` options. + * + * The `(validate)` option is a field-level constraint that enables recursive + * validation of nested message fields, repeated message fields, and map fields. + * + * The `(if_invalid)` option provides custom error messages for validation failures. + * + * Supported field types: + * - Message fields (singular) + * - Repeated message fields + * - Map fields (validates each entry) + * + * Features: + * - Recursive validation: validates constraints in nested messages + * - Custom error messages with token replacement (`{value}`) + * - Validates each item in repeated fields + * - Validates each value in map entries + * + * Examples: + * ```protobuf + * message Address { + * string street = 1 [(required) = true]; + * } + * Address address = 1 [(validate) = true]; + * repeated Product products = 2 [(validate) = true]; + * Customer customer = 3 [(validate) = true, (if_invalid).error_msg = "Invalid customer: {value}."]; + * ``` + */ + +import type { Message } from '@bufbuild/protobuf'; +import { getOption, hasOption, create } from '@bufbuild/protobuf'; +import type { GenMessage } from '@bufbuild/protobuf/codegenv2'; +import type { ConstraintViolation } from '../generated/spine/validate/validation_error_pb'; +import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; +import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; +import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; +import type { IfInvalidOption } from '../generated/spine/options_pb'; +import { getRegisteredOption } from '../options-registry'; + +/** + * Creates a constraint violation for nested validation failure. + * + * @param typeName The fully qualified message type name. + * @param fieldName Array representing the field path. + * @param errorMessage The error message describing the violation. + * @param fieldValue The actual value of the field (optional). + * @returns A `ConstraintViolation` object. + */ +function createViolation( + typeName: string, + fieldName: string[], + errorMessage: string, + fieldValue?: any +): ConstraintViolation { + return create(ConstraintViolationSchema, { + typeName, + fieldPath: create(FieldPathSchema, { + fieldName + }), + fieldValue: undefined, + message: create(TemplateStringSchema, { + withPlaceholders: errorMessage, + placeholderValue: { + 'value': fieldValue ? String(fieldValue) : '' + } + }), + msgFormat: '', + param: [], + violation: [] + }); +} + +/** + * Gets the error message from `(if_invalid)` option or returns default. + * + * @param ifInvalidOption The `(if_invalid)` option object. + * @returns The custom error message or a default message. + */ +function getErrorMessage(ifInvalidOption: IfInvalidOption | undefined): string { + if (ifInvalidOption && ifInvalidOption.errorMsg) { + return ifInvalidOption.errorMsg; + } + return 'Nested message validation failed.'; +} + +/** + * Validates a single message field by recursively calling validate on it. + * + * @param parentTypeName The fully qualified parent message type name. + * @param fieldPath Array representing the field path from parent. + * @param nestedMessage The nested message instance to validate. + * @param nestedSchema The schema of the nested message. + * @param ifInvalidOption The `(if_invalid)` option if present. + * @param violations Array to collect constraint violations. + */ +function validateNestedMessage( + parentTypeName: string, + fieldPath: string[], + nestedMessage: any, + nestedSchema: GenMessage, + ifInvalidOption: IfInvalidOption | undefined, + violations: ConstraintViolation[] +): void { + const { validate } = require('../validation'); + + const nestedViolations = validate(nestedSchema, nestedMessage); + + if (nestedViolations.length > 0) { + const errorMessage = getErrorMessage(ifInvalidOption); + + violations.push(createViolation( + parentTypeName, + fieldPath, + errorMessage, + nestedMessage + )); + + for (const nestedViolation of nestedViolations) { + const adjustedViolation = create(ConstraintViolationSchema, { + typeName: nestedViolation.typeName, + fieldPath: create(FieldPathSchema, { + fieldName: [...fieldPath, ...(nestedViolation.fieldPath?.fieldName || [])] + }), + fieldValue: nestedViolation.fieldValue, + message: nestedViolation.message, + msgFormat: nestedViolation.msgFormat, + param: nestedViolation.param, + violation: nestedViolation.violation + }); + violations.push(adjustedViolation); + } + } +} + +/** + * Validates `(validate)` constraint for a single field. + * + * @param schema The message schema containing field descriptors. + * @param message The message instance being validated. + * @param field The field descriptor to validate. + * @param violations Array to collect constraint violations. + */ +function validateFieldValidate( + schema: GenMessage, + message: any, + field: any, + violations: ConstraintViolation[] +): void { + const validateOpt = getRegisteredOption('validate'); + const ifInvalidOpt = getRegisteredOption('if_invalid'); + + if (!validateOpt) { + return; + } + + if (!hasOption(field, validateOpt)) { + return; + } + + const validateValue = getOption(field, validateOpt); + if (validateValue !== true) { + return; + } + + const ifInvalidOption = ifInvalidOpt && hasOption(field, ifInvalidOpt) + ? getOption(field, ifInvalidOpt) as IfInvalidOption + : undefined; + + const fieldValue = (message as any)[field.localName]; + + if (field.fieldKind === 'message') { + if (!fieldValue) { + return; + } + + const nestedSchema = field.message; + if (!nestedSchema) { + return; + } + + validateNestedMessage( + schema.typeName, + [field.name], + fieldValue, + nestedSchema, + ifInvalidOption, + violations + ); + } else if (field.fieldKind === 'list') { + if (!Array.isArray(fieldValue) || fieldValue.length === 0) { + return; + } + + if (field.listKind !== 'message' || !field.message) { + return; + } + + const nestedSchema = field.message; + + fieldValue.forEach((element: any, index: number) => { + if (element) { + validateNestedMessage( + schema.typeName, + [field.name, String(index)], + element, + nestedSchema, + ifInvalidOption, + violations + ); + } + }); + } else if (field.fieldKind === 'map') { + if (!fieldValue || Object.keys(fieldValue).length === 0) { + return; + } + + if (!field.mapValue || field.mapKind !== 'message' || !field.message) { + return; + } + + const nestedSchema = field.message; + + for (const [key, value] of Object.entries(fieldValue)) { + if (value) { + validateNestedMessage( + schema.typeName, + [field.name, key], + value, + nestedSchema, + ifInvalidOption, + violations + ); + } + } + } +} + +/** + * Validates the `(validate)` and `(if_invalid)` options for all fields in a message. + * + * This enables recursive validation of nested message fields. When `(validate) = true` + * is set on a message field, the validation framework will recursively validate + * all constraints defined in that nested message. + * + * @param schema The message schema containing field descriptors. + * @param message The message instance to validate. + * @param violations Array to collect constraint violations. + */ +export function validateNestedFields( + schema: GenMessage, + message: any, + violations: ConstraintViolation[] +): void { + for (const field of schema.fields) { + validateFieldValidate(schema, message, field, violations); + } +} diff --git a/packages/spine-validation-ts/src/validation.ts b/packages/spine-validation-ts/src/validation.ts new file mode 100644 index 0000000..e6520c2 --- /dev/null +++ b/packages/spine-validation-ts/src/validation.ts @@ -0,0 +1,135 @@ +/** + * Validation module for Protobuf messages with Spine validation options. + * + * This module provides the main validation API and utility functions + * for validating Protobuf messages against Spine validation constraints. + */ + +import type { Message } from '@bufbuild/protobuf'; +import type { GenMessage } from '@bufbuild/protobuf/codegenv2'; + +import type { ConstraintViolation } from './generated/spine/validate/validation_error_pb'; +import type { TemplateString } from './generated/spine/validate/error_message_pb'; + +import { validateRequiredFields } from './options/required'; +import { validatePatternFields } from './options/pattern'; +import { validateRequiredFieldOption } from './options/required-field'; +import { validateMinMaxFields } from './options/min-max'; +import { validateRangeFields } from './options/range'; +import { validateDistinctFields } from './options/distinct'; +import { validateNestedFields } from './options/validate'; +import { validateGoesFields } from './options/goes'; + +export type { ConstraintViolation, ValidationError } from './generated/spine/validate/validation_error_pb'; +export type { TemplateString } from './generated/spine/validate/error_message_pb'; +export type { FieldPath } from './generated/spine/base/field_path_pb'; + +/** + * Validates a message against its Spine validation constraints. + * + * This function applies all registered validation rules to the given message + * and returns an array of constraint violations. An empty array indicates + * the message is valid. + * + * Currently supported validation options: + * - `(required)` - ensures field has a non-default value + * - `(pattern)` - validates string fields against regular expressions + * - `(required_field)` - requires specific combinations of fields at message level + * - `(min)` / `(max)` - numeric range validation with inclusive/exclusive bounds + * - `(range)` - bounded numeric ranges using bracket notation for inclusive/exclusive bounds + * - `(distinct)` - ensures all elements in repeated fields are unique + * - `(validate)` - enables recursive validation of nested message fields + * - `(goes)` - enforces field dependency (field can only be set if another field is set) + * + * @param schema The message schema containing validation metadata. + * @param message The message instance to validate. + * @returns Array of constraint violations (empty if valid). + * + * @example + * ```typescript + * import { validate } from 'spine-validation-ts'; + * import { UserSchema } from './generated/user_pb'; + * import { create } from '@bufbuild/protobuf'; + * + * const user = create(UserSchema, { name: '', email: '' }); + * const violations = validate(UserSchema, user); + * + * if (violations.length > 0) { + * console.log('Validation failed:', formatViolations(violations)); + * } + * ``` + */ +export function validate( + schema: GenMessage, + message: any +): ConstraintViolation[] { + const violations: ConstraintViolation[] = []; + + validateRequiredFields(schema, message, violations); + validatePatternFields(schema, message, violations); + validateRequiredFieldOption(schema, message, violations); + validateMinMaxFields(schema, message, violations); + validateRangeFields(schema, message, violations); + validateDistinctFields(schema, message, violations); + validateNestedFields(schema, message, violations); + validateGoesFields(schema, message, violations); + + return violations; +} + +/** + * Formats a `TemplateString` by replacing all placeholders with their values. + * + * Placeholders in the format `${key}` are replaced with corresponding values + * from the `placeholderValue` map. + * + * @param template The template string with placeholders. + * @returns Formatted string with placeholders replaced. + * + * @example + * ```typescript + * const template = { + * withPlaceholders: 'Field ${field} has invalid value: ${value}', + * placeholderValue: { field: 'email', value: 'invalid@' } + * }; + * const result = formatTemplateString(template); + * // Result: "Field email has invalid value: invalid@" + * ``` + */ +export function formatTemplateString(template: TemplateString): string { + let result = template.withPlaceholders; + for (const [key, value] of Object.entries(template.placeholderValue)) { + result = result.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), value); + } + return result; +} + +/** + * Formats an array of constraint violations into a human-readable string. + * + * Each violation is formatted as: `. .: ` + * + * @param violations Array of constraint violations to format. + * @returns Formatted string describing all violations, or "No violations" if empty. + * + * @example + * ```typescript + * const user = create(UserSchema, { name: '', email: '' }); + * const violations = validate(UserSchema, user); + * console.log(formatViolations(violations)); + * // Output: + * // 1. example.User.name: A value must be set. + * // 2. example.User.email: A value must be set. + * ``` + */ +export function formatViolations(violations: ConstraintViolation[]): string { + if (violations.length === 0) { + return 'No violations'; + } + + return violations.map((v, index) => { + const fieldPath = v.fieldPath?.fieldName.join('.') || 'unknown'; + const message = v.message ? formatTemplateString(v.message) : 'Validation failed'; + return `${index + 1}. ${v.typeName}.${fieldPath}: ${message}`; + }).join('\n'); +} diff --git a/packages/spine-validation-ts/tests/basic-validation.test.ts b/packages/spine-validation-ts/tests/basic-validation.test.ts new file mode 100644 index 0000000..8aa8ff5 --- /dev/null +++ b/packages/spine-validation-ts/tests/basic-validation.test.ts @@ -0,0 +1,24 @@ +/** + * Unit tests for spine-validation-ts package - Basic validation and formatting. + * + * Tests basic validation functionality and violation formatting. + */ + +import { validate, formatViolations } from '../src'; + +describe('Basic Validation', () => { + it('should export `validate` function', () => { + expect(typeof validate).toBe('function'); + }); + + it('should export `formatViolations` function', () => { + expect(typeof formatViolations).toBe('function'); + }); +}); + +describe('Format Violations', () => { + it('should return "No violations" for empty array', () => { + const result = formatViolations([]); + expect(result).toBe('No violations'); + }); +}); diff --git a/packages/spine-validation-ts/tests/buf.gen.yaml b/packages/spine-validation-ts/tests/buf.gen.yaml new file mode 100644 index 0000000..cd57dcf --- /dev/null +++ b/packages/spine-validation-ts/tests/buf.gen.yaml @@ -0,0 +1,6 @@ +version: v2 +plugins: + - local: protoc-gen-es + out: generated + opt: + - target=ts diff --git a/packages/spine-validation-ts/tests/buf.yaml b/packages/spine-validation-ts/tests/buf.yaml new file mode 100644 index 0000000..c7e30e3 --- /dev/null +++ b/packages/spine-validation-ts/tests/buf.yaml @@ -0,0 +1,9 @@ +version: v2 +modules: + - path: proto +lint: + use: + - STANDARD +breaking: + use: + - FILE diff --git a/packages/spine-validation-ts/tests/distinct.test.ts b/packages/spine-validation-ts/tests/distinct.test.ts new file mode 100644 index 0000000..00d1055 --- /dev/null +++ b/packages/spine-validation-ts/tests/distinct.test.ts @@ -0,0 +1,371 @@ +/** + * Unit tests for `(distinct)` validation option. + * + * Tests uniqueness validation for repeated fields. + */ + +import { create } from '@bufbuild/protobuf'; +import { validate } from '../src'; + +import { + DistinctPrimitivesSchema, + DistinctEnumsSchema, + Status as DistinctStatus, + NonDistinctFieldsSchema, + CombinedConstraintsSchema as DistinctCombinedConstraintsSchema, + OptionalDistinctSchema, + UserProfileSchema, + ShoppingCartSchema, + DistinctNumericTypesSchema, + DistinctEdgeCasesSchema +} from './generated/test-distinct_pb'; + +describe('Distinct Validation', () => { + describe('Primitive Types with Distinct', () => { + it('should pass when all elements are unique', () => { + const valid = create(DistinctPrimitivesSchema, { + numbers: [1, 2, 3, 4, 5], + tags: ['alpha', 'beta', 'gamma'], + scores: [85.5, 92.3, 78.9], + flags: [true, false] + }); + + const violations = validate(DistinctPrimitivesSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when numbers have duplicates', () => { + const invalid = create(DistinctPrimitivesSchema, { + numbers: [1, 2, 3, 2, 4], // 2 is duplicated at indices 1 and 3. + tags: ['alpha', 'beta', 'gamma'], + scores: [85.5, 92.3, 78.9], + flags: [true, false] + }); + + const violations = validate(DistinctPrimitivesSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const numberViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'numbers' && v.fieldPath?.fieldName[1] === '3' + ); + expect(numberViolation).toBeDefined(); + expect(numberViolation?.message?.placeholderValue?.['value']).toBe('2'); + expect(numberViolation?.message?.placeholderValue?.['first_index']).toBe('1'); + expect(numberViolation?.message?.placeholderValue?.['duplicate_index']).toBe('3'); + }); + + it('should fail when strings have duplicates', () => { + const invalid = create(DistinctPrimitivesSchema, { + numbers: [1, 2, 3], + tags: ['alpha', 'beta', 'alpha', 'gamma'], // 'alpha' duplicated. + scores: [85.5, 92.3, 78.9], + flags: [true, false] + }); + + const violations = validate(DistinctPrimitivesSchema, invalid); + const tagViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'tags'); + expect(tagViolation).toBeDefined(); + expect(tagViolation?.message?.placeholderValue?.['value']).toBe('alpha'); + }); + + it('should fail when doubles have duplicates', () => { + const invalid = create(DistinctPrimitivesSchema, { + numbers: [1, 2, 3], + tags: ['alpha', 'beta', 'gamma'], + scores: [85.5, 92.3, 85.5, 78.9], // 85.5 duplicated. + flags: [true, false] + }); + + const violations = validate(DistinctPrimitivesSchema, invalid); + const scoreViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'scores'); + expect(scoreViolation).toBeDefined(); + }); + + it('should detect multiple duplicates in same field', () => { + const invalid = create(DistinctPrimitivesSchema, { + numbers: [1, 2, 1, 3, 2, 4], // Both 1 and 2 duplicated. + tags: ['alpha'], + scores: [85.5], + flags: [true] + }); + + const violations = validate(DistinctPrimitivesSchema, invalid); + const numberViolations = violations.filter(v => v.fieldPath?.fieldName[0] === 'numbers'); + expect(numberViolations.length).toBe(2); // Two violations for two duplicates. + }); + }); + + describe('Enum Fields with Distinct', () => { + it('should pass when all enum values are unique', () => { + const valid = create(DistinctEnumsSchema, { + statuses: [DistinctStatus.ACTIVE, DistinctStatus.INACTIVE, DistinctStatus.PENDING] + }); + + const violations = validate(DistinctEnumsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when enum values are duplicated', () => { + const invalid = create(DistinctEnumsSchema, { + statuses: [DistinctStatus.ACTIVE, DistinctStatus.INACTIVE, DistinctStatus.ACTIVE] + }); + + const violations = validate(DistinctEnumsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const statusViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'statuses'); + expect(statusViolation).toBeDefined(); + }); + }); + + describe('Non-Distinct Fields (Control Group)', () => { + it('should allow duplicates when `distinct` is not set', () => { + const withDuplicates = create(NonDistinctFieldsSchema, { + numbers: [1, 2, 2, 3, 3, 3], // Duplicates allowed. + tags: ['alpha', 'alpha', 'beta'] // Duplicates allowed. + }); + + const violations = validate(NonDistinctFieldsSchema, withDuplicates); + expect(violations).toHaveLength(0); // No violations - duplicates are OK. + }); + }); + + describe('Combined Constraints (Distinct + Other Options)', () => { + it('should pass when all constraints are satisfied', () => { + const valid = create(DistinctCombinedConstraintsSchema, { + productIds: [1, 100, 500, 999], // Distinct and within range. + emails: ['user1@example.com', 'user2@example.com'], // Distinct and match pattern. + scores: [75, 85, 92] // Distinct and within min/max. + }); + + const violations = validate(DistinctCombinedConstraintsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should detect `distinct` violation even when `range` is satisfied', () => { + const invalid = create(DistinctCombinedConstraintsSchema, { + productIds: [100, 200, 100], // Duplicate but within range. + emails: ['user1@example.com', 'user2@example.com'], + scores: [75, 85, 92] + }); + + const violations = validate(DistinctCombinedConstraintsSchema, invalid); + const distinctViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'product_ids' && + v.message?.withPlaceholders.includes('Duplicate') + ); + expect(distinctViolation).toBeDefined(); + }); + + it('should detect `distinct` violation in repeated emails', () => { + const invalid = create(DistinctCombinedConstraintsSchema, { + productIds: [100, 200, 300], + emails: ['user1@example.com', 'user2@example.com', 'user1@example.com'], // Duplicate. + scores: [75, 85, 92] + }); + + const violations = validate(DistinctCombinedConstraintsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + // Should have distinct violation for duplicate email. + const distinctViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'emails' && + v.message?.withPlaceholders.includes('Duplicate') + ); + expect(distinctViolation).toBeDefined(); + expect(distinctViolation?.message?.placeholderValue?.['value']).toBe('user1@example.com'); + }); + + it('should detect both `distinct` and `range` violations', () => { + const invalid = create(DistinctCombinedConstraintsSchema, { + productIds: [100, 200, 300], + emails: ['user1@example.com', 'user2@example.com'], + scores: [75, 101, 75] // 101 violates max, 75 is duplicate. + }); + + const violations = validate(DistinctCombinedConstraintsSchema, invalid); + expect(violations.length).toBeGreaterThanOrEqual(2); + + const rangeViolation = violations.find(v => + v.fieldPath?.fieldName[1] === '1' && + v.message?.withPlaceholders.includes('at most') + ); + expect(rangeViolation).toBeDefined(); + + const distinctViolation = violations.find(v => + v.message?.withPlaceholders.includes('Duplicate') + ); + expect(distinctViolation).toBeDefined(); + }); + }); + + describe('Optional/Empty Repeated Fields', () => { + it('should pass when repeated fields are empty', () => { + const empty = create(OptionalDistinctSchema, { + optionalNumbers: [], + optionalTags: [] + }); + + const violations = validate(OptionalDistinctSchema, empty); + expect(violations).toHaveLength(0); + }); + + it('should pass when repeated field has single element', () => { + const singleElement = create(OptionalDistinctSchema, { + optionalNumbers: [42], + optionalTags: ['solo'] + }); + + const violations = validate(OptionalDistinctSchema, singleElement); + expect(violations).toHaveLength(0); + }); + + it('should `validate` when optional fields have multiple elements', () => { + const invalid = create(OptionalDistinctSchema, { + optionalNumbers: [1, 2, 1], // Duplicate. + optionalTags: ['tag1', 'tag2'] + }); + + const violations = validate(OptionalDistinctSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + }); + }); + + describe('Real-World Scenarios', () => { + it('should `validate` user profile with `distinct` tags', () => { + const valid = create(UserProfileSchema, { + username: 'johndoe', + tags: ['developer', 'typescript', 'nodejs'], + skills: ['javascript', 'react', 'python'] + }); + + const violations = validate(UserProfileSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should reject user profile with duplicate tags', () => { + const invalid = create(UserProfileSchema, { + username: 'johndoe', + tags: ['developer', 'typescript', 'developer'], // Duplicate. + skills: ['javascript', 'react', 'python'] + }); + + const violations = validate(UserProfileSchema, invalid); + const tagViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'tags'); + expect(tagViolation).toBeDefined(); + }); + + it('should `validate` shopping cart with unique product IDs', () => { + const valid = create(ShoppingCartSchema, { + productIds: [101, 202, 303], + couponCodes: ['SUMMER2024', 'FREESHIP'] + }); + + const violations = validate(ShoppingCartSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should reject shopping cart with duplicate product IDs', () => { + const invalid = create(ShoppingCartSchema, { + productIds: [101, 202, 101], // Duplicate product. + couponCodes: ['SUMMER2024', 'FREESHIP'] + }); + + const violations = validate(ShoppingCartSchema, invalid); + const productViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'product_ids'); + expect(productViolation).toBeDefined(); + }); + + it('should reject duplicate coupon codes', () => { + const invalid = create(ShoppingCartSchema, { + productIds: [101, 202, 303], + couponCodes: ['SUMMER2024', 'FREESHIP', 'SUMMER2024'] // Duplicate. + }); + + const violations = validate(ShoppingCartSchema, invalid); + const couponViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'coupon_codes'); + expect(couponViolation).toBeDefined(); + }); + }); + + describe('Different Numeric Types with Distinct', () => { + it('should `validate` `distinct` for all numeric types', () => { + const valid = create(DistinctNumericTypesSchema, { + int32Values: [1, 2, 3], + int64Values: [100n, 200n, 300n], + uint32Values: [10, 20, 30], + uint64Values: [1000n, 2000n, 3000n], + floatValues: [1.1, 2.2, 3.3], + doubleValues: [10.1, 20.2, 30.3] + }); + + const violations = validate(DistinctNumericTypesSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should detect duplicates in int64 fields', () => { + const invalid = create(DistinctNumericTypesSchema, { + int32Values: [1, 2, 3], + int64Values: [100n, 200n, 100n], // Duplicate. + uint32Values: [10, 20, 30], + uint64Values: [1000n, 2000n, 3000n], + floatValues: [1.1, 2.2, 3.3], + doubleValues: [10.1, 20.2, 30.3] + }); + + const violations = validate(DistinctNumericTypesSchema, invalid); + const int64Violation = violations.find(v => v.fieldPath?.fieldName[0] === 'int64_values'); + expect(int64Violation).toBeDefined(); + }); + }); + + describe('Edge Cases', () => { + it('should treat empty strings as duplicates', () => { + const invalid = create(DistinctEdgeCasesSchema, { + emptyStrings: ['', 'value', ''], // Two empty strings. + zeros: [0, 1, 2], + caseSensitive: ['Tag', 'tag'] + }); + + const violations = validate(DistinctEdgeCasesSchema, invalid); + const emptyViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'empty_strings'); + expect(emptyViolation).toBeDefined(); + }); + + it('should treat zeros as duplicates', () => { + const invalid = create(DistinctEdgeCasesSchema, { + emptyStrings: ['value1', 'value2'], + zeros: [0, 1, 0], // Two zeros. + caseSensitive: ['Tag', 'tag'] + }); + + const violations = validate(DistinctEdgeCasesSchema, invalid); + const zeroViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'zeros'); + expect(zeroViolation).toBeDefined(); + }); + + it('should be case-sensitive for strings', () => { + const valid = create(DistinctEdgeCasesSchema, { + emptyStrings: ['value1', 'value2'], + zeros: [0, 1, 2], + caseSensitive: ['Tag', 'tag', 'TAG'] // All different due to case. + }); + + const violations = validate(DistinctEdgeCasesSchema, valid); + expect(violations).toHaveLength(0); // No violations - case matters. + }); + + it('should detect case-insensitive duplicates correctly', () => { + const invalid = create(DistinctEdgeCasesSchema, { + emptyStrings: ['value1', 'value2'], + zeros: [0, 1, 2], + caseSensitive: ['Tag', 'tag', 'Tag'] // 'Tag' duplicated (exact match). + }); + + const violations = validate(DistinctEdgeCasesSchema, invalid); + const caseViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'case_sensitive'); + expect(caseViolation).toBeDefined(); + }); + }); +}); + diff --git a/packages/spine-validation-ts/tests/goes.test.ts b/packages/spine-validation-ts/tests/goes.test.ts new file mode 100644 index 0000000..0aced5b --- /dev/null +++ b/packages/spine-validation-ts/tests/goes.test.ts @@ -0,0 +1,524 @@ +/** + * Unit tests for `(goes)` validation option. + * + * Tests field dependency validation (field can only be set if another field is set). + */ + +import { create } from '@bufbuild/protobuf'; +import { validate } from '../src'; + +import { + ScheduledEventSchema, + ShippingDetailsSchema, + ColorSettingsSchema, + PaymentInfoSchema, + ProfileSettingsSchema, + DocumentMetadataSchema, + TimestampSchema, + SecureAccountSchema, + SimpleConfigSchema, + FeatureFlagsSchema, + FeatureLevel, + ReportGenerationSchema, + OptionalSettingsSchema, + AdvancedConfigSchema +} from './generated/test-goes_pb'; + +describe('Field Dependency Validation (goes)', () => { + describe('Basic Goes Constraint', () => { + it('should pass when dependent field is not set', () => { + const valid = create(ScheduledEventSchema, { + eventName: 'Team Meeting', + date: '' + // time not set - valid because time is only required when date is set. + }); + + const violations = validate(ScheduledEventSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should pass when both fields are set', () => { + const valid = create(ScheduledEventSchema, { + eventName: 'Team Meeting', + date: '2024-12-25', + time: '14:30' + }); + + const violations = validate(ScheduledEventSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when dependent field is set but `required` field is not', () => { + const invalid = create(ScheduledEventSchema, { + eventName: 'Team Meeting', + date: '', // Not set. + time: '14:30' // Set - violates (goes).with = "date". + }); + + const violations = validate(ScheduledEventSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const goesViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'time' + ); + expect(goesViolation).toBeDefined(); + expect(goesViolation?.message?.withPlaceholders).toContain('date'); + }); + + it('should pass when both fields are unset', () => { + const valid = create(ScheduledEventSchema, { + eventName: 'Team Meeting' + // Both date and time are unset - valid. + }); + + const violations = validate(ScheduledEventSchema, valid); + expect(violations).toHaveLength(0); + }); + }); + + describe('Custom Error Messages', () => { + it('should use custom error message from (`goes`).error_msg', () => { + const invalid = create(ShippingDetailsSchema, { + address: '', // Not set. + trackingNumber: 'TRACK123' // Set - violates goes constraint. + }); + + const violations = validate(ShippingDetailsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const goesViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'tracking_number' + ); + expect(goesViolation).toBeDefined(); + expect(goesViolation?.message?.withPlaceholders).toBe( + 'Tracking number requires a shipping address: {value}.' + ); + }); + + it('should pass when both fields are set', () => { + const valid = create(ShippingDetailsSchema, { + address: '123 Main St', + trackingNumber: 'TRACK123' + }); + + const violations = validate(ShippingDetailsSchema, valid); + expect(violations).toHaveLength(0); + }); + }); + + describe('Mutual Dependencies (Bidirectional)', () => { + it('should pass when both fields are set', () => { + const valid = create(ColorSettingsSchema, { + textColor: '#000000', + highlightColor: '#FFFF00' + }); + + const violations = validate(ColorSettingsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should pass when both fields are unset', () => { + const valid = create(ColorSettingsSchema, { + // Both unset. + }); + + const violations = validate(ColorSettingsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when only text_color is set', () => { + const invalid = create(ColorSettingsSchema, { + textColor: '#000000', + highlightColor: '' // Not set. + }); + + const violations = validate(ColorSettingsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const textColorViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'text_color' + ); + expect(textColorViolation).toBeDefined(); + }); + + it('should fail when only highlight_color is set', () => { + const invalid = create(ColorSettingsSchema, { + textColor: '', // Not set. + highlightColor: '#FFFF00' + }); + + const violations = validate(ColorSettingsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const highlightViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'highlight_color' + ); + expect(highlightViolation).toBeDefined(); + }); + }); + + describe('Multiple Independent Goes Constraints', () => { + it('should pass when all fields are set', () => { + const valid = create(PaymentInfoSchema, { + cardholderName: 'John Doe', + cardNumber: '4111111111111111', + cvv: '123', + expiryMonth: 12 + }); + + const violations = validate(PaymentInfoSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when card_number is set but cardholder_name is not', () => { + const invalid = create(PaymentInfoSchema, { + cardholderName: '', // Not set. + cardNumber: '4111111111111111', // Violates goes constraint. + cvv: '', + expiryMonth: 0 + }); + + const violations = validate(PaymentInfoSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const cardNumberViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'card_number' + ); + expect(cardNumberViolation).toBeDefined(); + }); + + it('should fail when cvv is set but card_number is not', () => { + const invalid = create(PaymentInfoSchema, { + cardholderName: 'John Doe', + cardNumber: '', // Not set. + cvv: '123', // Violates goes constraint. + expiryMonth: 0 + }); + + const violations = validate(PaymentInfoSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const cvvViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'cvv' + ); + expect(cvvViolation).toBeDefined(); + }); + + it('should detect multiple `goes` violations', () => { + const invalid = create(PaymentInfoSchema, { + cardholderName: '', // Not set. + cardNumber: '4111111111111111', // Violates (cardholder_name missing). + cvv: '123', // Violates (card_number dependency). + expiryMonth: 12 // Violates (card_number dependency). + }); + + const violations = validate(PaymentInfoSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const cardNumberViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'card_number' + ); + expect(cardNumberViolation).toBeDefined(); + + // Note: cvv and `expiry_month` don't violate because `card_number` IS set. + // Only `card_number` violates because `cardholder_name` is NOT set. + }); + }); + + describe('Different Field Types', () => { + it('should `validate` `goes` constraint on int32 field', () => { + const invalid = create(ProfileSettingsSchema, { + username: '', // Not set. + displayId: 12345 // Set - violates goes constraint. + }); + + const violations = validate(ProfileSettingsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const displayIdViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'display_id' + ); + expect(displayIdViolation).toBeDefined(); + }); + + it('should `validate` `goes` constraint on bool field', () => { + const invalid = create(ProfileSettingsSchema, { + username: '', // Not set. + isVerified: true // Set - violates goes constraint. + }); + + const violations = validate(ProfileSettingsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const verifiedViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'is_verified' + ); + expect(verifiedViolation).toBeDefined(); + }); + + it('should `validate` `goes` constraint on double field', () => { + const invalid = create(ProfileSettingsSchema, { + username: '', // Not set. + rating: 4.5 // Set - violates goes constraint. + }); + + const violations = validate(ProfileSettingsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const ratingViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'rating' + ); + expect(ratingViolation).toBeDefined(); + }); + + it('should `validate` `goes` constraint on message field', () => { + const invalid = create(DocumentMetadataSchema, { + title: '', // Not set. + createdAt: create(TimestampSchema, { + seconds: BigInt(1234567890), + nanos: 0 + }) // Set - violates goes constraint. + }); + + const violations = validate(DocumentMetadataSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const createdAtViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'created_at' + ); + expect(createdAtViolation).toBeDefined(); + }); + }); + + describe('Without Goes Constraint (Control Group)', () => { + it('should allow independent fields without `goes` constraint', () => { + const valid = create(SimpleConfigSchema, { + primaryOption: '', + secondaryOption: 'some value' // Can be set independently. + }); + + const violations = validate(SimpleConfigSchema, valid); + expect(violations).toHaveLength(0); + }); + }); + + describe('Goes with Enum Fields', () => { + it('should pass when both enum and dependent field are set', () => { + const valid = create(FeatureFlagsSchema, { + level: FeatureLevel.PREMIUM, + customConfig: 'advanced-settings' + }); + + const violations = validate(FeatureFlagsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when dependent field is set but enum is unspecified', () => { + const invalid = create(FeatureFlagsSchema, { + level: FeatureLevel.UNSPECIFIED, // Default/unset. + customConfig: 'advanced-settings' // Violates goes constraint. + }); + + const violations = validate(FeatureFlagsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const configViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'custom_config' + ); + expect(configViolation).toBeDefined(); + }); + }); + + describe('Chain Dependencies', () => { + it('should `validate` independent chain dependencies', () => { + const valid = create(ReportGenerationSchema, { + reportType: 'monthly', + outputFormat: 'pdf', + emailRecipient: 'admin@example.com', + schedule: 'daily' + }); + + const violations = validate(ReportGenerationSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when output_format is set but report_type is not', () => { + const invalid = create(ReportGenerationSchema, { + reportType: '', // Not set. + outputFormat: 'pdf', // Violates goes constraint. + emailRecipient: '', + schedule: '' + }); + + const violations = validate(ReportGenerationSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const formatViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'output_format' + ); + expect(formatViolation).toBeDefined(); + }); + + it('should fail when schedule is set but output_format is not', () => { + const invalid = create(ReportGenerationSchema, { + reportType: 'monthly', + outputFormat: '', // Not set. + emailRecipient: '', + schedule: 'daily' // Violates goes constraint (depends on output_format). + }); + + const violations = validate(ReportGenerationSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const scheduleViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'schedule' + ); + expect(scheduleViolation).toBeDefined(); + }); + }); + + describe('Optional Fields with Goes', () => { + it('should pass when base field and dependent fields are all set', () => { + const valid = create(OptionalSettingsSchema, { + baseUrl: 'https://api.example.com', + port: 8080, + path: '/v1/api' + }); + + const violations = validate(OptionalSettingsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should pass when all fields are unset', () => { + const valid = create(OptionalSettingsSchema, { + // All unset. + }); + + const violations = validate(OptionalSettingsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when port is set without base_url', () => { + const invalid = create(OptionalSettingsSchema, { + baseUrl: '', // Not set. + port: 8080, // Violates goes constraint. + path: '' + }); + + const violations = validate(OptionalSettingsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const portViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'port' + ); + expect(portViolation).toBeDefined(); + }); + }); + + describe('Combined Constraints (Goes + Other Options)', () => { + it('should pass when all constraints are satisfied', () => { + const valid = create(SecureAccountSchema, { + username: 'john_doe', + password: 'securepass123', + recoveryEmail: 'john@example.com', + recoveryPhone: '+1234567890' + }); + + const violations = validate(SecureAccountSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should detect `goes` violation when recovery_phone is set without recovery_email', () => { + const invalid = create(SecureAccountSchema, { + username: 'john_doe', + password: 'securepass123', + recoveryEmail: '', // Not set. + recoveryPhone: '+1234567890' // Violates goes constraint. + }); + + const violations = validate(SecureAccountSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const phoneViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'recovery_phone' + ); + expect(phoneViolation).toBeDefined(); + }); + + it('should detect both `pattern` and `goes` violations', () => { + const invalid = create(SecureAccountSchema, { + username: 'ab', // Too short - violates pattern. + password: 'short', // Too short - violates pattern. + recoveryEmail: 'invalid', // Invalid format - violates pattern (but is "set" for goes purposes). + recoveryPhone: '+1234567890' // Does NOT violate goes because recovery_email IS set (even though invalid). + }); + + const violations = validate(SecureAccountSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + // Should have pattern violations for username, password, and `recovery_email`. + const usernameViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'username' + ); + expect(usernameViolation).toBeDefined(); + + const passwordViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'password' + ); + expect(passwordViolation).toBeDefined(); + + const emailViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'recovery_email' + ); + expect(emailViolation).toBeDefined(); + + // Note: `recovery_phone` does NOT violate goes constraint because `recovery_email` IS set. + // (goes checks if field is set, not if it's valid). + }); + + it('should `validate` `goes` combined with `range` constraint', () => { + const valid = create(AdvancedConfigSchema, { + configName: 'production', + maxConnections: 500, // Within range [1..1000]. + timeoutSeconds: 30.0 // Above min 0.1. + }); + + const violations = validate(AdvancedConfigSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should detect `range` violation even when `goes` constraint is satisfied', () => { + const invalid = create(AdvancedConfigSchema, { + configName: 'production', + maxConnections: 2000, // Exceeds range [1..1000]. + timeoutSeconds: 30.0 + }); + + const violations = validate(AdvancedConfigSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const rangeViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'max_connections' + ); + expect(rangeViolation).toBeDefined(); + expect(rangeViolation?.message?.withPlaceholders).toContain('[1..1000]'); + }); + + it('should detect `goes` violation when max_connections is set without config_name', () => { + const invalid = create(AdvancedConfigSchema, { + configName: '', // Not set. + maxConnections: 500, // Violates goes constraint. + timeoutSeconds: 0 + }); + + const violations = validate(AdvancedConfigSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const goesViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'max_connections' + ); + expect(goesViolation).toBeDefined(); + }); + }); +}); + diff --git a/packages/spine-validation-ts/tests/integration.test.ts b/packages/spine-validation-ts/tests/integration.test.ts new file mode 100644 index 0000000..9dab819 --- /dev/null +++ b/packages/spine-validation-ts/tests/integration.test.ts @@ -0,0 +1,642 @@ +/** + * Integration tests combining multiple validation options. + * + * Tests real-world scenarios with complex validation constraints. + */ + +import { create } from '@bufbuild/protobuf'; +import { validate, formatViolations } from '../src'; + +import { UserSchema, Role, GetUserResponseSchema } from './generated/integration-user_pb'; +import { AccountSchema, AccountType } from './generated/integration-account_pb'; +import { SecureAccountSchema, AdvancedConfigSchema, FeatureLevel, ColorSettingsSchema, ScheduledEventSchema } from './generated/test-goes_pb'; + +describe('Integration Tests', () => { + it('should `validate` User message with multiple constraint types', () => { + const validUser = create(UserSchema, { + id: 1, + name: 'John Doe', + email: 'john.doe@example.com', + role: Role.ADMIN, + tags: ['developer', 'typescript'] + }); + + const violations = validate(UserSchema, validUser); + expect(violations).toHaveLength(0); + }); + + it('should detect both `required` and `pattern` violations', () => { + const invalidUser = create(UserSchema, { + id: 1, + name: '', // Required violation. + email: 'bad@', // Pattern violation. + role: Role.USER, + tags: [] + }); + + const violations = validate(UserSchema, invalidUser); + expect(violations.length).toBeGreaterThanOrEqual(2); + + const fieldNames = violations.map(v => v.fieldPath?.fieldName[0]); + expect(fieldNames).toContain('name'); + expect(fieldNames).toContain('email'); + }); + + it('should format violations correctly', () => { + const invalidUser = create(UserSchema, { + id: 6, + name: '', + email: '', + role: Role.USER, + tags: [] + }); + + const violations = validate(UserSchema, invalidUser); + const formatted = formatViolations(violations); + + expect(formatted).toContain('spine.validation.testing.integration.User.name'); + expect(formatted).toContain('spine.validation.testing.integration.User.email'); + expect(formatted).toContain('A value must be set'); + }); + + it('should `validate` User with `distinct` tags', () => { + const validUser = create(UserSchema, { + id: 1, + name: 'John Doe', + email: 'john.doe@example.com', + role: Role.ADMIN, + tags: ['developer', 'typescript', 'nodejs', 'react'] // All distinct. + }); + + const violations = validate(UserSchema, validUser); + expect(violations).toHaveLength(0); + }); + + it('should detect duplicate tags in User', () => { + const invalidUser = create(UserSchema, { + id: 1, + name: 'John Doe', + email: 'john.doe@example.com', + role: Role.ADMIN, + tags: ['developer', 'typescript', 'developer', 'nodejs'] // 'developer' duplicated. + }); + + const violations = validate(UserSchema, invalidUser); + expect(violations.length).toBeGreaterThan(0); + + const tagViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'tags' && + v.message?.withPlaceholders.includes('Duplicate') + ); + expect(tagViolation).toBeDefined(); + expect(tagViolation?.message?.placeholderValue?.['value']).toBe('developer'); + }); + + it('should detect multiple constraint violations including `distinct`', () => { + const invalidUser = create(UserSchema, { + id: 1, + name: '1', // Too short (pattern violation). + email: 'invalid', // Pattern violation. + role: Role.USER, + tags: ['tag1', 'tag2', 'tag1'] // Distinct violation. + }); + + const violations = validate(UserSchema, invalidUser); + expect(violations.length).toBeGreaterThanOrEqual(3); + + const fieldNames = violations.map(v => v.fieldPath?.fieldName[0]); + expect(fieldNames).toContain('name'); + expect(fieldNames).toContain('email'); + expect(fieldNames).toContain('tags'); + }); + + it('should `validate` Account with combined `required_field`, `required`, `pattern`, `min`/`max`, and `range` constraints', () => { + // Valid account with `id` provided (satisfies `required_field`). + const validAccount = create(AccountSchema, { + id: 123, + email: 'user@example.com', + username: 'johndoe', + password: 'secure_password_123', + accountType: AccountType.PREMIUM, + age: 25, // Within range [13..120]. + balance: 5000.0, // Within min/max [0.0..1000000.0]. + failedLoginAttempts: 0, // Within range [0..5]. + rating: 4.5 // Within range [1.0..5.0]. + }); + + const violations = validate(AccountSchema, validAccount); + expect(violations).toHaveLength(0); + }); + + it('should `validate` Account with second field provided instead of first', () => { + // Note: `id` has `(min).value="1"`, so we provide a valid ID even though. + // the `required_field` "id | email" would be satisfied by email alone. + // Proto3 doesn't allow truly "unset" numeric fields (they default to 0). + const validAccount = create(AccountSchema, { + id: 1, // Provide valid ID (>= 1) to avoid min violation. + email: 'user@example.com', + username: 'johndoe', + password: 'secure_password_123', + accountType: AccountType.FREE, + age: 18, // Within range. + balance: 100.0, + failedLoginAttempts: 2, + rating: 3.0 + }); + + const violations = validate(AccountSchema, validAccount); + expect(violations).toHaveLength(0); + }); + + it('should detect `required_field` violation when neither `required` field is provided', () => { + const invalid = create(AccountSchema, { + id: 0, + email: '', // Violates both (required_field) and (required). + username: 'johndoe', + password: 'secure_password_123', + accountType: AccountType.FREE + }); + + const violations = validate(AccountSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + // Should have violations for `required_field`, required email, or both. + const hasRequiredFieldViolation = violations.some(v => + v.message?.withPlaceholders.includes('id | email') + ); + const hasRequiredEmailViolation = violations.some(v => + v.fieldPath?.fieldName[0] === 'email' + ); + + expect(hasRequiredFieldViolation || hasRequiredEmailViolation).toBe(true); + }); + + it('should detect `pattern` violation in username field', () => { + const invalid = create(AccountSchema, { + id: 123, + email: 'user@example.com', + username: 'ab', // Too short, violates pattern. + password: 'secure_password_123', + accountType: AccountType.FREE + }); + + const violations = validate(AccountSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const usernameViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'username'); + expect(usernameViolation).toBeDefined(); + expect(usernameViolation?.message?.withPlaceholders).toContain('3-20 characters'); + }); + + it('should detect `pattern` violation in email field', () => { + const invalid = create(AccountSchema, { + id: 123, + email: 'invalid-email', // Invalid email format. + username: 'johndoe', + password: 'secure_password_123', + accountType: AccountType.FREE + }); + + const violations = validate(AccountSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const emailViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'email'); + expect(emailViolation).toBeDefined(); + expect(emailViolation?.message?.withPlaceholders).toContain('Invalid email format'); + }); + + it('should detect multiple violations across different constraint types', () => { + const invalid = create(AccountSchema, { + id: 0, // Doesn't satisfy required_field. + email: '', // Empty (violates required) and doesn't satisfy required_field. + username: 'a', // Too short (violates pattern). + password: 'short', // Too short (violates pattern). + accountType: 0, // UNSPECIFIED (violates required). + age: 10, // Violates range [13..120]. + balance: -100.0, // Violates min 0.0. + failedLoginAttempts: 10, // Violates range [0..5]. + rating: 0.5 // Violates range [1.0..5.0]. + }); + + const violations = validate(AccountSchema, invalid); + expect(violations.length).toBeGreaterThanOrEqual(7); + + // Check for various types of violations. + const fieldPaths = violations.map(v => v.fieldPath?.fieldName[0] || ''); + const hasMessageLevelViolation = violations.some(v => + v.fieldPath?.fieldName.length === 0 + ); + + // Should have violations for username, password, `account_type`, age, balance, `failed_login_attempts`, rating. + expect(fieldPaths.includes('username') || fieldPaths.includes('password')).toBe(true); + expect(fieldPaths.includes('age')).toBe(true); + expect(fieldPaths.includes('failed_login_attempts')).toBe(true); + }); + + it('should detect `range` violations while other fields are valid', () => { + const invalid = create(AccountSchema, { + id: 123, + email: 'user@example.com', + username: 'johndoe', + password: 'secure_password_123', + accountType: AccountType.FREE, + age: 150, // Violates range [13..120]. + balance: 50000.0, + failedLoginAttempts: 6, // Violates range [0..5]. + rating: 3.5 + }); + + const violations = validate(AccountSchema, invalid); + expect(violations.length).toBe(2); + + const ageViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'age'); + expect(ageViolation).toBeDefined(); + expect(ageViolation?.message?.withPlaceholders).toContain('[13..120]'); + + const attemptsViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'failed_login_attempts'); + expect(attemptsViolation).toBeDefined(); + expect(attemptsViolation?.message?.withPlaceholders).toContain('[0..5]'); + }); + + it('should detect both `required` and `range` violations on age field', () => { + const invalid = create(AccountSchema, { + id: 123, + email: 'user@example.com', + username: 'johndoe', + password: 'secure_password_123', + accountType: AccountType.FREE, + age: 0, // Violates both (required) and range [13..120]. + balance: 1000.0, + failedLoginAttempts: 0, + rating: 4.0 + }); + + const violations = validate(AccountSchema, invalid); + expect(violations.length).toBeGreaterThanOrEqual(1); + + // Age 0 should violate range constraint (and possibly required). + const ageViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'age'); + expect(ageViolation).toBeDefined(); + }); + + it('should `validate` balance with `min`/`max` constraints', () => { + const validBalance = create(AccountSchema, { + id: 123, + email: 'user@example.com', + username: 'johndoe', + password: 'secure_password_123', + accountType: AccountType.PREMIUM, + age: 30, + balance: 999999.99, // Just under max. + failedLoginAttempts: 0, + rating: 5.0 + }); + + const violations1 = validate(AccountSchema, validBalance); + expect(violations1).toHaveLength(0); + + const invalidBalance = create(AccountSchema, { + id: 123, + email: 'user@example.com', + username: 'johndoe', + password: 'secure_password_123', + accountType: AccountType.PREMIUM, + age: 30, + balance: 1000001.0, // Violates max 1000000.0. + failedLoginAttempts: 0, + rating: 5.0 + }); + + const violations2 = validate(AccountSchema, invalidBalance); + const balanceViolation = violations2.find(v => v.fieldPath?.fieldName[0] === 'balance'); + expect(balanceViolation).toBeDefined(); + }); + + it('should `validate` rating `range` boundaries', () => { + const validMin = create(AccountSchema, { + id: 123, + email: 'user@example.com', + username: 'johndoe', + password: 'secure_password_123', + accountType: AccountType.FREE, + age: 25, + balance: 1000.0, + failedLoginAttempts: 0, + rating: 1.0 // Min boundary. + }); + + const violations1 = validate(AccountSchema, validMin); + expect(violations1).toHaveLength(0); + + const validMax = create(AccountSchema, { + id: 123, + email: 'user@example.com', + username: 'johndoe', + password: 'secure_password_123', + accountType: AccountType.FREE, + age: 25, + balance: 1000.0, + failedLoginAttempts: 0, + rating: 5.0 // Max boundary. + }); + + const violations2 = validate(AccountSchema, validMax); + expect(violations2).toHaveLength(0); + + const invalidRating = create(AccountSchema, { + id: 123, + email: 'user@example.com', + username: 'johndoe', + password: 'secure_password_123', + accountType: AccountType.FREE, + age: 25, + balance: 1000.0, + failedLoginAttempts: 0, + rating: 5.5 // Violates range [1.0..5.0]. + }); + + const violations3 = validate(AccountSchema, invalidRating); + const ratingViolation = violations3.find(v => v.fieldPath?.fieldName[0] === 'rating'); + expect(ratingViolation).toBeDefined(); + expect(ratingViolation?.message?.withPlaceholders).toContain('[1.0..5.0]'); + }); + + describe('Nested Validation (validate) Integration', () => { + it('should `validate` GetUserResponse with valid nested User', () => { + const validResponse = create(GetUserResponseSchema, { + user: create(UserSchema, { + id: 1, + name: 'Alice Smith', + email: 'alice@example.com', + role: Role.ADMIN, + tags: ['developer', 'typescript'] + }), + found: true + }); + + const violations = validate(GetUserResponseSchema, validResponse); + expect(violations).toHaveLength(0); + }); + + it('should detect nested User violations with custom error message', () => { + const invalidResponse = create(GetUserResponseSchema, { + user: create(UserSchema, { + id: 1, + name: '', // Required violation. + email: 'alice@example.com', + role: Role.USER, + tags: [] + }), + found: true + }); + + const violations = validate(GetUserResponseSchema, invalidResponse); + expect(violations.length).toBeGreaterThan(0); + + // Should have parent-level violation with custom message. + const parentViolation = violations.find(v => + v.fieldPath?.fieldName.length === 1 && + v.fieldPath?.fieldName[0] === 'user' + ); + expect(parentViolation).toBeDefined(); + expect(parentViolation?.message?.withPlaceholders).toBe('User data is invalid.'); + + // Should also have nested violation for name field. + const nameViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'user' && + v.fieldPath?.fieldName[1] === 'name' + ); + expect(nameViolation).toBeDefined(); + }); + + it('should detect multiple nested constraint violations (`required` + `pattern` + `distinct`)', () => { + const invalidResponse = create(GetUserResponseSchema, { + user: create(UserSchema, { + id: 0, // Violates min constraint. + name: '123', // Violates pattern (must start with letter). + email: 'not-an-email', // Violates pattern. + role: Role.USER, + tags: ['dev', 'dev', 'ops'] // Violates distinct. + }), + found: true + }); + + const violations = validate(GetUserResponseSchema, invalidResponse); + expect(violations.length).toBeGreaterThan(0); + + // Check for various nested violations. + const idViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'user' && + v.fieldPath?.fieldName[1] === 'id' + ); + expect(idViolation).toBeDefined(); + + const nameViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'user' && + v.fieldPath?.fieldName[1] === 'name' + ); + expect(nameViolation).toBeDefined(); + + const emailViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'user' && + v.fieldPath?.fieldName[1] === 'email' + ); + expect(emailViolation).toBeDefined(); + + const tagsViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'user' && + v.fieldPath?.fieldName[1] === 'tags' + ); + expect(tagsViolation).toBeDefined(); + }); + + it('should detect `required_field` violation in nested User', () => { + const invalidResponse = create(GetUserResponseSchema, { + user: create(UserSchema, { + // Neither `id` nor `email` provided - violates `required_field` option. + name: 'Bob Jones', + role: Role.USER, + tags: [] + }), + found: true + }); + + const violations = validate(GetUserResponseSchema, invalidResponse); + expect(violations.length).toBeGreaterThan(0); + + // Should have `required_field` violation. + const requiredFieldViolation = violations.find(v => + v.message?.withPlaceholders.includes('id | email') + ); + expect(requiredFieldViolation).toBeDefined(); + }); + + it('should format nested violations correctly', () => { + const invalidResponse = create(GetUserResponseSchema, { + user: create(UserSchema, { + id: 1, + name: '', // Required. + email: '', // Required. + role: Role.USER, + tags: [] + }), + found: true + }); + + const violations = validate(GetUserResponseSchema, invalidResponse); + const formatted = formatViolations(violations); + + // Should contain nested field paths. + expect(formatted).toContain('user'); + expect(formatted).toContain('name'); + expect(formatted).toContain('email'); + }); + + it('should pass when nested User is not set `(optional)`', () => { + const responseWithoutUser = create(GetUserResponseSchema, { + found: false + // user field not set. + }); + + const violations = validate(GetUserResponseSchema, responseWithoutUser); + expect(violations).toHaveLength(0); + }); + }); + + describe('Field Dependency (goes) Integration', () => { + it('should `validate` `goes` with `required` and `pattern` constraints', () => { + const valid = create(SecureAccountSchema, { + username: 'alice_secure', + password: 'strongpass123', + recoveryEmail: 'alice@example.com', + recoveryPhone: '+1234567890' + }); + + const violations = validate(SecureAccountSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should detect `goes` violation independently from `pattern` violations', () => { + const invalid = create(SecureAccountSchema, { + username: 'alice_secure', + password: 'strongpass123', + recoveryEmail: '', // Not set. + recoveryPhone: '+1234567890' // Violates goes constraint. + }); + + const violations = validate(SecureAccountSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const goesViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'recovery_phone' && + v.message?.withPlaceholders.includes('recovery_email') + ); + expect(goesViolation).toBeDefined(); + }); + + it('should detect both `required` and `goes` violations together', () => { + const invalid = create(SecureAccountSchema, { + username: '', // Required violation. + password: '', // Required violation. + recoveryEmail: '', + recoveryPhone: '+1234567890' // Goes violation. + }); + + const violations = validate(SecureAccountSchema, invalid); + expect(violations.length).toBeGreaterThanOrEqual(3); + + const usernameViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'username' + ); + expect(usernameViolation).toBeDefined(); + + const passwordViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'password' + ); + expect(passwordViolation).toBeDefined(); + + const goesViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'recovery_phone' + ); + expect(goesViolation).toBeDefined(); + }); + + it('should `validate` `goes` with `range` and `min` constraints', () => { + const valid = create(AdvancedConfigSchema, { + configName: 'staging', + maxConnections: 100, + timeoutSeconds: 15.5 + }); + + const violations = validate(AdvancedConfigSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should detect `goes` and `range` violations independently', () => { + const invalid1 = create(AdvancedConfigSchema, { + configName: 'production', + maxConnections: 5000, // Violates range [1..1000]. + timeoutSeconds: 10.0 + }); + + const violations1 = validate(AdvancedConfigSchema, invalid1); + const rangeViolation = violations1.find(v => + v.fieldPath?.fieldName[0] === 'max_connections' + ); + expect(rangeViolation).toBeDefined(); + expect(rangeViolation?.message?.withPlaceholders).toContain('[1..1000]'); + + const invalid2 = create(AdvancedConfigSchema, { + configName: '', // Not set. + maxConnections: 500, // Violates goes constraint. + timeoutSeconds: 10.0 + }); + + const violations2 = validate(AdvancedConfigSchema, invalid2); + const goesViolation = violations2.find(v => + v.fieldPath?.fieldName[0] === 'max_connections' + ); + expect(goesViolation).toBeDefined(); + }); + + it('should handle mutual dependencies with multiple constraint types', () => { + const valid = create(ColorSettingsSchema, { + textColor: '#FF0000', + highlightColor: '#00FF00' + }); + + const violations = validate(ColorSettingsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should detect violations in mutual dependencies', () => { + const invalid = create(ColorSettingsSchema, { + textColor: '#FF0000', + highlightColor: '' // Not set - violates mutual dependency. + }); + + const violations = validate(ColorSettingsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const textColorViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'text_color' + ); + expect(textColorViolation).toBeDefined(); + expect(textColorViolation?.message?.withPlaceholders).toContain('highlight_color'); + }); + + it('should format `goes` violations correctly', () => { + const invalid = create(ScheduledEventSchema, { + eventName: 'Conference', + date: '', + time: '10:00 AM' + }); + + const violations = validate(ScheduledEventSchema, invalid); + const formatted = formatViolations(violations); + + expect(formatted).toContain('time'); + expect(formatted).toContain('date'); + }); + }); +}); diff --git a/packages/spine-validation-ts/tests/min-max.test.ts b/packages/spine-validation-ts/tests/min-max.test.ts new file mode 100644 index 0000000..f8004c9 --- /dev/null +++ b/packages/spine-validation-ts/tests/min-max.test.ts @@ -0,0 +1,457 @@ +/** + * Unit tests for `(min)` and `(max)` validation options. + * + * Tests numeric range validation with inclusive/exclusive bounds. + */ + +import { create } from '@bufbuild/protobuf'; +import { validate } from '../src'; + +import { + MinValueSchema, + MaxValueSchema, + MinMaxRangeSchema, + ExclusiveBoundsSchema, + CustomErrorMessagesSchema, + NumericTypesSchema, + RepeatedMinMaxSchema, + CombinedConstraintsSchema, + OptionalMinMaxSchema +} from './generated/test-min-max_pb'; + +describe('Min/Max Validation', () => { + describe('Basic Min Constraint', () => { + it('should pass when value meets minimum `(inclusive)`', () => { + const valid = create(MinValueSchema, { + positiveId: 1, + nonNegative: 0, + price: 0.01 + }); + + const violations = validate(MinValueSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should pass when value exceeds minimum', () => { + const valid = create(MinValueSchema, { + positiveId: 100, + nonNegative: 50, + price: 19.99 + }); + + const violations = validate(MinValueSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when value is below minimum', () => { + const invalid = create(MinValueSchema, { + positiveId: 0, // Violates min = 1. + nonNegative: 5, + price: 0.01 + }); + + const violations = validate(MinValueSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const positiveIdViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'positive_id'); + expect(positiveIdViolation).toBeDefined(); + expect(positiveIdViolation?.message?.withPlaceholders).toContain('at least'); + }); + + it('should fail when price is below minimum', () => { + const invalid = create(MinValueSchema, { + positiveId: 1, + nonNegative: 0, + price: 0.001 // Violates min = 0.01. + }); + + const violations = validate(MinValueSchema, invalid); + const priceViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'price'); + expect(priceViolation).toBeDefined(); + }); + + it('should `validate` zero values (`proto3` cannot distinguish unset from zero)', () => { + const withDefaults = create(MinValueSchema, { + positiveId: 0, + nonNegative: 0, + price: 0 + }); + + // `positive_id` violates `min=1`, price violates `min=0.01`, nonNegative is valid. + const violations = validate(MinValueSchema, withDefaults); + expect(violations.length).toBeGreaterThanOrEqual(2); + + const positiveIdViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'positive_id'); + expect(positiveIdViolation).toBeDefined(); + + const priceViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'price'); + expect(priceViolation).toBeDefined(); + }); + }); + + describe('Basic Max Constraint', () => { + it('should pass when value meets maximum `(inclusive)`', () => { + const valid = create(MaxValueSchema, { + percentage: 100, + altitude: 8848.86, + year: 2100n + }); + + const violations = validate(MaxValueSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should pass when value is below maximum', () => { + const valid = create(MaxValueSchema, { + percentage: 50, + altitude: 1000.0, + year: 2025n + }); + + const violations = validate(MaxValueSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when value exceeds maximum', () => { + const invalid = create(MaxValueSchema, { + percentage: 101, // Violates max = 100. + altitude: 8000.0, + year: 2050n + }); + + const violations = validate(MaxValueSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const percentageViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'percentage'); + expect(percentageViolation).toBeDefined(); + expect(percentageViolation?.message?.withPlaceholders).toContain('at most'); + }); + + it('should fail when altitude exceeds maximum', () => { + const invalid = create(MaxValueSchema, { + percentage: 100, + altitude: 9000.0, // Violates max = 8848.86. + year: 2050n + }); + + const violations = validate(MaxValueSchema, invalid); + const altitudeViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'altitude'); + expect(altitudeViolation).toBeDefined(); + }); + }); + + describe('Combined Min and Max Constraints', () => { + it('should pass when value is within `range`', () => { + const valid = create(MinMaxRangeSchema, { + age: 25, + temperature: 20.5, + percentage: 50 + }); + + const violations = validate(MinMaxRangeSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should pass at boundary values', () => { + const valid = create(MinMaxRangeSchema, { + age: 0, // min boundary. + temperature: -273.15, // min boundary. + percentage: 100 // max boundary. + }); + + const violations = validate(MinMaxRangeSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when value is below minimum', () => { + const invalid = create(MinMaxRangeSchema, { + age: -1, // Violates min = 0. + temperature: 20.0, + percentage: 50 + }); + + const violations = validate(MinMaxRangeSchema, invalid); + const ageViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'age'); + expect(ageViolation).toBeDefined(); + }); + + it('should fail when value exceeds maximum', () => { + const invalid = create(MinMaxRangeSchema, { + age: 25, + temperature: 1001.0, // Violates max = 1000.0. + percentage: 50 + }); + + const violations = validate(MinMaxRangeSchema, invalid); + const tempViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'temperature'); + expect(tempViolation).toBeDefined(); + }); + + it('should detect multiple violations', () => { + const invalid = create(MinMaxRangeSchema, { + age: 151, // Violates max = 150. + temperature: -300.0, // Violates min = -273.15. + percentage: 101 // Violates max = 100. + }); + + const violations = validate(MinMaxRangeSchema, invalid); + expect(violations.length).toBeGreaterThanOrEqual(3); + }); + }); + + describe('Exclusive Bounds', () => { + it('should pass when value is strictly greater than exclusive minimum', () => { + const valid = create(ExclusiveBoundsSchema, { + positiveValue: 0.1, + temperatureKelvin: 100.0, + belowLimit: 50 + }); + + const violations = validate(ExclusiveBoundsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when value equals exclusive minimum', () => { + const invalid = create(ExclusiveBoundsSchema, { + positiveValue: 0.0, // Violates exclusive min = 0.0. + temperatureKelvin: 100.0, + belowLimit: 50 + }); + + const violations = validate(ExclusiveBoundsSchema, invalid); + const positiveValueViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'positive_value'); + expect(positiveValueViolation).toBeDefined(); + expect(positiveValueViolation?.message?.withPlaceholders).toContain('greater than'); + }); + + it('should fail when value equals exclusive maximum', () => { + const invalid = create(ExclusiveBoundsSchema, { + positiveValue: 0.1, + temperatureKelvin: 100.0, + belowLimit: 100 // Violates exclusive max = 100. + }); + + const violations = validate(ExclusiveBoundsSchema, invalid); + const belowLimitViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'below_limit'); + expect(belowLimitViolation).toBeDefined(); + expect(belowLimitViolation?.message?.withPlaceholders).toContain('less than'); + }); + + it('should use custom error message for temperature', () => { + const invalid = create(ExclusiveBoundsSchema, { + positiveValue: 0.1, + temperatureKelvin: 0.0, // Violates exclusive min with custom message. + belowLimit: 50 + }); + + const violations = validate(ExclusiveBoundsSchema, invalid); + const tempViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'temperature_kelvin'); + expect(tempViolation).toBeDefined(); + expect(tempViolation?.message?.withPlaceholders).toContain('Temperature cannot reach'); + expect(tempViolation?.message?.placeholderValue?.['other']).toBe('0.0'); + expect(tempViolation?.message?.placeholderValue?.['value']).toBe('0'); + }); + }); + + describe('Custom Error Messages', () => { + it('should use custom error message for age minimum', () => { + const invalid = create(CustomErrorMessagesSchema, { + age: 17, // Violates min = 18. + balance: 100.0 + }); + + const violations = validate(CustomErrorMessagesSchema, invalid); + const ageViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'age'); + expect(ageViolation).toBeDefined(); + expect(ageViolation?.message?.withPlaceholders).toContain('Must be at least'); + expect(ageViolation?.message?.withPlaceholders).toContain('years old'); + expect(ageViolation?.message?.placeholderValue?.['other']).toBe('18'); + expect(ageViolation?.message?.placeholderValue?.['value']).toBe('17'); + }); + + it('should use custom error message for balance minimum', () => { + const invalid = create(CustomErrorMessagesSchema, { + age: 25, + balance: 0.001 // Violates min = 0.01. + }); + + const violations = validate(CustomErrorMessagesSchema, invalid); + const balanceViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'balance'); + expect(balanceViolation).toBeDefined(); + expect(balanceViolation?.message?.withPlaceholders).toContain('Balance must be at least'); + }); + + it('should use custom error message for balance maximum', () => { + const invalid = create(CustomErrorMessagesSchema, { + age: 25, + balance: 1000001.0 // Violates max = 1000000.0. + }); + + const violations = validate(CustomErrorMessagesSchema, invalid); + const balanceViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'balance'); + expect(balanceViolation).toBeDefined(); + expect(balanceViolation?.message?.withPlaceholders).toContain('Balance cannot exceed'); + }); + }); + + describe('Different Numeric Types', () => { + it('should `validate` all numeric types correctly', () => { + const valid = create(NumericTypesSchema, { + int32Field: 100, + int64Field: 1000n, + uint32Field: 1000, + uint64Field: 1n, + floatField: 50.0, + doubleField: 0.0 + }); + + const violations = validate(NumericTypesSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should detect violations across different types', () => { + const invalid = create(NumericTypesSchema, { + int32Field: -1, // Violates min = 0. + int64Field: -1n, // Violates min = 0. + uint32Field: 5000000000, // Violates max (too large). + uint64Field: 0n, // Violates min = 1. + floatField: 101.0, // Violates max = 100.0. + doubleField: 1001.0 // Violates max = 1000.0. + }); + + const violations = validate(NumericTypesSchema, invalid); + expect(violations.length).toBeGreaterThanOrEqual(4); + }); + }); + + describe('Repeated Fields', () => { + it('should `validate` all elements in repeated field', () => { + const valid = create(RepeatedMinMaxSchema, { + scores: [0, 50, 100], + prices: [0.01, 10.0, 99.99] + }); + + const violations = validate(RepeatedMinMaxSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should detect violation in one element of repeated field', () => { + const invalid = create(RepeatedMinMaxSchema, { + scores: [50, 101, 75], // Second element violates max = 100. + prices: [10.0, 20.0] + }); + + const violations = validate(RepeatedMinMaxSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const scoreViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'scores' && v.fieldPath?.fieldName[1] === '1' + ); + expect(scoreViolation).toBeDefined(); + }); + + it('should detect multiple violations in repeated field', () => { + const invalid = create(RepeatedMinMaxSchema, { + scores: [-1, 50, 101], // First and third violate constraints. + prices: [0.001, 10.0] // First violates min = 0.01. + }); + + const violations = validate(RepeatedMinMaxSchema, invalid); + expect(violations.length).toBeGreaterThanOrEqual(3); + }); + + it('should not `validate` empty repeated fields', () => { + const empty = create(RepeatedMinMaxSchema, { + scores: [], + prices: [] + }); + + const violations = validate(RepeatedMinMaxSchema, empty); + expect(violations).toHaveLength(0); + }); + }); + + describe('Combined with Required', () => { + it('should pass when `required` field meets `min` constraint', () => { + const valid = create(CombinedConstraintsSchema, { + productId: 1, + price: 0.01, + stock: 100 + }); + + const violations = validate(CombinedConstraintsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should detect `required` violation', () => { + const invalid = create(CombinedConstraintsSchema, { + productId: 0, // Required but set to default. + price: 0, // Required but set to default. + stock: 10 + }); + + const violations = validate(CombinedConstraintsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + // Should have violations for required fields. + const hasRequiredViolation = violations.some(v => + v.message?.withPlaceholders.includes('value must be set') + ); + expect(hasRequiredViolation).toBe(true); + }); + + it('should detect `min` violation on `required` field', () => { + const invalid = create(CombinedConstraintsSchema, { + productId: 0, // Violates min = 1 AND required. + price: 10.0, + stock: 5 + }); + + const violations = validate(CombinedConstraintsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + }); + + it('should use custom error message from `min` option', () => { + const invalid = create(CombinedConstraintsSchema, { + productId: 10, + price: 0.001, // Violates min = 0.01. + stock: 5 + }); + + const violations = validate(CombinedConstraintsSchema, invalid); + const priceViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'price'); + expect(priceViolation).toBeDefined(); + expect(priceViolation?.message?.withPlaceholders).toContain('Price must be at least'); + expect(priceViolation?.message?.placeholderValue?.['other']).toBe('0.01'); + }); + }); + + describe('Optional Fields', () => { + it('should `validate` even zero values in `proto3`', () => { + const withDefaults = create(OptionalMinMaxSchema, { + optionalCount: 0, // Violates min = 1 (proto3 treats 0 as set). + optionalRating: 0 // Within max = 5.0, so valid. + }); + + const violations = validate(OptionalMinMaxSchema, withDefaults); + expect(violations.length).toBeGreaterThan(0); + + const countViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'optional_count'); + expect(countViolation).toBeDefined(); + }); + + it('should `validate` when optional fields have non-default values', () => { + const invalid = create(OptionalMinMaxSchema, { + optionalCount: 2, // Valid. + optionalRating: 5.5 // Violates max = 5.0. + }); + + const violations = validate(OptionalMinMaxSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const ratingViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'optional_rating'); + expect(ratingViolation).toBeDefined(); + }); + }); +}); + diff --git a/packages/spine-validation-ts/tests/pattern.test.ts b/packages/spine-validation-ts/tests/pattern.test.ts new file mode 100644 index 0000000..2c2d98a --- /dev/null +++ b/packages/spine-validation-ts/tests/pattern.test.ts @@ -0,0 +1,178 @@ +/** + * Unit tests for `(pattern)` validation option. + * + * Tests regex pattern validation for string fields. + */ + +import { create } from '@bufbuild/protobuf'; +import { validate } from '../src'; + +import { + PatternValidationSchema, + RepeatedPatternValidationSchema, + CaseInsensitivePatternSchema, + OptionalPatternSchema +} from './generated/test-pattern_pb'; + +describe('Pattern Field Validation', () => { + describe('Single Pattern Fields', () => { + it('should validate alpha-only field', () => { + const valid = create(PatternValidationSchema, { + alphaField: 'HelloWorld', + alphanumericField: 'Test123', + email: 'test@example.com', + phone: '555-123-4567', + website: 'https://example.com', + colorHex: '#FF5733', + username: 'user_name-123' + }); + + const violations = validate(PatternValidationSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should detect invalid alpha-only field (contains numbers)', () => { + const invalid = create(PatternValidationSchema, { + alphaField: 'Hello123', // Invalid: contains numbers. + alphanumericField: 'Test', + email: 'test@example.com', + phone: '555-123-4567', + website: 'https://example.com', + colorHex: '#FF5733', + username: 'username' + }); + + const violations = validate(PatternValidationSchema, invalid); + const alphaViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'alpha_field'); + expect(alphaViolation).toBeDefined(); + expect(alphaViolation?.message?.withPlaceholders).toContain('must contain only letters'); + }); + + it('should detect invalid email `pattern`', () => { + const invalid = create(PatternValidationSchema, { + alphaField: 'Test', + alphanumericField: 'Test', + email: 'notanemail', // Invalid email. + phone: '555-123-4567', + website: 'https://example.com', + colorHex: '#FF5733', + username: 'username' + }); + + const violations = validate(PatternValidationSchema, invalid); + const emailViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'email'); + expect(emailViolation).toBeDefined(); + expect(emailViolation?.message?.withPlaceholders).toContain('Invalid email format'); + }); + + it('should detect invalid phone `pattern`', () => { + const invalid = create(PatternValidationSchema, { + alphaField: 'Test', + alphanumericField: 'Test', + email: 'test@example.com', + phone: '1234567890', // Invalid: missing dashes. + website: 'https://example.com', + colorHex: '#FF5733', + username: 'username' + }); + + const violations = validate(PatternValidationSchema, invalid); + const phoneViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'phone'); + expect(phoneViolation).toBeDefined(); + expect(phoneViolation?.message?.withPlaceholders).toContain('XXX-XXX-XXXX'); + }); + + it('should detect invalid hex color', () => { + const invalid = create(PatternValidationSchema, { + alphaField: 'Test', + alphanumericField: 'Test', + email: 'test@example.com', + phone: '555-123-4567', + website: 'https://example.com', + colorHex: 'FF5733', // Invalid: missing #. + username: 'username' + }); + + const violations = validate(PatternValidationSchema, invalid); + const colorViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'color_hex'); + expect(colorViolation).toBeDefined(); + expect(colorViolation?.message?.withPlaceholders).toContain('hex code'); + }); + + it('should detect invalid username (too short)', () => { + const invalid = create(PatternValidationSchema, { + alphaField: 'Test', + alphanumericField: 'Test', + email: 'test@example.com', + phone: '555-123-4567', + website: 'https://example.com', + colorHex: '#FF5733', + username: 'ab' // Invalid: too short (needs 3-20). + }); + + const violations = validate(PatternValidationSchema, invalid); + const usernameViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'username'); + expect(usernameViolation).toBeDefined(); + expect(usernameViolation?.message?.withPlaceholders).toContain('3-20 characters'); + }); + }); + + describe('Repeated Pattern Fields', () => { + it('should validate repeated fields with all valid values', () => { + const valid = create(RepeatedPatternValidationSchema, { + emails: ['user1@example.com', 'user2@test.org'], + tags: ['tag1', 'tag2', 'tag3'] + }); + + const violations = validate(RepeatedPatternValidationSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should detect invalid email in repeated field', () => { + const invalid = create(RepeatedPatternValidationSchema, { + emails: ['valid@example.com', 'invalid-email', 'another@test.org'], + tags: ['tag1'] + }); + + const violations = validate(RepeatedPatternValidationSchema, invalid); + const emailViolation = violations.find(v => v.fieldPath?.fieldName[0]?.startsWith('emails')); + expect(emailViolation).toBeDefined(); + }); + + it('should detect invalid tag in repeated field', () => { + const invalid = create(RepeatedPatternValidationSchema, { + emails: ['valid@example.com'], + tags: ['validtag', 'invalid-tag!', 'another'] // Middle tag has special char. + }); + + const violations = validate(RepeatedPatternValidationSchema, invalid); + const tagViolation = violations.find(v => v.fieldPath?.fieldName[0]?.startsWith('tags')); + expect(tagViolation).toBeDefined(); + }); + }); + + describe('Optional Pattern Fields', () => { + it('should not `validate` `pattern` on empty optional fields', () => { + const valid = create(OptionalPatternSchema, { + optionalEmail: '', + optionalPhone: '' + }); + + const violations = validate(OptionalPatternSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should validate pattern when optional field has value', () => { + const invalid = create(OptionalPatternSchema, { + optionalEmail: 'invalid', // Invalid email format. + optionalPhone: '' + }); + + const violations = validate(OptionalPatternSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + const emailViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'optional_email'); + expect(emailViolation).toBeDefined(); + }); + }); +}); + diff --git a/packages/spine-validation-ts/tests/proto/integration-account.proto b/packages/spine-validation-ts/tests/proto/integration-account.proto new file mode 100644 index 0000000..d786d44 --- /dev/null +++ b/packages/spine-validation-ts/tests/proto/integration-account.proto @@ -0,0 +1,55 @@ +syntax = "proto3"; + +package spine.validation.testing.integration; + +// Integration test messages demonstrating multiple validation constraints. +// +// This file showcases complex validation scenarios combining required_field, +// required, pattern, min/max, and range constraints in an account management +// context. + +import "spine/options.proto"; + +// Account message combining multiple validation constraints. +message Account { + option (required_field) = "id | email"; + + int32 id = 1 [(min).value = "1"]; + // Must be a valid email address (basic format validation). + string email = 2 [ + (required) = true, + (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + (pattern).error_msg = "Invalid email format: `{value}`." + ]; + // Must be 3-20 characters containing only letters, numbers, underscores, or hyphens. + string username = 3 [ + (required) = true, + (pattern).regex = "^[A-Za-z0-9_-]{3,20}$", + (pattern).error_msg = "Username must be 3-20 characters (letters, numbers, _, -). Got: `{value}`." + ]; + // Must be at least 8 characters long. + string password = 4 [ + (required) = true, + (pattern).regex = "^.{8,}$", + (pattern).error_msg = "Password must be at least 8 characters. Got length: {value}." + ]; + AccountType account_type = 5 [(required) = true]; + int32 age = 6 [ + (required) = true, + (range) = "[13..120]" + ]; + double balance = 7 [ + (min).value = "0.0", + (max).value = "1000000.0" + ]; + int32 failed_login_attempts = 8 [(range) = "[0..5]"]; + double rating = 9 [(range) = "[1.0..5.0]"]; +} + +// Account type enumeration. +enum AccountType { + ACCOUNT_TYPE_UNSPECIFIED = 0; + ACCOUNT_TYPE_FREE = 1; + ACCOUNT_TYPE_PREMIUM = 2; + ACCOUNT_TYPE_ENTERPRISE = 3; +} diff --git a/packages/spine-validation-ts/tests/proto/integration-product.proto b/packages/spine-validation-ts/tests/proto/integration-product.proto new file mode 100644 index 0000000..daca3cc --- /dev/null +++ b/packages/spine-validation-ts/tests/proto/integration-product.proto @@ -0,0 +1,124 @@ +syntax = "proto3"; + +package spine.validation.testing.integration; + +// Integration test messages demonstrating validation in a product management system. +// +// This file showcases complex validation scenarios including nested message +// validation, field dependencies with `(goes)`, pattern validation, and +// range constraints across multiple message types. + +import "google/protobuf/timestamp.proto"; +import "spine/options.proto"; + +// Product message representing a product entity with validation constraints. +message Product { + // Must follow format 'prod-XXX' (e.g., prod-123). + string id = 1 [ + (required) = true, + (set_once) = true, + (pattern).regex = "^prod-[0-9]+$", + (pattern).error_msg = "Product ID must follow format 'prod-XXX'. Provided: `{value}`." + ]; + string name = 2 [ + (required) = true, + (if_missing).error_msg = "Product name is required." + ]; + string description = 3; + double price = 4 [ + (required) = true, + (min).value = "0.01", + (min).error_msg = "Price must be at least {other}. Provided: {value}." + ]; + int32 stock = 5 [ + (min).value = "0", + (range) = "[0..1000000)" + ]; + google.protobuf.Timestamp created_at = 6 [(required) = true]; + Category category = 7 [ + (required) = true, + (validate) = true, + (if_invalid).error_msg = "Category is invalid." + ]; + Color text_color = 8 [(goes).with = "highlight_color"]; + Color highlight_color = 9 [(goes).with = "text_color"]; +} + +// Color message for display settings. +message Color { + int32 red = 1 [(range) = "[0..255]"]; + int32 green = 2 [(range) = "[0..255]"]; + int32 blue = 3 [(range) = "[0..255]"]; +} + +// Category message with validation. +message Category { + int32 id = 1 [ + (required) = true, + (min).value = "1" + ]; + string name = 2 [(required) = true]; +} + +// Payment method demonstrating is_required oneof option. +message PaymentMethod { + oneof method { + option (is_required) = true; + + PaymentCardNumber payment_card = 1 [(validate) = true]; + BankAccount bank_account = 2 [(validate) = true]; + } +} + +// Payment card number with validation. +message PaymentCardNumber { + // Must be 13-19 digits. + string number = 1 [ + (required) = true, + (pattern).regex = "^[0-9]{13,19}$", + (pattern).error_msg = "Card number must be 13-19 digits." + ]; + int32 expiry_month = 2 [ + (required) = true, + (range) = "[1..12]" + ]; + int32 expiry_year = 3 [ + (required) = true, + (min).value = "2024" + ]; +} + +// Bank account with validation. +message BankAccount { + // Must be 8-17 digits. + string account_number = 1 [ + (required) = true, + (pattern).regex = "^[0-9]{8,17}$" + ]; + // Must be exactly 9 digits (US routing number format). + string routing_number = 2 [ + (required) = true, + (pattern).regex = "^[0-9]{9}$" + ]; +} + +// Request message for listing products with pagination. +message ListProductsRequest { + int32 page = 1 [ + (required) = true, + (min).value = "1", + (if_missing).error_msg = "Page number is required." + ]; + int32 page_size = 2 [ + (required) = true, + (range) = "[1..100]", + (if_missing).error_msg = "Page size is required." + ]; + string search_query = 3; +} + +// Response message for listing products. +message ListProductsResponse { + repeated Product products = 1 [(validate) = true]; + int32 total_count = 2 [(min).value = "0"]; +} diff --git a/packages/spine-validation-ts/tests/proto/integration-user.proto b/packages/spine-validation-ts/tests/proto/integration-user.proto new file mode 100644 index 0000000..9948cd8 --- /dev/null +++ b/packages/spine-validation-ts/tests/proto/integration-user.proto @@ -0,0 +1,58 @@ +syntax = "proto3"; + +package spine.validation.testing.integration; + +// Integration test messages demonstrating validation in a complete user management API. +// +// This file showcases how multiple validation options work together in real-world +// scenarios including required fields, pattern validation, distinct values, and +// nested message validation. + +import "spine/options.proto"; + +// User message representing a user entity with validation constraints. +message User { + option (required_field) = "id | email"; + + int32 id = 1 [(set_once) = true, (min).value = "1"]; + // Must start with a letter and be 2-50 characters (letters, numbers, spaces allowed). + string name = 2 [ + (required) = true, + (pattern).regex = "^[A-Za-z][A-Za-z0-9 ]{1,49}$", + (pattern).error_msg = "Name must start with a letter and be 2-50 characters. Provided: `{value}`." + ]; + // Must be a valid email address (basic format validation). + string email = 3 [ + (required) = true, + (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + (pattern).error_msg = "Email must be valid. Provided: `{value}`." + ]; + Role role = 4 [(required) = true]; + repeated string tags = 5 [(distinct) = true]; +} + +// Role enumeration. +enum Role { + ROLE_UNSPECIFIED = 0; + ROLE_USER = 1; + ROLE_ADMIN = 2; + ROLE_MODERATOR = 3; +} + +// Request message for getting a user. +message GetUserRequest { + int32 user_id = 1 [ + (required) = true, + (min).value = "1", + (if_missing).error_msg = "User ID is required." + ]; +} + +// Response message for getting a user. +message GetUserResponse { + User user = 1 [ + (validate) = true, + (if_invalid).error_msg = "User data is invalid." + ]; + bool found = 2; +} diff --git a/packages/spine-validation-ts/tests/proto/spine/options.proto b/packages/spine-validation-ts/tests/proto/spine/options.proto new file mode 100644 index 0000000..2e05914 --- /dev/null +++ b/packages/spine-validation-ts/tests/proto/spine/options.proto @@ -0,0 +1,958 @@ +/* + * Copyright 2022, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +syntax = "proto3"; + +// API Note on Packaging +// --------------------- +// We do not define the package for this file to allow shorter options for user-defined types. +// This allows to write: +// +// option (internal) = true; +// +// instead of: +// +// option (spine.base.internal) = true; +// + +// Custom Type Prefix Option +// ------------------------- +// The custom `type_url_prefix` option allows to define specify custom type URL prefix for messages +// defined in a proto file. This option is declared in this file. Other proto files must import +// `options.proto` to be able to specify custom type URL prefix. +// +// It is recommended that the import statement is provided before the line with `type_url_prefix` +// option to make it obvious that custom option is defined in the imported file. +// +// For example: +// +// syntax = "proto3"; +// +// package my.package; +// +// import "spine/options.proto"; +// +// option (type_url_prefix) = "type.example.org"; +// + +option (type_url_prefix) = "type.spine.io"; +option java_multiple_files = true; +option java_outer_classname = "OptionsProto"; +option java_package = "io.spine.option"; + +import "google/protobuf/descriptor.proto"; + +// +// Reserved Range of Option Field Numbers +// -------------------------------------- +// Spine Options use the range of option field numbers from the internal range reserved for +// individual organizations. For details of custom Protobuf options and this range please see: +// +// https://developers.google.com/protocol-buffers/docs/proto#customoptions +// +// The whole range reserved for individual organizations is 50000-99999. +// The range used by Spine Options is 73812-75000. +// In order to prevent number collision with custom options used by a project based on Spine, +// numbers for custom options defined in this project should be in the range 50000-73811 or +// 75001-99999. +// + +extend google.protobuf.FieldOptions { + + // Field Validation Constraints + //----------------------------- + // For constraints defined via message-based types, please see documentation of corresponding + // message types. + // + + // The option to mark a field as required. + // + // If the field type is a `message`, it must be set to a non-default instance. + // If it is `string` or `bytes`, the value must not be an empty string or an array. + // Other field types are not applicable. + // If the field is repeated, it must have at least one element. + // + // Unlike the `required` keyword used in Protobuf 2, the option does not affect the transfer + // layer. Even if a message content violates the requirement set by the option, it would still + // be a valid message for the Protobuf library. + // + // Example: Using `(required)` field validation constraint. + // + // message MyOuterMessage { + // MyMessage field = 1 [(required) = true]; + // } + // + bool required = 73812; + + // See `IfMissingOption` + IfMissingOption if_missing = 73813; + + // Reserved 73814 and 73815 for deleted options `decimal_max` and `decimal_min`. + + // A higher boundary to the range of values of a number. + MaxOption max = 73816; + + // A lower boundary to the range of values of a number. + MinOption min = 73817; + + // 73818 reserved for the (digits) option. + + // 73819 reserved for the (when) option. + + // See `PatternOption`. + PatternOption pattern = 73820; + + // Turns on validation constraint checking for a value of a message, a map, or a repeated field. + // + // Default value is `false`. + // + // If set to `true`, the outer message declaring the annotated field would be valid if: + // + // 1. A message field value satisfies the validation constraints defined in the corresponding + // message type of the field. + // + // 2. Each value of a map entry satisfies validation constraints. + // + // 3. Each item of a repeated field satisfies validation constraints. + // + bool validate = 73821; + + // See `IfInvalidOption`. + IfInvalidOption if_invalid = 73822; + + // See `GoesOption`. + GoesOption goes = 73823; + + // Indicates that a field can only be set once. + // + // A typical use-case would include a value of an ID, which doesn't change over the course of + // the life of an entity. + // + // Example: Using `(set_once)` field validation constraint. + // + // message User { + // UserId id = 1 [(set_once) = true]; + // } + // + // Once set, the `id` field cannot be changed. + // + bool set_once = 73824; + + // The option to mark a `repeated` field as a collection of unique elements. + // + // Example: Using `(distinct)` constraint for a repeated field. + // + // message Blizzard { + // + // // All snowflakes must be unique in this blizzard. + // // + // // Attempting to add a snowflake that is equal to an existing one would result + // // in constraint violation error. + // // + // repeated Snowflake = 1 [(distinct) = true]; + // } + // + bool distinct = 73825; + + // The option to indicate that a numeric field is required to have a value which belongs + // to the specified bounded range. For unbounded ranges, please use `(min)` and `(max) options. + // + // The range can be open (not including the endpoint) or closed (including the endpoint) on + // each side. Open endpoints are indicated using a parenthesis (`(`, `)`). Closed endpoints are + // indicated using a square bracket (`[`, `]`). + // + // Example: Defining ranges of numeric values. + // + // message NumRanges { + // int32 hour = 1 [(range) = "[0..24)"]; + // uint32 minute = 2 [(range) = "[0..59]"]; + // float degree = 3 [(range) = "[0.0..360.0)"]; + // double angle = 4 [(range) = "(0.0..180.0)"]; + // } + // + // NOTE: That definition of ranges must be consistent with the type they constrain. + // An range for an integer field must be defined with integer endpoints. + // A range for a floating point field must be defined with decimal separator (`.`), + // even if the endpoint value does not have a fractional part. + // + string range = 73826; + + // Reserved 73827 to 73849 for future validation options. + + // API Annotations + //----------------- + + // Indicates a field which is internal to Spine, not part of the public API, and should not be + // used by users of the framework. + // + // If you plan to implement an extension of the framework, which is going to be + // wired into the framework, you may use the internal parts. Please consult with the Spine + // team, as the internal APIs do not have the same stability API guarantee as public ones. + // + bool internal = 73850; + + // Reserved 73851 for the deleted SPI option. + + // Indicates a field that can change at any time, and has no guarantee of API stability and + // backward-compatibility. + // + // Usage guidelines: + // 1. This annotation is used only on public API. Internal interfaces should not use it. + // 2. This annotation can only be added to new API. Adding it to an existing API is considered + // API-breaking. + // 3. Removing this annotation from an API gives it stable status. + // + bool experimental = 73852; + + // Signifies that a public API is subject to incompatible changes, or even removal, in a future + // release. + // + // An API bearing this annotation is exempt from any compatibility guarantees made by its + // containing library. Note that the presence of this annotation implies nothing about the + // quality of the API in question, only the fact that it is not "API-frozen." + // It is generally safe for applications to depend on beta APIs, at the cost of some extra work + // during upgrades. + // + bool beta = 73853; + + // Marks an entity state field as column. + // + // The column fields are stored separately from the entity record and can be specified as + // filtering criteria during entity querying. + // + // The column field should be declared as follows: + // + // message UserProfile { + // ... + // int32 year_of_registration = 8 [(column) = true]; + // } + // + // The `year_of_registration` field value can then be used as query parameter when reading + // entities of `UserProfile` type from the server side. + // + // The value of a column field can be updated in two ways: + // + // 1. In the receptors of the entity, just like any other part of entity state. + // 2. Using the language-specific tools like `EntityWithColumns` interface in Java. + // + // All column fields are considered optional by the framework. + // + // Currently, only entities of projection and process manager type are eligible for having + // columns (see `EntityOption`). For all other message types the column declarations are + // ignored. + // + // The `repeated` and `map` fields cannot be columns. + // + bool column = 73854; + + // Reserved 73855 to 73890 for future options. + + // Reserved 73900 for removed `by` option. +} + +extend google.protobuf.OneofOptions { + + // Marks a `oneof` group, in which one field *must* be set. + // + // Alternative to `(required_field)` with all the field in the group joined with the OR + // operator. + // + bool is_required = 73891; + + // Reserved 73892 to 73899 for future options. +} + +extend google.protobuf.MessageOptions { + + // Validation Constraints + //------------------------ + + // The default format string for validation error message text. + // + // This option extends message types that extend `FieldOptions` + // The number of parameters and their types are determined by the type of field options. + // + // Usage of this value is deprecated. Along with the old `msg_format`s, it exists to support + // the old validation library. The new version of the validation library, which does not lie in + // the `base` repository, constructs the default error messages separately when creating + // language-agnostic validation rules. + // + string default_message = 73901 [deprecated = true]; + + // The constraint to require at least one of the fields or a combination of fields. + // + // Unlike the `required` field constraint which always require corresponding field, + // this message option allows to require alternative fields or a combination of them as + // an alternative. Field names and `oneof` group names are acceptable. + // + // Field names are separated using the pipe (`|`) symbol. The combination of fields is defined + // using the ampersand (`&`) symbol. + // + // Example: Pipe syntax for defining alternative required fields. + // + // message PersonName { + // option (required_field) = "given_name|honorific_prefix & family_name"; + // + // string honorific_prefix = 1; + // string given_name = 2; + // string middle_name = 3; + // string family_name = 4; + // string honorific_suffix = 5; + // } + // + string required_field = 73902; + + // See `EntityOption`. + EntityOption entity = 73903; + + // An external validation constraint for a field. + // + // Allows to re-define validation constraints for a message when its usage as a field of + // another type requires alternative constraints. This includes definition of constraints for + // a message which does not have them defined within the type. + // + // A target field of an external constraint should be specified using a fully-qualified + // field name (e.g. `mypackage.MessageName.field_name`). + // + // Example: Defining external validation constraint. + // + // package io.spine.example; + // + // // Defines a change in a string value. + // // + // // Both of the fields of this message are not `required` to be able to describe + // // a change from empty value to non-empty value, or from a non-empty value to + // // an empty string. + // // + // message StringChange { + // + // // The value of the field that's changing. + // string previous_value = 1; + // + // // The new value of the field. + // string new_value = 2; + // } + // + // // A command to change a name of a task. + // // + // // The task has a non-empty name. A new name cannot be empty. + // // + // message RenameTask { + // + // // The ID of the task to rename. + // string task_id = 1; + // + // // Instruction for changing the name. + // // + // // The value of `change.previous_value` is the current name of the task. + // // It cannot be empty. + // // + // // The value of `change.new_value` is the new name of the task. + // // It cannot be empty either. + // // + // StringChange change = 1 [(validate) = true]; + // } + // + // // External validation constraint for both fields of the `StringChange` message + // // in the scope of the `RenameTask` command. + // // + // message RequireTaskNames { + // option (constraint_for) = "spine.example.RenameTask.change"; + // + // string previous_value = 1 [(required) = true]; + // string new_value = 2 [(required) = true]; + // } + // + // NOTE: A target field for an external validation constraint must be have the option `(validate)` + // set to `true`. See the definition of the `RenameTask.change` field in the example + // above. If there is no such option defined, or it is set to `false`, the external + // constraint will not be applied. + // + // External validation constraints can be applied to fields of several types. + // To do so, separate fully-qualified references to these fields with comma. + // + // Example: External validation constraints for multiple fields. + // + // // External validation constraint for requiring a new value in renaming commands. + // message RequireNewName { + // option (constraint_for) = "spine.example.RenameTask.change," + // "spine.example.RenameProject.change,"; + // "spine.example.UpdateComment.text_change; + // + // string new_value = 1 [(required) = true]; + // } + // + // NOTE: An external validation constraint for a field must be defined only once. + // Spine Model Compiler does not check such an "overwriting". + // See the issue: https://github.com/SpineEventEngine/base/issues/318. + // + string constraint_for = 73904; + + // Reserved 73905 to 73910 for future validation options. + + // API Annotations + //----------------- + + // Indicates a type usage of which is restricted in one of the following ways. + // + // 1. This type is internal to the Spine Event Engine framework. It is not a part of + // the public API, and must not be used by framework users. + // + // 2. The type is internal to a bounded context, artifact of which exposes the type to + // the outside world (presumably for historical reasons). + // + // The type with such an option can be used only inside the bounded context which declares it. + // + // The type must not be used neither for inbound (i.e. being sent to the bounded context + // which declares this type) nor for outbound communication (i.e. being sent by this + // bounded context outside). + // + // An attempt to violate these usage restrictions will result in a runtime error. + // + bool internal_type = 73911; + + // Indicates a file which contains elements of Service Provider Interface (SPI). + bool SPI_type = 73912; + + // Indicates a public API that can change at any time, and has no guarantee of + // API stability and backward-compatibility. + bool experimental_type = 73913; + + // Signifies that a public API is subject to incompatible changes, or even removal, + // in a future release. + bool beta_type = 73914; + + // Specifies a characteristic inherent in the the given message type. + // + // Example: Using `(is)` message option. + // + // message CreateProject { + // option (is).java_type = "ProjectCommand"; + // + // // Remainder omitted. + // } + // + // In the example above, `CreateProject` message is a `ProjectCommand`. + // + // To specify a characteristic for every message in a `.proto` file, use `(every_is)` file + // option. If both `(is)` and `(every_is)` options are found, both are applied. + // + // When targeting Java, specify the name of a Java interface to be implemented by this + // message via `(is).java_type`. + // + IsOption is = 73915; + + // Reserved 73916 to 73921 for future API annotation options. + + // Reserved 73922 for removed `enrichment_for` option. + + // Specifies the natural ordering strategy for this type. + // + // Code generators should generate language-specific comparisons based on the field paths. + // + // Runtime comparators may use the reflection API to compare field values. + // + CompareByOption compare_by = 73923; + + // Reserved 73924 to 73938 for future options. + + // Reserved 73939 and 73940 for the deleted options `events` and `rejections`. +} + +extend google.protobuf.FileOptions { + + // Specifies a type URL prefix for all types within a file. + // + // This type URL will be used when packing messages into `Any`. + // See `any.proto` for more details. + // + string type_url_prefix = 73941; + + // Indicates a file which contains types usage of which is restricted. + // + // For more information on such restrictions please see the documentation of + // the type option called `internal_type`. + // + bool internal_all = 73942; + + // Indicates a file which contains elements of Service Provider Interface (SPI). + bool SPI_all = 73943; + + // Indicates a public API that can change at any time, and has no guarantee of + // API stability and backward-compatibility. + bool experimental_all = 73944; + + // Signifies that a public API is subject to incompatible changes, or even removal, + // in a future release. + bool beta_all = 73945; + + // Specifies a characteristic common for all the message types in the given file. + // + // Example: Marking all the messages using the `(every_is)` file option. + // ``` + // option (every_is).java_type = "ProjectCommand"; + // + // message CreateProject { + // // ... + // + // message WithAssignee { + // // ... + // } + // } + // + // message DeleteProject { /*...*/ } + // ``` + // + // In the example above, `CreateProject`, `CreateProject.WithAssignee`, and `DeleteProject` + // messages are `ProjectCommand`-s. + // + // To specify a characteristic for a single message, use `(is)` message option. If both `(is)` + // and `(every_is)` options are found, both are applied. + // + // When targeting Java, specify the name of a Java interface to be implemented by these + // message types via `(every_is).java_type`. + // + IsOption every_is = 73946; + + // Reserved 73947 to 73970 for future use. +} + +extend google.protobuf.ServiceOptions { + + // Indicates that the service is a part of Service Provider Interface (SPI). + bool SPI_service = 73971; + + // Reserved 73971 to 73980. +} + +// Reserved 73981 to 74000 for other future Spine Options numbers. + +// +// Validation Option Types +//--------------------------- + +// Defines the error handling for `required` field with no value set. +// +// Applies only to the fields marked as `required`. +// Validation error message is composed according to the rules defined by this option. +// +// Example: Using the `(if_missing)` option. +// +// message Holder { +// MyMessage field = 1 [(required) = true, +// (if_missing).error_msg = "This field is required."]; +// } +// +message IfMissingOption { + + // The default error message. + option (default_message) = "A value must be set."; + + // A user-defined validation error format message. + // + // Use `error_msg` instead. + // + string msg_format = 1 [deprecated = true]; + + // A user-defined error message. + string error_msg = 2; +} + +// The field value must be greater than or equal to the given minimum number. +// +// Is applicable only to numbers. +// Repeated fields are supported. +// +// Example: Defining lower boundary for a numeric field. +// +// message KelvinTemperature { +// double value = 1 [(min) = { +// value = "0.0" +// exclusive = true +// error_msg = "Temperature cannot reach {other}K, but provided {value}." +// }]; +// } +// +message MinOption { + + // The default error message format string. + // + // The format parameters are: + // 1) "or equal to " string (if the `exclusive` parameter is false) or an empty string; + // 2) the minimum number. + // + option (default_message) = "The number must be greater than %s%s."; + + // The string representation of the minimum field value. + string value = 1; + + // Specifies if the field should be strictly greater than the specified minimum. + // + // The default value is false, i.e. the bound is inclusive. + // + bool exclusive = 2; + + // A user-defined validation error format message. + string msg_format = 3 [deprecated = true]; + + // A user-defined validation error format message. + // + // May include tokens `{value}`β€”for the actual value of the field, and `{other}`β€”for + // the threshold value. The tokens will be replaced at runtime when the error is constructed. + // + string error_msg = 4; +} + +// The field value must be less than or equal to the given maximum number. +// +// Is applicable only to numbers. +// Repeated fields are supported. +// +// Example: Defining upper boundary for a numeric field. +// +// message Elevation { +// double value = 1 [(max).value = "8848.86"]; +// } +// +message MaxOption { + + // The default error message format string. + // + // The format parameters are: + // 1) "or equal to " string (if the `exclusive` parameter is false) or an empty string; + // 2) the maximum number. + // + option (default_message) = "The number must be less than %s%s."; + + // The string representation of the maximum field value. + string value = 1; + + // Specifies if the field should be strictly less than the specified maximum + // + // The default value is false, i.e. the bound is inclusive. + // + bool exclusive = 2; + + // A user-defined validation error format message. + string msg_format = 3 [deprecated = true]; + + // A user-defined validation error format message. + // + // May include tokens `{value}`β€”for the actual value of the field, and `{other}`β€”for + // the threshold value. The tokens will be replaced at runtime when the error is constructed. + // + string error_msg = 4; +} + +// A string field value must match the given regular expression. +// Is applicable only to strings. +// Repeated fields are supported. +// +// Example: Using the `(pattern)` option. +// +// message CreateAccount { +// string id = 1 [(pattern).regex = "^[A-Za-z0-9+]+$", +// (pattern).error_msg = "ID must be alphanumerical. Provided: `{value}`."]; +// } +// +message PatternOption { + + // The default error message format string. + // + // The format parameter is the regular expression to which the value must match. + // + option (default_message) = "The string must match the regular expression `%s`."; + + // The regular expression to match. + string regex = 1; + + reserved 2; + reserved "flag"; + + // Modifiers for this pattern. + Modifier modifier = 4; + + // A user-defined validation error format message. + string msg_format = 3 [deprecated = true]; + + // A user-defined validation error format message. + // + // May include tokens `{value}`β€”for the actual value of the field, and `{other}`β€”for + // the threshold value. The tokens will be replaced at runtime when the error is constructed. + // + string error_msg = 5; + + // Regular expression modifiers. + // + // These modifiers are specifically selected to be supported in many implementation platforms. + // + message Modifier { + + // Enables the dot (`.`) symbol to match all the characters. + // + // By default, the dot does not match line break characters. + // + // May also be known in some platforms as "single line" mode and be encoded with the `s` + // flag. + // + bool dot_all = 1; + + // Allows to ignore the case of the matched symbols. + // + // For example, this modifier is specified, string `ABC` would be a complete match for + // the regex `[a-z]+`. + // + // On some platforms may be represented by the `i` flag. + // + bool case_insensitive = 2; + + // Enables the `^` (caret) and `$` (dollar) signs to match a start and an end of a line + // instead of a start and an end of the whole expression. + // + // On some platforms may be represented by the `m` flag. + // + bool multiline = 3; + + // Enables matching the whole UTF-8 sequences, + // + // On some platforms may be represented by the `u` flag. + // + bool unicode = 4; + + // Allows the matched strings to contain a full match to the pattern and some other + // characters as well. + // + // By default, a string only matches a pattern if it is a full match, i.e. there are no + // unaccounted for leading and/or trailing characters. + // + // This modifier is usually not represented programming languages, as the control over + // weather to match an entire string or only its part is provided to the user by other + // language means. For example, in Java, this would be the difference between methods + // `matches()` and `find()` of the `java.util.regex.Matcher` class. + // + bool partial_match = 5; + } +} + +// Specifies the message to show if a validated field happens to be invalid. +// Is applicable only to messages. +// Repeated fields are supported. +// +// Example: Using the `(if_invalid)` option. +// +// message Holder { +// MyMessage field = 1 [(validate) = true, +// (if_invalid).error_msg = "The field is invalid."]; +// } +// +message IfInvalidOption { + + // The default error message for the field. + option (default_message) = "The message must have valid properties."; + + // A user-defined validation error format message. + string msg_format = 1 [deprecated = true]; + + // A user-defined validation error format message. + // + // May include the token `{value}` for the actual value of the field. The token will be replaced + // at runtime when the error is constructed. + // + string error_msg = 2; +} + +// Specifies that a message field can be present only if another field is present. +// +// Unlike the `required_field` that handles combination of required fields, this option is useful +// when it is needed to say that an optional field makes sense only when another optional field is +// present. +// +// Example: Requiring mutual presence of optional fields. +// +// message ScheduledItem { +// ... +// spine.time.LocalDate date = 4; +// spine.time.LocalTime time = 5 [(goes).with = "date"]; +// } +// +message GoesOption { + + // The default error message format string. + // + // The first parameter is the name of the field for which we specify the option. + // The second parameter is the name of the field set in the "with" value. + // + option (default_message) = "The field `%s` can only be set when the field `%s` is defined."; + + // A name of the field required for presence of the field for which we set the option. + string with = 1; + + // A user-defined validation error format message. + string msg_format = 2 [deprecated = true]; + + // A user-defined validation error format message. + // + // May include the token `{value}` for the actual value of the field. The token will be replaced + // at runtime when the error is constructed. + // + string error_msg = 3; +} + +// Defines options of a message representing a state of an entity. +message EntityOption { + + // A type of an entity for state of which the message is defined. + enum Kind { + option allow_alias = true; + + // Reserved for errors. + KIND_UNKNOWN = 0; + + // The message is an aggregate state. + AGGREGATE = 1; + + // The message is a state of a projection (same as "view"). + PROJECTION = 2; + + // The message is a state of a view (same as "projection"). + VIEW = 2; + + // The message is a state of a process manager. + PROCESS_MANAGER = 3; + + // The message is a state of an entity, which is not of the type + // defined by other members of this enumeration. + ENTITY = 4; + } + + // The type of the entity. + Kind kind = 1; + + // The level of visibility of the entity to queries. + enum Visibility { + + // Default visibility is different for different types of entities: + // - for projections, "FULL" is default; + // - for aggregates, process managers, and other entities, "NONE" is default. + // + DEFAULT = 0; + + // The entity is not visible to queries. + NONE = 1; + + // Client-side applications can subscribe to updates of entities of this type. + SUBSCRIBE = 2; + + // Client-side applications can query this type of entities. + QUERY = 3; + + // Client-side applications can subscribe and query this type of entity. + FULL = 4; + } + + // The visibility of the entity. + // + // If not defined, the value of this option is `DEFAULT`. + // + Visibility visibility = 2; +} + +// Defines a marker for a given type or a set of types. +// +// The option may be used in two modes: +// - with the marker code generation; +// - without the marker code generation. +// +// When used with the code generation, language-specific markers are generated by the Protobuf +// compiler. Otherwise, it is expected that the user creates such markers manually. +// +message IsOption { + + // Enables the generation of marker interfaces. + // + // The generation is disabled by default. + bool generate = 1; + + // The reference to a Java interface. + // + // May be an fully-qualified or a simple name. In the latter case, the interface should belong + // to the same Java package as the message class which implements this interface. + // + // The framework does not ensure the referenced type exists. + // If the generation is disabled, the Java type is used as-is. Otherwise, a corresponding Java + // interface is generated. + // + // A generated interface has no declared methods and extends `com.google.protobuf.Message`. + // + // The `.java` file is placed alongside with the code generated by the proto-to-java compiler. + // + // If fully-qualified name given, the package of the generated type matches the fully-qualified + // name. When a simple name is set in the option, the package of the interface matches the + // package of the message class. + // + // If both `(is)` and `(every_is)` options specify a Java interface, the message class + // implements both interfaces. + // + string java_type = 2; +} + +// Defines the way to compare two messages of the same type to one another. +// +// Comparisons can be used to sort values. +// +// See the `(compare_by)` option. +// +message CompareByOption { + + // Field paths used for comparisons. + // + // The allowed field types are: + // - any number type; + // - `bool` (false is less than true); + // - `string` (in the order of respective Unicode values); + // - enumerations (following the order of numbers associated with each constant); + // - messages marked with `(compare_by)`. + // + // Other types are not permitted. Neither are repeated and map fields. Such declarations can + // lead to build-time errors. + // + // To refer to nested fields, separate the field names with a dot (`.`). No fields in the path + // can be repeated or maps. + // + // When multiple field paths are specified, comparison is executed in the order of reference. + // For example, specifying ["seconds", "nanos"] makes the comparison mechanism prioritize + // the `seconds` field and refer to `nanos` only when `seconds` are equal. + // + // Note. When comparing message fields, a non-set message is always less than a set message. + // But if a message is set to a default value, the comparison falls back to + // the field-wise comparison, i.e. number values are treated as zeros, `bool` β€” as `false`, + // and so on. + // + repeated string field = 1; + + // If true, the default order is reversed. For example, numbers are ordered from the greater to + // the lower, enums β€” from the last number value to the 0th value, etc. + bool descending = 2; +} diff --git a/packages/spine-validation-ts/tests/proto/test-distinct.proto b/packages/spine-validation-ts/tests/proto/test-distinct.proto new file mode 100644 index 0000000..e725c47 --- /dev/null +++ b/packages/spine-validation-ts/tests/proto/test-distinct.proto @@ -0,0 +1,90 @@ +syntax = "proto3"; + +package spine.validation.testing.distinct_suite; + +// Test messages for the `(distinct)` validation option. +// +// This file contains test cases for uniqueness validation on repeated fields. +// The `(distinct)` constraint ensures all elements in a repeated field are unique. + +import "spine/options.proto"; + +// Tests distinct constraint on primitive types. +message DistinctPrimitives { + repeated int32 numbers = 1 [(distinct) = true]; + repeated string tags = 2 [(distinct) = true]; + repeated double scores = 3 [(distinct) = true]; + repeated bool flags = 4 [(distinct) = true]; +} + +// Tests distinct constraint on enum values. +message DistinctEnums { + repeated Status statuses = 1 [(distinct) = true]; +} + +enum Status { + STATUS_UNSPECIFIED = 0; + STATUS_ACTIVE = 1; + STATUS_INACTIVE = 2; + STATUS_PENDING = 3; +} + +// Tests repeated fields without distinct constraint. +message NonDistinctFields { + repeated int32 numbers = 1; + repeated string tags = 2; +} + +// Tests distinct combined with other constraints. +message CombinedConstraints { + repeated int32 product_ids = 1 [ + (distinct) = true, + (range) = "[1..999999]" + ]; + // Each email must be a valid email address (basic format validation). + repeated string emails = 2 [ + (distinct) = true, + (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + ]; + repeated int32 scores = 3 [ + (distinct) = true, + (min).value = "0", + (max).value = "100" + ]; +} + +// Tests distinct on optional repeated fields. +message OptionalDistinct { + repeated int32 optional_numbers = 1 [(distinct) = true]; + repeated string optional_tags = 2 [(distinct) = true]; +} + +// Tests distinct for user profile with unique tags. +message UserProfile { + string username = 1 [(required) = true]; + repeated string tags = 2 [(distinct) = true]; + repeated string skills = 3 [(distinct) = true]; +} + +// Tests distinct for shopping cart with unique items. +message ShoppingCart { + repeated int32 product_ids = 1 [(distinct) = true]; + repeated string coupon_codes = 2 [(distinct) = true]; +} + +// Tests distinct across different numeric types. +message DistinctNumericTypes { + repeated int32 int32_values = 1 [(distinct) = true]; + repeated int64 int64_values = 2 [(distinct) = true]; + repeated uint32 uint32_values = 3 [(distinct) = true]; + repeated uint64 uint64_values = 4 [(distinct) = true]; + repeated float float_values = 5 [(distinct) = true]; + repeated double double_values = 6 [(distinct) = true]; +} + +// Tests edge cases for distinct validation. +message DistinctEdgeCases { + repeated string empty_strings = 1 [(distinct) = true]; + repeated int32 zeros = 2 [(distinct) = true]; + repeated string case_sensitive = 3 [(distinct) = true]; +} diff --git a/packages/spine-validation-ts/tests/proto/test-goes.proto b/packages/spine-validation-ts/tests/proto/test-goes.proto new file mode 100644 index 0000000..bf6b19e --- /dev/null +++ b/packages/spine-validation-ts/tests/proto/test-goes.proto @@ -0,0 +1,121 @@ +syntax = "proto3"; + +package spine.validation.testing.goes_suite; + +// Test messages for the `(goes)` field dependency validation option. +// +// This file contains test cases for the `(goes)` constraint that enforces field +// dependencies. A field with `(goes).with = "other_field"` can only be set if +// the referenced field is also set. + +import "spine/options.proto"; + +// Tests basic goes constraint. +message ScheduledEvent { + string event_name = 1 [(required) = true]; + string date = 2; + string time = 3 [(goes).with = "date"]; +} + +// Tests custom error message via `(goes).error_msg`. +message ShippingDetails { + string address = 1; + string tracking_number = 2 [ + (goes).with = "address", + (goes).error_msg = "Tracking number requires a shipping address: {value}." + ]; +} + +// Tests mutual dependencies (bidirectional). +message ColorSettings { + string text_color = 1 [(goes).with = "highlight_color"]; + string highlight_color = 2 [(goes).with = "text_color"]; +} + +// Tests multiple independent goes constraints. +message PaymentInfo { + string cardholder_name = 1; + string card_number = 2 [(goes).with = "cardholder_name"]; + string cvv = 3 [(goes).with = "card_number"]; + int32 expiry_month = 4 [(goes).with = "card_number"]; +} + +// Tests goes constraint on different field types. +message ProfileSettings { + string username = 1; + int32 display_id = 2 [(goes).with = "username"]; + bool is_verified = 3 [(goes).with = "username"]; + double rating = 4 [(goes).with = "username"]; +} + +// Tests goes constraint on message field type. +message DocumentMetadata { + string title = 1; + Timestamp created_at = 2 [(goes).with = "title"]; +} + +message Timestamp { + int64 seconds = 1; + int32 nanos = 2; +} + +// Tests goes combined with pattern constraints. +message SecureAccount { + // Must be 3-20 characters containing only letters, numbers, and underscores. + string username = 1 [(required) = true, (pattern).regex = "^[a-zA-Z0-9_]{3,20}$"]; + // Must be at least 8 characters long. + string password = 2 [ + (required) = true, + (pattern).regex = "^.{8,}$" + ]; + // If provided, must be a valid email address (basic format validation). + string recovery_email = 3 [(pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"]; + string recovery_phone = 4 [(goes).with = "recovery_email"]; +} + +// Tests message without goes constraint. +message SimpleConfig { + string primary_option = 1; + string secondary_option = 2; +} + +// Tests goes constraint with enum field. +message FeatureFlags { + FeatureLevel level = 1; + string custom_config = 2 [(goes).with = "level"]; +} + +enum FeatureLevel { + FEATURE_LEVEL_UNSPECIFIED = 0; + FEATURE_LEVEL_BASIC = 1; + FEATURE_LEVEL_ADVANCED = 2; + FEATURE_LEVEL_PREMIUM = 3; +} + +// Tests chain dependencies. +message ReportGeneration { + string report_type = 1; + string output_format = 2 [(goes).with = "report_type"]; + string email_recipient = 3 [(goes).with = "report_type"]; + string schedule = 4 [(goes).with = "output_format"]; +} + +// Tests goes constraint on optional fields. +message OptionalSettings { + string base_url = 1; + int32 port = 2 [(goes).with = "base_url"]; + string path = 3 [(goes).with = "base_url"]; +} + +// Tests goes combined with min/max/range constraints. +message AdvancedConfig { + string config_name = 1; + int32 max_connections = 2 [ + (goes).with = "config_name", + (range) = "[1..1000]" + ]; + double timeout_seconds = 3 [ + (goes).with = "config_name", + (min).value = "0.1" + ]; +} diff --git a/packages/spine-validation-ts/tests/proto/test-min-max.proto b/packages/spine-validation-ts/tests/proto/test-min-max.proto new file mode 100644 index 0000000..f3b235d --- /dev/null +++ b/packages/spine-validation-ts/tests/proto/test-min-max.proto @@ -0,0 +1,99 @@ +syntax = "proto3"; + +package spine.validation.testing.minmax_suite; + +// Test messages for the `(min)` and `(max)` validation options. +// +// This file contains test cases for numeric range validation using minimum and +// maximum constraints. Supports inclusive/exclusive bounds, custom error messages, +// and validation across different numeric types and repeated fields. + +import "spine/options.proto"; + +// Tests basic min constraint (inclusive by default). +message MinValue { + int32 positive_id = 1 [(min).value = "1"]; + int32 non_negative = 2 [(min).value = "0"]; + double price = 3 [(min).value = "0.01"]; +} + +// Tests basic max constraint (inclusive by default). +message MaxValue { + int32 percentage = 1 [(max).value = "100"]; + double altitude = 2 [(max).value = "8848.86"]; + int64 year = 3 [(max).value = "2100"]; +} + +// Tests combined min and max constraints. +message MinMaxRange { + int32 age = 1 [(min).value = "0", (max).value = "150"]; + double temperature = 2 [(min).value = "-273.15", (max).value = "1000.0"]; + int32 percentage = 3 [(min).value = "0", (max).value = "100"]; +} + +// Tests exclusive bounds. +message ExclusiveBounds { + double positive_value = 1 [(min) = { + value: "0.0", + exclusive: true + }]; + double temperature_kelvin = 2 [(min) = { + value: "0.0", + exclusive: true, + error_msg: "Temperature cannot reach {other}K, but provided {value}." + }]; + int32 below_limit = 3 [(max) = { + value: "100", + exclusive: true + }]; +} + +// Tests custom error messages. +message CustomErrorMessages { + int32 age = 1 [(min) = { + value: "18", + error_msg: "Must be at least {other} years old. Provided: {value}." + }]; + double balance = 2 [(min) = { + value: "0.01", + error_msg: "Balance must be at least ${other}. Current: ${value}." + }, (max) = { + value: "1000000.0", + error_msg: "Balance cannot exceed ${other}. Current: ${value}." + }]; +} + +// Tests min/max validation across different numeric types. +message NumericTypes { + int32 int32_field = 1 [(min).value = "0", (max).value = "2147483647"]; + int64 int64_field = 2 [(min).value = "0"]; + uint32 uint32_field = 3 [(max).value = "4294967295"]; + uint64 uint64_field = 4 [(min).value = "1"]; + float float_field = 5 [(min).value = "0.0", (max).value = "100.0"]; + double double_field = 6 [(min).value = "-1000.0", (max).value = "1000.0"]; +} + +// Tests min/max validation on repeated fields. +message RepeatedMinMax { + repeated int32 scores = 1 [(min).value = "0", (max).value = "100"]; + repeated double prices = 2 [(min).value = "0.01"]; +} + +// Tests combined required and min/max constraints. +message CombinedConstraints { + int32 product_id = 1 [(required) = true, (min).value = "1"]; + double price = 2 [ + (required) = true, + (min) = { + value: "0.01", + error_msg: "Price must be at least {other}." + } + ]; + int32 stock = 3 [(min).value = "0"]; +} + +// Tests optional fields with min/max constraints. +message OptionalMinMax { + int32 optional_count = 1 [(min).value = "1"]; + double optional_rating = 2 [(max).value = "5.0"]; +} diff --git a/packages/spine-validation-ts/tests/proto/test-pattern.proto b/packages/spine-validation-ts/tests/proto/test-pattern.proto new file mode 100644 index 0000000..0b53bf7 --- /dev/null +++ b/packages/spine-validation-ts/tests/proto/test-pattern.proto @@ -0,0 +1,96 @@ +syntax = "proto3"; + +package spine.validation.testing.pattern_suite; + +// Test messages for the `(pattern)` validation option. +// +// This file contains test cases for regex pattern validation on string fields, +// including single fields, repeated fields, optional fields, and various pattern +// modifiers like case-insensitive matching. + +import "spine/options.proto"; + +// Tests basic pattern validation on various string field types. +message PatternValidation { + // Must contain only letters (A-Z, a-z). + string alpha_field = 1 [ + (pattern).regex = "^[A-Za-z]+$", + (pattern).error_msg = "Field must contain only letters. Got: `{value}`." + ]; + + // Must contain only letters and numbers. + string alphanumeric_field = 2 [ + (pattern).regex = "^[A-Za-z0-9]+$", + (pattern).error_msg = "Field must be alphanumeric. Got: `{value}`." + ]; + + // Must be a valid email address (basic format validation). + string email = 3 [ + (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + (pattern).error_msg = "Invalid email format: `{value}`." + ]; + + // Must be a US phone number in XXX-XXX-XXXX format. + string phone = 4 [ + (pattern).regex = "^\\d{3}-\\d{3}-\\d{4}$", + (pattern).error_msg = "Phone must be in format XXX-XXX-XXXX. Got: `{value}`." + ]; + + // Must be a valid HTTP or HTTPS URL. + string website = 5 [ + (pattern).regex = "^https?://[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}(/.*)?$", + (pattern).error_msg = "Invalid URL format: `{value}`." + ]; + + // Must be a hexadecimal color code in #RRGGBB format. + string color_hex = 6 [ + (pattern).regex = "^#[0-9A-Fa-f]{6}$", + (pattern).error_msg = "Color must be a valid hex code (#RRGGBB). Got: `{value}`." + ]; + + // Must be 3-20 characters containing only letters, numbers, underscores, or hyphens. + string username = 7 [ + (pattern).regex = "^[A-Za-z0-9_-]{3,20}$", + (pattern).error_msg = "Username must be 3-20 characters (letters, numbers, _, -). Got: `{value}`." + ]; +} + +// Tests pattern validation on repeated string fields. +message RepeatedPatternValidation { + // Each email must be a valid email address (basic format validation). + repeated string emails = 1 [ + (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + (pattern).error_msg = "Invalid email in list: `{value}`." + ]; + + // Each tag must contain only letters and numbers. + repeated string tags = 2 [ + (pattern).regex = "^[A-Za-z0-9]+$", + (pattern).error_msg = "Tag must be alphanumeric: `{value}`." + ]; +} + +// Tests pattern validation with case-insensitive modifier. +message CaseInsensitivePattern { + // Must be either "yes" or "no" (case-insensitive). + string yes_or_no = 1 [ + (pattern).regex = "^(yes|no)$", + (pattern).modifier.case_insensitive = true, + (pattern).error_msg = "Must be 'yes' or 'no' (case-insensitive). Got: `{value}`." + ]; +} + +// Tests pattern validation on optional fields (validated only when set). +message OptionalPattern { + // If provided, must be a valid email address (basic format validation). + string optional_email = 1 [ + (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + (pattern).error_msg = "If provided, email must be valid: `{value}`." + ]; + + // If provided, must be a US phone number in XXX-XXX-XXXX format. + string optional_phone = 2 [ + (pattern).regex = "^\\d{3}-\\d{3}-\\d{4}$", + (pattern).error_msg = "If provided, phone must be XXX-XXX-XXXX: `{value}`." + ]; +} diff --git a/packages/spine-validation-ts/tests/proto/test-range.proto b/packages/spine-validation-ts/tests/proto/test-range.proto new file mode 100644 index 0000000..29b4c59 --- /dev/null +++ b/packages/spine-validation-ts/tests/proto/test-range.proto @@ -0,0 +1,88 @@ +syntax = "proto3"; + +package spine.validation.testing.range_suite; + +// Test messages for the `(range)` validation option. +// +// This file contains test cases for bounded numeric range validation using +// bracket notation. Supports inclusive bounds `[min..max]`, exclusive bounds +// `(min..max)`, and half-open intervals `[min..max)` or `(min..max]`. + +import "spine/options.proto"; + +// Tests closed (inclusive) ranges. +message ClosedRange { + int32 percentage = 1 [(range) = "[0..100]"]; + int32 rgb_value = 2 [(range) = "[0..255]"]; + double temperature_c = 3 [(range) = "[-273.15..1000.0]"]; +} + +// Tests open (exclusive) ranges. +message OpenRange { + double positive_value = 1 [(range) = "(0.0..100.0)"]; + int32 exclusive_count = 2 [(range) = "(0..10)"]; +} + +// Tests half-open ranges. +message HalfOpenRange { + int32 hour = 1 [(range) = "[0..24)"]; + int32 minute = 2 [(range) = "[0..60)"]; + float degree = 3 [(range) = "[0.0..360.0)"]; + double angle = 4 [(range) = "(0.0..180.0]"]; +} + +// Tests range validation across different numeric types. +message NumericTypeRanges { + int32 int32_field = 1 [(range) = "[1..100]"]; + int64 int64_field = 2 [(range) = "[0..1000000]"]; + uint32 uint32_field = 3 [(range) = "[1..65535]"]; + uint64 uint64_field = 4 [(range) = "[1..4294967295]"]; + float float_field = 5 [(range) = "[0.0..1.0]"]; + double double_field = 6 [(range) = "[-1000.0..1000.0]"]; +} + +// Tests range validation on repeated fields. +message RepeatedRange { + repeated int32 scores = 1 [(range) = "[0..100]"]; + repeated double percentages = 2 [(range) = "[0.0..100.0]"]; +} + +// Tests combined required and range constraints. +message CombinedConstraints { + int32 product_id = 1 [(required) = true, (range) = "[1..999999]"]; + int32 quantity = 2 [(required) = true, (range) = "[1..1000]"]; + double discount = 3 [(range) = "[0.0..1.0]"]; +} + +// Tests range validation for payment card fields. +message PaymentCard { + int32 expiry_month = 1 [(required) = true, (range) = "[1..12]"]; + int32 expiry_year = 2 [(required) = true, (range) = "[2024..2050]"]; + int32 cvv = 3 [(required) = true, (range) = "[0..999]"]; +} + +// Tests range validation for RGB color values. +message RGBColor { + int32 red = 1 [(range) = "[0..255]"]; + int32 green = 2 [(range) = "[0..255]"]; + int32 blue = 3 [(range) = "[0..255]"]; + double alpha = 4 [(range) = "[0.0..1.0]"]; +} + +// Tests range validation for pagination parameters. +message PaginationRequest { + int32 page = 1 [(required) = true, (range) = "[1..10000]"]; + int32 page_size = 2 [(required) = true, (range) = "[1..100]"]; +} + +// Tests optional fields with range constraints. +message OptionalRange { + int32 optional_score = 1 [(range) = "[1..100]"]; + double optional_rating = 2 [(range) = "[1.0..5.0]"]; +} + +// Tests edge cases with single-value ranges. +message EdgeCaseRanges { + int32 exact_value = 1 [(range) = "[42..42]"]; + double pi_approx = 2 [(range) = "[3.14..3.15]"]; +} diff --git a/packages/spine-validation-ts/tests/proto/test-required-field.proto b/packages/spine-validation-ts/tests/proto/test-required-field.proto new file mode 100644 index 0000000..46713b4 --- /dev/null +++ b/packages/spine-validation-ts/tests/proto/test-required-field.proto @@ -0,0 +1,74 @@ +syntax = "proto3"; + +package spine.validation.testing.requiredfield_suite; + +// Test messages for the `(required_field)` message-level validation option. +// +// This file contains test cases for the `(required_field)` constraint that +// requires specific combinations of fields using boolean logic (OR, AND, and +// parentheses for grouping). The constraint is specified at the message level. + +import "spine/options.proto"; + +// Tests simple OR logic: at least one field must be set. +message UserIdentifier { + option (required_field) = "id | email"; + + int32 id = 1; + string email = 2; +} + +// Tests AND logic: both fields must be set together. +message ContactInfo { + option (required_field) = "phone & country_code"; + + string phone = 1; + string country_code = 2; +} + +// Tests complex OR with AND groups. +message PersonName { + option (required_field) = "given_name | (honorific_prefix & family_name)"; + + string honorific_prefix = 1; + string given_name = 2; + string middle_name = 3; + string family_name = 4; + string honorific_suffix = 5; +} + +// Tests multiple OR alternatives. +message PaymentMethod { + option (required_field) = "credit_card | bank_account | paypal_email"; + + string credit_card = 1; + string bank_account = 2; + string paypal_email = 3; +} + +// Tests multiple AND requirements. +message ShippingAddress { + option (required_field) = "street & city & postal_code & country"; + + string street = 1; + string city = 2; + string postal_code = 3; + string country = 4; + string state = 5; +} + +// Tests complex nested logic with grouping. +message AccountCreation { + option (required_field) = "(username & password) | oauth_token"; + + string username = 1; + string password = 2; + string oauth_token = 3; +} + +// Tests message without required_field constraint (all fields optional). +message OptionalData { + string field1 = 1; + string field2 = 2; + int32 field3 = 3; +} diff --git a/packages/spine-validation-ts/tests/proto/test-required.proto b/packages/spine-validation-ts/tests/proto/test-required.proto new file mode 100644 index 0000000..b74fed2 --- /dev/null +++ b/packages/spine-validation-ts/tests/proto/test-required.proto @@ -0,0 +1,52 @@ +syntax = "proto3"; + +package spine.validation.testing.required_suite; + +// Test messages for the `(required)` and `(if_missing)` validation options. +// +// This file contains test cases for required field validation across different +// field types (strings, numbers, messages, enums, repeated fields) and custom +// error messages via the `(if_missing)` option. + +import "spine/options.proto"; + +// Tests required field validation on various field types. +message RequiredFields { + string name = 1 [(required) = true]; + int32 age = 2 [(required) = true]; + Address address = 3 [(required) = true]; + Status status = 4 [(required) = true]; + repeated string tags = 5 [(required) = true]; +} + +// Nested message for testing required message fields. +message Address { + string street = 1; + string city = 2; +} + +// Enum for testing required enum fields. +enum Status { + STATUS_UNSPECIFIED = 0; + STATUS_ACTIVE = 1; + STATUS_INACTIVE = 2; +} + +// Tests custom error messages via the `(if_missing)` option. +message CustomErrorMessages { + string username = 1 [ + (required) = true, + (if_missing).error_msg = "Username is mandatory for account creation." + ]; + + string email = 2 [ + (required) = true, + (if_missing).error_msg = "Email address must be provided." + ]; +} + +// Tests optional fields without required constraints. +message OptionalFields { + string nickname = 1; + int32 score = 2; +} diff --git a/packages/spine-validation-ts/tests/proto/test-validate.proto b/packages/spine-validation-ts/tests/proto/test-validate.proto new file mode 100644 index 0000000..6339eae --- /dev/null +++ b/packages/spine-validation-ts/tests/proto/test-validate.proto @@ -0,0 +1,150 @@ +syntax = "proto3"; + +package spine.validation.testing.validate_suite; + +// Test messages for the `(validate)` and `(if_invalid)` validation options. +// +// This file contains test cases for recursive validation of nested message fields. +// The `(validate)` option enables validation of constraints in nested messages, +// and `(if_invalid)` provides custom error messages for validation failures. + +import "spine/options.proto"; + +// Tests basic nested message validation. +message PersonWithAddress { + string name = 1 [(required) = true]; + Address address = 2 [(validate) = true]; +} + +message Address { + string street = 1 [(required) = true]; + string city = 2 [(required) = true]; + // Must be a 5-digit US ZIP code. + string zip_code = 3 [ + (required) = true, + (pattern).regex = "^[0-9]{5}$" + ]; +} + +// Tests custom error messages via `(if_invalid)`. +message OrderWithCustomError { + int32 order_id = 1 [(required) = true]; + Customer customer = 2 [ + (validate) = true, + (if_invalid).error_msg = "Customer information is invalid: {value}." + ]; +} + +message Customer { + // Must be a valid email address (basic format validation). + string email = 1 [ + (required) = true, + (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + ]; + int32 age = 2 [(required) = true, (range) = "[18..120]"]; +} + +// Tests validation on repeated message fields. +message TeamWithMembers { + string team_name = 1 [(required) = true]; + repeated Member members = 2 [(validate) = true]; +} + +message Member { + string name = 1 [(required) = true]; + // Must be a valid email address (basic format validation). + string email = 2 [ + (required) = true, + (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + ]; +} + +// Tests deeply nested validation. +message CompanyStructure { + string company_name = 1 [(required) = true]; + Department department = 2 [(validate) = true]; +} + +message Department { + string dept_name = 1 [(required) = true]; + Manager manager = 2 [(validate) = true]; +} + +message Manager { + string name = 1 [(required) = true]; + // Must be a valid email address (basic format validation). + string email = 2 [ + (required) = true, + (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + ]; +} + +// Tests validation on optional nested fields. +message ProfileWithOptionalData { + string username = 1 [(required) = true]; + OptionalData optional_data = 2 [(validate) = true]; +} + +message OptionalData { + string bio = 1; + int32 followers = 2 [(min).value = "0"]; +} + +// Tests message without validate option. +message PersonWithoutValidation { + string name = 1 [(required) = true]; + Address address = 2; +} + +// Tests combining multiple validation types. +message ProductOrder { + int32 product_id = 1 [(required) = true, (min).value = "1"]; + ProductDetails product = 2 [ + (validate) = true, + (if_invalid).error_msg = "Product details are invalid." + ]; + repeated Review reviews = 3 [(validate) = true]; + ShippingInfo shipping = 4 [ + (required) = true, + (validate) = true + ]; +} + +message ProductDetails { + string name = 1 [(required) = true]; + double price = 2 [(required) = true, (min).value = "0.01"]; + repeated string tags = 3 [(distinct) = true]; +} + +message Review { + int32 rating = 1 [(required) = true, (range) = "[1..5]"]; + string comment = 2; +} + +message ShippingInfo { + Address address = 1 [(required) = true, (validate) = true]; + string method = 2 [(required) = true]; +} + +// Tests validation on message field without constraints. +message ContainerWithEmptyMessage { + string id = 1 [(required) = true]; + EmptyValidated empty = 2 [(validate) = true]; +} + +message EmptyValidated { + string note = 1; +} + +// Tests nested validation combined with distinct. +message ProjectWithTasks { + string project_name = 1 [(required) = true]; + repeated Task tasks = 2 [(validate) = true]; + repeated string tags = 3 [(distinct) = true]; +} + +message Task { + string title = 1 [(required) = true]; + int32 priority = 2 [(range) = "[1..5]"]; + repeated string assignees = 3 [(distinct) = true]; +} diff --git a/packages/spine-validation-ts/tests/range.test.ts b/packages/spine-validation-ts/tests/range.test.ts new file mode 100644 index 0000000..b8e57d5 --- /dev/null +++ b/packages/spine-validation-ts/tests/range.test.ts @@ -0,0 +1,417 @@ +/** + * Unit tests for `(range)` validation option. + * + * Tests numeric range validation using bracket notation. + */ + +import { create } from '@bufbuild/protobuf'; +import { validate } from '../src'; + +import { + ClosedRangeSchema, + OpenRangeSchema, + HalfOpenRangeSchema, + NumericTypeRangesSchema, + RepeatedRangeSchema, + CombinedConstraintsSchema as RangeCombinedConstraintsSchema, + PaymentCardSchema, + RGBColorSchema, + PaginationRequestSchema, + OptionalRangeSchema, + EdgeCaseRangesSchema +} from './generated/test-range_pb'; + +describe('Range Validation', () => { + describe('Closed (Inclusive) Ranges', () => { + it('should pass when value is within closed `range`', () => { + const valid = create(ClosedRangeSchema, { + percentage: 50, + rgbValue: 128, + temperatureC: 25.0 + }); + + const violations = validate(ClosedRangeSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should pass at boundary values `(inclusive)`', () => { + const valid = create(ClosedRangeSchema, { + percentage: 0, // Min boundary. + rgbValue: 255, // Max boundary. + temperatureC: -273.15 // Min boundary. + }); + + const violations = validate(ClosedRangeSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when value is below minimum', () => { + const invalid = create(ClosedRangeSchema, { + percentage: -1, // Violates [0..100]. + rgbValue: 128, + temperatureC: 25.0 + }); + + const violations = validate(ClosedRangeSchema, invalid); + const percentageViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'percentage'); + expect(percentageViolation).toBeDefined(); + expect(percentageViolation?.message?.withPlaceholders).toContain('[0..100]'); + }); + + it('should fail when value exceeds maximum', () => { + const invalid = create(ClosedRangeSchema, { + percentage: 50, + rgbValue: 256, // Violates [0..255]. + temperatureC: 25.0 + }); + + const violations = validate(ClosedRangeSchema, invalid); + const rgbViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'rgb_value'); + expect(rgbViolation).toBeDefined(); + expect(rgbViolation?.message?.withPlaceholders).toContain('[0..255]'); + }); + }); + + describe('Open (Exclusive) Ranges', () => { + it('should pass when value is within exclusive `range`', () => { + const valid = create(OpenRangeSchema, { + positiveValue: 50.0, + exclusiveCount: 5 + }); + + const violations = validate(OpenRangeSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail at boundary values `(exclusive)`', () => { + const invalidMin = create(OpenRangeSchema, { + positiveValue: 0.0, // Violates (0.0..100.0) - must be > 0. + exclusiveCount: 5 + }); + + const violationsMin = validate(OpenRangeSchema, invalidMin); + const minViolation = violationsMin.find(v => v.fieldPath?.fieldName[0] === 'positive_value'); + expect(minViolation).toBeDefined(); + + const invalidMax = create(OpenRangeSchema, { + positiveValue: 50.0, + exclusiveCount: 10 // Violates (0..10) - must be < 10. + }); + + const violationsMax = validate(OpenRangeSchema, invalidMax); + const maxViolation = violationsMax.find(v => v.fieldPath?.fieldName[0] === 'exclusive_count'); + expect(maxViolation).toBeDefined(); + }); + }); + + describe('Half-Open Ranges', () => { + it('should pass when value is within half-open `range`', () => { + const valid = create(HalfOpenRangeSchema, { + hour: 12, // [0..24). + minute: 30, // [0..60). + degree: 180.0, // [0.0..360.0). + angle: 90.0 // (0.0..180.0]. + }); + + const violations = validate(HalfOpenRangeSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should pass at inclusive boundary and fail at exclusive boundary', () => { + // Test [0..24) - 0 is valid, 24 is not. + const validHour = create(HalfOpenRangeSchema, { + hour: 0, // Min is inclusive. + minute: 0, + degree: 0.0, + angle: 90.0 + }); + + const violations1 = validate(HalfOpenRangeSchema, validHour); + expect(violations1).toHaveLength(0); + + const invalidHour = create(HalfOpenRangeSchema, { + hour: 24, // Violates [0..24) - max is exclusive. + minute: 0, + degree: 0.0, + angle: 90.0 + }); + + const violations2 = validate(HalfOpenRangeSchema, invalidHour); + const hourViolation = violations2.find(v => v.fieldPath?.fieldName[0] === 'hour'); + expect(hourViolation).toBeDefined(); + }); + + it('should handle (`min`..`max`] correctly', () => { + // Test (0.0..180.0] - 0 is not valid, 180 is valid. + const invalidAngle = create(HalfOpenRangeSchema, { + hour: 12, + minute: 30, + degree: 180.0, + angle: 0.0 // Violates (0.0..180.0] - min is exclusive. + }); + + const violations1 = validate(HalfOpenRangeSchema, invalidAngle); + const angleViolation = violations1.find(v => v.fieldPath?.fieldName[0] === 'angle'); + expect(angleViolation).toBeDefined(); + + const validAngle = create(HalfOpenRangeSchema, { + hour: 12, + minute: 30, + degree: 180.0, + angle: 180.0 // Max is inclusive. + }); + + const violations2 = validate(HalfOpenRangeSchema, validAngle); + expect(violations2).toHaveLength(0); + }); + }); + + describe('Different Numeric Types', () => { + it('should `validate` ranges for all numeric types', () => { + const valid = create(NumericTypeRangesSchema, { + int32Field: 50, + int64Field: 500000n, + uint32Field: 30000, + uint64Field: 1000000n, + floatField: 0.5, + doubleField: 250.0 + }); + + const violations = validate(NumericTypeRangesSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when any numeric type violates its `range`', () => { + const invalid = create(NumericTypeRangesSchema, { + int32Field: 101, // Violates [1..100]. + int64Field: 500000n, + uint32Field: 30000, + uint64Field: 1000000n, + floatField: 1.5, // Violates [0.0..1.0]. + doubleField: 250.0 + }); + + const violations = validate(NumericTypeRangesSchema, invalid); + expect(violations.length).toBeGreaterThanOrEqual(2); + + const int32Violation = violations.find(v => v.fieldPath?.fieldName[0] === 'int32_field'); + expect(int32Violation).toBeDefined(); + + const floatViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'float_field'); + expect(floatViolation).toBeDefined(); + }); + }); + + describe('Repeated Fields with Range', () => { + it('should pass when all repeated elements are within `range`', () => { + const valid = create(RepeatedRangeSchema, { + scores: [85, 92, 78, 100, 0], + percentages: [25.5, 50.0, 75.3, 100.0] + }); + + const violations = validate(RepeatedRangeSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when any repeated element violates `range`', () => { + const invalid = create(RepeatedRangeSchema, { + scores: [85, 92, 105, 78], // 105 violates [0..100]. + percentages: [25.5, 50.0] + }); + + const violations = validate(RepeatedRangeSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const scoreViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'scores' && v.fieldPath?.fieldName[1] === '2' + ); + expect(scoreViolation).toBeDefined(); + expect(scoreViolation?.message?.placeholderValue?.['value']).toBe('105'); + }); + + it('should report violations for multiple invalid elements', () => { + const invalid = create(RepeatedRangeSchema, { + scores: [85, 101, 92, 102], // 101 and 102 both violate [0..100]. + percentages: [25.5, 50.0] + }); + + const violations = validate(RepeatedRangeSchema, invalid); + const scoreViolations = violations.filter(v => v.fieldPath?.fieldName[0] === 'scores'); + expect(scoreViolations.length).toBe(2); + }); + }); + + describe('Combined Constraints (Required + Range)', () => { + it('should pass when all constraints are satisfied', () => { + const valid = create(RangeCombinedConstraintsSchema, { + productId: 12345, + quantity: 50, + discount: 0.15 + }); + + const violations = validate(RangeCombinedConstraintsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail `range` validation even when `required` is satisfied', () => { + const invalid = create(RangeCombinedConstraintsSchema, { + productId: 12345, + quantity: 1001, // Violates [1..1000]. + discount: 0.15 + }); + + const violations = validate(RangeCombinedConstraintsSchema, invalid); + const quantityViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'quantity'); + expect(quantityViolation).toBeDefined(); + expect(quantityViolation?.message?.withPlaceholders).toContain('[1..1000]'); + }); + + it('should detect both `required` and `range` violations', () => { + const invalid = create(RangeCombinedConstraintsSchema, { + productId: 0, // Violates both (required) and range [1..999999]. + quantity: 1001, // Violates range [1..1000]. + discount: 0.15 + }); + + const violations = validate(RangeCombinedConstraintsSchema, invalid); + expect(violations.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('Real-World Scenarios', () => { + it('should `validate` payment card expiry dates', () => { + const valid = create(PaymentCardSchema, { + expiryMonth: 12, + expiryYear: 2026, + cvv: 123 + }); + + const violations = validate(PaymentCardSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should reject invalid expiry month', () => { + const invalid = create(PaymentCardSchema, { + expiryMonth: 13, // Violates [1..12]. + expiryYear: 2026, + cvv: 123 + }); + + const violations = validate(PaymentCardSchema, invalid); + const monthViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'expiry_month'); + expect(monthViolation).toBeDefined(); + }); + + it('should `validate` RGB color values', () => { + const valid = create(RGBColorSchema, { + red: 255, + green: 128, + blue: 0, + alpha: 0.8 + }); + + const violations = validate(RGBColorSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should reject invalid RGB values', () => { + const invalid = create(RGBColorSchema, { + red: 256, // Violates [0..255]. + green: 128, + blue: 0, + alpha: 1.5 // Violates [0.0..1.0]. + }); + + const violations = validate(RGBColorSchema, invalid); + expect(violations.length).toBe(2); + + const redViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'red'); + expect(redViolation).toBeDefined(); + + const alphaViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'alpha'); + expect(alphaViolation).toBeDefined(); + }); + + it('should `validate` pagination parameters', () => { + const valid = create(PaginationRequestSchema, { + page: 5, + pageSize: 25 + }); + + const violations = validate(PaginationRequestSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should reject invalid pagination', () => { + const invalid = create(PaginationRequestSchema, { + page: 0, // Violates [1..10000]. + pageSize: 150 // Violates [1..100]. + }); + + const violations = validate(PaginationRequestSchema, invalid); + expect(violations.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('Optional Fields with Range', () => { + it('should `validate` zero values in `proto3`', () => { + const withDefaults = create(OptionalRangeSchema, { + optionalScore: 0, // Violates [1..100] (proto3 treats 0 as set). + optionalRating: 0 // Violates [1.0..5.0]. + }); + + const violations = validate(OptionalRangeSchema, withDefaults); + expect(violations.length).toBeGreaterThanOrEqual(2); + + const scoreViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'optional_score'); + expect(scoreViolation).toBeDefined(); + + const ratingViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'optional_rating'); + expect(ratingViolation).toBeDefined(); + }); + + it('should `validate` when optional fields have non-default values', () => { + const valid = create(OptionalRangeSchema, { + optionalScore: 75, + optionalRating: 4.5 + }); + + const violations = validate(OptionalRangeSchema, valid); + expect(violations).toHaveLength(0); + }); + }); + + describe('Edge Cases', () => { + it('should handle single-value ranges (exact value)', () => { + const valid = create(EdgeCaseRangesSchema, { + exactValue: 42, // Must be exactly 42. + piApprox: 3.14 + }); + + const violations = validate(EdgeCaseRangesSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should reject values outside single-value `range`', () => { + const invalid = create(EdgeCaseRangesSchema, { + exactValue: 43, // Violates [42..42]. + piApprox: 3.14 + }); + + const violations = validate(EdgeCaseRangesSchema, invalid); + const exactViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'exact_value'); + expect(exactViolation).toBeDefined(); + }); + + it('should handle narrow ranges for doubles', () => { + const valid = create(EdgeCaseRangesSchema, { + exactValue: 42, + piApprox: 3.1415 // Within [3.14..3.15]. + }); + + const violations = validate(EdgeCaseRangesSchema, valid); + expect(violations).toHaveLength(0); + }); + }); +}); + diff --git a/packages/spine-validation-ts/tests/required-field.test.ts b/packages/spine-validation-ts/tests/required-field.test.ts new file mode 100644 index 0000000..88b272c --- /dev/null +++ b/packages/spine-validation-ts/tests/required-field.test.ts @@ -0,0 +1,370 @@ +/** + * Unit tests for `(required_field)` message-level validation option. + * + * Tests boolean logic for required field combinations. + */ + +import { create } from '@bufbuild/protobuf'; +import { validate } from '../src'; + +import { + UserIdentifierSchema, + ContactInfoSchema, + PersonNameSchema, + PaymentMethodSchema, + ShippingAddressSchema, + AccountCreationSchema, + OptionalDataSchema +} from './generated/test-required-field_pb'; + +describe('Required Field Option Validation', () => { + describe('Simple OR Logic', () => { + it('should pass when first `required` field is provided', () => { + const valid = create(UserIdentifierSchema, { + id: 123, + email: '' + }); + + const violations = validate(UserIdentifierSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should pass when second `required` field is provided', () => { + const valid = create(UserIdentifierSchema, { + id: 0, + email: 'user@example.com' + }); + + const violations = validate(UserIdentifierSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should pass when both `required` fields are provided', () => { + const valid = create(UserIdentifierSchema, { + id: 123, + email: 'user@example.com' + }); + + const violations = validate(UserIdentifierSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when neither `required` field is provided', () => { + const invalid = create(UserIdentifierSchema, { + id: 0, + email: '' + }); + + const violations = validate(UserIdentifierSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0].message?.withPlaceholders).toContain('id | email'); + }); + }); + + describe('Simple AND Logic', () => { + it('should pass when both required fields are provided', () => { + const valid = create(ContactInfoSchema, { + phone: '555-1234', + countryCode: '+1' + }); + + const violations = validate(ContactInfoSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when only first field is provided', () => { + const invalid = create(ContactInfoSchema, { + phone: '555-1234', + countryCode: '' + }); + + const violations = validate(ContactInfoSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0].message?.withPlaceholders).toContain('phone & country_code'); + }); + + it('should fail when only second field is provided', () => { + const invalid = create(ContactInfoSchema, { + phone: '', + countryCode: '+1' + }); + + const violations = validate(ContactInfoSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + }); + + it('should fail when neither field is provided', () => { + const invalid = create(ContactInfoSchema, { + phone: '', + countryCode: '' + }); + + const violations = validate(ContactInfoSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + }); + }); + + describe('Complex OR with AND Groups', () => { + it('should pass when only given_name is provided', () => { + const valid = create(PersonNameSchema, { + givenName: 'John', + honorificPrefix: '', + familyName: '', + middleName: '', + honorificSuffix: '' + }); + + const violations = validate(PersonNameSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should pass when honorific_prefix and family_name are both provided', () => { + const valid = create(PersonNameSchema, { + givenName: '', + honorificPrefix: 'Dr.', + familyName: 'Smith', + middleName: '', + honorificSuffix: '' + }); + + const violations = validate(PersonNameSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should pass when all fields are provided', () => { + const valid = create(PersonNameSchema, { + givenName: 'John', + honorificPrefix: 'Dr.', + familyName: 'Smith', + middleName: 'M.', + honorificSuffix: 'Jr.' + }); + + const violations = validate(PersonNameSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when only honorific_prefix is provided (missing family_name)', () => { + const invalid = create(PersonNameSchema, { + givenName: '', + honorificPrefix: 'Dr.', + familyName: '', + middleName: '', + honorificSuffix: '' + }); + + const violations = validate(PersonNameSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0].message?.withPlaceholders).toContain('given_name | (honorific_prefix & family_name)'); + }); + + it('should fail when only family_name is provided (missing honorific_prefix)', () => { + const invalid = create(PersonNameSchema, { + givenName: '', + honorificPrefix: '', + familyName: 'Smith', + middleName: '', + honorificSuffix: '' + }); + + const violations = validate(PersonNameSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + }); + + it('should fail when no `required` fields are provided', () => { + const invalid = create(PersonNameSchema, { + givenName: '', + honorificPrefix: '', + familyName: '', + middleName: '', + honorificSuffix: '' + }); + + const violations = validate(PersonNameSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + }); + }); + + describe('Multiple OR Alternatives', () => { + it('should pass when credit_card is provided', () => { + const valid = create(PaymentMethodSchema, { + creditCard: '4111-1111-1111-1111', + bankAccount: '', + paypalEmail: '' + }); + + const violations = validate(PaymentMethodSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should pass when bank_account is provided', () => { + const valid = create(PaymentMethodSchema, { + creditCard: '', + bankAccount: 'ACC123456', + paypalEmail: '' + }); + + const violations = validate(PaymentMethodSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should pass when paypal_email is provided', () => { + const valid = create(PaymentMethodSchema, { + creditCard: '', + bankAccount: '', + paypalEmail: 'user@paypal.com' + }); + + const violations = validate(PaymentMethodSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when no payment method is provided', () => { + const invalid = create(PaymentMethodSchema, { + creditCard: '', + bankAccount: '', + paypalEmail: '' + }); + + const violations = validate(PaymentMethodSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0].message?.withPlaceholders).toContain('credit_card | bank_account | paypal_email'); + }); + }); + + describe('Multiple AND Requirements', () => { + it('should pass when all `required` fields are provided', () => { + const valid = create(ShippingAddressSchema, { + street: '123 Main St', + city: 'Boston', + postalCode: '02101', + country: 'USA', + state: '' + }); + + const violations = validate(ShippingAddressSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when street is missing', () => { + const invalid = create(ShippingAddressSchema, { + street: '', + city: 'Boston', + postalCode: '02101', + country: 'USA', + state: '' + }); + + const violations = validate(ShippingAddressSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0].message?.withPlaceholders).toContain('street & city & postal_code & country'); + }); + + it('should fail when multiple fields are missing', () => { + const invalid = create(ShippingAddressSchema, { + street: '123 Main St', + city: '', + postalCode: '', + country: 'USA', + state: '' + }); + + const violations = validate(ShippingAddressSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + }); + }); + + describe('Nested AND/OR Logic', () => { + it('should pass when username and password are both provided', () => { + const valid = create(AccountCreationSchema, { + username: 'johndoe', + password: 'secret123', + oauthToken: '' + }); + + const violations = validate(AccountCreationSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should pass when oauth_token is provided', () => { + const valid = create(AccountCreationSchema, { + username: '', + password: '', + oauthToken: 'oauth_abc123' + }); + + const violations = validate(AccountCreationSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should pass when all fields are provided', () => { + const valid = create(AccountCreationSchema, { + username: 'johndoe', + password: 'secret123', + oauthToken: 'oauth_abc123' + }); + + const violations = validate(AccountCreationSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when only username is provided (missing password)', () => { + const invalid = create(AccountCreationSchema, { + username: 'johndoe', + password: '', + oauthToken: '' + }); + + const violations = validate(AccountCreationSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0].message?.withPlaceholders).toContain('(username & password) | oauth_token'); + }); + + it('should fail when only password is provided (missing username)', () => { + const invalid = create(AccountCreationSchema, { + username: '', + password: 'secret123', + oauthToken: '' + }); + + const violations = validate(AccountCreationSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + }); + + it('should fail when no fields are provided', () => { + const invalid = create(AccountCreationSchema, { + username: '', + password: '', + oauthToken: '' + }); + + const violations = validate(AccountCreationSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + }); + }); + + describe('Optional Fields (No required_field option)', () => { + it('should pass when all fields are empty', () => { + const valid = create(OptionalDataSchema, { + field1: '', + field2: '', + field3: 0 + }); + + const violations = validate(OptionalDataSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should pass when some fields are set', () => { + const valid = create(OptionalDataSchema, { + field1: 'test', + field2: '', + field3: 0 + }); + + const violations = validate(OptionalDataSchema, valid); + expect(violations).toHaveLength(0); + }); + }); +}); + diff --git a/packages/spine-validation-ts/tests/required.test.ts b/packages/spine-validation-ts/tests/required.test.ts new file mode 100644 index 0000000..dabf562 --- /dev/null +++ b/packages/spine-validation-ts/tests/required.test.ts @@ -0,0 +1,129 @@ +/** + * Unit tests for `(required)` and `(if_missing)` validation options. + * + * Tests the `(required)` option for ensuring fields have non-default values. + */ + +import { create } from '@bufbuild/protobuf'; +import { validate } from '../src'; + +import { + RequiredFieldsSchema, + CustomErrorMessagesSchema as RequiredCustomErrorMessagesSchema, + OptionalFieldsSchema, + Status +} from './generated/test-required_pb'; + +describe('Required Field Validation', () => { + describe('Basic Required Fields', () => { + it('should validate message with all `required` fields present', () => { + const valid = create(RequiredFieldsSchema, { + name: 'John Doe', + age: 30, + address: { street: '123 Main St', city: 'Boston' }, + status: Status.ACTIVE, + tags: ['tag1'] + }); + + const violations = validate(RequiredFieldsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should detect missing `required` string field', () => { + const invalid = create(RequiredFieldsSchema, { + name: '', // Required but empty. + age: 30, + address: { street: '123 Main St', city: 'Boston' }, + status: Status.ACTIVE, + tags: ['tag1'] + }); + + const violations = validate(RequiredFieldsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const nameViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'name'); + expect(nameViolation).toBeDefined(); + expect(nameViolation?.message?.withPlaceholders).toBe('A value must be set.'); + }); + + it('should detect missing `required` message field', () => { + const invalid = create(RequiredFieldsSchema, { + name: 'John Doe', + age: 30, + address: undefined, // Required but missing. + status: Status.ACTIVE, + tags: ['tag1'] + }); + + const violations = validate(RequiredFieldsSchema, invalid); + const addressViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'address'); + expect(addressViolation).toBeDefined(); + }); + + it('should detect empty `required` repeated field', () => { + const invalid = create(RequiredFieldsSchema, { + name: 'John Doe', + age: 30, + address: { street: '123 Main St', city: 'Boston' }, + status: Status.ACTIVE, + tags: [] // Required but empty. + }); + + const violations = validate(RequiredFieldsSchema, invalid); + const tagsViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'tags'); + expect(tagsViolation).toBeDefined(); + }); + + it('should detect multiple missing `required` fields', () => { + const invalid = create(RequiredFieldsSchema, { + name: '', + age: 0, + address: undefined, + status: 0, + tags: [] + }); + + const violations = validate(RequiredFieldsSchema, invalid); + expect(violations.length).toBeGreaterThanOrEqual(3); + }); + }); + + describe('Custom Error Messages', () => { + it('should use custom error message from (`if_missing`) option', () => { + const invalid = create(RequiredCustomErrorMessagesSchema, { + username: '', // Required with custom message. + email: 'valid@example.com' + }); + + const violations = validate(RequiredCustomErrorMessagesSchema, invalid); + const usernameViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'username'); + expect(usernameViolation).toBeDefined(); + expect(usernameViolation?.message?.withPlaceholders).toBe('Username is mandatory for account creation.'); + }); + + it('should use custom error message for field with custom error message', () => { + const invalid = create(RequiredCustomErrorMessagesSchema, { + username: 'johndoe', + email: '' // Required with custom message. + }); + + const violations = validate(RequiredCustomErrorMessagesSchema, invalid); + const emailViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'email'); + expect(emailViolation).toBeDefined(); + expect(emailViolation?.message?.withPlaceholders).toBe('Email address must be provided.'); + }); + }); + + describe('Optional Fields', () => { + it('should not validate optional fields when empty', () => { + const valid = create(OptionalFieldsSchema, { + nickname: '', + score: 0 + }); + + const violations = validate(OptionalFieldsSchema, valid); + expect(violations).toHaveLength(0); + }); + }); +}); + diff --git a/packages/spine-validation-ts/tests/validate.test.ts b/packages/spine-validation-ts/tests/validate.test.ts new file mode 100644 index 0000000..272090c --- /dev/null +++ b/packages/spine-validation-ts/tests/validate.test.ts @@ -0,0 +1,486 @@ +/** + * Unit tests for `(validate)` and `(if_invalid)` validation options. + * + * Tests recursive validation of nested message fields. + */ + +import { create } from '@bufbuild/protobuf'; +import { validate } from '../src'; + +import { + PersonWithAddressSchema, + AddressSchema, + OrderWithCustomErrorSchema, + CustomerSchema, + TeamWithMembersSchema, + MemberSchema, + CompanyStructureSchema, + DepartmentSchema, + ManagerSchema, + ProfileWithOptionalDataSchema, + OptionalDataSchema as ValidateOptionalDataSchema, + PersonWithoutValidationSchema, + ProductOrderSchema, + ProductDetailsSchema, + ReviewSchema, + ShippingInfoSchema, + ContainerWithEmptyMessageSchema, + EmptyValidatedSchema, + ProjectWithTasksSchema, + TaskSchema +} from './generated/test-validate_pb'; + +describe('Nested Message Validation (validate)', () => { + describe('Basic Nested Validation', () => { + it('should pass when nested message is valid', () => { + const valid = create(PersonWithAddressSchema, { + name: 'John Doe', + address: create(AddressSchema, { + street: '123 Main St', + city: 'Boston', + zipCode: '02101' + }) + }); + + const violations = validate(PersonWithAddressSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should fail when nested message violates constraints', () => { + const invalid = create(PersonWithAddressSchema, { + name: 'John Doe', + address: create(AddressSchema, { + street: '', // Required violation. + city: 'Boston', + zipCode: '02101' + }) + }); + + const violations = validate(PersonWithAddressSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + // Should have violation for nested field. + const nestedViolation = violations.find(v => + v.fieldPath?.fieldName.includes('address') + ); + expect(nestedViolation).toBeDefined(); + }); + + it('should report violations with correct nested field path', () => { + const invalid = create(PersonWithAddressSchema, { + name: 'John Doe', + address: create(AddressSchema, { + street: '123 Main St', + city: '', // Required violation. + zipCode: '02101' + }) + }); + + const violations = validate(PersonWithAddressSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + // Check for nested field path: `address.city`. + const cityViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'address' && + v.fieldPath?.fieldName[1] === 'city' + ); + expect(cityViolation).toBeDefined(); + }); + + it('should `validate` multiple constraints in nested message', () => { + const invalid = create(PersonWithAddressSchema, { + name: 'John Doe', + address: create(AddressSchema, { + street: '123 Main St', + city: 'Boston', + zipCode: 'ABCDE' // Pattern violation (should be 5 digits). + }) + }); + + const violations = validate(PersonWithAddressSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const zipViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'address' && + v.fieldPath?.fieldName[1] === 'zip_code' + ); + expect(zipViolation).toBeDefined(); + }); + }); + + describe('Custom Error Messages (if_invalid)', () => { + it('should use custom error message when nested validation fails', () => { + const invalid = create(OrderWithCustomErrorSchema, { + orderId: 123, + customer: create(CustomerSchema, { + email: 'invalid-email', // Pattern violation. + age: 25 + }) + }); + + const violations = validate(OrderWithCustomErrorSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + // Should have parent-level violation with custom message. + const parentViolation = violations.find(v => + v.fieldPath?.fieldName.length === 1 && + v.fieldPath?.fieldName[0] === 'customer' && + v.message?.withPlaceholders.includes('Customer information is invalid') + ); + expect(parentViolation).toBeDefined(); + }); + + it('should include both parent and nested violations', () => { + const invalid = create(OrderWithCustomErrorSchema, { + orderId: 123, + customer: create(CustomerSchema, { + email: 'invalid-email', + age: 15 // Violates range [18..120]. + }) + }); + + const violations = validate(OrderWithCustomErrorSchema, invalid); + expect(violations.length).toBeGreaterThanOrEqual(3); // Parent + 2 nested. + + // Parent violation. + const parentViolation = violations.find(v => + v.fieldPath?.fieldName.length === 1 && + v.fieldPath?.fieldName[0] === 'customer' + ); + expect(parentViolation).toBeDefined(); + + // Nested violations. + const emailViolation = violations.find(v => + v.fieldPath?.fieldName[1] === 'email' + ); + expect(emailViolation).toBeDefined(); + + const ageViolation = violations.find(v => + v.fieldPath?.fieldName[1] === 'age' + ); + expect(ageViolation).toBeDefined(); + }); + }); + + describe('Repeated Message Fields', () => { + it('should `validate` all elements in repeated message field', () => { + const valid = create(TeamWithMembersSchema, { + teamName: 'Engineering', + members: [ + create(MemberSchema, { name: 'Alice', email: 'alice@example.com' }), + create(MemberSchema, { name: 'Bob', email: 'bob@example.com' }) + ] + }); + + const violations = validate(TeamWithMembersSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should detect violation in one member', () => { + const invalid = create(TeamWithMembersSchema, { + teamName: 'Engineering', + members: [ + create(MemberSchema, { name: 'Alice', email: 'alice@example.com' }), + create(MemberSchema, { name: '', email: 'bob@example.com' }) // Name required. + ] + }); + + const violations = validate(TeamWithMembersSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + // Check for violation at `members[1].name`. + const nameViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'members' && + v.fieldPath?.fieldName[1] === '1' && + v.fieldPath?.fieldName[2] === 'name' + ); + expect(nameViolation).toBeDefined(); + }); + + it('should detect violations in multiple members', () => { + const invalid = create(TeamWithMembersSchema, { + teamName: 'Engineering', + members: [ + create(MemberSchema, { name: '', email: 'alice@example.com' }), // Name violation. + create(MemberSchema, { name: 'Bob', email: 'invalid' }) // Email violation. + ] + }); + + const violations = validate(TeamWithMembersSchema, invalid); + expect(violations.length).toBeGreaterThanOrEqual(4); // 2 parent + 2 nested. + }); + }); + + describe('Deeply Nested Validation', () => { + it('should `validate` multiple levels of nesting', () => { + const valid = create(CompanyStructureSchema, { + companyName: 'Tech Corp', + department: create(DepartmentSchema, { + deptName: 'Engineering', + manager: create(ManagerSchema, { + name: 'Jane Smith', + email: 'jane@techcorp.com' + }) + }) + }); + + const violations = validate(CompanyStructureSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should detect violations in deeply nested messages', () => { + const invalid = create(CompanyStructureSchema, { + companyName: 'Tech Corp', + department: create(DepartmentSchema, { + deptName: 'Engineering', + manager: create(ManagerSchema, { + name: '', // Required violation. + email: 'jane@techcorp.com' + }) + }) + }); + + const violations = validate(CompanyStructureSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + // Check for nested path: `department.manager.name`. + const deepViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'department' && + v.fieldPath?.fieldName[1] === 'manager' && + v.fieldPath?.fieldName[2] === 'name' + ); + expect(deepViolation).toBeDefined(); + }); + }); + + describe('Optional Nested Fields', () => { + it('should pass when optional nested field is not set', () => { + const valid = create(ProfileWithOptionalDataSchema, { + username: 'johndoe' + // `optional_data` not set. + }); + + const violations = validate(ProfileWithOptionalDataSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should `validate` when optional nested field is set', () => { + const valid = create(ProfileWithOptionalDataSchema, { + username: 'johndoe', + optionalData: create(ValidateOptionalDataSchema, { + bio: 'Software engineer', + followers: 100 + }) + }); + + const violations = validate(ProfileWithOptionalDataSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should detect violations in optional nested field when set', () => { + const invalid = create(ProfileWithOptionalDataSchema, { + username: 'johndoe', + optionalData: create(ValidateOptionalDataSchema, { + bio: 'Software engineer', + followers: -5 // Violates min = 0. + }) + }); + + const violations = validate(ProfileWithOptionalDataSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + }); + }); + + describe('Without Validate Option (Control Group)', () => { + it('should not `validate` nested message without (`validate`) = true', () => { + const invalid = create(PersonWithoutValidationSchema, { + name: 'John Doe', + address: create(AddressSchema, { + street: '', // Would violate required, but not validated. + city: '', // Would violate required, but not validated. + zipCode: '' // Would violate required, but not validated. + }) + }); + + const violations = validate(PersonWithoutValidationSchema, invalid); + expect(violations).toHaveLength(0); // No violations because validate is not enabled. + }); + }); + + describe('Complex Combined Validation', () => { + it('should `validate` complex message with multiple nested fields', () => { + const valid = create(ProductOrderSchema, { + productId: 123, + product: create(ProductDetailsSchema, { + name: 'Widget', + price: 19.99, + tags: ['electronics', 'gadget'] + }), + reviews: [ + create(ReviewSchema, { rating: 5, comment: 'Great!' }), + create(ReviewSchema, { rating: 4, comment: 'Good' }) + ], + shipping: create(ShippingInfoSchema, { + address: create(AddressSchema, { + street: '123 Main St', + city: 'Boston', + zipCode: '02101' + }), + method: 'Express' + }) + }); + + const violations = validate(ProductOrderSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should detect `distinct` violation in nested product', () => { + const invalid = create(ProductOrderSchema, { + productId: 123, + product: create(ProductDetailsSchema, { + name: 'Widget', + price: 19.99, + tags: ['electronics', 'gadget', 'electronics'] // Duplicate tag. + }), + reviews: [], + shipping: create(ShippingInfoSchema, { + address: create(AddressSchema, { + street: '123 Main St', + city: 'Boston', + zipCode: '02101' + }), + method: 'Express' + }) + }); + + const violations = validate(ProductOrderSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const tagsViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'product' && + v.fieldPath?.fieldName[1] === 'tags' + ); + expect(tagsViolation).toBeDefined(); + }); + + it('should detect violations in repeated reviews', () => { + const invalid = create(ProductOrderSchema, { + productId: 123, + product: create(ProductDetailsSchema, { + name: 'Widget', + price: 19.99, + tags: ['electronics'] + }), + reviews: [ + create(ReviewSchema, { rating: 5, comment: 'Great!' }), + create(ReviewSchema, { rating: 6, comment: 'Good' }) // Rating out of range. + ], + shipping: create(ShippingInfoSchema, { + address: create(AddressSchema, { + street: '123 Main St', + city: 'Boston', + zipCode: '02101' + }), + method: 'Express' + }) + }); + + const violations = validate(ProductOrderSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const ratingViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'reviews' && + v.fieldPath?.fieldName[1] === '1' && + v.fieldPath?.fieldName[2] === 'rating' + ); + expect(ratingViolation).toBeDefined(); + }); + + it('should detect violations in doubly-nested shipping address', () => { + const invalid = create(ProductOrderSchema, { + productId: 123, + product: create(ProductDetailsSchema, { + name: 'Widget', + price: 19.99, + tags: ['electronics'] + }), + reviews: [], + shipping: create(ShippingInfoSchema, { + address: create(AddressSchema, { + street: '123 Main St', + city: 'Boston', + zipCode: 'INVALID' // Pattern violation. + }), + method: 'Express' + }) + }); + + const violations = validate(ProductOrderSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + // Path: `shipping.address.zip_code`. + const zipViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'shipping' && + v.fieldPath?.fieldName[1] === 'address' && + v.fieldPath?.fieldName[2] === 'zip_code' + ); + expect(zipViolation).toBeDefined(); + }); + }); + + describe('Edge Cases', () => { + it('should pass when validating message with no constraints', () => { + const valid = create(ContainerWithEmptyMessageSchema, { + id: 'test-123', + empty: create(EmptyValidatedSchema, { + note: 'Some note' + }) + }); + + const violations = validate(ContainerWithEmptyMessageSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should `validate` nested message with its own nested validation', () => { + const valid = create(ProjectWithTasksSchema, { + projectName: 'Project Alpha', + tasks: [ + create(TaskSchema, { + title: 'Task 1', + priority: 3, + assignees: ['alice', 'bob'] + }) + ], + tags: ['urgent', 'backend'] + }); + + const violations = validate(ProjectWithTasksSchema, valid); + expect(violations).toHaveLength(0); + }); + + it('should detect `distinct` violation in nested task assignees', () => { + const invalid = create(ProjectWithTasksSchema, { + projectName: 'Project Alpha', + tasks: [ + create(TaskSchema, { + title: 'Task 1', + priority: 3, + assignees: ['alice', 'bob', 'alice'] // Duplicate assignee. + }) + ], + tags: ['urgent', 'backend'] + }); + + const violations = validate(ProjectWithTasksSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const assigneeViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'tasks' && + v.fieldPath?.fieldName[1] === '0' && + v.fieldPath?.fieldName[2] === 'assignees' + ); + expect(assigneeViolation).toBeDefined(); + }); + }); +}); + diff --git a/packages/spine-validation-ts/tsconfig.json b/packages/spine-validation-ts/tsconfig.json new file mode 100644 index 0000000..bb72e37 --- /dev/null +++ b/packages/spine-validation-ts/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} From 2e663a8f845acde445dc1fe58396c352a31ba20f Mon Sep 17 00:00:00 2001 From: Alex Tymchenko Date: Tue, 13 Jan 2026 17:44:16 +0000 Subject: [PATCH 02/16] Update `README.md` to make it shorter. --- README.md | 92 ++++++++++++++++--------------------------------------- 1 file changed, 26 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 2cca4f0..82ac370 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Spine Validation TypeScript +# Spine Validation for TypeScript -> Runtime validation for Protobuf messages with [Spine Event Engine](https://spine.io/) validation constraints. +_Runtime validation for Protobuf messages with [Spine Event Engine](https://spine.io/) Validation constraints._ [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) [![Protobuf-ES](https://img.shields.io/badge/protobuf--es-v2-green.svg)](https://github.com/bufbuild/protobuf-es) @@ -11,11 +11,12 @@ A TypeScript validation library for Protobuf messages using Spine validation opt ## πŸ’‘ Why Use This? -### For Spine Event Engine Users +### For Spine Event Engine users **You already have validation rules in your backend.** Now bring them to your TypeScript/JavaScript frontend with zero duplication! -If you're using [Spine Event Engine](https://spine.io/) with its Validation library on the server side, your Protobuf messages already have validation constraints defined using Spine options like `(required)`, `(pattern)`, `(min)`, `(max)`, etc. +If you're using the [Validation library](https://github.com/SpineEventEngine/validation/) on the server side, +your Protobuf messages already have validation constraints defined using Spine options like `(required)`, `(pattern)`, `(min)`, `(max)`, etc. **This library lets you:** - βœ… **Reuse the same validation rules** in your frontend that you defined in your backend. @@ -26,7 +27,7 @@ If you're using [Spine Event Engine](https://spine.io/) with its Validation libr **No code duplication. No maintenance burden. Just add this library and validate.** -### For New Users +### For new users Even if you're not using Spine Event Engine, this library provides a powerful way to add runtime validation to your Protobuf-based TypeScript applications: @@ -40,18 +41,18 @@ Even if you're not using Spine Event Engine, this library provides a powerful wa ## ✨ Features -**Comprehensive Validation Support:** +**Comprehensive Validation support:** -- βœ… **`(required)`** - Ensure fields have non-default values. -- πŸ”€ **`(pattern)`** - Regex validation for strings. -- πŸ”’ **`(min)` / `(max)`** - Numeric bounds with inclusive/exclusive support. -- πŸ“Š **`(range)`** - Bounded ranges with bracket notation `[min..max]`. -- πŸ” **`(distinct)`** - Enforce uniqueness in repeated fields. -- πŸ—οΈ **`(validate)`** - Recursive nested message validation. -- πŸ”— **`(goes)`** - Field dependency constraints. -- 🎯 **`(required_field)`** - Complex required field combinations with boolean logic. +- **`(required)`** - Ensure fields have non-default values. +- **`(pattern)`** - Regex validation for strings. +- **`(min)` / `(max)`** - Numeric bounds with inclusive/exclusive support. +- **`(range)`** - Bounded ranges with bracket notation `[min..max]`. +- **`(distinct)`** - Enforce uniqueness in repeated fields. +- ️ **`(validate)`** - Recursive nested message validation. +- **`(goes)`** - Field dependency constraints. +- **`(required_field)`** - Complex required field combinations with boolean logic. -**Developer Experience:** +**Developer experience:** - πŸš€ Full TypeScript type safety. - πŸ“ Custom error messages. @@ -59,7 +60,7 @@ Even if you're not using Spine Event Engine, this library provides a powerful wa - πŸ“š Extensive documentation. - 🎨 Clean, readable error formatting. -### ⚠️ Known Limitations +### ⚠️ Known limitations - **`(set_once)`** - Not currently supported. This option requires state tracking across multiple validations, which is outside the scope of single-message validation. If you need this feature, please [open an issue](../../issues). @@ -135,9 +136,9 @@ validation-ts/ β”œβ”€β”€ packages/ β”‚ β”œβ”€β”€ spine-validation-ts/ # πŸ“¦ Main validation package β”‚ β”‚ β”œβ”€β”€ src/ # Source code -β”‚ β”‚ β”œβ”€β”€ tests/ # 223 comprehensive tests -β”‚ β”‚ β”œβ”€β”€ proto/ # Spine validation proto definitions -β”‚ β”‚ └── README.md # Full package documentation +β”‚ β”‚ β”œβ”€β”€ tests/ # Tests +β”‚ β”‚ β”œβ”€β”€ proto/ # Spine proto definitions +β”‚ β”‚ └── README.md # The package documentation β”‚ β”‚ β”‚ └── example/ # 🎯 Example project β”‚ β”œβ”€β”€ proto/ # Example proto files @@ -149,14 +150,6 @@ validation-ts/ --- -## πŸŽ“ Documentation - -- **[Package README](packages/spine-validation-ts/README.md)** - Complete API documentation and usage guide. -- **[Descriptor API Guide](packages/spine-validation-ts/DESCRIPTOR_API_GUIDE.md)** - Working with message and field descriptors. -- **[Quick Reference](packages/spine-validation-ts/QUICK_REFERENCE.md)** - Cheat sheet for common operations. - ---- - ## πŸ› οΈ Development ### Setup @@ -170,7 +163,7 @@ cd validation-ts npm install ``` -### Build & Test +### Build & test ```bash # Build the validation package @@ -183,7 +176,7 @@ npm test npm run example ``` -### Workspace Scripts +### Workspace scripts | Command | Description | |---------|-------------| @@ -195,7 +188,7 @@ npm run example ## πŸ“‹ Validation Options Reference -### Field-Level Options +### Field-level options | Option | Description | Example | |--------|-------------|---------| @@ -209,13 +202,13 @@ npm run example | `(if_invalid)` | Custom error for nested validation. | `[(if_invalid).error_msg = "Invalid address"]` | | `(goes)` | Field dependency. | `[(goes).with = "other_field"]` | -### Message-Level Options +### Message-level options | Option | Description | Example | |--------|-------------|---------| | `(required_field)` | Required field combinations. | `option (required_field) = "id \| email";` | -### Not Supported +### Not supported | Option | Status | Notes | |--------|--------|-------| @@ -233,31 +226,6 @@ The package includes comprehensive test coverage: - **Edge cases** and real-world scenarios. - **100% coverage** of validation logic. -Test suites: -- Basic validation. -- Required fields. -- Pattern matching. -- Min/Max constraints. -- Range validation. -- Distinct elements. -- Nested validation. -- Field dependencies (goes). -- Required field combinations. -- Integration scenarios. - ---- - -## πŸ“ Example Output - -When validation fails, you get clear, actionable error messages: - -``` -Validation failed: -1. User.name: A value must be set. -2. User.email: Email must be valid. Provided: `invalid-email`. -3. User.age: Value must be at least 0. Provided: -5. -4. User.tags: Values must be distinct. Duplicates found: ["test"]. -``` --- @@ -277,7 +245,7 @@ The validation system is built with extensibility in mind: Contributions are welcome! Please ensure: -1. All tests pass: `npm test`. +1. All tests pass: `npm run test`. 2. Code follows existing patterns. 3. New features include tests. 4. Documentation is updated. @@ -290,14 +258,6 @@ Apache 2.0. --- -## πŸ”— Related Projects - -- [Spine Event Engine](https://spine.io/) - Event-driven framework for CQRS/ES applications. -- [Protobuf-ES](https://github.com/bufbuild/protobuf-es) - Protocol Buffers for ECMAScript. -- [Buf](https://buf.build/) - Modern Protobuf tooling. - ---- -
**Made with ❀️ for the Spine Event Engine ecosystem.** From 2008f810e08d60298d559f589bcce9ee84c4029b Mon Sep 17 00:00:00 2001 From: Alex Tymchenko Date: Tue, 13 Jan 2026 17:50:20 +0000 Subject: [PATCH 03/16] Shorten another `README.md`. --- packages/spine-validation-ts/README.md | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/spine-validation-ts/README.md b/packages/spine-validation-ts/README.md index 77f2ad7..96eaff8 100644 --- a/packages/spine-validation-ts/README.md +++ b/packages/spine-validation-ts/README.md @@ -1,6 +1,6 @@ # @spine-event-engine/validation-ts -TypeScript validation library for Protobuf messages with [Spine Event Engine](https://spine.io/) validation options. +TypeScript validation library for Protobuf messages with [Spine Validation](https://github.com/SpineEventEngine/validation/) options. ## Features @@ -21,7 +21,6 @@ npm install @spine-event-engine/validation-ts This library requires: - `@bufbuild/protobuf` v2.10.2 or later -- Protobuf definitions with Spine validation options The package includes: - Spine validation Proto definitions (`spine/options.proto`) @@ -86,7 +85,7 @@ Formats a `TemplateString` by replacing placeholders with provided values. ## Supported Validation Options -### Field-Level Options +### Field-level options - βœ… **`(required)`** - Ensures field has a non-default value - βœ… **`(if_missing)`** - Custom error message for required fields @@ -98,15 +97,15 @@ Formats a `TemplateString` by replacing placeholders with provided values. - βœ… **`(if_invalid)`** - Custom error message for nested validation failures - βœ… **`(goes)`** - Field dependency validation (field can only be set if another field is set) -### Message-Level Options +### Message-level options - βœ… **`(required_field)`** - Requires specific field combinations using boolean logic -### Oneof-Level Options +### Oneof-level options - βœ… **`(is_required)`** - Requires that one of the oneof fields must be set -## Example Proto File +## Example ```protobuf syntax = "proto3"; @@ -165,7 +164,7 @@ message UserProfile { ## Validation Behavior -### Proto3 Field Semantics +### Proto3 field semantics In `proto3`, fields have default values: - Numeric fields default to `0` @@ -180,7 +179,7 @@ The `(required)` validator considers a field "set" when: - Message fields are not `undefined` - Repeated fields have at least one element -### Nested Validation +### Nested validation Use `(validate) = true` on message fields to recursively validate nested messages: @@ -193,7 +192,7 @@ message Order { } ``` -### Field Dependencies +### Field dependencies Use `(goes)` to enforce field dependencies: @@ -207,7 +206,7 @@ message ShippingDetails { } ``` -### Required Field Combinations +### Required field combinations Use `(required_field)` for complex field requirements: From a61b6681d626d34faf889076a838b3b49bea3c56 Mon Sep 17 00:00:00 2001 From: Alex Tymchenko Date: Tue, 13 Jan 2026 17:52:26 +0000 Subject: [PATCH 04/16] Shorten one more `README.md`. --- packages/example/README.md | 121 ++----------------------------------- 1 file changed, 5 insertions(+), 116 deletions(-) diff --git a/packages/example/README.md b/packages/example/README.md index 9927bcf..723b01c 100644 --- a/packages/example/README.md +++ b/packages/example/README.md @@ -1,23 +1,24 @@ # Spine Validation TypeScript - Example Project -A standalone example demonstrating runtime validation of Protobuf messages with Spine validation constraints. +A standalone example demonstrating runtime validation of Protobuf messages +with [Spine Validation](https://github.com/SpineEventEngine/validation/) constraints. ## What This Example Shows -- Defining Protobuf messages with Spine validation options. +- Defining Protobuf messages with Spine Validation options. - Validating messages at runtime. - Programmatically handling validation violations. - Various validation scenarios (required fields, patterns, ranges, etc.). ## Quick Start -### Install Dependencies +### Install dependencies ```bash npm install ``` -### Run the Example +### Run the example ```bash npm start @@ -28,118 +29,6 @@ This will: 2. Build the TypeScript code. 3. Run the example showing various validation scenarios. -## Project Structure - -``` -example/ -β”œβ”€β”€ proto/ -β”‚ β”œβ”€β”€ user.proto # User message with validation constraints -β”‚ └── product.proto # Product message with validation examples -β”œβ”€β”€ src/ -β”‚ └── index.ts # Example validation code -β”œβ”€β”€ package.json -└── README.md # This file -``` - -## Expected Output - -When you run the example, you'll see validation results for different scenarios: - -``` -=== Spine Validation Example === - -Example 1: Valid User ---------------------- -Violations: 0 -No violations - -Example 2: Missing Required Email ----------------------------------- -Violations: 1 -1. example.User.email: A value must be set. - -... -``` - -## Key Code Pattern - -The example demonstrates the core validation pattern: - -```typescript -import { create } from '@bufbuild/protobuf'; -import { validate } from '@spine-event-engine/validation-ts'; -import { UserSchema } from './generated/user_pb'; - -// Create a message -const user = create(UserSchema, { - name: 'John Doe', - email: 'john@example.com' -}); - -// Validate the message -const violations = validate(UserSchema, user); - -// Handle violations programmatically -if (violations.length === 0) { - // Valid - proceed with business logic - processUser(user); -} else { - // Invalid - handle errors - violations.forEach(v => { - console.log(`Field: ${v.fieldPath}`); - console.log(`Error: ${v.message}`); - }); -} -``` - -## Validation Options Used - -The example proto files demonstrate these Spine validation options: - -- `(required)` - Field must have a non-default value. -- `(pattern)` - String must match a regex pattern. -- `(min)` / `(max)` - Numeric bounds. -- `(range)` - Numeric ranges with bracket notation. -- `(distinct)` - Unique elements in repeated fields. -- `(validate)` - Nested message validation. -- `(goes)` - Field dependency constraints. -- `(required_field)` - Message-level field combinations. - -## Learn More - -For complete documentation: - -- **[Validation Library README](../spine-validation-ts/README.md)** - Full API documentation. -- **[Root README](../../README.md)** - Project overview and setup. -- **[Spine Event Engine](https://spine.io/)** - Server-side validation framework. - -## Adding Your Own Validation - -1. Create a `.proto` file in the `proto/` directory. -2. Import `spine/options.proto`. -3. Add validation options to your message fields. -4. Run `npm run generate` to generate TypeScript code. -5. Use the generated schemas in your code. - -Example: - -```protobuf -syntax = "proto3"; - -import "spine/options.proto"; - -message Order { - string order_id = 1 [ - (required) = true, - (pattern).regex = "^ORD-[0-9]{6}$" - ]; - - double total = 2 [ - (min).value = "0.01" - ]; -} -``` - ## License Apache License 2.0. From 4b3ddbe41cb4b7653f6a03353a8e4708480e2a40 Mon Sep 17 00:00:00 2001 From: Alex Tymchenko Date: Tue, 13 Jan 2026 18:26:20 +0000 Subject: [PATCH 05/16] Reformat code and add copyright statements. --- packages/example/proto/product.proto | 170 +++++++++--------- packages/example/proto/user.proto | 76 ++++---- packages/example/src/index.ts | 110 +++++++----- packages/spine-validation-ts/src/index.ts | 26 +++ .../src/options-registry.ts | 26 +++ .../src/options/distinct.ts | 26 +++ .../spine-validation-ts/src/options/goes.ts | 26 +++ .../src/options/min-max.ts | 26 +++ .../src/options/pattern.ts | 26 +++ .../spine-validation-ts/src/options/range.ts | 26 +++ .../src/options/required-field.ts | 26 +++ .../src/options/required.ts | 26 +++ .../src/options/validate.ts | 26 +++ .../spine-validation-ts/src/validation.ts | 28 ++- .../tests/basic-validation.test.ts | 28 ++- .../tests/distinct.test.ts | 26 +++ .../spine-validation-ts/tests/goes.test.ts | 26 +++ .../tests/integration.test.ts | 26 +++ .../spine-validation-ts/tests/min-max.test.ts | 26 +++ .../spine-validation-ts/tests/pattern.test.ts | 26 +++ .../tests/proto/integration-account.proto | 25 +++ .../tests/proto/integration-product.proto | 26 ++- .../tests/proto/integration-user.proto | 27 ++- .../tests/proto/test-distinct.proto | 25 +++ .../tests/proto/test-goes.proto | 25 +++ .../tests/proto/test-min-max.proto | 25 +++ .../tests/proto/test-pattern.proto | 25 +++ .../tests/proto/test-range.proto | 25 +++ .../tests/proto/test-required-field.proto | 25 +++ .../tests/proto/test-required.proto | 25 +++ .../tests/proto/test-validate.proto | 25 +++ .../spine-validation-ts/tests/range.test.ts | 26 +++ .../tests/required-field.test.ts | 26 +++ .../tests/required.test.ts | 26 +++ .../tests/validate.test.ts | 26 +++ 35 files changed, 1023 insertions(+), 161 deletions(-) diff --git a/packages/example/proto/product.proto b/packages/example/proto/product.proto index ab66c68..4127ed4 100644 --- a/packages/example/proto/product.proto +++ b/packages/example/proto/product.proto @@ -1,3 +1,28 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ syntax = "proto3"; package example; @@ -5,114 +30,91 @@ package example; import "google/protobuf/timestamp.proto"; import "spine/options.proto"; -// Product message representing a product entity with validation constraints message Product { - // Product ID - required and set once - string id = 1 [(required) = true, - (set_once) = true, - (pattern).regex = "^prod-[0-9]+$", - (pattern).error_msg = "Product ID must follow format 'prod-XXX'. Provided: `{value}`."]; - - // Product name - required - string name = 2 [(required) = true, - (if_missing).error_msg = "Product name is required."]; - - // Product description - optional - string description = 3; - - // Product price - must be positive - double price = 4 [(required) = true, - (min).value = "0.01", - (min).error_msg = "Price must be at least {other}. Provided: {value}."]; - - // Stock quantity - must be non-negative - int32 stock = 5 [(min).value = "0", - (range) = "[0..1000000)"]; - - // Creation timestamp - required - google.protobuf.Timestamp created_at = 6 [(required) = true]; - - // Category - required and validated - Category category = 7 [(required) = true, - (validate) = true, - (if_invalid).error_msg = "Category is invalid."]; - - // Display settings - demonstrates "goes" option - // Text color can only be set when highlight color is set, and vice versa - Color text_color = 8 [(goes).with = "highlight_color"]; - Color highlight_color = 9 [(goes).with = "text_color"]; + string id = 1 [(required) = true, + (pattern).regex = "^prod-[0-9]+$", + (pattern).error_msg = "Product ID must follow format 'prod-XXX'. Provided: `{value}`."]; + + string name = 2 [(required) = true, + (if_missing).error_msg = "Product name is required."]; + + string description = 3; + + double price = 4 [(required) = true, + (min).value = "0.01", + (min).error_msg = "Price must be at least {other}. Provided: {value}."]; + + int32 stock = 5 [(min).value = "0", + (range) = "[0..1000000)"]; + + google.protobuf.Timestamp created_at = 6 [(required) = true]; + + Category category = 7 [(required) = true, + (validate) = true, + (if_invalid).error_msg = "Category is invalid."]; + + // Display settings, demonstrates "goes" option. + // Text color can only be set when highlight color is set, and vice versa. + Color text_color = 8 [(goes).with = "highlight_color"]; + Color highlight_color = 9 [(goes).with = "text_color"]; } -// Color message for display settings message Color { - int32 red = 1 [(range) = "[0..255]"]; - int32 green = 2 [(range) = "[0..255]"]; - int32 blue = 3 [(range) = "[0..255]"]; + int32 red = 1 [(range) = "[0..255]"]; + int32 green = 2 [(range) = "[0..255]"]; + int32 blue = 3 [(range) = "[0..255]"]; } -// Category message with validation message Category { - // Category ID - must be positive - int32 id = 1 [(required) = true, - (min).value = "1"]; + int32 id = 1 [(required) = true, + (min).value = "1"]; - // Category name - required - string name = 2 [(required) = true]; + string name = 2 [(required) = true]; } -// Payment method demonstrating is_required oneof option message PaymentMethod { - oneof method { - option (is_required) = true; + oneof method { + option (is_required) = true; - // Money is provided from a payment card that has this number - PaymentCardNumber payment_card = 1 [(validate) = true]; + PaymentCardNumber payment_card = 1 [(validate) = true]; - // Money is provided from this bank account - BankAccount bank_account = 2 [(validate) = true]; - } + BankAccount bank_account = 2 [(validate) = true]; + } } -// Payment card number with validation message PaymentCardNumber { - string number = 1 [(required) = true, - (pattern).regex = "^[0-9]{13,19}$", - (pattern).error_msg = "Card number must be 13-19 digits."]; - int32 expiry_month = 2 [(required) = true, - (range) = "[1..12]"]; - int32 expiry_year = 3 [(required) = true, - (min).value = "2024"]; + string number = 1 [(required) = true, + (pattern).regex = "^[0-9]{13,19}$", + (pattern).error_msg = "Card number must be 13-19 digits."]; + int32 expiry_month = 2 [(required) = true, + (range) = "[1..12]"]; + int32 expiry_year = 3 [(required) = true, + (min).value = "2024"]; } -// Bank account with validation message BankAccount { - string account_number = 1 [(required) = true, - (pattern).regex = "^[0-9]{8,17}$"]; - string routing_number = 2 [(required) = true, - (pattern).regex = "^[0-9]{9}$"]; + string account_number = 1 [(required) = true, + (pattern).regex = "^[0-9]{8,17}$"]; + string routing_number = 2 [(required) = true, + (pattern).regex = "^[0-9]{9}$"]; } -// Request message for listing products with pagination message ListProductsRequest { - // Page number - must be positive - int32 page = 1 [(required) = true, - (min).value = "1", - (if_missing).error_msg = "Page number is required."]; - - // Page size - must be between 1 and 100 - int32 page_size = 2 [(required) = true, - (range) = "[1..100]", - (if_missing).error_msg = "Page size is required."]; - - // Optional search query - string search_query = 3; + int32 page = 1 [(required) = true, + (min).value = "1", + (if_missing).error_msg = "Page number is required."]; + + int32 page_size = 2 [(required) = true, + (range) = "[1..100]", + (if_missing).error_msg = "Page size is required."]; + + // Optional search query. + string search_query = 3; } -// Response message for listing products message ListProductsResponse { - // List of products with validation enabled - repeated Product products = 1 [(validate) = true]; + // List of products with the nested validation enabled. + repeated Product products = 1 [(validate) = true]; - // Total count - must be non-negative - int32 total_count = 2 [(min).value = "0"]; + int32 total_count = 2 [(min).value = "0"]; } diff --git a/packages/example/proto/user.proto b/packages/example/proto/user.proto index af2e5b1..15eceec 100644 --- a/packages/example/proto/user.proto +++ b/packages/example/proto/user.proto @@ -1,53 +1,67 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ syntax = "proto3"; package example; import "spine/options.proto"; -// User message representing a user entity with validation constraints message User { - option (required_field) = "id | email"; + option (required_field) = "id | email"; - // User ID - set once and cannot be changed - int32 id = 1 [(set_once) = true, (min).value = "1"]; + int32 id = 1 [(min).value = "1"]; - // User name - required, with pattern validation - string name = 2 [(required) = true, - (pattern).regex = "^[A-Za-z][A-Za-z0-9 ]{1,49}$", - (pattern).error_msg = "Name must start with a letter and be 2-50 characters. Provided: `{value}`."]; + string name = 2 [(required) = true, + (pattern).regex = "^[A-Za-z][A-Za-z0-9 ]{1,49}$", + (pattern).error_msg = "Name must start with a letter and be 2-50 characters. Provided: `{value}`."]; - // User email - required, with pattern validation for email format - string email = 3 [(required) = true, - (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", - (pattern).error_msg = "Email must be valid. Provided: `{value}`."]; + string email = 3 [(required) = true, + (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + (pattern).error_msg = "Email must be valid. Provided: `{value}`."]; - // User role - required - Role role = 4 [(required) = true]; + Role role = 4 [(required) = true]; - // Tags - distinct values only - repeated string tags = 5 [(distinct) = true]; + repeated string tags = 5 [(distinct) = true]; } -// Role enumeration enum Role { - ROLE_UNSPECIFIED = 0; - ROLE_USER = 1; - ROLE_ADMIN = 2; - ROLE_MODERATOR = 3; + ROLE_UNSPECIFIED = 0; + ROLE_USER = 1; + ROLE_ADMIN = 2; + ROLE_MODERATOR = 3; } -// Request message for getting a user message GetUserRequest { - // User ID must be positive - int32 user_id = 1 [(required) = true, - (min).value = "1", - (if_missing).error_msg = "User ID is required."]; + int32 user_id = 1 [(required) = true, + (min).value = "1", + (if_missing).error_msg = "User ID is required."]; } -// Response message for getting a user message GetUserResponse { - // User data with validation enabled - User user = 1 [(validate) = true, - (if_invalid).error_msg = "User data is invalid."]; - bool found = 2; + User user = 1 [(validate) = true, + (if_invalid).error_msg = "User data is invalid."]; + bool found = 2; } diff --git a/packages/example/src/index.ts b/packages/example/src/index.ts index 2f3f2af..1d7ef3f 100644 --- a/packages/example/src/index.ts +++ b/packages/example/src/index.ts @@ -1,12 +1,38 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** - * Example demonstrating the spine-validation-ts package. + * Example demonstrating the `@spine-event-engine/validation-ts` package. * * This example shows how to validate Protobuf messages with Spine validation constraints. */ -import { create } from '@bufbuild/protobuf'; -import { UserSchema, Role } from './generated/user_pb.js'; -import { validate, formatViolations } from '@spine-event-engine/validation-ts'; +import {create} from '@bufbuild/protobuf'; +import {UserSchema, Role} from './generated/user_pb.js'; +import {validate, formatViolations} from '@spine-event-engine/validation-ts'; console.log('=== Spine Validation Example ===\n'); @@ -14,11 +40,11 @@ console.log('=== Spine Validation Example ===\n'); console.log('Example 1: Valid User'); console.log('---------------------'); const validUser = create(UserSchema, { - id: 1, - name: 'John Doe', - email: 'john.doe@example.com', - role: Role.ADMIN, - tags: ['developer', 'typescript'] + id: 1, + name: 'John Doe', + email: 'john.doe@example.com', + role: Role.ADMIN, + tags: ['developer', 'typescript'] }); const validUserViolations = validate(UserSchema, validUser); @@ -30,11 +56,11 @@ console.log(); console.log('Example 2: Missing Required Email'); console.log('----------------------------------'); const invalidUser1 = create(UserSchema, { - id: 2, - name: 'Jane Smith', - email: '', // Required but empty - role: Role.USER, - tags: [] + id: 2, + name: 'Jane Smith', + email: '', // Required but empty + role: Role.USER, + tags: [] }); const violations1 = validate(UserSchema, invalidUser1); @@ -46,11 +72,11 @@ console.log(); console.log('Example 3: Missing Required Name'); console.log('---------------------------------'); const invalidUser2 = create(UserSchema, { - id: 3, - name: '', // Required but empty - email: 'alice@example.com', - role: Role.USER, - tags: [] + id: 3, + name: '', // Required but empty + email: 'alice@example.com', + role: Role.USER, + tags: [] }); const violations2 = validate(UserSchema, invalidUser2); @@ -62,11 +88,11 @@ console.log(); console.log('Example 4: Multiple Violations'); console.log('-------------------------------'); const invalidUser3 = create(UserSchema, { - id: 4, - name: '', // Required but empty - email: '', // Required but empty - role: 0, // ROLE_UNSPECIFIED - tags: [] + id: 4, + name: '', // Required but empty + email: '', // Required but empty + role: 0, // ROLE_UNSPECIFIED + tags: [] }); const violations3 = validate(UserSchema, invalidUser3); @@ -78,11 +104,11 @@ console.log(); console.log('Example 5: Pattern Validation (Invalid Name)'); console.log('----------------------------------------------'); const invalidPattern1 = create(UserSchema, { - id: 5, - name: '123Invalid', // Starts with number, violates pattern - email: 'valid@example.com', - role: Role.USER, - tags: [] + id: 5, + name: '123Invalid', // Starts with number, violates pattern + email: 'valid@example.com', + role: Role.USER, + tags: [] }); const violations4 = validate(UserSchema, invalidPattern1); @@ -94,11 +120,11 @@ console.log(); console.log('Example 6: Pattern Validation (Invalid Email)'); console.log('-----------------------------------------------'); const invalidPattern2 = create(UserSchema, { - id: 6, - name: 'Bob Wilson', - email: 'notanemail', // Invalid email format - role: Role.USER, - tags: [] + id: 6, + name: 'Bob Wilson', + email: 'notanemail', // Invalid email format + role: Role.USER, + tags: [] }); const violations5 = validate(UserSchema, invalidPattern2); @@ -110,19 +136,19 @@ console.log(); console.log('Example 7: Multiple Validation Types'); console.log('-------------------------------------'); const multipleInvalid = create(UserSchema, { - id: 7, - name: '', // Required violation - email: 'bad@', // Pattern violation - role: 0, - tags: [] + id: 7, + name: '', // Required violation + email: 'bad@', // Pattern violation + role: 0, + tags: [] }); const violations6 = validate(UserSchema, multipleInvalid); console.log('Violations:', violations6.length); violations6.forEach((v, i) => { - const fieldPath = v.fieldPath?.fieldName.join('.') || 'unknown'; - const message = v.message?.withPlaceholders || 'No message'; - console.log(`${i + 1}. Field "${fieldPath}": ${message}`); + const fieldPath = v.fieldPath?.fieldName.join('.') || 'unknown'; + const message = v.message?.withPlaceholders || 'No message'; + console.log(`${i + 1}. Field "${fieldPath}": ${message}`); }); console.log(); diff --git a/packages/spine-validation-ts/src/index.ts b/packages/spine-validation-ts/src/index.ts index 434e31f..1a990a7 100644 --- a/packages/spine-validation-ts/src/index.ts +++ b/packages/spine-validation-ts/src/index.ts @@ -1,3 +1,29 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** * Spine Validation for TypeScript. * diff --git a/packages/spine-validation-ts/src/options-registry.ts b/packages/spine-validation-ts/src/options-registry.ts index 2deeff3..09b91af 100644 --- a/packages/spine-validation-ts/src/options-registry.ts +++ b/packages/spine-validation-ts/src/options-registry.ts @@ -1,3 +1,29 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** * Internal registry for Spine validation option extensions. * diff --git a/packages/spine-validation-ts/src/options/distinct.ts b/packages/spine-validation-ts/src/options/distinct.ts index 5fb0f53..5d1b024 100644 --- a/packages/spine-validation-ts/src/options/distinct.ts +++ b/packages/spine-validation-ts/src/options/distinct.ts @@ -1,3 +1,29 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** * Validation logic for the `(distinct)` option. * diff --git a/packages/spine-validation-ts/src/options/goes.ts b/packages/spine-validation-ts/src/options/goes.ts index 2005f64..2ec72dc 100644 --- a/packages/spine-validation-ts/src/options/goes.ts +++ b/packages/spine-validation-ts/src/options/goes.ts @@ -1,3 +1,29 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** * Validation logic for the `(goes)` option. * diff --git a/packages/spine-validation-ts/src/options/min-max.ts b/packages/spine-validation-ts/src/options/min-max.ts index 48346f6..f70733e 100644 --- a/packages/spine-validation-ts/src/options/min-max.ts +++ b/packages/spine-validation-ts/src/options/min-max.ts @@ -1,3 +1,29 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** * Validation logic for the `(min)` and `(max)` options. * diff --git a/packages/spine-validation-ts/src/options/pattern.ts b/packages/spine-validation-ts/src/options/pattern.ts index 8b6dd5f..157dde7 100644 --- a/packages/spine-validation-ts/src/options/pattern.ts +++ b/packages/spine-validation-ts/src/options/pattern.ts @@ -1,3 +1,29 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** * Validation logic for the `(pattern)` option. * diff --git a/packages/spine-validation-ts/src/options/range.ts b/packages/spine-validation-ts/src/options/range.ts index 59492fe..8dc297f 100644 --- a/packages/spine-validation-ts/src/options/range.ts +++ b/packages/spine-validation-ts/src/options/range.ts @@ -1,3 +1,29 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** * Validation logic for the `(range)` option. * diff --git a/packages/spine-validation-ts/src/options/required-field.ts b/packages/spine-validation-ts/src/options/required-field.ts index 3261799..753d327 100644 --- a/packages/spine-validation-ts/src/options/required-field.ts +++ b/packages/spine-validation-ts/src/options/required-field.ts @@ -1,3 +1,29 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** * Validation logic for the `(required_field)` option. * diff --git a/packages/spine-validation-ts/src/options/required.ts b/packages/spine-validation-ts/src/options/required.ts index 2e2b911..92b2021 100644 --- a/packages/spine-validation-ts/src/options/required.ts +++ b/packages/spine-validation-ts/src/options/required.ts @@ -1,3 +1,29 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** * Validation logic for the `(required)` option. * diff --git a/packages/spine-validation-ts/src/options/validate.ts b/packages/spine-validation-ts/src/options/validate.ts index 518a14f..08cd12e 100644 --- a/packages/spine-validation-ts/src/options/validate.ts +++ b/packages/spine-validation-ts/src/options/validate.ts @@ -1,3 +1,29 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** * Validation logic for the `(validate)` and `(if_invalid)` options. * diff --git a/packages/spine-validation-ts/src/validation.ts b/packages/spine-validation-ts/src/validation.ts index e6520c2..e5be72e 100644 --- a/packages/spine-validation-ts/src/validation.ts +++ b/packages/spine-validation-ts/src/validation.ts @@ -1,3 +1,29 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** * Validation module for Protobuf messages with Spine validation options. * @@ -47,7 +73,7 @@ export type { FieldPath } from './generated/spine/base/field_path_pb'; * * @example * ```typescript - * import { validate } from 'spine-validation-ts'; + * import { validate } from '@spine-event-engine/validation-ts'; * import { UserSchema } from './generated/user_pb'; * import { create } from '@bufbuild/protobuf'; * diff --git a/packages/spine-validation-ts/tests/basic-validation.test.ts b/packages/spine-validation-ts/tests/basic-validation.test.ts index 8aa8ff5..8285902 100644 --- a/packages/spine-validation-ts/tests/basic-validation.test.ts +++ b/packages/spine-validation-ts/tests/basic-validation.test.ts @@ -1,5 +1,31 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** - * Unit tests for spine-validation-ts package - Basic validation and formatting. + * Unit tests for `@spine-event-engine/validation-ts` package. * * Tests basic validation functionality and violation formatting. */ diff --git a/packages/spine-validation-ts/tests/distinct.test.ts b/packages/spine-validation-ts/tests/distinct.test.ts index 00d1055..d83ae1d 100644 --- a/packages/spine-validation-ts/tests/distinct.test.ts +++ b/packages/spine-validation-ts/tests/distinct.test.ts @@ -1,3 +1,29 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** * Unit tests for `(distinct)` validation option. * diff --git a/packages/spine-validation-ts/tests/goes.test.ts b/packages/spine-validation-ts/tests/goes.test.ts index 0aced5b..db2f4d8 100644 --- a/packages/spine-validation-ts/tests/goes.test.ts +++ b/packages/spine-validation-ts/tests/goes.test.ts @@ -1,3 +1,29 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** * Unit tests for `(goes)` validation option. * diff --git a/packages/spine-validation-ts/tests/integration.test.ts b/packages/spine-validation-ts/tests/integration.test.ts index 9dab819..235fc64 100644 --- a/packages/spine-validation-ts/tests/integration.test.ts +++ b/packages/spine-validation-ts/tests/integration.test.ts @@ -1,3 +1,29 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** * Integration tests combining multiple validation options. * diff --git a/packages/spine-validation-ts/tests/min-max.test.ts b/packages/spine-validation-ts/tests/min-max.test.ts index f8004c9..7b5dfbd 100644 --- a/packages/spine-validation-ts/tests/min-max.test.ts +++ b/packages/spine-validation-ts/tests/min-max.test.ts @@ -1,3 +1,29 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** * Unit tests for `(min)` and `(max)` validation options. * diff --git a/packages/spine-validation-ts/tests/pattern.test.ts b/packages/spine-validation-ts/tests/pattern.test.ts index 2c2d98a..a2519db 100644 --- a/packages/spine-validation-ts/tests/pattern.test.ts +++ b/packages/spine-validation-ts/tests/pattern.test.ts @@ -1,3 +1,29 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** * Unit tests for `(pattern)` validation option. * diff --git a/packages/spine-validation-ts/tests/proto/integration-account.proto b/packages/spine-validation-ts/tests/proto/integration-account.proto index d786d44..0f4473b 100644 --- a/packages/spine-validation-ts/tests/proto/integration-account.proto +++ b/packages/spine-validation-ts/tests/proto/integration-account.proto @@ -1,3 +1,28 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ syntax = "proto3"; package spine.validation.testing.integration; diff --git a/packages/spine-validation-ts/tests/proto/integration-product.proto b/packages/spine-validation-ts/tests/proto/integration-product.proto index daca3cc..a02fe66 100644 --- a/packages/spine-validation-ts/tests/proto/integration-product.proto +++ b/packages/spine-validation-ts/tests/proto/integration-product.proto @@ -1,3 +1,28 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ syntax = "proto3"; package spine.validation.testing.integration; @@ -16,7 +41,6 @@ message Product { // Must follow format 'prod-XXX' (e.g., prod-123). string id = 1 [ (required) = true, - (set_once) = true, (pattern).regex = "^prod-[0-9]+$", (pattern).error_msg = "Product ID must follow format 'prod-XXX'. Provided: `{value}`." ]; diff --git a/packages/spine-validation-ts/tests/proto/integration-user.proto b/packages/spine-validation-ts/tests/proto/integration-user.proto index 9948cd8..01191c5 100644 --- a/packages/spine-validation-ts/tests/proto/integration-user.proto +++ b/packages/spine-validation-ts/tests/proto/integration-user.proto @@ -1,3 +1,28 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ syntax = "proto3"; package spine.validation.testing.integration; @@ -14,7 +39,7 @@ import "spine/options.proto"; message User { option (required_field) = "id | email"; - int32 id = 1 [(set_once) = true, (min).value = "1"]; + int32 id = 1 [(min).value = "1"]; // Must start with a letter and be 2-50 characters (letters, numbers, spaces allowed). string name = 2 [ (required) = true, diff --git a/packages/spine-validation-ts/tests/proto/test-distinct.proto b/packages/spine-validation-ts/tests/proto/test-distinct.proto index e725c47..d728e34 100644 --- a/packages/spine-validation-ts/tests/proto/test-distinct.proto +++ b/packages/spine-validation-ts/tests/proto/test-distinct.proto @@ -1,3 +1,28 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ syntax = "proto3"; package spine.validation.testing.distinct_suite; diff --git a/packages/spine-validation-ts/tests/proto/test-goes.proto b/packages/spine-validation-ts/tests/proto/test-goes.proto index bf6b19e..a2f4487 100644 --- a/packages/spine-validation-ts/tests/proto/test-goes.proto +++ b/packages/spine-validation-ts/tests/proto/test-goes.proto @@ -1,3 +1,28 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ syntax = "proto3"; package spine.validation.testing.goes_suite; diff --git a/packages/spine-validation-ts/tests/proto/test-min-max.proto b/packages/spine-validation-ts/tests/proto/test-min-max.proto index f3b235d..557a542 100644 --- a/packages/spine-validation-ts/tests/proto/test-min-max.proto +++ b/packages/spine-validation-ts/tests/proto/test-min-max.proto @@ -1,3 +1,28 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ syntax = "proto3"; package spine.validation.testing.minmax_suite; diff --git a/packages/spine-validation-ts/tests/proto/test-pattern.proto b/packages/spine-validation-ts/tests/proto/test-pattern.proto index 0b53bf7..633ea14 100644 --- a/packages/spine-validation-ts/tests/proto/test-pattern.proto +++ b/packages/spine-validation-ts/tests/proto/test-pattern.proto @@ -1,3 +1,28 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ syntax = "proto3"; package spine.validation.testing.pattern_suite; diff --git a/packages/spine-validation-ts/tests/proto/test-range.proto b/packages/spine-validation-ts/tests/proto/test-range.proto index 29b4c59..444a89c 100644 --- a/packages/spine-validation-ts/tests/proto/test-range.proto +++ b/packages/spine-validation-ts/tests/proto/test-range.proto @@ -1,3 +1,28 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ syntax = "proto3"; package spine.validation.testing.range_suite; diff --git a/packages/spine-validation-ts/tests/proto/test-required-field.proto b/packages/spine-validation-ts/tests/proto/test-required-field.proto index 46713b4..08c5545 100644 --- a/packages/spine-validation-ts/tests/proto/test-required-field.proto +++ b/packages/spine-validation-ts/tests/proto/test-required-field.proto @@ -1,3 +1,28 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ syntax = "proto3"; package spine.validation.testing.requiredfield_suite; diff --git a/packages/spine-validation-ts/tests/proto/test-required.proto b/packages/spine-validation-ts/tests/proto/test-required.proto index b74fed2..a577bba 100644 --- a/packages/spine-validation-ts/tests/proto/test-required.proto +++ b/packages/spine-validation-ts/tests/proto/test-required.proto @@ -1,3 +1,28 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ syntax = "proto3"; package spine.validation.testing.required_suite; diff --git a/packages/spine-validation-ts/tests/proto/test-validate.proto b/packages/spine-validation-ts/tests/proto/test-validate.proto index 6339eae..42ca70a 100644 --- a/packages/spine-validation-ts/tests/proto/test-validate.proto +++ b/packages/spine-validation-ts/tests/proto/test-validate.proto @@ -1,3 +1,28 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ syntax = "proto3"; package spine.validation.testing.validate_suite; diff --git a/packages/spine-validation-ts/tests/range.test.ts b/packages/spine-validation-ts/tests/range.test.ts index b8e57d5..782cc41 100644 --- a/packages/spine-validation-ts/tests/range.test.ts +++ b/packages/spine-validation-ts/tests/range.test.ts @@ -1,3 +1,29 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** * Unit tests for `(range)` validation option. * diff --git a/packages/spine-validation-ts/tests/required-field.test.ts b/packages/spine-validation-ts/tests/required-field.test.ts index 88b272c..8359ab6 100644 --- a/packages/spine-validation-ts/tests/required-field.test.ts +++ b/packages/spine-validation-ts/tests/required-field.test.ts @@ -1,3 +1,29 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** * Unit tests for `(required_field)` message-level validation option. * diff --git a/packages/spine-validation-ts/tests/required.test.ts b/packages/spine-validation-ts/tests/required.test.ts index dabf562..d1fad3e 100644 --- a/packages/spine-validation-ts/tests/required.test.ts +++ b/packages/spine-validation-ts/tests/required.test.ts @@ -1,3 +1,29 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** * Unit tests for `(required)` and `(if_missing)` validation options. * diff --git a/packages/spine-validation-ts/tests/validate.test.ts b/packages/spine-validation-ts/tests/validate.test.ts index 272090c..c437f73 100644 --- a/packages/spine-validation-ts/tests/validate.test.ts +++ b/packages/spine-validation-ts/tests/validate.test.ts @@ -1,3 +1,29 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /** * Unit tests for `(validate)` and `(if_invalid)` validation options. * From e035bfd1196e2491b44f09bd907c046f2858081e Mon Sep 17 00:00:00 2001 From: Alex Tymchenko Date: Tue, 13 Jan 2026 18:26:45 +0000 Subject: [PATCH 06/16] Use the name of the library correctly. --- packages/spine-validation-ts/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spine-validation-ts/package.json b/packages/spine-validation-ts/package.json index c965da0..fc74c34 100644 --- a/packages/spine-validation-ts/package.json +++ b/packages/spine-validation-ts/package.json @@ -1,7 +1,7 @@ { "name": "@spine-event-engine/validation-ts", "version": "2.0.0-snapshot.1", - "description": "TypeScript validation library for Protobuf messages with Spine validation options", + "description": "TypeScript validation library for Protobuf messages with Spine Validation options", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { From cd01b1f25045fed2ccdd3197a10d121ead29052a Mon Sep 17 00:00:00 2001 From: Alex Tymchenko Date: Wed, 14 Jan 2026 11:42:49 +0000 Subject: [PATCH 07/16] Use the latest version of `options.proto` and update the implementation along with the docs accordingly. --- README.md | 120 +- packages/example/proto/product.proto | 15 +- packages/example/proto/spine/options.proto | 1048 +++++++++++++---- packages/example/proto/user.proto | 3 +- .../DESCRIPTOR_API_GUIDE.md | 15 - .../spine-validation-ts/QUICK_REFERENCE.md | 3 +- packages/spine-validation-ts/README.md | 109 +- packages/spine-validation-ts/package.json | 4 +- .../proto/spine/options.proto | 1048 +++++++++++++---- .../src/options-registry.ts | 19 +- .../src/options/distinct.ts | 107 +- .../spine-validation-ts/src/options/range.ts | 37 +- .../src/options/required-field.ts | 37 +- .../src/options/validate.ts | 30 +- .../spine-validation-ts/src/validation.ts | 3 + .../spine-validation-ts/tests/choice.test.ts | 162 +++ .../tests/integration.test.ts | 8 +- .../tests/proto/integration-account.proto | 8 +- .../tests/proto/integration-product.proto | 16 +- .../tests/proto/integration-user.proto | 2 +- .../tests/proto/spine/options.proto | 1048 +++++++++++++---- .../tests/proto/test-distinct.proto | 2 +- .../tests/proto/test-goes.proto | 2 +- .../tests/proto/test-range.proto | 66 +- .../tests/proto/test-required-field.proto | 16 +- .../tests/proto/test-validate.proto | 6 +- .../tests/validate.test.ts | 8 +- 27 files changed, 3024 insertions(+), 918 deletions(-) create mode 100644 packages/spine-validation-ts/tests/choice.test.ts diff --git a/README.md b/README.md index 82ac370..c975229 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Spine Validation for TypeScript +# Spine Validation β€” TypeScript Client Library -_Runtime validation for Protobuf messages with [Spine Event Engine](https://spine.io/) Validation constraints._ +> Runtime validation in TypeScript for Protobuf messages with [Spine Event Engine](https://spine.io/) Validation. [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) [![Protobuf-ES](https://img.shields.io/badge/protobuf--es-v2-green.svg)](https://github.com/bufbuild/protobuf-es) @@ -11,12 +11,11 @@ A TypeScript validation library for Protobuf messages using Spine validation opt ## πŸ’‘ Why Use This? -### For Spine Event Engine users +### For Spine Event Engine Users **You already have validation rules in your backend.** Now bring them to your TypeScript/JavaScript frontend with zero duplication! -If you're using the [Validation library](https://github.com/SpineEventEngine/validation/) on the server side, -your Protobuf messages already have validation constraints defined using Spine options like `(required)`, `(pattern)`, `(min)`, `(max)`, etc. +If you're using [Spine Event Engine](https://spine.io/) with its Validation library on the server side, your Protobuf messages already have validation constraints defined using Spine options like `(required)`, `(pattern)`, `(min)`, `(max)`, etc. **This library lets you:** - βœ… **Reuse the same validation rules** in your frontend that you defined in your backend. @@ -25,9 +24,7 @@ your Protobuf messages already have validation constraints defined using Spine o - βœ… **Get type-safe validation** with full TypeScript support. - βœ… **Display the same error messages** to users that your backend generates. -**No code duplication. No maintenance burden. Just add this library and validate.** - -### For new users +### For New Users Even if you're not using Spine Event Engine, this library provides a powerful way to add runtime validation to your Protobuf-based TypeScript applications: @@ -41,28 +38,30 @@ Even if you're not using Spine Event Engine, this library provides a powerful wa ## ✨ Features -**Comprehensive Validation support:** +**Comprehensive Validation Support:** -- **`(required)`** - Ensure fields have non-default values. -- **`(pattern)`** - Regex validation for strings. -- **`(min)` / `(max)`** - Numeric bounds with inclusive/exclusive support. -- **`(range)`** - Bounded ranges with bracket notation `[min..max]`. -- **`(distinct)`** - Enforce uniqueness in repeated fields. -- ️ **`(validate)`** - Recursive nested message validation. -- **`(goes)`** - Field dependency constraints. -- **`(required_field)`** - Complex required field combinations with boolean logic. +- **`(required)`** - Ensure fields have non-default values. +- **`(pattern)`** - Regex validation for strings. +- **`(min)` / `(max)`** - Numeric bounds with inclusive/exclusive support. +- **`(range)`** - Bounded ranges with bracket notation `(min..max]`. +- **`(distinct)`** - Enforce uniqueness in repeated fields. +- **`(validate)`** - Recursive nested message validation. +- **`(goes)`** - Field dependency constraints. +- **`(require)`** - Complex required field combinations with boolean logic. +- **`(choice)`** - Require that a oneof group has at least one field set. -**Developer experience:** +**Developer Experience:** - πŸš€ Full TypeScript type safety. - πŸ“ Custom error messages. -- πŸ§ͺ 223+ comprehensive tests. +- πŸ§ͺ 200+ comprehensive tests. - πŸ“š Extensive documentation. - 🎨 Clean, readable error formatting. -### ⚠️ Known limitations +### ⚠️ Known Limitations -- **`(set_once)`** - Not currently supported. This option requires state tracking across multiple validations, which is outside the scope of single-message validation. If you need this feature, please [open an issue](../../issues). +- **`(set_once)`** - Not currently supported. This option requires state tracking across multiple validations, +which is outside the scope of single-message validation. --- @@ -76,7 +75,7 @@ npm install @spine-event-engine/validation-ts @bufbuild/protobuf ### Basic Usage -**Step 1: Define validation in your `.proto` file** +**Step 1:** Define validation options in your `.proto` file: ```protobuf syntax = "proto3"; @@ -101,7 +100,7 @@ message User { } ``` -**Step 2: Use validation in TypeScript** +**Step 2:** Use validation in TypeScript ```typescript import { create } from '@bufbuild/protobuf'; @@ -136,9 +135,9 @@ validation-ts/ β”œβ”€β”€ packages/ β”‚ β”œβ”€β”€ spine-validation-ts/ # πŸ“¦ Main validation package β”‚ β”‚ β”œβ”€β”€ src/ # Source code -β”‚ β”‚ β”œβ”€β”€ tests/ # Tests -β”‚ β”‚ β”œβ”€β”€ proto/ # Spine proto definitions -β”‚ β”‚ └── README.md # The package documentation +β”‚ β”‚ β”œβ”€β”€ tests/ # 200+ comprehensive tests +β”‚ β”‚ β”œβ”€β”€ proto/ # Spine validation proto definitions +β”‚ β”‚ └── README.md # Full package documentation β”‚ β”‚ β”‚ └── example/ # 🎯 Example project β”‚ β”œβ”€β”€ proto/ # Example proto files @@ -150,6 +149,14 @@ validation-ts/ --- +## πŸŽ“ Documentation + +- **[Package README](packages/spine-validation-ts/README.md)** - Complete API documentation and usage guide. +- **[Descriptor API Guide](packages/spine-validation-ts/DESCRIPTOR_API_GUIDE.md)** - Working with message and field descriptors. +- **[Quick Reference](packages/spine-validation-ts/QUICK_REFERENCE.md)** - Cheat sheet for common operations. + +--- + ## πŸ› οΈ Development ### Setup @@ -163,20 +170,20 @@ cd validation-ts npm install ``` -### Build & test +### Build & Test ```bash # Build the validation package npm run build -# Run all tests (223 tests) +# Run all tests npm test # Run the example project npm run example ``` -### Workspace scripts +### Workspace Scripts | Command | Description | |---------|-------------| @@ -188,7 +195,7 @@ npm run example ## πŸ“‹ Validation Options Reference -### Field-level options +### Field-Level Options | Option | Description | Example | |--------|-------------|---------| @@ -199,20 +206,27 @@ npm run example | `(range)` | Bounded numeric range. | `[(range) = "[0..100]"]` | | `(distinct)` | Unique repeated elements. | `[(distinct) = true]` | | `(validate)` | Validate nested messages. | `[(validate) = true]` | -| `(if_invalid)` | Custom error for nested validation. | `[(if_invalid).error_msg = "Invalid address"]` | | `(goes)` | Field dependency. | `[(goes).with = "other_field"]` | -### Message-level options +### Message-Level Options | Option | Description | Example | |--------|-------------|---------| -| `(required_field)` | Required field combinations. | `option (required_field) = "id \| email";` | +| `(require)` | Required field combinations. | `option (require).fields = "id \| email";` | -### Not supported +### Oneof-Level Options + +| Option | Description | Example | +|--------|-------------|---------| +| `(choice)` | Require oneof to have a field set. | `option (choice).required = true;` | + +### Not Supported | Option | Status | Notes | |--------|--------|-------| | `(set_once)` | ❌ Not supported | Requires state tracking across validations. See [limitations](#-known-limitations). | +| `(is_required)` | ❌ Not supported | Deprecated. Use `(choice)` instead. | +| `(required_field)` | ❌ Not supported | Deprecated. Use `(require)` instead. | --- @@ -220,12 +234,38 @@ npm run example The package includes comprehensive test coverage: -- **223 tests** across 10 test suites. +- **200+ tests** across 11 test suites. - **All validation options** thoroughly tested. - **Integration tests** combining multiple constraints. - **Edge cases** and real-world scenarios. - **100% coverage** of validation logic. +Test suites: +- Basic validation. +- Required fields. +- Pattern matching. +- Min/Max constraints. +- Range validation. +- Distinct elements. +- Nested validation. +- Field dependencies (goes). +- Required field combinations (require). +- Oneof validation (choice). +- Integration scenarios. + +--- + +## πŸ“ Example Output + +When validation fails, you get clear, actionable error messages: + +``` +Validation failed: +1. User.name: A value must be set. +2. User.email: Email must be valid. Provided: `invalid-email`. +3. User.age: Value must be at least 0. Provided: -5. +4. User.tags: Values must be distinct. Duplicates found: ["test"]. +``` --- @@ -245,7 +285,7 @@ The validation system is built with extensibility in mind: Contributions are welcome! Please ensure: -1. All tests pass: `npm run test`. +1. All tests pass: `npm test`. 2. Code follows existing patterns. 3. New features include tests. 4. Documentation is updated. @@ -258,6 +298,14 @@ Apache 2.0. --- +## πŸ”— Related Projects + +- [Spine Event Engine](https://spine.io/) - Event-driven framework for CQRS/ES applications. +- [Protobuf-ES](https://github.com/bufbuild/protobuf-es) - Protocol Buffers for ECMAScript. +- [Buf](https://buf.build/) - Modern Protobuf tooling. + +--- +
**Made with ❀️ for the Spine Event Engine ecosystem.** diff --git a/packages/example/proto/product.proto b/packages/example/proto/product.proto index 4127ed4..3701b87 100644 --- a/packages/example/proto/product.proto +++ b/packages/example/proto/product.proto @@ -45,13 +45,12 @@ message Product { (min).error_msg = "Price must be at least {other}. Provided: {value}."]; int32 stock = 5 [(min).value = "0", - (range) = "[0..1000000)"]; + (range).value = "[0..1000000)"]; google.protobuf.Timestamp created_at = 6 [(required) = true]; Category category = 7 [(required) = true, - (validate) = true, - (if_invalid).error_msg = "Category is invalid."]; + (validate) = true]; // Display settings, demonstrates "goes" option. // Text color can only be set when highlight color is set, and vice versa. @@ -60,9 +59,9 @@ message Product { } message Color { - int32 red = 1 [(range) = "[0..255]"]; - int32 green = 2 [(range) = "[0..255]"]; - int32 blue = 3 [(range) = "[0..255]"]; + int32 red = 1 [(range).value = "[0..255]"]; + int32 green = 2 [(range).value = "[0..255]"]; + int32 blue = 3 [(range).value = "[0..255]"]; } message Category { @@ -87,7 +86,7 @@ message PaymentCardNumber { (pattern).regex = "^[0-9]{13,19}$", (pattern).error_msg = "Card number must be 13-19 digits."]; int32 expiry_month = 2 [(required) = true, - (range) = "[1..12]"]; + (range).value = "[1..12]"]; int32 expiry_year = 3 [(required) = true, (min).value = "2024"]; } @@ -105,7 +104,7 @@ message ListProductsRequest { (if_missing).error_msg = "Page number is required."]; int32 page_size = 2 [(required) = true, - (range) = "[1..100]", + (range).value = "[1..100]", (if_missing).error_msg = "Page size is required."]; // Optional search query. diff --git a/packages/example/proto/spine/options.proto b/packages/example/proto/spine/options.proto index 2e05914..2baa543 100644 --- a/packages/example/proto/spine/options.proto +++ b/packages/example/proto/spine/options.proto @@ -1,11 +1,11 @@ /* - * Copyright 2022, TeamDev. All rights reserved. + * Copyright 2024, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -89,14 +89,17 @@ extend google.protobuf.FieldOptions { // The option to mark a field as required. // - // If the field type is a `message`, it must be set to a non-default instance. - // If it is `string` or `bytes`, the value must not be an empty string or an array. - // Other field types are not applicable. - // If the field is repeated, it must have at least one element. + // When this option is set: // - // Unlike the `required` keyword used in Protobuf 2, the option does not affect the transfer - // layer. Even if a message content violates the requirement set by the option, it would still - // be a valid message for the Protobuf library. + // 1. For message or enum fields, the field must be set to a non-default instance. + // 2. For `string` and `bytes` fields, the value must be set to a non-empty string or an array. + // 3. For repeated fields and maps, at least one element must be present. + // + // Other field types are not supported by the option. + // + // Unlike the `required` keyword in Protobuf 2, this option does not affect message + // serialization or deserialization. Even if a message content violates the requirement + // set by the option, it would still be a valid message for the Protobuf library. // // Example: Using `(required)` field validation constraint. // @@ -124,33 +127,155 @@ extend google.protobuf.FieldOptions { // See `PatternOption`. PatternOption pattern = 73820; - // Turns on validation constraint checking for a value of a message, a map, or a repeated field. + // Enables in-depth validation for fields that refer to a message. + // + // This option applies only to fields that reference a message: + // + // 1. Singular message fields. + // 2. Repeated fields of message types. + // 3. Map fields with message types as values. + // + // When set to `true`, the field is valid only if its value satisfies the validation + // constraints defined in the corresponding message type: + // + // 1. For singular message fields: the message must meet its constraints. + // + // Note: default instances are considered valid even if the message has required fields. + // In such cases, it is unclear whether the field is set with an invalid instance or + // simply unset. + // + // Example: + // + // ``` + // // Note that the default instance of `Address` is not valid because the `value` field + // // is mandatory. + // message Address { + // string value = 1 [(required) = true]; + // } + // + // // However, the default instance of `Address` in `Student.address` is valid, despite + // // having `(validate)` constraint that forces the message to meet its constraints. + // // Since the `address` field is optional for `Student`, `(validate)` would allow + // // default instances for this field treating them as "no value set". + // message Student { + // Address address = 1 [(validate) = true]; // implicit `(required) = false`. + // } // - // Default value is `false`. + // // Make the validated field required to avoid this behavior. In this case, `(validate)` + // // continues to bypass default instances, but the `(required)` option will report them. + // message Student { + // Address address = 1 [(validate) = true, (required) = true]; + // } + // ``` // - // If set to `true`, the outer message declaring the annotated field would be valid if: + // 2. For repeated fields: every element in the repeated field must meet the constraints + // of its message type. // - // 1. A message field value satisfies the validation constraints defined in the corresponding - // message type of the field. + // Example: // - // 2. Each value of a map entry satisfies validation constraints. + // ``` + // // Note that the default instance of `PhoneNumber` is not valid because the `value` + // // field is mandatory. + // message PhoneNumber { + // string value = 1 [(required) = true, (pattern).regex = "^\+?[0-9\s\-()]{1,30}$"]; + // } // - // 3. Each item of a repeated field satisfies validation constraints. + // // In contrast to singular fields, the default instances in `repeated` will also be + // // reported by the `(validate)` constraint, with those that do not match the pattern. + // message Student { + // repeated PhoneNumber number = 1 [(validate) = true]; + // } + // ``` + // + // 3. For map fields: each value in the map must meet the constraints of message type. + // Note: Protobuf does not allow messages to be used as map keys. + // + // Example: + // + // ``` + // // Note that the default instance of `PhoneNumber` is not valid because the `value` + // // field is mandatory. + // message PhoneNumber { + // string value = 1 [(required) = true, (pattern).regex = "^\+?[0-9\s\-()]{1,30}$"]; + // } + // + // // In contrast to singular fields, the default instances in `map` values will also be + // // reported by the `(validate)` constraint, with those that do not match the pattern. + // message Contacts { + // map map = 1 [(validate) = true]; + // } + // ``` + // + // If the field contains `google.protobuf.Any`, the option will first attempt to unpack + // the enclosed message, and only then validate it. However, unpacking is not always possible: + // + // 1. The default instance of `Any` is always valid because there is nothing to unpack + // and validate. + // 2. Instances with type URLs that are unknown to the application are also valid. + // + // Unpacking requires a corresponding Java class to deserialize the message, but if + // the application does not recognize the type URL, it has no way to determine which + // class to use. + // + // Such may happen when the packed message comes from a newer app version, an external + // system, or is simply not included in the application’s dependencies. // bool validate = 73821; // See `IfInvalidOption`. - IfInvalidOption if_invalid = 73822; + IfInvalidOption if_invalid = 73822 [deprecated = true]; // See `GoesOption`. GoesOption goes = 73823; // Indicates that a field can only be set once. // - // A typical use-case would include a value of an ID, which doesn't change over the course of - // the life of an entity. + // This option allows the target field to accept assignments only if one of the following + // conditions is met: + // + // 1. The current field value is the default for its type. + // Refer to the official docs on default values: https://protobuf.dev/programming-guides/proto3/#default. + // 2. The current field value equals to the proposed new value. + // + // The option can be applied to the following singular field types: // - // Example: Using `(set_once)` field validation constraint. + // - any message or enum type; + // - any numeric type; + // - `bool`, `string` and `bytes`. + // + // Repeated fields, maps, and fields with explicit `optional` cardinality are not supported. + // Such declarations will lead to build-time errors. For more information on field cardinality, + // refer to the official docs: https://protobuf.dev/programming-guides/proto3/#field-labels. + // + // Assigning a value to a message field can be done in various ways in the generated code. + // It depends on the target language and specific implementation of `protoc`. This option + // doesn't enforce field immutability at the binary representation level. Also, it does not + // prevent the use of Protobuf utilities that can construct new messages without using field + // setters or properties. The primary purpose of this option is to support standard use cases, + // such as assigning values through setters or retrieving them during data merging. + // + // For example, let's take a look on how it works for the generated Java code. The following + // use cases are supported and validated by the option: + // + // 1. Assigning a field value using the field setter. + // 2. Assigning a field value using the field descriptor. + // 3. Merging data from another instance of the message class. + // 4. Merging data from a message's binary representation. + // 5. Merging specific fields (available for Message fields). + // + // Unsupported Java use cases include: + // + // 1. Constructing a message using the `DynamicMessage` class. + // 2. Merging data from messages provided by third-party Protobuf implementations. + // 3. Clearing a specific field or an entire message. + // + // For unsupported scenarios, the option performs no validation. It does not throw errors + // or print warnings. + // + // A typical use case involves a field, such as an ID, which remains constant throughout + // the lifecycle of an entity. + // + // Example: using `(set_once)` field validation constraint. // // message User { // UserId id = 1 [(set_once) = true]; @@ -158,48 +283,74 @@ extend google.protobuf.FieldOptions { // // Once set, the `id` field cannot be changed. // + // Use `(if_set_again).error_msg` option to specify a custom error message that will be used for + // composing the error upon attempting to re-assign the field value. Refer to the documentation + // for the corresponding option for an example of its usage. + // bool set_once = 73824; - // The option to mark a `repeated` field as a collection of unique elements. + // The option to enforce uniqueness for collection fields. // - // Example: Using `(distinct)` constraint for a repeated field. + // When the option is set to `true`, the behavior is as follows: + // + // 1. For `repeated` fields: all elements must be unique. + // 2. For `map` fields: while the map keys are inherently unique, all associated values + // must also be unique. + // + // Other field types are not supported. + // + // Uniqueness is determined by comparing the elements themselves, using their full equality. + // For example, in Java, it is defined by the `equals()` method. No special cases are applied, + // such as comparing only specific fields like IDs. + // + // Example: using `(distinct)` constraint for a `repeated` field. // // message Blizzard { // // // All snowflakes must be unique in this blizzard. // // // // Attempting to add a snowflake that is equal to an existing one would result - // // in constraint violation error. + // // in a constraint violation error. // // - // repeated Snowflake = 1 [(distinct) = true]; + // repeated Snowflake snowflakes = 1 [(distinct) = true]; + // } + // + // Example: using `(distinct)` constraint for a `map` field. + // + // message UniqueEmails { + // + // // The associated email values must be unique. + // // + // // Attempting to add a key/value pair where the `Email` value duplicates + // // an existing one would result in a constraint violation error. + // // + // map emails = 1 [(distinct) = true]; // } // bool distinct = 73825; - // The option to indicate that a numeric field is required to have a value which belongs - // to the specified bounded range. For unbounded ranges, please use `(min)` and `(max) options. + // Reserved 73826 for deleted `range` option, which had `string` type. + + // Defines the error message used if a `set_once` field is set again. // - // The range can be open (not including the endpoint) or closed (including the endpoint) on - // each side. Open endpoints are indicated using a parenthesis (`(`, `)`). Closed endpoints are - // indicated using a square bracket (`[`, `]`). + // Applies only to the fields marked as `set_once`. // - // Example: Defining ranges of numeric values. + IfSetAgainOption if_set_again = 73827; + + // Defines the error message used if a `distinct` field has duplicates. // - // message NumRanges { - // int32 hour = 1 [(range) = "[0..24)"]; - // uint32 minute = 2 [(range) = "[0..59]"]; - // float degree = 3 [(range) = "[0.0..360.0)"]; - // double angle = 4 [(range) = "(0.0..180.0)"]; - // } + // Applies only to the repeated fields marked as `distinct`. // - // NOTE: That definition of ranges must be consistent with the type they constrain. - // An range for an integer field must be defined with integer endpoints. - // A range for a floating point field must be defined with decimal separator (`.`), - // even if the endpoint value does not have a fractional part. + IfHasDuplicatesOption if_has_duplicates = 73828; + + // The option to indicate that a numeric field is required to have a value which belongs + // to the specified bounded range. + // + // For unbounded ranges, please use `(min)` and `(max) options. // - string range = 73826; + RangeOption range = 73829; - // Reserved 73827 to 73849 for future validation options. + // Reserved 73830 to 73849 for future validation options. // API Annotations //----------------- @@ -232,8 +383,8 @@ extend google.protobuf.FieldOptions { // An API bearing this annotation is exempt from any compatibility guarantees made by its // containing library. Note that the presence of this annotation implies nothing about the // quality of the API in question, only the fact that it is not "API-frozen." - // It is generally safe for applications to depend on beta APIs, at the cost of some extra work - // during upgrades. + // It is generally safe for applications to depend on beta APIs, at the cost of + // some extra work during upgrades. // bool beta = 73853; @@ -259,9 +410,9 @@ extend google.protobuf.FieldOptions { // // All column fields are considered optional by the framework. // - // Currently, only entities of projection and process manager type are eligible for having - // columns (see `EntityOption`). For all other message types the column declarations are - // ignored. + // Currently, only entities of projection and process manager type are + // eligible for having columns (see `EntityOption`). + // For all other message types the column declarations are ignored. // // The `repeated` and `map` fields cannot be columns. // @@ -274,14 +425,13 @@ extend google.protobuf.FieldOptions { extend google.protobuf.OneofOptions { - // Marks a `oneof` group, in which one field *must* be set. - // - // Alternative to `(required_field)` with all the field in the group joined with the OR - // operator. - // - bool is_required = 73891; + // Deprecated: use the `(choice)` option instead. + bool is_required = 73891 [deprecated = true]; - // Reserved 73892 to 73899 for future options. + // Controls whether a `oneof` group must always have one of its fields set. + ChoiceOption choice = 73892; + + // Reserved 73893 to 73899 for future options. } extend google.protobuf.MessageOptions { @@ -289,46 +439,38 @@ extend google.protobuf.MessageOptions { // Validation Constraints //------------------------ - // The default format string for validation error message text. - // - // This option extends message types that extend `FieldOptions` - // The number of parameters and their types are determined by the type of field options. - // - // Usage of this value is deprecated. Along with the old `msg_format`s, it exists to support - // the old validation library. The new version of the validation library, which does not lie in - // the `base` repository, constructs the default error messages separately when creating - // language-agnostic validation rules. - // - string default_message = 73901 [deprecated = true]; - - // The constraint to require at least one of the fields or a combination of fields. + // The default validation error message. // - // Unlike the `required` field constraint which always require corresponding field, - // this message option allows to require alternative fields or a combination of them as - // an alternative. Field names and `oneof` group names are acceptable. + // Please note, this option is intended for INTERNAL USE only. It applies to message types + // that extend `FieldOptions` and is not intended for external usage. // - // Field names are separated using the pipe (`|`) symbol. The combination of fields is defined - // using the ampersand (`&`) symbol. + // If a validation option detects a constraint violation and no custom error message is defined + // for that specific option, it will fall back to the message specified by `(default_message)`. // - // Example: Pipe syntax for defining alternative required fields. + // For example, here is how to declare the default message for `(goes)` option: // - // message PersonName { - // option (required_field) = "given_name|honorific_prefix & family_name"; + // ``` + // message GoesOption { + // // The default error message. + // option (default_message) = "The field `${goes.companion}` must also be set when `${field.path}` is set."; + // } + // ``` // - // string honorific_prefix = 1; - // string given_name = 2; - // string middle_name = 3; - // string family_name = 4; - // string honorific_suffix = 5; - // } + // Note: The placeholders available within `(default_message)` depend solely on the particular + // validation option that uses it. Each option may define its own set of placeholders, or none. // - string required_field = 73902; + string default_message = 73901 [(internal) = true]; + + // Deprecated: use the `(require)` option instead. + string required_field = 73902 [deprecated = true]; // See `EntityOption`. EntityOption entity = 73903; // An external validation constraint for a field. // + // WARNING: This option is deprecated and is scheduled for removal in Spine v2.0.0. + // // Allows to re-define validation constraints for a message when its usage as a field of // another type requires alternative constraints. This includes definition of constraints for // a message which does not have them defined within the type. @@ -336,7 +478,7 @@ extend google.protobuf.MessageOptions { // A target field of an external constraint should be specified using a fully-qualified // field name (e.g. `mypackage.MessageName.field_name`). // - // Example: Defining external validation constraint. + // Example: defining external validation constraint. // // package io.spine.example; // @@ -393,7 +535,7 @@ extend google.protobuf.MessageOptions { // External validation constraints can be applied to fields of several types. // To do so, separate fully-qualified references to these fields with comma. // - // Example: External validation constraints for multiple fields. + // Example: external validation constraints for multiple fields. // // // External validation constraint for requiring a new value in renaming commands. // message RequireNewName { @@ -408,7 +550,7 @@ extend google.protobuf.MessageOptions { // Spine Model Compiler does not check such an "overwriting". // See the issue: https://github.com/SpineEventEngine/base/issues/318. // - string constraint_for = 73904; + string constraint_for = 73904 [deprecated = true]; // Reserved 73905 to 73910 for future validation options. @@ -446,7 +588,7 @@ extend google.protobuf.MessageOptions { // Specifies a characteristic inherent in the the given message type. // - // Example: Using `(is)` message option. + // Example: using `(is)` message option. // // message CreateProject { // option (is).java_type = "ProjectCommand"; @@ -456,8 +598,10 @@ extend google.protobuf.MessageOptions { // // In the example above, `CreateProject` message is a `ProjectCommand`. // - // To specify a characteristic for every message in a `.proto` file, use `(every_is)` file - // option. If both `(is)` and `(every_is)` options are found, both are applied. + // To specify a characteristic for every message in a `.proto` file, + // please use `(every_is)` file option. + // + // If both `(is)` and `(every_is)` options are applicable for a type, both are applied. // // When targeting Java, specify the name of a Java interface to be implemented by this // message via `(is).java_type`. @@ -476,7 +620,10 @@ extend google.protobuf.MessageOptions { // CompareByOption compare_by = 73923; - // Reserved 73924 to 73938 for future options. + // The constraint to require at least one of the fields or combinations of fields. + RequireOption require = 73924; + + // Reserved 73925 to 73938 for future options. // Reserved 73939 and 73940 for the deleted options `events` and `rejections`. } @@ -495,22 +642,39 @@ extend google.protobuf.FileOptions { // For more information on such restrictions please see the documentation of // the type option called `internal_type`. // + // If a file contains a declaration of a `service`, this option will NOT be applied to it. + // A service is not a data type, and therefore, this option does not apply to it. + // Internal services are not supported. + // bool internal_all = 73942; // Indicates a file which contains elements of Service Provider Interface (SPI). + // + // This option applies to messages, enums, and services. + // bool SPI_all = 73943; - // Indicates a public API that can change at any time, and has no guarantee of - // API stability and backward-compatibility. + // Indicates a file declaring public data type API which that can change at any time, + // has no guarantee of API stability and backward-compatibility. + // + // If a file contains a declaration of a `service`, this option will NOT be applied to it. + // A service is not a data type, and therefore, this option does not apply to it. + // Experimental services are not supported. + // bool experimental_all = 73944; - // Signifies that a public API is subject to incompatible changes, or even removal, + // Signifies that a public data type API is subject to incompatible changes, or even removal, // in a future release. + // + // If a file contains a declaration of a `service`, this option will NOT be applied to it. + // A service is not a data type, and therefore, this option does not apply to it. + // Beta services are not supported. + // bool beta_all = 73945; // Specifies a characteristic common for all the message types in the given file. // - // Example: Marking all the messages using the `(every_is)` file option. + // Example: marking all the messages using the `(every_is)` file option. // ``` // option (every_is).java_type = "ProjectCommand"; // @@ -528,13 +692,13 @@ extend google.protobuf.FileOptions { // In the example above, `CreateProject`, `CreateProject.WithAssignee`, and `DeleteProject` // messages are `ProjectCommand`-s. // - // To specify a characteristic for a single message, use `(is)` message option. If both `(is)` - // and `(every_is)` options are found, both are applied. + // To specify a characteristic for a single message, please use `(is)` message option. + // If both `(is)` and `(every_is)` options are applicable for a type, both are applied. // // When targeting Java, specify the name of a Java interface to be implemented by these // message types via `(every_is).java_type`. // - IsOption every_is = 73946; + EveryIsOption every_is = 73946; // Reserved 73947 to 73970 for future use. } @@ -553,22 +717,15 @@ extend google.protobuf.ServiceOptions { // Validation Option Types //--------------------------- -// Defines the error handling for `required` field with no value set. +// Defines the error message used if a `required` field is not set. // // Applies only to the fields marked as `required`. -// Validation error message is composed according to the rules defined by this option. -// -// Example: Using the `(if_missing)` option. -// -// message Holder { -// MyMessage field = 1 [(required) = true, -// (if_missing).error_msg = "This field is required."]; -// } // message IfMissingOption { // The default error message. - option (default_message) = "A value must be set."; + option (default_message) = "The field `${parent.type}.${field.path}`" + " of the type `${field.type}` must have a non-default value."; // A user-defined validation error format message. // @@ -577,35 +734,93 @@ message IfMissingOption { string msg_format = 1 [deprecated = true]; // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.type}` – the fully qualified name of the field type. + // 3. `${parent.type}` – the fully qualified name of the validated message. + // + // The placeholders will be replaced at runtime when the error is constructed. + // + // Example: using the `(if_missing)` option. + // + // message Student { + // Name name = 1 [(required) = true, + // (if_missing).error_msg = "The `${field.path}` field is mandatory for `${parent.type}`."]; + // } + // string error_msg = 2; } -// The field value must be greater than or equal to the given minimum number. -// -// Is applicable only to numbers. -// Repeated fields are supported. +// Indicates that the numeric field must be greater than or equal to the specified value. // -// Example: Defining lower boundary for a numeric field. -// -// message KelvinTemperature { -// double value = 1 [(min) = { -// value = "0.0" -// exclusive = true -// error_msg = "Temperature cannot reach {other}K, but provided {value}." -// }]; -// } +// The option supports all singular and repeated numeric fields. // message MinOption { - // The default error message format string. - // - // The format parameters are: - // 1) "or equal to " string (if the `exclusive` parameter is false) or an empty string; - // 2) the minimum number. - // - option (default_message) = "The number must be greater than %s%s."; + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}`" + " must be ${min.operator} ${min.value}. The passed value: `${field.value}`."; // The string representation of the minimum field value. + // + // ## Integer and floating-point values + // + // A minimum value for an integer field must use an integer number. Specifying a decimal + // number is not allowed, even if it has no fractional part (e.g., `5.0` is invalid). + // + // A minimum value for a floating-point field must use a decimal separator (`.`), even if + // the value has no fractional part. An exponent part represented by `E` or `e`, followed + // by an optional sign and digits is allowed (e.g., `1.2E3`, `0.5e-2`). + // + // Example: defining minimum values for integer and floating-point fields. + // + // message Measurements { + // int32 temperature = 1 [(min).value = "0"]; + // uint32 mass = 2 [(min).value = "5"]; + // float degree = 3 [(min).value = "0.0"]; + // double angle = 4 [(min).value = "30.0"]; + // float pressure = 5 [(min).value = "950.0E-2"]; + // } + // + // ## Field Type Limitations + // + // A minimum value must not fall below the limits of the field type. + // + // Example: invalid values that fall below the field type limits. + // + // message OverflowMeasurements { + // float pressure = 1 [(min).value = "-5.5E38"]; // Falls below the `float` minimum. + // uint32 mass = 2 [(min).value = "-5"]; // Falls below the `uint32` minimum. + // } + // + // ## Field references + // + // Instead of numeric literals, you can reference another numeric field. + // At runtime, the field’s value will be used as the bound. Nested fields are supported. + // + // Example: defining minimum values using field references. + // + // message Measurements { + // + // int32 min_length = 1; + // int32 length = 2 [(min).value = "min_length"]; + // + // Limits limits = 3; + // int32 temperature = 4 [(min).value = "limits.min_temperature"]; + // float pressure = 5 [(min).value = "limits.min_pressure"]; + // } + // + // message Limits { + // int32 min_temperature = 1; + // float min_pressure = 2; + // } + // + // Note: Field type compatibility is not required in this case; the value is + // automatically converted. However, only numeric fields can be referenced. + // Repeated and map fields are not supported. + // string value = 1; // Specifies if the field should be strictly greater than the specified minimum. @@ -617,36 +832,92 @@ message MinOption { // A user-defined validation error format message. string msg_format = 3 [deprecated = true]; - // A user-defined validation error format message. + // A user-defined error message. // - // May include tokens `{value}`β€”for the actual value of the field, and `{other}`β€”for - // the threshold value. The tokens will be replaced at runtime when the error is constructed. + // The specified message may include the following placeholders: + // + // 1. `${field.value}` - the field value. + // 2. `${field.path}` – the field path. + // 3. `${field.type}` – the fully qualified name of the field type. + // 4. `${parent.type}` – the fully qualified name of the validated message. + // 5. `${min.value}` – the specified minimum `value`. For referenced fields, the actual + // field value is also printed in round brackets along with the reference itself. + // 6. `${min.operator}` – if `exclusive` is set to `true`, this placeholder equals to ">". + // Otherwise, ">=". + // + // The placeholders will be replaced at runtime when the error is constructed. // string error_msg = 4; } -// The field value must be less than or equal to the given maximum number. +// Indicates that the numeric field must be less than or equal to the specified value. // -// Is applicable only to numbers. -// Repeated fields are supported. -// -// Example: Defining upper boundary for a numeric field. -// -// message Elevation { -// double value = 1 [(max).value = "8848.86"]; -// } +// The option supports all singular and repeated numeric fields. // message MaxOption { - // The default error message format string. - // - // The format parameters are: - // 1) "or equal to " string (if the `exclusive` parameter is false) or an empty string; - // 2) the maximum number. - // - option (default_message) = "The number must be less than %s%s."; + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}`" + " must be ${max.operator} ${max.value}. The passed value: `${field.value}`."; // The string representation of the maximum field value. + // + // ## Integer and floating-point values + // + // A maximum value for an integer field must use an integer number. Specifying a decimal + // number is not allowed, even if it has no fractional part (e.g., `5.0` is invalid). + // + // A maximum value for a floating-point field must use a decimal separator (`.`), even if + // the value has no fractional part. An exponent part represented by `E` or `e`, followed + // by an optional sign and digits is allowed (e.g., `1.2E3`, `0.5e-2`). + // + // Example: defining maximum values for integer and floating-point fields. + // + // message Measurements { + // int32 temperature = 1 [(max).value = "270"]; + // uint32 mass = 2 [(max).value = "1200"]; + // float degree = 3 [(max).value = "360.0"]; + // double angle = 4 [(max).value = "90.0"]; + // float pressure = 5 [(max).value = "1050.0E-2"]; + // } + // + // ## Field Type Limitations + // + // A maximum value must not exceed the limits of the field type. + // + // Example: invalid values that exceed the field type limits. + // + // message OverflowMeasurements { + // float pressure = 1 [(min).value = "5.5E38"]; // Exceeds the `float` maximum. + // int32 mass = 2 [(min).value = "2147483648"]; // Exceeds the `int32` maximum. + // } + // + // ## Field references + // + // Instead of numeric literals, you can reference another numeric field. + // At runtime, the field’s value will be used as the bound. Nested fields are supported. + // + // Example: defining maximum values using field references. + // + // message Measurements { + // + // int32 max_length = 1; + // int32 length = 2 [(max).value = "max_length"]; + // + // Limits limits = 3; + // int32 temperature = 4 [(max).value = "limits.max_temperature"]; + // float pressure = 5 [(max).value = "limits.max_pressure"]; + // } + // + // message Limits { + // int32 max_temperature = 1; + // float max_pressure = 2; + // } + // + // Note: Field type compatibility is not required in this case; the value is + // automatically converted. However, only numeric fields can be referenced. + // Repeated and map fields are not supported. + // string value = 1; // Specifies if the field should be strictly less than the specified maximum @@ -658,34 +929,55 @@ message MaxOption { // A user-defined validation error format message. string msg_format = 3 [deprecated = true]; - // A user-defined validation error format message. + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.value}` - the field value. + // 3. `${field.type}` – the fully qualified name of the field type. + // 4. `${parent.type}` – the fully qualified name of the validated message. + // 5. `${max.value}` – the specified maximum `value`. For referenced fields, the actual + // field value is also printed in round brackets along with the reference itself. + // 6. `${max.operator}` – if `exclusive` is set to `true`, this placeholder equals to "<". + // Otherwise, "<=". // - // May include tokens `{value}`β€”for the actual value of the field, and `{other}`β€”for - // the threshold value. The tokens will be replaced at runtime when the error is constructed. + // The placeholders will be replaced at runtime when the error is constructed. // string error_msg = 4; } // A string field value must match the given regular expression. -// Is applicable only to strings. -// Repeated fields are supported. // -// Example: Using the `(pattern)` option. +// This option is applicable only to string fields, +// including those that are repeated. +// +// Example: using the `(pattern)` option. // // message CreateAccount { // string id = 1 [(pattern).regex = "^[A-Za-z0-9+]+$", -// (pattern).error_msg = "ID must be alphanumerical. Provided: `{value}`."]; +// (pattern).error_msg = "ID must be alphanumerical in `${parent.type}`. Provided: `${field.value}`."]; // } // message PatternOption { - // The default error message format string. + // The default error message. + option (default_message) = "The `${parent.type}.${field.path}` field" + " must match the regular expression `${regex.pattern}` (modifiers: `${regex.modifiers}`)." + " The passed value: `${field.value}`."; + + // The regular expression that the field value must match. // - // The format parameter is the regular expression to which the value must match. + // Please use the Java regex dialect for the syntax baseline: + // https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/regex/Pattern.html + // + // Note: in Java, regex patterns are not wrapped in explicit delimiters like in Perl or PHP. + // Instead, the pattern is provided as a string literal. Therefore, `/` symbol does not need + // to be escaped. + // + // The provided string literal is passed directly to the regex engine. So, it must be exactly + // what you would supply to the `java.util.regex.Pattern.compile()` method. // - option (default_message) = "The string must match the regular expression `%s`."; - - // The regular expression to match. string regex = 1; reserved 2; @@ -699,8 +991,16 @@ message PatternOption { // A user-defined validation error format message. // - // May include tokens `{value}`β€”for the actual value of the field, and `{other}`β€”for - // the threshold value. The tokens will be replaced at runtime when the error is constructed. + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.value}` - the field value. + // 3. `${field.type}` – the fully qualified name of the field type. + // 4. `${parent.type}` – the fully qualified name of the validated message. + // 5. `${regex.pattern}` – the specified regex pattern. + // 6. `${regex.modifiers}` – the specified modifiers, if any. For example, `[dot_all, unicode]`. + // + // The placeholders will be replaced at runtime when the error is constructed. // string error_msg = 5; @@ -714,8 +1014,8 @@ message PatternOption { // // By default, the dot does not match line break characters. // - // May also be known in some platforms as "single line" mode and be encoded with the `s` - // flag. + // May also be known in some platforms as "single line" mode and be encoded with + // the `s` flag. // bool dot_all = 1; @@ -757,39 +1057,60 @@ message PatternOption { } // Specifies the message to show if a validated field happens to be invalid. -// Is applicable only to messages. -// Repeated fields are supported. -// -// Example: Using the `(if_invalid)` option. // -// message Holder { -// MyMessage field = 1 [(validate) = true, -// (if_invalid).error_msg = "The field is invalid."]; -// } +// It is applicable only to fields marked with `(validate)`. // message IfInvalidOption { - // The default error message for the field. - option (default_message) = "The message must have valid properties."; + // Do not specify error message for `(validate)`, it is no longer used by + // the validation library. + option deprecated = true; + + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}` of the type" + " `${field.type}` is invalid. The field value: `${field.value}`."; // A user-defined validation error format message. + // + // Use `error_msg` instead. + // string msg_format = 1 [deprecated = true]; - // A user-defined validation error format message. + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.value}` - the field value. + // 3. `${field.type}` – the fully qualified name of the field type. + // 4. `${parent.type}` – the fully qualified name of the field declaring type. // - // May include the token `{value}` for the actual value of the field. The token will be replaced - // at runtime when the error is constructed. + // The placeholders will be replaced at runtime when the error is constructed. + // + // Example: using the `(if_invalid)` option. + // + // message Transaction { + // TransactionDetails details = 1 [(validate) = true, + // (if_invalid).error_msg = "The `${field.path}` field is invalid."]; + // } // string error_msg = 2; } -// Specifies that a message field can be present only if another field is present. +// Specifies that another field must be present if the option's target field is present. // // Unlike the `required_field` that handles combination of required fields, this option is useful -// when it is needed to say that an optional field makes sense only when another optional field is -// present. +// when it is needed to say that an optional field makes sense only when another optional field +// is present. +// +// This option can be applied to the same field types as `(required)`, including both the +// target field and its companion. Supported field types are: +// +// - Messages and enums. +// - Repeated fields and maps. +// - `string` and `bytes`. // -// Example: Requiring mutual presence of optional fields. +// Example: requiring mutual presence of optional fields. // // message ScheduledItem { // ... @@ -799,23 +1120,27 @@ message IfInvalidOption { // message GoesOption { - // The default error message format string. - // - // The first parameter is the name of the field for which we specify the option. - // The second parameter is the name of the field set in the "with" value. - // - option (default_message) = "The field `%s` can only be set when the field `%s` is defined."; + // The default error message. + option (default_message) = "The field `${goes.companion}` must also be set when `${field.path}`" + " is set in `${parent.type}`."; - // A name of the field required for presence of the field for which we set the option. + // The name of the companion field whose presence is required for this field to be valid. string with = 1; // A user-defined validation error format message. string msg_format = 2 [deprecated = true]; - // A user-defined validation error format message. + // A user-defined error message. // - // May include the token `{value}` for the actual value of the field. The token will be replaced - // at runtime when the error is constructed. + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.value}` – the field value. + // 3. `${field.type}` – the fully qualified name of the field type. + // 4. `${parent.type}` – the fully qualified name of the validated message. + // 5. `${goes.companion}` – the name of the companion specified in `with`. + // + // The placeholders will be replaced at runtime when the error is constructed. // string error_msg = 3; } @@ -826,7 +1151,7 @@ message EntityOption { // A type of an entity for state of which the message is defined. enum Kind { option allow_alias = true; - + // Reserved for errors. KIND_UNKNOWN = 0; @@ -879,45 +1204,68 @@ message EntityOption { Visibility visibility = 2; } -// Defines a marker for a given type or a set of types. +// Defines a common type for message types declared in the same proto file. // -// The option may be used in two modes: -// - with the marker code generation; -// - without the marker code generation. +// The nature of the type depends on the target programming language. +// For example, the `java_type` property defines a name of the Java interface common +// to all message classes generated for the proto file having this option. // -// When used with the code generation, language-specific markers are generated by the Protobuf -// compiler. Otherwise, it is expected that the user creates such markers manually. +// The option triggers creation of the common type if the `generate` property is set to true. +// Otherwise, it is expected that the user provides the reference to an existing type. // -message IsOption { +message EveryIsOption { - // Enables the generation of marker interfaces. + // Enables the generation of the common type. + // + // The default value is `false`. // - // The generation is disabled by default. bool generate = 1; - // The reference to a Java interface. + // The reference to a Java top-level interface. // - // May be an fully-qualified or a simple name. In the latter case, the interface should belong - // to the same Java package as the message class which implements this interface. + // The interface cannot be nested into a class or another interface. + // If a nested interface is provided, the code generation should fail the build process. // - // The framework does not ensure the referenced type exists. - // If the generation is disabled, the Java type is used as-is. Otherwise, a corresponding Java - // interface is generated. + // The value may be a fully-qualified or a simple name. // - // A generated interface has no declared methods and extends `com.google.protobuf.Message`. + // When a simple name is set, it is assumed that the interface belongs to + // the package of the generated message classes. // - // The `.java` file is placed alongside with the code generated by the proto-to-java compiler. + // If the value of the `generate` field is set to `false` the referenced interface must exist. + // Otherwise, a compilation error will occur. // - // If fully-qualified name given, the package of the generated type matches the fully-qualified - // name. When a simple name is set in the option, the package of the interface matches the - // package of the message class. + // If the value of the `generate` field is set to `true`, the framework will + // generate the interface using the given name and the package as described above. // - // If both `(is)` and `(every_is)` options specify a Java interface, the message class - // implements both interfaces. + // The generated interface will extend `com.google.protobuf.Message` and + // will have no declared methods. // string java_type = 2; } +// Defines additional type for a message type in which this option is declared. +// +// The nature of the type depends on the target programming language. +// For example, the `java_type` property defines a name of the Java interface which +// the generated message class will implement. +// +message IsOption { + + // The reference to a Java top-level interface. + // + // The interface cannot be nested into a class or another interface. + // If a nested interface is provided, the code generation should fail the build process. + // + // The value may be a fully-qualified or a simple name. + // + // When a simple name is set, it is assumed that the interface belongs to + // the package of the generated message classes. + // + // The referenced interface must exist. Otherwise, a compilation error will occur. + // + string java_type = 1; +} + // Defines the way to compare two messages of the same type to one another. // // Comparisons can be used to sort values. @@ -930,23 +1278,25 @@ message CompareByOption { // // The allowed field types are: // - any number type; - // - `bool` (false is less than true); + // - `bool` (`false` is less than `true`); // - `string` (in the order of respective Unicode values); // - enumerations (following the order of numbers associated with each constant); - // - messages marked with `(compare_by)`. + // - local messages (generated within the current build) marked with `(compare_by)`; + // - external messages (from dependencies), which either marked with `(compare_by)` + // OR have a comparator provided in `io.spine.compare.ComparatorRegistry`. // - // Other types are not permitted. Neither are repeated and map fields. Such declarations can - // lead to build-time errors. + // Other types are not permitted. Repeated or map fields are not permitted either. + // Such declarations will lead to build-time errors. // - // To refer to nested fields, separate the field names with a dot (`.`). No fields in the path - // can be repeated or maps. + // To refer to nested fields, separate the field names with a dot (`.`). + // No fields in the path can be repeated or maps. // // When multiple field paths are specified, comparison is executed in the order of reference. - // For example, specifying ["seconds", "nanos"] makes the comparison mechanism prioritize + // For example, specifying `["seconds", "nanos"]` makes the comparison mechanism prioritize // the `seconds` field and refer to `nanos` only when `seconds` are equal. // - // Note. When comparing message fields, a non-set message is always less than a set message. - // But if a message is set to a default value, the comparison falls back to + // NOTE: When comparing fields with a message type, a non-set message is always less than + // a set message. But if a message is set to a default value, the comparison falls back to // the field-wise comparison, i.e. number values are treated as zeros, `bool` β€” as `false`, // and so on. // @@ -956,3 +1306,261 @@ message CompareByOption { // the lower, enums β€” from the last number value to the 0th value, etc. bool descending = 2; } + +// Defines the error message used if a `set_once` field is set again. +// +// Applies only to the fields marked as `set_once`. +// +message IfSetAgainOption { + + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}` of the type" + " `${field.type}` already has the value `${field.value}` and cannot be reassigned" + " to `${field.proposed_value}`."; + + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.type}` – the fully qualified name of the field type. + // 3. `${field.value}` – the current field value. + // 4. `${field.proposed_value}` – the value, which was attempted to be set. + // 5. `${parent.type}` – the fully qualified name of the validated message. + // + // The placeholders will be replaced at runtime when the error is constructed. + // + // Example: using the `(set_once)` option. + // + // message User { + // UserId id = 1 [(set_once) = true, + // (if_set_again).error_msg = "A student ID is used as a permanent identifier within academic system, and cannot be re-assigned."]; + // } + // + string error_msg = 1; +} + +// Defines the error message used if a `distinct` field has duplicates. +// +// Applies only to the `repeated` and `map` fields marked with the `(distinct)` option. +// +message IfHasDuplicatesOption { + + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}` of the type" + " `${field.type}` must not contain duplicates." + " The duplicates found: `${field.duplicates}`."; + + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.type}` – the fully qualified name of the field type. + // 3. `${field.value}` – the field value (the whole collection). + // 4. `${field.duplicates}` – the duplicates found (elements that occur more than once). + // 5. `${parent.type}` – the fully qualified name of the validated message. + // + // The placeholders will be replaced at runtime when the error is constructed. + // + // Example: using the `(distinct)` option. + // + // message Blizzard { + // repeated Snowflake = 1 [(distinct) = true, + // (if_has_duplicates).error_msg = "Every snowflake must be unique! The duplicates found: `${field.duplicates}`."]; + // } + // + string error_msg = 1; +} + +// Indicate that the numeric field must belong to the specified bounded range. +// +// For unbounded ranges, please use `(min)` and `(max) options. +// +// The option supports all singular and repeated numeric fields. +// +message RangeOption { + + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}` must be within" + " the following range: `${range.value}`. The passed value: `${field.value}`."; + + // The string representation of the range. + // + // A range consists of two bounds: a lower bound and an upper bound. These bounds are + // separated by either the `..` or ` .. ` delimiter. Each bound can be either open + // or closed. The format of the bounds and the valid values depend on the type of field. + // + // ## Bound Types + // + // - Closed bounds include the endpoint and are indicated using square brackets (`[`, `]`). + // Example: `[0..10]` represents values from 0 to 10, inclusive. + // + // - Open bounds exclude the endpoint and are indicated using parentheses (`(`, `)`). + // Example: `(0..10)` represents values strictly between 0 and 10. + // + // The lower bound must be less than or equal to the upper bound. + // + // ## Integer Fields + // + // A range for an integer field must use integer numbers. Specifying a decimal number + // is not allowed, even if it has no fractional part (e.g., `5.0` is invalid). + // + // Example: defining ranges for integer fields. + // + // message Measurements { + // int32 length = 1 [(range).value = "[0..100)"]; + // uint32 mass = 2 [(range).value = "(0..100]"]; + // } + // + // ## Floating-Point Fields + // + // A range for a floating-point field must use a decimal separator (`.`), even if the value + // has no fractional part. An exponent part represented by `E` or `e`, followed by an optional + // sign and digits is allowed (e.g., `1.2E3`, `0.5e-2`). + // + // Example: defining ranges for floating-point fields. + // + // message Measurements { + // float degree = 1 [(range).value = "[0.0 .. 360.0)"]; + // double angle = 2 [(range).value = "(0.0 .. 180.0)"]; + // float pressure = 3 [(range).value = "[950.0E-2 .. 1050.0E-2]"]; + // } + // + // ## Field Type Limitations + // + // A range must not exceed the limits of the field type. + // + // Example: invalid ranges that exceed the field type limits. + // + // message OverflowMeasurements { + // float price = 1 [(range).value = "[0, 5.5E38]"]; // Exceeds the `float` maximum. + // uint32 size = 2 [(range).value = "[-5; 10]"]; // Falls below the `uint32` minimum. + // } + // + // ## Field references + // + // Instead of numeric literals, you can reference another numeric field. + // At runtime, the field’s value will be used as the bound. Nested fields are supported. + // + // Example: defining ranges using field references. + // + // message Measurements { + // + // int32 max_length = 1; + // int32 length = 2 [(range).value = "[1 .. max_length"]; + // + // Limits limits = 3; + // int32 temperature = 4 [(range).value = "[limits.low_temp .. limits.high_temp]"]; + // } + // + // message Limits { + // int32 low_temp = 1; + // int32 high_temp = 2; + // } + // + // Note: Field type compatibility is not required in this case; the value is + // automatically converted. However, only numeric fields can be referenced. + // Repeated and map fields are not supported. + // + string value = 1; + + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.value}` - the field value. + // 3. `${field.type}` – the fully qualified name of the field type. + // 4. `${parent.type}` – the fully qualified name of the validated message. + // 5. `${range.value}` – the specified range. For referenced fields, the actual + // field value is also printed in round brackets along with the reference itself. + // + // The placeholders will be replaced at runtime when the error is constructed. + // + string error_msg = 2; +} + +// Controls whether a `oneof` group must always have one of its fields set. +// +// Note that unlike the `(required)` constraint, this option supports any field types +// within the group cases. +// +message ChoiceOption { + + // The default error message. + option (default_message) = "The `oneof` group `${parent.type}.${group.path}` must" + " have one of its fields set."; + + // Enables or disables the requirement for the `oneof` group to have a value. + bool required = 1; + + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${group.path}` – the group path. + // 2. `${parent.type}` – the fully qualified name of the validated message. + // + // The placeholders will be replaced at runtime when the error is constructed. + // + string error_msg = 2; +} + +// Declares the field groups, at least one of which must have all of its fields set. +// +// Unlike the `(required)` field constraint, which requires the presence of +// a specific field, this option allows to specify alternative field groups. +// +message RequireOption { + + // The default error message. + option (default_message) = "The message `${message.type}` must have at least one of" + " the following field groups set: `${require.fields}`."; + + // A set of field groups, at least one of which must have all of its fields set. + // + // A field group can include one or more fields joined by the ampersand (`&`) symbol. + // `oneof` group names are also valid and can be used along with field names. + // Groups are separated using the pipe (`|`) symbol. + // + // The field type determines when the field is considered set: + // + // 1. For message or enum fields, it must have a non-default instance. + // 2. For `string` and `bytes` fields, it must be non-empty. + // 3. For repeated fields and maps, it must contain at least one element. + // + // Fields of other types are not supported by this option. + // + // For `oneof`s, the restrictions above do not apply. Any `oneof` group can be used + // without considering the field types, and its value will be checked directly without + // relying on the default values of the fields within the `oneof`. + // + // Example: defining two field groups. + // + // message PersonName { + // option (require).fields = "given_name | honorific_prefix & family_name"; + // + // string honorific_prefix = 1; + // string given_name = 2; + // string middle_name = 3; + // string family_name = 4; + // string honorific_suffix = 5; + // } + // + // In this example, at least `given_name` or a group of `honorific_prefix` + // and `family_name` fields must be set. + // + string fields = 1; + + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${message.type}` – the fully qualified name of the validated message. + // 2. `${require.fields}` – the specified field groups. + // + // The placeholders will be replaced at runtime when the error is constructed. + // + string error_msg = 2; +} diff --git a/packages/example/proto/user.proto b/packages/example/proto/user.proto index 15eceec..826f00e 100644 --- a/packages/example/proto/user.proto +++ b/packages/example/proto/user.proto @@ -61,7 +61,6 @@ message GetUserRequest { } message GetUserResponse { - User user = 1 [(validate) = true, - (if_invalid).error_msg = "User data is invalid."]; + User user = 1 [(validate) = true]; bool found = 2; } diff --git a/packages/spine-validation-ts/DESCRIPTOR_API_GUIDE.md b/packages/spine-validation-ts/DESCRIPTOR_API_GUIDE.md index 1d89957..280e543 100644 --- a/packages/spine-validation-ts/DESCRIPTOR_API_GUIDE.md +++ b/packages/spine-validation-ts/DESCRIPTOR_API_GUIDE.md @@ -120,7 +120,6 @@ import { set_once, distinct, if_missing, - if_invalid, required_field } from './generated/options_pb'; import { hasOption, getOption } from '@bufbuild/protobuf'; @@ -181,11 +180,6 @@ for (const field of UserSchema.fields) { const ifMissingOpt = getOption(field, if_missing); console.log(` if_missing.errorMsg: "${ifMissingOpt.errorMsg}"`); } - - if (hasOption(field, if_invalid)) { - const ifInvalidOpt = getOption(field, if_invalid); - console.log(` if_invalid.errorMsg: "${ifInvalidOpt.errorMsg}"`); - } } ``` @@ -203,7 +197,6 @@ interface FieldValidationOptions { max?: { value: string; exclusive: boolean; errorMsg: string }; pattern?: { regex: string; errorMsg: string }; if_missing?: { errorMsg: string }; - if_invalid?: { errorMsg: string }; } function getFieldValidationOptions( @@ -264,13 +257,6 @@ function getFieldValidationOptions( }; } - if (hasOption(field, if_invalid)) { - const ifInvalidOpt = getOption(field, if_invalid); - options.if_invalid = { - errorMsg: ifInvalidOpt.errorMsg - }; - } - return Object.keys(options).length > 0 ? options : null; } @@ -406,7 +392,6 @@ From `/Users/armiol/development/Spine/validation-ts/src/generated/options_pb.ts` - `max: GenExtension` - Maximum value constraint - `pattern: GenExtension` - Regex pattern constraint - `validate: GenExtension` - Enable validation for nested messages -- `if_invalid: GenExtension` - Custom error for invalid field - `goes: GenExtension` - Field dependency - `set_once: GenExtension` - Field can only be set once - `distinct: GenExtension` - Repeated field must have unique values diff --git a/packages/spine-validation-ts/QUICK_REFERENCE.md b/packages/spine-validation-ts/QUICK_REFERENCE.md index 756b8e5..90bbe58 100644 --- a/packages/spine-validation-ts/QUICK_REFERENCE.md +++ b/packages/spine-validation-ts/QUICK_REFERENCE.md @@ -121,9 +121,8 @@ import { min, // MinOption { value, exclusive, errorMsg } max, // MaxOption { value, exclusive, errorMsg } pattern, // PatternOption { regex, errorMsg, modifier } - range, // string + range, // RangeOption { value, errorMsg } if_missing, // IfMissingOption { errorMsg } - if_invalid, // IfInvalidOption { errorMsg } goes, // GoesOption { with, errorMsg } } from './generated/options_pb'; ``` diff --git a/packages/spine-validation-ts/README.md b/packages/spine-validation-ts/README.md index 96eaff8..9af524f 100644 --- a/packages/spine-validation-ts/README.md +++ b/packages/spine-validation-ts/README.md @@ -59,29 +59,29 @@ if (violations.length > 0) { Validates a Protobuf message against its Spine validation constraints. **Parameters:** -- `schema`: The message schema (e.g., `UserSchema`) -- `message`: The message instance to validate +- `schema`: the message schema (e.g., `UserSchema`) +- `message`: the message instance to validate -**Returns:** Array of `ConstraintViolation` objects (empty if valid) +**Returns:** array of `ConstraintViolation` objects (empty if valid) ### `formatViolations(violations)` Formats validation violations into a human-readable string. **Parameters:** -- `violations`: Array of constraint violations +- `violations`: array of constraint violations -**Returns:** Formatted string describing all violations +**Returns:** formatted string describing all violations ### `formatTemplateString(template, values)` Formats a `TemplateString` by replacing placeholders with provided values. **Parameters:** -- `template`: Template string with placeholders (e.g., `{value}`, `{other}`) -- `values`: Object mapping placeholder names to their values +- `template`: template string with placeholders (e.g., `{value}`, `{other}`) +- `values`: object mapping placeholder names to their values -**Returns:** Formatted string with placeholders replaced +**Returns:** formatted string with placeholders replaced ## Supported Validation Options @@ -91,19 +91,25 @@ Formats a `TemplateString` by replacing placeholders with provided values. - βœ… **`(if_missing)`** - Custom error message for required fields - βœ… **`(pattern)`** - Regex validation for string fields - βœ… **`(min)` / `(max)`** - Numeric range validation with inclusive/exclusive bounds -- βœ… **`(range)`** - Bounded numeric ranges using bracket notation `[min..max]` -- βœ… **`(distinct)`** - Ensures unique elements in repeated fields +- βœ… **`(range)`** - Bounded numeric ranges using bracket notation `[min..max]`, with custom error messages +- βœ… **`(distinct)`** - Ensures unique elements in repeated fields and map values - βœ… **`(validate)`** - Enables recursive validation of nested messages -- βœ… **`(if_invalid)`** - Custom error message for nested validation failures - βœ… **`(goes)`** - Field dependency validation (field can only be set if another field is set) ### Message-level options -- βœ… **`(required_field)`** - Requires specific field combinations using boolean logic +- βœ… **`(require)`** - Requires specific field combinations using boolean logic ### Oneof-level options -- βœ… **`(is_required)`** - Requires that one of the oneof fields must be set +- βœ… **`(choice)`** - Requires that a `oneof` group has at least one field set + +### Not Supported + +- ❌ **`(set_once)`** - Requires state tracking across validations (not feasible in TypeScript runtime) +- ❌ **`(if_set_again)`** - Companion to `(set_once)` +- ❌ **`(required_field)`** - Deprecated, replaced by `(require)` +- ❌ **`(is_required)`** - Deprecated, replaced by `(choice)` ## Example @@ -113,7 +119,7 @@ syntax = "proto3"; import "spine/options.proto"; message User { - option (required_field) = "id | email"; + option (require).fields = "id | email"; int32 id = 1 [ (set_once) = true, @@ -133,12 +139,16 @@ message User { ]; int32 age = 4 [ - (range) = "[13..120]" + (range).value = "[13..120]" ]; repeated string tags = 5 [ (distinct) = true ]; + + map preferences = 6 [ + (distinct) = true // Values must be unique. + ]; } message Address { @@ -152,8 +162,7 @@ message Address { message UserProfile { User user = 1 [ (required) = true, - (validate) = true, - (if_invalid).error_msg = "User data is invalid." + (validate) = true ]; Address address = 2 [ @@ -208,11 +217,11 @@ message ShippingDetails { ### Required field combinations -Use `(required_field)` for complex field requirements: +Use `(require)` for complex field requirements: ```protobuf message ContactInfo { - option (required_field) = "(phone & country_code) | email"; + option (require).fields = "(phone & country_code) | email"; string phone = 1; string country_code = 2; @@ -220,19 +229,37 @@ message ContactInfo { } ``` +### `Oneof` constraints + +Use `(choice)` to require that a `oneof` group has a field set: + +```protobuf +message PaymentMethod { + oneof method { + option (choice).required = true; + option (choice).error_msg = "Payment method is required."; + + CreditCard credit_card = 1; + BankAccount bank_account = 2; + PayPal paypal = 3; + } +} +``` + ## Testing -The package includes comprehensive test coverage with 223 tests across 10 test suites: +The package includes comprehensive test coverage with 200+ tests across 11 test suites: - `basic-validation.test.ts` - Basic validation and formatting - `required.test.ts` - `(required)` and `(if_missing)` options - `pattern.test.ts` - `(pattern)` regex validation -- `required-field.test.ts` - `(required_field)` message-level option +- `required-field.test.ts` - `(require)` message-level option - `min-max.test.ts` - `(min)` and `(max)` numeric validation - `range.test.ts` - `(range)` bracket notation - `distinct.test.ts` - `(distinct)` uniqueness validation -- `validate.test.ts` - `(validate)` and `(if_invalid)` nested validation +- `validate.test.ts` - `(validate)` nested validation - `goes.test.ts` - `(goes)` field dependency validation +- `choice.test.ts` - `(choice)` `oneof` validation - `integration.test.ts` - Complex multi-option scenarios Run tests with: @@ -241,6 +268,44 @@ Run tests with: npm test ``` +## Development Notes + +### Generated Code Patching + +The package uses a post-generation script ([scripts/patch-generated.js](scripts/patch-generated.js)) to handle +JavaScript reserved word conflicts in generated Protobuf code. + +**Issue:** + +The Spine `(require)` option generates an export named `require` in the TypeScript output: + +```typescript +export const require: GenExtension +``` + +However, `require` is a reserved identifier in Node.js/CommonJS, which can cause issues with module systems and tooling. + +**Solution:** + +After running `buf generate`, the patch script automatically renames the export to `requireFields`: + +```typescript +export const requireFields: GenExtension +``` + +This happens automatically as part of the build process: + +```json +{ + "scripts": { + "generate": "buf generate && node scripts/patch-generated.js" + } +} +``` + +The script patches both the main generated files and test generated files, ensuring consistency across the codebase. +This approach allows us to use the standard `(require)` option name in proto files while avoiding conflicts in the generated TypeScript code. + ## License Apache License 2.0 diff --git a/packages/spine-validation-ts/package.json b/packages/spine-validation-ts/package.json index fc74c34..b9d5c49 100644 --- a/packages/spine-validation-ts/package.json +++ b/packages/spine-validation-ts/package.json @@ -5,8 +5,8 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "generate": "buf generate", - "generate:tests": "cd tests && buf generate", + "generate": "buf generate && node scripts/patch-generated.js", + "generate:tests": "cd tests && buf generate && cd .. && node scripts/patch-generated.js", "build": "npm run generate && tsc", "test": "npm run generate && npm run generate:tests && jest", "test:watch": "npm run generate && npm run generate:tests && jest --watch", diff --git a/packages/spine-validation-ts/proto/spine/options.proto b/packages/spine-validation-ts/proto/spine/options.proto index 2e05914..2baa543 100644 --- a/packages/spine-validation-ts/proto/spine/options.proto +++ b/packages/spine-validation-ts/proto/spine/options.proto @@ -1,11 +1,11 @@ /* - * Copyright 2022, TeamDev. All rights reserved. + * Copyright 2024, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -89,14 +89,17 @@ extend google.protobuf.FieldOptions { // The option to mark a field as required. // - // If the field type is a `message`, it must be set to a non-default instance. - // If it is `string` or `bytes`, the value must not be an empty string or an array. - // Other field types are not applicable. - // If the field is repeated, it must have at least one element. + // When this option is set: // - // Unlike the `required` keyword used in Protobuf 2, the option does not affect the transfer - // layer. Even if a message content violates the requirement set by the option, it would still - // be a valid message for the Protobuf library. + // 1. For message or enum fields, the field must be set to a non-default instance. + // 2. For `string` and `bytes` fields, the value must be set to a non-empty string or an array. + // 3. For repeated fields and maps, at least one element must be present. + // + // Other field types are not supported by the option. + // + // Unlike the `required` keyword in Protobuf 2, this option does not affect message + // serialization or deserialization. Even if a message content violates the requirement + // set by the option, it would still be a valid message for the Protobuf library. // // Example: Using `(required)` field validation constraint. // @@ -124,33 +127,155 @@ extend google.protobuf.FieldOptions { // See `PatternOption`. PatternOption pattern = 73820; - // Turns on validation constraint checking for a value of a message, a map, or a repeated field. + // Enables in-depth validation for fields that refer to a message. + // + // This option applies only to fields that reference a message: + // + // 1. Singular message fields. + // 2. Repeated fields of message types. + // 3. Map fields with message types as values. + // + // When set to `true`, the field is valid only if its value satisfies the validation + // constraints defined in the corresponding message type: + // + // 1. For singular message fields: the message must meet its constraints. + // + // Note: default instances are considered valid even if the message has required fields. + // In such cases, it is unclear whether the field is set with an invalid instance or + // simply unset. + // + // Example: + // + // ``` + // // Note that the default instance of `Address` is not valid because the `value` field + // // is mandatory. + // message Address { + // string value = 1 [(required) = true]; + // } + // + // // However, the default instance of `Address` in `Student.address` is valid, despite + // // having `(validate)` constraint that forces the message to meet its constraints. + // // Since the `address` field is optional for `Student`, `(validate)` would allow + // // default instances for this field treating them as "no value set". + // message Student { + // Address address = 1 [(validate) = true]; // implicit `(required) = false`. + // } // - // Default value is `false`. + // // Make the validated field required to avoid this behavior. In this case, `(validate)` + // // continues to bypass default instances, but the `(required)` option will report them. + // message Student { + // Address address = 1 [(validate) = true, (required) = true]; + // } + // ``` // - // If set to `true`, the outer message declaring the annotated field would be valid if: + // 2. For repeated fields: every element in the repeated field must meet the constraints + // of its message type. // - // 1. A message field value satisfies the validation constraints defined in the corresponding - // message type of the field. + // Example: // - // 2. Each value of a map entry satisfies validation constraints. + // ``` + // // Note that the default instance of `PhoneNumber` is not valid because the `value` + // // field is mandatory. + // message PhoneNumber { + // string value = 1 [(required) = true, (pattern).regex = "^\+?[0-9\s\-()]{1,30}$"]; + // } // - // 3. Each item of a repeated field satisfies validation constraints. + // // In contrast to singular fields, the default instances in `repeated` will also be + // // reported by the `(validate)` constraint, with those that do not match the pattern. + // message Student { + // repeated PhoneNumber number = 1 [(validate) = true]; + // } + // ``` + // + // 3. For map fields: each value in the map must meet the constraints of message type. + // Note: Protobuf does not allow messages to be used as map keys. + // + // Example: + // + // ``` + // // Note that the default instance of `PhoneNumber` is not valid because the `value` + // // field is mandatory. + // message PhoneNumber { + // string value = 1 [(required) = true, (pattern).regex = "^\+?[0-9\s\-()]{1,30}$"]; + // } + // + // // In contrast to singular fields, the default instances in `map` values will also be + // // reported by the `(validate)` constraint, with those that do not match the pattern. + // message Contacts { + // map map = 1 [(validate) = true]; + // } + // ``` + // + // If the field contains `google.protobuf.Any`, the option will first attempt to unpack + // the enclosed message, and only then validate it. However, unpacking is not always possible: + // + // 1. The default instance of `Any` is always valid because there is nothing to unpack + // and validate. + // 2. Instances with type URLs that are unknown to the application are also valid. + // + // Unpacking requires a corresponding Java class to deserialize the message, but if + // the application does not recognize the type URL, it has no way to determine which + // class to use. + // + // Such may happen when the packed message comes from a newer app version, an external + // system, or is simply not included in the application’s dependencies. // bool validate = 73821; // See `IfInvalidOption`. - IfInvalidOption if_invalid = 73822; + IfInvalidOption if_invalid = 73822 [deprecated = true]; // See `GoesOption`. GoesOption goes = 73823; // Indicates that a field can only be set once. // - // A typical use-case would include a value of an ID, which doesn't change over the course of - // the life of an entity. + // This option allows the target field to accept assignments only if one of the following + // conditions is met: + // + // 1. The current field value is the default for its type. + // Refer to the official docs on default values: https://protobuf.dev/programming-guides/proto3/#default. + // 2. The current field value equals to the proposed new value. + // + // The option can be applied to the following singular field types: // - // Example: Using `(set_once)` field validation constraint. + // - any message or enum type; + // - any numeric type; + // - `bool`, `string` and `bytes`. + // + // Repeated fields, maps, and fields with explicit `optional` cardinality are not supported. + // Such declarations will lead to build-time errors. For more information on field cardinality, + // refer to the official docs: https://protobuf.dev/programming-guides/proto3/#field-labels. + // + // Assigning a value to a message field can be done in various ways in the generated code. + // It depends on the target language and specific implementation of `protoc`. This option + // doesn't enforce field immutability at the binary representation level. Also, it does not + // prevent the use of Protobuf utilities that can construct new messages without using field + // setters or properties. The primary purpose of this option is to support standard use cases, + // such as assigning values through setters or retrieving them during data merging. + // + // For example, let's take a look on how it works for the generated Java code. The following + // use cases are supported and validated by the option: + // + // 1. Assigning a field value using the field setter. + // 2. Assigning a field value using the field descriptor. + // 3. Merging data from another instance of the message class. + // 4. Merging data from a message's binary representation. + // 5. Merging specific fields (available for Message fields). + // + // Unsupported Java use cases include: + // + // 1. Constructing a message using the `DynamicMessage` class. + // 2. Merging data from messages provided by third-party Protobuf implementations. + // 3. Clearing a specific field or an entire message. + // + // For unsupported scenarios, the option performs no validation. It does not throw errors + // or print warnings. + // + // A typical use case involves a field, such as an ID, which remains constant throughout + // the lifecycle of an entity. + // + // Example: using `(set_once)` field validation constraint. // // message User { // UserId id = 1 [(set_once) = true]; @@ -158,48 +283,74 @@ extend google.protobuf.FieldOptions { // // Once set, the `id` field cannot be changed. // + // Use `(if_set_again).error_msg` option to specify a custom error message that will be used for + // composing the error upon attempting to re-assign the field value. Refer to the documentation + // for the corresponding option for an example of its usage. + // bool set_once = 73824; - // The option to mark a `repeated` field as a collection of unique elements. + // The option to enforce uniqueness for collection fields. // - // Example: Using `(distinct)` constraint for a repeated field. + // When the option is set to `true`, the behavior is as follows: + // + // 1. For `repeated` fields: all elements must be unique. + // 2. For `map` fields: while the map keys are inherently unique, all associated values + // must also be unique. + // + // Other field types are not supported. + // + // Uniqueness is determined by comparing the elements themselves, using their full equality. + // For example, in Java, it is defined by the `equals()` method. No special cases are applied, + // such as comparing only specific fields like IDs. + // + // Example: using `(distinct)` constraint for a `repeated` field. // // message Blizzard { // // // All snowflakes must be unique in this blizzard. // // // // Attempting to add a snowflake that is equal to an existing one would result - // // in constraint violation error. + // // in a constraint violation error. // // - // repeated Snowflake = 1 [(distinct) = true]; + // repeated Snowflake snowflakes = 1 [(distinct) = true]; + // } + // + // Example: using `(distinct)` constraint for a `map` field. + // + // message UniqueEmails { + // + // // The associated email values must be unique. + // // + // // Attempting to add a key/value pair where the `Email` value duplicates + // // an existing one would result in a constraint violation error. + // // + // map emails = 1 [(distinct) = true]; // } // bool distinct = 73825; - // The option to indicate that a numeric field is required to have a value which belongs - // to the specified bounded range. For unbounded ranges, please use `(min)` and `(max) options. + // Reserved 73826 for deleted `range` option, which had `string` type. + + // Defines the error message used if a `set_once` field is set again. // - // The range can be open (not including the endpoint) or closed (including the endpoint) on - // each side. Open endpoints are indicated using a parenthesis (`(`, `)`). Closed endpoints are - // indicated using a square bracket (`[`, `]`). + // Applies only to the fields marked as `set_once`. // - // Example: Defining ranges of numeric values. + IfSetAgainOption if_set_again = 73827; + + // Defines the error message used if a `distinct` field has duplicates. // - // message NumRanges { - // int32 hour = 1 [(range) = "[0..24)"]; - // uint32 minute = 2 [(range) = "[0..59]"]; - // float degree = 3 [(range) = "[0.0..360.0)"]; - // double angle = 4 [(range) = "(0.0..180.0)"]; - // } + // Applies only to the repeated fields marked as `distinct`. // - // NOTE: That definition of ranges must be consistent with the type they constrain. - // An range for an integer field must be defined with integer endpoints. - // A range for a floating point field must be defined with decimal separator (`.`), - // even if the endpoint value does not have a fractional part. + IfHasDuplicatesOption if_has_duplicates = 73828; + + // The option to indicate that a numeric field is required to have a value which belongs + // to the specified bounded range. + // + // For unbounded ranges, please use `(min)` and `(max) options. // - string range = 73826; + RangeOption range = 73829; - // Reserved 73827 to 73849 for future validation options. + // Reserved 73830 to 73849 for future validation options. // API Annotations //----------------- @@ -232,8 +383,8 @@ extend google.protobuf.FieldOptions { // An API bearing this annotation is exempt from any compatibility guarantees made by its // containing library. Note that the presence of this annotation implies nothing about the // quality of the API in question, only the fact that it is not "API-frozen." - // It is generally safe for applications to depend on beta APIs, at the cost of some extra work - // during upgrades. + // It is generally safe for applications to depend on beta APIs, at the cost of + // some extra work during upgrades. // bool beta = 73853; @@ -259,9 +410,9 @@ extend google.protobuf.FieldOptions { // // All column fields are considered optional by the framework. // - // Currently, only entities of projection and process manager type are eligible for having - // columns (see `EntityOption`). For all other message types the column declarations are - // ignored. + // Currently, only entities of projection and process manager type are + // eligible for having columns (see `EntityOption`). + // For all other message types the column declarations are ignored. // // The `repeated` and `map` fields cannot be columns. // @@ -274,14 +425,13 @@ extend google.protobuf.FieldOptions { extend google.protobuf.OneofOptions { - // Marks a `oneof` group, in which one field *must* be set. - // - // Alternative to `(required_field)` with all the field in the group joined with the OR - // operator. - // - bool is_required = 73891; + // Deprecated: use the `(choice)` option instead. + bool is_required = 73891 [deprecated = true]; - // Reserved 73892 to 73899 for future options. + // Controls whether a `oneof` group must always have one of its fields set. + ChoiceOption choice = 73892; + + // Reserved 73893 to 73899 for future options. } extend google.protobuf.MessageOptions { @@ -289,46 +439,38 @@ extend google.protobuf.MessageOptions { // Validation Constraints //------------------------ - // The default format string for validation error message text. - // - // This option extends message types that extend `FieldOptions` - // The number of parameters and their types are determined by the type of field options. - // - // Usage of this value is deprecated. Along with the old `msg_format`s, it exists to support - // the old validation library. The new version of the validation library, which does not lie in - // the `base` repository, constructs the default error messages separately when creating - // language-agnostic validation rules. - // - string default_message = 73901 [deprecated = true]; - - // The constraint to require at least one of the fields or a combination of fields. + // The default validation error message. // - // Unlike the `required` field constraint which always require corresponding field, - // this message option allows to require alternative fields or a combination of them as - // an alternative. Field names and `oneof` group names are acceptable. + // Please note, this option is intended for INTERNAL USE only. It applies to message types + // that extend `FieldOptions` and is not intended for external usage. // - // Field names are separated using the pipe (`|`) symbol. The combination of fields is defined - // using the ampersand (`&`) symbol. + // If a validation option detects a constraint violation and no custom error message is defined + // for that specific option, it will fall back to the message specified by `(default_message)`. // - // Example: Pipe syntax for defining alternative required fields. + // For example, here is how to declare the default message for `(goes)` option: // - // message PersonName { - // option (required_field) = "given_name|honorific_prefix & family_name"; + // ``` + // message GoesOption { + // // The default error message. + // option (default_message) = "The field `${goes.companion}` must also be set when `${field.path}` is set."; + // } + // ``` // - // string honorific_prefix = 1; - // string given_name = 2; - // string middle_name = 3; - // string family_name = 4; - // string honorific_suffix = 5; - // } + // Note: The placeholders available within `(default_message)` depend solely on the particular + // validation option that uses it. Each option may define its own set of placeholders, or none. // - string required_field = 73902; + string default_message = 73901 [(internal) = true]; + + // Deprecated: use the `(require)` option instead. + string required_field = 73902 [deprecated = true]; // See `EntityOption`. EntityOption entity = 73903; // An external validation constraint for a field. // + // WARNING: This option is deprecated and is scheduled for removal in Spine v2.0.0. + // // Allows to re-define validation constraints for a message when its usage as a field of // another type requires alternative constraints. This includes definition of constraints for // a message which does not have them defined within the type. @@ -336,7 +478,7 @@ extend google.protobuf.MessageOptions { // A target field of an external constraint should be specified using a fully-qualified // field name (e.g. `mypackage.MessageName.field_name`). // - // Example: Defining external validation constraint. + // Example: defining external validation constraint. // // package io.spine.example; // @@ -393,7 +535,7 @@ extend google.protobuf.MessageOptions { // External validation constraints can be applied to fields of several types. // To do so, separate fully-qualified references to these fields with comma. // - // Example: External validation constraints for multiple fields. + // Example: external validation constraints for multiple fields. // // // External validation constraint for requiring a new value in renaming commands. // message RequireNewName { @@ -408,7 +550,7 @@ extend google.protobuf.MessageOptions { // Spine Model Compiler does not check such an "overwriting". // See the issue: https://github.com/SpineEventEngine/base/issues/318. // - string constraint_for = 73904; + string constraint_for = 73904 [deprecated = true]; // Reserved 73905 to 73910 for future validation options. @@ -446,7 +588,7 @@ extend google.protobuf.MessageOptions { // Specifies a characteristic inherent in the the given message type. // - // Example: Using `(is)` message option. + // Example: using `(is)` message option. // // message CreateProject { // option (is).java_type = "ProjectCommand"; @@ -456,8 +598,10 @@ extend google.protobuf.MessageOptions { // // In the example above, `CreateProject` message is a `ProjectCommand`. // - // To specify a characteristic for every message in a `.proto` file, use `(every_is)` file - // option. If both `(is)` and `(every_is)` options are found, both are applied. + // To specify a characteristic for every message in a `.proto` file, + // please use `(every_is)` file option. + // + // If both `(is)` and `(every_is)` options are applicable for a type, both are applied. // // When targeting Java, specify the name of a Java interface to be implemented by this // message via `(is).java_type`. @@ -476,7 +620,10 @@ extend google.protobuf.MessageOptions { // CompareByOption compare_by = 73923; - // Reserved 73924 to 73938 for future options. + // The constraint to require at least one of the fields or combinations of fields. + RequireOption require = 73924; + + // Reserved 73925 to 73938 for future options. // Reserved 73939 and 73940 for the deleted options `events` and `rejections`. } @@ -495,22 +642,39 @@ extend google.protobuf.FileOptions { // For more information on such restrictions please see the documentation of // the type option called `internal_type`. // + // If a file contains a declaration of a `service`, this option will NOT be applied to it. + // A service is not a data type, and therefore, this option does not apply to it. + // Internal services are not supported. + // bool internal_all = 73942; // Indicates a file which contains elements of Service Provider Interface (SPI). + // + // This option applies to messages, enums, and services. + // bool SPI_all = 73943; - // Indicates a public API that can change at any time, and has no guarantee of - // API stability and backward-compatibility. + // Indicates a file declaring public data type API which that can change at any time, + // has no guarantee of API stability and backward-compatibility. + // + // If a file contains a declaration of a `service`, this option will NOT be applied to it. + // A service is not a data type, and therefore, this option does not apply to it. + // Experimental services are not supported. + // bool experimental_all = 73944; - // Signifies that a public API is subject to incompatible changes, or even removal, + // Signifies that a public data type API is subject to incompatible changes, or even removal, // in a future release. + // + // If a file contains a declaration of a `service`, this option will NOT be applied to it. + // A service is not a data type, and therefore, this option does not apply to it. + // Beta services are not supported. + // bool beta_all = 73945; // Specifies a characteristic common for all the message types in the given file. // - // Example: Marking all the messages using the `(every_is)` file option. + // Example: marking all the messages using the `(every_is)` file option. // ``` // option (every_is).java_type = "ProjectCommand"; // @@ -528,13 +692,13 @@ extend google.protobuf.FileOptions { // In the example above, `CreateProject`, `CreateProject.WithAssignee`, and `DeleteProject` // messages are `ProjectCommand`-s. // - // To specify a characteristic for a single message, use `(is)` message option. If both `(is)` - // and `(every_is)` options are found, both are applied. + // To specify a characteristic for a single message, please use `(is)` message option. + // If both `(is)` and `(every_is)` options are applicable for a type, both are applied. // // When targeting Java, specify the name of a Java interface to be implemented by these // message types via `(every_is).java_type`. // - IsOption every_is = 73946; + EveryIsOption every_is = 73946; // Reserved 73947 to 73970 for future use. } @@ -553,22 +717,15 @@ extend google.protobuf.ServiceOptions { // Validation Option Types //--------------------------- -// Defines the error handling for `required` field with no value set. +// Defines the error message used if a `required` field is not set. // // Applies only to the fields marked as `required`. -// Validation error message is composed according to the rules defined by this option. -// -// Example: Using the `(if_missing)` option. -// -// message Holder { -// MyMessage field = 1 [(required) = true, -// (if_missing).error_msg = "This field is required."]; -// } // message IfMissingOption { // The default error message. - option (default_message) = "A value must be set."; + option (default_message) = "The field `${parent.type}.${field.path}`" + " of the type `${field.type}` must have a non-default value."; // A user-defined validation error format message. // @@ -577,35 +734,93 @@ message IfMissingOption { string msg_format = 1 [deprecated = true]; // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.type}` – the fully qualified name of the field type. + // 3. `${parent.type}` – the fully qualified name of the validated message. + // + // The placeholders will be replaced at runtime when the error is constructed. + // + // Example: using the `(if_missing)` option. + // + // message Student { + // Name name = 1 [(required) = true, + // (if_missing).error_msg = "The `${field.path}` field is mandatory for `${parent.type}`."]; + // } + // string error_msg = 2; } -// The field value must be greater than or equal to the given minimum number. -// -// Is applicable only to numbers. -// Repeated fields are supported. +// Indicates that the numeric field must be greater than or equal to the specified value. // -// Example: Defining lower boundary for a numeric field. -// -// message KelvinTemperature { -// double value = 1 [(min) = { -// value = "0.0" -// exclusive = true -// error_msg = "Temperature cannot reach {other}K, but provided {value}." -// }]; -// } +// The option supports all singular and repeated numeric fields. // message MinOption { - // The default error message format string. - // - // The format parameters are: - // 1) "or equal to " string (if the `exclusive` parameter is false) or an empty string; - // 2) the minimum number. - // - option (default_message) = "The number must be greater than %s%s."; + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}`" + " must be ${min.operator} ${min.value}. The passed value: `${field.value}`."; // The string representation of the minimum field value. + // + // ## Integer and floating-point values + // + // A minimum value for an integer field must use an integer number. Specifying a decimal + // number is not allowed, even if it has no fractional part (e.g., `5.0` is invalid). + // + // A minimum value for a floating-point field must use a decimal separator (`.`), even if + // the value has no fractional part. An exponent part represented by `E` or `e`, followed + // by an optional sign and digits is allowed (e.g., `1.2E3`, `0.5e-2`). + // + // Example: defining minimum values for integer and floating-point fields. + // + // message Measurements { + // int32 temperature = 1 [(min).value = "0"]; + // uint32 mass = 2 [(min).value = "5"]; + // float degree = 3 [(min).value = "0.0"]; + // double angle = 4 [(min).value = "30.0"]; + // float pressure = 5 [(min).value = "950.0E-2"]; + // } + // + // ## Field Type Limitations + // + // A minimum value must not fall below the limits of the field type. + // + // Example: invalid values that fall below the field type limits. + // + // message OverflowMeasurements { + // float pressure = 1 [(min).value = "-5.5E38"]; // Falls below the `float` minimum. + // uint32 mass = 2 [(min).value = "-5"]; // Falls below the `uint32` minimum. + // } + // + // ## Field references + // + // Instead of numeric literals, you can reference another numeric field. + // At runtime, the field’s value will be used as the bound. Nested fields are supported. + // + // Example: defining minimum values using field references. + // + // message Measurements { + // + // int32 min_length = 1; + // int32 length = 2 [(min).value = "min_length"]; + // + // Limits limits = 3; + // int32 temperature = 4 [(min).value = "limits.min_temperature"]; + // float pressure = 5 [(min).value = "limits.min_pressure"]; + // } + // + // message Limits { + // int32 min_temperature = 1; + // float min_pressure = 2; + // } + // + // Note: Field type compatibility is not required in this case; the value is + // automatically converted. However, only numeric fields can be referenced. + // Repeated and map fields are not supported. + // string value = 1; // Specifies if the field should be strictly greater than the specified minimum. @@ -617,36 +832,92 @@ message MinOption { // A user-defined validation error format message. string msg_format = 3 [deprecated = true]; - // A user-defined validation error format message. + // A user-defined error message. // - // May include tokens `{value}`β€”for the actual value of the field, and `{other}`β€”for - // the threshold value. The tokens will be replaced at runtime when the error is constructed. + // The specified message may include the following placeholders: + // + // 1. `${field.value}` - the field value. + // 2. `${field.path}` – the field path. + // 3. `${field.type}` – the fully qualified name of the field type. + // 4. `${parent.type}` – the fully qualified name of the validated message. + // 5. `${min.value}` – the specified minimum `value`. For referenced fields, the actual + // field value is also printed in round brackets along with the reference itself. + // 6. `${min.operator}` – if `exclusive` is set to `true`, this placeholder equals to ">". + // Otherwise, ">=". + // + // The placeholders will be replaced at runtime when the error is constructed. // string error_msg = 4; } -// The field value must be less than or equal to the given maximum number. +// Indicates that the numeric field must be less than or equal to the specified value. // -// Is applicable only to numbers. -// Repeated fields are supported. -// -// Example: Defining upper boundary for a numeric field. -// -// message Elevation { -// double value = 1 [(max).value = "8848.86"]; -// } +// The option supports all singular and repeated numeric fields. // message MaxOption { - // The default error message format string. - // - // The format parameters are: - // 1) "or equal to " string (if the `exclusive` parameter is false) or an empty string; - // 2) the maximum number. - // - option (default_message) = "The number must be less than %s%s."; + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}`" + " must be ${max.operator} ${max.value}. The passed value: `${field.value}`."; // The string representation of the maximum field value. + // + // ## Integer and floating-point values + // + // A maximum value for an integer field must use an integer number. Specifying a decimal + // number is not allowed, even if it has no fractional part (e.g., `5.0` is invalid). + // + // A maximum value for a floating-point field must use a decimal separator (`.`), even if + // the value has no fractional part. An exponent part represented by `E` or `e`, followed + // by an optional sign and digits is allowed (e.g., `1.2E3`, `0.5e-2`). + // + // Example: defining maximum values for integer and floating-point fields. + // + // message Measurements { + // int32 temperature = 1 [(max).value = "270"]; + // uint32 mass = 2 [(max).value = "1200"]; + // float degree = 3 [(max).value = "360.0"]; + // double angle = 4 [(max).value = "90.0"]; + // float pressure = 5 [(max).value = "1050.0E-2"]; + // } + // + // ## Field Type Limitations + // + // A maximum value must not exceed the limits of the field type. + // + // Example: invalid values that exceed the field type limits. + // + // message OverflowMeasurements { + // float pressure = 1 [(min).value = "5.5E38"]; // Exceeds the `float` maximum. + // int32 mass = 2 [(min).value = "2147483648"]; // Exceeds the `int32` maximum. + // } + // + // ## Field references + // + // Instead of numeric literals, you can reference another numeric field. + // At runtime, the field’s value will be used as the bound. Nested fields are supported. + // + // Example: defining maximum values using field references. + // + // message Measurements { + // + // int32 max_length = 1; + // int32 length = 2 [(max).value = "max_length"]; + // + // Limits limits = 3; + // int32 temperature = 4 [(max).value = "limits.max_temperature"]; + // float pressure = 5 [(max).value = "limits.max_pressure"]; + // } + // + // message Limits { + // int32 max_temperature = 1; + // float max_pressure = 2; + // } + // + // Note: Field type compatibility is not required in this case; the value is + // automatically converted. However, only numeric fields can be referenced. + // Repeated and map fields are not supported. + // string value = 1; // Specifies if the field should be strictly less than the specified maximum @@ -658,34 +929,55 @@ message MaxOption { // A user-defined validation error format message. string msg_format = 3 [deprecated = true]; - // A user-defined validation error format message. + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.value}` - the field value. + // 3. `${field.type}` – the fully qualified name of the field type. + // 4. `${parent.type}` – the fully qualified name of the validated message. + // 5. `${max.value}` – the specified maximum `value`. For referenced fields, the actual + // field value is also printed in round brackets along with the reference itself. + // 6. `${max.operator}` – if `exclusive` is set to `true`, this placeholder equals to "<". + // Otherwise, "<=". // - // May include tokens `{value}`β€”for the actual value of the field, and `{other}`β€”for - // the threshold value. The tokens will be replaced at runtime when the error is constructed. + // The placeholders will be replaced at runtime when the error is constructed. // string error_msg = 4; } // A string field value must match the given regular expression. -// Is applicable only to strings. -// Repeated fields are supported. // -// Example: Using the `(pattern)` option. +// This option is applicable only to string fields, +// including those that are repeated. +// +// Example: using the `(pattern)` option. // // message CreateAccount { // string id = 1 [(pattern).regex = "^[A-Za-z0-9+]+$", -// (pattern).error_msg = "ID must be alphanumerical. Provided: `{value}`."]; +// (pattern).error_msg = "ID must be alphanumerical in `${parent.type}`. Provided: `${field.value}`."]; // } // message PatternOption { - // The default error message format string. + // The default error message. + option (default_message) = "The `${parent.type}.${field.path}` field" + " must match the regular expression `${regex.pattern}` (modifiers: `${regex.modifiers}`)." + " The passed value: `${field.value}`."; + + // The regular expression that the field value must match. // - // The format parameter is the regular expression to which the value must match. + // Please use the Java regex dialect for the syntax baseline: + // https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/regex/Pattern.html + // + // Note: in Java, regex patterns are not wrapped in explicit delimiters like in Perl or PHP. + // Instead, the pattern is provided as a string literal. Therefore, `/` symbol does not need + // to be escaped. + // + // The provided string literal is passed directly to the regex engine. So, it must be exactly + // what you would supply to the `java.util.regex.Pattern.compile()` method. // - option (default_message) = "The string must match the regular expression `%s`."; - - // The regular expression to match. string regex = 1; reserved 2; @@ -699,8 +991,16 @@ message PatternOption { // A user-defined validation error format message. // - // May include tokens `{value}`β€”for the actual value of the field, and `{other}`β€”for - // the threshold value. The tokens will be replaced at runtime when the error is constructed. + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.value}` - the field value. + // 3. `${field.type}` – the fully qualified name of the field type. + // 4. `${parent.type}` – the fully qualified name of the validated message. + // 5. `${regex.pattern}` – the specified regex pattern. + // 6. `${regex.modifiers}` – the specified modifiers, if any. For example, `[dot_all, unicode]`. + // + // The placeholders will be replaced at runtime when the error is constructed. // string error_msg = 5; @@ -714,8 +1014,8 @@ message PatternOption { // // By default, the dot does not match line break characters. // - // May also be known in some platforms as "single line" mode and be encoded with the `s` - // flag. + // May also be known in some platforms as "single line" mode and be encoded with + // the `s` flag. // bool dot_all = 1; @@ -757,39 +1057,60 @@ message PatternOption { } // Specifies the message to show if a validated field happens to be invalid. -// Is applicable only to messages. -// Repeated fields are supported. -// -// Example: Using the `(if_invalid)` option. // -// message Holder { -// MyMessage field = 1 [(validate) = true, -// (if_invalid).error_msg = "The field is invalid."]; -// } +// It is applicable only to fields marked with `(validate)`. // message IfInvalidOption { - // The default error message for the field. - option (default_message) = "The message must have valid properties."; + // Do not specify error message for `(validate)`, it is no longer used by + // the validation library. + option deprecated = true; + + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}` of the type" + " `${field.type}` is invalid. The field value: `${field.value}`."; // A user-defined validation error format message. + // + // Use `error_msg` instead. + // string msg_format = 1 [deprecated = true]; - // A user-defined validation error format message. + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.value}` - the field value. + // 3. `${field.type}` – the fully qualified name of the field type. + // 4. `${parent.type}` – the fully qualified name of the field declaring type. // - // May include the token `{value}` for the actual value of the field. The token will be replaced - // at runtime when the error is constructed. + // The placeholders will be replaced at runtime when the error is constructed. + // + // Example: using the `(if_invalid)` option. + // + // message Transaction { + // TransactionDetails details = 1 [(validate) = true, + // (if_invalid).error_msg = "The `${field.path}` field is invalid."]; + // } // string error_msg = 2; } -// Specifies that a message field can be present only if another field is present. +// Specifies that another field must be present if the option's target field is present. // // Unlike the `required_field` that handles combination of required fields, this option is useful -// when it is needed to say that an optional field makes sense only when another optional field is -// present. +// when it is needed to say that an optional field makes sense only when another optional field +// is present. +// +// This option can be applied to the same field types as `(required)`, including both the +// target field and its companion. Supported field types are: +// +// - Messages and enums. +// - Repeated fields and maps. +// - `string` and `bytes`. // -// Example: Requiring mutual presence of optional fields. +// Example: requiring mutual presence of optional fields. // // message ScheduledItem { // ... @@ -799,23 +1120,27 @@ message IfInvalidOption { // message GoesOption { - // The default error message format string. - // - // The first parameter is the name of the field for which we specify the option. - // The second parameter is the name of the field set in the "with" value. - // - option (default_message) = "The field `%s` can only be set when the field `%s` is defined."; + // The default error message. + option (default_message) = "The field `${goes.companion}` must also be set when `${field.path}`" + " is set in `${parent.type}`."; - // A name of the field required for presence of the field for which we set the option. + // The name of the companion field whose presence is required for this field to be valid. string with = 1; // A user-defined validation error format message. string msg_format = 2 [deprecated = true]; - // A user-defined validation error format message. + // A user-defined error message. // - // May include the token `{value}` for the actual value of the field. The token will be replaced - // at runtime when the error is constructed. + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.value}` – the field value. + // 3. `${field.type}` – the fully qualified name of the field type. + // 4. `${parent.type}` – the fully qualified name of the validated message. + // 5. `${goes.companion}` – the name of the companion specified in `with`. + // + // The placeholders will be replaced at runtime when the error is constructed. // string error_msg = 3; } @@ -826,7 +1151,7 @@ message EntityOption { // A type of an entity for state of which the message is defined. enum Kind { option allow_alias = true; - + // Reserved for errors. KIND_UNKNOWN = 0; @@ -879,45 +1204,68 @@ message EntityOption { Visibility visibility = 2; } -// Defines a marker for a given type or a set of types. +// Defines a common type for message types declared in the same proto file. // -// The option may be used in two modes: -// - with the marker code generation; -// - without the marker code generation. +// The nature of the type depends on the target programming language. +// For example, the `java_type` property defines a name of the Java interface common +// to all message classes generated for the proto file having this option. // -// When used with the code generation, language-specific markers are generated by the Protobuf -// compiler. Otherwise, it is expected that the user creates such markers manually. +// The option triggers creation of the common type if the `generate` property is set to true. +// Otherwise, it is expected that the user provides the reference to an existing type. // -message IsOption { +message EveryIsOption { - // Enables the generation of marker interfaces. + // Enables the generation of the common type. + // + // The default value is `false`. // - // The generation is disabled by default. bool generate = 1; - // The reference to a Java interface. + // The reference to a Java top-level interface. // - // May be an fully-qualified or a simple name. In the latter case, the interface should belong - // to the same Java package as the message class which implements this interface. + // The interface cannot be nested into a class or another interface. + // If a nested interface is provided, the code generation should fail the build process. // - // The framework does not ensure the referenced type exists. - // If the generation is disabled, the Java type is used as-is. Otherwise, a corresponding Java - // interface is generated. + // The value may be a fully-qualified or a simple name. // - // A generated interface has no declared methods and extends `com.google.protobuf.Message`. + // When a simple name is set, it is assumed that the interface belongs to + // the package of the generated message classes. // - // The `.java` file is placed alongside with the code generated by the proto-to-java compiler. + // If the value of the `generate` field is set to `false` the referenced interface must exist. + // Otherwise, a compilation error will occur. // - // If fully-qualified name given, the package of the generated type matches the fully-qualified - // name. When a simple name is set in the option, the package of the interface matches the - // package of the message class. + // If the value of the `generate` field is set to `true`, the framework will + // generate the interface using the given name and the package as described above. // - // If both `(is)` and `(every_is)` options specify a Java interface, the message class - // implements both interfaces. + // The generated interface will extend `com.google.protobuf.Message` and + // will have no declared methods. // string java_type = 2; } +// Defines additional type for a message type in which this option is declared. +// +// The nature of the type depends on the target programming language. +// For example, the `java_type` property defines a name of the Java interface which +// the generated message class will implement. +// +message IsOption { + + // The reference to a Java top-level interface. + // + // The interface cannot be nested into a class or another interface. + // If a nested interface is provided, the code generation should fail the build process. + // + // The value may be a fully-qualified or a simple name. + // + // When a simple name is set, it is assumed that the interface belongs to + // the package of the generated message classes. + // + // The referenced interface must exist. Otherwise, a compilation error will occur. + // + string java_type = 1; +} + // Defines the way to compare two messages of the same type to one another. // // Comparisons can be used to sort values. @@ -930,23 +1278,25 @@ message CompareByOption { // // The allowed field types are: // - any number type; - // - `bool` (false is less than true); + // - `bool` (`false` is less than `true`); // - `string` (in the order of respective Unicode values); // - enumerations (following the order of numbers associated with each constant); - // - messages marked with `(compare_by)`. + // - local messages (generated within the current build) marked with `(compare_by)`; + // - external messages (from dependencies), which either marked with `(compare_by)` + // OR have a comparator provided in `io.spine.compare.ComparatorRegistry`. // - // Other types are not permitted. Neither are repeated and map fields. Such declarations can - // lead to build-time errors. + // Other types are not permitted. Repeated or map fields are not permitted either. + // Such declarations will lead to build-time errors. // - // To refer to nested fields, separate the field names with a dot (`.`). No fields in the path - // can be repeated or maps. + // To refer to nested fields, separate the field names with a dot (`.`). + // No fields in the path can be repeated or maps. // // When multiple field paths are specified, comparison is executed in the order of reference. - // For example, specifying ["seconds", "nanos"] makes the comparison mechanism prioritize + // For example, specifying `["seconds", "nanos"]` makes the comparison mechanism prioritize // the `seconds` field and refer to `nanos` only when `seconds` are equal. // - // Note. When comparing message fields, a non-set message is always less than a set message. - // But if a message is set to a default value, the comparison falls back to + // NOTE: When comparing fields with a message type, a non-set message is always less than + // a set message. But if a message is set to a default value, the comparison falls back to // the field-wise comparison, i.e. number values are treated as zeros, `bool` β€” as `false`, // and so on. // @@ -956,3 +1306,261 @@ message CompareByOption { // the lower, enums β€” from the last number value to the 0th value, etc. bool descending = 2; } + +// Defines the error message used if a `set_once` field is set again. +// +// Applies only to the fields marked as `set_once`. +// +message IfSetAgainOption { + + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}` of the type" + " `${field.type}` already has the value `${field.value}` and cannot be reassigned" + " to `${field.proposed_value}`."; + + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.type}` – the fully qualified name of the field type. + // 3. `${field.value}` – the current field value. + // 4. `${field.proposed_value}` – the value, which was attempted to be set. + // 5. `${parent.type}` – the fully qualified name of the validated message. + // + // The placeholders will be replaced at runtime when the error is constructed. + // + // Example: using the `(set_once)` option. + // + // message User { + // UserId id = 1 [(set_once) = true, + // (if_set_again).error_msg = "A student ID is used as a permanent identifier within academic system, and cannot be re-assigned."]; + // } + // + string error_msg = 1; +} + +// Defines the error message used if a `distinct` field has duplicates. +// +// Applies only to the `repeated` and `map` fields marked with the `(distinct)` option. +// +message IfHasDuplicatesOption { + + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}` of the type" + " `${field.type}` must not contain duplicates." + " The duplicates found: `${field.duplicates}`."; + + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.type}` – the fully qualified name of the field type. + // 3. `${field.value}` – the field value (the whole collection). + // 4. `${field.duplicates}` – the duplicates found (elements that occur more than once). + // 5. `${parent.type}` – the fully qualified name of the validated message. + // + // The placeholders will be replaced at runtime when the error is constructed. + // + // Example: using the `(distinct)` option. + // + // message Blizzard { + // repeated Snowflake = 1 [(distinct) = true, + // (if_has_duplicates).error_msg = "Every snowflake must be unique! The duplicates found: `${field.duplicates}`."]; + // } + // + string error_msg = 1; +} + +// Indicate that the numeric field must belong to the specified bounded range. +// +// For unbounded ranges, please use `(min)` and `(max) options. +// +// The option supports all singular and repeated numeric fields. +// +message RangeOption { + + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}` must be within" + " the following range: `${range.value}`. The passed value: `${field.value}`."; + + // The string representation of the range. + // + // A range consists of two bounds: a lower bound and an upper bound. These bounds are + // separated by either the `..` or ` .. ` delimiter. Each bound can be either open + // or closed. The format of the bounds and the valid values depend on the type of field. + // + // ## Bound Types + // + // - Closed bounds include the endpoint and are indicated using square brackets (`[`, `]`). + // Example: `[0..10]` represents values from 0 to 10, inclusive. + // + // - Open bounds exclude the endpoint and are indicated using parentheses (`(`, `)`). + // Example: `(0..10)` represents values strictly between 0 and 10. + // + // The lower bound must be less than or equal to the upper bound. + // + // ## Integer Fields + // + // A range for an integer field must use integer numbers. Specifying a decimal number + // is not allowed, even if it has no fractional part (e.g., `5.0` is invalid). + // + // Example: defining ranges for integer fields. + // + // message Measurements { + // int32 length = 1 [(range).value = "[0..100)"]; + // uint32 mass = 2 [(range).value = "(0..100]"]; + // } + // + // ## Floating-Point Fields + // + // A range for a floating-point field must use a decimal separator (`.`), even if the value + // has no fractional part. An exponent part represented by `E` or `e`, followed by an optional + // sign and digits is allowed (e.g., `1.2E3`, `0.5e-2`). + // + // Example: defining ranges for floating-point fields. + // + // message Measurements { + // float degree = 1 [(range).value = "[0.0 .. 360.0)"]; + // double angle = 2 [(range).value = "(0.0 .. 180.0)"]; + // float pressure = 3 [(range).value = "[950.0E-2 .. 1050.0E-2]"]; + // } + // + // ## Field Type Limitations + // + // A range must not exceed the limits of the field type. + // + // Example: invalid ranges that exceed the field type limits. + // + // message OverflowMeasurements { + // float price = 1 [(range).value = "[0, 5.5E38]"]; // Exceeds the `float` maximum. + // uint32 size = 2 [(range).value = "[-5; 10]"]; // Falls below the `uint32` minimum. + // } + // + // ## Field references + // + // Instead of numeric literals, you can reference another numeric field. + // At runtime, the field’s value will be used as the bound. Nested fields are supported. + // + // Example: defining ranges using field references. + // + // message Measurements { + // + // int32 max_length = 1; + // int32 length = 2 [(range).value = "[1 .. max_length"]; + // + // Limits limits = 3; + // int32 temperature = 4 [(range).value = "[limits.low_temp .. limits.high_temp]"]; + // } + // + // message Limits { + // int32 low_temp = 1; + // int32 high_temp = 2; + // } + // + // Note: Field type compatibility is not required in this case; the value is + // automatically converted. However, only numeric fields can be referenced. + // Repeated and map fields are not supported. + // + string value = 1; + + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.value}` - the field value. + // 3. `${field.type}` – the fully qualified name of the field type. + // 4. `${parent.type}` – the fully qualified name of the validated message. + // 5. `${range.value}` – the specified range. For referenced fields, the actual + // field value is also printed in round brackets along with the reference itself. + // + // The placeholders will be replaced at runtime when the error is constructed. + // + string error_msg = 2; +} + +// Controls whether a `oneof` group must always have one of its fields set. +// +// Note that unlike the `(required)` constraint, this option supports any field types +// within the group cases. +// +message ChoiceOption { + + // The default error message. + option (default_message) = "The `oneof` group `${parent.type}.${group.path}` must" + " have one of its fields set."; + + // Enables or disables the requirement for the `oneof` group to have a value. + bool required = 1; + + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${group.path}` – the group path. + // 2. `${parent.type}` – the fully qualified name of the validated message. + // + // The placeholders will be replaced at runtime when the error is constructed. + // + string error_msg = 2; +} + +// Declares the field groups, at least one of which must have all of its fields set. +// +// Unlike the `(required)` field constraint, which requires the presence of +// a specific field, this option allows to specify alternative field groups. +// +message RequireOption { + + // The default error message. + option (default_message) = "The message `${message.type}` must have at least one of" + " the following field groups set: `${require.fields}`."; + + // A set of field groups, at least one of which must have all of its fields set. + // + // A field group can include one or more fields joined by the ampersand (`&`) symbol. + // `oneof` group names are also valid and can be used along with field names. + // Groups are separated using the pipe (`|`) symbol. + // + // The field type determines when the field is considered set: + // + // 1. For message or enum fields, it must have a non-default instance. + // 2. For `string` and `bytes` fields, it must be non-empty. + // 3. For repeated fields and maps, it must contain at least one element. + // + // Fields of other types are not supported by this option. + // + // For `oneof`s, the restrictions above do not apply. Any `oneof` group can be used + // without considering the field types, and its value will be checked directly without + // relying on the default values of the fields within the `oneof`. + // + // Example: defining two field groups. + // + // message PersonName { + // option (require).fields = "given_name | honorific_prefix & family_name"; + // + // string honorific_prefix = 1; + // string given_name = 2; + // string middle_name = 3; + // string family_name = 4; + // string honorific_suffix = 5; + // } + // + // In this example, at least `given_name` or a group of `honorific_prefix` + // and `family_name` fields must be set. + // + string fields = 1; + + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${message.type}` – the fully qualified name of the validated message. + // 2. `${require.fields}` – the specified field groups. + // + // The placeholders will be replaced at runtime when the error is constructed. + // + string error_msg = 2; +} diff --git a/packages/spine-validation-ts/src/options-registry.ts b/packages/spine-validation-ts/src/options-registry.ts index 09b91af..f9e35b7 100644 --- a/packages/spine-validation-ts/src/options-registry.ts +++ b/packages/spine-validation-ts/src/options-registry.ts @@ -34,33 +34,42 @@ import { required, if_missing, pattern, - required_field, min, max, range, distinct, validate, - if_invalid, - goes + goes, + if_has_duplicates, + choice, + requireFields } from './generated/spine/options_pb'; /** * Registry storing option extension references. * * Currently supported options are automatically registered. + * + * Note: The following options are NOT SUPPORTED: + * - `if_invalid` (73822) - Deprecated, not supported + * - `set_once` (73824) - Requires state tracking across validations + * - `if_set_again` (73827) - Companion to `set_once` + * - `required_field` (73902) - Deprecated, replaced by `requireFields` + * - `is_required` (73891) - Deprecated, replaced by `choice` */ const optionRegistry = { required, if_missing, pattern, - required_field, min, max, range, distinct, validate, - if_invalid, goes, + if_has_duplicates, + choice, + requireFields, } as const; /** diff --git a/packages/spine-validation-ts/src/options/distinct.ts b/packages/spine-validation-ts/src/options/distinct.ts index 5d1b024..7c8b89a 100644 --- a/packages/spine-validation-ts/src/options/distinct.ts +++ b/packages/spine-validation-ts/src/options/distinct.ts @@ -28,16 +28,18 @@ * Validation logic for the `(distinct)` option. * * The `(distinct)` option is a field-level constraint that enforces uniqueness - * of elements in repeated fields. + * of elements in repeated fields and map field values. * * Supported field types: * - All repeated scalar types (`int32`, `int64`, `uint32`, `uint64`, `sint32`, `sint64`, * `fixed32`, `fixed64`, `sfixed32`, `sfixed64`, `float`, `double`, `bool`, `string`, `bytes`) * - Repeated enum fields + * - Map fields (validates that all values are unique; keys are inherently unique) * * Features: * - Ensures all elements in a repeated field are unique. - * - Detects duplicate values and reports violations with element indices. + * - Ensures all values in a map field are unique (keys are always unique by definition). + * - Detects duplicate values and reports violations with element indices or keys. * - Works with primitive types (numbers, strings, booleans). * - Works with enum values. * @@ -46,6 +48,7 @@ * repeated string tags = 1 [(distinct) = true]; * repeated int32 product_ids = 2 [(distinct) = true]; * repeated Status statuses = 3 [(distinct) = true]; + * map emails = 4 [(distinct) = true]; // Email values must be unique * ``` */ @@ -62,18 +65,18 @@ import { getRegisteredOption } from '../options-registry'; * Creates a constraint violation for `(distinct)` validation failures. * * @param typeName The fully qualified message type name. - * @param fieldName Array representing the field path (including index). + * @param fieldName Array representing the field path (including index or key). * @param duplicateValue The duplicate value found. - * @param firstIndex Index of the first occurrence of the value. - * @param duplicateIndex Index of the duplicate occurrence. + * @param firstLocation Index (for repeated fields) or key (for map fields) of the first occurrence. + * @param duplicateLocation Index (for repeated fields) or key (for map fields) of the duplicate occurrence. * @returns A `ConstraintViolation` object. */ function createViolation( typeName: string, fieldName: string[], duplicateValue: any, - firstIndex: number, - duplicateIndex: number + firstLocation: number | string, + duplicateLocation: number | string ): ConstraintViolation { return create(ConstraintViolationSchema, { typeName, @@ -82,11 +85,11 @@ function createViolation( }), fieldValue: undefined, message: create(TemplateStringSchema, { - withPlaceholders: `Duplicate value found in repeated field. Value {value} at index {duplicate_index} is a duplicate of the value at index {first_index}.`, + withPlaceholders: `Duplicate value found. Value {value} at location {duplicate_index} is a duplicate of the value at location {first_index}.`, placeholderValue: { 'value': String(duplicateValue), - 'first_index': String(firstIndex), - 'duplicate_index': String(duplicateIndex) + 'first_index': String(firstLocation), + 'duplicate_index': String(duplicateLocation) } }), msgFormat: '', @@ -128,7 +131,7 @@ function validateFieldDistinct( return; } - if (field.fieldKind !== 'list') { + if (field.fieldKind !== 'list' && field.fieldKind !== 'map') { return; } @@ -143,36 +146,70 @@ function validateFieldDistinct( const fieldValue = (message as any)[field.localName]; - if (!Array.isArray(fieldValue) || fieldValue.length <= 1) { - return; - } + if (field.fieldKind === 'list') { + if (!Array.isArray(fieldValue) || fieldValue.length <= 1) { + return; + } - const seenValues = new Map(); + const seenValues = new Map(); - fieldValue.forEach((element: any, index: number) => { - let isDuplicate = false; - let firstIndex = -1; + fieldValue.forEach((element: any, index: number) => { + let isDuplicate = false; + let firstIndex = -1; - for (const [seenValue, seenIndex] of seenValues.entries()) { - if (valuesAreEqual(element, seenValue)) { - isDuplicate = true; - firstIndex = seenIndex; - break; + for (const [seenValue, seenIndex] of seenValues.entries()) { + if (valuesAreEqual(element, seenValue)) { + isDuplicate = true; + firstIndex = seenIndex; + break; + } } - } - if (isDuplicate) { - violations.push(createViolation( - schema.typeName, - [field.name, String(index)], - element, - firstIndex, - index - )); - } else { - seenValues.set(element, index); + if (isDuplicate) { + violations.push(createViolation( + schema.typeName, + [field.name, String(index)], + element, + firstIndex, + index + )); + } else { + seenValues.set(element, index); + } + }); + } else if (field.fieldKind === 'map') { + if (!fieldValue || Object.keys(fieldValue).length <= 1) { + return; } - }); + + const seenValues = new Map(); + const entries = Object.entries(fieldValue); + + entries.forEach(([key, value]) => { + let isDuplicate = false; + let firstKey = ''; + + for (const [seenValue, seenKey] of seenValues.entries()) { + if (valuesAreEqual(value, seenValue)) { + isDuplicate = true; + firstKey = seenKey; + break; + } + } + + if (isDuplicate) { + violations.push(createViolation( + schema.typeName, + [field.name, key], + value, + firstKey, + key + )); + } else { + seenValues.set(value, key); + } + }); + } } /** diff --git a/packages/spine-validation-ts/src/options/range.ts b/packages/spine-validation-ts/src/options/range.ts index 8dc297f..9ddec70 100644 --- a/packages/spine-validation-ts/src/options/range.ts +++ b/packages/spine-validation-ts/src/options/range.ts @@ -49,9 +49,11 @@ * * Examples: * ```protobuf - * int32 rgb_value = 1 [(range) = "[0..255]"]; // RGB color value - * int32 hour = 2 [(range) = "[0..24)"]; // Hour (0-23) - * double percentage = 3 [(range) = "(0.0..1.0)"]; // Exclusive percentage + * int32 rgb_value = 1 [(range).value = "[0..255]"]; // RGB color value + * int32 hour = 2 [(range).value = "[0..24)"]; // Hour (0-23) + * double percentage = 3 [(range).value = "(0.0..1.0)"]; // Exclusive percentage + * // With custom error message: + * int32 age = 4 [(range) = {value: "[18..120]", error_msg: "Age must be between 18 and 120"}]; * ``` */ @@ -62,6 +64,7 @@ import type { ConstraintViolation } from '../generated/spine/validate/validation import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; +import type { RangeOption } from '../generated/spine/options_pb'; import { getRegisteredOption } from '../options-registry'; /** @@ -81,14 +84,18 @@ interface ParsedRange { * @param fieldName Array representing the field path. * @param fieldValue The actual value of the field. * @param rangeStr The range string that was violated. + * @param customErrorMsg Optional custom error message from RangeOption. * @returns A `ConstraintViolation` object. */ function createViolation( typeName: string, fieldName: string[], fieldValue: any, - rangeStr: string + rangeStr: string, + customErrorMsg?: string ): ConstraintViolation { + const errorMsg = customErrorMsg || `The number must be in range ${rangeStr}.`; + return create(ConstraintViolationSchema, { typeName, fieldPath: create(FieldPathSchema, { @@ -96,7 +103,7 @@ function createViolation( }), fieldValue: undefined, message: create(TemplateStringSchema, { - withPlaceholders: `The number must be in range ${rangeStr}.`, + withPlaceholders: errorMsg, placeholderValue: { 'value': String(fieldValue), 'range': rangeStr @@ -249,11 +256,14 @@ function validateFieldRange( return; } - const rangeStr = getOption(field, rangeOpt); - if (!rangeStr || typeof rangeStr !== 'string') { + const rangeOption = getOption(field, rangeOpt) as RangeOption | undefined; + if (!rangeOption || !rangeOption.value) { return; } + const rangeStr = rangeOption.value; + const customErrorMsg = rangeOption.errorMsg || undefined; + const range = parseRange(rangeStr, scalarType); if (!range) { return; @@ -269,7 +279,8 @@ function validateFieldRange( schema.typeName, [field.name, String(index)], element, - rangeStr + rangeStr, + customErrorMsg )); } }); @@ -287,11 +298,14 @@ function validateFieldRange( return; } - const rangeStr = getOption(field, rangeOpt); - if (!rangeStr || typeof rangeStr !== 'string') { + const rangeOption = getOption(field, rangeOpt) as RangeOption | undefined; + if (!rangeOption || !rangeOption.value) { return; } + const rangeStr = rangeOption.value; + const customErrorMsg = rangeOption.errorMsg || undefined; + const range = parseRange(rangeStr, scalarType); if (!range) { return; @@ -306,7 +320,8 @@ function validateFieldRange( schema.typeName, [field.name], fieldValue, - rangeStr + rangeStr, + customErrorMsg )); } } diff --git a/packages/spine-validation-ts/src/options/required-field.ts b/packages/spine-validation-ts/src/options/required-field.ts index 753d327..cfd627a 100644 --- a/packages/spine-validation-ts/src/options/required-field.ts +++ b/packages/spine-validation-ts/src/options/required-field.ts @@ -66,6 +66,7 @@ import type { ConstraintViolation } from '../generated/spine/validate/validation import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; +import type { RequireOption } from '../generated/spine/options_pb'; import { getRegisteredOption } from '../options-registry'; /** @@ -256,9 +257,9 @@ export function validateRequiredFieldOption( message: any, violations: ConstraintViolation[] ): void { - const requiredFieldOption = getRegisteredOption('required_field'); + const requireFieldsOption = getRegisteredOption('requireFields'); - if (!requiredFieldOption) { + if (!requireFieldsOption) { return; } @@ -267,39 +268,17 @@ export function validateRequiredFieldOption( return; } - const unknownFields = options.$unknown as Array<{ no: number; wireType: number; data: Uint8Array }>; - if (unknownFields) { - const requiredFieldExtension = unknownFields.find((f: any) => f.no === 73902); - if (requiredFieldExtension) { - const dataWithoutLength = requiredFieldExtension.data.slice(1); - const decoder = new TextDecoder(); - const expression = decoder.decode(dataWithoutLength); - - if (expression && typeof expression === 'string') { - const satisfied = evaluateExpression(expression, message, schema); - - if (!satisfied) { - const violationMessage = `At least one of the required field combinations must be satisfied: ${expression}`; - violations.push(createViolation( - schema.typeName, - expression, - violationMessage - )); - } - return; - } - } - } - - if (!hasExtension(options, requiredFieldOption)) { + if (!hasExtension(options, requireFieldsOption)) { return; } - const expression = getExtension(options, requiredFieldOption); - if (!expression || typeof expression !== 'string') { + const requireOption = getExtension(options, requireFieldsOption) as RequireOption; + if (!requireOption || !requireOption.fields) { return; } + const expression = requireOption.fields; + const satisfied = evaluateExpression(expression, message, schema); if (!satisfied) { diff --git a/packages/spine-validation-ts/src/options/validate.ts b/packages/spine-validation-ts/src/options/validate.ts index 08cd12e..683e4b4 100644 --- a/packages/spine-validation-ts/src/options/validate.ts +++ b/packages/spine-validation-ts/src/options/validate.ts @@ -25,13 +25,11 @@ */ /** - * Validation logic for the `(validate)` and `(if_invalid)` options. + * Validation logic for the `(validate)` option. * * The `(validate)` option is a field-level constraint that enables recursive * validation of nested message fields, repeated message fields, and map fields. * - * The `(if_invalid)` option provides custom error messages for validation failures. - * * Supported field types: * - Message fields (singular) * - Repeated message fields @@ -39,7 +37,6 @@ * * Features: * - Recursive validation: validates constraints in nested messages - * - Custom error messages with token replacement (`{value}`) * - Validates each item in repeated fields * - Validates each value in map entries * @@ -50,7 +47,7 @@ * } * Address address = 1 [(validate) = true]; * repeated Product products = 2 [(validate) = true]; - * Customer customer = 3 [(validate) = true, (if_invalid).error_msg = "Invalid customer: {value}."]; + * Customer customer = 3 [(validate) = true]; * ``` */ @@ -61,7 +58,6 @@ import type { ConstraintViolation } from '../generated/spine/validate/validation import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; -import type { IfInvalidOption } from '../generated/spine/options_pb'; import { getRegisteredOption } from '../options-registry'; /** @@ -98,15 +94,11 @@ function createViolation( } /** - * Gets the error message from `(if_invalid)` option or returns default. + * Gets the default error message for nested validation failures. * - * @param ifInvalidOption The `(if_invalid)` option object. - * @returns The custom error message or a default message. + * @returns The default error message. */ -function getErrorMessage(ifInvalidOption: IfInvalidOption | undefined): string { - if (ifInvalidOption && ifInvalidOption.errorMsg) { - return ifInvalidOption.errorMsg; - } +function getErrorMessage(): string { return 'Nested message validation failed.'; } @@ -117,7 +109,6 @@ function getErrorMessage(ifInvalidOption: IfInvalidOption | undefined): string { * @param fieldPath Array representing the field path from parent. * @param nestedMessage The nested message instance to validate. * @param nestedSchema The schema of the nested message. - * @param ifInvalidOption The `(if_invalid)` option if present. * @param violations Array to collect constraint violations. */ function validateNestedMessage( @@ -125,7 +116,6 @@ function validateNestedMessage( fieldPath: string[], nestedMessage: any, nestedSchema: GenMessage, - ifInvalidOption: IfInvalidOption | undefined, violations: ConstraintViolation[] ): void { const { validate } = require('../validation'); @@ -133,7 +123,7 @@ function validateNestedMessage( const nestedViolations = validate(nestedSchema, nestedMessage); if (nestedViolations.length > 0) { - const errorMessage = getErrorMessage(ifInvalidOption); + const errorMessage = getErrorMessage(); violations.push(createViolation( parentTypeName, @@ -174,7 +164,6 @@ function validateFieldValidate( violations: ConstraintViolation[] ): void { const validateOpt = getRegisteredOption('validate'); - const ifInvalidOpt = getRegisteredOption('if_invalid'); if (!validateOpt) { return; @@ -189,10 +178,6 @@ function validateFieldValidate( return; } - const ifInvalidOption = ifInvalidOpt && hasOption(field, ifInvalidOpt) - ? getOption(field, ifInvalidOpt) as IfInvalidOption - : undefined; - const fieldValue = (message as any)[field.localName]; if (field.fieldKind === 'message') { @@ -210,7 +195,6 @@ function validateFieldValidate( [field.name], fieldValue, nestedSchema, - ifInvalidOption, violations ); } else if (field.fieldKind === 'list') { @@ -231,7 +215,6 @@ function validateFieldValidate( [field.name, String(index)], element, nestedSchema, - ifInvalidOption, violations ); } @@ -254,7 +237,6 @@ function validateFieldValidate( [field.name, key], value, nestedSchema, - ifInvalidOption, violations ); } diff --git a/packages/spine-validation-ts/src/validation.ts b/packages/spine-validation-ts/src/validation.ts index e5be72e..0a40644 100644 --- a/packages/spine-validation-ts/src/validation.ts +++ b/packages/spine-validation-ts/src/validation.ts @@ -45,6 +45,7 @@ import { validateRangeFields } from './options/range'; import { validateDistinctFields } from './options/distinct'; import { validateNestedFields } from './options/validate'; import { validateGoesFields } from './options/goes'; +import { validateChoiceFields } from './options/choice'; export type { ConstraintViolation, ValidationError } from './generated/spine/validate/validation_error_pb'; export type { TemplateString } from './generated/spine/validate/error_message_pb'; @@ -66,6 +67,7 @@ export type { FieldPath } from './generated/spine/base/field_path_pb'; * - `(distinct)` - ensures all elements in repeated fields are unique * - `(validate)` - enables recursive validation of nested message fields * - `(goes)` - enforces field dependency (field can only be set if another field is set) + * - `(choice)` - requires that a oneof group has at least one field set * * @param schema The message schema containing validation metadata. * @param message The message instance to validate. @@ -99,6 +101,7 @@ export function validate( validateDistinctFields(schema, message, violations); validateNestedFields(schema, message, violations); validateGoesFields(schema, message, violations); + validateChoiceFields(schema, message, violations); return violations; } diff --git a/packages/spine-validation-ts/tests/choice.test.ts b/packages/spine-validation-ts/tests/choice.test.ts new file mode 100644 index 0000000..b1daacb --- /dev/null +++ b/packages/spine-validation-ts/tests/choice.test.ts @@ -0,0 +1,162 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { create } from '@bufbuild/protobuf'; +import { validate } from '../src/validation'; +import { + PaymentMethodSchema, + ContactMethodSchema, + ShippingOptionSchema +} from './generated/test-choice_pb'; + +describe('Choice Option Validation (oneof)', () => { + describe('Basic Choice Validation', () => { + it('should pass when one field in oneof is set', () => { + const payment = create(PaymentMethodSchema, { + method: { + case: 'creditCard', + value: '4111111111111111' + } + }); + + const violations = validate(PaymentMethodSchema, payment); + expect(violations).toHaveLength(0); + }); + + it('should fail when no field in required oneof is set', () => { + const payment = create(PaymentMethodSchema, { + // method oneof not set + }); + + const violations = validate(PaymentMethodSchema, payment); + expect(violations.length).toBeGreaterThan(0); + + const choiceViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'method' + ); + expect(choiceViolation).toBeDefined(); + expect(choiceViolation?.message?.withPlaceholders).toContain('oneof'); + }); + + it('should pass when different field in oneof is set', () => { + const payment = create(PaymentMethodSchema, { + method: { + case: 'bankAccount', + value: '123456789' + } + }); + + const violations = validate(PaymentMethodSchema, payment); + expect(violations).toHaveLength(0); + }); + }); + + describe('Custom Error Messages', () => { + it('should use custom error message when provided', () => { + const contact = create(ContactMethodSchema, { + // contact oneof not set, has custom error message + }); + + const violations = validate(ContactMethodSchema, contact); + expect(violations.length).toBeGreaterThan(0); + + const choiceViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'contact' + ); + expect(choiceViolation).toBeDefined(); + expect(choiceViolation?.message?.withPlaceholders).toContain( + 'must provide a contact method' + ); + }); + }); + + + describe('Optional Oneofs', () => { + it('should pass when optional oneof is not set', () => { + const shipping = create(ShippingOptionSchema, { + // delivery oneof is optional (choice.required = false) + }); + + const violations = validate(ShippingOptionSchema, shipping); + expect(violations).toHaveLength(0); + }); + + it('should pass when optional oneof has a field set', () => { + const shipping = create(ShippingOptionSchema, { + delivery: { + case: 'standard', + value: true + } + }); + + const violations = validate(ShippingOptionSchema, shipping); + expect(violations).toHaveLength(0); + }); + }); + + describe('Multiple Oneofs in Same Message', () => { + it('should validate all oneofs independently', () => { + // Test case would require a proto with multiple oneofs + // For now, we verify that each oneof is validated separately + const payment = create(PaymentMethodSchema, { + method: { + case: 'paypal', + value: 'user@example.com' + } + }); + + const violations = validate(PaymentMethodSchema, payment); + expect(violations).toHaveLength(0); + }); + }); + + describe('Edge Cases', () => { + it('should handle message with no oneofs', () => { + // Most messages don't have oneofs, should not cause errors + const payment = create(PaymentMethodSchema, { + method: { + case: 'creditCard', + value: '4111111111111111' + } + }); + + const violations = validate(PaymentMethodSchema, payment); + expect(violations).toHaveLength(0); + }); + + it('should provide clear field path in violation', () => { + const payment = create(PaymentMethodSchema, {}); + + const violations = validate(PaymentMethodSchema, payment); + const choiceViolation = violations.find(v => + v.fieldPath?.fieldName[0] === 'method' + ); + + expect(choiceViolation?.fieldPath?.fieldName).toEqual(['method']); + expect(choiceViolation?.typeName).toBe('test.PaymentMethod'); + }); + }); +}); diff --git a/packages/spine-validation-ts/tests/integration.test.ts b/packages/spine-validation-ts/tests/integration.test.ts index 235fc64..6eeb272 100644 --- a/packages/spine-validation-ts/tests/integration.test.ts +++ b/packages/spine-validation-ts/tests/integration.test.ts @@ -404,11 +404,11 @@ describe('Integration Tests', () => { expect(violations).toHaveLength(0); }); - it('should detect nested User violations with custom error message', () => { + it('should detect nested User violations with default error message', () => { const invalidResponse = create(GetUserResponseSchema, { user: create(UserSchema, { id: 1, - name: '', // Required violation. + name: '', // Required violation. email: 'alice@example.com', role: Role.USER, tags: [] @@ -419,13 +419,13 @@ describe('Integration Tests', () => { const violations = validate(GetUserResponseSchema, invalidResponse); expect(violations.length).toBeGreaterThan(0); - // Should have parent-level violation with custom message. + // Should have parent-level violation with default message. const parentViolation = violations.find(v => v.fieldPath?.fieldName.length === 1 && v.fieldPath?.fieldName[0] === 'user' ); expect(parentViolation).toBeDefined(); - expect(parentViolation?.message?.withPlaceholders).toBe('User data is invalid.'); + expect(parentViolation?.message?.withPlaceholders).toBe('Nested message validation failed.'); // Should also have nested violation for name field. const nameViolation = violations.find(v => diff --git a/packages/spine-validation-ts/tests/proto/integration-account.proto b/packages/spine-validation-ts/tests/proto/integration-account.proto index 0f4473b..798d2c3 100644 --- a/packages/spine-validation-ts/tests/proto/integration-account.proto +++ b/packages/spine-validation-ts/tests/proto/integration-account.proto @@ -37,7 +37,7 @@ import "spine/options.proto"; // Account message combining multiple validation constraints. message Account { - option (required_field) = "id | email"; + option (require).fields = "id | email"; int32 id = 1 [(min).value = "1"]; // Must be a valid email address (basic format validation). @@ -61,14 +61,14 @@ message Account { AccountType account_type = 5 [(required) = true]; int32 age = 6 [ (required) = true, - (range) = "[13..120]" + (range).value = "[13..120]" ]; double balance = 7 [ (min).value = "0.0", (max).value = "1000000.0" ]; - int32 failed_login_attempts = 8 [(range) = "[0..5]"]; - double rating = 9 [(range) = "[1.0..5.0]"]; + int32 failed_login_attempts = 8 [(range).value = "[0..5]"]; + double rating = 9 [(range).value = "[1.0..5.0]"]; } // Account type enumeration. diff --git a/packages/spine-validation-ts/tests/proto/integration-product.proto b/packages/spine-validation-ts/tests/proto/integration-product.proto index a02fe66..ce2095f 100644 --- a/packages/spine-validation-ts/tests/proto/integration-product.proto +++ b/packages/spine-validation-ts/tests/proto/integration-product.proto @@ -56,7 +56,7 @@ message Product { ]; int32 stock = 5 [ (min).value = "0", - (range) = "[0..1000000)" + (range).value = "[0..1000000)" ]; google.protobuf.Timestamp created_at = 6 [(required) = true]; Category category = 7 [ @@ -70,9 +70,9 @@ message Product { // Color message for display settings. message Color { - int32 red = 1 [(range) = "[0..255]"]; - int32 green = 2 [(range) = "[0..255]"]; - int32 blue = 3 [(range) = "[0..255]"]; + int32 red = 1 [(range).value = "[0..255]"]; + int32 green = 2 [(range).value = "[0..255]"]; + int32 blue = 3 [(range).value = "[0..255]"]; } // Category message with validation. @@ -84,10 +84,10 @@ message Category { string name = 2 [(required) = true]; } -// Payment method demonstrating is_required oneof option. +// Payment method demonstrating choice oneof option. message PaymentMethod { oneof method { - option (is_required) = true; + option (choice).required = true; PaymentCardNumber payment_card = 1 [(validate) = true]; BankAccount bank_account = 2 [(validate) = true]; @@ -104,7 +104,7 @@ message PaymentCardNumber { ]; int32 expiry_month = 2 [ (required) = true, - (range) = "[1..12]" + (range).value = "[1..12]" ]; int32 expiry_year = 3 [ (required) = true, @@ -135,7 +135,7 @@ message ListProductsRequest { ]; int32 page_size = 2 [ (required) = true, - (range) = "[1..100]", + (range).value = "[1..100]", (if_missing).error_msg = "Page size is required." ]; string search_query = 3; diff --git a/packages/spine-validation-ts/tests/proto/integration-user.proto b/packages/spine-validation-ts/tests/proto/integration-user.proto index 01191c5..14ae37c 100644 --- a/packages/spine-validation-ts/tests/proto/integration-user.proto +++ b/packages/spine-validation-ts/tests/proto/integration-user.proto @@ -37,7 +37,7 @@ import "spine/options.proto"; // User message representing a user entity with validation constraints. message User { - option (required_field) = "id | email"; + option (require).fields = "id | email"; int32 id = 1 [(min).value = "1"]; // Must start with a letter and be 2-50 characters (letters, numbers, spaces allowed). diff --git a/packages/spine-validation-ts/tests/proto/spine/options.proto b/packages/spine-validation-ts/tests/proto/spine/options.proto index 2e05914..2baa543 100644 --- a/packages/spine-validation-ts/tests/proto/spine/options.proto +++ b/packages/spine-validation-ts/tests/proto/spine/options.proto @@ -1,11 +1,11 @@ /* - * Copyright 2022, TeamDev. All rights reserved. + * Copyright 2024, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -89,14 +89,17 @@ extend google.protobuf.FieldOptions { // The option to mark a field as required. // - // If the field type is a `message`, it must be set to a non-default instance. - // If it is `string` or `bytes`, the value must not be an empty string or an array. - // Other field types are not applicable. - // If the field is repeated, it must have at least one element. + // When this option is set: // - // Unlike the `required` keyword used in Protobuf 2, the option does not affect the transfer - // layer. Even if a message content violates the requirement set by the option, it would still - // be a valid message for the Protobuf library. + // 1. For message or enum fields, the field must be set to a non-default instance. + // 2. For `string` and `bytes` fields, the value must be set to a non-empty string or an array. + // 3. For repeated fields and maps, at least one element must be present. + // + // Other field types are not supported by the option. + // + // Unlike the `required` keyword in Protobuf 2, this option does not affect message + // serialization or deserialization. Even if a message content violates the requirement + // set by the option, it would still be a valid message for the Protobuf library. // // Example: Using `(required)` field validation constraint. // @@ -124,33 +127,155 @@ extend google.protobuf.FieldOptions { // See `PatternOption`. PatternOption pattern = 73820; - // Turns on validation constraint checking for a value of a message, a map, or a repeated field. + // Enables in-depth validation for fields that refer to a message. + // + // This option applies only to fields that reference a message: + // + // 1. Singular message fields. + // 2. Repeated fields of message types. + // 3. Map fields with message types as values. + // + // When set to `true`, the field is valid only if its value satisfies the validation + // constraints defined in the corresponding message type: + // + // 1. For singular message fields: the message must meet its constraints. + // + // Note: default instances are considered valid even if the message has required fields. + // In such cases, it is unclear whether the field is set with an invalid instance or + // simply unset. + // + // Example: + // + // ``` + // // Note that the default instance of `Address` is not valid because the `value` field + // // is mandatory. + // message Address { + // string value = 1 [(required) = true]; + // } + // + // // However, the default instance of `Address` in `Student.address` is valid, despite + // // having `(validate)` constraint that forces the message to meet its constraints. + // // Since the `address` field is optional for `Student`, `(validate)` would allow + // // default instances for this field treating them as "no value set". + // message Student { + // Address address = 1 [(validate) = true]; // implicit `(required) = false`. + // } // - // Default value is `false`. + // // Make the validated field required to avoid this behavior. In this case, `(validate)` + // // continues to bypass default instances, but the `(required)` option will report them. + // message Student { + // Address address = 1 [(validate) = true, (required) = true]; + // } + // ``` // - // If set to `true`, the outer message declaring the annotated field would be valid if: + // 2. For repeated fields: every element in the repeated field must meet the constraints + // of its message type. // - // 1. A message field value satisfies the validation constraints defined in the corresponding - // message type of the field. + // Example: // - // 2. Each value of a map entry satisfies validation constraints. + // ``` + // // Note that the default instance of `PhoneNumber` is not valid because the `value` + // // field is mandatory. + // message PhoneNumber { + // string value = 1 [(required) = true, (pattern).regex = "^\+?[0-9\s\-()]{1,30}$"]; + // } // - // 3. Each item of a repeated field satisfies validation constraints. + // // In contrast to singular fields, the default instances in `repeated` will also be + // // reported by the `(validate)` constraint, with those that do not match the pattern. + // message Student { + // repeated PhoneNumber number = 1 [(validate) = true]; + // } + // ``` + // + // 3. For map fields: each value in the map must meet the constraints of message type. + // Note: Protobuf does not allow messages to be used as map keys. + // + // Example: + // + // ``` + // // Note that the default instance of `PhoneNumber` is not valid because the `value` + // // field is mandatory. + // message PhoneNumber { + // string value = 1 [(required) = true, (pattern).regex = "^\+?[0-9\s\-()]{1,30}$"]; + // } + // + // // In contrast to singular fields, the default instances in `map` values will also be + // // reported by the `(validate)` constraint, with those that do not match the pattern. + // message Contacts { + // map map = 1 [(validate) = true]; + // } + // ``` + // + // If the field contains `google.protobuf.Any`, the option will first attempt to unpack + // the enclosed message, and only then validate it. However, unpacking is not always possible: + // + // 1. The default instance of `Any` is always valid because there is nothing to unpack + // and validate. + // 2. Instances with type URLs that are unknown to the application are also valid. + // + // Unpacking requires a corresponding Java class to deserialize the message, but if + // the application does not recognize the type URL, it has no way to determine which + // class to use. + // + // Such may happen when the packed message comes from a newer app version, an external + // system, or is simply not included in the application’s dependencies. // bool validate = 73821; // See `IfInvalidOption`. - IfInvalidOption if_invalid = 73822; + IfInvalidOption if_invalid = 73822 [deprecated = true]; // See `GoesOption`. GoesOption goes = 73823; // Indicates that a field can only be set once. // - // A typical use-case would include a value of an ID, which doesn't change over the course of - // the life of an entity. + // This option allows the target field to accept assignments only if one of the following + // conditions is met: + // + // 1. The current field value is the default for its type. + // Refer to the official docs on default values: https://protobuf.dev/programming-guides/proto3/#default. + // 2. The current field value equals to the proposed new value. + // + // The option can be applied to the following singular field types: // - // Example: Using `(set_once)` field validation constraint. + // - any message or enum type; + // - any numeric type; + // - `bool`, `string` and `bytes`. + // + // Repeated fields, maps, and fields with explicit `optional` cardinality are not supported. + // Such declarations will lead to build-time errors. For more information on field cardinality, + // refer to the official docs: https://protobuf.dev/programming-guides/proto3/#field-labels. + // + // Assigning a value to a message field can be done in various ways in the generated code. + // It depends on the target language and specific implementation of `protoc`. This option + // doesn't enforce field immutability at the binary representation level. Also, it does not + // prevent the use of Protobuf utilities that can construct new messages without using field + // setters or properties. The primary purpose of this option is to support standard use cases, + // such as assigning values through setters or retrieving them during data merging. + // + // For example, let's take a look on how it works for the generated Java code. The following + // use cases are supported and validated by the option: + // + // 1. Assigning a field value using the field setter. + // 2. Assigning a field value using the field descriptor. + // 3. Merging data from another instance of the message class. + // 4. Merging data from a message's binary representation. + // 5. Merging specific fields (available for Message fields). + // + // Unsupported Java use cases include: + // + // 1. Constructing a message using the `DynamicMessage` class. + // 2. Merging data from messages provided by third-party Protobuf implementations. + // 3. Clearing a specific field or an entire message. + // + // For unsupported scenarios, the option performs no validation. It does not throw errors + // or print warnings. + // + // A typical use case involves a field, such as an ID, which remains constant throughout + // the lifecycle of an entity. + // + // Example: using `(set_once)` field validation constraint. // // message User { // UserId id = 1 [(set_once) = true]; @@ -158,48 +283,74 @@ extend google.protobuf.FieldOptions { // // Once set, the `id` field cannot be changed. // + // Use `(if_set_again).error_msg` option to specify a custom error message that will be used for + // composing the error upon attempting to re-assign the field value. Refer to the documentation + // for the corresponding option for an example of its usage. + // bool set_once = 73824; - // The option to mark a `repeated` field as a collection of unique elements. + // The option to enforce uniqueness for collection fields. // - // Example: Using `(distinct)` constraint for a repeated field. + // When the option is set to `true`, the behavior is as follows: + // + // 1. For `repeated` fields: all elements must be unique. + // 2. For `map` fields: while the map keys are inherently unique, all associated values + // must also be unique. + // + // Other field types are not supported. + // + // Uniqueness is determined by comparing the elements themselves, using their full equality. + // For example, in Java, it is defined by the `equals()` method. No special cases are applied, + // such as comparing only specific fields like IDs. + // + // Example: using `(distinct)` constraint for a `repeated` field. // // message Blizzard { // // // All snowflakes must be unique in this blizzard. // // // // Attempting to add a snowflake that is equal to an existing one would result - // // in constraint violation error. + // // in a constraint violation error. // // - // repeated Snowflake = 1 [(distinct) = true]; + // repeated Snowflake snowflakes = 1 [(distinct) = true]; + // } + // + // Example: using `(distinct)` constraint for a `map` field. + // + // message UniqueEmails { + // + // // The associated email values must be unique. + // // + // // Attempting to add a key/value pair where the `Email` value duplicates + // // an existing one would result in a constraint violation error. + // // + // map emails = 1 [(distinct) = true]; // } // bool distinct = 73825; - // The option to indicate that a numeric field is required to have a value which belongs - // to the specified bounded range. For unbounded ranges, please use `(min)` and `(max) options. + // Reserved 73826 for deleted `range` option, which had `string` type. + + // Defines the error message used if a `set_once` field is set again. // - // The range can be open (not including the endpoint) or closed (including the endpoint) on - // each side. Open endpoints are indicated using a parenthesis (`(`, `)`). Closed endpoints are - // indicated using a square bracket (`[`, `]`). + // Applies only to the fields marked as `set_once`. // - // Example: Defining ranges of numeric values. + IfSetAgainOption if_set_again = 73827; + + // Defines the error message used if a `distinct` field has duplicates. // - // message NumRanges { - // int32 hour = 1 [(range) = "[0..24)"]; - // uint32 minute = 2 [(range) = "[0..59]"]; - // float degree = 3 [(range) = "[0.0..360.0)"]; - // double angle = 4 [(range) = "(0.0..180.0)"]; - // } + // Applies only to the repeated fields marked as `distinct`. // - // NOTE: That definition of ranges must be consistent with the type they constrain. - // An range for an integer field must be defined with integer endpoints. - // A range for a floating point field must be defined with decimal separator (`.`), - // even if the endpoint value does not have a fractional part. + IfHasDuplicatesOption if_has_duplicates = 73828; + + // The option to indicate that a numeric field is required to have a value which belongs + // to the specified bounded range. + // + // For unbounded ranges, please use `(min)` and `(max) options. // - string range = 73826; + RangeOption range = 73829; - // Reserved 73827 to 73849 for future validation options. + // Reserved 73830 to 73849 for future validation options. // API Annotations //----------------- @@ -232,8 +383,8 @@ extend google.protobuf.FieldOptions { // An API bearing this annotation is exempt from any compatibility guarantees made by its // containing library. Note that the presence of this annotation implies nothing about the // quality of the API in question, only the fact that it is not "API-frozen." - // It is generally safe for applications to depend on beta APIs, at the cost of some extra work - // during upgrades. + // It is generally safe for applications to depend on beta APIs, at the cost of + // some extra work during upgrades. // bool beta = 73853; @@ -259,9 +410,9 @@ extend google.protobuf.FieldOptions { // // All column fields are considered optional by the framework. // - // Currently, only entities of projection and process manager type are eligible for having - // columns (see `EntityOption`). For all other message types the column declarations are - // ignored. + // Currently, only entities of projection and process manager type are + // eligible for having columns (see `EntityOption`). + // For all other message types the column declarations are ignored. // // The `repeated` and `map` fields cannot be columns. // @@ -274,14 +425,13 @@ extend google.protobuf.FieldOptions { extend google.protobuf.OneofOptions { - // Marks a `oneof` group, in which one field *must* be set. - // - // Alternative to `(required_field)` with all the field in the group joined with the OR - // operator. - // - bool is_required = 73891; + // Deprecated: use the `(choice)` option instead. + bool is_required = 73891 [deprecated = true]; - // Reserved 73892 to 73899 for future options. + // Controls whether a `oneof` group must always have one of its fields set. + ChoiceOption choice = 73892; + + // Reserved 73893 to 73899 for future options. } extend google.protobuf.MessageOptions { @@ -289,46 +439,38 @@ extend google.protobuf.MessageOptions { // Validation Constraints //------------------------ - // The default format string for validation error message text. - // - // This option extends message types that extend `FieldOptions` - // The number of parameters and their types are determined by the type of field options. - // - // Usage of this value is deprecated. Along with the old `msg_format`s, it exists to support - // the old validation library. The new version of the validation library, which does not lie in - // the `base` repository, constructs the default error messages separately when creating - // language-agnostic validation rules. - // - string default_message = 73901 [deprecated = true]; - - // The constraint to require at least one of the fields or a combination of fields. + // The default validation error message. // - // Unlike the `required` field constraint which always require corresponding field, - // this message option allows to require alternative fields or a combination of them as - // an alternative. Field names and `oneof` group names are acceptable. + // Please note, this option is intended for INTERNAL USE only. It applies to message types + // that extend `FieldOptions` and is not intended for external usage. // - // Field names are separated using the pipe (`|`) symbol. The combination of fields is defined - // using the ampersand (`&`) symbol. + // If a validation option detects a constraint violation and no custom error message is defined + // for that specific option, it will fall back to the message specified by `(default_message)`. // - // Example: Pipe syntax for defining alternative required fields. + // For example, here is how to declare the default message for `(goes)` option: // - // message PersonName { - // option (required_field) = "given_name|honorific_prefix & family_name"; + // ``` + // message GoesOption { + // // The default error message. + // option (default_message) = "The field `${goes.companion}` must also be set when `${field.path}` is set."; + // } + // ``` // - // string honorific_prefix = 1; - // string given_name = 2; - // string middle_name = 3; - // string family_name = 4; - // string honorific_suffix = 5; - // } + // Note: The placeholders available within `(default_message)` depend solely on the particular + // validation option that uses it. Each option may define its own set of placeholders, or none. // - string required_field = 73902; + string default_message = 73901 [(internal) = true]; + + // Deprecated: use the `(require)` option instead. + string required_field = 73902 [deprecated = true]; // See `EntityOption`. EntityOption entity = 73903; // An external validation constraint for a field. // + // WARNING: This option is deprecated and is scheduled for removal in Spine v2.0.0. + // // Allows to re-define validation constraints for a message when its usage as a field of // another type requires alternative constraints. This includes definition of constraints for // a message which does not have them defined within the type. @@ -336,7 +478,7 @@ extend google.protobuf.MessageOptions { // A target field of an external constraint should be specified using a fully-qualified // field name (e.g. `mypackage.MessageName.field_name`). // - // Example: Defining external validation constraint. + // Example: defining external validation constraint. // // package io.spine.example; // @@ -393,7 +535,7 @@ extend google.protobuf.MessageOptions { // External validation constraints can be applied to fields of several types. // To do so, separate fully-qualified references to these fields with comma. // - // Example: External validation constraints for multiple fields. + // Example: external validation constraints for multiple fields. // // // External validation constraint for requiring a new value in renaming commands. // message RequireNewName { @@ -408,7 +550,7 @@ extend google.protobuf.MessageOptions { // Spine Model Compiler does not check such an "overwriting". // See the issue: https://github.com/SpineEventEngine/base/issues/318. // - string constraint_for = 73904; + string constraint_for = 73904 [deprecated = true]; // Reserved 73905 to 73910 for future validation options. @@ -446,7 +588,7 @@ extend google.protobuf.MessageOptions { // Specifies a characteristic inherent in the the given message type. // - // Example: Using `(is)` message option. + // Example: using `(is)` message option. // // message CreateProject { // option (is).java_type = "ProjectCommand"; @@ -456,8 +598,10 @@ extend google.protobuf.MessageOptions { // // In the example above, `CreateProject` message is a `ProjectCommand`. // - // To specify a characteristic for every message in a `.proto` file, use `(every_is)` file - // option. If both `(is)` and `(every_is)` options are found, both are applied. + // To specify a characteristic for every message in a `.proto` file, + // please use `(every_is)` file option. + // + // If both `(is)` and `(every_is)` options are applicable for a type, both are applied. // // When targeting Java, specify the name of a Java interface to be implemented by this // message via `(is).java_type`. @@ -476,7 +620,10 @@ extend google.protobuf.MessageOptions { // CompareByOption compare_by = 73923; - // Reserved 73924 to 73938 for future options. + // The constraint to require at least one of the fields or combinations of fields. + RequireOption require = 73924; + + // Reserved 73925 to 73938 for future options. // Reserved 73939 and 73940 for the deleted options `events` and `rejections`. } @@ -495,22 +642,39 @@ extend google.protobuf.FileOptions { // For more information on such restrictions please see the documentation of // the type option called `internal_type`. // + // If a file contains a declaration of a `service`, this option will NOT be applied to it. + // A service is not a data type, and therefore, this option does not apply to it. + // Internal services are not supported. + // bool internal_all = 73942; // Indicates a file which contains elements of Service Provider Interface (SPI). + // + // This option applies to messages, enums, and services. + // bool SPI_all = 73943; - // Indicates a public API that can change at any time, and has no guarantee of - // API stability and backward-compatibility. + // Indicates a file declaring public data type API which that can change at any time, + // has no guarantee of API stability and backward-compatibility. + // + // If a file contains a declaration of a `service`, this option will NOT be applied to it. + // A service is not a data type, and therefore, this option does not apply to it. + // Experimental services are not supported. + // bool experimental_all = 73944; - // Signifies that a public API is subject to incompatible changes, or even removal, + // Signifies that a public data type API is subject to incompatible changes, or even removal, // in a future release. + // + // If a file contains a declaration of a `service`, this option will NOT be applied to it. + // A service is not a data type, and therefore, this option does not apply to it. + // Beta services are not supported. + // bool beta_all = 73945; // Specifies a characteristic common for all the message types in the given file. // - // Example: Marking all the messages using the `(every_is)` file option. + // Example: marking all the messages using the `(every_is)` file option. // ``` // option (every_is).java_type = "ProjectCommand"; // @@ -528,13 +692,13 @@ extend google.protobuf.FileOptions { // In the example above, `CreateProject`, `CreateProject.WithAssignee`, and `DeleteProject` // messages are `ProjectCommand`-s. // - // To specify a characteristic for a single message, use `(is)` message option. If both `(is)` - // and `(every_is)` options are found, both are applied. + // To specify a characteristic for a single message, please use `(is)` message option. + // If both `(is)` and `(every_is)` options are applicable for a type, both are applied. // // When targeting Java, specify the name of a Java interface to be implemented by these // message types via `(every_is).java_type`. // - IsOption every_is = 73946; + EveryIsOption every_is = 73946; // Reserved 73947 to 73970 for future use. } @@ -553,22 +717,15 @@ extend google.protobuf.ServiceOptions { // Validation Option Types //--------------------------- -// Defines the error handling for `required` field with no value set. +// Defines the error message used if a `required` field is not set. // // Applies only to the fields marked as `required`. -// Validation error message is composed according to the rules defined by this option. -// -// Example: Using the `(if_missing)` option. -// -// message Holder { -// MyMessage field = 1 [(required) = true, -// (if_missing).error_msg = "This field is required."]; -// } // message IfMissingOption { // The default error message. - option (default_message) = "A value must be set."; + option (default_message) = "The field `${parent.type}.${field.path}`" + " of the type `${field.type}` must have a non-default value."; // A user-defined validation error format message. // @@ -577,35 +734,93 @@ message IfMissingOption { string msg_format = 1 [deprecated = true]; // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.type}` – the fully qualified name of the field type. + // 3. `${parent.type}` – the fully qualified name of the validated message. + // + // The placeholders will be replaced at runtime when the error is constructed. + // + // Example: using the `(if_missing)` option. + // + // message Student { + // Name name = 1 [(required) = true, + // (if_missing).error_msg = "The `${field.path}` field is mandatory for `${parent.type}`."]; + // } + // string error_msg = 2; } -// The field value must be greater than or equal to the given minimum number. -// -// Is applicable only to numbers. -// Repeated fields are supported. +// Indicates that the numeric field must be greater than or equal to the specified value. // -// Example: Defining lower boundary for a numeric field. -// -// message KelvinTemperature { -// double value = 1 [(min) = { -// value = "0.0" -// exclusive = true -// error_msg = "Temperature cannot reach {other}K, but provided {value}." -// }]; -// } +// The option supports all singular and repeated numeric fields. // message MinOption { - // The default error message format string. - // - // The format parameters are: - // 1) "or equal to " string (if the `exclusive` parameter is false) or an empty string; - // 2) the minimum number. - // - option (default_message) = "The number must be greater than %s%s."; + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}`" + " must be ${min.operator} ${min.value}. The passed value: `${field.value}`."; // The string representation of the minimum field value. + // + // ## Integer and floating-point values + // + // A minimum value for an integer field must use an integer number. Specifying a decimal + // number is not allowed, even if it has no fractional part (e.g., `5.0` is invalid). + // + // A minimum value for a floating-point field must use a decimal separator (`.`), even if + // the value has no fractional part. An exponent part represented by `E` or `e`, followed + // by an optional sign and digits is allowed (e.g., `1.2E3`, `0.5e-2`). + // + // Example: defining minimum values for integer and floating-point fields. + // + // message Measurements { + // int32 temperature = 1 [(min).value = "0"]; + // uint32 mass = 2 [(min).value = "5"]; + // float degree = 3 [(min).value = "0.0"]; + // double angle = 4 [(min).value = "30.0"]; + // float pressure = 5 [(min).value = "950.0E-2"]; + // } + // + // ## Field Type Limitations + // + // A minimum value must not fall below the limits of the field type. + // + // Example: invalid values that fall below the field type limits. + // + // message OverflowMeasurements { + // float pressure = 1 [(min).value = "-5.5E38"]; // Falls below the `float` minimum. + // uint32 mass = 2 [(min).value = "-5"]; // Falls below the `uint32` minimum. + // } + // + // ## Field references + // + // Instead of numeric literals, you can reference another numeric field. + // At runtime, the field’s value will be used as the bound. Nested fields are supported. + // + // Example: defining minimum values using field references. + // + // message Measurements { + // + // int32 min_length = 1; + // int32 length = 2 [(min).value = "min_length"]; + // + // Limits limits = 3; + // int32 temperature = 4 [(min).value = "limits.min_temperature"]; + // float pressure = 5 [(min).value = "limits.min_pressure"]; + // } + // + // message Limits { + // int32 min_temperature = 1; + // float min_pressure = 2; + // } + // + // Note: Field type compatibility is not required in this case; the value is + // automatically converted. However, only numeric fields can be referenced. + // Repeated and map fields are not supported. + // string value = 1; // Specifies if the field should be strictly greater than the specified minimum. @@ -617,36 +832,92 @@ message MinOption { // A user-defined validation error format message. string msg_format = 3 [deprecated = true]; - // A user-defined validation error format message. + // A user-defined error message. // - // May include tokens `{value}`β€”for the actual value of the field, and `{other}`β€”for - // the threshold value. The tokens will be replaced at runtime when the error is constructed. + // The specified message may include the following placeholders: + // + // 1. `${field.value}` - the field value. + // 2. `${field.path}` – the field path. + // 3. `${field.type}` – the fully qualified name of the field type. + // 4. `${parent.type}` – the fully qualified name of the validated message. + // 5. `${min.value}` – the specified minimum `value`. For referenced fields, the actual + // field value is also printed in round brackets along with the reference itself. + // 6. `${min.operator}` – if `exclusive` is set to `true`, this placeholder equals to ">". + // Otherwise, ">=". + // + // The placeholders will be replaced at runtime when the error is constructed. // string error_msg = 4; } -// The field value must be less than or equal to the given maximum number. +// Indicates that the numeric field must be less than or equal to the specified value. // -// Is applicable only to numbers. -// Repeated fields are supported. -// -// Example: Defining upper boundary for a numeric field. -// -// message Elevation { -// double value = 1 [(max).value = "8848.86"]; -// } +// The option supports all singular and repeated numeric fields. // message MaxOption { - // The default error message format string. - // - // The format parameters are: - // 1) "or equal to " string (if the `exclusive` parameter is false) or an empty string; - // 2) the maximum number. - // - option (default_message) = "The number must be less than %s%s."; + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}`" + " must be ${max.operator} ${max.value}. The passed value: `${field.value}`."; // The string representation of the maximum field value. + // + // ## Integer and floating-point values + // + // A maximum value for an integer field must use an integer number. Specifying a decimal + // number is not allowed, even if it has no fractional part (e.g., `5.0` is invalid). + // + // A maximum value for a floating-point field must use a decimal separator (`.`), even if + // the value has no fractional part. An exponent part represented by `E` or `e`, followed + // by an optional sign and digits is allowed (e.g., `1.2E3`, `0.5e-2`). + // + // Example: defining maximum values for integer and floating-point fields. + // + // message Measurements { + // int32 temperature = 1 [(max).value = "270"]; + // uint32 mass = 2 [(max).value = "1200"]; + // float degree = 3 [(max).value = "360.0"]; + // double angle = 4 [(max).value = "90.0"]; + // float pressure = 5 [(max).value = "1050.0E-2"]; + // } + // + // ## Field Type Limitations + // + // A maximum value must not exceed the limits of the field type. + // + // Example: invalid values that exceed the field type limits. + // + // message OverflowMeasurements { + // float pressure = 1 [(min).value = "5.5E38"]; // Exceeds the `float` maximum. + // int32 mass = 2 [(min).value = "2147483648"]; // Exceeds the `int32` maximum. + // } + // + // ## Field references + // + // Instead of numeric literals, you can reference another numeric field. + // At runtime, the field’s value will be used as the bound. Nested fields are supported. + // + // Example: defining maximum values using field references. + // + // message Measurements { + // + // int32 max_length = 1; + // int32 length = 2 [(max).value = "max_length"]; + // + // Limits limits = 3; + // int32 temperature = 4 [(max).value = "limits.max_temperature"]; + // float pressure = 5 [(max).value = "limits.max_pressure"]; + // } + // + // message Limits { + // int32 max_temperature = 1; + // float max_pressure = 2; + // } + // + // Note: Field type compatibility is not required in this case; the value is + // automatically converted. However, only numeric fields can be referenced. + // Repeated and map fields are not supported. + // string value = 1; // Specifies if the field should be strictly less than the specified maximum @@ -658,34 +929,55 @@ message MaxOption { // A user-defined validation error format message. string msg_format = 3 [deprecated = true]; - // A user-defined validation error format message. + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.value}` - the field value. + // 3. `${field.type}` – the fully qualified name of the field type. + // 4. `${parent.type}` – the fully qualified name of the validated message. + // 5. `${max.value}` – the specified maximum `value`. For referenced fields, the actual + // field value is also printed in round brackets along with the reference itself. + // 6. `${max.operator}` – if `exclusive` is set to `true`, this placeholder equals to "<". + // Otherwise, "<=". // - // May include tokens `{value}`β€”for the actual value of the field, and `{other}`β€”for - // the threshold value. The tokens will be replaced at runtime when the error is constructed. + // The placeholders will be replaced at runtime when the error is constructed. // string error_msg = 4; } // A string field value must match the given regular expression. -// Is applicable only to strings. -// Repeated fields are supported. // -// Example: Using the `(pattern)` option. +// This option is applicable only to string fields, +// including those that are repeated. +// +// Example: using the `(pattern)` option. // // message CreateAccount { // string id = 1 [(pattern).regex = "^[A-Za-z0-9+]+$", -// (pattern).error_msg = "ID must be alphanumerical. Provided: `{value}`."]; +// (pattern).error_msg = "ID must be alphanumerical in `${parent.type}`. Provided: `${field.value}`."]; // } // message PatternOption { - // The default error message format string. + // The default error message. + option (default_message) = "The `${parent.type}.${field.path}` field" + " must match the regular expression `${regex.pattern}` (modifiers: `${regex.modifiers}`)." + " The passed value: `${field.value}`."; + + // The regular expression that the field value must match. // - // The format parameter is the regular expression to which the value must match. + // Please use the Java regex dialect for the syntax baseline: + // https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/regex/Pattern.html + // + // Note: in Java, regex patterns are not wrapped in explicit delimiters like in Perl or PHP. + // Instead, the pattern is provided as a string literal. Therefore, `/` symbol does not need + // to be escaped. + // + // The provided string literal is passed directly to the regex engine. So, it must be exactly + // what you would supply to the `java.util.regex.Pattern.compile()` method. // - option (default_message) = "The string must match the regular expression `%s`."; - - // The regular expression to match. string regex = 1; reserved 2; @@ -699,8 +991,16 @@ message PatternOption { // A user-defined validation error format message. // - // May include tokens `{value}`β€”for the actual value of the field, and `{other}`β€”for - // the threshold value. The tokens will be replaced at runtime when the error is constructed. + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.value}` - the field value. + // 3. `${field.type}` – the fully qualified name of the field type. + // 4. `${parent.type}` – the fully qualified name of the validated message. + // 5. `${regex.pattern}` – the specified regex pattern. + // 6. `${regex.modifiers}` – the specified modifiers, if any. For example, `[dot_all, unicode]`. + // + // The placeholders will be replaced at runtime when the error is constructed. // string error_msg = 5; @@ -714,8 +1014,8 @@ message PatternOption { // // By default, the dot does not match line break characters. // - // May also be known in some platforms as "single line" mode and be encoded with the `s` - // flag. + // May also be known in some platforms as "single line" mode and be encoded with + // the `s` flag. // bool dot_all = 1; @@ -757,39 +1057,60 @@ message PatternOption { } // Specifies the message to show if a validated field happens to be invalid. -// Is applicable only to messages. -// Repeated fields are supported. -// -// Example: Using the `(if_invalid)` option. // -// message Holder { -// MyMessage field = 1 [(validate) = true, -// (if_invalid).error_msg = "The field is invalid."]; -// } +// It is applicable only to fields marked with `(validate)`. // message IfInvalidOption { - // The default error message for the field. - option (default_message) = "The message must have valid properties."; + // Do not specify error message for `(validate)`, it is no longer used by + // the validation library. + option deprecated = true; + + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}` of the type" + " `${field.type}` is invalid. The field value: `${field.value}`."; // A user-defined validation error format message. + // + // Use `error_msg` instead. + // string msg_format = 1 [deprecated = true]; - // A user-defined validation error format message. + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.value}` - the field value. + // 3. `${field.type}` – the fully qualified name of the field type. + // 4. `${parent.type}` – the fully qualified name of the field declaring type. // - // May include the token `{value}` for the actual value of the field. The token will be replaced - // at runtime when the error is constructed. + // The placeholders will be replaced at runtime when the error is constructed. + // + // Example: using the `(if_invalid)` option. + // + // message Transaction { + // TransactionDetails details = 1 [(validate) = true, + // (if_invalid).error_msg = "The `${field.path}` field is invalid."]; + // } // string error_msg = 2; } -// Specifies that a message field can be present only if another field is present. +// Specifies that another field must be present if the option's target field is present. // // Unlike the `required_field` that handles combination of required fields, this option is useful -// when it is needed to say that an optional field makes sense only when another optional field is -// present. +// when it is needed to say that an optional field makes sense only when another optional field +// is present. +// +// This option can be applied to the same field types as `(required)`, including both the +// target field and its companion. Supported field types are: +// +// - Messages and enums. +// - Repeated fields and maps. +// - `string` and `bytes`. // -// Example: Requiring mutual presence of optional fields. +// Example: requiring mutual presence of optional fields. // // message ScheduledItem { // ... @@ -799,23 +1120,27 @@ message IfInvalidOption { // message GoesOption { - // The default error message format string. - // - // The first parameter is the name of the field for which we specify the option. - // The second parameter is the name of the field set in the "with" value. - // - option (default_message) = "The field `%s` can only be set when the field `%s` is defined."; + // The default error message. + option (default_message) = "The field `${goes.companion}` must also be set when `${field.path}`" + " is set in `${parent.type}`."; - // A name of the field required for presence of the field for which we set the option. + // The name of the companion field whose presence is required for this field to be valid. string with = 1; // A user-defined validation error format message. string msg_format = 2 [deprecated = true]; - // A user-defined validation error format message. + // A user-defined error message. // - // May include the token `{value}` for the actual value of the field. The token will be replaced - // at runtime when the error is constructed. + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.value}` – the field value. + // 3. `${field.type}` – the fully qualified name of the field type. + // 4. `${parent.type}` – the fully qualified name of the validated message. + // 5. `${goes.companion}` – the name of the companion specified in `with`. + // + // The placeholders will be replaced at runtime when the error is constructed. // string error_msg = 3; } @@ -826,7 +1151,7 @@ message EntityOption { // A type of an entity for state of which the message is defined. enum Kind { option allow_alias = true; - + // Reserved for errors. KIND_UNKNOWN = 0; @@ -879,45 +1204,68 @@ message EntityOption { Visibility visibility = 2; } -// Defines a marker for a given type or a set of types. +// Defines a common type for message types declared in the same proto file. // -// The option may be used in two modes: -// - with the marker code generation; -// - without the marker code generation. +// The nature of the type depends on the target programming language. +// For example, the `java_type` property defines a name of the Java interface common +// to all message classes generated for the proto file having this option. // -// When used with the code generation, language-specific markers are generated by the Protobuf -// compiler. Otherwise, it is expected that the user creates such markers manually. +// The option triggers creation of the common type if the `generate` property is set to true. +// Otherwise, it is expected that the user provides the reference to an existing type. // -message IsOption { +message EveryIsOption { - // Enables the generation of marker interfaces. + // Enables the generation of the common type. + // + // The default value is `false`. // - // The generation is disabled by default. bool generate = 1; - // The reference to a Java interface. + // The reference to a Java top-level interface. // - // May be an fully-qualified or a simple name. In the latter case, the interface should belong - // to the same Java package as the message class which implements this interface. + // The interface cannot be nested into a class or another interface. + // If a nested interface is provided, the code generation should fail the build process. // - // The framework does not ensure the referenced type exists. - // If the generation is disabled, the Java type is used as-is. Otherwise, a corresponding Java - // interface is generated. + // The value may be a fully-qualified or a simple name. // - // A generated interface has no declared methods and extends `com.google.protobuf.Message`. + // When a simple name is set, it is assumed that the interface belongs to + // the package of the generated message classes. // - // The `.java` file is placed alongside with the code generated by the proto-to-java compiler. + // If the value of the `generate` field is set to `false` the referenced interface must exist. + // Otherwise, a compilation error will occur. // - // If fully-qualified name given, the package of the generated type matches the fully-qualified - // name. When a simple name is set in the option, the package of the interface matches the - // package of the message class. + // If the value of the `generate` field is set to `true`, the framework will + // generate the interface using the given name and the package as described above. // - // If both `(is)` and `(every_is)` options specify a Java interface, the message class - // implements both interfaces. + // The generated interface will extend `com.google.protobuf.Message` and + // will have no declared methods. // string java_type = 2; } +// Defines additional type for a message type in which this option is declared. +// +// The nature of the type depends on the target programming language. +// For example, the `java_type` property defines a name of the Java interface which +// the generated message class will implement. +// +message IsOption { + + // The reference to a Java top-level interface. + // + // The interface cannot be nested into a class or another interface. + // If a nested interface is provided, the code generation should fail the build process. + // + // The value may be a fully-qualified or a simple name. + // + // When a simple name is set, it is assumed that the interface belongs to + // the package of the generated message classes. + // + // The referenced interface must exist. Otherwise, a compilation error will occur. + // + string java_type = 1; +} + // Defines the way to compare two messages of the same type to one another. // // Comparisons can be used to sort values. @@ -930,23 +1278,25 @@ message CompareByOption { // // The allowed field types are: // - any number type; - // - `bool` (false is less than true); + // - `bool` (`false` is less than `true`); // - `string` (in the order of respective Unicode values); // - enumerations (following the order of numbers associated with each constant); - // - messages marked with `(compare_by)`. + // - local messages (generated within the current build) marked with `(compare_by)`; + // - external messages (from dependencies), which either marked with `(compare_by)` + // OR have a comparator provided in `io.spine.compare.ComparatorRegistry`. // - // Other types are not permitted. Neither are repeated and map fields. Such declarations can - // lead to build-time errors. + // Other types are not permitted. Repeated or map fields are not permitted either. + // Such declarations will lead to build-time errors. // - // To refer to nested fields, separate the field names with a dot (`.`). No fields in the path - // can be repeated or maps. + // To refer to nested fields, separate the field names with a dot (`.`). + // No fields in the path can be repeated or maps. // // When multiple field paths are specified, comparison is executed in the order of reference. - // For example, specifying ["seconds", "nanos"] makes the comparison mechanism prioritize + // For example, specifying `["seconds", "nanos"]` makes the comparison mechanism prioritize // the `seconds` field and refer to `nanos` only when `seconds` are equal. // - // Note. When comparing message fields, a non-set message is always less than a set message. - // But if a message is set to a default value, the comparison falls back to + // NOTE: When comparing fields with a message type, a non-set message is always less than + // a set message. But if a message is set to a default value, the comparison falls back to // the field-wise comparison, i.e. number values are treated as zeros, `bool` β€” as `false`, // and so on. // @@ -956,3 +1306,261 @@ message CompareByOption { // the lower, enums β€” from the last number value to the 0th value, etc. bool descending = 2; } + +// Defines the error message used if a `set_once` field is set again. +// +// Applies only to the fields marked as `set_once`. +// +message IfSetAgainOption { + + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}` of the type" + " `${field.type}` already has the value `${field.value}` and cannot be reassigned" + " to `${field.proposed_value}`."; + + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.type}` – the fully qualified name of the field type. + // 3. `${field.value}` – the current field value. + // 4. `${field.proposed_value}` – the value, which was attempted to be set. + // 5. `${parent.type}` – the fully qualified name of the validated message. + // + // The placeholders will be replaced at runtime when the error is constructed. + // + // Example: using the `(set_once)` option. + // + // message User { + // UserId id = 1 [(set_once) = true, + // (if_set_again).error_msg = "A student ID is used as a permanent identifier within academic system, and cannot be re-assigned."]; + // } + // + string error_msg = 1; +} + +// Defines the error message used if a `distinct` field has duplicates. +// +// Applies only to the `repeated` and `map` fields marked with the `(distinct)` option. +// +message IfHasDuplicatesOption { + + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}` of the type" + " `${field.type}` must not contain duplicates." + " The duplicates found: `${field.duplicates}`."; + + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.type}` – the fully qualified name of the field type. + // 3. `${field.value}` – the field value (the whole collection). + // 4. `${field.duplicates}` – the duplicates found (elements that occur more than once). + // 5. `${parent.type}` – the fully qualified name of the validated message. + // + // The placeholders will be replaced at runtime when the error is constructed. + // + // Example: using the `(distinct)` option. + // + // message Blizzard { + // repeated Snowflake = 1 [(distinct) = true, + // (if_has_duplicates).error_msg = "Every snowflake must be unique! The duplicates found: `${field.duplicates}`."]; + // } + // + string error_msg = 1; +} + +// Indicate that the numeric field must belong to the specified bounded range. +// +// For unbounded ranges, please use `(min)` and `(max) options. +// +// The option supports all singular and repeated numeric fields. +// +message RangeOption { + + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}` must be within" + " the following range: `${range.value}`. The passed value: `${field.value}`."; + + // The string representation of the range. + // + // A range consists of two bounds: a lower bound and an upper bound. These bounds are + // separated by either the `..` or ` .. ` delimiter. Each bound can be either open + // or closed. The format of the bounds and the valid values depend on the type of field. + // + // ## Bound Types + // + // - Closed bounds include the endpoint and are indicated using square brackets (`[`, `]`). + // Example: `[0..10]` represents values from 0 to 10, inclusive. + // + // - Open bounds exclude the endpoint and are indicated using parentheses (`(`, `)`). + // Example: `(0..10)` represents values strictly between 0 and 10. + // + // The lower bound must be less than or equal to the upper bound. + // + // ## Integer Fields + // + // A range for an integer field must use integer numbers. Specifying a decimal number + // is not allowed, even if it has no fractional part (e.g., `5.0` is invalid). + // + // Example: defining ranges for integer fields. + // + // message Measurements { + // int32 length = 1 [(range).value = "[0..100)"]; + // uint32 mass = 2 [(range).value = "(0..100]"]; + // } + // + // ## Floating-Point Fields + // + // A range for a floating-point field must use a decimal separator (`.`), even if the value + // has no fractional part. An exponent part represented by `E` or `e`, followed by an optional + // sign and digits is allowed (e.g., `1.2E3`, `0.5e-2`). + // + // Example: defining ranges for floating-point fields. + // + // message Measurements { + // float degree = 1 [(range).value = "[0.0 .. 360.0)"]; + // double angle = 2 [(range).value = "(0.0 .. 180.0)"]; + // float pressure = 3 [(range).value = "[950.0E-2 .. 1050.0E-2]"]; + // } + // + // ## Field Type Limitations + // + // A range must not exceed the limits of the field type. + // + // Example: invalid ranges that exceed the field type limits. + // + // message OverflowMeasurements { + // float price = 1 [(range).value = "[0, 5.5E38]"]; // Exceeds the `float` maximum. + // uint32 size = 2 [(range).value = "[-5; 10]"]; // Falls below the `uint32` minimum. + // } + // + // ## Field references + // + // Instead of numeric literals, you can reference another numeric field. + // At runtime, the field’s value will be used as the bound. Nested fields are supported. + // + // Example: defining ranges using field references. + // + // message Measurements { + // + // int32 max_length = 1; + // int32 length = 2 [(range).value = "[1 .. max_length"]; + // + // Limits limits = 3; + // int32 temperature = 4 [(range).value = "[limits.low_temp .. limits.high_temp]"]; + // } + // + // message Limits { + // int32 low_temp = 1; + // int32 high_temp = 2; + // } + // + // Note: Field type compatibility is not required in this case; the value is + // automatically converted. However, only numeric fields can be referenced. + // Repeated and map fields are not supported. + // + string value = 1; + + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` – the field path. + // 2. `${field.value}` - the field value. + // 3. `${field.type}` – the fully qualified name of the field type. + // 4. `${parent.type}` – the fully qualified name of the validated message. + // 5. `${range.value}` – the specified range. For referenced fields, the actual + // field value is also printed in round brackets along with the reference itself. + // + // The placeholders will be replaced at runtime when the error is constructed. + // + string error_msg = 2; +} + +// Controls whether a `oneof` group must always have one of its fields set. +// +// Note that unlike the `(required)` constraint, this option supports any field types +// within the group cases. +// +message ChoiceOption { + + // The default error message. + option (default_message) = "The `oneof` group `${parent.type}.${group.path}` must" + " have one of its fields set."; + + // Enables or disables the requirement for the `oneof` group to have a value. + bool required = 1; + + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${group.path}` – the group path. + // 2. `${parent.type}` – the fully qualified name of the validated message. + // + // The placeholders will be replaced at runtime when the error is constructed. + // + string error_msg = 2; +} + +// Declares the field groups, at least one of which must have all of its fields set. +// +// Unlike the `(required)` field constraint, which requires the presence of +// a specific field, this option allows to specify alternative field groups. +// +message RequireOption { + + // The default error message. + option (default_message) = "The message `${message.type}` must have at least one of" + " the following field groups set: `${require.fields}`."; + + // A set of field groups, at least one of which must have all of its fields set. + // + // A field group can include one or more fields joined by the ampersand (`&`) symbol. + // `oneof` group names are also valid and can be used along with field names. + // Groups are separated using the pipe (`|`) symbol. + // + // The field type determines when the field is considered set: + // + // 1. For message or enum fields, it must have a non-default instance. + // 2. For `string` and `bytes` fields, it must be non-empty. + // 3. For repeated fields and maps, it must contain at least one element. + // + // Fields of other types are not supported by this option. + // + // For `oneof`s, the restrictions above do not apply. Any `oneof` group can be used + // without considering the field types, and its value will be checked directly without + // relying on the default values of the fields within the `oneof`. + // + // Example: defining two field groups. + // + // message PersonName { + // option (require).fields = "given_name | honorific_prefix & family_name"; + // + // string honorific_prefix = 1; + // string given_name = 2; + // string middle_name = 3; + // string family_name = 4; + // string honorific_suffix = 5; + // } + // + // In this example, at least `given_name` or a group of `honorific_prefix` + // and `family_name` fields must be set. + // + string fields = 1; + + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${message.type}` – the fully qualified name of the validated message. + // 2. `${require.fields}` – the specified field groups. + // + // The placeholders will be replaced at runtime when the error is constructed. + // + string error_msg = 2; +} diff --git a/packages/spine-validation-ts/tests/proto/test-distinct.proto b/packages/spine-validation-ts/tests/proto/test-distinct.proto index d728e34..ddda3a8 100644 --- a/packages/spine-validation-ts/tests/proto/test-distinct.proto +++ b/packages/spine-validation-ts/tests/proto/test-distinct.proto @@ -64,7 +64,7 @@ message NonDistinctFields { message CombinedConstraints { repeated int32 product_ids = 1 [ (distinct) = true, - (range) = "[1..999999]" + (range).value = "[1..999999]" ]; // Each email must be a valid email address (basic format validation). repeated string emails = 2 [ diff --git a/packages/spine-validation-ts/tests/proto/test-goes.proto b/packages/spine-validation-ts/tests/proto/test-goes.proto index a2f4487..03230db 100644 --- a/packages/spine-validation-ts/tests/proto/test-goes.proto +++ b/packages/spine-validation-ts/tests/proto/test-goes.proto @@ -137,7 +137,7 @@ message AdvancedConfig { string config_name = 1; int32 max_connections = 2 [ (goes).with = "config_name", - (range) = "[1..1000]" + (range).value = "[1..1000]" ]; double timeout_seconds = 3 [ (goes).with = "config_name", diff --git a/packages/spine-validation-ts/tests/proto/test-range.proto b/packages/spine-validation-ts/tests/proto/test-range.proto index 444a89c..fcc665a 100644 --- a/packages/spine-validation-ts/tests/proto/test-range.proto +++ b/packages/spine-validation-ts/tests/proto/test-range.proto @@ -37,77 +37,77 @@ import "spine/options.proto"; // Tests closed (inclusive) ranges. message ClosedRange { - int32 percentage = 1 [(range) = "[0..100]"]; - int32 rgb_value = 2 [(range) = "[0..255]"]; - double temperature_c = 3 [(range) = "[-273.15..1000.0]"]; + int32 percentage = 1 [(range).value = "[0..100]"]; + int32 rgb_value = 2 [(range).value = "[0..255]"]; + double temperature_c = 3 [(range).value = "[-273.15..1000.0]"]; } // Tests open (exclusive) ranges. message OpenRange { - double positive_value = 1 [(range) = "(0.0..100.0)"]; - int32 exclusive_count = 2 [(range) = "(0..10)"]; + double positive_value = 1 [(range).value = "(0.0..100.0)"]; + int32 exclusive_count = 2 [(range).value = "(0..10)"]; } // Tests half-open ranges. message HalfOpenRange { - int32 hour = 1 [(range) = "[0..24)"]; - int32 minute = 2 [(range) = "[0..60)"]; - float degree = 3 [(range) = "[0.0..360.0)"]; - double angle = 4 [(range) = "(0.0..180.0]"]; + int32 hour = 1 [(range).value = "[0..24)"]; + int32 minute = 2 [(range).value = "[0..60)"]; + float degree = 3 [(range).value = "[0.0..360.0)"]; + double angle = 4 [(range).value = "(0.0..180.0]"]; } // Tests range validation across different numeric types. message NumericTypeRanges { - int32 int32_field = 1 [(range) = "[1..100]"]; - int64 int64_field = 2 [(range) = "[0..1000000]"]; - uint32 uint32_field = 3 [(range) = "[1..65535]"]; - uint64 uint64_field = 4 [(range) = "[1..4294967295]"]; - float float_field = 5 [(range) = "[0.0..1.0]"]; - double double_field = 6 [(range) = "[-1000.0..1000.0]"]; + int32 int32_field = 1 [(range).value = "[1..100]"]; + int64 int64_field = 2 [(range).value = "[0..1000000]"]; + uint32 uint32_field = 3 [(range).value = "[1..65535]"]; + uint64 uint64_field = 4 [(range).value = "[1..4294967295]"]; + float float_field = 5 [(range).value = "[0.0..1.0]"]; + double double_field = 6 [(range).value = "[-1000.0..1000.0]"]; } // Tests range validation on repeated fields. message RepeatedRange { - repeated int32 scores = 1 [(range) = "[0..100]"]; - repeated double percentages = 2 [(range) = "[0.0..100.0]"]; + repeated int32 scores = 1 [(range).value = "[0..100]"]; + repeated double percentages = 2 [(range).value = "[0.0..100.0]"]; } // Tests combined required and range constraints. message CombinedConstraints { - int32 product_id = 1 [(required) = true, (range) = "[1..999999]"]; - int32 quantity = 2 [(required) = true, (range) = "[1..1000]"]; - double discount = 3 [(range) = "[0.0..1.0]"]; + int32 product_id = 1 [(required) = true, (range).value = "[1..999999]"]; + int32 quantity = 2 [(required) = true, (range).value = "[1..1000]"]; + double discount = 3 [(range).value = "[0.0..1.0]"]; } // Tests range validation for payment card fields. message PaymentCard { - int32 expiry_month = 1 [(required) = true, (range) = "[1..12]"]; - int32 expiry_year = 2 [(required) = true, (range) = "[2024..2050]"]; - int32 cvv = 3 [(required) = true, (range) = "[0..999]"]; + int32 expiry_month = 1 [(required) = true, (range).value = "[1..12]"]; + int32 expiry_year = 2 [(required) = true, (range).value = "[2024..2050]"]; + int32 cvv = 3 [(required) = true, (range).value = "[0..999]"]; } // Tests range validation for RGB color values. message RGBColor { - int32 red = 1 [(range) = "[0..255]"]; - int32 green = 2 [(range) = "[0..255]"]; - int32 blue = 3 [(range) = "[0..255]"]; - double alpha = 4 [(range) = "[0.0..1.0]"]; + int32 red = 1 [(range).value = "[0..255]"]; + int32 green = 2 [(range).value = "[0..255]"]; + int32 blue = 3 [(range).value = "[0..255]"]; + double alpha = 4 [(range).value = "[0.0..1.0]"]; } // Tests range validation for pagination parameters. message PaginationRequest { - int32 page = 1 [(required) = true, (range) = "[1..10000]"]; - int32 page_size = 2 [(required) = true, (range) = "[1..100]"]; + int32 page = 1 [(required) = true, (range).value = "[1..10000]"]; + int32 page_size = 2 [(required) = true, (range).value = "[1..100]"]; } // Tests optional fields with range constraints. message OptionalRange { - int32 optional_score = 1 [(range) = "[1..100]"]; - double optional_rating = 2 [(range) = "[1.0..5.0]"]; + int32 optional_score = 1 [(range).value = "[1..100]"]; + double optional_rating = 2 [(range).value = "[1.0..5.0]"]; } // Tests edge cases with single-value ranges. message EdgeCaseRanges { - int32 exact_value = 1 [(range) = "[42..42]"]; - double pi_approx = 2 [(range) = "[3.14..3.15]"]; + int32 exact_value = 1 [(range).value = "[42..42]"]; + double pi_approx = 2 [(range).value = "[3.14..3.15]"]; } diff --git a/packages/spine-validation-ts/tests/proto/test-required-field.proto b/packages/spine-validation-ts/tests/proto/test-required-field.proto index 08c5545..14a4d25 100644 --- a/packages/spine-validation-ts/tests/proto/test-required-field.proto +++ b/packages/spine-validation-ts/tests/proto/test-required-field.proto @@ -27,9 +27,9 @@ syntax = "proto3"; package spine.validation.testing.requiredfield_suite; -// Test messages for the `(required_field)` message-level validation option. +// Test messages for the `(require)` message-level validation option. // -// This file contains test cases for the `(required_field)` constraint that +// This file contains test cases for the `(require)` constraint that // requires specific combinations of fields using boolean logic (OR, AND, and // parentheses for grouping). The constraint is specified at the message level. @@ -37,7 +37,7 @@ import "spine/options.proto"; // Tests simple OR logic: at least one field must be set. message UserIdentifier { - option (required_field) = "id | email"; + option (require).fields = "id | email"; int32 id = 1; string email = 2; @@ -45,7 +45,7 @@ message UserIdentifier { // Tests AND logic: both fields must be set together. message ContactInfo { - option (required_field) = "phone & country_code"; + option (require).fields = "phone & country_code"; string phone = 1; string country_code = 2; @@ -53,7 +53,7 @@ message ContactInfo { // Tests complex OR with AND groups. message PersonName { - option (required_field) = "given_name | (honorific_prefix & family_name)"; + option (require).fields = "given_name | (honorific_prefix & family_name)"; string honorific_prefix = 1; string given_name = 2; @@ -64,7 +64,7 @@ message PersonName { // Tests multiple OR alternatives. message PaymentMethod { - option (required_field) = "credit_card | bank_account | paypal_email"; + option (require).fields = "credit_card | bank_account | paypal_email"; string credit_card = 1; string bank_account = 2; @@ -73,7 +73,7 @@ message PaymentMethod { // Tests multiple AND requirements. message ShippingAddress { - option (required_field) = "street & city & postal_code & country"; + option (require).fields = "street & city & postal_code & country"; string street = 1; string city = 2; @@ -84,7 +84,7 @@ message ShippingAddress { // Tests complex nested logic with grouping. message AccountCreation { - option (required_field) = "(username & password) | oauth_token"; + option (require).fields = "(username & password) | oauth_token"; string username = 1; string password = 2; diff --git a/packages/spine-validation-ts/tests/proto/test-validate.proto b/packages/spine-validation-ts/tests/proto/test-validate.proto index 42ca70a..84539eb 100644 --- a/packages/spine-validation-ts/tests/proto/test-validate.proto +++ b/packages/spine-validation-ts/tests/proto/test-validate.proto @@ -66,7 +66,7 @@ message Customer { (required) = true, (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" ]; - int32 age = 2 [(required) = true, (range) = "[18..120]"]; + int32 age = 2 [(required) = true, (range).value = "[18..120]"]; } // Tests validation on repeated message fields. @@ -142,7 +142,7 @@ message ProductDetails { } message Review { - int32 rating = 1 [(required) = true, (range) = "[1..5]"]; + int32 rating = 1 [(required) = true, (range).value = "[1..5]"]; string comment = 2; } @@ -170,6 +170,6 @@ message ProjectWithTasks { message Task { string title = 1 [(required) = true]; - int32 priority = 2 [(range) = "[1..5]"]; + int32 priority = 2 [(range).value = "[1..5]"]; repeated string assignees = 3 [(distinct) = true]; } diff --git a/packages/spine-validation-ts/tests/validate.test.ts b/packages/spine-validation-ts/tests/validate.test.ts index c437f73..2e863d2 100644 --- a/packages/spine-validation-ts/tests/validate.test.ts +++ b/packages/spine-validation-ts/tests/validate.test.ts @@ -135,11 +135,11 @@ describe('Nested Message Validation (validate)', () => { }); describe('Custom Error Messages (if_invalid)', () => { - it('should use custom error message when nested validation fails', () => { + it('should use default error message when nested validation fails', () => { const invalid = create(OrderWithCustomErrorSchema, { orderId: 123, customer: create(CustomerSchema, { - email: 'invalid-email', // Pattern violation. + email: 'invalid-email', // Pattern violation. age: 25 }) }); @@ -147,11 +147,11 @@ describe('Nested Message Validation (validate)', () => { const violations = validate(OrderWithCustomErrorSchema, invalid); expect(violations.length).toBeGreaterThan(0); - // Should have parent-level violation with custom message. + // Should have parent-level violation with default message. const parentViolation = violations.find(v => v.fieldPath?.fieldName.length === 1 && v.fieldPath?.fieldName[0] === 'customer' && - v.message?.withPlaceholders.includes('Customer information is invalid') + v.message?.withPlaceholders.includes('Nested message validation failed') ); expect(parentViolation).toBeDefined(); }); From 212e35ed458ae54844787cb15e7c1f5b8f7cd9f8 Mon Sep 17 00:00:00 2001 From: Alex Tymchenko Date: Wed, 14 Jan 2026 12:35:22 +0000 Subject: [PATCH 08/16] Add previously missing files. --- .../scripts/patch-generated.js | 65 +++++++ .../spine-validation-ts/src/options/choice.ts | 169 ++++++++++++++++++ .../tests/proto/test-choice.proto | 64 +++++++ 3 files changed, 298 insertions(+) create mode 100755 packages/spine-validation-ts/scripts/patch-generated.js create mode 100644 packages/spine-validation-ts/src/options/choice.ts create mode 100644 packages/spine-validation-ts/tests/proto/test-choice.proto diff --git a/packages/spine-validation-ts/scripts/patch-generated.js b/packages/spine-validation-ts/scripts/patch-generated.js new file mode 100755 index 0000000..de24093 --- /dev/null +++ b/packages/spine-validation-ts/scripts/patch-generated.js @@ -0,0 +1,65 @@ +#!/usr/bin/env node + +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * Post-generation script to patch generated TypeScript files. + * + * Renames `require` export to `requireFields` to avoid JavaScript reserved word conflict. + */ + +const fs = require('fs'); +const path = require('path'); + +function patchFile(filePath) { + const content = fs.readFileSync(filePath, 'utf8'); + + // Replace export const require with export const requireFields + const patched = content.replace( + /export const require: GenExtension/g, + 'export const requireFields: GenExtension' + ); + + if (content !== patched) { + fs.writeFileSync(filePath, patched, 'utf8'); + console.log(`Patched: ${filePath}`); + } +} + +// Patch main generated file +const mainFile = path.join(__dirname, '../src/generated/spine/options_pb.ts'); +if (fs.existsSync(mainFile)) { + patchFile(mainFile); +} + +// Patch test generated file +const testFile = path.join(__dirname, '../tests/generated/spine/options_pb.ts'); +if (fs.existsSync(testFile)) { + patchFile(testFile); +} + +console.log('Patching complete'); diff --git a/packages/spine-validation-ts/src/options/choice.ts b/packages/spine-validation-ts/src/options/choice.ts new file mode 100644 index 0000000..ed2cabd --- /dev/null +++ b/packages/spine-validation-ts/src/options/choice.ts @@ -0,0 +1,169 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * Validation logic for the `(choice)` option. + * + * The `(choice)` option is a oneof-level constraint that ensures at least one + * field in a oneof group is set. + * + * Features: + * - Validates that a oneof group has at least one field set when required + * - Supports custom error messages via ChoiceOption.errorMsg + * - Works with any field types within the oneof group + * + * Examples: + * ```protobuf + * message PaymentMethod { + * oneof method { + * option (choice).required = true; + * option (choice).error_msg = "Payment method is required."; + * + * CreditCard credit_card = 1; + * BankAccount bank_account = 2; + * PayPal paypal = 3; + * } + * } + * ``` + */ + +import type { Message } from '@bufbuild/protobuf'; +import { getOption, hasOption, create } from '@bufbuild/protobuf'; +import type { GenMessage } from '@bufbuild/protobuf/codegenv2'; +import type { ConstraintViolation } from '../generated/spine/validate/validation_error_pb'; +import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; +import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; +import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; +import type { ChoiceOption } from '../generated/spine/options_pb'; +import { getRegisteredOption } from '../options-registry'; + +/** + * Creates a constraint violation for `(choice)` validation failures. + * + * @param typeName The fully qualified message type name. + * @param oneofName The name of the oneof group. + * @param customErrorMsg Optional custom error message from ChoiceOption. + * @returns A `ConstraintViolation` object. + */ +function createViolation( + typeName: string, + oneofName: string, + customErrorMsg?: string +): ConstraintViolation { + const errorMsg = customErrorMsg || + `The oneof group '${oneofName}' must have one of its fields set.`; + + return create(ConstraintViolationSchema, { + typeName, + fieldPath: create(FieldPathSchema, { + fieldName: [oneofName] + }), + fieldValue: undefined, + message: create(TemplateStringSchema, { + withPlaceholders: errorMsg, + placeholderValue: { + 'group.path': oneofName, + 'parent.type': typeName + } + }), + msgFormat: '', + param: [], + violation: [] + }); +} + +/** + * Checks if any field in a oneof group is set. + * + * In Protobuf-ES v2, oneofs are represented as a single property with + * `case` and `value` fields. The oneof is set if `case` is defined. + * + * @param message The message instance. + * @param oneof The oneof descriptor. + * @returns `true` if at least one field is set, `false` otherwise. + */ +function isOneofSet(message: any, oneof: any): boolean { + const oneofValue = message[oneof.localName]; + return oneofValue !== undefined && oneofValue !== null && oneofValue.case !== undefined; +} + +/** + * Validates a single oneof group for `(choice)` constraint. + * + * @param schema The message schema containing oneof descriptors. + * @param message The message instance being validated. + * @param oneof The oneof descriptor to validate. + * @param violations Array to collect constraint violations. + */ +function validateOneofChoice( + schema: GenMessage, + message: any, + oneof: any, + violations: ConstraintViolation[] +): void { + const choiceOpt = getRegisteredOption('choice'); + + if (!choiceOpt || !hasOption(oneof, choiceOpt)) { + return; + } + + const choiceOption = getOption(oneof, choiceOpt) as ChoiceOption; + + // Only validate if required is true + if (choiceOption.required === true) { + if (!isOneofSet(message, oneof)) { + violations.push(createViolation( + schema.typeName, + oneof.name, + choiceOption.errorMsg || undefined + )); + } + } +} + +/** + * Validates the `(choice)` option for all oneof groups in a message. + * + * This is a oneof-level constraint that ensures at least one field + * in the oneof group is set when the option is enabled. + * + * @param schema The message schema containing oneof descriptors. + * @param message The message instance to validate. + * @param violations Array to collect constraint violations. + */ +export function validateChoiceFields( + schema: GenMessage, + message: any, + violations: ConstraintViolation[] +): void { + if (!schema.oneofs || schema.oneofs.length === 0) { + return; + } + + for (const oneof of schema.oneofs) { + validateOneofChoice(schema, message, oneof, violations); + } +} diff --git a/packages/spine-validation-ts/tests/proto/test-choice.proto b/packages/spine-validation-ts/tests/proto/test-choice.proto new file mode 100644 index 0000000..e8e9ad8 --- /dev/null +++ b/packages/spine-validation-ts/tests/proto/test-choice.proto @@ -0,0 +1,64 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +syntax = "proto3"; + +package test; + +import "spine/options.proto"; + +// Test message with required choice option. +message PaymentMethod { + oneof method { + option (choice).required = true; + + string credit_card = 1; + string bank_account = 2; + string paypal = 3; + } +} + +// Test message with custom error message. +message ContactMethod { + oneof contact { + option (choice).required = true; + option (choice).error_msg = "You must provide a contact method (email or phone)."; + + string email = 1; + string phone = 2; + } +} + +// Test message with optional oneof (choice.required = false). +message ShippingOption { + oneof delivery { + option (choice).required = false; + + bool standard = 1; + bool express = 2; + bool overnight = 3; + } +} From a21d994ec7ba5f7c279bfca6dee81f613522cb0b Mon Sep 17 00:00:00 2001 From: Alex Tymchenko Date: Wed, 14 Jan 2026 12:40:24 +0000 Subject: [PATCH 09/16] Improve the utility functionality. --- README.md | 105 ++++++++++----- packages/example/src/index.ts | 34 +++-- packages/spine-validation-ts/README.md | 122 ++++++++++++++---- packages/spine-validation-ts/src/index.ts | 11 +- .../spine-validation-ts/src/validation.ts | 55 ++++++++ 5 files changed, 261 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index c975229..6d368e6 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,7 @@ # Spine Validation β€” TypeScript Client Library -> Runtime validation in TypeScript for Protobuf messages with [Spine Event Engine](https://spine.io/) Validation. - -[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) -[![Protobuf-ES](https://img.shields.io/badge/protobuf--es-v2-green.svg)](https://github.com/bufbuild/protobuf-es) - -A TypeScript validation library for Protobuf messages using Spine validation options, built on [@bufbuild/protobuf](https://github.com/bufbuild/protobuf-es) (Protobuf-ES v2). +A TypeScript validation library for Protobuf messages using [Spine Validation](https://github.com/SpineEventEngine/validation/) options, +built on [@bufbuild/protobuf](https://github.com/bufbuild/protobuf-es) (Protobuf-ES v2). --- @@ -13,11 +9,8 @@ A TypeScript validation library for Protobuf messages using Spine validation opt ### For Spine Event Engine Users -**You already have validation rules in your backend.** Now bring them to your TypeScript/JavaScript frontend with zero duplication! +This library lets you: -If you're using [Spine Event Engine](https://spine.io/) with its Validation library on the server side, your Protobuf messages already have validation constraints defined using Spine options like `(required)`, `(pattern)`, `(min)`, `(max)`, etc. - -**This library lets you:** - βœ… **Reuse the same validation rules** in your frontend that you defined in your backend. - βœ… **Maintain a single source of truth** - validation logic lives in your `.proto` files. - βœ… **Keep frontend and backend validation in sync** automatically. @@ -38,7 +31,7 @@ Even if you're not using Spine Event Engine, this library provides a powerful wa ## ✨ Features -**Comprehensive Validation Support:** +**Comprehensive Validation Support** - **`(required)`** - Ensure fields have non-default values. - **`(pattern)`** - Regex validation for strings. @@ -50,7 +43,7 @@ Even if you're not using Spine Event Engine, this library provides a powerful wa - **`(require)`** - Complex required field combinations with boolean logic. - **`(choice)`** - Require that a oneof group has at least one field set. -**Developer Experience:** +**Developer Experience** - πŸš€ Full TypeScript type safety. - πŸ“ Custom error messages. @@ -67,15 +60,38 @@ which is outside the scope of single-message validation. ## πŸš€ Quick Start +### Prerequisites + +This library requires: +- **[Buf](https://buf.build/)** for Protobuf code generation +- **[@bufbuild/protobuf](https://github.com/bufbuild/protobuf-es)** (Protobuf-ES v2) for TypeScript/JavaScript runtime + +Your TypeScript code must be generated using Buf's Protobuf-ES code generator (`@bufbuild/protoc-gen-es`). +This library is specifically designed to work with Buf-generated TypeScript code and will not work out-of-the-box +with other Protobuf code generators. + ### Installation ```bash npm install @spine-event-engine/validation-ts @bufbuild/protobuf ``` -### Basic Usage +### Usage Guide -**Step 1:** Define validation options in your `.proto` file: +#### Step 1: Configure Buf for code generation + +Create a `buf.gen.yaml` file in your project root: + +```yaml +version: v2 +plugins: + - remote: buf.build/protocolbuffers/es:v2.2.3 + out: src/generated +``` + +#### Step 2: Define validation in your Proto files + +Create your `.proto` file with Spine validation options: ```protobuf syntax = "proto3"; @@ -100,27 +116,37 @@ message User { } ``` -**Step 2:** Use validation in TypeScript +#### Step 3: Generate TypeScript code + +Run Buf to generate TypeScript code from your proto files: + +```bash +buf generate +``` + +This generates TypeScript schemas in `src/generated/` that include all validation metadata. + +#### Step 4: Use Validation library in your TypeScript code ```typescript import { create } from '@bufbuild/protobuf'; -import { validate, formatViolations } from '@spine-event-engine/validation-ts'; +import { validate, Violations } from '@spine-event-engine/validation-ts'; import { UserSchema } from './generated/user_pb'; -// Create a message const user = create(UserSchema, { - name: '', // Missing required field - email: 'invalid-email' // Invalid pattern + name: '', // Missing required field. + email: 'invalid-email' // Invalid pattern. }); -// Validate const violations = validate(UserSchema, user); if (violations.length > 0) { - console.log(formatViolations(violations)); - // Output: - // 1. User.name: A value must be set. - // 2. User.email: Email must be valid. Provided: `invalid-email`. + violations.forEach(violation => { + const fieldPath = Violations.failurePath(violation); + const message = Violations.formatMessage(violation); + + console.error(`${violation.typeName}.${fieldPath}: ${message}`); + }); } ``` @@ -255,16 +281,28 @@ Test suites: --- -## πŸ“ Example Output +## πŸ“ Working with Violations -When validation fails, you get clear, actionable error messages: +When validation fails, you can access detailed information from each violation: -``` -Validation failed: -1. User.name: A value must be set. -2. User.email: Email must be valid. Provided: `invalid-email`. -3. User.age: Value must be at least 0. Provided: -5. -4. User.tags: Values must be distinct. Duplicates found: ["test"]. +```typescript +import { validate, Violations } from '@spine-event-engine/validation-ts'; + +const violations = validate(UserSchema, user); + +violations.forEach(violation => { + // Use Violations utility object for easy access to violation details + const field = Violations.failurePath(violation); + const message = Violations.formatMessage(violation); + + console.error(`${violation.typeName}.${field}: ${message}`); + + // Example outputs: + // "User.name: A value must be set." + // "User.email: Email must be valid. Provided: `invalid-email`." + // "User.age: Value must be at least 0. Provided: -5." + // "User.tags: Values must be distinct. Duplicates found: [\"test\"]." +}); ``` --- @@ -313,3 +351,6 @@ Apache 2.0. [Documentation](packages/spine-validation-ts/README.md) Β· [Examples](packages/example) Β· [Report Bug](../../issues)
+ +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) +[![Protobuf-ES](https://img.shields.io/badge/protobuf--es-v2-green.svg)](https://github.com/bufbuild/protobuf-es) diff --git a/packages/example/src/index.ts b/packages/example/src/index.ts index 1d7ef3f..60ca908 100644 --- a/packages/example/src/index.ts +++ b/packages/example/src/index.ts @@ -32,7 +32,23 @@ import {create} from '@bufbuild/protobuf'; import {UserSchema, Role} from './generated/user_pb.js'; -import {validate, formatViolations} from '@spine-event-engine/validation-ts'; +import {validate, Violations} from '@spine-event-engine/validation-ts'; + +/** + * Helper function to display violations in a readable format. + */ +function displayViolations(violations: any[]): void { + if (violations.length === 0) { + console.log('βœ“ No violations - message is valid!'); + return; + } + + violations.forEach((v, i) => { + const fieldPath = Violations.failurePath(v); + const message = Violations.formatMessage(v); + console.log(`${i + 1}. ${v.typeName}.${fieldPath}: ${message}`); + }); +} console.log('=== Spine Validation Example ===\n'); @@ -49,7 +65,7 @@ const validUser = create(UserSchema, { const validUserViolations = validate(UserSchema, validUser); console.log('Violations:', validUserViolations.length); -console.log(formatViolations(validUserViolations)); +displayViolations(validUserViolations); console.log(); // Example 2: Invalid user - missing required email @@ -65,7 +81,7 @@ const invalidUser1 = create(UserSchema, { const violations1 = validate(UserSchema, invalidUser1); console.log('Violations:', violations1.length); -console.log(formatViolations(violations1)); +displayViolations(violations1); console.log(); // Example 3: Invalid user - missing required name @@ -81,7 +97,7 @@ const invalidUser2 = create(UserSchema, { const violations2 = validate(UserSchema, invalidUser2); console.log('Violations:', violations2.length); -console.log(formatViolations(violations2)); +displayViolations(violations2); console.log(); // Example 4: Multiple violations @@ -97,7 +113,7 @@ const invalidUser3 = create(UserSchema, { const violations3 = validate(UserSchema, invalidUser3); console.log('Violations:', violations3.length); -console.log(formatViolations(violations3)); +displayViolations(violations3); console.log(); // Example 5: Pattern validation - invalid name format @@ -113,7 +129,7 @@ const invalidPattern1 = create(UserSchema, { const violations4 = validate(UserSchema, invalidPattern1); console.log('Violations:', violations4.length); -console.log(formatViolations(violations4)); +displayViolations(violations4); console.log(); // Example 6: Pattern validation - invalid email format @@ -129,7 +145,7 @@ const invalidPattern2 = create(UserSchema, { const violations5 = validate(UserSchema, invalidPattern2); console.log('Violations:', violations5.length); -console.log(formatViolations(violations5)); +displayViolations(violations5); console.log(); // Example 7: Multiple validation types @@ -146,8 +162,8 @@ const multipleInvalid = create(UserSchema, { const violations6 = validate(UserSchema, multipleInvalid); console.log('Violations:', violations6.length); violations6.forEach((v, i) => { - const fieldPath = v.fieldPath?.fieldName.join('.') || 'unknown'; - const message = v.message?.withPlaceholders || 'No message'; + const fieldPath = Violations.failurePath(v); + const message = Violations.formatMessage(v); console.log(`${i + 1}. Field "${fieldPath}": ${message}`); }); console.log(); diff --git a/packages/spine-validation-ts/README.md b/packages/spine-validation-ts/README.md index 9af524f..fea6839 100644 --- a/packages/spine-validation-ts/README.md +++ b/packages/spine-validation-ts/README.md @@ -9,29 +9,58 @@ TypeScript validation library for Protobuf messages with [Spine Validation](http - βœ… Custom error messages with placeholder substitution - βœ… Type-safe validation with full TypeScript support - βœ… Works with [@bufbuild/protobuf](https://github.com/bufbuild/protobuf-es) (Protobuf-ES v2) -- βœ… Comprehensive test coverage (223 tests) - -## Installation - -```bash -npm install @spine-event-engine/validation-ts -``` +- βœ… Comprehensive test coverage (200+ tests) ## Prerequisites +**Important:** This library is specifically designed for TypeScript code +generated by [Buf](https://buf.build/) using the Protobuf-ES code generator. + This library requires: -- `@bufbuild/protobuf` v2.10.2 or later +- **[Buf](https://buf.build/)** for Protobuf code generation +- **`@bufbuild/protobuf`** v2.10.2 or later for TypeScript/JavaScript runtime +- TypeScript code generated using `@bufbuild/protoc-gen-es` + +**This library will NOT work with:** +- Code generated by `protoc` with other plugins (e.g., `ts-proto`, `protobuf.js`) +- Hand-written Protobuf TypeScript bindings +- Other Protobuf code generators The package includes: - Spine validation Proto definitions (`spine/options.proto`) - TypeScript validation implementation - Pre-configured TypeScript build +## Installation + +```bash +npm install @spine-event-engine/validation-ts @bufbuild/protobuf +``` + +### Setup Code Generation + +Ensure your project uses [Buf](https://buf.build/) with the Protobuf-ES plugin. + +**buf.gen.yaml:** + +```yaml +version: v2 +plugins: + - remote: buf.build/protocolbuffers/es:v2.2.3 + out: src/generated +``` + +Generate TypeScript code from the Proto files: + +```bash +buf generate +``` + ## Quick Start ```typescript import { create } from '@bufbuild/protobuf'; -import { validate, formatViolations } from '@spine-event-engine/validation-ts'; +import { validate, Violations } from '@spine-event-engine/validation-ts'; import { UserSchema } from './generated/user_pb'; // Create a message with validation constraints. @@ -44,11 +73,13 @@ const user = create(UserSchema, { const violations = validate(UserSchema, user); if (violations.length > 0) { - console.log('Validation failed:'); - console.log(formatViolations(violations)); - // Output: - // 1. example.User.name: A value must be set. - // 2. example.User.email: A value must be set. + // Use Violations utility object to access violation details. + violations.forEach(violation => { + const fieldPath = Violations.failurePath(violation); + const message = Violations.formatMessage(violation); + + console.error(`${violation.typeName}.${fieldPath}: ${message}`); + }); } ``` @@ -64,24 +95,69 @@ Validates a Protobuf message against its Spine validation constraints. **Returns:** array of `ConstraintViolation` objects (empty if valid) -### `formatViolations(violations)` +Each `ConstraintViolation` contains: +- `typeName`: the message type that failed validation, +- `fieldPath`: the path to the field that violated the constraint, +- `message`: the error message with placeholders replaced, +- `param`: additional parameters related to the violation, +- `violation`: nested violations for complex constraints. + +### `Violations` Utility + +The `Violations` provides convenient methods for working with constraint violations. + +#### `Violations.formatMessage(violation)` + +Returns the formatted error message from a violation with all placeholders replaced by actual values. + +**Parameters:** +- `violation`: the `ConstraintViolation` object + +**Returns:** formatted error message string + +**Example:** +```typescript +const message = Violations.formatMessage(violation); +// Returns: "Email must be valid. Provided: `invalid@`." +``` -Formats validation violations into a human-readable string. +#### `Violations.failurePath(violation)` + +Returns the field path as a dot-separated string. **Parameters:** -- `violations`: array of constraint violations +- `violation`: the `ConstraintViolation` object + +**Returns:** field path string (e.g., `"user.email"`) + +**Example:** +```typescript +const fieldPath = Violations.failurePath(violation); +// Returns: "user.email" +``` -**Returns:** formatted string describing all violations +### `formatViolations(violations)` -### `formatTemplateString(template, values)` +Formats an array of violations into a human-readable numbered list string. -Formats a `TemplateString` by replacing placeholders with provided values. +Mostly usable for debugging. **Parameters:** -- `template`: template string with placeholders (e.g., `{value}`, `{other}`) -- `values`: object mapping placeholder names to their values +- `violations`: array of `ConstraintViolation` objects + +**Returns:** formatted string with one violation per line + +**Example:** +```typescript +const violations = validate(UserSchema, user); +console.log(formatViolations(violations)); +// Output: +// 1. example.User.name: A value must be set. +// 2. example.User.email: Email must be valid. Provided: `invalid@`. +``` -**Returns:** formatted string with placeholders replaced +**Note:** For production use, consider using `Violations.formatMessage()` and `Violations.failurePath()` +to build custom error displays tailored to your application. ## Supported Validation Options diff --git a/packages/spine-validation-ts/src/index.ts b/packages/spine-validation-ts/src/index.ts index 1a990a7..ed36a2f 100644 --- a/packages/spine-validation-ts/src/index.ts +++ b/packages/spine-validation-ts/src/index.ts @@ -34,10 +34,17 @@ export { validate, - formatTemplateString, - formatViolations + formatViolations, + Violations } from './validation'; +/** + * Internal utility function for formatting template strings. + * End-users typically don't need to use this directly. Use `Violations.formatMessage()` instead. + * @internal + */ +export { formatTemplateString } from './validation'; + export type { ConstraintViolation, ValidationError diff --git a/packages/spine-validation-ts/src/validation.ts b/packages/spine-validation-ts/src/validation.ts index 0a40644..8e4e376 100644 --- a/packages/spine-validation-ts/src/validation.ts +++ b/packages/spine-validation-ts/src/validation.ts @@ -162,3 +162,58 @@ export function formatViolations(violations: ConstraintViolation[]): string { return `${index + 1}. ${v.typeName}.${fieldPath}: ${message}`; }).join('\n'); } + +/** + * Utility object for working with constraint violations. + * + * Provides helper methods to extract formatted information from `ConstraintViolation` objects. + * + * @example + * ```typescript + * const violations = validate(UserSchema, user); + * violations.forEach(v => { + * const path = Violations.failurePath(v); + * const message = Violations.formatMessage(v); + * console.error(`${v.typeName}.${path}: ${message}`); + * }); + * ``` + */ +export const Violations = { + /** + * Returns the formatted error message from a violation with all placeholders replaced. + * + * Placeholders in the error message (e.g., `${field}`, `${value}`) are substituted + * with their corresponding values from the violation context. + * + * @param violation The constraint violation to format. + * @returns The formatted error message, or 'Validation failed' if no message is present. + * + * @example + * ```typescript + * const message = Violations.formatMessage(violation); + * // Returns: "Email must be valid. Provided: `invalid@`." + * ``` + */ + formatMessage(violation: ConstraintViolation): string { + return violation.message ? formatTemplateString(violation.message) : 'Validation failed'; + }, + + /** + * Returns the field path from a violation as a dot-separated string. + * + * Converts the field path array (e.g., `['user', 'email']`) into a single + * dot-separated string (e.g., `'user.email'`). + * + * @param violation The constraint violation. + * @returns The field path as a string, or 'unknown' if no field path is present. + * + * @example + * ```typescript + * const path = Violations.failurePath(violation); + * // Returns: "user.email" + * ``` + */ + failurePath(violation: ConstraintViolation): string { + return violation.fieldPath?.fieldName.join('.') || 'unknown'; + } +} as const; From 132fb4e81f0171c3da99818b440069d341e33509 Mon Sep 17 00:00:00 2001 From: Alex Tymchenko Date: Wed, 14 Jan 2026 12:55:10 +0000 Subject: [PATCH 10/16] Improve the documentation in terms of formatting. Also, mention two more unsupported (deprecated) options. --- README.md | 116 +++++++++--------- packages/spine-validation-ts/README.md | 43 +++---- .../src/options-registry.ts | 10 +- .../src/options/distinct.ts | 10 +- .../spine-validation-ts/src/options/goes.ts | 16 +-- .../spine-validation-ts/src/options/range.ts | 6 +- .../src/options/required-field.ts | 6 +- .../src/options/validate.ts | 2 +- .../spine-validation-ts/src/validation.ts | 18 +-- 9 files changed, 115 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index 6d368e6..89d2d3e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ built on [@bufbuild/protobuf](https://github.com/bufbuild/protobuf-es) (Protobuf This library lets you: - βœ… **Reuse the same validation rules** in your frontend that you defined in your backend. -- βœ… **Maintain a single source of truth** - validation logic lives in your `.proto` files. +- βœ… **Maintain a single source of truth** β€” validation logic lives in your `.proto` files. - βœ… **Keep frontend and backend validation in sync** automatically. - βœ… **Get type-safe validation** with full TypeScript support. - βœ… **Display the same error messages** to users that your backend generates. @@ -33,15 +33,15 @@ Even if you're not using Spine Event Engine, this library provides a powerful wa **Comprehensive Validation Support** -- **`(required)`** - Ensure fields have non-default values. -- **`(pattern)`** - Regex validation for strings. -- **`(min)` / `(max)`** - Numeric bounds with inclusive/exclusive support. -- **`(range)`** - Bounded ranges with bracket notation `(min..max]`. -- **`(distinct)`** - Enforce uniqueness in repeated fields. -- **`(validate)`** - Recursive nested message validation. -- **`(goes)`** - Field dependency constraints. -- **`(require)`** - Complex required field combinations with boolean logic. -- **`(choice)`** - Require that a oneof group has at least one field set. +- **`(required)`** β€” Ensure fields have non-default values. +- **`(pattern)`** β€” Regex validation for strings. +- **`(min)` / `(max)`** β€” Numeric bounds with inclusive/exclusive support. +- **`(range)`** β€” Bounded ranges with bracket notation `(min..max]`. +- **`(distinct)`** β€” Enforce uniqueness in repeated fields. +- **`(validate)`** β€” Recursive nested message validation. +- **`(goes)`** β€” Field dependency constraints. +- **`(require)`** β€” Complex required field combinations with boolean logic. +- **`(choice)`** β€” Require that a oneof group has at least one field set. **Developer Experience** @@ -53,7 +53,7 @@ Even if you're not using Spine Event Engine, this library provides a powerful wa ### ⚠️ Known Limitations -- **`(set_once)`** - Not currently supported. This option requires state tracking across multiple validations, +- **`(set_once)`** β€” Not currently supported. This option requires state tracking across multiple validations, which is outside the scope of single-message validation. --- @@ -134,8 +134,8 @@ import { validate, Violations } from '@spine-event-engine/validation-ts'; import { UserSchema } from './generated/user_pb'; const user = create(UserSchema, { - name: '', // Missing required field. - email: 'invalid-email' // Invalid pattern. + name: '', // Missing required field + email: 'invalid-email' // Invalid pattern }); const violations = validate(UserSchema, user); @@ -213,9 +213,9 @@ npm run example | Command | Description | |---------|-------------| -| `npm run build` | Build the validation package. | -| `npm test` | Run all validation tests. | -| `npm run example` | Run the example project. | +| `npm run build` | Build the validation package | +| `npm test` | Run all validation tests | +| `npm run example` | Run the example project | --- @@ -225,34 +225,36 @@ npm run example | Option | Description | Example | |--------|-------------|---------| -| `(required)` | Field must have a non-default value. | `[(required) = true]` | -| `(if_missing)` | Custom error for missing field. | `[(if_missing).error_msg = "Name is required"]` | -| `(pattern)` | Regex pattern matching. | `[(pattern).regex = "^[A-Z].*"]` | -| `(min)` / `(max)` | Numeric minimum/maximum. | `[(min).value = "0", (max).value = "100"]` | -| `(range)` | Bounded numeric range. | `[(range) = "[0..100]"]` | -| `(distinct)` | Unique repeated elements. | `[(distinct) = true]` | -| `(validate)` | Validate nested messages. | `[(validate) = true]` | -| `(goes)` | Field dependency. | `[(goes).with = "other_field"]` | +| `(required)` | Field must have a non-default value | `[(required) = true]` | +| `(if_missing)` | Custom error for missing field | `[(if_missing).error_msg = "Name is required"]` | +| `(pattern)` | Regex pattern matching | `[(pattern).regex = "^[A-Z].*"]` | +| `(min)` / `(max)` | Numeric minimum/maximum | `[(min).value = "0", (max).value = "100"]` | +| `(range)` | Bounded numeric range | `[(range) = "[0..100]"]` | +| `(distinct)` | Unique repeated elements | `[(distinct) = true]` | +| `(validate)` | Validate nested messages | `[(validate) = true]` | +| `(goes)` | Field dependency | `[(goes).with = "other_field"]` | ### Message-Level Options | Option | Description | Example | |--------|-------------|---------| -| `(require)` | Required field combinations. | `option (require).fields = "id \| email";` | +| `(require)` | Required field combinations | `option (require).fields = "id \| email";` | ### Oneof-Level Options | Option | Description | Example | |--------|-------------|---------| -| `(choice)` | Require oneof to have a field set. | `option (choice).required = true;` | +| `(choice)` | Require oneof to have a field set | `option (choice).required = true;` | ### Not Supported | Option | Status | Notes | |--------|--------|-------| -| `(set_once)` | ❌ Not supported | Requires state tracking across validations. See [limitations](#-known-limitations). | -| `(is_required)` | ❌ Not supported | Deprecated. Use `(choice)` instead. | -| `(required_field)` | ❌ Not supported | Deprecated. Use `(require)` instead. | +| `(if_invalid)` | ❌ Not supported | Deprecated field-level option | +| `(set_once)` | ❌ Not supported | Requires state tracking across validations. See [limitations](#-known-limitations) | +| `(if_set_again)` | ❌ Not supported | Companion to `(set_once)` | +| `(is_required)` | ❌ Not supported | Deprecated. Use `(choice)` instead | +| `(required_field)` | ❌ Not supported | Deprecated. Use `(require)` instead | --- @@ -260,24 +262,24 @@ npm run example The package includes comprehensive test coverage: -- **200+ tests** across 11 test suites. -- **All validation options** thoroughly tested. -- **Integration tests** combining multiple constraints. -- **Edge cases** and real-world scenarios. -- **100% coverage** of validation logic. +- **200+ tests** across 11 test suites +- **All validation options** thoroughly tested +- **Integration tests** combining multiple constraints +- **Edge cases** and real-world scenarios +- **100% coverage** of validation logic Test suites: -- Basic validation. -- Required fields. -- Pattern matching. -- Min/Max constraints. -- Range validation. -- Distinct elements. -- Nested validation. -- Field dependencies (goes). -- Required field combinations (require). -- Oneof validation (choice). -- Integration scenarios. +- Basic validation +- Required fields +- Pattern matching +- Min/Max constraints +- Range validation +- Distinct elements +- Nested validation +- Field dependencies (`goes`) +- Required field combinations (`require`) +- `fneof` validation (`choice`) +- Integration scenarios --- @@ -311,11 +313,11 @@ violations.forEach(violation => { The validation system is built with extensibility in mind: -- **`validation.ts`** - Core validation engine using the visitor pattern. -- **`options-registry.ts`** - Dynamic registration of validation options. -- **`options/`** - Modular validators for each Spine option. -- **Proto-first** - Validation rules defined in `.proto` files. -- **Type-safe** - Full TypeScript support with generated types. +- **`validation.ts`** β€” Core validation engine using the visitor pattern +- **`options-registry.ts`** β€” Dynamic registration of validation options +- **`options/`** β€” Modular validators for each Spine option +- **Proto-first** β€” Validation rules defined in `.proto` files +- **Type-safe** β€” Full TypeScript support with generated types --- @@ -323,10 +325,10 @@ The validation system is built with extensibility in mind: Contributions are welcome! Please ensure: -1. All tests pass: `npm test`. -2. Code follows existing patterns. -3. New features include tests. -4. Documentation is updated. +1. All tests pass: `npm test` +2. Code follows existing patterns +3. New features include tests +4. Documentation is updated --- @@ -338,9 +340,9 @@ Apache 2.0. ## πŸ”— Related Projects -- [Spine Event Engine](https://spine.io/) - Event-driven framework for CQRS/ES applications. -- [Protobuf-ES](https://github.com/bufbuild/protobuf-es) - Protocol Buffers for ECMAScript. -- [Buf](https://buf.build/) - Modern Protobuf tooling. +- [Spine Event Engine](https://spine.io/) β€” Event-driven framework for CQRS/ES applications +- [Protobuf-ES](https://github.com/bufbuild/protobuf-es) β€” Protocol Buffers for ECMAScript +- [Buf](https://buf.build/) β€” Modern Protobuf tooling --- diff --git a/packages/spine-validation-ts/README.md b/packages/spine-validation-ts/README.md index fea6839..5245802 100644 --- a/packages/spine-validation-ts/README.md +++ b/packages/spine-validation-ts/README.md @@ -65,8 +65,8 @@ import { UserSchema } from './generated/user_pb'; // Create a message with validation constraints. const user = create(UserSchema, { - name: '', // This field is marked as `(required) = true`. - email: '' // This field is also required. + name: '', // This field is marked as `(required) = true` + email: '' // This field is also required }); // Validate the message. @@ -96,11 +96,11 @@ Validates a Protobuf message against its Spine validation constraints. **Returns:** array of `ConstraintViolation` objects (empty if valid) Each `ConstraintViolation` contains: -- `typeName`: the message type that failed validation, -- `fieldPath`: the path to the field that violated the constraint, -- `message`: the error message with placeholders replaced, -- `param`: additional parameters related to the violation, -- `violation`: nested violations for complex constraints. +- `typeName` β€” the message type that failed validation +- `fieldPath` β€” the path to the field that violated the constraint +- `message` β€” the error message with placeholders replaced +- `param` β€” additional parameters related to the violation +- `violation` β€” nested violations for complex constraints ### `Violations` Utility @@ -163,29 +163,30 @@ to build custom error displays tailored to your application. ### Field-level options -- βœ… **`(required)`** - Ensures field has a non-default value -- βœ… **`(if_missing)`** - Custom error message for required fields -- βœ… **`(pattern)`** - Regex validation for string fields -- βœ… **`(min)` / `(max)`** - Numeric range validation with inclusive/exclusive bounds -- βœ… **`(range)`** - Bounded numeric ranges using bracket notation `[min..max]`, with custom error messages -- βœ… **`(distinct)`** - Ensures unique elements in repeated fields and map values -- βœ… **`(validate)`** - Enables recursive validation of nested messages -- βœ… **`(goes)`** - Field dependency validation (field can only be set if another field is set) +- βœ… **`(required)`** β€” Ensures field has a non-default value +- βœ… **`(if_missing)`** β€” Custom error message for required fields +- βœ… **`(pattern)`** β€” Regex validation for string fields +- βœ… **`(min)` / `(max)`** β€” Numeric range validation with inclusive/exclusive bounds +- βœ… **`(range)`** β€” Bounded numeric ranges using bracket notation `[min..max]`, with custom error messages +- βœ… **`(distinct)`** β€” Ensures unique elements in repeated fields and map values +- βœ… **`(validate)`** β€” Enables recursive validation of nested messages +- βœ… **`(goes)`** β€” Field dependency validation (field can only be set if another field is set) ### Message-level options -- βœ… **`(require)`** - Requires specific field combinations using boolean logic +- βœ… **`(require)`** β€” Requires specific field combinations using boolean logic ### Oneof-level options -- βœ… **`(choice)`** - Requires that a `oneof` group has at least one field set +- βœ… **`(choice)`** β€” Requires that a `oneof` group has at least one field set ### Not Supported -- ❌ **`(set_once)`** - Requires state tracking across validations (not feasible in TypeScript runtime) -- ❌ **`(if_set_again)`** - Companion to `(set_once)` -- ❌ **`(required_field)`** - Deprecated, replaced by `(require)` -- ❌ **`(is_required)`** - Deprecated, replaced by `(choice)` +- ❌ **`(if_invalid)`** β€” Deprecated field-level option +- ❌ **`(set_once)`** β€” Requires state tracking across validations (not feasible in TypeScript runtime) +- ❌ **`(if_set_again)`** β€” Companion to `(set_once)` +- ❌ **`(is_required)`** β€” Deprecated, replaced by `(choice)` +- ❌ **`(required_field)`** β€” Deprecated, replaced by `(require)` ## Example diff --git a/packages/spine-validation-ts/src/options-registry.ts b/packages/spine-validation-ts/src/options-registry.ts index f9e35b7..0f3cc2b 100644 --- a/packages/spine-validation-ts/src/options-registry.ts +++ b/packages/spine-validation-ts/src/options-registry.ts @@ -51,11 +51,11 @@ import { * Currently supported options are automatically registered. * * Note: The following options are NOT SUPPORTED: - * - `if_invalid` (73822) - Deprecated, not supported - * - `set_once` (73824) - Requires state tracking across validations - * - `if_set_again` (73827) - Companion to `set_once` - * - `required_field` (73902) - Deprecated, replaced by `requireFields` - * - `is_required` (73891) - Deprecated, replaced by `choice` + * - `if_invalid` (73822) β€” Deprecated, not supported + * - `set_once` (73824) β€” Requires state tracking across validations + * - `if_set_again` (73827) β€” Companion to `set_once` + * - `required_field` (73902) β€” Deprecated, replaced by `requireFields` + * - `is_required` (73891) β€” Deprecated, replaced by `choice` */ const optionRegistry = { required, diff --git a/packages/spine-validation-ts/src/options/distinct.ts b/packages/spine-validation-ts/src/options/distinct.ts index 7c8b89a..f789761 100644 --- a/packages/spine-validation-ts/src/options/distinct.ts +++ b/packages/spine-validation-ts/src/options/distinct.ts @@ -37,11 +37,11 @@ * - Map fields (validates that all values are unique; keys are inherently unique) * * Features: - * - Ensures all elements in a repeated field are unique. - * - Ensures all values in a map field are unique (keys are always unique by definition). - * - Detects duplicate values and reports violations with element indices or keys. - * - Works with primitive types (numbers, strings, booleans). - * - Works with enum values. + * - Ensures all elements in a repeated field are unique + * - Ensures all values in a map field are unique (keys are always unique by definition) + * - Detects duplicate values and reports violations with element indices or keys + * - Works with primitive types (numbers, strings, booleans) + * - Works with enum values * * Examples: * ```protobuf diff --git a/packages/spine-validation-ts/src/options/goes.ts b/packages/spine-validation-ts/src/options/goes.ts index 2ec72dc..ee28632 100644 --- a/packages/spine-validation-ts/src/options/goes.ts +++ b/packages/spine-validation-ts/src/options/goes.ts @@ -32,9 +32,9 @@ * * Semantics: * - If field A has `(goes).with = "B"`: - * - A is set AND B is NOT set β†’ VIOLATION - * - A is set AND B is set β†’ VALID - * - A is NOT set β†’ VALID (regardless of B) + * - A is set AND B is NOT set β€” VIOLATION + * - A is set AND B is set β€” VALID + * - A is NOT set β€” VALID (regardless of B) * * Examples: * ```protobuf @@ -61,11 +61,11 @@ import { getRegisteredOption } from '../options-registry'; * Checks if a field has a non-default value (is "set") in proto3. * * For proto3 fields: - * - Message fields: non-default instance (not `undefined`/`null`) - * - String fields: non-empty string - * - Numeric fields: non-zero value - * - Bool fields: any value (`true` or `false` both count as "set") - * - Enum fields: non-zero value + * - Message fields β€” non-default instance (not `undefined`/`null`) + * - String fields β€” non-empty string + * - Numeric fields β€” non-zero value + * - Bool fields β€” any value (`true` or `false` both count as "set") + * - Enum fields β€” non-zero value * * @param value The field value to check. * @returns `true` if the field is considered set, `false` otherwise. diff --git a/packages/spine-validation-ts/src/options/range.ts b/packages/spine-validation-ts/src/options/range.ts index 9ddec70..35e5b34 100644 --- a/packages/spine-validation-ts/src/options/range.ts +++ b/packages/spine-validation-ts/src/options/range.ts @@ -36,9 +36,9 @@ * - `float`, `double` * * Features: - * - Inclusive bounds (closed intervals): `[min..max]` - * - Exclusive bounds (open intervals): `(min..max)` - * - Half-open intervals: `[min..max)` or `(min..max]` + * - Inclusive bounds (closed intervals) β€” `[min..max]` + * - Exclusive bounds (open intervals) β€” `(min..max)` + * - Half-open intervals β€” `[min..max)` or `(min..max]` * - Validation applies to repeated fields (each element checked independently) * * Syntax: diff --git a/packages/spine-validation-ts/src/options/required-field.ts b/packages/spine-validation-ts/src/options/required-field.ts index cfd627a..2b903b2 100644 --- a/packages/spine-validation-ts/src/options/required-field.ts +++ b/packages/spine-validation-ts/src/options/required-field.ts @@ -31,9 +31,9 @@ * at least one field from a set of alternatives or combinations of fields. * * Syntax: - * - `|` (pipe): OR operator - at least one field must be set - * - `&` (ampersand): AND operator - all fields must be set together - * - Parentheses for grouping: `(field1 & field2) | field3` + * - `|` (pipe) β€” OR operator, at least one field must be set + * - `&` (ampersand) β€” AND operator, all fields must be set together + * - Parentheses for grouping β€” `(field1 & field2) | field3` * * Examples: * ```protobuf diff --git a/packages/spine-validation-ts/src/options/validate.ts b/packages/spine-validation-ts/src/options/validate.ts index 683e4b4..c78da1b 100644 --- a/packages/spine-validation-ts/src/options/validate.ts +++ b/packages/spine-validation-ts/src/options/validate.ts @@ -36,7 +36,7 @@ * - Map fields (validates each entry) * * Features: - * - Recursive validation: validates constraints in nested messages + * - Recursive validation β€” validates constraints in nested messages * - Validates each item in repeated fields * - Validates each value in map entries * diff --git a/packages/spine-validation-ts/src/validation.ts b/packages/spine-validation-ts/src/validation.ts index 8e4e376..f2f07a5 100644 --- a/packages/spine-validation-ts/src/validation.ts +++ b/packages/spine-validation-ts/src/validation.ts @@ -59,15 +59,15 @@ export type { FieldPath } from './generated/spine/base/field_path_pb'; * the message is valid. * * Currently supported validation options: - * - `(required)` - ensures field has a non-default value - * - `(pattern)` - validates string fields against regular expressions - * - `(required_field)` - requires specific combinations of fields at message level - * - `(min)` / `(max)` - numeric range validation with inclusive/exclusive bounds - * - `(range)` - bounded numeric ranges using bracket notation for inclusive/exclusive bounds - * - `(distinct)` - ensures all elements in repeated fields are unique - * - `(validate)` - enables recursive validation of nested message fields - * - `(goes)` - enforces field dependency (field can only be set if another field is set) - * - `(choice)` - requires that a oneof group has at least one field set + * - `(required)` β€” ensures field has a non-default value + * - `(pattern)` β€” validates string fields against regular expressions + * - `(required_field)` β€” requires specific combinations of fields at message level + * - `(min)` / `(max)` β€” numeric range validation with inclusive/exclusive bounds + * - `(range)` β€” bounded numeric ranges using bracket notation for inclusive/exclusive bounds + * - `(distinct)` β€” ensures all elements in repeated fields are unique + * - `(validate)` β€” enables recursive validation of nested message fields + * - `(goes)` β€” enforces field dependency (field can only be set if another field is set) + * - `(choice)` β€” requires that a oneof group has at least one field set * * @param schema The message schema containing validation metadata. * @param message The message instance to validate. From b405e199d8b8134374e3bc27941d55c1bac71ad3 Mon Sep 17 00:00:00 2001 From: Alex Tymchenko Date: Wed, 14 Jan 2026 13:11:57 +0000 Subject: [PATCH 11/16] Introduce the CI build workflow. --- .github/workflows/build.yml | 40 +++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ad8a662 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,40 @@ +name: Build and Test + +on: + pull_request: + branches: + - '**' + push: + branches: + - '**' + +jobs: + build-and-test: + name: Build and Test + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x] + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build validation package + run: npm run build + + - name: Run tests + run: npm test + + - name: Build example + run: npm run example From d57d03d9f242d7796681dcd1187411857d6e6f14 Mon Sep 17 00:00:00 2001 From: Alex Tymchenko Date: Wed, 14 Jan 2026 13:14:18 +0000 Subject: [PATCH 12/16] Execute the flow only on push. --- .github/workflows/build.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ad8a662..b1921a8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,12 +1,6 @@ name: Build and Test -on: - pull_request: - branches: - - '**' - push: - branches: - - '**' +on: push jobs: build-and-test: From 2f39f97d295d8f98c7bc7b931f1e9f4d5e61574f Mon Sep 17 00:00:00 2001 From: Alex Tymchenko Date: Wed, 14 Jan 2026 13:16:27 +0000 Subject: [PATCH 13/16] Avoid using the npm cache. --- .github/workflows/build.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b1921a8..72cd67d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,6 @@ jobs: uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} - cache: 'npm' - name: Install dependencies run: npm ci From 8eb95204ce31f98a224b14813b617a98a64303db Mon Sep 17 00:00:00 2001 From: Alex Tymchenko Date: Wed, 14 Jan 2026 13:18:37 +0000 Subject: [PATCH 14/16] Use `npm install` since we don't want to carry `package-lock.json` in the repo. --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 72cd67d..96c18f7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,7 +21,7 @@ jobs: node-version: ${{ matrix.node-version }} - name: Install dependencies - run: npm ci + run: npm install - name: Build validation package run: npm run build From efcbead141fe2da52bfe1606fa3acfb033491578 Mon Sep 17 00:00:00 2001 From: Alex Tymchenko Date: Wed, 14 Jan 2026 15:00:26 +0000 Subject: [PATCH 15/16] Add the publishing script. --- .github/workflows/publish.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..0a42e26 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,35 @@ +name: Publish to NPM + +on: + push: + branches: + - master + +jobs: + publish: + name: Publish Package to NPM + runs-on: ubuntu-latest + + permissions: + contents: read # Read repository code + id-token: write # Generate OIDC token for npm authentication + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 20.x + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm install + + - name: Build validation package + run: npm run build --workspace=@spine-event-engine/validation-ts + # This runs: buf generate β†’ patch β†’ tsc (via prepublishOnly) + + - name: Publish to npm with provenance + run: npm publish --workspace=@spine-event-engine/validation-ts --provenance --access public From 862064c6c6dfd40f0be0e27a92dd8ec0db88084c Mon Sep 17 00:00:00 2001 From: Alex Tymchenko Date: Wed, 14 Jan 2026 15:02:16 +0000 Subject: [PATCH 16/16] Bump version -> `2.0.0-snapshot.2` --- package.json | 2 +- packages/example/package.json | 2 +- packages/spine-validation-ts/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index b888c37..2527ef2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@spine-event-engine/validation-ts-workspace", - "version": "2.0.0-snapshot.1", + "version": "2.0.0-snapshot.2", "private": true, "workspaces": [ "packages/*" diff --git a/packages/example/package.json b/packages/example/package.json index 849a96b..4acf5ca 100644 --- a/packages/example/package.json +++ b/packages/example/package.json @@ -1,6 +1,6 @@ { "name": "@spine-event-engine/example-smoke", - "version": "2.0.0-snapshot.1", + "version": "2.0.0-snapshot.2", "private": true, "description": "Example project demonstrating @spine-event-engine/validation-ts usage", "type": "module", diff --git a/packages/spine-validation-ts/package.json b/packages/spine-validation-ts/package.json index b9d5c49..13d5c63 100644 --- a/packages/spine-validation-ts/package.json +++ b/packages/spine-validation-ts/package.json @@ -1,6 +1,6 @@ { "name": "@spine-event-engine/validation-ts", - "version": "2.0.0-snapshot.1", + "version": "2.0.0-snapshot.2", "description": "TypeScript validation library for Protobuf messages with Spine Validation options", "main": "dist/index.js", "types": "dist/index.d.ts",