diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index 8e4f41c4..e0687a8e 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -2,6 +2,8 @@ - Add warning when an environment has duplicate top-level keys [#615](https://github.com/pulumi/esc/issues/615) +- Add support for `fn::validate` built-in function that validates an input against a json schema. + [#618](https://github.com/pulumi/esc/pulls/618) ### Bug Fixes diff --git a/ast/expr.go b/ast/expr.go index ef5155e3..a9bd142d 100644 --- a/ast/expr.go +++ b/ast/expr.go @@ -671,6 +671,34 @@ func FromBase64(value Expr) *FromBase64Expr { return FromBase64Syntax(nil, name, value) } +// ValidateExpr validates a value against a JSON schema. +type ValidateExpr struct { + builtinNode + + Schema Expr // The JSON schema to validate against + Value Expr // The value to validate +} + +func ValidateSyntax(node *syntax.ObjectNode, name *StringExpr, args, schemaExpr, valueExpr Expr) *ValidateExpr { + return &ValidateExpr{ + builtinNode: builtin(node, name, args), + Schema: schemaExpr, + Value: valueExpr, + } +} + +func Validate(schemaExpr, valueExpr Expr) *ValidateExpr { + name := String("fn::validate") + return &ValidateExpr{ + builtinNode: builtin(nil, name, Object( + ObjectProperty{Key: String("schema"), Value: schemaExpr}, + ObjectProperty{Key: String("value"), Value: valueExpr}, + )), + Schema: schemaExpr, + Value: valueExpr, + } +} + func tryParseFunction(node *syntax.ObjectNode) (Expr, syntax.Diagnostics, bool) { var diags syntax.Diagnostics if node.Len() != 1 { @@ -689,6 +717,8 @@ func tryParseFunction(node *syntax.ObjectNode) (Expr, syntax.Diagnostics, bool) switch kvp.Key.Value() { case "fn::concat": parse = parseConcat + case "fn::validate": + parse = parseValidate case "fn::fromJSON": parse = parseFromJSON case "fn::fromBase64": @@ -944,3 +974,32 @@ func parseSecret(node *syntax.ObjectNode, name *StringExpr, value Expr) (Expr, s } return PlaintextSyntax(node, name, str), diags } + +func parseValidate(node *syntax.ObjectNode, name *StringExpr, args Expr) (Expr, syntax.Diagnostics) { + obj, ok := args.(*ObjectExpr) + if !ok { + diags := syntax.Diagnostics{ExprError(args, "the argument to fn::validate must be an object containing 'schema' and 'value'")} + return ValidateSyntax(node, name, args, nil, nil), diags + } + + var schemaExpr, valueExpr Expr + var diags syntax.Diagnostics + + for _, kvp := range obj.Entries { + switch kvp.Key.GetValue() { + case "schema": + schemaExpr = kvp.Value + case "value": + valueExpr = kvp.Value + } + } + + if schemaExpr == nil { + diags.Extend(ExprError(obj, "missing required property 'schema'")) + } + if valueExpr == nil { + diags.Extend(ExprError(obj, "missing required property 'value'")) + } + + return ValidateSyntax(node, name, obj, schemaExpr, valueExpr), diags +} diff --git a/eval/eval.go b/eval/eval.go index e6372ebf..c8372ed8 100644 --- a/eval/eval.go +++ b/eval/eval.go @@ -15,6 +15,7 @@ package eval import ( + "bytes" "context" "encoding/base64" "encoding/json" @@ -331,6 +332,14 @@ func declare[Expr exprNode](e *evalContext, path string, x Expr, base *value) *e case *ast.FromBase64Expr: repr := &fromBase64Expr{node: x, string: declare(e, "", x.String, nil)} return newExpr(path, repr, schema.String().Schema(), base) + case *ast.ValidateExpr: + repr := &validateExpr{ + node: x, + schemaExpr: declare(e, "", x.Schema, nil), + value: declare(e, "", x.Value, nil), + } + // Output schema is dynamic - will be determined during evaluation + return newExpr(path, repr, schema.Always().Schema(), base) case *ast.FromJSONExpr: repr := &fromJSONExpr{node: x, string: declare(e, "", x.String, nil)} return newExpr(path, repr, schema.Always(), base) @@ -602,6 +611,8 @@ func (e *evalContext) evaluateExpr(x *expr, accept *schema.Schema) *value { val = e.evaluateBuiltinConcat(x, repr) case *fromBase64Expr: val = e.evaluateBuiltinFromBase64(x, repr) + case *validateExpr: + val = e.evaluateBuiltinValidate(x, repr) case *fromJSONExpr: val = e.evaluateBuiltinFromJSON(x, repr) case *joinExpr: @@ -1326,6 +1337,92 @@ func (e *evalContext) evaluateBuiltinFromBase64(x *expr, repr *fromBase64Expr) * return v } +// evaluateBuiltinValidate evaluates a call to the fn::validate builtin. +// It validates the value against the provided schema and emits diagnostics on failure. +// The value is always returned (pass-through semantics). +func (e *evalContext) evaluateBuiltinValidate(x *expr, repr *validateExpr) *value { + v := &value{def: x} + + // Evaluate and validate the schema expression against the JSON schema schema + schemaVal, schemaOk := e.evaluateTypedExpr(repr.schemaExpr, schema.JSONSchemaSchema()) + if schemaVal.containsUnknowns() { + // If schema is unknown, we can't validate - just return the value + val := e.evaluateExpr(repr.value, schema.Always()) + v.schema = val.schema + v.repr = val.repr + v.combine(val) + return v + } + + // If schema validation failed, still try to convert and use it + // (the error has already been reported) + if !schemaOk { + val := e.evaluateExpr(repr.value, schema.Always()) + v.schema = val.schema + v.repr = val.repr + v.combine(val) + return v + } + + // Convert the evaluated schema value to a *schema.Schema + validationSchema, err := e.valueToSchema(schemaVal) + if err != nil { + e.errorf(repr.schemaExpr.repr.syntax(), "invalid schema: %v", err) + val := e.evaluateExpr(repr.value, schema.Always()) + v.schema = val.schema + v.repr = val.repr + v.combine(val) + return v + } + repr.conformSchema = validationSchema + + // Compile the schema (like fn::open does with provider schemas) + if err := validationSchema.Compile(); err != nil { + e.errorf(repr.schemaExpr.repr.syntax(), "invalid schema: %v", err) + val := e.evaluateExpr(repr.value, schema.Always()) + v.schema = val.schema + v.repr = val.repr + v.combine(val) + return v + } + + // Validate value against the conform schema using evaluateTypedExpr + // This follows the same pattern as fn::open: inputs, ok := e.evaluateTypedExpr(repr.inputs, repr.inputSchema) + val, _ := e.evaluateTypedExpr(repr.value, validationSchema) + + // Return the value with its schema (pass-through semantics) + v.schema = val.schema + v.repr = val.repr + v.combine(val) + return v +} + +// valueToSchema converts an evaluated value to a *schema.Schema. +func (e *evalContext) valueToSchema(v *value) (*schema.Schema, error) { + // Export the value to esc.Value + ev, diags := v.export("") + e.diags.Extend(diags...) + + // Convert to JSON representation + jsonVal := ev.ToJSON(false) + + // Marshal to JSON bytes + jsonBytes, err := json.Marshal(jsonVal) + if err != nil { + return nil, fmt.Errorf("failed to marshal schema to JSON: %w", err) + } + + // Unmarshal into a schema.Schema + var s schema.Schema + dec := json.NewDecoder(bytes.NewReader(jsonBytes)) + dec.UseNumber() + if err := dec.Decode(&s); err != nil { + return nil, fmt.Errorf("failed to parse schema: %w", err) + } + + return &s, nil +} + // evaluateBuiltinFromJSON evaluates a call from the fn::fromJSON builtin. func (e *evalContext) evaluateBuiltinFromJSON(x *expr, repr *fromJSONExpr) *value { v := &value{def: x, schema: x.schema} diff --git a/eval/expr.go b/eval/expr.go index ca4357a8..33f2d7be 100644 --- a/eval/expr.go +++ b/eval/expr.go @@ -16,6 +16,7 @@ package eval import ( "fmt" + "github.com/hashicorp/hcl/v2" "github.com/pulumi/esc" "github.com/pulumi/esc/ast" @@ -168,6 +169,22 @@ func (x *expr) export(environment string) esc.Expr { ArgSchema: schema.String().Schema(), Arg: repr.string.export(environment), } + case *validateExpr: + ex.Builtin = &esc.BuiltinExpr{ + Name: repr.node.Name().Value, + NameRange: convertRange(repr.node.Name().Syntax().Syntax().Range(), environment), + ArgSchema: schema.Record(schema.SchemaMap{ + "schema": schema.JSONSchemaSchema(), + "value": schema.Always(), + }).Schema(), + Arg: esc.Expr{ + Range: convertRange(repr.node.Args().Syntax().Syntax().Range(), environment), + Object: map[string]esc.Expr{ + "schema": repr.schemaExpr.export(environment), + "value": repr.value.export(environment), + }, + }, + } case *fromJSONExpr: ex.Builtin = &esc.BuiltinExpr{ Name: repr.node.Name().Value, @@ -550,3 +567,17 @@ type fromBase64Expr struct { func (x *fromBase64Expr) syntax() ast.Expr { return x.node } + +// validateExpr represents a call to the fn::validate builtin. +type validateExpr struct { + node *ast.ValidateExpr + + schemaExpr *expr // The schema expression (evaluated to get schema value) + value *expr // The value expression to validate + + conformSchema *schema.Schema // Computed schema (populated during evaluation) +} + +func (x *validateExpr) syntax() ast.Expr { + return x.node +} diff --git a/schema/json_schema.go b/schema/json_schema.go new file mode 100644 index 00000000..e4537344 --- /dev/null +++ b/schema/json_schema.go @@ -0,0 +1,92 @@ +// Copyright 2026, Pulumi Corporation. +// +// 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package schema + +// JSONSchemaSchema is a schema that validates JSON schema definitions. +func JSONSchemaSchema() *Schema { + return &Schema{ + Defs: map[string]*Schema{ + "schema": { + AnyOf: []*Schema{ + // Boolean schema (true = accept all, false = reject all) + {Type: "boolean"}, + // Object schema + { + Type: "object", + Properties: map[string]*Schema{ + // Core vocabulary + "$defs": { + Type: "object", + AdditionalProperties: &Schema{Ref: "#/$defs/schema"}, + }, + + // Applicator vocabulary + "$ref": {Type: "string"}, + "anyOf": {Type: "array", Items: &Schema{Ref: "#/$defs/schema"}}, + "oneOf": {Type: "array", Items: &Schema{Ref: "#/$defs/schema"}}, + "prefixItems": { + Type: "array", + Items: &Schema{Ref: "#/$defs/schema"}, + }, + "items": {Ref: "#/$defs/schema"}, + "additionalProperties": {Ref: "#/$defs/schema"}, + "properties": { + Type: "object", + AdditionalProperties: &Schema{Ref: "#/$defs/schema"}, + }, + + // Validation vocabulary + "type": { + Type: "string", + Enum: []any{"string", "number", "boolean", "array", "object", "null"}, + }, + "const": {}, // Any value + "enum": {Type: "array"}, + "multipleOf": {Type: "number"}, + "maximum": {Type: "number"}, + "exclusiveMaximum": {Type: "number"}, + "minimum": {Type: "number"}, + "exclusiveMinimum": {Type: "number"}, + "maxLength": {Type: "number"}, + "minLength": {Type: "number"}, + "pattern": {Type: "string"}, + "maxItems": {Type: "number"}, + "minItems": {Type: "number"}, + "uniqueItems": {Type: "boolean"}, + "maxProperties": {Type: "number"}, + "minProperties": {Type: "number"}, + "required": { + Type: "array", + Items: &Schema{Type: "string"}, + }, + "dependentRequired": { + Type: "object", + AdditionalProperties: &Schema{Type: "array", Items: &Schema{Type: "string"}}, + }, + + // Metadata vocabulary + "title": {Type: "string"}, + "description": {Type: "string"}, + "default": {}, // Any value + "deprecated": {Type: "boolean"}, + "examples": {Type: "array"}, + }, + }, + }, + }, + }, + Ref: "#/$defs/schema", + } +}