Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 0.0.23

- Replace the single-pane docs page with an integrated docs/admin shell (top navigation + summary tiles).
- Add a built-in SQL explorer panel that surfaces `db/sql/schemas` and `db/sql/queries` files for quick visibility.
- Add a live runtime log panel with request status/latency history via JSON snapshot and SSE stream endpoints.
- Make runtime request logging explicit and mux-level via `AttachLogger(...)`; when not attached, docs show a zero-state with the exact enablement snippet.
- Fix RPC response encoding error handling to avoid superfluous `WriteHeader` warnings on partial/broken client writes.

## 0.0.22

- Replace default Swagger UI docs pages with Scalar API Reference for both RPC and legacy HTTP routers.
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This is a quick index of the primary entry points used in Virtuous apps. For ful
- `(*rpc.Router).HandleRPC(fn any, guards ...rpc.Guard)`
- `(*rpc.Router).ServeDocs(opts ...rpc.DocOpt)`
- `(*rpc.Router).ServeAllDocs(opts ...rpc.ServeAllDocsOpt)`
- `(*rpc.Router).AttachLogger(next http.Handler)`
- `(*rpc.Router).OpenAPI()`
- `(*rpc.Router).Routes()`
- `(*rpc.Router).SetTypeOverrides(overrides map[string]rpc.TypeOverride)`
Expand All @@ -29,6 +30,7 @@ This is a quick index of the primary entry points used in Virtuous apps. For ful
- `httpapi.WrapFunc(handler func(http.ResponseWriter, *http.Request), req any, resp any, meta httpapi.HandlerMeta)`
- `(*httpapi.Router).ServeDocs(opts ...httpapi.DocOpt)`
- `(*httpapi.Router).ServeAllDocs(opts ...httpapi.ServeAllDocsOpt)`
- `(*httpapi.Router).AttachLogger(next http.Handler)`
- `(*httpapi.Router).OpenAPI()`
- `(*httpapi.Router).Routes()`
- `(*httpapi.Router).SetTypeOverrides(overrides map[string]httpapi.TypeOverride)`
Expand Down
3 changes: 2 additions & 1 deletion example/byodb-sqlite/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ func NewRouter(cfg config.Config, queries *db.Queries, pool *sql.DB) http.Handle
mux.Handle("/rpc/", rpcRouter)
mux.Handle("/", embedAndServeReact())

return httpapi.Cors(
handler := httpapi.Cors(
httpapi.WithAllowedOrigins(cfg.AllowedOrigins...),
)(mux)
return rpcRouter.AttachLogger(handler)
}

func BuildRouter(cfg config.Config, queries *db.Queries, pool *sql.DB) *rpc.Router {
Expand Down
3 changes: 2 additions & 1 deletion example/byodb/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ func NewRouter(cfg config.Config, queries *db.Queries, pool *pgxpool.Pool) http.
mux.Handle("/rpc/", rpcRouter)
mux.Handle("/", embedAndServeReact())

return httpapi.Cors(
handler := httpapi.Cors(
httpapi.WithAllowedOrigins(cfg.AllowedOrigins...),
)(mux)
return rpcRouter.AttachLogger(handler)
}

func BuildRouter(cfg config.Config, queries *db.Queries, pool *pgxpool.Pool) *rpc.Router {
Expand Down
232 changes: 66 additions & 166 deletions httpapi/docs.go
Original file line number Diff line number Diff line change
@@ -1,173 +1,26 @@
package httpapi

import (
"fmt"
"encoding/json"
"log"
"net/http"
"os"
"strings"

"github.com/swetjen/virtuous/internal/adminui"
"github.com/swetjen/virtuous/internal/textutil"
)

// DefaultDocsHTML returns a Scalar API Reference HTML page for the provided OpenAPI path.
// DefaultDocsHTML returns the integrated docs/admin UI HTML.
func DefaultDocsHTML(openAPIPath string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Virtuous API Docs</title>
<style>
* {
box-sizing: border-box;
}

body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at 10%% 10%%, #e5f4ff 0%%, rgba(229, 244, 255, 0) 45%%),
radial-gradient(circle at 90%% 0%%, #d9ffe9 0%%, rgba(217, 255, 233, 0) 35%%),
#f4f7fb;
}

.docs-shell {
padding: 12px;
}

#app {
min-height: calc(100vh - 24px);
border: 1px solid #c7d4e0;
border-radius: 14px;
overflow: hidden;
background: #ffffff;
box-shadow: 0 20px 60px rgba(17, 46, 79, 0.1);
}

@media (max-width: 800px) {
.docs-shell {
padding: 0;
}

#app {
min-height: 100vh;
border-radius: 0;
border: 0;
box-shadow: none;
}
}
</style>
</head>
<body>
<main class="docs-shell">
<div id="app"></div>
</main>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
<script>
(function () {
const VIRTUOUS_DEBUG_AUTH = false
const OPENAPI_URL = %q

function buildPrefixMap(spec) {
const map = {}
if (!spec || !spec.components || !spec.components.securitySchemes) {
return map
}
const schemes = spec.components.securitySchemes
Object.keys(schemes).forEach(function (key) {
const scheme = schemes[key]
if (!scheme) {
return
}
const location = scheme.in
const headerName = scheme.name
const prefix = scheme["x-virtuousauth-prefix"]
if (location !== "header" || !headerName || !prefix) {
return
}
map[String(headerName).toLowerCase()] = String(prefix)
})
return map
}

function installFetchAuthPrefixer(prefixMap) {
window.__virtuousDocsPrefixMap = prefixMap || {}
if (window.__virtuousDocsFetchWrapped) {
return
}
window.__virtuousDocsFetchWrapped = true
const originalFetch = window.fetch.bind(window)
window.fetch = function (input, init) {
const activePrefixMap = window.__virtuousDocsPrefixMap || {}
const headerNames = Object.keys(activePrefixMap)
if (headerNames.length === 0) {
return originalFetch(input, init)
}
try {
const sourceHeaders = init && init.headers ? init.headers : (input instanceof Request ? input.headers : undefined)
const headers = new Headers(sourceHeaders || {})
let changed = false
headerNames.forEach(function (headerName) {
const prefix = activePrefixMap[headerName]
const current = headers.get(headerName)
if (!prefix || !current) {
return
}
const expected = prefix + " "
if (!String(current).startsWith(expected)) {
headers.set(headerName, expected + current)
changed = true
if (VIRTUOUS_DEBUG_AUTH && typeof console !== "undefined") {
console.log("virtuous docs auth prefix applied", headerName)
}
}
})
if (!changed) {
return originalFetch(input, init)
}
if (input instanceof Request) {
const nextInit = init ? Object.assign({}, init) : {}
nextInit.headers = headers
return originalFetch(new Request(input, nextInit))
}
const nextInit = init ? Object.assign({}, init) : {}
nextInit.headers = headers
return originalFetch(input, nextInit)
} catch (e) {
return originalFetch(input, init)
}
}
}

function renderScalar(prefixMap) {
installFetchAuthPrefixer(prefixMap)
if (typeof Scalar === "undefined" || !Scalar.createApiReference) {
const app = document.getElementById("app")
if (app) {
app.innerHTML = "<pre style=\"padding: 24px; margin: 0;\">Unable to load Scalar API Reference.</pre>"
}
return
}
Scalar.createApiReference("#app", {
url: OPENAPI_URL,
})
}

fetch(OPENAPI_URL)
.then(function (resp) {
return resp.json()
})
.then(function (spec) {
renderScalar(buildPrefixMap(spec))
})
.catch(function () {
renderScalar({})
})
})()
</script>
</body>
</html>`, openAPIPath)
return adminui.DocsShellHTML(adminui.DocsShellOptions{
Title: "Virtuous API Docs",
OpenAPIURL: openAPIPath,
SQLCatalogURL: "./_admin/sql",
EventsURL: "./_admin/events",
EventsStreamURL: "./_admin/events.stream",
LoggingStatusURL: "./_admin/logging",
})
}

// WriteDocsHTMLFile writes the default docs HTML to the path provided.
Expand All @@ -181,6 +34,7 @@ type DocsOptions struct {
DocsFile string
OpenAPIPath string
OpenAPIFile string
SQLRoot string
}

// DocOpt mutates DocsOptions.
Expand Down Expand Up @@ -222,21 +76,35 @@ func WithOpenAPIFile(path string) DocOpt {
}
}

// WithSQLRoot sets the root folder scanned for db/sql schema and query files.
func WithSQLRoot(path string) DocOpt {
return func(o *DocsOptions) {
if path != "" {
o.SQLRoot = path
}
}
}

// HandleDocs registers default docs and OpenAPI routes on the router.
func (r *Router) ServeDocs(opts ...DocOpt) {
config := DocsOptions{
DocsPath: "/docs",
DocsFile: "docs.html",
OpenAPIPath: "/openapi.json",
OpenAPIFile: "openapi.json",
SQLRoot: "db/sql",
}

for _, opt := range opts {
opt(&config)
}

docsHtml := DefaultDocsHTML(config.OpenAPIPath)
OpenAPI, err := r.OpenAPI()
if r.events == nil {
r.events = adminui.NewEventFeed(600)
}

docsHTML := DefaultDocsHTML(config.OpenAPIPath)
openAPI, err := r.OpenAPI()
if err != nil {
log.Fatal(err)
}
Expand All @@ -246,24 +114,56 @@ func (r *Router) ServeDocs(opts ...DocOpt) {
docsBase = "/docs"
}
docsIndex := docsBase + "/"
adminSQLPath := docsIndex + "_admin/sql"
adminEventsPath := docsIndex + "_admin/events"
adminEventsStreamPath := docsIndex + "_admin/events.stream"
adminLoggingPath := docsIndex + "_admin/logging"

r.Handle("GET "+docsBase, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
r.mux.Handle("GET "+docsBase, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, docsIndex, http.StatusMovedPermanently)
}))

r.Handle("GET "+docsIndex, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
r.mux.Handle("GET "+docsIndex, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(docsHtml))
w.Write([]byte(docsHTML))
}))

r.mux.Handle("GET "+config.OpenAPIPath, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Write(openAPI)
}))

r.Handle("GET "+config.OpenAPIPath, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "text/json; charset=utf-8")
w.Write(OpenAPI)
r.mux.Handle("GET "+adminSQLPath, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
catalog := adminui.LoadSQLCatalog(config.SQLRoot)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(catalog)
}))

r.mux.Handle("GET "+adminEventsPath, http.HandlerFunc(r.events.ServeJSON))
r.mux.Handle("GET "+adminEventsStreamPath, http.HandlerFunc(r.events.ServeStream))
r.mux.Handle("GET "+adminLoggingPath, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
payload := struct {
Enabled bool `json:"enabled"`
Active bool `json:"active"`
Snippet string `json:"snippet"`
}{
Enabled: r.loggingEnabled(),
Active: r.loggingActive(),
Snippet: httpLoggerSnippet(),
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(payload)
}))

r.events.RecordSystem("docs online: " + docsIndex)
r.logger.Info(
"docs online",
"path", docsIndex,
"openapi", config.OpenAPIPath,
"sql", adminSQLPath,
"events", adminEventsPath,
"stream", adminEventsStreamPath,
"logging", adminLoggingPath,
)
}

Expand Down
14 changes: 13 additions & 1 deletion httpapi/docs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,24 @@ func TestDefaultDocsHTMLUsesScalar(t *testing.T) {
if !strings.Contains(html, "https://cdn.jsdelivr.net/npm/@scalar/api-reference") {
t.Fatalf("expected Scalar script in docs HTML")
}
if !strings.Contains(html, "Scalar.createApiReference(\"#app\"") {
if !strings.Contains(html, "Scalar.createApiReference(\"#scalar-root\"") {
t.Fatalf("expected Scalar initialization in docs HTML")
}
if !strings.Contains(html, "const OPENAPI_URL = \"/openapi.json\"") {
t.Fatalf("expected openapi path in docs HTML")
}
if !strings.Contains(html, "Database Explorer") {
t.Fatalf("expected SQL explorer section in docs HTML")
}
if !strings.Contains(html, "const EVENTS_URL = \"./_admin/events\"") {
t.Fatalf("expected live events endpoint in docs HTML")
}
if !strings.Contains(html, "const SQL_CATALOG_URL = \"./_admin/sql\"") {
t.Fatalf("expected sql catalog endpoint in docs HTML")
}
if !strings.Contains(html, "const LOGGING_STATUS_URL = \"./_admin/logging\"") {
t.Fatalf("expected logging status endpoint in docs HTML")
}
if strings.Contains(html, "SwaggerUIBundle") {
t.Fatalf("unexpected Swagger UI bundle in docs HTML")
}
Expand Down
Loading
Loading