Skip to content

Commit 41f7152

Browse files
fix: fixed inlining shared schemas and improvements to sequenced maps
1 parent e91d0c6 commit 41f7152

File tree

11 files changed

+62150
-145
lines changed

11 files changed

+62150
-145
lines changed

jsonschema/oas3/inline.go

Lines changed: 131 additions & 114 deletions
Large diffs are not rendered by default.

jsonschema/oas3/inline_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"io"
1010
"io/fs"
11+
"os"
1112
"path/filepath"
1213
"strings"
1314
"testing"
@@ -1375,3 +1376,85 @@ func extractSchemaFromOpenAPI(openAPIDoc *openapi.OpenAPI, pointer string) (*oas
13751376

13761377
return schema, nil
13771378
}
1379+
1380+
func TestInline_EmailParser_PagerDuty_Success(t *testing.T) {
1381+
t.Parallel()
1382+
1383+
// This test reproduces the bug with EmailParser schema from pagerduty.json
1384+
// The EmailParser schema contains a reference to MatchPredicate, which has a circular reference
1385+
// through its "children" property that references back to itself
1386+
ctx := t.Context()
1387+
1388+
// Read the pagerduty.json file content
1389+
pagerDutyContent, err := os.ReadFile("testdata/pagerduty.json")
1390+
require.NoError(t, err, "failed to read pagerduty.json")
1391+
1392+
// Parse as OpenAPI document
1393+
pagerDutyDoc, err := parseJSONToOpenAPI(ctx, string(pagerDutyContent))
1394+
require.NoError(t, err, "failed to parse pagerduty.json as OpenAPI")
1395+
1396+
// Extract multiple schemas that all use the Integration schema to trigger the bug
1397+
// when the same MatchPredicate gets processed multiple times in the same session
1398+
1399+
// PUT /services/{id}/integrations/{integration_id} requestBody
1400+
putRequestBodySchema, err := extractSchemaFromOpenAPI(pagerDutyDoc, "/paths/~1services~1{id}~1integrations~1{integration_id}/put/requestBody/content/application~1json/schema")
1401+
require.NoError(t, err, "failed to extract PUT requestBody schema")
1402+
1403+
// POST /services/{id}/integrations requestBody (same Integration schema)
1404+
postRequestBodySchema, err := extractSchemaFromOpenAPI(pagerDutyDoc, "/paths/~1services~1{id}~1integrations/post/requestBody/content/application~1json/schema")
1405+
require.NoError(t, err, "failed to extract POST requestBody schema")
1406+
1407+
// POST /services/{id}/integrations 201 response (also uses Integration schema)
1408+
postResponseSchema, err := extractSchemaFromOpenAPI(pagerDutyDoc, "/paths/~1services~1{id}~1integrations/post/responses/201/content/application~1json/schema")
1409+
require.NoError(t, err, "failed to extract POST 201 response schema")
1410+
1411+
// Create resolve options using the pagerduty document as the root document
1412+
opts := oas3.InlineOptions{
1413+
ResolveOptions: oas3.ResolveOptions{
1414+
TargetLocation: "testdata/pagerduty.json",
1415+
RootDocument: pagerDutyDoc,
1416+
},
1417+
RemoveUnusedDefs: true,
1418+
}
1419+
1420+
// First, inline the PUT requestBody schema
1421+
inlined1, err := oas3.Inline(ctx, putRequestBodySchema, opts)
1422+
require.NoError(t, err, "first inlining should succeed for PUT requestBody schema")
1423+
require.NotNil(t, inlined1, "first inlined schema should not be nil")
1424+
1425+
// Then, inline the POST requestBody schema in the same session
1426+
// This should trigger the bug because MatchPredicate gets processed again
1427+
inlined2, err := oas3.Inline(ctx, postRequestBodySchema, opts)
1428+
require.NoError(t, err, "second inlining should succeed for POST requestBody schema")
1429+
require.NotNil(t, inlined2, "second inlined schema should not be nil")
1430+
1431+
// Finally, inline the POST response schema in the same session
1432+
// This is the third time the same Integration->EmailParser->MatchPredicate chain gets processed
1433+
inlined3, err := oas3.Inline(ctx, postResponseSchema, opts)
1434+
require.NoError(t, err, "third inlining should succeed for POST response schema")
1435+
require.NotNil(t, inlined3, "third inlined schema should not be nil")
1436+
1437+
// Verify all results are valid
1438+
actualJSON1, err := schemaToJSON(ctx, inlined1)
1439+
require.NoError(t, err, "failed to convert first inlined result to JSON")
1440+
require.NotEmpty(t, actualJSON1, "first inlined JSON should not be empty")
1441+
1442+
actualJSON2, err := schemaToJSON(ctx, inlined2)
1443+
require.NoError(t, err, "failed to convert second inlined result to JSON")
1444+
require.NotEmpty(t, actualJSON2, "second inlined JSON should not be empty")
1445+
1446+
actualJSON3, err := schemaToJSON(ctx, inlined3)
1447+
require.NoError(t, err, "failed to convert third inlined result to JSON")
1448+
require.NotEmpty(t, actualJSON3, "third inlined JSON should not be empty")
1449+
1450+
// All schemas should contain the Integration structure with EmailParser inlined
1451+
for i, actualJSON := range []string{actualJSON1, actualJSON2, actualJSON3} {
1452+
assert.Contains(t, actualJSON, `"integration"`, "schema %d should contain integration property", i+1)
1453+
assert.Contains(t, actualJSON, `"action"`, "schema %d should contain action property from inlined EmailParser", i+1)
1454+
assert.Contains(t, actualJSON, `"match_predicate"`, "schema %d should contain match_predicate property", i+1)
1455+
}
1456+
1457+
t.Logf("Successfully inlined 3 schemas with shared Integration->EmailParser->MatchPredicate references")
1458+
t.Logf("PUT request: %d chars, POST request: %d chars, POST response: %d chars",
1459+
len(actualJSON1), len(actualJSON2), len(actualJSON3))
1460+
}

jsonschema/oas3/jsonschema.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,38 @@ func (j *JSONSchema[T]) Validate(ctx context.Context, opts ...validation.Option)
216216
func ConcreteToReferenceable(concrete *JSONSchema[Concrete]) *JSONSchema[Referenceable] {
217217
return (*JSONSchema[Referenceable])(unsafe.Pointer(concrete)) //nolint:gosec
218218
}
219+
220+
// ReferenceableToConcrete converts a JSONSchema[Referenceable] to JSONSchema[Concrete] using unsafe pointer casting.
221+
// This is safe because the underlying structure is identical, only the type parameter differs.
222+
// This allows for efficient conversion without allocation when you need to walk a referenceable schema
223+
// as if it were a concrete schema.
224+
func ReferenceableToConcrete(referenceable *JSONSchema[Referenceable]) *JSONSchema[Concrete] {
225+
return (*JSONSchema[Concrete])(unsafe.Pointer(referenceable)) //nolint:gosec
226+
}
227+
228+
// ShallowCopy creates a shallow copy of the JSONSchema.
229+
func (j *JSONSchema[T]) ShallowCopy() *JSONSchema[T] {
230+
if j == nil {
231+
return nil
232+
}
233+
234+
result := &JSONSchema[T]{
235+
referenceResolutionCache: j.referenceResolutionCache,
236+
validationErrsCache: j.validationErrsCache,
237+
circularErrorFound: j.circularErrorFound,
238+
resolvedSchemaCache: j.resolvedSchemaCache,
239+
parent: j.parent,
240+
topLevelParent: j.topLevelParent,
241+
}
242+
243+
// Shallow copy the EitherValue contents
244+
if j.IsLeft() && j.GetLeft() != nil {
245+
result.Left = j.GetLeft().ShallowCopy()
246+
}
247+
if j.IsRight() && j.GetRight() != nil {
248+
rightVal := *j.GetRight()
249+
result.Right = &rightVal
250+
}
251+
252+
return result
253+
}

jsonschema/oas3/schema.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,108 @@ type Schema struct {
7474
Extensions *extensions.Extensions
7575
}
7676

77+
// ShallowCopy creates a shallow copy of the Schema.
78+
// This copies all struct fields and creates new slices/maps but does not deep copy the referenced objects.
79+
// The elements within slices and maps are copied by reference, not deep copied.
80+
func (s *Schema) ShallowCopy() *Schema {
81+
if s == nil {
82+
return nil
83+
}
84+
85+
result := &Schema{
86+
Model: s.Model,
87+
Ref: s.Ref,
88+
ExclusiveMaximum: s.ExclusiveMaximum,
89+
ExclusiveMinimum: s.ExclusiveMinimum,
90+
Type: s.Type,
91+
Discriminator: s.Discriminator,
92+
Contains: s.Contains,
93+
MinContains: s.MinContains,
94+
MaxContains: s.MaxContains,
95+
If: s.If,
96+
Else: s.Else,
97+
Then: s.Then,
98+
PropertyNames: s.PropertyNames,
99+
UnevaluatedItems: s.UnevaluatedItems,
100+
UnevaluatedProperties: s.UnevaluatedProperties,
101+
Items: s.Items,
102+
Anchor: s.Anchor,
103+
Not: s.Not,
104+
Title: s.Title,
105+
MultipleOf: s.MultipleOf,
106+
Maximum: s.Maximum,
107+
Minimum: s.Minimum,
108+
MaxLength: s.MaxLength,
109+
MinLength: s.MinLength,
110+
Pattern: s.Pattern,
111+
Format: s.Format,
112+
MaxItems: s.MaxItems,
113+
MinItems: s.MinItems,
114+
UniqueItems: s.UniqueItems,
115+
MaxProperties: s.MaxProperties,
116+
MinProperties: s.MinProperties,
117+
AdditionalProperties: s.AdditionalProperties,
118+
Description: s.Description,
119+
Default: s.Default,
120+
Const: s.Const,
121+
Nullable: s.Nullable,
122+
ReadOnly: s.ReadOnly,
123+
WriteOnly: s.WriteOnly,
124+
ExternalDocs: s.ExternalDocs,
125+
Example: s.Example,
126+
Deprecated: s.Deprecated,
127+
Schema: s.Schema,
128+
XML: s.XML,
129+
Extensions: s.Extensions,
130+
}
131+
132+
// Shallow copy slices - create new slice but reference same elements
133+
if s.AllOf != nil {
134+
result.AllOf = make([]*JSONSchema[Referenceable], len(s.AllOf))
135+
copy(result.AllOf, s.AllOf)
136+
}
137+
if s.OneOf != nil {
138+
result.OneOf = make([]*JSONSchema[Referenceable], len(s.OneOf))
139+
copy(result.OneOf, s.OneOf)
140+
}
141+
if s.AnyOf != nil {
142+
result.AnyOf = make([]*JSONSchema[Referenceable], len(s.AnyOf))
143+
copy(result.AnyOf, s.AnyOf)
144+
}
145+
if s.Examples != nil {
146+
result.Examples = make([]values.Value, len(s.Examples))
147+
copy(result.Examples, s.Examples)
148+
}
149+
if s.PrefixItems != nil {
150+
result.PrefixItems = make([]*JSONSchema[Referenceable], len(s.PrefixItems))
151+
copy(result.PrefixItems, s.PrefixItems)
152+
}
153+
if s.Required != nil {
154+
result.Required = make([]string, len(s.Required))
155+
copy(result.Required, s.Required)
156+
}
157+
if s.Enum != nil {
158+
result.Enum = make([]values.Value, len(s.Enum))
159+
copy(result.Enum, s.Enum)
160+
}
161+
162+
// Shallow copy maps - create new map but reference same elements
163+
if s.DependentSchemas != nil {
164+
result.DependentSchemas = sequencedmap.From(s.DependentSchemas.All())
165+
}
166+
if s.PatternProperties != nil {
167+
result.PatternProperties = sequencedmap.From(s.PatternProperties.All())
168+
}
169+
if s.Properties != nil {
170+
result.Properties = sequencedmap.From(s.Properties.All())
171+
}
172+
if s.Defs != nil {
173+
result.Defs = sequencedmap.From(s.Defs.All())
174+
}
175+
176+
return result
177+
}
178+
77179
// GetRef returns the value of the Ref field. Returns empty string if not set.
78180
func (s *Schema) GetRef() references.Reference {
79181
if s == nil || s.Ref == nil {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package oas3_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/speakeasy-api/openapi/jsonschema/oas3"
7+
"github.com/speakeasy-api/openapi/sequencedmap"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestJSONSchema_ShallowCopy_Success(t *testing.T) {
13+
t.Parallel()
14+
15+
// Create a JSONSchema with properties
16+
original := oas3.NewJSONSchemaFromSchema[oas3.Referenceable](&oas3.Schema{
17+
Type: oas3.NewTypeFromString("object"),
18+
Properties: sequencedmap.New(
19+
sequencedmap.NewElem("name", oas3.NewJSONSchemaFromSchema[oas3.Referenceable](&oas3.Schema{
20+
Type: oas3.NewTypeFromString("string"),
21+
})),
22+
),
23+
})
24+
25+
// Create a shallow copy
26+
copied := original.ShallowCopy()
27+
require.NotNil(t, copied, "shallow copy should not be nil")
28+
29+
// Initially they should be equal
30+
assert.True(t, original.IsEqual(copied), "original and copy should be equal initially")
31+
32+
// Modify the copy by adding a new property
33+
copied.GetLeft().Properties.Set("email", oas3.NewJSONSchemaFromSchema[oas3.Referenceable](&oas3.Schema{
34+
Type: oas3.NewTypeFromString("string"),
35+
}))
36+
37+
// Now they should not be equal
38+
assert.False(t, original.IsEqual(copied), "original and copy should not be equal after modification")
39+
}

0 commit comments

Comments
 (0)