From 00b42234e96bbbe259f40c8084a60d29f121a725 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 21 Apr 2026 13:06:26 +0200 Subject: [PATCH] feat(api/v2): serve Scalar docs UI at /api/v2/docs --- magefile.go | 40 +++++++++++++++++++ pkg/routes/api/v2/docs.go | 40 +++++++++++++++++++ pkg/routes/api/v2/scalar/scalar.html | 13 ++++++ pkg/routes/api/v2/scalar/scalar.standalone.js | 13 ++++++ pkg/routes/routes.go | 4 ++ 5 files changed, 110 insertions(+) create mode 100644 pkg/routes/api/v2/docs.go create mode 100644 pkg/routes/api/v2/scalar/scalar.html create mode 100644 pkg/routes/api/v2/scalar/scalar.standalone.js diff --git a/magefile.go b/magefile.go index 2d546d378..f0b614b72 100644 --- a/magefile.go +++ b/magefile.go @@ -1510,6 +1510,46 @@ func (Generate) ConfigYAML(commented bool) { generateConfigYAMLFromJSON(DefaultConfigYAMLSamplePath, commented) } +// ScalarBundle downloads the Scalar API reference standalone JS bundle into +// pkg/routes/api/v2/scalar/. Version is pinned to match the Scalar version +// used in Huma's internal docs at the time of last update. +func (Generate) ScalarBundle() error { + const ( + version = "1.44.20" + dest = "pkg/routes/api/v2/scalar/scalar.standalone.js" + ) + url := fmt.Sprintf("https://unpkg.com/@scalar/api-reference@%s/dist/browser/standalone.js", version) + + fmt.Printf("Downloading Scalar bundle %s from %s\n", version, url) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) //nolint:gosec // This is a dev-only mage task and the URL is hard-coded above. + if err != nil { + return fmt.Errorf("build scalar bundle request: %w", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("download scalar bundle: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download scalar bundle: unexpected status %s", resp.Status) + } + + var buf bytes.Buffer + if _, err := io.Copy(&buf, resp.Body); err != nil { + return fmt.Errorf("read scalar bundle body: %w", err) + } + + if err := os.WriteFile(dest, buf.Bytes(), 0o600); err != nil { + return fmt.Errorf("write %s: %w", dest, err) + } + + fmt.Printf("Wrote %d bytes to %s\n", buf.Len(), dest) + return nil +} + func localBranchExists(ctx context.Context, name string) bool { return exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+name).Run() == nil //nolint:gosec // This is a dev-only mage task and the branch name is supplied by the developer running it. } diff --git a/pkg/routes/api/v2/docs.go b/pkg/routes/api/v2/docs.go new file mode 100644 index 000000000..c2f15e53c --- /dev/null +++ b/pkg/routes/api/v2/docs.go @@ -0,0 +1,40 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + _ "embed" + "net/http" + + "github.com/labstack/echo/v5" +) + +//go:embed scalar/scalar.html +var scalarHTML string + +//go:embed scalar/scalar.standalone.js +var scalarJS []byte + +// ScalarUI serves the Scalar API reference HTML; assets ship locally to avoid a CDN dependency. +func ScalarUI(c *echo.Context) error { + return c.HTML(http.StatusOK, scalarHTML) +} + +// ScalarJS serves the embedded Scalar standalone JS bundle. +func ScalarJS(c *echo.Context) error { + return c.Blob(http.StatusOK, echo.MIMEApplicationJavaScript, scalarJS) +} diff --git a/pkg/routes/api/v2/scalar/scalar.html b/pkg/routes/api/v2/scalar/scalar.html new file mode 100644 index 000000000..f1e4136d5 --- /dev/null +++ b/pkg/routes/api/v2/scalar/scalar.html @@ -0,0 +1,13 @@ + + + + + + + Vikunja API + + + + + + diff --git a/pkg/routes/api/v2/scalar/scalar.standalone.js b/pkg/routes/api/v2/scalar/scalar.standalone.js new file mode 100644 index 000000000..6e22b63fa --- /dev/null +++ b/pkg/routes/api/v2/scalar/scalar.standalone.js @@ -0,0 +1,13 @@ +// Placeholder — the actual Scalar standalone bundle is vendored by +// `mage generate:scalarBundle` (see magefile.go) and lands in a follow-up +// PR to keep this one reviewable. Until then, /api/v2/docs renders the +// HTML shell but the Scalar reference itself will not initialise. +(function () { + var el = document.getElementById('api-reference'); + if (el && el.parentNode) { + var note = document.createElement('p'); + note.style.cssText = 'font-family:system-ui;padding:2rem;color:#555'; + note.textContent = 'Scalar bundle not vendored yet — run `mage generate:scalarBundle` to fetch it.'; + el.parentNode.insertBefore(note, el); + } +})(); diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 511547494..ceb82d20b 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -384,6 +384,10 @@ func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) { api := apiv2.NewAPI(e, a) + // Scalar docs UI — embedded, no CDN. See pkg/routes/api/v2/docs.go. + a.GET("/docs", apiv2.ScalarUI) + a.GET("/docs/scalar.standalone.js", apiv2.ScalarJS) + // Resource registrations. apiv2.RegisterLabelRoutes(api)