Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# protovalidate-es

[Protovalidate][protovalidate] provides standard annotations to validate common constraints on messages and fields, as well as the ability to use [CEL][cel] to write custom constraints. It's the next generation of [protoc-gen-validate][protoc-gen-validate], the only widely used validation library for Protobuf.
[Protovalidate][protovalidate] provides standard annotations to validate common rules on messages and fields, as well as the ability to use [CEL][cel] to write custom rules. It's the next generation of [protoc-gen-validate][protoc-gen-validate], the only widely used validation library for Protobuf.

With Protovalidate, you can annotate your Protobuf messages with both standard and custom validation rules:

Expand Down
923 changes: 433 additions & 490 deletions packages/protovalidate-testing/expected-failures.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/protovalidate-testing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
},
"scripts": {
"install-protovalidate-conformance": "node scripts/install-protovalidate-conformance.js",
"generate": "buf generate buf.build/bufbuild/protovalidate-testing:v0.10.4",
"generate": "buf generate buf.build/bufbuild/protovalidate-testing:v0.11.0",
"postgenerate": "license-header src/gen",
"test": "protovalidate-conformance --strict_message --strict_error --expected_failures=expected-failures.yaml -- tsx src/executor.ts",
"format": "prettier --write --ignore-unknown '.' '!dist' '!src/gen'",
Expand Down
2 changes: 1 addition & 1 deletion packages/protovalidate-testing/scripts/failure-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const suites = [];
let suite;
let kase;
for (const line of lines) {
// --- FAIL: standard_constraints/map (failed: 18, skipped: 0, passed: 11, total: 29)
// --- FAIL: standard_rules/map (failed: 18, skipped: 0, passed: 11, total: 29)
const mSuite =
/^--- FAIL: (.+) \(failed: \d+, skipped: \d+, passed: \d+, total: \d+\)$/.exec(
line,
Expand Down

This file was deleted.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

390 changes: 218 additions & 172 deletions packages/protovalidate-testing/src/gen/buf/validate/validate_pb.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/protovalidate/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @bufbuild/protovalidate

[Protovalidate][protovalidate] provides standard annotations to validate common constraints on messages and fields, as well as the ability to use [CEL][cel] to write custom constraints. It's the next generation of [protoc-gen-validate][protoc-gen-validate], the only widely used validation library for Protobuf.
[Protovalidate][protovalidate] provides standard annotations to validate common rules on messages and fields, as well as the ability to use [CEL][cel] to write custom rules. It's the next generation of [protoc-gen-validate][protoc-gen-validate], the only widely used validation library for Protobuf.

With Protovalidate, you can annotate your Protobuf messages with both standard and custom validation rules:

Expand Down
2 changes: 1 addition & 1 deletion packages/protovalidate/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"directory": "packages/protovalidate"
},
"scripts": {
"fetch-proto": "buf export buf.build/bufbuild/protovalidate:v0.10.3 --output proto",
"fetch-proto": "buf export buf.build/bufbuild/protovalidate:v0.11.0 --output proto",
"postfetch-proto": "license-header proto",
"generate": "buf generate",
"postgenerate": "license-header src/gen",
Expand Down
474 changes: 260 additions & 214 deletions packages/protovalidate/proto/buf/validate/validate.proto

Large diffs are not rendered by default.

97 changes: 36 additions & 61 deletions packages/protovalidate/src/cel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ import {
FuncRegistry,
} from "@bufbuild/cel";
import {
type Constraint,
type FieldConstraints,
type Rule,
type FieldRules,
predefined,
} from "./gen/buf/validate/validate_pb.js";
import { CompilationError, RuntimeError } from "./error.js";
Expand Down Expand Up @@ -62,28 +62,28 @@ import {
type CelCompiledRules = {
standard: {
field: DescField;
constraint: Constraint;
compiled: CelCompiledConstraint;
rule: Rule;
compiled: CelCompiledRule;
}[];
extensions: Map<
number,
{
ext: DescExtension;
constraint: Constraint;
compiled: CelCompiledConstraint;
rule: Rule;
compiled: CelCompiledRule;
}[]
>;
};

export type CelCompiledConstraint =
export type CelCompiledRule =
| {
kind: "compilation_error";
error: CompilationError;
}
| {
kind: "interpretable";
interpretable: ReturnType<CelEnv["plan"]>;
constraint: Constraint;
rule: Rule;
};

// TODO contains, endsWith, startsWith for bytes
Expand Down Expand Up @@ -281,48 +281,48 @@ export class CelManager {
this.env.set(key, value);
}

eval(compiled: CelCompiledConstraint) {
eval(compiled: CelCompiledRule) {
if (compiled.kind == "compilation_error") {
throw compiled.error;
}
const constraint = compiled.constraint;
const rule = compiled.rule;
const result = this.env.eval(compiled.interpretable);
if (typeof result == "string" || typeof result == "boolean") {
const success = typeof result == "boolean" ? result : result.length == 0;
if (success) {
return undefined;
}
// From field buf.validate.Constraint.message:
// From field buf.validate.Rule.message:
// > If a non-empty message is provided, any strings resulting from the CEL
// > expression evaluation are ignored.
return {
message:
constraint.message.length == 0 && typeof result == "string"
rule.message.length == 0 && typeof result == "string"
? result
: constraint.message,
constraintId: constraint.id,
: rule.message,
ruleId: rule.id,
};
}
if (result instanceof CelError) {
throw new RuntimeError(result.message, { cause: result });
}
throw new RuntimeError(
`expression ${constraint.id} outputs ${typeof result}, wanted either bool or string`,
`expression ${rule.id} outputs ${typeof result}, wanted either bool or string`,
);
}

compileConstraint(constraint: Constraint): CelCompiledConstraint {
compileRule(rule: Rule): CelCompiledRule {
try {
return {
kind: "interpretable",
interpretable: this.env.plan(this.env.parse(constraint.expression)),
constraint,
interpretable: this.env.plan(this.env.parse(rule.expression)),
rule,
};
} catch (cause) {
return {
kind: "compilation_error",
error: new CompilationError(
`failed to compile ${constraint.id}: ${String(cause)}`,
`failed to compile ${rule.id}: ${String(cause)}`,
{ cause },
),
};
Expand All @@ -347,11 +347,11 @@ export class CelManager {
if (!hasOption(field, predefined)) {
continue;
}
for (const constraint of getOption(field, predefined).cel) {
for (const rule of getOption(field, predefined).cel) {
standard.push({
field,
constraint,
compiled: this.compileConstraint(constraint),
rule,
compiled: this.compileRule(rule),
});
}
}
Expand All @@ -363,11 +363,11 @@ export class CelManager {
if (!list) {
extensions.set(ext.number, (list = []));
}
for (const constraint of getOption(ext, predefined).cel) {
for (const rule of getOption(ext, predefined).cel) {
list.push({
ext,
constraint,
compiled: this.compileConstraint(constraint),
rule,
compiled: this.compileRule(rule),
});
}
}
Expand All @@ -393,7 +393,7 @@ function registryGetExtensionsFor(

export class EvalCustomCel implements Eval<ReflectMessageGet> {
private readonly children: {
compiled: CelCompiledConstraint;
compiled: CelCompiledRule;
rulePath: Path;
}[] = [];

Expand All @@ -402,7 +402,7 @@ export class EvalCustomCel implements Eval<ReflectMessageGet> {
private readonly forMapKey: boolean,
) {}

add(compiled: CelCompiledConstraint, rulePath: Path): void {
add(compiled: CelCompiledRule, rulePath: Path): void {
this.children.push({ compiled, rulePath });
}

Expand All @@ -413,12 +413,7 @@ export class EvalCustomCel implements Eval<ReflectMessageGet> {
for (const child of this.children) {
const vio = this.celMan.eval(child.compiled);
if (vio) {
cursor.violate(
vio.message,
vio.constraintId,
child.rulePath,
this.forMapKey,
);
cursor.violate(vio.message, vio.ruleId, child.rulePath, this.forMapKey);
}
}
}
Expand All @@ -430,25 +425,18 @@ export class EvalCustomCel implements Eval<ReflectMessageGet> {

export class EvalExtendedRulesCel implements Eval<ReflectMessageGet> {
private readonly children: {
compiled: CelCompiledConstraint;
compiled: CelCompiledRule;
rulePath: Path;
ruleValue: unknown;
}[] = [];

constructor(
private readonly celMan: CelManager,
private readonly rules: Exclude<
FieldConstraints["type"]["value"],
undefined
>,
private readonly rules: Exclude<FieldRules["type"]["value"], undefined>,
private readonly forMapKey: boolean,
) {}

add(
compiled: CelCompiledConstraint,
rulePath: Path,
ruleValue: unknown,
): void {
add(compiled: CelCompiledRule, rulePath: Path, ruleValue: unknown): void {
this.children.push({
compiled,
rulePath,
Expand All @@ -463,12 +451,7 @@ export class EvalExtendedRulesCel implements Eval<ReflectMessageGet> {
this.celMan.setEnv("rule", child.ruleValue);
const vio = this.celMan.eval(child.compiled);
if (vio) {
cursor.violate(
vio.message,
vio.constraintId,
child.rulePath,
this.forMapKey,
);
cursor.violate(vio.message, vio.ruleId, child.rulePath, this.forMapKey);
}
}
}
Expand All @@ -480,20 +463,17 @@ export class EvalExtendedRulesCel implements Eval<ReflectMessageGet> {

export class EvalStandardRulesCel implements Eval<ReflectMessageGet> {
private readonly children: {
compiled: CelCompiledConstraint;
compiled: CelCompiledRule;
rulePath: Path;
}[] = [];

constructor(
private readonly celMan: CelManager,
private readonly rules: Exclude<
FieldConstraints["type"]["value"],
undefined
>,
private readonly rules: Exclude<FieldRules["type"]["value"], undefined>,
private readonly forMapKey: boolean,
) {}

add(compiled: CelCompiledConstraint, rulePath: Path): void {
add(compiled: CelCompiledRule, rulePath: Path): void {
this.children.push({ compiled, rulePath });
}

Expand All @@ -504,12 +484,7 @@ export class EvalStandardRulesCel implements Eval<ReflectMessageGet> {
for (const child of this.children) {
const vio = this.celMan.eval(child.compiled);
if (vio) {
cursor.violate(
vio.message,
vio.constraintId,
child.rulePath,
this.forMapKey,
);
cursor.violate(vio.message, vio.ruleId, child.rulePath, this.forMapKey);
}
}
}
Expand Down
20 changes: 10 additions & 10 deletions packages/protovalidate/src/cursor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,24 @@
import * as assert from "node:assert";
import { suite, test } from "node:test";
import { Cursor } from "./cursor.js";
import { ConstraintSchema } from "./gen/buf/validate/validate_pb.js";
import { RuleSchema } from "./gen/buf/validate/validate_pb.js";
import { ValidationError } from "./error.js";

void suite("Cursor", () => {
void test("create()", () => {
const cursor = Cursor.create(ConstraintSchema, false);
const cursor = Cursor.create(RuleSchema, false);
assert.equal(cursor.getPath().length, 0);
assert.equal(cursor.violated, false);
});
void suite("violate()", () => {
void test("sets violated = true", () => {
const cursor = Cursor.create(ConstraintSchema, false);
const cursor = Cursor.create(RuleSchema, false);
assert.equal(cursor.violated, false);
cursor.violate("msg", "constraint-id", []);
cursor.violate("msg", "rule-id", []);
assert.equal(cursor.violated, true);
});
void test("failFast=true throws on call", () => {
const cursor = Cursor.create(ConstraintSchema, true);
const cursor = Cursor.create(RuleSchema, true);
assert.throws(() => cursor.violate("msg-1", "id-1", []), {
name: "ValidationError",
message: "msg-1 [id-1]",
Expand All @@ -41,7 +41,7 @@ void suite("Cursor", () => {
});
void suite("throwIfViolated()", () => {
void test("throws if violated", () => {
const cursor = Cursor.create(ConstraintSchema, false);
const cursor = Cursor.create(RuleSchema, false);
cursor.violate("msg-1", "id-1", []);
assert.equal(cursor.violated, true);
assert.throws(() => cursor.throwIfViolated(), {
Expand All @@ -50,7 +50,7 @@ void suite("Cursor", () => {
});
});
void test("throws all violations", () => {
const cursor = Cursor.create(ConstraintSchema, false);
const cursor = Cursor.create(RuleSchema, false);
cursor.violate("msg-1", "id-1", []);
cursor.violate("msg-2", "id-2", []);
assert.equal(cursor.violated, true);
Expand All @@ -65,15 +65,15 @@ void suite("Cursor", () => {
}
});
void test("does not throw if not violated", () => {
const cursor = Cursor.create(ConstraintSchema, false);
const cursor = Cursor.create(RuleSchema, false);
assert.equal(cursor.violated, false);
cursor.throwIfViolated();
assert.ok(true);
});
});
void test("field() clones", () => {
const root = Cursor.create(ConstraintSchema, false);
const cursor = root.field(ConstraintSchema.field.message);
const root = Cursor.create(RuleSchema, false);
const cursor = root.field(RuleSchema.field.message);
assert.notStrictEqual(root, cursor);
assert.equal(root.getPath().length, 0);
assert.equal(cursor.getPath().length, 1);
Expand Down
4 changes: 2 additions & 2 deletions packages/protovalidate/src/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,14 @@ export class Cursor {

violate(
message: string,
constraintId: string,
ruleId: string,
rulePath: Path,
forMapKey = false,
): void {
this.violations.push(
new Violation(
message,
constraintId,
ruleId,
this.builder.toPath(),
rulePath,
forMapKey,
Expand Down
Loading
Loading