Skip to content

Commit 9813279

Browse files
committed
feat(parser): add ParseObject to generate OpenAPI schemas
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 9813279

File tree

4 files changed

+784
-0
lines changed

4 files changed

+784
-0
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

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

0 commit comments

Comments
 (0)