diff --git a/packages/openapi/package.json b/packages/openapi/package.json index 0ebf9895..71f65bbe 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -28,5 +28,9 @@ }, "publishConfig": { "access": "public" + }, + "devDependencies": { + "@hono/node-server": "^1.14.1", + "hono": "^4.7.9" } } diff --git a/packages/openapi/src/validate-openapi-spec.test.ts b/packages/openapi/src/validate-openapi-spec.test.ts index c330ba54..d4653cca 100644 --- a/packages/openapi/src/validate-openapi-spec.test.ts +++ b/packages/openapi/src/validate-openapi-spec.test.ts @@ -2,7 +2,9 @@ import { readFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { describe, expect, test } from 'vitest' +import { serve } from '@hono/node-server' +import { Hono } from 'hono' +import { afterAll, assert, beforeAll, describe, expect, test } from 'vitest' import { validateOpenAPISpec } from './validate-openapi-spec' @@ -11,7 +13,7 @@ const fixtures = [ 'firecrawl.json', 'github.json', 'notion.json', - // 'open-meteo.yaml', // TODO + 'open-meteo.yaml', 'pet-store.json', 'petstore-expanded.json', 'security.json', @@ -19,20 +21,95 @@ const fixtures = [ 'tic-tac-toe.json' ] -const dirname = path.join(fileURLToPath(import.meta.url), '..', '..') +const fixturesDir = path.join( + fileURLToPath(import.meta.url), + '..', + '..', + 'fixtures' +) + +let server: ReturnType | undefined +let port: number | undefined + +// Setup a simple HTTP server to test loading remote versions of the fixtures +beforeAll(async () => { + const app = new Hono() + + app.get('/fixtures/*', async (c) => { + const fixtureFile = c.req.path.split('/').at(-1)! + assert(fixtureFile, `Missing fixture file: ${c.req.path}`) + + const fixturePath = path.join(fixturesDir, fixtureFile) + const spec = await readFile(fixturePath, 'utf8') + + return c.json(spec) + }) + + await new Promise((resolve) => { + port = 6039 + server = serve( + { + fetch: app.fetch, + port + }, + resolve + ) + }) +}) + +// Close the HTTP server +afterAll(async () => { + await new Promise((resolve, reject) => { + if (server) { + server.close((err) => (err ? reject(err) : resolve())) + } else { + resolve() + } + }) +}) describe('validateOpenAPISpec', () => { for (const fixture of fixtures) { test( - fixture, + `${fixture} (string)`, { timeout: 60_000 }, async () => { - const fixturePath = path.join(dirname, 'fixtures', fixture) - const spec = await readFile(fixturePath, 'utf8') + const fixturePath = path.join(fixturesDir, fixture) + const source = await readFile(fixturePath, 'utf8') - const result = await validateOpenAPISpec(spec) + const result = await validateOpenAPISpec(source) + expect(result).toMatchSnapshot() + } + ) + + test( + `${fixture} (file url)`, + { + timeout: 60_000 + }, + async () => { + const source = new URL(`file://${path.join(fixturesDir, fixture)}`) + + const result = await validateOpenAPISpec(source) + expect(result).toMatchSnapshot() + } + ) + + test( + `${fixture} (http url)`, + { + timeout: 60_000 + }, + // eslint-disable-next-line no-loop-func + async () => { + assert(server) + assert(port) + + const source = new URL(`http://localhost:${port}/fixtures/${fixture}`) + + const result = await validateOpenAPISpec(source) expect(result).toMatchSnapshot() } ) diff --git a/packages/openapi/src/validate-openapi-spec.ts b/packages/openapi/src/validate-openapi-spec.ts index e4e64305..c3ba1fd3 100644 --- a/packages/openapi/src/validate-openapi-spec.ts +++ b/packages/openapi/src/validate-openapi-spec.ts @@ -1,10 +1,13 @@ -import { parseJson } from '@agentic/platform-core' +import { fileURLToPath } from 'node:url' + +import { assert, parseJson } from '@agentic/platform-core' import { BaseResolver, bundle, type Config as RedoclyConfig, type Document, lintDocument, + makeDocumentFromString, type NormalizedProblem, Source } from '@redocly/openapi-core' @@ -12,6 +15,11 @@ import { import type { Logger, LooseOpenAPI3Spec } from './types' import { getDefaultRedoclyConfig } from './redocly-config' +interface ParseSchemaOptions { + absoluteRef: string + resolver: BaseResolver +} + /** * Validates an OpenAPI spec and bundles it into a single, normalized schema. * @@ -20,12 +28,14 @@ import { getDefaultRedoclyConfig } from './redocly-config' * Adapted from https://github.com/openapi-ts/openapi-typescript/blob/main/packages/openapi-typescript/src/lib/redoc.ts */ export async function validateOpenAPISpec( - source: string, + source: string | URL | Buffer | Record, { + cwd, redoclyConfig, logger = console, silent = false }: { + cwd?: URL redoclyConfig?: RedoclyConfig logger?: Logger silent?: boolean @@ -35,18 +45,24 @@ export async function validateOpenAPISpec( redoclyConfig = await getDefaultRedoclyConfig() } - const resolver = new BaseResolver(redoclyConfig.resolve) - - let parsed: any - try { - parsed = parseJson(source) - } catch (err: any) { - throw new Error(`Invalid OpenAPI spec: ${err.message}`) + let absoluteRef: string + if (source instanceof URL) { + absoluteRef = + source.protocol === 'file:' ? fileURLToPath(source) : source.href + } else { + absoluteRef = fileURLToPath(new URL(cwd ?? `file://${process.cwd()}/`)) } - const document: Document = { - source: new Source('', source, 'application/json'), - parsed + const resolver = new BaseResolver(redoclyConfig.resolve) + let document: Document + + try { + document = await parseSchema(source, { + absoluteRef, + resolver + }) + } catch (err: any) { + throw new Error(`Invalid OpenAPI spec: ${err.message}`) } // Check for OpenAPI 3 or greater @@ -88,6 +104,91 @@ export async function validateOpenAPISpec( return bundled.bundle.parsed } +async function parseSchema( + schema: unknown, + { absoluteRef, resolver }: ParseSchemaOptions +): Promise { + if (!schema) { + throw new Error('Invalid schema: empty') + } + + if (schema instanceof URL) { + const result = await resolver.resolveDocument(null, absoluteRef, true) + + if ('parsed' in result) { + const { parsed } = result + if (typeof parsed === 'object') { + return result + } else if (typeof parsed === 'string') { + // Result is a string that we need to parse down below + schema = parsed + } else { + throw new Error('Invalid OpenAPI spec: failed to parse remote schema') + } + } else { + throw result.originalError + } + } + + if (schema instanceof Buffer) { + return parseSchema(schema.toString('utf8'), { absoluteRef, resolver }) + } + + if (typeof schema === 'string') { + schema = schema.trim() + assert(typeof schema === 'string') + + // URL + if ( + schema.startsWith('http://') || + schema.startsWith('https://') || + schema.startsWith('file://') + ) { + const url = new URL(schema) + + return parseSchema(url, { + absoluteRef: url.protocol === 'file:' ? fileURLToPath(url) : url.href, + resolver + }) + } + + // JSON + if (schema[0] === '{') { + return { + source: new Source(absoluteRef, schema, 'application/json'), + parsed: parseJson(schema) + } + } + + // YAML + const result = makeDocumentFromString(schema, absoluteRef) + if ( + typeof result !== 'object' || + !('parsed' in result) || + typeof result.parsed !== 'object' + ) { + throw new Error('Invalid OpenAPI spec: failed to parse schema') + } + + return result + } + + if (typeof schema === 'object' && !Array.isArray(schema)) { + return { + source: new Source( + absoluteRef, + JSON.stringify(schema), + 'application/json' + ), + parsed: schema + } + } + + throw new Error( + `Error parsing OpenAPI spec: Expected string, object, or Buffer. Got ${Array.isArray(schema) ? 'Array' : typeof schema}` + ) +} + function _processProblems( problems: NormalizedProblem[], { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecf7b7bb..b9b0acb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -312,6 +312,13 @@ importers: '@redocly/openapi-core': specifier: ^1.34.3 version: 1.34.3(supports-color@10.0.0) + devDependencies: + '@hono/node-server': + specifier: ^1.14.1 + version: 1.14.1(hono@4.7.9) + hono: + specifier: ^4.7.9 + version: 4.7.9 packages/schemas: dependencies: