pull/715/head
Travis Fischer 2025-05-25 07:41:48 +07:00
rodzic ea4b96be45
commit 68d3934a72
4 zmienionych plików z 208 dodań i 19 usunięć

Wyświetl plik

@ -28,5 +28,9 @@
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@hono/node-server": "^1.14.1",
"hono": "^4.7.9"
}
}

Wyświetl plik

@ -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<typeof serve> | 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<void>((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()
}
)

Wyświetl plik

@ -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<string, unknown>,
{
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<Document> {
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[],
{

Wyświetl plik

@ -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: