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: 2 additions & 0 deletions CHANGELOG_PENDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
59 changes: 59 additions & 0 deletions ast/expr.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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":
Expand Down Expand Up @@ -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
}
97 changes: 97 additions & 0 deletions eval/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package eval

import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}
Expand Down
31 changes: 31 additions & 0 deletions eval/expr.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package eval

import (
"fmt"

"github.com/hashicorp/hcl/v2"
"github.com/pulumi/esc"
"github.com/pulumi/esc/ast"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
92 changes: 92 additions & 0 deletions schema/json_schema.go
Original file line number Diff line number Diff line change
@@ -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",
}
}
Loading