Spec: Hotel Discovery API
A production-ready RESTful API for finding and exploring hotels, backed by the provided 40-Property dataset. Vocabulary follows CONTEXT.md; decisions respect docs/adr/0001–0003.
Problem Statement
A traveller browsing a travel platform needs to find Properties that fit their trip and quickly tell which ones are actually bookable for their dates — without wading through irrelevant Properties or discovering only late that nothing is open for their Stay window. From the platform's side, there is no backend service yet that lets a client search the Property catalogue, drill into a single Property's full details, and check live Room availability and pricing for a set of travel dates.
Solution
A lightweight RESTful API over the Property catalogue that lets a client:
- Search Properties, filtering by city, star rating, and price.
- Retrieve a single Property's full details by id.
- Check availability — see which Rooms in a Property are open for a given Stay window, with per-night and total pricing.
The catalogue is loaded in memory from the provided dataset behind a repository interface. The service is self-documenting (OpenAPI/Swagger) and ships with a health check, tests, CI, and a container image.
User Stories
- As an API client, I want to list all Properties, so that I can browse the full catalogue.
- As a traveller, I want to filter Properties by city, so that I only see options where I'm travelling.
- As a traveller, I want city filtering to be case-insensitive, so that "chicago" and "Chicago" both match.
- As a traveller, I want city matching to be exact (not substring), so that "York" does not wrongly match "New York".
- As a traveller, I want to filter Properties by an exact star rating, so that I can target a specific class of hotel.
- As a traveller, I want to filter Properties by a minimum price, so that I can exclude options below a floor.
- As a traveller, I want to filter Properties by a maximum price, so that I can stay within budget.
- As a traveller, I want to use minimum and maximum price independently or together, so that "under $200" or "$100–$200" both work.
- As a traveller, I want price filtering to use each Property's cheapest Room (
price_from), so that "from $X" matches how I think about hotel prices.
- As a traveller, I want to combine city, star, and price filters, so that all supplied criteria narrow the results together (AND).
- As an API client, I want the Property list to return a compact summary per Property (id, name, city, country, star_rating, overall_rating, review_count, price_from), so that I can render result cards without over-fetching.
- As an API client, I want a search with no matches to return an empty successful result, so that "nothing found" is not treated as an error.
- As an API client, I want to retrieve a single Property's full details by id, so that I can show its description, address, contact, amenities, policies, ratings, and Rooms.
- As an API client, I want a request for an unknown Property id to return Not Found, so that I can distinguish missing resources from other failures.
- As a traveller, I want to check Room availability for a Property by supplying check-in and check-out dates, so that I can see what is bookable for my trip.
- As a traveller, I want only the Rooms that are available for my whole Stay window returned, so that I never see options I cannot book.
- As a traveller, I want availability to require every Night of my Stay window (check-in up to but not including check-out), so that a two-night stay correctly needs both of its Nights.
- As a traveller, I want each available Room to show its price per night, so that I can compare nightly rates.
- As a traveller, I want each available Room to show the total price and the number of nights for my Stay window, so that I know the full cost, not just the nightly rate.
- As a traveller, I want a Property with no open Rooms for my dates to return an empty successful result, so that "no availability" is a clear, non-error outcome.
- As an API client, I want check-in and check-out to be required on the availability endpoint, so that a missing date is rejected rather than silently ignored.
- As an API client, I want a check-out on or before check-in to be rejected as a bad request, so that nonsensical Stay windows fail fast.
- As an API client, I want malformed dates, out-of-range star ratings, and a minimum price above the maximum to be rejected as bad requests, so that invalid input is caught at the boundary.
- As an API client, I want all errors in one consistent shape (
{ error: { code, message } }), so that I can handle failures uniformly.
- As an operator, I want a health-check endpoint, so that a load balancer or orchestrator can probe liveness.
- As a developer, I want interactive API documentation (Swagger UI), so that I can explore and exercise every endpoint without writing a client.
- As a reviewer, I want the availability, filtering, and pricing logic covered by exhaustive unit tests, so that I can trust the trickiest behaviour.
- As a reviewer, I want each endpoint covered by integration tests that also assert the response matches its published schema, so that the contract is provably enforced.
- As a maintainer, I want the data source hidden behind a repository interface with a documented migration path, so that swapping the in-memory store for Postgres/SQLite touches one place.
Implementation Decisions
- Stack: TypeScript + Node + Fastify; input validation with Zod via the Fastify Zod type-provider (one schema → runtime validation + inferred types); npm; Node 20 LTS pinned.
- Layering:
routes → services → repository (interface) plus pure domain functions (isRoomAvailable, filterHotels, priceFrom) with no framework or I/O dependency. Services depend on the HotelRepository interface, never on the seed directly.
- Data source (ADR-0001): the dataset is loaded into memory at startup behind
HotelRepository. No database. A migration guide to Postgres/SQLite is documented.
- Availability (ADR-0002): a Room is available for a Stay window iff every Night in the half-open interval
[check_in, check_out) is present in that Room's open dates (per-Night set membership). Checkout day is not required. Empty open-dates → never available.
- Price (ADR-0003):
price_from = the minimum price_per_night across a Property's Rooms; price filtering uses it and is independent of date availability.
- Endpoints:
GET /hotels — optional filters city (case-insensitive exact), star_rating (exact integer 1–5), min_price, max_price (over price_from), combined with AND. Returns a summary projection wrapped as { data: [...] }. No pagination. No matches → 200 { data: [] }.
GET /hotels/:id — full Property object; unknown id → 404.
GET /hotels/:id/rooms?check_in&check_out — dates required; returns available Rooms (each with price_per_night, total_price, nights) wrapped as { data: [...] } with the Stay window echoed as metadata; unknown id → 404; none available → 200 { data: [] }.
GET /health — 200.
- Errors: only
400 (invalid/malformed input; missing required dates; check_out <= check_in; star_rating out of range; min_price > max_price) and 404 (unknown Property). Empty results are 200, never 404. A central error handler renders all failures — including normalized Zod validation errors — as { error: { code, message } }.
- Wire format:
snake_case throughout (query params and response bodies), matching the brief and the dataset. Derived fields (price_from, total_price, nights) follow the same convention.
- Response envelope: list responses are wrapped
{ data: [...] } (keeps future pagination non-breaking); single-resource detail is returned directly.
- Self-documentation: OpenAPI + Swagger UI at
/docs, generated from the Zod request/response schemas.
Testing Decisions
- What a good test is: it exercises external behaviour (inputs → outputs, status codes, response shape), never implementation details. Tests must survive a refactor that preserves behaviour.
- Framework: Vitest.
- Two seams (see the seam discussion in the design thread):
- HTTP boundary via
app.inject() (primary, highest seam) — exercises routing → validation → services → repository → domain end-to-end, in-process. Covers each endpoint's full contract: happy path, 404, 400, and empty 200. Each response is asserted to conform to its Zod response schema (schema-conformance / contract enforcement).
- Pure domain functions (secondary, deliberately lower seam) — the availability/filter/pricing edge-case matrix: nights-vs-days, one-night stays, empty open-dates, the sparse 2026-07-14 upper boundary, a synthetic gapped open-dates set (proving per-Night membership rather than range containment), AND filter combinations, and
price_from across multi-Room Properties.
- Modules tested: the pure domain functions (exhaustive) and every endpoint (integration). Depth over blanket coverage percentage.
- Prior art: none — greenfield. This spec establishes the two-seam pattern for the repo.
- Contract tests: enforced via Zod response-schema conformance in the integration tests. Consumer-driven contract tests (Pact) are deliberately not used — there is no consumer service — and this is documented.
Out of Scope
- A real database or any persistence (in-memory only; migration path documented, not built).
- Pagination (deliberately omitted at 40 records; documented, and the
{ data } envelope keeps it non-breaking).
- Authentication and authorization (no
401/403).
- Amenity filtering, region/country filtering, guest-count/occupancy filtering, and sorting.
- Multi-currency (USD assumed; the dataset has no currency field).
- Booking, reservations, holds, or any mutation of inventory — this is a read-only discovery API.
- Consumer-driven contract testing (Pact).
- The frontend exercise (the backend option was chosen).
Further Notes
- Dataset characteristics to respect: availability is per-Night not per-day; the open dates all fall 2026-07-10..14 and are treated as the source of truth rather than gated on the real current date; exactly 15% of Properties (6 of 40) have no availability at all;
"free Wi-Fi" is the one amenity that is not clean snake_case; pricing exists only at the Room level; regions/states are non-US for international Properties.
- Operational hardening (part of this feature):
tsconfig strict + noUncheckedIndexedAccess; ESLint (flat) + Prettier; a Husky pre-push hook running typecheck → lint → test (fail-fast); a GitHub Actions CI workflow mirroring the hook plus a docker build job; a multi-stage Dockerfile (slim base, non-root, .dockerignore, HEALTHCHECK against /health).
- Documentation deliverables:
README.md (quickstart, endpoint reference + Swagger link, architecture, testing, and an AI-usage section) and a standalone ASSUMPTIONS.md capturing every decision and its rationale, including the Pact "know when not to reach for it" note.
- AI usage is documented transparently per the brief and reflected in the commit history.
Spec: Hotel Discovery API
A production-ready RESTful API for finding and exploring hotels, backed by the provided 40-Property dataset. Vocabulary follows
CONTEXT.md; decisions respectdocs/adr/0001–0003.Problem Statement
A traveller browsing a travel platform needs to find Properties that fit their trip and quickly tell which ones are actually bookable for their dates — without wading through irrelevant Properties or discovering only late that nothing is open for their Stay window. From the platform's side, there is no backend service yet that lets a client search the Property catalogue, drill into a single Property's full details, and check live Room availability and pricing for a set of travel dates.
Solution
A lightweight RESTful API over the Property catalogue that lets a client:
The catalogue is loaded in memory from the provided dataset behind a repository interface. The service is self-documenting (OpenAPI/Swagger) and ships with a health check, tests, CI, and a container image.
User Stories
price_from), so that "from $X" matches how I think about hotel prices.{ error: { code, message } }), so that I can handle failures uniformly.Implementation Decisions
routes → services → repository (interface)plus pure domain functions (isRoomAvailable,filterHotels,priceFrom) with no framework or I/O dependency. Services depend on theHotelRepositoryinterface, never on the seed directly.HotelRepository. No database. A migration guide to Postgres/SQLite is documented.[check_in, check_out)is present in that Room's open dates (per-Night set membership). Checkout day is not required. Empty open-dates → never available.price_from= the minimumprice_per_nightacross a Property's Rooms; price filtering uses it and is independent of date availability.GET /hotels— optional filterscity(case-insensitive exact),star_rating(exact integer 1–5),min_price,max_price(overprice_from), combined with AND. Returns a summary projection wrapped as{ data: [...] }. No pagination. No matches →200 { data: [] }.GET /hotels/:id— full Property object; unknown id →404.GET /hotels/:id/rooms?check_in&check_out— dates required; returns available Rooms (each withprice_per_night,total_price,nights) wrapped as{ data: [...] }with the Stay window echoed as metadata; unknown id →404; none available →200 { data: [] }.GET /health—200.400(invalid/malformed input; missing required dates;check_out <= check_in;star_ratingout of range;min_price > max_price) and404(unknown Property). Empty results are200, never404. A central error handler renders all failures — including normalized Zod validation errors — as{ error: { code, message } }.snake_casethroughout (query params and response bodies), matching the brief and the dataset. Derived fields (price_from,total_price,nights) follow the same convention.{ data: [...] }(keeps future pagination non-breaking); single-resource detail is returned directly./docs, generated from the Zod request/response schemas.Testing Decisions
app.inject()(primary, highest seam) — exercises routing → validation → services → repository → domain end-to-end, in-process. Covers each endpoint's full contract: happy path,404,400, and empty200. Each response is asserted to conform to its Zod response schema (schema-conformance / contract enforcement).price_fromacross multi-Room Properties.Out of Scope
{ data }envelope keeps it non-breaking).401/403).Further Notes
"free Wi-Fi"is the one amenity that is not clean snake_case; pricing exists only at the Room level; regions/states are non-US for international Properties.tsconfigstrict +noUncheckedIndexedAccess; ESLint (flat) + Prettier; a Husky pre-push hook running typecheck → lint → test (fail-fast); a GitHub Actions CI workflow mirroring the hook plus adocker buildjob; a multi-stage Dockerfile (slim base, non-root,.dockerignore,HEALTHCHECKagainst/health).README.md(quickstart, endpoint reference + Swagger link, architecture, testing, and an AI-usage section) and a standaloneASSUMPTIONS.mdcapturing every decision and its rationale, including the Pact "know when not to reach for it" note.