diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f44588d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,29 @@ +# Rebuilt or irrelevant inside the image — keep the build context small and hermetic. +node_modules +dist +coverage +.git +.gitignore +.github +.husky + +# Tests and their config never run in the runtime image. +test +vitest.config.mts + +# Local tooling, docs, and secrets. +docs +*.md +.env +.env.* +.DS_Store +.vscode +.idea +.nvmrc +.prettierrc.json +.prettierignore +eslint.config.mjs + +# The image build files themselves. +Dockerfile +.dockerignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ad6e580 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + # Trunk pushes plus every PR. Feature-branch work is covered via its PR, so we + # don't list branches under `push` — that would double-run CI (push + PR events). + push: + branches: [main] + pull_request: + +# Cancel superseded runs on the same ref to save runner minutes. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Typecheck · Lint · Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + # Run the same gate the pre-push hook does — one source of truth. + - run: npm run verify + + docker: + name: Docker build + needs: verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + # Build the runtime image to prove it assembles; no registry push. + - uses: docker/build-push-action@v6 + with: + context: . + push: false + load: true + tags: infinite-choice:ci + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..fed2b8f --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,6 @@ +# Pre-push quality gate: typecheck → lint → test (via `npm run verify`). +# `set -e` makes the fail-fast contract explicit here, independent of husky's runner. +# Mirrors GitHub Actions CI so failures surface locally first. +# Escape hatch: `git push --no-verify` to skip (use sparingly). +set -e +npm run verify diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2455f2f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1 + +# ---- Builder: install the full toolchain and compile TypeScript to dist/ ---- +FROM node:20-slim AS builder +WORKDIR /app + +# Install deps against the lockfile first so this layer caches across source edits. +# --ignore-scripts skips lifecycle hooks (e.g. husky's `prepare`, which needs a +# git repo that isn't in the build context) — the build only needs `tsc`. +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts + +COPY tsconfig.json tsconfig.build.json ./ +COPY src ./src +RUN npm run build + +# ---- Runtime: production dependencies only, non-root, slim ---- +FROM node:20-slim AS runtime +# One source of truth for the port: the app, EXPOSE, and the healthcheck all +# read PORT, so overriding it at runtime keeps them in lockstep. +ENV NODE_ENV=production \ + PORT=3000 +WORKDIR /app + +# Production dependencies only — no dev toolchain ships in the final image. +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force + +# Compiled output plus the catalogue seed the app reads from cwd/data at runtime. +COPY --from=builder /app/dist ./dist +COPY data ./data + +# Drop privileges: run as the unprivileged `node` user baked into the base image. +USER node + +EXPOSE ${PORT} + +# Liveness probe hits the app's own GET /health using Node's global fetch, +# so the slim image needs no curl/wget. Non-zero exit marks the container unhealthy. +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node --no-warnings -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["node", "dist/server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..13f7e13 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# InfiniteChoice — Hotel Discovery API + +[![CI](https://github.com/kernvex/InfiniteChoice/actions/workflows/ci.yml/badge.svg)](https://github.com/kernvex/InfiniteChoice/actions/workflows/ci.yml) + +A lightweight, production-ready RESTful API for finding and exploring hotels. Built +with [Fastify](https://fastify.dev/), TypeScript, and Zod. See [`CONTEXT.md`](CONTEXT.md) +for the domain glossary. + +## Endpoints + +| Method | Path | Description | +| ------ | ------------------- | ----------------------------------------------- | +| GET | `/health` | Liveness probe | +| GET | `/hotels` | Search & filter properties (summary projection) | +| GET | `/hotels/:id` | Property detail | +| GET | `/hotels/:id/rooms` | Room availability & pricing for a stay window | +| GET | `/docs` | Swagger UI | + +## Requirements + +- Node.js 20 (see [`.nvmrc`](.nvmrc)) + +## Getting started + +```sh +npm ci +npm run dev # hot-reloading dev server on http://localhost:3000 +``` + +The service reads its catalogue seed from [`data/hotels.json`](data/hotels.json) at startup. + +### Scripts + +| Script | Purpose | +| ------------------- | ------------------------------------------------------- | +| `npm run dev` | Dev server with hot reload | +| `npm run build` | Compile TypeScript to `dist/` | +| `npm start` | Run the compiled server | +| `npm run typecheck` | Type-check without emitting | +| `npm run lint` | ESLint | +| `npm test` | Run the test suite once (Vitest) | +| `npm run verify` | `typecheck → lint → test`, fail-fast (the quality gate) | + +## Quality gate + +A Husky **pre-push** hook runs `npm run verify` (`typecheck → lint → test`, +fail-fast), so failures surface locally before they reach CI. To bypass it in a pinch: + +```sh +git push --no-verify +``` + +GitHub Actions [CI](.github/workflows/ci.yml) mirrors the same checks on every push +and pull request, then builds the Docker image (no registry push). + +## Docker + +Multi-stage build producing a slim, dev-dependency-free image that runs as the +non-root `node` user with a `/health` HEALTHCHECK: + +```sh +docker build -t infinite-choice . +docker run --rm -p 3000:3000 infinite-choice +``` + +Then hit . diff --git a/package-lock.json b/package-lock.json index 4a23ea4..5b14c96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@types/node": "^20.19.43", "eslint": "^10.7.0", "eslint-config-prettier": "^10.1.8", + "husky": "^9.1.7", "prettier": "^3.9.6", "tsx": "^4.23.1", "typescript": "^6.0.3", @@ -2490,6 +2491,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/package.json b/package.json index 99e06ae..710a0c4 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "test:watch": "vitest", "typecheck": "tsc --noEmit", "lint": "eslint .", - "format": "prettier --write ." + "verify": "npm run typecheck && npm run lint && npm test", + "format": "prettier --write .", + "prepare": "husky" }, "dependencies": { "@fastify/swagger": "^9.8.1", @@ -29,6 +31,7 @@ "@types/node": "^20.19.43", "eslint": "^10.7.0", "eslint-config-prettier": "^10.1.8", + "husky": "^9.1.7", "prettier": "^3.9.6", "tsx": "^4.23.1", "typescript": "^6.0.3",