feat: Publish an OpenAPI 3.1 document and a Scalar reference
Chart Release / chart (push) Successful in 25s
Server Deploy / deploy (push) Successful in 10m17s

Generated from swaggo v2 annotations, committed rather than built into the
image: the runtime stage is scratch and adding codegen puts the toolchain
in the build. CI regenerates and diffs, so an annotation edited without
regenerating fails the build — without that the annotations would drift
while still looking authoritative.

Scalar is vendored rather than loaded from a CDN, because air-gapped
self-hosted installs are supported and a reference page that fails closed
offline is a support ticket.
This commit is contained in:
2026-08-12 15:23:02 +00:00
parent a85a354e57
commit 4c88d6e768
6 changed files with 11019 additions and 0 deletions
+18
View File
@@ -95,6 +95,24 @@ jobs:
docker login ${{ vars.DOCKER_HOST }} \
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Set up Go
if: steps.changed.outputs.server == 'true'
uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true
cache-dependency-path: server/go.sum
- name: Verify the OpenAPI document is current
if: steps.changed.outputs.server == 'true'
run: |
go install github.com/swaggo/swag/v2/cmd/swag@v2.0.0-rc5
cd server
swag init --generalInfo cmd/main.go --dir ./,../shared \
--output internal/api/docs --outputTypes json --v3.1
mv -f internal/api/docs/swagger.json internal/api/docs/openapi.json
git diff --exit-code internal/api/docs/openapi.json
- name: Build and push server image
if: steps.changed.outputs.server == 'true'
run: |
+23
View File
@@ -0,0 +1,23 @@
// Package docs holds the generated OpenAPI document and the vendored Scalar
// bundle that renders it.
//
// openapi.json is generated by `swag init` and committed rather than built into
// the image: server/Dockerfile produces a scratch runtime from a Go build
// stage, and adding codegen there means putting the toolchain in the image.
// server-deploy.yml regenerates and diffs it, so an annotation edited without
// regenerating fails the build.
//
// scalar.standalone.js is vendored from
// https://cdn.jsdelivr.net/npm/@scalar/api-reference@latest/dist/browser/standalone.js
// and refreshed by hand. Fetched at build time it would break an air-gapped
// install; fetched at page load it would break an air-gapped install more
// visibly.
package docs
import _ "embed"
//go:embed openapi.json
var OpenAPI []byte
//go:embed scalar.standalone.js
var ScalarJS []byte
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+71
View File
@@ -0,0 +1,71 @@
package api
import (
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/api/docs"
"github.com/gin-gonic/gin"
)
// scalarPage renders the reference against this instance's own spec, so "Try
// it" acts on the reader's API with the reader's session.
const scalarPage = `<!doctype html>
<html>
<head>
<title>Vantage API</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="app"></div>
<script src="/api/docs/scalar.js"></script>
<script>
Scalar.createApiReference('#app', {
url: '/api/openapi.json',
theme: 'deepSpace',
})
</script>
</body>
</html>`
// getOpenAPI godoc
//
// @Summary Get the OpenAPI document
// @Description Generated from swaggo annotations at build time and committed; served verbatim.
// @Tags docs
// @Produce json
// @Success 200 {object} map[string]any
// @Security cookieAuth
// @Security bearerAuth
// @Router /openapi.json [get]
func getOpenAPI(c *gin.Context) {
c.Data(http.StatusOK, "application/json; charset=utf-8", docs.OpenAPI)
}
// getScalarJS godoc
//
// @Summary Get the vendored Scalar bundle
// @Description Served locally rather than from a CDN so the reference page works on an air-gapped install.
// @Tags docs
// @Produce application/javascript
// @Success 200 {string} string "javascript bundle"
// @Security cookieAuth
// @Security bearerAuth
// @Router /docs/scalar.js [get]
func getScalarJS(c *gin.Context) {
c.Data(http.StatusOK, "application/javascript; charset=utf-8", docs.ScalarJS)
}
// getAPIDocs godoc
//
// @Summary API reference page
// @Description Renders the Scalar reference against this instance's own OpenAPI document.
// @Tags docs
// @Produce html
// @Success 200 {string} string "HTML page"
// @Security cookieAuth
// @Security bearerAuth
// @Router /docs [get]
func getAPIDocs(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(scalarPage))
}
+7
View File
@@ -148,6 +148,13 @@ var routeScopes = map[string]string{
"GET /api/tokens/scopes": "settings:read",
"POST /api/tokens": "settings:write",
"DELETE /api/tokens/:id": "settings:write",
// The generated OpenAPI document and its Scalar reference page. Read-only,
// so they share the settings:read scope with the rest of the docs a token
// can already see about its own instance.
"GET /api/openapi.json": "settings:read",
"GET /api/docs": "settings:read",
"GET /api/docs/scalar.js": "settings:read",
}
// RequireScopes enforces routeScopes for token-authenticated requests and does