diff --git a/README.md b/README.md index 25caee2..8497104 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ 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). ---- +> **🔧 This library is in its experimental stage, the public API should not be considered stable.** ## 💡 Why Use This? @@ -19,15 +19,15 @@ This library lets you: ### 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: +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. +- ✅ **Define validation in `.proto` files** using declarative [Spine Validation options](https://github.com/SpineEventEngine/base-libraries/blob/master/base/src/main/proto/spine/options.proto). - ✅ **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 @@ -41,7 +41,7 @@ Even if you're not using Spine Event Engine, this library provides a powerful wa - **`(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. +- **`(choice)`** — Require that a `oneof` group has at least one field set. **Developer Experience** @@ -56,111 +56,17 @@ Even if you're not using Spine Event Engine, this library provides a powerful wa - **`(set_once)`** — Not currently supported. This option requires state tracking across multiple validations, 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 +## 🚀 Getting Started -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. +See the [package-level README](packages/spine-validation-ts/README.md) for complete installation instructions and usage guide. -### Installation - -This package is currently published as a **pre-release (snapshot)** version. -Install it using the `@snapshot` dist-tag: +**Quick install:** ```bash npm install @spine-event-engine/validation-ts@snapshot @bufbuild/protobuf ``` -To install a specific snapshot version: - -```bash -npm install @spine-event-engine/validation-ts@2.0.0-snapshot.3 @bufbuild/protobuf -``` - -> **Note:** This library is in active development and therefore it is published as a snapshot. - -### Usage Guide - -#### 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"; - -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 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, 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 -}); - -const violations = validate(UserSchema, user); - -if (violations.length > 0) { - violations.forEach(violation => { - const fieldPath = Violations.failurePath(violation); - const message = Violations.formatMessage(violation); - - console.error(`${violation.typeName}.${fieldPath}: ${message}`); - }); -} -``` - --- ## 📦 What's Included @@ -184,13 +90,10 @@ validation-ts/ └── 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. +See the [package-level README](packages/spine-validation-ts/README.md) for more details. --- @@ -230,108 +133,6 @@ npm run example --- -## 📋 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]` | -| `(goes)` | Field dependency | `[(goes).with = "other_field"]` | - -### Message-Level Options - -| Option | Description | Example | -|--------|-------------|---------| -| `(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;` | - -### Not Supported - -| Option | Status | Notes | -|--------|--------|-------| -| `(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 | - ---- - -## ✅ Test Coverage - -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 - -Test suites: -- 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 - ---- - -## 📝 Working with Violations - -When validation fails, you can access detailed information from each violation: - -```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\"]." -}); -``` - ---- - -## 🏗️ 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: @@ -351,7 +152,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 diff --git a/package.json b/package.json index 1a49023..e103a50 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@spine-event-engine/validation-ts-workspace", - "version": "2.0.0-snapshot.3", + "version": "2.0.0-snapshot.4", "private": true, "workspaces": [ "packages/*" diff --git a/packages/example/package.json b/packages/example/package.json index d5bc5cc..61e2ead 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.3", + "version": "2.0.0-snapshot.4", "private": true, "description": "Example project demonstrating @spine-event-engine/validation-ts usage", "type": "module", diff --git a/packages/spine-validation-ts/DESCRIPTOR_API_GUIDE.md b/packages/spine-validation-ts/DESCRIPTOR_API_GUIDE.md deleted file mode 100644 index 280e543..0000000 --- a/packages/spine-validation-ts/DESCRIPTOR_API_GUIDE.md +++ /dev/null @@ -1,415 +0,0 @@ -# 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, - 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}"`); - } -} -``` - -## 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 }; -} - -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 - }; - } - - 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 -- `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 deleted file mode 100644 index 90bbe58..0000000 --- a/packages/spine-validation-ts/QUICK_REFERENCE.md +++ /dev/null @@ -1,201 +0,0 @@ -# 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, // RangeOption { value, errorMsg } - if_missing, // IfMissingOption { 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 index 89945c2..0fa1485 100644 --- a/packages/spine-validation-ts/README.md +++ b/packages/spine-validation-ts/README.md @@ -2,6 +2,8 @@ TypeScript validation library for Protobuf messages with [Spine Validation](https://github.com/SpineEventEngine/validation/) options. +> **🔧 This package is in its experimental stage, the public API should not be considered stable.** + ## Features - ✅ Runtime validation of Protobuf messages against Spine validation constraints @@ -9,7 +11,6 @@ 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 (200+ tests) ## Prerequisites @@ -40,19 +41,15 @@ Install it using the `@snapshot` dist-tag: npm install @spine-event-engine/validation-ts@snapshot @bufbuild/protobuf ``` -To install a specific snapshot version: - -```bash -npm install @spine-event-engine/validation-ts@2.0.0-snapshot.3 @bufbuild/protobuf -``` +**Note:** `@bufbuild/protobuf` is a peer dependency and must be installed explicitly. You'll use it for creating and working with Protobuf messages in your application code. -> **Note:** This library is in active development and therefore it is published as a snapshot. +## Quick Start -### Setup Code Generation +### Usage Guide -Ensure your project uses [Buf](https://buf.build/) with the Protobuf-ES plugin. +#### Step 1: Configure Buf for code generation -**buf.gen.yaml:** +Create a `buf.gen.yaml` file in your project root: ```yaml version: v2 @@ -61,30 +58,58 @@ plugins: out: src/generated ``` -Generate TypeScript code from the Proto files: +#### Step 2: Define validation in your Proto files + +Create your `.proto` file with Spine validation options: + +```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 3: Generate TypeScript code + +Run Buf to generate TypeScript code from your proto files: ```bash buf generate ``` -## Quick Start +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, Violations } 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 + name: '', // Missing required field + email: 'invalid-email' // Invalid pattern }); -// Validate the message. const violations = validate(UserSchema, user); if (violations.length > 0) { - // Use Violations utility object to access violation details. violations.forEach(violation => { const fieldPath = Violations.failurePath(violation); const message = Violations.formatMessage(violation); @@ -187,7 +212,7 @@ to build custom error displays tailored to your application. - ✅ **`(require)`** — Requires specific field combinations using boolean logic -### Oneof-level options +### `oneof`-Level Options - ✅ **`(choice)`** — Requires that a `oneof` group has at least one field set @@ -317,7 +342,7 @@ message ContactInfo { } ``` -### `Oneof` constraints +### `oneof` Constraints Use `(choice)` to require that a `oneof` group has a field set: @@ -336,7 +361,7 @@ message PaymentMethod { ## Testing -The package includes comprehensive test coverage with 200+ tests across 11 test suites: +The production code is covered at ~80% of statements with 200+ tests across 11 test suites: - `basic-validation.test.ts` - Basic validation and formatting - `required.test.ts` - `(required)` and `(if_missing)` options @@ -356,6 +381,16 @@ Run tests with: npm 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 + ## Development Notes ### Generated Code Patching diff --git a/packages/spine-validation-ts/package.json b/packages/spine-validation-ts/package.json index 47ac222..5427e8c 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.3", + "version": "2.0.0-snapshot.4", "description": "TypeScript validation library for Protobuf messages with Spine Validation options", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/spine-validation-ts/src/options/choice.ts b/packages/spine-validation-ts/src/options/choice.ts index ed2cabd..7cdd5a6 100644 --- a/packages/spine-validation-ts/src/options/choice.ts +++ b/packages/spine-validation-ts/src/options/choice.ts @@ -27,13 +27,13 @@ /** * 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. + * 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 + * - 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 + * - Works with any field types within the `oneof` group * * Examples: * ```protobuf @@ -64,8 +64,8 @@ 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. + * @param oneofName The name of the `oneof` group. + * @param customErrorMsg Optional custom error message from `ChoiceOption`. * @returns A `ConstraintViolation` object. */ function createViolation( @@ -74,7 +74,7 @@ function createViolation( customErrorMsg?: string ): ConstraintViolation { const errorMsg = customErrorMsg || - `The oneof group '${oneofName}' must have one of its fields set.`; + `The \`oneof\` group '${oneofName}' must have one of its fields set.`; return create(ConstraintViolationSchema, { typeName, @@ -96,13 +96,13 @@ function createViolation( } /** - * Checks if any field in a oneof group is set. + * 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. + * In Protobuf-ES v2, `oneof`s 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. + * @param oneof The `oneof` descriptor. * @returns `true` if at least one field is set, `false` otherwise. */ function isOneofSet(message: any, oneof: any): boolean { @@ -147,8 +147,8 @@ function validateOneofChoice( /** * 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. + * 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. diff --git a/packages/spine-validation-ts/src/options/goes.ts b/packages/spine-validation-ts/src/options/goes.ts index ee28632..413c989 100644 --- a/packages/spine-validation-ts/src/options/goes.ts +++ b/packages/spine-validation-ts/src/options/goes.ts @@ -60,7 +60,7 @@ import { getRegisteredOption } from '../options-registry'; /** * Checks if a field has a non-default value (is "set") in proto3. * - * For proto3 fields: + * For `proto3` fields: * - Message fields — non-default instance (not `undefined`/`null`) * - String fields — non-empty string * - Numeric fields — non-zero value diff --git a/packages/spine-validation-ts/src/validation.ts b/packages/spine-validation-ts/src/validation.ts index f2f07a5..8b97d88 100644 --- a/packages/spine-validation-ts/src/validation.ts +++ b/packages/spine-validation-ts/src/validation.ts @@ -67,7 +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 + * - `(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. diff --git a/packages/spine-validation-ts/tests/choice.test.ts b/packages/spine-validation-ts/tests/choice.test.ts index b1daacb..890c0c1 100644 --- a/packages/spine-validation-ts/tests/choice.test.ts +++ b/packages/spine-validation-ts/tests/choice.test.ts @@ -48,7 +48,7 @@ describe('Choice Option Validation (oneof)', () => { it('should fail when no field in required oneof is set', () => { const payment = create(PaymentMethodSchema, { - // method oneof not set + // method `oneof` not set }); const violations = validate(PaymentMethodSchema, payment); @@ -77,7 +77,7 @@ describe('Choice Option Validation (oneof)', () => { describe('Custom Error Messages', () => { it('should use custom error message when provided', () => { const contact = create(ContactMethodSchema, { - // contact oneof not set, has custom error message + // contact `oneof` not set, has custom error message }); const violations = validate(ContactMethodSchema, contact); @@ -97,7 +97,7 @@ describe('Choice Option Validation (oneof)', () => { describe('Optional Oneofs', () => { it('should pass when optional oneof is not set', () => { const shipping = create(ShippingOptionSchema, { - // delivery oneof is optional (choice.required = false) + // delivery `oneof` is optional (choice.required = false) }); const violations = validate(ShippingOptionSchema, shipping);