Skip to content

Commit b53e219

Browse files
mbprabhooMadhav Prabhoo
andauthored
feat: add inline JSON Schema validation for DAG params (closes #1182) (#1887)
Co-authored-by: Madhav Prabhoo <[email protected]>
1 parent bed0c6f commit b53e219

6 files changed

Lines changed: 667 additions & 1 deletion

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,7 @@ __debug_bin*
4747

4848
# Ralph PRD file
4949
prd.json
50+
51+
# Compiled integration test binary
52+
intg.test
53+

internal/core/spec/dag_params.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type dagParamKind uint8
1717
const (
1818
dagParamKindLegacy dagParamKind = iota
1919
dagParamKindExternalSchema
20+
dagParamKindInlineSchema
2021
)
2122

2223
type dagParamPlan struct {
@@ -92,6 +93,15 @@ func buildDAGParamsResult(ctx BuildContext, d *dag) (*paramsResult, error) {
9293
}
9394
finalPairs = runtimePairsFromEntries(finalEntries)
9495
}
96+
97+
case dagParamKindInlineSchema:
98+
if resolveRuntimeParams {
99+
finalEntries, err := resolveExternalSchemaEntries(plan, ctx.opts.Parameters, ctx.opts.ParametersList)
100+
if err != nil {
101+
return nil, err
102+
}
103+
finalPairs = runtimePairsFromEntries(finalEntries)
104+
}
95105
}
96106

97107
defaultParts := make([]string, 0, len(defaultPairs))
@@ -142,6 +152,12 @@ func buildDAGParamPlan(ctx BuildContext, d *dag) (*dagParamPlan, error) {
142152
}
143153
return buildExternalSchemaParamPlan(d.Params, d.WorkingDir, ctx.file)
144154
}
155+
if isInlineJSONSchema(d.Params) {
156+
if ctx.opts.Has(BuildFlagSkipSchemaValidation) {
157+
return &dagParamPlan{kind: dagParamKindLegacy}, nil
158+
}
159+
return buildInlineSchemaParamPlan(d.Params)
160+
}
145161
return buildLegacyParamPlan(d.Params)
146162
}
147163

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright (C) 2026 Yota Hamada
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
package spec
5+
6+
import (
7+
"encoding/json"
8+
"fmt"
9+
"maps"
10+
11+
"github.com/dagu-org/dagu/internal/core"
12+
"github.com/google/jsonschema-go/jsonschema"
13+
)
14+
15+
// isInlineJSONSchema returns true when params is a map[string]any with a
16+
// "properties" key whose value is also a map[string]any. It returns false for
17+
// the external-schema format (which uses a "schema" key with a file path).
18+
func isInlineJSONSchema(input any) bool {
19+
m, ok := input.(map[string]any)
20+
if !ok {
21+
return false
22+
}
23+
// External schema format takes precedence.
24+
if _, ok := extractParamsSchemaDeclaration(input); ok {
25+
return false
26+
}
27+
props, ok := m["properties"]
28+
if !ok {
29+
return false
30+
}
31+
_, ok = props.(map[string]any)
32+
return ok
33+
}
34+
35+
// buildInlineSchemaParamPlan compiles the inline JSON Schema stored directly in
36+
// the params field and returns a dagParamPlan backed by the compiled schema.
37+
func buildInlineSchemaParamPlan(input any) (*dagParamPlan, error) {
38+
m, ok := input.(map[string]any)
39+
if !ok {
40+
return nil, core.NewValidationError("params", input, fmt.Errorf("%w: expected an object for inline JSON Schema", ErrInvalidParamValue))
41+
}
42+
43+
// Strip fields that are not understood by the JSON Schema library at the
44+
// top level to avoid resolution errors. Nested readonly fields are passed
45+
// through; the library absorbs unknown keywords inside property definitions.
46+
stripped := make(map[string]any, len(m))
47+
maps.Copy(stripped, m)
48+
delete(stripped, "readOnly")
49+
delete(stripped, "readonly")
50+
51+
raw, err := json.Marshal(stripped)
52+
if err != nil {
53+
return nil, fmt.Errorf("failed to marshal inline param schema: %w", err)
54+
}
55+
56+
var schema jsonschema.Schema
57+
if err := json.Unmarshal(raw, &schema); err != nil {
58+
return nil, fmt.Errorf("failed to parse inline param schema: %w", err)
59+
}
60+
61+
resolved, err := schema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true})
62+
if err != nil {
63+
return nil, fmt.Errorf("failed to resolve inline param schema: %w", err)
64+
}
65+
66+
root := resolved.Schema()
67+
schemaOrder := topLevelSchemaOrder(root)
68+
schemaProperties := map[string]*jsonschema.Schema{}
69+
if root != nil {
70+
maps.Copy(schemaProperties, root.Properties)
71+
}
72+
73+
// Start with an empty typed map and apply schema defaults.
74+
typedDefaults := map[string]any{}
75+
typedDefaults, err = validateSchemaMap(typedDefaults, resolved, true)
76+
if err != nil {
77+
return nil, err
78+
}
79+
80+
plan := &dagParamPlan{
81+
kind: dagParamKindInlineSchema,
82+
schema: resolved,
83+
schemaOrder: schemaOrder,
84+
schemaProperties: schemaProperties,
85+
entries: entriesFromTypedMap(typedDefaults, schemaOrder),
86+
}
87+
88+
if paramDefs, ok := deriveExternalSchemaParamDefs(root, typedDefaults); ok {
89+
plan.paramDefs = paramDefs
90+
}
91+
92+
return plan, nil
93+
}

0 commit comments

Comments
 (0)