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
91 changes: 91 additions & 0 deletions src/app/api/revalidate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# `/api/revalidate` Endpoint

This endpoint is used by external systems to trigger on-demand Next.js cache revalidation for feed pages. It exposes two HTTP methods:

- **GET** — Used exclusively by the Vercel cron job to revalidate all GBFS feed pages on a schedule.
- **POST** — Used by external systems to trigger targeted or full-site cache revalidation.

---

## GET

Revalidates all GBFS feed pages. This handler is invoked automatically by Vercel's cron scheduler (configured in `vercel.json`) at 4am UTC Monday–Saturday and 7am UTC Sunday.

### Authentication

Vercel automatically passes an `Authorization: Bearer <CRON_SECRET>` header on every cron invocation. The value must match the `CRON_SECRET` environment variable.

### Response

| Status | Body |
|--------|------|
| `200` | `{ "ok": true, "message": "All GBFS feeds revalidated successfully" }` |
| `401` | `{ "ok": false, "error": "Unauthorized" }` |
| `500` | `{ "ok": false, "error": "Server misconfigured: CRON_SECRET missing" }` |
| `500` | `{ "ok": false, "error": "Revalidation failed" }` |

---

## POST

Triggers targeted cache revalidation. The caller controls the scope of revalidation via the request body.

### Authentication

Include the `x-revalidate-secret` header with the value of the `REVALIDATE_SECRET` environment variable.

```
x-revalidate-secret: <REVALIDATE_SECRET>
```

### Request Body

```json
{
"type": "<revalidation-type>",
"feedIds": ["<feed-id-1>", "<feed-id-2>"]
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | `string` | Yes | The revalidation scope. Must be one of the valid types listed below. |
| `feedIds` | `string[]` | Only for `specific-feeds` | List of feed IDs to revalidate. Ignored for all other types. |

If the request body is missing or cannot be parsed, the endpoint falls back to the default: `type: "specific-feeds"` with an empty `feedIds` array (no-op).

### Valid `type` Values

| Type | Description |
|------|-------------|
| `full` | Revalidates the entire site (all pages and all cache tags). |
| `all-feeds` | Revalidates all GTFS, GTFS-RT, and GBFS feed detail pages and their shared cache tags. |
| `all-gtfs-feeds` | Revalidates all GTFS feed detail pages and the `feed-type-gtfs` cache tag. |
| `all-gtfs-rt-feeds` | Revalidates all GTFS-RT feed detail pages and the `feed-type-gtfs_rt` cache tag. |
| `all-gbfs-feeds` | Revalidates all GBFS feed detail pages and the `feed-type-gbfs` cache tag. |
| `specific-feeds` | Revalidates only the pages for the feed IDs listed in `feedIds`. Each feed is revalidated across all feed-type paths (GTFS, GTFS-RT, GBFS) and all locales. |

If an unrecognized or missing `type` is provided, the endpoint returns a `500` error:

```json
{ "ok": false, "error": "invalid or missing type parameter" }
```

### Response

| Status | Body |
|--------|------|
| `200` | `{ "ok": true, "message": "Revalidation triggered successfully" }` |
| `401` | `{ "ok": false, "error": "Unauthorized" }` |
| `500` | `{ "ok": false, "error": "Server misconfigured: REVALIDATE_SECRET missing" }` |
| `500` | `{ "ok": false, "error": "invalid or missing type parameter" }` |
| `500` | `{ "ok": false, "error": "Failed to revalidate" }` |

### Example Request

```bash
curl -X POST https://mobilitydatabase.org/api/revalidate \
-H "Content-Type: application/json" \
-H "x-revalidate-secret: <REVALIDATE_SECRET>" \
-d '{ "type": "specific-feeds", "feedIds": ["feed-abc123", "feed-def456"] }'
```
24 changes: 24 additions & 0 deletions src/app/api/revalidate/route.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,30 @@ describe('POST /api/revalidate', () => {
expect(mockRevalidatePath).toHaveBeenCalledTimes(36);
});

it('returns 500 when type is invalid', async () => {
const request = new Request('http://localhost:3000/api/revalidate', {
method: 'POST',
headers: {
'x-revalidate-secret': 'test-secret',
'content-type': 'application/json',
},
body: JSON.stringify({
type: 'not-a-valid-type',
}),
});

const response = await POST(request);
const json = await response.json();

expect(response.status).toBe(500);
expect(json).toEqual({
ok: false,
error: 'invalid or missing type parameter',
});
expect(mockRevalidatePath).not.toHaveBeenCalled();
expect(mockRevalidateTag).not.toHaveBeenCalled();
});

it('handles specific-feeds with empty feedIds', async () => {
const request = new Request('http://localhost:3000/api/revalidate', {
method: 'POST',
Expand Down
24 changes: 17 additions & 7 deletions src/app/api/revalidate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ import {
revalidateSpecificFeeds,
} from '../../utils/revalidate-feeds';

type RevalidateTypes =
| 'full'
| 'all-feeds'
| 'all-gbfs-feeds'
| 'all-gtfs-rt-feeds'
| 'all-gtfs-feeds'
| 'specific-feeds';
const VALID_REVALIDATE_TYPES = [
'full',
'all-feeds',
'all-gbfs-feeds',
'all-gtfs-rt-feeds',
'all-gtfs-feeds',
'specific-feeds',
] as const;

type RevalidateTypes = (typeof VALID_REVALIDATE_TYPES)[number];

interface RevalidateBody {
feedIds: string[]; // only for 'specific-feeds' revalidation type
Expand Down Expand Up @@ -104,6 +107,13 @@ export async function POST(req: Request): Promise<NextResponse> {
payload = { ...defaultRevalidateOptions };
}

if (!VALID_REVALIDATE_TYPES.includes(payload.type)) {
return NextResponse.json(
{ ok: false, error: 'invalid or missing type parameter' },
{ status: 500 },
);
}

// NOTE
// revalidatePath = triggers revalidation for entire page cache
// revalidateTag = triggers revalidation for API calls using `unstable_cache` with matching tags (e.g., feed-123, guest-feeds)
Expand Down
Loading