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
29 changes: 29 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <http://localhost:3000/health>.
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading