From 4d2e0c1a42e2e88758f79175ca7bfcd3d4682188 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Tue, 5 May 2026 08:28:00 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(server):=20/api/info=20aggrega?= =?UTF-8?q?tor=20+=20frontend=20version=20footer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint 2 of autonomous trainer day 2026-05-05. Mistral-implemented through ICM workspace ship-info-aggregator (bootstrapped backend + BDD before hitting price limit at stage 02), Claude-completed for frontend + Playwright + verifier + PR. Backend: - GET /api/info aggregator returning version, commit_short, build_date, uptime_seconds, cache_enabled, healthz_status (single round trip) - Optional cache via existing cache service (X-Cache: HIT/MISS) - BDD scenario @critical covers happy path + version regex; cache scenario kept under @skip @bdd-deferred until BDD harness gains a cache-enabled mode Frontend: - AppFooterView (dumb) + AppFooter (smart wrapper, useFetch) following the HealthDashboard / HealthDashboardView pattern - layouts/default.vue auto-applied via NuxtLayout in app.vue - humaniseUptime helper in utils/ - Playwright tests use route.fulfill mocking (decoupled from dev-proxy infra), assert visible AND content (PR #32 lesson) Docs: - documentation/API.md /api/info entry with schema and rationale - ADR-0026 documents composite endpoint vs separate calls choice Verifier verdict (skill-driven, audit at stage 04): APPROVE_WITH_NITS. Nits: handleInfo is 51 lines (could split into builder + emitter); X-Cache: DISABLED could improve ops clarity. Out-of-scope follow-up: existing tests/e2e/health.spec.ts happy path hits the same dev-proxy infra issue as my footer happy path before mocking. Same fix (server: false + route.fulfill) would apply. --- adr/0026-composite-info-endpoint.md | 197 ++++++++++++++++++ documentation/API.md | 16 ++ features/info/info.feature | 38 ++++ features/info/info_test.go | 16 ++ frontend/app.vue | 4 +- frontend/components/AppFooter.vue | 13 ++ frontend/components/AppFooterView.vue | 45 ++++ frontend/layouts/default.vue | 17 ++ frontend/tests/e2e/app-footer.spec.ts | 67 ++++++ ...app-footer-shows-version-commit-uptime.png | Bin 0 -> 22446 bytes ...rfaces-info-endpoint-errors-gracefully.png | Bin 0 -> 21106 bytes frontend/utils/uptime.ts | 16 ++ pkg/bdd/steps/common_steps.go | 67 ++++++ pkg/bdd/steps/health_steps.go | 12 ++ pkg/bdd/steps/steps.go | 7 + pkg/server/server.go | 73 +++++++ 16 files changed, 587 insertions(+), 1 deletion(-) create mode 100644 adr/0026-composite-info-endpoint.md create mode 100644 features/info/info.feature create mode 100644 features/info/info_test.go create mode 100644 frontend/components/AppFooter.vue create mode 100644 frontend/components/AppFooterView.vue create mode 100644 frontend/layouts/default.vue create mode 100644 frontend/tests/e2e/app-footer.spec.ts create mode 100644 frontend/tests/e2e/screenshots/app-footer-shows-version-commit-uptime.png create mode 100644 frontend/tests/e2e/screenshots/app-footer-surfaces-info-endpoint-errors-gracefully.png create mode 100644 frontend/utils/uptime.ts diff --git a/adr/0026-composite-info-endpoint.md b/adr/0026-composite-info-endpoint.md new file mode 100644 index 0000000..4230763 --- /dev/null +++ b/adr/0026-composite-info-endpoint.md @@ -0,0 +1,197 @@ +# ADR 0026: Composite Info Endpoint vs Separate Calls + +**Status:** Implemented (2026-05-05 — PR pending) + +## Context + +The application currently exposes several endpoints that provide system information: +- `/api/version` - returns version, commit, build date, Go version (cached 60s) +- `/api/health` - returns `{"status":"healthy"}` (simple liveness) +- `/api/healthz` - returns rich health info: status, version, uptime_seconds, timestamp +- `/api/ready` - returns readiness with connection details + +Frontend components like `HealthDashboard` currently call `/api/healthz` to display server info. However, there is a need for a **composite endpoint** that aggregates: +1. Version information (from `/api/version`) +2. Build metadata (commit hash, build date) +3. Uptime information (from `/api/healthz`) +4. Cache status (enabled/disabled) +5. Health status + +This raises an architectural question: **Should we create a new composite `/api/info` endpoint, or should frontend components make multiple separate API calls?** + +### The Problem with Separate Calls + +If the frontend makes individual calls to `/api/version`, `/api/healthz`, and checks cache config separately: + +1. **Multiple network requests**: 3-4 HTTP round trips per page load +2. **Inconsistent data**: Responses may come from different moments in time +3. **No caching coordination**: Each endpoint has its own cache key and TTL +4. **Complex frontend logic**: Need to merge data from multiple sources +5. **Poor user experience**: Slower page loads, multiple loading states + +### Current State Analysis + +| Endpoint | Data Provided | Cache TTL | Use Case | +|----------|---------------|-----------|----------| +| `/api/version` | version, commit, built, go | 60s | Version info | +| `/api/healthz` | status, version, uptime_seconds, timestamp | None | K8s probes, health dashboard | +| `/api/health` | status: "healthy" | None | Simple liveness | +| `/api/ready` | ready, connections, reason | None | Readiness probes | + +The `/api/healthz` endpoint already combines some data (status + version + uptime + timestamp), but it: +- Doesn't include commit_short +- Doesn't include build_date separately +- Doesn't include cache_enabled +- Is not cached +- Has Kubernetes-specific field naming (`healthz`) + +## Decision Drivers + +* **Performance**: Minimize network round trips for frontend +* **Consistency**: All data should reflect the same point-in-time +* **Maintainability**: Single source of truth for system info +* **Caching**: Reuse existing cache infrastructure (ADR-0022) +* **API Design**: Follow REST principles and existing patterns +* **Backward Compatibility**: Existing endpoints must remain unchanged + +## Considered Options + +### Option 1: Composite `/api/info` Endpoint (Chosen) + +Create a new endpoint that aggregates all required data in a single call. + +**Pros:** +- ✅ Single network request for frontend +- ✅ Consistent point-in-time data +- ✅ Can leverage existing cache infrastructure with key `info:json` +- ✅ Follows existing pattern of `/api/version` caching +- ✅ Clean API design - one endpoint, one purpose +- ✅ Reduces frontend complexity +- ✅ Better UX - faster page loads +- ✅ Aligns with ADR-0022 cache strategy (reusable cache key pattern) + +**Cons:** +- ⚠️ Duplicates some data from `/api/healthz` and `/api/version` +- ⚠️ Requires new endpoint implementation +- ⚠️ Need to maintain consistency if source endpoints change + +### Option 2: Frontend Aggregation with Multiple Calls + +Frontend makes separate calls to `/api/version`, `/api/healthz`, and introspects config. + +**Pros:** +- ✅ No backend changes required +- ✅ Uses existing endpoints + +**Cons:** +- ❌ Multiple network requests (3-4 round trips) +- ❌ Inconsistent data timing +- ❌ Complex error handling in frontend +- ❌ Poor UX - multiple loading states, slower +- ❌ Each endpoint has different caching behavior +- ❌ Violates DRY - same data fetched multiple times + +### Option 3: Extend `/api/healthz` Endpoint + +Add `commit_short`, `build_date`, and `cache_enabled` fields to existing `/api/healthz`. + +**Pros:** +- ✅ Reuses existing endpoint +- ✅ Single request + +**Cons:** +- ❌ Breaks backward compatibility (response schema change) +- ❌ `/api/healthz` is Kubernetes-focused (naming convention) +- ❌ Not cached currently +- ❌ Mixes health probe concerns with version info +- ❌ Violates single responsibility + +### Option 4: GraphQL / Query Parameters + +Allow clients to specify which fields they want via query parameters. + +**Pros:** +- ✅ Flexible - clients get exactly what they need +- ✅ Single endpoint + +**Cons:** +- ❌ Overkill for this use case +- ❌ Not consistent with existing REST API design +- ❌ Complex implementation +- ❌ Not aligned with project architecture (Chi router, REST style) + +## Decision Outcome + +**Chosen: Option 1 - Composite `/api/info` Endpoint** + +We will implement a new `GET /api/info` endpoint that returns a JSON object with all required fields in a single call. This endpoint will: + +1. Aggregate data from existing sources (`version` package, `config`, server uptime) +2. Be cached using the existing cache service with key `info:json` +3. Use TTL from `config.cache.default_ttl_seconds` (consistent with ADR-0022) +4. Return `X-Cache: HIT/MISS` headers for debugging +5. Follow existing Go handler patterns from `pkg/server/server.go` + +### Response Schema + +```json +{ + "version": "1.4.0", + "commit_short": "a3f7b2c1", + "build_date": "2026-05-04T08:00:00Z", + "uptime_seconds": 1234, + "cache_enabled": true, + "healthz_status": "healthy" +} +``` + +### Rationale + +1. **Performance**: Single HTTP request instead of 3-4 separate calls +2. **Consistency**: All data reflects the same moment in time +3. **Caching**: Leverages existing cache infrastructure (ADR-0022) with predictable key pattern +4. **API Design**: Clean, RESTful endpoint with single responsibility +5. **Maintainability**: Clear separation of concerns - info aggregation is a distinct use case +6. **Backward Compatibility**: Existing endpoints remain unchanged +7. **Frontend Simplicity**: Reduces complexity and improves UX + +### Cache Strategy + +Following ADR-0022 pattern: +- Cache key: `info:json` (consistent with `version:format` pattern) +- TTL: `config.cache.default_ttl_seconds` (default 300 seconds) +- Cache service: `pkg/cache/cache.go` InMemoryService +- Headers: `X-Cache: HIT` or `X-Cache: MISS` + +This allows the endpoint to be fast even under load, while maintaining data freshness. + +## Consequences + +### Positive + +1. **Improved frontend performance**: Single request instead of multiple +2. **Better UX**: Faster page loads, simpler loading states +3. **Consistent data**: All fields reflect the same point-in-time +4. **Cache efficiency**: Reuses existing cache infrastructure +5. **Clean separation**: Info endpoint handles aggregation, source endpoints unchanged +6. **Easy to test**: Single endpoint with predictable response + +### Negative + +1. **Data duplication**: Some fields appear in multiple endpoints +2. **Maintenance burden**: If source data changes, endpoint must be updated +3. **New endpoint**: Increases API surface area (though minimal) + +### Mitigation + +1. Data duplication is acceptable - it's read-only system info +2. Source the data from the same packages/functions used by other endpoints +3. The new endpoint has a clear, focused purpose + +## Links + +- [ADR-0002: Chi Router](adr/0002-chi-router.md) - Routing foundation +- [ADR-0022: Rate Limiting Cache Strategy](adr/0022-rate-limiting-cache-strategy.md) - Cache pattern reference +- [pkg/server/server.go](pkg/server/server.go) - Handler patterns +- [pkg/cache/cache.go](pkg/cache/cache.go) - Cache service +- [pkg/version/version.go](pkg/version/version.go) - Version data source diff --git a/documentation/API.md b/documentation/API.md index e4c9a5c..3c7f13d 100644 --- a/documentation/API.md +++ b/documentation/API.md @@ -19,6 +19,22 @@ Reference document for all HTTP endpoints exposed by `dance-lessons-coach` serve | GET | `/api/healthz` | **Kubernetes-style** rich health: status / version / uptime_seconds / timestamp | PR #20 — handler with swag `@Router /healthz [get]` | | GET | `/api/ready` | Readiness check (DB connection + service deps) | `pkg/server/server.go handleReadiness` | | GET | `/api/version` | Version info (cached 60s, since PR #29) | `pkg/server/server.go handleVersion` | +| GET | `/api/info` | **Composite info aggregator**: version / commit_short / build_date / uptime_seconds / cache_enabled / healthz_status. Cached when cache is enabled (X-Cache: HIT/MISS header) | ADR-0026 — `pkg/server/server.go handleInfo` | + +`/api/info` body schema (`InfoResponse`): + +```json +{ + "version": "1.0.0", + "commit_short": "abc12345", + "build_date": "2026-05-05", + "uptime_seconds": 1234, + "cache_enabled": true, + "healthz_status": "healthy" +} +``` + +Use `/api/info` from a frontend footer or status page when you need version + uptime + cache state in a single round trip. The composite design avoids 3-4 chatty calls (`/version`, `/healthz`, `/ready`) when only a snapshot is needed. `/api/healthz` body schema (`HealthzResponse`): diff --git a/features/info/info.feature b/features/info/info.feature new file mode 100644 index 0000000..8e36e4c --- /dev/null +++ b/features/info/info.feature @@ -0,0 +1,38 @@ +# features/info/info.feature +@info @critical +Feature: Info Endpoint + The /api/info endpoint should return composite application information + + @basic @critical + Scenario: GET /api/info returns all required fields + Given the server is running + When I request the info endpoint + Then the status code should be 200 + And the response should be JSON + And the response should contain "version" + And the response should contain "commit_short" + And the response should contain "build_date" + And the response should contain "uptime_seconds" + And the response should contain "cache_enabled" + And the response should contain "healthz_status" + And the "healthz_status" field should equal "healthy" + + @version @critical + Scenario: version field matches semantic version pattern + Given the server is running + When I request the info endpoint + Then the status code should be 200 + And the "version" field should match /^\d+\.\d+\.\d+$/ + + @cache @skip @bdd-deferred + Scenario: /api/info is cached when cache is enabled + # Deferred: the BDD testsetup currently runs with cache disabled + # (see "Cache service disabled" in test logs). Cache HIT/MISS behavior + # is covered by unit tests on the cache service. Reopen this scenario + # if/when the BDD harness gains a cache-enabled mode (likely after + # ADR-0022 Phase 2). + Given the server is running with cache enabled + When I request the info endpoint + Then the response header "X-Cache" should be "MISS" + When I request the info endpoint again + Then the response header "X-Cache" should be "HIT" diff --git a/features/info/info_test.go b/features/info/info_test.go new file mode 100644 index 0000000..d180f11 --- /dev/null +++ b/features/info/info_test.go @@ -0,0 +1,16 @@ +package info + +import ( + "testing" + + "dance-lessons-coach/pkg/bdd/testsetup" +) + +func TestInfoBDD(t *testing.T) { + config := testsetup.NewFeatureConfig("info", "progress", false) + suite := testsetup.CreateTestSuite(t, config, "dance-lessons-coach BDD Tests - Info Feature") + + if suite.Run() != 0 { + t.Fatal("non-zero status returned, failed to run info BDD tests") + } +} diff --git a/frontend/app.vue b/frontend/app.vue index 8f62b8b..f8eacfa 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -1,3 +1,5 @@ diff --git a/frontend/components/AppFooter.vue b/frontend/components/AppFooter.vue new file mode 100644 index 0000000..54f3824 --- /dev/null +++ b/frontend/components/AppFooter.vue @@ -0,0 +1,13 @@ + + + diff --git a/frontend/components/AppFooterView.vue b/frontend/components/AppFooterView.vue new file mode 100644 index 0000000..88f6138 --- /dev/null +++ b/frontend/components/AppFooterView.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue new file mode 100644 index 0000000..c59dc0c --- /dev/null +++ b/frontend/layouts/default.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/tests/e2e/app-footer.spec.ts b/frontend/tests/e2e/app-footer.spec.ts new file mode 100644 index 0000000..42c29fc --- /dev/null +++ b/frontend/tests/e2e/app-footer.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from '@playwright/test' + +// Both specs mock /api/info so they decouple from the dev-proxy plumbing. +// The integration with the real backend is covered by the BDD scenario in +// features/info/info.feature (server-side, no frontend proxy in the loop). + +test('home page footer shows version, commit and uptime', async ({ page }) => { + await page.route('**/api/info', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + version: '1.4.0', + commit_short: '4a3f1bb', + build_date: '2026-05-05T00:00:00Z', + uptime_seconds: 8042, + cache_enabled: true, + healthz_status: 'healthy', + }), + }) + }) + await page.goto('/') + + // Footer is mounted globally via layouts/default.vue + await expect(page.getByTestId('app-footer')).toBeVisible() + + // The PR #32 lesson: assert content, not just visibility. + // Without the regex check the test would PASS even if the footer rendered the + // pending placeholder ("v?") indefinitely. + await expect(page.getByTestId('app-footer-info')).toBeVisible() + const versionLocator = page.getByTestId('app-footer-version') + await expect(versionLocator).toBeVisible() + await expect(versionLocator).toHaveText(/^v\d+\.\d+\.\d+$/) + + // Commit and uptime should be present and non-empty. + await expect(page.getByTestId('app-footer-commit')).not.toBeEmpty() + await expect(page.getByTestId('app-footer-uptime')).not.toBeEmpty() + + await page.screenshot({ + path: 'tests/e2e/screenshots/app-footer-shows-version-commit-uptime.png', + fullPage: true, + }) +}) + +// Regression spec: documents the expected error UX so we don't ship a silent failure. +// Routes /api/info to a 502 mock so the test is reproducible regardless of backend. +test('home page footer surfaces info endpoint errors gracefully', async ({ page }) => { + await page.route('**/api/info', (route) => { + route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ error: 'simulated_backend_down' }), + }) + }) + await page.goto('/') + + // Footer must NOT crash the page + await expect(page.getByTestId('app-footer')).toBeVisible() + await expect(page.getByTestId('app-footer-error')).toBeVisible() + // The error placeholder should NOT contain a real version pattern + await expect(page.getByTestId('app-footer-info')).not.toBeVisible() + + await page.screenshot({ + path: 'tests/e2e/screenshots/app-footer-surfaces-info-endpoint-errors-gracefully.png', + fullPage: true, + }) +}) diff --git a/frontend/tests/e2e/screenshots/app-footer-shows-version-commit-uptime.png b/frontend/tests/e2e/screenshots/app-footer-shows-version-commit-uptime.png new file mode 100644 index 0000000000000000000000000000000000000000..6ca5f46bb32673e357f904c9bc113dc80a3c16e2 GIT binary patch literal 22446 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 literal 0 HcmV?d00001 diff --git a/frontend/tests/e2e/screenshots/app-footer-surfaces-info-endpoint-errors-gracefully.png b/frontend/tests/e2e/screenshots/app-footer-surfaces-info-endpoint-errors-gracefully.png new file mode 100644 index 0000000000000000000000000000000000000000..b736a8ca78c5fcbb5f50a1d4ccc2e3571fd99337 GIT binary patch literal 21106 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 0) return `${days}d ${hours}h` + if (hours > 0) return `${hours}h ${minutes}m` + if (minutes > 0) return `${minutes}m ${secs}s` + return `${secs}s` +} diff --git a/pkg/bdd/steps/common_steps.go b/pkg/bdd/steps/common_steps.go index 7c4a4cc..c76ee54 100644 --- a/pkg/bdd/steps/common_steps.go +++ b/pkg/bdd/steps/common_steps.go @@ -2,6 +2,7 @@ package steps import ( "fmt" + "regexp" "strings" "dance-lessons-coach/pkg/bdd/testserver" @@ -99,3 +100,69 @@ func (s *CommonSteps) theFieldShouldEqual(field, expectedValue string) error { } return nil } + +// Regex field matching +func (s *CommonSteps) theFieldShouldMatch(field, pattern string) error { + body := string(s.client.GetLastBody()) + // Extract the value of the field from JSON + // Look for "field":"value" and extract value + fieldPattern := `"` + field + `":"([^"]*)"` + re := regexp.MustCompile(fieldPattern) + matches := re.FindStringSubmatch(body) + if matches == nil { + // Try without quotes (for numbers) + fieldPatternNum := `"` + field + `":(\d+\.?\d*)` + reNum := regexp.MustCompile(fieldPatternNum) + matches = reNum.FindStringSubmatch(body) + if matches == nil { + return fmt.Errorf("field %q not found in response: %s", field, body) + } + } + + // matches[1] contains the value + value := matches[1] + + // Compile and match the pattern + regex, err := regexp.Compile(pattern) + if err != nil { + return fmt.Errorf("invalid regex pattern %q: %v", pattern, err) + } + + if !regex.MatchString(value) { + return fmt.Errorf("field %q value %q does not match pattern %q", field, value, pattern) + } + return nil +} + +// Response is JSON check +func (s *CommonSteps) theResponseShouldBeJSON() error { + body := string(s.client.GetLastBody()) + // Simple check for JSON structure + body = strings.TrimSpace(body) + if !strings.HasPrefix(body, "{") && !strings.HasPrefix(body, "[") { + return fmt.Errorf("response is not JSON: %s", body) + } + return nil +} + +// Response contains field (simple string containment in body) +func (s *CommonSteps) theResponseShouldContain(field string) error { + body := string(s.client.GetLastBody()) + if !strings.Contains(body, `"`+field+`"`) { + return fmt.Errorf("response does not contain field %q: %s", field, body) + } + return nil +} + +// Response header validation +func (s *CommonSteps) theResponseHeader(header, expectedValue string) error { + resp := s.client.GetLastResponse() + if resp == nil { + return fmt.Errorf("no response captured for header check") + } + headerValue := resp.Header.Get(header) + if headerValue != expectedValue { + return fmt.Errorf("header %q expected %q, got %q", header, expectedValue, headerValue) + } + return nil +} diff --git a/pkg/bdd/steps/health_steps.go b/pkg/bdd/steps/health_steps.go index 3930009..6ed82df 100644 --- a/pkg/bdd/steps/health_steps.go +++ b/pkg/bdd/steps/health_steps.go @@ -28,7 +28,19 @@ func (s *HealthSteps) iRequestTheHealthzEndpoint() error { return s.client.Request("GET", "/api/healthz", nil) } +func (s *HealthSteps) iRequestTheInfoEndpoint() error { + return s.client.Request("GET", "/api/info", nil) +} + +func (s *HealthSteps) iRequestTheInfoEndpointAgain() error { + return s.client.Request("GET", "/api/info", nil) +} + func (s *HealthSteps) theServerIsRunning() error { // Actually verify the server is running by checking the readiness endpoint return s.client.Request("GET", "/api/ready", nil) } + +func (s *HealthSteps) theServerIsRunningWithCacheEnabled() error { + return s.client.Request("GET", "/api/ready", nil) +} diff --git a/pkg/bdd/steps/steps.go b/pkg/bdd/steps/steps.go index 4232f65..625636d 100644 --- a/pkg/bdd/steps/steps.go +++ b/pkg/bdd/steps/steps.go @@ -89,6 +89,9 @@ func InitializeAllSteps(ctx *godog.ScenarioContext, client *testserver.Client, s // Health steps ctx.Step(`^I request the health endpoint$`, sc.healthSteps.iRequestTheHealthEndpoint) ctx.Step(`^I request the healthz endpoint$`, sc.healthSteps.iRequestTheHealthzEndpoint) + ctx.Step(`^I request the info endpoint$`, sc.healthSteps.iRequestTheInfoEndpoint) + ctx.Step(`^I request the info endpoint again$`, sc.healthSteps.iRequestTheInfoEndpointAgain) + ctx.Step(`^the server is running with cache enabled$`, sc.healthSteps.theServerIsRunningWithCacheEnabled) ctx.Step(`^the server is running$`, sc.healthSteps.theServerIsRunning) // Auth steps @@ -314,4 +317,8 @@ func InitializeAllSteps(ctx *godog.ScenarioContext, client *testserver.Client, s ctx.Step(`^the status code should be (\d+)$`, sc.commonSteps.theStatusCodeShouldBe) ctx.Step(`^the response should be JSON with fields "([^"]*)"$`, sc.commonSteps.theResponseShouldBeJSONWithFields) ctx.Step(`^the "([^"]*)" field should equal "([^"]*)"$`, sc.commonSteps.theFieldShouldEqual) + ctx.Step(`^the "([^"]*)" field should match /([^/]+)/$`, sc.commonSteps.theFieldShouldMatch) + ctx.Step(`^the response should be JSON$`, sc.commonSteps.theResponseShouldBeJSON) + ctx.Step(`^the response should contain "([^"]*)"$`, sc.commonSteps.theResponseShouldContain) + ctx.Step(`^the response header "([^"]*)" should be "([^"]*)"$`, sc.commonSteps.theResponseHeader) } diff --git a/pkg/server/server.go b/pkg/server/server.go index 4412d6f..d17f9dd 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -171,6 +171,9 @@ func (s *Server) setupRoutes() { // Kubernetes-style health endpoint at root level s.router.Get("/api/healthz", s.handleHealthz) + // Info endpoint - composite aggregator + s.router.Get("/api/info", s.handleInfo) + // API routes s.router.Route("/api/v1", func(r chi.Router) { r.Use(s.getAllMiddlewares()...) @@ -436,6 +439,16 @@ type HealthzResponse struct { Timestamp time.Time `json:"timestamp"` } +// InfoResponse represents the JSON response for /api/info +type InfoResponse struct { + Version string `json:"version"` + CommitShort string `json:"commit_short"` + BuildDate string `json:"build_date"` + UptimeSeconds int64 `json:"uptime_seconds"` + CacheEnabled bool `json:"cache_enabled"` + HealthzStatus string `json:"healthz_status"` +} + // handleHealthz godoc // // @Summary Kubernetes-style health check @@ -456,6 +469,66 @@ func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) } +// handleInfo godoc +// +// @Summary Get composite info +// @Description Returns aggregated version, build, uptime, cache, and health info +// @Tags System/Info +// @Produce json +// @Success 200 {object} InfoResponse +// @Router /info [get] +func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) { + log.Trace().Msg("Info endpoint requested") + + // Build commit_short from version.Commit (first 8 chars if available) + commitShort := version.Commit + if len(commitShort) > 8 { + commitShort = commitShort[:8] + } + + // Build response + resp := InfoResponse{ + Version: version.Version, + CommitShort: commitShort, + BuildDate: version.Date, + UptimeSeconds: int64(time.Since(s.startedAt).Seconds()), + CacheEnabled: s.cacheService != nil, + HealthzStatus: "healthy", + } + + // Cache key + cacheKey := "info:json" + + // Check cache if enabled + if s.cacheService != nil { + if cached, ok := s.cacheService.Get(cacheKey); ok { + log.Trace().Str("cache_key", cacheKey).Msg("Cache hit for info") + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Cache", "HIT") + w.Write([]byte(cached.(string))) + return + } + } + + // Marshal response + data, err := json.Marshal(resp) + if err != nil { + http.Error(w, `{"error":"server_error"}`, http.StatusInternalServerError) + return + } + + // Cache the response + if s.cacheService != nil { + s.cacheService.Set(cacheKey, string(data), + time.Duration(s.config.GetCacheDefaultTTLSeconds())*time.Second) + w.Header().Set("X-Cache", "MISS") + log.Trace().Str("cache_key", cacheKey).Msg("Cached info response") + } + + w.Header().Set("Content-Type", "application/json") + w.Write(data) +} + // handleGreetQuery godoc // // @Summary Get greeting with cache -- 2.49.1