mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-27 15:25:25 +00:00
282 lines
7.9 KiB
Go
282 lines
7.9 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package openapi3gen
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"gitea.dev/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,
|
|
)
|
|
}
|