Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions api/_lib/dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from 'vitest';
import { dispatch, routeAction } from './dispatch';

describe('routeAction', () => {
it('reads the rewritten query first', () => {
expect(routeAction({ url: '/api/daily?action=start', query: { action: 'start' } }, 'daily')).toBe('start');
});
it('falls back to the path', () => {
expect(routeAction({ url: '/api/ranked/submit?x=1' }, 'ranked')).toBe('submit');
expect(routeAction({ url: '/api/daily?userId=u1', query: { userId: 'u1' } }, 'daily')).toBe('');
});
});

describe('dispatch', () => {
it('runs the matching route and 404s the rest', async () => {
const start = vi.fn();
const json = vi.fn();
const res = { status: vi.fn(() => ({ json })) };
const handler = dispatch('ranked', { start });
await handler({ url: '/api/ranked/start' }, res);
expect(start).toHaveBeenCalledOnce();
await handler({ url: '/api/ranked/nope' }, res);
expect(res.status).toHaveBeenCalledWith(404);
});
});
36 changes: 36 additions & 0 deletions api/_lib/dispatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* One serverless function serving several routes, to stay inside the Vercel
* Hobby plan's 12 functions. vercel.json rewrites /api/<group>/<action> to
* /api/<group>?action=<action>; the path is read too, for local servers that
* skip the rewrite.
*/

interface DispatchRequest {
url?: string;
query?: Record<string, string | string[] | undefined>;
}

interface DispatchResponse {
status: (code: number) => { json: (body: unknown) => void };
}

// Each route declares its own request and response shapes, so they are loosely typed here.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Route = (req: any, res: any) => unknown;

export function routeAction(req: DispatchRequest, group: string): string {
const fromQuery = req.query?.action;
const action = Array.isArray(fromQuery) ? fromQuery[0] : fromQuery;
if (action) return action;
const path = (req.url ?? '').split('?')[0];
const match = path.match(new RegExp(`/api/${group}/([\\w-]+)/?$`));
return match ? match[1] : '';
}

export function dispatch(group: string, routes: Record<string, Route>) {
return (req: DispatchRequest, res: DispatchResponse) => {
const route = routes[routeAction(req, group)];
if (!route) return void res.status(404).json({ error: 'Not found.' });
return route(req, res);
};
}
2 changes: 1 addition & 1 deletion api/_lib/ranked-challenge.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { selectSnippetCode } from "../ranked/challenge";
import { selectSnippetCode } from "./routes/ranked-challenge";
import { isWithinLengthSpec } from "../../src/utils/ranking";
import type { SnippetLength } from "../../src/types";

Expand Down
4 changes: 2 additions & 2 deletions api/daily/start.ts → api/_lib/routes/daily-start.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import crypto from 'node:crypto';
import { Permission, Role } from 'node-appwrite';
import { APPWRITE, adminDatabases, applyCors, authenticateRequest, isConfigured, type ApiRequest, type ApiResponse } from '../_lib/appwrite-admin.js';
import { getOrCreateChallenge, sessionChallengeHash, utcDateKey, type DailyDb } from '../_lib/daily.js';
import { APPWRITE, adminDatabases, applyCors, authenticateRequest, isConfigured, type ApiRequest, type ApiResponse } from '../appwrite-admin.js';
import { getOrCreateChallenge, sessionChallengeHash, utcDateKey, type DailyDb } from '../daily.js';

const RUN_WINDOW_MS = 20 * 60 * 1000;

Expand Down
4 changes: 2 additions & 2 deletions api/daily/submit.ts → api/_lib/routes/daily-submit.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { APPWRITE, adminDatabases, applyCors, authenticateRequest, isConfigured, readBody, type ApiRequest, type ApiResponse } from '../_lib/appwrite-admin.js';
import { APPWRITE, adminDatabases, applyCors, authenticateRequest, isConfigured, readBody, type ApiRequest, type ApiResponse } from '../appwrite-admin.js';
import {
findChallenge,
isDateKey,
Expand All @@ -10,7 +10,7 @@ import {
utcDateKey,
type DailyDb,
type SubmittedMetrics,
} from '../_lib/daily.js';
} from '../daily.js';

interface SubmitBody extends SubmittedMetrics {
sessionId: string;
Expand Down
4 changes: 2 additions & 2 deletions api/daily/index.ts → api/_lib/routes/daily-today.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Query } from 'node-appwrite';
import { APPWRITE, adminDatabases, applyCors, isConfigured, type ApiRequest, type ApiResponse } from '../_lib/appwrite-admin.js';
import { getOrCreateChallenge, listBoard, nextResetAt, userStreaks, utcDateKey, type DailyDb } from '../_lib/daily.js';
import { APPWRITE, adminDatabases, applyCors, isConfigured, type ApiRequest, type ApiResponse } from '../appwrite-admin.js';
import { getOrCreateChallenge, listBoard, nextResetAt, userStreaks, utcDateKey, type DailyDb } from '../daily.js';

/**
* GET /api/daily?userId=...
Expand Down
12 changes: 6 additions & 6 deletions api/ranked/challenge.ts → api/_lib/routes/ranked-challenge.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Client, Databases, Account, Permission, Role } from 'node-appwrite';
import crypto from 'node:crypto';
import { SNIPPETS } from '../../src/data/snippets.js';
import { SNIPPET_LENGTH_SPEC, isTextLanguage } from '../../src/utils/ranking.js';
import { ENGLISH_WORDS } from '../../src/data/text/english-words.js';
import { INDONESIAN_WORDS } from '../../src/data/text/indonesian-words.js';
import { PASSAGES } from '../../src/data/text/passages.js';
import type { SnippetLength, TestMode } from '../../src/types.js';
import { SNIPPETS } from '../../../src/data/snippets.js';
import { SNIPPET_LENGTH_SPEC, isTextLanguage } from '../../../src/utils/ranking.js';
import { ENGLISH_WORDS } from '../../../src/data/text/english-words.js';
import { INDONESIAN_WORDS } from '../../../src/data/text/indonesian-words.js';
import { PASSAGES } from '../../../src/data/text/passages.js';
import type { SnippetLength, TestMode } from '../../../src/types.js';

interface ApiRequest {
method?: string;
Expand Down
File renamed without changes.
6 changes: 3 additions & 3 deletions api/ranked/submit.ts → api/_lib/routes/ranked-submit.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Client, Databases, Account, Permission, Role } from 'node-appwrite';
import { MIN_RANKED_ACCURACY, MIN_RANKED_WPM } from '../../src/utils/ranking.js';
import type { SnippetLength, TestMode } from '../../src/types.js';
import { consistencyFromIntervals, encodeTrace, traceFromIntervals } from '../../src/utils/speed-trace.js';
import { MIN_RANKED_ACCURACY, MIN_RANKED_WPM } from '../../../src/utils/ranking.js';
import type { SnippetLength, TestMode } from '../../../src/types.js';
import { consistencyFromIntervals, encodeTrace, traceFromIntervals } from '../../../src/utils/speed-trace.js';

interface ApiRequest {
method?: string;
Expand Down
7 changes: 7 additions & 0 deletions api/daily.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { dispatch } from './_lib/dispatch.js';
import today from './_lib/routes/daily-today.js';
import start from './_lib/routes/daily-start.js';
import submit from './_lib/routes/daily-submit.js';

/** GET /api/daily, POST /api/daily/start, POST /api/daily/submit. */
export default dispatch('daily', { '': today, start, submit });
7 changes: 7 additions & 0 deletions api/ranked.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { dispatch } from './_lib/dispatch.js';
import challenge from './_lib/routes/ranked-challenge.js';
import start from './_lib/routes/ranked-start.js';
import submit from './_lib/routes/ranked-submit.js';

/** POST /api/ranked/challenge, /api/ranked/start and /api/ranked/submit. */
export default dispatch('ranked', { challenge, start, submit });
Loading
Loading