Skip to content

Commit 57d40db

Browse files
authored
Merge pull request #180 from atomantic/claim/issue-158
fix: reserve API misses for JSON responses (#158)
2 parents 599562a + 3ee2965 commit 57d40db

6 files changed

Lines changed: 180 additions & 101 deletions

File tree

‎.changelog/NEXT.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050

5151
## Fixed
5252

53+
- **[issue-158] Unknown API routes now return JSON errors** — Requests to unrecognized `/api` paths receive a stable 404 error envelope instead of the browser app's HTML, while client-side navigation continues to use the SPA fallback.
5354
- Search results now keep their alphabetical ordering. The batch person-loader (`getPersonsBatch`) re-orders rows back to the requested order, fixing a regression where SQLite's `WHERE person_id IN (...)` returned rows in table order and silently discarded the search query's `ORDER BY display_name` (so the default, unsorted search view appeared randomly ordered).
5455
- Platform comparison now treats equivalent place spellings as matches: "Dallas, Texas, USA" vs "Dallas, Texas, United States" (and U.S.A. / United States of America / state abbreviations like TX vs Texas, UK vs United Kingdom, etc.) — no longer flagged as `different`. Place containment is now suffix-based, so "Texas" no longer falsely matches "Texarkana"
5556
- Platform comparison now treats equivalent date formats as matches (e.g., "1979-07-31" vs "31 JUL 1979")

‎docs/api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# API Reference
22

3-
Backend runs on port 6374 by default. All endpoints return JSON with `{ success: boolean, data?: T, error?: string }`.
3+
Backend runs on port 6374 by default. Application JSON endpoints use the envelope `{ success: boolean, data?: T, error?: string }`. Explicitly documented file downloads and server-sent event (SSE) streams retain their native payloads, while `/api/health` returns its operational status object directly. Requests that do not match an `/api` endpoint return HTTP 404 with `{ success: false, error: "API route not found" }`.
44

55
## Databases (Roots)
66

‎server/src/app.ts‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import cors from 'cors';
2+
import express, { type Express } from 'express';
3+
import { existsSync } from 'fs';
4+
import path from 'path';
5+
import { fileURLToPath } from 'url';
6+
import { apiNotFound } from './middleware/apiNotFound.js';
7+
import { errorHandler } from './middleware/errorHandler.js';
8+
import { requestLogger } from './middleware/requestLogger.js';
9+
import { requestTimeout } from './middleware/requestTimeout.js';
10+
import { aiDiscoveryRouter } from './routes/ai-discovery.routes.js';
11+
import { ancestryHintsRouter } from './routes/ancestry-hints.routes.js';
12+
import { ancestryTreeRouter } from './routes/ancestry-tree.routes.js';
13+
import { ancestryUpdateRouter } from './routes/ancestry-update.routes.js';
14+
import { auditorRouter } from './routes/auditor.routes.js';
15+
import { augmentationRouter } from './routes/augmentation.routes.js';
16+
import { browserRouter } from './routes/browser.routes.js';
17+
import { databaseRoutes } from './routes/database.routes.js';
18+
import { deathsRouter } from './routes/deaths.routes.js';
19+
import { exportRoutes } from './routes/export.routes.js';
20+
import { favoritesRouter } from './routes/favorites.routes.js';
21+
import { gedcomRouter } from './routes/gedcom.routes.js';
22+
import { genealogyProviderRouter } from './routes/genealogy-provider.routes.js';
23+
import { indexerRoutes } from './routes/indexer.routes.js';
24+
import { integrityRouter } from './routes/integrity.routes.js';
25+
import { mapRouter } from './routes/map.routes.js';
26+
import { pathRoutes } from './routes/path.routes.js';
27+
import { personRoutes } from './routes/person.routes.js';
28+
import { providerRouter } from './routes/provider.routes.js';
29+
import { searchRoutes } from './routes/search.routes.js';
30+
import { syncRouter } from './routes/sync.routes.js';
31+
import { testRunnerRouter } from './routes/test-runner.routes.js';
32+
import { initAIToolkit } from './services/ai-toolkit.service.js';
33+
import { logger } from './lib/logger.js';
34+
35+
const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:6373';
36+
const corsOrigin = CORS_ORIGIN.includes(',')
37+
? CORS_ORIGIN.split(',').map(origin => {
38+
const trimmed = origin.trim();
39+
new URL(trimmed);
40+
return trimmed;
41+
})
42+
: CORS_ORIGIN;
43+
44+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
45+
const CLIENT_DIST = path.join(__dirname, '..', '..', 'client', 'dist');
46+
const CLIENT_DIST_ALT = path.join(__dirname, '..', '..', '..', 'client', 'dist');
47+
48+
const findClientDist = (): string => (
49+
existsSync(CLIENT_DIST) ? CLIENT_DIST : CLIENT_DIST_ALT
50+
);
51+
52+
export interface CreateAppOptions {
53+
aiToolkit?: {
54+
mountRoutes: (app: Express) => void;
55+
};
56+
clientDist?: string;
57+
}
58+
59+
export const createApp = ({
60+
aiToolkit = initAIToolkit(null),
61+
clientDist = findClientDist()
62+
}: CreateAppOptions = {}): Express => {
63+
const app = express();
64+
65+
app.use(cors({ origin: corsOrigin }));
66+
app.use(express.json());
67+
app.use(requestTimeout);
68+
app.use(requestLogger);
69+
70+
aiToolkit.mountRoutes(app);
71+
72+
app.use('/api/databases', databaseRoutes);
73+
app.use('/api/persons', personRoutes);
74+
app.use('/api/search', searchRoutes);
75+
app.use('/api/path', pathRoutes);
76+
app.use('/api/indexer', indexerRoutes);
77+
app.use('/api/export', exportRoutes);
78+
app.use('/api/browser', browserRouter);
79+
app.use('/api/augment', augmentationRouter);
80+
app.use('/api/genealogy-providers', genealogyProviderRouter);
81+
app.use('/api/scrape-providers', providerRouter);
82+
app.use('/api/gedcom', gedcomRouter);
83+
app.use('/api/sync', syncRouter);
84+
app.use('/api/favorites', favoritesRouter);
85+
app.use('/api/ancestry-tree', ancestryTreeRouter);
86+
app.use('/api/ai-discovery', aiDiscoveryRouter);
87+
app.use('/api/test-runner', testRunnerRouter);
88+
app.use('/api/integrity', integrityRouter);
89+
app.use('/api/ancestry-hints', ancestryHintsRouter);
90+
app.use('/api/ancestry-update', ancestryUpdateRouter);
91+
app.use('/api/map', mapRouter);
92+
app.use('/api/audit', auditorRouter);
93+
app.use('/api/deaths', deathsRouter);
94+
95+
app.get('/api/health', (_req, res) => {
96+
res.json({ status: 'ok', timestamp: new Date().toISOString() });
97+
});
98+
99+
app.use('/api', apiNotFound);
100+
101+
if (existsSync(clientDist)) {
102+
app.use(express.static(clientDist));
103+
app.get('/{*splat}', (_req, res) => {
104+
res.sendFile(path.join(clientDist, 'index.html'));
105+
});
106+
logger.ok('server', 'Serving built UI from client/dist');
107+
}
108+
109+
app.use(errorHandler);
110+
111+
return app;
112+
};

‎server/src/index.ts‎

Lines changed: 2 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,14 @@
1-
import express from 'express';
2-
import cors from 'cors';
3-
import { existsSync } from 'fs';
41
import { createServer } from 'http';
5-
import path from 'path';
6-
import { fileURLToPath } from 'url';
7-
import { initAIToolkit } from './services/ai-toolkit.service.js';
8-
import { databaseRoutes } from './routes/database.routes.js';
9-
import { personRoutes } from './routes/person.routes.js';
10-
import { searchRoutes } from './routes/search.routes.js';
11-
import { pathRoutes } from './routes/path.routes.js';
12-
import { indexerRoutes } from './routes/indexer.routes.js';
13-
import { exportRoutes } from './routes/export.routes.js';
14-
import { browserRouter } from './routes/browser.routes.js';
152
import { browserService } from './services/browser.service.js';
16-
import { augmentationRouter } from './routes/augmentation.routes.js';
17-
import { genealogyProviderRouter } from './routes/genealogy-provider.routes.js';
18-
import { providerRouter } from './routes/provider.routes.js';
19-
import { gedcomRouter } from './routes/gedcom.routes.js';
20-
import { syncRouter } from './routes/sync.routes.js';
21-
import { favoritesRouter } from './routes/favorites.routes.js';
22-
import { ancestryTreeRouter } from './routes/ancestry-tree.routes.js';
23-
import { aiDiscoveryRouter } from './routes/ai-discovery.routes.js';
24-
import { testRunnerRouter } from './routes/test-runner.routes.js';
25-
import { integrityRouter } from './routes/integrity.routes.js';
26-
import { ancestryHintsRouter } from './routes/ancestry-hints.routes.js';
27-
import { ancestryUpdateRouter } from './routes/ancestry-update.routes.js';
28-
import { mapRouter } from './routes/map.routes.js';
29-
import { auditorRouter } from './routes/auditor.routes.js';
30-
import { deathsRouter } from './routes/deaths.routes.js';
313
import { runMigrations } from './db/migrations/index.js';
32-
import { errorHandler } from './middleware/errorHandler.js';
33-
import { requestLogger } from './middleware/requestLogger.js';
34-
import { requestTimeout } from './middleware/requestTimeout.js';
354
import { logger } from './lib/logger.js';
5+
import { createApp } from './app.js';
366

37-
const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:6373';
38-
const corsOrigin = CORS_ORIGIN.includes(',')
39-
? CORS_ORIGIN.split(',').map(o => {
40-
const trimmed = o.trim();
41-
new URL(trimmed); // throws on invalid origin
42-
return trimmed;
43-
})
44-
: CORS_ORIGIN;
45-
46-
const app = express();
7+
const app = createApp();
478
const httpServer = createServer(app);
489

4910
const PORT = parseInt(process.env.PORT || '6374', 10);
5011

51-
// Middleware
52-
app.use(cors({ origin: corsOrigin }));
53-
app.use(express.json());
54-
app.use(requestTimeout);
55-
app.use(requestLogger);
56-
57-
// Initialize AI Toolkit with routes for providers, runs, and prompts
58-
const aiToolkit = initAIToolkit(null);
59-
aiToolkit.mountRoutes(app);
60-
61-
// Routes
62-
app.use('/api/databases', databaseRoutes);
63-
app.use('/api/persons', personRoutes);
64-
app.use('/api/search', searchRoutes);
65-
app.use('/api/path', pathRoutes);
66-
app.use('/api/indexer', indexerRoutes);
67-
app.use('/api/export', exportRoutes);
68-
app.use('/api/browser', browserRouter);
69-
app.use('/api/augment', augmentationRouter);
70-
app.use('/api/genealogy-providers', genealogyProviderRouter);
71-
app.use('/api/scrape-providers', providerRouter);
72-
app.use('/api/gedcom', gedcomRouter);
73-
app.use('/api/sync', syncRouter);
74-
app.use('/api/favorites', favoritesRouter);
75-
app.use('/api/ancestry-tree', ancestryTreeRouter);
76-
app.use('/api/ai-discovery', aiDiscoveryRouter);
77-
app.use('/api/test-runner', testRunnerRouter);
78-
app.use('/api/integrity', integrityRouter);
79-
app.use('/api/ancestry-hints', ancestryHintsRouter);
80-
app.use('/api/ancestry-update', ancestryUpdateRouter);
81-
app.use('/api/map', mapRouter);
82-
app.use('/api/audit', auditorRouter);
83-
app.use('/api/deaths', deathsRouter);
84-
85-
// Health check
86-
app.get('/api/health', (_req, res) => {
87-
res.json({ status: 'ok', timestamp: new Date().toISOString() });
88-
});
89-
90-
// Serve built client UI in production
91-
const __filename = fileURLToPath(import.meta.url);
92-
const __dirname = path.dirname(__filename);
93-
// From src/index.ts: up to server/, then to project root, then client/dist
94-
const CLIENT_DIST = path.join(__dirname, '..', '..', 'client', 'dist');
95-
// From dist/index.js (compiled): up to server/, then to project root, then client/dist
96-
const CLIENT_DIST_ALT = path.join(__dirname, '..', '..', '..', 'client', 'dist');
97-
const clientDist = existsSync(CLIENT_DIST) ? CLIENT_DIST : CLIENT_DIST_ALT;
98-
99-
if (existsSync(clientDist)) {
100-
app.use(express.static(clientDist));
101-
app.get('/{*splat}', (_req, res) => {
102-
res.sendFile(path.join(clientDist, 'index.html'));
103-
});
104-
logger.ok('server', 'Serving built UI from client/dist');
105-
}
106-
107-
// Error handling
108-
app.use(errorHandler);
109-
11012
const HOST = process.env.HOST || 'localhost';
11113

11214
const shutdown = () => {
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type { RequestHandler } from 'express';
2+
3+
export const apiNotFound: RequestHandler = (_req, res) => {
4+
res.status(404).json({
5+
success: false,
6+
error: 'API route not found'
7+
});
8+
};
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
2+
import { tmpdir } from 'os';
3+
import path from 'path';
4+
import type { Express } from 'express';
5+
import request from 'supertest';
6+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
7+
import { createApp } from '../../../server/src/app.js';
8+
9+
describe('terminal API boundary', () => {
10+
let app: Express;
11+
let clientDist: string;
12+
13+
beforeAll(() => {
14+
clientDist = mkdtempSync(path.join(tmpdir(), 'sparsetree-client-'));
15+
writeFileSync(
16+
path.join(clientDist, 'index.html'),
17+
'<!doctype html><html><body>SparseTree test client</body></html>'
18+
);
19+
app = createApp({
20+
aiToolkit: { mountRoutes: () => undefined },
21+
clientDist
22+
});
23+
});
24+
25+
afterAll(() => {
26+
rmSync(clientDist, { recursive: true, force: true });
27+
});
28+
29+
it.each([
30+
['get', '/api/persons/missing/typo/extra'],
31+
['post', '/api/missing']
32+
] as const)('returns the JSON error envelope for unknown %s routes', async (method, url) => {
33+
const response = await request(app)[method](url).expect(404);
34+
35+
expect(response.type).toBe('application/json');
36+
expect(response.body).toEqual({
37+
success: false,
38+
error: 'API route not found'
39+
});
40+
});
41+
42+
it('keeps registered API routes ahead of the terminal boundary', async () => {
43+
const response = await request(app).get('/api/health').expect(200);
44+
45+
expect(response.body.status).toBe('ok');
46+
});
47+
48+
it('keeps non-API navigation behind the SPA fallback', async () => {
49+
const response = await request(app)
50+
.get('/people/interesting-ancestor')
51+
.expect(200);
52+
53+
expect(response.type).toBe('text/html');
54+
expect(response.text).toContain('SparseTree test client');
55+
});
56+
});

0 commit comments

Comments
 (0)