// A small node:http stand-in for the anonymous Journal API // (/_journal/api/v1), answering from JSON fixtures. It follows the Go // handlers' query rules (page, per_page clamp, sort allow-list, category with // nested children, tag, search, include_empty) and the author routes, and it // records every request so a smoke run can assert header forwarding. // // Plain .mjs with JSDoc types: Node does not strip TypeScript under // node_modules, and consuming apps import this file from there. import { createServer } from 'node:http' import { readFileSync } from 'node:fs' import { join } from 'node:path' const PREFIX = '/_journal/api/v1' const DEFAULT_PER_PAGE = 9 const MAX_PER_PAGE = 30 const SORT_COLUMNS = new Set(['published_at', 'created_at', 'updated_at', 'title', 'id']) /** * @typedef {{ method: string, url: string, xff: string | null }} FixtureRequest * @typedef {{ url: string, requests: FixtureRequest[], reload: () => void, close: () => Promise }} FixtureApi */ /** @param {string} dir @param {string} name @param {any} fallback */ function readJson(dir, name, fallback) { try { return JSON.parse(readFileSync(join(dir, name), 'utf8')) } catch (err) { if (/** @type {any} */ (err).code === 'ENOENT') return fallback throw err } } /** @param {any} post */ function isPublished(post) { return post.published === true && !!post.published_at && Date.parse(post.published_at) <= Date.now() } /** @param {string | null} raw */ function parsePage(raw) { const n = Number.parseInt(raw ?? '', 10) return Number.isFinite(n) && n > 0 ? n : 1 } /** @param {string | null} raw */ function parsePerPage(raw) { if (raw === null || raw.trim() === '') return DEFAULT_PER_PAGE const n = Number.parseInt(raw, 10) if (!Number.isFinite(n)) return DEFAULT_PER_PAGE return Math.min(MAX_PER_PAGE, Math.max(1, n)) } /** @param {string | null} raw @returns {[string, 'asc' | 'desc']} */ function parseSort(raw) { const [col, dir] = (raw ?? '').trim().split(/\s+/) if (col && SORT_COLUMNS.has(col)) return [col, dir === 'asc' ? 'asc' : 'desc'] return ['published_at', 'desc'] } /** @param {any[]} rows @param {[string, 'asc' | 'desc']} sort */ function sortRows(rows, [col, dir]) { const sign = dir === 'asc' ? 1 : -1 return [...rows].sort((a, b) => { const av = a[col] const bv = b[col] if (av === bv) return (a.id - b.id) * sign return (av < bv ? -1 : 1) * sign }) } /** @param {any[]} tree @returns {any[]} */ function flatten(tree) { /** @type {any[]} */ const out = [] const walk = (/** @type {any[]} */ nodes) => { for (const n of nodes ?? []) { out.push(n) walk(n.children) } } walk(tree) return out } /** @param {any[]} tree @param {boolean} includeEmpty @returns {any[]} */ function filterTree(tree, includeEmpty) { /** @type {any[]} */ const out = [] for (const node of tree ?? []) { const children = filterTree(node.children ?? [], includeEmpty) if (!includeEmpty && node.post_count === 0 && children.length === 0) continue const { children: _drop, ...row } = node if (children.length > 0) row.children = children out.push(row) } return out } /** @param {any[]} rows @param {number} page @param {number} perPage */ function paginate(rows, page, perPage) { const total = rows.length const lastPage = Math.max(1, Math.ceil(total / perPage)) return { data: rows.slice((page - 1) * perPage, page * perPage), meta: { current_page: page, last_page: lastPage, per_page: perPage, total }, } } /** @param {any} post */ function sibling(post) { return post ? { id: post.id, title: post.title, slug: post.slug } : null } /** * Starts the fixture API on 127.0.0.1. * @param {{ dir: string, port?: number }} opts * @returns {Promise} */ export function startFixtureApi({ dir, port = 0 }) { /** @type {any[]} */ let posts = [] /** @type {any[]} */ let categories = [] /** @type {any[]} */ let tags = [] /** @type {any[]} */ let authors = [] let rss = '' // Reads (or re-reads) the fixtures, so a check can change a fixture file // while the API runs. const load = () => { posts = readJson(dir, 'posts.json', []) categories = readJson(dir, 'categories.json', []) tags = readJson(dir, 'tags.json', []) authors = readJson(dir, 'authors.json', []) try { rss = readFileSync(join(dir, 'rss.xml'), 'utf8') } catch { rss = '' } } load() /** @type {FixtureRequest[]} */ const requests = [] const published = () => posts.filter(isPublished) /** @param {string} value */ const resolveAuthor = (value) => { const bySlug = authors.find((a) => a.slug === value) if (bySlug) return bySlug const byLogin = authors.find((a) => a.login === value) if (byLogin && published().some((p) => p.author && p.author.id === byLogin.id)) return byLogin return null } /** @param {URLSearchParams} q */ const listPosts = (q, base = published()) => { let rows = base const category = q.get('category') if (category) { const flat = flatten(categories) const root = flat.find((c) => c.slug === category || String(c.id) === category) const ids = new Set(root ? flatten([root]).map((c) => c.id) : []) rows = rows.filter((p) => p.categories.some((/** @type {any} */ c) => ids.has(c.id))) } const tag = q.get('tag') if (tag) rows = rows.filter((p) => p.tags.some((/** @type {any} */ t) => t.slug === tag)) const search = (q.get('search') ?? '').trim() if (search.length >= 3) { const s = search.toLowerCase() rows = rows.filter((p) => p.title.toLowerCase().includes(s)) } rows = sortRows(rows, parseSort(q.get('sort'))) return paginate(rows, parsePage(q.get('page')), parsePerPage(q.get('per_page'))) } /** * @param {import('node:http').IncomingMessage} req * @returns {{ status: number, body: string, type: string }} */ const route = (req) => { const url = new URL(req.url ?? '/', 'http://fixture.local') const json = (/** @type {number} */ status, /** @type {any} */ value) => ({ status, body: JSON.stringify(value), type: 'application/json', }) if (!url.pathname.startsWith(PREFIX + '/')) return json(404, { error: 'Not found' }) if (req.method !== 'GET' && req.method !== 'HEAD') return json(405, { error: 'Method not allowed' }) const path = decodeURIComponent(url.pathname.slice(PREFIX.length)) const q = url.searchParams if (path === '/posts') return json(200, listPosts(q)) if (path.startsWith('/posts/')) { const slug = path.slice('/posts/'.length) const live = sortRows(published(), ['published_at', 'desc']) const post = live.find((p) => p.slug === slug || String(p.id) === slug) if (!post) return json(404, { error: 'Post not found' }) const idx = live.indexOf(post) const catIds = new Set(post.categories.map((/** @type {any} */ c) => c.id)) const related = catIds.size === 0 ? [] : live .filter((p) => p.id !== post.id && p.categories.some((/** @type {any} */ c) => catIds.has(c.id))) .slice(0, 3) return json(200, { data: { ...post, previous_post: sibling(live[idx + 1]), next_post: sibling(live[idx - 1]), related_posts: related }, }) } if (path === '/categories') { const includeEmpty = ['1', 'true'].includes(q.get('include_empty') ?? '') return json(200, { data: filterTree(categories, includeEmpty) }) } if (path === '/tags') return json(200, { data: tags }) if (path === '/rss') return { status: 200, body: rss, type: 'application/rss+xml; charset=utf-8' } const author = /^\/authors\/([^/]+)(\/posts)?$/.exec(path) if (author) { const row = resolveAuthor(author[1].trim()) if (!row) return json(404, { error: 'Author not found' }) if (!author[2]) { return json(200, { data: { id: row.id, slug: row.slug, first_name: row.first_name, last_name: row.last_name } }) } const mine = published().filter((p) => p.author && p.author.id === row.id) return json(200, listPosts(new URLSearchParams({ page: q.get('page') ?? '', per_page: q.get('per_page') ?? '', sort: q.get('sort') ?? '' }), mine)) } return json(404, { error: 'Not found' }) } const server = createServer((req, res) => { const xff = req.headers['x-forwarded-for'] requests.push({ method: req.method ?? 'GET', url: req.url ?? '/', xff: Array.isArray(xff) ? xff.join(', ') : (xff ?? null) }) let out try { out = route(req) } catch (err) { out = { status: 500, body: JSON.stringify({ error: String(err) }), type: 'application/json' } } res.writeHead(out.status, { 'content-type': out.type }) res.end(req.method === 'HEAD' ? undefined : out.body) }) return new Promise((resolve, reject) => { server.once('error', reject) server.listen(port, '127.0.0.1', () => { const addr = /** @type {import('node:net').AddressInfo} */ (server.address()) resolve({ url: `http://127.0.0.1:${addr.port}`, requests, reload: load, close: () => new Promise((done) => server.close(() => done())), }) }) }) }