Skip to content

Commit d2f8ee3

Browse files
fix: improved unmarshalling and validation errors of eithervalues (#55)
1 parent 3e915b0 commit d2f8ee3

File tree

8 files changed

+91
-29
lines changed

8 files changed

+91
-29
lines changed

marshaller/unmarshaller.go

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -199,28 +199,28 @@ func unmarshal(ctx context.Context, parentName string, node *yaml.Node, out refl
199199
switch {
200200
case isStructType(out):
201201
// Target expects a struct/object
202-
if err := validateNodeKind(resolvedNode, yaml.MappingNode, parentName); err != nil {
202+
if err := validateNodeKind(resolvedNode, yaml.MappingNode, parentName, "object"); err != nil {
203203
return []error{err}, nil //nolint:nilerr
204204
}
205205
return unmarshalMapping(ctx, parentName, node, out)
206206

207207
case isSliceType(out):
208208
// Target expects a slice/array
209-
if err := validateNodeKind(resolvedNode, yaml.SequenceNode, parentName); err != nil {
209+
if err := validateNodeKind(resolvedNode, yaml.SequenceNode, parentName, "sequence"); err != nil {
210210
return []error{err}, nil //nolint:nilerr
211211
}
212212
return unmarshalSequence(ctx, parentName, node, out)
213213

214214
case isMapType(out):
215215
// Target expects a map
216-
if err := validateNodeKind(resolvedNode, yaml.MappingNode, parentName); err != nil {
216+
if err := validateNodeKind(resolvedNode, yaml.MappingNode, parentName, "object"); err != nil {
217217
return []error{err}, nil //nolint:nilerr
218218
}
219219
return unmarshalMapping(ctx, parentName, node, out)
220220

221221
default:
222222
// Target expects a scalar value (string, int, bool, etc.)
223-
if err := validateNodeKind(resolvedNode, yaml.ScalarNode, parentName); err != nil {
223+
if err := validateNodeKind(resolvedNode, yaml.ScalarNode, parentName, out.Type().String()); err != nil {
224224
return []error{err}, nil //nolint:nilerr
225225
}
226226
return decodeNode(ctx, parentName, resolvedNode, out.Addr().Interface())
@@ -602,16 +602,15 @@ func isMapType(out reflect.Value) bool {
602602
}
603603

604604
// validateNodeKind checks if the node kind matches the expected kind and returns appropriate error
605-
func validateNodeKind(resolvedNode *yaml.Node, expectedKind yaml.Kind, parentName string) error {
605+
func validateNodeKind(resolvedNode *yaml.Node, expectedKind yaml.Kind, parentName, expectedType string) error {
606606
if resolvedNode == nil {
607607
return validation.NewValidationError(validation.NewTypeMismatchError("%sexpected %s, got nil", getOptionalParentName(parentName), yml.NodeKindToString(expectedKind)), nil)
608608
}
609609

610610
if resolvedNode.Kind != expectedKind {
611-
expectedKindStr := yml.NodeKindToString(expectedKind)
612611
actualKindStr := yml.NodeKindToString(resolvedNode.Kind)
613612

614-
return validation.NewValidationError(validation.NewTypeMismatchError("%sexpected %s, got %s", getOptionalParentName(parentName), expectedKindStr, actualKindStr), resolvedNode)
613+
return validation.NewValidationError(validation.NewTypeMismatchError("%sexpected %s, got %s", getOptionalParentName(parentName), expectedType, actualKindStr), resolvedNode)
615614
}
616615
return nil
617616
}

marshaller/unmarshalling_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ boolField: true
165165
intField: 42
166166
float64Field: 3.14
167167
`,
168-
wantErrs: []string{"[2:14] testPrimitiveModel.stringField expected scalar, got sequence"},
168+
wantErrs: []string{"[2:14] testPrimitiveModel.stringField expected string, got sequence"},
169169
},
170170
{
171171
name: "type mismatch - bool field gets string",

openapi/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,9 @@ Shows both automatic validation during unmarshaling and explicit validation.
185185
```go
186186
ctx := context.Background()
187187

188-
f, err := os.Open("testdata/invalid.openapi.yaml")
188+
path := "testdata/invalid.openapi.yaml"
189+
190+
f, err := os.Open(path)
189191
if err != nil {
190192
panic(err)
191193
}

openapi/openapi_examples_test.go

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,9 @@ func Example_marshalingJSONSchema() {
187187
func Example_validating() {
188188
ctx := context.Background()
189189

190-
f, err := os.Open("testdata/invalid.openapi.yaml")
190+
path := "testdata/invalid.openapi.yaml"
191+
192+
f, err := os.Open(path)
191193
if err != nil {
192194
panic(err)
193195
}
@@ -214,11 +216,21 @@ func Example_validating() {
214216
fmt.Println("Document is valid!")
215217
}
216218
// Output: Validation error: [3:3] info field version is missing
217-
// Validation error: [18:30] response.content expected object, got scalar
218-
// Validation error: [31:25] schema field properties.name.type value must be one of 'array', 'boolean', 'integer', 'null', 'number', 'object', 'string'
219-
// Validation error: [31:25] schema field properties.name.type got string, want array
220-
// Additional validation error: [31:25] schema field properties.name.type value must be one of 'array', 'boolean', 'integer', 'null', 'number', 'object', 'string'
221-
// Additional validation error: [31:25] schema field properties.name.type got string, want array
219+
// Validation error: [22:15] schema field type value must be one of 'array', 'boolean', 'integer', 'null', 'number', 'object', 'string'
220+
// Validation error: [22:17] schema field type.0 value must be one of 'array', 'boolean', 'integer', 'null', 'number', 'object', 'string'
221+
// Validation error: [28:30] response.content expected object, got scalar
222+
// Validation error: [43:19] schema.properties failed to validate either Schema or bool: [43:19] schema.properties expected object, got sequence
223+
// [43:19] schema.properties expected bool, got sequence
224+
// Validation error: [47:7] components.schemas failed to validate either Schema or bool: [50:15] schema.properties failed to validate either Schema or bool: [50:15] schema.properties expected object, got scalar
225+
// [50:15] schema.properties yaml: unmarshal errors:
226+
// line 50: cannot unmarshal !!str `string` into bool
227+
// [51:18] schema.properties failed to validate either Schema or bool: [51:18] schema.properties expected object, got scalar
228+
// [51:18] schema.properties yaml: unmarshal errors:
229+
// line 51: cannot unmarshal !!str `John Doe` into bool
230+
// [54:9] schema.examples expected sequence, got object
231+
// [47:7] components.schemas expected bool, got object
232+
// Additional validation error: [22:15] schema field type value must be one of 'array', 'boolean', 'integer', 'null', 'number', 'object', 'string'
233+
// Additional validation error: [22:17] schema field type.0 value must be one of 'array', 'boolean', 'integer', 'null', 'number', 'object', 'string'
222234
}
223235

224236
// Example_mutating demonstrates how to read and modify an OpenAPI document.

openapi/testdata/invalid.openapi.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ paths:
1111
in: path
1212
required: true
1313
# Missing schema definition
14+
- name: invalidSchema
15+
in: query
16+
schema:
17+
$ref: "#/components/schemas/InvalidSchema"
18+
- name: invalidNullSchema
19+
in: query
20+
schema:
21+
type:
22+
- null
23+
- string
1424
responses:
1525
"200":
1626
description: User found
@@ -29,3 +39,17 @@ paths:
2939
properties:
3040
name:
3141
type: invalid_type # Invalid schema type
42+
required: # at wrong level
43+
- name
44+
components:
45+
schemas:
46+
InvalidSchema:
47+
type: object
48+
properties:
49+
# Incorrectly defined properties
50+
name: string
51+
example: "John Doe"
52+
examples:
53+
# non-sequence based examples
54+
Example 1:
55+
syncId: 1ad0695c-4566-4715-918c-adbb03eac81e

validation/errors.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ var _ error = (*TypeMismatchError)(nil)
144144

145145
func NewTypeMismatchError(msg string, args ...any) *TypeMismatchError {
146146
if len(args) > 0 {
147-
msg = fmt.Sprintf(msg, args...)
147+
msg = fmt.Errorf(msg, args...).Error()
148148
}
149149

150150
return &TypeMismatchError{

values/core/eithervalue.go

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/speakeasy-api/openapi/internal/interfaces"
1111
"github.com/speakeasy-api/openapi/marshaller"
12+
"github.com/speakeasy-api/openapi/validation"
1213
"gopkg.in/yaml.v3"
1314
)
1415

@@ -33,7 +34,7 @@ func (v *EitherValue[L, R]) Unmarshal(ctx context.Context, parentName string, no
3334
// Try Left type without strict mode
3435
leftValidationErrs, leftUnmarshalErr = marshaller.UnmarshalCore(ctx, parentName, node, &v.Left)
3536
if leftUnmarshalErr == nil && !hasTypeMismatchErrors(leftValidationErrs) {
36-
// No unmarshalling error and no type mismatch validation errors - this is successful
37+
// No unmarshaling error and no type mismatch validation errors - this is successful
3738
v.IsLeft = true
3839
v.SetRootNode(node)
3940
return leftValidationErrs, nil
@@ -42,22 +43,35 @@ func (v *EitherValue[L, R]) Unmarshal(ctx context.Context, parentName string, no
4243
// Try Right type without strict mode
4344
rightValidationErrs, rightUnmarshalErr = marshaller.UnmarshalCore(ctx, parentName, node, &v.Right)
4445
if rightUnmarshalErr == nil && !hasTypeMismatchErrors(rightValidationErrs) {
45-
// No unmarshalling error and no type mismatch validation errors - this is successful
46+
// No unmarshaling error and no type mismatch validation errors - this is successful
4647
v.IsRight = true
4748
v.SetRootNode(node)
4849
return rightValidationErrs, nil
4950
}
5051

51-
// Both types failed - determine if we should return validation errors or unmarshalling errors
52+
leftType := reflect.TypeOf((*L)(nil)).Elem().Name()
53+
rightType := reflect.TypeOf((*R)(nil)).Elem().Name()
54+
55+
// Both types failed - determine if we should return validation errors or unmarshaling errors
56+
// Both failed with validation errors only (no real unmarshaling errors)
5257
if leftUnmarshalErr == nil && rightUnmarshalErr == nil {
53-
// Both failed with validation errors only (no real unmarshalling errors)
5458
// Combine the validation errors and return them instead of an error
5559
allValidationErrs := leftValidationErrs
5660
allValidationErrs = append(allValidationErrs, rightValidationErrs...)
57-
return allValidationErrs, nil
61+
62+
msg := fmt.Errorf("%s failed to validate either %s or %s: %w", parentName, leftType, rightType, errors.Join(allValidationErrs...)).Error()
63+
64+
var validationError error
65+
if hasTypeMismatchErrors(allValidationErrs) {
66+
validationError = validation.NewTypeMismatchError(msg)
67+
} else {
68+
validationError = validation.NewValueValidationError(msg)
69+
}
70+
71+
return []error{validation.NewValidationError(validationError, node)}, nil
5872
}
5973

60-
// At least one had a real unmarshalling error - return as unmarshalling failure
74+
// At least one had a real unmarshaling error - return as unmarshaling failure
6175
errs := []error{}
6276
if leftUnmarshalErr != nil {
6377
errs = append(errs, leftUnmarshalErr)
@@ -71,19 +85,27 @@ func (v *EitherValue[L, R]) Unmarshal(ctx context.Context, parentName string, no
7185
errs = append(errs, fmt.Errorf("right type validation failed: %v", rightValidationErrs))
7286
}
7387

74-
return nil, fmt.Errorf("unable to marshal into either %s or %s: %w", reflect.TypeOf((*L)(nil)).Elem().Name(), reflect.TypeOf((*R)(nil)).Elem().Name(), errors.Join(errs...))
88+
return nil, fmt.Errorf("unable to marshal into either %s or %s: %w", leftType, rightType, errors.Join(errs...))
7589
}
7690

7791
// hasTypeMismatchErrors checks if the validation errors contain type mismatch errors
78-
// indicating that the type couldn't be unmarshalled successfully
92+
// indicating that the type couldn't be unmarshaled successfully.
93+
// It ignores type mismatch errors from nested either values to avoid cascading failures.
7994
func hasTypeMismatchErrors(validationErrs []error) bool {
8095
if len(validationErrs) == 0 {
8196
return false
8297
}
8398

8499
for _, err := range validationErrs {
85-
// Check if this is a type mismatch error by looking for common patterns
86100
errStr := err.Error()
101+
102+
// Skip errors from nested either values - these are child validation failures
103+
// that shouldn't cause the parent either value to fail
104+
if strings.Contains(errStr, "failed to validate either") {
105+
continue
106+
}
107+
108+
// Check if this is a type mismatch error by looking for common patterns
87109
if strings.Contains(errStr, "expected") && (strings.Contains(errStr, "got") || strings.Contains(errStr, "but received")) {
88110
return true
89111
}

values/core/eithervalue_test.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,14 +101,17 @@ func TestEitherValue_BothTypesFailValidation(t *testing.T) {
101101
assert.False(t, target.IsRight, "Should not have set Right as successful")
102102

103103
// Validation errors should contain type mismatch information for both types
104-
foundScalarError := false
104+
foundTypeMismatchError := false
105105
for _, validationErr := range validationErrs {
106-
if strings.Contains(validationErr.Error(), "expected scalar") {
107-
foundScalarError = true
106+
errStr := validationErr.Error()
107+
// Check for type mismatch patterns like "expected X, got Y"
108+
if (strings.Contains(errStr, "expected string") || strings.Contains(errStr, "expected bool")) &&
109+
strings.Contains(errStr, "got sequence") {
110+
foundTypeMismatchError = true
108111
break
109112
}
110113
}
111-
assert.True(t, foundScalarError, "Should contain type mismatch error for scalar types")
114+
assert.True(t, foundTypeMismatchError, "Should contain type mismatch error for string/bool types")
112115
}
113116

114117
func TestEitherValue_GetNavigableNode_Success(t *testing.T) {

0 commit comments

Comments
 (0)