Skip to content

Commit 114fd46

Browse files
CLOUDP-325047: add support for Extentensios to the Spec Struct (#774)
1 parent 34fd58b commit 114fd46

File tree

2 files changed

+265
-1
lines changed

2 files changed

+265
-1
lines changed

tools/cli/internal/openapi/openapi.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package openapi
1616

1717
//go:generate mockgen -destination=../openapi/mock_openapi.go -package=openapi github.com/mongodb/openapi/tools/cli/internal/openapi Parser,Merger
1818
import (
19+
"encoding/json"
1920
"log"
2021

2122
"github.com/getkin/kin-openapi/openapi3"
@@ -26,6 +27,7 @@ import (
2627
// Spec is a struct is a 1-to-1 copy of the Spec struct in the openapi3 package.
2728
// We need this to override the order of the fields in the struct.
2829
type Spec struct {
30+
Extensions map[string]any `json:"-" yaml:"-"`
2931
OpenAPI string `json:"openapi" yaml:"openapi"`
3032
Security openapi3.SecurityRequirements `json:"security,omitempty" yaml:"security,omitempty"`
3133
Servers openapi3.Servers `json:"servers,omitempty" yaml:"servers,omitempty"`
@@ -34,7 +36,6 @@ type Spec struct {
3436
Paths *openapi3.Paths `json:"paths" yaml:"paths"`
3537
Components *openapi3.Components `json:"components,omitempty" yaml:"components,omitempty"`
3638
ExternalDocs *openapi3.ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"`
37-
Extensions map[string]any `json:"-" yaml:"-"`
3839
}
3940
type Parser interface {
4041
CreateOpenAPISpecFromPath(string) (*load.SpecInfo, error)
@@ -98,6 +99,7 @@ func NewOasDiffWithSpecInfo(base, external *load.SpecInfo, config *diff.Config)
9899

99100
func newSpec(spec *openapi3.T) *Spec {
100101
return &Spec{
102+
Extensions: spec.Extensions,
101103
OpenAPI: spec.OpenAPI,
102104
Components: spec.Components,
103105
Info: spec.Info,
@@ -108,3 +110,45 @@ func newSpec(spec *openapi3.T) *Spec {
108110
ExternalDocs: spec.ExternalDocs,
109111
}
110112
}
113+
114+
// MarshalJSON returns the JSON encoding of Spec.
115+
// We need a custom definition of MarshalJSON to include support for
116+
// Extensions map[string]any `json:"-" yaml:"-"` where
117+
// we only what to serialize the value of the field.
118+
func (doc *Spec) MarshalJSON() ([]byte, error) {
119+
x, err := doc.MarshalYAML()
120+
if err != nil {
121+
return nil, err
122+
}
123+
return json.Marshal(x)
124+
}
125+
126+
// MarshalYAML returns the YAML encoding of Spec.
127+
func (doc *Spec) MarshalYAML() (any, error) {
128+
if doc == nil {
129+
return nil, nil
130+
}
131+
m := make(map[string]any, 4+len(doc.Extensions))
132+
for k, v := range doc.Extensions {
133+
m[k] = v
134+
}
135+
m["openapi"] = doc.OpenAPI
136+
if x := doc.Components; x != nil {
137+
m["components"] = x
138+
}
139+
m["info"] = doc.Info
140+
m["paths"] = doc.Paths
141+
if x := doc.Security; len(x) != 0 {
142+
m["security"] = x
143+
}
144+
if x := doc.Servers; len(x) != 0 {
145+
m["servers"] = x
146+
}
147+
if x := doc.Tags; len(x) != 0 {
148+
m["tags"] = x
149+
}
150+
if x := doc.ExternalDocs; x != nil {
151+
m["externalDocs"] = x
152+
}
153+
return m, nil
154+
}
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// Copyright 2025 MongoDB Inc
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
package openapi
15+
16+
import (
17+
"encoding/json"
18+
"reflect"
19+
"testing"
20+
21+
"github.com/getkin/kin-openapi/openapi3"
22+
"github.com/stretchr/testify/require"
23+
)
24+
25+
func TestSpec_MarshalJSON(t *testing.T) {
26+
minimalInfo := &openapi3.Info{Title: "Test API", Version: "1.0.0"}
27+
28+
tests := []struct {
29+
name string
30+
spec *Spec
31+
jsonOutput string
32+
wantErr bool
33+
}{
34+
{
35+
name: "spec with nil extensions",
36+
spec: &Spec{
37+
OpenAPI: "3.0.3",
38+
Info: minimalInfo,
39+
Paths: &openapi3.Paths{},
40+
Extensions: nil,
41+
},
42+
jsonOutput: `{"info":{"title":"Test API","version":"1.0.0"},"openapi":"3.0.3","paths":{}}`,
43+
wantErr: false,
44+
},
45+
{
46+
name: "spec with empty extensions",
47+
spec: &Spec{
48+
OpenAPI: "3.0.3",
49+
Info: minimalInfo,
50+
Paths: &openapi3.Paths{},
51+
Extensions: map[string]any{},
52+
},
53+
jsonOutput: `{"info":{"title":"Test API","version":"1.0.0"},"openapi":"3.0.3","paths":{}}`,
54+
wantErr: false,
55+
},
56+
{
57+
name: "spec with single string extension",
58+
spec: &Spec{
59+
OpenAPI: "3.0.3",
60+
Info: minimalInfo,
61+
Paths: &openapi3.Paths{},
62+
Extensions: map[string]any{
63+
"x-custom-string": "hello world",
64+
},
65+
},
66+
jsonOutput: `{
67+
"info": {
68+
"title": "Test API",
69+
"version": "1.0.0"
70+
},
71+
"openapi": "3.0.3",
72+
"paths": {},
73+
"x-custom-string": "hello world"
74+
}`,
75+
wantErr: false,
76+
},
77+
{
78+
name: "spec with multiple extensions of different types",
79+
spec: &Spec{
80+
OpenAPI: "3.0.3",
81+
Info: minimalInfo,
82+
Paths: &openapi3.Paths{},
83+
Extensions: map[string]any{
84+
"x-custom-string": "hello",
85+
"x-custom-number": 123.45,
86+
"x-custom-bool": true,
87+
"x-custom-null": nil,
88+
},
89+
},
90+
jsonOutput: `{
91+
"info": {
92+
"title": "Test API",
93+
"version": "1.0.0"
94+
},
95+
"openapi": "3.0.3",
96+
"paths": {},
97+
"x-custom-bool": true,
98+
"x-custom-null": null,
99+
"x-custom-number": 123.45,
100+
"x-custom-string": "hello"
101+
}`,
102+
wantErr: false,
103+
},
104+
{
105+
name: "spec with nested object extension",
106+
spec: &Spec{
107+
OpenAPI: "3.0.3",
108+
Info: minimalInfo,
109+
Paths: &openapi3.Paths{},
110+
Extensions: map[string]any{
111+
"x-custom-object": map[string]any{
112+
"key1": "value1",
113+
"key2": 100,
114+
},
115+
},
116+
},
117+
jsonOutput: `{
118+
"info": {
119+
"title": "Test API",
120+
"version": "1.0.0"
121+
},
122+
"openapi": "3.0.3",
123+
"paths": {},
124+
"x-custom-object": {
125+
"key1": "value1",
126+
"key2": 100
127+
}
128+
}`,
129+
wantErr: false,
130+
},
131+
{
132+
name: "spec with array extension",
133+
spec: &Spec{
134+
OpenAPI: "3.0.3",
135+
Info: minimalInfo,
136+
Paths: &openapi3.Paths{},
137+
Extensions: map[string]any{
138+
"x-custom-array": []any{"a", 2, true, map[string]any{"nested": "item"}},
139+
},
140+
},
141+
jsonOutput: `{
142+
"info": {
143+
"title": "Test API",
144+
"version": "1.0.0"
145+
},
146+
"openapi": "3.0.3",
147+
"paths": {},
148+
"x-custom-array": [
149+
"a",
150+
2,
151+
true,
152+
{
153+
"nested": "item"
154+
}
155+
]
156+
}`,
157+
wantErr: false,
158+
},
159+
{
160+
name: "spec with extensions and other optional fields (e.g., components)",
161+
spec: &Spec{
162+
OpenAPI: "3.0.3",
163+
Info: minimalInfo,
164+
Paths: &openapi3.Paths{},
165+
Components: &openapi3.Components{
166+
Schemas: openapi3.Schemas{
167+
"MySchema": &openapi3.SchemaRef{
168+
Value: openapi3.NewObjectSchema().WithProperty("id", openapi3.NewIntegerSchema()),
169+
},
170+
},
171+
},
172+
Extensions: map[string]any{
173+
"x-marker": "present",
174+
},
175+
},
176+
jsonOutput: `{
177+
"components": {
178+
"schemas": {
179+
"MySchema": {
180+
"properties": {
181+
"id": {
182+
"type": "integer"
183+
}
184+
},
185+
"type": "object"
186+
}
187+
}
188+
},
189+
"info": {
190+
"title": "Test API",
191+
"version": "1.0.0"
192+
},
193+
"openapi": "3.0.3",
194+
"paths": {},
195+
"x-marker": "present"
196+
}`,
197+
wantErr: false,
198+
},
199+
}
200+
201+
for _, tt := range tests {
202+
t.Run(tt.name, func(t *testing.T) {
203+
gotBytes, err := tt.spec.MarshalJSON()
204+
require.NoError(t, err)
205+
206+
var gotMap map[string]any
207+
err = json.Unmarshal(gotBytes, &gotMap)
208+
require.NoError(t, err)
209+
210+
var wantMap map[string]any
211+
err = json.Unmarshal([]byte(tt.jsonOutput), &wantMap)
212+
require.NoError(t, err)
213+
if !reflect.DeepEqual(gotMap, wantMap) {
214+
gotPretty, _ := json.MarshalIndent(gotMap, "", " ")
215+
wantPretty, _ := json.MarshalIndent(wantMap, "", " ")
216+
t.Errorf("Spec.MarshalJSON() mismatch:\nGot:\n%s\nWant:\n%s", string(gotPretty), string(wantPretty))
217+
}
218+
})
219+
}
220+
}

0 commit comments

Comments
 (0)