From 1c0df5a6eadf20d3dce490b5c5c87a3ee750fe34 Mon Sep 17 00:00:00 2001 From: yyamashita Date: Thu, 14 May 2026 23:14:49 +0900 Subject: Add JSON REST API: GET /api/events, /api/events/:id, /api/venues, /api/openapi.json Co-Authored-By: Claude Sonnet 4.6 --- app/routes.ts | 4 + app/routes/api.events.$id.ts | 22 +++ app/routes/api.events._index.ts | 47 ++++++ app/routes/api.openapi.ts | 337 ++++++++++++++++++++++++++++++++++++++++ app/routes/api.venues.ts | 13 ++ 5 files changed, 423 insertions(+) create mode 100644 app/routes/api.events.$id.ts create mode 100644 app/routes/api.events._index.ts create mode 100644 app/routes/api.openapi.ts create mode 100644 app/routes/api.venues.ts (limited to 'app') diff --git a/app/routes.ts b/app/routes.ts index 07ba8a9..91757b5 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -11,6 +11,10 @@ export default [ ...prefix("api", [ route("scrape", "routes/api.scrape.ts"), route("scrape-status", "routes/api.scrape-status.ts"), + route("events", "routes/api.events._index.ts"), + route("events/:id", "routes/api.events.$id.ts"), route("events/:id/calendar.ics", "routes/api.events.$id.ics.ts"), + route("venues", "routes/api.venues.ts"), + route("openapi.json", "routes/api.openapi.ts"), ]), ] satisfies RouteConfig; diff --git a/app/routes/api.events.$id.ts b/app/routes/api.events.$id.ts new file mode 100644 index 0000000..0f0f2e8 --- /dev/null +++ b/app/routes/api.events.$id.ts @@ -0,0 +1,22 @@ +/** + * GET /api/events/:id + */ +import type { Route } from "./+types/api.events.$id"; +import { getEvent } from "~/lib/db.server"; + +export async function loader({ params }: Route.LoaderArgs) { + const id = parseInt(params.id, 10); + if (Number.isNaN(id)) { + return Response.json({ error: "Invalid id" }, { status: 400 }); + } + + const event = getEvent(id); + if (!event) { + return Response.json({ error: "Not found" }, { status: 404 }); + } + + return Response.json( + { event }, + { headers: { "Cache-Control": "public, max-age=300" } } + ); +} diff --git a/app/routes/api.events._index.ts b/app/routes/api.events._index.ts new file mode 100644 index 0000000..cc5a288 --- /dev/null +++ b/app/routes/api.events._index.ts @@ -0,0 +1,47 @@ +/** + * GET /api/events + * + * Query params: + * date_from YYYY-MM-DD (default: today) + * date_to YYYY-MM-DD + * venue_id string + * keyword string (title or artist partial match) + * capacity_range small | medium | large + * area string + * limit 1–100 (default 60) + * offset integer (default 0) + */ +import type { Route } from "./+types/api.events._index"; +import { queryEvents } from "~/lib/db.server"; + +export async function loader({ request }: Route.LoaderArgs) { + const url = new URL(request.url); + const p = url.searchParams; + + const rawLimit = parseInt(p.get("limit") ?? "60", 10); + const limit = Number.isNaN(rawLimit) ? 60 : Math.min(Math.max(rawLimit, 1), 100); + const rawOffset = parseInt(p.get("offset") ?? "0", 10); + const offset = Number.isNaN(rawOffset) ? 0 : Math.max(rawOffset, 0); + + const capacityRaw = p.get("capacity_range"); + const capacity_range = + capacityRaw === "small" || capacityRaw === "medium" || capacityRaw === "large" + ? capacityRaw + : undefined; + + const events = queryEvents({ + date_from: p.get("date_from") ?? undefined, + date_to: p.get("date_to") ?? undefined, + venue_id: p.get("venue_id") ?? undefined, + keyword: p.get("keyword") ?? undefined, + area: p.get("area") ?? undefined, + capacity_range, + limit, + offset, + }); + + return Response.json( + { events, meta: { limit, offset, count: events.length } }, + { headers: { "Cache-Control": "public, max-age=300" } } + ); +} diff --git a/app/routes/api.openapi.ts b/app/routes/api.openapi.ts new file mode 100644 index 0000000..8598d2c --- /dev/null +++ b/app/routes/api.openapi.ts @@ -0,0 +1,337 @@ +/** + * GET /api/openapi.json — OpenAPI 3.1 spec + */ +import type { Route } from "./+types/api.openapi"; + +const SPEC = { + openapi: "3.1.0", + info: { + title: "Tokyo Livehouse Events API", + version: "1.0.0", + description: + "東京・近郊のライブハウスのイベント情報を提供する REST API。" + + "スクレイパーが定期取得したデータを SQLite に保存し、JSON で返します。", + }, + servers: [{ url: "/api", description: "本番 / ローカル共通" }], + tags: [ + { name: "events", description: "イベント情報" }, + { name: "venues", description: "会場情報" }, + { name: "scrape", description: "スクレイプ操作" }, + ], + paths: { + "/events": { + get: { + tags: ["events"], + summary: "イベント一覧", + description: "条件でフィルタリングしたイベントをページネーション付きで返します。", + operationId: "listEvents", + parameters: [ + { + name: "date_from", + in: "query", + description: "開始日 (YYYY-MM-DD)。未指定時は当日。", + schema: { type: "string", format: "date", example: "2026-05-01" }, + }, + { + name: "date_to", + in: "query", + description: "終了日 (YYYY-MM-DD)。", + schema: { type: "string", format: "date", example: "2026-05-31" }, + }, + { + name: "venue_id", + in: "query", + description: "会場 ID で絞り込み。", + schema: { type: "string", example: "liquid-room" }, + }, + { + name: "keyword", + in: "query", + description: "タイトル・アーティスト名の部分一致。", + schema: { type: "string", example: "メリー" }, + }, + { + name: "area", + in: "query", + description: "エリア名で絞り込み (例: 渋谷、東高円寺)。", + schema: { type: "string", example: "東高円寺" }, + }, + { + name: "capacity_range", + in: "query", + description: "キャパシティ帯: small (≤100) / medium (101–299) / large (≥300)。", + schema: { type: "string", enum: ["small", "medium", "large"] }, + }, + { + name: "limit", + in: "query", + description: "取得件数 (1–100、デフォルト 60)。", + schema: { type: "integer", minimum: 1, maximum: 100, default: 60 }, + }, + { + name: "offset", + in: "query", + description: "スキップ件数 (デフォルト 0)。", + schema: { type: "integer", minimum: 0, default: 0 }, + }, + ], + responses: { + "200": { + description: "成功", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/EventListResponse" }, + }, + }, + }, + }, + }, + }, + "/events/{id}": { + get: { + tags: ["events"], + summary: "イベント詳細", + operationId: "getEvent", + parameters: [ + { + name: "id", + in: "path", + required: true, + description: "イベント ID (整数)。", + schema: { type: "integer", example: 42 }, + }, + ], + responses: { + "200": { + description: "成功", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/EventDetailResponse" }, + }, + }, + }, + "400": { description: "id が不正" }, + "404": { description: "イベントが見つからない" }, + }, + }, + }, + "/events/{id}/calendar.ics": { + get: { + tags: ["events"], + summary: "iCalendar 形式でイベントをダウンロード", + operationId: "getEventIcs", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "integer" }, + }, + ], + responses: { + "200": { + description: ".ics ファイル", + content: { "text/calendar": { schema: { type: "string" } } }, + }, + "404": { description: "イベントが見つからない" }, + }, + }, + }, + "/venues": { + get: { + tags: ["venues"], + summary: "会場一覧", + description: "登録済み会場の一覧とイベント件数を返します。", + operationId: "listVenues", + responses: { + "200": { + description: "成功", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/VenueListResponse" }, + }, + }, + }, + }, + }, + }, + "/scrape": { + get: { + tags: ["scrape"], + summary: "スクレイプ開始 (GET)", + description: "`venue_id` を指定すると該当会場のみ、省略すると全会場をバックグラウンドでスクレイプします。", + operationId: "startScrapeGet", + parameters: [ + { + name: "venue_id", + in: "query", + description: "会場 ID。省略で全会場。", + schema: { type: "string" }, + }, + ], + responses: { + "202": { + description: "スクレイプ開始", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ScrapeStartResponse" }, + }, + }, + }, + }, + }, + post: { + tags: ["scrape"], + summary: "スクレイプ開始 (POST)", + operationId: "startScrapePost", + requestBody: { + content: { + "application/x-www-form-urlencoded": { + schema: { + type: "object", + properties: { + venue_id: { type: "string", description: "省略で全会場" }, + }, + }, + }, + }, + }, + responses: { + "202": { + description: "スクレイプ開始", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ScrapeStartResponse" }, + }, + }, + }, + }, + }, + }, + "/scrape-status": { + get: { + tags: ["scrape"], + summary: "スクレイプ状況確認", + operationId: "getScrapeStatus", + parameters: [ + { + name: "run_id", + in: "query", + description: "run_id を省略すると最新ランの状況を返します。", + schema: { type: "string", format: "uuid" }, + }, + ], + responses: { + "200": { + description: "成功", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ScrapeStatusResponse" }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Event: { + type: "object", + properties: { + id: { type: "integer" }, + venue_id: { type: "string" }, + venue_name: { type: "string" }, + venue_area: { type: ["string", "null"] }, + venue_url: { type: ["string", "null"] }, + title: { type: "string" }, + artist: { type: ["string", "null"] }, + date: { type: "string", format: "date", example: "2026-05-15" }, + start_time: { type: ["string", "null"], example: "19:00" }, + open_time: { type: ["string", "null"], example: "18:30" }, + ticket_url: { type: ["string", "null"], format: "uri" }, + price: { type: ["string", "null"], example: "前売 ¥3,000 / 当日 ¥3,500" }, + image_url: { type: ["string", "null"], format: "uri" }, + description: { type: ["string", "null"] }, + source_url: { type: ["string", "null"], format: "uri" }, + fetched_at: { type: "string", format: "date-time" }, + }, + }, + Venue: { + type: "object", + properties: { + id: { type: "string", example: "liquid-room" }, + name: { type: "string", example: "LIQUID ROOM" }, + url: { type: "string", format: "uri" }, + area: { type: ["string", "null"], example: "恵比寿" }, + capacity: { type: ["integer", "null"], example: 1000 }, + event_count: { type: "integer" }, + }, + }, + EventListResponse: { + type: "object", + properties: { + events: { type: "array", items: { $ref: "#/components/schemas/Event" } }, + meta: { + type: "object", + properties: { + limit: { type: "integer" }, + offset: { type: "integer" }, + count: { type: "integer", description: "今回返却した件数" }, + }, + }, + }, + }, + EventDetailResponse: { + type: "object", + properties: { + event: { $ref: "#/components/schemas/Event" }, + }, + }, + VenueListResponse: { + type: "object", + properties: { + venues: { type: "array", items: { $ref: "#/components/schemas/Venue" } }, + }, + }, + ScrapeStartResponse: { + type: "object", + properties: { + run_id: { type: "string", format: "uuid" }, + status: { type: "string", enum: ["started"] }, + }, + }, + ScrapeStatusResponse: { + type: "object", + properties: { + running: { type: "boolean" }, + results: { + type: "array", + items: { + type: "object", + properties: { + run_id: { type: "string" }, + venue_id: { type: "string" }, + venue_name: { type: "string" }, + status: { type: "string", enum: ["running", "ok", "error"] }, + events_saved: { type: "integer" }, + error: { type: ["string", "null"] }, + started_at: { type: "string", format: "date-time" }, + finished_at: { type: ["string", "null"], format: "date-time" }, + }, + }, + }, + }, + }, + }, + }, +} as const; + +export async function loader(_: Route.LoaderArgs) { + return Response.json(SPEC, { + headers: { + "Cache-Control": "public, max-age=3600", + "Access-Control-Allow-Origin": "*", + }, + }); +} diff --git a/app/routes/api.venues.ts b/app/routes/api.venues.ts new file mode 100644 index 0000000..2555d02 --- /dev/null +++ b/app/routes/api.venues.ts @@ -0,0 +1,13 @@ +/** + * GET /api/venues + */ +import type { Route } from "./+types/api.venues"; +import { getVenues } from "~/lib/db.server"; + +export async function loader(_: Route.LoaderArgs) { + const venues = getVenues(); + return Response.json( + { venues }, + { headers: { "Cache-Control": "public, max-age=60" } } + ); +} -- cgit v1.2.3