diff --git a/CHANGELOG.md b/CHANGELOG.md index 70157c2..77cafd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index 41c1aad..f4d45aa 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -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)` @@ -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)` diff --git a/example/byodb-sqlite/router.go b/example/byodb-sqlite/router.go index 20d1084..d1e0e77 100644 --- a/example/byodb-sqlite/router.go +++ b/example/byodb-sqlite/router.go @@ -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 { diff --git a/example/byodb/router.go b/example/byodb/router.go index f96f6cb..5f79765 100644 --- a/example/byodb/router.go +++ b/example/byodb/router.go @@ -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 { diff --git a/httpapi/docs.go b/httpapi/docs.go index 2ae264c..fbd8483 100644 --- a/httpapi/docs.go +++ b/httpapi/docs.go @@ -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(` - - - - - Virtuous API Docs - - - -
-
-
- - - -`, 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. @@ -181,6 +34,7 @@ type DocsOptions struct { DocsFile string OpenAPIPath string OpenAPIFile string + SQLRoot string } // DocOpt mutates DocsOptions. @@ -222,6 +76,15 @@ 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{ @@ -229,14 +92,19 @@ func (r *Router) ServeDocs(opts ...DocOpt) { 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) } @@ -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, ) } diff --git a/httpapi/docs_test.go b/httpapi/docs_test.go index 164dcac..1f08a91 100644 --- a/httpapi/docs_test.go +++ b/httpapi/docs_test.go @@ -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") } diff --git a/httpapi/logger.go b/httpapi/logger.go new file mode 100644 index 0000000..2525a95 --- /dev/null +++ b/httpapi/logger.go @@ -0,0 +1,60 @@ +package httpapi + +import ( + "net/http" + "sync/atomic" + + "github.com/swetjen/virtuous/internal/adminui" +) + +// AttachLogger wraps next with request-event capture for docs live logging. +// +// Call this once at the top-level mux/handler boundary for all-or-nothing logging. +// If next is nil, the router itself is wrapped. +func (r *Router) AttachLogger(next http.Handler) http.Handler { + if r == nil { + return next + } + if next == nil { + next = r + } + if r.events == nil { + r.events = adminui.NewEventFeed(600) + } + atomic.StoreUint32(&r.loggerAttached, 1) + captured := r.events.Capture(next, "", "") + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + atomic.StoreUint32(&r.loggerActive, 1) + captured.ServeHTTP(w, req) + }) +} + +func (r *Router) loggingEnabled() bool { + if r == nil { + return false + } + return atomic.LoadUint32(&r.loggerAttached) == 1 +} + +func (r *Router) loggingActive() bool { + if r == nil { + return false + } + return atomic.LoadUint32(&r.loggerActive) == 1 +} + +func httpLoggerSnippet() string { + return `router := httpapi.NewRouter() +// register routes... +router.ServeAllDocs() + +mux := http.NewServeMux() +mux.Handle("/", router) +// mux.Handle("/assets/", assetsHandler) + +handler := router.AttachLogger(mux) // attach once at top-level +log.Fatal(http.ListenAndServe(":8000", handler)) + +// If router is already top-level: +// handler := router.AttachLogger(router)` +} diff --git a/httpapi/router.go b/httpapi/router.go index 5e543e3..ee72943 100644 --- a/httpapi/router.go +++ b/httpapi/router.go @@ -4,6 +4,8 @@ import ( "log/slog" "net/http" "strings" + + "github.com/swetjen/virtuous/internal/adminui" ) // HandlerMeta provides optional documentation metadata for a handler. @@ -39,6 +41,9 @@ type Router struct { mux *http.ServeMux routes []Route logger *slog.Logger + events *adminui.EventFeed + loggerAttached uint32 + loggerActive uint32 typeOverrides map[string]TypeOverride openAPIOptions *OpenAPIOptions } @@ -48,6 +53,7 @@ func NewRouter() *Router { return &Router{ mux: http.NewServeMux(), logger: slog.Default(), + events: adminui.NewEventFeed(600), } } diff --git a/httpapi/router_event_test.go b/httpapi/router_event_test.go new file mode 100644 index 0000000..41e0825 --- /dev/null +++ b/httpapi/router_event_test.go @@ -0,0 +1,79 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +type eventFeedReq struct { + ID string `json:"id"` +} + +type eventFeedResp struct { + OK bool `json:"ok"` +} + +type eventFeedHandler struct{} + +func (eventFeedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + Encode(w, r, http.StatusOK, eventFeedResp{OK: true}) +} + +func (eventFeedHandler) RequestType() any { return eventFeedReq{} } + +func (eventFeedHandler) ResponseType() any { return eventFeedResp{} } + +func (eventFeedHandler) Metadata() HandlerMeta { + return HandlerMeta{Service: "EventFeed", Method: "Get"} +} + +func TestRouterEventFeedCapturesRequest(t *testing.T) { + router := NewRouter() + router.HandleTyped("GET /events/{id}", eventFeedHandler{}) + + req := httptest.NewRequest(http.MethodGet, "/events/42", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + if got := len(router.events.Snapshot(10)); got != 0 { + t.Fatalf("expected no events before AttachLogger, got %d", got) + } + + wrapped := router.AttachLogger(router) + req = httptest.NewRequest(http.MethodGet, "/events/42", nil) + rec = httptest.NewRecorder() + wrapped.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200 from wrapped handler, got %d", rec.Code) + } + + events := router.events.Snapshot(10) + if len(events) == 0 { + t.Fatalf("expected at least one event") + } + last := events[len(events)-1] + if last.Kind != "request" { + t.Fatalf("expected request event, got %q", last.Kind) + } + if last.Method != http.MethodGet { + t.Fatalf("expected method GET, got %q", last.Method) + } + if last.Path != "/events/42" { + t.Fatalf("expected request path /events/42, got %q", last.Path) + } + if last.Status != http.StatusOK { + t.Fatalf("expected status 200, got %d", last.Status) + } + if !router.loggingEnabled() { + t.Fatalf("expected logger to be marked enabled") + } + if !router.loggingActive() { + t.Fatalf("expected logger to be marked active") + } +} diff --git a/internal/adminui/feed.go b/internal/adminui/feed.go new file mode 100644 index 0000000..1e3d790 --- /dev/null +++ b/internal/adminui/feed.go @@ -0,0 +1,326 @@ +package adminui + +import ( + "bufio" + "encoding/json" + "errors" + "net" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +const ( + defaultMaxEvents = 600 + defaultSnapshotMax = 200 + hardSnapshotMax = 1000 +) + +// Event represents one admin-console activity row. +type Event struct { + ID int64 `json:"id"` + Time string `json:"time"` + Kind string `json:"kind"` + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + Status int `json:"status,omitempty"` + DurationMS int64 `json:"durationMs,omitempty"` + Bytes int64 `json:"bytes,omitempty"` + Outcome string `json:"outcome,omitempty"` + Message string `json:"message,omitempty"` +} + +// EventFeed keeps a bounded in-memory feed and broadcasts live updates via SSE. +type EventFeed struct { + mu sync.RWMutex + maxEvents int + nextID int64 + events []Event + subscribers map[chan Event]struct{} +} + +// NewEventFeed returns an in-memory event feed with a bounded history. +func NewEventFeed(maxEvents int) *EventFeed { + if maxEvents <= 0 { + maxEvents = defaultMaxEvents + } + return &EventFeed{ + maxEvents: maxEvents, + events: make([]Event, 0, maxEvents), + subscribers: make(map[chan Event]struct{}), + } +} + +// RecordSystem appends a non-request event to the feed. +func (f *EventFeed) RecordSystem(message string) { + if f == nil || strings.TrimSpace(message) == "" { + return + } + f.append(Event{ + Kind: "system", + Message: message, + }) +} + +// RecordRequest appends one request outcome to the feed. +func (f *EventFeed) RecordRequest(method, path string, status int, duration time.Duration, bytes int64) { + if f == nil { + return + } + method = strings.ToUpper(strings.TrimSpace(method)) + if method == "" { + method = http.MethodGet + } + path = strings.TrimSpace(path) + if path == "" { + path = "/" + } + if status == 0 { + status = http.StatusOK + } + f.append(Event{ + Kind: "request", + Method: method, + Path: path, + Status: status, + DurationMS: duration.Milliseconds(), + Bytes: bytes, + Outcome: OutcomeForStatus(status), + }) +} + +func (f *EventFeed) append(event Event) { + event.Time = time.Now().UTC().Format(time.RFC3339Nano) + + f.mu.Lock() + f.nextID++ + event.ID = f.nextID + f.events = append(f.events, event) + if len(f.events) > f.maxEvents { + trim := len(f.events) - f.maxEvents + f.events = append([]Event(nil), f.events[trim:]...) + } + for ch := range f.subscribers { + select { + case ch <- event: + default: + } + } + f.mu.Unlock() +} + +// Snapshot returns up to the latest limit events. +func (f *EventFeed) Snapshot(limit int) []Event { + if f == nil { + return nil + } + if limit <= 0 { + limit = defaultSnapshotMax + } + if limit > hardSnapshotMax { + limit = hardSnapshotMax + } + + f.mu.RLock() + defer f.mu.RUnlock() + + total := len(f.events) + if total == 0 { + return []Event{} + } + if limit > total { + limit = total + } + start := total - limit + out := make([]Event, limit) + copy(out, f.events[start:]) + return out +} + +// ServeJSON serves a JSON snapshot of recent events. +func (f *EventFeed) ServeJSON(w http.ResponseWriter, req *http.Request) { + limit := parseLimit(req.URL.Query().Get("limit")) + payload := struct { + Events []Event `json:"events"` + }{ + Events: f.Snapshot(limit), + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(payload) +} + +// ServeStream serves live events using Server-Sent Events. +func (f *EventFeed) ServeStream(w http.ResponseWriter, req *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + ch := make(chan Event, 64) + f.subscribe(ch) + defer f.unsubscribe(ch) + + keepalive := time.NewTicker(20 * time.Second) + defer keepalive.Stop() + + for { + select { + case <-req.Context().Done(): + return + case ev := <-ch: + data, err := json.Marshal(ev) + if err != nil { + continue + } + if _, err := w.Write([]byte("data: ")); err != nil { + return + } + if _, err := w.Write(data); err != nil { + return + } + if _, err := w.Write([]byte("\n\n")); err != nil { + return + } + flusher.Flush() + case <-keepalive.C: + if _, err := w.Write([]byte(": ping\n\n")); err != nil { + return + } + flusher.Flush() + } + } +} + +func (f *EventFeed) subscribe(ch chan Event) { + if f == nil { + return + } + f.mu.Lock() + f.subscribers[ch] = struct{}{} + f.mu.Unlock() +} + +func (f *EventFeed) unsubscribe(ch chan Event) { + if f == nil { + return + } + f.mu.Lock() + delete(f.subscribers, ch) + f.mu.Unlock() + close(ch) +} + +func parseLimit(raw string) int { + if raw == "" { + return defaultSnapshotMax + } + parsed, err := strconv.Atoi(raw) + if err != nil { + return defaultSnapshotMax + } + if parsed <= 0 { + return defaultSnapshotMax + } + if parsed > hardSnapshotMax { + return hardSnapshotMax + } + return parsed +} + +// OutcomeForStatus returns one of ok, invalid, or err for quick UI summaries. +func OutcomeForStatus(status int) string { + switch { + case status >= 500: + return "err" + case status >= 400: + return "invalid" + default: + return "ok" + } +} + +// Capture wraps a handler and records method/path/status/duration/bytes. +func (f *EventFeed) Capture(next http.Handler, methodHint, pathHint string) http.Handler { + if f == nil || next == nil { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + started := time.Now() + recorder := &statusRecorder{ResponseWriter: w} + next.ServeHTTP(recorder, req) + + method := methodHint + if method == "" { + method = req.Method + } + path := pathHint + if path == "" { + path = req.URL.Path + } + f.RecordRequest(method, path, recorder.Status(), time.Since(started), recorder.BytesWritten()) + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int + bytes int64 +} + +func (w *statusRecorder) WriteHeader(status int) { + w.status = status + w.ResponseWriter.WriteHeader(status) +} + +func (w *statusRecorder) Write(data []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + n, err := w.ResponseWriter.Write(data) + w.bytes += int64(n) + return n, err +} + +func (w *statusRecorder) Status() int { + if w.status == 0 { + return http.StatusOK + } + return w.status +} + +func (w *statusRecorder) BytesWritten() int64 { + return w.bytes +} + +func (w *statusRecorder) Flush() { + if flusher, ok := w.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + +func (w *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hijacker, ok := w.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, errors.New("hijacker unsupported") + } + return hijacker.Hijack() +} + +func (w *statusRecorder) Push(target string, opts *http.PushOptions) error { + pusher, ok := w.ResponseWriter.(http.Pusher) + if !ok { + return http.ErrNotSupported + } + return pusher.Push(target, opts) +} + +func (w *statusRecorder) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} diff --git a/internal/adminui/html.go b/internal/adminui/html.go new file mode 100644 index 0000000..7159997 --- /dev/null +++ b/internal/adminui/html.go @@ -0,0 +1,1281 @@ +package adminui + +import ( + "html" + "strconv" + "strings" +) + +// DocsShellOptions configures the integrated docs/admin UI shell. +type DocsShellOptions struct { + Title string + OpenAPIURL string + SQLCatalogURL string + EventsURL string + EventsStreamURL string + LoggingStatusURL string +} + +// DocsShellHTML renders the docs/admin shell HTML. +func DocsShellHTML(opts DocsShellOptions) string { + title := strings.TrimSpace(opts.Title) + if title == "" { + title = "Virtuous Docs" + } + openAPIURL := strings.TrimSpace(opts.OpenAPIURL) + if openAPIURL == "" { + openAPIURL = "/openapi.json" + } + sqlCatalogURL := strings.TrimSpace(opts.SQLCatalogURL) + if sqlCatalogURL == "" { + sqlCatalogURL = "./_admin/sql" + } + eventsURL := strings.TrimSpace(opts.EventsURL) + if eventsURL == "" { + eventsURL = "./_admin/events" + } + eventsStreamURL := strings.TrimSpace(opts.EventsStreamURL) + if eventsStreamURL == "" { + eventsStreamURL = "./_admin/events.stream" + } + loggingStatusURL := strings.TrimSpace(opts.LoggingStatusURL) + if loggingStatusURL == "" { + loggingStatusURL = "./_admin/logging" + } + + replacer := strings.NewReplacer( + "__TITLE__", html.EscapeString(title), + "__OPENAPI_URL__", strconv.Quote(openAPIURL), + "__SQL_CATALOG_URL__", strconv.Quote(sqlCatalogURL), + "__EVENTS_URL__", strconv.Quote(eventsURL), + "__EVENTS_STREAM_URL__", strconv.Quote(eventsStreamURL), + "__LOGGING_STATUS_URL__", strconv.Quote(loggingStatusURL), + ) + return replacer.Replace(docsShellTemplate) +} + +const docsShellTemplate = ` + + + + + __TITLE__ + + + +
+
+
Virtuous Console
+
docs + sql + runtime logs
+
+ +
+ +
+
+
+
+

Total

+

0

+
+
+

OK

+

0

+
+
+

Invalid

+

0

+
+
+

Server Err

+

0

+
+
+
+ offline +
+
+ +
+
+
+

API Reference

+

Scalar-rendered OpenAPI explorer.

+
+
+
+
+
+
+ +
+
+
+

Database Explorer

+

Goose migrations + SQLC queries discovered from db/sql.

+
+ +
+
Loading SQL catalog...
+ +
+ +
+
+
+

Live Logs

+

Recent events with route method, response code, and latency.

+
+
+ +
+ + + + + + + + + + + + + + + +
TimeMethodPath / MessageStatusDurationBytesOutcome
Waiting for events...
+
+
+
+
+ + + + + +` diff --git a/internal/adminui/sql_catalog.go b/internal/adminui/sql_catalog.go new file mode 100644 index 0000000..8164c0a --- /dev/null +++ b/internal/adminui/sql_catalog.go @@ -0,0 +1,153 @@ +package adminui + +import ( + "errors" + "os" + "path/filepath" + "sort" + "strings" +) + +const ( + defaultSQLRoot = "db/sql" + defaultMaxSQLLen = 256 * 1024 +) + +// SQLCatalog summarizes schema/query files under db/sql for docs display. +type SQLCatalog struct { + Root string `json:"root"` + Missing bool `json:"missing"` + Error string `json:"error,omitempty"` + Schemas []SQLFile `json:"schemas"` + Queries []SQLFile `json:"queries"` +} + +// SQLFile describes one SQL file surfaced in the docs explorer. +type SQLFile struct { + Name string `json:"name"` + Path string `json:"path"` + Bytes int64 `json:"bytes"` + Lines int `json:"lines"` + Truncated bool `json:"truncated"` + Content string `json:"content"` +} + +// LoadSQLCatalog reads schema/query SQL files from root (defaults to db/sql). +func LoadSQLCatalog(root string) SQLCatalog { + root = strings.TrimSpace(root) + if root == "" { + root = defaultSQLRoot + } + catalog := SQLCatalog{ + Root: root, + Schemas: []SQLFile{}, + Queries: []SQLFile{}, + } + + info, err := os.Stat(root) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + catalog.Missing = true + return catalog + } + catalog.Error = err.Error() + return catalog + } + if !info.IsDir() { + catalog.Error = "sql root is not a directory" + return catalog + } + + schemas, err := loadSQLDir(root, "schemas") + if err != nil { + catalog.Error = err.Error() + return catalog + } + queries, err := loadSQLDir(root, "queries") + if err != nil { + catalog.Error = err.Error() + return catalog + } + catalog.Schemas = schemas + catalog.Queries = queries + return catalog +} + +func loadSQLDir(root, subdir string) ([]SQLFile, error) { + dir := filepath.Join(root, subdir) + info, err := os.Stat(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return []SQLFile{}, nil + } + return nil, err + } + if !info.IsDir() { + return nil, errors.New(subdir + " is not a directory") + } + + files := []SQLFile{} + err = filepath.WalkDir(dir, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + if !strings.EqualFold(filepath.Ext(d.Name()), ".sql") { + return nil + } + item, err := loadSQLFile(root, path) + if err != nil { + return err + } + files = append(files, item) + return nil + }) + if err != nil { + return nil, err + } + + sort.Slice(files, func(i, j int) bool { + return files[i].Path < files[j].Path + }) + return files, nil +} + +func loadSQLFile(root, path string) (SQLFile, error) { + content, err := os.ReadFile(path) + if err != nil { + return SQLFile{}, err + } + + rel := path + if relPath, err := filepath.Rel(root, path); err == nil { + rel = relPath + } + rel = filepath.ToSlash(rel) + + truncated := false + if int64(len(content)) > defaultMaxSQLLen { + content = content[:defaultMaxSQLLen] + truncated = true + } + text := string(content) + lines := 0 + if text != "" { + lines = strings.Count(text, "\n") + 1 + } + + info, err := os.Stat(path) + if err != nil { + return SQLFile{}, err + } + + return SQLFile{ + Name: filepath.Base(path), + Path: rel, + Bytes: info.Size(), + Lines: lines, + Truncated: truncated, + Content: text, + }, nil +} diff --git a/rpc/docs.go b/rpc/docs.go index a9f8697..c08437f 100644 --- a/rpc/docs.go +++ b/rpc/docs.go @@ -1,171 +1,25 @@ package rpc import ( - "fmt" + "encoding/json" "log" "net/http" "os" "strings" + + "github.com/swetjen/virtuous/internal/adminui" ) -// 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(` - - - - - Virtuous RPC Docs - - - -
-
-
- - - -`, openAPIPath) + return adminui.DocsShellHTML(adminui.DocsShellOptions{ + Title: "Virtuous RPC 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. @@ -179,6 +33,7 @@ type DocsOptions struct { DocsFile string OpenAPIPath string OpenAPIFile string + SQLRoot string } // DocOpt mutates DocsOptions. @@ -220,6 +75,15 @@ 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 + } + } +} + // ServeDocs registers default docs and OpenAPI routes on the router. func (r *Router) ServeDocs(opts ...DocOpt) { config := DocsOptions{ @@ -227,12 +91,17 @@ func (r *Router) ServeDocs(opts ...DocOpt) { DocsFile: "docs.html", OpenAPIPath: "/rpc/openapi.json", OpenAPIFile: "openapi.json", + SQLRoot: "db/sql", } for _, opt := range opts { opt(&config) } + if r.events == nil { + r.events = adminui.NewEventFeed(600) + } + docsHTML := DefaultDocsHTML(config.OpenAPIPath) openAPI, err := r.OpenAPI() if err != nil { @@ -244,6 +113,10 @@ func (r *Router) ServeDocs(opts ...DocOpt) { docsBase = "/rpc/docs" } docsIndex := docsBase + "/" + adminSQLPath := docsIndex + "_admin/sql" + adminEventsPath := docsIndex + "_admin/events" + adminEventsStreamPath := docsIndex + "_admin/events.stream" + adminLoggingPath := docsIndex + "_admin/logging" r.mux.Handle("GET "+docsBase, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { http.Redirect(w, req, docsIndex, http.StatusMovedPermanently) @@ -255,12 +128,40 @@ func (r *Router) ServeDocs(opts ...DocOpt) { })) r.mux.Handle("GET "+config.OpenAPIPath, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", "text/json; charset=utf-8") + w.Header().Set("Content-Type", "application/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: rpcLoggerSnippet(), + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(payload) + })) + + r.events.RecordSystem("docs online: " + docsIndex) r.logger.Info( "rpc docs online", "path", docsIndex, "openapi", config.OpenAPIPath, + "sql", adminSQLPath, + "events", adminEventsPath, + "stream", adminEventsStreamPath, + "logging", adminLoggingPath, ) } diff --git a/rpc/docs_test.go b/rpc/docs_test.go index 632c9e5..9232463 100644 --- a/rpc/docs_test.go +++ b/rpc/docs_test.go @@ -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 = \"/rpc/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") } diff --git a/rpc/handler.go b/rpc/handler.go index f793c22..644ebcf 100644 --- a/rpc/handler.go +++ b/rpc/handler.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "net/http" "reflect" "runtime" @@ -176,10 +175,9 @@ func writeJSON(w http.ResponseWriter, status int, v reflect.Value) { return } enc := json.NewEncoder(w) - if err := enc.Encode(v.Interface()); err != nil { - msg := fmt.Sprintf("encode json: %v", err) - http.Error(w, msg, http.StatusInternalServerError) - } + // At this point headers are already written; do not attempt to write another + // status line on encode/write failure. + _ = enc.Encode(v.Interface()) } func buildRPCPath(prefix, pkgName, funcName string) string { diff --git a/rpc/handler_test.go b/rpc/handler_test.go index 0c967a6..d570a97 100644 --- a/rpc/handler_test.go +++ b/rpc/handler_test.go @@ -3,8 +3,10 @@ package rpc import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" + "reflect" "strings" "testing" ) @@ -74,3 +76,37 @@ func TestRPCHandleInvalid(t *testing.T) { t.Fatalf("unexpected error response: %q", body.Error) } } + +func TestWriteJSONEncodeFailureDoesNotWriteHeaderTwice(t *testing.T) { + w := &failingResponseWriter{header: make(http.Header)} + writeJSON(w, StatusOK, reflect.ValueOf(testResp{Message: "ok"})) + + if w.writeHeaderCalls != 1 { + t.Fatalf("expected exactly one WriteHeader call, got %d", w.writeHeaderCalls) + } + if w.status != StatusOK { + t.Fatalf("expected status %d, got %d", StatusOK, w.status) + } + if got := w.header.Get("Content-Type"); got != "application/json" { + t.Fatalf("expected content type application/json, got %q", got) + } +} + +type failingResponseWriter struct { + header http.Header + status int + writeHeaderCalls int +} + +func (w *failingResponseWriter) Header() http.Header { + return w.header +} + +func (w *failingResponseWriter) WriteHeader(statusCode int) { + w.status = statusCode + w.writeHeaderCalls++ +} + +func (w *failingResponseWriter) Write(_ []byte) (int, error) { + return 0, errors.New("write failed") +} diff --git a/rpc/logger.go b/rpc/logger.go new file mode 100644 index 0000000..7d7f917 --- /dev/null +++ b/rpc/logger.go @@ -0,0 +1,60 @@ +package rpc + +import ( + "net/http" + "sync/atomic" + + "github.com/swetjen/virtuous/internal/adminui" +) + +// AttachLogger wraps next with request-event capture for docs live logging. +// +// Call this once at the top-level mux/handler boundary for all-or-nothing logging. +// If next is nil, the router itself is wrapped. +func (r *Router) AttachLogger(next http.Handler) http.Handler { + if r == nil { + return next + } + if next == nil { + next = r + } + if r.events == nil { + r.events = adminui.NewEventFeed(600) + } + atomic.StoreUint32(&r.loggerAttached, 1) + captured := r.events.Capture(next, "", "") + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + atomic.StoreUint32(&r.loggerActive, 1) + captured.ServeHTTP(w, req) + }) +} + +func (r *Router) loggingEnabled() bool { + if r == nil { + return false + } + return atomic.LoadUint32(&r.loggerAttached) == 1 +} + +func (r *Router) loggingActive() bool { + if r == nil { + return false + } + return atomic.LoadUint32(&r.loggerActive) == 1 +} + +func rpcLoggerSnippet() string { + return `router := rpc.NewRouter(rpc.WithPrefix("/rpc")) +// register routes... +router.ServeAllDocs() + +mux := http.NewServeMux() +mux.Handle("/rpc/", router) +// mux.Handle("/", otherHandler) + +handler := router.AttachLogger(mux) // attach once at top-level +log.Fatal(http.ListenAndServe(":8000", handler)) + +// If router is already top-level: +// handler := router.AttachLogger(router)` +} diff --git a/rpc/pgtype_test.go b/rpc/pgtype_test.go index accb96b..8fbc681 100644 --- a/rpc/pgtype_test.go +++ b/rpc/pgtype_test.go @@ -175,8 +175,8 @@ func TestRPCPgtypeOpenAPIAndClients(t *testing.T) { t.Fatalf("js client missing %s", name) } } - if !strings.Contains(ts.String(), "raw: any;") { - t.Fatalf("ts client should type raw as any") + if !strings.Contains(ts.String(), "raw: object|any[];") { + t.Fatalf("ts client should type raw as object|any[]") } if !strings.Contains(py.String(), "raw: Any") { t.Fatalf("py client should type raw as Any") diff --git a/rpc/router.go b/rpc/router.go index 05ebdb5..a9613ec 100644 --- a/rpc/router.go +++ b/rpc/router.go @@ -3,6 +3,8 @@ package rpc import ( "log/slog" "net/http" + + "github.com/swetjen/virtuous/internal/adminui" ) // Router registers RPC handlers and exposes documentation metadata. @@ -12,6 +14,9 @@ type Router struct { prefix string guards []Guard logger *slog.Logger + events *adminui.EventFeed + loggerAttached uint32 + loggerActive uint32 typeOverrides map[string]TypeOverride openAPIOptions *OpenAPIOptions } @@ -52,6 +57,7 @@ func NewRouter(opts ...RouterOption) *Router { prefix: normalizePrefix(config.Prefix), guards: append([]Guard(nil), config.Guards...), logger: slog.Default(), + events: adminui.NewEventFeed(600), } } diff --git a/rpc/router_test.go b/rpc/router_test.go index 0348c35..2630c8a 100644 --- a/rpc/router_test.go +++ b/rpc/router_test.go @@ -134,6 +134,57 @@ func TestRPCRouterAndHandlerGuardOrder(t *testing.T) { } } +func TestRPCEventFeedRequiresAttachLogger(t *testing.T) { + router := NewRouter() + router.HandleRPC(testHandler) + path := router.Routes()[0].Path + + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"name":"Virtuous"}`)) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + if got := len(router.events.Snapshot(10)); got != 0 { + t.Fatalf("expected no events before AttachLogger, got %d", got) + } + + wrapped := router.AttachLogger(router) + req = httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"name":"Virtuous"}`)) + rec = httptest.NewRecorder() + wrapped.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200 from wrapped handler, got %d", rec.Code) + } + + events := router.events.Snapshot(10) + if len(events) == 0 { + t.Fatalf("expected at least one event") + } + last := events[len(events)-1] + if last.Kind != "request" { + t.Fatalf("expected request event, got %q", last.Kind) + } + if last.Path != path { + t.Fatalf("expected event path %q, got %q", path, last.Path) + } + if last.Status != http.StatusOK { + t.Fatalf("expected event status 200, got %d", last.Status) + } + if last.Outcome != "ok" { + t.Fatalf("expected outcome ok, got %q", last.Outcome) + } + if !router.loggingEnabled() { + t.Fatalf("expected logger to be marked enabled") + } + if !router.loggingActive() { + t.Fatalf("expected logger to be marked active") + } +} + func expectPanic(t *testing.T, fn func()) { t.Helper() defer func() { diff --git a/schema/registry.go b/schema/registry.go index 137d468..db07cb7 100644 --- a/schema/registry.go +++ b/schema/registry.go @@ -156,7 +156,7 @@ func defaultTypeOverrides() map[string]TypeOverride { OpenAPIFormat: "date-time", }, "encoding/json.RawMessage": { - JSType: "any", + JSType: "object|any[]", PyType: "Any", OpenAPIType: "object", },