From 3724a82bd8a868261eeb51fca8cf7a995b3bf5db Mon Sep 17 00:00:00 2001 From: "Aryan Singh K." <70511529+aryansk@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:29:49 +0530 Subject: [PATCH] feat: add public read-only places API (#123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/places exposes the merged data/places/*.json dataset programmatically, so anything can build on top of it without cloning the repo. Reuses the existing record contract (data/places.schema.json) rather than inventing a second one. - Filters: `city` (case-insensitive, slug-normalized like the city pages) and `category` (one of the six PLACE_TYPES). Unknown categories, empty or repeated values, and malformed limit/offset return 400 with a message — never silently ignored. - Pagination: default limit 100, hard maximum 500 (values above are clamped, not dumped), zero-based `offset`, and `total` reflecting the full match set before slicing. - The `country` filter is rejected with an explicit 400: the schema has no country field, so no record could satisfy it. Flagged for maintainer input rather than inventing a city->country mapping. - Headers: permissive CORS (Access-Control-Allow-Origin: *) and cache headers matching reality (dataset changes a few times a week: 6h ISR revalidate + stale-while-revalidate). - Docs: new /docs/places-api page (registered in the Developers group) documenting the shape, filters, pagination, and errors, linking the schema. 76 vitest tests (24 new: full parse/query contract unit tests plus end-to-end route-handler tests covering the issue's own verification examples), eslint clean, next build clean with /api/places and /docs/places-api both emitted. Closes #123 --- src/app/api/places/route.test.ts | 75 +++++++++++ src/app/api/places/route.ts | 43 +++++++ src/app/docs/places-api/page.tsx | 210 +++++++++++++++++++++++++++++++ src/lib/docs-nav.ts | 8 ++ src/lib/places-api.test.ts | 158 +++++++++++++++++++++++ src/lib/places-api.ts | 149 ++++++++++++++++++++++ 6 files changed, 643 insertions(+) create mode 100644 src/app/api/places/route.test.ts create mode 100644 src/app/api/places/route.ts create mode 100644 src/app/docs/places-api/page.tsx create mode 100644 src/lib/places-api.test.ts create mode 100644 src/lib/places-api.ts diff --git a/src/app/api/places/route.test.ts b/src/app/api/places/route.test.ts new file mode 100644 index 0000000..bf4b1c4 --- /dev/null +++ b/src/app/api/places/route.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { GET } from "./route"; + +function request(path: string): Request { + return new Request(`http://localhost${path}`); +} + +async function json(response: Response): Promise> { + return (await response.json()) as Record; +} + +describe("GET /api/places (route handler)", () => { + it("returns the dataset with the default limit", async () => { + const response = await GET(request("/api/places")); + expect(response.status).toBe(200); + const body = await json(response); + const data = body.data as unknown[]; + expect(body.total).toBeGreaterThan(0); + expect(data.length).toBe(100); // default limit + }) + + it("applies the city filter case-insensitively (issue verification example)", async () => { + const response = await GET(request("/api/places?city=Mumbai&limit=5")); + const body = await json(response); + expect(response.status).toBe(200); + expect(body.limit).toBe(5); + expect((body.data as { city: string }[]).every((p) => p.city === "mumbai")).toBe(true); + }) + + it("clamps a huge limit instead of dumping everything", async () => { + const response = await GET(request("/api/places?limit=999999")); + const body = await json(response); + expect(body.limit).toBe(500); + // The whole dataset (328 places today) fits under the cap, so the clamp + // must still return every row rather than erroring or dropping data. + expect((body.data as unknown[]).length).toBe(body.total); + }) + + it("filters by category", async () => { + const response = await GET(request("/api/places?category=airport")); + const body = await json(response); + expect(response.status).toBe(200); + expect((body.data as { type: string }[]).every((p) => p.type === "airport")).toBe(true); + }) + + it("returns 400 for an unknown category", async () => { + const response = await GET(request("/api/places?category=bookshop")); + expect(response.status).toBe(400); + const body = await json(response); + expect(body.error).toContain("bookshop"); + }) + + it("returns 400 for a country filter (dataset has no country field)", async () => { + const response = await GET(request("/api/places?country=India")); + expect(response.status).toBe(400); + const body = await json(response); + expect(String(body.error)).toContain("country"); + }) + + it("returns 400 for a malformed limit", async () => { + const response = await GET(request("/api/places?limit=abc")); + expect(response.status).toBe(400); + }) + + it("sends permissive CORS and cache headers on every response", async () => { + const ok = await GET(request("/api/places?limit=5")); + expect(ok.headers.get("access-control-allow-origin")).toBe("*"); + expect(ok.headers.get("cache-control")).toContain("public"); + + const bad = await GET(request("/api/places?category=bookshop")); + expect(bad.headers.get("access-control-allow-origin")).toBe("*"); + expect(bad.headers.get("cache-control")).toContain("public"); + }) +}); diff --git a/src/app/api/places/route.ts b/src/app/api/places/route.ts new file mode 100644 index 0000000..2c177e6 --- /dev/null +++ b/src/app/api/places/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; + +import { parsePlacesQuery, queryPlaces } from "@/lib/places-api"; +import { getPlaces } from "@/lib/places"; + +/** + * The dataset changes a few times a week at most (issue #123), so a 6-hour + * ISR window plus a stale-while-revalidate header keeps responses cached + * without ever serving stale data for long. + */ +export const revalidate = 21600; + +/** Every origin may read the dataset — that is the entire point of the API. */ +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", +} as const; + +const CACHE_HEADERS = { + "Cache-Control": + "public, max-age=3600, s-maxage=21600, stale-while-revalidate=86400", +} as const; + +export async function OPTIONS() { + return new NextResponse(null, { status: 204, headers: CORS_HEADERS }); +} + +export async function GET(request: Request) { + const url = new URL(request.url); + const parsed = parsePlacesQuery(url.searchParams); + if (!parsed.ok) { + return NextResponse.json( + { error: parsed.error }, + { status: 400, headers: { ...CORS_HEADERS, ...CACHE_HEADERS } }, + ); + } + + const result = queryPlaces(getPlaces(), parsed.query); + return NextResponse.json(result, { + headers: { ...CORS_HEADERS, ...CACHE_HEADERS }, + }); +} diff --git a/src/app/docs/places-api/page.tsx b/src/app/docs/places-api/page.tsx new file mode 100644 index 0000000..157c602 --- /dev/null +++ b/src/app/docs/places-api/page.tsx @@ -0,0 +1,210 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { CodeBlock } from "@/components/docs/code-block"; +import { CalloutCard } from "@/components/docs/callout-card"; +import { PLACES_API_LIMITS } from "@/lib/places-api"; + +export const metadata: Metadata = { + title: "Places API", + description: + "Read StudyMap's crowdsourced places dataset programmatically: GET /api/places, filters, pagination, and errors.", +}; + +const RESPONSE_EXAMPLE = `{ + "data": [ + { + "id": "mum-library-01", + "name": "David Sassoon Library", + "type": "library", + "city": "mumbai", + "lat": 18.9674, + "lng": 72.8339, + "address": "Fort, Mumbai 400001", + "gmaps_link": "https://maps.google.com/?q=18.9674,72.8339", + "added_by": "thunderblitzyt-eng" + } + ], + "total": 1, + "limit": 100, + "offset": 0 +}`; + +const ERROR_EXAMPLE = `{ + "error": "unknown category \\"bookshop\\"; expected one of library, other_places, airport, sat_centre, foreign_lang_exam_centre, gov_offices" +}`; + +const FILTERS: { name: string; type: string; notes: string }[] = [ + { + name: "city", + type: "string", + notes: + "Case-insensitive; spaces and hyphens are normalized to the dataset's underscore slugs (e.g. `New Delhi` matches `new delhi`). No matching city returns an empty `data` array.", + }, + { + name: "category", + type: "enum", + notes: + "One of `library`, `other_places`, `airport`, `sat_centre`, `foreign_lang_exam_centre`, `gov_offices`. Anything else is a 400.", + }, + { + name: "country", + type: "string", + notes: + "Rejected with a 400 today: the dataset schema has no `country` field yet, so no record could satisfy the filter.", + }, + { + name: "limit", + type: "positive integer", + notes: + `Default ${PLACES_API_LIMITS.defaultLimit}; values above the hard maximum of ${PLACES_API_LIMITS.maxLimit} are clamped, never dumped.`, + }, + { + name: "offset", + type: "non-negative integer", + notes: "Zero-based. Combine with `limit` to page through large results.", + }, +]; + +export default function PlacesApiPage() { + return ( +
+

+ Every place StudyMap renders is crowdsourced and committed to{" "} + data/places/*.json{" "} + — one file per category. The API below exposes that dataset read-only, + so anything can be built on top of it without cloning the repo. +

+ + + + GET /api/places + + The merged dataset, optionally filtered, with bounded pagination. + + + + +

+ Responses are plain JSON with permissive CORS ( + + Access-Control-Allow-Origin: * + + ), so browser code can call it directly. Responses are cached for 6 + hours (the dataset changes a few times a week at most) via{" "} + + Cache-Control + {" "} + headers. +

+
+
+ + + + Query parameters + + All optional; every value is validated, and invalid values are a + 400 with a message - never silently ignored. + + + +
    + {FILTERS.map((filter) => ( +
  • +

    + + {filter.name} + {" "} + + ({filter.type}) + +

    +

    {filter.notes}

    +
  • + ))} +
+
+
+ + + + Response shape + + Each record is exactly one entry from{" "} + + data/places/*.json + + , as defined by the schema. + + + + +

+ total{" "} + is the number of matches before pagination, so consumers know the + full result set size. The canonical record shape lives in{" "} + + data/places.schema.json + + . +

+
+
+ + + + Errors + + Invalid filter values return 400 with an{" "} + error{" "} + message instead of being ignored. + + + + +

+ 400 cases: an unknown category,{" "} + country{" "} + (no country data in the schema yet), a non-integer{" "} + limit{" "} + or offset, or + a repeated parameter. A city{" "} + with no places is an empty result, not an error. +

+
+
+ + + This endpoint is read-only and deliberately unauthenticated - the + dataset is public by design. To add or correct a place, open a pull + request against data/places/*.json{" "} + following the{" "} + + contributing guide + + . + +
+ ); +} diff --git a/src/lib/docs-nav.ts b/src/lib/docs-nav.ts index ce75351..dd9058e 100644 --- a/src/lib/docs-nav.ts +++ b/src/lib/docs-nav.ts @@ -158,6 +158,14 @@ export const docsNav: DocsNavEntry[] = [ iconClassName: "text-primary", group: "Developers", }, + { + href: "/docs/places-api", + title: "Places API", + description: "Read the crowdsourced places dataset programmatically: GET /api/places, filters, pagination, and errors.", + icon: Database, + iconClassName: "text-primary", + group: "Developers", + }, { href: "/docs/self-hosting", title: "Self-Hosting Guide", diff --git a/src/lib/places-api.test.ts b/src/lib/places-api.test.ts new file mode 100644 index 0000000..5fb9054 --- /dev/null +++ b/src/lib/places-api.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; + +import { + parsePlacesQuery, + PLACES_API_LIMITS, + queryPlaces, +} from "@/lib/places-api"; +import type { Place } from "@/lib/types"; + +function place(id: string, city: string, type: Place["type"] = "library"): Place { + return { + id, + name: id, + type, + city, + lat: 19.07, + lng: 72.87, + gmaps_link: "https://maps.google.com/?q=19.07,72.87", + added_by: "test", + }; +} + +const DATASET: Place[] = [ + place("mum-1", "mumbai", "library"), + place("mum-2", "mumbai", "sat_centre"), + place("nd-1", "new delhi", "library"), + place("nd-2", "new delhi", "gov_offices"), + place("ny-1", "new_york", "airport"), +]; + +function params(entries: [string, string][]): URLSearchParams { + return new URLSearchParams(entries); +} + +describe("parsePlacesQuery defaults", () => { + it("applies the default limit and zero offset", () => { + const parsed = parsePlacesQuery(params([])); + expect(parsed).toEqual({ + ok: true, + query: { + city: undefined, + category: undefined, + limit: PLACES_API_LIMITS.defaultLimit, + offset: 0, + }, + }); + }) + + it("clamps a limit above the hard maximum instead of dumping everything", () => { + const parsed = parsePlacesQuery(params([["limit", "999999"]])); + expect(parsed.ok && parsed.query.limit).toBe(PLACES_API_LIMITS.maxLimit); + }) +}); + +describe("parsePlacesQuery validation", () => { + it("normalizes city to the dataset's lowercase underscore slug form", () => { + const parsed = parsePlacesQuery(params([["city", "New Delhi"]])); + expect(parsed.ok && parsed.query.city).toBe("new_delhi"); + }) + + it("rejects an empty city", () => { + const parsed = parsePlacesQuery(params([["city", " "]])); + expect(parsed.ok).toBe(false); + }) + + it("rejects an unknown category with the accepted enum", () => { + const parsed = parsePlacesQuery(params([["category", "bookshop"]])); + expect(parsed.ok).toBe(false); + if (!parsed.ok) { + expect(parsed.error).toContain("library"); + } + }) + + it("accepts every PLACE_TYPES value as a category", () => { + for (const category of [ + "library", + "other_places", + "airport", + "sat_centre", + "foreign_lang_exam_centre", + "gov_offices", + ]) { + expect(parsePlacesQuery(params([["category", category]])).ok).toBe(true); + } + }) + + it("rejects a country filter because the dataset has no country field", () => { + const parsed = parsePlacesQuery(params([["country", "India"]])); + expect(parsed.ok).toBe(false); + }) + + it("rejects malformed limit and offset values", () => { + expect(parsePlacesQuery(params([["limit", "abc"]])).ok).toBe(false); + expect(parsePlacesQuery(params([["limit", "0"]])).ok).toBe(false); + expect(parsePlacesQuery(params([["limit", "-5"]])).ok).toBe(false); + expect(parsePlacesQuery(params([["offset", "1.5"]])).ok).toBe(false); + expect(parsePlacesQuery(params([["offset", "-1"]])).ok).toBe(false); + }) + + it("rejects repeated filter values instead of silently using one", () => { + expect( + parsePlacesQuery( + params([ + ["city", "mumbai"], + ["city", "thane"], + ]), + ).ok, + ).toBe(false); + expect(parsePlacesQuery(params([["limit", "5"], ["limit", "10"]])).ok).toBe( + false, + ); + }) +}); + +describe("queryPlaces", () => { + it("returns everything unpaginated when no filters apply", () => { + const result = queryPlaces(DATASET, { limit: 100, offset: 0 }); + expect(result.total).toBe(5); + expect(result.data).toHaveLength(5); + }) + + it("filters by city with slug normalization", () => { + const result = queryPlaces(DATASET, { city: "new_delhi", limit: 100, offset: 0 }); + expect(result.data.map((p) => p.id)).toEqual(["nd-1", "nd-2"]); + }) + + it("filters by category", () => { + const result = queryPlaces(DATASET, { category: "library", limit: 100, offset: 0 }); + expect(result.data.map((p) => p.id)).toEqual(["mum-1", "nd-1"]); + }) + + it("combines city and category filters", () => { + const result = queryPlaces(DATASET, { + city: "mumbai", + category: "sat_centre", + limit: 100, + offset: 0, + }); + expect(result.data.map((p) => p.id)).toEqual(["mum-2"]); + }) + + it("applies limit and offset with total reflecting the full match set", () => { + const page = queryPlaces(DATASET, { limit: 2, offset: 1 }); + expect(page.data.map((p) => p.id)).toEqual(["mum-2", "nd-1"]); + expect(page.total).toBe(5); + }) + + it("returns an empty page past the end without erroring", () => { + const result = queryPlaces(DATASET, { limit: 100, offset: 100 }); + expect(result.data).toEqual([]); + expect(result.total).toBe(5); + }) + + it("returns an empty dataset unchanged", () => { + const result = queryPlaces([], { limit: 100, offset: 0 }); + expect(result).toEqual({ data: [], total: 0, limit: 100, offset: 0 }); + }) +}); diff --git a/src/lib/places-api.ts b/src/lib/places-api.ts new file mode 100644 index 0000000..45af799 --- /dev/null +++ b/src/lib/places-api.ts @@ -0,0 +1,149 @@ +import { cityToSlug } from "@/lib/city-pages"; +import { PLACE_TYPES } from "@/lib/types"; +import type { City, Place, PlaceType } from "@/lib/types"; + +/** + * Public API contract for GET /api/places. Kept separate from the route + * handler so the whole contract (filtering, validation, pagination) is + * unit-testable without HTTP. + * + * The dataset changes a few times a week at most, so the default limit is + * generous but bounded: 100 rows, hard cap 500, offset for deep paging. + */ +export const PLACES_API_LIMITS = { + defaultLimit: 100, + maxLimit: 500, +} as const; + +export interface PlacesQuery { + city?: City; + category?: PlaceType; + limit: number; + offset: number; +} + +export interface PlacesQueryResult { + /** Places matching the filters, sliced by limit/offset. */ + data: Place[]; + /** Matches before pagination, so consumers can page to the end. */ + total: number; + limit: number; + offset: number; +} + +export type PlacesQueryParse = + | { ok: true; query: PlacesQuery } + | { ok: false; error: string }; + +function isNonNegativeInteger(value: string): boolean { + return /^\d+$/.test(value); +} + +/** + * Parse and validate the query string of GET /api/places. + * + * Strict by design: unknown categories, cities, or a country filter are + * 400s with a message, never silently ignored — a consumer building on the + * dataset should not have to guess why their filter matched nothing. The one + * deliberate leniency is `limit`: values above the hard cap are clamped + * (per the issue's own verification example) rather than rejected. + */ +export function parsePlacesQuery(params: URLSearchParams): PlacesQueryParse { + if (params.getAll("city").length > 1) { + return { ok: false, error: "city must be given at most once" }; + } + if (params.getAll("category").length > 1) { + return { ok: false, error: "category must be given at most once" }; + } + if (params.getAll("country").length > 1) { + return { ok: false, error: "country must be given at most once" }; + } + if (params.getAll("limit").length > 1) { + return { ok: false, error: "limit must be given at most once" }; + } + if (params.getAll("offset").length > 1) { + return { ok: false, error: "offset must be given at most once" }; + } + + const rawCity = params.get("city"); + let city: City | undefined; + if (rawCity !== null) { + if (rawCity.trim() === "") { + return { ok: false, error: "city must not be empty" }; + } + // Cities are stored as lowercase underscore slugs (e.g. "new_delhi"); + // accept the human-readable form and any casing, like the city pages do. + city = cityToSlug(rawCity); + } + + const rawCategory = params.get("category"); + let category: PlaceType | undefined; + if (rawCategory !== null) { + if (!(PLACE_TYPES as readonly string[]).includes(rawCategory)) { + return { + ok: false, + error: `unknown category "${rawCategory}"; expected one of ${PLACE_TYPES.join(", ")}`, + }; + } + category = rawCategory as PlaceType; + } + + const rawCountry = params.get("country"); + if (rawCountry !== null) { + if (rawCountry.trim() === "") { + return { ok: false, error: "country must not be empty" }; + } + // The schema has no country field today, so no record can satisfy this + // filter. Reject loudly rather than return an empty list that looks like + // a bug, and point at the contract. + return { + ok: false, + error: + 'the dataset does not carry a "country" field yet, so the country filter cannot match anything; see /docs/places-api', + }; + } + + const rawLimit = params.get("limit"); + let limit: number = PLACES_API_LIMITS.defaultLimit; + if (rawLimit !== null) { + if (!isNonNegativeInteger(rawLimit) || Number(rawLimit) === 0) { + return { ok: false, error: 'limit must be a positive integer' }; + } + limit = Math.min(Number(rawLimit), PLACES_API_LIMITS.maxLimit); + } + + const rawOffset = params.get("offset"); + let offset = 0; + if (rawOffset !== null) { + if (!isNonNegativeInteger(rawOffset)) { + return { ok: false, error: "offset must be a non-negative integer" }; + } + offset = Number(rawOffset); + } + + return { ok: true, query: { city, category, limit, offset } }; +} + +/** + * Apply the parsed filters and pagination. `total` counts matches before + * slicing so consumers know the full result set size. + */ +export function queryPlaces( + places: Place[], + { city, category, limit, offset }: PlacesQuery, +): PlacesQueryResult { + // `country` is rejected at parse time because the schema has no country + // field; adding a match here is a one-line change once the dataset carries + // one. + const matches = places.filter( + (place) => + (city === undefined || cityToSlug(place.city) === city) && + (category === undefined || place.type === category), + ); + return { + data: matches.slice(offset, offset + limit), + total: matches.length, + limit, + offset, + }; +}