summaryrefslogtreecommitdiff
path: root/app/routes/api.openapi.ts
diff options
context:
space:
mode:
Diffstat (limited to 'app/routes/api.openapi.ts')
-rw-r--r--app/routes/api.openapi.ts337
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": "*",
+ },
+ });
+}