Skip to content

Commit ce27721

Browse files
committed
Addressing pb33f/wiretap#147
1 parent 16cd936 commit ce27721

4 files changed

Lines changed: 157 additions & 27 deletions

File tree

helpers/schema_compiler.go

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -99,56 +99,60 @@ func NewCompiledSchemaWithVersion(name string, jsonSchema []byte, options *confi
9999
return jsch, nil
100100
}
101101

102-
// transformOpenAPI30Schema transforms OpenAPI 3.0 schemas to JSON Schema compatible format
103-
// This specifically handles the nullable keyword by converting it to proper type arrays
102+
// transformOpenAPI30Schema transforms OpenAPI 3.0 schemas to JSON Schema 2020-12 compatible format.
103+
// Handles OAS 3.0-specific keywords:
104+
// - nullable: true → type array with "null"
105+
// - exclusiveMinimum/exclusiveMaximum: bool → numeric (draft-04 → 2020-12)
104106
func transformOpenAPI30Schema(jsonSchema []byte) []byte {
105107
var schema map[string]interface{}
106108
if err := json.Unmarshal(jsonSchema, &schema); err != nil {
107-
// If we can't parse it, return as-is
108109
return jsonSchema
109110
}
110111

111-
transformed := transformNullableInSchema(schema)
112+
transformed := transformOAS30Keywords(schema)
112113

113114
result, err := json.Marshal(transformed)
114115
if err != nil {
115-
// If we can't marshal the result, return original
116116
return jsonSchema
117117
}
118118

119119
return result
120120
}
121121

122-
// transformNullableInSchema recursively transforms nullable keywords in a schema object
123-
func transformNullableInSchema(schema interface{}) interface{} {
122+
// transformOAS30Keywords recursively transforms OAS 3.0-specific keywords in a schema object
123+
func transformOAS30Keywords(schema interface{}) interface{} {
124124
switch s := schema.(type) {
125125
case map[string]interface{}:
126126
result := make(map[string]interface{})
127127

128-
// copy all properties first
128+
// copy all properties first, recursing into nested schemas
129129
for key, value := range s {
130-
result[key] = transformNullableInSchema(value)
130+
result[key] = transformOAS30Keywords(value)
131131
}
132132

133-
// check if this schema has nullable keyword
133+
// handle nullable keyword
134134
if nullable, ok := s["nullable"]; ok {
135135
if nullableBool, ok := nullable.(bool); ok {
136136
if nullableBool {
137-
// Transform the schema to support null values
138-
return transformNullableSchema(result)
137+
result = transformNullableSchema(result)
139138
} else {
140-
// nullable: false - just remove the nullable keyword
141139
delete(result, "nullable")
142140
}
143141
}
144142
}
145143

144+
// handle exclusiveMinimum: bool → numeric
145+
transformExclusiveBound(result, "exclusiveMinimum", "minimum")
146+
147+
// handle exclusiveMaximum: bool → numeric
148+
transformExclusiveBound(result, "exclusiveMaximum", "maximum")
149+
146150
return result
147151

148152
case []interface{}:
149153
result := make([]interface{}, len(s))
150154
for i, item := range s {
151-
result[i] = transformNullableInSchema(item)
155+
result[i] = transformOAS30Keywords(item)
152156
}
153157
return result
154158

@@ -157,6 +161,35 @@ func transformNullableInSchema(schema interface{}) interface{} {
157161
}
158162
}
159163

164+
// transformExclusiveBound converts OAS 3.0 boolean exclusiveMinimum/exclusiveMaximum
165+
// to JSON Schema 2020-12 numeric form.
166+
//
167+
// OAS 3.0 (draft-04): minimum: 10, exclusiveMinimum: true → value must be > 10
168+
// JSON Schema 2020-12: exclusiveMinimum: 10 → value must be > 10
169+
func transformExclusiveBound(schema map[string]interface{}, exclusiveKey, boundKey string) {
170+
exVal, ok := schema[exclusiveKey]
171+
if !ok {
172+
return
173+
}
174+
exBool, isBool := exVal.(bool)
175+
if !isBool {
176+
return // already numeric (3.1 style), leave as-is
177+
}
178+
if exBool {
179+
// exclusiveMinimum: true + minimum: X → exclusiveMinimum: X (remove minimum)
180+
if bound, hasBound := schema[boundKey]; hasBound {
181+
schema[exclusiveKey] = bound
182+
delete(schema, boundKey)
183+
} else {
184+
// boolean true without a corresponding bound is invalid, just remove it
185+
delete(schema, exclusiveKey)
186+
}
187+
} else {
188+
// exclusiveMinimum: false is a no-op, just remove the keyword
189+
delete(schema, exclusiveKey)
190+
}
191+
}
192+
160193
// transformNullableSchema transforms a schema with nullable: true to JSON Schema compatible format
161194
func transformNullableSchema(schema map[string]interface{}) map[string]interface{} {
162195
delete(schema, "nullable")

helpers/schema_compiler_test.go

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ func TestTransformNullableInSchema_MapWithNullableTrue(t *testing.T) {
330330
"nullable": true,
331331
}
332332

333-
result := transformNullableInSchema(schema)
333+
result := transformOAS30Keywords(schema)
334334

335335
resultMap, ok := result.(map[string]interface{})
336336
require.True(t, ok)
@@ -353,7 +353,7 @@ func TestTransformNullableInSchema_MapWithNullableFalse(t *testing.T) {
353353
"nullable": false,
354354
}
355355

356-
result := transformNullableInSchema(schema)
356+
result := transformOAS30Keywords(schema)
357357

358358
resultMap, ok := result.(map[string]interface{})
359359
require.True(t, ok)
@@ -376,7 +376,7 @@ func TestTransformNullableInSchema_Array(t *testing.T) {
376376
"other-item",
377377
}
378378

379-
result := transformNullableInSchema(schema)
379+
result := transformOAS30Keywords(schema)
380380

381381
resultArray, ok := result.([]interface{})
382382
require.True(t, ok)
@@ -395,18 +395,115 @@ func TestTransformNullableInSchema_Array(t *testing.T) {
395395

396396
func TestTransformNullableInSchema_OtherTypes(t *testing.T) {
397397
stringSchema := "string-value"
398-
result := transformNullableInSchema(stringSchema)
398+
result := transformOAS30Keywords(stringSchema)
399399
assert.Equal(t, stringSchema, result)
400400

401401
numberSchema := 123
402-
result = transformNullableInSchema(numberSchema)
402+
result = transformOAS30Keywords(numberSchema)
403403
assert.Equal(t, numberSchema, result)
404404

405405
var nilSchema interface{} = nil
406-
result = transformNullableInSchema(nilSchema)
406+
result = transformOAS30Keywords(nilSchema)
407407
assert.Equal(t, nilSchema, result)
408408
}
409409

410+
func TestTransformExclusiveBound_TrueWithBound(t *testing.T) {
411+
schema := map[string]interface{}{
412+
"type": "number",
413+
"minimum": float64(10),
414+
"exclusiveMinimum": true,
415+
}
416+
transformExclusiveBound(schema, "exclusiveMinimum", "minimum")
417+
418+
assert.Equal(t, float64(10), schema["exclusiveMinimum"])
419+
_, hasMin := schema["minimum"]
420+
assert.False(t, hasMin)
421+
}
422+
423+
func TestTransformExclusiveBound_TrueWithoutBound(t *testing.T) {
424+
schema := map[string]interface{}{
425+
"type": "number",
426+
"exclusiveMinimum": true,
427+
}
428+
transformExclusiveBound(schema, "exclusiveMinimum", "minimum")
429+
430+
_, hasExMin := schema["exclusiveMinimum"]
431+
assert.False(t, hasExMin)
432+
}
433+
434+
func TestTransformExclusiveBound_False(t *testing.T) {
435+
schema := map[string]interface{}{
436+
"type": "number",
437+
"minimum": float64(10),
438+
"exclusiveMinimum": false,
439+
}
440+
transformExclusiveBound(schema, "exclusiveMinimum", "minimum")
441+
442+
_, hasExMin := schema["exclusiveMinimum"]
443+
assert.False(t, hasExMin)
444+
assert.Equal(t, float64(10), schema["minimum"])
445+
}
446+
447+
func TestTransformExclusiveBound_NotPresent(t *testing.T) {
448+
schema := map[string]interface{}{
449+
"type": "number",
450+
"minimum": float64(10),
451+
}
452+
transformExclusiveBound(schema, "exclusiveMinimum", "minimum")
453+
454+
assert.Equal(t, float64(10), schema["minimum"])
455+
}
456+
457+
func TestTransformExclusiveBound_AlreadyNumeric(t *testing.T) {
458+
schema := map[string]interface{}{
459+
"type": "number",
460+
"exclusiveMinimum": float64(10),
461+
}
462+
transformExclusiveBound(schema, "exclusiveMinimum", "minimum")
463+
464+
assert.Equal(t, float64(10), schema["exclusiveMinimum"])
465+
}
466+
467+
func TestTransformExclusiveBound_Maximum(t *testing.T) {
468+
schema := map[string]interface{}{
469+
"type": "number",
470+
"maximum": float64(100),
471+
"exclusiveMaximum": true,
472+
}
473+
transformExclusiveBound(schema, "exclusiveMaximum", "maximum")
474+
475+
assert.Equal(t, float64(100), schema["exclusiveMaximum"])
476+
_, hasMax := schema["maximum"]
477+
assert.False(t, hasMax)
478+
}
479+
480+
func TestTransformOAS30Keywords_ExclusiveMinMaxRecursive(t *testing.T) {
481+
schema := map[string]interface{}{
482+
"type": "object",
483+
"properties": map[string]interface{}{
484+
"price": map[string]interface{}{
485+
"type": "number",
486+
"minimum": float64(0),
487+
"exclusiveMinimum": true,
488+
"maximum": float64(1000),
489+
"exclusiveMaximum": true,
490+
},
491+
},
492+
}
493+
494+
result := transformOAS30Keywords(schema)
495+
resultMap := result.(map[string]interface{})
496+
props := resultMap["properties"].(map[string]interface{})
497+
price := props["price"].(map[string]interface{})
498+
499+
assert.Equal(t, float64(0), price["exclusiveMinimum"])
500+
assert.Equal(t, float64(1000), price["exclusiveMaximum"])
501+
_, hasMin := price["minimum"]
502+
assert.False(t, hasMin)
503+
_, hasMax := price["maximum"]
504+
assert.False(t, hasMax)
505+
}
506+
410507
func TestTransformNullableSchema_ArrayType(t *testing.T) {
411508
schema := map[string]interface{}{
412509
"type": []interface{}{"string", "number"},

requests/validate_request_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ properties:
110110
}
111111
}
112112

113-
func TestInvalidMin(t *testing.T) {
113+
func TestBooleanExclusiveMin_ValidValue(t *testing.T) {
114114
openAPIVersion := float32(3.0)
115115
schema := parseSchemaFromSpec(t, `type: object
116116
properties:
@@ -126,8 +126,8 @@ properties:
126126
Version: openAPIVersion,
127127
})
128128

129-
assert.False(t, valid)
130-
assert.Len(t, errors, 1)
129+
assert.True(t, valid)
130+
assert.Empty(t, errors)
131131
}
132132

133133
func TestValidateRequestSchema_CachePopulation(t *testing.T) {

responses/validate_response_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func TestValidateResponseSchema(t *testing.T) {
2727
}{
2828
"FailOnBooleanExclusiveMinimum": {
2929
request: postRequest(),
30-
response: responseWithBody(`{"exclusiveNumber": 13}`),
30+
response: responseWithBody(`{"exclusiveNumber": 10}`),
3131
schemaYAML: `type: object
3232
properties:
3333
exclusiveNumber:
@@ -117,7 +117,7 @@ properties:
117117
}
118118
}
119119

120-
func TestInvalidMin(t *testing.T) {
120+
func TestBooleanExclusiveMin_ValidValue(t *testing.T) {
121121
openAPIVersion := float32(3.0)
122122
schema := parseSchemaFromSpec(t, `type: object
123123
properties:
@@ -134,8 +134,8 @@ properties:
134134
Version: openAPIVersion,
135135
})
136136

137-
assert.False(t, valid)
138-
assert.Len(t, errors, 1)
137+
assert.True(t, valid)
138+
assert.Empty(t, errors)
139139
}
140140

141141
func TestValidateResponseSchema_CachePopulation(t *testing.T) {

0 commit comments

Comments
 (0)