Skip to content

Commit 697b331

Browse files
authored
feat(parser): add ParseObject to generate OpenAPI schemas (#87)
Introduce ParseObject function to parse Go types into OpenAPI schema representations, supporting structs, pointers, maps, slices, and primitives. Handle tags for customizing schema fields, references, and metadata. This enables automatic schema generation from Go types, improving integration with OpenAPI components and reducing manual schema definitions.
1 parent 34e5ace commit 697b331

File tree

6 files changed

+897
-84
lines changed

6 files changed

+897
-84
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ go get github.com/sv-tools/openapi
2828
* `Validator.ValidateData()` method validates the data.
2929
* `Validator.ValidateDataAsJSON()` method validates the data by converting it into `map[string]any` type first using `json.Marshal` and `json.Unmarshal`.
3030
**WARNING**: the function is slow due to double conversion.
31+
* Added `ParseObject` function to create `SchemaBuilder` by parsing an object.
32+
The function supports `json`, `yaml` and `openapi` field tags for the structs.
3133
* Use OpenAPI `v3.1.1` by default.
3234

3335
## Features

bool_or_schema.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ func NewBoolOrSchema(v any) *BoolOrSchema {
7878
return &BoolOrSchema{Allowed: v}
7979
case *RefOrSpec[Schema]:
8080
return &BoolOrSchema{Schema: v}
81-
case *SchemaBulder:
81+
case *SchemaBuilder:
8282
return &BoolOrSchema{Schema: v.Build()}
8383
default:
8484
return nil

components.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ func (o *Components) validateSpec(location string, validator *Validator) []*vali
181181
}
182182
errs = append(errs, v.validateSpec(joinLoc(location, "responses", k), validator)...)
183183
}
184+
184185
for k, v := range o.Parameters {
185186
if !namePattern.MatchString(k) {
186187
errs = append(errs, newValidationError(joinLoc(location, "parameters", k), "invalid name %q, must match %q", k, namePattern.String()))

parser.go

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
package openapi
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"reflect"
7+
"strings"
8+
)
9+
10+
type parseOptions struct {
11+
DefaultEncoding string // default encoding for the schema, used for []byte
12+
}
13+
14+
type ParseOption func(*parseOptions)
15+
16+
// WithDefaultEncoding sets the default encoding for the schema for []byte fields.
17+
// The default encoding is Base64Encoding.
18+
func WithDefaultEncoding(v string) ParseOption {
19+
return func(opts *parseOptions) {
20+
opts.DefaultEncoding = v
21+
}
22+
}
23+
24+
const is64Bit = uint64(^uintptr(0)) == ^uint64(0)
25+
26+
// ParseObject parses the object and returns the schema or the reference to the schema.
27+
//
28+
// The object can be a struct, pointer to struct, map, slice, pointer to map or slice, or any other type.
29+
// The object can contain fields with `json`, `yaml` or `openapi` tags.
30+
//
31+
// `openapi:"<name>[,ref:<ref> || any other tags]"` tag:
32+
// - <name> is the name of the field in the schema, can be "-" to skip the field or empty to use the name from json, yaml tags or original field name.
33+
// json schema fields:
34+
// - ref:<ref> is a reference to the schema, can not be used with jsonschema fields.
35+
// - required, marks the field as required by adding it to the required list of the parent schema.
36+
// - deprecated, marks the field as deprecated.
37+
// - title:<title>, sets the title of the field or summary for the fereference.
38+
// - summary:<summary>, sets the summary of the reference.
39+
// - description:<description>, sets the description of the field.
40+
// - type:<type> (boolean, integer, number, string, array, object), may be used multiple times.
41+
// The first usage overrides the default type, all other types are added.
42+
// - addtype:<type>, adds additional type, may be used multiple times.
43+
// - format:<format>, sets the format of the type.
44+
//
45+
// The `components` parameter is needed to store the schemas of the structs, and to avoid the circular references.
46+
// In case of the given object is struct, the function will return a reference to the schema stored in the components
47+
// Otherwise, the function will return the schema itself.
48+
func ParseObject(obj any, components *Extendable[Components], opts ...ParseOption) (*SchemaBuilder, error) {
49+
opt := &parseOptions{
50+
DefaultEncoding: Base64Encoding, // default encoding for []byte
51+
}
52+
for _, o := range opts {
53+
o(opt)
54+
}
55+
t := reflect.TypeOf(obj)
56+
if t == nil {
57+
return NewSchemaBuilder().Type(NullType).GoType("nil"), nil
58+
}
59+
return parseObject(joinLoc("", t.String()), t, components, opt)
60+
}
61+
62+
// MapKeyMustBeStringError is an error that is returned when the map key is not a string.
63+
type MapKeyMustBeStringError struct {
64+
Location string
65+
KeyType reflect.Kind
66+
}
67+
68+
func (e *MapKeyMustBeStringError) Error() string {
69+
return fmt.Sprintf("%s: unsupported map key type %s, expected string", e.Location, e.KeyType)
70+
}
71+
72+
func (e *MapKeyMustBeStringError) Is(target error) bool {
73+
if target == nil {
74+
return false
75+
}
76+
_, ok := target.(*MapKeyMustBeStringError)
77+
return ok
78+
}
79+
80+
// NewMapKeyMustBeStringError creates a new MapKeyMustBeStringError with the given location and key type.
81+
func NewMapKeyMustBeStringError(location string, keyType reflect.Kind) *MapKeyMustBeStringError {
82+
return &MapKeyMustBeStringError{
83+
Location: location,
84+
KeyType: keyType,
85+
}
86+
}
87+
88+
func parseObject(location string, t reflect.Type, components *Extendable[Components], opt *parseOptions) (*SchemaBuilder, error) {
89+
if t == nil {
90+
return NewSchemaBuilder().Type(NullType).GoType("nil"), nil
91+
}
92+
kind := t.Kind()
93+
if kind == reflect.Ptr {
94+
builder, err := parseObject(location, t.Elem(), components, opt)
95+
if err != nil {
96+
return nil, err
97+
}
98+
if builder.IsRef() {
99+
builder = NewSchemaBuilder().OneOf(
100+
builder.Build(),
101+
NewSchemaBuilder().Type(NullType).Build(),
102+
)
103+
} else {
104+
builder.AddType(NullType)
105+
}
106+
return builder, nil
107+
}
108+
if kind == reflect.Interface {
109+
return NewSchemaBuilder().GoType("any"), nil
110+
}
111+
obj := reflect.New(t).Elem()
112+
builder := NewSchemaBuilder().GoType(fmt.Sprintf("%T", obj.Interface()))
113+
switch obj.Interface().(type) {
114+
case bool:
115+
builder.Type(BooleanType)
116+
case int, uint:
117+
if is64Bit {
118+
builder.Type(IntegerType).Format(Int64Format)
119+
} else {
120+
builder.Type(IntegerType).Format(Int32Format)
121+
}
122+
case int8, int16, int32, uint8, uint16, uint32:
123+
builder.Type(IntegerType).Format(Int32Format)
124+
case int64, uint64:
125+
builder.Type(IntegerType).Format(Int64Format)
126+
case float32:
127+
builder.Type(NumberType).Format(FloatFormat)
128+
case float64:
129+
builder.Type(NumberType).Format(DoubleFormat)
130+
case string:
131+
builder.Type(StringType)
132+
case []byte:
133+
builder.Type(StringType).ContentEncoding(opt.DefaultEncoding).GoType("[]byte")
134+
case json.Number:
135+
builder.Type(NumberType).GoPackage(t.PkgPath())
136+
case json.RawMessage:
137+
builder.Type(StringType).ContentMediaType("application/json").GoPackage(t.PkgPath())
138+
default:
139+
switch kind {
140+
case reflect.Array, reflect.Slice:
141+
var elemSchema any
142+
elem := t.Elem()
143+
if elem.Kind() == reflect.Interface {
144+
elemSchema = true
145+
} else {
146+
var err error
147+
elemSchema, err = parseObject(location, elem, components, opt)
148+
if err != nil {
149+
return nil, err
150+
}
151+
}
152+
builder.Type(ArrayType).Items(NewBoolOrSchema(elemSchema)).GoType("")
153+
case reflect.Map:
154+
if k := t.Key().Kind(); k != reflect.String {
155+
return nil, NewMapKeyMustBeStringError(location, k)
156+
}
157+
var elemSchema any
158+
elem := t.Elem()
159+
if elem.Kind() == reflect.Interface {
160+
elemSchema = true
161+
} else {
162+
var err error
163+
elemSchema, err = parseObject(location, elem, components, opt)
164+
if err != nil {
165+
return nil, err
166+
}
167+
}
168+
builder.Type(ObjectType).AdditionalProperties(NewBoolOrSchema(elemSchema)).GoType("")
169+
case reflect.Struct:
170+
objName := strings.ReplaceAll(t.PkgPath()+"."+t.Name(), "/", ".")
171+
if components.Spec.Schemas[objName] != nil {
172+
return NewSchemaBuilder().Ref("#/components/schemas/" + objName), nil
173+
}
174+
// add a temporary schema to avoid circular references
175+
if components.Spec.Schemas == nil {
176+
components.Spec.Schemas = make(map[string]*RefOrSpec[Schema], 1)
177+
}
178+
// reserve the name of the schema
179+
const toBeDeleted = "to be deleted"
180+
components.Spec.Schemas[objName] = NewSchemaBuilder().Ref(toBeDeleted).Build()
181+
var allOf []*RefOrSpec[Schema]
182+
for i := range t.NumField() {
183+
field := t.Field(i)
184+
// skip unexported fields
185+
if !field.IsExported() {
186+
continue
187+
}
188+
fieldSchema, err := parseObject(joinLoc(location, field.Name), obj.Field(i).Type(), components, opt)
189+
if err != nil {
190+
// remove the temporary schema
191+
delete(components.Spec.Schemas, objName)
192+
return nil, err
193+
}
194+
if field.Anonymous {
195+
allOf = append(allOf, fieldSchema.Build())
196+
continue
197+
}
198+
name := applyTag(&field, fieldSchema, builder)
199+
// skip the field if it's marked as "-"
200+
if name == "-" {
201+
continue
202+
}
203+
builder.AddProperty(name, fieldSchema.Build())
204+
}
205+
if len(allOf) > 0 {
206+
allOf = append(allOf, builder.Type(ObjectType).GoType("").Build())
207+
builder = NewSchemaBuilder().AllOf(allOf...).GoType(t.String())
208+
} else {
209+
builder.Type(ObjectType)
210+
}
211+
builder.GoPackage(t.PkgPath())
212+
components.Spec.Schemas[objName] = builder.Build()
213+
builder = NewSchemaBuilder().Ref("#/components/schemas/" + objName)
214+
default:
215+
// ignore unsupported types gracefully
216+
}
217+
}
218+
219+
return builder, nil
220+
}
221+
222+
func applyTag(field *reflect.StructField, schema, parent *SchemaBuilder) (name string) {
223+
name = field.Name
224+
225+
for _, tagName := range []string{"json", "yaml"} {
226+
if tag, ok := field.Tag.Lookup(tagName); ok {
227+
parts := strings.SplitN(tag, ",", 2)
228+
if len(parts) > 0 {
229+
part := strings.TrimSpace(parts[0])
230+
if part != "" {
231+
name = part
232+
break
233+
}
234+
}
235+
}
236+
}
237+
238+
tag, ok := field.Tag.Lookup("openapi")
239+
if !ok {
240+
return
241+
}
242+
parts := strings.Split(tag, ",")
243+
if len(parts) == 0 {
244+
return
245+
}
246+
247+
if parts[0] != "" {
248+
name = parts[0]
249+
}
250+
if name == "-" {
251+
return parts[0]
252+
}
253+
parts = parts[1:]
254+
if len(parts) == 0 {
255+
return
256+
}
257+
258+
if strings.HasPrefix(parts[0], "ref:") {
259+
schema.Ref(parts[0][4:])
260+
}
261+
262+
var isTypeOverridden bool
263+
264+
for _, part := range parts {
265+
prefixIndex := strings.Index(part, ":")
266+
var prefix string
267+
if prefixIndex == -1 {
268+
prefix = part
269+
} else {
270+
prefix = part[:prefixIndex]
271+
if prefixIndex == len(part)-1 {
272+
part = ""
273+
}
274+
part = part[prefixIndex+1:]
275+
}
276+
277+
// the tags for the references only
278+
if schema.IsRef() {
279+
switch prefix {
280+
case "required":
281+
parent.AddRequired(name)
282+
case "description":
283+
schema.Description(part)
284+
case "title", "summary":
285+
schema.Title(part)
286+
default:
287+
// ignore unknown or unsupported tag prefixes gracefully
288+
}
289+
continue
290+
}
291+
292+
switch prefix {
293+
case "required":
294+
parent.AddRequired(name)
295+
case "deprecated":
296+
schema.Deprecated(true)
297+
case "title":
298+
schema.Title(part)
299+
case "description":
300+
schema.Description(part)
301+
case "type":
302+
// first type overrides the default type, all other types are added
303+
if !isTypeOverridden {
304+
schema.Type(part)
305+
isTypeOverridden = true
306+
} else {
307+
schema.AddType(part)
308+
}
309+
case "addtype":
310+
schema.AddType(part)
311+
case "format":
312+
schema.Format(part)
313+
default:
314+
// handle unknown or unsupported tag prefixes gracefully
315+
}
316+
}
317+
318+
return
319+
}

0 commit comments

Comments
 (0)