Unified · Split
| @@ -1,14 +1,8 @@ | |||
| 1 | { | 1 | { |
| 2 | "sources": { | 2 | "sources": { |
| 3 | "github": { | 3 | "vercel-api": { |
| 4 | "kind": "openapi", | 4 | "kind": "openapi", |
| 5 | "connection": { | 5 | "name": "Vercel API" |
| 6 | "endpoint": "https://raw.githubusercontent.com/github/rest-api-description/refs/heads/main/descriptions/ghes-3.9/%7Bprotocol%7D://%7Bhostname%7D/api/v3" | ||
| 7 | }, | ||
| 8 | "binding": { | ||
| 9 | "specUrl": "https://raw.githubusercontent.com/github/rest-api-description/refs/heads/main/descriptions/ghes-3.9/ghes-3.9.yaml", | ||
| 10 | "defaultHeaders": null | ||
| 11 | } | ||
| 12 | } | 6 | } |
| 13 | } | 7 | } |
| 14 | } | 8 | } |
| @@ -83,4 +83,5 @@ sources/composio-scrape/data/ · 82 unmodified lines | |||
| 83 | personal-notes/ | 83 | personal-notes/ |
| 84 | .tmp/ | 84 | .tmp/ |
| 85 | executor/.env.executor-push | 85 | executor/.env.executor-push |
| 86 | ignored/ | 86 | ignored/ |
| 87 | agent-notes/ | ||
| @@ -105,7 +105,7 @@ export function SourcePluginDetailPage(input: { · 104 unmodified lines | |||
| 105 | } | 105 | } |
| 106 | 106 | ||
| 107 | return ( | 107 | return ( |
| 108 | <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14"> | 108 | <div className="h-full min-h-0"> |
| 109 | {definition.renderDetailPage({ | 109 | {definition.renderDetailPage({ |
| 110 | source: loadedSource, | 110 | source: loadedSource, |
| 111 | route: { | 111 | route: { |
| @@ -146,12 +146,12 @@ · 145 unmodified lines | |||
| 146 | "dependencies": { | 146 | "dependencies": { |
| 147 | "@effect-atom/atom": "0.5.3", | 147 | "@effect-atom/atom": "0.5.3", |
| 148 | "@effect-atom/atom-react": "0.5.0", | 148 | "@effect-atom/atom-react": "0.5.0", |
| 149 | "@effect/platform": "catalog:", | ||
| 149 | "@executor/platform-api": "workspace:*", | 150 | "@executor/platform-api": "workspace:*", |
| 150 | "@executor/platform-sdk": "workspace:*", | 151 | "@executor/platform-sdk": "workspace:*", |
| 151 | "effect": "catalog:", | 152 | "effect": "catalog:", |
| 152 | }, | 153 | }, |
| 153 | "devDependencies": { | 154 | "devDependencies": { |
| 154 | "@effect/platform": "catalog:", | ||
| 155 | "@effect/platform-node": "catalog:", | 155 | "@effect/platform-node": "catalog:", |
| 156 | "@effect/vitest": "catalog:", | 156 | "@effect/vitest": "catalog:", |
| 157 | "@executor/effect-test-utils": "workspace:*", | 157 | "@executor/effect-test-utils": "workspace:*", |
| @@ -460,10 +460,13 @@ · 302 unmodified lines | |||
| 460 | "name": "@executor/plugin-openapi-sdk", | 460 | "name": "@executor/plugin-openapi-sdk", |
| 461 | "version": "0.0.0", | 461 | "version": "0.0.0", |
| 462 | "dependencies": { | 462 | "dependencies": { |
| 463 | "@executor/codemode-core": "workspace:*", | ||
| 464 | "@executor/ir": "workspace:*", | ||
| 463 | "@executor/platform-sdk": "workspace:*", | 465 | "@executor/platform-sdk": "workspace:*", |
| 464 | "@executor/plugin-openapi-shared": "workspace:*", | 466 | "@executor/plugin-openapi-shared": "workspace:*", |
| 465 | "@executor/source-core": "workspace:*", | 467 | "@executor/source-core": "workspace:*", |
| 466 | "effect": "catalog:", | 468 | "effect": "catalog:", |
| 469 | "yaml": "^2.8.1", | ||
| 467 | }, | 470 | }, |
| 468 | "devDependencies": { | 471 | "devDependencies": { |
| 469 | "@types/node": "catalog:", | 472 | "@types/node": "catalog:", |
| @@ -48,11 +48,12 @@ import { · 47 unmodified lines | |||
| 48 | getSourceInspectionToolDetail, | 48 | getSourceInspectionToolDetail, |
| 49 | } from "./sources/inspection"; | 49 | } from "./sources/inspection"; |
| 50 | import { | 50 | import { |
| 51 | createManagedSource, | 51 | createManagedSourceRecord, |
| 52 | getSource, | 52 | getSource, |
| 53 | refreshManagedSourceCatalog, | ||
| 53 | listSources, | 54 | listSources, |
| 54 | removeSource, | 55 | removeSource, |
| 55 | saveManagedSource, | 56 | saveManagedSourceRecord, |
| 56 | } from "./sources/operations"; | 57 | } from "./sources/operations"; |
| 57 | import type { ExecutorBackend } from "./backend"; | 58 | import type { ExecutorBackend } from "./backend"; |
| 58 | import { | 59 | import { |
| @@ -288,7 +289,7 @@ export const createExecutorEffect = < · 229 unmodified lines | |||
| 288 | sources: { | 289 | sources: { |
| 289 | create: ({ source }) => | 290 | create: ({ source }) => |
| 290 | providePluginHostEffect( | 291 | providePluginHostEffect( |
| 291 | createManagedSource({ | 292 | createManagedSourceRecord({ |
| 292 | scopeId: executor.scopeId, | 293 | scopeId: executor.scopeId, |
| 293 | actorScopeId: executor.actorScopeId, | 294 | actorScopeId: executor.actorScopeId, |
| 294 | source, | 295 | source, |
| @@ -304,11 +305,19 @@ export const createExecutorEffect = < · 9 unmodified lines | |||
| 304 | ), | 305 | ), |
| 305 | save: (source) => | 306 | save: (source) => |
| 306 | providePluginHostEffect( | 307 | providePluginHostEffect( |
| 307 | saveManagedSource({ | 308 | saveManagedSourceRecord({ |
| 308 | actorScopeId: executor.actorScopeId, | 309 | actorScopeId: executor.actorScopeId, |
| 309 | source, | 310 | source, |
| 310 | }), | 311 | }), |
| 311 | ), | 312 | ), |
| 313 | refreshCatalog: (sourceId) => | ||
| 314 | providePluginHostEffect( | ||
| 315 | refreshManagedSourceCatalog({ | ||
| 316 | scopeId: executor.scopeId, | ||
| 317 | sourceId, | ||
| 318 | actorScopeId: executor.actorScopeId, | ||
| 319 | }), | ||
| 320 | ), | ||
| 312 | remove: (sourceId) => | 321 | remove: (sourceId) => |
| 313 | providePluginHostEffect( | 322 | providePluginHostEffect( |
| 314 | removeSource({ | 323 | removeSource({ |
| @@ -34,6 +34,9 @@ export type ExecutorSdkPluginHost = { · 33 unmodified lines | |||
| 34 | }) => Effect.Effect<ExecutorSource, Error, never>; | 34 | }) => Effect.Effect<ExecutorSource, Error, never>; |
| 35 | get: (sourceId: ExecutorSource["id"]) => Effect.Effect<ExecutorSource, Error, never>; | 35 | get: (sourceId: ExecutorSource["id"]) => Effect.Effect<ExecutorSource, Error, never>; |
| 36 | save: (source: ExecutorSource) => Effect.Effect<ExecutorSource, Error, never>; | 36 | save: (source: ExecutorSource) => Effect.Effect<ExecutorSource, Error, never>; |
| 37 | refreshCatalog: ( | ||
| 38 | sourceId: ExecutorSource["id"], | ||
| 39 | ) => Effect.Effect<ExecutorSource, Error, never>; | ||
| 37 | remove: (sourceId: ExecutorSource["id"]) => Effect.Effect<boolean, Error, never>; | 40 | remove: (sourceId: ExecutorSource["id"]) => Effect.Effect<boolean, Error, never>; |
| 38 | }; | 41 | }; |
| 39 | }; | 42 | }; |
| @@ -156,6 +156,42 @@ export const createManagedSource = (input: { · 155 unmodified lines | |||
| 156 | }), | 156 | }), |
| 157 | ); | 157 | ); |
| 158 | 158 | ||
| 159 | export const createManagedSourceRecord = (input: { | ||
| 160 | scopeId: ScopeId; | ||
| 161 | actorScopeId: ScopeId; | ||
| 162 | source: Omit< | ||
| 163 | Source, | ||
| 164 | "id" | "scopeId" | "createdAt" | "updatedAt" | ||
| 165 | >; | ||
| 166 | }) => | ||
| 167 | Effect.flatMap(ExecutorStateStore, () => | ||
| 168 | Effect.gen(function* () { | ||
| 169 | const sourceStore = yield* RuntimeSourceStoreService; | ||
| 170 | const now = Date.now(); | ||
| 171 | |||
| 172 | const source = yield* normalizeSourceForCreate({ | ||
| 173 | scopeId: input.scopeId, | ||
| 174 | sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`), | ||
| 175 | source: input.source, | ||
| 176 | now, | ||
| 177 | }).pipe( | ||
| 178 | Effect.mapError((cause) => | ||
| 179 | sourceOps.create.badRequest( | ||
| 180 | "Invalid source definition", | ||
| 181 | cause instanceof Error ? cause.message : String(cause), | ||
| 182 | ), | ||
| 183 | ), | ||
| 184 | ); | ||
| 185 | |||
| 186 | return yield* mapPersistenceError( | ||
| 187 | sourceOps.create.child("persist"), | ||
| 188 | sourceStore.persistSource(source, { | ||
| 189 | actorScopeId: input.actorScopeId, | ||
| 190 | }), | ||
| 191 | ); | ||
| 192 | }), | ||
| 193 | ); | ||
| 194 | |||
| 159 | export const getSource = (input: { | 195 | export const getSource = (input: { |
| 160 | scopeId: ScopeId; | 196 | scopeId: ScopeId; |
| 161 | sourceId: SourceId; | 197 | sourceId: SourceId; |
| @@ -224,6 +260,84 @@ export const saveManagedSource = (input: { · 62 unmodified lines | |||
| 224 | }), | 260 | }), |
| 225 | ); | 261 | ); |
| 226 | 262 | ||
| 263 | export const saveManagedSourceRecord = (input: { | ||
| 264 | actorScopeId: ScopeId; | ||
| 265 | source: Source; | ||
| 266 | }) => | ||
| 267 | Effect.flatMap(ExecutorStateStore, () => | ||
| 268 | Effect.gen(function* () { | ||
| 269 | const sourceStore = yield* RuntimeSourceStoreService; | ||
| 270 | const updatedSource = yield* normalizeSourceForSave({ | ||
| 271 | source: input.source, | ||
| 272 | now: Date.now(), | ||
| 273 | }).pipe( | ||
| 274 | Effect.mapError((cause) => | ||
| 275 | sourceOps.update.badRequest( | ||
| 276 | "Invalid source definition", | ||
| 277 | cause instanceof Error ? cause.message : String(cause), | ||
| 278 | ), | ||
| 279 | ), | ||
| 280 | ); | ||
| 281 | |||
| 282 | return yield* mapPersistenceError( | ||
| 283 | sourceOps.update.child("persist"), | ||
| 284 | sourceStore.persistSource(updatedSource, { | ||
| 285 | actorScopeId: input.actorScopeId, | ||
| 286 | }), | ||
| 287 | ); | ||
| 288 | }), | ||
| 289 | ); | ||
| 290 | |||
| 291 | export const refreshManagedSourceCatalog = (input: { | ||
| 292 | scopeId: ScopeId; | ||
| 293 | sourceId: SourceId; | ||
| 294 | actorScopeId: ScopeId; | ||
| 295 | }) => | ||
| 296 | Effect.flatMap(ExecutorStateStore, (store) => | ||
| 297 | Effect.gen(function* () { | ||
| 298 | const sourceStore = yield* RuntimeSourceStoreService; | ||
| 299 | const source = yield* sourceStore.loadSourceById({ | ||
| 300 | scopeId: input.scopeId, | ||
| 301 | sourceId: input.sourceId, | ||
| 302 | actorScopeId: input.actorScopeId, | ||
| 303 | }).pipe( | ||
| 304 | Effect.mapError((cause) => | ||
| 305 | cause instanceof Error && | ||
| 306 | cause.message.startsWith("Source not found:") | ||
| 307 | ? sourceOps.get.notFound( | ||
| 308 | "Source not found", | ||
| 309 | `scopeId=${input.scopeId} sourceId=${input.sourceId}`, | ||
| 310 | ) | ||
| 311 | : sourceOps.get.unknownStorage( | ||
| 312 | cause, | ||
| 313 | "Failed projecting stored source", | ||
| 314 | ), | ||
| 315 | ), | ||
| 316 | ); | ||
| 317 | |||
| 318 | yield* syncArtifactsForSource({ | ||
| 319 | store, | ||
| 320 | sourceStore, | ||
| 321 | source, | ||
| 322 | actorScopeId: input.actorScopeId, | ||
| 323 | operation: sourceOps.update, | ||
| 324 | }); | ||
| 325 | |||
| 326 | return yield* sourceStore.loadSourceById({ | ||
| 327 | scopeId: input.scopeId, | ||
| 328 | sourceId: input.sourceId, | ||
| 329 | actorScopeId: input.actorScopeId, | ||
| 330 | }).pipe( | ||
| 331 | Effect.mapError((cause) => | ||
| 332 | sourceOps.update.unknownStorage( | ||
| 333 | cause, | ||
| 334 | "Failed loading refreshed source", | ||
| 335 | ), | ||
| 336 | ), | ||
| 337 | ); | ||
| 338 | }), | ||
| 339 | ); | ||
| 340 | |||
| 227 | export const removeSource = (input: { | 341 | export const removeSource = (input: { |
| 228 | scopeId: ScopeId; | 342 | scopeId: ScopeId; |
| 229 | sourceId: SourceId; | 343 | sourceId: SourceId; |
| @@ -6,6 +6,7 @@ import { · 5 unmodified lines | |||
| 6 | HttpApiSchema, | 6 | HttpApiSchema, |
| 7 | } from "@effect/platform"; | 7 | } from "@effect/platform"; |
| 8 | import { | 8 | import { |
| 9 | ControlPlaneNotFoundError, | ||
| 9 | ControlPlaneBadRequestError, | 10 | ControlPlaneBadRequestError, |
| 10 | ControlPlaneForbiddenError, | 11 | ControlPlaneForbiddenError, |
| 11 | ControlPlaneStorageError, | 12 | ControlPlaneStorageError, |
| @@ -15,18 +16,24 @@ import { · 3 unmodified lines | |||
| 15 | import { resolveRequestedLocalWorkspace } from "@executor/platform-api/local-context"; | 16 | import { resolveRequestedLocalWorkspace } from "@executor/platform-api/local-context"; |
| 16 | import { | 17 | import { |
| 17 | ScopeIdSchema, | 18 | ScopeIdSchema, |
| 19 | SourceIdSchema, | ||
| 18 | SourceSchema, | 20 | SourceSchema, |
| 19 | type Source, | 21 | type Source, |
| 20 | } from "@executor/platform-sdk/schema"; | 22 | } from "@executor/platform-sdk/schema"; |
| 21 | import * as Effect from "effect/Effect"; | 23 | import * as Effect from "effect/Effect"; |
| 24 | import * as Schema from "effect/Schema"; | ||
| 22 | 25 | ||
| 23 | import { | 26 | import { |
| 24 | OpenApiConnectInputSchema, | 27 | OpenApiConnectInputSchema, |
| 25 | OpenApiPreviewRequestSchema, | 28 | OpenApiPreviewRequestSchema, |
| 26 | OpenApiPreviewResponseSchema, | 29 | OpenApiPreviewResponseSchema, |
| 30 | OpenApiSourceConfigPayloadSchema, | ||
| 31 | OpenApiUpdateSourceInputSchema, | ||
| 27 | type OpenApiConnectInput, | 32 | type OpenApiConnectInput, |
| 28 | type OpenApiPreviewRequest, | 33 | type OpenApiPreviewRequest, |
| 29 | type OpenApiPreviewResponse, | 34 | type OpenApiPreviewResponse, |
| 35 | type OpenApiSourceConfigPayload, | ||
| 36 | type OpenApiUpdateSourceInput, | ||
| 30 | } from "@executor/plugin-openapi-shared"; | 37 | } from "@executor/plugin-openapi-shared"; |
| 31 | 38 | ||
| 32 | type OpenApiExecutorExtension = { | 39 | type OpenApiExecutorExtension = { |
| @@ -37,10 +44,20 @@ type OpenApiExecutorExtension = { · 4 unmodified lines | |||
| 37 | createSource: ( | 44 | createSource: ( |
| 38 | input: OpenApiConnectInput, | 45 | input: OpenApiConnectInput, |
| 39 | ) => Effect.Effect<Source, Error, never>; | 46 | ) => Effect.Effect<Source, Error, never>; |
| 47 | getSourceConfig: ( | ||
| 48 | sourceId: Source["id"], | ||
| 49 | ) => Effect.Effect<OpenApiSourceConfigPayload, Error, never>; | ||
| 50 | updateSource: ( | ||
| 51 | input: OpenApiUpdateSourceInput, | ||
| 52 | ) => Effect.Effect<Source, Error, never>; | ||
| 53 | removeSource: ( | ||
| 54 | sourceId: Source["id"], | ||
| 55 | ) => Effect.Effect<boolean, Error, never>; | ||
| 40 | }; | 56 | }; |
| 41 | }; | 57 | }; |
| 42 | 58 | ||
| 43 | const workspaceIdParam = HttpApiSchema.param("workspaceId", ScopeIdSchema); | 59 | const workspaceIdParam = HttpApiSchema.param("workspaceId", ScopeIdSchema); |
| 60 | const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema); | ||
| 44 | 61 | ||
| 45 | export const OpenApiHttpGroup = HttpApiGroup.make("openapi") | 62 | export const OpenApiHttpGroup = HttpApiGroup.make("openapi") |
| 46 | .add( | 63 | .add( |
| @@ -59,6 +76,31 @@ export const OpenApiHttpGroup = HttpApiGroup.make("openapi") · 12 unmodified lines | |||
| 59 | .addError(ControlPlaneForbiddenError) | 76 | .addError(ControlPlaneForbiddenError) |
| 60 | .addError(ControlPlaneStorageError), | 77 | .addError(ControlPlaneStorageError), |
| 61 | ) | 78 | ) |
| 79 | .add( | ||
| 80 | HttpApiEndpoint.get("getSourceConfig")`/workspaces/${workspaceIdParam}/plugins/openapi/sources/${sourceIdParam}` | ||
| 81 | .addSuccess(OpenApiSourceConfigPayloadSchema) | ||
| 82 | .addError(ControlPlaneBadRequestError) | ||
| 83 | .addError(ControlPlaneForbiddenError) | ||
| 84 | .addError(ControlPlaneNotFoundError) | ||
| 85 | .addError(ControlPlaneStorageError), | ||
| 86 | ) | ||
| 87 | .add( | ||
| 88 | HttpApiEndpoint.put("updateSource")`/workspaces/${workspaceIdParam}/plugins/openapi/sources/${sourceIdParam}` | ||
| 89 | .setPayload(OpenApiSourceConfigPayloadSchema) | ||
| 90 | .addSuccess(SourceSchema) | ||
| 91 | .addError(ControlPlaneBadRequestError) | ||
| 92 | .addError(ControlPlaneForbiddenError) | ||
| 93 | .addError(ControlPlaneNotFoundError) | ||
| 94 | .addError(ControlPlaneStorageError), | ||
| 95 | ) | ||
| 96 | .add( | ||
| 97 | HttpApiEndpoint.del("removeSource")`/workspaces/${workspaceIdParam}/plugins/openapi/sources/${sourceIdParam}` | ||
| 98 | .addSuccess(Schema.Struct({ removed: Schema.Boolean })) | ||
| 99 | .addError(ControlPlaneBadRequestError) | ||
| 100 | .addError(ControlPlaneForbiddenError) | ||
| 101 | .addError(ControlPlaneNotFoundError) | ||
| 102 | .addError(ControlPlaneStorageError), | ||
| 103 | ) | ||
| 62 | .prefix("/v1"); | 104 | .prefix("/v1"); |
| 63 | 105 | ||
| 64 | export const OpenApiHttpApi = HttpApi.make("executor").add(OpenApiHttpGroup); | 106 | export const OpenApiHttpApi = HttpApi.make("executor").add(OpenApiHttpGroup); |
| @@ -82,6 +124,22 @@ const toStorageError = (operation: string) => (cause: unknown) => · 17 unmodified lines | |||
| 82 | details: cause instanceof Error ? cause.stack ?? cause.message : String(cause), | 124 | details: cause instanceof Error ? cause.stack ?? cause.message : String(cause), |
| 83 | }); | 125 | }); |
| 84 | 126 | ||
| 127 | const toNotFoundError = (operation: string, cause: unknown) => | ||
| 128 | new ControlPlaneNotFoundError({ | ||
| 129 | operation, | ||
| 130 | message: cause instanceof Error ? cause.message : String(cause), | ||
| 131 | details: cause instanceof Error ? cause.stack ?? cause.message : String(cause), | ||
| 132 | }); | ||
| 133 | |||
| 134 | const mapPluginStorageError = (operation: string) => (cause: unknown) => { | ||
| 135 | const message = cause instanceof Error ? cause.message : String(cause); | ||
| 136 | if (message.includes("not found") || message.includes("Not found")) { | ||
| 137 | return toNotFoundError(operation, cause); | ||
| 138 | } | ||
| 139 | |||
| 140 | return toStorageError(operation)(cause); | ||
| 141 | }; | ||
| 142 | |||
| 85 | export const openApiHttpPlugin = (): ExecutorHttpPlugin< | 143 | export const openApiHttpPlugin = (): ExecutorHttpPlugin< |
| 86 | typeof OpenApiHttpGroup, | 144 | typeof OpenApiHttpGroup, |
| 87 | OpenApiExecutorExtension | 145 | OpenApiExecutorExtension |
| @@ -109,5 +167,38 @@ export const openApiHttpPlugin = (): ExecutorHttpPlugin< · 21 unmodified lines | |||
| 109 | Effect.mapError(toStorageError("openapi.createSource")), | 167 | Effect.mapError(toStorageError("openapi.createSource")), |
| 110 | ) | 168 | ) |
| 111 | ) | 169 | ) |
| 170 | .handle("getSourceConfig", ({ path }) => | ||
| 171 | resolveRequestedLocalWorkspace( | ||
| 172 | "openapi.getSourceConfig", | ||
| 173 | path.workspaceId, | ||
| 174 | ).pipe( | ||
| 175 | Effect.flatMap(() => executor.openapi.getSourceConfig(path.sourceId)), | ||
| 176 | Effect.mapError(mapPluginStorageError("openapi.getSourceConfig")), | ||
| 177 | ) | ||
| 178 | ) | ||
| 179 | .handle("updateSource", ({ path, payload }) => | ||
| 180 | resolveRequestedLocalWorkspace( | ||
| 181 | "openapi.updateSource", | ||
| 182 | path.workspaceId, | ||
| 183 | ).pipe( | ||
| 184 | Effect.flatMap(() => | ||
| 185 | executor.openapi.updateSource({ | ||
| 186 | sourceId: path.sourceId, | ||
| 187 | config: payload, | ||
| 188 | }) | ||
| 189 | ), | ||
| 190 | Effect.mapError(mapPluginStorageError("openapi.updateSource")), | ||
| 191 | ) | ||
| 192 | ) | ||
| 193 | .handle("removeSource", ({ path }) => | ||
| 194 | resolveRequestedLocalWorkspace( | ||
| 195 | "openapi.removeSource", | ||
| 196 | path.workspaceId, | ||
| 197 | ).pipe( | ||
| 198 | Effect.flatMap(() => executor.openapi.removeSource(path.sourceId)), | ||
| 199 | Effect.map((removed) => ({ removed })), | ||
| 200 | Effect.mapError(mapPluginStorageError("openapi.removeSource")), | ||
| 201 | ) | ||
| 202 | ) | ||
| 112 | ), | 203 | ), |
| 113 | }); | 204 | }); |
| @@ -1,4 +1,9 @@ | |||
| 1 | import type { Source } from "@executor/react"; | 1 | import type { |
| 2 | Loadable, | ||
| 3 | Source, | ||
| 4 | SourceInspection, | ||
| 5 | SourceInspectionToolDetail, | ||
| 6 | } from "@executor/react"; | ||
| 2 | import { | 7 | import { |
| 3 | Result, | 8 | Result, |
| 4 | defineExecutorPluginHttpApiClient, | 9 | defineExecutorPluginHttpApiClient, |
| @@ -6,17 +11,38 @@ import { · 1 unmodified line | |||
| 6 | useAtomSet, | 11 | useAtomSet, |
| 7 | useExecutorMutation, | 12 | useExecutorMutation, |
| 8 | useLocalInstallation, | 13 | useLocalInstallation, |
| 14 | usePrefetchToolDetail, | ||
| 15 | useSourceDiscovery, | ||
| 16 | useSourceInspection, | ||
| 17 | useSourceToolDetail, | ||
| 9 | } from "@executor/react"; | 18 | } from "@executor/react"; |
| 19 | import { EmptyState, LoadableBlock } from "../../../apps/web/src/components/loadable"; | ||
| 20 | import { DocumentPanel } from "../../../apps/web/src/components/document-panel"; | ||
| 21 | import { Markdown } from "../../../apps/web/src/components/markdown"; | ||
| 22 | import { | ||
| 23 | IconCheck, | ||
| 24 | IconChevron, | ||
| 25 | IconClose, | ||
| 26 | IconCopy, | ||
| 27 | IconFolder, | ||
| 28 | IconPencil, | ||
| 29 | IconSearch, | ||
| 30 | IconTool, | ||
| 31 | } from "../../../apps/web/src/components/icons"; | ||
| 32 | import { Badge, MethodBadge } from "../../../apps/web/src/components/ui/badge"; | ||
| 33 | import { cn } from "../../../apps/web/src/lib/utils"; | ||
| 10 | import { | 34 | import { |
| 11 | openApiHttpApiExtension, | 35 | openApiHttpApiExtension, |
| 12 | } from "@executor/plugin-openapi-http"; | 36 | } from "@executor/plugin-openapi-http"; |
| 13 | import type { | 37 | import type { |
| 14 | OpenApiConnectInput, | 38 | OpenApiConnectInput, |
| 15 | OpenApiPreviewRequest, | 39 | OpenApiPreviewRequest, |
| 40 | OpenApiPreviewSecurityScheme, | ||
| 16 | OpenApiPreviewResponse, | 41 | OpenApiPreviewResponse, |
| 42 | OpenApiSourceConfigPayload, | ||
| 17 | } from "@executor/plugin-openapi-shared"; | 43 | } from "@executor/plugin-openapi-shared"; |
| 18 | import { useNavigate } from "@tanstack/react-router"; | 44 | import { useNavigate } from "@tanstack/react-router"; |
| 19 | import { startTransition, useState, type ReactNode } from "react"; | 45 | import { startTransition, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 20 | 46 | ||
| 21 | type FrontendSourceTypeDefinition = { | 47 | type FrontendSourceTypeDefinition = { |
| 22 | kind: string; | 48 | kind: string; |
| @@ -38,6 +64,12 @@ type FrontendPluginRegisterApi = { · 15 unmodified lines | |||
| 38 | }; | 64 | }; |
| 39 | }; | 65 | }; |
| 40 | 66 | ||
| 67 | type RouteToolSearch = { | ||
| 68 | tab?: "model" | "discover"; | ||
| 69 | tool?: string; | ||
| 70 | query?: string; | ||
| 71 | }; | ||
| 72 | |||
| 41 | const defaultOpenApiInput = (): OpenApiConnectInput => ({ | 73 | const defaultOpenApiInput = (): OpenApiConnectInput => ({ |
| 42 | name: "My OpenAPI Source", | 74 | name: "My OpenAPI Source", |
| 43 | specUrl: "https://example.com/openapi.json", | 75 | specUrl: "https://example.com/openapi.json", |
| @@ -68,19 +100,108 @@ const Section = (props: { · 24 unmodified lines | |||
| 68 | </section> | 100 | </section> |
| 69 | ); | 101 | ); |
| 70 | 102 | ||
| 71 | function OpenApiAddSourcePage(props: { | 103 | type ToolTreeNode = { |
| 72 | initialValue: OpenApiConnectInput; | 104 | segment: string; |
| 73 | }) { | 105 | tool?: SourceInspectionToolDetail["summary"] | SourceInspection["tools"][number]; |
| 74 | const navigate = useNavigate(); | 106 | children: Map<string, ToolTreeNode>; |
| 107 | }; | ||
| 108 | |||
| 109 | const buildToolTree = (tools: SourceInspection["tools"]): ToolTreeNode => { | ||
| 110 | const root: ToolTreeNode = { | ||
| 111 | segment: "", | ||
| 112 | children: new Map(), | ||
| 113 | }; | ||
| 114 | |||
| 115 | for (const tool of tools) { | ||
| 116 | const parts = tool.path.split("."); | ||
| 117 | let node = root; | ||
| 118 | for (const part of parts) { | ||
| 119 | const existing = node.children.get(part); | ||
| 120 | if (existing) { | ||
| 121 | node = existing; | ||
| 122 | continue; | ||
| 123 | } | ||
| 124 | |||
| 125 | const next: ToolTreeNode = { | ||
| 126 | segment: part, | ||
| 127 | children: new Map(), | ||
| 128 | }; | ||
| 129 | node.children.set(part, next); | ||
| 130 | node = next; | ||
| 131 | } | ||
| 132 | node.tool = tool; | ||
| 133 | } | ||
| 134 | |||
| 135 | return root; | ||
| 136 | }; | ||
| 137 | |||
| 138 | const countToolLeaves = (node: ToolTreeNode): number => { | ||
| 139 | let count = node.tool ? 1 : 0; | ||
| 140 | for (const child of node.children.values()) { | ||
| 141 | count += countToolLeaves(child); | ||
| 142 | } | ||
| 143 | return count; | ||
| 144 | }; | ||
| 145 | |||
| 146 | const isPreviewableSpecUrl = (value: string): boolean => { | ||
| 147 | const trimmed = value.trim(); | ||
| 148 | if (!/^https?:\/\//i.test(trimmed)) { | ||
| 149 | return false; | ||
| 150 | } | ||
| 151 | |||
| 152 | try { | ||
| 153 | const url = new URL(trimmed); | ||
| 154 | return url.hostname !== "example.com"; | ||
| 155 | } catch { | ||
| 156 | return false; | ||
| 157 | } | ||
| 158 | }; | ||
| 159 | |||
| 160 | const previewSecuritySchemeLabel = (scheme: OpenApiPreviewSecurityScheme): string => { | ||
| 161 | if (scheme.kind === "apiKey") { | ||
| 162 | return scheme.placement ? `API key in ${scheme.placement}` : "API key"; | ||
| 163 | } | ||
| 164 | |||
| 165 | if (scheme.kind === "http") { | ||
| 166 | return scheme.scheme ? `HTTP ${scheme.scheme}` : "HTTP auth"; | ||
| 167 | } | ||
| 168 | |||
| 169 | if (scheme.kind === "oauth2") { | ||
| 170 | return "OAuth 2.0"; | ||
| 171 | } | ||
| 172 | |||
| 173 | if (scheme.kind === "openIdConnect") { | ||
| 174 | return "OpenID Connect"; | ||
| 175 | } | ||
| 176 | |||
| 177 | return "Custom auth"; | ||
| 178 | }; | ||
| 179 | |||
| 180 | const inputFromConfig = ( | ||
| 181 | config: OpenApiSourceConfigPayload, | ||
| 182 | ): OpenApiConnectInput => ({ | ||
| 183 | name: config.name, | ||
| 184 | specUrl: config.specUrl, | ||
| 185 | baseUrl: config.baseUrl, | ||
| 186 | auth: config.auth, | ||
| 187 | }); | ||
| 188 | |||
| 189 | const useWorkspaceId = (): Source["scopeId"] => { | ||
| 75 | const installation = useLocalInstallation(); | 190 | const installation = useLocalInstallation(); |
| 76 | const [name, setName] = useState(props.initialValue.name); | 191 | if (installation.status === "ready") { |
| 77 | const [specUrl, setSpecUrl] = useState(props.initialValue.specUrl); | 192 | return installation.data.scopeId; |
| 78 | const [baseUrl, setBaseUrl] = useState(props.initialValue.baseUrl ?? ""); | 193 | } |
| 79 | const [error, setError] = useState<string | null>(null); | 194 | |
| 80 | const [preview, setPreview] = useState<OpenApiPreviewResponse | null>(null); | 195 | if (installation.status === "error") { |
| 81 | const [nameEdited, setNameEdited] = useState(false); | 196 | throw installation.error; |
| 82 | const [baseUrlEdited, setBaseUrlEdited] = useState(false); | 197 | } |
| 83 | const openApiHttpClient = getOpenApiHttpClient(); | 198 | |
| 199 | throw new Error("Workspace is still loading."); | ||
| 200 | }; | ||
| 201 | |||
| 202 | const useAvailableSecrets = ( | ||
| 203 | openApiHttpClient: ReturnType<typeof getOpenApiHttpClient>, | ||
| 204 | ) => { | ||
| 84 | const secretsResult = useAtomValue( | 205 | const secretsResult = useAtomValue( |
| 85 | openApiHttpClient.query("local", "listSecrets", { | 206 | openApiHttpClient.query("local", "listSecrets", { |
| 86 | reactivityKeys: { | 207 | reactivityKeys: { |
| @@ -89,9 +210,27 @@ function OpenApiAddSourcePage(props: { · 2 unmodified lines | |||
| 89 | timeToLive: "1 minute", | 210 | timeToLive: "1 minute", |
| 90 | }), | 211 | }), |
| 91 | ); | 212 | ); |
| 92 | const availableSecrets = Result.isSuccess(secretsResult) | 213 | |
| 93 | ? secretsResult.value | 214 | return Result.isSuccess(secretsResult) ? secretsResult.value : []; |
| 94 | : []; | 215 | }; |
| 216 | |||
| 217 | function OpenApiSourceForm(props: { | ||
| 218 | mode: "create" | "edit"; | ||
| 219 | initialValue: OpenApiConnectInput; | ||
| 220 | submitLabel: string; | ||
| 221 | busyLabel: string; | ||
| 222 | onSubmit: (input: OpenApiConnectInput) => Promise<void>; | ||
| 223 | }) { | ||
| 224 | const openApiHttpClient = getOpenApiHttpClient(); | ||
| 225 | const availableSecrets = useAvailableSecrets(openApiHttpClient); | ||
| 226 | const previewDocument = useAtomSet( | ||
| 227 | openApiHttpClient.mutation("openapi", "previewDocument"), | ||
| 228 | { mode: "promise" }, | ||
| 229 | ); | ||
| 230 | const workspaceId = useWorkspaceId(); | ||
| 231 | const [name, setName] = useState(props.initialValue.name); | ||
| 232 | const [specUrl, setSpecUrl] = useState(props.initialValue.specUrl); | ||
| 233 | const [baseUrl, setBaseUrl] = useState(props.initialValue.baseUrl ?? ""); | ||
| 95 | const [authKind, setAuthKind] = useState<OpenApiConnectInput["auth"]["kind"]>( | 234 | const [authKind, setAuthKind] = useState<OpenApiConnectInput["auth"]["kind"]>( |
| 96 | props.initialValue.auth.kind, | 235 | props.initialValue.auth.kind, |
| 97 | ); | 236 | ); |
| @@ -100,64 +239,36 @@ function OpenApiAddSourcePage(props: { · 2 unmodified lines | |||
| 100 | ? props.initialValue.auth.tokenSecretRef | 239 | ? props.initialValue.auth.tokenSecretRef |
| 101 | : "", | 240 | : "", |
| 102 | ); | 241 | ); |
| 103 | const previewDocument = useAtomSet( | 242 | const [error, setError] = useState<string | null>(null); |
| 104 | openApiHttpClient.mutation("openapi", "previewDocument"), | 243 | const [preview, setPreview] = useState<OpenApiPreviewResponse | null>(null); |
| 105 | { mode: "promise" }, | 244 | const [nameEdited, setNameEdited] = useState(false); |
| 106 | ); | 245 | const [baseUrlEdited, setBaseUrlEdited] = useState(false); |
| 107 | const createSource = useAtomSet( | 246 | const [lastPreviewedSpecUrl, setLastPreviewedSpecUrl] = useState<string | null>(null); |
| 108 | openApiHttpClient.mutation("openapi", "createSource"), | ||
| 109 | { mode: "promise" }, | ||
| 110 | ); | ||
| 111 | |||
| 112 | const requireWorkspaceId = (): string => { | ||
| 113 | if (installation.status === "ready") { | ||
| 114 | return installation.data.scopeId; | ||
| 115 | } | ||
| 116 | |||
| 117 | if (installation.status === "error") { | ||
| 118 | throw installation.error; | ||
| 119 | } | ||
| 120 | |||
| 121 | throw new Error("Workspace is still loading."); | ||
| 122 | }; | ||
| 123 | |||
| 124 | const previewMutation = useExecutorMutation< | 247 | const previewMutation = useExecutorMutation< |
| 125 | OpenApiPreviewRequest, | 248 | OpenApiPreviewRequest, |
| 126 | OpenApiPreviewResponse | 249 | OpenApiPreviewResponse |
| 127 | >(async (payload) => | 250 | >(async (payload) => |
| 128 | previewDocument({ | 251 | previewDocument({ |
| 129 | path: { | 252 | path: { workspaceId }, |
| 130 | workspaceId: requireWorkspaceId() as Source["scopeId"], | ||
| 131 | }, | ||
| 132 | payload, | ||
| 133 | }) | ||
| 134 | ); | ||
| 135 | |||
| 136 | const createMutation = useExecutorMutation< | ||
| 137 | OpenApiConnectInput, | ||
| 138 | Source | ||
| 139 | >(async (payload) => | ||
| 140 | createSource({ | ||
| 141 | path: { | ||
| 142 | workspaceId: requireWorkspaceId() as Source["scopeId"], | ||
| 143 | }, | ||
| 144 | payload, | 253 | payload, |
| 145 | reactivityKeys: { | ||
| 146 | sources: [requireWorkspaceId()], | ||
| 147 | }, | ||
| 148 | }) | 254 | }) |
| 149 | ); | 255 | ); |
| 256 | const submitMutation = useExecutorMutation<OpenApiConnectInput, void>(props.onSubmit); | ||
| 150 | 257 | ||
| 151 | const handlePreview = async () => { | 258 | const runPreview = async (input: { |
| 152 | setError(null); | 259 | mode: "auto" | "manual"; |
| 260 | }) => { | ||
| 153 | const trimmedSpecUrl = specUrl.trim(); | 261 | const trimmedSpecUrl = specUrl.trim(); |
| 154 | if (!trimmedSpecUrl) { | 262 | if (!trimmedSpecUrl) { |
| 155 | setError("Spec URL is required."); | 263 | if (input.mode === "manual") { |
| 264 | setError("Spec URL is required."); | ||
| 265 | } | ||
| 266 | setPreview(null); | ||
| 267 | setLastPreviewedSpecUrl(null); | ||
| 156 | return; | 268 | return; |
| 157 | } | 269 | } |
| 158 | 270 | ||
| 159 | if (authKind === "bearer" && tokenSecretRef.trim().length === 0) { | 271 | if (!isPreviewableSpecUrl(trimmedSpecUrl)) { |
| 160 | setError("Select a secret for bearer auth."); | ||
| 161 | return; | 272 | return; |
| 162 | } | 273 | } |
| 163 | 274 | ||
| @@ -168,24 +279,48 @@ function OpenApiAddSourcePage(props: { · 4 unmodified lines | |||
| 168 | setPreview({ | 279 | setPreview({ |
| 169 | ...result, | 280 | ...result, |
| 170 | warnings: [...result.warnings], | 281 | warnings: [...result.warnings], |
| 282 | securitySchemes: [...result.securitySchemes], | ||
| 171 | }); | 283 | }); |
| 284 | setLastPreviewedSpecUrl(trimmedSpecUrl); | ||
| 285 | if (error) { | ||
| 286 | setError(null); | ||
| 287 | } | ||
| 172 | 288 | ||
| 173 | if (!nameEdited && result.title) { | 289 | if (!nameEdited && result.title) { |
| 174 | setName(result.title); | 290 | setName(result.title); |
| 175 | } | 291 | } |
| 176 | |||
| 177 | if (!baseUrlEdited && result.baseUrl) { | 292 | if (!baseUrlEdited && result.baseUrl) { |
| 178 | setBaseUrl(result.baseUrl); | 293 | setBaseUrl(result.baseUrl); |
| 179 | } | 294 | } |
| 180 | } catch (cause) { | 295 | } catch (cause) { |
| 181 | setError(cause instanceof Error ? cause.message : "Failed previewing document."); | 296 | if (input.mode === "manual") { |
| 297 | setError(cause instanceof Error ? cause.message : "Failed previewing document."); | ||
| 298 | } | ||
| 182 | setPreview(null); | 299 | setPreview(null); |
| 183 | } | 300 | } |
| 184 | }; | 301 | }; |
| 185 | 302 | ||
| 303 | useEffect(() => { | ||
| 304 | const trimmedSpecUrl = specUrl.trim(); | ||
| 305 | if (!isPreviewableSpecUrl(trimmedSpecUrl)) { | ||
| 306 | return; | ||
| 307 | } | ||
| 308 | |||
| 309 | if (trimmedSpecUrl === lastPreviewedSpecUrl) { | ||
| 310 | return; | ||
| 311 | } | ||
| 312 | |||
| 313 | const timeout = window.setTimeout(() => { | ||
| 314 | void runPreview({ mode: "auto" }); | ||
| 315 | }, 450); | ||
| 316 | |||
| 317 | return () => { | ||
| 318 | window.clearTimeout(timeout); | ||
| 319 | }; | ||
| 320 | }, [specUrl, lastPreviewedSpecUrl]); | ||
| 321 | |||
| 186 | const handleSubmit = async () => { | 322 | const handleSubmit = async () => { |
| 187 | setError(null); | 323 | setError(null); |
| 188 | |||
| 189 | const trimmedName = name.trim(); | 324 | const trimmedName = name.trim(); |
| 190 | const trimmedSpecUrl = specUrl.trim(); | 325 | const trimmedSpecUrl = specUrl.trim(); |
| 191 | const trimmedBaseUrl = baseUrl.trim(); | 326 | const trimmedBaseUrl = baseUrl.trim(); |
| @@ -194,14 +329,17 @@ function OpenApiAddSourcePage(props: { · 2 unmodified lines | |||
| 194 | setError("Name is required."); | 329 | setError("Name is required."); |
| 195 | return; | 330 | return; |
| 196 | } | 331 | } |
| 197 | |||
| 198 | if (!trimmedSpecUrl) { | 332 | if (!trimmedSpecUrl) { |
| 199 | setError("Spec URL is required."); | 333 | setError("Spec URL is required."); |
| 200 | return; | 334 | return; |
| 201 | } | 335 | } |
| 336 | if (authKind === "bearer" && tokenSecretRef.trim().length === 0) { | ||
| 337 | setError("Select a secret for bearer auth."); | ||
| 338 | return; | ||
| 339 | } | ||
| 202 | 340 | ||
| 203 | try { | 341 | try { |
| 204 | const source = await createMutation.mutateAsync({ | 342 | await submitMutation.mutateAsync({ |
| 205 | name: trimmedName, | 343 | name: trimmedName, |
| 206 | specUrl: trimmedSpecUrl, | 344 | specUrl: trimmedSpecUrl, |
| 207 | baseUrl: trimmedBaseUrl || null, | 345 | baseUrl: trimmedBaseUrl || null, |
| @@ -209,26 +347,14 @@ function OpenApiAddSourcePage(props: { · 1 unmodified line | |||
| 209 | authKind === "bearer" | 347 | authKind === "bearer" |
| 210 | ? { | 348 | ? { |
| 211 | kind: "bearer", | 349 | kind: "bearer", |
| 212 | tokenSecretRef, | 350 | tokenSecretRef: tokenSecretRef.trim(), |
| 213 | } | 351 | } |
| 214 | : { | 352 | : { |
| 215 | kind: "none", | 353 | kind: "none", |
| 216 | }, | 354 | }, |
| 217 | }); | 355 | }); |
| 218 | |||
| 219 | startTransition(() => { | ||
| 220 | void navigate({ | ||
| 221 | to: "/sources/$sourceId", | ||
| 222 | params: { | ||
| 223 | sourceId: source.id, | ||
| 224 | }, | ||
| 225 | search: { | ||
| 226 | tab: "model", | ||
| 227 | }, | ||
| 228 | }); | ||
| 229 | }); | ||
| 230 | } catch (cause) { | 356 | } catch (cause) { |
| 231 | setError(cause instanceof Error ? cause.message : "Failed creating source."); | 357 | setError(cause instanceof Error ? cause.message : "Failed saving source."); |
| 232 | } | 358 | } |
| 233 | }; | 359 | }; |
| 234 | 360 | ||
| @@ -258,6 +384,9 @@ function OpenApiAddSourcePage(props: { · 23 unmodified lines | |||
| 258 | <input | 384 | <input |
| 259 | value={specUrl} | 385 | value={specUrl} |
| 260 | onChange={(event) => setSpecUrl(event.target.value)} | 386 | onChange={(event) => setSpecUrl(event.target.value)} |
| 387 | onBlur={() => { | ||
| 388 | void runPreview({ mode: "manual" }); | ||
| 389 | }} | ||
| 261 | placeholder="https://example.com/openapi.json" | 390 | placeholder="https://example.com/openapi.json" |
| 262 | className="h-10 w-full rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | 391 | className="h-10 w-full rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 263 | /> | 392 | /> |
| @@ -318,13 +447,18 @@ function OpenApiAddSourcePage(props: { · 54 unmodified lines | |||
| 318 | <button | 447 | <button |
| 319 | type="button" | 448 | type="button" |
| 320 | onClick={() => { | 449 | onClick={() => { |
| 321 | void handlePreview(); | 450 | void runPreview({ mode: "manual" }); |
| 322 | }} | 451 | }} |
| 323 | disabled={previewMutation.status === "pending" || createMutation.status === "pending"} | 452 | disabled={previewMutation.status === "pending" || submitMutation.status === "pending"} |
| 324 | className="inline-flex h-7 items-center justify-center rounded-md border border-input bg-transparent px-2.5 text-xs font-medium text-foreground disabled:pointer-events-none disabled:opacity-50" | 453 | className="inline-flex h-7 items-center justify-center rounded-md border border-input bg-transparent px-2.5 text-xs font-medium text-foreground disabled:pointer-events-none disabled:opacity-50" |
| 325 | > | 454 | > |
| 326 | {previewMutation.status === "pending" ? "Previewing..." : "Preview OpenAPI Document"} | 455 | {previewMutation.status === "pending" ? "Previewing..." : "Preview OpenAPI Document"} |
| 327 | </button> | 456 | </button> |
| 457 | {previewMutation.status === "pending" && ( | ||
| 458 | <div className="text-xs text-muted-foreground"> | ||
| 459 | Inferring from spec... | ||
| 460 | </div> | ||
| 461 | )} | ||
| 328 | {preview && ( | 462 | {preview && ( |
| 329 | <div className="text-xs text-muted-foreground"> | 463 | <div className="text-xs text-muted-foreground"> |
| 330 | {preview.operationCount} operations | 464 | {preview.operationCount} operations |
| @@ -347,6 +481,20 @@ function OpenApiAddSourcePage(props: { · 16 unmodified lines | |||
| 347 | <span className="font-medium text-foreground">Base URL:</span>{" "} | 481 | <span className="font-medium text-foreground">Base URL:</span>{" "} |
| 348 | <span className="text-muted-foreground">{preview.baseUrl ?? "Not declared"}</span> | 482 | <span className="text-muted-foreground">{preview.baseUrl ?? "Not declared"}</span> |
| 349 | </div> | 483 | </div> |
| 484 | <div> | ||
| 485 | <span className="font-medium text-foreground">Namespace:</span>{" "} | ||
| 486 | <span className="text-muted-foreground">{preview.namespace ?? "Not inferred"}</span> | ||
| 487 | </div> | ||
| 488 | <div> | ||
| 489 | <span className="font-medium text-foreground">Auth:</span>{" "} | ||
| 490 | <span className="text-muted-foreground"> | ||
| 491 | {preview.securitySchemes.length > 0 | ||
| 492 | ? preview.securitySchemes | ||
| 493 | .map((scheme) => `${scheme.name} (${previewSecuritySchemeLabel(scheme)})`) | ||
| 494 | .join(", ") | ||
| 495 | : "No declared auth schemes"} | ||
| 496 | </span> | ||
| 497 | </div> | ||
| 350 | </div> | 498 | </div> |
| 351 | {preview.warnings.length > 0 && ( | 499 | {preview.warnings.length > 0 && ( |
| 352 | <div className="mt-3 rounded-md border border-amber-300/40 bg-amber-100/20 px-3 py-2 text-xs text-amber-800"> | 500 | <div className="mt-3 rounded-md border border-amber-300/40 bg-amber-100/20 px-3 py-2 text-xs text-amber-800"> |
| @@ -357,11 +505,7 @@ function OpenApiAddSourcePage(props: { · 4 unmodified lines | |||
| 357 | )} | 505 | )} |
| 358 | </Section> | 506 | </Section> |
| 359 | 507 | ||
| 360 | <Section title="Submit"> | 508 | <Section title={props.mode === "create" ? "Submit" : "Save"}> |
| 361 | <p className="text-sm text-muted-foreground"> | ||
| 362 | This plugin can use its own HTTP endpoints and the core API from one merged client. That | ||
| 363 | is how the bearer-secret selector is loaded without a separate app-owned client path. | ||
| 364 | </p> | ||
| 365 | {error && ( | 509 | {error && ( |
| 366 | <div className="mb-3 rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-[13px] text-destructive"> | 510 | <div className="mb-3 rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-[13px] text-destructive"> |
| 367 | {error} | 511 | {error} |
| @@ -373,13 +517,15 @@ function OpenApiAddSourcePage(props: { · 5 unmodified lines | |||
| 373 | onClick={() => { | 517 | onClick={() => { |
| 374 | void handleSubmit(); | 518 | void handleSubmit(); |
| 375 | }} | 519 | }} |
| 376 | disabled={createMutation.status === "pending" || installation.status === "loading"} | 520 | disabled={submitMutation.status === "pending"} |
| 377 | className="inline-flex h-7 items-center justify-center rounded-md bg-primary px-2.5 text-xs font-medium text-primary-foreground disabled:pointer-events-none disabled:opacity-50" | 521 | className="inline-flex h-7 items-center justify-center rounded-md bg-primary px-2.5 text-xs font-medium text-primary-foreground disabled:pointer-events-none disabled:opacity-50" |
| 378 | > | 522 | > |
| 379 | {createMutation.status === "pending" ? "Creating..." : "Create Source"} | 523 | {submitMutation.status === "pending" ? props.busyLabel : props.submitLabel} |
| 380 | </button> | 524 | </button> |
| 381 | <div className="text-xs text-muted-foreground"> | 525 | <div className="text-xs text-muted-foreground"> |
| 382 | Creates a real <code>kind: "openapi"</code> source. | 526 | {props.mode === "create" |
| 527 | ? "Creates a real source and immediately imports its tools." | ||
| 528 | : "Updates plugin-owned config and refreshes the imported model."} | ||
| 383 | </div> | 529 | </div> |
| 384 | </div> | 530 | </div> |
| 385 | </Section> | 531 | </Section> |
| @@ -387,55 +533,900 @@ function OpenApiAddSourcePage(props: { · 1 unmodified line | |||
| 387 | ); | 533 | ); |
| 388 | } | 534 | } |
| 389 | 535 | ||
| 536 | function OpenApiAddSourcePage(props: { | ||
| 537 | initialValue: OpenApiConnectInput; | ||
| 538 | }) { | ||
| 539 | const navigate = useNavigate(); | ||
| 540 | const openApiHttpClient = getOpenApiHttpClient(); | ||
| 541 | const createSource = useAtomSet( | ||
| 542 | openApiHttpClient.mutation("openapi", "createSource"), | ||
| 543 | { mode: "promise" }, | ||
| 544 | ); | ||
| 545 | const workspaceId = useWorkspaceId(); | ||
| 546 | |||
| 547 | return ( | ||
| 548 | <OpenApiSourceForm | ||
| 549 | mode="create" | ||
| 550 | initialValue={props.initialValue} | ||
| 551 | submitLabel="Create Source" | ||
| 552 | busyLabel="Creating..." | ||
| 553 | onSubmit={async (payload) => { | ||
| 554 | const source = await createSource({ | ||
| 555 | path: { workspaceId }, | ||
| 556 | payload, | ||
| 557 | reactivityKeys: { | ||
| 558 | sources: [workspaceId], | ||
| 559 | }, | ||
| 560 | }); | ||
| 561 | |||
| 562 | startTransition(() => { | ||
| 563 | void navigate({ | ||
| 564 | to: "/sources/$sourceId", | ||
| 565 | params: { | ||
| 566 | sourceId: source.id, | ||
| 567 | }, | ||
| 568 | search: { | ||
| 569 | tab: "model", | ||
| 570 | }, | ||
| 571 | }); | ||
| 572 | }); | ||
| 573 | }} | ||
| 574 | /> | ||
| 575 | ); | ||
| 576 | } | ||
| 577 | |||
| 390 | function OpenApiEditSourcePage(props: { | 578 | function OpenApiEditSourcePage(props: { |
| 391 | source: Source; | 579 | source: Source; |
| 392 | }) { | 580 | }) { |
| 581 | const navigate = useNavigate(); | ||
| 582 | const openApiHttpClient = getOpenApiHttpClient(); | ||
| 583 | const workspaceId = useWorkspaceId(); | ||
| 584 | const configResult = useAtomValue( | ||
| 585 | openApiHttpClient.query("openapi", "getSourceConfig", { | ||
| 586 | path: { | ||
| 587 | workspaceId, | ||
| 588 | sourceId: props.source.id, | ||
| 589 | }, | ||
| 590 | reactivityKeys: { | ||
| 591 | source: [workspaceId, props.source.id], | ||
| 592 | }, | ||
| 593 | timeToLive: "30 seconds", | ||
| 594 | }), | ||
| 595 | ); | ||
| 596 | const updateSource = useAtomSet( | ||
| 597 | openApiHttpClient.mutation("openapi", "updateSource"), | ||
| 598 | { mode: "promise" }, | ||
| 599 | ); | ||
| 600 | |||
| 601 | if (!Result.isSuccess(configResult)) { | ||
| 602 | if (Result.isFailure(configResult)) { | ||
| 603 | return ( | ||
| 604 | <Section title="OpenAPI Plugin Editor"> | ||
| 605 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 p-4 text-sm text-destructive"> | ||
| 606 | Failed loading plugin config. | ||
| 607 | </div> | ||
| 608 | </Section> | ||
| 609 | ); | ||
| 610 | } | ||
| 611 | |||
| 612 | return ( | ||
| 613 | <Section title="OpenAPI Plugin Editor"> | ||
| 614 | <div className="text-sm text-muted-foreground">Loading plugin config...</div> | ||
| 615 | </Section> | ||
| 616 | ); | ||
| 617 | } | ||
| 618 | |||
| 393 | return ( | 619 | return ( |
| 394 | <Section title="OpenAPI Plugin Editor"> | 620 | <OpenApiSourceForm |
| 395 | <p className="text-sm text-muted-foreground"> | 621 | mode="edit" |
| 396 | Plugin-specific configuration is stored behind the OpenAPI plugin boundary, not on the | 622 | initialValue={inputFromConfig(configResult.value)} |
| 397 | shared source row. | 623 | submitLabel="Save Changes" |
| 398 | </p> | 624 | busyLabel="Saving..." |
| 399 | <pre className="mt-3 overflow-x-auto rounded-md bg-muted/60 p-3 text-xs"> | 625 | onSubmit={async (config) => { |
| 400 | {JSON.stringify({ | 626 | const source = await updateSource({ |
| 401 | id: props.source.id, | 627 | path: { |
| 402 | kind: props.source.kind, | 628 | workspaceId, |
| 403 | }, null, 2)} | 629 | sourceId: props.source.id, |
| 404 | </pre> | 630 | }, |
| 405 | </Section> | 631 | payload: config, |
| 632 | reactivityKeys: { | ||
| 633 | sources: [workspaceId], | ||
| 634 | source: [workspaceId, props.source.id], | ||
| 635 | sourceInspection: [workspaceId, props.source.id], | ||
| 636 | sourceInspectionTool: [workspaceId, props.source.id], | ||
| 637 | sourceDiscovery: [workspaceId, props.source.id], | ||
| 638 | }, | ||
| 639 | }); | ||
| 640 | |||
| 641 | startTransition(() => { | ||
| 642 | void navigate({ | ||
| 643 | to: "/sources/$sourceId", | ||
| 644 | params: { | ||
| 645 | sourceId: source.id, | ||
| 646 | }, | ||
| 647 | search: { | ||
| 648 | tab: "model", | ||
| 649 | }, | ||
| 650 | }); | ||
| 651 | }); | ||
| 652 | }} | ||
| 653 | /> | ||
| 406 | ); | 654 | ); |
| 407 | } | 655 | } |
| 408 | 656 | ||
| 409 | function OpenApiSourceDetailPage(props: { | 657 | function OpenApiSourceDetailPage(props: { |
| 410 | source: Source; | 658 | source: Source; |
| 659 | route: { | ||
| 660 | search?: unknown; | ||
| 661 | navigate?: unknown; | ||
| 662 | }; | ||
| 411 | }) { | 663 | }) { |
| 664 | const routerNavigate = useNavigate(); | ||
| 665 | const openApiHttpClient = getOpenApiHttpClient(); | ||
| 666 | const workspaceId = useWorkspaceId(); | ||
| 667 | const removeSource = useAtomSet( | ||
| 668 | openApiHttpClient.mutation("openapi", "removeSource"), | ||
| 669 | { mode: "promise" }, | ||
| 670 | ); | ||
| 671 | const removeMutation = useExecutorMutation<Source["id"], { removed: boolean }>(async (sourceId) => | ||
| 672 | removeSource({ | ||
| 673 | path: { | ||
| 674 | workspaceId, | ||
| 675 | sourceId, | ||
| 676 | }, | ||
| 677 | reactivityKeys: { | ||
| 678 | sources: [workspaceId], | ||
| 679 | source: [workspaceId, sourceId], | ||
| 680 | sourceInspection: [workspaceId, sourceId], | ||
| 681 | sourceInspectionTool: [workspaceId, sourceId], | ||
| 682 | sourceDiscovery: [workspaceId, sourceId], | ||
| 683 | }, | ||
| 684 | }) | ||
| 685 | ); | ||
| 686 | const inspection = useSourceInspection(props.source.id); | ||
| 687 | const search = (props.route.search ?? {}) as RouteToolSearch; | ||
| 688 | const navigate = | ||
| 689 | props.route.navigate as | ||
| 690 | | ((input: { | ||
| 691 | search: { | ||
| 692 | tab: "model" | "discover"; | ||
| 693 | tool?: string; | ||
| 694 | query?: string; | ||
| 695 | }; | ||
| 696 | }) => void | Promise<void>) | ||
| 697 | | undefined; | ||
| 698 | const tab = search.tab === "discover" ? "discover" : "model"; | ||
| 699 | const query = search.query ?? ""; | ||
| 700 | const selectedToolPath = | ||
| 701 | search.tool ?? (inspection.status === "ready" ? inspection.data.tools[0]?.path ?? null : null); | ||
| 702 | const discovery = useSourceDiscovery({ | ||
| 703 | sourceId: props.source.id, | ||
| 704 | query, | ||
| 705 | limit: 20, | ||
| 706 | }); | ||
| 707 | const toolDetail = useSourceToolDetail(props.source.id, selectedToolPath); | ||
| 708 | |||
| 709 | const setRouteSearch = (next: { | ||
| 710 | tab?: "model" | "discover"; | ||
| 711 | tool?: string; | ||
| 712 | query?: string; | ||
| 713 | }) => { | ||
| 714 | if (!navigate) { | ||
| 715 | return; | ||
| 716 | } | ||
| 717 | |||
| 718 | void navigate({ | ||
| 719 | search: { | ||
| 720 | tab: next.tab ?? tab, | ||
| 721 | ...(next.tool !== undefined | ||
| 722 | ? { tool: next.tool || undefined } | ||
| 723 | : { tool: search.tool }), | ||
| 724 | ...(next.query !== undefined | ||
| 725 | ? { query: next.query || undefined } | ||
| 726 | : { query }), | ||
| 727 | }, | ||
| 728 | }); | ||
| 729 | }; | ||
| 730 | |||
| 731 | useEffect(() => { | ||
| 732 | if (tab !== "model" || selectedToolPath || inspection.status !== "ready") { | ||
| 733 | return; | ||
| 734 | } | ||
| 735 | |||
| 736 | const firstTool = inspection.data.tools[0]?.path; | ||
| 737 | if (!firstTool) { | ||
| 738 | return; | ||
| 739 | } | ||
| 740 | |||
| 741 | setRouteSearch({ | ||
| 742 | tab: "model", | ||
| 743 | tool: firstTool, | ||
| 744 | }); | ||
| 745 | }, [inspection.status, selectedToolPath, tab]); | ||
| 746 | |||
| 412 | return ( | 747 | return ( |
| 413 | <div className="space-y-4"> | 748 | <LoadableBlock loadable={inspection} loading="Loading source..."> |
| 414 | <Section title="Source Detail"> | 749 | {(loadedInspection) => { |
| 415 | <p className="text-sm text-muted-foreground"> | 750 | const selectedTool = |
| 416 | OpenAPI-specific configuration and auth live in plugin storage. The shared source record | 751 | loadedInspection.tools.find((tool) => tool.path === selectedToolPath) |
| 417 | only carries generic routing and status metadata. | 752 | ?? loadedInspection.tools[0] |
| 418 | </p> | 753 | ?? null; |
| 419 | <pre className="mt-3 overflow-x-auto rounded-md bg-muted/60 p-3 text-xs"> | ||
| 420 | {JSON.stringify({ | ||
| 421 | id: props.source.id, | ||
| 422 | name: props.source.name, | ||
| 423 | kind: props.source.kind, | ||
| 424 | status: props.source.status, | ||
| 425 | }, null, 2)} | ||
| 426 | </pre> | ||
| 427 | </Section> | ||
| 428 | 754 | ||
| 429 | <Section title="Plugin Boundary"> | 755 | return ( |
| 430 | <p className="text-sm text-muted-foreground"> | 756 | <div className="flex h-full flex-col overflow-hidden"> |
| 431 | The shell only knows there is a registered source plugin named <code>openapi</code>. | 757 | <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur-sm"> |
| 432 | Everything specific stays inside this plugin. | 758 | <div className="flex min-w-0 items-center gap-3"> |
| 433 | </p> | 759 | <h2 className="truncate text-sm font-semibold text-foreground"> |
| 434 | </Section> | 760 | {loadedInspection.source.name} |
| 761 | </h2> | ||
| 762 | <Badge variant="outline">{loadedInspection.source.kind}</Badge> | ||
| 763 | <span className="hidden text-[11px] tabular-nums text-muted-foreground/50 sm:block"> | ||
| 764 | {loadedInspection.toolCount} {loadedInspection.toolCount === 1 ? "tool" : "tools"} | ||
| 765 | </span> | ||
| 766 | </div> | ||
| 767 | <div className="flex items-center gap-2"> | ||
| 768 | <div className="flex items-center gap-0.5 rounded-lg bg-muted/50 p-0.5"> | ||
| 769 | {(["model", "discover"] as const).map((tabId) => ( | ||
| 770 | <button | ||
| 771 | key={tabId} | ||
| 772 | type="button" | ||
| 773 | onClick={() => setRouteSearch({ tab: tabId })} | ||
| 774 | className={cn( | ||
| 775 | "rounded-md px-3 py-1 text-[12px] font-medium transition-colors", | ||
| 776 | tabId === tab | ||
| 777 | ? "bg-background text-foreground shadow-sm" | ||
| 778 | : "text-muted-foreground hover:text-foreground", | ||
| 779 | )} | ||
| 780 | > | ||
| 781 | {tabId === "model" ? "Tools" : "Search"} | ||
| 782 | </button> | ||
| 783 | ))} | ||
| 784 | </div> | ||
| 785 | <button | ||
| 786 | type="button" | ||
| 787 | onClick={() => | ||
| 788 | void routerNavigate({ | ||
| 789 | to: "/sources/$sourceId/edit", | ||
| 790 | params: { sourceId: props.source.id }, | ||
| 791 | })} | ||
| 792 | className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground" | ||
| 793 | > | ||
| 794 | <IconPencil className="size-3" /> | ||
| 795 | Edit | ||
| 796 | </button> | ||
| 797 | <button | ||
| 798 | type="button" | ||
| 799 | onClick={() => { | ||
| 800 | const confirmed = window.confirm(`Delete OpenAPI source "${props.source.name}"?`); | ||
| 801 | if (!confirmed) return; | ||
| 802 | |||
| 803 | void removeMutation.mutateAsync(props.source.id).then(() => { | ||
| 804 | startTransition(() => { | ||
| 805 | void routerNavigate({ to: "/" }); | ||
| 806 | }); | ||
| 807 | }); | ||
| 808 | }} | ||
| 809 | disabled={removeMutation.status === "pending"} | ||
| 810 | className="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50" | ||
| 811 | > | ||
| 812 | {removeMutation.status === "pending" ? "Deleting..." : "Delete"} | ||
| 813 | </button> | ||
| 814 | </div> | ||
| 815 | </div> | ||
| 816 | |||
| 817 | <div className="flex flex-1 min-h-0 overflow-hidden"> | ||
| 818 | {tab === "model" ? ( | ||
| 819 | <ModelView | ||
| 820 | bundle={loadedInspection} | ||
| 821 | detail={toolDetail} | ||
| 822 | selectedToolPath={selectedTool?.path ?? null} | ||
| 823 | onSelectTool={(toolPath) => | ||
| 824 | setRouteSearch({ | ||
| 825 | tab: "model", | ||
| 826 | tool: toolPath, | ||
| 827 | })} | ||
| 828 | sourceId={props.source.id} | ||
| 829 | /> | ||
| 830 | ) : ( | ||
| 831 | <DiscoveryView | ||
| 832 | bundle={loadedInspection} | ||
| 833 | initialQuery={query} | ||
| 834 | discovery={discovery} | ||
| 835 | onSubmitQuery={(nextQuery) => | ||
| 836 | setRouteSearch({ | ||
| 837 | tab: "discover", | ||
| 838 | query: nextQuery, | ||
| 839 | })} | ||
| 840 | onOpenTool={(toolPath) => | ||
| 841 | setRouteSearch({ | ||
| 842 | tab: "model", | ||
| 843 | tool: toolPath, | ||
| 844 | query, | ||
| 845 | })} | ||
| 846 | /> | ||
| 847 | )} | ||
| 848 | </div> | ||
| 849 | </div> | ||
| 850 | ); | ||
| 851 | }} | ||
| 852 | </LoadableBlock> | ||
| 853 | ); | ||
| 854 | } | ||
| 855 | |||
| 856 | function ModelView(props: { | ||
| 857 | bundle: SourceInspection; | ||
| 858 | detail: Loadable<SourceInspectionToolDetail | null>; | ||
| 859 | selectedToolPath: string | null; | ||
| 860 | onSelectTool: (toolPath: string) => void; | ||
| 861 | sourceId: string; | ||
| 862 | }) { | ||
| 863 | const [search, setSearch] = useState(""); | ||
| 864 | const searchRef = useRef<HTMLInputElement>(null); | ||
| 865 | const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); | ||
| 866 | |||
| 867 | const filteredTools = props.bundle.tools.filter((tool) => { | ||
| 868 | if (terms.length === 0) return true; | ||
| 869 | const corpus = [ | ||
| 870 | tool.path, | ||
| 871 | tool.method ?? "", | ||
| 872 | tool.inputTypePreview ?? "", | ||
| 873 | tool.outputTypePreview ?? "", | ||
| 874 | ] | ||
| 875 | .join(" ") | ||
| 876 | .toLowerCase(); | ||
| 877 | return terms.every((term) => corpus.includes(term)); | ||
| 878 | }); | ||
| 879 | |||
| 880 | useEffect(() => { | ||
| 881 | const handler = (event: KeyboardEvent) => { | ||
| 882 | if (event.key === "/" && document.activeElement?.tagName !== "INPUT") { | ||
| 883 | event.preventDefault(); | ||
| 884 | searchRef.current?.focus(); | ||
| 885 | } | ||
| 886 | if (event.key === "Escape") { | ||
| 887 | searchRef.current?.blur(); | ||
| 888 | if (search.length > 0) setSearch(""); | ||
| 889 | } | ||
| 890 | }; | ||
| 891 | document.addEventListener("keydown", handler); | ||
| 892 | return () => document.removeEventListener("keydown", handler); | ||
| 893 | }, [search]); | ||
| 894 | |||
| 895 | return ( | ||
| 896 | <> | ||
| 897 | <div className="flex w-72 shrink-0 flex-col border-r border-border bg-card/30 lg:w-80 xl:w-[22rem]"> | ||
| 898 | <div className="shrink-0 border-b border-border px-3 py-2"> | ||
| 899 | <div className="relative"> | ||
| 900 | <IconSearch className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/40" /> | ||
| 901 | <input | ||
| 902 | ref={searchRef} | ||
| 903 | value={search} | ||
| 904 | onChange={(event) => setSearch(event.target.value)} | ||
| 905 | placeholder={`Filter ${props.bundle.toolCount} tools…`} | ||
| 906 | className="h-8 w-full rounded-md border border-input bg-background pl-8 pr-8 text-[13px] outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/30" | ||
| 907 | /> | ||
| 908 | {search.length > 0 ? ( | ||
| 909 | <button | ||
| 910 | type="button" | ||
| 911 | onClick={() => setSearch("")} | ||
| 912 | className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground/40 hover:text-foreground" | ||
| 913 | > | ||
| 914 | <IconClose /> | ||
| 915 | </button> | ||
| 916 | ) : ( | ||
| 917 | <kbd className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded border border-border bg-muted px-1 py-px text-[10px] text-muted-foreground/50"> | ||
| 918 | / | ||
| 919 | </kbd> | ||
| 920 | )} | ||
| 921 | </div> | ||
| 922 | </div> | ||
| 923 | |||
| 924 | <div className="flex-1 overflow-y-auto"> | ||
| 925 | {filteredTools.length === 0 ? ( | ||
| 926 | <div className="p-4 text-center text-[13px] text-muted-foreground/50"> | ||
| 927 | {terms.length > 0 ? "No tools match your filter" : "No tools available"} | ||
| 928 | </div> | ||
| 929 | ) : ( | ||
| 930 | <div className="p-1.5"> | ||
| 931 | <ToolTree | ||
| 932 | tools={filteredTools} | ||
| 933 | selectedToolPath={props.selectedToolPath} | ||
| 934 | onSelectTool={props.onSelectTool} | ||
| 935 | search={search} | ||
| 936 | isFiltered={terms.length > 0} | ||
| 937 | sourceId={props.sourceId} | ||
| 938 | /> | ||
| 939 | </div> | ||
| 940 | )} | ||
| 941 | </div> | ||
| 942 | </div> | ||
| 943 | |||
| 944 | <div className="flex min-w-0 flex-1 flex-col overflow-hidden"> | ||
| 945 | <LoadableBlock loadable={props.detail} loading="Loading tool..."> | ||
| 946 | {(detail) => | ||
| 947 | detail ? ( | ||
| 948 | <ToolDetailPanel detail={detail} /> | ||
| 949 | ) : ( | ||
| 950 | <EmptyState | ||
| 951 | title={props.bundle.toolCount > 0 ? "Select a tool" : "No tools available"} | ||
| 952 | description={props.bundle.toolCount > 0 ? "Choose from the list or press / to search" : undefined} | ||
| 953 | /> | ||
| 954 | ) | ||
| 955 | } | ||
| 956 | </LoadableBlock> | ||
| 957 | </div> | ||
| 958 | </> | ||
| 959 | ); | ||
| 960 | } | ||
| 961 | |||
| 962 | function ToolTree(props: { | ||
| 963 | tools: SourceInspection["tools"]; | ||
| 964 | selectedToolPath: string | null; | ||
| 965 | onSelectTool: (path: string) => void; | ||
| 966 | search: string; | ||
| 967 | isFiltered: boolean; | ||
| 968 | sourceId: string; | ||
| 969 | }) { | ||
| 970 | const tree = useMemo(() => buildToolTree(props.tools), [props.tools]); | ||
| 971 | const prefetch = usePrefetchToolDetail(); | ||
| 972 | const entries = [...tree.children.values()].sort((a, b) => a.segment.localeCompare(b.segment)); | ||
| 973 | |||
| 974 | return ( | ||
| 975 | <div className="flex flex-col gap-px"> | ||
| 976 | {entries.map((node) => ( | ||
| 977 | <ToolTreeNodeView | ||
| 978 | key={node.segment} | ||
| 979 | node={node} | ||
| 980 | depth={0} | ||
| 981 | selectedToolPath={props.selectedToolPath} | ||
| 982 | onSelectTool={props.onSelectTool} | ||
| 983 | search={props.search} | ||
| 984 | defaultOpen={props.isFiltered} | ||
| 985 | sourceId={props.sourceId} | ||
| 986 | prefetch={prefetch} | ||
| 987 | /> | ||
| 988 | ))} | ||
| 989 | </div> | ||
| 990 | ); | ||
| 991 | } | ||
| 992 | |||
| 993 | function ToolTreeNodeView(props: { | ||
| 994 | node: ToolTreeNode; | ||
| 995 | depth: number; | ||
| 996 | selectedToolPath: string | null; | ||
| 997 | onSelectTool: (path: string) => void; | ||
| 998 | search: string; | ||
| 999 | defaultOpen: boolean; | ||
| 1000 | sourceId: string; | ||
| 1001 | prefetch: ReturnType<typeof usePrefetchToolDetail>; | ||
| 1002 | }) { | ||
| 1003 | const { node, depth, selectedToolPath, onSelectTool, search, defaultOpen, sourceId, prefetch } = props; | ||
| 1004 | const hasChildren = node.children.size > 0; | ||
| 1005 | const isLeaf = !!node.tool && !hasChildren; | ||
| 1006 | |||
| 1007 | const hasSelectedDescendant = useMemo(() => { | ||
| 1008 | if (!selectedToolPath) return false; | ||
| 1009 | function check(candidate: ToolTreeNode): boolean { | ||
| 1010 | if (candidate.tool?.path === selectedToolPath) return true; | ||
| 1011 | for (const child of candidate.children.values()) { | ||
| 1012 | if (check(child)) return true; | ||
| 1013 | } | ||
| 1014 | return false; | ||
| 1015 | } | ||
| 1016 | return check(node); | ||
| 1017 | }, [node, selectedToolPath]); | ||
| 1018 | |||
| 1019 | const [open, setOpen] = useState(defaultOpen || hasSelectedDescendant); | ||
| 1020 | |||
| 1021 | useEffect(() => { | ||
| 1022 | if (defaultOpen || hasSelectedDescendant) setOpen(true); | ||
| 1023 | }, [defaultOpen, hasSelectedDescendant]); | ||
| 1024 | |||
| 1025 | if (isLeaf) { | ||
| 1026 | return ( | ||
| 1027 | <ToolListItem | ||
| 1028 | tool={node.tool as SourceInspection["tools"][number]} | ||
| 1029 | active={node.tool?.path === selectedToolPath} | ||
| 1030 | onSelect={() => onSelectTool(node.tool!.path)} | ||
| 1031 | search={search} | ||
| 1032 | depth={depth} | ||
| 1033 | sourceId={sourceId} | ||
| 1034 | prefetch={prefetch} | ||
| 1035 | /> | ||
| 1036 | ); | ||
| 1037 | } | ||
| 1038 | |||
| 1039 | const paddingLeft = 8 + depth * 16; | ||
| 1040 | const sortedChildren = [...node.children.values()].sort((a, b) => a.segment.localeCompare(b.segment)); | ||
| 1041 | const leafCount = countToolLeaves(node); | ||
| 1042 | |||
| 1043 | return ( | ||
| 1044 | <div> | ||
| 1045 | {node.tool ? ( | ||
| 1046 | <div className="flex items-center"> | ||
| 1047 | <button | ||
| 1048 | type="button" | ||
| 1049 | onClick={() => setOpen((value) => !value)} | ||
| 1050 | className="shrink-0 rounded p-0.5 text-muted-foreground/30 hover:text-muted-foreground" | ||
| 1051 | style={{ marginLeft: paddingLeft }} | ||
| 1052 | > | ||
| 1053 | <IconChevron className={cn("size-2.5 transition-transform duration-150", open && "rotate-90")} /> | ||
| 1054 | </button> | ||
| 1055 | <ToolListItem | ||
| 1056 | tool={node.tool as SourceInspection["tools"][number]} | ||
| 1057 | active={node.tool?.path === selectedToolPath} | ||
| 1058 | onSelect={() => onSelectTool(node.tool!.path)} | ||
| 1059 | search={search} | ||
| 1060 | depth={-1} | ||
| 1061 | className="flex-1 pl-1" | ||
| 1062 | sourceId={sourceId} | ||
| 1063 | prefetch={prefetch} | ||
| 1064 | /> | ||
| 1065 | </div> | ||
| 1066 | ) : ( | ||
| 1067 | <button | ||
| 1068 | type="button" | ||
| 1069 | onClick={() => setOpen((value) => !value)} | ||
| 1070 | className={cn( | ||
| 1071 | "group flex w-full items-center gap-1.5 rounded-md py-1 pr-2.5 text-[12px] transition-colors hover:bg-accent/40", | ||
| 1072 | open ? "text-foreground/80" : "text-muted-foreground/60", | ||
| 1073 | )} | ||
| 1074 | style={{ paddingLeft }} | ||
| 1075 | > | ||
| 1076 | <IconChevron | ||
| 1077 | className={cn( | ||
| 1078 | "size-2.5 shrink-0 text-muted-foreground/30 transition-transform duration-150", | ||
| 1079 | open && "rotate-90", | ||
| 1080 | )} | ||
| 1081 | /> | ||
| 1082 | <IconFolder className={cn("size-3 shrink-0", open ? "text-primary/60" : "text-muted-foreground/30")} /> | ||
| 1083 | <span className="flex-1 truncate text-left font-mono"> | ||
| 1084 | {highlightMatch(node.segment, search)} | ||
| 1085 | </span> | ||
| 1086 | <span className="shrink-0 tabular-nums text-[10px] text-muted-foreground/25">{leafCount}</span> | ||
| 1087 | </button> | ||
| 1088 | )} | ||
| 1089 | |||
| 1090 | {open && hasChildren && ( | ||
| 1091 | <div className="relative flex flex-col gap-px"> | ||
| 1092 | <span className="absolute bottom-1 top-0 w-px bg-border/40" style={{ left: paddingLeft + 5 }} aria-hidden /> | ||
| 1093 | {sortedChildren.map((child) => ( | ||
| 1094 | <ToolTreeNodeView | ||
| 1095 | key={child.segment} | ||
| 1096 | node={child} | ||
| 1097 | depth={depth + 1} | ||
| 1098 | selectedToolPath={selectedToolPath} | ||
| 1099 | onSelectTool={onSelectTool} | ||
| 1100 | search={search} | ||
| 1101 | defaultOpen={defaultOpen} | ||
| 1102 | sourceId={sourceId} | ||
| 1103 | prefetch={prefetch} | ||
| 1104 | /> | ||
| 1105 | ))} | ||
| 1106 | </div> | ||
| 1107 | )} | ||
| 1108 | </div> | ||
| 1109 | ); | ||
| 1110 | } | ||
| 1111 | |||
| 1112 | function ToolListItem(props: { | ||
| 1113 | tool: SourceInspection["tools"][number]; | ||
| 1114 | active: boolean; | ||
| 1115 | onSelect: () => void; | ||
| 1116 | search: string; | ||
| 1117 | depth: number; | ||
| 1118 | className?: string; | ||
| 1119 | sourceId: string; | ||
| 1120 | prefetch: ReturnType<typeof usePrefetchToolDetail>; | ||
| 1121 | }) { | ||
| 1122 | const ref = useRef<HTMLButtonElement>(null); | ||
| 1123 | const paddingLeft = props.depth >= 0 ? 8 + props.depth * 16 + 8 : undefined; | ||
| 1124 | |||
| 1125 | useEffect(() => { | ||
| 1126 | if (props.active && ref.current) { | ||
| 1127 | ref.current.scrollIntoView({ block: "nearest" }); | ||
| 1128 | } | ||
| 1129 | }, [props.active]); | ||
| 1130 | |||
| 1131 | const label = props.depth >= 0 | ||
| 1132 | ? props.tool.path.split(".").pop() ?? props.tool.path | ||
| 1133 | : props.tool.path; | ||
| 1134 | |||
| 1135 | return ( | ||
| 1136 | <button | ||
| 1137 | ref={ref} | ||
| 1138 | type="button" | ||
| 1139 | onMouseEnter={() => { | ||
| 1140 | props.prefetch(props.sourceId, props.tool.path); | ||
| 1141 | }} | ||
| 1142 | onClick={props.onSelect} | ||
| 1143 | className={cn( | ||
| 1144 | "group flex w-full items-center gap-2 rounded-md py-1.5 pr-2.5 text-left transition-colors", | ||
| 1145 | props.active | ||
| 1146 | ? "border-l-2 border-l-primary bg-primary/10 text-foreground" | ||
| 1147 | : "text-foreground/70 hover:bg-accent/50 hover:text-foreground", | ||
| 1148 | props.className, | ||
| 1149 | )} | ||
| 1150 | style={paddingLeft != null ? { paddingLeft } : undefined} | ||
| 1151 | > | ||
| 1152 | <IconTool className="size-3 shrink-0 text-muted-foreground/40" /> | ||
| 1153 | <span className="flex-1 truncate font-mono text-[12px]"> | ||
| 1154 | {highlightMatch(label, props.search)} | ||
| 1155 | </span> | ||
| 1156 | {props.tool.method && <MethodBadge method={props.tool.method} />} | ||
| 1157 | </button> | ||
| 1158 | ); | ||
| 1159 | } | ||
| 1160 | |||
| 1161 | function ToolDetailPanel(props: { | ||
| 1162 | detail: SourceInspectionToolDetail; | ||
| 1163 | }) { | ||
| 1164 | const { detail } = props; | ||
| 1165 | const [copiedField, setCopiedField] = useState<string | null>(null); | ||
| 1166 | const inputType = detail.contract.input.typeDeclaration | ||
| 1167 | ?? detail.contract.input.typePreview | ||
| 1168 | ?? null; | ||
| 1169 | const outputType = detail.contract.output.typeDeclaration | ||
| 1170 | ?? detail.contract.output.typePreview | ||
| 1171 | ?? null; | ||
| 1172 | |||
| 1173 | return ( | ||
| 1174 | <div className="flex h-full flex-col overflow-hidden"> | ||
| 1175 | <div className="shrink-0 border-b border-border bg-background/95 backdrop-blur-sm"> | ||
| 1176 | <div className="flex items-start gap-3 px-5 py-3.5"> | ||
| 1177 | <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"> | ||
| 1178 | <IconTool className="size-4" /> | ||
| 1179 | </div> | ||
| 1180 | <div className="min-w-0 flex-1"> | ||
| 1181 | <div className="flex items-center gap-2"> | ||
| 1182 | <h3 className="truncate text-sm font-semibold text-foreground"> | ||
| 1183 | {detail.summary.path} | ||
| 1184 | </h3> | ||
| 1185 | <CopyButton | ||
| 1186 | text={detail.summary.path} | ||
| 1187 | field="path" | ||
| 1188 | copiedField={copiedField} | ||
| 1189 | onCopy={async (text, field) => { | ||
| 1190 | await navigator.clipboard.writeText(text); | ||
| 1191 | setCopiedField(field); | ||
| 1192 | window.setTimeout(() => setCopiedField(null), 1500); | ||
| 1193 | }} | ||
| 1194 | /> | ||
| 1195 | </div> | ||
| 1196 | <div className="mt-1 flex flex-wrap items-center gap-1.5"> | ||
| 1197 | {detail.summary.method && <MethodBadge method={detail.summary.method} />} | ||
| 1198 | {detail.summary.pathTemplate && ( | ||
| 1199 | <span className="font-mono text-[11px] text-muted-foreground/60"> | ||
| 1200 | {detail.summary.pathTemplate} | ||
| 1201 | </span> | ||
| 1202 | )} | ||
| 1203 | </div> | ||
| 1204 | </div> | ||
| 1205 | </div> | ||
| 1206 | </div> | ||
| 1207 | |||
| 1208 | <div className="flex-1 overflow-y-auto"> | ||
| 1209 | <div className="space-y-4 px-5 py-4"> | ||
| 1210 | {detail.summary.description && <Markdown>{detail.summary.description}</Markdown>} | ||
| 1211 | |||
| 1212 | <div className="grid grid-cols-1 gap-3 xl:grid-cols-2"> | ||
| 1213 | <DocumentPanel title="Input" body={inputType} lang="typescript" empty="No input." /> | ||
| 1214 | <DocumentPanel title="Output" body={outputType} lang="typescript" empty="No output." /> | ||
| 1215 | </div> | ||
| 1216 | |||
| 1217 | <DocumentPanel title="Call Signature" body={detail.contract.callSignature} lang="typescript" empty="No call signature." /> | ||
| 1218 | |||
| 1219 | <div className="grid grid-cols-1 gap-2 xl:grid-cols-2"> | ||
| 1220 | <DocumentPanel title="Input schema" body={detail.contract.input.schemaJson} empty="No input schema." compact /> | ||
| 1221 | <DocumentPanel title="Output schema" body={detail.contract.output.schemaJson} empty="No output schema." compact /> | ||
| 1222 | {detail.contract.input.exampleJson && ( | ||
| 1223 | <DocumentPanel title="Example request" body={detail.contract.input.exampleJson} empty="" compact /> | ||
| 1224 | )} | ||
| 1225 | {detail.contract.output.exampleJson && ( | ||
| 1226 | <DocumentPanel title="Example response" body={detail.contract.output.exampleJson} empty="" compact /> | ||
| 1227 | )} | ||
| 1228 | </div> | ||
| 1229 | |||
| 1230 | {detail.sections.map((section, index) => ( | ||
| 1231 | <section | ||
| 1232 | key={`${section.title}-${String(index)}`} | ||
| 1233 | className="overflow-hidden rounded-lg border border-border bg-card/60" | ||
| 1234 | > | ||
| 1235 | <div className="border-b border-border px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70"> | ||
| 1236 | {section.title} | ||
| 1237 | </div> | ||
| 1238 | {section.kind === "facts" ? ( | ||
| 1239 | <div className="grid gap-2 p-4"> | ||
| 1240 | {section.items.map((item) => ( | ||
| 1241 | <div key={`${section.title}-${item.label}`} className="text-sm"> | ||
| 1242 | <span className="text-muted-foreground">{item.label}:</span>{" "} | ||
| 1243 | <span className={item.mono ? "font-mono text-xs text-foreground" : "text-foreground"}> | ||
| 1244 | {item.value} | ||
| 1245 | </span> | ||
| 1246 | </div> | ||
| 1247 | ))} | ||
| 1248 | </div> | ||
| 1249 | ) : section.kind === "markdown" ? ( | ||
| 1250 | <div className="p-4"> | ||
| 1251 | <Markdown>{section.body}</Markdown> | ||
| 1252 | </div> | ||
| 1253 | ) : ( | ||
| 1254 | <DocumentPanel title={section.title} body={section.body} lang={section.language} empty="" /> | ||
| 1255 | )} | ||
| 1256 | </section> | ||
| 1257 | ))} | ||
| 1258 | </div> | ||
| 1259 | </div> | ||
| 435 | </div> | 1260 | </div> |
| 436 | ); | 1261 | ); |
| 437 | } | 1262 | } |
| 438 | 1263 | ||
| 1264 | function DiscoveryView(props: { | ||
| 1265 | bundle: SourceInspection; | ||
| 1266 | discovery: Loadable<ReturnType<typeof useSourceDiscovery> extends Loadable<infer T> ? T : never>; | ||
| 1267 | initialQuery: string; | ||
| 1268 | onSubmitQuery: (query: string) => void; | ||
| 1269 | onOpenTool: (toolPath: string) => void; | ||
| 1270 | }) { | ||
| 1271 | const [draftQuery, setDraftQuery] = useState(props.initialQuery); | ||
| 1272 | |||
| 1273 | useEffect(() => { | ||
| 1274 | setDraftQuery(props.initialQuery); | ||
| 1275 | }, [props.initialQuery]); | ||
| 1276 | |||
| 1277 | return ( | ||
| 1278 | <div className="flex flex-1 flex-col overflow-hidden"> | ||
| 1279 | <div className="shrink-0 border-b border-border px-4 py-3"> | ||
| 1280 | <form | ||
| 1281 | className="flex max-w-2xl items-center gap-2" | ||
| 1282 | onSubmit={(event) => { | ||
| 1283 | event.preventDefault(); | ||
| 1284 | props.onSubmitQuery(draftQuery.trim()); | ||
| 1285 | }} | ||
| 1286 | > | ||
| 1287 | <div className="relative flex-1"> | ||
| 1288 | <IconSearch className="absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/40" /> | ||
| 1289 | <input | ||
| 1290 | value={draftQuery} | ||
| 1291 | onChange={(event) => setDraftQuery(event.target.value)} | ||
| 1292 | placeholder="Search tools…" | ||
| 1293 | className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/30" | ||
| 1294 | /> | ||
| 1295 | </div> | ||
| 1296 | <button | ||
| 1297 | type="submit" | ||
| 1298 | className="inline-flex h-9 items-center rounded-md border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50" | ||
| 1299 | > | ||
| 1300 | Search | ||
| 1301 | </button> | ||
| 1302 | </form> | ||
| 1303 | </div> | ||
| 1304 | |||
| 1305 | <div className="flex-1 overflow-y-auto p-4"> | ||
| 1306 | <LoadableBlock loadable={props.discovery} loading="Searching…"> | ||
| 1307 | {(result) => | ||
| 1308 | result.query.length === 0 ? ( | ||
| 1309 | <EmptyState | ||
| 1310 | title="Search your tools" | ||
| 1311 | description="Type a query to find matching tools across this source." | ||
| 1312 | /> | ||
| 1313 | ) : result.results.length === 0 ? ( | ||
| 1314 | <EmptyState | ||
| 1315 | title="No results" | ||
| 1316 | description="Try different search terms." | ||
| 1317 | /> | ||
| 1318 | ) : ( | ||
| 1319 | <div className="max-w-3xl space-y-2"> | ||
| 1320 | {result.results.map((item, index) => ( | ||
| 1321 | <button | ||
| 1322 | key={item.path} | ||
| 1323 | type="button" | ||
| 1324 | onClick={() => props.onOpenTool(item.path)} | ||
| 1325 | className="group w-full rounded-lg border border-border bg-card/60 p-3.5 text-left transition-all hover:border-primary/30 hover:shadow-sm" | ||
| 1326 | > | ||
| 1327 | <div className="mb-1.5 flex items-center justify-between gap-3"> | ||
| 1328 | <div className="flex min-w-0 items-center gap-2.5"> | ||
| 1329 | <span className="flex size-5 shrink-0 items-center justify-center rounded-md bg-muted text-[10px] font-mono tabular-nums text-muted-foreground/60"> | ||
| 1330 | {index + 1} | ||
| 1331 | </span> | ||
| 1332 | <h4 className="truncate font-mono text-[13px] font-medium text-foreground transition-colors group-hover:text-primary"> | ||
| 1333 | {item.path} | ||
| 1334 | </h4> | ||
| 1335 | </div> | ||
| 1336 | <span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground/50"> | ||
| 1337 | {item.score.toFixed(2)} | ||
| 1338 | </span> | ||
| 1339 | </div> | ||
| 1340 | {item.description && ( | ||
| 1341 | <p className="line-clamp-2 text-[12px] leading-relaxed text-muted-foreground"> | ||
| 1342 | {item.description} | ||
| 1343 | </p> | ||
| 1344 | )} | ||
| 1345 | </button> | ||
| 1346 | ))} | ||
| 1347 | </div> | ||
| 1348 | ) | ||
| 1349 | } | ||
| 1350 | </LoadableBlock> | ||
| 1351 | </div> | ||
| 1352 | </div> | ||
| 1353 | ); | ||
| 1354 | } | ||
| 1355 | |||
| 1356 | function CopyButton(props: { | ||
| 1357 | text: string; | ||
| 1358 | field: string; | ||
| 1359 | copiedField: string | null; | ||
| 1360 | onCopy: (text: string, field: string) => void | Promise<void>; | ||
| 1361 | }) { | ||
| 1362 | return ( | ||
| 1363 | <button | ||
| 1364 | type="button" | ||
| 1365 | onClick={() => { | ||
| 1366 | void props.onCopy(props.text, props.field); | ||
| 1367 | }} | ||
| 1368 | className="shrink-0 rounded p-1 text-muted-foreground/30 transition-colors hover:text-muted-foreground" | ||
| 1369 | title={`Copy ${props.field}`} | ||
| 1370 | > | ||
| 1371 | {props.copiedField === props.field ? <IconCheck /> : <IconCopy />} | ||
| 1372 | </button> | ||
| 1373 | ); | ||
| 1374 | } | ||
| 1375 | |||
| 1376 | function highlightMatch(text: string, search: string) { | ||
| 1377 | if (!search.trim()) return text; | ||
| 1378 | const terms = search.trim().toLowerCase().split(/\s+/); | ||
| 1379 | const lowerText = text.toLowerCase(); | ||
| 1380 | const ranges: Array<[number, number]> = []; | ||
| 1381 | |||
| 1382 | for (const term of terms) { | ||
| 1383 | let idx = 0; | ||
| 1384 | while (idx < lowerText.length) { | ||
| 1385 | const found = lowerText.indexOf(term, idx); | ||
| 1386 | if (found === -1) break; | ||
| 1387 | ranges.push([found, found + term.length]); | ||
| 1388 | idx = found + 1; | ||
| 1389 | } | ||
| 1390 | } | ||
| 1391 | |||
| 1392 | if (ranges.length === 0) return text; | ||
| 1393 | |||
| 1394 | ranges.sort((a, b) => a[0] - b[0]); | ||
| 1395 | const merged: Array<[number, number]> = [ranges[0]!]; | ||
| 1396 | for (let index = 1; index < ranges.length; index++) { | ||
| 1397 | const last = merged[merged.length - 1]!; | ||
| 1398 | const current = ranges[index]!; | ||
| 1399 | if (current[0] <= last[1]) { | ||
| 1400 | last[1] = Math.max(last[1], current[1]); | ||
| 1401 | } else { | ||
| 1402 | merged.push(current); | ||
| 1403 | } | ||
| 1404 | } | ||
| 1405 | |||
| 1406 | const parts: Array<{ text: string; hl: boolean }> = []; | ||
| 1407 | let cursor = 0; | ||
| 1408 | for (const [start, end] of merged) { | ||
| 1409 | if (cursor < start) parts.push({ text: text.slice(cursor, start), hl: false }); | ||
| 1410 | parts.push({ text: text.slice(start, end), hl: true }); | ||
| 1411 | cursor = end; | ||
| 1412 | } | ||
| 1413 | if (cursor < text.length) parts.push({ text: text.slice(cursor), hl: false }); | ||
| 1414 | |||
| 1415 | return ( | ||
| 1416 | <> | ||
| 1417 | {parts.map((part, index) => | ||
| 1418 | part.hl ? ( | ||
| 1419 | <mark key={index} className="rounded-sm bg-primary/20 px-px text-foreground"> | ||
| 1420 | {part.text} | ||
| 1421 | </mark> | ||
| 1422 | ) : ( | ||
| 1423 | <span key={index}>{part.text}</span> | ||
| 1424 | ), | ||
| 1425 | )} | ||
| 1426 | </> | ||
| 1427 | ); | ||
| 1428 | } | ||
| 1429 | |||
| 439 | export const OpenApiReactPlugin = { | 1430 | export const OpenApiReactPlugin = { |
| 440 | key: "openapi", | 1431 | key: "openapi", |
| 441 | register(api: FrontendPluginRegisterApi) { | 1432 | register(api: FrontendPluginRegisterApi) { |
| @@ -448,8 +1439,8 @@ export const OpenApiReactPlugin = { · 6 unmodified lines | |||
| 448 | renderEditPage: ({ source }) => ( | 1439 | renderEditPage: ({ source }) => ( |
| 449 | <OpenApiEditSourcePage source={source} /> | 1440 | <OpenApiEditSourcePage source={source} /> |
| 450 | ), | 1441 | ), |
| 451 | renderDetailPage: ({ source }) => ( | 1442 | renderDetailPage: ({ source, route }) => ( |
| 452 | <OpenApiSourceDetailPage source={source} /> | 1443 | <OpenApiSourceDetailPage source={source} route={route} /> |
| 453 | ), | 1444 | ), |
| 454 | }); | 1445 | }); |
| 455 | }, | 1446 | }, |
| @@ -0,0 +1,919 @@ | |||
| 1 | import { | ||
| 2 | CapabilityIdSchema, | ||
| 3 | DocumentIdSchema, | ||
| 4 | ExampleSymbolIdSchema, | ||
| 5 | ExecutableIdSchema, | ||
| 6 | HeaderSymbolIdSchema, | ||
| 7 | ParameterSymbolIdSchema, | ||
| 8 | RequestBodySymbolIdSchema, | ||
| 9 | ResponseSymbolIdSchema, | ||
| 10 | ScopeIdSchema, | ||
| 11 | SecuritySchemeSymbolIdSchema, | ||
| 12 | } from "@executor/ir/ids"; | ||
| 13 | import type { | ||
| 14 | AuthRequirement, | ||
| 15 | Capability, | ||
| 16 | ContentSpec, | ||
| 17 | Executable, | ||
| 18 | ParameterSymbol, | ||
| 19 | ResponseSet, | ||
| 20 | ResponseSymbol, | ||
| 21 | Scope, | ||
| 22 | SecuritySchemeSymbol, | ||
| 23 | } from "@executor/ir/model"; | ||
| 24 | import { | ||
| 25 | EXECUTABLE_BINDING_VERSION, | ||
| 26 | buildCatalogFragment, | ||
| 27 | docsFrom, | ||
| 28 | exampleSymbolFromValue, | ||
| 29 | groupedSchemaForParameter, | ||
| 30 | interactionForEffect, | ||
| 31 | mutableRecord, | ||
| 32 | preferredResponseContentTypes, | ||
| 33 | provenanceFor, | ||
| 34 | requestBodySchemaFromInput, | ||
| 35 | responseSetFromSingleResponse, | ||
| 36 | responseSetFromVariants, | ||
| 37 | stableHash, | ||
| 38 | statusMatchFromHttpStatusCode, | ||
| 39 | toolPathSegments, | ||
| 40 | type BaseCatalogOperationInput, | ||
| 41 | type CatalogFragmentBuilder, | ||
| 42 | type CatalogSourceDocumentInput, | ||
| 43 | type JsonSchemaImporter, | ||
| 44 | type Source, | ||
| 45 | } from "@executor/source-core"; | ||
| 46 | |||
| 47 | import type { | ||
| 48 | OpenApiHeader, | ||
| 49 | OpenApiMediaContent, | ||
| 50 | OpenApiResponseVariant, | ||
| 51 | OpenApiSecurityRequirement, | ||
| 52 | OpenApiSecurityScheme, | ||
| 53 | OpenApiToolProviderData, | ||
| 54 | OpenApiToolDefinition, | ||
| 55 | } from "./types"; | ||
| 56 | import { openApiProviderDataFromDefinition } from "./definitions"; | ||
| 57 | |||
| 58 | export type OpenApiCatalogOperationInput = BaseCatalogOperationInput & { | ||
| 59 | providerData: OpenApiToolProviderData; | ||
| 60 | }; | ||
| 61 | |||
| 62 | const openApiServerSpecs = ( | ||
| 63 | servers: | ||
| 64 | | OpenApiToolProviderData["servers"] | ||
| 65 | | OpenApiToolProviderData["documentServers"] | ||
| 66 | | undefined, | ||
| 67 | ): NonNullable<NonNullable<Scope["defaults"]>["servers"]> | undefined => { | ||
| 68 | if (!servers || servers.length === 0) { | ||
| 69 | return undefined; | ||
| 70 | } | ||
| 71 | |||
| 72 | return servers.map((server) => ({ | ||
| 73 | url: server.url, | ||
| 74 | ...(server.description ? { description: server.description } : {}), | ||
| 75 | ...(server.variables ? { variables: server.variables } : {}), | ||
| 76 | })); | ||
| 77 | }; | ||
| 78 | |||
| 79 | const createOperationScope = (input: { | ||
| 80 | catalog: CatalogFragmentBuilder; | ||
| 81 | source: Pick<Source, "id">; | ||
| 82 | documentId: ReturnType<typeof DocumentIdSchema.make>; | ||
| 83 | parentScopeId: ReturnType<typeof ScopeIdSchema.make>; | ||
| 84 | operation: OpenApiCatalogOperationInput; | ||
| 85 | defaults: Scope["defaults"]; | ||
| 86 | }) => { | ||
| 87 | const scopeId = ScopeIdSchema.make( | ||
| 88 | `scope_${stableHash({ | ||
| 89 | sourceId: input.source.id, | ||
| 90 | toolId: input.operation.providerData.toolId, | ||
| 91 | kind: "operation", | ||
| 92 | })}`, | ||
| 93 | ); | ||
| 94 | |||
| 95 | mutableRecord(input.catalog.scopes)[scopeId] = { | ||
| 96 | id: scopeId, | ||
| 97 | kind: "operation", | ||
| 98 | parentId: input.parentScopeId, | ||
| 99 | name: input.operation.title ?? input.operation.providerData.toolId, | ||
| 100 | docs: docsFrom({ | ||
| 101 | summary: input.operation.title ?? input.operation.providerData.toolId, | ||
| 102 | description: input.operation.description ?? undefined, | ||
| 103 | }), | ||
| 104 | defaults: input.defaults, | ||
| 105 | synthetic: false, | ||
| 106 | provenance: provenanceFor( | ||
| 107 | input.documentId, | ||
| 108 | `#/openapi/${input.operation.providerData.toolId}/scope`, | ||
| 109 | ), | ||
| 110 | } satisfies Scope; | ||
| 111 | |||
| 112 | return scopeId; | ||
| 113 | }; | ||
| 114 | |||
| 115 | const ensureOpenApiSecuritySchemeSymbol = (input: { | ||
| 116 | catalog: CatalogFragmentBuilder; | ||
| 117 | source: Pick<Source, "id">; | ||
| 118 | documentId: ReturnType<typeof DocumentIdSchema.make>; | ||
| 119 | schemeName: string; | ||
| 120 | scheme?: OpenApiSecurityScheme; | ||
| 121 | }) => { | ||
| 122 | const schemeId = SecuritySchemeSymbolIdSchema.make( | ||
| 123 | `security_${stableHash({ | ||
| 124 | sourceId: input.source.id, | ||
| 125 | provider: "openapi", | ||
| 126 | schemeName: input.schemeName, | ||
| 127 | })}`, | ||
| 128 | ); | ||
| 129 | |||
| 130 | if (input.catalog.symbols[schemeId]) { | ||
| 131 | return schemeId; | ||
| 132 | } | ||
| 133 | |||
| 134 | const scheme = input.scheme; | ||
| 135 | const httpScheme = scheme?.scheme?.toLowerCase(); | ||
| 136 | const schemeType = | ||
| 137 | scheme?.schemeType === "apiKey" | ||
| 138 | ? "apiKey" | ||
| 139 | : scheme?.schemeType === "oauth2" | ||
| 140 | ? "oauth2" | ||
| 141 | : scheme?.schemeType === "http" && httpScheme === "basic" | ||
| 142 | ? "basic" | ||
| 143 | : scheme?.schemeType === "http" && httpScheme === "bearer" | ||
| 144 | ? "bearer" | ||
| 145 | : scheme?.schemeType === "openIdConnect" | ||
| 146 | ? "custom" | ||
| 147 | : scheme?.schemeType === "http" | ||
| 148 | ? "http" | ||
| 149 | : "custom"; | ||
| 150 | |||
| 151 | const oauthFlows = Object.fromEntries( | ||
| 152 | Object.entries(scheme?.flows ?? {}).map(([flowName, flow]) => [flowName, flow]), | ||
| 153 | ); | ||
| 154 | const oauthScopes = Object.fromEntries( | ||
| 155 | Object.entries(scheme?.flows ?? {}).flatMap(([, flow]) => | ||
| 156 | Object.entries(flow.scopes ?? {}), | ||
| 157 | ), | ||
| 158 | ); | ||
| 159 | const description = | ||
| 160 | scheme?.description ?? | ||
| 161 | (scheme?.openIdConnectUrl ? `OpenID Connect: ${scheme.openIdConnectUrl}` : null); | ||
| 162 | |||
| 163 | mutableRecord(input.catalog.symbols)[schemeId] = { | ||
| 164 | id: schemeId, | ||
| 165 | kind: "securityScheme", | ||
| 166 | schemeType, | ||
| 167 | ...(docsFrom({ | ||
| 168 | summary: input.schemeName, | ||
| 169 | description, | ||
| 170 | }) | ||
| 171 | ? { | ||
| 172 | docs: docsFrom({ | ||
| 173 | summary: input.schemeName, | ||
| 174 | description, | ||
| 175 | })!, | ||
| 176 | } | ||
| 177 | : {}), | ||
| 178 | ...(scheme?.placementIn || scheme?.placementName | ||
| 179 | ? { | ||
| 180 | placement: { | ||
| 181 | ...(scheme?.placementIn ? { in: scheme.placementIn } : {}), | ||
| 182 | ...(scheme?.placementName ? { name: scheme.placementName } : {}), | ||
| 183 | }, | ||
| 184 | } | ||
| 185 | : {}), | ||
| 186 | ...(schemeType === "apiKey" && scheme?.placementIn && scheme?.placementName | ||
| 187 | ? { | ||
| 188 | apiKey: { | ||
| 189 | in: scheme.placementIn, | ||
| 190 | name: scheme.placementName, | ||
| 191 | }, | ||
| 192 | } | ||
| 193 | : {}), | ||
| 194 | ...((schemeType === "basic" || schemeType === "bearer" || schemeType === "http") && | ||
| 195 | scheme?.scheme | ||
| 196 | ? { | ||
| 197 | http: { | ||
| 198 | scheme: scheme.scheme, | ||
| 199 | ...(scheme.bearerFormat ? { bearerFormat: scheme.bearerFormat } : {}), | ||
| 200 | }, | ||
| 201 | } | ||
| 202 | : {}), | ||
| 203 | ...(schemeType === "oauth2" | ||
| 204 | ? { | ||
| 205 | oauth: { | ||
| 206 | ...(Object.keys(oauthFlows).length > 0 ? { flows: oauthFlows } : {}), | ||
| 207 | ...(Object.keys(oauthScopes).length > 0 ? { scopes: oauthScopes } : {}), | ||
| 208 | }, | ||
| 209 | } | ||
| 210 | : {}), | ||
| 211 | ...(schemeType === "custom" ? { custom: {} } : {}), | ||
| 212 | synthetic: false, | ||
| 213 | provenance: provenanceFor( | ||
| 214 | input.documentId, | ||
| 215 | `#/openapi/securitySchemes/${input.schemeName}`, | ||
| 216 | ), | ||
| 217 | } satisfies SecuritySchemeSymbol; | ||
| 218 | |||
| 219 | return schemeId; | ||
| 220 | }; | ||
| 221 | |||
| 222 | const openApiAuthRequirementToIr = (input: { | ||
| 223 | catalog: CatalogFragmentBuilder; | ||
| 224 | source: Pick<Source, "id">; | ||
| 225 | documentId: ReturnType<typeof DocumentIdSchema.make>; | ||
| 226 | authRequirement: OpenApiSecurityRequirement | undefined; | ||
| 227 | schemesByName: ReadonlyMap<string, OpenApiSecurityScheme>; | ||
| 228 | }): AuthRequirement => { | ||
| 229 | const requirement = input.authRequirement; | ||
| 230 | if (!requirement) { | ||
| 231 | return { kind: "none" }; | ||
| 232 | } | ||
| 233 | |||
| 234 | switch (requirement.kind) { | ||
| 235 | case "none": | ||
| 236 | return { kind: "none" }; | ||
| 237 | case "scheme": { | ||
| 238 | const schemeId = ensureOpenApiSecuritySchemeSymbol({ | ||
| 239 | catalog: input.catalog, | ||
| 240 | source: input.source, | ||
| 241 | documentId: input.documentId, | ||
| 242 | schemeName: requirement.schemeName, | ||
| 243 | scheme: input.schemesByName.get(requirement.schemeName), | ||
| 244 | }); | ||
| 245 | |||
| 246 | return { | ||
| 247 | kind: "scheme", | ||
| 248 | schemeId, | ||
| 249 | ...(requirement.scopes && requirement.scopes.length > 0 | ||
| 250 | ? { scopes: [...requirement.scopes] } | ||
| 251 | : {}), | ||
| 252 | }; | ||
| 253 | } | ||
| 254 | case "allOf": | ||
| 255 | case "anyOf": | ||
| 256 | return { | ||
| 257 | kind: requirement.kind, | ||
| 258 | items: requirement.items.map((item) => | ||
| 259 | openApiAuthRequirementToIr({ | ||
| 260 | catalog: input.catalog, | ||
| 261 | source: input.source, | ||
| 262 | documentId: input.documentId, | ||
| 263 | authRequirement: item, | ||
| 264 | schemesByName: input.schemesByName, | ||
| 265 | }), | ||
| 266 | ), | ||
| 267 | }; | ||
| 268 | } | ||
| 269 | }; | ||
| 270 | |||
| 271 | const contentSpecsFromOpenApiContents = (input: { | ||
| 272 | catalog: CatalogFragmentBuilder; | ||
| 273 | source: Pick<Source, "kind">; | ||
| 274 | documentId: ReturnType<typeof DocumentIdSchema.make>; | ||
| 275 | importer: JsonSchemaImporter; | ||
| 276 | rootSchema?: unknown; | ||
| 277 | contents: ReadonlyArray<OpenApiMediaContent>; | ||
| 278 | pointerBase: string; | ||
| 279 | }) => | ||
| 280 | input.contents.map((content, contentIndex) => { | ||
| 281 | const exampleIds = (content.examples ?? []).map((example, exampleIndex) => | ||
| 282 | exampleSymbolFromValue({ | ||
| 283 | catalog: input.catalog, | ||
| 284 | source: input.source, | ||
| 285 | documentId: input.documentId, | ||
| 286 | pointer: `${input.pointerBase}/content/${contentIndex}/example/${exampleIndex}`, | ||
| 287 | name: example.label, | ||
| 288 | summary: example.label, | ||
| 289 | value: JSON.parse(example.valueJson) as unknown, | ||
| 290 | }), | ||
| 291 | ); | ||
| 292 | |||
| 293 | return { | ||
| 294 | mediaType: content.mediaType, | ||
| 295 | ...(content.schema !== undefined | ||
| 296 | ? { | ||
| 297 | shapeId: input.importer.importSchema( | ||
| 298 | content.schema, | ||
| 299 | `${input.pointerBase}/content/${contentIndex}`, | ||
| 300 | input.rootSchema, | ||
| 301 | ), | ||
| 302 | } | ||
| 303 | : {}), | ||
| 304 | ...(exampleIds.length > 0 ? { exampleIds } : {}), | ||
| 305 | } satisfies ContentSpec; | ||
| 306 | }); | ||
| 307 | |||
| 308 | const createOpenApiHeaderSymbol = (input: { | ||
| 309 | catalog: CatalogFragmentBuilder; | ||
| 310 | source: Pick<Source, "kind">; | ||
| 311 | documentId: ReturnType<typeof DocumentIdSchema.make>; | ||
| 312 | importer: JsonSchemaImporter; | ||
| 313 | rootSchema?: unknown; | ||
| 314 | pointer: string; | ||
| 315 | idSeed: Record<string, unknown>; | ||
| 316 | header: OpenApiHeader; | ||
| 317 | }) => { | ||
| 318 | const headerId = HeaderSymbolIdSchema.make(`header_${stableHash(input.idSeed)}`); | ||
| 319 | const exampleIds = (input.header.examples ?? []).map((example, index) => | ||
| 320 | exampleSymbolFromValue({ | ||
| 321 | catalog: input.catalog, | ||
| 322 | source: input.source, | ||
| 323 | documentId: input.documentId, | ||
| 324 | pointer: `${input.pointer}/example/${index}`, | ||
| 325 | name: example.label, | ||
| 326 | summary: example.label, | ||
| 327 | value: JSON.parse(example.valueJson) as unknown, | ||
| 328 | }), | ||
| 329 | ); | ||
| 330 | const contents = input.header.content | ||
| 331 | ? contentSpecsFromOpenApiContents({ | ||
| 332 | catalog: input.catalog, | ||
| 333 | source: input.source, | ||
| 334 | documentId: input.documentId, | ||
| 335 | importer: input.importer, | ||
| 336 | rootSchema: input.rootSchema, | ||
| 337 | contents: input.header.content, | ||
| 338 | pointerBase: input.pointer, | ||
| 339 | }) | ||
| 340 | : undefined; | ||
| 341 | |||
| 342 | mutableRecord(input.catalog.symbols)[headerId] = { | ||
| 343 | id: headerId, | ||
| 344 | kind: "header", | ||
| 345 | name: input.header.name, | ||
| 346 | ...(docsFrom({ | ||
| 347 | description: input.header.description, | ||
| 348 | }) | ||
| 349 | ? { | ||
| 350 | docs: docsFrom({ | ||
| 351 | description: input.header.description, | ||
| 352 | })!, | ||
| 353 | } | ||
| 354 | : {}), | ||
| 355 | ...(typeof input.header.deprecated === "boolean" | ||
| 356 | ? { deprecated: input.header.deprecated } | ||
| 357 | : {}), | ||
| 358 | ...(input.header.schema !== undefined | ||
| 359 | ? { | ||
| 360 | schemaShapeId: input.importer.importSchema( | ||
| 361 | input.header.schema, | ||
| 362 | input.pointer, | ||
| 363 | input.rootSchema, | ||
| 364 | ), | ||
| 365 | } | ||
| 366 | : {}), | ||
| 367 | ...(contents && contents.length > 0 ? { content: contents } : {}), | ||
| 368 | ...(exampleIds.length > 0 ? { exampleIds } : {}), | ||
| 369 | ...(input.header.style ? { style: input.header.style } : {}), | ||
| 370 | ...(typeof input.header.explode === "boolean" | ||
| 371 | ? { explode: input.header.explode } | ||
| 372 | : {}), | ||
| 373 | synthetic: false, | ||
| 374 | provenance: provenanceFor(input.documentId, input.pointer), | ||
| 375 | }; | ||
| 376 | |||
| 377 | return headerId; | ||
| 378 | }; | ||
| 379 | |||
| 380 | const createHttpCapabilityFromOpenApi = (input: { | ||
| 381 | catalog: CatalogFragmentBuilder; | ||
| 382 | source: Pick<Source, "id" | "kind" | "name" | "namespace">; | ||
| 383 | documentId: ReturnType<typeof DocumentIdSchema.make>; | ||
| 384 | serviceScopeId: ReturnType<typeof ScopeIdSchema.make>; | ||
| 385 | operation: OpenApiCatalogOperationInput; | ||
| 386 | importer: JsonSchemaImporter; | ||
| 387 | rootSchema?: unknown; | ||
| 388 | }) => { | ||
| 389 | const toolPath = toolPathSegments(input.source, input.operation.providerData.toolId); | ||
| 390 | const capabilityId = CapabilityIdSchema.make( | ||
| 391 | `cap_${stableHash({ | ||
| 392 | sourceId: input.source.id, | ||
| 393 | toolId: input.operation.providerData.toolId, | ||
| 394 | })}`, | ||
| 395 | ); | ||
| 396 | const executableId = ExecutableIdSchema.make( | ||
| 397 | `exec_${stableHash({ | ||
| 398 | sourceId: input.source.id, | ||
| 399 | toolId: input.operation.providerData.toolId, | ||
| 400 | protocol: "http", | ||
| 401 | })}`, | ||
| 402 | ); | ||
| 403 | const inputSchema = input.operation.inputSchema ?? {}; | ||
| 404 | const outputSchema = input.operation.outputSchema ?? {}; | ||
| 405 | const exampleIds: Array<ReturnType<typeof ExampleSymbolIdSchema.make>> = []; | ||
| 406 | const schemesByName = new Map( | ||
| 407 | (input.operation.providerData.securitySchemes ?? []).map((scheme) => [ | ||
| 408 | scheme.schemeName, | ||
| 409 | scheme, | ||
| 410 | ]), | ||
| 411 | ); | ||
| 412 | |||
| 413 | input.operation.providerData.invocation.parameters.forEach((parameter) => { | ||
| 414 | const parameterId = ParameterSymbolIdSchema.make( | ||
| 415 | `param_${stableHash({ | ||
| 416 | capabilityId, | ||
| 417 | location: parameter.location, | ||
| 418 | name: parameter.name, | ||
| 419 | })}`, | ||
| 420 | ); | ||
| 421 | const parameterSchema = groupedSchemaForParameter( | ||
| 422 | inputSchema, | ||
| 423 | parameter.location, | ||
| 424 | parameter.name, | ||
| 425 | ); | ||
| 426 | const matchingDocs = input.operation.providerData.documentation?.parameters.find( | ||
| 427 | (candidate) => | ||
| 428 | candidate.name === parameter.name && candidate.location === parameter.location, | ||
| 429 | ); | ||
| 430 | const parameterExampleIds = (matchingDocs?.examples ?? []).map((example, index) => | ||
| 431 | exampleSymbolFromValue({ | ||
| 432 | catalog: input.catalog, | ||
| 433 | source: input.source, | ||
| 434 | documentId: input.documentId, | ||
| 435 | pointer: `#/openapi/${input.operation.providerData.toolId}/parameter/${parameter.location}/${parameter.name}/example/${index}`, | ||
| 436 | name: example.label, | ||
| 437 | summary: example.label, | ||
| 438 | value: JSON.parse(example.valueJson) as unknown, | ||
| 439 | }), | ||
| 440 | ); | ||
| 441 | exampleIds.push(...parameterExampleIds); | ||
| 442 | const parameterContent = parameter.content | ||
| 443 | ? contentSpecsFromOpenApiContents({ | ||
| 444 | catalog: input.catalog, | ||
| 445 | source: input.source, | ||
| 446 | documentId: input.documentId, | ||
| 447 | importer: input.importer, | ||
| 448 | rootSchema: input.rootSchema, | ||
| 449 | contents: parameter.content, | ||
| 450 | pointerBase: `#/openapi/${input.operation.providerData.toolId}/parameter/${parameter.location}/${parameter.name}`, | ||
| 451 | }) | ||
| 452 | : undefined; | ||
| 453 | mutableRecord(input.catalog.symbols)[parameterId] = { | ||
| 454 | id: parameterId, | ||
| 455 | kind: "parameter", | ||
| 456 | name: parameter.name, | ||
| 457 | location: parameter.location, | ||
| 458 | required: parameter.required, | ||
| 459 | ...(docsFrom({ | ||
| 460 | description: matchingDocs?.description ?? null, | ||
| 461 | }) | ||
| 462 | ? { | ||
| 463 | docs: docsFrom({ | ||
| 464 | description: matchingDocs?.description ?? null, | ||
| 465 | })!, | ||
| 466 | } | ||
| 467 | : {}), | ||
| 468 | ...(parameterSchema !== undefined && (!parameterContent || parameterContent.length === 0) | ||
| 469 | ? { | ||
| 470 | schemaShapeId: input.importer.importSchema( | ||
| 471 | parameterSchema, | ||
| 472 | `#/openapi/${input.operation.providerData.toolId}/parameter/${parameter.location}/${parameter.name}`, | ||
| 473 | input.rootSchema, | ||
| 474 | ), | ||
| 475 | } | ||
| 476 | : {}), | ||
| 477 | ...(parameterContent && parameterContent.length > 0 ? { content: parameterContent } : {}), | ||
| 478 | ...(parameterExampleIds.length > 0 ? { exampleIds: parameterExampleIds } : {}), | ||
| 479 | ...(parameter.style ? { style: parameter.style } : {}), | ||
| 480 | ...(typeof parameter.explode === "boolean" ? { explode: parameter.explode } : {}), | ||
| 481 | ...(typeof parameter.allowReserved === "boolean" | ||
| 482 | ? { allowReserved: parameter.allowReserved } | ||
| 483 | : {}), | ||
| 484 | synthetic: false, | ||
| 485 | provenance: provenanceFor( | ||
| 486 | input.documentId, | ||
| 487 | `#/openapi/${input.operation.providerData.toolId}/parameter/${parameter.location}/${parameter.name}`, | ||
| 488 | ), | ||
| 489 | } satisfies ParameterSymbol; | ||
| 490 | }); | ||
| 491 | |||
| 492 | const requestBodyId = input.operation.providerData.invocation.requestBody | ||
| 493 | ? RequestBodySymbolIdSchema.make(`request_body_${stableHash({ capabilityId })}`) | ||
| 494 | : undefined; | ||
| 495 | |||
| 496 | if (requestBodyId) { | ||
| 497 | const requestBody = input.operation.providerData.invocation.requestBody; | ||
| 498 | const requestBodySchema = requestBodySchemaFromInput(inputSchema); | ||
| 499 | const requestBodyContents = requestBody?.contents | ||
| 500 | ? contentSpecsFromOpenApiContents({ | ||
| 501 | catalog: input.catalog, | ||
| 502 | source: input.source, | ||
| 503 | documentId: input.documentId, | ||
| 504 | importer: input.importer, | ||
| 505 | rootSchema: input.rootSchema, | ||
| 506 | contents: requestBody.contents, | ||
| 507 | pointerBase: `#/openapi/${input.operation.providerData.toolId}/requestBody`, | ||
| 508 | }) | ||
| 509 | : undefined; | ||
| 510 | const requestBodyExampleIds = | ||
| 511 | requestBodyContents?.flatMap((content) => content.exampleIds ?? []) ?? | ||
| 512 | (input.operation.providerData.documentation?.requestBody?.examples ?? []).map( | ||
| 513 | (example, index) => | ||
| 514 | exampleSymbolFromValue({ | ||
| 515 | catalog: input.catalog, | ||
| 516 | source: input.source, | ||
| 517 | documentId: input.documentId, | ||
| 518 | pointer: `#/openapi/${input.operation.providerData.toolId}/requestBody/example/${index}`, | ||
| 519 | name: example.label, | ||
| 520 | summary: example.label, | ||
| 521 | value: JSON.parse(example.valueJson) as unknown, | ||
| 522 | }), | ||
| 523 | ); | ||
| 524 | exampleIds.push(...requestBodyExampleIds); | ||
| 525 | const contents: ContentSpec[] = | ||
| 526 | requestBodyContents && requestBodyContents.length > 0 | ||
| 527 | ? requestBodyContents | ||
| 528 | : preferredResponseContentTypes( | ||
| 529 | input.operation.providerData.invocation.requestBody?.contentTypes, | ||
| 530 | ).map((mediaType) => ({ | ||
| 531 | mediaType, | ||
| 532 | ...(requestBodySchema !== undefined | ||
| 533 | ? { | ||
| 534 | shapeId: input.importer.importSchema( | ||
| 535 | requestBodySchema, | ||
| 536 | `#/openapi/${input.operation.providerData.toolId}/requestBody`, | ||
| 537 | input.rootSchema, | ||
| 538 | ), | ||
| 539 | } | ||
| 540 | : {}), | ||
| 541 | ...(requestBodyExampleIds.length > 0 | ||
| 542 | ? { exampleIds: requestBodyExampleIds } | ||
| 543 | : {}), | ||
| 544 | })); | ||
| 545 | |||
| 546 | mutableRecord(input.catalog.symbols)[requestBodyId] = { | ||
| 547 | id: requestBodyId, | ||
| 548 | kind: "requestBody", | ||
| 549 | ...(docsFrom({ | ||
| 550 | description: | ||
| 551 | input.operation.providerData.documentation?.requestBody?.description ?? null, | ||
| 552 | }) | ||
| 553 | ? { | ||
| 554 | docs: docsFrom({ | ||
| 555 | description: | ||
| 556 | input.operation.providerData.documentation?.requestBody?.description ?? | ||
| 557 | null, | ||
| 558 | })!, | ||
| 559 | } | ||
| 560 | : {}), | ||
| 561 | required: input.operation.providerData.invocation.requestBody?.required ?? false, | ||
| 562 | contents, | ||
| 563 | synthetic: false, | ||
| 564 | provenance: provenanceFor( | ||
| 565 | input.documentId, | ||
| 566 | `#/openapi/${input.operation.providerData.toolId}/requestBody`, | ||
| 567 | ), | ||
| 568 | }; | ||
| 569 | } | ||
| 570 | |||
| 571 | const openApiResponseVariants = input.operation.providerData.responses ?? []; | ||
| 572 | const responseSetId = | ||
| 573 | openApiResponseVariants.length > 0 | ||
| 574 | ? responseSetFromVariants({ | ||
| 575 | catalog: input.catalog, | ||
| 576 | variants: openApiResponseVariants.map((response, responseIndex) => { | ||
| 577 | const responseId = ResponseSymbolIdSchema.make( | ||
| 578 | `response_${stableHash({ | ||
| 579 | capabilityId, | ||
| 580 | statusCode: response.statusCode, | ||
| 581 | responseIndex, | ||
| 582 | })}`, | ||
| 583 | ); | ||
| 584 | const responseExampleIds = (response.examples ?? []).map((example, index) => | ||
| 585 | exampleSymbolFromValue({ | ||
| 586 | catalog: input.catalog, | ||
| 587 | source: input.source, | ||
| 588 | documentId: input.documentId, | ||
| 589 | pointer: `#/openapi/${input.operation.providerData.toolId}/responses/${response.statusCode}/example/${index}`, | ||
| 590 | name: example.label, | ||
| 591 | summary: example.label, | ||
| 592 | value: JSON.parse(example.valueJson) as unknown, | ||
| 593 | }), | ||
| 594 | ); | ||
| 595 | exampleIds.push(...responseExampleIds); | ||
| 596 | |||
| 597 | const contents = | ||
| 598 | response.contents && response.contents.length > 0 | ||
| 599 | ? contentSpecsFromOpenApiContents({ | ||
| 600 | catalog: input.catalog, | ||
| 601 | source: input.source, | ||
| 602 | documentId: input.documentId, | ||
| 603 | importer: input.importer, | ||
| 604 | rootSchema: input.rootSchema, | ||
| 605 | contents: response.contents, | ||
| 606 | pointerBase: `#/openapi/${input.operation.providerData.toolId}/responses/${response.statusCode}`, | ||
| 607 | }) | ||
| 608 | : (() => { | ||
| 609 | const responseShapeId = | ||
| 610 | response.schema !== undefined | ||
| 611 | ? input.importer.importSchema( | ||
| 612 | response.schema, | ||
| 613 | `#/openapi/${input.operation.providerData.toolId}/responses/${response.statusCode}`, | ||
| 614 | input.rootSchema, | ||
| 615 | ) | ||
| 616 | : undefined; | ||
| 617 | const preferredContentTypes = preferredResponseContentTypes( | ||
| 618 | response.contentTypes, | ||
| 619 | ); | ||
| 620 | |||
| 621 | return preferredContentTypes.length > 0 | ||
| 622 | ? preferredContentTypes.map((mediaType, contentIndex) => ({ | ||
| 623 | mediaType, | ||
| 624 | ...(responseShapeId !== undefined && contentIndex === 0 | ||
| 625 | ? { shapeId: responseShapeId } | ||
| 626 | : {}), | ||
| 627 | ...(responseExampleIds.length > 0 && contentIndex === 0 | ||
| 628 | ? { exampleIds: responseExampleIds } | ||
| 629 | : {}), | ||
| 630 | })) | ||
| 631 | : undefined; | ||
| 632 | })(); | ||
| 633 | const headerIds = (response.headers ?? []).map((header, headerIndex) => | ||
| 634 | createOpenApiHeaderSymbol({ | ||
| 635 | catalog: input.catalog, | ||
| 636 | source: input.source, | ||
| 637 | documentId: input.documentId, | ||
| 638 | importer: input.importer, | ||
| 639 | rootSchema: input.rootSchema, | ||
| 640 | pointer: `#/openapi/${input.operation.providerData.toolId}/responses/${response.statusCode}/headers/${header.name}`, | ||
| 641 | idSeed: { | ||
| 642 | capabilityId, | ||
| 643 | responseId, | ||
| 644 | headerIndex, | ||
| 645 | headerName: header.name, | ||
| 646 | }, | ||
| 647 | header, | ||
| 648 | }), | ||
| 649 | ); | ||
| 650 | |||
| 651 | mutableRecord(input.catalog.symbols)[responseId] = { | ||
| 652 | id: responseId, | ||
| 653 | kind: "response", | ||
| 654 | ...(docsFrom({ | ||
| 655 | description: | ||
| 656 | response.description ?? | ||
| 657 | (responseIndex === 0 ? input.operation.description : null), | ||
| 658 | }) | ||
| 659 | ? { | ||
| 660 | docs: docsFrom({ | ||
| 661 | description: | ||
| 662 | response.description ?? | ||
| 663 | (responseIndex === 0 ? input.operation.description : null), | ||
| 664 | })!, | ||
| 665 | } | ||
| 666 | : {}), | ||
| 667 | ...(headerIds.length > 0 ? { headerIds } : {}), | ||
| 668 | ...(contents && contents.length > 0 ? { contents } : {}), | ||
| 669 | synthetic: false, | ||
| 670 | provenance: provenanceFor( | ||
| 671 | input.documentId, | ||
| 672 | `#/openapi/${input.operation.providerData.toolId}/responses/${response.statusCode}`, | ||
| 673 | ), | ||
| 674 | } satisfies ResponseSymbol; | ||
| 675 | |||
| 676 | return { | ||
| 677 | match: statusMatchFromHttpStatusCode(response.statusCode), | ||
| 678 | responseId, | ||
| 679 | } satisfies ResponseSet["variants"][number]; | ||
| 680 | }), | ||
| 681 | provenance: provenanceFor( | ||
| 682 | input.documentId, | ||
| 683 | `#/openapi/${input.operation.providerData.toolId}/responseSet`, | ||
| 684 | ), | ||
| 685 | }) | ||
| 686 | : (() => { | ||
| 687 | const responseId = ResponseSymbolIdSchema.make( | ||
| 688 | `response_${stableHash({ capabilityId })}`, | ||
| 689 | ); | ||
| 690 | const responseExampleIds = ( | ||
| 691 | input.operation.providerData.documentation?.response?.examples ?? [] | ||
| 692 | ).map((example, index) => | ||
| 693 | exampleSymbolFromValue({ | ||
| 694 | catalog: input.catalog, | ||
| 695 | source: input.source, | ||
| 696 | documentId: input.documentId, | ||
| 697 | pointer: `#/openapi/${input.operation.providerData.toolId}/response/example/${index}`, | ||
| 698 | name: example.label, | ||
| 699 | summary: example.label, | ||
| 700 | value: JSON.parse(example.valueJson) as unknown, | ||
| 701 | }), | ||
| 702 | ); | ||
| 703 | exampleIds.push(...responseExampleIds); | ||
| 704 | mutableRecord(input.catalog.symbols)[responseId] = { | ||
| 705 | id: responseId, | ||
| 706 | kind: "response", | ||
| 707 | ...(docsFrom({ | ||
| 708 | description: | ||
| 709 | input.operation.providerData.documentation?.response?.description ?? | ||
| 710 | input.operation.description, | ||
| 711 | }) | ||
| 712 | ? { | ||
| 713 | docs: docsFrom({ | ||
| 714 | description: | ||
| 715 | input.operation.providerData.documentation?.response | ||
| 716 | ?.description ?? input.operation.description, | ||
| 717 | })!, | ||
| 718 | } | ||
| 719 | : {}), | ||
| 720 | contents: [ | ||
| 721 | { | ||
| 722 | mediaType: | ||
| 723 | preferredResponseContentTypes( | ||
| 724 | input.operation.providerData.documentation?.response?.contentTypes, | ||
| 725 | )[0] ?? "application/json", | ||
| 726 | ...(input.operation.outputSchema !== undefined | ||
| 727 | ? { | ||
| 728 | shapeId: input.importer.importSchema( | ||
| 729 | outputSchema, | ||
| 730 | `#/openapi/${input.operation.providerData.toolId}/response`, | ||
| 731 | ), | ||
| 732 | } | ||
| 733 | : {}), | ||
| 734 | ...(responseExampleIds.length > 0 | ||
| 735 | ? { exampleIds: responseExampleIds } | ||
| 736 | : {}), | ||
| 737 | }, | ||
| 738 | ], | ||
| 739 | synthetic: false, | ||
| 740 | provenance: provenanceFor( | ||
| 741 | input.documentId, | ||
| 742 | `#/openapi/${input.operation.providerData.toolId}/response`, | ||
| 743 | ), | ||
| 744 | } satisfies ResponseSymbol; | ||
| 745 | |||
| 746 | return responseSetFromSingleResponse({ | ||
| 747 | catalog: input.catalog, | ||
| 748 | responseId, | ||
| 749 | provenance: provenanceFor( | ||
| 750 | input.documentId, | ||
| 751 | `#/openapi/${input.operation.providerData.toolId}/responseSet`, | ||
| 752 | ), | ||
| 753 | }); | ||
| 754 | })(); | ||
| 755 | |||
| 756 | const callShapeId = | ||
| 757 | input.operation.inputSchema !== undefined | ||
| 758 | ? input.importer.importSchema( | ||
| 759 | input.operation.inputSchema, | ||
| 760 | `#/openapi/${input.operation.providerData.toolId}/call`, | ||
| 761 | input.operation.inputSchema, | ||
| 762 | ) | ||
| 763 | : input.importer.importSchema( | ||
| 764 | { | ||
| 765 | type: "object", | ||
| 766 | additionalProperties: false, | ||
| 767 | }, | ||
| 768 | `#/openapi/${input.operation.providerData.toolId}/call`, | ||
| 769 | ); | ||
| 770 | |||
| 771 | const executable: Executable = { | ||
| 772 | id: executableId, | ||
| 773 | capabilityId, | ||
| 774 | scopeId: (() => { | ||
| 775 | const operationServers = openApiServerSpecs(input.operation.providerData.servers); | ||
| 776 | if (!operationServers || operationServers.length === 0) { | ||
| 777 | return input.serviceScopeId; | ||
| 778 | } | ||
| 779 | |||
| 780 | return createOperationScope({ | ||
| 781 | catalog: input.catalog, | ||
| 782 | source: input.source, | ||
| 783 | documentId: input.documentId, | ||
| 784 | parentScopeId: input.serviceScopeId, | ||
| 785 | operation: input.operation, | ||
| 786 | defaults: { | ||
| 787 | servers: operationServers, | ||
| 788 | }, | ||
| 789 | }); | ||
| 790 | })(), | ||
| 791 | pluginKey: "openapi", | ||
| 792 | bindingVersion: EXECUTABLE_BINDING_VERSION, | ||
| 793 | binding: input.operation.providerData, | ||
| 794 | projection: { | ||
| 795 | responseSetId, | ||
| 796 | callShapeId, | ||
| 797 | }, | ||
| 798 | display: { | ||
| 799 | protocol: "http", | ||
| 800 | method: input.operation.providerData.invocation.method.toUpperCase(), | ||
| 801 | pathTemplate: input.operation.providerData.invocation.pathTemplate, | ||
| 802 | operationId: input.operation.providerData.operationId ?? null, | ||
| 803 | group: input.operation.providerData.group, | ||
| 804 | leaf: input.operation.providerData.leaf, | ||
| 805 | rawToolId: input.operation.providerData.rawToolId, | ||
| 806 | title: input.operation.title ?? null, | ||
| 807 | summary: input.operation.description ?? null, | ||
| 808 | }, | ||
| 809 | synthetic: false, | ||
| 810 | provenance: provenanceFor( | ||
| 811 | input.documentId, | ||
| 812 | `#/openapi/${input.operation.providerData.toolId}/executable`, | ||
| 813 | ), | ||
| 814 | }; | ||
| 815 | mutableRecord(input.catalog.executables)[executableId] = executable; | ||
| 816 | |||
| 817 | const effect = input.operation.effect; | ||
| 818 | const auth = openApiAuthRequirementToIr({ | ||
| 819 | catalog: input.catalog, | ||
| 820 | source: input.source, | ||
| 821 | documentId: input.documentId, | ||
| 822 | authRequirement: input.operation.providerData.authRequirement, | ||
| 823 | schemesByName, | ||
| 824 | }); | ||
| 825 | mutableRecord(input.catalog.capabilities)[capabilityId] = { | ||
| 826 | id: capabilityId, | ||
| 827 | serviceScopeId: input.serviceScopeId, | ||
| 828 | surface: { | ||
| 829 | toolPath, | ||
| 830 | ...(input.operation.title ? { title: input.operation.title } : {}), | ||
| 831 | ...(input.operation.description ? { summary: input.operation.description } : {}), | ||
| 832 | ...(input.operation.providerData.tags.length > 0 | ||
| 833 | ? { tags: input.operation.providerData.tags } | ||
| 834 | : {}), | ||
| 835 | }, | ||
| 836 | semantics: { | ||
| 837 | effect, | ||
| 838 | safe: effect === "read", | ||
| 839 | idempotent: effect === "read" || effect === "delete", | ||
| 840 | destructive: effect === "delete", | ||
| 841 | }, | ||
| 842 | auth, | ||
| 843 | interaction: interactionForEffect(effect), | ||
| 844 | executableIds: [executableId], | ||
| 845 | ...(exampleIds.length > 0 ? { exampleIds } : {}), | ||
| 846 | synthetic: false, | ||
| 847 | provenance: provenanceFor( | ||
| 848 | input.documentId, | ||
| 849 | `#/openapi/${input.operation.providerData.toolId}/capability`, | ||
| 850 | ), | ||
| 851 | } satisfies Capability; | ||
| 852 | }; | ||
| 853 | |||
| 854 | export const openApiCatalogOperationFromDefinition = (definition: OpenApiToolDefinition) => { | ||
| 855 | const providerData = openApiProviderDataFromDefinition(definition); | ||
| 856 | const method = definition.method.toUpperCase(); | ||
| 857 | |||
| 858 | return { | ||
| 859 | toolId: definition.toolId, | ||
| 860 | title: definition.name, | ||
| 861 | description: definition.description, | ||
| 862 | effect: | ||
| 863 | method === "GET" || method === "HEAD" | ||
| 864 | ? "read" | ||
| 865 | : method === "DELETE" | ||
| 866 | ? "delete" | ||
| 867 | : "write", | ||
| 868 | inputSchema: definition.inputSchema, | ||
| 869 | outputSchema: definition.outputSchema, | ||
| 870 | providerData, | ||
| 871 | } satisfies OpenApiCatalogOperationInput; | ||
| 872 | }; | ||
| 873 | |||
| 874 | export const createOpenApiCatalogFragment = (input: { | ||
| 875 | source: Source; | ||
| 876 | documents: readonly CatalogSourceDocumentInput[]; | ||
| 877 | operations: readonly OpenApiCatalogOperationInput[]; | ||
| 878 | }) => { | ||
| 879 | const rootSchema = (() => { | ||
| 880 | const primaryDocumentText = input.documents[0]?.contentText; | ||
| 881 | if (!primaryDocumentText) { | ||
| 882 | return undefined; | ||
| 883 | } | ||
| 884 | |||
| 885 | try { | ||
| 886 | return JSON.parse(primaryDocumentText) as unknown; | ||
| 887 | } catch { | ||
| 888 | return undefined; | ||
| 889 | } | ||
| 890 | })(); | ||
| 891 | |||
| 892 | return buildCatalogFragment({ | ||
| 893 | source: input.source, | ||
| 894 | documents: input.documents, | ||
| 895 | resourceDialectUri: "https://json-schema.org/draft/2020-12/schema", | ||
| 896 | serviceScopeDefaults: (() => { | ||
| 897 | const documentServers = openApiServerSpecs( | ||
| 898 | input.operations.find( | ||
| 899 | (operation) => (operation.providerData.documentServers ?? []).length > 0, | ||
| 900 | )?.providerData.documentServers, | ||
| 901 | ); | ||
| 902 | |||
| 903 | return documentServers ? { servers: documentServers } : undefined; | ||
| 904 | })(), | ||
| 905 | registerOperations: ({ catalog, documentId, serviceScopeId, importer }) => { | ||
| 906 | for (const operation of input.operations) { | ||
| 907 | createHttpCapabilityFromOpenApi({ | ||
| 908 | catalog, | ||
| 909 | source: input.source, | ||
| 910 | documentId, | ||
| 911 | serviceScopeId, | ||
| 912 | operation, | ||
| 913 | importer, | ||
| 914 | rootSchema, | ||
| 915 | }); | ||
| 916 | } | ||
| 917 | }, | ||
| 918 | }); | ||
| 919 | }; | ||
| @@ -0,0 +1,249 @@ | |||
| 1 | import type { | ||
| 2 | OpenApiExtractedTool, | ||
| 3 | OpenApiToolDefinition, | ||
| 4 | OpenApiToolManifest, | ||
| 5 | OpenApiToolProviderData, | ||
| 6 | } from "./types"; | ||
| 7 | |||
| 8 | const VERSION_SEGMENT_REGEX = /^v\d+(?:[._-]\d+)?$/i; | ||
| 9 | const IGNORED_PATH_SEGMENTS = new Set(["api"]); | ||
| 10 | |||
| 11 | const splitWords = (value: string): Array<string> => | ||
| 12 | value | ||
| 13 | .replace(/([a-z0-9])([A-Z])/g, "$1 $2") | ||
| 14 | .replace(/([A-Z]+)([A-Z][a-z0-9]+)/g, "$1 $2") | ||
| 15 | .replace(/[^a-zA-Z0-9]+/g, " ") | ||
| 16 | .trim() | ||
| 17 | .split(/\s+/) | ||
| 18 | .filter((part) => part.length > 0); | ||
| 19 | |||
| 20 | const normalizeWord = (value: string): string => value.toLowerCase(); | ||
| 21 | |||
| 22 | const toCamelCase = (value: string): string => { | ||
| 23 | const words = splitWords(value).map(normalizeWord); | ||
| 24 | if (words.length === 0) { | ||
| 25 | return "tool"; | ||
| 26 | } | ||
| 27 | |||
| 28 | const [first, ...rest] = words; | ||
| 29 | return `${first}${rest | ||
| 30 | .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`) | ||
| 31 | .join("")}`; | ||
| 32 | }; | ||
| 33 | |||
| 34 | const toPascalCase = (value: string): string => { | ||
| 35 | const camel = toCamelCase(value); | ||
| 36 | return `${camel[0]?.toUpperCase() ?? ""}${camel.slice(1)}`; | ||
| 37 | }; | ||
| 38 | |||
| 39 | const normalizeGroupSegment = (value: string | undefined): string | null => { | ||
| 40 | const candidate = value?.trim(); | ||
| 41 | if (!candidate) { | ||
| 42 | return null; | ||
| 43 | } | ||
| 44 | |||
| 45 | return toCamelCase(candidate); | ||
| 46 | }; | ||
| 47 | |||
| 48 | const pathSegmentsFromTemplate = (pathTemplate: string): Array<string> => | ||
| 49 | pathTemplate | ||
| 50 | .split("/") | ||
| 51 | .map((segment) => segment.trim()) | ||
| 52 | .filter((segment) => segment.length > 0); | ||
| 53 | |||
| 54 | const isPathParameterSegment = (segment: string): boolean => | ||
| 55 | segment.startsWith("{") && segment.endsWith("}"); | ||
| 56 | |||
| 57 | const deriveVersionSegment = (pathTemplate: string): string | undefined => | ||
| 58 | pathSegmentsFromTemplate(pathTemplate) | ||
| 59 | .map((segment) => segment.toLowerCase()) | ||
| 60 | .find((segment) => VERSION_SEGMENT_REGEX.test(segment)); | ||
| 61 | |||
| 62 | const derivePathGroup = (pathTemplate: string): string => { | ||
| 63 | for (const segment of pathSegmentsFromTemplate(pathTemplate)) { | ||
| 64 | const lower = segment.toLowerCase(); | ||
| 65 | if (VERSION_SEGMENT_REGEX.test(lower)) { | ||
| 66 | continue; | ||
| 67 | } | ||
| 68 | if (IGNORED_PATH_SEGMENTS.has(lower)) { | ||
| 69 | continue; | ||
| 70 | } | ||
| 71 | if (isPathParameterSegment(segment)) { | ||
| 72 | continue; | ||
| 73 | } | ||
| 74 | |||
| 75 | return normalizeGroupSegment(segment) ?? "root"; | ||
| 76 | } | ||
| 77 | |||
| 78 | return "root"; | ||
| 79 | }; | ||
| 80 | |||
| 81 | const splitOperationIdSegments = (value: string): Array<string> => | ||
| 82 | value | ||
| 83 | .split(/[/.]+/) | ||
| 84 | .map((segment) => segment.trim()) | ||
| 85 | .filter((segment) => segment.length > 0); | ||
| 86 | |||
| 87 | const deriveLeafSeed = ( | ||
| 88 | tool: OpenApiExtractedTool, | ||
| 89 | group: string, | ||
| 90 | ): string => { | ||
| 91 | const preferredSource = tool.operationId ?? tool.toolId; | ||
| 92 | const segments = splitOperationIdSegments(preferredSource); | ||
| 93 | if (segments.length > 1) { | ||
| 94 | const [first, ...rest] = segments; | ||
| 95 | if ((normalizeGroupSegment(first) ?? first) === group && rest.length > 0) { | ||
| 96 | return rest.join(" "); | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | return preferredSource; | ||
| 101 | }; | ||
| 102 | |||
| 103 | const fallbackLeafSeed = ( | ||
| 104 | tool: OpenApiExtractedTool, | ||
| 105 | group: string, | ||
| 106 | ): string => { | ||
| 107 | const relevantSegments = pathSegmentsFromTemplate(tool.path) | ||
| 108 | .filter((segment) => !VERSION_SEGMENT_REGEX.test(segment.toLowerCase())) | ||
| 109 | .filter((segment) => !IGNORED_PATH_SEGMENTS.has(segment.toLowerCase())) | ||
| 110 | .filter((segment) => !isPathParameterSegment(segment)) | ||
| 111 | .map((segment) => normalizeGroupSegment(segment) ?? segment) | ||
| 112 | .filter((segment) => segment !== group); | ||
| 113 | |||
| 114 | const segmentSuffix = relevantSegments | ||
| 115 | .map((segment) => toPascalCase(segment)) | ||
| 116 | .join(""); | ||
| 117 | |||
| 118 | return `${tool.method}${segmentSuffix || "Operation"}`; | ||
| 119 | }; | ||
| 120 | |||
| 121 | const deriveLeaf = (tool: OpenApiExtractedTool, group: string): string => { | ||
| 122 | const preferred = toCamelCase(deriveLeafSeed(tool, group)); | ||
| 123 | if (preferred.length > 0 && preferred !== group) { | ||
| 124 | return preferred; | ||
| 125 | } | ||
| 126 | |||
| 127 | return toCamelCase(fallbackLeafSeed(tool, group)); | ||
| 128 | }; | ||
| 129 | |||
| 130 | const defaultDescription = (tool: OpenApiExtractedTool): string => | ||
| 131 | tool.description ?? `${tool.method.toUpperCase()} ${tool.path}`; | ||
| 132 | |||
| 133 | const withResolvedToolIds = ( | ||
| 134 | definitions: Array<Omit<OpenApiToolDefinition, "toolId">>, | ||
| 135 | ): Array<OpenApiToolDefinition> => { | ||
| 136 | const staged = definitions.map((definition) => ({ | ||
| 137 | ...definition, | ||
| 138 | toolId: `${definition.group}.${definition.leaf}`, | ||
| 139 | })); | ||
| 140 | |||
| 141 | const applyCandidates = ( | ||
| 142 | candidates: Array<OpenApiToolDefinition>, | ||
| 143 | factory: (definition: OpenApiToolDefinition) => string, | ||
| 144 | ) => { | ||
| 145 | const byToolId = new Map<string, Array<OpenApiToolDefinition>>(); | ||
| 146 | for (const candidate of candidates) { | ||
| 147 | const bucket = byToolId.get(candidate.toolId) ?? []; | ||
| 148 | bucket.push(candidate); | ||
| 149 | byToolId.set(candidate.toolId, bucket); | ||
| 150 | } | ||
| 151 | |||
| 152 | for (const bucket of byToolId.values()) { | ||
| 153 | if (bucket.length < 2) { | ||
| 154 | continue; | ||
| 155 | } | ||
| 156 | |||
| 157 | for (const definition of bucket) { | ||
| 158 | definition.toolId = factory(definition); | ||
| 159 | } | ||
| 160 | } | ||
| 161 | }; | ||
| 162 | |||
| 163 | applyCandidates(staged, (definition) => | ||
| 164 | definition.versionSegment | ||
| 165 | ? `${definition.group}.${definition.versionSegment}.${definition.leaf}` | ||
| 166 | : definition.toolId, | ||
| 167 | ); | ||
| 168 | |||
| 169 | applyCandidates(staged, (definition) => { | ||
| 170 | const prefix = definition.versionSegment | ||
| 171 | ? `${definition.group}.${definition.versionSegment}` | ||
| 172 | : definition.group; | ||
| 173 | return `${prefix}.${definition.leaf}${toPascalCase(definition.method)}`; | ||
| 174 | }); | ||
| 175 | |||
| 176 | applyCandidates(staged, (definition) => { | ||
| 177 | const prefix = definition.versionSegment | ||
| 178 | ? `${definition.group}.${definition.versionSegment}` | ||
| 179 | : definition.group; | ||
| 180 | return `${prefix}.${definition.leaf}${toPascalCase( | ||
| 181 | definition.method, | ||
| 182 | )}${definition.operationHash.slice(0, 8)}`; | ||
| 183 | }); | ||
| 184 | |||
| 185 | return staged; | ||
| 186 | }; | ||
| 187 | |||
| 188 | export const compileOpenApiToolDefinitions = ( | ||
| 189 | manifest: OpenApiToolManifest, | ||
| 190 | ): Array<OpenApiToolDefinition> => { | ||
| 191 | const definitions = manifest.tools.map((tool) => { | ||
| 192 | const group = | ||
| 193 | normalizeGroupSegment(tool.tags[0]) ?? derivePathGroup(tool.path); | ||
| 194 | const leaf = deriveLeaf(tool, group); | ||
| 195 | |||
| 196 | return { | ||
| 197 | rawToolId: tool.toolId, | ||
| 198 | operationId: tool.operationId, | ||
| 199 | name: tool.name, | ||
| 200 | description: defaultDescription(tool), | ||
| 201 | group, | ||
| 202 | leaf, | ||
| 203 | tags: [...tool.tags], | ||
| 204 | versionSegment: deriveVersionSegment(tool.path), | ||
| 205 | method: tool.method, | ||
| 206 | path: tool.path, | ||
| 207 | invocation: tool.invocation, | ||
| 208 | operationHash: tool.operationHash, | ||
| 209 | inputSchema: tool.inputSchema, | ||
| 210 | outputSchema: tool.outputSchema, | ||
| 211 | documentation: tool.documentation, | ||
| 212 | responses: tool.responses, | ||
| 213 | authRequirement: tool.authRequirement, | ||
| 214 | securitySchemes: tool.securitySchemes, | ||
| 215 | documentServers: tool.documentServers, | ||
| 216 | servers: tool.servers, | ||
| 217 | } satisfies Omit<OpenApiToolDefinition, "toolId">; | ||
| 218 | }); | ||
| 219 | |||
| 220 | return withResolvedToolIds(definitions).sort( | ||
| 221 | (left, right) => | ||
| 222 | left.toolId.localeCompare(right.toolId) || | ||
| 223 | left.rawToolId.localeCompare(right.rawToolId) || | ||
| 224 | left.operationHash.localeCompare(right.operationHash), | ||
| 225 | ); | ||
| 226 | }; | ||
| 227 | |||
| 228 | export const openApiProviderDataFromDefinition = ( | ||
| 229 | definition: OpenApiToolDefinition, | ||
| 230 | ): OpenApiToolProviderData => ({ | ||
| 231 | kind: "openapi", | ||
| 232 | toolId: definition.toolId, | ||
| 233 | rawToolId: definition.rawToolId, | ||
| 234 | operationId: definition.operationId, | ||
| 235 | group: definition.group, | ||
| 236 | leaf: definition.leaf, | ||
| 237 | tags: definition.tags, | ||
| 238 | versionSegment: definition.versionSegment, | ||
| 239 | method: definition.method, | ||
| 240 | path: definition.path, | ||
| 241 | operationHash: definition.operationHash, | ||
| 242 | invocation: definition.invocation, | ||
| 243 | documentation: definition.documentation, | ||
| 244 | responses: definition.responses, | ||
| 245 | authRequirement: definition.authRequirement, | ||
| 246 | securitySchemes: definition.securitySchemes, | ||
| 247 | documentServers: definition.documentServers, | ||
| 248 | servers: definition.servers, | ||
| 249 | }); | ||
| @@ -0,0 +1,32 @@ | |||
| 1 | import YAML from "yaml"; | ||
| 2 | |||
| 3 | import type { OpenApiJsonObject } from "./types"; | ||
| 4 | |||
| 5 | const isOpenApiJsonObject = (value: unknown): value is OpenApiJsonObject => | ||
| 6 | typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 7 | |||
| 8 | const parseJsonDocument = (input: string): unknown => JSON.parse(input); | ||
| 9 | |||
| 10 | const parseYamlDocument = (input: string): unknown => YAML.parse(input); | ||
| 11 | |||
| 12 | const parseDocument = (input: string): unknown => { | ||
| 13 | try { | ||
| 14 | return parseJsonDocument(input); | ||
| 15 | } catch { | ||
| 16 | return parseYamlDocument(input); | ||
| 17 | } | ||
| 18 | }; | ||
| 19 | |||
| 20 | export const parseOpenApiDocument = (input: string): OpenApiJsonObject => { | ||
| 21 | const text = input.trim(); | ||
| 22 | if (text.length === 0) { | ||
| 23 | throw new Error("OpenAPI document is empty"); | ||
| 24 | } | ||
| 25 | |||
| 26 | const parsed = parseDocument(text); | ||
| 27 | if (!isOpenApiJsonObject(parsed)) { | ||
| 28 | throw new Error("OpenAPI document must parse to an object"); | ||
| 29 | } | ||
| 30 | |||
| 31 | return parsed; | ||
| 32 | }; | ||
| @@ -0,0 +1,886 @@ | |||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | |||
| 3 | import { contentHash, stableHash } from "@executor/source-core"; | ||
| 4 | |||
| 5 | import { parseOpenApiDocument } from "./document"; | ||
| 6 | import type { | ||
| 7 | OpenApiExample, | ||
| 8 | OpenApiExtractedTool, | ||
| 9 | OpenApiHeader, | ||
| 10 | OpenApiJsonObject, | ||
| 11 | OpenApiMediaContent, | ||
| 12 | OpenApiResponseVariant, | ||
| 13 | OpenApiSecurityRequirement, | ||
| 14 | OpenApiSecurityScheme, | ||
| 15 | OpenApiServer, | ||
| 16 | OpenApiToolDocumentation, | ||
| 17 | OpenApiToolManifest, | ||
| 18 | OpenApiToolParameter, | ||
| 19 | OpenApiToolRequestBody, | ||
| 20 | } from "./types"; | ||
| 21 | import { OPEN_API_HTTP_METHODS, type OpenApiHttpMethod } from "./types"; | ||
| 22 | |||
| 23 | const asObject = (value: unknown): Record<string, unknown> => | ||
| 24 | value !== null && typeof value === "object" && !Array.isArray(value) | ||
| 25 | ? (value as Record<string, unknown>) | ||
| 26 | : {}; | ||
| 27 | |||
| 28 | const asArray = (value: unknown): ReadonlyArray<unknown> => | ||
| 29 | Array.isArray(value) ? value : []; | ||
| 30 | |||
| 31 | const asTrimmedString = (value: unknown): string | undefined => | ||
| 32 | typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; | ||
| 33 | |||
| 34 | const stableJsonValue = (value: unknown): unknown => { | ||
| 35 | if (Array.isArray(value)) { | ||
| 36 | return value.map((entry) => stableJsonValue(entry)); | ||
| 37 | } | ||
| 38 | |||
| 39 | if (value !== null && typeof value === "object") { | ||
| 40 | return Object.fromEntries( | ||
| 41 | Object.entries(value as Record<string, unknown>) | ||
| 42 | .sort(([left], [right]) => left.localeCompare(right)) | ||
| 43 | .map(([key, entry]) => [key, stableJsonValue(entry)]), | ||
| 44 | ); | ||
| 45 | } | ||
| 46 | |||
| 47 | return value; | ||
| 48 | }; | ||
| 49 | |||
| 50 | const stableJsonStringify = (value: unknown): string => | ||
| 51 | JSON.stringify(stableJsonValue(value)); | ||
| 52 | |||
| 53 | const resolvePointerSegment = (segment: string): string => | ||
| 54 | segment.replaceAll("~1", "/").replaceAll("~0", "~"); | ||
| 55 | |||
| 56 | const resolveLocalRef = ( | ||
| 57 | document: OpenApiJsonObject, | ||
| 58 | value: unknown, | ||
| 59 | activeRefs: ReadonlySet<string> = new Set<string>(), | ||
| 60 | ): unknown => { | ||
| 61 | const object = asObject(value); | ||
| 62 | const ref = typeof object.$ref === "string" ? object.$ref : null; | ||
| 63 | if (!ref || !ref.startsWith("#/") || activeRefs.has(ref)) { | ||
| 64 | return value; | ||
| 65 | } | ||
| 66 | |||
| 67 | const resolved = ref | ||
| 68 | .slice(2) | ||
| 69 | .split("/") | ||
| 70 | .reduce<unknown>((current, segment) => { | ||
| 71 | if (current === undefined || current === null) { | ||
| 72 | return undefined; | ||
| 73 | } | ||
| 74 | |||
| 75 | return asObject(current)[resolvePointerSegment(segment)]; | ||
| 76 | }, document); | ||
| 77 | |||
| 78 | if (resolved === undefined) { | ||
| 79 | return value; | ||
| 80 | } | ||
| 81 | |||
| 82 | const nextActiveRefs = new Set(activeRefs); | ||
| 83 | nextActiveRefs.add(ref); | ||
| 84 | |||
| 85 | const resolvedObject = asObject( | ||
| 86 | resolveLocalRef(document, resolved, nextActiveRefs), | ||
| 87 | ); | ||
| 88 | const { $ref: _ignoredRef, ...rest } = object; | ||
| 89 | |||
| 90 | return Object.keys(rest).length > 0 ? { ...resolvedObject, ...rest } : resolvedObject; | ||
| 91 | }; | ||
| 92 | |||
| 93 | const preferredContentEntry = ( | ||
| 94 | content: unknown, | ||
| 95 | ): readonly [string, Record<string, unknown>] | undefined => { | ||
| 96 | const entries = Object.entries(asObject(content)) | ||
| 97 | .sort(([left], [right]) => left.localeCompare(right)) | ||
| 98 | .map(([mediaType, value]) => [mediaType, asObject(value)] as const); | ||
| 99 | |||
| 100 | return ( | ||
| 101 | entries.find(([mediaType]) => mediaType === "application/json") ?? | ||
| 102 | entries.find(([mediaType]) => mediaType.toLowerCase().includes("+json")) ?? | ||
| 103 | entries.find(([mediaType]) => mediaType.toLowerCase().includes("json")) ?? | ||
| 104 | entries[0] | ||
| 105 | ); | ||
| 106 | }; | ||
| 107 | |||
| 108 | const contentSchemaFromOperationContent = ( | ||
| 109 | document: OpenApiJsonObject, | ||
| 110 | content: unknown, | ||
| 111 | ): unknown | undefined => { | ||
| 112 | const preferred = preferredContentEntry(content); | ||
| 113 | return preferred?.[1].schema === undefined | ||
| 114 | ? undefined | ||
| 115 | : resolveLocalRef(document, preferred[1].schema); | ||
| 116 | }; | ||
| 117 | |||
| 118 | const examplesFromValue = ( | ||
| 119 | value: unknown, | ||
| 120 | input: { | ||
| 121 | label?: string; | ||
| 122 | mediaType?: string; | ||
| 123 | } = {}, | ||
| 124 | ): Array<OpenApiExample> => { | ||
| 125 | const record = asObject(value); | ||
| 126 | const examples: Array<OpenApiExample> = []; | ||
| 127 | |||
| 128 | if (record.example !== undefined) { | ||
| 129 | examples.push({ | ||
| 130 | valueJson: stableJsonStringify(record.example), | ||
| 131 | ...(input.mediaType ? { mediaType: input.mediaType } : {}), | ||
| 132 | ...(input.label ? { label: input.label } : {}), | ||
| 133 | }); | ||
| 134 | } | ||
| 135 | |||
| 136 | const exampleEntries = Object.entries(asObject(record.examples)).sort(([left], [right]) => | ||
| 137 | left.localeCompare(right), | ||
| 138 | ); | ||
| 139 | for (const [key, exampleValue] of exampleEntries) { | ||
| 140 | const exampleRecord = asObject(exampleValue); | ||
| 141 | examples.push({ | ||
| 142 | valueJson: stableJsonStringify( | ||
| 143 | exampleRecord.value !== undefined ? exampleRecord.value : exampleValue, | ||
| 144 | ), | ||
| 145 | ...(input.mediaType ? { mediaType: input.mediaType } : {}), | ||
| 146 | label: key, | ||
| 147 | }); | ||
| 148 | } | ||
| 149 | |||
| 150 | return examples; | ||
| 151 | }; | ||
| 152 | |||
| 153 | const examplesFromSchema = (schema: unknown): Array<OpenApiExample> => | ||
| 154 | examplesFromValue(schema); | ||
| 155 | |||
| 156 | const examplesFromMediaType = ( | ||
| 157 | mediaType: string, | ||
| 158 | mediaTypeRecord: Record<string, unknown>, | ||
| 159 | ): Array<OpenApiExample> => { | ||
| 160 | const direct = examplesFromValue(mediaTypeRecord, { mediaType }); | ||
| 161 | if (direct.length > 0) { | ||
| 162 | return direct; | ||
| 163 | } | ||
| 164 | |||
| 165 | return examplesFromSchema(mediaTypeRecord.schema).map((example) => ({ | ||
| 166 | ...example, | ||
| 167 | mediaType, | ||
| 168 | })); | ||
| 169 | }; | ||
| 170 | |||
| 171 | const contentEntriesFromContent = ( | ||
| 172 | document: OpenApiJsonObject, | ||
| 173 | content: unknown, | ||
| 174 | ): ReadonlyArray<OpenApiMediaContent> => | ||
| 175 | Object.entries(asObject(content)) | ||
| 176 | .sort(([left], [right]) => left.localeCompare(right)) | ||
| 177 | .map(([mediaType, mediaValue]) => { | ||
| 178 | const mediaRecord = asObject(resolveLocalRef(document, mediaValue)); | ||
| 179 | const examples = examplesFromMediaType(mediaType, mediaRecord); | ||
| 180 | |||
| 181 | return { | ||
| 182 | mediaType, | ||
| 183 | ...(mediaRecord.schema !== undefined | ||
| 184 | ? { schema: resolveLocalRef(document, mediaRecord.schema) } | ||
| 185 | : {}), | ||
| 186 | ...(examples.length > 0 ? { examples } : {}), | ||
| 187 | }; | ||
| 188 | }); | ||
| 189 | |||
| 190 | const headerFromValue = ( | ||
| 191 | document: OpenApiJsonObject, | ||
| 192 | name: string, | ||
| 193 | value: unknown, | ||
| 194 | ): OpenApiHeader | undefined => { | ||
| 195 | const header = asObject(resolveLocalRef(document, value)); | ||
| 196 | if (Object.keys(header).length === 0) { | ||
| 197 | return undefined; | ||
| 198 | } | ||
| 199 | |||
| 200 | const content = contentEntriesFromContent(document, header.content); | ||
| 201 | const directExamples = content.length > 0 ? [] : examplesFromValue(header); | ||
| 202 | |||
| 203 | return { | ||
| 204 | name, | ||
| 205 | ...(asTrimmedString(header.description) | ||
| 206 | ? { description: asTrimmedString(header.description) } | ||
| 207 | : {}), | ||
| 208 | ...(typeof header.required === "boolean" ? { required: header.required } : {}), | ||
| 209 | ...(typeof header.deprecated === "boolean" | ||
| 210 | ? { deprecated: header.deprecated } | ||
| 211 | : {}), | ||
| 212 | ...(header.schema !== undefined | ||
| 213 | ? { schema: resolveLocalRef(document, header.schema) } | ||
| 214 | : {}), | ||
| 215 | ...(content.length > 0 ? { content } : {}), | ||
| 216 | ...(asTrimmedString(header.style) ? { style: asTrimmedString(header.style) } : {}), | ||
| 217 | ...(typeof header.explode === "boolean" ? { explode: header.explode } : {}), | ||
| 218 | ...(directExamples.length > 0 ? { examples: directExamples } : {}), | ||
| 219 | }; | ||
| 220 | }; | ||
| 221 | |||
| 222 | const headersFromValue = ( | ||
| 223 | document: OpenApiJsonObject, | ||
| 224 | value: unknown, | ||
| 225 | ): ReadonlyArray<OpenApiHeader> => | ||
| 226 | Object.entries(asObject(value)) | ||
| 227 | .sort(([left], [right]) => left.localeCompare(right)) | ||
| 228 | .flatMap(([name, headerValue]) => { | ||
| 229 | const header = headerFromValue(document, name, headerValue); | ||
| 230 | return header ? [header] : []; | ||
| 231 | }); | ||
| 232 | |||
| 233 | const serversFromValue = (value: unknown): ReadonlyArray<OpenApiServer> => | ||
| 234 | asArray(value) | ||
| 235 | .map((entry) => asObject(entry)) | ||
| 236 | .flatMap((server) => { | ||
| 237 | const url = asTrimmedString(server.url); | ||
| 238 | if (!url) { | ||
| 239 | return []; | ||
| 240 | } | ||
| 241 | |||
| 242 | const variables = Object.fromEntries( | ||
| 243 | Object.entries(asObject(server.variables)) | ||
| 244 | .sort(([left], [right]) => left.localeCompare(right)) | ||
| 245 | .flatMap(([name, variableValue]) => { | ||
| 246 | const variableRecord = asObject(variableValue); | ||
| 247 | const defaultValue = asTrimmedString(variableRecord.default); | ||
| 248 | return defaultValue ? [[name, defaultValue] as const] : []; | ||
| 249 | }), | ||
| 250 | ); | ||
| 251 | |||
| 252 | return [ | ||
| 253 | { | ||
| 254 | url, | ||
| 255 | ...(asTrimmedString(server.description) | ||
| 256 | ? { description: asTrimmedString(server.description) } | ||
| 257 | : {}), | ||
| 258 | ...(Object.keys(variables).length > 0 ? { variables } : {}), | ||
| 259 | }, | ||
| 260 | ]; | ||
| 261 | }); | ||
| 262 | |||
| 263 | const responseStatusRank = (statusCode: string): number => { | ||
| 264 | if (/^2\\d\\d$/.test(statusCode)) { | ||
| 265 | return 0; | ||
| 266 | } | ||
| 267 | |||
| 268 | if (statusCode === "default") { | ||
| 269 | return 1; | ||
| 270 | } | ||
| 271 | |||
| 272 | return 2; | ||
| 273 | }; | ||
| 274 | |||
| 275 | const operationFor = ( | ||
| 276 | document: OpenApiJsonObject, | ||
| 277 | pathTemplate: string, | ||
| 278 | method: OpenApiHttpMethod, | ||
| 279 | ): Record<string, unknown> => | ||
| 280 | asObject(asObject(asObject(document.paths)[pathTemplate])[method]); | ||
| 281 | |||
| 282 | const pathItemFor = ( | ||
| 283 | document: OpenApiJsonObject, | ||
| 284 | pathTemplate: string, | ||
| 285 | ): Record<string, unknown> => asObject(asObject(document.paths)[pathTemplate]); | ||
| 286 | |||
| 287 | const parameterKey = (input: { | ||
| 288 | location: string; | ||
| 289 | name: string; | ||
| 290 | }): string => `${input.location}:${input.name}`; | ||
| 291 | |||
| 292 | const mergedParameterRecords = ( | ||
| 293 | document: OpenApiJsonObject, | ||
| 294 | pathTemplate: string, | ||
| 295 | method: OpenApiHttpMethod, | ||
| 296 | ): ReadonlyMap<string, Record<string, unknown>> => { | ||
| 297 | const merged = new Map<string, Record<string, unknown>>(); | ||
| 298 | const pathItem = pathItemFor(document, pathTemplate); | ||
| 299 | const operation = operationFor(document, pathTemplate, method); | ||
| 300 | |||
| 301 | for (const parameterValue of asArray(pathItem.parameters)) { | ||
| 302 | const parameter = asObject(resolveLocalRef(document, parameterValue)); | ||
| 303 | const name = asTrimmedString(parameter.name); | ||
| 304 | const location = asTrimmedString(parameter.in); | ||
| 305 | if (!name || !location) { | ||
| 306 | continue; | ||
| 307 | } | ||
| 308 | |||
| 309 | merged.set(parameterKey({ location, name }), parameter); | ||
| 310 | } | ||
| 311 | |||
| 312 | for (const parameterValue of asArray(operation.parameters)) { | ||
| 313 | const parameter = asObject(resolveLocalRef(document, parameterValue)); | ||
| 314 | const name = asTrimmedString(parameter.name); | ||
| 315 | const location = asTrimmedString(parameter.in); | ||
| 316 | if (!name || !location) { | ||
| 317 | continue; | ||
| 318 | } | ||
| 319 | |||
| 320 | merged.set(parameterKey({ location, name }), parameter); | ||
| 321 | } | ||
| 322 | |||
| 323 | return merged; | ||
| 324 | }; | ||
| 325 | |||
| 326 | const requestBodyPayloadFor = ( | ||
| 327 | document: OpenApiJsonObject, | ||
| 328 | pathTemplate: string, | ||
| 329 | method: OpenApiHttpMethod, | ||
| 330 | ): OpenApiToolRequestBody | null => { | ||
| 331 | const operation = operationFor(document, pathTemplate, method); | ||
| 332 | const requestBody = asObject(resolveLocalRef(document, operation.requestBody)); | ||
| 333 | if (Object.keys(requestBody).length === 0) { | ||
| 334 | return null; | ||
| 335 | } | ||
| 336 | |||
| 337 | const contents = contentEntriesFromContent(document, requestBody.content); | ||
| 338 | const contentTypes = contents.map((content) => content.mediaType); | ||
| 339 | |||
| 340 | return { | ||
| 341 | required: | ||
| 342 | typeof requestBody.required === "boolean" ? requestBody.required : false, | ||
| 343 | contentTypes, | ||
| 344 | ...(contents.length > 0 ? { contents } : {}), | ||
| 345 | }; | ||
| 346 | }; | ||
| 347 | |||
| 348 | const responseSchemaFor = ( | ||
| 349 | document: OpenApiJsonObject, | ||
| 350 | pathTemplate: string, | ||
| 351 | method: OpenApiHttpMethod, | ||
| 352 | ): unknown | undefined => { | ||
| 353 | const operation = operationFor(document, pathTemplate, method); | ||
| 354 | const responseEntries = Object.entries(asObject(operation.responses)); | ||
| 355 | const preferredResponses = responseEntries | ||
| 356 | .filter(([status]) => /^2\\d\\d$/.test(status)) | ||
| 357 | .sort(([left], [right]) => left.localeCompare(right)); | ||
| 358 | const fallbackResponses = responseEntries.filter(([status]) => status === "default"); | ||
| 359 | |||
| 360 | for (const [, responseValue] of [...preferredResponses, ...fallbackResponses]) { | ||
| 361 | const response = resolveLocalRef(document, responseValue); | ||
| 362 | const schema = contentSchemaFromOperationContent(document, asObject(response).content); | ||
| 363 | if (schema !== undefined) { | ||
| 364 | return schema; | ||
| 365 | } | ||
| 366 | } | ||
| 367 | |||
| 368 | return undefined; | ||
| 369 | }; | ||
| 370 | |||
| 371 | const responseVariantsFor = ( | ||
| 372 | document: OpenApiJsonObject, | ||
| 373 | pathTemplate: string, | ||
| 374 | method: OpenApiHttpMethod, | ||
| 375 | ): OpenApiResponseVariant[] | undefined => { | ||
| 376 | const operation = operationFor(document, pathTemplate, method); | ||
| 377 | const responseEntries = Object.entries(asObject(operation.responses)).sort( | ||
| 378 | ([left], [right]) => | ||
| 379 | responseStatusRank(left) - responseStatusRank(right) || | ||
| 380 | left.localeCompare(right), | ||
| 381 | ); | ||
| 382 | |||
| 383 | const responses = responseEntries.map(([statusCode, responseValue]) => { | ||
| 384 | const response = asObject(resolveLocalRef(document, responseValue)); | ||
| 385 | const contents = contentEntriesFromContent(document, response.content); | ||
| 386 | const contentTypes = contents.map((content) => content.mediaType); | ||
| 387 | const preferredContent = preferredContentEntry(response.content); | ||
| 388 | const examples = preferredContent | ||
| 389 | ? examplesFromMediaType(preferredContent[0], preferredContent[1]) | ||
| 390 | : []; | ||
| 391 | const headers = headersFromValue(document, response.headers); | ||
| 392 | |||
| 393 | return { | ||
| 394 | statusCode, | ||
| 395 | ...(asTrimmedString(response.description) | ||
| 396 | ? { description: asTrimmedString(response.description) } | ||
| 397 | : {}), | ||
| 398 | contentTypes, | ||
| 399 | ...(contentSchemaFromOperationContent(document, response.content) !== undefined | ||
| 400 | ? { schema: contentSchemaFromOperationContent(document, response.content) } | ||
| 401 | : {}), | ||
| 402 | ...(examples.length > 0 ? { examples } : {}), | ||
| 403 | ...(contents.length > 0 ? { contents } : {}), | ||
| 404 | ...(headers.length > 0 ? { headers } : {}), | ||
| 405 | }; | ||
| 406 | }); | ||
| 407 | |||
| 408 | return responses.length > 0 ? responses : undefined; | ||
| 409 | }; | ||
| 410 | |||
| 411 | const securityRequirementFromValue = ( | ||
| 412 | value: unknown, | ||
| 413 | ): OpenApiSecurityRequirement | undefined => { | ||
| 414 | const requirementEntries = asArray(value); | ||
| 415 | if (requirementEntries.length === 0) { | ||
| 416 | return { kind: "none" }; | ||
| 417 | } | ||
| 418 | |||
| 419 | const anyOfItems = requirementEntries.flatMap((entry) => { | ||
| 420 | const schemes = Object.entries(asObject(entry)) | ||
| 421 | .sort(([left], [right]) => left.localeCompare(right)) | ||
| 422 | .map(([schemeName, rawScopes]) => { | ||
| 423 | const scopes = asArray(rawScopes).flatMap((scope) => | ||
| 424 | typeof scope === "string" && scope.trim().length > 0 ? [scope.trim()] : [], | ||
| 425 | ); | ||
| 426 | |||
| 427 | return { | ||
| 428 | kind: "scheme" as const, | ||
| 429 | schemeName, | ||
| 430 | ...(scopes.length > 0 ? { scopes } : {}), | ||
| 431 | }; | ||
| 432 | }); | ||
| 433 | |||
| 434 | if (schemes.length === 0) { | ||
| 435 | return []; | ||
| 436 | } | ||
| 437 | |||
| 438 | return [ | ||
| 439 | schemes.length === 1 | ||
| 440 | ? schemes[0]! | ||
| 441 | : { | ||
| 442 | kind: "allOf" as const, | ||
| 443 | items: schemes, | ||
| 444 | }, | ||
| 445 | ]; | ||
| 446 | }); | ||
| 447 | |||
| 448 | if (anyOfItems.length === 0) { | ||
| 449 | return undefined; | ||
| 450 | } | ||
| 451 | |||
| 452 | return anyOfItems.length === 1 | ||
| 453 | ? anyOfItems[0] | ||
| 454 | : { | ||
| 455 | kind: "anyOf", | ||
| 456 | items: anyOfItems, | ||
| 457 | }; | ||
| 458 | }; | ||
| 459 | |||
| 460 | const authRequirementFor = ( | ||
| 461 | document: OpenApiJsonObject, | ||
| 462 | pathTemplate: string, | ||
| 463 | method: OpenApiHttpMethod, | ||
| 464 | ): OpenApiSecurityRequirement | undefined => { | ||
| 465 | const operation = operationFor(document, pathTemplate, method); | ||
| 466 | if (Object.prototype.hasOwnProperty.call(operation, "security")) { | ||
| 467 | return securityRequirementFromValue(operation.security); | ||
| 468 | } | ||
| 469 | |||
| 470 | return securityRequirementFromValue(document.security); | ||
| 471 | }; | ||
| 472 | |||
| 473 | const collectReferencedSecuritySchemeNames = ( | ||
| 474 | authRequirement: OpenApiSecurityRequirement | undefined, | ||
| 475 | names: Set<string>, | ||
| 476 | ): void => { | ||
| 477 | if (!authRequirement) { | ||
| 478 | return; | ||
| 479 | } | ||
| 480 | |||
| 481 | switch (authRequirement.kind) { | ||
| 482 | case "none": | ||
| 483 | return; | ||
| 484 | case "scheme": | ||
| 485 | names.add(authRequirement.schemeName); | ||
| 486 | return; | ||
| 487 | case "allOf": | ||
| 488 | case "anyOf": | ||
| 489 | for (const item of authRequirement.items) { | ||
| 490 | collectReferencedSecuritySchemeNames(item, names); | ||
| 491 | } | ||
| 492 | } | ||
| 493 | }; | ||
| 494 | |||
| 495 | const oauthFlowRecord = ( | ||
| 496 | value: unknown, | ||
| 497 | ): | ||
| 498 | | Record< | ||
| 499 | string, | ||
| 500 | { | ||
| 501 | authorizationUrl?: string; | ||
| 502 | tokenUrl?: string; | ||
| 503 | refreshUrl?: string; | ||
| 504 | scopes?: Record<string, string>; | ||
| 505 | } | ||
| 506 | > | ||
| 507 | | undefined => { | ||
| 508 | const result = Object.fromEntries( | ||
| 509 | Object.entries(asObject(value)) | ||
| 510 | .sort(([left], [right]) => left.localeCompare(right)) | ||
| 511 | .map(([flowName, flowValue]) => { | ||
| 512 | const flowRecord = asObject(flowValue); | ||
| 513 | const scopes = Object.fromEntries( | ||
| 514 | Object.entries(asObject(flowRecord.scopes)) | ||
| 515 | .sort(([left], [right]) => left.localeCompare(right)) | ||
| 516 | .map(([scope, description]) => [scope, asTrimmedString(description) ?? ""]), | ||
| 517 | ); | ||
| 518 | |||
| 519 | return [ | ||
| 520 | flowName, | ||
| 521 | { | ||
| 522 | ...(asTrimmedString(flowRecord.authorizationUrl) | ||
| 523 | ? { authorizationUrl: asTrimmedString(flowRecord.authorizationUrl) } | ||
| 524 | : {}), | ||
| 525 | ...(asTrimmedString(flowRecord.tokenUrl) | ||
| 526 | ? { tokenUrl: asTrimmedString(flowRecord.tokenUrl) } | ||
| 527 | : {}), | ||
| 528 | ...(asTrimmedString(flowRecord.refreshUrl) | ||
| 529 | ? { refreshUrl: asTrimmedString(flowRecord.refreshUrl) } | ||
| 530 | : {}), | ||
| 531 | ...(Object.keys(scopes).length > 0 ? { scopes } : {}), | ||
| 532 | }, | ||
| 533 | ]; | ||
| 534 | }), | ||
| 535 | ); | ||
| 536 | |||
| 537 | return Object.keys(result).length > 0 ? result : undefined; | ||
| 538 | }; | ||
| 539 | |||
| 540 | const securitySchemesFor = ( | ||
| 541 | document: OpenApiJsonObject, | ||
| 542 | authRequirement: OpenApiSecurityRequirement | undefined, | ||
| 543 | ): OpenApiSecurityScheme[] | undefined => { | ||
| 544 | if (!authRequirement || authRequirement.kind === "none") { | ||
| 545 | return undefined; | ||
| 546 | } | ||
| 547 | |||
| 548 | const schemeNames = new Set<string>(); | ||
| 549 | collectReferencedSecuritySchemeNames(authRequirement, schemeNames); | ||
| 550 | |||
| 551 | const securitySchemes = asObject(asObject(document.components).securitySchemes); | ||
| 552 | const resolved = [...schemeNames] | ||
| 553 | .sort((left, right) => left.localeCompare(right)) | ||
| 554 | .flatMap((schemeName) => { | ||
| 555 | const rawScheme = securitySchemes[schemeName]; | ||
| 556 | if (rawScheme === undefined) { | ||
| 557 | return []; | ||
| 558 | } | ||
| 559 | |||
| 560 | const scheme = asObject(resolveLocalRef(document, rawScheme)); | ||
| 561 | const schemeType = asTrimmedString(scheme.type); | ||
| 562 | if (!schemeType) { | ||
| 563 | return []; | ||
| 564 | } | ||
| 565 | |||
| 566 | const normalizedSchemeType: "apiKey" | "http" | "oauth2" | "openIdConnect" = | ||
| 567 | schemeType === "apiKey" || | ||
| 568 | schemeType === "http" || | ||
| 569 | schemeType === "oauth2" || | ||
| 570 | schemeType === "openIdConnect" | ||
| 571 | ? schemeType | ||
| 572 | : "http"; | ||
| 573 | |||
| 574 | const placementIn = asTrimmedString(scheme.in); | ||
| 575 | const normalizedPlacementIn: "header" | "query" | "cookie" | undefined = | ||
| 576 | placementIn === "header" || placementIn === "query" || placementIn === "cookie" | ||
| 577 | ? placementIn | ||
| 578 | : undefined; | ||
| 579 | |||
| 580 | return [ | ||
| 581 | { | ||
| 582 | schemeName, | ||
| 583 | schemeType: normalizedSchemeType, | ||
| 584 | ...(asTrimmedString(scheme.description) | ||
| 585 | ? { description: asTrimmedString(scheme.description) } | ||
| 586 | : {}), | ||
| 587 | ...(normalizedPlacementIn ? { placementIn: normalizedPlacementIn } : {}), | ||
| 588 | ...(asTrimmedString(scheme.name) | ||
| 589 | ? { placementName: asTrimmedString(scheme.name) } | ||
| 590 | : {}), | ||
| 591 | ...(asTrimmedString(scheme.scheme) | ||
| 592 | ? { scheme: asTrimmedString(scheme.scheme) } | ||
| 593 | : {}), | ||
| 594 | ...(asTrimmedString(scheme.bearerFormat) | ||
| 595 | ? { bearerFormat: asTrimmedString(scheme.bearerFormat) } | ||
| 596 | : {}), | ||
| 597 | ...(asTrimmedString(scheme.openIdConnectUrl) | ||
| 598 | ? { openIdConnectUrl: asTrimmedString(scheme.openIdConnectUrl) } | ||
| 599 | : {}), | ||
| 600 | ...(oauthFlowRecord(scheme.flows) ? { flows: oauthFlowRecord(scheme.flows) } : {}), | ||
| 601 | }, | ||
| 602 | ]; | ||
| 603 | }); | ||
| 604 | |||
| 605 | return resolved.length > 0 ? resolved : undefined; | ||
| 606 | }; | ||
| 607 | |||
| 608 | const buildInputSchema = (input: { | ||
| 609 | parameters: ReadonlyArray<OpenApiToolParameter>; | ||
| 610 | requestBody: OpenApiToolRequestBody | null; | ||
| 611 | }): Record<string, unknown> | undefined => { | ||
| 612 | const properties: Record<string, unknown> = {}; | ||
| 613 | const required: string[] = []; | ||
| 614 | |||
| 615 | for (const parameter of input.parameters) { | ||
| 616 | const preferredContent = parameter.content?.[0]?.schema; | ||
| 617 | properties[parameter.name] = preferredContent ?? { type: "string" }; | ||
| 618 | if (parameter.required) { | ||
| 619 | required.push(parameter.name); | ||
| 620 | } | ||
| 621 | } | ||
| 622 | |||
| 623 | if (input.requestBody) { | ||
| 624 | properties.body = | ||
| 625 | input.requestBody.contents?.[0]?.schema ?? { | ||
| 626 | type: "object", | ||
| 627 | }; | ||
| 628 | if (input.requestBody.required) { | ||
| 629 | required.push("body"); | ||
| 630 | } | ||
| 631 | } | ||
| 632 | |||
| 633 | return Object.keys(properties).length > 0 | ||
| 634 | ? { | ||
| 635 | type: "object", | ||
| 636 | properties, | ||
| 637 | ...(required.length > 0 ? { required } : {}), | ||
| 638 | additionalProperties: false, | ||
| 639 | } | ||
| 640 | : undefined; | ||
| 641 | }; | ||
| 642 | |||
| 643 | const buildDocumentation = (input: { | ||
| 644 | operation: Record<string, unknown>; | ||
| 645 | parameters: ReadonlyArray<OpenApiToolParameter>; | ||
| 646 | requestBody: OpenApiToolRequestBody | null; | ||
| 647 | responses: ReadonlyArray<OpenApiResponseVariant> | undefined; | ||
| 648 | }): OpenApiToolDocumentation => { | ||
| 649 | const parameterDocs = input.parameters.map((parameter) => ({ | ||
| 650 | name: parameter.name, | ||
| 651 | location: parameter.location, | ||
| 652 | required: parameter.required, | ||
| 653 | ...(asTrimmedString( | ||
| 654 | (parameter as unknown as Record<string, unknown>).description, | ||
| 655 | ) | ||
| 656 | ? { | ||
| 657 | description: asTrimmedString( | ||
| 658 | (parameter as unknown as Record<string, unknown>).description, | ||
| 659 | ), | ||
| 660 | } | ||
| 661 | : {}), | ||
| 662 | ...(parameter.content?.[0]?.examples && parameter.content[0].examples.length > 0 | ||
| 663 | ? { examples: parameter.content[0].examples } | ||
| 664 | : {}), | ||
| 665 | })); | ||
| 666 | |||
| 667 | const preferredResponse = | ||
| 668 | input.responses?.find((response) => /^2\\d\\d$/.test(response.statusCode)) ?? | ||
| 669 | input.responses?.find((response) => response.statusCode === "default") ?? | ||
| 670 | input.responses?.[0]; | ||
| 671 | |||
| 672 | return { | ||
| 673 | ...(asTrimmedString(input.operation.summary) | ||
| 674 | ? { summary: asTrimmedString(input.operation.summary) } | ||
| 675 | : {}), | ||
| 676 | ...(typeof input.operation.deprecated === "boolean" | ||
| 677 | ? { deprecated: input.operation.deprecated } | ||
| 678 | : {}), | ||
| 679 | parameters: parameterDocs, | ||
| 680 | ...(input.requestBody | ||
| 681 | ? { | ||
| 682 | requestBody: { | ||
| 683 | ...(asTrimmedString(asObject(input.operation.requestBody).description) | ||
| 684 | ? { | ||
| 685 | description: asTrimmedString( | ||
| 686 | asObject(input.operation.requestBody).description, | ||
| 687 | ), | ||
| 688 | } | ||
| 689 | : {}), | ||
| 690 | ...(input.requestBody.contents?.[0]?.examples && | ||
| 691 | input.requestBody.contents[0].examples.length > 0 | ||
| 692 | ? { examples: input.requestBody.contents[0].examples } | ||
| 693 | : {}), | ||
| 694 | }, | ||
| 695 | } | ||
| 696 | : {}), | ||
| 697 | ...(preferredResponse | ||
| 698 | ? { | ||
| 699 | response: { | ||
| 700 | statusCode: preferredResponse.statusCode, | ||
| 701 | ...(preferredResponse.description | ||
| 702 | ? { description: preferredResponse.description } | ||
| 703 | : {}), | ||
| 704 | contentTypes: preferredResponse.contentTypes, | ||
| 705 | ...(preferredResponse.examples && preferredResponse.examples.length > 0 | ||
| 706 | ? { examples: preferredResponse.examples } | ||
| 707 | : {}), | ||
| 708 | }, | ||
| 709 | } | ||
| 710 | : {}), | ||
| 711 | }; | ||
| 712 | }; | ||
| 713 | |||
| 714 | const extractToolParameters = ( | ||
| 715 | document: OpenApiJsonObject, | ||
| 716 | pathTemplate: string, | ||
| 717 | method: OpenApiHttpMethod, | ||
| 718 | ): OpenApiToolParameter[] => | ||
| 719 | [...mergedParameterRecords(document, pathTemplate, method).values()].map( | ||
| 720 | (parameter) => { | ||
| 721 | const location = asTrimmedString(parameter.in); | ||
| 722 | const name = asTrimmedString(parameter.name); | ||
| 723 | if (!location || !name) { | ||
| 724 | throw new Error(`Invalid OpenAPI parameter on ${method.toUpperCase()} ${pathTemplate}`); | ||
| 725 | } | ||
| 726 | |||
| 727 | const content = contentEntriesFromContent(document, parameter.content); | ||
| 728 | |||
| 729 | return { | ||
| 730 | name, | ||
| 731 | location: location as OpenApiToolParameter["location"], | ||
| 732 | required: | ||
| 733 | location === "path" | ||
| 734 | ? true | ||
| 735 | : typeof parameter.required === "boolean" | ||
| 736 | ? parameter.required | ||
| 737 | : false, | ||
| 738 | ...(asTrimmedString(parameter.style) | ||
| 739 | ? { style: asTrimmedString(parameter.style) } | ||
| 740 | : {}), | ||
| 741 | ...(typeof parameter.explode === "boolean" | ||
| 742 | ? { explode: parameter.explode } | ||
| 743 | : {}), | ||
| 744 | ...(typeof parameter.allowReserved === "boolean" | ||
| 745 | ? { allowReserved: parameter.allowReserved } | ||
| 746 | : {}), | ||
| 747 | ...(parameter.schema !== undefined && content.length === 0 | ||
| 748 | ? { | ||
| 749 | content: [ | ||
| 750 | { | ||
| 751 | mediaType: "application/json", | ||
| 752 | schema: resolveLocalRef(document, parameter.schema), | ||
| 753 | ...(examplesFromValue(parameter).length > 0 | ||
| 754 | ? { examples: examplesFromValue(parameter) } | ||
| 755 | : {}), | ||
| 756 | }, | ||
| 757 | ], | ||
| 758 | } | ||
| 759 | : {}), | ||
| 760 | ...(content.length > 0 ? { content } : {}), | ||
| 761 | ...(asTrimmedString(parameter.description) | ||
| 762 | ? { | ||
| 763 | description: asTrimmedString(parameter.description), | ||
| 764 | } | ||
| 765 | : {}), | ||
| 766 | } as OpenApiToolParameter; | ||
| 767 | }, | ||
| 768 | ); | ||
| 769 | |||
| 770 | const rawToolIdForOperation = (input: { | ||
| 771 | method: OpenApiHttpMethod; | ||
| 772 | pathTemplate: string; | ||
| 773 | operation: Record<string, unknown>; | ||
| 774 | }): string => | ||
| 775 | asTrimmedString(input.operation.operationId) ?? | ||
| 776 | (`${input.method}_${input.pathTemplate.replace(/[^a-zA-Z0-9]+/g, "_")}`.replace( | ||
| 777 | /^_+|_+$/g, | ||
| 778 | "", | ||
| 779 | ) || `${input.method}_operation`); | ||
| 780 | |||
| 781 | export const extractOpenApiManifest = ( | ||
| 782 | sourceName: string, | ||
| 783 | openApiDocumentText: string, | ||
| 784 | ): Effect.Effect<OpenApiToolManifest, Error, never> => | ||
| 785 | Effect.try({ | ||
| 786 | try: () => { | ||
| 787 | const document = parseOpenApiDocument(openApiDocumentText); | ||
| 788 | const tools: OpenApiExtractedTool[] = []; | ||
| 789 | const paths = asObject(document.paths); | ||
| 790 | const documentServers = serversFromValue(document.servers); | ||
| 791 | |||
| 792 | for (const [pathTemplate, pathItemValue] of Object.entries(paths).sort( | ||
| 793 | ([left], [right]) => left.localeCompare(right), | ||
| 794 | )) { | ||
| 795 | const pathItem = asObject(pathItemValue); | ||
| 796 | for (const method of OPEN_API_HTTP_METHODS) { | ||
| 797 | const operation = asObject(pathItem[method]); | ||
| 798 | if (Object.keys(operation).length === 0) { | ||
| 799 | continue; | ||
| 800 | } | ||
| 801 | |||
| 802 | const parameters = extractToolParameters(document, pathTemplate, method); | ||
| 803 | const requestBody = requestBodyPayloadFor(document, pathTemplate, method); | ||
| 804 | const responses = responseVariantsFor(document, pathTemplate, method); | ||
| 805 | const authRequirement = authRequirementFor(document, pathTemplate, method); | ||
| 806 | const servers = (() => { | ||
| 807 | const operationServers = serversFromValue(operation.servers); | ||
| 808 | if (operationServers.length > 0) { | ||
| 809 | return operationServers; | ||
| 810 | } | ||
| 811 | |||
| 812 | const pathServers = serversFromValue(pathItem.servers); | ||
| 813 | return pathServers.length > 0 ? pathServers : undefined; | ||
| 814 | })(); | ||
| 815 | const inputSchema = buildInputSchema({ | ||
| 816 | parameters, | ||
| 817 | requestBody, | ||
| 818 | }); | ||
| 819 | const outputSchema = responseSchemaFor(document, pathTemplate, method); | ||
| 820 | const documentation = buildDocumentation({ | ||
| 821 | operation, | ||
| 822 | parameters, | ||
| 823 | requestBody, | ||
| 824 | responses, | ||
| 825 | }); | ||
| 826 | |||
| 827 | tools.push({ | ||
| 828 | toolId: rawToolIdForOperation({ | ||
| 829 | method, | ||
| 830 | pathTemplate, | ||
| 831 | operation, | ||
| 832 | }), | ||
| 833 | ...(asTrimmedString(operation.operationId) | ||
| 834 | ? { operationId: asTrimmedString(operation.operationId) } | ||
| 835 | : {}), | ||
| 836 | tags: asArray(operation.tags).flatMap((tag) => | ||
| 837 | typeof tag === "string" && tag.trim().length > 0 ? [tag.trim()] : [], | ||
| 838 | ), | ||
| 839 | name: | ||
| 840 | asTrimmedString(operation.summary) ?? | ||
| 841 | asTrimmedString(operation.operationId) ?? | ||
| 842 | `${method.toUpperCase()} ${pathTemplate}`, | ||
| 843 | description: | ||
| 844 | asTrimmedString(operation.description) ?? | ||
| 845 | asTrimmedString(operation.summary) ?? | ||
| 846 | null, | ||
| 847 | method, | ||
| 848 | path: pathTemplate, | ||
| 849 | invocation: { | ||
| 850 | method, | ||
| 851 | pathTemplate, | ||
| 852 | parameters, | ||
| 853 | requestBody, | ||
| 854 | }, | ||
| 855 | operationHash: stableHash({ | ||
| 856 | method, | ||
| 857 | path: pathTemplate, | ||
| 858 | operation: stableJsonValue(operation), | ||
| 859 | }), | ||
| 860 | ...(inputSchema ? { inputSchema } : {}), | ||
| 861 | ...(outputSchema !== undefined ? { outputSchema } : {}), | ||
| 862 | documentation, | ||
| 863 | ...(responses ? { responses } : {}), | ||
| 864 | ...(authRequirement ? { authRequirement } : {}), | ||
| 865 | ...(authRequirement | ||
| 866 | ? { | ||
| 867 | securitySchemes: securitySchemesFor(document, authRequirement), | ||
| 868 | } | ||
| 869 | : {}), | ||
| 870 | ...(documentServers.length > 0 ? { documentServers } : {}), | ||
| 871 | ...(servers ? { servers } : {}), | ||
| 872 | }); | ||
| 873 | } | ||
| 874 | } | ||
| 875 | |||
| 876 | return { | ||
| 877 | version: 1, | ||
| 878 | sourceHash: contentHash(openApiDocumentText), | ||
| 879 | tools, | ||
| 880 | }; | ||
| 881 | }, | ||
| 882 | catch: (cause) => | ||
| 883 | cause instanceof Error | ||
| 884 | ? new Error(`Failed extracting OpenAPI manifest for ${sourceName}: ${cause.message}`) | ||
| 885 | : new Error(String(cause)), | ||
| 886 | }); | ||
| @@ -0,0 +1,568 @@ | |||
| 1 | type OpenApiSerializableLocation = "path" | "query" | "header" | "cookie"; | ||
| 2 | |||
| 3 | export type OpenApiSerializableContent = { | ||
| 4 | mediaType: string; | ||
| 5 | }; | ||
| 6 | |||
| 7 | export type OpenApiSerializableParameter = { | ||
| 8 | name: string; | ||
| 9 | location: OpenApiSerializableLocation; | ||
| 10 | style?: string; | ||
| 11 | explode?: boolean; | ||
| 12 | allowReserved?: boolean; | ||
| 13 | content?: ReadonlyArray<OpenApiSerializableContent>; | ||
| 14 | }; | ||
| 15 | |||
| 16 | export type OpenApiSerializableRequestBody = { | ||
| 17 | contentTypes?: ReadonlyArray<string>; | ||
| 18 | contents?: ReadonlyArray<OpenApiSerializableContent>; | ||
| 19 | }; | ||
| 20 | |||
| 21 | export type HttpBodyDecodingMode = "json" | "text" | "bytes"; | ||
| 22 | |||
| 23 | export type SerializedOpenApiRequestBody = { | ||
| 24 | contentType: string; | ||
| 25 | body: string | Uint8Array; | ||
| 26 | }; | ||
| 27 | |||
| 28 | export type SerializedOpenApiQueryEntry = { | ||
| 29 | name: string; | ||
| 30 | value: string; | ||
| 31 | allowReserved?: boolean; | ||
| 32 | }; | ||
| 33 | |||
| 34 | export type SerializedOpenApiParameterValue = | ||
| 35 | | { | ||
| 36 | kind: "path" | "header"; | ||
| 37 | value: string; | ||
| 38 | } | ||
| 39 | | { | ||
| 40 | kind: "query"; | ||
| 41 | entries: ReadonlyArray<SerializedOpenApiQueryEntry>; | ||
| 42 | } | ||
| 43 | | { | ||
| 44 | kind: "cookie"; | ||
| 45 | pairs: ReadonlyArray<{ | ||
| 46 | name: string; | ||
| 47 | value: string; | ||
| 48 | }>; | ||
| 49 | }; | ||
| 50 | |||
| 51 | const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| 52 | value !== null && typeof value === "object" && !Array.isArray(value); | ||
| 53 | |||
| 54 | const textEncoder = new TextEncoder(); | ||
| 55 | |||
| 56 | const normalizeMediaType = (value: string | undefined | null): string => | ||
| 57 | (value ?? "").split(";", 1)[0]?.trim().toLowerCase() ?? ""; | ||
| 58 | |||
| 59 | const primitiveString = (value: unknown): string => { | ||
| 60 | if (typeof value === "string") { | ||
| 61 | return value; | ||
| 62 | } | ||
| 63 | |||
| 64 | if ( | ||
| 65 | typeof value === "number" || | ||
| 66 | typeof value === "boolean" || | ||
| 67 | typeof value === "bigint" | ||
| 68 | ) { | ||
| 69 | return String(value); | ||
| 70 | } | ||
| 71 | |||
| 72 | if (value === null || value === undefined) { | ||
| 73 | return ""; | ||
| 74 | } | ||
| 75 | |||
| 76 | try { | ||
| 77 | return JSON.stringify(value); | ||
| 78 | } catch { | ||
| 79 | return String(value); | ||
| 80 | } | ||
| 81 | }; | ||
| 82 | |||
| 83 | export const isJsonMediaType = (mediaType: string | undefined | null): boolean => { | ||
| 84 | const normalized = normalizeMediaType(mediaType); | ||
| 85 | return ( | ||
| 86 | normalized === "application/json" || | ||
| 87 | normalized.endsWith("+json") || | ||
| 88 | normalized.includes("json") | ||
| 89 | ); | ||
| 90 | }; | ||
| 91 | |||
| 92 | const isTextMediaType = (mediaType: string | undefined | null): boolean => { | ||
| 93 | const normalized = normalizeMediaType(mediaType); | ||
| 94 | if (normalized.length === 0) { | ||
| 95 | return false; | ||
| 96 | } | ||
| 97 | |||
| 98 | return ( | ||
| 99 | normalized.startsWith("text/") || | ||
| 100 | normalized === "application/xml" || | ||
| 101 | normalized.endsWith("+xml") || | ||
| 102 | normalized.endsWith("/xml") || | ||
| 103 | normalized === "application/x-www-form-urlencoded" || | ||
| 104 | normalized === "application/javascript" || | ||
| 105 | normalized === "application/ecmascript" || | ||
| 106 | normalized === "application/graphql" || | ||
| 107 | normalized === "application/sql" || | ||
| 108 | normalized === "application/x-yaml" || | ||
| 109 | normalized === "application/yaml" || | ||
| 110 | normalized === "application/toml" || | ||
| 111 | normalized === "application/csv" || | ||
| 112 | normalized === "image/svg+xml" || | ||
| 113 | normalized.endsWith("+yaml") || | ||
| 114 | normalized.endsWith("+toml") | ||
| 115 | ); | ||
| 116 | }; | ||
| 117 | |||
| 118 | export const httpBodyModeFromContentType = ( | ||
| 119 | mediaType: string | undefined | null, | ||
| 120 | ): HttpBodyDecodingMode => { | ||
| 121 | if (isJsonMediaType(mediaType)) { | ||
| 122 | return "json"; | ||
| 123 | } | ||
| 124 | |||
| 125 | if (isTextMediaType(mediaType)) { | ||
| 126 | return "text"; | ||
| 127 | } | ||
| 128 | |||
| 129 | return normalizeMediaType(mediaType).length === 0 ? "text" : "bytes"; | ||
| 130 | }; | ||
| 131 | |||
| 132 | const objectEntries = (value: unknown): Array<[string, unknown]> => | ||
| 133 | Object.entries(isRecord(value) ? value : {}).sort(([left], [right]) => | ||
| 134 | left.localeCompare(right), | ||
| 135 | ); | ||
| 136 | |||
| 137 | const defaultStyle = (location: OpenApiSerializableLocation): string => { | ||
| 138 | switch (location) { | ||
| 139 | case "path": | ||
| 140 | case "header": | ||
| 141 | return "simple"; | ||
| 142 | case "cookie": | ||
| 143 | case "query": | ||
| 144 | return "form"; | ||
| 145 | } | ||
| 146 | }; | ||
| 147 | |||
| 148 | const defaultExplode = ( | ||
| 149 | location: OpenApiSerializableLocation, | ||
| 150 | style: string, | ||
| 151 | ): boolean => | ||
| 152 | location === "query" || location === "cookie" ? style === "form" : false; | ||
| 153 | |||
| 154 | const selectedStyle = (parameter: OpenApiSerializableParameter): string => | ||
| 155 | parameter.style ?? defaultStyle(parameter.location); | ||
| 156 | |||
| 157 | const selectedExplode = (parameter: OpenApiSerializableParameter): boolean => | ||
| 158 | parameter.explode ?? defaultExplode(parameter.location, selectedStyle(parameter)); | ||
| 159 | |||
| 160 | const encodePathPart = (value: unknown): string => | ||
| 161 | encodeURIComponent(primitiveString(value)); | ||
| 162 | |||
| 163 | const encodeQueryValue = (value: string, allowReserved: boolean): string => { | ||
| 164 | const encoded = encodeURIComponent(value); | ||
| 165 | if (!allowReserved) { | ||
| 166 | return encoded; | ||
| 167 | } | ||
| 168 | |||
| 169 | return encoded | ||
| 170 | .replace(/%3A/gi, ":") | ||
| 171 | .replace(/%2F/gi, "/") | ||
| 172 | .replace(/%3F/gi, "?") | ||
| 173 | .replace(/%23/gi, "#") | ||
| 174 | .replace(/%5B/gi, "[") | ||
| 175 | .replace(/%5D/gi, "]") | ||
| 176 | .replace(/%40/gi, "@") | ||
| 177 | .replace(/%21/gi, "!") | ||
| 178 | .replace(/%24/gi, "$") | ||
| 179 | .replace(/%26/gi, "&") | ||
| 180 | .replace(/%27/gi, "'") | ||
| 181 | .replace(/%28/gi, "(") | ||
| 182 | .replace(/%29/gi, ")") | ||
| 183 | .replace(/%2A/gi, "*") | ||
| 184 | .replace(/%2B/gi, "+") | ||
| 185 | .replace(/%2C/gi, ",") | ||
| 186 | .replace(/%3B/gi, ";") | ||
| 187 | .replace(/%3D/gi, "="); | ||
| 188 | }; | ||
| 189 | |||
| 190 | const preferredContentType = (input: { | ||
| 191 | contents?: ReadonlyArray<OpenApiSerializableContent>; | ||
| 192 | contentTypes?: ReadonlyArray<string>; | ||
| 193 | }): string | undefined => { | ||
| 194 | const candidates = [ | ||
| 195 | ...(input.contents ?? []).map((content) => content.mediaType), | ||
| 196 | ...(input.contentTypes ?? []), | ||
| 197 | ]; | ||
| 198 | if (candidates.length === 0) { | ||
| 199 | return undefined; | ||
| 200 | } | ||
| 201 | |||
| 202 | return ( | ||
| 203 | candidates.find((mediaType) => mediaType === "application/json") ?? | ||
| 204 | candidates.find((mediaType) => mediaType.toLowerCase().includes("+json")) ?? | ||
| 205 | candidates.find((mediaType) => mediaType.toLowerCase().includes("json")) ?? | ||
| 206 | candidates[0] | ||
| 207 | ); | ||
| 208 | }; | ||
| 209 | |||
| 210 | const serializeBinaryContentValue = (value: unknown): Uint8Array => { | ||
| 211 | if (value instanceof Uint8Array) { | ||
| 212 | return value; | ||
| 213 | } | ||
| 214 | |||
| 215 | if (value instanceof ArrayBuffer) { | ||
| 216 | return new Uint8Array(value); | ||
| 217 | } | ||
| 218 | |||
| 219 | if (ArrayBuffer.isView(value)) { | ||
| 220 | return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); | ||
| 221 | } | ||
| 222 | |||
| 223 | if (typeof value === "string") { | ||
| 224 | return textEncoder.encode(value); | ||
| 225 | } | ||
| 226 | |||
| 227 | if ( | ||
| 228 | Array.isArray(value) && | ||
| 229 | value.every( | ||
| 230 | (entry) => | ||
| 231 | typeof entry === "number" && | ||
| 232 | Number.isInteger(entry) && | ||
| 233 | entry >= 0 && | ||
| 234 | entry <= 255, | ||
| 235 | ) | ||
| 236 | ) { | ||
| 237 | return Uint8Array.from(value); | ||
| 238 | } | ||
| 239 | |||
| 240 | throw new Error( | ||
| 241 | "Binary OpenAPI request bodies must be bytes, a string, or an array of byte values", | ||
| 242 | ); | ||
| 243 | }; | ||
| 244 | |||
| 245 | const serializeContentValue = (value: unknown, mediaType: string): string => { | ||
| 246 | const normalized = mediaType.toLowerCase(); | ||
| 247 | if ( | ||
| 248 | normalized === "application/json" || | ||
| 249 | normalized.includes("+json") || | ||
| 250 | normalized.includes("json") | ||
| 251 | ) { | ||
| 252 | return JSON.stringify(value); | ||
| 253 | } | ||
| 254 | |||
| 255 | if (normalized === "application/x-www-form-urlencoded") { | ||
| 256 | const params = new URLSearchParams(); | ||
| 257 | for (const [key, entryValue] of objectEntries(value)) { | ||
| 258 | if (Array.isArray(entryValue)) { | ||
| 259 | for (const item of entryValue) { | ||
| 260 | params.append(key, primitiveString(item)); | ||
| 261 | } | ||
| 262 | continue; | ||
| 263 | } | ||
| 264 | |||
| 265 | params.append(key, primitiveString(entryValue)); | ||
| 266 | } | ||
| 267 | return params.toString(); | ||
| 268 | } | ||
| 269 | |||
| 270 | return primitiveString(value); | ||
| 271 | }; | ||
| 272 | |||
| 273 | const serializeQueryParameter = ( | ||
| 274 | parameter: OpenApiSerializableParameter, | ||
| 275 | value: unknown, | ||
| 276 | ): SerializedOpenApiParameterValue => { | ||
| 277 | const style = selectedStyle(parameter); | ||
| 278 | const explode = selectedExplode(parameter); | ||
| 279 | const allowReserved = parameter.allowReserved === true; | ||
| 280 | const contentType = preferredContentType({ contents: parameter.content }); | ||
| 281 | |||
| 282 | if (contentType) { | ||
| 283 | return { | ||
| 284 | kind: "query", | ||
| 285 | entries: [ | ||
| 286 | { | ||
| 287 | name: parameter.name, | ||
| 288 | value: serializeContentValue(value, contentType), | ||
| 289 | allowReserved, | ||
| 290 | }, | ||
| 291 | ], | ||
| 292 | }; | ||
| 293 | } | ||
| 294 | |||
| 295 | if (Array.isArray(value)) { | ||
| 296 | if (style === "spaceDelimited") { | ||
| 297 | return { | ||
| 298 | kind: "query", | ||
| 299 | entries: [ | ||
| 300 | { | ||
| 301 | name: parameter.name, | ||
| 302 | value: value.map((entry) => primitiveString(entry)).join(" "), | ||
| 303 | allowReserved, | ||
| 304 | }, | ||
| 305 | ], | ||
| 306 | }; | ||
| 307 | } | ||
| 308 | |||
| 309 | if (style === "pipeDelimited") { | ||
| 310 | return { | ||
| 311 | kind: "query", | ||
| 312 | entries: [ | ||
| 313 | { | ||
| 314 | name: parameter.name, | ||
| 315 | value: value.map((entry) => primitiveString(entry)).join("|"), | ||
| 316 | allowReserved, | ||
| 317 | }, | ||
| 318 | ], | ||
| 319 | }; | ||
| 320 | } | ||
| 321 | |||
| 322 | return { | ||
| 323 | kind: "query", | ||
| 324 | entries: explode | ||
| 325 | ? value.map((entry) => ({ | ||
| 326 | name: parameter.name, | ||
| 327 | value: primitiveString(entry), | ||
| 328 | allowReserved, | ||
| 329 | })) | ||
| 330 | : [ | ||
| 331 | { | ||
| 332 | name: parameter.name, | ||
| 333 | value: value.map((entry) => primitiveString(entry)).join(","), | ||
| 334 | allowReserved, | ||
| 335 | }, | ||
| 336 | ], | ||
| 337 | }; | ||
| 338 | } | ||
| 339 | |||
| 340 | if (isRecord(value)) { | ||
| 341 | const entries = objectEntries(value); | ||
| 342 | |||
| 343 | if (style === "deepObject") { | ||
| 344 | return { | ||
| 345 | kind: "query", | ||
| 346 | entries: entries.map(([key, entryValue]) => ({ | ||
| 347 | name: `${parameter.name}[${key}]`, | ||
| 348 | value: primitiveString(entryValue), | ||
| 349 | allowReserved, | ||
| 350 | })), | ||
| 351 | }; | ||
| 352 | } | ||
| 353 | |||
| 354 | return { | ||
| 355 | kind: "query", | ||
| 356 | entries: explode | ||
| 357 | ? entries.map(([key, entryValue]) => ({ | ||
| 358 | name: key, | ||
| 359 | value: primitiveString(entryValue), | ||
| 360 | allowReserved, | ||
| 361 | })) | ||
| 362 | : [ | ||
| 363 | { | ||
| 364 | name: parameter.name, | ||
| 365 | value: entries | ||
| 366 | .flatMap(([key, entryValue]) => [key, primitiveString(entryValue)]) | ||
| 367 | .join(","), | ||
| 368 | allowReserved, | ||
| 369 | }, | ||
| 370 | ], | ||
| 371 | }; | ||
| 372 | } | ||
| 373 | |||
| 374 | return { | ||
| 375 | kind: "query", | ||
| 376 | entries: [ | ||
| 377 | { | ||
| 378 | name: parameter.name, | ||
| 379 | value: primitiveString(value), | ||
| 380 | allowReserved, | ||
| 381 | }, | ||
| 382 | ], | ||
| 383 | }; | ||
| 384 | }; | ||
| 385 | |||
| 386 | const serializeHeaderParameter = ( | ||
| 387 | parameter: OpenApiSerializableParameter, | ||
| 388 | value: unknown, | ||
| 389 | ): SerializedOpenApiParameterValue => { | ||
| 390 | if (Array.isArray(value)) { | ||
| 391 | return { | ||
| 392 | kind: "header", | ||
| 393 | value: value.map((entry) => primitiveString(entry)).join(","), | ||
| 394 | }; | ||
| 395 | } | ||
| 396 | |||
| 397 | if (isRecord(value)) { | ||
| 398 | const explode = selectedExplode(parameter); | ||
| 399 | return { | ||
| 400 | kind: "header", | ||
| 401 | value: explode | ||
| 402 | ? objectEntries(value) | ||
| 403 | .map(([key, entryValue]) => `${key}=${primitiveString(entryValue)}`) | ||
| 404 | .join(",") | ||
| 405 | : objectEntries(value) | ||
| 406 | .flatMap(([key, entryValue]) => [key, primitiveString(entryValue)]) | ||
| 407 | .join(","), | ||
| 408 | }; | ||
| 409 | } | ||
| 410 | |||
| 411 | return { | ||
| 412 | kind: "header", | ||
| 413 | value: primitiveString(value), | ||
| 414 | }; | ||
| 415 | }; | ||
| 416 | |||
| 417 | const serializeCookieParameter = ( | ||
| 418 | parameter: OpenApiSerializableParameter, | ||
| 419 | value: unknown, | ||
| 420 | ): SerializedOpenApiParameterValue => { | ||
| 421 | if (Array.isArray(value)) { | ||
| 422 | return { | ||
| 423 | kind: "cookie", | ||
| 424 | pairs: value.map((entry, index) => ({ | ||
| 425 | name: | ||
| 426 | selectedExplode(parameter) || value.length === 1 | ||
| 427 | ? parameter.name | ||
| 428 | : `${parameter.name}${index + 1}`, | ||
| 429 | value: primitiveString(entry), | ||
| 430 | })), | ||
| 431 | }; | ||
| 432 | } | ||
| 433 | |||
| 434 | if (isRecord(value)) { | ||
| 435 | const explode = selectedExplode(parameter); | ||
| 436 | return { | ||
| 437 | kind: "cookie", | ||
| 438 | pairs: explode | ||
| 439 | ? objectEntries(value).map(([key, entryValue]) => ({ | ||
| 440 | name: key, | ||
| 441 | value: primitiveString(entryValue), | ||
| 442 | })) | ||
| 443 | : [ | ||
| 444 | { | ||
| 445 | name: parameter.name, | ||
| 446 | value: objectEntries(value) | ||
| 447 | .flatMap(([key, entryValue]) => [key, primitiveString(entryValue)]) | ||
| 448 | .join(","), | ||
| 449 | }, | ||
| 450 | ], | ||
| 451 | }; | ||
| 452 | } | ||
| 453 | |||
| 454 | return { | ||
| 455 | kind: "cookie", | ||
| 456 | pairs: [ | ||
| 457 | { | ||
| 458 | name: parameter.name, | ||
| 459 | value: primitiveString(value), | ||
| 460 | }, | ||
| 461 | ], | ||
| 462 | }; | ||
| 463 | }; | ||
| 464 | |||
| 465 | const serializePathParameter = ( | ||
| 466 | parameter: OpenApiSerializableParameter, | ||
| 467 | value: unknown, | ||
| 468 | ): SerializedOpenApiParameterValue => { | ||
| 469 | const style = selectedStyle(parameter); | ||
| 470 | const explode = selectedExplode(parameter); | ||
| 471 | |||
| 472 | if (Array.isArray(value)) { | ||
| 473 | const separator = | ||
| 474 | style === "label" ? "." : style === "matrix" ? "," : ","; | ||
| 475 | return { | ||
| 476 | kind: "path", | ||
| 477 | value: | ||
| 478 | style === "label" | ||
| 479 | ? `.${value.map((entry) => encodePathPart(entry)).join(separator)}` | ||
| 480 | : style === "matrix" | ||
| 481 | ? `;${parameter.name}=${value | ||
| 482 | .map((entry) => encodePathPart(entry)) | ||
| 483 | .join(separator)}` | ||
| 484 | : value.map((entry) => encodePathPart(entry)).join(separator), | ||
| 485 | }; | ||
| 486 | } | ||
| 487 | |||
| 488 | if (isRecord(value)) { | ||
| 489 | const entries = objectEntries(value).flatMap(([key, entryValue]) => [ | ||
| 490 | encodePathPart(key), | ||
| 491 | encodePathPart(entryValue), | ||
| 492 | ]); | ||
| 493 | const joined = explode ? entries.join("=") : entries.join(","); | ||
| 494 | return { | ||
| 495 | kind: "path", | ||
| 496 | value: | ||
| 497 | style === "label" | ||
| 498 | ? `.${joined}` | ||
| 499 | : style === "matrix" | ||
| 500 | ? `;${parameter.name}=${joined}` | ||
| 501 | : joined, | ||
| 502 | }; | ||
| 503 | } | ||
| 504 | |||
| 505 | const encoded = encodePathPart(value); | ||
| 506 | return { | ||
| 507 | kind: "path", | ||
| 508 | value: | ||
| 509 | style === "label" | ||
| 510 | ? `.${encoded}` | ||
| 511 | : style === "matrix" | ||
| 512 | ? `;${parameter.name}=${encoded}` | ||
| 513 | : encoded, | ||
| 514 | }; | ||
| 515 | }; | ||
| 516 | |||
| 517 | export const serializeOpenApiParameterValue = ( | ||
| 518 | parameter: OpenApiSerializableParameter, | ||
| 519 | value: unknown, | ||
| 520 | ): SerializedOpenApiParameterValue => { | ||
| 521 | switch (parameter.location) { | ||
| 522 | case "path": | ||
| 523 | return serializePathParameter(parameter, value); | ||
| 524 | case "query": | ||
| 525 | return serializeQueryParameter(parameter, value); | ||
| 526 | case "header": | ||
| 527 | return serializeHeaderParameter(parameter, value); | ||
| 528 | case "cookie": | ||
| 529 | return serializeCookieParameter(parameter, value); | ||
| 530 | } | ||
| 531 | }; | ||
| 532 | |||
| 533 | export const serializeOpenApiRequestBody = (input: { | ||
| 534 | requestBody: OpenApiSerializableRequestBody; | ||
| 535 | body: unknown; | ||
| 536 | }): SerializedOpenApiRequestBody => { | ||
| 537 | const contentType = preferredContentType({ | ||
| 538 | contents: input.requestBody.contents, | ||
| 539 | contentTypes: input.requestBody.contentTypes, | ||
| 540 | }); | ||
| 541 | |||
| 542 | const selectedContentType = contentType ?? "application/json"; | ||
| 543 | if (isJsonMediaType(selectedContentType) || isTextMediaType(selectedContentType)) { | ||
| 544 | return { | ||
| 545 | contentType: selectedContentType, | ||
| 546 | body: serializeContentValue(input.body, selectedContentType), | ||
| 547 | }; | ||
| 548 | } | ||
| 549 | |||
| 550 | return { | ||
| 551 | contentType: selectedContentType, | ||
| 552 | body: serializeBinaryContentValue(input.body), | ||
| 553 | }; | ||
| 554 | }; | ||
| 555 | |||
| 556 | export const withSerializedQueryEntries = ( | ||
| 557 | url: URL, | ||
| 558 | entries: ReadonlyArray<SerializedOpenApiQueryEntry>, | ||
| 559 | ): URL => { | ||
| 560 | const next = new URL(url.toString()); | ||
| 561 | for (const entry of entries) { | ||
| 562 | next.searchParams.append( | ||
| 563 | entry.name, | ||
| 564 | encodeQueryValue(entry.value, entry.allowReserved === true), | ||
| 565 | ); | ||
| 566 | } | ||
| 567 | return next; | ||
| 568 | }; | ||
| @@ -1,5 +1,6 @@ | |||
| 1 | import { createHash } from "node:crypto"; | 1 | import { createHash } from "node:crypto"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schema from "effect/Schema"; | ||
| 3 | 4 | ||
| 4 | import { | 5 | import { |
| 5 | createCatalogImportMetadata, | 6 | createCatalogImportMetadata, |
| @@ -11,12 +12,35 @@ import type { · 5 unmodified lines | |||
| 11 | SourcePluginRuntime, | 12 | SourcePluginRuntime, |
| 12 | } from "@executor/platform-sdk/plugins"; | 13 | } from "@executor/platform-sdk/plugins"; |
| 13 | import { | 14 | import { |
| 15 | deriveOpenApiNamespace, | ||
| 14 | previewOpenApiDocument, | 16 | previewOpenApiDocument, |
| 15 | type OpenApiConnectInput, | 17 | type OpenApiConnectInput, |
| 16 | type OpenApiPreviewRequest, | 18 | type OpenApiPreviewRequest, |
| 17 | type OpenApiPreviewResponse, | 19 | type OpenApiPreviewResponse, |
| 20 | type OpenApiSourceConfigPayload, | ||
| 18 | type OpenApiStoredSourceData, | 21 | type OpenApiStoredSourceData, |
| 22 | type OpenApiUpdateSourceInput, | ||
| 19 | } from "@executor/plugin-openapi-shared"; | 23 | } from "@executor/plugin-openapi-shared"; |
| 24 | import { | ||
| 25 | createOpenApiCatalogFragment, | ||
| 26 | openApiCatalogOperationFromDefinition, | ||
| 27 | } from "./catalog"; | ||
| 28 | import { | ||
| 29 | compileOpenApiToolDefinitions, | ||
| 30 | } from "./definitions"; | ||
| 31 | import { | ||
| 32 | extractOpenApiManifest, | ||
| 33 | } from "./extraction"; | ||
| 34 | import { | ||
| 35 | httpBodyModeFromContentType, | ||
| 36 | serializeOpenApiParameterValue, | ||
| 37 | serializeOpenApiRequestBody, | ||
| 38 | withSerializedQueryEntries, | ||
| 39 | } from "./http-serialization"; | ||
| 40 | import { | ||
| 41 | OpenApiToolProviderDataSchema, | ||
| 42 | type OpenApiToolProviderData, | ||
| 43 | } from "./types"; | ||
| 20 | 44 | ||
| 21 | const stableSourceHash = (value: OpenApiStoredSourceData): string => | 45 | const stableSourceHash = (value: OpenApiStoredSourceData): string => |
| 22 | createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 24); | 46 | createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 24); |
| @@ -47,9 +71,18 @@ export type OpenApiSdk = { · 24 unmodified lines | |||
| 47 | previewDocument: ( | 71 | previewDocument: ( |
| 48 | input: OpenApiPreviewRequest, | 72 | input: OpenApiPreviewRequest, |
| 49 | ) => Effect.Effect<OpenApiPreviewResponse, Error, never>; | 73 | ) => Effect.Effect<OpenApiPreviewResponse, Error, never>; |
| 74 | getSourceConfig: ( | ||
| 75 | sourceId: Source["id"], | ||
| 76 | ) => Effect.Effect<OpenApiSourceConfigPayload, Error, never>; | ||
| 50 | createSource: ( | 77 | createSource: ( |
| 51 | input: OpenApiConnectInput, | 78 | input: OpenApiConnectInput, |
| 52 | ) => Effect.Effect<Source, Error, never>; | 79 | ) => Effect.Effect<Source, Error, never>; |
| 80 | updateSource: ( | ||
| 81 | input: OpenApiUpdateSourceInput, | ||
| 82 | ) => Effect.Effect<Source, Error, never>; | ||
| 83 | removeSource: ( | ||
| 84 | sourceId: Source["id"], | ||
| 85 | ) => Effect.Effect<boolean, Error, never>; | ||
| 53 | }; | 86 | }; |
| 54 | 87 | ||
| 55 | export type OpenApiSdkPluginOptions = { | 88 | export type OpenApiSdkPluginOptions = { |
| @@ -68,6 +101,235 @@ const createStoredSourceData = ( · 12 unmodified lines | |||
| 68 | lastSyncAt: null, | 101 | lastSyncAt: null, |
| 69 | }); | 102 | }); |
| 70 | 103 | ||
| 104 | const configFromStoredSourceData = ( | ||
| 105 | source: Source, | ||
| 106 | stored: OpenApiStoredSourceData, | ||
| 107 | ): OpenApiSourceConfigPayload => ({ | ||
| 108 | name: source.name, | ||
| 109 | specUrl: stored.specUrl, | ||
| 110 | baseUrl: stored.baseUrl, | ||
| 111 | auth: stored.auth, | ||
| 112 | }); | ||
| 113 | |||
| 114 | const decodeProviderData = Schema.decodeUnknownSync(OpenApiToolProviderDataSchema); | ||
| 115 | |||
| 116 | const asRecord = (value: unknown): Record<string, unknown> => | ||
| 117 | typeof value === "object" && value !== null && !Array.isArray(value) | ||
| 118 | ? (value as Record<string, unknown>) | ||
| 119 | : {}; | ||
| 120 | |||
| 121 | type OpenApiToolArgs = Record<string, unknown>; | ||
| 122 | type OpenApiToolParameter = OpenApiToolProviderData["invocation"]["parameters"][number]; | ||
| 123 | |||
| 124 | const parameterContainerKeys: Record< | ||
| 125 | OpenApiToolParameter["location"], | ||
| 126 | Array<string> | ||
| 127 | > = { | ||
| 128 | path: ["path", "pathParams", "params"], | ||
| 129 | query: ["query", "queryParams", "params"], | ||
| 130 | header: ["headers", "header"], | ||
| 131 | cookie: ["cookies", "cookie"], | ||
| 132 | }; | ||
| 133 | |||
| 134 | const readParameterValue = ( | ||
| 135 | args: OpenApiToolArgs, | ||
| 136 | parameter: OpenApiToolParameter, | ||
| 137 | ): unknown => { | ||
| 138 | const directValue = args[parameter.name]; | ||
| 139 | if (directValue !== undefined) { | ||
| 140 | return directValue; | ||
| 141 | } | ||
| 142 | |||
| 143 | for (const key of parameterContainerKeys[parameter.location]) { | ||
| 144 | const container = args[key]; | ||
| 145 | if ( | ||
| 146 | typeof container !== "object" || | ||
| 147 | container === null || | ||
| 148 | Array.isArray(container) | ||
| 149 | ) { | ||
| 150 | continue; | ||
| 151 | } | ||
| 152 | |||
| 153 | const nestedValue = (container as Record<string, unknown>)[parameter.name]; | ||
| 154 | if (nestedValue !== undefined) { | ||
| 155 | return nestedValue; | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 159 | return undefined; | ||
| 160 | }; | ||
| 161 | |||
| 162 | const replacePathTemplate = ( | ||
| 163 | pathTemplate: string, | ||
| 164 | args: OpenApiToolArgs, | ||
| 165 | payload: OpenApiToolProviderData["invocation"], | ||
| 166 | ): string => { | ||
| 167 | let resolvedPath = pathTemplate; | ||
| 168 | |||
| 169 | for (const parameter of payload.parameters) { | ||
| 170 | if (parameter.location !== "path") { | ||
| 171 | continue; | ||
| 172 | } | ||
| 173 | |||
| 174 | const parameterValue = readParameterValue(args, parameter); | ||
| 175 | if (parameterValue === undefined || parameterValue === null) { | ||
| 176 | if (parameter.required) { | ||
| 177 | throw new Error(`Missing required path parameter: ${parameter.name}`); | ||
| 178 | } | ||
| 179 | continue; | ||
| 180 | } | ||
| 181 | |||
| 182 | const serialized = serializeOpenApiParameterValue(parameter, parameterValue); | ||
| 183 | resolvedPath = resolvedPath.replaceAll( | ||
| 184 | `{${parameter.name}}`, | ||
| 185 | serialized.kind === "path" | ||
| 186 | ? serialized.value | ||
| 187 | : encodeURIComponent(String(parameterValue)), | ||
| 188 | ); | ||
| 189 | } | ||
| 190 | |||
| 191 | const unresolved = [...resolvedPath.matchAll(/\{([^{}]+)\}/g)] | ||
| 192 | .map((match) => match[1]) | ||
| 193 | .filter((value): value is string => typeof value === "string" && value.length > 0); | ||
| 194 | |||
| 195 | if (unresolved.length > 0) { | ||
| 196 | const names = [...new Set(unresolved)].sort().join(", "); | ||
| 197 | throw new Error(`Unresolved path parameters after substitution: ${names}`); | ||
| 198 | } | ||
| 199 | |||
| 200 | return resolvedPath; | ||
| 201 | }; | ||
| 202 | |||
| 203 | const resolveOpenApiBaseUrl = (input: { | ||
| 204 | stored: OpenApiStoredSourceData; | ||
| 205 | providerData: OpenApiToolProviderData; | ||
| 206 | }): string => { | ||
| 207 | if (input.stored.baseUrl && input.stored.baseUrl.trim().length > 0) { | ||
| 208 | return new URL(input.stored.baseUrl).toString(); | ||
| 209 | } | ||
| 210 | |||
| 211 | const server = | ||
| 212 | input.providerData.servers?.[0] ?? input.providerData.documentServers?.[0]; | ||
| 213 | if (server) { | ||
| 214 | const expanded = Object.entries(server.variables ?? {}).reduce( | ||
| 215 | (url, [name, value]) => url.replaceAll(`{${name}}`, value), | ||
| 216 | server.url, | ||
| 217 | ); | ||
| 218 | return new URL(expanded, input.stored.specUrl).toString(); | ||
| 219 | } | ||
| 220 | |||
| 221 | return new URL("/", input.stored.specUrl).toString(); | ||
| 222 | }; | ||
| 223 | |||
| 224 | const resolveRequestUrl = (baseUrl: string, resolvedPath: string): URL => { | ||
| 225 | try { | ||
| 226 | return new URL(resolvedPath); | ||
| 227 | } catch { | ||
| 228 | const resolved = new URL(baseUrl); | ||
| 229 | const basePath = | ||
| 230 | resolved.pathname === "/" | ||
| 231 | ? "" | ||
| 232 | : resolved.pathname.endsWith("/") | ||
| 233 | ? resolved.pathname.slice(0, -1) | ||
| 234 | : resolved.pathname; | ||
| 235 | const pathPart = resolvedPath.startsWith("/") ? resolvedPath : `/${resolvedPath}`; | ||
| 236 | |||
| 237 | resolved.pathname = `${basePath}${pathPart}`.replace(/\/{2,}/g, "/"); | ||
| 238 | resolved.search = ""; | ||
| 239 | resolved.hash = ""; | ||
| 240 | return resolved; | ||
| 241 | } | ||
| 242 | }; | ||
| 243 | |||
| 244 | const responseHeadersRecord = (response: Response): Record<string, string> => { | ||
| 245 | const headers: Record<string, string> = {}; | ||
| 246 | response.headers.forEach((value, key) => { | ||
| 247 | headers[key] = value; | ||
| 248 | }); | ||
| 249 | return headers; | ||
| 250 | }; | ||
| 251 | |||
| 252 | const decodeResponseBody = async (response: Response): Promise<unknown> => { | ||
| 253 | if (response.status === 204) { | ||
| 254 | return null; | ||
| 255 | } | ||
| 256 | |||
| 257 | const bodyMode = httpBodyModeFromContentType(response.headers.get("content-type")); | ||
| 258 | if (bodyMode === "json") { | ||
| 259 | return response.json(); | ||
| 260 | } | ||
| 261 | if (bodyMode === "bytes") { | ||
| 262 | return new Uint8Array(await response.arrayBuffer()); | ||
| 263 | } | ||
| 264 | |||
| 265 | return response.text(); | ||
| 266 | }; | ||
| 267 | |||
| 268 | const resolveBearerToken = ( | ||
| 269 | options: OpenApiSdkPluginOptions, | ||
| 270 | stored: OpenApiStoredSourceData, | ||
| 271 | ): Effect.Effect<string | null, Error, never> => { | ||
| 272 | if (stored.auth.kind === "none") { | ||
| 273 | return Effect.succeed(null); | ||
| 274 | } | ||
| 275 | |||
| 276 | if (!options.secrets) { | ||
| 277 | return Effect.fail( | ||
| 278 | new Error("OpenAPI bearer auth is configured, but no secret resolver is available."), | ||
| 279 | ); | ||
| 280 | } | ||
| 281 | |||
| 282 | return options.secrets.resolve({ | ||
| 283 | ref: stored.auth.tokenSecretRef, | ||
| 284 | }).pipe(Effect.map((token) => token.trim())); | ||
| 285 | }; | ||
| 286 | |||
| 287 | const fetchOpenApiDocument = ( | ||
| 288 | options: OpenApiSdkPluginOptions, | ||
| 289 | stored: OpenApiStoredSourceData, | ||
| 290 | ): Effect.Effect<{ | ||
| 291 | text: string; | ||
| 292 | etag: string | null; | ||
| 293 | }, Error, never> => | ||
| 294 | Effect.gen(function* () { | ||
| 295 | const bearerToken = yield* resolveBearerToken(options, stored); | ||
| 296 | const headers = new Headers(); | ||
| 297 | |||
| 298 | for (const [key, value] of Object.entries(stored.defaultHeaders ?? {})) { | ||
| 299 | headers.set(key, value); | ||
| 300 | } | ||
| 301 | if (bearerToken && bearerToken.length > 0) { | ||
| 302 | headers.set("authorization", `Bearer ${bearerToken}`); | ||
| 303 | } | ||
| 304 | if (stored.etag) { | ||
| 305 | headers.set("if-none-match", stored.etag); | ||
| 306 | } | ||
| 307 | |||
| 308 | const response = yield* Effect.tryPromise({ | ||
| 309 | try: () => | ||
| 310 | fetch(stored.specUrl, { | ||
| 311 | headers, | ||
| 312 | }), | ||
| 313 | catch: (cause) => | ||
| 314 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 315 | }); | ||
| 316 | |||
| 317 | if (!response.ok) { | ||
| 318 | throw new Error( | ||
| 319 | `Failed fetching OpenAPI spec (${response.status} ${response.statusText})`, | ||
| 320 | ); | ||
| 321 | } | ||
| 322 | |||
| 323 | return { | ||
| 324 | text: yield* Effect.tryPromise({ | ||
| 325 | try: () => response.text(), | ||
| 326 | catch: (cause) => | ||
| 327 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 328 | }), | ||
| 329 | etag: response.headers.get("etag"), | ||
| 330 | }; | ||
| 331 | }); | ||
| 332 | |||
| 71 | const createOpenApiSourceRuntime = ( | 333 | const createOpenApiSourceRuntime = ( |
| 72 | options: OpenApiSdkPluginOptions, | 334 | options: OpenApiSdkPluginOptions, |
| 73 | ): SourcePluginRuntime => ({ | 335 | ): SourcePluginRuntime => ({ |
| @@ -84,25 +346,194 @@ const createOpenApiSourceRuntime = ( · 10 unmodified lines | |||
| 84 | scopeId: source.scopeId, | 346 | scopeId: source.scopeId, |
| 85 | sourceId: source.id, | 347 | sourceId: source.id, |
| 86 | }); | 348 | }); |
| 349 | if (stored === null) { | ||
| 350 | return createSourceCatalogSyncResult({ | ||
| 351 | fragment: { | ||
| 352 | version: "ir.v1.fragment", | ||
| 353 | }, | ||
| 354 | importMetadata: { | ||
| 355 | ...createCatalogImportMetadata({ | ||
| 356 | source, | ||
| 357 | pluginKey: "openapi", | ||
| 358 | }), | ||
| 359 | importerVersion: "ir.v1.openapi", | ||
| 360 | sourceConfigHash: "missing", | ||
| 361 | }, | ||
| 362 | sourceHash: null, | ||
| 363 | }); | ||
| 364 | } | ||
| 87 | 365 | ||
| 88 | return createSourceCatalogSyncResult({ | 366 | const fetched = yield* fetchOpenApiDocument(options, stored); |
| 89 | fragment: { | 367 | const manifest = yield* extractOpenApiManifest(source.name, fetched.text); |
| 90 | version: "ir.v1.fragment", | 368 | const definitions = compileOpenApiToolDefinitions(manifest); |
| 369 | const now = Date.now(); | ||
| 370 | |||
| 371 | yield* options.storage.put({ | ||
| 372 | scopeId: source.scopeId, | ||
| 373 | sourceId: source.id, | ||
| 374 | value: { | ||
| 375 | ...stored, | ||
| 376 | etag: fetched.etag, | ||
| 377 | lastSyncAt: now, | ||
| 91 | }, | 378 | }, |
| 379 | }); | ||
| 380 | |||
| 381 | return createSourceCatalogSyncResult({ | ||
| 382 | fragment: createOpenApiCatalogFragment({ | ||
| 383 | source, | ||
| 384 | documents: [ | ||
| 385 | { | ||
| 386 | documentKind: "openapi", | ||
| 387 | documentKey: stored.specUrl, | ||
| 388 | contentText: fetched.text, | ||
| 389 | fetchedAt: now, | ||
| 390 | }, | ||
| 391 | ], | ||
| 392 | operations: definitions.map(openApiCatalogOperationFromDefinition), | ||
| 393 | }), | ||
| 92 | importMetadata: { | 394 | importMetadata: { |
| 93 | ...createCatalogImportMetadata({ | 395 | ...createCatalogImportMetadata({ |
| 94 | source, | 396 | source, |
| 95 | pluginKey: "openapi", | 397 | pluginKey: "openapi", |
| 96 | }), | 398 | }), |
| 97 | importerVersion: "ir.v1.openapi", | 399 | importerVersion: "ir.v1.openapi", |
| 98 | sourceConfigHash: | 400 | sourceConfigHash: stableSourceHash(stored), |
| 99 | stored === null ? "openapi" : stableSourceHash(stored), | ||
| 100 | }, | 401 | }, |
| 101 | sourceHash: stored === null ? null : stableSourceHash(stored), | 402 | sourceHash: manifest.sourceHash, |
| 102 | }); | 403 | }); |
| 103 | }), | 404 | }), |
| 104 | invoke: () => | 405 | invoke: (input) => |
| 105 | Effect.fail(new Error("OpenAPI plugin invocation is not implemented.")), | 406 | Effect.gen(function* () { |
| 407 | const stored = yield* options.storage.get({ | ||
| 408 | scopeId: input.source.scopeId, | ||
| 409 | sourceId: input.source.id, | ||
| 410 | }); | ||
| 411 | if (stored === null) { | ||
| 412 | return yield* Effect.fail( | ||
| 413 | new Error(`OpenAPI source storage missing for ${input.source.id}`), | ||
| 414 | ); | ||
| 415 | } | ||
| 416 | |||
| 417 | const providerData = decodeProviderData( | ||
| 418 | input.executable.binding, | ||
| 419 | ) as OpenApiToolProviderData; | ||
| 420 | const args = asRecord(input.args); | ||
| 421 | const resolvedPath = replacePathTemplate( | ||
| 422 | providerData.invocation.pathTemplate, | ||
| 423 | args, | ||
| 424 | providerData.invocation, | ||
| 425 | ); | ||
| 426 | const headers: Record<string, string> = { | ||
| 427 | ...(stored.defaultHeaders ?? {}), | ||
| 428 | }; | ||
| 429 | const queryEntries: Array<{ | ||
| 430 | name: string; | ||
| 431 | value: string; | ||
| 432 | allowReserved?: boolean; | ||
| 433 | }> = []; | ||
| 434 | const cookieParts: string[] = []; | ||
| 435 | |||
| 436 | for (const parameter of providerData.invocation.parameters) { | ||
| 437 | if (parameter.location === "path") { | ||
| 438 | continue; | ||
| 439 | } | ||
| 440 | |||
| 441 | const value = readParameterValue(args, parameter); | ||
| 442 | if (value === undefined || value === null) { | ||
| 443 | if (parameter.required) { | ||
| 444 | throw new Error( | ||
| 445 | `Missing required ${parameter.location} parameter ${parameter.name}`, | ||
| 446 | ); | ||
| 447 | } | ||
| 448 | continue; | ||
| 449 | } | ||
| 450 | |||
| 451 | const serialized = serializeOpenApiParameterValue(parameter, value); | ||
| 452 | if (serialized.kind === "query") { | ||
| 453 | queryEntries.push(...serialized.entries); | ||
| 454 | continue; | ||
| 455 | } | ||
| 456 | if (serialized.kind === "header") { | ||
| 457 | headers[parameter.name] = serialized.value; | ||
| 458 | continue; | ||
| 459 | } | ||
| 460 | if (serialized.kind === "cookie") { | ||
| 461 | cookieParts.push( | ||
| 462 | ...serialized.pairs.map( | ||
| 463 | (pair) => `${pair.name}=${encodeURIComponent(pair.value)}`, | ||
| 464 | ), | ||
| 465 | ); | ||
| 466 | } | ||
| 467 | } | ||
| 468 | |||
| 469 | let body: string | Uint8Array | undefined; | ||
| 470 | if (providerData.invocation.requestBody) { | ||
| 471 | const bodyValue = args.body ?? args.input; | ||
| 472 | if (bodyValue !== undefined) { | ||
| 473 | const serializedBody = serializeOpenApiRequestBody({ | ||
| 474 | requestBody: providerData.invocation.requestBody, | ||
| 475 | body: bodyValue, | ||
| 476 | }); | ||
| 477 | headers["content-type"] = serializedBody.contentType; | ||
| 478 | body = serializedBody.body; | ||
| 479 | } | ||
| 480 | } | ||
| 481 | |||
| 482 | const bearerToken = yield* resolveBearerToken(options, stored); | ||
| 483 | if (bearerToken && bearerToken.length > 0) { | ||
| 484 | headers.authorization = `Bearer ${bearerToken}`; | ||
| 485 | } | ||
| 486 | |||
| 487 | const requestUrl = resolveRequestUrl( | ||
| 488 | resolveOpenApiBaseUrl({ | ||
| 489 | stored, | ||
| 490 | providerData, | ||
| 491 | }), | ||
| 492 | resolvedPath, | ||
| 493 | ); | ||
| 494 | const finalUrl = withSerializedQueryEntries(requestUrl, queryEntries); | ||
| 495 | |||
| 496 | const requestHeaders = new Headers(headers); | ||
| 497 | if (cookieParts.length > 0) { | ||
| 498 | const existingCookie = requestHeaders.get("cookie"); | ||
| 499 | requestHeaders.set( | ||
| 500 | "cookie", | ||
| 501 | existingCookie | ||
| 502 | ? `${existingCookie}; ${cookieParts.join("; ")}` | ||
| 503 | : cookieParts.join("; "), | ||
| 504 | ); | ||
| 505 | } | ||
| 506 | |||
| 507 | const response = yield* Effect.tryPromise({ | ||
| 508 | try: () => | ||
| 509 | fetch(finalUrl.toString(), { | ||
| 510 | method: providerData.method.toUpperCase(), | ||
| 511 | headers: requestHeaders, | ||
| 512 | ...(body !== undefined | ||
| 513 | ? { | ||
| 514 | body: | ||
| 515 | typeof body === "string" | ||
| 516 | ? body | ||
| 517 | : new Uint8Array(body).buffer, | ||
| 518 | } | ||
| 519 | : {}), | ||
| 520 | }), | ||
| 521 | catch: (cause) => | ||
| 522 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 523 | }); | ||
| 524 | const responseBody = yield* Effect.tryPromise({ | ||
| 525 | try: () => decodeResponseBody(response), | ||
| 526 | catch: (cause) => | ||
| 527 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 528 | }); | ||
| 529 | |||
| 530 | return { | ||
| 531 | data: response.ok ? responseBody : null, | ||
| 532 | error: response.ok ? null : responseBody, | ||
| 533 | headers: responseHeadersRecord(response), | ||
| 534 | status: response.status, | ||
| 535 | }; | ||
| 536 | }), | ||
| 106 | }); | 537 | }); |
| 107 | 538 | ||
| 108 | export const openApiSdkPlugin = ( | 539 | export const openApiSdkPlugin = ( |
| @@ -117,26 +548,95 @@ export const openApiSdkPlugin = ( · 8 unmodified lines | |||
| 117 | catch: (cause) => | 548 | catch: (cause) => |
| 118 | cause instanceof Error ? cause : new Error(String(cause)), | 549 | cause instanceof Error ? cause : new Error(String(cause)), |
| 119 | }), | 550 | }), |
| 551 | getSourceConfig: (sourceId) => | ||
| 552 | Effect.gen(function* () { | ||
| 553 | const source = yield* host.sources.get(sourceId); | ||
| 554 | if (source.kind !== "openapi") { | ||
| 555 | return yield* Effect.fail( | ||
| 556 | new Error(`Source ${sourceId} is not an OpenAPI source.`), | ||
| 557 | ); | ||
| 558 | } | ||
| 559 | |||
| 560 | const stored = yield* options.storage.get({ | ||
| 561 | scopeId: source.scopeId, | ||
| 562 | sourceId: source.id, | ||
| 563 | }); | ||
| 564 | if (stored === null) { | ||
| 565 | return yield* Effect.fail( | ||
| 566 | new Error(`OpenAPI source storage missing for ${source.id}`), | ||
| 567 | ); | ||
| 568 | } | ||
| 569 | |||
| 570 | return configFromStoredSourceData(source, stored); | ||
| 571 | }), | ||
| 120 | createSource: (input) => | 572 | createSource: (input) => |
| 121 | Effect.gen(function* () { | 573 | Effect.gen(function* () { |
| 122 | const stored = createStoredSourceData(input); | 574 | const stored = createStoredSourceData(input); |
| 123 | const source = yield* host.sources.create({ | 575 | const createdSource = yield* host.sources.create({ |
| 124 | source: { | 576 | source: { |
| 125 | name: input.name.trim(), | 577 | name: input.name.trim(), |
| 126 | kind: "openapi", | 578 | kind: "openapi", |
| 127 | status: "connected", | 579 | status: "connected", |
| 128 | enabled: true, | 580 | enabled: true, |
| 129 | namespace: null, | 581 | namespace: deriveOpenApiNamespace({ |
| 582 | specUrl: input.specUrl, | ||
| 583 | title: input.name, | ||
| 584 | }), | ||
| 130 | }, | 585 | }, |
| 131 | }); | 586 | }); |
| 132 | 587 | ||
| 133 | yield* options.storage.put({ | 588 | yield* options.storage.put({ |
| 134 | scopeId: source.scopeId, | 589 | scopeId: createdSource.scopeId, |
| 135 | sourceId: source.id, | 590 | sourceId: createdSource.id, |
| 136 | value: stored, | 591 | value: stored, |
| 137 | }); | 592 | }); |
| 138 | 593 | ||
| 139 | return source; | 594 | return yield* host.sources.refreshCatalog(createdSource.id); |
| 595 | }), | ||
| 596 | updateSource: (input) => | ||
| 597 | Effect.gen(function* () { | ||
| 598 | const source = yield* host.sources.get(input.sourceId as Source["id"]); | ||
| 599 | if (source.kind !== "openapi") { | ||
| 600 | return yield* Effect.fail( | ||
| 601 | new Error(`Source ${input.sourceId} is not an OpenAPI source.`), | ||
| 602 | ); | ||
| 603 | } | ||
| 604 | |||
| 605 | const nextStored = createStoredSourceData(input.config); | ||
| 606 | const savedSource = yield* host.sources.save({ | ||
| 607 | ...source, | ||
| 608 | name: input.config.name.trim(), | ||
| 609 | namespace: deriveOpenApiNamespace({ | ||
| 610 | specUrl: input.config.specUrl, | ||
| 611 | title: input.config.name, | ||
| 612 | }), | ||
| 613 | }); | ||
| 614 | |||
| 615 | yield* options.storage.put({ | ||
| 616 | scopeId: savedSource.scopeId, | ||
| 617 | sourceId: savedSource.id, | ||
| 618 | value: nextStored, | ||
| 619 | }); | ||
| 620 | |||
| 621 | return yield* host.sources.refreshCatalog(savedSource.id); | ||
| 622 | }), | ||
| 623 | removeSource: (sourceId) => | ||
| 624 | Effect.gen(function* () { | ||
| 625 | const source = yield* host.sources.get(sourceId); | ||
| 626 | if (source.kind !== "openapi") { | ||
| 627 | return yield* Effect.fail( | ||
| 628 | new Error(`Source ${sourceId} is not an OpenAPI source.`), | ||
| 629 | ); | ||
| 630 | } | ||
| 631 | |||
| 632 | if (options.storage.remove) { | ||
| 633 | yield* options.storage.remove({ | ||
| 634 | scopeId: source.scopeId, | ||
| 635 | sourceId: source.id, | ||
| 636 | }); | ||
| 637 | } | ||
| 638 | |||
| 639 | return yield* host.sources.remove(source.id); | ||
| 140 | }), | 640 | }), |
| 141 | }), | 641 | }), |
| 142 | }); | 642 | }); |
| @@ -6,16 +6,19 @@ · 5 unmodified lines | |||
| 6 | ".": "./index.ts" | 6 | ".": "./index.ts" |
| 7 | }, | 7 | }, |
| 8 | "files": [ | 8 | "files": [ |
| 9 | "index.ts" | 9 | "*.ts" |
| 10 | ], | 10 | ], |
| 11 | "scripts": { | 11 | "scripts": { |
| 12 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | 12 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| 13 | }, | 13 | }, |
| 14 | "dependencies": { | 14 | "dependencies": { |
| 15 | "@executor/ir": "workspace:*", | ||
| 16 | "@executor/codemode-core": "workspace:*", | ||
| 15 | "@executor/platform-sdk": "workspace:*", | 17 | "@executor/platform-sdk": "workspace:*", |
| 16 | "@executor/plugin-openapi-shared": "workspace:*", | 18 | "@executor/plugin-openapi-shared": "workspace:*", |
| 17 | "@executor/source-core": "workspace:*", | 19 | "@executor/source-core": "workspace:*", |
| 18 | "effect": "catalog:" | 20 | "effect": "catalog:", |
| 21 | "yaml": "^2.8.1" | ||
| 19 | }, | 22 | }, |
| 20 | "devDependencies": { | 23 | "devDependencies": { |
| 21 | "@types/node": "catalog:", | 24 | "@types/node": "catalog:", |
| @@ -9,7 +9,5 @@ · 8 unmodified lines | |||
| 9 | "lib": ["ES2022", "DOM"], | 9 | "lib": ["ES2022", "DOM"], |
| 10 | "types": ["bun-types", "node"] | 10 | "types": ["bun-types", "node"] |
| 11 | }, | 11 | }, |
| 12 | "include": [ | 12 | "include": ["*.ts"] |
| 13 | "index.ts" | ||
| 14 | ] | ||
| 15 | } | 13 | } |
| @@ -0,0 +1,298 @@ | |||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | |||
| 3 | export const OPEN_API_HTTP_METHODS = [ | ||
| 4 | "get", | ||
| 5 | "put", | ||
| 6 | "post", | ||
| 7 | "delete", | ||
| 8 | "patch", | ||
| 9 | "head", | ||
| 10 | "options", | ||
| 11 | "trace", | ||
| 12 | ] as const; | ||
| 13 | |||
| 14 | export const OPEN_API_PARAMETER_LOCATIONS = [ | ||
| 15 | "path", | ||
| 16 | "query", | ||
| 17 | "header", | ||
| 18 | "cookie", | ||
| 19 | ] as const; | ||
| 20 | |||
| 21 | export const OpenApiHttpMethodSchema = Schema.Literal(...OPEN_API_HTTP_METHODS); | ||
| 22 | export const OpenApiParameterLocationSchema = Schema.Literal( | ||
| 23 | ...OPEN_API_PARAMETER_LOCATIONS, | ||
| 24 | ); | ||
| 25 | |||
| 26 | export const OpenApiExampleSchema = Schema.Struct({ | ||
| 27 | valueJson: Schema.String, | ||
| 28 | mediaType: Schema.optional(Schema.String), | ||
| 29 | label: Schema.optional(Schema.String), | ||
| 30 | }); | ||
| 31 | |||
| 32 | export const OpenApiMediaContentSchema = Schema.Struct({ | ||
| 33 | mediaType: Schema.String, | ||
| 34 | schema: Schema.optional(Schema.Unknown), | ||
| 35 | examples: Schema.optional(Schema.Array(OpenApiExampleSchema)), | ||
| 36 | }); | ||
| 37 | |||
| 38 | export const OpenApiHeaderSchema = Schema.Struct({ | ||
| 39 | name: Schema.String, | ||
| 40 | description: Schema.optional(Schema.String), | ||
| 41 | required: Schema.optional(Schema.Boolean), | ||
| 42 | deprecated: Schema.optional(Schema.Boolean), | ||
| 43 | schema: Schema.optional(Schema.Unknown), | ||
| 44 | content: Schema.optional(Schema.Array(OpenApiMediaContentSchema)), | ||
| 45 | style: Schema.optional(Schema.String), | ||
| 46 | explode: Schema.optional(Schema.Boolean), | ||
| 47 | examples: Schema.optional(Schema.Array(OpenApiExampleSchema)), | ||
| 48 | }); | ||
| 49 | |||
| 50 | export const OpenApiToolParameterSchema = Schema.Struct({ | ||
| 51 | name: Schema.String, | ||
| 52 | location: OpenApiParameterLocationSchema, | ||
| 53 | required: Schema.Boolean, | ||
| 54 | style: Schema.optional(Schema.String), | ||
| 55 | explode: Schema.optional(Schema.Boolean), | ||
| 56 | allowReserved: Schema.optional(Schema.Boolean), | ||
| 57 | content: Schema.optional(Schema.Array(OpenApiMediaContentSchema)), | ||
| 58 | }); | ||
| 59 | |||
| 60 | export const OpenApiToolRequestBodySchema = Schema.Struct({ | ||
| 61 | required: Schema.Boolean, | ||
| 62 | contentTypes: Schema.Array(Schema.String), | ||
| 63 | contents: Schema.optional(Schema.Array(OpenApiMediaContentSchema)), | ||
| 64 | }); | ||
| 65 | |||
| 66 | export const OpenApiServerSchema = Schema.Struct({ | ||
| 67 | url: Schema.String, | ||
| 68 | description: Schema.optional(Schema.String), | ||
| 69 | variables: Schema.optional( | ||
| 70 | Schema.Record({ | ||
| 71 | key: Schema.String, | ||
| 72 | value: Schema.String, | ||
| 73 | }), | ||
| 74 | ), | ||
| 75 | }); | ||
| 76 | |||
| 77 | export const OpenApiInvocationPayloadSchema = Schema.Struct({ | ||
| 78 | method: OpenApiHttpMethodSchema, | ||
| 79 | pathTemplate: Schema.String, | ||
| 80 | parameters: Schema.Array(OpenApiToolParameterSchema), | ||
| 81 | requestBody: Schema.NullOr(OpenApiToolRequestBodySchema), | ||
| 82 | }); | ||
| 83 | |||
| 84 | export const OpenApiParameterDocumentationSchema = Schema.Struct({ | ||
| 85 | name: Schema.String, | ||
| 86 | location: OpenApiParameterLocationSchema, | ||
| 87 | required: Schema.Boolean, | ||
| 88 | description: Schema.optional(Schema.String), | ||
| 89 | examples: Schema.optional(Schema.Array(OpenApiExampleSchema)), | ||
| 90 | }); | ||
| 91 | |||
| 92 | export const OpenApiRequestBodyDocumentationSchema = Schema.Struct({ | ||
| 93 | description: Schema.optional(Schema.String), | ||
| 94 | examples: Schema.optional(Schema.Array(OpenApiExampleSchema)), | ||
| 95 | }); | ||
| 96 | |||
| 97 | export const OpenApiResponseDocumentationSchema = Schema.Struct({ | ||
| 98 | statusCode: Schema.String, | ||
| 99 | description: Schema.optional(Schema.String), | ||
| 100 | contentTypes: Schema.Array(Schema.String), | ||
| 101 | examples: Schema.optional(Schema.Array(OpenApiExampleSchema)), | ||
| 102 | }); | ||
| 103 | |||
| 104 | export const OpenApiToolDocumentationSchema = Schema.Struct({ | ||
| 105 | summary: Schema.optional(Schema.String), | ||
| 106 | deprecated: Schema.optional(Schema.Boolean), | ||
| 107 | parameters: Schema.Array(OpenApiParameterDocumentationSchema), | ||
| 108 | requestBody: Schema.optional(OpenApiRequestBodyDocumentationSchema), | ||
| 109 | response: Schema.optional(OpenApiResponseDocumentationSchema), | ||
| 110 | }); | ||
| 111 | |||
| 112 | export type OpenApiSecurityRequirement = | ||
| 113 | | { | ||
| 114 | readonly kind: "none"; | ||
| 115 | } | ||
| 116 | | { | ||
| 117 | readonly kind: "scheme"; | ||
| 118 | readonly schemeName: string; | ||
| 119 | readonly scopes?: readonly string[]; | ||
| 120 | } | ||
| 121 | | { | ||
| 122 | readonly kind: "allOf"; | ||
| 123 | readonly items: readonly OpenApiSecurityRequirement[]; | ||
| 124 | } | ||
| 125 | | { | ||
| 126 | readonly kind: "anyOf"; | ||
| 127 | readonly items: readonly OpenApiSecurityRequirement[]; | ||
| 128 | }; | ||
| 129 | |||
| 130 | export const OpenApiSecurityRequirementSchema = Schema.suspend(() => | ||
| 131 | Schema.Union( | ||
| 132 | Schema.Struct({ | ||
| 133 | kind: Schema.Literal("none"), | ||
| 134 | }), | ||
| 135 | Schema.Struct({ | ||
| 136 | kind: Schema.Literal("scheme"), | ||
| 137 | schemeName: Schema.String, | ||
| 138 | scopes: Schema.optional(Schema.Array(Schema.String)), | ||
| 139 | }), | ||
| 140 | Schema.Struct({ | ||
| 141 | kind: Schema.Literal("allOf"), | ||
| 142 | items: Schema.Array(OpenApiSecurityRequirementSchema), | ||
| 143 | }), | ||
| 144 | Schema.Struct({ | ||
| 145 | kind: Schema.Literal("anyOf"), | ||
| 146 | items: Schema.Array(OpenApiSecurityRequirementSchema), | ||
| 147 | }), | ||
| 148 | ) | ||
| 149 | ) as Schema.Schema<OpenApiSecurityRequirement, OpenApiSecurityRequirement, never>; | ||
| 150 | |||
| 151 | export const OpenApiSecuritySchemeTypeSchema = Schema.Literal( | ||
| 152 | "apiKey", | ||
| 153 | "http", | ||
| 154 | "oauth2", | ||
| 155 | "openIdConnect", | ||
| 156 | ); | ||
| 157 | |||
| 158 | export const OpenApiOAuthFlowSchema = Schema.Struct({ | ||
| 159 | authorizationUrl: Schema.optional(Schema.String), | ||
| 160 | tokenUrl: Schema.optional(Schema.String), | ||
| 161 | refreshUrl: Schema.optional(Schema.String), | ||
| 162 | scopes: Schema.optional( | ||
| 163 | Schema.Record({ | ||
| 164 | key: Schema.String, | ||
| 165 | value: Schema.String, | ||
| 166 | }), | ||
| 167 | ), | ||
| 168 | }); | ||
| 169 | |||
| 170 | export const OpenApiSecuritySchemeSchema = Schema.Struct({ | ||
| 171 | schemeName: Schema.String, | ||
| 172 | schemeType: OpenApiSecuritySchemeTypeSchema, | ||
| 173 | description: Schema.optional(Schema.String), | ||
| 174 | placementIn: Schema.optional(Schema.Literal("header", "query", "cookie")), | ||
| 175 | placementName: Schema.optional(Schema.String), | ||
| 176 | scheme: Schema.optional(Schema.String), | ||
| 177 | bearerFormat: Schema.optional(Schema.String), | ||
| 178 | openIdConnectUrl: Schema.optional(Schema.String), | ||
| 179 | flows: Schema.optional( | ||
| 180 | Schema.Record({ | ||
| 181 | key: Schema.String, | ||
| 182 | value: OpenApiOAuthFlowSchema, | ||
| 183 | }), | ||
| 184 | ), | ||
| 185 | }); | ||
| 186 | |||
| 187 | export const OpenApiResponseVariantSchema = Schema.Struct({ | ||
| 188 | statusCode: Schema.String, | ||
| 189 | description: Schema.optional(Schema.String), | ||
| 190 | contentTypes: Schema.Array(Schema.String), | ||
| 191 | schema: Schema.optional(Schema.Unknown), | ||
| 192 | examples: Schema.optional(Schema.Array(OpenApiExampleSchema)), | ||
| 193 | contents: Schema.optional(Schema.Array(OpenApiMediaContentSchema)), | ||
| 194 | headers: Schema.optional(Schema.Array(OpenApiHeaderSchema)), | ||
| 195 | }); | ||
| 196 | |||
| 197 | export const OpenApiToolProviderDataSchema = Schema.Struct({ | ||
| 198 | kind: Schema.Literal("openapi"), | ||
| 199 | toolId: Schema.String, | ||
| 200 | rawToolId: Schema.String, | ||
| 201 | operationId: Schema.optional(Schema.String), | ||
| 202 | group: Schema.String, | ||
| 203 | leaf: Schema.String, | ||
| 204 | tags: Schema.Array(Schema.String), | ||
| 205 | versionSegment: Schema.optional(Schema.String), | ||
| 206 | method: OpenApiHttpMethodSchema, | ||
| 207 | path: Schema.String, | ||
| 208 | operationHash: Schema.String, | ||
| 209 | invocation: OpenApiInvocationPayloadSchema, | ||
| 210 | documentation: Schema.optional(OpenApiToolDocumentationSchema), | ||
| 211 | responses: Schema.optional(Schema.Array(OpenApiResponseVariantSchema)), | ||
| 212 | authRequirement: Schema.optional(OpenApiSecurityRequirementSchema), | ||
| 213 | securitySchemes: Schema.optional(Schema.Array(OpenApiSecuritySchemeSchema)), | ||
| 214 | documentServers: Schema.optional(Schema.Array(OpenApiServerSchema)), | ||
| 215 | servers: Schema.optional(Schema.Array(OpenApiServerSchema)), | ||
| 216 | }); | ||
| 217 | |||
| 218 | export type OpenApiHttpMethod = typeof OpenApiHttpMethodSchema.Type; | ||
| 219 | export type OpenApiExample = typeof OpenApiExampleSchema.Type; | ||
| 220 | export type OpenApiMediaContent = typeof OpenApiMediaContentSchema.Type; | ||
| 221 | export type OpenApiHeader = typeof OpenApiHeaderSchema.Type; | ||
| 222 | export type OpenApiToolParameter = typeof OpenApiToolParameterSchema.Type; | ||
| 223 | export type OpenApiToolRequestBody = typeof OpenApiToolRequestBodySchema.Type; | ||
| 224 | export type OpenApiServer = typeof OpenApiServerSchema.Type; | ||
| 225 | export type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type; | ||
| 226 | export type OpenApiParameterDocumentation = | ||
| 227 | typeof OpenApiParameterDocumentationSchema.Type; | ||
| 228 | export type OpenApiRequestBodyDocumentation = | ||
| 229 | typeof OpenApiRequestBodyDocumentationSchema.Type; | ||
| 230 | export type OpenApiResponseDocumentation = | ||
| 231 | typeof OpenApiResponseDocumentationSchema.Type; | ||
| 232 | export type OpenApiToolDocumentation = | ||
| 233 | typeof OpenApiToolDocumentationSchema.Type; | ||
| 234 | export type OpenApiSecurityScheme = | ||
| 235 | typeof OpenApiSecuritySchemeSchema.Type; | ||
| 236 | export type OpenApiResponseVariant = | ||
| 237 | typeof OpenApiResponseVariantSchema.Type; | ||
| 238 | export type OpenApiToolProviderData = | ||
| 239 | typeof OpenApiToolProviderDataSchema.Type; | ||
| 240 | |||
| 241 | export type OpenApiJsonPrimitive = string | number | boolean | null; | ||
| 242 | export type OpenApiJsonValue = | ||
| 243 | | OpenApiJsonPrimitive | ||
| 244 | | OpenApiJsonObject | ||
| 245 | | Array<OpenApiJsonValue>; | ||
| 246 | export type OpenApiJsonObject = { | ||
| 247 | [key: string]: OpenApiJsonValue; | ||
| 248 | }; | ||
| 249 | |||
| 250 | export type OpenApiExtractedTool = { | ||
| 251 | toolId: string; | ||
| 252 | operationId?: string; | ||
| 253 | tags: readonly string[]; | ||
| 254 | name: string; | ||
| 255 | description: string | null; | ||
| 256 | method: OpenApiHttpMethod; | ||
| 257 | path: string; | ||
| 258 | invocation: OpenApiInvocationPayload; | ||
| 259 | operationHash: string; | ||
| 260 | inputSchema?: unknown; | ||
| 261 | outputSchema?: unknown; | ||
| 262 | documentation?: OpenApiToolDocumentation; | ||
| 263 | responses?: readonly OpenApiResponseVariant[]; | ||
| 264 | authRequirement?: OpenApiSecurityRequirement; | ||
| 265 | securitySchemes?: readonly OpenApiSecurityScheme[]; | ||
| 266 | documentServers?: readonly OpenApiServer[]; | ||
| 267 | servers?: readonly OpenApiServer[]; | ||
| 268 | }; | ||
| 269 | |||
| 270 | export type OpenApiToolManifest = { | ||
| 271 | version: 1; | ||
| 272 | sourceHash: string; | ||
| 273 | tools: readonly OpenApiExtractedTool[]; | ||
| 274 | }; | ||
| 275 | |||
| 276 | export type OpenApiToolDefinition = { | ||
| 277 | toolId: string; | ||
| 278 | rawToolId: string; | ||
| 279 | operationId?: string; | ||
| 280 | name: string; | ||
| 281 | description: string; | ||
| 282 | group: string; | ||
| 283 | leaf: string; | ||
| 284 | tags: readonly string[]; | ||
| 285 | versionSegment?: string; | ||
| 286 | method: OpenApiHttpMethod; | ||
| 287 | path: string; | ||
| 288 | invocation: OpenApiInvocationPayload; | ||
| 289 | operationHash: string; | ||
| 290 | inputSchema?: unknown; | ||
| 291 | outputSchema?: unknown; | ||
| 292 | documentation?: OpenApiToolDocumentation; | ||
| 293 | responses?: readonly OpenApiResponseVariant[]; | ||
| 294 | authRequirement?: OpenApiSecurityRequirement; | ||
| 295 | securitySchemes?: readonly OpenApiSecurityScheme[]; | ||
| 296 | documentServers?: readonly OpenApiServer[]; | ||
| 297 | servers?: readonly OpenApiServer[]; | ||
| 298 | }; | ||
| @@ -23,6 +23,13 @@ export const OpenApiConnectInputSchema = Schema.Struct({ · 22 unmodified lines | |||
| 23 | auth: OpenApiConnectionAuthSchema, | 23 | auth: OpenApiConnectionAuthSchema, |
| 24 | }); | 24 | }); |
| 25 | 25 | ||
| 26 | export const OpenApiSourceConfigPayloadSchema = OpenApiConnectInputSchema; | ||
| 27 | |||
| 28 | export const OpenApiUpdateSourceInputSchema = Schema.Struct({ | ||
| 29 | sourceId: Schema.String, | ||
| 30 | config: OpenApiSourceConfigPayloadSchema, | ||
| 31 | }); | ||
| 32 | |||
| 26 | export const OpenApiSourceConfigSchema = Schema.Struct({ | 33 | export const OpenApiSourceConfigSchema = Schema.Struct({ |
| 27 | specUrl: Schema.String, | 34 | specUrl: Schema.String, |
| 28 | baseUrl: Schema.NullOr(Schema.String), | 35 | baseUrl: Schema.NullOr(Schema.String), |
| @@ -43,20 +50,35 @@ export const OpenApiPreviewRequestSchema = Schema.Struct({ · 14 unmodified lines | |||
| 43 | specUrl: Schema.String, | 50 | specUrl: Schema.String, |
| 44 | }); | 51 | }); |
| 45 | 52 | ||
| 53 | export const OpenApiPreviewSecuritySchemeSchema = Schema.Struct({ | ||
| 54 | name: Schema.String, | ||
| 55 | kind: Schema.Literal("apiKey", "http", "oauth2", "openIdConnect", "unknown"), | ||
| 56 | placement: Schema.NullOr(Schema.String), | ||
| 57 | scheme: Schema.NullOr(Schema.String), | ||
| 58 | }); | ||
| 59 | |||
| 46 | export const OpenApiPreviewResponseSchema = Schema.Struct({ | 60 | export const OpenApiPreviewResponseSchema = Schema.Struct({ |
| 47 | title: Schema.NullOr(Schema.String), | 61 | title: Schema.NullOr(Schema.String), |
| 48 | version: Schema.NullOr(Schema.String), | 62 | version: Schema.NullOr(Schema.String), |
| 49 | baseUrl: Schema.NullOr(Schema.String), | 63 | baseUrl: Schema.NullOr(Schema.String), |
| 64 | namespace: Schema.NullOr(Schema.String), | ||
| 50 | operationCount: Schema.Number, | 65 | operationCount: Schema.Number, |
| 66 | securitySchemes: Schema.Array(OpenApiPreviewSecuritySchemeSchema), | ||
| 51 | warnings: Schema.Array(Schema.String), | 67 | warnings: Schema.Array(Schema.String), |
| 52 | }); | 68 | }); |
| 53 | 69 | ||
| 54 | export type OpenApiConnectionAuth = typeof OpenApiConnectionAuthSchema.Type; | 70 | export type OpenApiConnectionAuth = typeof OpenApiConnectionAuthSchema.Type; |
| 55 | export type OpenApiConnectInput = typeof OpenApiConnectInputSchema.Type; | 71 | export type OpenApiConnectInput = typeof OpenApiConnectInputSchema.Type; |
| 72 | export type OpenApiSourceConfigPayload = | ||
| 73 | typeof OpenApiSourceConfigPayloadSchema.Type; | ||
| 56 | export type OpenApiSourceConfig = typeof OpenApiSourceConfigSchema.Type; | 74 | export type OpenApiSourceConfig = typeof OpenApiSourceConfigSchema.Type; |
| 57 | export type OpenApiStoredSourceData = typeof OpenApiStoredSourceDataSchema.Type; | 75 | export type OpenApiStoredSourceData = typeof OpenApiStoredSourceDataSchema.Type; |
| 58 | export type OpenApiPreviewRequest = typeof OpenApiPreviewRequestSchema.Type; | 76 | export type OpenApiPreviewRequest = typeof OpenApiPreviewRequestSchema.Type; |
| 77 | export type OpenApiPreviewSecurityScheme = | ||
| 78 | typeof OpenApiPreviewSecuritySchemeSchema.Type; | ||
| 59 | export type OpenApiPreviewResponse = typeof OpenApiPreviewResponseSchema.Type; | 79 | export type OpenApiPreviewResponse = typeof OpenApiPreviewResponseSchema.Type; |
| 80 | export type OpenApiUpdateSourceInput = | ||
| 81 | typeof OpenApiUpdateSourceInputSchema.Type; | ||
| 60 | 82 | ||
| 61 | type JsonRecord = Record<string, unknown>; | 83 | type JsonRecord = Record<string, unknown>; |
| 62 | 84 | ||
| @@ -68,6 +90,45 @@ const asRecord = (value: unknown): JsonRecord | null => · 5 unmodified lines | |||
| 68 | const asString = (value: unknown): string | null => | 90 | const asString = (value: unknown): string | null => |
| 69 | typeof value === "string" && value.trim().length > 0 ? value.trim() : null; | 91 | typeof value === "string" && value.trim().length > 0 ? value.trim() : null; |
| 70 | 92 | ||
| 93 | const namespaceFromUrl = (value: string): string | null => { | ||
| 94 | try { | ||
| 95 | const url = new URL(value); | ||
| 96 | const hostname = url.hostname.trim().toLowerCase(); | ||
| 97 | if (!hostname) { | ||
| 98 | return null; | ||
| 99 | } | ||
| 100 | |||
| 101 | const parts = hostname.split(".").filter(Boolean); | ||
| 102 | if (parts.length === 0) { | ||
| 103 | return null; | ||
| 104 | } | ||
| 105 | |||
| 106 | const candidate = (parts.length >= 2 ? parts[parts.length - 2] : parts[0])?.trim(); | ||
| 107 | return candidate && candidate.length > 0 ? candidate : null; | ||
| 108 | } catch { | ||
| 109 | return null; | ||
| 110 | } | ||
| 111 | }; | ||
| 112 | |||
| 113 | const slugifyName = (value: string | null): string | null => { | ||
| 114 | if (!value) { | ||
| 115 | return null; | ||
| 116 | } | ||
| 117 | |||
| 118 | const slug = value | ||
| 119 | .trim() | ||
| 120 | .toLowerCase() | ||
| 121 | .replace(/[^a-z0-9]+/g, "-") | ||
| 122 | .replace(/^-+|-+$/g, ""); | ||
| 123 | |||
| 124 | return slug.length > 0 ? slug : null; | ||
| 125 | }; | ||
| 126 | |||
| 127 | export const deriveOpenApiNamespace = (input: { | ||
| 128 | specUrl: string; | ||
| 129 | title?: string | null; | ||
| 130 | }): string | null => slugifyName(input.title ?? null) ?? namespaceFromUrl(input.specUrl); | ||
| 131 | |||
| 71 | const parseDocument = (text: string): unknown => { | 132 | const parseDocument = (text: string): unknown => { |
| 72 | const trimmed = text.trim(); | 133 | const trimmed = text.trim(); |
| 73 | if (!trimmed) { | 134 | if (!trimmed) { |
| @@ -96,6 +157,35 @@ const deriveBaseUrl = (document: JsonRecord, specUrl: string): string | null => · 22 unmodified lines | |||
| 96 | } | 157 | } |
| 97 | }; | 158 | }; |
| 98 | 159 | ||
| 160 | const previewSecuritySchemes = (document: JsonRecord): Array<OpenApiPreviewSecurityScheme> => { | ||
| 161 | const components = asRecord(document.components); | ||
| 162 | const securitySchemes = asRecord(components?.securitySchemes); | ||
| 163 | if (!securitySchemes) { | ||
| 164 | return []; | ||
| 165 | } | ||
| 166 | |||
| 167 | return Object.entries(securitySchemes) | ||
| 168 | .sort(([left], [right]) => left.localeCompare(right)) | ||
| 169 | .map(([name, rawScheme]) => { | ||
| 170 | const scheme = asRecord(rawScheme); | ||
| 171 | const rawKind = asString(scheme?.type); | ||
| 172 | const kind = | ||
| 173 | rawKind === "apiKey" || | ||
| 174 | rawKind === "http" || | ||
| 175 | rawKind === "oauth2" || | ||
| 176 | rawKind === "openIdConnect" | ||
| 177 | ? rawKind | ||
| 178 | : "unknown"; | ||
| 179 | |||
| 180 | return { | ||
| 181 | name, | ||
| 182 | kind, | ||
| 183 | placement: asString(scheme?.in), | ||
| 184 | scheme: asString(scheme?.scheme), | ||
| 185 | } satisfies OpenApiPreviewSecurityScheme; | ||
| 186 | }); | ||
| 187 | }; | ||
| 188 | |||
| 99 | const countOperations = (document: JsonRecord): number => { | 189 | const countOperations = (document: JsonRecord): number => { |
| 100 | const paths = asRecord(document.paths); | 190 | const paths = asRecord(document.paths); |
| 101 | if (paths === null) { | 191 | if (paths === null) { |
| @@ -147,6 +237,7 @@ export const previewOpenApiDocument = async ( · 45 unmodified lines | |||
| 147 | 237 | ||
| 148 | const info = asRecord(document.info); | 238 | const info = asRecord(document.info); |
| 149 | const warnings: string[] = []; | 239 | const warnings: string[] = []; |
| 240 | const title = asString(info?.title); | ||
| 150 | 241 | ||
| 151 | const openapiVersion = asString(document.openapi) ?? asString(document.swagger); | 242 | const openapiVersion = asString(document.openapi) ?? asString(document.swagger); |
| 152 | if (openapiVersion === null) { | 243 | if (openapiVersion === null) { |
| @@ -159,10 +250,15 @@ export const previewOpenApiDocument = async ( · 6 unmodified lines | |||
| 159 | } | 250 | } |
| 160 | 251 | ||
| 161 | return { | 252 | return { |
| 162 | title: asString(info?.title), | 253 | title, |
| 163 | version: asString(info?.version), | 254 | version: asString(info?.version), |
| 164 | baseUrl, | 255 | baseUrl, |
| 256 | namespace: deriveOpenApiNamespace({ | ||
| 257 | specUrl: input.specUrl, | ||
| 258 | title, | ||
| 259 | }), | ||
| 165 | operationCount: countOperations(document), | 260 | operationCount: countOperations(document), |
| 261 | securitySchemes: previewSecuritySchemes(document), | ||
| 166 | warnings, | 262 | warnings, |
| 167 | }; | 263 | }; |
| 168 | }; | 264 | }; |
| @@ -0,0 +1,20 @@ | |||
| 1 | import { $ } from "bun"; | ||
| 2 | |||
| 3 | let prompt = ` | ||
| 4 | You're running in a while loop with new sesions each time, you will have to check to see what the progress so far that has been made is. | ||
| 5 | |||
| 6 | We are migrating from an old, very messy model to a plugin system for OpenAPI, GraphQL, MCP, and Google Discovery. You'll see that we've got the OpenAPI implemented. If you look at the Git diff on this branch, you'll see a bunch of deleted files and functionality around the rest of the product. We have to bring back all of the functionality that was removed for GraphQL and MCP of an API or Discovery. The thing is that it has to be now localised to those plugins, so you can read the plugin and the functionality will go away. Some examples of that are: | ||
| 7 | |||
| 8 | - how Google Discovery has the or credentials and that or flow; that all gets implemented in the Google Discovery plugin | ||
| 9 | - the MCP OAuth stuff gets implemented in the MCP plugin | ||
| 10 | |||
| 11 | Plugins can own storage; they can own API routes. They're meant to extend the product. If the product does not match what the plugins need, then we can extend the core, but the core should not be plugin-specific. What you need to do is work until this is fully migrated to this new model. Once it is fully migrated, output <STOP-TOKEN-124124> 3 times so you stop working. | ||
| 12 | |||
| 13 | You can take notes in agent-notes/ | ||
| 14 | ` | ||
| 15 | let output = '' | ||
| 16 | |||
| 17 | do { | ||
| 18 | output = await $`codex exec --yolo "${prompt}"`.then(result => String(result.stdout)); | ||
| 19 | } while (// stop token has to show up more than 1 time | ||
| 20 | output.split("\n").filter(line => line.includes("<STOP-TOKEN-124124>")).length < 2); | ||