Skip to content

Commit

Permalink
parse objects
Browse files Browse the repository at this point in the history
  • Loading branch information
SVilgelm committed Dec 27, 2024
1 parent 0706e46 commit 54b5933
Show file tree
Hide file tree
Showing 7 changed files with 1,043 additions and 41 deletions.
2 changes: 2 additions & 0 deletions bool_or_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ func NewBoolOrSchema(v any) *BoolOrSchema {
return &BoolOrSchema{Allowed: v}
case *RefOrSpec[Schema]:
return &BoolOrSchema{Schema: v}
case *SchemaBulder:
return &BoolOrSchema{Schema: v.Build()}
default:
return nil
}
Expand Down
84 changes: 54 additions & 30 deletions components.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package openapi

import (
"regexp"
)

// Components holds a set of reusable objects for different aspects of the OAS.
// All objects defined within the components object will have no effect on the API unless they are explicitly referenced
// from properties outside the components object.
Expand Down Expand Up @@ -160,57 +164,77 @@ func (o *Components) Add(name string, v any) *Components {
return o
}

var namePattern = regexp.MustCompile(`^[a-zA-Z0-9\.\-_]+$`)

func (o *Components) validateSpec(location string, validator *Validator) []*validationError {
var errs []*validationError
if o.Schemas != nil {
for k, v := range o.Schemas {
errs = append(errs, v.validateSpec(joinLoc(location, "schemas", k), validator)...)
for k, v := range o.Schemas {
if !namePattern.MatchString(k) {
errs = append(errs, newValidationError(joinLoc(location, "schemas", k), "invalid name %q, must match %q", k, namePattern.String()))

Check warning on line 173 in components.go

View check run for this annotation

Codecov / codecov/patch

components.go#L173

Added line #L173 was not covered by tests
}
errs = append(errs, v.validateSpec(joinLoc(location, "schemas", k), validator)...)
}
if o.Responses != nil {
for k, v := range o.Responses {
errs = append(errs, v.validateSpec(joinLoc(location, "responses", k), validator)...)

for k, v := range o.Responses {
if !namePattern.MatchString(k) {
errs = append(errs, newValidationError(joinLoc(location, "schemas", k), "invalid name %q, must match %q", k, namePattern.String()))

Check warning on line 180 in components.go

View check run for this annotation

Codecov / codecov/patch

components.go#L179-L180

Added lines #L179 - L180 were not covered by tests
}
errs = append(errs, v.validateSpec(joinLoc(location, "responses", k), validator)...)

Check warning on line 182 in components.go

View check run for this annotation

Codecov / codecov/patch

components.go#L182

Added line #L182 was not covered by tests
}
if o.Parameters != nil {
for k, v := range o.Parameters {
errs = append(errs, v.validateSpec(joinLoc(location, "parameters", k), validator)...)
for k, v := range o.Parameters {
if !namePattern.MatchString(k) {
errs = append(errs, newValidationError(joinLoc(location, "schemas", k), "invalid name %q, must match %q", k, namePattern.String()))

Check warning on line 186 in components.go

View check run for this annotation

Codecov / codecov/patch

components.go#L185-L186

Added lines #L185 - L186 were not covered by tests
}
errs = append(errs, v.validateSpec(joinLoc(location, "parameters", k), validator)...)

Check warning on line 188 in components.go

View check run for this annotation

Codecov / codecov/patch

components.go#L188

Added line #L188 was not covered by tests
}
if o.Examples != nil {
for k, v := range o.Examples {
errs = append(errs, v.validateSpec(joinLoc(location, "examples", k), validator)...)

for k, v := range o.Examples {
if !namePattern.MatchString(k) {
errs = append(errs, newValidationError(joinLoc(location, "schemas", k), "invalid name %q, must match %q", k, namePattern.String()))

Check warning on line 193 in components.go

View check run for this annotation

Codecov / codecov/patch

components.go#L192-L193

Added lines #L192 - L193 were not covered by tests
}
errs = append(errs, v.validateSpec(joinLoc(location, "examples", k), validator)...)

Check warning on line 195 in components.go

View check run for this annotation

Codecov / codecov/patch

components.go#L195

Added line #L195 was not covered by tests
}
if o.RequestBodies != nil {
for k, v := range o.RequestBodies {
errs = append(errs, v.validateSpec(joinLoc(location, "requestBodies", k), validator)...)

for k, v := range o.RequestBodies {
if !namePattern.MatchString(k) {
errs = append(errs, newValidationError(joinLoc(location, "schemas", k), "invalid name %q, must match %q", k, namePattern.String()))

Check warning on line 200 in components.go

View check run for this annotation

Codecov / codecov/patch

components.go#L199-L200

Added lines #L199 - L200 were not covered by tests
}
errs = append(errs, v.validateSpec(joinLoc(location, "requestBodies", k), validator)...)

Check warning on line 202 in components.go

View check run for this annotation

Codecov / codecov/patch

components.go#L202

Added line #L202 was not covered by tests
}
if o.Headers != nil {
for k, v := range o.Headers {
errs = append(errs, v.validateSpec(joinLoc(location, "headers", k), validator)...)

for k, v := range o.Headers {
if !namePattern.MatchString(k) {
errs = append(errs, newValidationError(joinLoc(location, "schemas", k), "invalid name %q, must match %q", k, namePattern.String()))

Check warning on line 207 in components.go

View check run for this annotation

Codecov / codecov/patch

components.go#L206-L207

Added lines #L206 - L207 were not covered by tests
}
errs = append(errs, v.validateSpec(joinLoc(location, "headers", k), validator)...)

Check warning on line 209 in components.go

View check run for this annotation

Codecov / codecov/patch

components.go#L209

Added line #L209 was not covered by tests
}
if o.SecuritySchemes != nil {
for k, v := range o.SecuritySchemes {
errs = append(errs, v.validateSpec(joinLoc(location, "securitySchemes", k), validator)...)

for k, v := range o.SecuritySchemes {
if !namePattern.MatchString(k) {
errs = append(errs, newValidationError(joinLoc(location, "schemas", k), "invalid name %q, must match %q", k, namePattern.String()))

Check warning on line 214 in components.go

View check run for this annotation

Codecov / codecov/patch

components.go#L214

Added line #L214 was not covered by tests
}
errs = append(errs, v.validateSpec(joinLoc(location, "securitySchemes", k), validator)...)
}
if o.Links != nil {
for k, v := range o.Links {
errs = append(errs, v.validateSpec(joinLoc(location, "links", k), validator)...)

for k, v := range o.Links {
if !namePattern.MatchString(k) {
errs = append(errs, newValidationError(joinLoc(location, "schemas", k), "invalid name %q, must match %q", k, namePattern.String()))

Check warning on line 221 in components.go

View check run for this annotation

Codecov / codecov/patch

components.go#L221

Added line #L221 was not covered by tests
}
errs = append(errs, v.validateSpec(joinLoc(location, "links", k), validator)...)
}
if o.Callbacks != nil {
for k, v := range o.Callbacks {
errs = append(errs, v.validateSpec(joinLoc(location, "callbacks", k), validator)...)

for k, v := range o.Callbacks {
if !namePattern.MatchString(k) {
errs = append(errs, newValidationError(joinLoc(location, "schemas", k), "invalid name %q, must match %q", k, namePattern.String()))

Check warning on line 228 in components.go

View check run for this annotation

Codecov / codecov/patch

components.go#L227-L228

Added lines #L227 - L228 were not covered by tests
}
errs = append(errs, v.validateSpec(joinLoc(location, "callbacks", k), validator)...)

Check warning on line 230 in components.go

View check run for this annotation

Codecov / codecov/patch

components.go#L230

Added line #L230 was not covered by tests
}
if o.Paths != nil {
for k, v := range o.Paths {
errs = append(errs, v.validateSpec(joinLoc(location, "paths", k), validator)...)

for k, v := range o.Paths {
if !namePattern.MatchString(k) {
errs = append(errs, newValidationError(joinLoc(location, "schemas", k), "invalid name %q, must match %q", k, namePattern.String()))

Check warning on line 235 in components.go

View check run for this annotation

Codecov / codecov/patch

components.go#L234-L235

Added lines #L234 - L235 were not covered by tests
}
errs = append(errs, v.validateSpec(joinLoc(location, "paths", k), validator)...)

Check warning on line 237 in components.go

View check run for this annotation

Codecov / codecov/patch

components.go#L237

Added line #L237 was not covered by tests
}

return errs
Expand Down
268 changes: 268 additions & 0 deletions parser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
package openapi

import (
"encoding/json"
"fmt"
"reflect"
"strings"
)

const is64Bit = uint64(^uintptr(0)) == ^uint64(0)

// ParseObject parses the object and returns the schema or the reference to the schema.
//
// The object can be a struct, pointer to struct, map, slice, pointer to map or slice, or any other type.
// The object can contain fields with `json`, `yaml` or `openapi` tags.
//
// `opanapi:"<name>[,schema:<ref> || named fields]"` tag
// - <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.
// - ref:<ref> is a reference to the schema, can not be used with jsonschema fields
// jsonschema fields:
// - required
// - deprecated
// - title:<title>
// - description:<description>
// - type:<type> (boolean, integer, number, string, array, object), may be used multiple times.
// The first type overrides the default type, all other types are added.
// - addtype:<type>, adds additional type, may be used multiple times.
// - format:<format>
//
// The components is needed to store the schemas of the structs, and to avoid the circular references.
// In case of the given object is struct, the function will return a reference to the schema.
// Otherwise, the function will return the schema itself.
func ParseObject(obj any, components *Extendable[Components]) (*SchemaBulder, error) {
t := reflect.TypeOf(obj)
if t == nil {
return NewSchemaBuilder().Type(NullType).GoType("nil"), nil
}
value := reflect.ValueOf(obj)
return parseObject(joinLoc("", t.String()), value, components)
}

func parseObject(location string, obj reflect.Value, components *Extendable[Components]) (*SchemaBulder, error) {
t := obj.Type()
if t == nil {
return NewSchemaBuilder().Type(NullType).GoType("nil"), nil
}

Check warning on line 46 in parser.go

View check run for this annotation

Codecov / codecov/patch

parser.go#L45-L46

Added lines #L45 - L46 were not covered by tests
kind := t.Kind()
if kind == reflect.Ptr {
builder, err := parseObject(location, obj.Elem(), components)
if err != nil {
return nil, err
}

Check warning on line 52 in parser.go

View check run for this annotation

Codecov / codecov/patch

parser.go#L51-L52

Added lines #L51 - L52 were not covered by tests
if builder.IsRef() {
builder = NewSchemaBuilder().OneOf(
builder.Build(),
NewSchemaBuilder().Type(NullType).Build(),
)
} else {
builder.AddType(NullType)
}
return builder, nil
}
if kind == reflect.Interface {
return NewSchemaBuilder().GoType("any"), nil
}
builder := NewSchemaBuilder().GoType(fmt.Sprintf("%T", obj.Interface()))
switch obj.Interface().(type) {
case bool:
builder.Type(BooleanType)
case int, uint:
if is64Bit {
builder.Type(IntegerType).Format(Int64Format)
} else {
builder.Type(IntegerType).Format(Int32Format)
}

Check warning on line 75 in parser.go

View check run for this annotation

Codecov / codecov/patch

parser.go#L74-L75

Added lines #L74 - L75 were not covered by tests
case int8, int16, int32, uint8, uint16, uint32:
builder.Type(IntegerType).Format(Int32Format)
case int64, uint64:
builder.Type(IntegerType).Format(Int64Format)
case float32:
builder.Type(NumberType).Format(FloatFormat)
case float64:
builder.Type(NumberType).Format(DoubleFormat)
case string:
builder.Type(StringType)
case []byte:
builder.Type(StringType).ContentEncoding(Base64Encoding).GoType("[]byte") // TODO: create an option for default ContentEncoding
case json.Number:
builder.Type(NumberType).GoPackage(t.PkgPath())
case json.RawMessage:
builder.Type(StringType).ContentMediaType("application/json").GoPackage(t.PkgPath())
default:
switch kind {
case reflect.Array, reflect.Slice:
var elemSchema any
if t.Elem().Kind() == reflect.Interface {
elemSchema = true
} else {
var (
err error
newElem reflect.Value
)
if t.Elem().Kind() == reflect.Ptr {
newElem = reflect.New(t.Elem())

Check warning on line 104 in parser.go

View check run for this annotation

Codecov / codecov/patch

parser.go#L104

Added line #L104 was not covered by tests
} else {
newElem = reflect.New(t.Elem()).Elem()
}
elemSchema, err = parseObject(location, newElem, components)
if err != nil {
return nil, err
}

Check warning on line 111 in parser.go

View check run for this annotation

Codecov / codecov/patch

parser.go#L110-L111

Added lines #L110 - L111 were not covered by tests
}
builder.Type(ArrayType).Items(NewBoolOrSchema(elemSchema)).GoType("")
case reflect.Map:
if k := t.Key().Kind(); k != reflect.String {
return nil, fmt.Errorf("%s: unsupported map key type %s, expected string", location, k)
}

Check warning on line 117 in parser.go

View check run for this annotation

Codecov / codecov/patch

parser.go#L116-L117

Added lines #L116 - L117 were not covered by tests
var elemSchema any
if t.Elem().Kind() == reflect.Interface {
elemSchema = true
} else {
var (
err error
newElem reflect.Value
)
if t.Elem().Kind() == reflect.Ptr {
newElem = reflect.New(t.Elem().Elem())
} else {
newElem = reflect.New(t.Elem()).Elem()
}
elemSchema, err = parseObject(location, newElem, components)
if err != nil {
return nil, err
}

Check warning on line 134 in parser.go

View check run for this annotation

Codecov / codecov/patch

parser.go#L133-L134

Added lines #L133 - L134 were not covered by tests
}
builder.Type(ObjectType).AdditionalProperties(NewBoolOrSchema(elemSchema)).GoType("")
case reflect.Struct:
objName := strings.ReplaceAll(t.PkgPath()+"."+t.Name(), "/", ".")
if components.Spec.Schemas[objName] != nil {
return NewSchemaBuilder().Ref("#/components/schemas/" + objName), nil
}
// add a temporary schema to avoid circular references
if components.Spec.Schemas == nil {
components.Spec.Schemas = make(map[string]*RefOrSpec[Schema], 1)
}
// reserve the name of the schema
components.Spec.Schemas[objName] = NewSchemaBuilder().Ref("to be deleted").Build()
var allOf []*RefOrSpec[Schema]
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
// skip unexported fields
if !field.IsExported() {
continue
}
fieldSchema, err := parseObject(joinLoc(location, field.Name), obj.Field(i), components)
if err != nil {
// remove the temporary schema
delete(components.Spec.Schemas, objName)
return nil, err
}

Check warning on line 160 in parser.go

View check run for this annotation

Codecov / codecov/patch

parser.go#L157-L160

Added lines #L157 - L160 were not covered by tests
if field.Anonymous {
allOf = append(allOf, fieldSchema.Build())
continue
}
name := applyTag(field, fieldSchema, builder)
// skip the field if it's marked as "-"
if name == "-" {
continue
}
builder.AddProperty(name, fieldSchema.Build())
}
if len(allOf) > 0 {
allOf = append(allOf, builder.Type(ObjectType).GoType("").Build())
builder = NewSchemaBuilder().AllOf(allOf...).GoType(t.String())
} else {
builder.Type(ObjectType)
}
builder.GoPackage(t.PkgPath())
components.Spec.Schemas[objName] = builder.Build()
builder = NewSchemaBuilder().Ref("#/components/schemas/" + objName)
}
}

return builder, nil
}

func applyTag(field reflect.StructField, schema *SchemaBulder, parent *SchemaBulder) (name string) {
name = field.Name

for _, tagName := range []string{"json", "yaml"} {
if tag, ok := field.Tag.Lookup(tagName); ok {
parts := strings.SplitN(tag, ",", 2)
if len(parts) > 0 {
part := strings.TrimSpace(parts[0])
if part != "" {
name = part
break
}
}
}
}

tag, ok := field.Tag.Lookup("openapi")
if !ok {
return
}
parts := strings.Split(tag, ",")
if len(parts) == 0 {
return
}

Check warning on line 210 in parser.go

View check run for this annotation

Codecov / codecov/patch

parser.go#L209-L210

Added lines #L209 - L210 were not covered by tests

if parts[0] != "" {
name = parts[0]
}
if name == "-" {
return parts[0]
}
parts = parts[1:]
if len(parts) == 0 {
return
}

if strings.HasPrefix("ref:", parts[0]) {
schema.Ref(parts[0][4:])
return
}

Check warning on line 226 in parser.go

View check run for this annotation

Codecov / codecov/patch

parser.go#L224-L226

Added lines #L224 - L226 were not covered by tests

var isTypeOverriden bool

for _, part := range parts {
prefixIndex := strings.Index(part, ":")
var prefix string
if prefixIndex == -1 {
prefix = part
} else {
prefix = part[:prefixIndex]
if prefixIndex == len(part)-1 {
part = ""
}

Check warning on line 239 in parser.go

View check run for this annotation

Codecov / codecov/patch

parser.go#L238-L239

Added lines #L238 - L239 were not covered by tests
part = part[prefixIndex+1:]
}
switch prefix {
case "required":
parent.AddRequired(name)
case "deprecated":
schema.Deprecated(true)
case "title":
schema.Title(part)
case "description":
schema.Description(part)
case "type":
// first type overrides the default type
// all other types are added
if !isTypeOverriden {
schema.Type(part)
isTypeOverriden = true
} else {
schema.AddType(part)
}

Check warning on line 259 in parser.go

View check run for this annotation

Codecov / codecov/patch

parser.go#L249-L259

Added lines #L249 - L259 were not covered by tests
case "addtype":
schema.AddType(part)
case "format":
schema.Format(part)
}
}

return
}
Loading

0 comments on commit 54b5933

Please sign in to comment.