refactor: serve the api specs as plain json (#38715)

The api specs were Go templates whose committed form was not a valid
swagger document, so `swagger-validate`, `generate-openapi.go` and
`.spectral.yaml` each worked around it. They are now plain json,
substituted at serve time.

Renaming them off `.tmpl` also stops `make fmt` rewriting them, which
used to bump their mtime and silently skip the next `make
generate-swagger`.

Also enables stricter spectral linting: extends `lint-swagger` to the
OpenAPI 3 spec, turns on `openapi-tags`, `operation-singular-tag` and
`operation-tag-defined`, adds a top-level `tags` array with descriptions
to the swagger input, and drops the redundant `repository` tag from
`POST /user/repos`.

---------

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
silverwind
2026-07-31 16:16:27 +02:00
committed by GitHub
parent a3e7fe1f11
commit f0a95eebe3
16 changed files with 178 additions and 79 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ indent_style = tab
[templates/custom/*.tmpl]
insert_final_newline = false
[templates/swagger/*_json.tmpl]
[templates/swagger/*.generated.json]
indent_style = space
insert_final_newline = false
+1 -2
View File
@@ -3,8 +3,7 @@
*.pb.go linguist-generated
/assets/*.json linguist-generated
/public/assets/img/svg/*.svg linguist-generated
/templates/swagger/v1_json.tmpl linguist-generated
/templates/swagger/v1_openapi3_json.tmpl linguist-generated
/templates/swagger/*.generated.json linguist-generated
/options/fileicon/** linguist-generated
/vendor/** -text -eol linguist-vendored
/web_src/js/vendor/** -text -eol linguist-vendored
+1 -2
View File
@@ -113,8 +113,7 @@ jobs:
- "Dockerfile.rootless"
swagger:
- "templates/swagger/v1_json.tmpl"
- "templates/swagger/v1_input.json"
- "templates/swagger/*.json"
- "Makefile"
- "package.json"
- "pnpm-lock.yaml"
-5
View File
@@ -4,9 +4,4 @@ rules:
info-contact: off
oas2-api-host: off
oas2-parameter-description: off
oas2-schema: off
oas2-valid-schema-example: off
openapi-tags: off
operation-description: off
operation-singular-tag: off
operation-tag-defined: off
+6 -9
View File
@@ -150,10 +150,10 @@ GO_SOURCES += $(GENERATED_GO_DEST)
ESLINT_CONCURRENCY ?= 2
ESLINT_ARGS := --color --max-warnings=0 --concurrency $(ESLINT_CONCURRENCY)
SWAGGER_SPEC := templates/swagger/v1_json.tmpl
SWAGGER_SPEC_INPUT := templates/swagger/v1_input.json
SWAGGER_EXCLUDE := gitea.dev/sdk
OPENAPI3_SPEC := templates/swagger/v1_openapi3_json.tmpl
SWAGGER_SPEC_INPUT := templates/swagger/v1-input.json
SWAGGER_SPEC := templates/swagger/v1-swagger.generated.json
OPENAPI3_SPEC := templates/swagger/v1-openapi3.generated.json
TEST_MYSQL_HOST ?= mysql:3306
TEST_MYSQL_DBNAME ?= testgitea
@@ -247,13 +247,10 @@ swagger-check: generate-swagger
.PHONY: swagger-validate
swagger-validate: ## check if the swagger spec is valid
@# swagger "validate" requires that the "basePath" must start with a slash, but we are using Golang template "{{...}}"
@$(SED_INPLACE) -E -e 's|"basePath":( *)"(.*)"|"basePath":\1"/\2"|g' './$(SWAGGER_SPEC)' # add a prefix slash to basePath
@# ensure no warnings
@output="$$($(GO) run $(SWAGGER_PACKAGE) validate './$(SWAGGER_SPEC)' 2>&1)"; status=$$?; \
$(SED_INPLACE) -E -e 's|"basePath":( *)"/(.*)"|"basePath":\1"\2"|g' './$(SWAGGER_SPEC)'; \
printf '%s\n' "$$output" | grep -v '^go: '; \
[ $$status -eq 0 ] || exit $$status; \
case "$$output" in *WARNING:*) exit 1;; esac
case "$$output" in *WARNING:*) exit 1;; esac; exit $$status
.PHONY: generate-openapi3
generate-openapi3: $(OPENAPI3_SPEC) ## generate the OpenAPI 3.0 spec from the Swagger 2.0 spec
@@ -317,7 +314,7 @@ lint-css-fix: node_modules ## lint css files and fix issues
.PHONY: lint-swagger
lint-swagger: node_modules ## lint swagger files
pnpm exec spectral lint -q -F hint $(SWAGGER_SPEC)
pnpm exec spectral lint -q -F hint $(SWAGGER_SPEC) $(OPENAPI3_SPEC)
.PHONY: lint-md
lint-md: node_modules ## lint markdown files
+9 -34
View File
@@ -10,7 +10,7 @@
// 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
// Output: templates/swagger/v1-openapi3.generated.json
//go:build ignore
@@ -21,35 +21,21 @@ import (
"fmt"
"log"
"os"
"regexp"
"sort"
"strings"
"gitea.dev/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"
swaggerSpecPath = "templates/swagger/v1-swagger.generated.json"
openapi3OutPath = "templates/swagger/v1-openapi3.generated.json"
)
var (
appSubUrlRe = regexp.MustCompile(regexp.QuoteMeta(appSubUrlVar))
appVerRe = regexp.MustCompile(regexp.QuoteMeta(appVerVar))
enumScanDirs = []string{
"modules/structs",
"modules/commitstatus",
}
)
var enumScanDirs = []string{
"modules/structs",
"modules/commitstatus",
}
func main() {
astEnumMap, err := openapi3gen.ScanSwaggerEnumTypes(enumScanDirs)
@@ -68,28 +54,17 @@ func main() {
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)
oas3, err := openapi3gen.Convert(data, 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 {
if err := os.WriteFile(openapi3OutPath, out, 0o644); err != nil {
log.Fatalf("writing openapi 3.0 spec: %v", err)
}
+3 -2
View File
@@ -23,8 +23,8 @@ import (
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.
// Gitea-specific post-processing: server URL, file-schema fixups, URI
// formats, deprecated flags, and shared-enum extraction.
//
// astEnumMap is a value-set-key → Go-type-name(s) map (built by
// ScanSwaggerEnumTypes). When a value set is shared by multiple Go types,
@@ -42,6 +42,7 @@ func Convert(swaggerJSON []byte, astEnumMap map[string][]string) (*openapi3.T, e
return nil, fmt.Errorf("converting to openapi 3.0: %w", err)
}
oas3.Servers = openapi3.Servers{{URL: swagger2.BasePath}}
fixFileSchemas(oas3)
addURIFormats(oas3)
addDeprecatedFlags(oas3)
+1 -1
View File
@@ -277,7 +277,7 @@ func CreateUserRepo(ctx *context.APIContext, owner *user_model.User, opt api.Cre
// Create one repository of mine
func Create(ctx *context.APIContext) {
// swagger:operation POST /user/repos repository user createCurrentUserRepo
// swagger:operation POST /user/repos user createCurrentUserRepo
// ---
// summary: Create a repository
// consumes:
+1
View File
@@ -482,6 +482,7 @@ func OIDCWellKnown(ctx *context.Context) {
ctx.Data["OidcIssuer"] = jwtRegisteredClaims.Issuer // use the consistent issuer from the JWT registered claims
ctx.Data["OidcBaseUrl"] = strings.TrimSuffix(setting.AppURL, "/")
ctx.Data["SigningKeyMethodAlg"] = oauth2_provider.DefaultSigningKey.SigningMethod().Alg()
// FIXME: no need to use a Golang template to render JSON, just build the JSON response directly in the future
ctx.JSONTemplate("user/auth/oidc_wellknown")
}
+22 -10
View File
@@ -5,21 +5,33 @@ package web
import (
"html/template"
"net/http"
"strings"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/services/context"
)
// SwaggerV1Json render swagger v1 json
func SwaggerV1Json(ctx *context.Context) {
ctx.Data["SwaggerAppVer"] = template.HTML(template.JSEscapeString(setting.AppVer))
ctx.Data["SwaggerAppSubUrl"] = setting.AppSubURL // it is JS-safe
ctx.JSONTemplate("swagger/v1_json")
func swaggerJsonServe(ctx *context.Context, file string) {
buf, err := templates.AssetFS().ReadFile(file)
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, "unable to read api json file: "+file)
return
}
r := strings.NewReplacer(
"0.0.0+GITEA-API-APP-VERSION", template.JSEscapeString(setting.AppVer),
"/GITEA-API-APP-SUBURL/", template.JSEscapeString(setting.AppSubURL)+"/",
)
ctx.Resp.Header().Set("Content-Type", "application/json")
_, _ = r.WriteString(ctx.Resp, util.UnsafeBytesToString(buf))
}
// OpenAPI3Json render OpenAPI 3.0 json (auto-converted from Swagger 2.0)
func OpenAPI3Json(ctx *context.Context) {
ctx.Data["SwaggerAppVer"] = template.HTML(template.JSEscapeString(setting.AppVer))
ctx.Data["SwaggerAppSubUrl"] = setting.AppSubURL // it is JS-safe
ctx.JSONTemplate("swagger/v1_openapi3_json")
func SwaggerV1Json(ctx *context.Context) {
swaggerJsonServe(ctx, "swagger/v1-swagger.generated.json")
}
func OpenAPI3Json(ctx *context.Context) {
swaggerJsonServe(ctx, "swagger/v1-openapi3.generated.json")
}
-1
View File
@@ -26,7 +26,6 @@ export default {
prefix: 'tw-',
important: true, // the frameworks are mixed together, so tailwind needs to override other framework's styles
content: [
'!./templates/swagger/v1_json.tmpl',
'!./templates/user/auth/oidc_wellknown.tmpl',
'!**/*_test.go',
'./{build,models,modules,routers,services}/**/*.go',
+17
View File
@@ -0,0 +1,17 @@
{
"info": {
"version": "0.0.0+GITEA-API-APP-VERSION"
},
"basePath": "/GITEA-API-APP-SUBURL/api/v1",
"tags": [
{"name": "admin", "description": "Site administration"},
{"name": "issue", "description": "Issues, pull requests, comments, labels and milestones"},
{"name": "miscellaneous", "description": "Miscellaneous endpoints"},
{"name": "notification", "description": "User notifications"},
{"name": "organization", "description": "Organizations and teams"},
{"name": "package", "description": "Package registry"},
{"name": "repository", "description": "Repositories and their contents"},
{"name": "settings", "description": "Server settings"},
{"name": "user", "description": "The authenticated user"}
]
}
@@ -10733,7 +10733,7 @@
"url": "http://opensource.org/licenses/MIT"
},
"title": "Gitea API",
"version": "{{.SwaggerAppVer}}"
"version": "0.0.0+GITEA-API-APP-VERSION"
},
"openapi": "3.0.3",
"paths": {
@@ -33073,7 +33073,6 @@
},
"summary": "Create a repository",
"tags": [
"repository",
"user"
]
}
@@ -34201,7 +34200,45 @@
],
"servers": [
{
"url": "{{.SwaggerAppSubUrl}}/api/v1"
"url": "/GITEA-API-APP-SUBURL/api/v1"
}
],
"tags": [
{
"description": "Site administration",
"name": "admin"
},
{
"description": "Issues, pull requests, comments, labels and milestones",
"name": "issue"
},
{
"description": "Miscellaneous endpoints",
"name": "miscellaneous"
},
{
"description": "User notifications",
"name": "notification"
},
{
"description": "Organizations and teams",
"name": "organization"
},
{
"description": "Package registry",
"name": "package"
},
{
"description": "Repositories and their contents",
"name": "repository"
},
{
"description": "Server settings",
"name": "settings"
},
{
"description": "The authenticated user",
"name": "user"
}
]
}
@@ -17,9 +17,9 @@
"name": "MIT",
"url": "http://opensource.org/licenses/MIT"
},
"version": "{{.SwaggerAppVer}}"
"version": "0.0.0+GITEA-API-APP-VERSION"
},
"basePath": "{{.SwaggerAppSubUrl}}/api/v1",
"basePath": "/GITEA-API-APP-SUBURL/api/v1",
"paths": {
"/admin/actions/jobs": {
"get": {
@@ -20912,7 +20912,6 @@
"application/json"
],
"tags": [
"repository",
"user"
],
"summary": "Create a repository",
@@ -32087,5 +32086,43 @@
{
"TOTPHeader": []
}
],
"tags": [
{
"description": "Site administration",
"name": "admin"
},
{
"description": "Issues, pull requests, comments, labels and milestones",
"name": "issue"
},
{
"description": "Miscellaneous endpoints",
"name": "miscellaneous"
},
{
"description": "User notifications",
"name": "notification"
},
{
"description": "Organizations and teams",
"name": "organization"
},
{
"description": "Package registry",
"name": "package"
},
{
"description": "Repositories and their contents",
"name": "repository"
},
{
"description": "Server settings",
"name": "settings"
},
{
"description": "The authenticated user",
"name": "user"
}
]
}
-6
View File
@@ -1,6 +0,0 @@
{
"info": {
"version": "{{.SwaggerAppVer}}"
},
"basePath": "{{.SwaggerAppSubUrl}}/api/v1"
}
+36
View File
@@ -31,6 +31,42 @@ func TestLinks(t *testing.T) {
t.Run("NoLoginNotExist", testLinksNoLoginNotExist)
t.Run("AsUser", testLinksAsUser)
t.Run("RepoCommon", testLinksRepoCommon)
t.Run("ApiJson", testLinksApiJson)
}
func testLinksApiJson(t *testing.T) {
defer test.MockVariableValue(&setting.AppVer, "1.2.3")()
defer test.MockVariableValue(&setting.AppSubURL)()
t.Run("Swagger", func(t *testing.T) {
for _, subURL := range []string{"", "/sub"} {
setting.AppSubURL = subURL
resp := MakeRequest(t, NewRequest(t, "GET", "/swagger.v1.json"), http.StatusOK)
decoded := DecodeJSON(t, resp, &struct {
BasePath string `json:"basePath"`
Info struct {
Version string `json:"version"`
}
}{})
assert.Equal(t, subURL+"/api/v1", decoded.BasePath)
assert.Equal(t, "1.2.3", decoded.Info.Version)
}
})
t.Run("OpenAPI3", func(t *testing.T) {
for _, subURL := range []string{"", "/sub"} {
setting.AppSubURL = subURL
resp := MakeRequest(t, NewRequest(t, "GET", "/openapi3.v1.json"), http.StatusOK)
decoded := DecodeJSON(t, resp, &struct {
Servers []struct {
URL string `json:"url"`
} `json:"servers"`
Info struct {
Version string `json:"version"`
}
}{})
assert.Equal(t, subURL+"/api/v1", decoded.Servers[0].URL)
assert.Equal(t, "1.2.3", decoded.Info.Version)
}
})
}
func testLinksNoLogin(t *testing.T) {