mirror of
https://github.com/go-gitea/gitea.git
synced 2026-06-10 05:20:28 +00:00
Serve OpenAPI 3.0 spec at /openapi.v1.json (#37038)
Add a build-time conversion step that transforms the existing Swagger 2.0 spec into an OpenAPI 3.0 spec. The OAS3 spec is served alongside the existing Swagger 2.0 spec, enabling API clients that require OAS3 to generate code directly from Gitea's API. This is not to be an answer to how gitea handles OAS3 long term, but a way to use what we have to move a step forward. --------- Signed-off-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
97
build/generate-openapi.go
Normal file
97
build/generate-openapi.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// generate-openapi converts Gitea's Swagger 2.0 spec into an OpenAPI 3.0 spec.
|
||||
//
|
||||
// Gitea generates a Swagger 2.0 spec from code annotations (make generate-swagger).
|
||||
// This tool converts it to OAS3 so that SDK generators and tools that require
|
||||
// OAS3 (e.g. progenitor for Rust) can consume it directly. The conversion also
|
||||
// deduplicates inline enum definitions into named schema components, producing
|
||||
// cleaner SDK output with proper enum types instead of anonymous strings.
|
||||
//
|
||||
// Run: go run build/generate-openapi.go
|
||||
// Output: templates/swagger/v1_openapi3_json.tmpl
|
||||
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/build/openapi3gen"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
)
|
||||
|
||||
const (
|
||||
swaggerSpecPath = "templates/swagger/v1_json.tmpl"
|
||||
openapi3OutPath = "templates/swagger/v1_openapi3_json.tmpl"
|
||||
|
||||
appSubUrlVar = "{{.SwaggerAppSubUrl}}"
|
||||
appVerVar = "{{.SwaggerAppVer}}"
|
||||
|
||||
appSubUrlPlaceholder = "GITEA_APP_SUB_URL_PLACEHOLDER"
|
||||
appVerPlaceholder = "0.0.0-gitea-placeholder"
|
||||
)
|
||||
|
||||
var (
|
||||
appSubUrlRe = regexp.MustCompile(regexp.QuoteMeta(appSubUrlVar))
|
||||
appVerRe = regexp.MustCompile(regexp.QuoteMeta(appVerVar))
|
||||
|
||||
enumScanDirs = []string{
|
||||
"modules/structs",
|
||||
"modules/commitstatus",
|
||||
}
|
||||
)
|
||||
|
||||
func main() {
|
||||
astEnumMap, err := openapi3gen.ScanSwaggerEnumTypes(enumScanDirs)
|
||||
if err != nil {
|
||||
log.Fatalf("scanning swagger:enum annotations: %v", err)
|
||||
}
|
||||
names := make([]string, 0, len(astEnumMap))
|
||||
for _, n := range astEnumMap {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
fmt.Fprintf(os.Stderr, "discovered %d swagger:enum types: %s\n", len(names), strings.Join(names, ", "))
|
||||
|
||||
data, err := os.ReadFile(swaggerSpecPath)
|
||||
if err != nil {
|
||||
log.Fatalf("reading swagger spec: %v", err)
|
||||
}
|
||||
|
||||
cleaned := appSubUrlRe.ReplaceAll(data, []byte(appSubUrlPlaceholder))
|
||||
cleaned = appVerRe.ReplaceAll(cleaned, []byte(appVerPlaceholder))
|
||||
|
||||
oas3, err := openapi3gen.Convert(cleaned, astEnumMap)
|
||||
if err != nil {
|
||||
log.Fatalf("converting to openapi 3.0: %v", err)
|
||||
}
|
||||
|
||||
oas3.Servers = openapi3.Servers{
|
||||
{URL: appSubUrlPlaceholder + "/api/v1"},
|
||||
}
|
||||
|
||||
out, err := json.MarshalIndent(oas3, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("marshaling openapi 3.0: %v", err)
|
||||
}
|
||||
|
||||
result := strings.ReplaceAll(string(out), appSubUrlPlaceholder, appSubUrlVar)
|
||||
result = strings.ReplaceAll(result, appVerPlaceholder, appVerVar)
|
||||
result = strings.TrimSpace(result)
|
||||
|
||||
if err := os.WriteFile(openapi3OutPath, []byte(result), 0o644); err != nil {
|
||||
log.Fatalf("writing openapi 3.0 spec: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Generated %s\n", openapi3OutPath)
|
||||
}
|
||||
281
build/openapi3gen/convert.go
Normal file
281
build/openapi3gen/convert.go
Normal file
@@ -0,0 +1,281 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package openapi3gen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi2"
|
||||
"github.com/getkin/kin-openapi/openapi2conv"
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
)
|
||||
|
||||
// rxDeprecated matches "deprecated" as a word at the start of a description
|
||||
// or preceded by whitespace/punctuation that indicates a leading marker (e.g.
|
||||
// "Deprecated: true", "deprecated (use X instead)"). Rejects negated phrases
|
||||
// like "not deprecated" or "previously deprecated, now supported".
|
||||
var rxDeprecated = regexp.MustCompile(`(?i)(?:^|[\n.;])\s*deprecated\b`)
|
||||
|
||||
// Convert parses a Swagger 2.0 spec and returns an OAS3 spec, applying
|
||||
// Gitea-specific post-processing: file-schema fixups, URI formats,
|
||||
// deprecated flags, and shared-enum extraction.
|
||||
//
|
||||
// astEnumMap is a value-set-key → Go-type-name map (built by
|
||||
// ScanSwaggerEnumTypes). If a shared enum in the spec has no entry in the
|
||||
// map, Convert returns an error — no fallback naming.
|
||||
func Convert(swaggerJSON []byte, astEnumMap map[string]string) (*openapi3.T, error) {
|
||||
var swagger2 openapi2.T
|
||||
if err := json.Unmarshal(swaggerJSON, &swagger2); err != nil {
|
||||
return nil, fmt.Errorf("parsing swagger 2.0: %w", err)
|
||||
}
|
||||
|
||||
oas3, err := openapi2conv.ToV3(&swagger2)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting to openapi 3.0: %w", err)
|
||||
}
|
||||
|
||||
fixFileSchemas(oas3)
|
||||
addURIFormats(oas3)
|
||||
addDeprecatedFlags(oas3)
|
||||
if err := extractSharedEnums(oas3, astEnumMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return oas3, nil
|
||||
}
|
||||
|
||||
func fixFileSchemas(doc *openapi3.T) {
|
||||
for _, pathItem := range doc.Paths.Map() {
|
||||
for _, op := range []*openapi3.Operation{
|
||||
pathItem.Get, pathItem.Post, pathItem.Put, pathItem.Patch,
|
||||
pathItem.Delete, pathItem.Head, pathItem.Options, pathItem.Trace,
|
||||
} {
|
||||
if op == nil {
|
||||
continue
|
||||
}
|
||||
for _, resp := range op.Responses.Map() {
|
||||
if resp.Value == nil {
|
||||
continue
|
||||
}
|
||||
for _, mediaType := range resp.Value.Content {
|
||||
fixSchema(mediaType.Schema)
|
||||
}
|
||||
}
|
||||
if op.RequestBody != nil && op.RequestBody.Value != nil {
|
||||
for _, mediaType := range op.RequestBody.Value.Content {
|
||||
fixSchema(mediaType.Schema)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fixSchema rewrites any "type: file" schemas to the OAS3 equivalent
|
||||
// (type: string, format: binary), recursing into Properties, Items, and
|
||||
// AllOf/OneOf/AnyOf/Not branches. $ref nodes are skipped so shared schemas
|
||||
// are rewritten exactly once when visited through their declaration.
|
||||
func fixSchema(ref *openapi3.SchemaRef) {
|
||||
if ref == nil || ref.Value == nil || ref.Ref != "" {
|
||||
return
|
||||
}
|
||||
s := ref.Value
|
||||
if s.Type.Is("file") {
|
||||
s.Type = &openapi3.Types{"string"}
|
||||
s.Format = "binary"
|
||||
}
|
||||
for _, p := range s.Properties {
|
||||
fixSchema(p)
|
||||
}
|
||||
fixSchema(s.Items)
|
||||
for _, sub := range s.AllOf {
|
||||
fixSchema(sub)
|
||||
}
|
||||
for _, sub := range s.OneOf {
|
||||
fixSchema(sub)
|
||||
}
|
||||
for _, sub := range s.AnyOf {
|
||||
fixSchema(sub)
|
||||
}
|
||||
fixSchema(s.Not)
|
||||
}
|
||||
|
||||
// addURIFormats sets format: uri on string properties whose names indicate
|
||||
// they hold URLs. This information is lost in Swagger 2.0 but is valuable
|
||||
// for code generators.
|
||||
func addURIFormats(doc *openapi3.T) {
|
||||
if doc.Components == nil {
|
||||
return
|
||||
}
|
||||
for _, schemaRef := range doc.Components.Schemas {
|
||||
if schemaRef.Value == nil {
|
||||
continue
|
||||
}
|
||||
for propName, propRef := range schemaRef.Value.Properties {
|
||||
if propRef == nil || propRef.Value == nil || propRef.Ref != "" {
|
||||
continue
|
||||
}
|
||||
prop := propRef.Value
|
||||
if !prop.Type.Is("string") || prop.Format != "" {
|
||||
continue
|
||||
}
|
||||
if isURLProperty(propName) {
|
||||
prop.Format = "uri"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isURLProperty(name string) bool {
|
||||
if strings.HasSuffix(name, "_url") {
|
||||
return true
|
||||
}
|
||||
switch name {
|
||||
case "url", "html_url", "clone_url":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// addDeprecatedFlags sets deprecated: true on schema properties whose
|
||||
// description starts with a "deprecated" marker (e.g. "Deprecated: true"
|
||||
// or "deprecated (use X instead)"). Does not match negated phrases.
|
||||
func addDeprecatedFlags(doc *openapi3.T) {
|
||||
if doc.Components == nil {
|
||||
return
|
||||
}
|
||||
for _, schemaRef := range doc.Components.Schemas {
|
||||
if schemaRef.Value == nil {
|
||||
continue
|
||||
}
|
||||
for _, propRef := range schemaRef.Value.Properties {
|
||||
if propRef == nil || propRef.Value == nil || propRef.Ref != "" {
|
||||
continue
|
||||
}
|
||||
if rxDeprecated.MatchString(propRef.Value.Description) {
|
||||
propRef.Value.Deprecated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type enumUsage struct {
|
||||
schemaName string
|
||||
propName string
|
||||
propRef *openapi3.SchemaRef
|
||||
inItems bool
|
||||
}
|
||||
|
||||
// extractSharedEnums finds identical enum arrays used by multiple schema
|
||||
// properties, creates a standalone named schema for each, and replaces
|
||||
// the inline enums with $ref pointers.
|
||||
//
|
||||
// If the derived enum name collides with an existing component schema, or
|
||||
// no // swagger:enum annotation matches the value set, generation aborts
|
||||
// with an actionable error — there are no silent fallbacks.
|
||||
func extractSharedEnums(doc *openapi3.T, astEnumMap map[string]string) error {
|
||||
if doc.Components == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
enumGroups := map[string][]enumUsage{}
|
||||
|
||||
for schemaName, schemaRef := range doc.Components.Schemas {
|
||||
if schemaRef.Value == nil {
|
||||
continue
|
||||
}
|
||||
for propName, propRef := range schemaRef.Value.Properties {
|
||||
if propRef == nil || propRef.Value == nil || propRef.Ref != "" {
|
||||
continue
|
||||
}
|
||||
if len(propRef.Value.Enum) > 1 && propRef.Value.Type.Is("string") {
|
||||
key := EnumKey(propRef.Value.Enum)
|
||||
enumGroups[key] = append(enumGroups[key], enumUsage{schemaName, propName, propRef, false})
|
||||
}
|
||||
if propRef.Value.Type.Is("array") && propRef.Value.Items != nil &&
|
||||
propRef.Value.Items.Value != nil && propRef.Value.Items.Ref == "" &&
|
||||
len(propRef.Value.Items.Value.Enum) > 1 && propRef.Value.Items.Value.Type.Is("string") {
|
||||
key := EnumKey(propRef.Value.Items.Value.Enum)
|
||||
enumGroups[key] = append(enumGroups[key], enumUsage{schemaName, propName, propRef, true})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key, usages := range enumGroups {
|
||||
if len(usages) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
enumName, err := deriveEnumName(key, usages, astEnumMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := doc.Components.Schemas[enumName]; exists {
|
||||
return fmt.Errorf("enum name collision: %s already exists as a component schema", enumName)
|
||||
}
|
||||
|
||||
var enumValues []any
|
||||
if usages[0].inItems {
|
||||
enumValues = usages[0].propRef.Value.Items.Value.Enum
|
||||
} else {
|
||||
enumValues = usages[0].propRef.Value.Enum
|
||||
}
|
||||
|
||||
doc.Components.Schemas[enumName] = &openapi3.SchemaRef{
|
||||
Value: &openapi3.Schema{
|
||||
Type: &openapi3.Types{"string"},
|
||||
Enum: enumValues,
|
||||
},
|
||||
}
|
||||
|
||||
ref := "#/components/schemas/" + enumName
|
||||
|
||||
for _, usage := range usages {
|
||||
if usage.inItems {
|
||||
usage.propRef.Value.Items = &openapi3.SchemaRef{Ref: ref}
|
||||
} else {
|
||||
old := usage.propRef.Value
|
||||
if old.Description == "" && !old.Deprecated && old.Format == "" {
|
||||
usage.propRef.Ref = ref
|
||||
usage.propRef.Value = nil
|
||||
} else {
|
||||
usage.propRef.Value = &openapi3.Schema{
|
||||
AllOf: openapi3.SchemaRefs{
|
||||
{Ref: ref},
|
||||
},
|
||||
Description: old.Description,
|
||||
Deprecated: old.Deprecated,
|
||||
Format: old.Format,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deriveEnumName looks up a shared enum's Go type name from astEnumMap by
|
||||
// value-set key. If no annotation matches, returns an error identifying the
|
||||
// offending properties and the fix.
|
||||
func deriveEnumName(key string, usages []enumUsage, astEnumMap map[string]string) (string, error) {
|
||||
if name, ok := astEnumMap[key]; ok {
|
||||
return name, nil
|
||||
}
|
||||
|
||||
props := map[string]bool{}
|
||||
for _, u := range usages {
|
||||
props[fmt.Sprintf("%s.%s", u.schemaName, u.propName)] = true
|
||||
}
|
||||
propList := make([]string, 0, len(props))
|
||||
for p := range props {
|
||||
propList = append(propList, p)
|
||||
}
|
||||
return "", fmt.Errorf(
|
||||
"no swagger:enum annotation matches value-set %q used by %d properties: %v; "+
|
||||
"fix by adding a named string type with // swagger:enum to modules/structs or modules/commitstatus",
|
||||
key, len(usages), propList,
|
||||
)
|
||||
}
|
||||
170
build/openapi3gen/convert_test.go
Normal file
170
build/openapi3gen/convert_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package openapi3gen
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
)
|
||||
|
||||
func TestDeriveEnumName_hit(t *testing.T) {
|
||||
key := EnumKey([]any{"red", "green", "blue"})
|
||||
astMap := map[string]string{key: "Color"}
|
||||
usages := []enumUsage{{schemaName: "Paint", propName: "color"}}
|
||||
got, err := deriveEnumName(key, usages, astMap)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "Color" {
|
||||
t.Fatalf("got %q, want %q", got, "Color")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveEnumName_miss(t *testing.T) {
|
||||
key := EnumKey([]any{"x", "y"})
|
||||
usages := []enumUsage{{schemaName: "Thing", propName: "kind"}}
|
||||
_, err := deriveEnumName(key, usages, map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected miss error, got nil")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "Thing.kind") {
|
||||
t.Fatalf("error %q should list the missing usage", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "swagger:enum") {
|
||||
t.Fatalf("error %q should hint at the fix", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSharedEnums_usesASTMap(t *testing.T) {
|
||||
doc := &openapi3.T{
|
||||
Components: &openapi3.Components{
|
||||
Schemas: openapi3.Schemas{
|
||||
"A": {Value: &openapi3.Schema{
|
||||
Type: &openapi3.Types{"object"},
|
||||
Properties: openapi3.Schemas{
|
||||
"color": {Value: &openapi3.Schema{
|
||||
Type: &openapi3.Types{"string"},
|
||||
Enum: []any{"red", "green", "blue"},
|
||||
}},
|
||||
},
|
||||
}},
|
||||
"B": {Value: &openapi3.Schema{
|
||||
Type: &openapi3.Types{"object"},
|
||||
Properties: openapi3.Schemas{
|
||||
"color": {Value: &openapi3.Schema{
|
||||
Type: &openapi3.Types{"string"},
|
||||
Enum: []any{"red", "green", "blue"},
|
||||
}},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
astMap := map[string]string{EnumKey([]any{"red", "green", "blue"}): "Color"}
|
||||
if err := extractSharedEnums(doc, astMap); err != nil {
|
||||
t.Fatalf("extractSharedEnums: %v", err)
|
||||
}
|
||||
if _, ok := doc.Components.Schemas["Color"]; !ok {
|
||||
t.Fatalf("expected Color schema to be extracted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixFileSchemas_recursesIntoNested(t *testing.T) {
|
||||
fileType := func() *openapi3.SchemaRef {
|
||||
return &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"file"}}}
|
||||
}
|
||||
doc := &openapi3.T{
|
||||
Paths: openapi3.NewPaths(),
|
||||
}
|
||||
doc.Paths.Set("/upload", &openapi3.PathItem{
|
||||
Post: &openapi3.Operation{
|
||||
RequestBody: &openapi3.RequestBodyRef{
|
||||
Value: &openapi3.RequestBody{
|
||||
Content: openapi3.Content{
|
||||
"multipart/form-data": {
|
||||
Schema: &openapi3.SchemaRef{Value: &openapi3.Schema{
|
||||
Type: &openapi3.Types{"object"},
|
||||
Properties: openapi3.Schemas{
|
||||
"attachment": fileType(),
|
||||
"items": {Value: &openapi3.Schema{
|
||||
Type: &openapi3.Types{"array"},
|
||||
Items: fileType(),
|
||||
}},
|
||||
"alt": {Value: &openapi3.Schema{
|
||||
AllOf: openapi3.SchemaRefs{fileType()},
|
||||
}},
|
||||
"one": {Value: &openapi3.Schema{
|
||||
OneOf: openapi3.SchemaRefs{fileType()},
|
||||
}},
|
||||
"any": {Value: &openapi3.Schema{
|
||||
AnyOf: openapi3.SchemaRefs{fileType()},
|
||||
}},
|
||||
"not": {Value: &openapi3.Schema{
|
||||
Not: fileType(),
|
||||
}},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Responses: openapi3.NewResponses(),
|
||||
},
|
||||
})
|
||||
|
||||
fixFileSchemas(doc)
|
||||
|
||||
props := doc.Paths.Value("/upload").Post.RequestBody.Value.Content["multipart/form-data"].Schema.Value.Properties
|
||||
if !props["attachment"].Value.Type.Is("string") || props["attachment"].Value.Format != "binary" {
|
||||
t.Errorf("nested property not fixed: %+v", props["attachment"].Value)
|
||||
}
|
||||
if !props["items"].Value.Items.Value.Type.Is("string") || props["items"].Value.Items.Value.Format != "binary" {
|
||||
t.Errorf("array items not fixed: %+v", props["items"].Value.Items.Value)
|
||||
}
|
||||
if !props["alt"].Value.AllOf[0].Value.Type.Is("string") || props["alt"].Value.AllOf[0].Value.Format != "binary" {
|
||||
t.Errorf("allOf branch not fixed: %+v", props["alt"].Value.AllOf[0].Value)
|
||||
}
|
||||
if !props["one"].Value.OneOf[0].Value.Type.Is("string") || props["one"].Value.OneOf[0].Value.Format != "binary" {
|
||||
t.Errorf("oneOf branch not fixed: %+v", props["one"].Value.OneOf[0].Value)
|
||||
}
|
||||
if !props["any"].Value.AnyOf[0].Value.Type.Is("string") || props["any"].Value.AnyOf[0].Value.Format != "binary" {
|
||||
t.Errorf("anyOf branch not fixed: %+v", props["any"].Value.AnyOf[0].Value)
|
||||
}
|
||||
if !props["not"].Value.Not.Value.Type.Is("string") || props["not"].Value.Not.Value.Format != "binary" {
|
||||
t.Errorf("not branch not fixed: %+v", props["not"].Value.Not.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSharedEnums_missReturnsError(t *testing.T) {
|
||||
doc := &openapi3.T{
|
||||
Components: &openapi3.Components{
|
||||
Schemas: openapi3.Schemas{
|
||||
"A": {Value: &openapi3.Schema{
|
||||
Type: &openapi3.Types{"object"},
|
||||
Properties: openapi3.Schemas{
|
||||
"color": {Value: &openapi3.Schema{
|
||||
Type: &openapi3.Types{"string"},
|
||||
Enum: []any{"red", "green"},
|
||||
}},
|
||||
},
|
||||
}},
|
||||
"B": {Value: &openapi3.Schema{
|
||||
Type: &openapi3.Types{"object"},
|
||||
Properties: openapi3.Schemas{
|
||||
"color": {Value: &openapi3.Schema{
|
||||
Type: &openapi3.Types{"string"},
|
||||
Enum: []any{"red", "green"},
|
||||
}},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := extractSharedEnums(doc, map[string]string{}); err == nil {
|
||||
t.Fatal("expected miss error")
|
||||
}
|
||||
}
|
||||
188
build/openapi3gen/enumscan.go
Normal file
188
build/openapi3gen/enumscan.go
Normal file
@@ -0,0 +1,188 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package openapi3gen converts Gitea's Swagger 2.0 spec to an OpenAPI 3.0
|
||||
// spec. It discovers Go enum type names by scanning swagger:enum annotations
|
||||
// in the source tree, then names extracted shared-enum schemas accordingly.
|
||||
package openapi3gen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EnumKey returns a canonical key for a set of enum values: values are
|
||||
// stringified, sorted, and joined with "|". Used to match enum value sets
|
||||
// across spec properties and scanned Go type declarations.
|
||||
func EnumKey(values []any) string {
|
||||
strs := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
strs[i] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
sort.Strings(strs)
|
||||
return strings.Join(strs, "|")
|
||||
}
|
||||
|
||||
var rxSwaggerEnum = regexp.MustCompile(`swagger:enum\s+(\w+)`)
|
||||
|
||||
// ScanSwaggerEnumTypes walks .go files under each dir and returns a map from
|
||||
// a canonical value-set key (see EnumKey) to the Go type name declared with
|
||||
// // swagger:enum TypeName.
|
||||
//
|
||||
// Returns an error on parse failure, on an annotation for a type whose
|
||||
// constants can't be extracted, or on value-set collisions between two
|
||||
// different enum types.
|
||||
func ScanSwaggerEnumTypes(dirs []string) (map[string]string, error) {
|
||||
fset := token.NewFileSet()
|
||||
parsed := []*ast.File{}
|
||||
|
||||
for _, dir := range dirs {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", dir, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(entry.Name(), "_test.go") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
file, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
parsed = append(parsed, file)
|
||||
}
|
||||
}
|
||||
|
||||
enumTypes := map[string]string{} // typeName → "" (presence marker)
|
||||
enumValues := map[string][]any{} // typeName → values
|
||||
|
||||
// Pass 1: collect every // swagger:enum TypeName declaration.
|
||||
for _, file := range parsed {
|
||||
for _, decl := range file.Decls {
|
||||
gd, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gd.Tok != token.TYPE {
|
||||
continue
|
||||
}
|
||||
if err := collectEnumType(gd, enumTypes); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", fset.Position(gd.Pos()).Filename, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: collect const values; now every annotated type is visible.
|
||||
for _, file := range parsed {
|
||||
for _, decl := range file.Decls {
|
||||
gd, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gd.Tok != token.CONST {
|
||||
continue
|
||||
}
|
||||
collectEnumValues(gd, enumTypes, enumValues)
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]string{}
|
||||
for typeName := range enumTypes {
|
||||
values, ok := enumValues[typeName]
|
||||
if !ok || len(values) == 0 {
|
||||
return nil, fmt.Errorf("swagger:enum %s has no const block with typed string values", typeName)
|
||||
}
|
||||
key := EnumKey(values)
|
||||
if existing, ok := result[key]; ok && existing != typeName {
|
||||
return nil, fmt.Errorf("swagger:enum value-set collision: %s and %s both use %q", existing, typeName, key)
|
||||
}
|
||||
result[key] = typeName
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// collectEnumType scans a `type` GenDecl for // swagger:enum annotations,
|
||||
// handling both the lone form (`// swagger:enum Foo\n type Foo string`)
|
||||
// where the comment group is attached to the GenDecl, and the grouped form:
|
||||
//
|
||||
// type (
|
||||
// // swagger:enum Foo
|
||||
// Foo string
|
||||
// )
|
||||
//
|
||||
// where the comment group is attached to each TypeSpec. Caveat: Go's parser
|
||||
// only attaches a CommentGroup when it is immediately adjacent to the decl.
|
||||
// A blank line (not a `//` continuation line) between the comment and the
|
||||
// declaration drops the Doc, so annotations MUST sit directly above their
|
||||
// type. All current annotated files obey this — the rule is noted here so
|
||||
// a future edit that inserts a blank line fails fast rather than silently.
|
||||
func collectEnumType(gd *ast.GenDecl, enumTypes map[string]string) error {
|
||||
if err := registerEnumAnnotation(gd.Doc, gd.Specs, enumTypes); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, spec := range gd.Specs {
|
||||
ts, ok := spec.(*ast.TypeSpec)
|
||||
if !ok || ts.Doc == nil {
|
||||
continue
|
||||
}
|
||||
if err := registerEnumAnnotation(ts.Doc, []ast.Spec{ts}, enumTypes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func registerEnumAnnotation(doc *ast.CommentGroup, specs []ast.Spec, enumTypes map[string]string) error {
|
||||
if doc == nil {
|
||||
return nil
|
||||
}
|
||||
matches := rxSwaggerEnum.FindStringSubmatch(doc.Text())
|
||||
if len(matches) < 2 {
|
||||
return nil
|
||||
}
|
||||
annotated := matches[1]
|
||||
for _, spec := range specs {
|
||||
ts, ok := spec.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if ts.Name.Name == annotated {
|
||||
enumTypes[annotated] = ""
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("swagger:enum %s: no type declaration with that name in the same decl group; check for a typo", annotated)
|
||||
}
|
||||
|
||||
func collectEnumValues(gd *ast.GenDecl, enumTypes map[string]string, enumValues map[string][]any) {
|
||||
for _, spec := range gd.Specs {
|
||||
vs, ok := spec.(*ast.ValueSpec)
|
||||
if !ok || vs.Type == nil {
|
||||
continue
|
||||
}
|
||||
ident, ok := vs.Type.(*ast.Ident)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, isEnum := enumTypes[ident.Name]; !isEnum {
|
||||
continue
|
||||
}
|
||||
for _, val := range vs.Values {
|
||||
lit, ok := val.(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING {
|
||||
continue
|
||||
}
|
||||
unquoted, err := strconv.Unquote(lit.Value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
enumValues[ident.Name] = append(enumValues[ident.Name], unquoted)
|
||||
}
|
||||
}
|
||||
}
|
||||
239
build/openapi3gen/enumscan_test.go
Normal file
239
build/openapi3gen/enumscan_test.go
Normal file
@@ -0,0 +1,239 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package openapi3gen
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnumKey_sortsAndJoins(t *testing.T) {
|
||||
key := EnumKey([]any{"b", "a", "c"})
|
||||
if key != "a|b|c" {
|
||||
t.Fatalf("EnumKey = %q, want %q", key, "a|b|c")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnumKey_handlesNonStringValues(t *testing.T) {
|
||||
key := EnumKey([]any{2, 1, 3})
|
||||
if key != "1|2|3" {
|
||||
t.Fatalf("EnumKey = %q, want %q", key, "1|2|3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSwaggerEnumTypes_basic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := `package fixture
|
||||
|
||||
// Color is a primary color.
|
||||
// swagger:enum Color
|
||||
type Color string
|
||||
|
||||
const (
|
||||
ColorRed Color = "red"
|
||||
ColorGreen Color = "green"
|
||||
ColorBlue Color = "blue"
|
||||
)
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "color.go"), []byte(src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ScanSwaggerEnumTypes([]string{dir})
|
||||
if err != nil {
|
||||
t.Fatalf("ScanSwaggerEnumTypes: %v", err)
|
||||
}
|
||||
wantKey := EnumKey([]any{"red", "green", "blue"})
|
||||
if got[wantKey] != "Color" {
|
||||
t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Color")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSwaggerEnumTypes_orphanAnnotation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := `package fixture
|
||||
|
||||
// swagger:enum Sttype
|
||||
type StateType string
|
||||
|
||||
const (
|
||||
StateOpen StateType = "open"
|
||||
)
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "typo.go"), []byte(src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := ScanSwaggerEnumTypes([]string{dir})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for annotation referencing a non-matching type name")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Sttype") {
|
||||
t.Fatalf("error %q should mention the typo'd name Sttype", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSwaggerEnumTypes_collision(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := `package fixture
|
||||
|
||||
// swagger:enum Alpha
|
||||
type Alpha string
|
||||
const (
|
||||
AlphaX Alpha = "x"
|
||||
AlphaY Alpha = "y"
|
||||
)
|
||||
|
||||
// swagger:enum Beta
|
||||
type Beta string
|
||||
const (
|
||||
BetaX Beta = "x"
|
||||
BetaY Beta = "y"
|
||||
)
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "dup.go"), []byte(src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := ScanSwaggerEnumTypes([]string{dir})
|
||||
if err == nil {
|
||||
t.Fatal("expected collision error, got nil")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "Alpha") || !strings.Contains(msg, "Beta") {
|
||||
t.Fatalf("error %q should mention both Alpha and Beta", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSwaggerEnumTypes_parseFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "bad.go"), []byte("package fixture\nfunc Foo() {"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := ScanSwaggerEnumTypes([]string{dir})
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSwaggerEnumTypes_annotationWithoutConsts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := `package fixture
|
||||
|
||||
// swagger:enum Lonely
|
||||
type Lonely string
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "lonely.go"), []byte(src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := ScanSwaggerEnumTypes([]string{dir})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for annotation without consts")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Lonely") {
|
||||
t.Fatalf("error %q should mention Lonely", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSwaggerEnumTypes_constsAndTypeInDifferentFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Name ordering: `a_consts.go` < `b_type.go`, so readdir returns consts first.
|
||||
// Old single-pass scanner would miss the values; two-pass must not.
|
||||
constsSrc := `package fixture
|
||||
|
||||
const (
|
||||
HueA Hue = "a"
|
||||
HueB Hue = "b"
|
||||
)
|
||||
`
|
||||
typeSrc := `package fixture
|
||||
|
||||
// swagger:enum Hue
|
||||
type Hue string
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "a_consts.go"), []byte(constsSrc), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "b_type.go"), []byte(typeSrc), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ScanSwaggerEnumTypes([]string{dir})
|
||||
if err != nil {
|
||||
t.Fatalf("ScanSwaggerEnumTypes: %v", err)
|
||||
}
|
||||
wantKey := EnumKey([]any{"a", "b"})
|
||||
if got[wantKey] != "Hue" {
|
||||
t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Hue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSwaggerEnumTypes_constsBeforeType(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := `package fixture
|
||||
|
||||
const (
|
||||
ShadeDark Shade = "dark"
|
||||
ShadeLight Shade = "light"
|
||||
)
|
||||
|
||||
// swagger:enum Shade
|
||||
type Shade string
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "shade.go"), []byte(src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ScanSwaggerEnumTypes([]string{dir})
|
||||
if err != nil {
|
||||
t.Fatalf("ScanSwaggerEnumTypes: %v", err)
|
||||
}
|
||||
wantKey := EnumKey([]any{"dark", "light"})
|
||||
if got[wantKey] != "Shade" {
|
||||
t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Shade")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSwaggerEnumTypes_groupedTypeDecl(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := `package fixture
|
||||
|
||||
type (
|
||||
// swagger:enum Color
|
||||
Color string
|
||||
// swagger:enum Shade
|
||||
Shade string
|
||||
)
|
||||
|
||||
const (
|
||||
ColorRed Color = "red"
|
||||
ColorBlue Color = "blue"
|
||||
)
|
||||
|
||||
const (
|
||||
ShadeDark Shade = "dark"
|
||||
ShadeLight Shade = "light"
|
||||
)
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "grouped.go"), []byte(src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ScanSwaggerEnumTypes([]string{dir})
|
||||
if err != nil {
|
||||
t.Fatalf("ScanSwaggerEnumTypes: %v", err)
|
||||
}
|
||||
colorKey := EnumKey([]any{"red", "blue"})
|
||||
shadeKey := EnumKey([]any{"dark", "light"})
|
||||
if got[colorKey] != "Color" {
|
||||
t.Fatalf("Color: map[%q] = %q, want %q", colorKey, got[colorKey], "Color")
|
||||
}
|
||||
if got[shadeKey] != "Shade" {
|
||||
t.Fatalf("Shade: map[%q] = %q, want %q", shadeKey, got[shadeKey], "Shade")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user