diff --git a/api/.dockerignore b/api/.dockerignore new file mode 100644 index 0000000..d5843d0 --- /dev/null +++ b/api/.dockerignore @@ -0,0 +1,3 @@ +node_modules +**/*.test.ts +.env* diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..b5aa2fa --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,24 @@ +# The Relay API image. Bun runs our TypeScript directly, so there's no build step. + +# Stage 1: install production dependencies only (no TypeScript, no @types). +FROM oven/bun:1.4.4-slim AS deps +WORKDIR /app +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile --production + +# Stage 2: the image that runs. Bun's download cache from stage 1 stays behind. +FROM oven/bun:1.4.4-slim +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY package.json ./ +COPY src ./src + +# The pipeline passes the commit; /version reports it, so we can see what's running. +ARG GIT_SHA=dev +ENV GIT_SHA=$GIT_SHA + +USER bun +EXPOSE 8080 + +# Exec form: Bun itself is the main process, so it receives SIGTERM directly. +CMD ["bun", "src/server.ts"] diff --git a/api/bun.lock b/api/bun.lock new file mode 100644 index 0000000..92cac1f --- /dev/null +++ b/api/bun.lock @@ -0,0 +1,125 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "dependencies": { + "fastify": "5.12.7", + "zod": "4.6.7", + }, + "devDependencies": { + "@types/bun": "1.4.4", + "typescript": "6.0.3", + }, + }, + }, + "packages": { + "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.7", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^4.0.0" } }, "sha512-VP0yIfwlc1ohj733keYDqyHm1VWeP9VQxYomunhr90jkC4aNNibbkrRiaNgPgqj1ORxUenILR4qzA/ppYBNAtQ=="], + + "@fastify/deepmerge": ["@fastify/deepmerge@3.2.4", "", {}, "sha512-kc8GQq5gX9RRhw0rov3DMrN8wS8yRlxJRdDzsYwwdBV/wazuVgc/1K7CKL90tlvu3RlHan6Y0R0aE3nJtBz2eQ=="], + + "@fastify/error": ["@fastify/error@4.2.1", "", {}, "sha512-MYBumuIwP+5MIpXSyKm6/o1Eigcoij186lWUssX3LH5U8KWqU0ktZhzC4CErIX9EJeQeRGI+zJZQWakBlFbUcg=="], + + "@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.1.1", "", { "dependencies": { "fast-json-stringify": "^7.0.0" } }, "sha512-pyxRKk7UpUnnrztP9pF4+ZYxusEQ4LifTyzRdiXmyql1QezNHxO02D25MVU2o4kMeAMYWAjG6RKRlojgkbiDAw=="], + + "@fastify/forwarded": ["@fastify/forwarded@3.1.2", "", {}, "sha512-89WiRG+hW3VQSfCwg4Hb8b43PeyEX15K5+lEfsZ4EUth/FyOjS/lv8HsVuswUNXWiSnxMGNouE/fq/0w3VumHQ=="], + + "@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.2", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-d/gx9sRbUCVgGRGzma46wLcwiY8noTqO2Nmjg/vbTARxHRE5bdh5PIL8s97Lt1X7ScRWRW52eVYxwIoPwIdVUA=="], + + "@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.2", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-uqB4QsgVoro0XDC+DIftIMGXHdBPn+lK6Ek3xXAIxPMCJfqxnbtyEoMCm2KPUVOuIosd7Cyw9HawEqMFM/UA+g=="], + + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + + "@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="], + + "@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="], + + "abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + + "avvio": ["avvio@9.3.1", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-QyTKW61/qlba+cUQBm/sT9X8lri4UdomqSDwCnrnj1IvLVDRRiXRkz4CgBRWB+/cmfC7SK5WQc8T1vSdGvaUcQ=="], + + "bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="], + + "cookie": ["cookie@2.0.1", "", {}, "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stringify": ["fast-json-stringify@7.2.0", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^4.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-q18imgm9fEdQVfvJbRJALE9VP/A5HGme0+NUnWcmFYM9rbQbLkZZk20FyIp1LQ8gDOauQKwqMc52YozqFaQyOw=="], + + "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], + + "fast-uri": ["fast-uri@4.2.3", "", {}, "sha512-I1PXwP5eAhlNlcdpjt9CgB59op2ZvZfxw+BkqGFVIUOgNpCVTcTqp3HhTX7brxj5vsip+nSY4nTvff+eIA76hg=="], + + "fastify": ["fastify@5.12.7", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^7.0.0", "find-my-way": "^9.6.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.1.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw=="], + + "fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="], + + "find-my-way": ["find-my-way@9.10.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-eru+uCPQ2g+nLBkf84MHP/KyNNMar0BDy0j+i8Fb72GF9/YHBZ58q7FODUtfVV7XU9nfRUHXSmaM8NmZcIbZ4Q=="], + + "ipaddr.js": ["ipaddr.js@2.5.0", "", {}, "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w=="], + + "json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-w+HwgljXpVwZ3IidoUR1qPZxSOQ+NRQWuVe6a5pe8XbHsNZE4UAwVPUvzZagn7yBKbJtt6UJnRFYbNTRPZI45Q=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "light-my-request": ["light-my-request@6.8.0", "", { "dependencies": { "@fastify/deepmerge": "^3.1.0", "cookie": "^2.0.0", "process-warning": "^5.0.0", "set-cookie-parser": "^3.0.1" } }, "sha512-5Q8PjoSEdtGIB8fikZsTem5vgRo4F2mR6vUKTDW/H1g6MtAt8e3duQbg/QxL5yzg6BZhor2KOUm3hhWMWLTZwg=="], + + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + + "pino": ["pino@10.4.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^1.0.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA=="], + + "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], + + "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + + "process-warning": ["process-warning@5.1.1", "", {}, "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg=="], + + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + + "real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "ret": ["ret@0.5.1", "", {}, "sha512-Z/nyvABwcRnRD2mMP7Kz6G1dFZZqX1mNErOnxF42OCQKE07b9k9hK9SQxHL3LFEZUdzQaLM4dId1E/E6Vm4WhA=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + + "safe-regex2": ["safe-regex2@5.1.2", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-tAtCGFPPP02VroDVzp4daGi6bC7jWG+41D47GKf18FeH/ubvKQHT9VmhAkvtjNOmJwqtApzWeUpEhkyEYULvmw=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "secure-json-parse": ["secure-json-parse@4.1.1", "", {}, "sha512-lnd81MQEZKjhW/Gd2TQqdd4XP6QYNdDW/EP+I7nTrFHP9y2LmaGPIHk9xsnzcOiEr1c39c/e84xFgGviQKBTRw=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "set-cookie-parser": ["set-cookie-parser@3.2.1", "", {}, "sha512-Npd1Pt+4zb3BrSZDPd2qFwRpS6b1EUEasRrO8xS0zMJi0fApXXK5YuzP0minyJnxwnFHoVoJwA55q0A0cu63gQ=="], + + "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="], + + "toad-cache": ["toad-cache@3.7.4", "", {}, "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], + + "ajv/fast-uri": ["fast-uri@3.1.9", "", {}, "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ=="], + } +} diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..ca6c435 --- /dev/null +++ b/api/package.json @@ -0,0 +1,19 @@ +{ + "name": "relay-api", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --watch src/server.ts", + "start": "bun src/server.ts", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "fastify": "5.12.7", + "zod": "4.6.7" + }, + "devDependencies": { + "@types/bun": "1.4.4", + "typescript": "6.0.3" + } +} \ No newline at end of file diff --git a/api/src/app.test.ts b/api/src/app.test.ts new file mode 100644 index 0000000..7e28ee1 --- /dev/null +++ b/api/src/app.test.ts @@ -0,0 +1,32 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { buildApp } from "./app.ts"; +import { loadConfig } from "./config.ts"; + +// A config made from a fixed object, so the test never depends on this machine's environment. +const app = buildApp(loadConfig({ GIT_SHA: "abc123" }), { logger: false }); +afterAll(() => app.close()); + +describe("health routes", () => { + test("GET /health/live answers ok", async () => { + const res = await app.inject({ method: "GET", url: "/health/live" }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ status: "ok" }); + }); + + test("GET /health/ready answers ok", async () => { + const res = await app.inject({ method: "GET", url: "/health/ready" }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ status: "ok" }); + }); + + test("GET /version shows the commit that is running", async () => { + const res = await app.inject({ method: "GET", url: "/version" }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ sha: "abc123" }); + }); + + test("every response carries an x-request-id", async () => { + const res = await app.inject({ method: "GET", url: "/health/live" }); + expect(res.headers["x-request-id"]).toMatch(/^[0-9a-f-]{36}$/); + }); +}); diff --git a/api/src/app.ts b/api/src/app.ts new file mode 100644 index 0000000..cda8204 --- /dev/null +++ b/api/src/app.ts @@ -0,0 +1,36 @@ +// Builds the Fastify app: settings, hooks and routes, wired together. +// It never listens on a port itself. server.ts does that, so tests can call +// buildApp() and send fake requests with app.inject(). +import Fastify, { LogController, type FastifyInstance } from "fastify"; +import type { Config } from "./config.ts"; +import { errorHandler, notFoundHandler } from "./error-handler.ts"; +import { healthRoutes } from "./routes/health.ts"; + +export function buildApp(config: Config, options: { logger?: boolean } = {}): FastifyInstance { + const app = Fastify({ + // JSON logs (pino). Tests pass logger: false to keep their output clean. + logger: options.logger === false ? false : { level: config.LOG_LEVEL }, + bodyLimit: 256 * 1024, // 256 KiB, bigger bodies get 413 + genReqId: () => crypto.randomUUID(), // a unique id for every request + logController: new LogController({ + requestIdLogLabel: "request_id", // the name of that id in every log line + // Kubernetes calls the health routes every few seconds on every pod. + // Logging them would bury the lines that matter. + disableRequestLogging: (request) => request.url.startsWith("/health/"), + }), + }); + + // Every response tells the client its request id: the same id as in our logs, + // so "it failed" plus the id is enough to find the exact log lines. + app.addHook("onRequest", async (request, reply) => { + reply.header("x-request-id", request.id); + }); + + // Set before the routes are registered, so every route uses them. + app.setErrorHandler(errorHandler); + app.setNotFoundHandler(notFoundHandler); + + app.register(healthRoutes, { config }); + + return app; +} diff --git a/api/src/config.test.ts b/api/src/config.test.ts new file mode 100644 index 0000000..74ff307 --- /dev/null +++ b/api/src/config.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { loadConfig } from "./config.ts"; + +describe("loadConfig", () => { + test("uses safe defaults when nothing is set", () => { + expect(loadConfig({})).toEqual({ PORT: 8080, GIT_SHA: "dev", LOG_LEVEL: "info" }); + }); + + test("turns PORT from text into a number", () => { + expect(loadConfig({ PORT: "3000" }).PORT).toBe(3000); + }); + + test("refuses a PORT that is not a valid port", () => { + expect(() => loadConfig({ PORT: "banana" })).toThrow(/PORT/); + expect(() => loadConfig({ PORT: "70000" })).toThrow(/PORT/); + expect(() => loadConfig({ PORT: "3000abc" })).toThrow(/PORT/); + expect(() => loadConfig({ PORT: "80.5" })).toThrow(/PORT/); + }); + + test("refuses an unknown LOG_LEVEL", () => { + expect(() => loadConfig({ LOG_LEVEL: "loud" })).toThrow(/LOG_LEVEL/); + }); +}); diff --git a/api/src/config.ts b/api/src/config.ts new file mode 100644 index 0000000..89dd7e0 --- /dev/null +++ b/api/src/config.ts @@ -0,0 +1,35 @@ +// Reads the environment variables once at startup and checks them with Zod. +// A missing or wrong value stops the app right away with a clear message, +// instead of failing later in the middle of a request. +import { z } from "zod"; + + + +// The settings the API needs. Env vars are text, so PORT is converted and checked. +const Env = z.object({ + + PORT: z + .string() + .transform((s) => Number(s)) + .refine((n) => Number.isInteger(n), { message: "must be a whole number" }) + .refine((n) => n >= 1 && n <= 65535, { message: "must be a whole number between 1 and 65535" }) + .default(8080), + + GIT_SHA: z + .string().default("dev"), + + LOG_LEVEL: z + .enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"), + + +}); + +export type Config = z.infer; + +export function loadConfig(env: Record = process.env): Config { + const result = Env.safeParse(env); + if (!result.success) { + throw new Error(`Invalid configuration:\n${z.prettifyError(result.error)}`); + } + return result.data; +} diff --git a/api/src/error-handler.test.ts b/api/src/error-handler.test.ts new file mode 100644 index 0000000..3d1f512 --- /dev/null +++ b/api/src/error-handler.test.ts @@ -0,0 +1,81 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { z } from "zod"; +import { buildApp } from "./app.ts"; +import { loadConfig } from "./config.ts"; +import { conflict } from "./errors.ts"; + +const app = buildApp(loadConfig({}), { logger: false }); + +// Test-only routes that fail on purpose, one for each kind of error. +app.get("/test/zod", async () => z.object({ name: z.string() }).parse({})); +app.get("/test/conflict", async () => { + throw conflict("already_delivered", "This delivery was already delivered"); +}); +app.get("/test/crash", async () => { + throw new Error("connection failed: password=hunter2"); +}); +app.post("/test/echo", async (request) => request.body); + +afterAll(() => app.close()); + +describe("error handler", () => { + test("unknown route → 404 not_found, in our error shape", async () => { + const res = await app.inject({ method: "GET", url: "/nope" }); + expect(res.statusCode).toBe(404); + expect(res.json().error.code).toBe("not_found"); + expect(res.json().error.request_id).toBe(res.headers["x-request-id"]); + }); + + test("Zod error → 422 validation_failed, naming the wrong field", async () => { + const res = await app.inject({ method: "GET", url: "/test/zod" }); + expect(res.statusCode).toBe(422); + expect(res.json().error.code).toBe("validation_failed"); + expect(res.json().error.details).toEqual([{ path: "name", message: expect.any(String) }]); + }); + + test("our own AppError → its own status and code", async () => { + const res = await app.inject({ method: "GET", url: "/test/conflict" }); + expect(res.statusCode).toBe(409); + expect(res.json().error.code).toBe("already_delivered"); + }); + + test("broken JSON → 400 bad_request", async () => { + const res = await app.inject({ + method: "POST", + url: "/test/echo", + headers: { "content-type": "application/json" }, + payload: "{not json", + }); + expect(res.statusCode).toBe(400); + expect(res.json().error.code).toBe("bad_request"); + }); + + test("body that isn't JSON → 415 unsupported_media_type", async () => { + const res = await app.inject({ + method: "POST", + url: "/test/echo", + headers: { "content-type": "application/xml" }, + payload: "", + }); + expect(res.statusCode).toBe(415); + expect(res.json().error.code).toBe("unsupported_media_type"); + }); + + test("body over 256 KiB → 413 payload_too_large", async () => { + const res = await app.inject({ + method: "POST", + url: "/test/echo", + headers: { "content-type": "application/json" }, + payload: JSON.stringify({ data: "x".repeat(300 * 1024) }), + }); + expect(res.statusCode).toBe(413); + expect(res.json().error.code).toBe("payload_too_large"); + }); + + test("unexpected crash → 500 internal_error, and the details are NOT leaked", async () => { + const res = await app.inject({ method: "GET", url: "/test/crash" }); + expect(res.statusCode).toBe(500); + expect(res.json().error.code).toBe("internal_error"); + expect(res.body).not.toContain("hunter2"); + }); +}); diff --git a/api/src/error-handler.ts b/api/src/error-handler.ts new file mode 100644 index 0000000..7449102 --- /dev/null +++ b/api/src/error-handler.ts @@ -0,0 +1,50 @@ +// The ONE place where errors become HTTP answers. Routes, services and repositories +// never build error responses: they throw, and Fastify sends every thrown error here. +// Every answer has the same shape: {"error": {"code", "message", "request_id"}}. +import type { FastifyError, FastifyReply, FastifyRequest } from "fastify"; +import { ZodError } from "zod"; +import { AppError } from "./errors.ts"; + +// Fastify's own client errors, by status, with our names for them. +const CLIENT_ERROR_CODES: Record = { + 400: "bad_request", // for example broken JSON + 413: "payload_too_large", // body over our 256 KiB limit + 415: "unsupported_media_type", // body that isn't JSON +}; + +function send( + reply: FastifyReply, + request: FastifyRequest, + status: number, + code: string, + message: string, + details?: unknown, +) { + const error = { code, message, request_id: request.id, ...(details ? { details } : {}) }; + return reply.code(status).send({ error }); +} + +export function errorHandler(error: FastifyError, request: FastifyRequest, reply: FastifyReply) { + + if (error instanceof ZodError) { + const details = error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })); + return send(reply, request, 422, "validation_failed", "Request validation failed", details); + } + + if (error instanceof AppError) { + return send(reply, request, error.status, error.code, error.message); + } + + if (error.statusCode && error.statusCode >= 400 && error.statusCode < 500) { + const code = CLIENT_ERROR_CODES[error.statusCode] || "bad_request"; + return send(reply, request, error.statusCode, code, error.message); + } + + request.log.error(error); + + return send(reply, request, 500, "internal_error", "Something went wrong on our side"); +} + +export function notFoundHandler(request: FastifyRequest, reply: FastifyReply) { + return send(reply, request, 404, "not_found", `Route ${request.method} ${request.url} not found`); +} diff --git a/api/src/errors.ts b/api/src/errors.ts new file mode 100644 index 0000000..eb2f53b --- /dev/null +++ b/api/src/errors.ts @@ -0,0 +1,17 @@ +// Errors our own code throws on purpose ("this delivery doesn't exist", "already delivered"). +// Code deep down only throws them; the error handler decides what the client sees. +export class AppError extends Error { + readonly status: number; + readonly code: string; + + constructor(status: number, code: string, message: string) { + super(message); + this.name = "AppError"; + this.status = status; + this.code = code; + } +} + +export const notFound = (what: string) => new AppError(404, "not_found", `${what} not found`); + +export const conflict = (code: string, message: string) => new AppError(409, code, message); diff --git a/api/src/routes/health.ts b/api/src/routes/health.ts new file mode 100644 index 0000000..25faf92 --- /dev/null +++ b/api/src/routes/health.ts @@ -0,0 +1,17 @@ +// Routes that Kubernetes and the pipeline call. No API key needed. +// /health/live "is the process alive?" Never checks the database: a database outage +// must not make Kubernetes restart every pod. +// /health/ready "can I take traffic right now?" Phase 2 adds the database check here. +// While shutting down it answers 503 without any code here: after +// app.close() (server.ts), Fastify answers every new request with 503. +// /version which commit is running. The pipeline checks it after each deploy. +import type { FastifyPluginAsync } from "fastify"; +import type { Config } from "../config.ts"; + +export const healthRoutes: FastifyPluginAsync<{ config: Config }> = async (app, { config }) => { + app.get("/health/live", async () => ({ status: "ok" })); + + app.get("/health/ready", async () => ({ status: "ok" })); + + app.get("/version", async () => ({ sha: config.GIT_SHA })); +}; diff --git a/api/src/server.ts b/api/src/server.ts new file mode 100644 index 0000000..5b50744 --- /dev/null +++ b/api/src/server.ts @@ -0,0 +1,26 @@ +// Starts the API: reads the config, builds the app, listens on a port, +// and stops cleanly when Kubernetes (or Ctrl+C) asks it to. +import { buildApp } from "./app.ts"; +import { loadConfig } from "./config.ts"; + +const config = loadConfig(); // a bad environment stops the app here, with a clear message +const app = buildApp(config); + +// 0.0.0.0 means "every network interface". Fastify's default (localhost) can't be +// reached from outside the container, so Kubernetes and Traefik would never reach us. +await app.listen({ port: config.PORT, host: "0.0.0.0" }); + +// When Kubernetes sends SIGTERM or Ctrl+C sends SIGINT, we stop the server +async function shutdown(signal: string) { + app.log.info({ signal }, "shutting down"); + try { + await app.close(); + process.exit(0); + } catch (error) { + app.log.error(error); + process.exit(1); + } +} + +process.once("SIGTERM", () => shutdown("SIGTERM")); +process.once("SIGINT", () => shutdown("SIGINT")); diff --git a/api/tsconfig.json b/api/tsconfig.json new file mode 100644 index 0000000..b2e7497 --- /dev/null +++ b/api/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "types": ["bun"], + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}