Skip to content

Commit a0e034e

Browse files
Paul Mulliganclaude
andcommitted
feat(example): wire dependency-checking /health into todo-api-cloudflare
Mirrors the new health endpoint design in the example: - examples/todo-api-cloudflare/api/src/db/ping.ts (D1: SELECT 1 via c.env.DB.prepare('SELECT 1').first()) - examples/todo-api-cloudflare/api/src/routes/health.ts (same handler shape as the Workers template, but typed for the example's D1 binding) - src/index.ts: drop inline /health handler, mount healthRoutes - wrangler.toml [vars] and .dev.vars.example: add APP_VERSION and HEALTH_DB_TIMEOUT_MS - tests/unit/health.test.ts: full mocked coverage matching the template Also refactors pingDatabase in both the Workers template and the example to take the binding directly (Hyperdrive | undefined / D1Database | undefined) instead of the whole Hono Context — the Context<Bindings> variance caused a typecheck error when the route's wider Bindings included APP_VERSION/HEALTH_DB_TIMEOUT_MS. Decoupling db/ping.ts from Hono's type also makes it easier to reuse outside HTTP handlers. Example tests: 19/19 passing (7 health + 12 todos integration). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fb6f7e8 commit a0e034e

7 files changed

Lines changed: 149 additions & 30 deletions

File tree

examples/todo-api-cloudflare/api/.dev.vars.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@
33
# cp .dev.vars.example .dev.vars
44

55
ENVIRONMENT=development
6+
APP_VERSION=0.0.1
7+
HEALTH_DB_TIMEOUT_MS=2000
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export async function pingDatabase(db: D1Database | undefined): Promise<'connected' | 'disconnected'> {
2+
if (!db) return 'disconnected';
3+
try {
4+
await db.prepare('SELECT 1').first();
5+
return 'connected';
6+
} catch {
7+
return 'disconnected';
8+
}
9+
}

examples/todo-api-cloudflare/api/src/index.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import { etag } from 'hono/etag';
44
import { logger } from 'hono/logger';
55
import { requestId } from 'hono/request-id';
66
import { secureHeaders } from 'hono/secure-headers';
7+
import { healthRoutes } from './routes/health';
78
import { todosRoutes } from './routes/todos';
89
// Note: Response compression is handled automatically by Cloudflare's edge network.
910
// No compress() middleware is needed for Workers deployments.
1011

1112
interface Bindings {
1213
DB: D1Database;
1314
ENVIRONMENT: string;
15+
APP_VERSION: string;
16+
HEALTH_DB_TIMEOUT_MS: string;
1417
}
1518

1619
const app = new Hono<{ Bindings: Bindings }>();
@@ -22,16 +25,8 @@ app.use('*', etag());
2225
app.use('*', secureHeaders());
2326
app.use('*', requestId());
2427

25-
// --- Health check ---
26-
app.get('/health', (c) => {
27-
return c.json({
28-
status: 'ok',
29-
requestId: c.get('requestId'),
30-
timestamp: new Date().toISOString(),
31-
});
32-
});
33-
3428
// --- Routes ---
29+
app.route('/health', healthRoutes);
3530
app.route('/todos', todosRoutes);
3631

3732
// --- Root ---
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Hono } from 'hono';
2+
import { pingDatabase } from '../db/ping';
3+
4+
const startTime = Date.now();
5+
6+
interface Bindings {
7+
DB: D1Database;
8+
APP_VERSION: string;
9+
HEALTH_DB_TIMEOUT_MS: string;
10+
}
11+
12+
export const healthRoutes = new Hono<{ Bindings: Bindings }>().get('/', async (c) => {
13+
const timeoutMs = Number(c.env.HEALTH_DB_TIMEOUT_MS) || 2000;
14+
const timeout = new Promise<'disconnected'>((resolve) =>
15+
setTimeout(() => resolve('disconnected'), timeoutMs),
16+
);
17+
18+
let database: 'connected' | 'disconnected';
19+
try {
20+
database = await Promise.race([pingDatabase(c.env.DB), timeout]);
21+
} catch {
22+
database = 'disconnected';
23+
}
24+
25+
const status = database === 'connected' ? 'healthy' : 'unhealthy';
26+
const body = {
27+
status,
28+
version: c.env.APP_VERSION ?? 'unknown',
29+
uptime: Math.floor((Date.now() - startTime) / 1000),
30+
timestamp: new Date().toISOString(),
31+
requestId: c.get('requestId'),
32+
checks: { database },
33+
};
34+
return c.json(body, status === 'healthy' ? 200 : 503);
35+
});
Lines changed: 93 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,103 @@
1-
import { describe, it, expect } from 'vitest';
2-
import app from '../../src/index';
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { Hono } from 'hono';
3+
import { requestId } from 'hono/request-id';
4+
import { pingDatabase } from '../../src/db/ping';
5+
import { healthRoutes } from '../../src/routes/health';
6+
7+
vi.mock('../../src/db/ping', () => ({
8+
pingDatabase: vi.fn(),
9+
}));
10+
11+
interface HealthBody {
12+
status: 'healthy' | 'unhealthy';
13+
version: string;
14+
uptime: number;
15+
timestamp: string;
16+
requestId: string;
17+
checks: { database: 'connected' | 'disconnected' };
18+
}
19+
20+
type TestEnv = {
21+
APP_VERSION: string;
22+
HEALTH_DB_TIMEOUT_MS: string;
23+
DB: D1Database | undefined;
24+
};
25+
26+
const env: TestEnv = {
27+
APP_VERSION: '0.0.1',
28+
HEALTH_DB_TIMEOUT_MS: '2000',
29+
DB: undefined,
30+
};
31+
32+
const makeApp = (): Hono<{ Bindings: TestEnv }> => {
33+
const app = new Hono<{ Bindings: TestEnv }>();
34+
app.use('*', requestId());
35+
app.route('/health', healthRoutes);
36+
return app;
37+
};
38+
39+
const mockedPing = vi.mocked(pingDatabase);
340

441
describe('Health endpoint', () => {
5-
it('should return 200 with ok status', async () => {
6-
const res = await app.request('/health');
42+
beforeEach(() => {
43+
mockedPing.mockReset();
44+
});
45+
46+
it('returns 200 + healthy when DB is connected', async () => {
47+
mockedPing.mockResolvedValue('connected');
48+
const res = await makeApp().request('/health', {}, env);
749
expect(res.status).toBe(200);
8-
const body = (await res.json()) as { status: string };
9-
expect(body.status).toBe('ok');
50+
const body = (await res.json()) as HealthBody;
51+
expect(body.status).toBe('healthy');
52+
expect(body.checks.database).toBe('connected');
1053
});
1154

12-
it('should include a requestId in the response body', async () => {
13-
const res = await app.request('/health');
14-
const body = (await res.json()) as { requestId: string };
15-
expect(body.requestId).toBeDefined();
16-
expect(typeof body.requestId).toBe('string');
17-
expect(body.requestId.length).toBeGreaterThan(0);
55+
it('returns version from APP_VERSION binding', async () => {
56+
mockedPing.mockResolvedValue('connected');
57+
const res = await makeApp().request('/health', {}, env);
58+
const body = (await res.json()) as HealthBody;
59+
expect(body.version).toBe('0.0.1');
1860
});
1961

20-
it('should include X-Request-Id response header', async () => {
21-
const res = await app.request('/health');
62+
it('returns numeric uptime >= 0', async () => {
63+
mockedPing.mockResolvedValue('connected');
64+
const res = await makeApp().request('/health', {}, env);
65+
const body = (await res.json()) as HealthBody;
66+
expect(typeof body.uptime).toBe('number');
67+
expect(body.uptime).toBeGreaterThanOrEqual(0);
68+
});
69+
70+
it('returns 503 + unhealthy when DB is disconnected', async () => {
71+
mockedPing.mockResolvedValue('disconnected');
72+
const res = await makeApp().request('/health', {}, env);
73+
expect(res.status).toBe(503);
74+
const body = (await res.json()) as HealthBody;
75+
expect(body.status).toBe('unhealthy');
76+
expect(body.checks.database).toBe('disconnected');
77+
});
78+
79+
it('returns 503 when ping exceeds timeout', async () => {
80+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
81+
mockedPing.mockImplementation(() => new Promise(() => {}));
82+
const reqPromise = makeApp().request('/health', {}, env);
83+
await vi.advanceTimersByTimeAsync(2001);
84+
const res = await reqPromise;
85+
expect(res.status).toBe(503);
86+
vi.useRealTimers();
87+
});
88+
89+
it('returns 503 when ping throws (never 500)', async () => {
90+
mockedPing.mockRejectedValue(new Error('boom'));
91+
const res = await makeApp().request('/health', {}, env);
92+
expect(res.status).toBe(503);
93+
});
94+
95+
it('preserves requestId in body and X-Request-Id header', async () => {
96+
mockedPing.mockResolvedValue('connected');
97+
const res = await makeApp().request('/health', {}, env);
98+
const body = (await res.json()) as HealthBody;
99+
expect(typeof body.requestId).toBe('string');
100+
expect(body.requestId.length).toBeGreaterThan(0);
22101
expect(res.headers.get('X-Request-Id')).not.toBeNull();
23102
});
24103
});

examples/todo-api-cloudflare/api/wrangler.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,5 @@ database_id = "<YOUR_D1_DATABASE_ID>"
1717

1818
[vars]
1919
ENVIRONMENT = "development"
20+
APP_VERSION = "0.0.1"
21+
HEALTH_DB_TIMEOUT_MS = "2000"

scripts/setup-project.sh

Lines changed: 4 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)