Skip to content

Commit

Permalink
refactor(openapi): 添加 OpenAPISchema 接口
Browse files Browse the repository at this point in the history
  • Loading branch information
caixw committed Dec 3, 2024
1 parent b618b02 commit be8cc8d
Show file tree
Hide file tree
Showing 8 changed files with 150 additions and 62 deletions.
31 changes: 29 additions & 2 deletions openapi/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,34 @@
// Package openapi 采用 [web.Middleware] 中间件的形式生成 [openapi] 文档
//
// 结构体标签
// - [CommentTag] 用于可翻译的注释,该内容会被翻译后保存在字段的 Schema.Description 中;
// - openapi 对 openapi 类型的自定义,格式为 type,format,可以自定义字段的类型和格式;
//
// - comment 用于可翻译的注释,该内容会被翻译后保存在字段的 Schema.Description 中;
// - openapi 对 openapi 类型的自定义,格式为 type,format,可以自定义字段的类型和格式;
// # Schema
//
// 大部分情况下,通过 [NewSchema] 可以将一个类型直接转换为 [Schema] 对象,
// 如果对类型中的字段有特殊要求,可以通过结构体标签 openapi 进行简单的自定义:
//
// type State int8
//
// type x struct {
// State State `openapi:"string"`
// }
//
// 以上代码会将 State 解析为字符串类型,而不是默认的数值。
//
// 当然对于复杂的需求,还可以通过实现 [OpenAPISchema] 接口实现自定义:
//
// type State int8
//
// func(s State) OpenAPISchema(s *Schema) {
// s.Type = TypeString
// s.Enum = []string {"stopped", "running" }
// }
//
// 以上代码,会在解析 State 始终采用 OpenAPISchema 修改。
//
// 结构体标签的优先级要高于 [OpenAPISchema] 接口。
//
// [openapi]: https://spec.openapis.org/oas/v3.1.1.html
package openapi
Expand Down Expand Up @@ -414,6 +439,8 @@ func New(s web.Server, title web.LocaleStringer, o ...Option) *Document {
}

// Disable 是否禁用 [Document.Handler] 接口输出内容
//
// 不影响内容的添加,但是会在调用 [Document.Handler] 时输出 404。
func (d *Document) Disable(disable bool) { d.disable = disable }

// AddWebhook 添加 Webhook 的定义
Expand Down
2 changes: 1 addition & 1 deletion openapi/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func (o *openAPIRenderer) MarshalHTML() (name string, data any) {
func (d *Document) Handler(tag ...string) web.HandlerFunc {
return func(ctx *web.Context) web.Responser {
if d.disable {
return ctx.NotImplemented()
return ctx.NotFound()
}

if m := ctx.Mimetype(false); (m != json.Mimetype && m != yaml.Mimetype && m != html.Mimetype) ||
Expand Down
2 changes: 1 addition & 1 deletion openapi/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func TestDocument_Handler(t *testing.T) {
d.Disable(true)
servertest.Get(a, "http://localhost:8080/p/openapi").Header("accept", json.Mimetype).
Do(nil).
Status(http.StatusNotImplemented)
Status(http.StatusNotFound)

s.Close(500 * time.Millisecond)
cancel()
Expand Down
112 changes: 64 additions & 48 deletions openapi/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ import (
"github.com/issue9/web"
)

// OpenAPISchema 自定义某个类型在 openapi 文档中的类型
type OpenAPISchema interface {
// OpenAPISchema 修改当前类型的 [Schema] 表示形式
OpenAPISchema(s *Schema)
}

var openAPISchemaType = reflect.TypeFor[OpenAPISchema]()

type Parameter struct {
Ref *Ref

Expand Down Expand Up @@ -163,6 +171,14 @@ var timeType = reflect.TypeFor[time.Time]()
// desc 表示类型 t 的 Description 属性
// rootName 根结构体的名称,主要是为了解决子元素又引用了根元素的类型引起的循环引用。
func schemaFromType(d *Document, t reflect.Type, isRoot bool, rootName string, s *Schema) {
if t.Implements(openAPISchemaType) {
if t.Kind() == reflect.Pointer { // 值类型的指针符合 t.Implements,但是无法使用 reflect.New(t).Elem 获得一个有效的值。
t = t.Elem()
}
reflect.New(t).Interface().(OpenAPISchema).OpenAPISchema(s)
return
}

for t.Kind() == reflect.Pointer {
t = t.Elem()
}
Expand Down Expand Up @@ -219,70 +235,70 @@ func schemaFromObjectType(d *Document, t reflect.Type, isRoot bool, rootName str

for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
k := f.Type.Kind()
var itemDesc web.LocaleStringer

if f.Anonymous {
schemaFromType(d, f.Type, isRoot, rootName, s)
continue
}

if f.IsExported() && k != reflect.Chan && k != reflect.Func && k != reflect.Complex64 && k != reflect.Complex128 {
name := f.Name
var xml *XML
if f.Tag != "" {
tag, omitempty, _ := getTagName(f, "json")
if tag == "-" {
continue
} else if tag != "" {
name = tag
}

if !omitempty {
s.Required = append(s.Required, name)
}

if xmlName, _, attr := getTagName(f, "xml"); xmlName != "" && xmlName != name {
xml = &XML{Name: xmlName, Attribute: attr}
}
if k := f.Type.Kind(); !f.IsExported() || k == reflect.Chan || k == reflect.Func || k == reflect.Complex64 || k == reflect.Complex128 {
continue
}

comment := f.Tag.Get(CommentTag)
if comment != "" {
itemDesc = web.Phrase(comment)
}
var itemDesc web.LocaleStringer
name := f.Name
var xml *XML
if f.Tag != "" {
tag, omitempty, _ := getTagName(f, "json")
if tag == "-" {
continue
} else if tag != "" {
name = tag
}

if tt := f.Tag.Get("openapi"); tt != "" {
if tt != "-" {
tags := strings.Split(tt, ",")

format := ""
if len(tags) > 1 {
format = tags[1]
}

s.Properties[name] = &Schema{
Description: itemDesc,
Type: tags[0],
Format: format,
XML: xml,
}
}
continue
}
if !omitempty {
s.Required = append(s.Required, name)
}

if xmlName, _, attr := getTagName(f, "xml"); xmlName != "" && xmlName != name {
xml = &XML{Name: xmlName, Attribute: attr}
}

item := &Schema{
Description: itemDesc,
XML: xml,
comment := f.Tag.Get(CommentTag)
if comment != "" {
itemDesc = web.Phrase(comment)
}
schemaFromType(d, t.Field(i).Type, false, rootName, item)
if item.Type == "" {

if tt := f.Tag.Get("openapi"); tt != "" {
if tt != "-" {
tags := strings.Split(tt, ",")

format := ""
if len(tags) > 1 {
format = tags[1]
}

s.Properties[name] = &Schema{
Description: itemDesc,
Type: tags[0],
Format: format,
XML: xml,
}
}
continue
}
} // end f.Tag

s.Properties[name] = item
item := &Schema{
Description: itemDesc,
XML: xml,
}
schemaFromType(d, t.Field(i).Type, false, rootName, item)
if item.Type == "" {
continue
}

s.Properties[name] = item
}
}

Expand Down
56 changes: 53 additions & 3 deletions openapi/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,40 @@ import (
"github.com/issue9/web"
)

type State int8

func (ss State) OpenAPISchema(s *Schema) {
s.Type = TypeString
s.Enum = []any{"1", "2"}
}

type schemaObject1 struct {
object
Root string
T time.Time
X string `openapi:"-"`
Y string `openapi:"integer"`
Z *object `openapi:"string,date"`

S1 State `openapi:"integer" json:"s1"`
S2 *State `comment:"s2" json:"s2" xml:"S2"`
S3 State

unExported bool
}

type schemaObject2 struct {
schemaObject1
}

type schemaObject3 struct {
X int
}

func (*schemaObject3) OpenAPISchema(s *Schema) {
s.Type = TypeString
}

func TestOfSchema(t *testing.T) {
a := assert.New(t, false)

Expand Down Expand Up @@ -70,12 +91,33 @@ func TestDocument_newSchema(t *testing.T) {
Equal(s.Items.Type, TypeInteger).
Equal(s.Default, []int{5, 6})

a.Nil(d.newSchema(nil))

s = d.newSchema(map[string]float32{"1": 3.2})
a.Equal(s.Type, TypeObject).
Nil(s.Ref).
NotNil(s.AdditionalProperties).
Equal(s.AdditionalProperties.Type, TypeNumber)

// 指针实现 OpenAPISchema
s = d.newSchema(schemaObject3{})
a.Equal(s.Type, TypeObject)
s = d.newSchema(&schemaObject3{})
a.Equal(s.Type, TypeString)
so := &schemaObject3{}
s = d.newSchema(&so) // ** schemaObject{}
a.Equal(s.Type, TypeObject)

// 值类型实现 OpenAPISchema
s = d.newSchema(State(5))
a.Equal(s.Type, TypeString)
sss := State(5)
s = d.newSchema(&sss)
a.Equal(s.Type, TypeString)
ssss := &sss
s = d.newSchema(&ssss) // ** State
a.Equal(s.Type, TypeInteger)

s = d.newSchema(&object{})
a.Equal(s.Type, TypeObject).
NotZero(s.Ref.Ref).
Expand All @@ -90,19 +132,27 @@ func TestDocument_newSchema(t *testing.T) {
a.Equal(s.Type, TypeObject).
NotZero(s.Ref.Ref).
Nil(s.Default).
Length(s.Properties, 7).
Length(s.Properties, 10).
Equal(s.Properties["id"].Type, TypeInteger).
Equal(s.Properties["Root"].Type, TypeString).
Equal(s.Properties["T"].Type, TypeString).
Equal(s.Properties["T"].Format, FormatDateTime).
Equal(s.Properties["Y"].Type, TypeInteger).
Equal(s.Properties["Z"].Type, TypeString).
Equal(s.Properties["Z"].Format, FormatDate)
Equal(s.Properties["Z"].Format, FormatDate).
Equal(s.Properties["s1"].Type, TypeInteger). // openapi 标签优先于 OpenAPISchema
Equal(s.Properties["s2"].Type, TypeString). // OpenAPISchema 接口
Equal(s.Properties["s2"].XML.Name, "S2").
Equal(s.Properties["s2"].Enum, []any{"1", "2"}).
Equal(s.Properties["s2"].Description, web.Phrase("s2")). // 注释可正确获取
Equal(s.Properties["S3"].Type, TypeString).
Nil(s.Properties["S3"].XML).
Equal(s.Properties["S3"].Enum, []any{"1", "2"})

s = d.newSchema(schemaObject2{})
a.Equal(s.Type, TypeObject).
NotZero(s.Ref.Ref).
Length(s.Properties, 7).
Length(s.Properties, 10).
Equal(s.Properties["id"].Type, TypeInteger).
Equal(s.Properties["Root"].Type, TypeString).
Equal(s.Properties["T"].Type, TypeString).
Expand Down
2 changes: 1 addition & 1 deletion server.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,8 +327,8 @@ func (s *InternalServer) Close() {
}
}

s.doneErr = http.ErrServerClosed // 在 close 之前调用,以保证在 close 之后,doneErr 始终是正确的。
close(s.done)
s.doneErr = http.ErrServerClosed
}

func (s *InternalServer) Deadline() (time.Time, bool) { return time.Time{}, false }
Expand Down
5 changes: 0 additions & 5 deletions service.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,6 @@ type (
ServiceFunc func(context.Context) error

// State 服务状态
//
// 以下设置用于 restdoc
//
// @type string
// @enum stopped running failed
State = scheduled.State

Job = scheduled.Job
Expand Down
2 changes: 1 addition & 1 deletion web.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import (
)

// Version 当前框架的版本
const Version = "0.99.0"
const Version = "0.100.0"

type (
Logger = logs.Logger
Expand Down

0 comments on commit be8cc8d

Please sign in to comment.