Skip to content

Commit d9ec95c

Browse files
authored
Merge pull request #2519 from keboola/hosan/SUPPORT-15208
Fix conditional requirements in JSON Schema validation
2 parents 55e5af9 + 593111e commit d9ec95c

18 files changed

Lines changed: 344 additions & 11 deletions

File tree

internal/pkg/encoding/json/schema/schema.go

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -207,18 +207,32 @@ func NormalizeSchema(schema []byte) ([]byte, error) {
207207
})
208208

209209
// Process conditional requirements: remove from required arrays and add if/then/else constructs
210+
// IMPORTANT: Only add if/then for fields that were ORIGINALLY in the required array.
211+
// options.dependencies is for UI visibility, not for making fields required.
212+
// If a field was never required, it should stay optional even when visible.
210213
for parentPathStr, reqs := range conditionalReqs {
211214
parentObj := getObjectAtPath(m, parentPathStr)
212215
if parentObj == nil {
213216
continue
214217
}
215218

216-
// Remove conditionally required fields from the required array
217-
removeConditionalFieldsFromRequired(parentObj, reqs)
219+
// Get the set of originally required fields
220+
originallyRequired := getRequiredFields(parentObj)
218221

219-
// Generate if/then/else constructs for each conditional requirement
220-
allOfItems := make([]any, 0, len(reqs))
222+
// Filter to only fields that were originally required
223+
var requiredReqs []conditionalRequirement
221224
for _, req := range reqs {
225+
if originallyRequired[req.fieldName] {
226+
requiredReqs = append(requiredReqs, req)
227+
}
228+
}
229+
230+
// Remove conditionally required fields from the required array
231+
removeConditionalFieldsFromRequired(parentObj, requiredReqs)
232+
233+
// Generate if/then/else constructs only for fields that were originally required
234+
allOfItems := make([]any, 0, len(requiredReqs))
235+
for _, req := range requiredReqs {
222236
ifThenElse := buildIfThenElse(req)
223237
if ifThenElse != nil {
224238
allOfItems = append(allOfItems, ifThenElse)
@@ -246,6 +260,25 @@ func NormalizeSchema(schema []byte) ([]byte, error) {
246260
return normalized, nil
247261
}
248262

263+
// getRequiredFields returns a set of field names that are in the required array.
264+
func getRequiredFields(parentObj *orderedmap.OrderedMap) map[string]bool {
265+
result := make(map[string]bool)
266+
requiredVal, found := parentObj.Get("required")
267+
if !found {
268+
return result
269+
}
270+
requiredArr, ok := requiredVal.([]any)
271+
if !ok {
272+
return result
273+
}
274+
for _, field := range requiredArr {
275+
if fieldStr, ok := field.(string); ok {
276+
result[fieldStr] = true
277+
}
278+
}
279+
return result
280+
}
281+
249282
// removeConditionalFieldsFromRequired removes conditionally required fields from the required array.
250283
func removeConditionalFieldsFromRequired(parentObj *orderedmap.OrderedMap, reqs []conditionalRequirement) {
251284
requiredVal, found := parentObj.Get("required")
@@ -310,8 +343,9 @@ func buildIfThenElse(req conditionalRequirement) *orderedmap.OrderedMap {
310343
return nil
311344
}
312345

313-
// Build the "if" condition properties
346+
// Build the "if" condition properties and collect dependency field names
314347
ifProperties := orderedmap.New()
348+
requiredFields := make([]any, 0, len(req.dependencies))
315349
for depField, depValue := range req.dependencies {
316350
condition := orderedmap.New()
317351
// Handle array values (e.g., "protocol": ["FTP", "FTPS"]) using enum
@@ -322,10 +356,19 @@ func buildIfThenElse(req conditionalRequirement) *orderedmap.OrderedMap {
322356
condition.Set("const", depValue)
323357
}
324358
ifProperties.Set(depField, condition)
359+
requiredFields = append(requiredFields, depField)
325360
}
326361

327362
// Build the "if" clause
363+
// IMPORTANT: In JSON Schema, "if: { properties: { field: {const: value} } }" without "required"
364+
// evaluates to TRUE when field is MISSING (because missing properties match any schema).
365+
// We must add "required" to ensure "if" only applies when the dependency field exists.
366+
// This way:
367+
// - If dependency field is MISSING → "if" is FALSE → "then" doesn't apply → field is optional ✓
368+
// - If dependency field EXISTS but has wrong value → "if" is FALSE → "then" doesn't apply → field is optional ✓
369+
// - If dependency field EXISTS and has correct value → "if" is TRUE → "then" applies → field is required ✓
328370
ifClause := orderedmap.New()
371+
ifClause.Set("required", requiredFields)
329372
ifClause.Set("properties", ifProperties)
330373

331374
// Build the "then" clause with required field

internal/pkg/encoding/json/schema/schema_test.go

Lines changed: 157 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -729,8 +729,9 @@ func TestNormalizeSchema_OptionsDependencies_NestedObject(t *testing.T) {
729729
func TestNormalizeSchema_OptionsDependencies_NotInRequired(t *testing.T) {
730730
t.Parallel()
731731

732-
// Schema with options.dependencies but the field is not in the required array
733-
// The transformation should still add the if/then construct but not modify required
732+
// Schema with options.dependencies but the field is NOT in the required array.
733+
// options.dependencies is for UI visibility, NOT for making fields required.
734+
// Therefore, if the field was not originally required, we should NOT add if/then for it.
734735
schema := []byte(`
735736
{
736737
"type": "object",
@@ -765,10 +766,10 @@ func TestNormalizeSchema_OptionsDependencies_NotInRequired(t *testing.T) {
765766
require.True(t, ok)
766767
assert.Equal(t, []any{"mode"}, required)
767768

768-
// Check that allOf with if/then is still added
769-
allOf, ok := result["allOf"].([]any)
770-
require.True(t, ok)
771-
require.Len(t, allOf, 1)
769+
// Check that NO allOf was added - optional_field was not in required,
770+
// so we don't need conditional requirement logic
771+
_, hasAllOf := result["allOf"]
772+
assert.False(t, hasAllOf, "allOf should NOT be added for fields that were not originally required")
772773
}
773774

774775
func TestNormalizeSchema_OptionsDependencies_Validation(t *testing.T) {
@@ -962,6 +963,156 @@ func TestNormalizeSchema_ExistingAllOfIfThen(t *testing.T) {
962963
require.NoError(t, err, "Should pass validation when append_date=1 and append_date_format is provided")
963964
}
964965

966+
func TestNormalizeSchema_OptionsDependencies_MissingDependencyField(t *testing.T) {
967+
t.Parallel()
968+
969+
// This test uses the actual schema from kds-team.app-orchestration-trigger-queue-v2 component.
970+
// triggerActionOnFailure is NOT in the required array.
971+
// triggerActionOnFailure has options.dependencies: { waitUntilFinish: true }
972+
// actionOnFailureSettings has options.dependencies: { triggerActionOnFailure: true }
973+
schema := []byte(`
974+
{
975+
"type": "object",
976+
"required": ["waitUntilFinish", "#kbcToken", "kbcUrl", "orchestrationId"],
977+
"properties": {
978+
"waitUntilFinish": {
979+
"type": "boolean"
980+
},
981+
"triggerActionOnFailure": {
982+
"type": "boolean",
983+
"options": {
984+
"dependencies": {
985+
"waitUntilFinish": true
986+
}
987+
}
988+
},
989+
"actionOnFailureSettings": {
990+
"type": "object",
991+
"required": ["failureConfigurationId"],
992+
"properties": {
993+
"failureConfigurationId": {
994+
"type": "string"
995+
}
996+
},
997+
"options": {
998+
"dependencies": {
999+
"triggerActionOnFailure": true
1000+
}
1001+
}
1002+
},
1003+
"#kbcToken": {
1004+
"type": "string"
1005+
},
1006+
"kbcUrl": {
1007+
"type": "string"
1008+
},
1009+
"orchestrationId": {
1010+
"type": "string"
1011+
}
1012+
}
1013+
}
1014+
`)
1015+
1016+
// Test 1: triggerActionOnFailure field is missing entirely (fire-and-forget config)
1017+
// This should PASS - actionOnFailureSettings should NOT be required
1018+
content1 := orderedmap.FromPairs([]orderedmap.Pair{
1019+
{
1020+
Key: "parameters",
1021+
Value: orderedmap.FromPairs([]orderedmap.Pair{
1022+
{Key: "waitUntilFinish", Value: false},
1023+
{Key: "#kbcToken", Value: "xxx"},
1024+
{Key: "kbcUrl", Value: "-"},
1025+
{Key: "orchestrationId", Value: "123"},
1026+
// triggerActionOnFailure is NOT present at all
1027+
}),
1028+
},
1029+
})
1030+
err := ValidateContent(schema, content1)
1031+
require.NoError(t, err, "Should pass when triggerActionOnFailure field is missing (undefined)")
1032+
1033+
// Test 2: triggerActionOnFailure is explicitly false
1034+
// This should PASS - actionOnFailureSettings should NOT be required
1035+
content2 := orderedmap.FromPairs([]orderedmap.Pair{
1036+
{
1037+
Key: "parameters",
1038+
Value: orderedmap.FromPairs([]orderedmap.Pair{
1039+
{Key: "waitUntilFinish", Value: true},
1040+
{Key: "#kbcToken", Value: "xxx"},
1041+
{Key: "kbcUrl", Value: "-"},
1042+
{Key: "triggerActionOnFailure", Value: false},
1043+
{Key: "orchestrationId", Value: "123"},
1044+
}),
1045+
},
1046+
})
1047+
err = ValidateContent(schema, content2)
1048+
require.NoError(t, err, "Should pass when triggerActionOnFailure is explicitly false")
1049+
1050+
// Test 3: triggerActionOnFailure is true but actionOnFailureSettings is missing
1051+
// This should PASS - actionOnFailureSettings was NOT in the required array,
1052+
// options.dependencies is only for UI visibility, not for making fields required.
1053+
content3 := orderedmap.FromPairs([]orderedmap.Pair{
1054+
{
1055+
Key: "parameters",
1056+
Value: orderedmap.FromPairs([]orderedmap.Pair{
1057+
{Key: "waitUntilFinish", Value: true},
1058+
{Key: "#kbcToken", Value: "xxx"},
1059+
{Key: "kbcUrl", Value: "-"},
1060+
{Key: "triggerActionOnFailure", Value: true},
1061+
{Key: "orchestrationId", Value: "123"},
1062+
}),
1063+
},
1064+
})
1065+
err = ValidateContent(schema, content3)
1066+
require.NoError(t, err, "Should pass - actionOnFailureSettings was NOT in required, options.dependencies is for visibility only")
1067+
1068+
// Test 4: triggerActionOnFailure is true and actionOnFailureSettings is provided but missing internal required field
1069+
// This should FAIL - failureConfigurationId is required WITHIN actionOnFailureSettings
1070+
content4 := orderedmap.FromPairs([]orderedmap.Pair{
1071+
{
1072+
Key: "parameters",
1073+
Value: orderedmap.FromPairs([]orderedmap.Pair{
1074+
{Key: "waitUntilFinish", Value: true},
1075+
{Key: "#kbcToken", Value: "xxx"},
1076+
{Key: "kbcUrl", Value: "-"},
1077+
{Key: "triggerActionOnFailure", Value: true},
1078+
{Key: "orchestrationId", Value: "123"},
1079+
{
1080+
Key: "actionOnFailureSettings",
1081+
Value: orderedmap.FromPairs([]orderedmap.Pair{
1082+
// missing failureConfigurationId
1083+
}),
1084+
},
1085+
}),
1086+
},
1087+
})
1088+
err = ValidateContent(schema, content4)
1089+
require.Error(t, err, "Should fail - failureConfigurationId is required within actionOnFailureSettings")
1090+
assert.Contains(t, err.Error(), "failureConfigurationId")
1091+
1092+
// Test 5: triggerActionOnFailure is true and actionOnFailureSettings is provided with required field
1093+
// This should PASS
1094+
content5 := orderedmap.FromPairs([]orderedmap.Pair{
1095+
{
1096+
Key: "parameters",
1097+
Value: orderedmap.FromPairs([]orderedmap.Pair{
1098+
{Key: "waitUntilFinish", Value: true},
1099+
{Key: "#kbcToken", Value: "xxx"},
1100+
{Key: "kbcUrl", Value: "-"},
1101+
{Key: "triggerActionOnFailure", Value: true},
1102+
{Key: "orchestrationId", Value: "123"},
1103+
{
1104+
Key: "actionOnFailureSettings",
1105+
Value: orderedmap.FromPairs([]orderedmap.Pair{
1106+
{Key: "failureConfigurationId", Value: "456"},
1107+
}),
1108+
},
1109+
}),
1110+
},
1111+
})
1112+
err = ValidateContent(schema, content5)
1113+
require.NoError(t, err, "Should pass when actionOnFailureSettings is provided with required fields")
1114+
}
1115+
9651116
func getTestSchema() []byte {
9661117
return []byte(`
9671118
{
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"componentId": "kds-team.app-orchestration-trigger-queue-v2",
3+
"name": "trigger-fire-and-forget",
4+
"configuration": {
5+
"parameters": {
6+
"waitUntilFinish": false,
7+
"#kbcToken": "KBC::ProjectSecure::dummytoken",
8+
"kbcUrl": "-",
9+
"orchestrationId": "123456"
10+
}
11+
},
12+
"rows": null
13+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pull --storage-api-token %%TEST_KBC_STORAGE_API_TOKEN%%
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
0

test/cli/pull/orchestration-trigger-conditional-requirement/expected-stderr

Whitespace-only changes.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Plan for "pull" operation:
2+
+ B main
3+
+ C main/application/kds-team.app-orchestration-trigger-queue-v2/trigger-fire-and-forget
4+
Pull done.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"version": 2,
3+
"project": {
4+
"id": %%TEST_KBC_PROJECT_ID%%,
5+
"apiHost": "%%TEST_KBC_STORAGE_API_HOST%%"
6+
},
7+
"allowTargetEnv": false,
8+
"sortBy": "id",
9+
"naming": {
10+
"branch": "{branch_name}",
11+
"config": "{component_type}/{component_id}/{config_name}",
12+
"configRow": "rows/{config_row_name}",
13+
"schedulerConfig": "schedules/{config_name}",
14+
"sharedCodeConfig": "_shared/{target_component_id}",
15+
"sharedCodeConfigRow": "codes/{config_row_name}",
16+
"variablesConfig": "variables",
17+
"variablesValuesRow": "values/{config_row_name}",
18+
"dataAppConfig": "app/{component_id}/{config_name}"
19+
},
20+
"allowedBranches": [
21+
"*"
22+
],
23+
"ignoredComponents": [],
24+
"branches": [],
25+
"configurations": []
26+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"allBranchesConfigs": [
3+
"trigger-fire-and-forget"
4+
],
5+
"branches": [
6+
{
7+
"branch": {
8+
"name": "Main",
9+
"isDefault": true
10+
}
11+
}
12+
]
13+
}

test/cli/pull/orchestration-trigger-conditional-requirement/out/.gitkeep

Whitespace-only changes.

0 commit comments

Comments
 (0)