✨ feat(server): /api/info aggregator + frontend version footer (#40)
Co-authored-by: Gabriel Radureau <arcodange@gmail.com> Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
This commit was merged in pull request #40.
This commit is contained in:
197
adr/0026-composite-info-endpoint.md
Normal file
197
adr/0026-composite-info-endpoint.md
Normal file
@@ -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
|
||||||
@@ -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/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/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/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`):
|
`/api/healthz` body schema (`HealthzResponse`):
|
||||||
|
|
||||||
|
|||||||
38
features/info/info.feature
Normal file
38
features/info/info.feature
Normal file
@@ -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"
|
||||||
16
features/info/info_test.go
Normal file
16
features/info/info_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<NuxtPage />
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
13
frontend/components/AppFooter.vue
Normal file
13
frontend/components/AppFooter.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import AppFooterView, { type AppInfo } from './AppFooterView.vue'
|
||||||
|
|
||||||
|
// Wrapper: handles data fetching, delegates rendering to AppFooterView.
|
||||||
|
// Separation of concerns (SRP) - same pattern as HealthDashboard / HealthDashboardView.
|
||||||
|
// server: false → fetch client-side only. Avoids SSR fetching through the dev proxy
|
||||||
|
// (which can fail in some local setups), and lets Playwright route mocks apply.
|
||||||
|
const { data, pending, error } = useFetch<AppInfo>('/api/info', { server: false })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppFooterView :data="data" :pending="pending" :error="error" />
|
||||||
|
</template>
|
||||||
45
frontend/components/AppFooterView.vue
Normal file
45
frontend/components/AppFooterView.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { humaniseUptime } from '~/utils/uptime'
|
||||||
|
|
||||||
|
export interface AppInfo {
|
||||||
|
version: string
|
||||||
|
commit_short: string
|
||||||
|
build_date: string
|
||||||
|
uptime_seconds: number
|
||||||
|
cache_enabled: boolean
|
||||||
|
healthz_status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
data: AppInfo | null | undefined
|
||||||
|
pending: boolean
|
||||||
|
error: { message: string } | null | undefined
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<footer data-testid="app-footer">
|
||||||
|
<p v-if="pending" data-testid="app-footer-pending">v?</p>
|
||||||
|
<p v-else-if="error" data-testid="app-footer-error">v? · info unavailable</p>
|
||||||
|
<p v-else-if="data" data-testid="app-footer-info">
|
||||||
|
<span data-testid="app-footer-version">v{{ data.version }}</span>
|
||||||
|
<span> · commit </span>
|
||||||
|
<span data-testid="app-footer-commit">{{ data.commit_short }}</span>
|
||||||
|
<span> · uptime </span>
|
||||||
|
<span data-testid="app-footer-uptime">{{ humaniseUptime(data.uptime_seconds) }}</span>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
footer {
|
||||||
|
border-top: 1px solid #ccc;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #555;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
footer p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
17
frontend/layouts/default.vue
Normal file
17
frontend/layouts/default.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<div class="layout-root">
|
||||||
|
<slot />
|
||||||
|
<AppFooter />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout-root {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.layout-root > :first-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
67
frontend/tests/e2e/app-footer.spec.ts
Normal file
67
frontend/tests/e2e/app-footer.spec.ts
Normal file
@@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
16
frontend/utils/uptime.ts
Normal file
16
frontend/utils/uptime.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// Convert a duration in seconds to a humanised string like "2h 13m" or "45m 12s".
|
||||||
|
// Returns "?" for non-finite or negative input so the UI never renders NaN/empty.
|
||||||
|
export function humaniseUptime(seconds: number | null | undefined): string {
|
||||||
|
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return '?'
|
||||||
|
|
||||||
|
const s = Math.floor(seconds)
|
||||||
|
const days = Math.floor(s / 86400)
|
||||||
|
const hours = Math.floor((s % 86400) / 3600)
|
||||||
|
const minutes = Math.floor((s % 3600) / 60)
|
||||||
|
const secs = s % 60
|
||||||
|
|
||||||
|
if (days > 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`
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package steps
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"dance-lessons-coach/pkg/bdd/testserver"
|
"dance-lessons-coach/pkg/bdd/testserver"
|
||||||
@@ -99,3 +100,69 @@ func (s *CommonSteps) theFieldShouldEqual(field, expectedValue string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,19 @@ func (s *HealthSteps) iRequestTheHealthzEndpoint() error {
|
|||||||
return s.client.Request("GET", "/api/healthz", nil)
|
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 {
|
func (s *HealthSteps) theServerIsRunning() error {
|
||||||
// Actually verify the server is running by checking the readiness endpoint
|
// Actually verify the server is running by checking the readiness endpoint
|
||||||
return s.client.Request("GET", "/api/ready", nil)
|
return s.client.Request("GET", "/api/ready", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *HealthSteps) theServerIsRunningWithCacheEnabled() error {
|
||||||
|
return s.client.Request("GET", "/api/ready", nil)
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ func InitializeAllSteps(ctx *godog.ScenarioContext, client *testserver.Client, s
|
|||||||
// Health steps
|
// Health steps
|
||||||
ctx.Step(`^I request the health endpoint$`, sc.healthSteps.iRequestTheHealthEndpoint)
|
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 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)
|
ctx.Step(`^the server is running$`, sc.healthSteps.theServerIsRunning)
|
||||||
|
|
||||||
// Auth steps
|
// 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 status code should be (\d+)$`, sc.commonSteps.theStatusCodeShouldBe)
|
||||||
ctx.Step(`^the response should be JSON with fields "([^"]*)"$`, sc.commonSteps.theResponseShouldBeJSONWithFields)
|
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 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,9 @@ func (s *Server) setupRoutes() {
|
|||||||
// Kubernetes-style health endpoint at root level
|
// Kubernetes-style health endpoint at root level
|
||||||
s.router.Get("/api/healthz", s.handleHealthz)
|
s.router.Get("/api/healthz", s.handleHealthz)
|
||||||
|
|
||||||
|
// Info endpoint - composite aggregator
|
||||||
|
s.router.Get("/api/info", s.handleInfo)
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
s.router.Route("/api/v1", func(r chi.Router) {
|
s.router.Route("/api/v1", func(r chi.Router) {
|
||||||
r.Use(s.getAllMiddlewares()...)
|
r.Use(s.getAllMiddlewares()...)
|
||||||
@@ -436,6 +439,16 @@ type HealthzResponse struct {
|
|||||||
Timestamp time.Time `json:"timestamp"`
|
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
|
// handleHealthz godoc
|
||||||
//
|
//
|
||||||
// @Summary Kubernetes-style health check
|
// @Summary Kubernetes-style health check
|
||||||
@@ -456,6 +469,66 @@ func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(resp)
|
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
|
// handleGreetQuery godoc
|
||||||
//
|
//
|
||||||
// @Summary Get greeting with cache
|
// @Summary Get greeting with cache
|
||||||
|
|||||||
Reference in New Issue
Block a user