Skip to content

Commit ffd3845

Browse files
committed
Resolve JSON Schema properties onto Control nodes during Parse (#1)
Add SchemaProperty type and resolve each Control's scope against the data schema as a post-parse step, so renderers get type, format, enum, constraints, and required status without manually walking the schema.
1 parent aa54d92 commit ffd3845

3 files changed

Lines changed: 370 additions & 4 deletions

File tree

parser.go

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"errors"
66
"fmt"
7+
"strings"
78
)
89

910
// Static errors for err113 compliance
@@ -43,10 +44,144 @@ func Parse(uiSchemaJSON, schemaJSON []byte) (*AST, error) {
4344
}
4445
}
4546

46-
return &AST{
47+
ast := &AST{
4748
UISchema: uiSchema,
4849
Schema: schema,
49-
}, nil
50+
}
51+
52+
// Post-parse: resolve schema properties onto controls
53+
if schema != nil {
54+
resolveSchemaProperties(ast)
55+
}
56+
57+
return ast, nil
58+
}
59+
60+
// resolveSchemaProperties walks the AST and resolves schema property definitions onto Control nodes
61+
func resolveSchemaProperties(ast *AST) {
62+
_ = Walk(ast.UISchema, &schemaResolver{schema: ast.Schema})
63+
}
64+
65+
type schemaResolver struct {
66+
BaseVisitor
67+
schema any
68+
}
69+
70+
func (r *schemaResolver) VisitControl(c *Control) error {
71+
c.SchemaProperty = resolveScope(c.Scope, r.schema)
72+
return nil
73+
}
74+
75+
// resolveScope walks the JSON Schema following a JSON Pointer scope path
76+
// and returns the property definition at that path.
77+
func resolveScope(scope string, schema any) *SchemaProperty {
78+
if schema == nil {
79+
return nil
80+
}
81+
82+
// Strip leading "#/" or "#"
83+
path := strings.TrimPrefix(scope, "#/")
84+
85+
path = strings.TrimPrefix(path, "#")
86+
87+
if path == "" {
88+
return nil
89+
}
90+
91+
segments := strings.Split(path, "/")
92+
93+
current, ok := schema.(map[string]any)
94+
if !ok {
95+
return nil
96+
}
97+
98+
// Track the parent node at each "properties" level for required checking
99+
var parent map[string]any
100+
101+
var propertyName string
102+
103+
for i, segment := range segments {
104+
val, exists := current[segment]
105+
if !exists {
106+
return nil
107+
}
108+
109+
next, ok := val.(map[string]any)
110+
if !ok {
111+
return nil
112+
}
113+
114+
// Track parent: when we see "properties", the next segment is a property name
115+
if segment == "properties" && i+1 < len(segments) {
116+
parent = current
117+
} else if i > 0 && segments[i-1] == "properties" {
118+
propertyName = segment
119+
}
120+
121+
current = next
122+
}
123+
124+
return buildSchemaProperty(current, parent, propertyName)
125+
}
126+
127+
func buildSchemaProperty(node, parent map[string]any, propertyName string) *SchemaProperty {
128+
sp := &SchemaProperty{}
129+
130+
if t, ok := node["type"].(string); ok {
131+
sp.Type = t
132+
}
133+
134+
if f, ok := node["format"].(string); ok {
135+
sp.Format = f
136+
}
137+
138+
if p, ok := node["pattern"].(string); ok {
139+
sp.Pattern = p
140+
}
141+
142+
if e, ok := node["enum"].([]any); ok {
143+
sp.Enum = e
144+
}
145+
146+
if c, exists := node["const"]; exists {
147+
sp.Const = c
148+
}
149+
150+
if d, exists := node["default"]; exists {
151+
sp.Default = d
152+
}
153+
154+
if v, ok := node["minLength"].(float64); ok {
155+
i := int(v)
156+
sp.MinLength = &i
157+
}
158+
159+
if v, ok := node["maxLength"].(float64); ok {
160+
i := int(v)
161+
sp.MaxLength = &i
162+
}
163+
164+
if v, ok := node["minimum"].(float64); ok {
165+
sp.Minimum = &v
166+
}
167+
168+
if v, ok := node["maximum"].(float64); ok {
169+
sp.Maximum = &v
170+
}
171+
172+
// Check if this property is in parent's "required" array
173+
if parent != nil && propertyName != "" {
174+
if req, ok := parent["required"].([]any); ok {
175+
for _, r := range req {
176+
if s, ok := r.(string); ok && s == propertyName {
177+
sp.Required = true
178+
break
179+
}
180+
}
181+
}
182+
}
183+
184+
return sp
50185
}
51186

52187
// parseUISchema parses the UI schema JSON into a UISchemaElement

parser_test.go

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,219 @@ func TestParseNestedCategorization(t *testing.T) {
625625
}
626626
}
627627

628+
func TestControlSchemaPropertyResolved(t *testing.T) {
629+
uiSchema := []byte(`{
630+
"type": "Control",
631+
"scope": "#/properties/name"
632+
}`)
633+
schema := []byte(`{
634+
"type": "object",
635+
"required": ["name"],
636+
"properties": {
637+
"name": {
638+
"type": "string",
639+
"minLength": 2,
640+
"maxLength": 50
641+
}
642+
}
643+
}`)
644+
645+
result, err := Parse(uiSchema, schema)
646+
require.NoError(t, err)
647+
648+
control, ok := result.UISchema.(*Control)
649+
require.True(t, ok)
650+
require.NotNil(t, control.SchemaProperty, "Expected SchemaProperty to be resolved")
651+
652+
assert.Equal(t, "string", control.SchemaProperty.Type)
653+
assert.Equal(t, 2, *control.SchemaProperty.MinLength)
654+
assert.Equal(t, 50, *control.SchemaProperty.MaxLength)
655+
assert.True(t, control.SchemaProperty.Required)
656+
}
657+
658+
func TestControlSchemaPropertyEnum(t *testing.T) {
659+
uiSchema := []byte(`{
660+
"type": "Control",
661+
"scope": "#/properties/title"
662+
}`)
663+
schema := []byte(`{
664+
"type": "object",
665+
"properties": {
666+
"title": {
667+
"type": "string",
668+
"enum": ["Mr", "Mrs", "Miss", "Ms"]
669+
}
670+
}
671+
}`)
672+
673+
result, err := Parse(uiSchema, schema)
674+
require.NoError(t, err)
675+
676+
control := result.UISchema.(*Control)
677+
require.NotNil(t, control.SchemaProperty)
678+
679+
assert.Equal(t, "string", control.SchemaProperty.Type)
680+
assert.Equal(t, []any{"Mr", "Mrs", "Miss", "Ms"}, control.SchemaProperty.Enum)
681+
assert.False(t, control.SchemaProperty.Required)
682+
}
683+
684+
func TestControlSchemaPropertyBoolean(t *testing.T) {
685+
uiSchema := []byte(`{
686+
"type": "Control",
687+
"scope": "#/properties/acceptTerms"
688+
}`)
689+
schema := []byte(`{
690+
"type": "object",
691+
"properties": {
692+
"acceptTerms": {
693+
"type": "boolean",
694+
"default": false,
695+
"const": true
696+
}
697+
}
698+
}`)
699+
700+
result, err := Parse(uiSchema, schema)
701+
require.NoError(t, err)
702+
703+
control := result.UISchema.(*Control)
704+
require.NotNil(t, control.SchemaProperty)
705+
706+
assert.Equal(t, "boolean", control.SchemaProperty.Type)
707+
assert.Equal(t, false, control.SchemaProperty.Default)
708+
assert.Equal(t, true, control.SchemaProperty.Const)
709+
}
710+
711+
func TestControlSchemaPropertyNestedScope(t *testing.T) {
712+
uiSchema := []byte(`{
713+
"type": "Control",
714+
"scope": "#/properties/personalDetails/properties/address/properties/postcode"
715+
}`)
716+
schema := []byte(`{
717+
"type": "object",
718+
"properties": {
719+
"personalDetails": {
720+
"type": "object",
721+
"properties": {
722+
"address": {
723+
"type": "object",
724+
"required": ["postcode"],
725+
"properties": {
726+
"postcode": {
727+
"type": "string",
728+
"pattern": "^[A-Z]{1,2}[0-9][0-9A-Z]?\\s?[0-9][A-Z]{2}$"
729+
}
730+
}
731+
}
732+
}
733+
}
734+
}
735+
}`)
736+
737+
result, err := Parse(uiSchema, schema)
738+
require.NoError(t, err)
739+
740+
control := result.UISchema.(*Control)
741+
require.NotNil(t, control.SchemaProperty)
742+
743+
assert.Equal(t, "string", control.SchemaProperty.Type)
744+
assert.Equal(t, `^[A-Z]{1,2}[0-9][0-9A-Z]?\s?[0-9][A-Z]{2}$`, control.SchemaProperty.Pattern)
745+
assert.True(t, control.SchemaProperty.Required)
746+
}
747+
748+
func TestControlSchemaPropertyNilSchema(t *testing.T) {
749+
uiSchema := []byte(`{
750+
"type": "Control",
751+
"scope": "#/properties/name"
752+
}`)
753+
754+
result, err := Parse(uiSchema, nil)
755+
require.NoError(t, err)
756+
757+
control := result.UISchema.(*Control)
758+
assert.Nil(t, control.SchemaProperty)
759+
}
760+
761+
func TestControlSchemaPropertyUnresolvableScope(t *testing.T) {
762+
uiSchema := []byte(`{
763+
"type": "Control",
764+
"scope": "#/properties/nonexistent/properties/field"
765+
}`)
766+
schema := []byte(`{
767+
"type": "object",
768+
"properties": {
769+
"name": { "type": "string" }
770+
}
771+
}`)
772+
773+
result, err := Parse(uiSchema, schema)
774+
require.NoError(t, err)
775+
776+
control := result.UISchema.(*Control)
777+
assert.Nil(t, control.SchemaProperty)
778+
}
779+
780+
func TestControlSchemaPropertyWithFormat(t *testing.T) {
781+
uiSchema := []byte(`{
782+
"type": "Control",
783+
"scope": "#/properties/email"
784+
}`)
785+
schema := []byte(`{
786+
"type": "object",
787+
"properties": {
788+
"email": {
789+
"type": "string",
790+
"format": "email"
791+
}
792+
}
793+
}`)
794+
795+
result, err := Parse(uiSchema, schema)
796+
require.NoError(t, err)
797+
798+
control := result.UISchema.(*Control)
799+
require.NotNil(t, control.SchemaProperty)
800+
801+
assert.Equal(t, "string", control.SchemaProperty.Type)
802+
assert.Equal(t, "email", control.SchemaProperty.Format)
803+
}
804+
805+
func TestLayoutWithMultipleControlsSchemaResolved(t *testing.T) {
806+
uiSchema := []byte(`{
807+
"type": "VerticalLayout",
808+
"elements": [
809+
{ "type": "Control", "scope": "#/properties/name" },
810+
{ "type": "Control", "scope": "#/properties/age" }
811+
]
812+
}`)
813+
schema := []byte(`{
814+
"type": "object",
815+
"required": ["name"],
816+
"properties": {
817+
"name": { "type": "string" },
818+
"age": { "type": "integer", "minimum": 0, "maximum": 150 }
819+
}
820+
}`)
821+
822+
result, err := Parse(uiSchema, schema)
823+
require.NoError(t, err)
824+
825+
layout := result.UISchema.(*VerticalLayout)
826+
require.Len(t, layout.Elements, 2)
827+
828+
nameCtrl := layout.Elements[0].(*Control)
829+
require.NotNil(t, nameCtrl.SchemaProperty)
830+
assert.Equal(t, "string", nameCtrl.SchemaProperty.Type)
831+
assert.True(t, nameCtrl.SchemaProperty.Required)
832+
833+
ageCtrl := layout.Elements[1].(*Control)
834+
require.NotNil(t, ageCtrl.SchemaProperty)
835+
assert.Equal(t, "integer", ageCtrl.SchemaProperty.Type)
836+
assert.InDelta(t, float64(0), *ageCtrl.SchemaProperty.Minimum, 0)
837+
assert.InDelta(t, float64(150), *ageCtrl.SchemaProperty.Maximum, 0)
838+
assert.False(t, ageCtrl.SchemaProperty.Required)
839+
}
840+
628841
// countingVisitor counts all element types encountered during a walk
629842
type countingVisitor struct {
630843
BaseVisitor

0 commit comments

Comments
 (0)