diff options
Diffstat (limited to 'app/routes/api.openapi.ts')
| -rw-r--r-- | app/routes/api.openapi.ts | 337 |
1 files changed, 337 insertions, 0 deletions
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": "*", + }, + }); +} |
