Skip to content
Open
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
1,532 changes: 1,532 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,14 @@
"build": "rm -rf lib && tsc",
"prepublishOnly": "rm -rf lib && tsc",
"typecheck": "tsc --noEmit",
"test": "node --experimental-strip-types --test src/*.test.ts",
"test": "node --experimental-strip-types --test \"src/**/*.test.ts\"",
"lint": "oxlint src",
"fmt": "oxfmt",
"fmt:check": "oxfmt --check",
"prepare": "husky"
},
"dependencies": {
"@tinyhttp/url": "^2.1.1",
"@tinyhttp/app": "^3.0.1",
"@tinyhttp/cors": "^2.0.1",
"@tinyhttp/logger": "^2.1.0",
Expand Down
46 changes: 46 additions & 0 deletions src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,49 @@ await test('createApp', async (t) => {
assert.deepEqual(data, { error: 'Body must be a JSON object' })
})
})

await test('createApp query route rewrites run before API routing', async (t) => {
const rewritePort = await getPort()
const rewriteDb = new Low<Data>(new Memory<Data>(), {})
rewriteDb.data = {
posts: [
{ id: 1, title: 'foo', group: 'a' },
{ id: 2, title: 'bar', group: 'b' },
],
}

const rewriteApp = createApp(rewriteDb, {
routes: {
'/blog?customNamedId=:id': '/posts?id=:id',
},
})

await new Promise<void>((resolve, reject) => {
try {
const server = rewriteApp.listen(rewritePort, () => resolve())
t.after(() => server.close())
} catch (err) {
reject(err)
}
})

await t.test('rewrites /blog?customNamedId=1 to /posts?id=1', async () => {
const response = await fetch(`http://localhost:${rewritePort}/blog?customNamedId=1`)
assert.equal(response.status, 200)
assert.deepEqual(await response.json(), [{ id: 1, title: 'foo', group: 'a' }])
})

await t.test('preserves unrelated query params after rewrite', async () => {
const response = await fetch(
`http://localhost:${rewritePort}/blog?customNamedId=1&group=a`,
)
assert.equal(response.status, 200)
assert.deepEqual(await response.json(), [{ id: 1, title: 'foo', group: 'a' }])
})

await t.test('does not break existing routes', async () => {
const response = await fetch(`http://localhost:${rewritePort}/posts?title=bar`)
assert.equal(response.status, 200)
assert.deepEqual(await response.json(), [{ id: 2, title: 'bar', group: 'b' }])
})
})
28 changes: 28 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { existsSync } from 'node:fs'
import { dirname, isAbsolute, join } from 'node:path'
import { fileURLToPath } from 'node:url'

Expand All @@ -9,6 +10,12 @@ import { json } from 'milliparsec'
import sirv from 'sirv'

import { parseWhere } from './parse-where.ts'
import {
compileRewriteRules,
createQueryRewriteMiddleware,
loadRoutesFile,
type RoutesConfig,
} from './rewrite/rewrite-middleware.ts'
import type { Data } from './service.ts'
import { isItem, Service } from './service.ts'

Expand All @@ -18,6 +25,18 @@ const isProduction = process.env['NODE_ENV'] === 'production'
export type AppOptions = {
logger?: boolean
static?: string[]
/** Query-based rewrites; overrides same keys from `routes.json` when both exist. */
routes?: RoutesConfig
/** Defaults to `<cwd>/routes.json` when that file exists. */
routesFile?: string
/** Log query rewrites to stdout, or set env `JSON_SERVER_REWRITE_DEBUG=1`. */
rewriteDebug?: boolean
}

function resolveRoutesConfig(options: AppOptions): RoutesConfig {
const path = options.routesFile ?? join(process.cwd(), 'routes.json')
const fromFile = existsSync(path) ? (loadRoutesFile(path) ?? {}) : {}
return { ...fromFile, ...options.routes }
}

const eta = new Eta({
Expand Down Expand Up @@ -118,6 +137,15 @@ export function createApp(db: Low<Data>, options: AppOptions = {}) {
// Body parser
app.use(json())

const rewriteRules = compileRewriteRules(resolveRoutesConfig(options))
if (rewriteRules.length > 0) {
app.use(
createQueryRewriteMiddleware(rewriteRules, {
debug: options.rewriteDebug,
}),
)
}

app.get('/', (_req, res) => res.send(eta.render('index.html', { data: db.data })))

app.get('/:name', (req, res, next) => {
Expand Down
57 changes: 57 additions & 0 deletions src/rewrite/rewrite-middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import assert from 'node:assert'
import { describe, it } from 'node:test'

import { getPathname, getQueryParams } from '@tinyhttp/url'

import { compileRewriteRules, createQueryRewriteMiddleware } from './rewrite-middleware.ts'

function runRewrite(
url: string,
query: Record<string, string | string[] | undefined>,
routes: Record<string, string>,
) {
const req: {
method: string
url: string
query: Record<string, unknown>
} = {
method: 'GET',
url,
query: query as Record<string, unknown>,
}
const mw = createQueryRewriteMiddleware(compileRewriteRules(routes))
mw(req, {}, () => {})
return { req }
}

describe('createQueryRewriteMiddleware', () => {
it('rewrites /blog?customNamedId=1 to /posts?id=1', () => {
const { req } = runRewrite(
'/blog?customNamedId=1',
{ customNamedId: '1' },
{ '/blog?customNamedId=:id': '/posts?id=:id' },
)
assert.strictEqual(req.url, '/posts?id=1')
assert.strictEqual(getPathname(req.url), '/posts')
assert.strictEqual(getQueryParams(req.url)['id'], '1')
})

it('leaves URL unchanged when no rule matches', () => {
const { req } = runRewrite('/posts', {}, { '/blog?customNamedId=:id': '/posts?id=:id' })
assert.strictEqual(req.url, '/posts')
})

it('handles multiple captured query params', () => {
const { req } = runRewrite(
'/search?author=a&tag=js',
{ author: 'a', tag: 'js' },
{ '/search?author=:x&tag=:y': '/posts?user=:x&filter=:y' },
)
assert.strictEqual(req.url, '/posts?user=a&filter=js')
})

it('does not match when a required pattern query param is missing', () => {
const { req } = runRewrite('/blog', {}, { '/blog?customNamedId=:id': '/posts?id=:id' })
assert.strictEqual(req.url, '/blog')
})
})
115 changes: 115 additions & 0 deletions src/rewrite/rewrite-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'

import { getPathname, getQueryParams } from '@tinyhttp/url'

import {
buildRewrittenUrl,
matchRoutePattern,
parseRoutePattern,
type ParsedRoutePattern,
} from './route-pattern.ts'

export type RoutesConfig = Record<string, string>

export type QueryRewriteMiddlewareOptions = {
/** When true, logs incoming URL, matched rule, and rewritten URL. */
debug?: boolean
}

function resolveRewriteDebug(options?: QueryRewriteMiddlewareOptions): boolean {
if (options?.debug !== undefined) return options.debug
return process.env['JSON_SERVER_REWRITE_DEBUG'] === '1'
}

export type CompiledRewriteRule = {
sourcePattern: string
parsed: ParsedRoutePattern
destinationTemplate: string
}

export function compileRewriteRules(routes: RoutesConfig): CompiledRewriteRule[] {
const rules: CompiledRewriteRule[] = []
for (const [sourcePattern, destinationTemplate] of Object.entries(routes)) {
rules.push({
sourcePattern,
parsed: parseRoutePattern(sourcePattern),
destinationTemplate,
})
}
return rules
}

function syncRequestUrl(req: { url: string; path?: string; query: Record<string, unknown> }) {
req.url = req.url.startsWith('/') ? req.url : `/${req.url}`
req.path = getPathname(req.url)
req.query = getQueryParams(req.url) as Record<string, unknown>
}

/**
* Loads `routes.json` from cwd if present. Invalid JSON throws.
*/
export function loadRoutesFile(path = join(process.cwd(), 'routes.json')): RoutesConfig | undefined {
if (!existsSync(path)) return undefined
const raw = readFileSync(path, 'utf-8')
const data = JSON.parse(raw) as unknown
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('routes.json must be a JSON object')
}
const out: RoutesConfig = {}
for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
if (k.startsWith('$')) continue
if (typeof v !== 'string') {
throw new Error(`routes.json: destination for "${k}" must be a string`)
}
out[k] = v
}
return out
}

/**
* Middleware: applies the first matching rewrite rule. Rules are compiled at startup (see {@link compileRewriteRules}).
* After rewriting, `req.url`, `req.path`, and `req.query` are synced for tinyhttp + json-server.
*
* Debug: set `options.debug`, or env `JSON_SERVER_REWRITE_DEBUG=1`.
*/
export function createQueryRewriteMiddleware(
rules: CompiledRewriteRule[],
options?: QueryRewriteMiddlewareOptions,
) {
const debug = resolveRewriteDebug(options)

return (
req: { method?: string; url: string; path?: string; query: Record<string, unknown> },
_res: unknown,
next: () => void,
) => {
if (debug) console.log('[json-server rewrite] incoming', req.method ?? 'GET', req.url)

const pathname = getPathname(req.url)
const query = req.query as Record<string, string | string[] | undefined>

for (const rule of rules) {
const result = matchRoutePattern(rule.parsed, pathname, query)
if (!result.match) continue

const nextUrl = buildRewrittenUrl(
rule.destinationTemplate,
rule.parsed,
result.params,
query,
)

if (debug) {
console.log('[json-server rewrite] matched', rule.sourcePattern, '->', rule.destinationTemplate)
console.log('[json-server rewrite] rewritten', nextUrl)
}

req.url = nextUrl
syncRequestUrl(req)
return next()
}

next()
}
}
93 changes: 93 additions & 0 deletions src/rewrite/route-pattern.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import assert from 'node:assert'
import { describe, it } from 'node:test'

import {
applyDestinationPattern,
buildRewrittenUrl,
matchRoutePattern,
parseRoutePattern,
} from './route-pattern.ts'

describe('parseRoutePattern', () => {
it('parses path and single capture query', () => {
assert.deepStrictEqual(parseRoutePattern('/blog?customNamedId=:id'), {
path: '/blog',
query: { customNamedId: ':id' },
})
})

it('parses multiple query params', () => {
assert.deepStrictEqual(parseRoutePattern('/a?x=:id&y=fixed&z=:other'), {
path: '/a',
query: { x: ':id', y: 'fixed', z: ':other' },
})
})
})

describe('matchRoutePattern', () => {
it('matches blog example and extracts id', () => {
const pattern = parseRoutePattern('/blog?customNamedId=:id')
const r = matchRoutePattern(pattern, '/blog', { customNamedId: '1' })
assert.strictEqual(r.match, true)
if (r.match) assert.deepStrictEqual(r.params, { id: '1' })
})

it('rejects wrong path', () => {
const pattern = parseRoutePattern('/blog?customNamedId=:id')
const r = matchRoutePattern(pattern, '/news', { customNamedId: '1' })
assert.strictEqual(r.match, false)
})

it('matches literal query value', () => {
const pattern = parseRoutePattern('/x?mode=edit&id=:id')
const r = matchRoutePattern(pattern, '/x', { mode: 'edit', id: '99' })
assert.strictEqual(r.match, true)
if (r.match) assert.deepStrictEqual(r.params, { id: '99' })
})

it('allows extra query keys on request', () => {
const pattern = parseRoutePattern('/blog?customNamedId=:id')
const r = matchRoutePattern(pattern, '/blog', {
customNamedId: '1',
sort: 'title',
})
assert.strictEqual(r.match, true)
})
})

describe('applyDestinationPattern', () => {
it('builds /posts?id=1 from template and params', () => {
assert.strictEqual(applyDestinationPattern('/posts?id=:id', { id: '1' }), '/posts?id=1')
})

it('formats several query params with URLSearchParams', () => {
assert.strictEqual(
applyDestinationPattern('/x?a=:x&b=:y', { x: '1', y: 'two' }),
'/x?a=1&b=two',
)
})

it('encodes special characters in query values', () => {
assert.strictEqual(
applyDestinationPattern('/q?n=:name', { name: 'a&b' }),
'/q?n=a%26b',
)
})
})

describe('buildRewrittenUrl', () => {
it('rewrites and preserves unrelated query params', () => {
const pattern = parseRoutePattern('/blog?customNamedId=:id')
const r = matchRoutePattern(pattern, '/blog', {
customNamedId: '1',
sort: 'title',
})
assert.strictEqual(r.match, true)
if (!r.match) return
const url = buildRewrittenUrl('/posts?id=:id', pattern, r.params, {
customNamedId: '1',
sort: 'title',
})
assert.strictEqual(url, '/posts?id=1&sort=title')
})
})
Loading