From 23efcd57c0fb0fcc29bf63a125131db821268663 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 19 Nov 2024 19:21:33 +0100 Subject: [PATCH 01/67] Add simple openapi example app --- examples/openapi-zod/.dev.vars.example | 1 + examples/openapi-zod/.gitignore | 35 +++ examples/openapi-zod/README.md | 18 ++ examples/openapi-zod/db-touch.sql | 0 examples/openapi-zod/drizzle.config.ts | 72 +++++ examples/openapi-zod/package.json | 26 ++ examples/openapi-zod/src/db/index.ts | 1 + examples/openapi-zod/src/db/schema.ts | 17 ++ examples/openapi-zod/src/index.ts | 80 ++++++ examples/openapi-zod/tsconfig.json | 17 ++ examples/openapi-zod/wrangler.toml | 23 ++ examples/server-side-events/package.json | 2 +- honc-code-gen/package.json | 1 - package.json | 2 +- pnpm-lock.yaml | 331 +++++++++++++++-------- 15 files changed, 516 insertions(+), 110 deletions(-) create mode 100644 examples/openapi-zod/.dev.vars.example create mode 100644 examples/openapi-zod/.gitignore create mode 100644 examples/openapi-zod/README.md create mode 100644 examples/openapi-zod/db-touch.sql create mode 100644 examples/openapi-zod/drizzle.config.ts create mode 100644 examples/openapi-zod/package.json create mode 100644 examples/openapi-zod/src/db/index.ts create mode 100644 examples/openapi-zod/src/db/schema.ts create mode 100644 examples/openapi-zod/src/index.ts create mode 100644 examples/openapi-zod/tsconfig.json create mode 100644 examples/openapi-zod/wrangler.toml diff --git a/examples/openapi-zod/.dev.vars.example b/examples/openapi-zod/.dev.vars.example new file mode 100644 index 000000000..56568de93 --- /dev/null +++ b/examples/openapi-zod/.dev.vars.example @@ -0,0 +1 @@ +FPX_ENDPOINT=http://localhost:8788/v1/traces \ No newline at end of file diff --git a/examples/openapi-zod/.gitignore b/examples/openapi-zod/.gitignore new file mode 100644 index 000000000..f2ab12025 --- /dev/null +++ b/examples/openapi-zod/.gitignore @@ -0,0 +1,35 @@ +# prod +.prod.vars +dist/ + +# dev +package-lock.json +.yarn/ +!.yarn/releases +.vscode/* +!.vscode/launch.json +!.vscode/*.code-snippets +.idea/workspace.xml +.idea/usage.statistics.xml +.idea/shelf + +# deps +node_modules/ +.wrangler + +# env +.env +.env.production +.dev.vars + +# logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# misc +.DS_Store diff --git a/examples/openapi-zod/README.md b/examples/openapi-zod/README.md new file mode 100644 index 000000000..d16fe871a --- /dev/null +++ b/examples/openapi-zod/README.md @@ -0,0 +1,18 @@ +## Overview + +This is an implementation of the Hono-Zod-OpenAPI integration from the Hono docs. + +## Commands + +```sh +# HACK - This script initializes a D1 database *locally* so that we can mess with it +pnpm db:touch +pnpm db:generate +pnpm db:migrate +``` + +```sh +pnpm i +pnpm dev +``` + diff --git a/examples/openapi-zod/db-touch.sql b/examples/openapi-zod/db-touch.sql new file mode 100644 index 000000000..e69de29bb diff --git a/examples/openapi-zod/drizzle.config.ts b/examples/openapi-zod/drizzle.config.ts new file mode 100644 index 000000000..b8fc80b8b --- /dev/null +++ b/examples/openapi-zod/drizzle.config.ts @@ -0,0 +1,72 @@ +import fs from "node:fs"; +import path from "node:path"; +import { config } from "dotenv"; +import { defineConfig } from "drizzle-kit"; + +let dbConfig: ReturnType; +if (process.env.GOOSIFY_ENV === "production") { + config({ path: "./.prod.vars" }); + dbConfig = defineConfig({ + schema: "./src/db/schema.ts", + out: "./drizzle/migrations", + dialect: "sqlite", + driver: "d1-http", + dbCredentials: { + // biome-ignore lint/style/noNonNullAssertion: + accountId: process.env.CLOUDFLARE_ACCOUNT_ID!, + // biome-ignore lint/style/noNonNullAssertion: + databaseId: process.env.CLOUDFLARE_DATABASE_ID!, + // biome-ignore lint/style/noNonNullAssertion: + token: process.env.CLOUDFLARE_D1_TOKEN!, + }, + }); +} else { + config({ path: "./.dev.vars" }); + const localD1DB = getLocalD1DB(); + if (!localD1DB) { + process.exit(1); + } + + dbConfig = defineConfig({ + schema: "./src/db/schema.ts", + out: "./drizzle/migrations", + dialect: "sqlite", + dbCredentials: { + url: localD1DB, + }, + }); +} + +export default dbConfig; + +// Modified from: https://github.com/drizzle-team/drizzle-orm/discussions/1545 +function getLocalD1DB() { + try { + const basePath = path.resolve(".wrangler"); + const files = fs + .readdirSync(basePath, { encoding: "utf-8", recursive: true }) + .filter((f) => f.endsWith(".sqlite")); + + // In case there are multiple .sqlite files, we want the most recent one. + files.sort((a, b) => { + const statA = fs.statSync(path.join(basePath, a)); + const statB = fs.statSync(path.join(basePath, b)); + return statB.mtime.getTime() - statA.mtime.getTime(); + }); + const dbFile = files[0]; + + if (!dbFile) { + throw new Error(`.sqlite file not found in ${basePath}`); + } + + const url = path.resolve(basePath, dbFile); + + return url; + } catch (err) { + if (err instanceof Error) { + console.log(`Error resolving local D1 DB: ${err.message}`); + } else { + console.log(`Error resolving local D1 DB: ${err}`); + } + } +} diff --git a/examples/openapi-zod/package.json b/examples/openapi-zod/package.json new file mode 100644 index 000000000..8c4a1f408 --- /dev/null +++ b/examples/openapi-zod/package.json @@ -0,0 +1,26 @@ +{ + "name": "zod-openapi", + "scripts": { + "dev": "wrangler dev src/index.ts", + "deploy": "wrangler deploy --minify src/index.ts", + "db:generate": "drizzle-kit generate", + "db:migrate": "wrangler d1 migrations apply zod-openapi --local", + "db:studio": "drizzle-kit studio", + "db:touch": "wrangler d1 execute zod-openapi --local --command='SELECT 1'", + "db:migrate:prod": "GOOSIFY_ENV=production drizzle-kit migrate", + "db:studio:prod": "GOOSIFY_ENV=production drizzle-kit studio" + }, + "dependencies": { + "@fiberplane/hono-otel": "workspace:*", + "@hono/zod-openapi": "^0.18.0", + "dotenv": "^16.4.5", + "drizzle-orm": "^0.35.3", + "hono": "^4.6.7", + "zod": "^3.23.8" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241112.0", + "drizzle-kit": "^0.26.2", + "wrangler": "^3.87.0" + } +} diff --git a/examples/openapi-zod/src/db/index.ts b/examples/openapi-zod/src/db/index.ts new file mode 100644 index 000000000..ed6091c62 --- /dev/null +++ b/examples/openapi-zod/src/db/index.ts @@ -0,0 +1 @@ +export { geese, gooseImages } from "./schema"; diff --git a/examples/openapi-zod/src/db/schema.ts b/examples/openapi-zod/src/db/schema.ts new file mode 100644 index 000000000..9f2d8ef9e --- /dev/null +++ b/examples/openapi-zod/src/db/schema.ts @@ -0,0 +1,17 @@ +import { sql } from "drizzle-orm"; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const geese = sqliteTable("geese", { + id: integer("id", { mode: "number" }).primaryKey(), + name: text("name").notNull(), + avatar: text("avatar"), + createdAt: text("created_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const gooseImages = sqliteTable("goose_images", { + id: integer("id", { mode: "number" }).primaryKey(), + filename: text("filename").notNull(), + prompt: text("prompt").notNull(), + createdAt: text("created_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), +}); diff --git a/examples/openapi-zod/src/index.ts b/examples/openapi-zod/src/index.ts new file mode 100644 index 000000000..cb7a17702 --- /dev/null +++ b/examples/openapi-zod/src/index.ts @@ -0,0 +1,80 @@ +import { instrument } from "@fiberplane/hono-otel"; +import { drizzle } from "drizzle-orm/d1"; +import { OpenAPIHono } from '@hono/zod-openapi' +import { createRoute, z } from '@hono/zod-openapi' +import * as schema from "./db/schema"; + +type Bindings = { + DB: D1Database; +}; + +const app = new OpenAPIHono<{ Bindings: Bindings }>(); + +const ParamsSchema = z.object({ + id: z + .string() + .min(3) + .openapi({ + param: { + name: 'id', + in: 'path', + }, + example: '1212121', + }), +}) + +const UserSchema = z + .object({ + id: z.string().openapi({ + example: '123', + }), + name: z.string().openapi({ + example: 'John Doe', + }), + age: z.number().openapi({ + example: 42, + }), + }) + .openapi('User') + +const route = createRoute({ + method: 'get', + path: '/users/{id}', + request: { + params: ParamsSchema, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: UserSchema, + }, + }, + description: 'Retrieve the user', + }, + }, +}) + +app.openapi(route, (c) => { + const { id } = c.req.valid('param') + return c.json({ + id, + age: 20, + name: 'Ultra-user', + }) +}) + +// The OpenAPI documentation will be available at /doc +app.doc('/doc', { + openapi: '3.0.0', + info: { + version: '1.0.0', + title: 'My API', + }, +}) + +app.get("/", (c) => { + return c.text("Hello Hono OpenAPI!"); +}); + +export default instrument(app); diff --git a/examples/openapi-zod/tsconfig.json b/examples/openapi-zod/tsconfig.json new file mode 100644 index 000000000..4581f9807 --- /dev/null +++ b/examples/openapi-zod/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": [ + "ESNext" + ], + "types": [ + "@cloudflare/workers-types/2023-07-01" + ], + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx" + }, +} \ No newline at end of file diff --git a/examples/openapi-zod/wrangler.toml b/examples/openapi-zod/wrangler.toml new file mode 100644 index 000000000..9b60ce58d --- /dev/null +++ b/examples/openapi-zod/wrangler.toml @@ -0,0 +1,23 @@ +name = "zod-openapi" +compatibility_date = "2024-11-19" +compatibility_flags = [ "nodejs_compat" ] + +# [dev] +# port = 3003 + +# [[kv_namespaces]] +# binding = "KV" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# [[r2_buckets]] +# binding = "R2" +# bucket_name = "zod-openapi-bucket" + +[[d1_databases]] +binding = "DB" +database_name = "zod-openapi" +database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +migrations_dir = "drizzle/migrations" + +# [ai] +# binding = "AI" \ No newline at end of file diff --git a/examples/server-side-events/package.json b/examples/server-side-events/package.json index e32e473ce..33f9fa128 100644 --- a/examples/server-side-events/package.json +++ b/examples/server-side-events/package.json @@ -11,7 +11,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20240821.1", - "wrangler": "^3.73.0" + "wrangler": "^3.83.0" }, "homepage": "https://github.com/fiberplane/fpx/examples/server-side-events#readme" } diff --git a/honc-code-gen/package.json b/honc-code-gen/package.json index 7a86b6702..562e3e3b6 100644 --- a/honc-code-gen/package.json +++ b/honc-code-gen/package.json @@ -67,7 +67,6 @@ "@xenova/transformers": "^2.17.2", "chalk": "^5.3.0", "cli-highlight": "^2.1.11", - "dotenv": "^16.4.5", "highlight.js": "^11.10.0", "ts-to-zod": "^3.8.5", "tsup": "^8.3.0", diff --git a/package.json b/package.json index a99743d30..5293cfd6b 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,6 @@ "pkg-pr-new": "^0.0.20", "rimraf": "^5.0.7", "typescript": "^5.5.4", - "wrangler": "^3.73.0" + "wrangler": "^3.83.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75fc7d7d0..b05d525e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^5.5.4 version: 5.5.4 wrangler: - specifier: ^3.73.0 - version: 3.73.0(@cloudflare/workers-types@4.20241022.0) + specifier: ^3.83.0 + version: 3.83.0(@cloudflare/workers-types@4.20241112.0) api: dependencies: @@ -230,6 +230,37 @@ importers: specifier: ^4.19.2 version: 4.19.2 + examples/openapi-zod: + dependencies: + '@fiberplane/hono-otel': + specifier: workspace:* + version: link:../../packages/client-library-otel + '@hono/zod-openapi': + specifier: ^0.18.0 + version: 0.18.0(hono@4.6.9)(zod@3.23.8) + dotenv: + specifier: ^16.4.5 + version: 16.4.5 + drizzle-orm: + specifier: ^0.35.3 + version: 0.35.3(@cloudflare/workers-types@4.20241112.0)(@libsql/client-wasm@0.14.0)(@libsql/client@0.14.0)(@neondatabase/serverless@0.10.1)(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@18.3.3)(react@18.3.1) + hono: + specifier: ^4.6.7 + version: 4.6.9 + zod: + specifier: ^3.23.8 + version: 3.23.8 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20241112.0 + version: 4.20241112.0 + drizzle-kit: + specifier: ^0.26.2 + version: 0.26.2 + wrangler: + specifier: ^3.87.0 + version: 3.87.0(@cloudflare/workers-types@4.20241112.0) + examples/server-side-events: dependencies: '@fiberplane/hono-otel': @@ -246,7 +277,7 @@ importers: specifier: ^4.20240821.1 version: 4.20241022.0 wrangler: - specifier: ^3.73.0 + specifier: ^3.83.0 version: 3.83.0(@cloudflare/workers-types@4.20241022.0) examples/test-static-analysis: @@ -823,7 +854,7 @@ importers: version: 0.14.1 wrangler: specifier: ^3.78.5 - version: 3.78.5(@cloudflare/workers-types@4.20241022.0) + version: 3.78.5(@cloudflare/workers-types@4.20241112.0) packages: @@ -945,6 +976,11 @@ packages: '@anthropic-ai/sdk@0.29.2': resolution: {integrity: sha512-5dwiOPO/AZvhY4bJIG9vjFKU9Kza3hA6VEsbIQg6L9vny2RQIpCFhV50nB9IrG2edZaHZb4HuQ9Wmsn5zgWyZg==} + '@asteasolutions/zod-to-openapi@7.2.0': + resolution: {integrity: sha512-Va+Fq1QzKkSgmiYINSp3cASFhMsbdRH/kmCk2feijhC+yNjGoC056CRqihrVFhR8MY8HOZHdlYm2Ns2lmszCiw==} + peerDependencies: + zod: ^3.20.2 + '@astrojs/check@0.9.4': resolution: {integrity: sha512-IOheHwCtpUfvogHHsvu0AbeRZEnjJg3MopdLddkJE70mULItS/Vh37BHcI00mcOJcH1vhD3odbpvWokpxam7xA==} hasBin: true @@ -1433,12 +1469,6 @@ packages: cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-64@1.20240821.1': - resolution: {integrity: sha512-CDBpfZKrSy4YrIdqS84z67r3Tzal2pOhjCsIb63IuCnvVes59/ft1qhczBzk9EffeOE2iTCrA4YBT7Sbn7USew==} - engines: {node: '>=16'} - cpu: [x64] - os: [darwin] - '@cloudflare/workerd-darwin-64@1.20240909.0': resolution: {integrity: sha512-nJ8jm/6PR8DPzVb4QifNAfSdrFZXNblwIdOhLTU5FpSvFFocmzFX5WgzQagvtmcC9/ZAQyxuf7WynDNyBcoe0Q==} engines: {node: '>=16'} @@ -1451,14 +1481,14 @@ packages: cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20240806.0': - resolution: {integrity: sha512-8c3KvmzYp/wg+82KHSOzDetJK+pThH4MTrU1OsjmsR2cUfedm5dk5Lah9/0Ld68+6A0umFACi4W2xJHs/RoBpA==} + '@cloudflare/workerd-darwin-64@1.20241106.1': + resolution: {integrity: sha512-zxvaToi1m0qzAScrxFt7UvFVqU8DxrCO2CinM1yQkv5no7pA1HolpIrwZ0xOhR3ny64Is2s/J6BrRjpO5dM9Zw==} engines: {node: '>=16'} - cpu: [arm64] + cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20240821.1': - resolution: {integrity: sha512-Q+9RedvNbPcEt/dKni1oN94OxbvuNAeJkgHmrLFTGF8zu21wzOhVkQeRNxcYxrMa9mfStc457NAg13OVCj2kHQ==} + '@cloudflare/workerd-darwin-arm64@1.20240806.0': + resolution: {integrity: sha512-8c3KvmzYp/wg+82KHSOzDetJK+pThH4MTrU1OsjmsR2cUfedm5dk5Lah9/0Ld68+6A0umFACi4W2xJHs/RoBpA==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] @@ -1475,14 +1505,14 @@ packages: cpu: [arm64] os: [darwin] - '@cloudflare/workerd-linux-64@1.20240806.0': - resolution: {integrity: sha512-/149Bpxw4e2p5QqnBc06g0mx+4sZYh9j0doilnt0wk/uqYkLp0DdXGMQVRB74sBLg2UD3wW8amn1w3KyFhK2tQ==} + '@cloudflare/workerd-darwin-arm64@1.20241106.1': + resolution: {integrity: sha512-j3dg/42D/bPgfNP3cRUBxF+4waCKO/5YKwXNj+lnVOwHxDu+ne5pFw9TIkKYcWTcwn0ZUkbNZNM5rhJqRn4xbg==} engines: {node: '>=16'} - cpu: [x64] - os: [linux] + cpu: [arm64] + os: [darwin] - '@cloudflare/workerd-linux-64@1.20240821.1': - resolution: {integrity: sha512-j6z3KsPtawrscoLuP985LbqFrmsJL6q1mvSXOXTqXGODAHIzGBipHARdOjms3UQqovzvqB2lQaQsZtLBwCZxtA==} + '@cloudflare/workerd-linux-64@1.20240806.0': + resolution: {integrity: sha512-/149Bpxw4e2p5QqnBc06g0mx+4sZYh9j0doilnt0wk/uqYkLp0DdXGMQVRB74sBLg2UD3wW8amn1w3KyFhK2tQ==} engines: {node: '>=16'} cpu: [x64] os: [linux] @@ -1499,14 +1529,14 @@ packages: cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20240806.0': - resolution: {integrity: sha512-lacDWY3S1rKL/xT6iMtTQJEKmTTKrBavPczInEuBFXElmrS6IwVjZwv8hhVm32piyNt/AuFu9BYoJALi9D85/g==} + '@cloudflare/workerd-linux-64@1.20241106.1': + resolution: {integrity: sha512-Ih+Ye8E1DMBXcKrJktGfGztFqHKaX1CeByqshmTbODnWKHt6O65ax3oTecUwyC0+abuyraOpAtdhHNpFMhUkmw==} engines: {node: '>=16'} - cpu: [arm64] + cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20240821.1': - resolution: {integrity: sha512-I9bHgZOxJQW0CV5gTdilyxzTG7ILzbTirehQWgfPx9X77E/7eIbR9sboOMgyeC69W4he0SKtpx0sYZuTJu4ERw==} + '@cloudflare/workerd-linux-arm64@1.20240806.0': + resolution: {integrity: sha512-lacDWY3S1rKL/xT6iMtTQJEKmTTKrBavPczInEuBFXElmrS6IwVjZwv8hhVm32piyNt/AuFu9BYoJALi9D85/g==} engines: {node: '>=16'} cpu: [arm64] os: [linux] @@ -1523,14 +1553,14 @@ packages: cpu: [arm64] os: [linux] - '@cloudflare/workerd-windows-64@1.20240806.0': - resolution: {integrity: sha512-hC6JEfTSQK6//Lg+D54TLVn1ceTPY+fv4MXqDZIYlPP53iN+dL8Xd0utn2SG57UYdlL5FRAhm/EWHcATZg1RgA==} + '@cloudflare/workerd-linux-arm64@1.20241106.1': + resolution: {integrity: sha512-mdQFPk4+14Yywn7n1xIzI+6olWM8Ybz10R7H3h+rk0XulMumCWUCy1CzIDauOx6GyIcSgKIibYMssVHZR30ObA==} engines: {node: '>=16'} - cpu: [x64] - os: [win32] + cpu: [arm64] + os: [linux] - '@cloudflare/workerd-windows-64@1.20240821.1': - resolution: {integrity: sha512-keC97QPArs6LWbPejQM7/Y8Jy8QqyaZow4/ZdsGo+QjlOLiZRDpAenfZx3CBUoWwEeFwQTl2FLO+8hV1SWFFYw==} + '@cloudflare/workerd-windows-64@1.20240806.0': + resolution: {integrity: sha512-hC6JEfTSQK6//Lg+D54TLVn1ceTPY+fv4MXqDZIYlPP53iN+dL8Xd0utn2SG57UYdlL5FRAhm/EWHcATZg1RgA==} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -1547,13 +1577,15 @@ packages: cpu: [x64] os: [win32] + '@cloudflare/workerd-windows-64@1.20241106.1': + resolution: {integrity: sha512-4rtcss31E/Rb/PeFocZfr+B9i1MdrkhsTBWizh8siNR4KMmkslU2xs2wPaH1z8+ErxkOsHrKRa5EPLh5rIiFeg==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@cloudflare/workers-shared@0.1.0': resolution: {integrity: sha512-SyD4iw6jM4anZaG+ujgVETV4fulF2KHBOW31eavbVN7TNpk2l4aJgwY1YSPK00IKSWsoQuH2TigR446KuT5lqQ==} - '@cloudflare/workers-shared@0.4.1': - resolution: {integrity: sha512-nYh4r8JwOOjYIdH2zub++CmIKlkYFlpxI1nBHimoiHcytJXD/b7ldJ21TtfzUZMCgI78mxVlymMHA/ReaOxKlA==} - engines: {node: '>=16.7.0'} - '@cloudflare/workers-shared@0.5.3': resolution: {integrity: sha512-Yk5Im7zsyKbzd7qi+DrL7ZJR9+bdZwq9BqZWS4muDIWA8MCUeSLsUC+C9u+jdwfPSi5It2AcQG4f0iwZr6jkkQ==} engines: {node: '>=16.7.0'} @@ -1562,6 +1594,10 @@ packages: resolution: {integrity: sha512-LLQRTqx7lKC7o2eCYMpyc5FXV8d0pUX6r3A+agzhqS9aoR5A6zCPefwQGcvbKx83ozX22ATZcemwxQXn12UofQ==} engines: {node: '>=16.7.0'} + '@cloudflare/workers-shared@0.7.1': + resolution: {integrity: sha512-46cP5FCrl3TrvHeoHLb5SRuiDMKH5kc9Yvo36SAfzt8dqJI/qJRoY1GP3ioHn/gP7v2QIoUOTAzIl7Ml7MnfrA==} + engines: {node: '>=16.7.0'} + '@cloudflare/workers-types@4.20240806.0': resolution: {integrity: sha512-8lvgrwXGTZEBsUQJ8YUnMk72Anh9omwr6fqWLw/EwVgcw1nQxs/bfdadBEbdP48l9fWXjE4E5XERLUrrFuEpsg==} @@ -1571,6 +1607,9 @@ packages: '@cloudflare/workers-types@4.20241022.0': resolution: {integrity: sha512-1zOAw5QIDKItzGatzCrEpfLOB1AuMTwVqKmbw9B9eBfCUGRFNfJYMrJxIwcse9EmKahsQt2GruqU00pY/GyXgg==} + '@cloudflare/workers-types@4.20241112.0': + resolution: {integrity: sha512-Q4p9bAWZrX14bSCKY9to19xl0KMU7nsO5sJ2cTVspHoypsjPUMeQCsjHjmsO2C4Myo8/LPeDvmqFmkyNAPPYZw==} + '@codemirror/autocomplete@6.18.0': resolution: {integrity: sha512-5DbOvBbY4qW5l57cjDsmmpDh3/TeK1vXfTHa+BUMrRzdWdcxKZ4U4V7vQaTtOpApNU4kLS4FQ6cINtLg245LXA==} peerDependencies: @@ -2621,6 +2660,13 @@ packages: peerDependencies: hono: '>=3.*' + '@hono/zod-openapi@0.18.0': + resolution: {integrity: sha512-MNdFSbACkEq1txteKsBrVB0Mnil0zd5urOrP8eti6kUDI95CKVws+vjHQWNddRqmqlBHa5kFAcVXSInHFmcYGQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + hono: '>=4.3.6' + zod: 3.* + '@hono/zod-validator@0.2.2': resolution: {integrity: sha512-dSDxaPV70Py8wuIU2QNpoVEIOSzSXZ/6/B/h4xA7eOMz7+AarKTSGV8E6QwrdcCbBLkpqfJ4Q2TmBO0eP1tCBQ==} peerDependencies: @@ -7799,11 +7845,6 @@ packages: engines: {node: '>=16.13'} hasBin: true - miniflare@3.20240821.0: - resolution: {integrity: sha512-4BhLGpssQxM/O6TZmJ10GkT3wBJK6emFkZ3V87/HyvQmVt8zMxEBvyw5uv6kdtp+7F54Nw6IKFJjPUL8rFVQrQ==} - engines: {node: '>=16.13'} - hasBin: true - miniflare@3.20240909.3: resolution: {integrity: sha512-HsWMexA4m0Ti8wTjqRdg50otufgoQ/I/rL3AHxf3dI/TN8zJC/5aMApqspW6I88Lzm24C+SRKnW0nm465PStIw==} engines: {node: '>=16.13'} @@ -7814,6 +7855,11 @@ packages: engines: {node: '>=16.13'} hasBin: true + miniflare@3.20241106.0: + resolution: {integrity: sha512-PjOoJKjUUofCueQskfhXlGvvHxZj36UAJAp1DnquMK88MFF50zCULblh0KXMSNM+bXeQYA94Gj06a7kfmBGxPw==} + engines: {node: '>=16.13'} + hasBin: true + minimatch@10.0.1: resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} engines: {node: 20 || >=22} @@ -8158,6 +8204,9 @@ packages: zod: optional: true + openapi3-ts@4.4.0: + resolution: {integrity: sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw==} + option@0.2.4: resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==} @@ -9718,15 +9767,15 @@ packages: unenv-nightly@1.10.0-1717606461.a117952: resolution: {integrity: sha512-u3TfBX02WzbHTpaEfWEKwDijDSFAHcgXkayUZ+MVDrjhLFvgAJzFGTSTmwlEhwWi2exyRQey23ah9wELMM6etg==} - unenv-nightly@2.0.0-1724863496.70db6f1: - resolution: {integrity: sha512-r+VIl1gnsI4WQxluruSQhy8alpAf1AsLRLm4sEKp3otCyTIVD6I6wHEYzeQnwsyWgaD4+3BD4A/eqrgOpdTzhw==} - unenv-nightly@2.0.0-1726478054.1e87097: resolution: {integrity: sha512-uZso8dCkGlJzWQqkyjOA5L4aUqNJl9E9oKRm03V/d+URrg6rFMJwBonlX9AAq538NxwJpPnCX0gAz0IfTxsbFQ==} unenv-nightly@2.0.0-20241018-011344-e666fcf: resolution: {integrity: sha512-D00bYn8rzkCBOlLx+k1iHQlc69jvtJRT7Eek4yIGQ6461a2tUBjngGZdRpqsoXAJCz/qBW0NgPting7Zvg+ysg==} + unenv-nightly@2.0.0-20241024-111401-d4156ac: + resolution: {integrity: sha512-xJO1hfY+Te+/XnfCYrCbFbRcgu6XEODND1s5wnVbaBCkuQX7JXF7fHEXPrukFE2j8EOH848P8QN19VO47XN8hw==} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -10273,11 +10322,6 @@ packages: engines: {node: '>=16'} hasBin: true - workerd@1.20240821.1: - resolution: {integrity: sha512-y4phjCnEG96u8ZkgkkHB+gSw0i6uMNo23rBmixylWpjxDklB+LWD8dztasvsu7xGaZbLoTxQESdEw956F7VJDA==} - engines: {node: '>=16'} - hasBin: true - workerd@1.20240909.0: resolution: {integrity: sha512-NwuYh/Fgr/MK0H+Ht687sHl/f8tumwT5CWzYR0MZMHri8m3CIYu2IaY4tBFWoKE/tOU1Z5XjEXECa9zXY4+lwg==} engines: {node: '>=16'} @@ -10288,6 +10332,11 @@ packages: engines: {node: '>=16'} hasBin: true + workerd@1.20241106.1: + resolution: {integrity: sha512-1GdKl0kDw8rrirr/ThcK66Kbl4/jd4h8uHx5g7YHBrnenY5SX1UPuop2cnCzYUxlg55kPjzIqqYslz1muRFgFw==} + engines: {node: '>=16'} + hasBin: true + wrangler@3.70.0: resolution: {integrity: sha512-aMtCEXmH02SIxbxOFGGuJ8ZemmG9W+IcNRh5D4qIKgzSxqy0mt9mRoPNPSv1geGB2/8YAyeLGPf+tB4lxz+ssg==} engines: {node: '>=16.17.0'} @@ -10298,32 +10347,32 @@ packages: '@cloudflare/workers-types': optional: true - wrangler@3.73.0: - resolution: {integrity: sha512-VrdDR2OpvsCQp+r5Of3rDP1W64cNN/LHLVx1roULOlPS8PZiv7rUYgkwhdCQ61+HICAaeSxWYIzkL5+B9+8W3g==} + wrangler@3.78.5: + resolution: {integrity: sha512-EqCQOuuxHCBHLSjWw7kWT/1PDSw38XUhSxPC3VnDcL7F6TukVBfHHyLFO4NYGTDDoH+G8KVK1bL1q8LXY2Rcbg==} engines: {node: '>=16.17.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^4.20240821.1 + '@cloudflare/workers-types': ^4.20240909.0 peerDependenciesMeta: '@cloudflare/workers-types': optional: true - wrangler@3.78.5: - resolution: {integrity: sha512-EqCQOuuxHCBHLSjWw7kWT/1PDSw38XUhSxPC3VnDcL7F6TukVBfHHyLFO4NYGTDDoH+G8KVK1bL1q8LXY2Rcbg==} + wrangler@3.83.0: + resolution: {integrity: sha512-qDzdUuTngKqmm2OJUZm7Gk4+Hv37F2nNNAHuhIgItEIhxBdOVDsgKmvpd+f41MFxyuGg3fbGWYANHI+0V2Z5yw==} engines: {node: '>=16.17.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^4.20240909.0 + '@cloudflare/workers-types': ^4.20241022.0 peerDependenciesMeta: '@cloudflare/workers-types': optional: true - wrangler@3.83.0: - resolution: {integrity: sha512-qDzdUuTngKqmm2OJUZm7Gk4+Hv37F2nNNAHuhIgItEIhxBdOVDsgKmvpd+f41MFxyuGg3fbGWYANHI+0V2Z5yw==} + wrangler@3.87.0: + resolution: {integrity: sha512-BExktnSLeGgG+uxgnr4h9eZ5nefdpTVcTHR+gEIWRvqk07XL04nJwpPYAOIPKPpB7E2tMdDJgNLGQN/CY6e1xQ==} engines: {node: '>=16.17.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^4.20241022.0 + '@cloudflare/workers-types': ^4.20241106.0 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -10632,6 +10681,11 @@ snapshots: transitivePeerDependencies: - encoding + '@asteasolutions/zod-to-openapi@7.2.0(zod@3.23.8)': + dependencies: + openapi3-ts: 4.4.0 + zod: 3.23.8 + '@astrojs/check@0.9.4(prettier-plugin-astro@0.14.1)(prettier@3.3.3)(typescript@5.6.2)': dependencies: '@astrojs/language-server': 2.15.4(prettier-plugin-astro@0.14.1)(prettier@3.3.3)(typescript@5.6.2) @@ -11667,19 +11721,16 @@ snapshots: '@cloudflare/workerd-darwin-64@1.20240806.0': optional: true - '@cloudflare/workerd-darwin-64@1.20240821.1': - optional: true - '@cloudflare/workerd-darwin-64@1.20240909.0': optional: true '@cloudflare/workerd-darwin-64@1.20241022.0': optional: true - '@cloudflare/workerd-darwin-arm64@1.20240806.0': + '@cloudflare/workerd-darwin-64@1.20241106.1': optional: true - '@cloudflare/workerd-darwin-arm64@1.20240821.1': + '@cloudflare/workerd-darwin-arm64@1.20240806.0': optional: true '@cloudflare/workerd-darwin-arm64@1.20240909.0': @@ -11688,10 +11739,10 @@ snapshots: '@cloudflare/workerd-darwin-arm64@1.20241022.0': optional: true - '@cloudflare/workerd-linux-64@1.20240806.0': + '@cloudflare/workerd-darwin-arm64@1.20241106.1': optional: true - '@cloudflare/workerd-linux-64@1.20240821.1': + '@cloudflare/workerd-linux-64@1.20240806.0': optional: true '@cloudflare/workerd-linux-64@1.20240909.0': @@ -11700,10 +11751,10 @@ snapshots: '@cloudflare/workerd-linux-64@1.20241022.0': optional: true - '@cloudflare/workerd-linux-arm64@1.20240806.0': + '@cloudflare/workerd-linux-64@1.20241106.1': optional: true - '@cloudflare/workerd-linux-arm64@1.20240821.1': + '@cloudflare/workerd-linux-arm64@1.20240806.0': optional: true '@cloudflare/workerd-linux-arm64@1.20240909.0': @@ -11712,10 +11763,10 @@ snapshots: '@cloudflare/workerd-linux-arm64@1.20241022.0': optional: true - '@cloudflare/workerd-windows-64@1.20240806.0': + '@cloudflare/workerd-linux-arm64@1.20241106.1': optional: true - '@cloudflare/workerd-windows-64@1.20240821.1': + '@cloudflare/workerd-windows-64@1.20240806.0': optional: true '@cloudflare/workerd-windows-64@1.20240909.0': @@ -11724,9 +11775,10 @@ snapshots: '@cloudflare/workerd-windows-64@1.20241022.0': optional: true - '@cloudflare/workers-shared@0.1.0': {} + '@cloudflare/workerd-windows-64@1.20241106.1': + optional: true - '@cloudflare/workers-shared@0.4.1': {} + '@cloudflare/workers-shared@0.1.0': {} '@cloudflare/workers-shared@0.5.3': dependencies: @@ -11738,12 +11790,19 @@ snapshots: mime: 3.0.0 zod: 3.23.8 + '@cloudflare/workers-shared@0.7.1': + dependencies: + mime: 3.0.0 + zod: 3.23.8 + '@cloudflare/workers-types@4.20240806.0': {} '@cloudflare/workers-types@4.20241018.0': {} '@cloudflare/workers-types@4.20241022.0': {} + '@cloudflare/workers-types@4.20241112.0': {} + '@codemirror/autocomplete@6.18.0(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.33.0)(@lezer/common@1.2.1)': dependencies: '@codemirror/language': 6.10.2 @@ -12486,6 +12545,13 @@ snapshots: dependencies: hono: 4.6.6 + '@hono/zod-openapi@0.18.0(hono@4.6.9)(zod@3.23.8)': + dependencies: + '@asteasolutions/zod-to-openapi': 7.2.0(zod@3.23.8) + '@hono/zod-validator': 0.4.1(hono@4.6.9)(zod@3.23.8) + hono: 4.6.9 + zod: 3.23.8 + '@hono/zod-validator@0.2.2(hono@4.5.5)(zod@3.23.8)': dependencies: hono: 4.5.5 @@ -12506,6 +12572,11 @@ snapshots: hono: 4.6.6 zod: 3.23.8 + '@hono/zod-validator@0.4.1(hono@4.6.9)(zod@3.23.8)': + dependencies: + hono: 4.6.9 + zod: 3.23.8 + '@hookform/resolvers@3.9.0(react-hook-form@7.53.0(react@18.3.1))': dependencies: react-hook-form: 7.53.0(react@18.3.1) @@ -16369,6 +16440,18 @@ snapshots: '@types/react': 18.3.3 react: 18.3.1 + drizzle-orm@0.35.3(@cloudflare/workers-types@4.20241112.0)(@libsql/client-wasm@0.14.0)(@libsql/client@0.14.0)(@neondatabase/serverless@0.10.1)(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@18.3.3)(react@18.3.1): + dependencies: + '@libsql/client-wasm': 0.14.0 + optionalDependencies: + '@cloudflare/workers-types': 4.20241112.0 + '@libsql/client': 0.14.0 + '@neondatabase/serverless': 0.10.1 + '@opentelemetry/api': 1.9.0 + '@types/pg': 8.11.10 + '@types/react': 18.3.3 + react: 18.3.1 + drizzle-zod@0.5.1(drizzle-orm@0.33.0(@cloudflare/workers-types@4.20240806.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.10.1)(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@18.3.3)(react@18.3.1))(zod@3.23.8): dependencies: drizzle-orm: 0.33.0(@cloudflare/workers-types@4.20240806.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.10.1)(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@18.3.3)(react@18.3.1) @@ -18635,7 +18718,7 @@ snapshots: - supports-color - utf-8-validate - miniflare@3.20240821.0: + miniflare@3.20240909.3: dependencies: '@cspotcode/source-map-support': 0.8.1 acorn: 8.12.1 @@ -18645,7 +18728,7 @@ snapshots: glob-to-regexp: 0.4.1 stoppable: 1.1.0 undici: 5.28.4 - workerd: 1.20240821.1 + workerd: 1.20240909.0 ws: 8.18.0 youch: 3.3.3 zod: 3.23.8 @@ -18654,7 +18737,7 @@ snapshots: - supports-color - utf-8-validate - miniflare@3.20240909.3: + miniflare@3.20241022.0: dependencies: '@cspotcode/source-map-support': 0.8.1 acorn: 8.12.1 @@ -18664,7 +18747,7 @@ snapshots: glob-to-regexp: 0.4.1 stoppable: 1.1.0 undici: 5.28.4 - workerd: 1.20240909.0 + workerd: 1.20241022.0 ws: 8.18.0 youch: 3.3.3 zod: 3.23.8 @@ -18673,17 +18756,17 @@ snapshots: - supports-color - utf-8-validate - miniflare@3.20241022.0: + miniflare@3.20241106.0: dependencies: '@cspotcode/source-map-support': 0.8.1 - acorn: 8.12.1 + acorn: 8.13.0 acorn-walk: 8.3.3 capnp-ts: 0.7.0 exit-hook: 2.2.1 glob-to-regexp: 0.4.1 stoppable: 1.1.0 undici: 5.28.4 - workerd: 1.20241022.0 + workerd: 1.20241106.1 ws: 8.18.0 youch: 3.3.3 zod: 3.23.8 @@ -19024,6 +19107,10 @@ snapshots: transitivePeerDependencies: - encoding + openapi3-ts@4.4.0: + dependencies: + yaml: 2.5.0 + option@0.2.4: {} ora@5.4.1: @@ -20850,21 +20937,21 @@ snapshots: pathe: 1.1.2 ufo: 1.5.4 - unenv-nightly@2.0.0-1724863496.70db6f1: + unenv-nightly@2.0.0-1726478054.1e87097: dependencies: defu: 6.1.4 ohash: 1.1.4 pathe: 1.1.2 ufo: 1.5.4 - unenv-nightly@2.0.0-1726478054.1e87097: + unenv-nightly@2.0.0-20241018-011344-e666fcf: dependencies: defu: 6.1.4 ohash: 1.1.4 pathe: 1.1.2 ufo: 1.5.4 - unenv-nightly@2.0.0-20241018-011344-e666fcf: + unenv-nightly@2.0.0-20241024-111401-d4156ac: dependencies: defu: 6.1.4 ohash: 1.1.4 @@ -21519,14 +21606,6 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20240806.0 '@cloudflare/workerd-windows-64': 1.20240806.0 - workerd@1.20240821.1: - optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20240821.1 - '@cloudflare/workerd-darwin-arm64': 1.20240821.1 - '@cloudflare/workerd-linux-64': 1.20240821.1 - '@cloudflare/workerd-linux-arm64': 1.20240821.1 - '@cloudflare/workerd-windows-64': 1.20240821.1 - workerd@1.20240909.0: optionalDependencies: '@cloudflare/workerd-darwin-64': 1.20240909.0 @@ -21543,6 +21622,14 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20241022.0 '@cloudflare/workerd-windows-64': 1.20241022.0 + workerd@1.20241106.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20241106.1 + '@cloudflare/workerd-darwin-arm64': 1.20241106.1 + '@cloudflare/workerd-linux-64': 1.20241106.1 + '@cloudflare/workerd-linux-arm64': 1.20241106.1 + '@cloudflare/workerd-windows-64': 1.20241106.1 + wrangler@3.70.0(@cloudflare/workers-types@4.20240806.0): dependencies: '@cloudflare/kv-asset-handler': 0.3.4 @@ -21571,63 +21658,64 @@ snapshots: - supports-color - utf-8-validate - wrangler@3.73.0(@cloudflare/workers-types@4.20241022.0): + wrangler@3.78.5(@cloudflare/workers-types@4.20241112.0): dependencies: '@cloudflare/kv-asset-handler': 0.3.4 - '@cloudflare/workers-shared': 0.4.1 + '@cloudflare/workers-shared': 0.5.3 '@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19) '@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19) blake3-wasm: 2.1.5 chokidar: 3.6.0 date-fns: 3.6.0 esbuild: 0.17.19 - miniflare: 3.20240821.0 + miniflare: 3.20240909.3 nanoid: 3.3.7 - path-to-regexp: 6.2.2 + path-to-regexp: 6.3.0 resolve: 1.22.8 resolve.exports: 2.0.2 selfsigned: 2.4.1 source-map: 0.6.1 - unenv: unenv-nightly@2.0.0-1724863496.70db6f1 - workerd: 1.20240821.1 + unenv: unenv-nightly@2.0.0-1726478054.1e87097 + workerd: 1.20240909.0 xxhash-wasm: 1.0.2 optionalDependencies: - '@cloudflare/workers-types': 4.20241022.0 + '@cloudflare/workers-types': 4.20241112.0 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - wrangler@3.78.5(@cloudflare/workers-types@4.20241022.0): + wrangler@3.83.0(@cloudflare/workers-types@4.20241018.0): dependencies: '@cloudflare/kv-asset-handler': 0.3.4 - '@cloudflare/workers-shared': 0.5.3 + '@cloudflare/workers-shared': 0.7.0 '@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19) '@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19) blake3-wasm: 2.1.5 chokidar: 3.6.0 - date-fns: 3.6.0 + date-fns: 4.1.0 esbuild: 0.17.19 - miniflare: 3.20240909.3 + itty-time: 1.0.6 + miniflare: 3.20241022.0 nanoid: 3.3.7 path-to-regexp: 6.3.0 resolve: 1.22.8 resolve.exports: 2.0.2 selfsigned: 2.4.1 source-map: 0.6.1 - unenv: unenv-nightly@2.0.0-1726478054.1e87097 - workerd: 1.20240909.0 + unenv: unenv-nightly@2.0.0-20241018-011344-e666fcf + workerd: 1.20241022.0 xxhash-wasm: 1.0.2 optionalDependencies: - '@cloudflare/workers-types': 4.20241022.0 + '@cloudflare/workers-types': 4.20241018.0 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - wrangler@3.83.0(@cloudflare/workers-types@4.20241018.0): + wrangler@3.83.0(@cloudflare/workers-types@4.20241022.0): dependencies: '@cloudflare/kv-asset-handler': 0.3.4 '@cloudflare/workers-shared': 0.7.0 @@ -21649,14 +21737,14 @@ snapshots: workerd: 1.20241022.0 xxhash-wasm: 1.0.2 optionalDependencies: - '@cloudflare/workers-types': 4.20241018.0 + '@cloudflare/workers-types': 4.20241022.0 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - wrangler@3.83.0(@cloudflare/workers-types@4.20241022.0): + wrangler@3.83.0(@cloudflare/workers-types@4.20241112.0): dependencies: '@cloudflare/kv-asset-handler': 0.3.4 '@cloudflare/workers-shared': 0.7.0 @@ -21678,7 +21766,36 @@ snapshots: workerd: 1.20241022.0 xxhash-wasm: 1.0.2 optionalDependencies: - '@cloudflare/workers-types': 4.20241022.0 + '@cloudflare/workers-types': 4.20241112.0 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + wrangler@3.87.0(@cloudflare/workers-types@4.20241112.0): + dependencies: + '@cloudflare/kv-asset-handler': 0.3.4 + '@cloudflare/workers-shared': 0.7.1 + '@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19) + '@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19) + blake3-wasm: 2.1.5 + chokidar: 4.0.1 + date-fns: 4.1.0 + esbuild: 0.17.19 + itty-time: 1.0.6 + miniflare: 3.20241106.0 + nanoid: 3.3.7 + path-to-regexp: 6.3.0 + resolve: 1.22.8 + resolve.exports: 2.0.2 + selfsigned: 2.4.1 + source-map: 0.6.1 + unenv: unenv-nightly@2.0.0-20241024-111401-d4156ac + workerd: 1.20241106.1 + xxhash-wasm: 1.0.2 + optionalDependencies: + '@cloudflare/workers-types': 4.20241112.0 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil From 318eff700d0eca3db755b960ba8acff84d073e3c Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 19 Nov 2024 19:35:54 +0100 Subject: [PATCH 02/67] Add settings page for openapispecurl --- examples/openapi-zod/src/index.ts | 46 ++++----- packages/fpx-types/src/settings.ts | 0 packages/types/package.json | 2 +- packages/types/src/settings.ts | 1 + .../SettingsPage/OpenAPISettingsForm.tsx | 96 +++++++++++++++++++ .../src/pages/SettingsPage/SettingsPage.tsx | 13 +++ studio/src/pages/SettingsPage/form/form.tsx | 1 + 7 files changed, 135 insertions(+), 24 deletions(-) create mode 100644 packages/fpx-types/src/settings.ts create mode 100644 studio/src/pages/SettingsPage/OpenAPISettingsForm.tsx diff --git a/examples/openapi-zod/src/index.ts b/examples/openapi-zod/src/index.ts index cb7a17702..a11b61b92 100644 --- a/examples/openapi-zod/src/index.ts +++ b/examples/openapi-zod/src/index.ts @@ -1,7 +1,7 @@ import { instrument } from "@fiberplane/hono-otel"; +import { OpenAPIHono } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { drizzle } from "drizzle-orm/d1"; -import { OpenAPIHono } from '@hono/zod-openapi' -import { createRoute, z } from '@hono/zod-openapi' import * as schema from "./db/schema"; type Bindings = { @@ -16,62 +16,62 @@ const ParamsSchema = z.object({ .min(3) .openapi({ param: { - name: 'id', - in: 'path', + name: "id", + in: "path", }, - example: '1212121', + example: "1212121", }), -}) +}); const UserSchema = z .object({ id: z.string().openapi({ - example: '123', + example: "123", }), name: z.string().openapi({ - example: 'John Doe', + example: "John Doe", }), age: z.number().openapi({ example: 42, }), }) - .openapi('User') + .openapi("User"); const route = createRoute({ - method: 'get', - path: '/users/{id}', + method: "get", + path: "/users/{id}", request: { params: ParamsSchema, }, responses: { 200: { content: { - 'application/json': { + "application/json": { schema: UserSchema, }, }, - description: 'Retrieve the user', + description: "Retrieve the user", }, }, -}) +}); app.openapi(route, (c) => { - const { id } = c.req.valid('param') + const { id } = c.req.valid("param"); return c.json({ id, age: 20, - name: 'Ultra-user', - }) -}) + name: "Ultra-user", + }); +}); // The OpenAPI documentation will be available at /doc -app.doc('/doc', { - openapi: '3.0.0', +app.doc("/doc", { + openapi: "3.0.0", info: { - version: '1.0.0', - title: 'My API', + version: "1.0.0", + title: "My API", }, -}) +}); app.get("/", (c) => { return c.text("Hello Hono OpenAPI!"); diff --git a/packages/fpx-types/src/settings.ts b/packages/fpx-types/src/settings.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/types/package.json b/packages/types/package.json index 59f77cf77..8f78597d0 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,7 +1,7 @@ { "name": "@fiberplane/fpx-types", "description": "Shared types and schemas for fpx", - "version": "0.0.9", + "version": "0.0.10", "type": "module", "exports": { ".": "./dist/index.js" diff --git a/packages/types/src/settings.ts b/packages/types/src/settings.ts index bb374006c..1bae960a9 100644 --- a/packages/types/src/settings.ts +++ b/packages/types/src/settings.ts @@ -123,6 +123,7 @@ export const SettingsSchema = z.object({ baseUrl: z.union([z.literal(""), z.string().trim().url()]).optional(), }) .optional(), + openApiSpecUrl: z.string().optional(), }); export type Settings = z.infer; diff --git a/studio/src/pages/SettingsPage/OpenAPISettingsForm.tsx b/studio/src/pages/SettingsPage/OpenAPISettingsForm.tsx new file mode 100644 index 000000000..b0b2bb99a --- /dev/null +++ b/studio/src/pages/SettingsPage/OpenAPISettingsForm.tsx @@ -0,0 +1,96 @@ +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import type { Settings } from "@fiberplane/fpx-types"; +import { InfoCircledIcon } from "@radix-ui/react-icons"; +import { useSettingsForm } from "./form"; + +type OpenAPISettingsFormProps = { + settings: Settings; +}; + +export function OpenAPISettingsForm({ settings }: OpenAPISettingsFormProps) { + const { form, onSubmit } = useSettingsForm(settings); + + const isFormDirty = Object.keys(form.formState.dirtyFields).length > 0; + + return ( +
+ { + onSubmit(data); + }, + (error) => { + console.error("Form submission error:", error); + }, + )} + className="w-full space-y-4 pb-8 px-0.5" + > +
+

+ OpenAPI Settings +

+
+ ( + +
+ OpenAPI Specification URL + + Enter the URL of your OpenAPI specification file (JSON or + YAML format). + +
+ + + +
+ )} + /> +
+
+ +
+
+ +
+
+ About OpenAPI Integration +
+ + The OpenAPI specification will be used to enhance request + validation and provide better suggestions for request + parameters. + +
+
+
+ +
+ +
+
+ + ); +} diff --git a/studio/src/pages/SettingsPage/SettingsPage.tsx b/studio/src/pages/SettingsPage/SettingsPage.tsx index 932690c71..3bfea6650 100644 --- a/studio/src/pages/SettingsPage/SettingsPage.tsx +++ b/studio/src/pages/SettingsPage/SettingsPage.tsx @@ -8,6 +8,7 @@ import { useState } from "react"; import { useRequestorStore } from "../RequestorPage/store"; import { AISettingsForm } from "./AISettingsForm"; import { FpxWorkerProxySettingsForm } from "./FpxWorkerProxySettingsForm"; +import { OpenAPISettingsForm } from "./OpenAPISettingsForm"; import { Profile } from "./Profile"; import { ProxyRequestsSettingsForm } from "./ProxyRequestsSettingsForm"; @@ -37,6 +38,7 @@ export function SettingsPage() { const PROFILE_TAB = "Profile"; // Exported allow us to navigate to this tab from the requestor page export const AI_TAB = "AI"; +const OPENAPI_TAB = "OpenAPI"; const PROXY_REQUESTS_TAB = "Proxy Requests"; const FPX_WORKER_PROXY_TAB = "Production Ingestion"; @@ -92,6 +94,14 @@ function SettingsLayout({ You + + + OpenAPI + + + + + diff --git a/studio/src/pages/SettingsPage/form/form.tsx b/studio/src/pages/SettingsPage/form/form.tsx index 7f557f2c0..13dff3d66 100644 --- a/studio/src/pages/SettingsPage/form/form.tsx +++ b/studio/src/pages/SettingsPage/form/form.tsx @@ -31,6 +31,7 @@ const DEFAULT_VALUES = { }, proxyRequestsEnabled: false, proxyBaseUrl: "https://webhonc.mies.workers.dev", + openApiSpecUrl: undefined, } satisfies Settings; export function useSettingsForm(settings: Settings) { From 1c47ddfbf0fa7a74aecfcc3d2a147da0738dd239 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 19 Nov 2024 20:18:26 +0100 Subject: [PATCH 03/67] Add openapi lib file --- .../src/lib/openapi/index.ts | 0 api/src/lib/openapi/openapi.ts | 36 +++++++++++++++++++ 2 files changed, 36 insertions(+) rename packages/fpx-types/src/settings.ts => api/src/lib/openapi/index.ts (100%) create mode 100644 api/src/lib/openapi/openapi.ts diff --git a/packages/fpx-types/src/settings.ts b/api/src/lib/openapi/index.ts similarity index 100% rename from packages/fpx-types/src/settings.ts rename to api/src/lib/openapi/index.ts diff --git a/api/src/lib/openapi/openapi.ts b/api/src/lib/openapi/openapi.ts new file mode 100644 index 000000000..d9778617c --- /dev/null +++ b/api/src/lib/openapi/openapi.ts @@ -0,0 +1,36 @@ +import type { LibSQLDatabase } from "drizzle-orm/libsql"; +import { resolveServiceArg } from "../../probe-routes.js"; +import type * as schema from "../../db/schema.js"; +import { getAllSettings } from "../settings/index.js"; + +/** + * Get the OpenAPI spec URL from the settings record in the database. + */ +export async function getSpecUrl(db: LibSQLDatabase) { + const settingsRecord = await getAllSettings(db); + return settingsRecord.openApiSpecUrl; +} + +/** + * Resolve the OpenAPI spec URL to an absolute URL. + * + * @param specUrl - The spec URL to resolve. + * @returns The resolved spec URL or null if the spec URL is not provided. + */ +export function resolveSpecUrl(specUrl: string) { + if (!specUrl) { + return null; + } + try { + // Try parsing as URL to check if it's already absolute + new URL(specUrl); + return specUrl; + } catch { + const serviceTargetArgument = process.env.FPX_SERVICE_TARGET; + const serviceUrl = resolveServiceArg(serviceTargetArgument); + + // Remove leading slash if present to avoid double slashes + const cleanSpecUrl = specUrl.startsWith('/') ? specUrl.slice(1) : specUrl; + return `${serviceUrl}/${cleanSpecUrl}`; + } +} From 98de1144e28a930251a485b72807d2917c8ed7a4 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 19 Nov 2024 20:56:46 +0100 Subject: [PATCH 04/67] Format --- api/src/lib/openapi/openapi.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/src/lib/openapi/openapi.ts b/api/src/lib/openapi/openapi.ts index d9778617c..82fb258d9 100644 --- a/api/src/lib/openapi/openapi.ts +++ b/api/src/lib/openapi/openapi.ts @@ -1,6 +1,6 @@ import type { LibSQLDatabase } from "drizzle-orm/libsql"; -import { resolveServiceArg } from "../../probe-routes.js"; import type * as schema from "../../db/schema.js"; +import { resolveServiceArg } from "../../probe-routes.js"; import { getAllSettings } from "../settings/index.js"; /** @@ -28,9 +28,9 @@ export function resolveSpecUrl(specUrl: string) { } catch { const serviceTargetArgument = process.env.FPX_SERVICE_TARGET; const serviceUrl = resolveServiceArg(serviceTargetArgument); - + // Remove leading slash if present to avoid double slashes - const cleanSpecUrl = specUrl.startsWith('/') ? specUrl.slice(1) : specUrl; + const cleanSpecUrl = specUrl.startsWith("/") ? specUrl.slice(1) : specUrl; return `${serviceUrl}/${cleanSpecUrl}`; } } From 93c88928a55121dae99db303bb99af4fd149f494 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 19 Nov 2024 22:05:06 +0100 Subject: [PATCH 05/67] Hack in a way to save a simple openapi description for a route --- api/src/lib/app-routes.ts | 3 + api/src/lib/openapi/fetch.ts | 60 ++++++++++++++++ api/src/lib/openapi/index.ts | 3 + api/src/lib/openapi/map-routes.test.ts | 58 +++++++++++++++ api/src/lib/openapi/map-routes.ts | 31 ++++++++ api/src/lib/openapi/openapi.ts | 70 +++++++++++-------- api/src/lib/openapi/types.ts | 51 ++++++++++++++ api/src/routes/app-routes.ts | 6 +- api/src/routes/traces.ts | 1 + packages/client-library-otel/package.json | 2 +- .../src/instrumentation.ts | 7 ++ 11 files changed, 260 insertions(+), 32 deletions(-) create mode 100644 api/src/lib/openapi/fetch.ts create mode 100644 api/src/lib/openapi/map-routes.test.ts create mode 100644 api/src/lib/openapi/map-routes.ts create mode 100644 api/src/lib/openapi/types.ts diff --git a/api/src/lib/app-routes.ts b/api/src/lib/app-routes.ts index 59caefb00..5d109faab 100644 --- a/api/src/lib/app-routes.ts +++ b/api/src/lib/app-routes.ts @@ -12,6 +12,7 @@ export const schemaProbedRoutes = z.object({ path: z.string(), handler: z.string(), handlerType: z.string(), + openApiSpec: z.string().nullish(), }), ), }); @@ -70,6 +71,8 @@ export async function reregisterRoutes( handler: route.handler, currentlyRegistered: true, registrationOrder: index, + openApiSpec: + route.handlerType === "route" ? route.openApiSpec : null, }) .where(eq(appRoutes.id, routeToUpdate.id)); } else { diff --git a/api/src/lib/openapi/fetch.ts b/api/src/lib/openapi/fetch.ts new file mode 100644 index 000000000..981b230fd --- /dev/null +++ b/api/src/lib/openapi/fetch.ts @@ -0,0 +1,60 @@ +import type { LibSQLDatabase } from "drizzle-orm/libsql"; +import type * as schema from "../../db/schema.js"; +import logger from "../../logger/index.js"; +import { resolveServiceArg } from "../../probe-routes.js"; +import { getAllSettings } from "../settings/index.js"; +import type { OpenApiSpec } from "./types.js"; + +export async function fetchOpenApiSpec(db: LibSQLDatabase) { + const specUrl = await getSpecUrl(db); + if (!specUrl) { + logger.debug("No OpenAPI spec URL found"); + return null; + } + const resolvedSpecUrl = resolveSpecUrl(specUrl); + if (!resolvedSpecUrl) { + logger.debug("No resolved OpenAPI spec URL found"); + return null; + } + logger.debug(`Fetching OpenAPI spec from ${resolvedSpecUrl}`); + const response = await fetch(resolvedSpecUrl, { + headers: { + // NOTE - This is to avoid infinite loops when the OpenAPI spec is fetched from Studio + // We need to make sure that the user has instrumented their app with @fiberplane/hono-otel >= 0.4.1 + "x-fpx-ignore": "true", + }, + }); + return response.json() as Promise; +} + +/** + * Get the OpenAPI spec URL from the settings record in the database. + */ +async function getSpecUrl(db: LibSQLDatabase) { + const settingsRecord = await getAllSettings(db); + return settingsRecord.openApiSpecUrl; +} + +/** + * Resolve the OpenAPI spec URL to an absolute URL. + * + * @param specUrl - The spec URL to resolve. + * @returns The resolved spec URL or null if the spec URL is not provided. + */ +function resolveSpecUrl(specUrl: string) { + if (!specUrl) { + return null; + } + try { + // Try parsing as URL to check if it's already absolute + new URL(specUrl); + return specUrl; + } catch { + const serviceTargetArgument = process.env.FPX_SERVICE_TARGET; + const serviceUrl = resolveServiceArg(serviceTargetArgument); + + // Remove leading slash if present to avoid double slashes + const cleanSpecUrl = specUrl.startsWith("/") ? specUrl.slice(1) : specUrl; + return `${serviceUrl}/${cleanSpecUrl}`; + } +} diff --git a/api/src/lib/openapi/index.ts b/api/src/lib/openapi/index.ts index e69de29bb..5e8c95ed0 100644 --- a/api/src/lib/openapi/index.ts +++ b/api/src/lib/openapi/index.ts @@ -0,0 +1,3 @@ +import { addOpenApiSpecToRoutes } from "./openapi.js"; + +export { addOpenApiSpecToRoutes }; diff --git a/api/src/lib/openapi/map-routes.test.ts b/api/src/lib/openapi/map-routes.test.ts new file mode 100644 index 000000000..002cc6faa --- /dev/null +++ b/api/src/lib/openapi/map-routes.test.ts @@ -0,0 +1,58 @@ +import { correlatePaths } from "./map-routes.js"; +import type { OpenAPIOperation, OpenApiSpec } from "./types.js"; + +describe("correlatePaths", () => { + it("should convert OpenAPI paths to Hono paths", () => { + const mockSpec: OpenApiSpec = { + paths: { + "/users/{id}": { + get: { operationId: "getUser" } as unknown as OpenAPIOperation, + put: { operationId: "updateUser" } as unknown as OpenAPIOperation, + }, + "/posts/{postId}/comments/{commentId}": { + get: { operationId: "getComment" } as unknown as OpenAPIOperation, + }, + "/simple/path": { + post: { + operationId: "createSomething", + } as unknown as OpenAPIOperation, + }, + }, + }; + + const result = correlatePaths(mockSpec); + + expect(result).toEqual([ + { + honoPath: "/users/:id", + method: "GET", + operation: { operationId: "getUser" }, + }, + { + honoPath: "/users/:id", + method: "PUT", + operation: { operationId: "updateUser" }, + }, + { + honoPath: "/posts/:postId/comments/:commentId", + method: "GET", + operation: { operationId: "getComment" }, + }, + { + honoPath: "/simple/path", + method: "POST", + operation: { operationId: "createSomething" }, + }, + ]); + }); + + it("should handle empty paths object", () => { + const mockSpec: OpenApiSpec = { + paths: {}, + }; + + const result = correlatePaths(mockSpec); + + expect(result).toEqual([]); + }); +}); diff --git a/api/src/lib/openapi/map-routes.ts b/api/src/lib/openapi/map-routes.ts new file mode 100644 index 000000000..4c7e1ccce --- /dev/null +++ b/api/src/lib/openapi/map-routes.ts @@ -0,0 +1,31 @@ +import type { OpenAPIOperation, OpenApiSpec } from "./types.js"; + +type PathMapping = { + honoPath: string; + method: string; + operation: OpenAPIOperation; +}; + +/** + * Correlate Hono routes to OpenAPI paths. + */ +export function mapOpenApiToHonoRoutes( + openApiSpec: OpenApiSpec, +): PathMapping[] { + const pathMappings: PathMapping[] = []; + + for (const [openApiPath, methods] of Object.entries(openApiSpec.paths)) { + for (const [method, operation] of Object.entries(methods)) { + // Convert OpenAPI path parameters {param} to Hono format :param + const honoPath = openApiPath.replace(/{(\w+)}/g, ":$1"); + + pathMappings.push({ + honoPath, + method: method.toUpperCase(), + operation, + }); + } + } + + return pathMappings; +} diff --git a/api/src/lib/openapi/openapi.ts b/api/src/lib/openapi/openapi.ts index 82fb258d9..5c5343fb2 100644 --- a/api/src/lib/openapi/openapi.ts +++ b/api/src/lib/openapi/openapi.ts @@ -1,36 +1,46 @@ import type { LibSQLDatabase } from "drizzle-orm/libsql"; +import type { z } from "zod"; import type * as schema from "../../db/schema.js"; -import { resolveServiceArg } from "../../probe-routes.js"; -import { getAllSettings } from "../settings/index.js"; +import type { schemaProbedRoutes } from "../../lib/app-routes.js"; +import logger from "../../logger/index.js"; +import { fetchOpenApiSpec } from "./fetch.js"; +import { mapOpenApiToHonoRoutes } from "./map-routes.js"; -/** - * Get the OpenAPI spec URL from the settings record in the database. - */ -export async function getSpecUrl(db: LibSQLDatabase) { - const settingsRecord = await getAllSettings(db); - return settingsRecord.openApiSpecUrl; -} - -/** - * Resolve the OpenAPI spec URL to an absolute URL. - * - * @param specUrl - The spec URL to resolve. - * @returns The resolved spec URL or null if the spec URL is not provided. - */ -export function resolveSpecUrl(specUrl: string) { - if (!specUrl) { - return null; - } - try { - // Try parsing as URL to check if it's already absolute - new URL(specUrl); - return specUrl; - } catch { - const serviceTargetArgument = process.env.FPX_SERVICE_TARGET; - const serviceUrl = resolveServiceArg(serviceTargetArgument); +type Routes = z.infer["routes"]; - // Remove leading slash if present to avoid double slashes - const cleanSpecUrl = specUrl.startsWith("/") ? specUrl.slice(1) : specUrl; - return `${serviceUrl}/${cleanSpecUrl}`; +export async function addOpenApiSpecToRoutes( + db: LibSQLDatabase, + routes: Routes, +) { + const spec = await fetchOpenApiSpec(db); + if (!spec) { + logger.debug("No OpenAPI spec found"); + return []; } + const openApiRoutes = mapOpenApiToHonoRoutes(spec); + const appRoutes = Array.isArray(routes) ? routes : [routes]; + logger.info("[addOpenApiSpecToRoutes] length of appRoutes", appRoutes.length); + return appRoutes.map((route) => { + logger.debug( + `Mapping OpenAPI spec to route ${route.path} ${route.method} (handlerType: ${route.handlerType})`, + ); + // console.log(openApiRoutes); + const openApiRoute = openApiRoutes.find( + (r) => + route.handlerType === "route" && + r.honoPath === route.path && + r.method === route.method, + ); + logger.debug(`Found OpenAPI route ${openApiRoute ? "yes" : "no"}`); + const result = { + ...route, + openApiSpec: openApiRoute?.operation + ? JSON.stringify(openApiRoute.operation) + : null, + }; + if (openApiRoute) { + logger.debug(`YES Result: ${JSON.stringify(result, null, 2)}`); + } + return result; + }); } diff --git a/api/src/lib/openapi/types.ts b/api/src/lib/openapi/types.ts new file mode 100644 index 000000000..a49dda1d2 --- /dev/null +++ b/api/src/lib/openapi/types.ts @@ -0,0 +1,51 @@ +export type OpenApiPathItem = { + [method: string]: OpenAPIOperation; +}; + +export type OpenApiSpec = { + paths: { + [path: string]: OpenApiPathItem; + }; + // components?: any; +}; + +// Define types for OpenAPI operation objects +type OpenAPIParameter = { + name: string; + in: "query" | "header" | "path" | "cookie"; + required?: boolean; + schema?: { + type: string; + format?: string; + }; + description?: string; +}; + +type OpenAPIResponse = { + description: string; + content?: { + [mediaType: string]: { + schema: { + type: string; + properties?: Record; + }; + }; + }; +}; + +export type OpenAPIOperation = { + summary?: string; + description?: string; + parameters?: OpenAPIParameter[]; + requestBody?: { + content: { + [mediaType: string]: { + schema: Record; + }; + }; + }; + responses: { + [statusCode: string]: OpenAPIResponse; + }; + tags?: string[]; +}; diff --git a/api/src/routes/app-routes.ts b/api/src/routes/app-routes.ts index 831aa7485..9bc118c52 100644 --- a/api/src/routes/app-routes.ts +++ b/api/src/routes/app-routes.ts @@ -11,6 +11,7 @@ import { appRoutesInsertSchema, } from "../db/schema.js"; import { reregisterRoutes, schemaProbedRoutes } from "../lib/app-routes.js"; +import { addOpenApiSpecToRoutes } from "../lib/openapi/index.js"; import { OTEL_TRACE_ID_REGEX, generateOtelTraceId, @@ -93,12 +94,15 @@ app.post( zValidator("json", schemaProbedRoutes), async (ctx) => { const db = ctx.get("db"); + const { routes } = ctx.req.valid("json"); + const routesWithOpenApiSpec = await addOpenApiSpecToRoutes(db, routes); + try { if (routes.length > 0) { // "Re-register" all current app routes in a database transaction - await reregisterRoutes(db, { routes }); + await reregisterRoutes(db, { routes: routesWithOpenApiSpec }); // TODO - Detect if anything actually changed before invalidating the query on the frontend // This would be more of an optimization, but is friendlier to the frontend diff --git a/api/src/routes/traces.ts b/api/src/routes/traces.ts index c71a60b4e..ec19277d4 100644 --- a/api/src/routes/traces.ts +++ b/api/src/routes/traces.ts @@ -35,6 +35,7 @@ app.get("/v1/traces", async (ctx) => { const spans = await db.query.otelSpans.findMany({ where: sql`inner->>'scope_name' = 'fpx-tracer'`, orderBy: desc(sql`inner->>'end_time'`), + limit: 1000, }); const traceMap = new Map>(); diff --git a/packages/client-library-otel/package.json b/packages/client-library-otel/package.json index e6044c9f9..f4fe41944 100644 --- a/packages/client-library-otel/package.json +++ b/packages/client-library-otel/package.json @@ -4,7 +4,7 @@ "author": "Fiberplane", "type": "module", "main": "dist/index.js", - "version": "0.4.0-canary.0", + "version": "0.4.1", "dependencies": { "@opentelemetry/api": "~1.9.0", "@opentelemetry/exporter-trace-otlp-http": "^0.52.1", diff --git a/packages/client-library-otel/src/instrumentation.ts b/packages/client-library-otel/src/instrumentation.ts index 254ceda16..2eee15873 100644 --- a/packages/client-library-otel/src/instrumentation.ts +++ b/packages/client-library-otel/src/instrumentation.ts @@ -129,6 +129,13 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) { return await originalFetch(request, rawEnv, executionContext); } + // Ignore instrumentation for requests that have the x-fpx-ignore header + // This is useful for not triggering infinite loops when the OpenAPI spec is fetched from Studio + if (request.headers.get("x-fpx-ignore")) { + logger.debug("Ignoring request"); + return await originalFetch(request, rawEnv, executionContext); + } + // If the request is from the route inspector, send latest routes to the Studio API and respond with 200 OK if (isRouteInspectorRequest(request)) { logger.debug("Responding to route inspector request"); From 4caab50eb29d5293fc81a773e521308242352cc9 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 19 Nov 2024 22:37:05 +0100 Subject: [PATCH 06/67] Fix bad import --- api/src/lib/openapi/map-routes.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/src/lib/openapi/map-routes.test.ts b/api/src/lib/openapi/map-routes.test.ts index 002cc6faa..7135dd85d 100644 --- a/api/src/lib/openapi/map-routes.test.ts +++ b/api/src/lib/openapi/map-routes.test.ts @@ -1,7 +1,7 @@ -import { correlatePaths } from "./map-routes.js"; +import { mapOpenApiToHonoRoutes } from "./map-routes.js"; import type { OpenAPIOperation, OpenApiSpec } from "./types.js"; -describe("correlatePaths", () => { +describe("mapOpenApiToHonoRoutes", () => { it("should convert OpenAPI paths to Hono paths", () => { const mockSpec: OpenApiSpec = { paths: { @@ -20,7 +20,7 @@ describe("correlatePaths", () => { }, }; - const result = correlatePaths(mockSpec); + const result = mapOpenApiToHonoRoutes(mockSpec); expect(result).toEqual([ { @@ -51,7 +51,7 @@ describe("correlatePaths", () => { paths: {}, }; - const result = correlatePaths(mockSpec); + const result = mapOpenApiToHonoRoutes(mockSpec); expect(result).toEqual([]); }); From 600e95e38cb61226f17a048668e682c0cc7e4ca4 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 19 Nov 2024 23:08:26 +0100 Subject: [PATCH 07/67] Add an openapi route for creating a user in the example --- .../drizzle/migrations/0000_bright_clea.sql | 7 ++ .../migrations/meta/0000_snapshot.json | 65 +++++++++++++ .../drizzle/migrations/meta/_journal.json | 13 +++ examples/openapi-zod/package.json | 1 + examples/openapi-zod/src/db/index.ts | 1 - examples/openapi-zod/src/db/schema.ts | 18 ++-- examples/openapi-zod/src/index.ts | 96 ++++++++++++++++--- pnpm-lock.yaml | 10 ++ 8 files changed, 190 insertions(+), 21 deletions(-) create mode 100644 examples/openapi-zod/drizzle/migrations/0000_bright_clea.sql create mode 100644 examples/openapi-zod/drizzle/migrations/meta/0000_snapshot.json create mode 100644 examples/openapi-zod/drizzle/migrations/meta/_journal.json delete mode 100644 examples/openapi-zod/src/db/index.ts diff --git a/examples/openapi-zod/drizzle/migrations/0000_bright_clea.sql b/examples/openapi-zod/drizzle/migrations/0000_bright_clea.sql new file mode 100644 index 000000000..7b5714cb1 --- /dev/null +++ b/examples/openapi-zod/drizzle/migrations/0000_bright_clea.sql @@ -0,0 +1,7 @@ +CREATE TABLE `users` ( + `id` integer PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `age` integer NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL +); diff --git a/examples/openapi-zod/drizzle/migrations/meta/0000_snapshot.json b/examples/openapi-zod/drizzle/migrations/meta/0000_snapshot.json new file mode 100644 index 000000000..53df2d3fd --- /dev/null +++ b/examples/openapi-zod/drizzle/migrations/meta/0000_snapshot.json @@ -0,0 +1,65 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "230311e6-ddef-479e-9241-704cbf3ab77d", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/examples/openapi-zod/drizzle/migrations/meta/_journal.json b/examples/openapi-zod/drizzle/migrations/meta/_journal.json new file mode 100644 index 000000000..e5d3d8de6 --- /dev/null +++ b/examples/openapi-zod/drizzle/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1732053841935, + "tag": "0000_bright_clea", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/examples/openapi-zod/package.json b/examples/openapi-zod/package.json index 8c4a1f408..6f4706f5a 100644 --- a/examples/openapi-zod/package.json +++ b/examples/openapi-zod/package.json @@ -15,6 +15,7 @@ "@hono/zod-openapi": "^0.18.0", "dotenv": "^16.4.5", "drizzle-orm": "^0.35.3", + "drizzle-zod": "^0.5.1", "hono": "^4.6.7", "zod": "^3.23.8" }, diff --git a/examples/openapi-zod/src/db/index.ts b/examples/openapi-zod/src/db/index.ts deleted file mode 100644 index ed6091c62..000000000 --- a/examples/openapi-zod/src/db/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { geese, gooseImages } from "./schema"; diff --git a/examples/openapi-zod/src/db/schema.ts b/examples/openapi-zod/src/db/schema.ts index 9f2d8ef9e..342db1991 100644 --- a/examples/openapi-zod/src/db/schema.ts +++ b/examples/openapi-zod/src/db/schema.ts @@ -1,17 +1,19 @@ import { sql } from "drizzle-orm"; import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { createInsertSchema, createSelectSchema } from "drizzle-zod"; -export const geese = sqliteTable("geese", { +export const users = sqliteTable("users", { id: integer("id", { mode: "number" }).primaryKey(), name: text("name").notNull(), - avatar: text("avatar"), + age: integer("age").notNull(), createdAt: text("created_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), updatedAt: text("updated_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), }); -export const gooseImages = sqliteTable("goose_images", { - id: integer("id", { mode: "number" }).primaryKey(), - filename: text("filename").notNull(), - prompt: text("prompt").notNull(), - createdAt: text("created_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), -}); +// TODO - Figure out how to use drizzle with "@hono/zod-openapi" +// +export const UserSchema = createSelectSchema(users); +export const NewUserSchema = createInsertSchema(users); + +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; diff --git a/examples/openapi-zod/src/index.ts b/examples/openapi-zod/src/index.ts index a11b61b92..2875a2751 100644 --- a/examples/openapi-zod/src/index.ts +++ b/examples/openapi-zod/src/index.ts @@ -1,8 +1,12 @@ import { instrument } from "@fiberplane/hono-otel"; import { OpenAPIHono } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi"; + import { drizzle } from "drizzle-orm/d1"; import * as schema from "./db/schema"; +// TODO - Figure out how to use drizzle with "@hono/zod-openapi" +// +// import { UserSchema } from "./db/schema"; type Bindings = { DB: D1Database; @@ -12,21 +16,34 @@ const app = new OpenAPIHono<{ Bindings: Bindings }>(); const ParamsSchema = z.object({ id: z - .string() - .min(3) + .number() + // .min(1) .openapi({ param: { name: "id", in: "path", }, - example: "1212121", + example: 1212121, }), }); +const NewUserSchema = z + .object({ + name: z.string().openapi({ + example: "John Doe", + }), + age: z.number().openapi({ + example: 42, + }), + }) + .openapi("NewUser"); + +// TODO - Figure out how to extend the NewUserSchema object +// const UserSchema = z .object({ - id: z.string().openapi({ - example: "123", + id: z.number().openapi({ + example: 123, }), name: z.string().openapi({ example: "John Doe", @@ -37,7 +54,7 @@ const UserSchema = z }) .openapi("User"); -const route = createRoute({ +const getUserRoute = createRoute({ method: "get", path: "/users/{id}", request: { @@ -52,16 +69,71 @@ const route = createRoute({ }, description: "Retrieve the user", }, + 400: { + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + description: "Invalid ID", + }, }, }); -app.openapi(route, (c) => { +const createUserRoute = createRoute({ + method: "post", + path: "/users", + request: { + body: { + content: { + "application/json": { + schema: NewUserSchema, + }, + }, + }, + }, + responses: { + 201: { + content: { + "application/json": { + schema: UserSchema, + }, + }, + description: "Retrieve the user", + }, + }, +}); + +app.openapi(getUserRoute, (c) => { const { id } = c.req.valid("param"); - return c.json({ - id, - age: 20, - name: "Ultra-user", - }); + + const idNumber = +id; + if (Number.isNaN(idNumber) || idNumber < 1) { + return c.json({ error: "Invalid ID" }, 400); + } + return c.json( + { + id: idNumber, + age: 20, + name: "Ultra-user", + }, + 200, + ); +}); + +app.openapi(createUserRoute, async (c) => { + const { name, age } = c.req.valid("json"); + const db = drizzle(c.env.DB); + const [result] = await db + .insert(schema.users) + .values({ + name, + age, + }) + .returning(); + return c.json(result, 201); }); // The OpenAPI documentation will be available at /doc diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b05d525e3..43f3aeb18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -244,6 +244,9 @@ importers: drizzle-orm: specifier: ^0.35.3 version: 0.35.3(@cloudflare/workers-types@4.20241112.0)(@libsql/client-wasm@0.14.0)(@libsql/client@0.14.0)(@neondatabase/serverless@0.10.1)(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@18.3.3)(react@18.3.1) + drizzle-zod: + specifier: ^0.5.1 + version: 0.5.1(drizzle-orm@0.35.3(@cloudflare/workers-types@4.20241112.0)(@libsql/client-wasm@0.14.0)(@libsql/client@0.14.0)(@neondatabase/serverless@0.10.1)(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@18.3.3)(react@18.3.1))(zod@3.23.8) hono: specifier: ^4.6.7 version: 4.6.9 @@ -7360,10 +7363,12 @@ packages: libsql@0.3.19: resolution: {integrity: sha512-Aj5cQ5uk/6fHdmeW0TiXK42FqUlwx7ytmMLPSaUQPin5HKKKuUPD62MAbN4OEweGBBI7q1BekoEN4gPUEL6MZA==} + cpu: [x64, arm64, wasm32] os: [darwin, linux, win32] libsql@0.4.6: resolution: {integrity: sha512-F5M+ltteK6dCcpjMahrkgT96uFJvVI8aQ4r9f2AzHQjC7BkAYtvfMSTWGvRBezRgMUIU2h1Sy0pF9nOGOD5iyA==} + cpu: [x64, arm64, wasm32] os: [darwin, linux, win32] lie@3.3.0: @@ -16462,6 +16467,11 @@ snapshots: drizzle-orm: 0.33.0(@cloudflare/workers-types@4.20241018.0)(@libsql/client@0.14.0)(@neondatabase/serverless@0.10.1)(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@18.3.3)(react@18.3.1) zod: 3.23.8 + drizzle-zod@0.5.1(drizzle-orm@0.35.3(@cloudflare/workers-types@4.20241112.0)(@libsql/client-wasm@0.14.0)(@libsql/client@0.14.0)(@neondatabase/serverless@0.10.1)(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@18.3.3)(react@18.3.1))(zod@3.23.8): + dependencies: + drizzle-orm: 0.35.3(@cloudflare/workers-types@4.20241112.0)(@libsql/client-wasm@0.14.0)(@libsql/client@0.14.0)(@neondatabase/serverless@0.10.1)(@opentelemetry/api@1.9.0)(@types/pg@8.11.10)(@types/react@18.3.3)(react@18.3.1) + zod: 3.23.8 + dset@3.1.4: {} duck@0.1.12: From 3fee79549ced6972cd1aa945c170a35c6662281c Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 20 Nov 2024 14:40:00 +0100 Subject: [PATCH 08/67] Implement automagical query params for openapi routes --- examples/openapi-zod/src/index.ts | 36 ++++++- .../RequestorPage/store/slices/routesSlice.ts | 5 + studio/src/pages/RequestorPage/store/utils.ts | 100 ++++++++++++++++++ 3 files changed, 140 insertions(+), 1 deletion(-) diff --git a/examples/openapi-zod/src/index.ts b/examples/openapi-zod/src/index.ts index 2875a2751..cf2baf3bd 100644 --- a/examples/openapi-zod/src/index.ts +++ b/examples/openapi-zod/src/index.ts @@ -1,7 +1,7 @@ import { instrument } from "@fiberplane/hono-otel"; import { OpenAPIHono } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi"; - +import { eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/d1"; import * as schema from "./db/schema"; // TODO - Figure out how to use drizzle with "@hono/zod-openapi" @@ -82,6 +82,26 @@ const getUserRoute = createRoute({ }, }); +const listUsersRoute = createRoute({ + method: "get", + path: "/users", + request: { + query: z.object({ + name: z.string().optional(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.array(UserSchema), + }, + }, + description: "List all users", + }, + }, +}); + const createUserRoute = createRoute({ method: "post", path: "/users", @@ -123,6 +143,20 @@ app.openapi(getUserRoute, (c) => { ); }); +app.openapi(listUsersRoute, async (c) => { + const { name } = c.req.valid("query"); + const db = drizzle(c.env.DB); + + // Only apply where clause if name is provided and not empty + const query = db.select().from(schema.users); + if (name && name.trim() !== "") { + query.where(eq(schema.users.name, name)); + } + + const result = await query; + return c.json(result, 200); +}); + app.openapi(createUserRoute, async (c) => { const { name, age } = c.req.valid("json"); const db = drizzle(c.env.DB); diff --git a/studio/src/pages/RequestorPage/store/slices/routesSlice.ts b/studio/src/pages/RequestorPage/store/slices/routesSlice.ts index ab1685756..d13b86add 100644 --- a/studio/src/pages/RequestorPage/store/slices/routesSlice.ts +++ b/studio/src/pages/RequestorPage/store/slices/routesSlice.ts @@ -10,6 +10,7 @@ import { addBaseUrl, extractMatchedPathParams, extractPathParams, + extractQueryParamsFromOpenApiDefinition, mapPathParamKey, pathHasValidBaseUrl, removeBaseUrl, @@ -57,6 +58,10 @@ export const routesSlice: StateCreator< state.pathParams = extractPathParams(route.path).map(mapPathParamKey); state.activeHistoryResponseTraceId = null; state.activeResponse = null; + state.queryParams = extractQueryParamsFromOpenApiDefinition( + state.queryParams, + route, + ); // Update tabs (you might want to move this logic to a separate slice) state.visibleRequestsPanelTabs = getVisibleRequestPanelTabs({ diff --git a/studio/src/pages/RequestorPage/store/utils.ts b/studio/src/pages/RequestorPage/store/utils.ts index 082135d9d..81ab4e6a1 100644 --- a/studio/src/pages/RequestorPage/store/utils.ts +++ b/studio/src/pages/RequestorPage/store/utils.ts @@ -1,3 +1,8 @@ +import { z } from "zod"; +import { + type KeyValueParameter, + enforceTerminalDraftParameter, +} from "../KeyValueForm"; import type { findMatchedRoute } from "../routes"; import type { ProbedRoute } from "../types"; import { type RequestMethod, type RequestType, isWsRequest } from "../types"; @@ -51,6 +56,101 @@ export function probedRouteToInputMethod(route: ProbedRoute): RequestMethod { } } +export function filterDisabledEmptyQueryParams( + currentQueryParams: KeyValueParameter[], +) { + return enforceTerminalDraftParameter( + currentQueryParams.filter((param) => param.enabled || !!param.value), + ); +} + +export function extractQueryParamsFromOpenApiDefinition( + currentQueryParams: KeyValueParameter[], + route: ProbedRoute, +) { + if (!route.openApiSpec) { + return enforceTerminalDraftParameter(currentQueryParams); + } + + const parsedSpec = safeParseOpenApiSpec(route.openApiSpec, route.path); + if (!parsedSpec) { + return enforceTerminalDraftParameter(currentQueryParams); + } + + // Extract query parameters from OpenAPI spec + const specQueryParams = + parsedSpec.parameters?.filter((param) => param.in === "query") ?? []; + + // Convert OpenAPI params to KeyValueParameter format + const openApiQueryParams: KeyValueParameter[] = specQueryParams.map( + (param) => ({ + id: param.name, + key: param.name, + value: param.schema.example?.toString() ?? "", + enabled: param.required, + }), + ); + + // Merge with existing parameters, preferring existing values + const mergedParams = openApiQueryParams.map((openApiParam) => { + const existingParam = currentQueryParams.find( + (p) => p.key === openApiParam.key, + ); + return existingParam ?? openApiParam; + }); + + // Add any existing parameters that weren't in the OpenAPI spec + const additionalParams = currentQueryParams.filter( + (param) => !openApiQueryParams.some((p) => p.key === param.key), + ); + + return enforceTerminalDraftParameter([...mergedParams, ...additionalParams]); +} + +const OpenApiParameterSchema = z.object({ + parameters: z + .array( + z.object({ + schema: z.object({ + type: z.string(), + example: z.any().optional(), + }), + required: z.boolean(), + name: z.string(), + in: z.enum(["path", "query", "header", "cookie"]), + }), + ) + .optional(), + requestBody: z + .object({ + content: z.object({ + "application/json": z.object({ + schema: z.object({ + $ref: z.string().startsWith("#/components/schemas/"), + }), + }), + }), + }) + .optional(), + responses: z.record(z.string(), z.any()).optional(), // We don't validate responses structure since we only care about parameters +}); + +function safeParseOpenApiSpec(openApiSpec: string, routePath: string) { + try { + const spec = JSON.parse(openApiSpec); + const parsedSpec = OpenApiParameterSchema.safeParse(spec); + if (!parsedSpec.success) { + console.warn( + `Data in openApiSpec for ${routePath} was not in expected format. Here is the error: ${parsedSpec.error?.format?.()}`, + ); + return null; + } + return parsedSpec.data; + } catch { + return null; + } +} + /** * Extracts path parameters from a path * From 03c23d821283f6b09da82162cdfd427b37cc87b5 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 20 Nov 2024 14:44:10 +0100 Subject: [PATCH 09/67] Filter dead query params when changing routes --- studio/src/pages/RequestorPage/store/slices/routesSlice.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/studio/src/pages/RequestorPage/store/slices/routesSlice.ts b/studio/src/pages/RequestorPage/store/slices/routesSlice.ts index d13b86add..f9019a1a6 100644 --- a/studio/src/pages/RequestorPage/store/slices/routesSlice.ts +++ b/studio/src/pages/RequestorPage/store/slices/routesSlice.ts @@ -11,6 +11,7 @@ import { extractMatchedPathParams, extractPathParams, extractQueryParamsFromOpenApiDefinition, + filterDisabledEmptyQueryParams, mapPathParamKey, pathHasValidBaseUrl, removeBaseUrl, @@ -58,6 +59,10 @@ export const routesSlice: StateCreator< state.pathParams = extractPathParams(route.path).map(mapPathParamKey); state.activeHistoryResponseTraceId = null; state.activeResponse = null; + // Filter out disabled and empty query params + // TODO - Only do this if the route has an open api definition? + state.queryParams = filterDisabledEmptyQueryParams(state.queryParams); + // Extract query params from the open api definition, if it exists state.queryParams = extractQueryParamsFromOpenApiDefinition( state.queryParams, route, From 73a7bea102af144d8ae5c622b3251cd3e8254754 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 20 Nov 2024 14:58:34 +0100 Subject: [PATCH 10/67] Dereference schema entries into route openapi definition used by studio --- api/src/lib/openapi/dereference.ts | 97 ++++++++++++++++++++++++++++++ api/src/lib/openapi/openapi.ts | 44 ++++++++++---- api/src/lib/openapi/types.ts | 27 ++++++++- 3 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 api/src/lib/openapi/dereference.ts diff --git a/api/src/lib/openapi/dereference.ts b/api/src/lib/openapi/dereference.ts new file mode 100644 index 000000000..caddc03d7 --- /dev/null +++ b/api/src/lib/openapi/dereference.ts @@ -0,0 +1,97 @@ +import type { OpenAPIComponents, RefCache } from "./types.js"; + +export class CircularReferenceError extends Error { + constructor(ref: string) { + super(`Circular reference detected: ${ref}`); + this.name = "CircularReferenceError"; + } +} + +export class MissingReferenceError extends Error { + constructor(ref: string) { + super(`Reference not found: ${ref}`); + this.name = "MissingReferenceError"; + } +} + +export function resolveRef( + ref: string, + components: OpenAPIComponents, + refStack: Set = new Set(), + cache: RefCache = new Map(), +): unknown { + // Check cache first + const cached = cache.get(ref); + if (cached) { + return cached; + } + + // Check for circular references + if (refStack.has(ref)) { + throw new CircularReferenceError(ref); + } + refStack.add(ref); + + const path = ref.replace("#/components/", "").split("/"); + const resolved = path.reduce>( + (acc, part) => { + if (!acc || typeof acc !== "object") { + throw new MissingReferenceError(ref); + } + const value = (acc as Record)[part]; + return value as Record; + }, + components as unknown as Record, + ); + + if (!resolved) { + throw new MissingReferenceError(ref); + } + + // Cache the result + cache.set(ref, resolved); + refStack.delete(ref); + + return resolved; +} + +export function dereferenceSchema( + obj: T, + components: OpenAPIComponents, + refStack: Set = new Set(), + cache: RefCache = new Map(), +): T { + // Deep clone the object to avoid modifying the original + const cloned = JSON.parse(JSON.stringify(obj)) as T; + + if (!cloned || typeof cloned !== "object") { + return cloned; + } + + if ("$ref" in cloned && typeof cloned.$ref === "string") { + return resolveRef(cloned.$ref, components, refStack, cache) as T; + } + + return Object.entries(cloned).reduce((acc, [key, value]) => { + if (Array.isArray(value)) { + acc[key as keyof T] = value.map((item) => + dereferenceSchema( + item as Record, + components, + refStack, + cache, + ), + ) as T[keyof T]; + } else if (typeof value === "object" && value !== null) { + acc[key as keyof T] = dereferenceSchema( + value as Record, + components, + refStack, + cache, + ) as T[keyof T]; + } else { + acc[key as keyof T] = value as T[keyof T]; + } + return acc; + }, {} as T); +} diff --git a/api/src/lib/openapi/openapi.ts b/api/src/lib/openapi/openapi.ts index 5c5343fb2..238bb0e38 100644 --- a/api/src/lib/openapi/openapi.ts +++ b/api/src/lib/openapi/openapi.ts @@ -3,8 +3,14 @@ import type { z } from "zod"; import type * as schema from "../../db/schema.js"; import type { schemaProbedRoutes } from "../../lib/app-routes.js"; import logger from "../../logger/index.js"; +import { + CircularReferenceError, + MissingReferenceError, + dereferenceSchema, +} from "./dereference.js"; import { fetchOpenApiSpec } from "./fetch.js"; import { mapOpenApiToHonoRoutes } from "./map-routes.js"; +import type { OpenAPIOperation } from "./types.js"; type Routes = z.infer["routes"]; @@ -19,28 +25,42 @@ export async function addOpenApiSpecToRoutes( } const openApiRoutes = mapOpenApiToHonoRoutes(spec); const appRoutes = Array.isArray(routes) ? routes : [routes]; - logger.info("[addOpenApiSpecToRoutes] length of appRoutes", appRoutes.length); + return appRoutes.map((route) => { - logger.debug( - `Mapping OpenAPI spec to route ${route.path} ${route.method} (handlerType: ${route.handlerType})`, - ); - // console.log(openApiRoutes); const openApiRoute = openApiRoutes.find( (r) => route.handlerType === "route" && r.honoPath === route.path && r.method === route.method, ); - logger.debug(`Found OpenAPI route ${openApiRoute ? "yes" : "no"}`); + + let operation = openApiRoute?.operation; + if (operation) { + try { + operation = dereferenceSchema( + operation, + spec.components ?? {}, + new Set(), + new Map(), + ); + } catch (error) { + if ( + error instanceof CircularReferenceError || + error instanceof MissingReferenceError + ) { + logger.warn(`Failed to dereference OpenAPI spec: ${error.message}`); + operation = undefined; + } else { + throw error; + } + } + } + const result = { ...route, - openApiSpec: openApiRoute?.operation - ? JSON.stringify(openApiRoute.operation) - : null, + openApiSpec: operation ? JSON.stringify(operation) : null, }; - if (openApiRoute) { - logger.debug(`YES Result: ${JSON.stringify(result, null, 2)}`); - } + return result; }); } diff --git a/api/src/lib/openapi/types.ts b/api/src/lib/openapi/types.ts index a49dda1d2..be88a3d88 100644 --- a/api/src/lib/openapi/types.ts +++ b/api/src/lib/openapi/types.ts @@ -6,7 +6,7 @@ export type OpenApiSpec = { paths: { [path: string]: OpenApiPathItem; }; - // components?: any; + components?: OpenAPIComponents; }; // Define types for OpenAPI operation objects @@ -49,3 +49,28 @@ export type OpenAPIOperation = { }; tags?: string[]; }; + +export type OpenAPIComponents = { + schemas?: Record; + parameters?: Record; + responses?: Record; + requestBodies?: Record; +}; + +export type OpenAPISchema = { + type?: string; + properties?: Record; + items?: OpenAPISchema; + $ref?: string; + // Add other schema properties as needed +}; + +export type OpenAPIRequestBody = { + content: { + [mediaType: string]: { + schema: Record; + }; + }; +}; + +export type RefCache = Map; From b1ff63050105d6b9005ddebfe8193d171cf2ee04 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 20 Nov 2024 15:14:35 +0100 Subject: [PATCH 11/67] First pass at generating sample data from openapi schema for json bodies --- .../RequestorPage/store/slices/routesSlice.ts | 9 +- .../RequestorPage/store/utils-openapi.ts | 209 ++++++++++++++++++ studio/src/pages/RequestorPage/store/utils.ts | 100 --------- 3 files changed, 216 insertions(+), 102 deletions(-) create mode 100644 studio/src/pages/RequestorPage/store/utils-openapi.ts diff --git a/studio/src/pages/RequestorPage/store/slices/routesSlice.ts b/studio/src/pages/RequestorPage/store/slices/routesSlice.ts index f9019a1a6..18343f65f 100644 --- a/studio/src/pages/RequestorPage/store/slices/routesSlice.ts +++ b/studio/src/pages/RequestorPage/store/slices/routesSlice.ts @@ -10,12 +10,15 @@ import { addBaseUrl, extractMatchedPathParams, extractPathParams, - extractQueryParamsFromOpenApiDefinition, - filterDisabledEmptyQueryParams, mapPathParamKey, pathHasValidBaseUrl, removeBaseUrl, } from "../utils"; +import { + extractJsonBodyFromOpenApiDefinition, + extractQueryParamsFromOpenApiDefinition, + filterDisabledEmptyQueryParams, +} from "../utils-openapi"; import type { RoutesSlice, Store } from "./types"; export const routesSlice: StateCreator< @@ -68,6 +71,8 @@ export const routesSlice: StateCreator< route, ); + state.body = extractJsonBodyFromOpenApiDefinition(state.body, route); + // Update tabs (you might want to move this logic to a separate slice) state.visibleRequestsPanelTabs = getVisibleRequestPanelTabs({ requestType: nextRequestType, diff --git a/studio/src/pages/RequestorPage/store/utils-openapi.ts b/studio/src/pages/RequestorPage/store/utils-openapi.ts new file mode 100644 index 000000000..0b1248dc4 --- /dev/null +++ b/studio/src/pages/RequestorPage/store/utils-openapi.ts @@ -0,0 +1,209 @@ +import { z } from "zod"; +import { + type KeyValueParameter, + enforceTerminalDraftParameter, +} from "../KeyValueForm"; +import type { ProbedRoute } from "../types"; +import type { RequestorBody } from "./types"; + +/** + * Filters query parameters to only include those that are either enabled or have a value + * Intent is that when you change routes, we auto-clear anything dangling from previous openapi specs + * + * @param currentQueryParams - Array of key-value parameters to filter + * @returns Filtered array of parameters with a terminal draft parameter + */ +export function filterDisabledEmptyQueryParams( + currentQueryParams: KeyValueParameter[], +) { + return enforceTerminalDraftParameter( + currentQueryParams.filter((param) => param.enabled || !!param.value), + ); +} + +/** + * Extracts and merges query parameters from an OpenAPI specification with existing parameters + * + * @param currentQueryParams - Current array of key-value parameters + * @param route - Route object containing OpenAPI specification and path + * @returns Merged array of parameters with a terminal draft parameter + */ +export function extractQueryParamsFromOpenApiDefinition( + currentQueryParams: KeyValueParameter[], + route: ProbedRoute, +) { + if (!route.openApiSpec) { + return enforceTerminalDraftParameter(currentQueryParams); + } + + const parsedSpec = safeParseOpenApiSpec(route.openApiSpec, route.path); + if (!parsedSpec) { + return enforceTerminalDraftParameter(currentQueryParams); + } + + // Extract query parameters from OpenAPI spec + const specQueryParams = + parsedSpec.parameters?.filter((param) => param.in === "query") ?? []; + + // Convert OpenAPI params to KeyValueParameter format + const openApiQueryParams: KeyValueParameter[] = specQueryParams.map( + (param) => ({ + id: param.name, + key: param.name, + value: param.schema.example?.toString() ?? "", + enabled: param.required, + }), + ); + + // Merge with existing parameters, preferring existing values + const mergedParams = openApiQueryParams.map((openApiParam) => { + const existingParam = currentQueryParams.find( + (p) => p.key === openApiParam.key, + ); + return existingParam ?? openApiParam; + }); + + // Add any existing parameters that weren't in the OpenAPI spec + const additionalParams = currentQueryParams.filter( + (param) => !openApiQueryParams.some((p) => p.key === param.key), + ); + + return enforceTerminalDraftParameter([...mergedParams, ...additionalParams]); +} + +// Declare the type first since we're going to have a recursive reference +// biome-ignore lint/suspicious/noExplicitAny: +type JsonSchemaPropertyType = z.ZodType; + +// Then define the schema +const JsonSchemaProperty: JsonSchemaPropertyType = z.object({ + type: z.string(), + example: z.any().optional(), + properties: z.record(z.lazy(() => JsonSchemaProperty)).optional(), + required: z.array(z.string()).optional(), +}); + +const OpenApiParameterSchema = z.object({ + parameters: z + .array( + z.object({ + schema: z.object({ + type: z.string(), + example: z.any().optional(), + }), + required: z.boolean(), + name: z.string(), + in: z.enum(["path", "query", "header", "cookie"]), + }), + ) + .optional(), + requestBody: z + .object({ + content: z.object({ + "application/json": z.object({ + schema: JsonSchemaProperty, + }), + }), + }) + .optional(), + responses: z.record(z.string(), z.any()).optional(), +}); + +/** + * Validates and parses an OpenAPI specification string against a defined schema + * @param openApiSpec - OpenAPI specification as a JSON string + * @param routePath - Route path for error logging purposes + * @returns Parsed OpenAPI specification object or null if parsing fails + */ +function safeParseOpenApiSpec(openApiSpec: string, routePath: string) { + try { + const spec = JSON.parse(openApiSpec); + const parsedSpec = OpenApiParameterSchema.safeParse(spec); + if (!parsedSpec.success) { + console.warn( + `Data in openApiSpec for ${routePath} was not in expected format. Here is the error: ${parsedSpec.error?.format?.()}`, + ); + return null; + } + return parsedSpec.data; + } catch { + return null; + } +} + +/** + * Extracts a sample JSON body from OpenAPI specification if the current body is empty + * + * @param currentBody - Current request body content + * @param route - Route object containing OpenAPI specification + * @returns Sample JSON body string or the current body if no valid schema found + */ +export function extractJsonBodyFromOpenApiDefinition( + currentBody: RequestorBody, + route: ProbedRoute, +): RequestorBody { + // If no OpenAPI spec exists, return current body + if (!route.openApiSpec) { + return currentBody; + } + + // FIXME - Just skip modifying file or form data bodies + if (currentBody.type === "file" || currentBody.type === "form-data") { + return currentBody; + } + + // If current body is not empty return current body + if (currentBody.value?.trim()) { + return currentBody; + } + + const parsedSpec = safeParseOpenApiSpec(route.openApiSpec, route.path); + if (!parsedSpec?.requestBody?.content?.["application/json"]?.schema) { + return currentBody; + } + + const schema = parsedSpec.requestBody.content["application/json"].schema; + try { + const sampleBody = generateSampleFromSchema(schema); + return { + type: "json", + value: JSON.stringify(sampleBody, null, 2), + }; + } catch (error) { + console.warn(`Failed to generate sample body for ${route.path}:`, error); + return currentBody; + } +} + +// biome-ignore lint/suspicious/noExplicitAny: +function generateSampleFromSchema( + schema: z.infer, +): any { + if (schema.example !== undefined) { + return schema.example; + } + + if (schema.type === "object" && schema.properties) { + // biome-ignore lint/suspicious/noExplicitAny: + const result: Record = {}; + for (const [key, prop] of Object.entries(schema.properties)) { + result[key] = generateSampleFromSchema(prop); + } + return result; + } + + // Default values for different types + switch (schema.type) { + case "string": + return "string"; + case "number": + case "integer": + return 0; + case "boolean": + return false; + case "array": + return []; + default: + return null; + } +} diff --git a/studio/src/pages/RequestorPage/store/utils.ts b/studio/src/pages/RequestorPage/store/utils.ts index 81ab4e6a1..082135d9d 100644 --- a/studio/src/pages/RequestorPage/store/utils.ts +++ b/studio/src/pages/RequestorPage/store/utils.ts @@ -1,8 +1,3 @@ -import { z } from "zod"; -import { - type KeyValueParameter, - enforceTerminalDraftParameter, -} from "../KeyValueForm"; import type { findMatchedRoute } from "../routes"; import type { ProbedRoute } from "../types"; import { type RequestMethod, type RequestType, isWsRequest } from "../types"; @@ -56,101 +51,6 @@ export function probedRouteToInputMethod(route: ProbedRoute): RequestMethod { } } -export function filterDisabledEmptyQueryParams( - currentQueryParams: KeyValueParameter[], -) { - return enforceTerminalDraftParameter( - currentQueryParams.filter((param) => param.enabled || !!param.value), - ); -} - -export function extractQueryParamsFromOpenApiDefinition( - currentQueryParams: KeyValueParameter[], - route: ProbedRoute, -) { - if (!route.openApiSpec) { - return enforceTerminalDraftParameter(currentQueryParams); - } - - const parsedSpec = safeParseOpenApiSpec(route.openApiSpec, route.path); - if (!parsedSpec) { - return enforceTerminalDraftParameter(currentQueryParams); - } - - // Extract query parameters from OpenAPI spec - const specQueryParams = - parsedSpec.parameters?.filter((param) => param.in === "query") ?? []; - - // Convert OpenAPI params to KeyValueParameter format - const openApiQueryParams: KeyValueParameter[] = specQueryParams.map( - (param) => ({ - id: param.name, - key: param.name, - value: param.schema.example?.toString() ?? "", - enabled: param.required, - }), - ); - - // Merge with existing parameters, preferring existing values - const mergedParams = openApiQueryParams.map((openApiParam) => { - const existingParam = currentQueryParams.find( - (p) => p.key === openApiParam.key, - ); - return existingParam ?? openApiParam; - }); - - // Add any existing parameters that weren't in the OpenAPI spec - const additionalParams = currentQueryParams.filter( - (param) => !openApiQueryParams.some((p) => p.key === param.key), - ); - - return enforceTerminalDraftParameter([...mergedParams, ...additionalParams]); -} - -const OpenApiParameterSchema = z.object({ - parameters: z - .array( - z.object({ - schema: z.object({ - type: z.string(), - example: z.any().optional(), - }), - required: z.boolean(), - name: z.string(), - in: z.enum(["path", "query", "header", "cookie"]), - }), - ) - .optional(), - requestBody: z - .object({ - content: z.object({ - "application/json": z.object({ - schema: z.object({ - $ref: z.string().startsWith("#/components/schemas/"), - }), - }), - }), - }) - .optional(), - responses: z.record(z.string(), z.any()).optional(), // We don't validate responses structure since we only care about parameters -}); - -function safeParseOpenApiSpec(openApiSpec: string, routePath: string) { - try { - const spec = JSON.parse(openApiSpec); - const parsedSpec = OpenApiParameterSchema.safeParse(spec); - if (!parsedSpec.success) { - console.warn( - `Data in openApiSpec for ${routePath} was not in expected format. Here is the error: ${parsedSpec.error?.format?.()}`, - ); - return null; - } - return parsedSpec.data; - } catch { - return null; - } -} - /** * Extracts path parameters from a path * From a0dadfe10348eebd8cb72f9932ffabfad1ff53c4 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 20 Nov 2024 15:16:41 +0100 Subject: [PATCH 12/67] Comment out automagical bodies --- .../src/pages/RequestorPage/store/slices/routesSlice.ts | 8 ++++++-- studio/src/pages/RequestorPage/store/utils-openapi.ts | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/studio/src/pages/RequestorPage/store/slices/routesSlice.ts b/studio/src/pages/RequestorPage/store/slices/routesSlice.ts index 18343f65f..255b5553b 100644 --- a/studio/src/pages/RequestorPage/store/slices/routesSlice.ts +++ b/studio/src/pages/RequestorPage/store/slices/routesSlice.ts @@ -15,7 +15,7 @@ import { removeBaseUrl, } from "../utils"; import { - extractJsonBodyFromOpenApiDefinition, + // extractJsonBodyFromOpenApiDefinition, extractQueryParamsFromOpenApiDefinition, filterDisabledEmptyQueryParams, } from "../utils-openapi"; @@ -71,7 +71,11 @@ export const routesSlice: StateCreator< route, ); - state.body = extractJsonBodyFromOpenApiDefinition(state.body, route); + // TODO - Instead of automatically setting body here, + // have a button? Idk. + // All I know is it'd take some bookkeeping to do "automagical bodies" elegantly + // + // state.body = extractJsonBodyFromOpenApiDefinition(state.body, route); // Update tabs (you might want to move this logic to a separate slice) state.visibleRequestsPanelTabs = getVisibleRequestPanelTabs({ diff --git a/studio/src/pages/RequestorPage/store/utils-openapi.ts b/studio/src/pages/RequestorPage/store/utils-openapi.ts index 0b1248dc4..963a57dfc 100644 --- a/studio/src/pages/RequestorPage/store/utils-openapi.ts +++ b/studio/src/pages/RequestorPage/store/utils-openapi.ts @@ -175,9 +175,9 @@ export function extractJsonBodyFromOpenApiDefinition( } } -// biome-ignore lint/suspicious/noExplicitAny: function generateSampleFromSchema( schema: z.infer, + // biome-ignore lint/suspicious/noExplicitAny: ): any { if (schema.example !== undefined) { return schema.example; From c24788a724910106c0af4238a09a24c2e1d55337 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 20 Nov 2024 15:43:18 +0100 Subject: [PATCH 13/67] Add triple dot on hover --- .../NavigationPanel/RoutesPanel/RoutesItem.tsx | 6 ++++++ .../NavigationPanel/RoutesPanel/RoutesPanel.tsx | 15 ++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/studio/src/pages/RequestorPage/NavigationPanel/RoutesPanel/RoutesItem.tsx b/studio/src/pages/RequestorPage/NavigationPanel/RoutesPanel/RoutesItem.tsx index 3542fea15..3e787e89e 100644 --- a/studio/src/pages/RequestorPage/NavigationPanel/RoutesPanel/RoutesItem.tsx +++ b/studio/src/pages/RequestorPage/NavigationPanel/RoutesPanel/RoutesItem.tsx @@ -11,6 +11,8 @@ type RoutesItemProps = { selectedRoute: ProbedRoute | null; handleRouteClick: (route: ProbedRoute) => void; setSelectedRouteIndex: (index: number | null) => void; + routeActions?: React.ReactNode; + className?: string; }; export const RoutesItem = memo(function RoutesItem(props: RoutesItemProps) { @@ -21,6 +23,8 @@ export const RoutesItem = memo(function RoutesItem(props: RoutesItemProps) { selectedRoute, handleRouteClick, setSelectedRouteIndex, + routeActions, + className, } = props; const { mutate: deleteRoute } = useDeleteRoute(); const canDeleteRoute = @@ -59,6 +63,7 @@ export const RoutesItem = memo(function RoutesItem(props: RoutesItemProps) { "focus:ring-inset focus:ring-1 focus:ring-blue-500 focus:ring-opacity-25 bg-muted": isSelected, }, + className, )} id={`route-${index}`} > @@ -70,6 +75,7 @@ export const RoutesItem = memo(function RoutesItem(props: RoutesItemProps) { {route.path} + {routeActions} {canDeleteRoute && (
+ { + e.stopPropagation(); + window.alert("TODO"); + // TODO + }} + /> +
+ } /> ))} From 24699adbec5d8573a6b303c2769f5391ed8c9c48 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 20 Nov 2024 15:46:53 +0100 Subject: [PATCH 14/67] Revert "Add triple dot on hover" This reverts commit c24788a724910106c0af4238a09a24c2e1d55337. --- .../NavigationPanel/RoutesPanel/RoutesItem.tsx | 6 ------ .../NavigationPanel/RoutesPanel/RoutesPanel.tsx | 15 +-------------- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/studio/src/pages/RequestorPage/NavigationPanel/RoutesPanel/RoutesItem.tsx b/studio/src/pages/RequestorPage/NavigationPanel/RoutesPanel/RoutesItem.tsx index 3e787e89e..3542fea15 100644 --- a/studio/src/pages/RequestorPage/NavigationPanel/RoutesPanel/RoutesItem.tsx +++ b/studio/src/pages/RequestorPage/NavigationPanel/RoutesPanel/RoutesItem.tsx @@ -11,8 +11,6 @@ type RoutesItemProps = { selectedRoute: ProbedRoute | null; handleRouteClick: (route: ProbedRoute) => void; setSelectedRouteIndex: (index: number | null) => void; - routeActions?: React.ReactNode; - className?: string; }; export const RoutesItem = memo(function RoutesItem(props: RoutesItemProps) { @@ -23,8 +21,6 @@ export const RoutesItem = memo(function RoutesItem(props: RoutesItemProps) { selectedRoute, handleRouteClick, setSelectedRouteIndex, - routeActions, - className, } = props; const { mutate: deleteRoute } = useDeleteRoute(); const canDeleteRoute = @@ -63,7 +59,6 @@ export const RoutesItem = memo(function RoutesItem(props: RoutesItemProps) { "focus:ring-inset focus:ring-1 focus:ring-blue-500 focus:ring-opacity-25 bg-muted": isSelected, }, - className, )} id={`route-${index}`} > @@ -75,7 +70,6 @@ export const RoutesItem = memo(function RoutesItem(props: RoutesItemProps) { {route.path} - {routeActions} {canDeleteRoute && (
- { - e.stopPropagation(); - window.alert("TODO"); - // TODO - }} - /> -
- } /> ))} From 680c982a2b0d2cf0b467271d474901544e906d19 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Thu, 21 Nov 2024 08:25:33 +0100 Subject: [PATCH 15/67] Write documentation for openapi dereferencing code --- api/src/lib/openapi/dereference.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/api/src/lib/openapi/dereference.ts b/api/src/lib/openapi/dereference.ts index caddc03d7..e09a7daa7 100644 --- a/api/src/lib/openapi/dereference.ts +++ b/api/src/lib/openapi/dereference.ts @@ -14,6 +14,17 @@ export class MissingReferenceError extends Error { } } +/** + * Resolves a reference string to its corresponding object in the OpenAPI components. + * + * @param ref - The reference string to resolve (e.g., "#/components/schemas/Pet") + * @param components - The OpenAPI components object containing all definitions + * @param refStack - Set to track reference paths for circular reference detection + * @param cache - Map to cache resolved references for better performance + * @returns The resolved object from the components + * @throws {CircularReferenceError} When a circular reference is detected + * @throws {MissingReferenceError} When the referenced object cannot be found + */ export function resolveRef( ref: string, components: OpenAPIComponents, @@ -33,6 +44,7 @@ export function resolveRef( refStack.add(ref); const path = ref.replace("#/components/", "").split("/"); + // TODO - The `unknown` type is to avoid writing our own types for openapi schemas for now const resolved = path.reduce>( (acc, part) => { if (!acc || typeof acc !== "object") { @@ -55,6 +67,17 @@ export function resolveRef( return resolved; } +/** + * Dereferences all $ref properties in an OpenAPI schema object, replacing them with their actual values. + * Handles nested objects and arrays recursively. + * + * @param obj - The object to dereference, as of writing this is an OpenAPI operation + * @param components - The OpenAPI components object containing all definitions + * @param refStack - Set to track reference paths for circular reference detection + * @param cache - Map to cache resolved references for better performance + * @returns A new object with all references resolved + * @template T - The type of the input object + */ export function dereferenceSchema( obj: T, components: OpenAPIComponents, From 77b671627b50cac16a9fc352e7106011afcaa5cc Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Thu, 21 Nov 2024 08:29:27 +0100 Subject: [PATCH 16/67] Specify in UI that we only support JSON specs --- studio/src/pages/SettingsPage/OpenAPISettingsForm.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/studio/src/pages/SettingsPage/OpenAPISettingsForm.tsx b/studio/src/pages/SettingsPage/OpenAPISettingsForm.tsx index b0b2bb99a..a135a4f19 100644 --- a/studio/src/pages/SettingsPage/OpenAPISettingsForm.tsx +++ b/studio/src/pages/SettingsPage/OpenAPISettingsForm.tsx @@ -47,8 +47,7 @@ export function OpenAPISettingsForm({ settings }: OpenAPISettingsFormProps) {
OpenAPI Specification URL - Enter the URL of your OpenAPI specification file (JSON or - YAML format). + Enter the URL of your JSON OpenAPI specification file.
@@ -73,9 +72,8 @@ export function OpenAPISettingsForm({ settings }: OpenAPISettingsFormProps) { About OpenAPI Integration
- The OpenAPI specification will be used to enhance request - validation and provide better suggestions for request - parameters. + The OpenAPI specification will be used to provide better + suggestions for request parameters in the UI.
From ff791fe0bfc504b9d1b277bb6805b4e62d54b5ed Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Thu, 21 Nov 2024 08:41:31 +0100 Subject: [PATCH 17/67] Add error handler to the fetch to the openapi spec --- api/src/lib/openapi/fetch.ts | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/api/src/lib/openapi/fetch.ts b/api/src/lib/openapi/fetch.ts index 981b230fd..93f40c27d 100644 --- a/api/src/lib/openapi/fetch.ts +++ b/api/src/lib/openapi/fetch.ts @@ -16,15 +16,27 @@ export async function fetchOpenApiSpec(db: LibSQLDatabase) { logger.debug("No resolved OpenAPI spec URL found"); return null; } - logger.debug(`Fetching OpenAPI spec from ${resolvedSpecUrl}`); - const response = await fetch(resolvedSpecUrl, { - headers: { - // NOTE - This is to avoid infinite loops when the OpenAPI spec is fetched from Studio - // We need to make sure that the user has instrumented their app with @fiberplane/hono-otel >= 0.4.1 - "x-fpx-ignore": "true", - }, - }); - return response.json() as Promise; + try { + const response = await fetch(resolvedSpecUrl, { + headers: { + // NOTE - This is to avoid infinite loops when the OpenAPI spec is fetched from Studio + // We need to make sure that the user has instrumented their app with @fiberplane/hono-otel >= 0.4.1 + "x-fpx-ignore": "true", + }, + }); + if (!response.ok) { + logger.error( + `Error fetching OpenAPI spec from ${resolvedSpecUrl}: ${response.statusText}`, + ); + return null; + } + return response.json() as Promise; + } catch (error) { + logger.error( + `Error making fetch to OpenAPI spec at ${resolvedSpecUrl}: ${error}`, + ); + return null; + } } /** From e6014ad7c478b6ec0e71f74a2968a0ca8806cc11 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Thu, 21 Nov 2024 08:54:41 +0100 Subject: [PATCH 18/67] Add tests for opneapi module --- api/src/lib/openapi/dereference.test.ts | 145 ++++++++++++++++++++++++ api/src/lib/openapi/dereference.ts | 6 + api/src/lib/openapi/fetch.test.ts | 121 ++++++++++++++++++++ api/src/lib/openapi/fetch.ts | 26 ++++- 4 files changed, 294 insertions(+), 4 deletions(-) create mode 100644 api/src/lib/openapi/dereference.test.ts create mode 100644 api/src/lib/openapi/fetch.test.ts diff --git a/api/src/lib/openapi/dereference.test.ts b/api/src/lib/openapi/dereference.test.ts new file mode 100644 index 000000000..e43ca8d13 --- /dev/null +++ b/api/src/lib/openapi/dereference.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { + CircularReferenceError, + MissingReferenceError, + dereferenceSchema, + resolveRef, +} from "./dereference.js"; + +describe("resolveRef", () => { + const mockComponents = { + schemas: { + Pet: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + }, + Error: { + type: "object", + properties: { + code: { type: "integer" }, + message: { type: "string" }, + }, + }, + }, + }; + + it("should resolve a simple reference", () => { + const result = resolveRef("#/components/schemas/Pet", mockComponents); + expect(result).toEqual(mockComponents.schemas.Pet); + }); + + it("should use cache for repeated references", () => { + const cache = new Map(); + const ref = "#/components/schemas/Pet"; + + const result1 = resolveRef(ref, mockComponents, new Set(), cache); + const result2 = resolveRef(ref, mockComponents, new Set(), cache); + + expect(result1).toEqual(result2); + expect(cache.get(ref)).toBeDefined(); + }); + + it("should throw MissingReferenceError for non-existent reference", () => { + expect(() => + resolveRef("#/components/schemas/NonExistent", mockComponents), + ).toThrow(MissingReferenceError); + }); + + it("should throw CircularReferenceError for circular references", () => { + const componentsWithCircular = { + schemas: { + A: { $ref: "#/components/schemas/B" }, + B: { $ref: "#/components/schemas/A" }, + }, + }; + + expect(() => + resolveRef("#/components/schemas/A", componentsWithCircular), + ).toThrow(CircularReferenceError); + }); +}); + +describe("dereferenceSchema", () => { + const mockComponents = { + schemas: { + Pet: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + Error: { + type: "object", + properties: { + code: { type: "integer" }, + }, + }, + }, + }; + + it("should dereference a simple schema", () => { + const input = { + type: "object", + properties: { + pet: { $ref: "#/components/schemas/Pet" }, + }, + }; + + const expected = { + type: "object", + properties: { + pet: mockComponents.schemas.Pet, + }, + }; + + const result = dereferenceSchema(input, mockComponents); + expect(result).toEqual(expected); + }); + + it("should handle nested references", () => { + const input = { + type: "object", + properties: { + pet: { $ref: "#/components/schemas/Pet" }, + error: { $ref: "#/components/schemas/Error" }, + }, + }; + + const result = dereferenceSchema(input, mockComponents); + expect(result.properties.pet).toEqual(mockComponents.schemas.Pet); + expect(result.properties.error).toEqual(mockComponents.schemas.Error); + }); + + it("should handle arrays of references", () => { + const input = { + type: "array", + items: [ + { $ref: "#/components/schemas/Pet" }, + { $ref: "#/components/schemas/Error" }, + ], + }; + + const result = dereferenceSchema(input, mockComponents); + expect(result.items[0]).toEqual(mockComponents.schemas.Pet); + expect(result.items[1]).toEqual(mockComponents.schemas.Error); + }); + + it("should return primitive values as-is", () => { + const input = { + type: "string", + format: "email", + }; + + const result = dereferenceSchema(input, mockComponents); + expect(result).toEqual(input); + }); + + it("should handle empty objects", () => { + const input = {}; + const result = dereferenceSchema(input, mockComponents); + expect(result).toEqual({}); + }); +}); diff --git a/api/src/lib/openapi/dereference.ts b/api/src/lib/openapi/dereference.ts index e09a7daa7..1166dde81 100644 --- a/api/src/lib/openapi/dereference.ts +++ b/api/src/lib/openapi/dereference.ts @@ -60,6 +60,12 @@ export function resolveRef( throw new MissingReferenceError(ref); } + // If the resolved value contains another reference, resolve it + if (typeof resolved === "object" && resolved !== null && "$ref" in resolved) { + const nestedRef = resolved.$ref as string; + return resolveRef(nestedRef, components, refStack, cache); + } + // Cache the result cache.set(ref, resolved); refStack.delete(ref); diff --git a/api/src/lib/openapi/fetch.test.ts b/api/src/lib/openapi/fetch.test.ts new file mode 100644 index 000000000..4faeb10bf --- /dev/null +++ b/api/src/lib/openapi/fetch.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import logger from "../../logger/index.js"; +import { getAllSettings } from "../settings/index.js"; +import { fetchOpenApiSpec } from "./fetch.js"; + +// biome-ignore lint/suspicious/noExplicitAny: it's for the test +type Any = any; + +// Mock the logger +vi.mock("../../logger/index.js", () => ({ + default: { + debug: vi.fn(), + error: vi.fn(), + }, +})); + +// Mock the settings module +vi.mock("../settings/index.js", () => ({ + getAllSettings: vi.fn(), +})); + +// Mock global fetch +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe("fetchOpenApiSpec", () => { + const mockDb = { + // Mock minimal DB interface needed for tests + } as Any; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.FPX_SERVICE_TARGET = undefined; + // Reset the mock implementation + (getAllSettings as Any).mockReset(); + }); + + it("should return null when no spec URL is configured", async () => { + // Mock getAllSettings to return no URL + (getAllSettings as Any).mockResolvedValue({ openApiSpecUrl: undefined }); + + const result = await fetchOpenApiSpec(mockDb); + expect(result).toBeNull(); + expect(logger.debug).toHaveBeenCalledWith("No OpenAPI spec URL found"); + }); + + it("should fetch and return OpenAPI spec from absolute URL", async () => { + const mockSpec = { openapi: "3.0.0", paths: {} }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockSpec), + }); + + // Mock getAllSettings to return an absolute URL + (getAllSettings as Any).mockResolvedValue({ + openApiSpecUrl: "https://api.example.com/openapi.json", + }); + + const result = await fetchOpenApiSpec(mockDb); + expect(result).toEqual(mockSpec); + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/openapi.json", + { + headers: { "x-fpx-ignore": "true" }, + }, + ); + }); + + it("should handle relative URLs with FPX_SERVICE_TARGET", async () => { + const mockSpec = { openapi: "3.0.0", paths: {} }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockSpec), + }); + + process.env.FPX_SERVICE_TARGET = "http://localhost:3000"; + + // Mock getAllSettings to return a relative URL + (getAllSettings as Any).mockResolvedValue({ + openApiSpecUrl: "/api-docs/openapi.json", + }); + + const result = await fetchOpenApiSpec(mockDb); + expect(result).toEqual(mockSpec); + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:3000/api-docs/openapi.json", + { + headers: { "x-fpx-ignore": "true" }, + }, + ); + }); + + it("should return null when fetch fails", async () => { + mockFetch.mockRejectedValueOnce(new Error("Network error")); + + // Mock getAllSettings to return a URL + (getAllSettings as Any).mockResolvedValue({ + openApiSpecUrl: "https://api.example.com/openapi.json", + }); + + const result = await fetchOpenApiSpec(mockDb); + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalled(); + }); + + it("should return null when response is not ok", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + statusText: "Not Found", + }); + + // Mock getAllSettings to return a URL + (getAllSettings as Any).mockResolvedValue({ + openApiSpecUrl: "https://api.example.com/openapi.json", + }); + + const result = await fetchOpenApiSpec(mockDb); + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalled(); + }); +}); diff --git a/api/src/lib/openapi/fetch.ts b/api/src/lib/openapi/fetch.ts index 93f40c27d..f43034689 100644 --- a/api/src/lib/openapi/fetch.ts +++ b/api/src/lib/openapi/fetch.ts @@ -5,6 +5,19 @@ import { resolveServiceArg } from "../../probe-routes.js"; import { getAllSettings } from "../settings/index.js"; import type { OpenApiSpec } from "./types.js"; +/** + * Fetches and parses an OpenAPI specification from a configured URL. + * + * @NOTE This function does not validate the payload returned by the OpenAPI spec URL, + * it only makes a type assertion. + * + * @param db - The database instance to retrieve settings from + * @returns Promise that resolves to the parsed OpenAPI specification object, or null if: + * - No spec URL is configured + * - The URL cannot be resolved + * - The fetch request fails + * - The response cannot be parsed as JSON + */ export async function fetchOpenApiSpec(db: LibSQLDatabase) { const specUrl = await getSpecUrl(db); if (!specUrl) { @@ -40,7 +53,10 @@ export async function fetchOpenApiSpec(db: LibSQLDatabase) { } /** - * Get the OpenAPI spec URL from the settings record in the database. + * Retrieves the OpenAPI specification URL from the application settings stored in the database. + * + * @param db - The database instance to query settings from + * @returns Promise that resolves to the configured OpenAPI spec URL, or undefined if not set */ async function getSpecUrl(db: LibSQLDatabase) { const settingsRecord = await getAllSettings(db); @@ -48,10 +64,12 @@ async function getSpecUrl(db: LibSQLDatabase) { } /** - * Resolve the OpenAPI spec URL to an absolute URL. + * Resolves a potentially relative OpenAPI specification URL to an absolute URL. + * If the input URL is relative, it will be resolved against the service URL + * obtained from the FPX_SERVICE_TARGET environment variable. * - * @param specUrl - The spec URL to resolve. - * @returns The resolved spec URL or null if the spec URL is not provided. + * @param specUrl - The OpenAPI specification URL to resolve (can be absolute or relative) + * @returns The resolved absolute URL, or null if the input URL is empty or invalid */ function resolveSpecUrl(specUrl: string) { if (!specUrl) { From 5071b9dc5baba374208ee1faa98ce501e31c75e4 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Thu, 21 Nov 2024 09:09:39 +0100 Subject: [PATCH 19/67] Add some basic caching to avoid infinite loops when fetching openapi spec from app that does not have hono-otel 0.4.1 --- api/src/lib/openapi/fetch.test.ts | 11 ++-- api/src/lib/openapi/fetch.ts | 99 ++++++++++++++++++++++++------- 2 files changed, 82 insertions(+), 28 deletions(-) diff --git a/api/src/lib/openapi/fetch.test.ts b/api/src/lib/openapi/fetch.test.ts index 4faeb10bf..615000166 100644 --- a/api/src/lib/openapi/fetch.test.ts +++ b/api/src/lib/openapi/fetch.test.ts @@ -39,9 +39,8 @@ describe("fetchOpenApiSpec", () => { // Mock getAllSettings to return no URL (getAllSettings as Any).mockResolvedValue({ openApiSpecUrl: undefined }); - const result = await fetchOpenApiSpec(mockDb); + const result = await fetchOpenApiSpec(mockDb, 0); expect(result).toBeNull(); - expect(logger.debug).toHaveBeenCalledWith("No OpenAPI spec URL found"); }); it("should fetch and return OpenAPI spec from absolute URL", async () => { @@ -56,7 +55,7 @@ describe("fetchOpenApiSpec", () => { openApiSpecUrl: "https://api.example.com/openapi.json", }); - const result = await fetchOpenApiSpec(mockDb); + const result = await fetchOpenApiSpec(mockDb, 0); expect(result).toEqual(mockSpec); expect(mockFetch).toHaveBeenCalledWith( "https://api.example.com/openapi.json", @@ -80,7 +79,7 @@ describe("fetchOpenApiSpec", () => { openApiSpecUrl: "/api-docs/openapi.json", }); - const result = await fetchOpenApiSpec(mockDb); + const result = await fetchOpenApiSpec(mockDb, 0); expect(result).toEqual(mockSpec); expect(mockFetch).toHaveBeenCalledWith( "http://localhost:3000/api-docs/openapi.json", @@ -98,7 +97,7 @@ describe("fetchOpenApiSpec", () => { openApiSpecUrl: "https://api.example.com/openapi.json", }); - const result = await fetchOpenApiSpec(mockDb); + const result = await fetchOpenApiSpec(mockDb, 0); expect(result).toBeNull(); expect(logger.error).toHaveBeenCalled(); }); @@ -114,7 +113,7 @@ describe("fetchOpenApiSpec", () => { openApiSpecUrl: "https://api.example.com/openapi.json", }); - const result = await fetchOpenApiSpec(mockDb); + const result = await fetchOpenApiSpec(mockDb, 0); expect(result).toBeNull(); expect(logger.error).toHaveBeenCalled(); }); diff --git a/api/src/lib/openapi/fetch.ts b/api/src/lib/openapi/fetch.ts index f43034689..88ef9aa04 100644 --- a/api/src/lib/openapi/fetch.ts +++ b/api/src/lib/openapi/fetch.ts @@ -5,6 +5,64 @@ import { resolveServiceArg } from "../../probe-routes.js"; import { getAllSettings } from "../settings/index.js"; import type { OpenApiSpec } from "./types.js"; +type CachedResponse = { + data: OpenApiSpec; + timestamp: number; +}; + +// HACK - We need to cache the OpenAPI spec fetch to avoid infinite loops +// when the OpenAPI spec is fetched from Studio. +// +// If the user has a version of @fiberplane/hono-otel >= 0.4.1, +// then we can remove this cache. +const specResponseCache = new Map(); +const CACHE_TTL_MS = 3000; // 3 seconds + +/** + * Fetches an OpenAPI specification from a URL with caching support. + * + * @param url - The URL to fetch the OpenAPI specification from + * @param options - Fetch options with an optional TTL override + * @param options.ttl - Cache time-to-live in milliseconds (defaults to CACHE_TTL_MS) + * @returns Promise that resolves to the parsed OpenAPI specification, or null if: + * - The fetch request fails + * - The response status is not OK + * - The response cannot be parsed as JSON + */ +async function cachedSpecFetch( + url: string, + options: RequestInit & { ttl?: number } = {}, +): Promise { + const { ttl = CACHE_TTL_MS, ...fetchOptions } = options; + + // Check cache + const cached = specResponseCache.get(url); + if (cached && Date.now() - cached.timestamp < ttl) { + logger.debug(`Returning cached response for ${url}`); + return cached.data; + } + + try { + const response = await fetch(url, fetchOptions); + if (!response.ok) { + logger.error(`Error fetching from ${url}: ${response.statusText}`); + return null; + } + const data = (await response.json()) as OpenApiSpec; + + // Update cache + specResponseCache.set(url, { + data, + timestamp: Date.now(), + }); + + return data; + } catch (error) { + logger.error(`Error making fetch to ${url}: ${error}`); + return null; + } +} + /** * Fetches and parses an OpenAPI specification from a configured URL. * @@ -18,7 +76,10 @@ import type { OpenApiSpec } from "./types.js"; * - The fetch request fails * - The response cannot be parsed as JSON */ -export async function fetchOpenApiSpec(db: LibSQLDatabase) { +export async function fetchOpenApiSpec( + db: LibSQLDatabase, + responseTtlMs?: number, +): Promise { const specUrl = await getSpecUrl(db); if (!specUrl) { logger.debug("No OpenAPI spec URL found"); @@ -29,27 +90,21 @@ export async function fetchOpenApiSpec(db: LibSQLDatabase) { logger.debug("No resolved OpenAPI spec URL found"); return null; } - try { - const response = await fetch(resolvedSpecUrl, { - headers: { - // NOTE - This is to avoid infinite loops when the OpenAPI spec is fetched from Studio - // We need to make sure that the user has instrumented their app with @fiberplane/hono-otel >= 0.4.1 - "x-fpx-ignore": "true", - }, - }); - if (!response.ok) { - logger.error( - `Error fetching OpenAPI spec from ${resolvedSpecUrl}: ${response.statusText}`, - ); - return null; - } - return response.json() as Promise; - } catch (error) { - logger.error( - `Error making fetch to OpenAPI spec at ${resolvedSpecUrl}: ${error}`, - ); - return null; - } + + // NOTE - This is to avoid infinite loops when the OpenAPI spec is fetched from Studio + // I.e., it's possible that the OpenAPI spec is fetched from the target service, + // in which case, making a request to the target service from Studio will result in the app reporting its routes to Studio, + // which will then re-trigger a fetch of the OpenAPI spec from Studio, and so on. + // + // If we want to rely on `x-fpx-ignore`, then we need to make sure that the user has instrumented their app with @fiberplane/hono-otel >= 0.4.1 + // Since we don't have a way to check the version of @fiberplane/hono-otel in the app yet, we're going with the hacky cache for now. + // + return cachedSpecFetch(resolvedSpecUrl, { + headers: { + "x-fpx-ignore": "true", + }, + ttl: responseTtlMs, + }); } /** From 0b10f0b261483fb4bfa9c31c75e0dd9040a39c65 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Thu, 21 Nov 2024 09:27:34 +0100 Subject: [PATCH 20/67] Remove comments --- api/src/lib/openapi/types.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/src/lib/openapi/types.ts b/api/src/lib/openapi/types.ts index be88a3d88..1bedc8b1b 100644 --- a/api/src/lib/openapi/types.ts +++ b/api/src/lib/openapi/types.ts @@ -9,7 +9,6 @@ export type OpenApiSpec = { components?: OpenAPIComponents; }; -// Define types for OpenAPI operation objects type OpenAPIParameter = { name: string; in: "query" | "header" | "path" | "cookie"; @@ -62,7 +61,6 @@ export type OpenAPISchema = { properties?: Record; items?: OpenAPISchema; $ref?: string; - // Add other schema properties as needed }; export type OpenAPIRequestBody = { From 80c1b1074fe8a52897715ba772c33a498be87a91 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Thu, 21 Nov 2024 09:43:29 +0100 Subject: [PATCH 21/67] Add small changelog entry --- www/src/content/changelog/!canary.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/www/src/content/changelog/!canary.mdx b/www/src/content/changelog/!canary.mdx index 9ba5d00cd..a84958790 100644 --- a/www/src/content/changelog/!canary.mdx +++ b/www/src/content/changelog/!canary.mdx @@ -6,5 +6,6 @@ draft: true ### Features * Improved code analysis. Through the new @fiberplane/source-analysis package a more flexbile and thorough source code implementation is included +* OpenAPI integration. You can now fetch the OpenAPI spec for your api, and Studio will use it to map spec definitions to your api routes. Expected query parameters, headers, and body fields are then surfaced in Studio. ### Bug fixes From 7e598905df9306bdb134574d383ebbf0cf154960 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Thu, 21 Nov 2024 14:17:12 +0100 Subject: [PATCH 22/67] Fix logic that adds openapi specs to routes (fallback to routes) --- api/src/lib/openapi/openapi.ts | 99 +++++++++++++++++++++------------- 1 file changed, 63 insertions(+), 36 deletions(-) diff --git a/api/src/lib/openapi/openapi.ts b/api/src/lib/openapi/openapi.ts index 238bb0e38..8fca39dfd 100644 --- a/api/src/lib/openapi/openapi.ts +++ b/api/src/lib/openapi/openapi.ts @@ -14,53 +14,80 @@ import type { OpenAPIOperation } from "./types.js"; type Routes = z.infer["routes"]; +/** + * Enriches API routes with their corresponding OpenAPI specifications by fetching and mapping + * OpenAPI definitions to Hono routes. This function handles both single routes and arrays of routes. + * + * @param db - LibSQL database instance containing the OpenAPI specifications. Used to fetch the latest spec. + * @param routes - Single route or array of routes to be enriched. Each route should contain path, method, + * and handlerType properties for proper matching with OpenAPI specs. + * + * @returns Array of enriched routes. Each route will contain all original properties plus an + * `openApiSpec` property that is either: + * - A stringified OpenAPI operation object if a match is found and dereferencing succeeds + * - null if no matching OpenAPI spec exists or if dereferencing fails + * If any error occurs during enrichment, returns the original routes unchanged. + * + * @example + * const routes = [ + * { path: "/api/users", method: "get", handlerType: "route" } + * ]; + * const enrichedRoutes = await addOpenApiSpecToRoutes(db, routes); + * // enrichedRoutes[0].openApiSpec will contain the stringified OpenAPI operation or null + */ export async function addOpenApiSpecToRoutes( db: LibSQLDatabase, routes: Routes, -) { - const spec = await fetchOpenApiSpec(db); +): Promise { + const spec = await fetchOpenApiSpec(db, 0); if (!spec) { - logger.debug("No OpenAPI spec found"); - return []; + return routes; } const openApiRoutes = mapOpenApiToHonoRoutes(spec); const appRoutes = Array.isArray(routes) ? routes : [routes]; - return appRoutes.map((route) => { - const openApiRoute = openApiRoutes.find( - (r) => - route.handlerType === "route" && - r.honoPath === route.path && - r.method === route.method, - ); + try { + const enrichedRoutes = appRoutes.map((route) => { + const openApiRoute = openApiRoutes.find( + (r) => + route.handlerType === "route" && + r.honoPath === route.path && + r.method === route.method, + ); - let operation = openApiRoute?.operation; - if (operation) { - try { - operation = dereferenceSchema( - operation, - spec.components ?? {}, - new Set(), - new Map(), - ); - } catch (error) { - if ( - error instanceof CircularReferenceError || - error instanceof MissingReferenceError - ) { - logger.warn(`Failed to dereference OpenAPI spec: ${error.message}`); - operation = undefined; - } else { - throw error; + let operation = openApiRoute?.operation; + if (operation) { + try { + operation = dereferenceSchema( + operation, + spec.components ?? {}, + new Set(), + new Map(), + ); + } catch (error) { + if ( + error instanceof CircularReferenceError || + error instanceof MissingReferenceError + ) { + logger.warn(`Failed to dereference OpenAPI spec: ${error.message}`); + operation = undefined; + } else { + throw error; + } } } - } - const result = { - ...route, - openApiSpec: operation ? JSON.stringify(operation) : null, - }; + const result = { + ...route, + openApiSpec: operation ? JSON.stringify(operation) : null, + }; - return result; - }); + return result; + }); + + return enrichedRoutes; + } catch (error) { + logger.error(`Error enriching routes with OpenAPI spec: ${error}`); + return appRoutes; + } } From 3ee1a0db06b77ecca42f5b4994f6bb914dc5a846 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Thu, 21 Nov 2024 15:14:51 +0100 Subject: [PATCH 23/67] Fix openapi params that specified numeric path param --- api/src/lib/openapi/openapi.ts | 6 +- examples/openapi-zod/src/index.ts | 141 ++++-------------------------- 2 files changed, 19 insertions(+), 128 deletions(-) diff --git a/api/src/lib/openapi/openapi.ts b/api/src/lib/openapi/openapi.ts index 8fca39dfd..eeac06bb7 100644 --- a/api/src/lib/openapi/openapi.ts +++ b/api/src/lib/openapi/openapi.ts @@ -17,17 +17,17 @@ type Routes = z.infer["routes"]; /** * Enriches API routes with their corresponding OpenAPI specifications by fetching and mapping * OpenAPI definitions to Hono routes. This function handles both single routes and arrays of routes. - * + * * @param db - LibSQL database instance containing the OpenAPI specifications. Used to fetch the latest spec. * @param routes - Single route or array of routes to be enriched. Each route should contain path, method, * and handlerType properties for proper matching with OpenAPI specs. - * + * * @returns Array of enriched routes. Each route will contain all original properties plus an * `openApiSpec` property that is either: * - A stringified OpenAPI operation object if a match is found and dereferencing succeeds * - null if no matching OpenAPI spec exists or if dereferencing fails * If any error occurs during enrichment, returns the original routes unchanged. - * + * * @example * const routes = [ * { path: "/api/users", method: "get", handlerType: "route" } diff --git a/examples/openapi-zod/src/index.ts b/examples/openapi-zod/src/index.ts index cf2baf3bd..ae4758788 100644 --- a/examples/openapi-zod/src/index.ts +++ b/examples/openapi-zod/src/index.ts @@ -1,12 +1,8 @@ import { instrument } from "@fiberplane/hono-otel"; import { OpenAPIHono } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi"; -import { eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/d1"; import * as schema from "./db/schema"; -// TODO - Figure out how to use drizzle with "@hono/zod-openapi" -// -// import { UserSchema } from "./db/schema"; type Bindings = { DB: D1Database; @@ -15,35 +11,19 @@ type Bindings = { const app = new OpenAPIHono<{ Bindings: Bindings }>(); const ParamsSchema = z.object({ - id: z - .number() - // .min(1) - .openapi({ - param: { - name: "id", - in: "path", - }, - example: 1212121, - }), + id: z.string().openapi({ + param: { + name: "id", + in: "path", + }, + example: "1212121", + }), }); -const NewUserSchema = z - .object({ - name: z.string().openapi({ - example: "John Doe", - }), - age: z.number().openapi({ - example: 42, - }), - }) - .openapi("NewUser"); - -// TODO - Figure out how to extend the NewUserSchema object -// const UserSchema = z .object({ - id: z.number().openapi({ - example: 123, + id: z.string().openapi({ + example: "123", }), name: z.string().openapi({ example: "John Doe", @@ -54,7 +34,7 @@ const UserSchema = z }) .openapi("User"); -const getUserRoute = createRoute({ +const route = createRoute({ method: "get", path: "/users/{id}", request: { @@ -69,105 +49,16 @@ const getUserRoute = createRoute({ }, description: "Retrieve the user", }, - 400: { - content: { - "application/json": { - schema: z.object({ - error: z.string(), - }), - }, - }, - description: "Invalid ID", - }, }, }); -const listUsersRoute = createRoute({ - method: "get", - path: "/users", - request: { - query: z.object({ - name: z.string().optional(), - }), - }, - responses: { - 200: { - content: { - "application/json": { - schema: z.array(UserSchema), - }, - }, - description: "List all users", - }, - }, -}); - -const createUserRoute = createRoute({ - method: "post", - path: "/users", - request: { - body: { - content: { - "application/json": { - schema: NewUserSchema, - }, - }, - }, - }, - responses: { - 201: { - content: { - "application/json": { - schema: UserSchema, - }, - }, - description: "Retrieve the user", - }, - }, -}); - -app.openapi(getUserRoute, (c) => { +app.openapi(route, (c) => { const { id } = c.req.valid("param"); - - const idNumber = +id; - if (Number.isNaN(idNumber) || idNumber < 1) { - return c.json({ error: "Invalid ID" }, 400); - } - return c.json( - { - id: idNumber, - age: 20, - name: "Ultra-user", - }, - 200, - ); -}); - -app.openapi(listUsersRoute, async (c) => { - const { name } = c.req.valid("query"); - const db = drizzle(c.env.DB); - - // Only apply where clause if name is provided and not empty - const query = db.select().from(schema.users); - if (name && name.trim() !== "") { - query.where(eq(schema.users.name, name)); - } - - const result = await query; - return c.json(result, 200); -}); - -app.openapi(createUserRoute, async (c) => { - const { name, age } = c.req.valid("json"); - const db = drizzle(c.env.DB); - const [result] = await db - .insert(schema.users) - .values({ - name, - age, - }) - .returning(); - return c.json(result, 201); + return c.json({ + id, + age: 20, + name: "Ultra-user", + }); }); // The OpenAPI documentation will be available at /doc From b4bb7336b1ffec162238ef4e98aae17c50b3d2d7 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Thu, 21 Nov 2024 15:15:24 +0100 Subject: [PATCH 24/67] Revert "Fix openapi params that specified numeric path param" This reverts commit 3ee1a0db06b77ecca42f5b4994f6bb914dc5a846. --- api/src/lib/openapi/openapi.ts | 6 +- examples/openapi-zod/src/index.ts | 141 ++++++++++++++++++++++++++---- 2 files changed, 128 insertions(+), 19 deletions(-) diff --git a/api/src/lib/openapi/openapi.ts b/api/src/lib/openapi/openapi.ts index eeac06bb7..8fca39dfd 100644 --- a/api/src/lib/openapi/openapi.ts +++ b/api/src/lib/openapi/openapi.ts @@ -17,17 +17,17 @@ type Routes = z.infer["routes"]; /** * Enriches API routes with their corresponding OpenAPI specifications by fetching and mapping * OpenAPI definitions to Hono routes. This function handles both single routes and arrays of routes. - * + * * @param db - LibSQL database instance containing the OpenAPI specifications. Used to fetch the latest spec. * @param routes - Single route or array of routes to be enriched. Each route should contain path, method, * and handlerType properties for proper matching with OpenAPI specs. - * + * * @returns Array of enriched routes. Each route will contain all original properties plus an * `openApiSpec` property that is either: * - A stringified OpenAPI operation object if a match is found and dereferencing succeeds * - null if no matching OpenAPI spec exists or if dereferencing fails * If any error occurs during enrichment, returns the original routes unchanged. - * + * * @example * const routes = [ * { path: "/api/users", method: "get", handlerType: "route" } diff --git a/examples/openapi-zod/src/index.ts b/examples/openapi-zod/src/index.ts index ae4758788..cf2baf3bd 100644 --- a/examples/openapi-zod/src/index.ts +++ b/examples/openapi-zod/src/index.ts @@ -1,8 +1,12 @@ import { instrument } from "@fiberplane/hono-otel"; import { OpenAPIHono } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi"; +import { eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/d1"; import * as schema from "./db/schema"; +// TODO - Figure out how to use drizzle with "@hono/zod-openapi" +// +// import { UserSchema } from "./db/schema"; type Bindings = { DB: D1Database; @@ -11,19 +15,35 @@ type Bindings = { const app = new OpenAPIHono<{ Bindings: Bindings }>(); const ParamsSchema = z.object({ - id: z.string().openapi({ - param: { - name: "id", - in: "path", - }, - example: "1212121", - }), + id: z + .number() + // .min(1) + .openapi({ + param: { + name: "id", + in: "path", + }, + example: 1212121, + }), }); +const NewUserSchema = z + .object({ + name: z.string().openapi({ + example: "John Doe", + }), + age: z.number().openapi({ + example: 42, + }), + }) + .openapi("NewUser"); + +// TODO - Figure out how to extend the NewUserSchema object +// const UserSchema = z .object({ - id: z.string().openapi({ - example: "123", + id: z.number().openapi({ + example: 123, }), name: z.string().openapi({ example: "John Doe", @@ -34,7 +54,7 @@ const UserSchema = z }) .openapi("User"); -const route = createRoute({ +const getUserRoute = createRoute({ method: "get", path: "/users/{id}", request: { @@ -49,16 +69,105 @@ const route = createRoute({ }, description: "Retrieve the user", }, + 400: { + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + description: "Invalid ID", + }, }, }); -app.openapi(route, (c) => { +const listUsersRoute = createRoute({ + method: "get", + path: "/users", + request: { + query: z.object({ + name: z.string().optional(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.array(UserSchema), + }, + }, + description: "List all users", + }, + }, +}); + +const createUserRoute = createRoute({ + method: "post", + path: "/users", + request: { + body: { + content: { + "application/json": { + schema: NewUserSchema, + }, + }, + }, + }, + responses: { + 201: { + content: { + "application/json": { + schema: UserSchema, + }, + }, + description: "Retrieve the user", + }, + }, +}); + +app.openapi(getUserRoute, (c) => { const { id } = c.req.valid("param"); - return c.json({ - id, - age: 20, - name: "Ultra-user", - }); + + const idNumber = +id; + if (Number.isNaN(idNumber) || idNumber < 1) { + return c.json({ error: "Invalid ID" }, 400); + } + return c.json( + { + id: idNumber, + age: 20, + name: "Ultra-user", + }, + 200, + ); +}); + +app.openapi(listUsersRoute, async (c) => { + const { name } = c.req.valid("query"); + const db = drizzle(c.env.DB); + + // Only apply where clause if name is provided and not empty + const query = db.select().from(schema.users); + if (name && name.trim() !== "") { + query.where(eq(schema.users.name, name)); + } + + const result = await query; + return c.json(result, 200); +}); + +app.openapi(createUserRoute, async (c) => { + const { name, age } = c.req.valid("json"); + const db = drizzle(c.env.DB); + const [result] = await db + .insert(schema.users) + .values({ + name, + age, + }) + .returning(); + return c.json(result, 201); }); // The OpenAPI documentation will be available at /doc From 0836324e887e941cfa1258838973be107c7f3d22 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Thu, 21 Nov 2024 15:16:28 +0100 Subject: [PATCH 25/67] Fix openapi specification of params for user (example app) --- api/src/lib/openapi/openapi.ts | 6 +++--- examples/openapi-zod/src/index.ts | 17 +++++++---------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/api/src/lib/openapi/openapi.ts b/api/src/lib/openapi/openapi.ts index 8fca39dfd..eeac06bb7 100644 --- a/api/src/lib/openapi/openapi.ts +++ b/api/src/lib/openapi/openapi.ts @@ -17,17 +17,17 @@ type Routes = z.infer["routes"]; /** * Enriches API routes with their corresponding OpenAPI specifications by fetching and mapping * OpenAPI definitions to Hono routes. This function handles both single routes and arrays of routes. - * + * * @param db - LibSQL database instance containing the OpenAPI specifications. Used to fetch the latest spec. * @param routes - Single route or array of routes to be enriched. Each route should contain path, method, * and handlerType properties for proper matching with OpenAPI specs. - * + * * @returns Array of enriched routes. Each route will contain all original properties plus an * `openApiSpec` property that is either: * - A stringified OpenAPI operation object if a match is found and dereferencing succeeds * - null if no matching OpenAPI spec exists or if dereferencing fails * If any error occurs during enrichment, returns the original routes unchanged. - * + * * @example * const routes = [ * { path: "/api/users", method: "get", handlerType: "route" } diff --git a/examples/openapi-zod/src/index.ts b/examples/openapi-zod/src/index.ts index cf2baf3bd..12b242fd0 100644 --- a/examples/openapi-zod/src/index.ts +++ b/examples/openapi-zod/src/index.ts @@ -15,16 +15,13 @@ type Bindings = { const app = new OpenAPIHono<{ Bindings: Bindings }>(); const ParamsSchema = z.object({ - id: z - .number() - // .min(1) - .openapi({ - param: { - name: "id", - in: "path", - }, - example: 1212121, - }), + id: z.string().openapi({ + param: { + name: "id", + in: "path", + }, + example: "1212121", + }), }); const NewUserSchema = z From 97d91e0a4b12ca25a5de0e83aee9325304a1b556 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Thu, 21 Nov 2024 15:24:12 +0100 Subject: [PATCH 26/67] Implement route to get user by id in openapi example --- api/src/lib/app-routes.ts | 1 + api/src/routes/app-routes.ts | 2 +- examples/openapi-zod/src/index.ts | 30 +++++++++++++++++++++--------- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/api/src/lib/app-routes.ts b/api/src/lib/app-routes.ts index 5d109faab..9722dad40 100644 --- a/api/src/lib/app-routes.ts +++ b/api/src/lib/app-routes.ts @@ -36,6 +36,7 @@ export async function reregisterRoutes( db: LibSQLDatabase, { routes }: z.infer, ) { + console.log("reregisterRoutes", routes); return db.transaction(async (tx) => { // Unregister all routes await tx diff --git a/api/src/routes/app-routes.ts b/api/src/routes/app-routes.ts index 9bc118c52..5675189b8 100644 --- a/api/src/routes/app-routes.ts +++ b/api/src/routes/app-routes.ts @@ -98,7 +98,7 @@ app.post( const { routes } = ctx.req.valid("json"); const routesWithOpenApiSpec = await addOpenApiSpecToRoutes(db, routes); - + console.log("routesWithOpenApiSpec", routesWithOpenApiSpec); try { if (routes.length > 0) { // "Re-register" all current app routes in a database transaction diff --git a/examples/openapi-zod/src/index.ts b/examples/openapi-zod/src/index.ts index 12b242fd0..f3a39443c 100644 --- a/examples/openapi-zod/src/index.ts +++ b/examples/openapi-zod/src/index.ts @@ -76,6 +76,14 @@ const getUserRoute = createRoute({ }, description: "Invalid ID", }, + 404: { + content: { + "application/json": { + schema: z.object({ error: z.string() }), + }, + }, + description: "User not found", + }, }, }); @@ -123,21 +131,25 @@ const createUserRoute = createRoute({ }, }); -app.openapi(getUserRoute, (c) => { +app.openapi(getUserRoute, async (c) => { const { id } = c.req.valid("param"); + const db = drizzle(c.env.DB); const idNumber = +id; if (Number.isNaN(idNumber) || idNumber < 1) { return c.json({ error: "Invalid ID" }, 400); } - return c.json( - { - id: idNumber, - age: 20, - name: "Ultra-user", - }, - 200, - ); + + const [result] = await db + .select() + .from(schema.users) + .where(eq(schema.users.id, idNumber)); + + if (!result) { + return c.json({ error: "User not found" }, 404); + } + + return c.json(result, 200); }); app.openapi(listUsersRoute, async (c) => { From 5ed86bef3445347d78c1b40faae1aafbd4e80891 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Thu, 21 Nov 2024 15:36:55 +0100 Subject: [PATCH 27/67] Update settings page copy --- studio/src/pages/SettingsPage/OpenAPISettingsForm.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/studio/src/pages/SettingsPage/OpenAPISettingsForm.tsx b/studio/src/pages/SettingsPage/OpenAPISettingsForm.tsx index a135a4f19..71e010f9d 100644 --- a/studio/src/pages/SettingsPage/OpenAPISettingsForm.tsx +++ b/studio/src/pages/SettingsPage/OpenAPISettingsForm.tsx @@ -35,9 +35,13 @@ export function OpenAPISettingsForm({ settings }: OpenAPISettingsFormProps) { className="w-full space-y-4 pb-8 px-0.5" >
-

- OpenAPI Settings +

+ OpenAPI Integration (Experimental)

+
+ Enrich your detected API routes with their corresponding OpenAPI + specifications. +
Date: Thu, 21 Nov 2024 17:01:40 +0100 Subject: [PATCH 28/67] Allow automatically detecting openapi schema when target service is HonoOpenAPI app --- api/src/lib/app-routes.ts | 1 + api/src/lib/openapi/openapi.ts | 9 +- api/src/lib/openapi/types.ts | 153 ++++++++++++--------- api/src/routes/app-routes.ts | 11 +- examples/openapi-zod/src/index.ts | 66 +++++++-- packages/client-library-otel/src/routes.ts | 7 +- packages/client-library-otel/src/types.ts | 2 + 7 files changed, 168 insertions(+), 81 deletions(-) diff --git a/api/src/lib/app-routes.ts b/api/src/lib/app-routes.ts index 9722dad40..1c4d31a99 100644 --- a/api/src/lib/app-routes.ts +++ b/api/src/lib/app-routes.ts @@ -6,6 +6,7 @@ import * as schema from "../db/schema.js"; const { appRoutes } = schema; export const schemaProbedRoutes = z.object({ + openApiSpec: z.unknown().nullish(), routes: z.array( z.object({ method: z.string(), diff --git a/api/src/lib/openapi/openapi.ts b/api/src/lib/openapi/openapi.ts index eeac06bb7..b92eba2bd 100644 --- a/api/src/lib/openapi/openapi.ts +++ b/api/src/lib/openapi/openapi.ts @@ -10,7 +10,7 @@ import { } from "./dereference.js"; import { fetchOpenApiSpec } from "./fetch.js"; import { mapOpenApiToHonoRoutes } from "./map-routes.js"; -import type { OpenAPIOperation } from "./types.js"; +import { type OpenAPIOperation, isOpenApiSpec } from "./types.js"; type Routes = z.infer["routes"]; @@ -38,8 +38,13 @@ type Routes = z.infer["routes"]; export async function addOpenApiSpecToRoutes( db: LibSQLDatabase, routes: Routes, + openApiSpec: unknown, ): Promise { - const spec = await fetchOpenApiSpec(db, 0); + // Validate openApiSpec is a valid OpenAPI spec object before using it + const spec = + openApiSpec && isOpenApiSpec(openApiSpec) + ? openApiSpec + : await fetchOpenApiSpec(db); if (!spec) { return routes; } diff --git a/api/src/lib/openapi/types.ts b/api/src/lib/openapi/types.ts index 1bedc8b1b..e6bf35135 100644 --- a/api/src/lib/openapi/types.ts +++ b/api/src/lib/openapi/types.ts @@ -1,74 +1,95 @@ -export type OpenApiPathItem = { - [method: string]: OpenAPIOperation; -}; +import { z } from "zod"; -export type OpenApiSpec = { - paths: { - [path: string]: OpenApiPathItem; - }; - components?: OpenAPIComponents; -}; +// Create a schema for references +const SchemaRefSchema = z.object({ + $ref: z.string(), +}); -type OpenAPIParameter = { - name: string; - in: "query" | "header" | "path" | "cookie"; - required?: boolean; - schema?: { - type: string; - format?: string; - }; - description?: string; -}; +// Create a schema for direct type definitions +const SchemaTypeSchema = z.object({ + type: z.string(), + // ... other schema properties +}); -type OpenAPIResponse = { - description: string; - content?: { - [mediaType: string]: { - schema: { - type: string; - properties?: Record; - }; - }; - }; -}; +// Combine them with discriminatedUnion or union +const SchemaSchema = z.union([SchemaRefSchema, SchemaTypeSchema]); -export type OpenAPIOperation = { - summary?: string; - description?: string; - parameters?: OpenAPIParameter[]; - requestBody?: { - content: { - [mediaType: string]: { - schema: Record; - }; - }; - }; - responses: { - [statusCode: string]: OpenAPIResponse; - }; - tags?: string[]; -}; +// Use this in OpenApiSpecSchema where schema validation is needed +const ContentSchema = z.object({ + schema: SchemaSchema, +}); -export type OpenAPIComponents = { - schemas?: Record; - parameters?: Record; - responses?: Record; - requestBodies?: Record; -}; +const OpenAPIParameterSchema = z.object({ + name: z.string(), + in: z.enum(["query", "header", "path", "cookie"]), + required: z.boolean().optional(), + schema: z + .object({ + type: z.string(), + format: z.string().optional(), + }) + .optional(), + description: z.string().optional(), +}); -export type OpenAPISchema = { - type?: string; - properties?: Record; - items?: OpenAPISchema; - $ref?: string; -}; +const OpenAPIResponseSchema = z.object({ + description: z.string(), + content: z.record(ContentSchema).optional(), +}); -export type OpenAPIRequestBody = { - content: { - [mediaType: string]: { - schema: Record; - }; - }; -}; +const OpenAPISchemaSchema: z.ZodType = z.lazy(() => + z.object({ + type: z.string().optional(), + properties: z.record(OpenAPISchemaSchema).optional(), + items: OpenAPISchemaSchema.optional(), + $ref: z.string().optional(), + }), +); -export type RefCache = Map; +const OpenAPIRequestBodySchema = z.object({ + content: z.record(ContentSchema), +}); + +const OpenAPIOperationSchema = z.object({ + summary: z.string().optional(), + description: z.string().optional(), + parameters: z.array(OpenAPIParameterSchema).optional(), + requestBody: OpenAPIRequestBodySchema.optional(), + responses: z.record(OpenAPIResponseSchema), + tags: z.array(z.string()).optional(), +}); + +const OpenAPIComponentsSchema = z.object({ + schemas: z.record(OpenAPISchemaSchema).optional(), + parameters: z.record(OpenAPIParameterSchema).optional(), + responses: z.record(OpenAPIResponseSchema).optional(), + requestBodies: z.record(OpenAPIRequestBodySchema).optional(), +}); + +// Export schemas if needed +export const OpenApiPathItemSchema = z.record(OpenAPIOperationSchema); +export const OpenApiSpecSchema = z.object({ + paths: z.record(OpenApiPathItemSchema), + components: OpenAPIComponentsSchema.optional(), +}); +export const RefCacheSchema = z.map(z.string(), z.unknown()); + +export type OpenApiPathItem = z.infer; +export type OpenApiSpec = z.infer; +export type OpenAPIOperation = z.infer; +export type OpenAPIComponents = z.infer; +export type OpenAPISchema = z.infer; +export type OpenAPIRequestBody = z.infer; +export type RefCache = z.infer; + +export function isOpenApiSpec(value: unknown): value is OpenApiSpec { + const result = OpenApiSpecSchema.safeParse(value); + console.log("isOpenApiSpec", result); + if (!result.success) { + console.error( + "isOpenApiSpec ERRORS", + JSON.stringify(result.error.format(), null, 2), + ); + } + return result.success; +} diff --git a/api/src/routes/app-routes.ts b/api/src/routes/app-routes.ts index 5675189b8..5d5f422ff 100644 --- a/api/src/routes/app-routes.ts +++ b/api/src/routes/app-routes.ts @@ -95,10 +95,13 @@ app.post( async (ctx) => { const db = ctx.get("db"); - const { routes } = ctx.req.valid("json"); - - const routesWithOpenApiSpec = await addOpenApiSpecToRoutes(db, routes); - console.log("routesWithOpenApiSpec", routesWithOpenApiSpec); + const { routes, openApiSpec } = ctx.req.valid("json"); + console.log("openApiSpec", JSON.stringify(openApiSpec, null, 2)); + const routesWithOpenApiSpec = await addOpenApiSpecToRoutes( + db, + routes, + openApiSpec, + ); try { if (routes.length > 0) { // "Re-register" all current app routes in a database transaction diff --git a/examples/openapi-zod/src/index.ts b/examples/openapi-zod/src/index.ts index f3a39443c..2803208c6 100644 --- a/examples/openapi-zod/src/index.ts +++ b/examples/openapi-zod/src/index.ts @@ -3,6 +3,7 @@ import { OpenAPIHono } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi"; import { eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/d1"; +import { basicAuth } from "hono/basic-auth"; import * as schema from "./db/schema"; // TODO - Figure out how to use drizzle with "@hono/zod-openapi" // @@ -15,13 +16,16 @@ type Bindings = { const app = new OpenAPIHono<{ Bindings: Bindings }>(); const ParamsSchema = z.object({ - id: z.string().openapi({ - param: { - name: "id", - in: "path", - }, - example: "1212121", - }), + id: z + .string() + .regex(/^\d+$/) + .openapi({ + param: { + name: "id", + in: "path", + }, + example: "123", + }), }); const NewUserSchema = z @@ -131,6 +135,45 @@ const createUserRoute = createRoute({ }, }); +const deleteUserRoute = createRoute({ + method: "delete", + path: "/users/{id}", + security: [ + { + basicAuth: [], + }, + ], + middleware: [ + basicAuth({ + username: "goose", + password: "honkhonk", + }), + ] as const, // Use `as const` to ensure TypeScript infers the middleware's Context + request: { + params: ParamsSchema, + }, + responses: { + 204: { + description: "User deleted", + }, + 401: { + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + description: "Unauthorized - Invalid credentials", + }, + }, +}); + +app.openAPIRegistry.registerComponent("securitySchemes", "basicAuth", { + type: "http", + scheme: "basic", +}); + app.openapi(getUserRoute, async (c) => { const { id } = c.req.valid("param"); const db = drizzle(c.env.DB); @@ -179,12 +222,19 @@ app.openapi(createUserRoute, async (c) => { return c.json(result, 201); }); +app.openapi(deleteUserRoute, async (c) => { + const { id } = c.req.valid("param"); + const db = drizzle(c.env.DB); + await db.delete(schema.users).where(eq(schema.users.id, +id)); + return c.body(null, 204); +}); + // The OpenAPI documentation will be available at /doc app.doc("/doc", { openapi: "3.0.0", info: { version: "1.0.0", - title: "My API", + title: "Simple Hono OpenAPI API", }, }); diff --git a/packages/client-library-otel/src/routes.ts b/packages/client-library-otel/src/routes.ts index 2ffbbd988..a4340551e 100644 --- a/packages/client-library-otel/src/routes.ts +++ b/packages/client-library-otel/src/routes.ts @@ -31,6 +31,7 @@ export async function sendRoutes( promiseStore?: PromiseStore, ) { const routes = getRoutesFromApp(app) ?? []; + const openApiSpec = getOpenApiSpecFromApp(app); try { // NOTE - Construct url to the routes endpoint here, given the FPX endpoint. @@ -41,7 +42,7 @@ export async function sendRoutes( headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ routes }), + body: JSON.stringify({ routes, openApiSpec }), }); if (promiseStore) { @@ -96,3 +97,7 @@ function getRoutesEndpoint(fpxEndpoint: string) { routesEndpoint.pathname = "/v0/probed-routes"; return routesEndpoint.toString(); } + +function getOpenApiSpecFromApp(app: HonoLikeApp) { + return app.getOpenAPIDocument?.(); +} diff --git a/packages/client-library-otel/src/types.ts b/packages/client-library-otel/src/types.ts index 20cb66fc8..f4a5ad529 100644 --- a/packages/client-library-otel/src/types.ts +++ b/packages/client-library-otel/src/types.ts @@ -50,6 +50,8 @@ export type HonoLikeFetch = ( export type HonoLikeApp = { fetch: HonoLikeFetch; routes: RouterRoute[]; + // NOTE - This exists on instances of OpenAPIHono + getOpenAPIDocument?: () => unknown; }; type RouterRoute = { From 3cbaed83e5b8909bc84d2c5d8b555d7cdec7eba6 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Thu, 21 Nov 2024 17:21:05 +0100 Subject: [PATCH 29/67] Add a command bar component (not hooked up) --- .../RequestorPage/CommandBar/CommandBar.tsx | 60 +++++++++++++++++++ .../RequestorPageContent.tsx | 17 +++++- 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx diff --git a/studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx b/studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx new file mode 100644 index 000000000..ef6babab7 --- /dev/null +++ b/studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx @@ -0,0 +1,60 @@ +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { + LayoutIcon, + FileTextIcon, + ChatBubbleIcon, + ClockIcon +} from "@radix-ui/react-icons"; + +interface CommandBarProps { + open: boolean; + setOpen: (open: boolean) => void; +} + +export function CommandBar({ open, setOpen }: CommandBarProps) { + return ( + + + + + + No results found. + + + + Toggle Timeline Panel + + + + Toggle Logs Panel + + + + Toggle AI Panel + + + + + + + Recent Requests + + + + + + + ); +} \ No newline at end of file diff --git a/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx b/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx index fa5fed557..894066b22 100644 --- a/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx +++ b/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx @@ -7,7 +7,7 @@ import { import { useToast } from "@/components/ui/use-toast"; import { useIsLgScreen, useKeySequence } from "@/hooks"; import { cn } from "@/utils"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { type To, useNavigate } from "react-router-dom"; import { useShallow } from "zustand/react/shallow"; @@ -23,6 +23,7 @@ import { useRequestorSubmitHandler } from "../useRequestorSubmitHandler"; import RequestorPageContentBottomPanel from "./RequestorPageContentBottomPanel"; import { useMostRecentProxiedRequestResponse } from "./useMostRecentProxiedRequestResponse"; import { getMainSectionWidth } from "./util"; +import { CommandBar } from "../CommandBar/CommandBar"; interface RequestorPageContentProps { history: ProxiedRequestResponse[]; @@ -169,6 +170,19 @@ export const RequestorPageContent: React.FC = ( }, ); + const [commandBarOpen, setCommandBarOpen] = useState(false); + + useHotkeys( + "mod+k", + (e) => { + e.preventDefault(); + setCommandBarOpen(true); + }, + { + enableOnFormTags: ["input"], + } + ); + const requestContent = ( = ( "overflow-hidden", )} > + Date: Thu, 21 Nov 2024 18:07:37 +0100 Subject: [PATCH 30/67] Update fp-services to accept a user prompt --- fp-services/src/routes/ai/service/index.ts | 2 ++ fp-services/src/routes/ai/service/openai.ts | 2 ++ fp-services/src/routes/ai/service/prompts.ts | 8 ++++++++ fp-services/src/routes/ai/types.ts | 1 + 4 files changed, 13 insertions(+) diff --git a/fp-services/src/routes/ai/service/index.ts b/fp-services/src/routes/ai/service/index.ts index 0a1fd2501..988860868 100644 --- a/fp-services/src/routes/ai/service/index.ts +++ b/fp-services/src/routes/ai/service/index.ts @@ -12,6 +12,7 @@ export async function generateRequest({ openApiSpec, middleware, middlewareContext, + prompt, }: GenerateRequestOptions & { apiKey: string }) { return generateRequestWithOpenAI({ apiKey, @@ -25,6 +26,7 @@ export async function generateRequest({ openApiSpec, middleware, middlewareContext, + prompt, }).catch((error) => { if (error instanceof Error) { return { data: null, error: { message: error.message } }; diff --git a/fp-services/src/routes/ai/service/openai.ts b/fp-services/src/routes/ai/service/openai.ts index 461e802f2..a14d0986f 100644 --- a/fp-services/src/routes/ai/service/openai.ts +++ b/fp-services/src/routes/ai/service/openai.ts @@ -28,6 +28,7 @@ export async function generateRequestWithOpenAI({ openApiSpec, middleware, middlewareContext, + prompt, }: GenerateRequestOptions & { model: string; apiKey: string }) { logger.debug( "Generating request data with OpenAI", @@ -55,6 +56,7 @@ export async function generateRequestWithOpenAI({ openApiSpec, middleware, middlewareContext, + prompt, }); const openai = openaiClient(model, { diff --git a/fp-services/src/routes/ai/service/prompts.ts b/fp-services/src/routes/ai/service/prompts.ts index 2bd07c1e6..025756f9b 100644 --- a/fp-services/src/routes/ai/service/prompts.ts +++ b/fp-services/src/routes/ai/service/prompts.ts @@ -37,6 +37,7 @@ export const invokeRequestGenerationPrompt = async ({ openApiSpec, middleware, middlewareContext, + prompt, }: { persona: string; method: string; @@ -51,6 +52,7 @@ export const invokeRequestGenerationPrompt = async ({ path: string; }[]; middlewareContext?: string; + prompt?: string; }) => { const promptTemplate = persona === "QA" ? qaTesterPrompt : friendlyTesterPrompt; @@ -63,6 +65,7 @@ export const invokeRequestGenerationPrompt = async ({ openApiSpec: openApiSpec ?? "NO OPENAPI SPEC", middleware: formatMiddleware(middleware), middlewareContext: middlewareContext ?? "NO MIDDLEWARE CONTEXT", + prompt: prompt ?? "", }); const userPrompt = userPromptInterface.value; return userPrompt; @@ -102,6 +105,8 @@ Here is the code for the handler: Here is some additional context for the handler source code, if you need it: {handlerContext} +Here are some additional instructions from the user: +{prompt} `.trim(), ); @@ -134,6 +139,9 @@ Here is the code for the handler: Here is some additional context for the handler source code, if you need it: {handlerContext} +Here are some additional instructions from the user: +{prompt} + REMEMBER YOU ARE A QA. MISUSE THE API. BUT DO NOT MISUSE YOURSELF. Keep your responses short-ish. Including your random data. `.trim(), diff --git a/fp-services/src/routes/ai/types.ts b/fp-services/src/routes/ai/types.ts index 9577238d2..53765c81e 100644 --- a/fp-services/src/routes/ai/types.ts +++ b/fp-services/src/routes/ai/types.ts @@ -18,6 +18,7 @@ export const GenerateRequestOptionsSchema = z.object({ ) .optional(), middlewareContext: z.string().optional(), + prompt: z.string().optional(), }); export type GenerateRequestOptions = z.infer< From 92e68de70454d9a3b2f086875cd611aeda12d596 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Thu, 21 Nov 2024 18:09:33 +0100 Subject: [PATCH 31/67] Sneak in a little command bar to modify ai request payloads --- api/src/lib/ai/fp.ts | 3 + api/src/lib/ai/index.ts | 4 + api/src/lib/ai/prompts.ts | 8 ++ api/src/routes/inference/inference.ts | 5 +- .../CommandBar/AiPromptInput.tsx | 64 +++++++++++++++ .../RequestorPage/CommandBar/CommandBar.tsx | 80 ++++++++++++++++--- .../RequestorPageContent.tsx | 52 ++++++++---- studio/src/pages/RequestorPage/ai/ai.ts | 3 + .../RequestorPage/ai/generate-request-data.ts | 12 ++- studio/src/pages/RequestorPage/store/index.ts | 2 + .../RequestorPage/store/slices/aiSlice.ts | 16 ++++ .../pages/RequestorPage/store/slices/types.ts | 8 +- 12 files changed, 225 insertions(+), 32 deletions(-) create mode 100644 studio/src/pages/RequestorPage/CommandBar/AiPromptInput.tsx create mode 100644 studio/src/pages/RequestorPage/store/slices/aiSlice.ts diff --git a/api/src/lib/ai/fp.ts b/api/src/lib/ai/fp.ts index 669c4be7a..848fdad2d 100644 --- a/api/src/lib/ai/fp.ts +++ b/api/src/lib/ai/fp.ts @@ -16,6 +16,7 @@ type GenerateRequestOptions = { path: string; }[]; middlewareContext?: string; + prompt?: string; }; export async function generateRequestWithFp({ @@ -29,6 +30,7 @@ export async function generateRequestWithFp({ openApiSpec, middleware, middlewareContext, + prompt, }: GenerateRequestOptions) { logger.debug( "Generating request data with FP", @@ -56,6 +58,7 @@ export async function generateRequestWithFp({ openApiSpec, middleware, middlewareContext, + prompt, }, }); diff --git a/api/src/lib/ai/index.ts b/api/src/lib/ai/index.ts index 4f5f6a6ad..f2906a508 100644 --- a/api/src/lib/ai/index.ts +++ b/api/src/lib/ai/index.ts @@ -64,6 +64,7 @@ export async function generateRequestWithAiProvider({ openApiSpec, middleware, middlewareContext, + prompt, }: { fpApiKey?: string; inferenceConfig: Settings; @@ -80,6 +81,7 @@ export async function generateRequestWithAiProvider({ path: string; }[]; middlewareContext?: string; + prompt?: string; }) { const { aiProviderConfigurations, aiProvider } = inferenceConfig; const aiEnabled = hasValidAiConfig(inferenceConfig); @@ -109,6 +111,7 @@ export async function generateRequestWithAiProvider({ persona, method, path, + prompt, }); } @@ -166,6 +169,7 @@ Here is some additional context for the handler source code, if you need it: openApiSpec, middleware, middlewareContext, + prompt, }); const systemPrompt = getSystemPrompt(persona, aiProvider); diff --git a/api/src/lib/ai/prompts.ts b/api/src/lib/ai/prompts.ts index a9e2f678b..ea8e573b3 100644 --- a/api/src/lib/ai/prompts.ts +++ b/api/src/lib/ai/prompts.ts @@ -42,6 +42,7 @@ export const invokeRequestGenerationPrompt = async ({ openApiSpec, middleware, middlewareContext, + prompt, }: { persona: string; method: string; @@ -56,6 +57,7 @@ export const invokeRequestGenerationPrompt = async ({ path: string; }[]; middlewareContext?: string; + prompt?: string; }) => { const promptTemplate = persona === "QA" ? qaTesterPrompt : friendlyTesterPrompt; @@ -68,6 +70,7 @@ export const invokeRequestGenerationPrompt = async ({ openApiSpec: openApiSpec ?? "NO OPENAPI SPEC", middleware: formatMiddleware(middleware), middlewareContext: middlewareContext ?? "NO MIDDLEWARE CONTEXT", + prompt: prompt ?? "", }); const userPrompt = userPromptInterface.value; return userPrompt; @@ -107,6 +110,8 @@ Here is the code for the handler: Here is some additional context for the handler source code, if you need it: {handlerContext} +Here are some additional instructions from the user: +{prompt} `.trim(), ); @@ -139,6 +144,9 @@ Here is the code for the handler: Here is some additional context for the handler source code, if you need it: {handlerContext} +Here are some additional instructions from the user: +{prompt} + REMEMBER YOU ARE A QA. MISUSE THE API. BUT DO NOT MISUSE YOURSELF. Keep your responses short-ish. Including your random data. `.trim(), diff --git a/api/src/routes/inference/inference.ts b/api/src/routes/inference/inference.ts index ceb095058..9012797a0 100644 --- a/api/src/routes/inference/inference.ts +++ b/api/src/routes/inference/inference.ts @@ -19,6 +19,7 @@ const generateRequestSchema = z.object({ history: z.array(z.string()).nullish(), persona: z.string(), openApiSpec: z.string().nullish(), + prompt: z.string().nullish(), }); app.post( @@ -26,7 +27,7 @@ app.post( cors(), zValidator("json", generateRequestSchema), async (ctx) => { - const { handler, method, path, history, persona, openApiSpec } = + const { handler, method, path, history, persona, openApiSpec, prompt } = ctx.req.valid("json"); const db = ctx.get("db"); @@ -93,6 +94,8 @@ app.post( // middleware: middleware ?? undefined, middleware: undefined, middlewareContext: middlewareContextPerformant ?? undefined, + // additional user prompt (from studio) + prompt: prompt ?? undefined, }; const { data: parsedArgs, error: generateError } = diff --git a/studio/src/pages/RequestorPage/CommandBar/AiPromptInput.tsx b/studio/src/pages/RequestorPage/CommandBar/AiPromptInput.tsx new file mode 100644 index 000000000..14540fd55 --- /dev/null +++ b/studio/src/pages/RequestorPage/CommandBar/AiPromptInput.tsx @@ -0,0 +1,64 @@ +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import React from "react"; + +interface AiPromptInputProps { + open: boolean; + setOpen: (open: boolean) => void; + onGenerateRequest?: (prompt?: string) => void; + setAiPrompt: (prompt?: string) => void; +} + +export function AiPromptInput({ + open, + setOpen, + onGenerateRequest, + setAiPrompt, +}: AiPromptInputProps) { + const [inputValue, setInputValue] = React.useState(""); + const inputRef = React.useRef(null); + + const handleSubmit = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + const trimmedValue = inputValue.trim(); + onGenerateRequest?.(trimmedValue); + setAiPrompt(undefined); + setOpen(false); + setInputValue(""); + } + }; + + // Reset input value when dialog closes + React.useEffect(() => { + if (!open) { + setInputValue(""); + } + }, [open]); + + // Focus input when dialog opens + React.useEffect(() => { + if (open) { + inputRef.current?.focus(); + } + }, [open]); + + return ( + + +
+ { + setInputValue(e.target.value); + setAiPrompt(e.target.value); + }} + placeholder="Enter prompt for AI request generation..." + className="flex h-11 w-full rounded-md bg-transparent px-3 py-2 text-sm outline-none" + onKeyDown={handleSubmit} + /> +
+
+
+ ); +} diff --git a/studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx b/studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx index ef6babab7..10f376ac7 100644 --- a/studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx +++ b/studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx @@ -8,39 +8,93 @@ import { CommandSeparator, } from "@/components/ui/command"; import { Dialog, DialogContent } from "@/components/ui/dialog"; -import { - LayoutIcon, - FileTextIcon, - ChatBubbleIcon, - ClockIcon +import { + ChatBubbleIcon, + ClockIcon, + FileTextIcon, + LayoutIcon, + MagicWandIcon, } from "@radix-ui/react-icons"; +import React from "react"; interface CommandBarProps { open: boolean; setOpen: (open: boolean) => void; + onGenerateRequest?: (prompt?: string) => void; + togglePanel: (panel: "timelinePanel" | "logsPanel" | "aiPanel") => void; } -export function CommandBar({ open, setOpen }: CommandBarProps) { +export function CommandBar({ + open, + setOpen, + onGenerateRequest, + togglePanel, +}: CommandBarProps) { + const [inputValue, setInputValue] = React.useState(""); + + const handleGenerateRequest = (currentInput: string) => { + // Extract any text after "generate" as the prompt + const prompt = currentInput.replace(/^generate\s*/, "").trim(); + onGenerateRequest?.(prompt.length > 0 ? prompt : undefined); + setOpen(false); + setInputValue(""); + }; + return ( - - + No results found. + + handleGenerateRequest(inputValue)} + > + + Generate Request Data + + Type additional text for custom prompt + + + + - + { + togglePanel("timelinePanel"); + setOpen(false); + }} + > Toggle Timeline Panel - + { + togglePanel("logsPanel"); + setOpen(false); + }} + > Toggle Logs Panel - + { + togglePanel("aiPanel"); + setOpen(false); + }} + > Toggle AI Panel @@ -57,4 +111,4 @@ export function CommandBar({ open, setOpen }: CommandBarProps) { ); -} \ No newline at end of file +} diff --git a/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx b/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx index 894066b22..011a1f375 100644 --- a/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx +++ b/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx @@ -11,6 +11,7 @@ import { useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { type To, useNavigate } from "react-router-dom"; import { useShallow } from "zustand/react/shallow"; +import { AiPromptInput } from "../CommandBar/AiPromptInput"; import { RequestPanel } from "../RequestPanel"; import { RequestorInput } from "../RequestorInput"; import { ResponsePanel } from "../ResponsePanel"; @@ -23,7 +24,6 @@ import { useRequestorSubmitHandler } from "../useRequestorSubmitHandler"; import RequestorPageContentBottomPanel from "./RequestorPageContentBottomPanel"; import { useMostRecentProxiedRequestResponse } from "./useMostRecentProxiedRequestResponse"; import { getMainSectionWidth } from "./util"; -import { CommandBar } from "../CommandBar/CommandBar"; interface RequestorPageContentProps { history: ProxiedRequestResponse[]; @@ -115,11 +115,14 @@ export const RequestorPageContent: React.FC = ( const isLgScreen = useIsLgScreen(); - const { togglePanel, setAIDropdownOpen } = useRequestorStore( + const { togglePanel, setAIDropdownOpen, setAiPrompt } = useRequestorStore( "togglePanel", "setAIDropdownOpen", + "setAiPrompt", ); + const [aiPromptOpen, setAiPromptOpen] = useState(false); + useHotkeys( "mod+g", (e) => { @@ -143,6 +146,21 @@ export const RequestorPageContent: React.FC = ( }, ); + useHotkeys( + "mod+shift+g", + (e) => { + e.preventDefault(); + if (aiEnabled && !isLoadingParameters) { + setAiPromptOpen(true); + } else { + setAIDropdownOpen(true); + } + }, + { + enableOnFormTags: ["input"], + }, + ); + useKeySequence( ["g", "l"], () => { @@ -170,19 +188,6 @@ export const RequestorPageContent: React.FC = ( }, ); - const [commandBarOpen, setCommandBarOpen] = useState(false); - - useHotkeys( - "mod+k", - (e) => { - e.preventDefault(); - setCommandBarOpen(true); - }, - { - enableOnFormTags: ["input"], - } - ); - const requestContent = ( = ( "overflow-hidden", )} > - + { + if (aiEnabled && !isLoadingParameters) { + toast({ + duration: 3000, + description: prompt + ? `Generating request with prompt: ${prompt}` + : "Generating request parameters with AI", + }); + fillInRequest(); + } + }} + /> ) { body, activeRoute, getMatchingMiddleware, + currentAiPrompt, } = useRequestorStore( "setBody", "setQueryParams", @@ -39,6 +40,7 @@ export function useAi(requestHistory: Array) { "body", "activeRoute", "getMatchingMiddleware", + "currentAiPrompt", ); const { ignoreAiInputsBanner, setIgnoreAiInputsBanner } = @@ -65,6 +67,7 @@ export function useAi(requestHistory: Array) { bodyType, recentHistory, testingPersona, + currentAiPrompt, ); const fillInRequest = useHandler(() => { diff --git a/studio/src/pages/RequestorPage/ai/generate-request-data.ts b/studio/src/pages/RequestorPage/ai/generate-request-data.ts index 49540189a..f2be95ffd 100644 --- a/studio/src/pages/RequestorPage/ai/generate-request-data.ts +++ b/studio/src/pages/RequestorPage/ai/generate-request-data.ts @@ -23,6 +23,7 @@ const fetchAiRequestData = async ( bodyType: RequestBodyType, history: Array, persona: string, + prompt?: string, ) => { // FIXME - type wonkiness const { handler, method, path, openApiSpec } = route ?? {}; @@ -46,6 +47,7 @@ const fetchAiRequestData = async ( persona, openApiSpec, middleware, + prompt, }), }).then(async (r) => { if (!r.ok) { @@ -62,11 +64,19 @@ export function useAiRequestData( bodyType: RequestBodyType, history: Array, persona = "Friendly", + prompt?: string, ) { return useQuery({ queryKey: ["generateRequest"], queryFn: () => - fetchAiRequestData(route, matchingMiddleware, bodyType, history, persona), + fetchAiRequestData( + route, + matchingMiddleware, + bodyType, + history, + persona, + prompt, + ), enabled: false, retry: false, }); diff --git a/studio/src/pages/RequestorPage/store/index.ts b/studio/src/pages/RequestorPage/store/index.ts index db2075d88..b83a93e26 100644 --- a/studio/src/pages/RequestorPage/store/index.ts +++ b/studio/src/pages/RequestorPage/store/index.ts @@ -9,6 +9,7 @@ import { create } from "zustand"; import { devtools } from "zustand/middleware"; import { immer } from "zustand/middleware/immer"; import { useShallow } from "zustand/react/shallow"; +import { aiSlice } from "./slices/aiSlice"; import { requestResponseSlice } from "./slices/requestResponseSlice"; import { routesSlice } from "./slices/routesSlice"; import { tabsSlice } from "./slices/tabsSlice"; @@ -27,6 +28,7 @@ export const useRequestorStoreRaw = create()( ...tabsSlice(...a), ...requestResponseSlice(...a), ...uiSlice(...a), + ...aiSlice(...a), })), { name: "RequestorStore" }, ), diff --git a/studio/src/pages/RequestorPage/store/slices/aiSlice.ts b/studio/src/pages/RequestorPage/store/slices/aiSlice.ts new file mode 100644 index 000000000..ed6d93a09 --- /dev/null +++ b/studio/src/pages/RequestorPage/store/slices/aiSlice.ts @@ -0,0 +1,16 @@ +import type { StateCreator } from "zustand"; +import type { AiState, Store } from "./types"; + +export const aiSlice: StateCreator< + Store, + [["zustand/immer", never], ["zustand/devtools", never]], + [], + AiState +> = (set) => ({ + currentAiPrompt: undefined, + + setAiPrompt: (prompt?: string) => + set((state) => { + state.currentAiPrompt = prompt; + }), +}); diff --git a/studio/src/pages/RequestorPage/store/slices/types.ts b/studio/src/pages/RequestorPage/store/slices/types.ts index 78a130426..2ef3d9c29 100644 --- a/studio/src/pages/RequestorPage/store/slices/types.ts +++ b/studio/src/pages/RequestorPage/store/slices/types.ts @@ -107,8 +107,14 @@ export const validBottomPanelNames: BOTTOM_PANEL_NAMES[] = [ export type PanelState = "open" | "closed"; +export type AiState = { + currentAiPrompt: string | undefined; + setAiPrompt: (prompt?: string) => void; +}; + export type Store = RequestResponseSlice & RoutesSlice & TabsSlice & WebsocketSlice & - UISlice; + UISlice & + AiState; From 95c05c4f4b95e442b8d8a8cb78cd806b8dc931a5 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Fri, 22 Nov 2024 11:59:51 +0100 Subject: [PATCH 32/67] Improve the schema validation for openapi schemas --- api/src/lib/app-routes.ts | 1 - api/src/lib/openapi/types.ts | 96 ++++++++++++++++++++++++++++++------ api/src/routes/app-routes.ts | 2 +- 3 files changed, 82 insertions(+), 17 deletions(-) diff --git a/api/src/lib/app-routes.ts b/api/src/lib/app-routes.ts index 1c4d31a99..6fcfbe6f6 100644 --- a/api/src/lib/app-routes.ts +++ b/api/src/lib/app-routes.ts @@ -37,7 +37,6 @@ export async function reregisterRoutes( db: LibSQLDatabase, { routes }: z.infer, ) { - console.log("reregisterRoutes", routes); return db.transaction(async (tx) => { // Unregister all routes await tx diff --git a/api/src/lib/openapi/types.ts b/api/src/lib/openapi/types.ts index e6bf35135..c47451e32 100644 --- a/api/src/lib/openapi/types.ts +++ b/api/src/lib/openapi/types.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import logger from "../../logger/index.js"; // Create a schema for references const SchemaRefSchema = z.object({ @@ -7,11 +8,16 @@ const SchemaRefSchema = z.object({ // Create a schema for direct type definitions const SchemaTypeSchema = z.object({ - type: z.string(), - // ... other schema properties + $ref: z.undefined(), + type: z.enum(["string", "number", "integer", "boolean", "array", "object"]), + format: z.string().optional(), + enum: z.array(z.string()).optional(), + default: z.any().optional(), + description: z.string().optional(), + // Add other relevant OpenAPI schema properties here }); -// Combine them with discriminatedUnion or union +// Combine them with a union instead of discriminatedUnion const SchemaSchema = z.union([SchemaRefSchema, SchemaTypeSchema]); // Use this in OpenApiSpecSchema where schema validation is needed @@ -22,13 +28,9 @@ const ContentSchema = z.object({ const OpenAPIParameterSchema = z.object({ name: z.string(), in: z.enum(["query", "header", "path", "cookie"]), + // TODO - Path parameters must have "required" set to true required: z.boolean().optional(), - schema: z - .object({ - type: z.string(), - format: z.string().optional(), - }) - .optional(), + schema: SchemaSchema.optional(), description: z.string().optional(), }); @@ -43,6 +45,12 @@ const OpenAPISchemaSchema: z.ZodType = z.lazy(() => properties: z.record(OpenAPISchemaSchema).optional(), items: OpenAPISchemaSchema.optional(), $ref: z.string().optional(), + required: z.array(z.string()).optional(), + additionalProperties: z.boolean().optional(), + allOf: z.array(OpenAPISchemaSchema).optional(), + anyOf: z.array(OpenAPISchemaSchema).optional(), + oneOf: z.array(OpenAPISchemaSchema).optional(), + // Add other complex schema properties as needed }), ); @@ -55,7 +63,21 @@ const OpenAPIOperationSchema = z.object({ description: z.string().optional(), parameters: z.array(OpenAPIParameterSchema).optional(), requestBody: OpenAPIRequestBodySchema.optional(), - responses: z.record(OpenAPIResponseSchema), + responses: z.record(OpenAPIResponseSchema).refine( + (responses) => { + // Check if any status code starts with '2' (i.e., 2xx) + const has2xx = Object.keys(responses).some((code) => + /^2\d{2}$/.test(code), + ); + // Check if 'default' is present + const hasDefault = "default" in responses; + return has2xx || hasDefault; + }, + { + message: + 'Responses must include at least a "200" or "default" status code.', + }, + ), tags: z.array(z.string()).optional(), }); @@ -64,9 +86,12 @@ const OpenAPIComponentsSchema = z.object({ parameters: z.record(OpenAPIParameterSchema).optional(), responses: z.record(OpenAPIResponseSchema).optional(), requestBodies: z.record(OpenAPIRequestBodySchema).optional(), + headers: z.record(z.any()).optional(), + securitySchemes: z.record(z.any()).optional(), + links: z.record(z.any()).optional(), + callbacks: z.record(z.any()).optional(), }); -// Export schemas if needed export const OpenApiPathItemSchema = z.record(OpenAPIOperationSchema); export const OpenApiSpecSchema = z.object({ paths: z.record(OpenApiPathItemSchema), @@ -84,12 +109,53 @@ export type RefCache = z.infer; export function isOpenApiSpec(value: unknown): value is OpenApiSpec { const result = OpenApiSpecSchema.safeParse(value); - console.log("isOpenApiSpec", result); if (!result.success) { - console.error( - "isOpenApiSpec ERRORS", - JSON.stringify(result.error.format(), null, 2), + logger.error( + "[isOpenApiSpec] Error parsing OpenAPI spec:", + // JSON.stringify(result.error.format(), null, 2), + JSON.stringify(result.error.issues, null, 2), ); } return result.success; } + +export function validateReferences(spec: OpenApiSpec): boolean { + const refs = Array.from( + spec.components?.schemas ? Object.keys(spec.components.schemas) : [], + ); + let isValid = true; + + for (const pathItem of Object.values(spec.paths)) { + for (const operation of Object.values(pathItem)) { + for (const param of operation.parameters ?? []) { + if (param.schema?.$ref) { + const refName = param.schema.$ref.split("/").pop(); + if (refName && !refs.includes(refName)) { + logger.error( + `Reference ${param.schema.$ref} not found in components.schemas`, + ); + isValid = false; + } + } + } + + if (operation.requestBody?.content) { + for (const content of Object.values(operation.requestBody.content)) { + if (content.schema?.$ref) { + const refName = content.schema.$ref.split("/").pop(); + if (refName && !refs.includes(refName)) { + logger.error( + `Reference ${content.schema.$ref} not found in components.schemas`, + ); + isValid = false; + } + } + } + } + + // Similarly validate responses and other $ref usages + } + } + + return isValid; +} diff --git a/api/src/routes/app-routes.ts b/api/src/routes/app-routes.ts index 5d5f422ff..e09b16d5d 100644 --- a/api/src/routes/app-routes.ts +++ b/api/src/routes/app-routes.ts @@ -96,7 +96,7 @@ app.post( const db = ctx.get("db"); const { routes, openApiSpec } = ctx.req.valid("json"); - console.log("openApiSpec", JSON.stringify(openApiSpec, null, 2)); + const routesWithOpenApiSpec = await addOpenApiSpecToRoutes( db, routes, From 07d42268c67d182ebd976af6bfeb9840e151efdc Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Fri, 22 Nov 2024 14:28:10 +0100 Subject: [PATCH 33/67] Add a docs tab --- studio/src/components/ui/scroll-area.tsx | 26 +++ .../RequestPanel/RequestPanel.tsx | 37 +++- .../RouteDocumentation/RouteDocumentation.tsx | 159 +++++++++++++++++ .../RouteDocumentation/openapi.ts | 160 ++++++++++++++++++ .../RequestorPage/store/slices/tabsSlice.ts | 2 +- studio/src/pages/RequestorPage/store/tabs.ts | 1 + 6 files changed, 381 insertions(+), 4 deletions(-) create mode 100644 studio/src/components/ui/scroll-area.tsx create mode 100644 studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx create mode 100644 studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/openapi.ts diff --git a/studio/src/components/ui/scroll-area.tsx b/studio/src/components/ui/scroll-area.tsx new file mode 100644 index 000000000..2fb736d81 --- /dev/null +++ b/studio/src/components/ui/scroll-area.tsx @@ -0,0 +1,26 @@ +import { cn } from "@/utils"; +import { type HTMLAttributes, forwardRef } from "react"; + +export const ScrollArea = forwardRef< + HTMLDivElement, + HTMLAttributes +>(({ className, children, ...props }, ref) => { + return ( +
+ {children} +
+ ); +}); + +ScrollArea.displayName = "ScrollArea"; \ No newline at end of file diff --git a/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx b/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx index 657bafa5c..928ee9687 100644 --- a/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx +++ b/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx @@ -1,9 +1,9 @@ import { Button } from "@/components/ui/button"; import { Tabs } from "@/components/ui/tabs"; import { useToast } from "@/components/ui/use-toast"; -import { cn } from "@/utils"; +import { cn, objectWithKey } from "@/utils"; import { EraserIcon, InfoCircledIcon } from "@radix-ui/react-icons"; -import { type Dispatch, type SetStateAction, memo } from "react"; +import { type Dispatch, type SetStateAction, memo, useMemo } from "react"; import { FormDataForm } from "../FormDataForm"; import { KeyValueForm } from "../KeyValueForm"; import { CustomTabTrigger, CustomTabsContent, CustomTabsList } from "../Tabs"; @@ -21,6 +21,7 @@ import { CodeMirrorTextEditor, } from "@/components/CodeMirrorEditor"; import { useRequestorStore } from "../store"; +import { RouteDocumentation } from "./RouteDocumentation/RouteDocumentation"; type RequestPanelProps = { aiEnabled: boolean; @@ -36,6 +37,14 @@ type RequestPanelProps = { onSubmit: () => void; }; +function isValidOpenApiSpec(openApiSpec: unknown): boolean { + return ( + objectWithKey(openApiSpec, "parameters") || + objectWithKey(openApiSpec, "requestBody") || + objectWithKey(openApiSpec, "responses") + ); +} + export const RequestPanel = memo(function RequestPanel( props: RequestPanelProps, ) { @@ -71,6 +80,7 @@ export const RequestPanel = memo(function RequestPanel( websocketMessage, setWebsocketMessage, visibleRequestsPanelTabs, + activeRoute, } = useRequestorStore( "path", "body", @@ -89,6 +99,7 @@ export const RequestPanel = memo(function RequestPanel( "websocketMessage", "setWebsocketMessage", "visibleRequestsPanelTabs", + "activeRoute", ); const { toast } = useToast(); @@ -98,6 +109,17 @@ export const RequestPanel = memo(function RequestPanel( const shouldShowBody = shouldShowRequestTab("body"); const shouldShowMessages = shouldShowRequestTab("messages"); + const openApiSpec = useMemo( + () => { + try { + return JSON.parse(activeRoute?.openApiSpec ?? "{}"); + } catch (_e) { + return null; + } + }, + [activeRoute?.openApiSpec], + ); + const shouldShowDocs = isValidOpenApiSpec(openApiSpec); return ( )} + {shouldShowDocs && ( + + Docs + + )} {shouldShowMessages && ( Message @@ -239,7 +266,6 @@ export const RequestPanel = memo(function RequestPanel( setBody(undefined); }} /> - {/* TODO - Have a proper text editor */} {body.type === "text" && ( )} + {shouldShowDocs && ( + + + + )} {shouldShowMessages && ( +
+ {/* Description Section */} + {(summary || description) && ( +
+ {summary &&

{summary}

} + {description &&

{description}

} +
+ )} + + {/* Parameters Section */} + {parameters && parameters.length > 0 && ( +
+

Parameters

+
+ {parameters.map((param, idx) => ( +
+
+ {param.name} + + {param.in} + + {param.required && ( + required + )} +
+ {param.description && ( +

+ {param.description} +

+ )} + {param.schema && ( + + )} +
+ ))} +
+
+ )} + + {/* Request Body Section */} + {requestBody && ( +
+

Request Body

+
+ {Object.entries(requestBody.content).map(([mediaType, content]) => ( +
+ {mediaType} + {content.schema && ( + + )} +
+ ))} +
+
+ )} + + {/* Responses Section */} + {responses && ( +
+

Responses

+
+ {Object.entries(responses).map(([status, response]) => ( +
+
+ + {status} + + + {response.description} + +
+ {response.content && ( +
+ {Object.entries(response.content).map( + ([mediaType, content]) => ( +
+ + {mediaType} + + {content.schema && ( + + )} +
+ ), + )} +
+ )} +
+ ))} +
+
+ )} +
+ + ); +}); + +type SchemaViewerProps = { + schema: OpenAPISchema; + className?: string; +}; + +function SchemaViewer({ schema, className }: SchemaViewerProps) { + if (!schema.type) { + return null; + } + + return ( +
+ {schema.type === "object" && schema.properties ? ( +
+ {Object.entries(schema.properties).map(([key, prop]) => { + const propSchema = prop as OpenAPISchema; + return ( +
+ {key} + {propSchema.type} + {schema.required?.includes(key) && ( + + required + + )} +
+ ); + })} +
+ ) : ( +
+ {schema.type} + {schema.enum && ( + + enum: [{schema.enum.join(", ")}] + + )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/openapi.ts b/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/openapi.ts new file mode 100644 index 000000000..b9655ab1e --- /dev/null +++ b/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/openapi.ts @@ -0,0 +1,160 @@ +import { z } from "zod"; + +// Create a schema for references +const SchemaRefSchema = z.object({ + $ref: z.string(), +}); + +// Create a schema for direct type definitions +const SchemaTypeSchema = z.object({ + $ref: z.undefined(), + type: z.enum(["string", "number", "integer", "boolean", "array", "object"]), + format: z.string().optional(), + enum: z.array(z.string()).optional(), + default: z.any().optional(), + description: z.string().optional(), + // Add other relevant OpenAPI schema properties here +}); + +// Combine them with a union instead of discriminatedUnion +const SchemaSchema = z.union([SchemaRefSchema, SchemaTypeSchema]); + +// Use this in OpenApiSpecSchema where schema validation is needed +const ContentSchema = z.object({ + schema: SchemaSchema, +}); + +const OpenAPIParameterSchema = z.object({ + name: z.string(), + in: z.enum(["query", "header", "path", "cookie"]), + // TODO - Path parameters must have "required" set to true + required: z.boolean().optional(), + schema: SchemaSchema.optional(), + description: z.string().optional(), +}); + +const OpenAPIResponseSchema = z.object({ + description: z.string(), + content: z.record(ContentSchema).optional(), +}); + +const OpenAPISchemaSchema: z.ZodType = z.lazy(() => + z.object({ + type: z.string().optional(), + properties: z.record(OpenAPISchemaSchema).optional(), + items: OpenAPISchemaSchema.optional(), + $ref: z.string().optional(), + required: z.array(z.string()).optional(), + additionalProperties: z.boolean().optional(), + allOf: z.array(OpenAPISchemaSchema).optional(), + anyOf: z.array(OpenAPISchemaSchema).optional(), + oneOf: z.array(OpenAPISchemaSchema).optional(), + // Add other complex schema properties as needed + }), +); + +const OpenAPIRequestBodySchema = z.object({ + content: z.record(ContentSchema), +}); + +const OpenAPIOperationSchema = z.object({ + summary: z.string().optional(), + description: z.string().optional(), + parameters: z.array(OpenAPIParameterSchema).optional(), + requestBody: OpenAPIRequestBodySchema.optional(), + responses: z.record(OpenAPIResponseSchema).refine( + (responses) => { + // Check if any status code starts with '2' (i.e., 2xx) + const has2xx = Object.keys(responses).some((code) => + /^2\d{2}$/.test(code), + ); + // Check if 'default' is present + const hasDefault = "default" in responses; + return has2xx || hasDefault; + }, + { + message: + 'Responses must include at least a "200" or "default" status code.', + }, + ), + tags: z.array(z.string()).optional(), +}); + +const OpenAPIComponentsSchema = z.object({ + schemas: z.record(OpenAPISchemaSchema).optional(), + parameters: z.record(OpenAPIParameterSchema).optional(), + responses: z.record(OpenAPIResponseSchema).optional(), + requestBodies: z.record(OpenAPIRequestBodySchema).optional(), + headers: z.record(z.any()).optional(), + securitySchemes: z.record(z.any()).optional(), + links: z.record(z.any()).optional(), + callbacks: z.record(z.any()).optional(), +}); + +export const OpenApiPathItemSchema = z.record(OpenAPIOperationSchema); +export const OpenApiSpecSchema = z.object({ + paths: z.record(OpenApiPathItemSchema), + components: OpenAPIComponentsSchema.optional(), +}); +export const RefCacheSchema = z.map(z.string(), z.unknown()); + +export type OpenApiPathItem = z.infer; +export type OpenApiSpec = z.infer; +export type OpenAPIOperation = z.infer; +export type OpenAPIComponents = z.infer; +export type OpenAPISchema = z.infer; +export type OpenAPIRequestBody = z.infer; +export type RefCache = z.infer; + +export function isOpenApiSpec(value: unknown): value is OpenApiSpec { + const result = OpenApiSpecSchema.safeParse(value); + if (!result.success) { + console.error( + "[isOpenApiSpec] Error parsing OpenAPI spec:", + // JSON.stringify(result.error.format(), null, 2), + JSON.stringify(result.error.issues, null, 2), + ); + } + return result.success; +} + +export function validateReferences(spec: OpenApiSpec): boolean { + const refs = Array.from( + spec.components?.schemas ? Object.keys(spec.components.schemas) : [], + ); + let isValid = true; + + for (const pathItem of Object.values(spec.paths)) { + for (const operation of Object.values(pathItem)) { + for (const param of operation.parameters ?? []) { + if (param.schema?.$ref) { + const refName = param.schema.$ref.split("/").pop(); + if (refName && !refs.includes(refName)) { + logger.error( + `Reference ${param.schema.$ref} not found in components.schemas`, + ); + isValid = false; + } + } + } + + if (operation.requestBody?.content) { + for (const content of Object.values(operation.requestBody.content)) { + if (content.schema?.$ref) { + const refName = content.schema.$ref.split("/").pop(); + if (refName && !refs.includes(refName)) { + logger.error( + `Reference ${content.schema.$ref} not found in components.schemas`, + ); + isValid = false; + } + } + } + } + + // Similarly validate responses and other $ref usages + } + } + + return isValid; +} diff --git a/studio/src/pages/RequestorPage/store/slices/tabsSlice.ts b/studio/src/pages/RequestorPage/store/slices/tabsSlice.ts index bf605a19b..f28d5d5ae 100644 --- a/studio/src/pages/RequestorPage/store/slices/tabsSlice.ts +++ b/studio/src/pages/RequestorPage/store/slices/tabsSlice.ts @@ -27,7 +27,7 @@ export const tabsSlice: StateCreator< // Helper functions function isRequestsPanelTab(tab: string): tab is RequestsPanelTab { - return ["params", "headers", "body", "websocket"].includes(tab); + return ["params", "headers", "body", "docs", "websocket"].includes(tab); } function isResponsePanelTab(tab: string): tab is ResponsePanelTab { diff --git a/studio/src/pages/RequestorPage/store/tabs.ts b/studio/src/pages/RequestorPage/store/tabs.ts index 5c6c00b24..015849685 100644 --- a/studio/src/pages/RequestorPage/store/tabs.ts +++ b/studio/src/pages/RequestorPage/store/tabs.ts @@ -5,6 +5,7 @@ export const RequestsPanelTabSchema = z.enum([ "params", "headers", "body", + "docs", "messages", ]); From c5d1371bde75af1d6888b9674ac792b3b76eb74b Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Fri, 22 Nov 2024 14:40:19 +0100 Subject: [PATCH 34/67] Add a Docs tab --- studio/src/components/ui/scroll-area.tsx | 2 +- .../RequestPanel/RequestPanel.tsx | 21 +- .../RouteDocumentation/RouteDocumentation.tsx | 216 +++++++++++++----- .../RequestorPage/store/slices/routesSlice.ts | 1 + studio/src/pages/RequestorPage/store/tabs.ts | 13 +- 5 files changed, 179 insertions(+), 74 deletions(-) diff --git a/studio/src/components/ui/scroll-area.tsx b/studio/src/components/ui/scroll-area.tsx index 2fb736d81..8d09273be 100644 --- a/studio/src/components/ui/scroll-area.tsx +++ b/studio/src/components/ui/scroll-area.tsx @@ -23,4 +23,4 @@ export const ScrollArea = forwardRef< ); }); -ScrollArea.displayName = "ScrollArea"; \ No newline at end of file +ScrollArea.displayName = "ScrollArea"; diff --git a/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx b/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx index 928ee9687..d7f8cdebe 100644 --- a/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx +++ b/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx @@ -109,16 +109,13 @@ export const RequestPanel = memo(function RequestPanel( const shouldShowBody = shouldShowRequestTab("body"); const shouldShowMessages = shouldShowRequestTab("messages"); - const openApiSpec = useMemo( - () => { - try { - return JSON.parse(activeRoute?.openApiSpec ?? "{}"); - } catch (_e) { - return null; - } - }, - [activeRoute?.openApiSpec], - ); + const openApiSpec = useMemo(() => { + try { + return JSON.parse(activeRoute?.openApiSpec ?? "{}"); + } catch (_e) { + return null; + } + }, [activeRoute?.openApiSpec]); const shouldShowDocs = isValidOpenApiSpec(openApiSpec); return ( @@ -158,9 +155,7 @@ export const RequestPanel = memo(function RequestPanel(
)} {shouldShowDocs && ( - - Docs - + Docs )} {shouldShowMessages && ( diff --git a/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx b/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx index 59f980ce3..c10b68b8c 100644 --- a/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx +++ b/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx @@ -1,7 +1,7 @@ -import { memo } from "react"; import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { cn } from "@/utils"; +import { memo } from "react"; import type { OpenAPIOperation, OpenAPISchema } from "./openapi"; type RouteDocumentationProps = { @@ -11,45 +11,63 @@ type RouteDocumentationProps = { export const RouteDocumentation = memo(function RouteDocumentation({ openApiSpec, }: RouteDocumentationProps) { - const { parameters, requestBody, responses, description, summary } = openApiSpec; + const { parameters, requestBody, responses, description, summary } = + openApiSpec; return ( - -
+ +
{/* Description Section */} {(summary || description) && ( -
- {summary &&

{summary}

} - {description &&

{description}

} +
+ {summary && ( +

+ {summary} +

+ )} + {description && ( +

+ {description} +

+ )}
)} {/* Parameters Section */} {parameters && parameters.length > 0 && ( -
-

Parameters

-
+
+

+ Parameters +

+
{parameters.map((param, idx) => (
-
- {param.name} - +
+ + {param.name} + + {param.in} {param.required && ( - required + + required + )}
{param.description && ( -

+

{param.description}

)} {param.schema && ( - + )}
))} @@ -59,46 +77,66 @@ export const RouteDocumentation = memo(function RouteDocumentation({ {/* Request Body Section */} {requestBody && ( -
-

Request Body

-
- {Object.entries(requestBody.content).map(([mediaType, content]) => ( -
- {mediaType} - {content.schema && ( - - )} -
- ))} +
+

+ Request Body +

+
+ {Object.entries(requestBody.content).map( + ([mediaType, content]) => ( +
+ + {mediaType} + + {content.schema && ( + + )} +
+ ), + )}
)} {/* Responses Section */} {responses && ( -
-

Responses

-
+
+

+ Responses +

+
{Object.entries(responses).map(([status, response]) => ( -
-
- - {status} - +
+
+ {response.description}
{response.content && ( -
+
{Object.entries(response.content).map( ([mediaType, content]) => (
- + {mediaType} {content.schema && ( - + )}
), @@ -121,39 +159,103 @@ type SchemaViewerProps = { }; function SchemaViewer({ schema, className }: SchemaViewerProps) { - if (!schema.type) { + if (!schema || typeof schema !== "object") { return null; } return (
{schema.type === "object" && schema.properties ? ( -
- {Object.entries(schema.properties).map(([key, prop]) => { - const propSchema = prop as OpenAPISchema; - return ( -
- {key} - {propSchema.type} +
+ {Object.entries( + schema.properties as Record, + ).map(([key, prop]) => ( +
+
+ {key} + + {prop.type} + {schema.required?.includes(key) && ( - + required )}
- ); - })} + + {/* Description and Example section */} + {(prop.description || prop.example !== undefined) && ( +
+ {prop.description && ( +

{prop.description}

+ )} + {prop.example !== undefined && ( +
+ Example: + + {typeof prop.example === "string" + ? `"${prop.example}"` + : JSON.stringify(prop.example)} + +
+ )} +
+ )} +
+ ))}
) : ( -
- {schema.type} - {schema.enum && ( - - enum: [{schema.enum.join(", ")}] - +
+
+ + {schema.type} + + {schema.enum && ( + + enum: [{schema.enum.join(", ")}] + + )} +
+ + {/* Description and Example for non-object types */} + {(schema.description || schema.example !== undefined) && ( +
+ {schema.description && ( +

{schema.description}

+ )} + {schema.example !== undefined && ( +
+ Example: + + {typeof schema.example === "string" + ? `"${schema.example}"` + : JSON.stringify(schema.example)} + +
+ )} +
)}
)}
); -} \ No newline at end of file +} + +function StatusBadge({ status }: { status: string }) { + const variant: "default" | "destructive" | "outline" = "outline"; + let statusClass = "text-gray-400 border-gray-700"; + + if (status.startsWith("2")) { + statusClass = "bg-green-500/10 text-green-400 border-green-800"; + } else if (status.startsWith("4")) { + statusClass = "bg-orange-500/10 text-orange-400 border-orange-800"; + } else if (status.startsWith("5")) { + statusClass = "bg-red-500/10 text-red-400 border-red-800"; + } + + return ( + + {status} + + ); +} diff --git a/studio/src/pages/RequestorPage/store/slices/routesSlice.ts b/studio/src/pages/RequestorPage/store/slices/routesSlice.ts index 255b5553b..c6fc6769a 100644 --- a/studio/src/pages/RequestorPage/store/slices/routesSlice.ts +++ b/studio/src/pages/RequestorPage/store/slices/routesSlice.ts @@ -81,6 +81,7 @@ export const routesSlice: StateCreator< state.visibleRequestsPanelTabs = getVisibleRequestPanelTabs({ requestType: nextRequestType, method: nextMethod, + openApiSpec: route?.openApiSpec, }); state.activeRequestsPanelTab = state.visibleRequestsPanelTabs.includes( state.activeRequestsPanelTab, diff --git a/studio/src/pages/RequestorPage/store/tabs.ts b/studio/src/pages/RequestorPage/store/tabs.ts index 015849685..64088aad9 100644 --- a/studio/src/pages/RequestorPage/store/tabs.ts +++ b/studio/src/pages/RequestorPage/store/tabs.ts @@ -18,14 +18,21 @@ export const isRequestsPanelTab = (tab: unknown): tab is RequestsPanelTab => { export const getVisibleRequestPanelTabs = (route: { requestType: RequestType; method: RequestMethod; + openApiSpec: unknown | undefined; }): RequestsPanelTab[] => { + const hasDocs = !!route.openApiSpec; + let result: RequestsPanelTab[] = []; if (route.requestType === "websocket") { - return ["params", "headers", "messages"]; + result = ["params", "headers", "messages"]; } if (route.method === "GET" || route.method === "HEAD") { - return ["params", "headers"]; + result = ["params", "headers"]; } - return ["params", "headers", "body"]; + result = ["params", "headers", "body"]; + if (hasDocs) { + result.push("docs"); + } + return result; }; export const ResponsePanelTabSchema = z.enum([ From a4e89e586806efc6b30f360215f847a28df8805e Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Fri, 22 Nov 2024 15:10:47 +0100 Subject: [PATCH 35/67] Implement request panel navigation in command menu --- .../RequestorPage/CommandBar/CommandBar.tsx | 72 ++++++++++++------- .../pages/RequestorPage/CommandBar/index.tsx | 2 + .../RequestorPageContent.tsx | 25 ++++++- 3 files changed, 71 insertions(+), 28 deletions(-) create mode 100644 studio/src/pages/RequestorPage/CommandBar/index.tsx diff --git a/studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx b/studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx index 10f376ac7..198490236 100644 --- a/studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx +++ b/studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx @@ -8,41 +8,41 @@ import { CommandSeparator, } from "@/components/ui/command"; import { Dialog, DialogContent } from "@/components/ui/dialog"; -import { - ChatBubbleIcon, - ClockIcon, - FileTextIcon, - LayoutIcon, - MagicWandIcon, -} from "@radix-ui/react-icons"; +import { Icon } from "@iconify/react/dist/iconify.js"; import React from "react"; +import { useRequestorStore } from "../store"; interface CommandBarProps { open: boolean; setOpen: (open: boolean) => void; onGenerateRequest?: (prompt?: string) => void; - togglePanel: (panel: "timelinePanel" | "logsPanel" | "aiPanel") => void; } export function CommandBar({ open, setOpen, - onGenerateRequest, - togglePanel, + // onGenerateRequest, }: CommandBarProps) { + const { togglePanel, visibleRequestsPanelTabs, setActiveRequestsPanelTab } = + useRequestorStore( + "togglePanel", + "visibleRequestsPanelTabs", + "setActiveRequestsPanelTab", + ); + const [inputValue, setInputValue] = React.useState(""); - const handleGenerateRequest = (currentInput: string) => { - // Extract any text after "generate" as the prompt - const prompt = currentInput.replace(/^generate\s*/, "").trim(); - onGenerateRequest?.(prompt.length > 0 ? prompt : undefined); - setOpen(false); - setInputValue(""); - }; + // const handleGenerateRequest = (currentInput: string) => { + // // Extract any text after "generate" as the prompt + // const prompt = currentInput.replace(/^generate\s*/, "").trim(); + // onGenerateRequest?.(prompt.length > 0 ? prompt : undefined); + // setOpen(false); + // setInputValue(""); + // }; return ( - - + + No results found. - + {/* handleGenerateRequest(inputValue)} @@ -65,8 +65,8 @@ export function CommandBar({ Type additional text for custom prompt - - + */} + {/* */} - + Toggle Timeline Panel - + Toggle Logs Panel - + Toggle AI Panel + + {visibleRequestsPanelTabs.map((tabName) => { + return ( + { + setActiveRequestsPanelTab(tabName); + setOpen(false); + }} + > + + + Open Request {tabName} + + + ); + })} + + {/* Recent Requests - + */} diff --git a/studio/src/pages/RequestorPage/CommandBar/index.tsx b/studio/src/pages/RequestorPage/CommandBar/index.tsx new file mode 100644 index 000000000..a3af3dd4a --- /dev/null +++ b/studio/src/pages/RequestorPage/CommandBar/index.tsx @@ -0,0 +1,2 @@ +export { AiPromptInput } from "./AiPromptInput"; +export { CommandBar } from "./CommandBar"; diff --git a/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx b/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx index 011a1f375..adc4ea652 100644 --- a/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx +++ b/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx @@ -11,7 +11,7 @@ import { useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { type To, useNavigate } from "react-router-dom"; import { useShallow } from "zustand/react/shallow"; -import { AiPromptInput } from "../CommandBar/AiPromptInput"; +import { AiPromptInput, CommandBar } from "../CommandBar"; import { RequestPanel } from "../RequestPanel"; import { RequestorInput } from "../RequestorInput"; import { ResponsePanel } from "../ResponsePanel"; @@ -115,14 +115,34 @@ export const RequestorPageContent: React.FC = ( const isLgScreen = useIsLgScreen(); - const { togglePanel, setAIDropdownOpen, setAiPrompt } = useRequestorStore( + const { + togglePanel, + setAIDropdownOpen, + setAiPrompt, + visibleRequestsPanelTabs, + setActiveRequestsPanelTab, + } = useRequestorStore( "togglePanel", "setAIDropdownOpen", "setAiPrompt", + "visibleRequestsPanelTabs", + "setActiveRequestsPanelTab", ); + const [commandBarOpen, setCommandBarOpen] = useState(false); const [aiPromptOpen, setAiPromptOpen] = useState(false); + useHotkeys( + "mod+k", + (e) => { + e.preventDefault(); + setCommandBarOpen(true); + }, + { + enableOnFormTags: ["input"], + }, + ); + useHotkeys( "mod+g", (e) => { @@ -260,6 +280,7 @@ export const RequestorPageContent: React.FC = ( } }} /> + Date: Fri, 22 Nov 2024 15:28:26 +0100 Subject: [PATCH 36/67] Factor out openapi types --- api/src/lib/openapi/types.ts | 130 ++++----------- packages/types/src/index.ts | 1 + packages/types/src/openapi.ts | 125 +++++++++++++++ .../RouteDocumentation/openapi.ts | 150 +----------------- 4 files changed, 157 insertions(+), 249 deletions(-) create mode 100644 packages/types/src/openapi.ts diff --git a/api/src/lib/openapi/types.ts b/api/src/lib/openapi/types.ts index c47451e32..f4c3bf85b 100644 --- a/api/src/lib/openapi/types.ts +++ b/api/src/lib/openapi/types.ts @@ -1,110 +1,36 @@ +import { + OpenAPIComponents, + OpenAPIOperation, + OpenAPIRequestBody, + OpenAPISchema, + OpenApiPathItem, + OpenApiPathItemSchema, + OpenApiSpec, + OpenApiSpecSchema, + // OpenAPIOperationSchema, + // OpenAPIComponentsSchema, + // OpenAPISchemaSchema, + // OpenAPIRequestBodySchema, +} from "@fiberplane/fpx-types"; import { z } from "zod"; import logger from "../../logger/index.js"; -// Create a schema for references -const SchemaRefSchema = z.object({ - $ref: z.string(), -}); +export { + OpenApiPathItem, + OpenApiSpec, + OpenAPIOperation, + OpenAPIComponents, + OpenAPISchema, + OpenAPIRequestBody, + OpenApiPathItemSchema, + OpenApiSpecSchema, + // OpenAPIOperationSchema, + // OpenAPIComponentsSchema, + // OpenAPISchemaSchema, + // OpenAPIRequestBodySchema, +}; -// Create a schema for direct type definitions -const SchemaTypeSchema = z.object({ - $ref: z.undefined(), - type: z.enum(["string", "number", "integer", "boolean", "array", "object"]), - format: z.string().optional(), - enum: z.array(z.string()).optional(), - default: z.any().optional(), - description: z.string().optional(), - // Add other relevant OpenAPI schema properties here -}); - -// Combine them with a union instead of discriminatedUnion -const SchemaSchema = z.union([SchemaRefSchema, SchemaTypeSchema]); - -// Use this in OpenApiSpecSchema where schema validation is needed -const ContentSchema = z.object({ - schema: SchemaSchema, -}); - -const OpenAPIParameterSchema = z.object({ - name: z.string(), - in: z.enum(["query", "header", "path", "cookie"]), - // TODO - Path parameters must have "required" set to true - required: z.boolean().optional(), - schema: SchemaSchema.optional(), - description: z.string().optional(), -}); - -const OpenAPIResponseSchema = z.object({ - description: z.string(), - content: z.record(ContentSchema).optional(), -}); - -const OpenAPISchemaSchema: z.ZodType = z.lazy(() => - z.object({ - type: z.string().optional(), - properties: z.record(OpenAPISchemaSchema).optional(), - items: OpenAPISchemaSchema.optional(), - $ref: z.string().optional(), - required: z.array(z.string()).optional(), - additionalProperties: z.boolean().optional(), - allOf: z.array(OpenAPISchemaSchema).optional(), - anyOf: z.array(OpenAPISchemaSchema).optional(), - oneOf: z.array(OpenAPISchemaSchema).optional(), - // Add other complex schema properties as needed - }), -); - -const OpenAPIRequestBodySchema = z.object({ - content: z.record(ContentSchema), -}); - -const OpenAPIOperationSchema = z.object({ - summary: z.string().optional(), - description: z.string().optional(), - parameters: z.array(OpenAPIParameterSchema).optional(), - requestBody: OpenAPIRequestBodySchema.optional(), - responses: z.record(OpenAPIResponseSchema).refine( - (responses) => { - // Check if any status code starts with '2' (i.e., 2xx) - const has2xx = Object.keys(responses).some((code) => - /^2\d{2}$/.test(code), - ); - // Check if 'default' is present - const hasDefault = "default" in responses; - return has2xx || hasDefault; - }, - { - message: - 'Responses must include at least a "200" or "default" status code.', - }, - ), - tags: z.array(z.string()).optional(), -}); - -const OpenAPIComponentsSchema = z.object({ - schemas: z.record(OpenAPISchemaSchema).optional(), - parameters: z.record(OpenAPIParameterSchema).optional(), - responses: z.record(OpenAPIResponseSchema).optional(), - requestBodies: z.record(OpenAPIRequestBodySchema).optional(), - headers: z.record(z.any()).optional(), - securitySchemes: z.record(z.any()).optional(), - links: z.record(z.any()).optional(), - callbacks: z.record(z.any()).optional(), -}); - -export const OpenApiPathItemSchema = z.record(OpenAPIOperationSchema); -export const OpenApiSpecSchema = z.object({ - paths: z.record(OpenApiPathItemSchema), - components: OpenAPIComponentsSchema.optional(), -}); export const RefCacheSchema = z.map(z.string(), z.unknown()); - -export type OpenApiPathItem = z.infer; -export type OpenApiSpec = z.infer; -export type OpenAPIOperation = z.infer; -export type OpenAPIComponents = z.infer; -export type OpenAPISchema = z.infer; -export type OpenAPIRequestBody = z.infer; export type RefCache = z.infer; export function isOpenApiSpec(value: unknown): value is OpenApiSpec { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 5110b7fb8..5ac0b60e1 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,4 +1,5 @@ export * from "./api.js"; +export * from "./openapi.js"; export * from "./otel.js"; export * from "./schemas.js"; export * from "./settings.js"; diff --git a/packages/types/src/openapi.ts b/packages/types/src/openapi.ts new file mode 100644 index 000000000..cceb19850 --- /dev/null +++ b/packages/types/src/openapi.ts @@ -0,0 +1,125 @@ +import { z } from "zod"; + +// Create a schema for references +const SchemaRefSchema = z.object({ + $ref: z.string(), +}); + +// Create a schema for direct type definitions +const SchemaTypeSchema = z.object({ + $ref: z.undefined(), + type: z.enum(["string", "number", "integer", "boolean", "array", "object"]), + format: z.string().optional(), + enum: z.array(z.string()).optional(), + default: z.any().optional(), + description: z.string().optional(), + // Add other relevant OpenAPI schema properties here +}); + +// Combine them with a union instead of discriminatedUnion +const SchemaSchema = z.union([SchemaRefSchema, SchemaTypeSchema]); + +// Use this in OpenApiSpecSchema where schema validation is needed +const ContentSchema = z.object({ + schema: SchemaSchema, +}); + +const OpenAPIParameterSchema = z.object({ + name: z.string(), + in: z.enum(["query", "header", "path", "cookie"]), + // TODO - Path parameters must have "required" set to true + required: z.boolean().optional(), + schema: SchemaSchema.optional(), + description: z.string().optional(), +}); + +const OpenAPIResponseSchema = z.object({ + description: z.string(), + content: z.record(ContentSchema).optional(), +}); + +// HACK - This is a workaround for typing the recursive OpenAPISchemaSchema +// because I haven't looked up how to do this properly with zod yet. +type OpenAPISchemaTypeExplicit = { + type?: string; + description?: string; + example?: string; + properties?: Record; + items?: OpenAPISchemaTypeExplicit; + $ref?: string; + required?: string[]; + additionalProperties?: boolean; + allOf?: OpenAPISchemaTypeExplicit[]; + anyOf?: OpenAPISchemaTypeExplicit[]; + oneOf?: OpenAPISchemaTypeExplicit[]; + enum?: (string | number | boolean)[]; +}; + +const OpenAPISchemaSchema: z.ZodType = z.lazy(() => + z.object({ + type: z.string().optional(), + description: z.string().optional(), + example: z.string().optional(), + properties: z.record(OpenAPISchemaSchema).optional(), + items: OpenAPISchemaSchema.optional(), + $ref: z.string().optional(), + required: z.array(z.string()).optional(), + additionalProperties: z.boolean().optional(), + allOf: z.array(OpenAPISchemaSchema).optional(), + anyOf: z.array(OpenAPISchemaSchema).optional(), + oneOf: z.array(OpenAPISchemaSchema).optional(), + enum: z.array(z.union([z.string(), z.number(), z.boolean()])).optional(), + // Add other complex schema properties as needed + }), +); + +const OpenAPIRequestBodySchema = z.object({ + content: z.record(ContentSchema), +}); + +const OpenAPIOperationSchema = z.object({ + summary: z.string().optional(), + description: z.string().optional(), + parameters: z.array(OpenAPIParameterSchema).optional(), + requestBody: OpenAPIRequestBodySchema.optional(), + responses: z.record(OpenAPIResponseSchema).refine( + (responses) => { + // Check if any status code starts with '2' (i.e., 2xx) + const has2xx = Object.keys(responses).some((code) => + /^2\d{2}$/.test(code), + ); + // Check if 'default' is present + const hasDefault = "default" in responses; + return has2xx || hasDefault; + }, + { + message: + 'Responses must include at least a "200" or "default" status code.', + }, + ), + tags: z.array(z.string()).optional(), +}); + +const OpenAPIComponentsSchema = z.object({ + schemas: z.record(OpenAPISchemaSchema).optional(), + parameters: z.record(OpenAPIParameterSchema).optional(), + responses: z.record(OpenAPIResponseSchema).optional(), + requestBodies: z.record(OpenAPIRequestBodySchema).optional(), + headers: z.record(z.any()).optional(), + securitySchemes: z.record(z.any()).optional(), + links: z.record(z.any()).optional(), + callbacks: z.record(z.any()).optional(), +}); + +export const OpenApiPathItemSchema = z.record(OpenAPIOperationSchema); +export const OpenApiSpecSchema = z.object({ + paths: z.record(OpenApiPathItemSchema), + components: OpenAPIComponentsSchema.optional(), +}); + +export type OpenApiPathItem = z.infer; +export type OpenApiSpec = z.infer; +export type OpenAPIOperation = z.infer; +export type OpenAPIComponents = z.infer; +export type OpenAPISchema = z.infer; +export type OpenAPIRequestBody = z.infer; diff --git a/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/openapi.ts b/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/openapi.ts index b9655ab1e..0538ea218 100644 --- a/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/openapi.ts +++ b/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/openapi.ts @@ -1,110 +1,7 @@ -import { z } from "zod"; +import { type OpenApiSpec, OpenApiSpecSchema } from "@fiberplane/fpx-types"; -// Create a schema for references -const SchemaRefSchema = z.object({ - $ref: z.string(), -}); - -// Create a schema for direct type definitions -const SchemaTypeSchema = z.object({ - $ref: z.undefined(), - type: z.enum(["string", "number", "integer", "boolean", "array", "object"]), - format: z.string().optional(), - enum: z.array(z.string()).optional(), - default: z.any().optional(), - description: z.string().optional(), - // Add other relevant OpenAPI schema properties here -}); - -// Combine them with a union instead of discriminatedUnion -const SchemaSchema = z.union([SchemaRefSchema, SchemaTypeSchema]); - -// Use this in OpenApiSpecSchema where schema validation is needed -const ContentSchema = z.object({ - schema: SchemaSchema, -}); - -const OpenAPIParameterSchema = z.object({ - name: z.string(), - in: z.enum(["query", "header", "path", "cookie"]), - // TODO - Path parameters must have "required" set to true - required: z.boolean().optional(), - schema: SchemaSchema.optional(), - description: z.string().optional(), -}); - -const OpenAPIResponseSchema = z.object({ - description: z.string(), - content: z.record(ContentSchema).optional(), -}); - -const OpenAPISchemaSchema: z.ZodType = z.lazy(() => - z.object({ - type: z.string().optional(), - properties: z.record(OpenAPISchemaSchema).optional(), - items: OpenAPISchemaSchema.optional(), - $ref: z.string().optional(), - required: z.array(z.string()).optional(), - additionalProperties: z.boolean().optional(), - allOf: z.array(OpenAPISchemaSchema).optional(), - anyOf: z.array(OpenAPISchemaSchema).optional(), - oneOf: z.array(OpenAPISchemaSchema).optional(), - // Add other complex schema properties as needed - }), -); - -const OpenAPIRequestBodySchema = z.object({ - content: z.record(ContentSchema), -}); - -const OpenAPIOperationSchema = z.object({ - summary: z.string().optional(), - description: z.string().optional(), - parameters: z.array(OpenAPIParameterSchema).optional(), - requestBody: OpenAPIRequestBodySchema.optional(), - responses: z.record(OpenAPIResponseSchema).refine( - (responses) => { - // Check if any status code starts with '2' (i.e., 2xx) - const has2xx = Object.keys(responses).some((code) => - /^2\d{2}$/.test(code), - ); - // Check if 'default' is present - const hasDefault = "default" in responses; - return has2xx || hasDefault; - }, - { - message: - 'Responses must include at least a "200" or "default" status code.', - }, - ), - tags: z.array(z.string()).optional(), -}); - -const OpenAPIComponentsSchema = z.object({ - schemas: z.record(OpenAPISchemaSchema).optional(), - parameters: z.record(OpenAPIParameterSchema).optional(), - responses: z.record(OpenAPIResponseSchema).optional(), - requestBodies: z.record(OpenAPIRequestBodySchema).optional(), - headers: z.record(z.any()).optional(), - securitySchemes: z.record(z.any()).optional(), - links: z.record(z.any()).optional(), - callbacks: z.record(z.any()).optional(), -}); - -export const OpenApiPathItemSchema = z.record(OpenAPIOperationSchema); -export const OpenApiSpecSchema = z.object({ - paths: z.record(OpenApiPathItemSchema), - components: OpenAPIComponentsSchema.optional(), -}); -export const RefCacheSchema = z.map(z.string(), z.unknown()); - -export type OpenApiPathItem = z.infer; -export type OpenApiSpec = z.infer; -export type OpenAPIOperation = z.infer; -export type OpenAPIComponents = z.infer; -export type OpenAPISchema = z.infer; -export type OpenAPIRequestBody = z.infer; -export type RefCache = z.infer; +export type { OpenAPIOperation } from "@fiberplane/fpx-types"; +export type { OpenAPISchema } from "@fiberplane/fpx-types"; export function isOpenApiSpec(value: unknown): value is OpenApiSpec { const result = OpenApiSpecSchema.safeParse(value); @@ -117,44 +14,3 @@ export function isOpenApiSpec(value: unknown): value is OpenApiSpec { } return result.success; } - -export function validateReferences(spec: OpenApiSpec): boolean { - const refs = Array.from( - spec.components?.schemas ? Object.keys(spec.components.schemas) : [], - ); - let isValid = true; - - for (const pathItem of Object.values(spec.paths)) { - for (const operation of Object.values(pathItem)) { - for (const param of operation.parameters ?? []) { - if (param.schema?.$ref) { - const refName = param.schema.$ref.split("/").pop(); - if (refName && !refs.includes(refName)) { - logger.error( - `Reference ${param.schema.$ref} not found in components.schemas`, - ); - isValid = false; - } - } - } - - if (operation.requestBody?.content) { - for (const content of Object.values(operation.requestBody.content)) { - if (content.schema?.$ref) { - const refName = content.schema.$ref.split("/").pop(); - if (refName && !refs.includes(refName)) { - logger.error( - `Reference ${content.schema.$ref} not found in components.schemas`, - ); - isValid = false; - } - } - } - } - - // Similarly validate responses and other $ref usages - } - } - - return isValid; -} From ce217a5864a044eacf576d5a2fa56498c43ac25e Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Fri, 22 Nov 2024 15:31:12 +0100 Subject: [PATCH 37/67] Fix issue where activeRoute did not update when method was manually changed --- .../RequestorPageContent/RequestorPageContent.tsx | 10 +--------- .../RequestorPage/store/slices/requestResponseSlice.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx b/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx index adc4ea652..740353d5c 100644 --- a/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx +++ b/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx @@ -115,18 +115,10 @@ export const RequestorPageContent: React.FC = ( const isLgScreen = useIsLgScreen(); - const { - togglePanel, - setAIDropdownOpen, - setAiPrompt, - visibleRequestsPanelTabs, - setActiveRequestsPanelTab, - } = useRequestorStore( + const { togglePanel, setAIDropdownOpen, setAiPrompt } = useRequestorStore( "togglePanel", "setAIDropdownOpen", "setAiPrompt", - "visibleRequestsPanelTabs", - "setActiveRequestsPanelTab", ); const [commandBarOpen, setCommandBarOpen] = useState(false); diff --git a/studio/src/pages/RequestorPage/store/slices/requestResponseSlice.ts b/studio/src/pages/RequestorPage/store/slices/requestResponseSlice.ts index 933fe6000..55682e88b 100644 --- a/studio/src/pages/RequestorPage/store/slices/requestResponseSlice.ts +++ b/studio/src/pages/RequestorPage/store/slices/requestResponseSlice.ts @@ -80,11 +80,20 @@ export const requestResponseSlice: StateCreator< // Update other state properties based on the new method and request type // (e.g., activeRoute, visibleRequestsPanelTabs, activeRequestsPanelTab, etc.) // You might want to move some of this logic to separate functions or slices + const matchedRoute = findMatchedRoute( + state.routes, + removeBaseUrl(state.serviceBaseUrl, state.path), + state.method, + state.requestType, + ); + const nextActiveRoute = matchedRoute ? matchedRoute.route : null; + state.activeRoute = nextActiveRoute; // Update visibleRequestsPanelTabs based on the new method and request type state.visibleRequestsPanelTabs = getVisibleRequestPanelTabs({ requestType, method, + openApiSpec: state.activeRoute?.openApiSpec, }); // Ensure the activeRequestsPanelTab is valid From f1c18475acdca1c6ef46ca99749e88e06ee86de2 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Fri, 22 Nov 2024 15:37:25 +0100 Subject: [PATCH 38/67] Fix openapi types and type imports --- api/src/lib/openapi/types.ts | 28 ++++++++++++---------------- packages/types/src/openapi.ts | 4 ++-- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/api/src/lib/openapi/types.ts b/api/src/lib/openapi/types.ts index f4c3bf85b..caef8588e 100644 --- a/api/src/lib/openapi/types.ts +++ b/api/src/lib/openapi/types.ts @@ -1,27 +1,23 @@ import { - OpenAPIComponents, - OpenAPIOperation, - OpenAPIRequestBody, - OpenAPISchema, - OpenApiPathItem, + type OpenAPIComponents, + type OpenAPIOperation, + type OpenAPIRequestBody, + type OpenAPISchema, + type OpenApiPathItem, OpenApiPathItemSchema, - OpenApiSpec, + type OpenApiSpec, OpenApiSpecSchema, - // OpenAPIOperationSchema, - // OpenAPIComponentsSchema, - // OpenAPISchemaSchema, - // OpenAPIRequestBodySchema, } from "@fiberplane/fpx-types"; import { z } from "zod"; import logger from "../../logger/index.js"; export { - OpenApiPathItem, - OpenApiSpec, - OpenAPIOperation, - OpenAPIComponents, - OpenAPISchema, - OpenAPIRequestBody, + type OpenApiPathItem, + type OpenApiSpec, + type OpenAPIOperation, + type OpenAPIComponents, + type OpenAPISchema, + type OpenAPIRequestBody, OpenApiPathItemSchema, OpenApiSpecSchema, // OpenAPIOperationSchema, diff --git a/packages/types/src/openapi.ts b/packages/types/src/openapi.ts index cceb19850..141920219 100644 --- a/packages/types/src/openapi.ts +++ b/packages/types/src/openapi.ts @@ -43,7 +43,7 @@ const OpenAPIResponseSchema = z.object({ type OpenAPISchemaTypeExplicit = { type?: string; description?: string; - example?: string; + example?: string | number | boolean; properties?: Record; items?: OpenAPISchemaTypeExplicit; $ref?: string; @@ -59,7 +59,7 @@ const OpenAPISchemaSchema: z.ZodType = z.lazy(() => z.object({ type: z.string().optional(), description: z.string().optional(), - example: z.string().optional(), + example: z.union([z.string(), z.number(), z.boolean()]).optional(), properties: z.record(OpenAPISchemaSchema).optional(), items: OpenAPISchemaSchema.optional(), $ref: z.string().optional(), From cfc3691db40b809a6398ed9341c654e9f82453f1 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Mon, 25 Nov 2024 15:15:11 +0100 Subject: [PATCH 39/67] Add a query parameter description --- examples/openapi-zod/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/openapi-zod/src/index.ts b/examples/openapi-zod/src/index.ts index 2803208c6..2a120b474 100644 --- a/examples/openapi-zod/src/index.ts +++ b/examples/openapi-zod/src/index.ts @@ -32,6 +32,7 @@ const NewUserSchema = z .object({ name: z.string().openapi({ example: "John Doe", + description: "The name of the user", }), age: z.number().openapi({ example: 42, From 99c3085694bd5fea98a320a078f52c9b57d5aeb2 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Fri, 29 Nov 2024 13:32:24 +0100 Subject: [PATCH 40/67] Add descriptions to openApiSpec fields in schemaProbedRoutes --- api/src/lib/app-routes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/lib/app-routes.ts b/api/src/lib/app-routes.ts index 6fcfbe6f6..6e593ec22 100644 --- a/api/src/lib/app-routes.ts +++ b/api/src/lib/app-routes.ts @@ -6,14 +6,14 @@ import * as schema from "../db/schema.js"; const { appRoutes } = schema; export const schemaProbedRoutes = z.object({ - openApiSpec: z.unknown().nullish(), + openApiSpec: z.unknown().nullish().describe("OpenAPI spec for the entire api"), routes: z.array( z.object({ method: z.string(), path: z.string(), handler: z.string(), handlerType: z.string(), - openApiSpec: z.string().nullish(), + openApiSpec: z.string().nullish().describe("OpenAPI spec for the singular route"), }), ), }); From 9ce40d4f7ae6095d534312be46a4d2f3f6f8f9a2 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Fri, 29 Nov 2024 13:42:45 +0100 Subject: [PATCH 41/67] Improve documentation and typing of the spec fetching --- api/src/lib/app-routes.ts | 10 +++++++-- api/src/lib/openapi/fetch.ts | 39 +++++++++++++++++++++++++--------- api/src/lib/openapi/openapi.ts | 19 +++++++++++------ api/src/lib/openapi/types.ts | 2 +- 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/api/src/lib/app-routes.ts b/api/src/lib/app-routes.ts index 6e593ec22..8e04b3558 100644 --- a/api/src/lib/app-routes.ts +++ b/api/src/lib/app-routes.ts @@ -6,14 +6,20 @@ import * as schema from "../db/schema.js"; const { appRoutes } = schema; export const schemaProbedRoutes = z.object({ - openApiSpec: z.unknown().nullish().describe("OpenAPI spec for the entire api"), + openApiSpec: z + .unknown() + .nullish() + .describe("OpenAPI spec for the entire api"), routes: z.array( z.object({ method: z.string(), path: z.string(), handler: z.string(), handlerType: z.string(), - openApiSpec: z.string().nullish().describe("OpenAPI spec for the singular route"), + openApiSpec: z + .string() + .nullish() + .describe("OpenAPI spec for the singular route"), }), ), }); diff --git a/api/src/lib/openapi/fetch.ts b/api/src/lib/openapi/fetch.ts index 88ef9aa04..9e04bed0a 100644 --- a/api/src/lib/openapi/fetch.ts +++ b/api/src/lib/openapi/fetch.ts @@ -3,7 +3,7 @@ import type * as schema from "../../db/schema.js"; import logger from "../../logger/index.js"; import { resolveServiceArg } from "../../probe-routes.js"; import { getAllSettings } from "../settings/index.js"; -import type { OpenApiSpec } from "./types.js"; +import { type OpenApiSpec, isOpenApiSpec } from "./types.js"; type CachedResponse = { data: OpenApiSpec; @@ -32,7 +32,7 @@ const CACHE_TTL_MS = 3000; // 3 seconds async function cachedSpecFetch( url: string, options: RequestInit & { ttl?: number } = {}, -): Promise { +): Promise { const { ttl = CACHE_TTL_MS, ...fetchOptions } = options; // Check cache @@ -48,7 +48,7 @@ async function cachedSpecFetch( logger.error(`Error fetching from ${url}: ${response.statusText}`); return null; } - const data = (await response.json()) as OpenApiSpec; + const data = await response.json(); // Update cache specResponseCache.set(url, { @@ -66,10 +66,14 @@ async function cachedSpecFetch( /** * Fetches and parses an OpenAPI specification from a configured URL. * - * @NOTE This function does not validate the payload returned by the OpenAPI spec URL, - * it only makes a type assertion. + * @NOTE Caching behavior is here for backwards compatibility with verions of the otel client library + * that do not support the `x-fpx-ignore` header. + * We can remove the cache once we know that all users have updated to a version of the otel client library + * that supports the `x-fpx-ignore` header (0.4.1+) * * @param db - The database instance to retrieve settings from + * @param responseTtlMs - Cache time in milliseconds (defaults to CACHE_TTL_MS) + * * @returns Promise that resolves to the parsed OpenAPI specification object, or null if: * - No spec URL is configured * - The URL cannot be resolved @@ -82,29 +86,44 @@ export async function fetchOpenApiSpec( ): Promise { const specUrl = await getSpecUrl(db); if (!specUrl) { - logger.debug("No OpenAPI spec URL found"); + logger.debug( + "[fetchOpenApiSpec] No OpenAPI spec URL found in settings table", + ); return null; } const resolvedSpecUrl = resolveSpecUrl(specUrl); if (!resolvedSpecUrl) { - logger.debug("No resolved OpenAPI spec URL found"); + logger.debug( + "[fetchOpenApiSpec] Could not resolve OpenAPI spec URL we got from settings:", + specUrl, + ); return null; } - // NOTE - This is to avoid infinite loops when the OpenAPI spec is fetched from Studio - // I.e., it's possible that the OpenAPI spec is fetched from the target service, + // NOTE - The caching and special headers are here to avoid infinite loops when the OpenAPI spec is fetched. + // I.e., it's possible that the OpenAPI spec is fetched from the target service directly, // in which case, making a request to the target service from Studio will result in the app reporting its routes to Studio, // which will then re-trigger a fetch of the OpenAPI spec from Studio, and so on. // // If we want to rely on `x-fpx-ignore`, then we need to make sure that the user has instrumented their app with @fiberplane/hono-otel >= 0.4.1 // Since we don't have a way to check the version of @fiberplane/hono-otel in the app yet, we're going with the hacky cache for now. // - return cachedSpecFetch(resolvedSpecUrl, { + const spec = await cachedSpecFetch(resolvedSpecUrl, { headers: { "x-fpx-ignore": "true", }, ttl: responseTtlMs, }); + + const isValidSpec = isOpenApiSpec(spec); + if (spec && !isValidSpec) { + logger.warn( + "[fetchOpenApiSpec] Received invalid OpenAPI spec from target service:", + spec, + ); + } + + return isValidSpec ? spec : null; } /** diff --git a/api/src/lib/openapi/openapi.ts b/api/src/lib/openapi/openapi.ts index b92eba2bd..8658fac01 100644 --- a/api/src/lib/openapi/openapi.ts +++ b/api/src/lib/openapi/openapi.ts @@ -15,12 +15,17 @@ import { type OpenAPIOperation, isOpenApiSpec } from "./types.js"; type Routes = z.infer["routes"]; /** - * Enriches API routes with their corresponding OpenAPI specifications by fetching and mapping - * OpenAPI definitions to Hono routes. This function handles both single routes and arrays of routes. + * Enriches API routes with their corresponding OpenAPI specifications by fetching OpenAPI definitions + * and mapping the routes in the spec to Hono routes. + * + * If an `openApiSpec` is provided, it will be used instead of fetching the latest spec from the database. + * + * This function handles both single routes and arrays of routes. * * @param db - LibSQL database instance containing the OpenAPI specifications. Used to fetch the latest spec. * @param routes - Single route or array of routes to be enriched. Each route should contain path, method, * and handlerType properties for proper matching with OpenAPI specs. + * @param openApiSpec - Optional OpenAPI specification to use instead of fetching from the database. * * @returns Array of enriched routes. Each route will contain all original properties plus an * `openApiSpec` property that is either: @@ -40,11 +45,11 @@ export async function addOpenApiSpecToRoutes( routes: Routes, openApiSpec: unknown, ): Promise { - // Validate openApiSpec is a valid OpenAPI spec object before using it - const spec = - openApiSpec && isOpenApiSpec(openApiSpec) - ? openApiSpec - : await fetchOpenApiSpec(db); + // Only fetch the spec from the database if the provided spec is not a valid OpenAPI spec + const shouldFetchSpec = !openApiSpec || !isOpenApiSpec(openApiSpec); + const spec = shouldFetchSpec ? await fetchOpenApiSpec(db) : openApiSpec; + + // Return the routes unchanged if there's no spec to draw from if (!spec) { return routes; } diff --git a/api/src/lib/openapi/types.ts b/api/src/lib/openapi/types.ts index caef8588e..f78fd0e1b 100644 --- a/api/src/lib/openapi/types.ts +++ b/api/src/lib/openapi/types.ts @@ -33,7 +33,7 @@ export function isOpenApiSpec(value: unknown): value is OpenApiSpec { const result = OpenApiSpecSchema.safeParse(value); if (!result.success) { logger.error( - "[isOpenApiSpec] Error parsing OpenAPI spec:", + "[isOpenApiSpec] Error parsing truthy OpenAPI spec:", // JSON.stringify(result.error.format(), null, 2), JSON.stringify(result.error.issues, null, 2), ); From 5aab89628489045e54cee175bf8eb25e0e90f96d Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Fri, 29 Nov 2024 13:45:38 +0100 Subject: [PATCH 42/67] Remove dead code --- api/src/lib/openapi/types.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/api/src/lib/openapi/types.ts b/api/src/lib/openapi/types.ts index f78fd0e1b..431b193c4 100644 --- a/api/src/lib/openapi/types.ts +++ b/api/src/lib/openapi/types.ts @@ -20,10 +20,6 @@ export { type OpenAPIRequestBody, OpenApiPathItemSchema, OpenApiSpecSchema, - // OpenAPIOperationSchema, - // OpenAPIComponentsSchema, - // OpenAPISchemaSchema, - // OpenAPIRequestBodySchema, }; export const RefCacheSchema = z.map(z.string(), z.unknown()); From c23c52f0f67be7fa85b520c4e6eddcc3e6179e84 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Fri, 29 Nov 2024 13:46:45 +0100 Subject: [PATCH 43/67] Remove dead code --- api/src/lib/openapi/types.ts | 42 ------------------------------------ 1 file changed, 42 deletions(-) diff --git a/api/src/lib/openapi/types.ts b/api/src/lib/openapi/types.ts index 431b193c4..8f8a184d9 100644 --- a/api/src/lib/openapi/types.ts +++ b/api/src/lib/openapi/types.ts @@ -30,50 +30,8 @@ export function isOpenApiSpec(value: unknown): value is OpenApiSpec { if (!result.success) { logger.error( "[isOpenApiSpec] Error parsing truthy OpenAPI spec:", - // JSON.stringify(result.error.format(), null, 2), JSON.stringify(result.error.issues, null, 2), ); } return result.success; } - -export function validateReferences(spec: OpenApiSpec): boolean { - const refs = Array.from( - spec.components?.schemas ? Object.keys(spec.components.schemas) : [], - ); - let isValid = true; - - for (const pathItem of Object.values(spec.paths)) { - for (const operation of Object.values(pathItem)) { - for (const param of operation.parameters ?? []) { - if (param.schema?.$ref) { - const refName = param.schema.$ref.split("/").pop(); - if (refName && !refs.includes(refName)) { - logger.error( - `Reference ${param.schema.$ref} not found in components.schemas`, - ); - isValid = false; - } - } - } - - if (operation.requestBody?.content) { - for (const content of Object.values(operation.requestBody.content)) { - if (content.schema?.$ref) { - const refName = content.schema.$ref.split("/").pop(); - if (refName && !refs.includes(refName)) { - logger.error( - `Reference ${content.schema.$ref} not found in components.schemas`, - ); - isValid = false; - } - } - } - } - - // Similarly validate responses and other $ref usages - } - } - - return isValid; -} From f0ce6e8cb2e945b6bdf1d9e06da2ff2e9d9ac8d6 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Fri, 29 Nov 2024 13:55:14 +0100 Subject: [PATCH 44/67] Update the hono-zod-openapi example --- examples/openapi-zod/src/index.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/examples/openapi-zod/src/index.ts b/examples/openapi-zod/src/index.ts index 2a120b474..093bb4ab1 100644 --- a/examples/openapi-zod/src/index.ts +++ b/examples/openapi-zod/src/index.ts @@ -15,9 +15,12 @@ type Bindings = { const app = new OpenAPIHono<{ Bindings: Bindings }>(); -const ParamsSchema = z.object({ +// Schema that defines presence of an ID in the path +const UserIdPathParamSchema = z.object({ id: z + // Path params are always strings .string() + // But an ID is always a number .regex(/^\d+$/) .openapi({ param: { @@ -28,6 +31,7 @@ const ParamsSchema = z.object({ }), }); +// Schema that defines the body of a request to create a new user const NewUserSchema = z .object({ name: z.string().openapi({ @@ -40,8 +44,8 @@ const NewUserSchema = z }) .openapi("NewUser"); +// Schema that defines the response of a request to get a user // TODO - Figure out how to extend the NewUserSchema object -// const UserSchema = z .object({ id: z.number().openapi({ @@ -56,11 +60,12 @@ const UserSchema = z }) .openapi("User"); +// Define the request/response schema for a route to get a user by ID const getUserRoute = createRoute({ method: "get", path: "/users/{id}", request: { - params: ParamsSchema, + params: UserIdPathParamSchema, }, responses: { 200: { @@ -92,6 +97,7 @@ const getUserRoute = createRoute({ }, }); +// Define the request/response schema for a route to list users const listUsersRoute = createRoute({ method: "get", path: "/users", @@ -112,6 +118,7 @@ const listUsersRoute = createRoute({ }, }); +// Define the request/response schema for a route to create a new user const createUserRoute = createRoute({ method: "post", path: "/users", @@ -136,6 +143,8 @@ const createUserRoute = createRoute({ }, }); +// Define the request/response schema for a route to delete a user by ID +// Add in basic auth middleware to the route to show how to add security to an endpoint const deleteUserRoute = createRoute({ method: "delete", path: "/users/{id}", @@ -149,9 +158,9 @@ const deleteUserRoute = createRoute({ username: "goose", password: "honkhonk", }), - ] as const, // Use `as const` to ensure TypeScript infers the middleware's Context + ] as const, // Use `as const` to ensure TypeScript infers the middleware's Context correctly request: { - params: ParamsSchema, + params: UserIdPathParamSchema, }, responses: { 204: { @@ -170,11 +179,13 @@ const deleteUserRoute = createRoute({ }, }); +// Register the basic auth security scheme app.openAPIRegistry.registerComponent("securitySchemes", "basicAuth", { type: "http", scheme: "basic", }); +// Define the handler for a route to get a user by ID app.openapi(getUserRoute, async (c) => { const { id } = c.req.valid("param"); const db = drizzle(c.env.DB); @@ -196,6 +207,7 @@ app.openapi(getUserRoute, async (c) => { return c.json(result, 200); }); +// Define the handler for a route to list users app.openapi(listUsersRoute, async (c) => { const { name } = c.req.valid("query"); const db = drizzle(c.env.DB); @@ -210,6 +222,7 @@ app.openapi(listUsersRoute, async (c) => { return c.json(result, 200); }); +// Define the handler for a route to create a new user app.openapi(createUserRoute, async (c) => { const { name, age } = c.req.valid("json"); const db = drizzle(c.env.DB); @@ -223,6 +236,7 @@ app.openapi(createUserRoute, async (c) => { return c.json(result, 201); }); +// Define the handler for a route to delete a user by ID app.openapi(deleteUserRoute, async (c) => { const { id } = c.req.valid("param"); const db = drizzle(c.env.DB); @@ -230,6 +244,7 @@ app.openapi(deleteUserRoute, async (c) => { return c.body(null, 204); }); +// Mount the api documentation // The OpenAPI documentation will be available at /doc app.doc("/doc", { openapi: "3.0.0", @@ -239,6 +254,7 @@ app.doc("/doc", { }, }); +// Define a simple route to test the API (this is not part of the OpenAPI spec) app.get("/", (c) => { return c.text("Hello Hono OpenAPI!"); }); From 09ade02220a9a354655d6c0f048cc7caf48f3b44 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Fri, 29 Nov 2024 14:05:33 +0100 Subject: [PATCH 45/67] Update comments about openapi types in the types pacakge --- packages/types/src/openapi.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/types/src/openapi.ts b/packages/types/src/openapi.ts index 141920219..10b3c63d9 100644 --- a/packages/types/src/openapi.ts +++ b/packages/types/src/openapi.ts @@ -1,13 +1,12 @@ import { z } from "zod"; // Create a schema for references -const SchemaRefSchema = z.object({ +const OpenAPISchemaRefSchema = z.object({ $ref: z.string(), }); // Create a schema for direct type definitions -const SchemaTypeSchema = z.object({ - $ref: z.undefined(), +const OpenAPISchemaTypeSchema = z.object({ type: z.enum(["string", "number", "integer", "boolean", "array", "object"]), format: z.string().optional(), enum: z.array(z.string()).optional(), @@ -16,8 +15,8 @@ const SchemaTypeSchema = z.object({ // Add other relevant OpenAPI schema properties here }); -// Combine them with a union instead of discriminatedUnion -const SchemaSchema = z.union([SchemaRefSchema, SchemaTypeSchema]); +// This is a terrible name, but it actually is the Schema for an OpenAPI schema +const SchemaSchema = z.union([OpenAPISchemaRefSchema, OpenAPISchemaTypeSchema]); // Use this in OpenApiSpecSchema where schema validation is needed const ContentSchema = z.object({ @@ -68,6 +67,7 @@ const OpenAPISchemaSchema: z.ZodType = z.lazy(() => allOf: z.array(OpenAPISchemaSchema).optional(), anyOf: z.array(OpenAPISchemaSchema).optional(), oneOf: z.array(OpenAPISchemaSchema).optional(), + // NOTE - In practice, each element of an enum should be of the same type, so we could narrow `enum` further enum: z.array(z.union([z.string(), z.number(), z.boolean()])).optional(), // Add other complex schema properties as needed }), From e03db0b560fb2557f6fdcde2bb4b244ac7cc860f Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Fri, 29 Nov 2024 14:09:35 +0100 Subject: [PATCH 46/67] Update changelog --- www/src/content/changelog/!canary.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/www/src/content/changelog/!canary.mdx b/www/src/content/changelog/!canary.mdx index a84958790..3a208ef92 100644 --- a/www/src/content/changelog/!canary.mdx +++ b/www/src/content/changelog/!canary.mdx @@ -7,5 +7,7 @@ draft: true ### Features * Improved code analysis. Through the new @fiberplane/source-analysis package a more flexbile and thorough source code implementation is included * OpenAPI integration. You can now fetch the OpenAPI spec for your api, and Studio will use it to map spec definitions to your api routes. Expected query parameters, headers, and body fields are then surfaced in Studio. +* Basic Command bar. You can now toggle the timeline or logs with the command bar (`cmd+k`), as well as switch between different tabs (Body, Headers, Params, Docs) in the request fields. +* Natural language request generation. You can now generate requests with an instruction prompt. Press `cmd+shift+g` to open the prompt input. ### Bug fixes From 0a23055992b55ce3bfdc1758bb2cd17519ea30ba Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Fri, 29 Nov 2024 14:20:14 +0100 Subject: [PATCH 47/67] Put the custom spec url feature behind a feature flag --- studio/src/hooks/index.ts | 1 + studio/src/hooks/useFeature.ts | 17 ++++++++++++++ .../SettingsPage/OpenAPISettingsForm.tsx | 9 ++++++-- .../src/pages/SettingsPage/SettingsPage.tsx | 23 +++++++++++++------ 4 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 studio/src/hooks/useFeature.ts diff --git a/studio/src/hooks/index.ts b/studio/src/hooks/index.ts index 7f637d40d..33a6b1341 100644 --- a/studio/src/hooks/index.ts +++ b/studio/src/hooks/index.ts @@ -11,3 +11,4 @@ export { useInputFocusDetection } from "./useInputFocusDetection.ts"; export { useLatest } from "./useLatest.ts"; export { useOrphanLogs } from "./useOrphanLogs.ts"; export { useActiveTraceId } from "./useActiveTraceId.ts"; +export { useFeature } from "./useFeature.ts"; diff --git a/studio/src/hooks/useFeature.ts b/studio/src/hooks/useFeature.ts new file mode 100644 index 000000000..ad2c1a127 --- /dev/null +++ b/studio/src/hooks/useFeature.ts @@ -0,0 +1,17 @@ +export function useFeature(featureName: string): boolean { + try { + // Check localStorage for feature flag + const storedValue = localStorage.getItem(`fpx.${featureName}`); + + // Return true if the feature is explicitly enabled in localStorage + if (storedValue === "true") { + return true; + } + + // Return false by default or if explicitly disabled + return false; + } catch (error) { + // If there is an error, return false + return false; + } +} diff --git a/studio/src/pages/SettingsPage/OpenAPISettingsForm.tsx b/studio/src/pages/SettingsPage/OpenAPISettingsForm.tsx index 71e010f9d..2a8a19c06 100644 --- a/studio/src/pages/SettingsPage/OpenAPISettingsForm.tsx +++ b/studio/src/pages/SettingsPage/OpenAPISettingsForm.tsx @@ -76,8 +76,13 @@ export function OpenAPISettingsForm({ settings }: OpenAPISettingsFormProps) { About OpenAPI Integration
- The OpenAPI specification will be used to provide better - suggestions for request parameters in the UI. + The OpenAPI specification will be used to show documentation and + generate more accurate suggestions for request parameters in the + UI. + + If you are using Hono-Zod-OpenAPI, you do not need to specify + a url to your spec. +
diff --git a/studio/src/pages/SettingsPage/SettingsPage.tsx b/studio/src/pages/SettingsPage/SettingsPage.tsx index 3bfea6650..4d6a3095f 100644 --- a/studio/src/pages/SettingsPage/SettingsPage.tsx +++ b/studio/src/pages/SettingsPage/SettingsPage.tsx @@ -1,5 +1,6 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useFeature } from "@/hooks"; import { type UserInfo, useFetchSettings, useUserInfo } from "@/queries"; import { cn } from "@/utils"; import type { Settings } from "@fiberplane/fpx-types"; @@ -66,6 +67,9 @@ function SettingsLayout({ const defaultTab = defaultSettingsTab ?? settingsTabFallback; const [activeTab, setActiveTab] = useState(defaultTab); + // To enable the custom spec url feature, set `fpx.openApiSettings` to "true" in localStorage + const isOpenAPISettingsEnabled = useFeature("openApiSettings"); + return ( - - - OpenAPI - + {isOpenAPISettingsEnabled && ( + + + OpenAPI + + )} Date: Fri, 29 Nov 2024 14:32:06 +0100 Subject: [PATCH 48/67] Fix visible tabs logic --- studio/src/pages/RequestorPage/store/tabs.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/studio/src/pages/RequestorPage/store/tabs.ts b/studio/src/pages/RequestorPage/store/tabs.ts index 64088aad9..70d9e7fde 100644 --- a/studio/src/pages/RequestorPage/store/tabs.ts +++ b/studio/src/pages/RequestorPage/store/tabs.ts @@ -20,15 +20,18 @@ export const getVisibleRequestPanelTabs = (route: { method: RequestMethod; openApiSpec: unknown | undefined; }): RequestsPanelTab[] => { - const hasDocs = !!route.openApiSpec; let result: RequestsPanelTab[] = []; if (route.requestType === "websocket") { result = ["params", "headers", "messages"]; - } - if (route.method === "GET" || route.method === "HEAD") { + } else { result = ["params", "headers"]; } - result = ["params", "headers", "body"]; + const canHaveBody = route.method !== "GET" && route.method !== "HEAD"; + if (canHaveBody) { + result.push("body"); + } + // If we have docs, show the docs tab + const hasDocs = !!route.openApiSpec; if (hasDocs) { result.push("docs"); } From b5f11ee1a95c9918705de699ba414b2025824309 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Fri, 29 Nov 2024 15:24:47 +0100 Subject: [PATCH 49/67] Stylize the docs tab a little tiny bit better --- .gitignore | 3 + examples/openapi-zod/src/index.ts | 4 + .../RouteDocumentation/RouteDocumentation.tsx | 243 ++++++++++-------- 3 files changed, 140 insertions(+), 110 deletions(-) diff --git a/.gitignore b/.gitignore index 99e372a31..675c41ffe 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ shared/dist .env .envrc +# Cursor +.cursorrules + # Fpx .fpxconfig diff --git a/examples/openapi-zod/src/index.ts b/examples/openapi-zod/src/index.ts index 093bb4ab1..5f4d17f60 100644 --- a/examples/openapi-zod/src/index.ts +++ b/examples/openapi-zod/src/index.ts @@ -27,6 +27,7 @@ const UserIdPathParamSchema = z.object({ name: "id", in: "path", }, + description: "The ID of the user", example: "123", }), }); @@ -101,6 +102,7 @@ const getUserRoute = createRoute({ const listUsersRoute = createRoute({ method: "get", path: "/users", + description: "List all users", request: { query: z.object({ name: z.string().optional(), @@ -122,6 +124,8 @@ const listUsersRoute = createRoute({ const createUserRoute = createRoute({ method: "post", path: "/users", + title: "CreateUser", + description: "Create a new user", request: { body: { content: { diff --git a/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx b/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx index c10b68b8c..6d32323e0 100644 --- a/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx +++ b/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx @@ -14,60 +14,50 @@ export const RouteDocumentation = memo(function RouteDocumentation({ const { parameters, requestBody, responses, description, summary } = openApiSpec; + console.log("openApiSpec", openApiSpec); return ( - -
- {/* Description Section */} + +
+ {/* Description Section - Updated styling */} {(summary || description) && (
{summary && ( -

+

{summary}

)} {description && ( -

+

{description}

)}
)} - {/* Parameters Section */} + {/* Parameters Section - Updated layout */} {parameters && parameters.length > 0 && ( -
-

- Parameters -

-
+
+ Parameters +
{parameters.map((param, idx) => (
-
- - {param.name} - - - {param.in} - - {param.required && ( - - required - - )} +
+ + + {param.required && }
{param.description && ( -

- {param.description} -

- )} - {param.schema && ( - + )}
))} @@ -77,23 +67,13 @@ export const RouteDocumentation = memo(function RouteDocumentation({ {/* Request Body Section */} {requestBody && ( -
-

- Request Body -

-
+
+ Request Body +
{Object.entries(requestBody.content).map( ([mediaType, content]) => ( -
- - {mediaType} - +
+ {content.schema && ( )} @@ -106,38 +86,33 @@ export const RouteDocumentation = memo(function RouteDocumentation({ {/* Responses Section */} {responses && ( -
-

- Responses -

-
+
+ Responses +
{Object.entries(responses).map(([status, response]) => (
-
+
- + {response.description}
{response.content && ( -
+
{Object.entries(response.content).map( ([mediaType, content]) => ( -
- - {mediaType} - - {content.schema && ( - - )} +
+ +
+ {content.schema && ( + + )} +
), )} @@ -166,46 +141,30 @@ function SchemaViewer({ schema, className }: SchemaViewerProps) { return (
{schema.type === "object" && schema.properties ? ( -
+
{Object.entries( schema.properties as Record, ).map(([key, prop]) => ( -
-
- {key} - - {prop.type} - - {schema.required?.includes(key) && ( - - required - - )} +
+
+ + + {schema.required?.includes(key) && }
+
+ {prop.description && ( + + )} - {/* Description and Example section */} - {(prop.description || prop.example !== undefined) && ( -
- {prop.description && ( -

{prop.description}

- )} - {prop.example !== undefined && ( -
- Example: - - {typeof prop.example === "string" - ? `"${prop.example}"` - : JSON.stringify(prop.example)} - -
- )} -
- )} + {prop.example !== undefined && ( + + )} +
))}
) : ( -
+
{schema.type} @@ -217,21 +176,13 @@ function SchemaViewer({ schema, className }: SchemaViewerProps) { )}
- {/* Description and Example for non-object types */} {(schema.description || schema.example !== undefined) && (
{schema.description && ( -

{schema.description}

+ )} {schema.example !== undefined && ( -
- Example: - - {typeof schema.example === "string" - ? `"${schema.example}"` - : JSON.stringify(schema.example)} - -
+ )}
)} @@ -259,3 +210,75 @@ function StatusBadge({ status }: { status: string }) { ); } + +function SectionHeader({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function RequiredBadge() { + return ( + + Required + + ); +} + +function TypeBadge({ type }: { type: string }) { + return ( + + {type} + + ); +} + +function ParameterName({ name }: { name: string }) { + return ( + + {name} + + ); +} + +type ParameterDescriptionProps = { + description: string; +}; + +function ParameterDescription({ description }: ParameterDescriptionProps) { + return

{description}

; +} + +type ParameterExampleProps = { + example: unknown; +}; + +function ParameterExample({ example }: ParameterExampleProps) { + return ( +
+ Example: + + {typeof example === "string" ? `"${example}"` : JSON.stringify(example)} + +
+ ); +} + +function ContentTypeBadge({ mediaType }: { mediaType: string }) { + return ( + + {mediaType} + + ); +} From 7a7d74ba420d873174f71f9ad4e44d73e6100177 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Fri, 29 Nov 2024 15:30:10 +0100 Subject: [PATCH 50/67] more more styles --- .../RouteDocumentation/RouteDocumentation.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx b/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx index 6d32323e0..d40fb0069 100644 --- a/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx +++ b/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx @@ -106,7 +106,7 @@ export const RouteDocumentation = memo(function RouteDocumentation({ ([mediaType, content]) => (
-
+
{content.schema && ( {description}

; + return

{description}

; } type ParameterExampleProps = { @@ -265,7 +265,7 @@ function ParameterExample({ example }: ParameterExampleProps) { return (
Example: - + {typeof example === "string" ? `"${example}"` : JSON.stringify(example)}
@@ -276,7 +276,7 @@ function ContentTypeBadge({ mediaType }: { mediaType: string }) { return ( {mediaType} From da59ce664df31773a9fa42957be4af701d8a16c0 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Fri, 29 Nov 2024 15:37:38 +0100 Subject: [PATCH 51/67] Add support for arrays to the routeDocumentation tab --- .../RouteDocumentation/RouteDocumentation.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx b/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx index d40fb0069..b08f8056a 100644 --- a/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx +++ b/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx @@ -140,7 +140,25 @@ function SchemaViewer({ schema, className }: SchemaViewerProps) { return (
- {schema.type === "object" && schema.properties ? ( + {schema.type === "array" ? ( +
+
+ + of + {schema.items && ( + + )} +
+ {schema.items && ( +
+
Array items:
+ +
+ )} +
+ ) : schema.type === "object" && schema.properties ? (
{Object.entries( schema.properties as Record, @@ -155,7 +173,6 @@ function SchemaViewer({ schema, className }: SchemaViewerProps) { {prop.description && ( )} - {prop.example !== undefined && ( )} From 1c50ca916611d9f4ddb3fbb5b7fdac4e791a8ab4 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Fri, 29 Nov 2024 15:42:10 +0100 Subject: [PATCH 52/67] Update README for openapi example --- examples/openapi-zod/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/examples/openapi-zod/README.md b/examples/openapi-zod/README.md index d16fe871a..978920381 100644 --- a/examples/openapi-zod/README.md +++ b/examples/openapi-zod/README.md @@ -2,6 +2,10 @@ This is an implementation of the Hono-Zod-OpenAPI integration from the Hono docs. +You can use it with Fiberplane Studio to get a feel for Studio's OpenAPI support. + +For each documented route, a `Docs` tab should be available in the Studio UI. + ## Commands ```sh @@ -16,3 +20,9 @@ pnpm i pnpm dev ``` +To test with Studio, have this app running, and then when you launch the api: + +```sh +cd api +FPX_WATCH_DIR=../examples/openapi-zod pnpm dev +``` \ No newline at end of file From 133e1b452256a125ec06dd272016d0e1ad8300bd Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Fri, 29 Nov 2024 15:49:27 +0100 Subject: [PATCH 53/67] Fix accessibility warnings in the command bar --- .../CommandBar/AiPromptInput.tsx | 19 +++++++++++++++---- .../RequestorPage/CommandBar/CommandBar.tsx | 11 ++++++++++- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/studio/src/pages/RequestorPage/CommandBar/AiPromptInput.tsx b/studio/src/pages/RequestorPage/CommandBar/AiPromptInput.tsx index 14540fd55..b3ce5cd35 100644 --- a/studio/src/pages/RequestorPage/CommandBar/AiPromptInput.tsx +++ b/studio/src/pages/RequestorPage/CommandBar/AiPromptInput.tsx @@ -1,5 +1,10 @@ -import { Dialog, DialogContent } from "@/components/ui/dialog"; -import React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ui/dialog"; +import React, { useEffect } from "react"; interface AiPromptInputProps { open: boolean; @@ -29,14 +34,14 @@ export function AiPromptInput({ }; // Reset input value when dialog closes - React.useEffect(() => { + useEffect(() => { if (!open) { setInputValue(""); } }, [open]); // Focus input when dialog opens - React.useEffect(() => { + useEffect(() => { if (open) { inputRef.current?.focus(); } @@ -45,6 +50,12 @@ export function AiPromptInput({ return ( + + Generate Request Data with AI + + + Enter a prompt for AI request generation. +
+ Command Menu + + Type a command or search in the menu... + Date: Fri, 29 Nov 2024 15:52:23 +0100 Subject: [PATCH 54/67] Minor tweaks --- studio/src/pages/RequestorPage/CommandBar/AiPromptInput.tsx | 4 ++-- studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx | 6 +++--- .../RequestorPageContent/RequestorPageContent.tsx | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/studio/src/pages/RequestorPage/CommandBar/AiPromptInput.tsx b/studio/src/pages/RequestorPage/CommandBar/AiPromptInput.tsx index b3ce5cd35..82757dcbb 100644 --- a/studio/src/pages/RequestorPage/CommandBar/AiPromptInput.tsx +++ b/studio/src/pages/RequestorPage/CommandBar/AiPromptInput.tsx @@ -6,12 +6,12 @@ import { } from "@/components/ui/dialog"; import React, { useEffect } from "react"; -interface AiPromptInputProps { +type AiPromptInputProps = { open: boolean; setOpen: (open: boolean) => void; onGenerateRequest?: (prompt?: string) => void; setAiPrompt: (prompt?: string) => void; -} +}; export function AiPromptInput({ open, diff --git a/studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx b/studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx index 45029caf7..0884a134b 100644 --- a/studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx +++ b/studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx @@ -17,11 +17,11 @@ import { Icon } from "@iconify/react/dist/iconify.js"; import React from "react"; import { useRequestorStore } from "../store"; -interface CommandBarProps { +type CommandBarProps = { open: boolean; setOpen: (open: boolean) => void; - onGenerateRequest?: (prompt?: string) => void; -} + // onGenerateRequest?: (prompt?: string) => void; +}; export function CommandBar({ open, diff --git a/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx b/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx index 740353d5c..88be28c8b 100644 --- a/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx +++ b/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx @@ -25,12 +25,12 @@ import RequestorPageContentBottomPanel from "./RequestorPageContentBottomPanel"; import { useMostRecentProxiedRequestResponse } from "./useMostRecentProxiedRequestResponse"; import { getMainSectionWidth } from "./util"; -interface RequestorPageContentProps { +type RequestorPageContentProps = { history: ProxiedRequestResponse[]; historyLoading: boolean; overrideTraceId?: string; generateNavigation: (traceId: string) => To; -} +}; export const RequestorPageContent: React.FC = ( props, @@ -265,7 +265,7 @@ export const RequestorPageContent: React.FC = ( toast({ duration: 3000, description: prompt - ? `Generating request with prompt: ${prompt}` + ? "Generating request with user instructions" : "Generating request parameters with AI", }); fillInRequest(); From 44c91d7adc383205673384244601311ce2ca655f Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 3 Dec 2024 10:24:57 +0100 Subject: [PATCH 55/67] Update package json version for client lib --- packages/client-library-otel/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client-library-otel/package.json b/packages/client-library-otel/package.json index 40ebd8d3e..baf22759c 100644 --- a/packages/client-library-otel/package.json +++ b/packages/client-library-otel/package.json @@ -4,7 +4,7 @@ "author": "Fiberplane", "type": "module", "main": "dist/index.js", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@opentelemetry/api": "~1.9.0", "@opentelemetry/exporter-trace-otlp-http": "^0.52.1", From 9aa2d9c6d541b6623790ecf682f4c7ff802fbdec Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 3 Dec 2024 16:10:16 +0100 Subject: [PATCH 56/67] Update openapi description for new user route --- examples/openapi-zod/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/openapi-zod/src/index.ts b/examples/openapi-zod/src/index.ts index 5f4d17f60..6b3460161 100644 --- a/examples/openapi-zod/src/index.ts +++ b/examples/openapi-zod/src/index.ts @@ -142,7 +142,7 @@ const createUserRoute = createRoute({ schema: UserSchema, }, }, - description: "Retrieve the user", + description: "Newly created user", }, }, }); From 1c84cc25f407340057324d35dd85e950bec67e4f Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 3 Dec 2024 17:21:32 +0100 Subject: [PATCH 57/67] Update styles for docs tab --- packages/types/src/openapi.ts | 1 + .../RequestPanel/RequestPanel.tsx | 2 +- .../RouteDocumentation/RouteDocumentation.tsx | 146 ++++++++++++------ 3 files changed, 101 insertions(+), 48 deletions(-) diff --git a/packages/types/src/openapi.ts b/packages/types/src/openapi.ts index 10b3c63d9..1930878fa 100644 --- a/packages/types/src/openapi.ts +++ b/packages/types/src/openapi.ts @@ -78,6 +78,7 @@ const OpenAPIRequestBodySchema = z.object({ }); const OpenAPIOperationSchema = z.object({ + title: z.string().optional(), summary: z.string().optional(), description: z.string().optional(), parameters: z.array(OpenAPIParameterSchema).optional(), diff --git a/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx b/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx index 0cc437a00..e914c7c69 100644 --- a/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx +++ b/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx @@ -309,7 +309,7 @@ export const RequestPanel = memo(function RequestPanel( )} {shouldShowDocs && ( - + )} {shouldShowMessages && ( diff --git a/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx b/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx index b08f8056a..12ddc617c 100644 --- a/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx +++ b/studio/src/pages/RequestorPage/RequestPanel/RouteDocumentation/RouteDocumentation.tsx @@ -6,35 +6,55 @@ import type { OpenAPIOperation, OpenAPISchema } from "./openapi"; type RouteDocumentationProps = { openApiSpec: OpenAPIOperation; + route: { path: string; method: string } | null; +}; + +const getTitleWithFallback = ( + title: string | undefined, + route: { path: string; method: string } | null, +) => { + if (title) { + return title; + } + if (!route) { + return "Untitled"; + } + return `${route.method} ${route.path}`; }; export const RouteDocumentation = memo(function RouteDocumentation({ openApiSpec, + route, }: RouteDocumentationProps) { - const { parameters, requestBody, responses, description, summary } = + const { parameters, requestBody, responses, description, summary, title } = openApiSpec; + const modTitle = getTitleWithFallback(title, route); console.log("openApiSpec", openApiSpec); return (
- {/* Description Section - Updated styling */} - {(summary || description) && ( -
- {summary && ( -

- {summary} + {(modTitle || summary || description) && ( +
+ {modTitle && ( +

+ {modTitle}

)} {description && ( -

+

{description}

)} + {summary && ( +

+ {summary} +

+ )}
)} - {/* Parameters Section - Updated layout */} + {/* URL Parameters Section */} {parameters && parameters.length > 0 && (
Parameters @@ -44,7 +64,7 @@ export const RouteDocumentation = memo(function RouteDocumentation({ key={`${param.name}-${idx}`} className="flex flex-col space-y-2" > -
+
- Request Body -
- {Object.entries(requestBody.content).map( - ([mediaType, content]) => ( -
- + {Object.entries(requestBody.content).map(([mediaType, content]) => ( +
+ + Request Body + + +
+
{content.schema && ( )}
- ), - )} -
+
+
+ ))}
)} @@ -94,19 +116,46 @@ export const RouteDocumentation = memo(function RouteDocumentation({ key={status} className="space-y-2 border-b border-gray-700 pb-4 border-dashed" > -
- - - {response.description} - -
+ {!response.content && ( +
+
+
+
+ +
+ +
+ {/* Commenting this out solely because it looks bad */} + {/* {response.description && ( +
+ {response.description} +
+ )} */} +
+ No Content +
+
+
+ )} + {response.content && (
{Object.entries(response.content).map( ([mediaType, content]) => (
- -
+
+
+ +
+ +
+ {/* Commenting this out solely because it looks bad */} + {/* {response.description && ( +
+ {response.description} +
+ )} */} +
{content.schema && (
- of + of {schema.items && ( , ).map(([key, prop]) => ( -
-
+
+
{schema.required?.includes(key) && }
-
+
{prop.description && ( )} @@ -230,7 +279,7 @@ function StatusBadge({ status }: { status: string }) { function SectionHeader({ children }: { children: React.ReactNode }) { return ( -

+

{children}

); @@ -238,29 +287,23 @@ function SectionHeader({ children }: { children: React.ReactNode }) { function RequiredBadge() { return ( - - Required - + + required + ); } function TypeBadge({ type }: { type: string }) { return ( - + {type} - + ); } function ParameterName({ name }: { name: string }) { return ( - + {name} ); @@ -271,7 +314,7 @@ type ParameterDescriptionProps = { }; function ParameterDescription({ description }: ParameterDescriptionProps) { - return

{description}

; + return

{description}

; } type ParameterExampleProps = { @@ -289,11 +332,20 @@ function ParameterExample({ example }: ParameterExampleProps) { ); } -function ContentTypeBadge({ mediaType }: { mediaType: string }) { +function ContentTypeBadge({ + mediaType, + className, +}: { + mediaType: string; + className?: string; +}) { return ( {mediaType} From 961b31585382084f6880597ca3b4df5145248508 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 3 Dec 2024 17:51:13 +0100 Subject: [PATCH 58/67] Remove dead code --- .../RequestorPage/CommandBar/CommandBar.tsx | 28 +------------------ 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx b/studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx index 0884a134b..8bc532d93 100644 --- a/studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx +++ b/studio/src/pages/RequestorPage/CommandBar/CommandBar.tsx @@ -20,14 +20,9 @@ import { useRequestorStore } from "../store"; type CommandBarProps = { open: boolean; setOpen: (open: boolean) => void; - // onGenerateRequest?: (prompt?: string) => void; }; -export function CommandBar({ - open, - setOpen, - // onGenerateRequest, -}: CommandBarProps) { +export function CommandBar({ open, setOpen }: CommandBarProps) { const { togglePanel, visibleRequestsPanelTabs, setActiveRequestsPanelTab } = useRequestorStore( "togglePanel", @@ -37,14 +32,6 @@ export function CommandBar({ const [inputValue, setInputValue] = React.useState(""); - // const handleGenerateRequest = (currentInput: string) => { - // // Extract any text after "generate" as the prompt - // const prompt = currentInput.replace(/^generate\s*/, "").trim(); - // onGenerateRequest?.(prompt.length > 0 ? prompt : undefined); - // setOpen(false); - // setInputValue(""); - // }; - return ( @@ -63,19 +50,6 @@ export function CommandBar({ /> No results found. - {/* - handleGenerateRequest(inputValue)} - > - - Generate Request Data - - Type additional text for custom prompt - - - */} - {/* */} Date: Tue, 3 Dec 2024 17:54:41 +0100 Subject: [PATCH 59/67] Fix logic for opening ai prompt guidance txt input --- .../RequestorPage/RequestorPageContent/RequestorPageContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx b/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx index 88be28c8b..ce2a63d7c 100644 --- a/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx +++ b/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx @@ -162,7 +162,7 @@ export const RequestorPageContent: React.FC = ( "mod+shift+g", (e) => { e.preventDefault(); - if (aiEnabled && !isLoadingParameters) { + if (aiEnabled) { setAiPromptOpen(true); } else { setAIDropdownOpen(true); From 34b5105f170c0c8f10efe889c5246708ba0e028c Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Tue, 3 Dec 2024 18:03:53 +0100 Subject: [PATCH 60/67] Validate openapi operation in the requestpanel --- packages/types/src/openapi.ts | 2 +- .../RequestorPage/RequestPanel/RequestPanel.tsx | 13 +++---------- .../RequestPanel/RouteDocumentation/openapi.ts | 12 +++++++++++- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/types/src/openapi.ts b/packages/types/src/openapi.ts index 1930878fa..ec05c8d96 100644 --- a/packages/types/src/openapi.ts +++ b/packages/types/src/openapi.ts @@ -77,7 +77,7 @@ const OpenAPIRequestBodySchema = z.object({ content: z.record(ContentSchema), }); -const OpenAPIOperationSchema = z.object({ +export const OpenAPIOperationSchema = z.object({ title: z.string().optional(), summary: z.string().optional(), description: z.string().optional(), diff --git a/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx b/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx index e914c7c69..acc6d6a86 100644 --- a/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx +++ b/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx @@ -1,7 +1,7 @@ import { Button } from "@/components/ui/button"; import { Tabs } from "@/components/ui/tabs"; import { useToast } from "@/components/ui/use-toast"; -import { cn, objectWithKey } from "@/utils"; +import { cn } from "@/utils"; import { EraserIcon, InfoCircledIcon } from "@radix-ui/react-icons"; import { type Dispatch, type SetStateAction, memo, useMemo } from "react"; import { FormDataForm } from "../FormDataForm"; @@ -22,6 +22,7 @@ import { } from "@/components/CodeMirrorEditor"; import { useRequestorStore } from "../store"; import { RouteDocumentation } from "./RouteDocumentation/RouteDocumentation"; +import { isOpenApiOperation } from "./RouteDocumentation/openapi"; type RequestPanelProps = { aiEnabled: boolean; @@ -37,14 +38,6 @@ type RequestPanelProps = { onSubmit: () => void; }; -function isValidOpenApiSpec(openApiSpec: unknown): boolean { - return ( - objectWithKey(openApiSpec, "parameters") || - objectWithKey(openApiSpec, "requestBody") || - objectWithKey(openApiSpec, "responses") - ); -} - export const RequestPanel = memo(function RequestPanel( props: RequestPanelProps, ) { @@ -116,7 +109,7 @@ export const RequestPanel = memo(function RequestPanel( return null; } }, [activeRoute?.openApiSpec]); - const shouldShowDocs = isValidOpenApiSpec(openApiSpec); + const shouldShowDocs = isOpenApiOperation(openApiSpec); return ( Date: Wed, 4 Dec 2024 11:12:30 +0100 Subject: [PATCH 61/67] Bump api package.json --- api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/package.json b/api/package.json index 2fbb7ffa0..a95acc322 100644 --- a/api/package.json +++ b/api/package.json @@ -1,5 +1,5 @@ { - "version": "0.11.0", + "version": "0.12.1", "name": "@fiberplane/studio", "description": "Local development debugging interface for Hono apps", "author": "Fiberplane", From 5e9bbfea00603d5a2caf7eb19b52bad2784ad3a3 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 4 Dec 2024 20:50:43 +0100 Subject: [PATCH 62/67] Update studio package.json --- api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/package.json b/api/package.json index a95acc322..a21a46b9b 100644 --- a/api/package.json +++ b/api/package.json @@ -1,5 +1,5 @@ { - "version": "0.12.1", + "version": "0.12.2", "name": "@fiberplane/studio", "description": "Local development debugging interface for Hono apps", "author": "Fiberplane", From 9bc9579f1848aef7f7264494b4237b9fe1d4e9eb Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 4 Dec 2024 20:53:39 +0100 Subject: [PATCH 63/67] Fix type issue --- api/src/lib/openapi/openapi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/lib/openapi/openapi.ts b/api/src/lib/openapi/openapi.ts index 8658fac01..df107a2d9 100644 --- a/api/src/lib/openapi/openapi.ts +++ b/api/src/lib/openapi/openapi.ts @@ -1,7 +1,7 @@ import type { LibSQLDatabase } from "drizzle-orm/libsql"; import type { z } from "zod"; import type * as schema from "../../db/schema.js"; -import type { schemaProbedRoutes } from "../../lib/app-routes.js"; +import type { schemaProbedRoutes } from "../../lib/app-routes/index.js"; import logger from "../../logger/index.js"; import { CircularReferenceError, From 8b7e391a155c7ba1d8d6f1ee5b272d2b184ae8cd Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 4 Dec 2024 20:57:56 +0100 Subject: [PATCH 64/67] Update comment and add lint and format scripts to openapi-zod example --- api/src/lib/openapi/openapi.ts | 2 +- examples/openapi-zod/package.json | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/api/src/lib/openapi/openapi.ts b/api/src/lib/openapi/openapi.ts index df107a2d9..49569df35 100644 --- a/api/src/lib/openapi/openapi.ts +++ b/api/src/lib/openapi/openapi.ts @@ -23,7 +23,7 @@ type Routes = z.infer["routes"]; * This function handles both single routes and arrays of routes. * * @param db - LibSQL database instance containing the OpenAPI specifications. Used to fetch the latest spec. - * @param routes - Single route or array of routes to be enriched. Each route should contain path, method, + * @param routes - Array of routes to be enriched. Each route should contain path, method, * and handlerType properties for proper matching with OpenAPI specs. * @param openApiSpec - Optional OpenAPI specification to use instead of fetching from the database. * diff --git a/examples/openapi-zod/package.json b/examples/openapi-zod/package.json index 91fa88ca7..5026658f8 100644 --- a/examples/openapi-zod/package.json +++ b/examples/openapi-zod/package.json @@ -8,7 +8,9 @@ "db:studio": "drizzle-kit studio", "db:touch": "wrangler d1 execute zod-openapi --local --command='SELECT 1'", "db:migrate:prod": "GOOSIFY_ENV=production drizzle-kit migrate", - "db:studio:prod": "GOOSIFY_ENV=production drizzle-kit studio" + "db:studio:prod": "GOOSIFY_ENV=production drizzle-kit studio", + "format": "biome check . --write", + "lint": "biome lint ." }, "dependencies": { "@fiberplane/hono-otel": "workspace:*", From e682c7ba44266483deb7ce22e82837062b861337 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 4 Dec 2024 21:02:18 +0100 Subject: [PATCH 65/67] Remove goosify references from teh openapi-zod package.json --- examples/openapi-zod/drizzle.config.ts | 2 +- examples/openapi-zod/package.json | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/examples/openapi-zod/drizzle.config.ts b/examples/openapi-zod/drizzle.config.ts index b8fc80b8b..1e72c9486 100644 --- a/examples/openapi-zod/drizzle.config.ts +++ b/examples/openapi-zod/drizzle.config.ts @@ -4,7 +4,7 @@ import { config } from "dotenv"; import { defineConfig } from "drizzle-kit"; let dbConfig: ReturnType; -if (process.env.GOOSIFY_ENV === "production") { +if (process.env.ENVIRONMENT === "production") { config({ path: "./.prod.vars" }); dbConfig = defineConfig({ schema: "./src/db/schema.ts", diff --git a/examples/openapi-zod/package.json b/examples/openapi-zod/package.json index 5026658f8..09f2463b4 100644 --- a/examples/openapi-zod/package.json +++ b/examples/openapi-zod/package.json @@ -7,10 +7,8 @@ "db:migrate": "wrangler d1 migrations apply zod-openapi --local", "db:studio": "drizzle-kit studio", "db:touch": "wrangler d1 execute zod-openapi --local --command='SELECT 1'", - "db:migrate:prod": "GOOSIFY_ENV=production drizzle-kit migrate", - "db:studio:prod": "GOOSIFY_ENV=production drizzle-kit studio", - "format": "biome check . --write", - "lint": "biome lint ." + "db:migrate:prod": "ENVIRONMENT=production drizzle-kit migrate", + "db:studio:prod": "ENVIRONMENT=production drizzle-kit studio" }, "dependencies": { "@fiberplane/hono-otel": "workspace:*", From f16fc7d4c3b141bafd56943ecc626bb2033e1dc4 Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 4 Dec 2024 21:03:41 +0100 Subject: [PATCH 66/67] Remove double .cursorrules line from gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 64ec4d825..675c41ffe 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ shared/dist .env .envrc -.cursorrules # Cursor .cursorrules From 9e38f23b241fa203cea2047abe85d56883d1a09a Mon Sep 17 00:00:00 2001 From: Brett Beutell Date: Wed, 4 Dec 2024 21:13:14 +0100 Subject: [PATCH 67/67] Add fix for when file tree query throws an error and crashes the app --- api/src/routes/app-routes.ts | 68 ++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/api/src/routes/app-routes.ts b/api/src/routes/app-routes.ts index e81794060..26f8b2af8 100644 --- a/api/src/routes/app-routes.ts +++ b/api/src/routes/app-routes.ts @@ -70,42 +70,50 @@ app.get("/v0/app-routes-file-tree", async (ctx) => { eq(appRoutes.routeOrigin, "discovered"), ), }); + try { + const result = await getResult(); + + const routeEntries = []; + for (const currentRoute of routes) { + const url = new URL("http://localhost"); + url.pathname = currentRoute.path ?? ""; + const request = new Request(url, { + method: currentRoute.method ?? "", + }); + result.resetHistory(); + const response = await result.currentApp.fetch(request); + const responseText = await response.text(); + + if (responseText !== "Ok") { + logger.warn( + "Failed to fetch route for context expansion", + responseText, + ); + continue; + } - const result = await getResult(); - - const routeEntries = []; - for (const currentRoute of routes) { - const url = new URL("http://localhost"); - url.pathname = currentRoute.path ?? ""; - const request = new Request(url, { - method: currentRoute.method ?? "", - }); - result.resetHistory(); - const response = await result.currentApp.fetch(request); - const responseText = await response.text(); + const history = result.getHistory(); + const routeEntryId = history[history.length - 1]; + const routeEntry = result.getRouteEntryById(routeEntryId as RouteEntryId); - if (responseText !== "Ok") { - logger.warn("Failed to fetch route for context expansion", responseText); - continue; + routeEntries.push({ + ...currentRoute, + fileName: routeEntry?.fileName, + }); } - const history = result.getHistory(); - const routeEntryId = history[history.length - 1]; - const routeEntry = result.getRouteEntryById(routeEntryId as RouteEntryId); + const tree = buildRouteTree( + routeEntries.filter( + (route) => route?.fileName !== undefined, + ) as Array, + ); - routeEntries.push({ - ...currentRoute, - fileName: routeEntry?.fileName, - }); + return ctx.json(tree); + } catch (error) { + logger.info("Error constructing file tree routes:", error); + const tree = buildRouteTree(routes as Array); + return ctx.json(tree); } - - const tree = buildRouteTree( - routeEntries.filter( - (route) => route?.fileName !== undefined, - ) as Array, - ); - - return ctx.json(tree); }); /**