Skip to content

Repository files navigation

InfiniteChoice — Hotel Discovery API

CI

A lightweight, production-ready RESTful API for finding and exploring hotels: search a catalogue of properties, view a property's full detail, and check which rooms are open for a set of travel dates. Built with Fastify, TypeScript, and Zod.

  • Domain glossary: CONTEXT.md — the ubiquitous language (Property, Room, Stay window, Night, Availability, price_from, …) the code and API share.
  • Decisions & rationale: ASSUMPTIONS.md and the ADRs.

Why this stack

Choice Rationale
Fastify Fast, minimal HTTP framework with first-class schema validation and a plugin model that keeps routes, error handling, and docs cleanly separated.
TypeScript Types are the contract. The domain models are inferred from the same schemas that validate data and responses, so drift between model, wire, and docs can't happen.
Zod + type-provider One schema per shape drives validation, response serialization, and the OpenAPI/Swagger docs — a single source of truth instead of three that rot apart.
In-memory data store The catalogue is 40 read-only properties in a JSON seed; a database would add setup cost with no payoff at this scale. It sits behind a repository interface so a real store is a one-file swap — see ADR-0001 and the migration guide.
Vitest Fast unit + integration runner; the app is built un-listened so integration tests drive it via app.inject() without binding a port.

Endpoints

Method Path Description Query / path params
GET /health Liveness probe
GET /hotels Search & filter properties (summary projection) city, star_rating (1–5), min_price, max_price — all optional, AND-combined
GET /hotels/:id Property detail id (path)
GET /hotels/:id/rooms Room availability & pricing for a stay window id (path); check_in, check_out (YYYY-MM-DD, required, half-open)
GET /docs Swagger UI (OpenAPI generated from the schemas)

Full, always-current request/response schemas live at /docs once the server is running.

Examples

# Four-star hotels in London from $200/night
curl 'http://localhost:3000/hotels?city=London&star_rating=4&min_price=200'

# One property's full detail
curl 'http://localhost:3000/hotels/hotel-01'

# Rooms available for a two-night stay (nights 07-10 and 07-11)
curl 'http://localhost:3000/hotels/hotel-01/rooms?check_in=2026-07-10&check_out=2026-07-12'

Every response is enveloped: successes as { "data": ... } (with meta on the rooms endpoint), failures as { "error": { "code", "message" } }.

Requirements

Getting started

npm ci
npm run dev        # hot-reloading dev server on http://localhost:3000

The service reads its catalogue seed from data/hotels.json at startup and validates it against the domain schema, so a malformed seed fails fast and legibly rather than surfacing mid-request.

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)

Architecture

Requests flow through three layers, each with a single job, plus a set of pure functions the layers lean on:

HTTP request
  │
  ▼
routes/         Fastify handlers. Own the wire contract: Zod schemas validate the
  │             query/params/body and serialize the response, and map results into
  │             the { data } / { error } envelope. No business logic.
  ▼
domain/         Pure functions over plain domain values — filtering (search.ts),
  │             availability & pricing (availability.ts), and the list/availability
  │             projections. No I/O, so the rules are exhaustively unit-testable.
  ▼
repository/     The data-source seam (HotelRepository). Loads & validates the JSON
                seed once and serves it from memory. Swapping in Postgres/SQLite
                touches only this file (see the migration guide).

Cross-cutting concerns are Fastify plugins: plugins/error-handler.ts normalizes every failure (Zod validation, thrown AppErrors, framework errors, unexpected exceptions) into the one { error: { code, message } } envelope, and plugins/swagger.ts generates the OpenAPI doc from the same Zod schemas the routes validate against. app.ts composes them and returns an un-listened instance; server.ts owns listen. The repository is injected, so tests can supply a fixture catalogue.

The key design move: the domain layer knows nothing about HTTP, and the routes know nothing about the business rules. That keeps the availability logic — the part the brief actually grades — testable in isolation and the data source swappable without touching either.

Data model

The catalogue is 40 Properties, each owning one or more Rooms — a single nested aggregate that mirrors the source dataset (no normalization or joins; the Property is the unit of storage and retrieval):

  • Propertyid, name, description, star_rating, overall_rating, review_count, nested address / contact / policies, amenities[], and rooms[].
  • Roomroom_id, type, bedding & occupancy (bed_type, bed_count, max_occupancy, square_footage), price_per_night, room_amenities[], and available_dates[] — the discrete open Nights that are the source of truth for availability.
  • Derived, never storedprice_from (a Property's cheapest Room rate) for search and list display, plus nights / total_price on the rooms endpoint. Computing these keeps one authoritative number per fact instead of a denormalized copy that can drift.

The Zod schemas in src/domain/hotel.ts are the single source of truth for these shapes: they validate the seed at load time (a malformed dataset fails fast and legibly) and their inferred types are the domain models the rest of the code programs against — so the model, the wire format, and the OpenAPI docs cannot drift apart. The wire format stays snake_case end-to-end to match the dataset. The domain vocabulary (Property, Room, Stay window, Night, price_from, …) is fixed in CONTEXT.md.

Testing strategy

npm test runs the suite across two complementary styles:

  • Unit tests over the pure domain functions (availability.test.ts, search.test.ts, hotel-summary.test.ts, repository.test.ts) exercise the rules and their edges directly — nights vs. days, the half-open upper boundary, empty open-dates, per-night set membership vs. range containment, the room-less price_from = null case — with no HTTP in the loop.
  • Integration tests (hotels.test.ts, hotel-detail.test.ts, hotel-rooms.test.ts, health.test.ts, docs.test.ts, error-envelope.test.ts) drive the real app and seed through app.inject(), asserting status codes, envelope shapes, and response-schema conformance (responses are parsed back through the Zod schemas), plus the validation boundaries and the standard 400/404 envelopes.

The seed itself is deliberately built to catch a naive availability reading, so the integration tests double as a check that the real data flows through correctly.

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:

git push --no-verify

GitHub Actions CI 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:

docker build -t infinite-choice .
docker run --rm -p 3000:3000 infinite-choice

Then hit http://localhost:3000/health.

Future work

Known, deliberately-deferred follow-ups — out of scope for this exercise, noted for honesty rather than left as silent debt:

  • Extract shared test factories. The makeRoom / makeProperty fixture builders are currently duplicated across test/availability.test.ts, test/hotel-summary.test.ts, and test/search.test.ts. Pulling them into a single test/factories.ts would remove the triplication and stop the defaults from drifting apart as tests grow. A related tidy is folding the repeated unknown-id 404 guard in src/routes/hotels.ts behind a small getPropertyOr404 helper.
  • Push filters into the data store. If the catalogue outgrew the in-memory seed, the pure filter predicates would move into SQL and the endpoint would gain pagination — see the migration guide.

Use of AI

This project was built with AI coding-agent assistance (Claude), under human direction, and this section is a transparent account of exactly how — including where AI was not used.

How it was used

  • Planning and scaffolding. Work was decomposed into GitHub Issues (foundation, search, detail, availability, hardening, docs), each implemented on its own branch and merged via PR. The agent drafted issues, ADRs, and the domain glossary, which were reviewed and edited before adoption.
  • Test-driven implementation. Each endpoint was built test-first: failing unit/integration tests capturing the acceptance criteria, then the implementation to pass them. The availability rule (nights vs. days, half-open window, set membership) was pinned down in tests before any code — see ADR-0002.
  • Refactoring and review. Every branch went through an automated code-review pass (standards + spec) before merge; several commits (e.g. "single-source CI gate", "enumerate Stay-window Nights once") are review follow-ups.
  • This documentation. The README, ASSUMPTIONS.md, and the migration guide were drafted from the actual source and then verified against it.

Where judgment stayed human

  • The domain interpretations — availability as per-night set membership over a half-open window, price_from as the cheapest room, price filtering independent of dates — are deliberate rulings, recorded as ADRs with their rationale rather than accepted as whatever the happy path produced.
  • Knowing when not to reach for a tool. Contract testing (e.g. Pact) and a real database were both consciously skipped: there is no second service consuming this API and no persistence requirement, so both would be ceremony without payoff at this scale. The repository seam and schema-driven docs give most of the same guarantees for free. (ASSUMPTIONS.md records these.)
  • Every AI-produced change was read, run (npm run verify), and owned before merge.

About

Hotel Discovery API — InfiniteChoice backend take-home

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages