feat(api): merge Forgejo spec-first endpoints into unified OAS3 spec

Add a merge step that combines the auto-converted OAS3 spec (from
Swagger 2.0) with the hand-written Forgejo API spec into a single
unified OpenAPI 3.0 document.

The merged spec uses /api as the server base URL, with /v1/... paths
for the main API and /forgejo/v1/... paths for Forgejo-specific
endpoints. Served at /openapi.v1.json.
This commit is contained in:
Myers Carpenter 2026-02-17 17:00:09 +00:00
parent 7218b66a51
commit 3ceec0e5bb
6 changed files with 1066 additions and 409 deletions

View file

@ -21,6 +21,9 @@ insert_final_newline = false
[templates/swagger/v1_json.tmpl]
indent_style = space
[templates/swagger/v1_openapi3_json.tmpl]
indent_style = space
[templates/user/auth/oidc_wellknown.tmpl]
indent_style = space

View file

@ -172,8 +172,8 @@ FORGEJO_API_SPEC := public/assets/forgejo/api.v1.yml
SWAGGER_SPEC := templates/swagger/v1_json.tmpl
OPENAPI3_SPEC := templates/swagger/v1_openapi3_json.tmpl
OPENAPI3_SPEC_S_TMPL := s|"url": *"/api/v1"|"url": "{{AppSubUrl \| JSEscape}}/api/v1"|g
OPENAPI3_SPEC_S_JSON := s|"url": *"{{AppSubUrl \| JSEscape}}/api/v1"|"url": "/api/v1"|g
OPENAPI3_SPEC_S_TMPL := s|"url": *"/api"|"url": "{{AppSubUrl \| JSEscape}}/api"|g
OPENAPI3_SPEC_S_JSON := s|"url": *"{{AppSubUrl \| JSEscape}}/api"|"url": "/api"|g
SWAGGER_SPEC_S_TMPL := s|"basePath": *"/api/v1"|"basePath": "{{AppSubUrl \| JSEscape}}/api/v1"|g
SWAGGER_SPEC_S_JSON := s|"basePath": *"{{AppSubUrl \| JSEscape}}/api/v1"|"basePath": "/api/v1"|g
SWAGGER_EXCLUDE := code.gitea.io/sdk
@ -423,8 +423,9 @@ swagger-validate:
.PHONY: generate-openapi3
generate-openapi3: $(OPENAPI3_SPEC)
$(OPENAPI3_SPEC): $(SWAGGER_SPEC)
$(OPENAPI3_SPEC): $(SWAGGER_SPEC) $(FORGEJO_API_SPEC)
$(GO) run build/generate-openapi.go
$(GO) run build/merge-openapi3.go
.PHONY: openapi3-check
openapi3-check: generate-openapi3

View file

@ -11,6 +11,7 @@ import (
"log"
"os"
"regexp"
"sort"
"strings"
"github.com/getkin/kin-openapi/openapi2"
@ -60,13 +61,26 @@ func main() {
// Ensure a servers entry exists with the base path including the placeholder
oas3.Servers = openapi3.Servers{
{URL: appSubUrlPlaceholder + "/api/v1"},
{URL: appSubUrlPlaceholder + "/api"},
}
// Prefix all paths with /v1 so the merged spec can use /api as the base
prefixedPaths := make(openapi3.Paths, len(oas3.Paths))
for path, item := range oas3.Paths {
prefixedPaths["/v1"+path] = item
}
oas3.Paths = prefixedPaths
// Fix "type: file" schemas left over from Swagger 2.0 conversion.
// In OAS3, file responses use type: string + format: binary.
fixFileSchemas(oas3)
// OAS3 post-processing: enrich the spec with details that Swagger 2.0
// and go-swagger cannot express.
addURIFormats(oas3)
addDeprecatedFlags(oas3)
extractSharedEnums(oas3)
// Marshal to JSON with indentation
out, err := json.MarshalIndent(oas3, "", " ")
if err != nil {
@ -126,3 +140,262 @@ func fixSchema(ref *openapi3.SchemaRef) {
ref.Value.Format = "binary"
}
}
// addURIFormats sets format: uri on string properties whose names indicate they
// hold URLs. This information is lost in Swagger 2.0 (go-swagger doesn't emit
// format annotations for URL fields) 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 != "string" || prop.Format != "" {
continue
}
if isURLProperty(propName) {
prop.Format = "uri"
}
}
}
}
// isURLProperty returns true if the property name indicates it holds a URL.
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 contains "deprecated". In OAS3, deprecated is a first-class
// boolean; Swagger 2.0 could only express it as text in the description.
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
}
desc := strings.ToLower(propRef.Value.Description)
if strings.Contains(desc, "deprecated") {
propRef.Value.Deprecated = true
}
}
}
}
type enumUsage struct {
schemaName string
propName string
propRef *openapi3.SchemaRef
inItems bool // true if enum is on .Items, not the prop itself
}
// 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. This produces proper enum types for code
// generators instead of anonymous inline enums repeated on each field.
func extractSharedEnums(doc *openapi3.T) {
if doc.Components == nil {
return
}
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 == "string" {
key := enumKey(propRef.Value.Enum)
enumGroups[key] = append(enumGroups[key], enumUsage{schemaName, propName, propRef, false})
}
// Check array items
if propRef.Value.Type == "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 == "string" {
key := enumKey(propRef.Value.Items.Value.Enum)
enumGroups[key] = append(enumGroups[key], enumUsage{schemaName, propName, propRef, true})
}
}
}
// Only extract enums used by 2+ fields
for key, usages := range enumGroups {
if len(usages) < 2 {
continue
}
// Derive a name from the enum values and usage context
enumName := deriveEnumName(usages)
// Avoid collisions with existing schemas
if _, exists := doc.Components.Schemas[enumName]; exists {
enumName += "Type"
}
if _, exists := doc.Components.Schemas[enumName]; exists {
continue // still collides, skip
}
// Get the enum values from the first usage
var enumValues []any
if usages[0].inItems {
enumValues = usages[0].propRef.Value.Items.Value.Enum
} else {
enumValues = usages[0].propRef.Value.Enum
}
// Create the standalone enum schema
doc.Components.Schemas[enumName] = &openapi3.SchemaRef{
Value: &openapi3.Schema{
Type: "string",
Enum: enumValues,
},
}
ref := "#/components/schemas/" + enumName
// Replace inline enums with $ref
for _, usage := range usages {
if usage.inItems {
usage.propRef.Value.Items = &openapi3.SchemaRef{Ref: ref}
} else {
// Preserve the property description and other metadata by
// wrapping in allOf: the $ref provides the type+enum, the
// inline schema provides description/deprecated/etc.
old := usage.propRef.Value
if old.Description == "" && !old.Deprecated && old.Format == "" {
// Simple case: just replace with a $ref
usage.propRef.Ref = ref
usage.propRef.Value = nil
} else {
// Has metadata: use allOf to combine $ref with metadata
usage.propRef.Value = &openapi3.Schema{
AllOf: openapi3.SchemaRefs{
{Ref: ref},
},
Description: old.Description,
Deprecated: old.Deprecated,
}
// Clear enum from the wrapper (it's in the $ref now)
}
}
}
_ = key // used as map key
}
}
// enumKey returns a canonical string key for an enum value set, for grouping.
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, "|")
}
// Known Go type names for enum types. These are not present in the Swagger 2.0
// definitions (go-swagger doesn't emit standalone string type definitions), so
// we map them explicitly using the const name prefix from x-go-enum-desc.
var knownEnumTypes = map[string]string{
"CommitStatus": "CommitStatusState",
"State": "StateType",
"ReviewState": "ReviewStateType",
"NotifySubject": "NotifySubjectType",
"IssueFormField": "IssueFormFieldType",
"ObjectFormatName": "ObjectFormatName",
}
// deriveEnumName picks a name for an extracted enum schema based on the
// Go const name prefix from x-go-enum-desc, with a fallback to the property name.
func deriveEnumName(usages []enumUsage) string {
// Try to extract the Go type name from x-go-enum-desc.
// Format: "value ConstName ConstName description\n..."
// e.g. "pending CommitStatusPending CommitStatusPending is for..."
for _, u := range usages {
if u.propRef.Value == nil {
continue
}
desc, ok := u.propRef.Value.Extensions["x-go-enum-desc"]
if !ok {
continue
}
s, ok := desc.(string)
if !ok {
continue
}
parts := strings.Fields(s)
if len(parts) < 2 {
continue
}
constName := parts[1]
// Try to strip the enum value from the const name to get the prefix
var vals []any
if u.inItems {
vals = u.propRef.Value.Items.Value.Enum
} else {
vals = u.propRef.Value.Enum
}
for _, v := range vals {
vs := fmt.Sprintf("%v", v)
// Case-insensitive suffix matching: the enum value may be
// lowercase ("pending"), UPPER ("APPROVED"), or mixed ("sha1"),
// while the Go const uses PascalCase ("Pending", "Approved", "SHA1").
lowerConst := strings.ToLower(constName)
lowerVal := strings.ToLower(vs)
if strings.HasSuffix(lowerConst, lowerVal) && len(lowerVal) < len(lowerConst) {
prefix := constName[:len(constName)-len(vs)]
// Check if we have a known Go type name for this prefix
if goType, ok := knownEnumTypes[prefix]; ok {
return goType
}
return prefix
}
}
}
// Fallback: use the most common property name, PascalCased
nameCounts := map[string]int{}
for _, u := range usages {
nameCounts[u.propName]++
}
bestName := ""
bestCount := 0
for name, count := range nameCounts {
if count > bestCount || (count == bestCount && name < bestName) {
bestName = name
bestCount = count
}
}
result := ""
for _, p := range strings.Split(bestName, "_") {
if len(p) > 0 {
result += strings.ToUpper(p[:1]) + p[1:]
}
}
return result + "Enum"
}

227
build/merge-openapi3.go Normal file
View file

@ -0,0 +1,227 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build ignore
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"regexp"
"strings"
"github.com/getkin/kin-openapi/openapi3"
)
const (
openapi3SpecPath = "templates/swagger/v1_openapi3_json.tmpl"
forgejoSpecPath = "public/assets/forgejo/api.v1.yml"
appSubUrlVar = "{{AppSubUrl | JSEscape}}"
appVerVar = "{{AppVer | JSEscape}}"
appSubUrlPlaceholder = "FORGEJO_APP_SUB_URL_PLACEHOLDER"
appVerPlaceholder = "0.0.0-forgejo-placeholder"
)
var (
appSubUrlRe = regexp.MustCompile(regexp.QuoteMeta(appSubUrlVar))
appVerRe = regexp.MustCompile(regexp.QuoteMeta(appVerVar))
)
func main() {
// --- Load the auto-converted OAS3 spec (from generate-openapi.go output) ---
data, err := os.ReadFile(openapi3SpecPath)
if err != nil {
log.Fatalf("reading openapi3 spec: %v", err)
}
// Replace Go template variables with placeholders so we have valid JSON for parsing
cleaned := appSubUrlRe.ReplaceAll(data, []byte(appSubUrlPlaceholder))
cleaned = appVerRe.ReplaceAll(cleaned, []byte(appVerPlaceholder))
baseDoc, err := openapi3.NewLoader().LoadFromData(cleaned)
if err != nil {
log.Fatalf("parsing openapi3 spec: %v", err)
}
// --- Load the hand-written Forgejo spec ---
forgejoDoc, err := openapi3.NewLoader().LoadFromFile(forgejoSpecPath)
if err != nil {
log.Fatalf("parsing forgejo spec: %v", err)
}
// --- Collect existing operationIds from the base spec ---
existingOpIDs := make(map[string]bool)
for _, item := range baseDoc.Paths {
for _, op := range []*openapi3.Operation{
item.Get, item.Post, item.Put, item.Patch,
item.Delete, item.Head, item.Options, item.Trace,
} {
if op != nil && op.OperationID != "" {
existingOpIDs[op.OperationID] = true
}
}
}
// --- Merge Forgejo paths into the base spec ---
// Forgejo spec paths are under /api/forgejo/v1; we prefix them with /forgejo/v1
// (the base spec already uses /api as the server URL).
// Prefix conflicting operationIds with "forgejo" to avoid duplicates.
for path, item := range forgejoDoc.Paths {
for _, op := range []*openapi3.Operation{
item.Get, item.Post, item.Put, item.Patch,
item.Delete, item.Head, item.Options, item.Trace,
} {
if op != nil && op.OperationID != "" && existingOpIDs[op.OperationID] {
op.OperationID = "forgejo" + strings.ToUpper(op.OperationID[:1]) + op.OperationID[1:]
}
}
mergedPath := "/forgejo/v1" + path
baseDoc.Paths[mergedPath] = item
}
// --- Merge Forgejo schemas into the base spec ---
if forgejoDoc.Components != nil && forgejoDoc.Components.Schemas != nil {
if baseDoc.Components == nil {
baseDoc.Components = &openapi3.Components{}
}
if baseDoc.Components.Schemas == nil {
baseDoc.Components.Schemas = make(openapi3.Schemas)
}
// Build a set of schema names that need renaming (conflicts)
renames := make(map[string]string)
for name := range forgejoDoc.Components.Schemas {
if _, exists := baseDoc.Components.Schemas[name]; exists {
renames[name] = "Forgejo" + name
}
}
// Add Forgejo schemas (renaming conflicts)
for name, schema := range forgejoDoc.Components.Schemas {
targetName := name
if renamed, ok := renames[name]; ok {
targetName = renamed
}
baseDoc.Components.Schemas[targetName] = schema
}
// Update $ref pointers in Forgejo paths to use renamed schemas
if len(renames) > 0 {
updateRefs(forgejoDoc, renames, baseDoc)
}
}
// --- Merge Forgejo parameters into the base spec ---
if forgejoDoc.Components != nil && forgejoDoc.Components.Parameters != nil {
if baseDoc.Components.Parameters == nil {
baseDoc.Components.Parameters = make(openapi3.ParametersMap)
}
for name, param := range forgejoDoc.Components.Parameters {
baseDoc.Components.Parameters[name] = param
}
}
// --- Merge Forgejo responses into the base spec ---
if forgejoDoc.Components != nil && forgejoDoc.Components.Responses != nil {
if baseDoc.Components.Responses == nil {
baseDoc.Components.Responses = make(openapi3.Responses)
}
for name, resp := range forgejoDoc.Components.Responses {
baseDoc.Components.Responses[name] = resp
}
}
// --- Marshal and re-inject template variables ---
out, err := json.MarshalIndent(baseDoc, "", " ")
if err != nil {
log.Fatalf("marshaling merged spec: %v", err)
}
result := strings.ReplaceAll(string(out), appSubUrlPlaceholder, appSubUrlVar)
result = strings.ReplaceAll(result, appVerPlaceholder, appVerVar)
if !strings.HasSuffix(result, "\n") {
result += "\n"
}
if err := os.WriteFile(openapi3SpecPath, []byte(result), 0o644); err != nil {
log.Fatalf("writing merged spec: %v", err)
}
fmt.Printf("Merged Forgejo spec into %s\n", openapi3SpecPath)
}
// updateRefs rewrites $ref pointers in the Forgejo paths (now merged into baseDoc)
// to account for renamed schemas.
func updateRefs(forgejoDoc *openapi3.T, renames map[string]string, baseDoc *openapi3.T) {
// Only need to fix refs in the Forgejo-originated paths
for path := range forgejoDoc.Paths {
mergedPath := "/forgejo/v1" + path
item := baseDoc.Paths[mergedPath]
if item == nil {
continue
}
for _, op := range []*openapi3.Operation{
item.Get, item.Post, item.Put, item.Patch,
item.Delete, item.Head, item.Options, item.Trace,
} {
if op == nil {
continue
}
// Fix response schema refs
for _, resp := range op.Responses {
if resp.Value == nil {
continue
}
for _, mt := range resp.Value.Content {
fixSchemaRef(mt.Schema, renames)
}
}
// Fix request body schema refs
if op.RequestBody != nil && op.RequestBody.Value != nil {
for _, mt := range op.RequestBody.Value.Content {
fixSchemaRef(mt.Schema, renames)
}
}
// Fix parameter schema refs
for _, param := range op.Parameters {
if param.Value != nil && param.Value.Schema != nil {
fixSchemaRef(param.Value.Schema, renames)
}
}
}
}
}
// fixSchemaRef rewrites a $ref if it points to a renamed schema.
func fixSchemaRef(ref *openapi3.SchemaRef, renames map[string]string) {
if ref == nil {
return
}
if ref.Ref != "" {
for oldName, newName := range renames {
oldRef := "#/components/schemas/" + oldName
if ref.Ref == oldRef {
ref.Ref = "#/components/schemas/" + newName
break
}
}
}
// Recurse into inline schemas
if ref.Value != nil {
if ref.Value.Items != nil {
fixSchemaRef(ref.Value.Items, renames)
}
for _, prop := range ref.Value.Properties {
fixSchemaRef(prop, renames)
}
if ref.Value.AdditionalProperties.Schema != nil {
fixSchemaRef(ref.Value.AdditionalProperties.Schema, renames)
}
}
}

View file

@ -10,4 +10,5 @@ package build
import (
_ "github.com/getkin/kin-openapi/openapi2"
_ "github.com/getkin/kin-openapi/openapi2conv"
_ "github.com/getkin/kin-openapi/openapi3"
)

File diff suppressed because it is too large Load diff