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
75 changes: 75 additions & 0 deletions src/app/api/places/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> {
return (await response.json()) as Record<string, unknown>;
}

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");
})
});
43 changes: 43 additions & 0 deletions src/app/api/places/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
});
}
210 changes: 210 additions & 0 deletions src/app/docs/places-api/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-6">
<p className="text-foreground/80">
Every place StudyMap renders is crowdsourced and committed to{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">data/places/*.json</code>{" "}
— 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.
</p>

<Card>
<CardHeader>
<CardTitle>GET /api/places</CardTitle>
<CardDescription>
The merged dataset, optionally filtered, with bounded pagination.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CodeBlock
lang="bash"
code={`# Everything (paginated at ${PLACES_API_LIMITS.defaultLimit} rows)
curl "https://studyymap.com/api/places"

# A city filter - "New Delhi" and "new_delhi" both work
curl "https://studyymap.com/api/places?city=mumbai&limit=5"

# Category + pagination
curl "https://studyymap.com/api/places?category=library&limit=50&offset=100"`}
/>
<p className="text-sm text-foreground/80">
Responses are plain JSON with permissive CORS (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">
Access-Control-Allow-Origin: *
</code>
), so browser code can call it directly. Responses are cached for 6
hours (the dataset changes a few times a week at most) via{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">
Cache-Control
</code>{" "}
headers.
</p>
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle>Query parameters</CardTitle>
<CardDescription>
All optional; every value is validated, and invalid values are a
400 with a message - never silently ignored.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="divide-y divide-border">
{FILTERS.map((filter) => (
<li key={filter.name} className="py-3">
<p className="font-medium">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">
{filter.name}
</code>{" "}
<span className="text-sm font-normal text-muted-foreground">
({filter.type})
</span>
</p>
<p className="mt-1 text-sm text-foreground/80">{filter.notes}</p>
</li>
))}
</ul>
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle>Response shape</CardTitle>
<CardDescription>
Each record is exactly one entry from{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">
data/places/*.json
</code>
, as defined by the schema.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<CodeBlock lang="json" code={RESPONSE_EXAMPLE} />
<p className="text-sm text-foreground/80">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">total</code>{" "}
is the number of matches before pagination, so consumers know the
full result set size. The canonical record shape lives in{" "}
<Link
href="https://github.com/StudentSuite/StudyMap/blob/main/data/places.schema.json"
className="font-medium text-primary hover:underline"
>
data/places.schema.json
</Link>
.
</p>
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle>Errors</CardTitle>
<CardDescription>
Invalid filter values return 400 with an{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">error</code>{" "}
message instead of being ignored.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<CodeBlock lang="json" code={ERROR_EXAMPLE} />
<p className="text-sm text-foreground/80">
400 cases: an unknown <code className="rounded bg-muted px-1.5 py-0.5 font-mono">category</code>,{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">country</code>{" "}
(no country data in the schema yet), a non-integer{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">limit</code>{" "}
or <code className="rounded bg-muted px-1.5 py-0.5 font-mono">offset</code>, or
a repeated parameter. A <code className="rounded bg-muted px-1.5 py-0.5 font-mono">city</code>{" "}
with no places is an empty result, not an error.
</p>
</CardContent>
</Card>

<CalloutCard title="No API key, no write access">
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 <code className="rounded bg-muted px-1.5 py-0.5 font-mono">data/places/*.json</code>{" "}
following the{" "}
<Link href="/docs/contributing" className="font-medium text-primary hover:underline">
contributing guide
</Link>
.
</CalloutCard>
</div>
);
}
8 changes: 8 additions & 0 deletions src/lib/docs-nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading