@kubb/adapter-oas 5.0.0-alpha.16 → 5.0.0-alpha.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubb/adapter-oas",
3
- "version": "5.0.0-alpha.16",
3
+ "version": "5.0.0-alpha.18",
4
4
  "description": "OpenAPI / Swagger adapter for Kubb — converts OAS input into a @kubb/ast RootNode.",
5
5
  "keywords": [
6
6
  "openapi",
@@ -36,8 +36,8 @@
36
36
  "!/**/__snapshots__/**"
37
37
  ],
38
38
  "dependencies": {
39
- "@kubb/fabric-core": "0.14.0",
40
- "@redocly/openapi-core": "^2.24.1",
39
+ "@kubb/fabric-core": "0.15.1",
40
+ "@redocly/openapi-core": "^2.25.1",
41
41
  "@stoplight/yaml": "^4.3.0",
42
42
  "fflate": "^0.8.2",
43
43
  "jsonpointer": "^5.0.1",
@@ -46,8 +46,8 @@
46
46
  "openapi-types": "^12.1.3",
47
47
  "remeda": "^2.33.6",
48
48
  "swagger2openapi": "^7.0.8",
49
- "@kubb/ast": "5.0.0-alpha.16",
50
- "@kubb/core": "5.0.0-alpha.16"
49
+ "@kubb/ast": "5.0.0-alpha.18",
50
+ "@kubb/core": "5.0.0-alpha.18"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/swagger2openapi": "^7.0.4",
package/src/adapter.ts CHANGED
@@ -1,38 +1,39 @@
1
- import path from 'node:path'
2
- import { createRoot } from '@kubb/ast'
3
- import type { AdapterSource } from '@kubb/core'
1
+ import { collectImports, createRoot } from '@kubb/ast'
4
2
  import { createAdapter } from '@kubb/core'
5
3
  import { DEFAULT_PARSER_OPTIONS } from './constants.ts'
6
- import { resolveServerUrl } from './oas/resolveServerUrl.ts'
7
- import { parseFromConfig } from './oas/utils.ts'
8
- import { createOasParser } from './parser.ts'
9
- import { getImports } from './refResolver.ts'
10
- import type { OasAdapter } from './types.ts'
4
+ import { applyDiscriminatorInheritance } from './discriminator.ts'
5
+ import { parseFromConfig, validateDocument } from './factory.ts'
6
+ import { parseOas } from './parser.ts'
7
+ import { resolveServerUrl } from './resolvers.ts'
8
+ import type { AdapterOas } from './types.ts'
11
9
 
12
- export const adapterOasName = 'oas' satisfies OasAdapter['name']
10
+ /**
11
+ * Stable string identifier for the OAS adapter used in Kubb's adapter registry.
12
+ */
13
+ export const adapterOasName = 'oas' satisfies AdapterOas['name']
13
14
 
14
15
  /**
15
- * Creates an OpenAPI / Swagger adapter for Kubb.
16
+ * Creates the default OpenAPI / Swagger adapter for Kubb.
16
17
  *
17
- * This is the default adapter you can omit it from your config when using
18
- * an OpenAPI spec, but supplying it explicitly lets you pass options.
18
+ * Parses the spec, optionally validates it, resolves the base URL, and converts
19
+ * everything into a `RootNode` that downstream plugins consume.
19
20
  *
20
21
  * @example
21
22
  * ```ts
22
23
  * import { defineConfig } from '@kubb/core'
23
24
  * import { adapterOas } from '@kubb/adapter-oas'
25
+ * import { pluginTs } from '@kubb/plugin-ts'
24
26
  *
25
27
  * export default defineConfig({
26
- * adapter: adapterOas({ validate: true, dateType: 'date' }),
27
- * input: { path: './openapi.yaml' },
28
- * plugins: [pluginTs(), pluginZod()],
28
+ * adapter: adapterOas({ dateType: 'date', serverIndex: 0 }),
29
+ * input: { path: './openapi.yaml' },
30
+ * plugins: [pluginTs()],
29
31
  * })
30
32
  * ```
31
33
  */
32
- export const adapterOas = createAdapter<OasAdapter>((options) => {
34
+ export const adapterOas = createAdapter<AdapterOas>((options) => {
33
35
  const {
34
36
  validate = true,
35
- oasClass,
36
37
  contentType,
37
38
  serverIndex,
38
39
  serverVariables,
@@ -44,79 +45,71 @@ export const adapterOas = createAdapter<OasAdapter>((options) => {
44
45
  emptySchemaType = unknownType || DEFAULT_PARSER_OPTIONS.emptySchemaType,
45
46
  } = options
46
47
 
47
- // Mutable Map shared between `options` and each `parse()` call.
48
- // Populated (and replaced) on every parse so consumers always see the latest state.
49
- const nameMapping = new Map<string, string>()
48
+ // Let-binding so parse() can replace it with a simple reassignment (no clear+loop).
49
+ let nameMapping = new Map<string, string>()
50
50
 
51
51
  return {
52
52
  name: adapterOasName,
53
- options: {
54
- validate,
55
- oasClass,
56
- contentType,
57
- serverIndex,
58
- serverVariables,
59
- discriminator,
60
- dateType,
61
- integerType,
62
- unknownType,
63
- emptySchemaType,
64
- enumSuffix,
65
- nameMapping,
53
+ get options() {
54
+ return {
55
+ validate,
56
+ contentType,
57
+ serverIndex,
58
+ serverVariables,
59
+ discriminator,
60
+ dateType,
61
+ integerType,
62
+ unknownType,
63
+ emptySchemaType,
64
+ enumSuffix,
65
+ nameMapping,
66
+ }
66
67
  },
67
68
  getImports(node, resolve) {
68
- return getImports({ node, nameMapping, resolve })
69
+ return collectImports({
70
+ node,
71
+ nameMapping,
72
+ resolve: (schemaName) => {
73
+ const result = resolve(schemaName)
74
+ if (!result) return
75
+
76
+ return { name: [result.name], path: result.path }
77
+ },
78
+ })
69
79
  },
70
80
  async parse(source) {
71
- const fakeConfig = sourceToFakeConfig(source)
72
- const oas = await parseFromConfig(fakeConfig, oasClass)
73
-
74
- oas.setOptions({ contentType, discriminator })
81
+ const document = await parseFromConfig(source)
75
82
 
76
83
  if (validate) {
77
- try {
78
- await oas.validate()
79
- } catch (_err) {
80
- // Validation failures are non-fatal — mirror plugin-oas behavior
81
- }
84
+ await validateDocument(document)
82
85
  }
83
86
 
84
- const server = serverIndex !== undefined ? oas.api.servers?.at(serverIndex) : undefined
87
+ const server = serverIndex !== undefined ? document.servers?.at(serverIndex) : undefined
85
88
  const baseURL = server?.url ? resolveServerUrl(server, serverVariables) : undefined
86
89
 
87
- const parser = createOasParser(oas, { contentType })
88
- const root = parser.parse({ dateType, integerType, unknownType, emptySchemaType, enumSuffix })
90
+ const { root: parsedRoot, nameMapping: parsedNameMapping } = parseOas(document, {
91
+ contentType,
92
+ dateType,
93
+ integerType,
94
+ unknownType,
95
+ emptySchemaType,
96
+ enumSuffix,
97
+ })
98
+
99
+ const root = discriminator === 'inherit' ? applyDiscriminatorInheritance(parsedRoot) : parsedRoot
89
100
 
90
- // This must happen after parse() because legacy enum remapping is finalized there.
91
- nameMapping.clear()
92
- for (const [key, value] of parser.nameMapping) {
93
- nameMapping.set(key, value)
94
- }
101
+ // This must happen after parseOas() because legacy enum remapping is finalized there.
102
+ nameMapping = parsedNameMapping
95
103
 
96
104
  return createRoot({
97
105
  ...root,
98
106
  meta: {
99
- title: oas.api.info?.title,
100
- description: oas.api.info?.description,
101
- version: oas.api.info?.version,
107
+ title: document.info?.title,
108
+ description: document.info?.description,
109
+ version: document.info?.version,
102
110
  baseURL,
103
111
  },
104
112
  })
105
113
  },
106
114
  }
107
115
  })
108
-
109
- // TODO: remove once parseFromConfig accepts AdapterSource directly
110
- function sourceToFakeConfig(source: AdapterSource): Parameters<typeof parseFromConfig>[0] {
111
- switch (source.type) {
112
- case 'path':
113
- return { root: path.dirname(source.path), input: { path: source.path } } as Parameters<typeof parseFromConfig>[0]
114
- case 'data':
115
- return { root: process.cwd(), input: { data: source.data } } as Parameters<typeof parseFromConfig>[0]
116
- case 'paths':
117
- return {
118
- root: source.paths[0] ? path.dirname(source.paths[0]) : process.cwd(),
119
- input: source.paths.map((p) => ({ path: p })),
120
- } as Parameters<typeof parseFromConfig>[0]
121
- }
122
- }
package/src/constants.ts CHANGED
@@ -1,8 +1,16 @@
1
- import type { SchemaType } from '@kubb/ast/types'
2
- import type { ParserOptions } from './types.ts'
1
+ import { schemaTypes } from '@kubb/ast'
2
+ import type { ParserOptions, ScalarSchemaType, SchemaType } from '@kubb/ast/types'
3
3
 
4
4
  /**
5
- * Default values for all `Options` fields.
5
+ * Default parser options applied when no explicit options are provided.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { DEFAULT_PARSER_OPTIONS } from '@kubb/adapter-oas'
10
+ *
11
+ * const parser = createOasParser(oas)
12
+ * const root = parser.parse({ ...DEFAULT_PARSER_OPTIONS, dateType: 'date' })
13
+ * ```
6
14
  */
7
15
  export const DEFAULT_PARSER_OPTIONS = {
8
16
  dateType: 'string',
@@ -13,36 +21,51 @@ export const DEFAULT_PARSER_OPTIONS = {
13
21
  } as const satisfies ParserOptions
14
22
 
15
23
  /**
16
- * OpenAPI version string written into merged document stubs.
24
+ * OpenAPI version string written into the stub document created during multi-spec merges.
17
25
  */
18
26
  export const MERGE_OPENAPI_VERSION = '3.0.0' as const
19
27
 
20
28
  /**
21
- * Fallback `info.title` used when merging multiple API documents.
29
+ * Fallback `info.title` placed in the stub document when merging multiple API files.
22
30
  */
23
31
  export const MERGE_DEFAULT_TITLE = 'Merged API' as const
24
32
 
25
33
  /**
26
- * Fallback `info.version` used when merging multiple API documents.
34
+ * Fallback `info.version` placed in the stub document when merging multiple API files.
27
35
  */
28
36
  export const MERGE_DEFAULT_VERSION = '1.0.0' as const
29
37
 
30
38
  /**
31
- * JSON Schema keywords that indicate structural composition.
32
- * A schema fragment containing any of these keys must not be inlined into its
33
- * parent during `allOf` flattening it carries semantic meaning of its own.
39
+ * Set of JSON Schema keywords that prevent a schema fragment from being inlined during `allOf` flattening.
40
+ *
41
+ * A fragment that contains any of these keys carries structural meaning of its own and must stay as a separate
42
+ * intersection member rather than being merged into the parent.
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * import { structuralKeys } from '@kubb/adapter-oas'
47
+ *
48
+ * const isStructural = Object.keys(fragment).some((key) => structuralKeys.has(key))
49
+ * // true when fragment has e.g. 'properties' or 'oneOf'
50
+ * ```
34
51
  */
35
52
  export const structuralKeys = new Set(['properties', 'items', 'additionalProperties', 'oneOf', 'anyOf', 'allOf', 'not'] as const)
36
53
 
37
54
  /**
38
- * Maps OAS/JSON Schema `format` strings to their Kubb `SchemaType` equivalents.
55
+ * Static map from OAS `format` strings to Kubb `SchemaType` values.
56
+ *
57
+ * Only formats whose AST type differs from the OAS `type` field appear here.
58
+ * Formats that depend on runtime options (`int64`, `date-time`, `date`, `time`) are handled separately
59
+ * in the parser. `ipv4`, `ipv6`, and `hostname` map to `'url'` as the closest supported scalar.
39
60
  *
40
- * Only formats that need a type different from the raw OAS `type` are listed.
41
- * `int64`, `date-time`, `date`, and `time` are handled separately because their
42
- * mapping depends on runtime parser options.
61
+ * @example
62
+ * ```ts
63
+ * import { formatMap } from '@kubb/adapter-oas'
43
64
  *
44
- * Note: `ipv4`, `ipv6`, and `hostname` map to `'url'` — the closest supported
45
- * scalar type in the Kubb AST, even though these are not strictly URLs.
65
+ * formatMap['uuid'] // 'uuid'
66
+ * formatMap['binary'] // 'blob'
67
+ * formatMap['float'] // 'number'
68
+ * ```
46
69
  */
47
70
  export const formatMap = {
48
71
  uuid: 'uuid',
@@ -65,7 +88,23 @@ export const formatMap = {
65
88
  } as const satisfies Record<string, SchemaType>
66
89
 
67
90
  /**
68
- * Vendor extension keys used to attach human-readable labels to enum values.
69
- * Checked in priority order: the first key found wins.
91
+ * Vendor extension keys that attach human-readable labels to enum values, checked in priority order.
92
+ *
93
+ * @example
94
+ * ```ts
95
+ * import { enumExtensionKeys } from '@kubb/adapter-oas'
96
+ *
97
+ * const key = enumExtensionKeys.find((k) => k in schema) // 'x-enumNames' | 'x-enum-varnames' | undefined
98
+ * ```
70
99
  */
71
100
  export const enumExtensionKeys = ['x-enumNames', 'x-enum-varnames'] as const
101
+
102
+ /**
103
+ * Maps `'any' | 'unknown' | 'void'` option strings to their `ScalarSchemaType` constant.
104
+ * Replaces a plain object lookup with a `Map` for explicit key membership testing via `.has()`.
105
+ */
106
+ export const typeOptionMap = new Map<'any' | 'unknown' | 'void', ScalarSchemaType>([
107
+ ['any', schemaTypes.any],
108
+ ['unknown', schemaTypes.unknown],
109
+ ['void', schemaTypes.void],
110
+ ])
@@ -1,25 +1,99 @@
1
- import { createProperty, createSchema } from '@kubb/ast'
2
- import type { SchemaNode } from '@kubb/ast/types'
1
+ import { createProperty, createSchema, narrowSchema, transform } from '@kubb/ast'
2
+ import type { RootNode } from '@kubb/ast/types'
3
3
 
4
- export function resolveDiscriminatorValue(mapping: Record<string, string> | undefined, ref: string | undefined): string | undefined {
5
- if (!mapping || !ref) return undefined
6
- return Object.entries(mapping).find(([, value]) => value === ref)?.[0]
7
- }
4
+ type DiscriminatorTarget = { propertyName: string; enumValues: Array<string | number | boolean> }
5
+
6
+ /**
7
+ * Injects discriminator enum values into child schemas so they know which value identifies them.
8
+ *
9
+ * Finds every union schema in `root.schemas` that has a `discriminatorPropertyName`, collects the
10
+ * enum value each union member is mapped to, then adds (or replaces) that property on the matching
11
+ * child object schema.
12
+ *
13
+ * Returns a new `RootNode` — the original is never mutated.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * const { root } = parseOas(document, options)
18
+ * const next = applyDiscriminatorInheritance(root)
19
+ * ```
20
+ */
21
+ export function applyDiscriminatorInheritance(root: RootNode): RootNode {
22
+ const childMap = new Map<string, DiscriminatorTarget>()
23
+
24
+ for (const schema of root.schemas) {
25
+ // Case 1: top-level schema is a union (oneOf/anyOf with discriminator)
26
+ // Case 2: top-level schema is an intersection wrapping a union (oneOf/anyOf + shared properties)
27
+ let unionNode = narrowSchema(schema, 'union')
28
+
29
+ if (!unionNode) {
30
+ const intersectionMembers = narrowSchema(schema, 'intersection')?.members
31
+ if (intersectionMembers) {
32
+ for (const m of intersectionMembers) {
33
+ const u = narrowSchema(m, 'union')
34
+ if (u) {
35
+ unionNode = u
36
+ break
37
+ }
38
+ }
39
+ }
40
+ }
41
+
42
+ if (!unionNode?.discriminatorPropertyName || !unionNode.members) continue
43
+
44
+ const { discriminatorPropertyName, members } = unionNode
45
+
46
+ for (const member of members) {
47
+ // Members with a discriminant value are intersections: [RefSchemaNode, ObjectSchemaNode]
48
+ const intersectionNode = narrowSchema(member, 'intersection')
49
+ if (!intersectionNode?.members) continue
50
+
51
+ let refNode: ReturnType<typeof narrowSchema<'ref'>> | undefined
52
+ let objNode: ReturnType<typeof narrowSchema<'object'>> | undefined
53
+
54
+ for (const m of intersectionNode.members) {
55
+ refNode ??= narrowSchema(m, 'ref')
56
+ objNode ??= narrowSchema(m, 'object')
57
+ }
58
+
59
+ if (!refNode?.name || !objNode) continue
60
+
61
+ const prop = objNode.properties.find((p) => p.name === discriminatorPropertyName)
62
+ const enumNode = prop ? narrowSchema(prop.schema, 'enum') : undefined
63
+ if (!enumNode?.enumValues?.length) continue
64
+
65
+ const enumValues = enumNode.enumValues.filter((v): v is string | number | boolean => v !== null)
66
+ if (!enumValues.length) continue
67
+
68
+ const existing = childMap.get(refNode.name)
69
+ if (existing) {
70
+ existing.enumValues.push(...enumValues)
71
+ } else {
72
+ childMap.set(refNode.name, { propertyName: discriminatorPropertyName, enumValues: [...enumValues] })
73
+ }
74
+ }
75
+ }
76
+
77
+ if (childMap.size === 0) return root
78
+
79
+ return transform(root, {
80
+ schema(node, { parent }) {
81
+ if (parent?.kind !== 'Root' || !node.name) return
82
+
83
+ const entry = childMap.get(node.name)
84
+ if (!entry) return
85
+
86
+ const objectNode = narrowSchema(node, 'object')
87
+ if (!objectNode) return
88
+
89
+ const { propertyName, enumValues } = entry
90
+ const enumSchema = createSchema({ type: 'enum', enumValues })
91
+ const newProp = createProperty({ name: propertyName, required: true, schema: enumSchema })
92
+
93
+ const existingIdx = objectNode.properties.findIndex((p) => p.name === propertyName)
94
+ const newProperties = existingIdx >= 0 ? objectNode.properties.map((p, i) => (i === existingIdx ? newProp : p)) : [...objectNode.properties, newProp]
8
95
 
9
- export function createDiscriminantNode(propertyName: string, value: string): SchemaNode {
10
- return createSchema({
11
- type: 'object',
12
- primitive: 'object',
13
- properties: [
14
- createProperty({
15
- name: propertyName,
16
- schema: createSchema({
17
- type: 'enum',
18
- primitive: 'string',
19
- enumValues: [value],
20
- }),
21
- required: true,
22
- }),
23
- ],
96
+ return { ...objectNode, properties: newProperties }
97
+ },
24
98
  })
25
99
  }
package/src/factory.ts ADDED
@@ -0,0 +1,151 @@
1
+ import path from 'node:path'
2
+ import { URLPath } from '@internals/utils'
3
+ import type { AdapterSource } from '@kubb/core'
4
+ import { bundle, loadConfig } from '@redocly/openapi-core'
5
+ import yaml from '@stoplight/yaml'
6
+ import OASNormalize from 'oas-normalize'
7
+ import { mergeDeep } from 'remeda'
8
+ import swagger2openapi from 'swagger2openapi'
9
+ import { MERGE_DEFAULT_TITLE, MERGE_DEFAULT_VERSION, MERGE_OPENAPI_VERSION } from './constants.ts'
10
+ import { isOpenApiV2Document } from './guards.ts'
11
+ import type { Document } from './types.ts'
12
+
13
+ export type ParseOptions = {
14
+ canBundle?: boolean
15
+ enablePaths?: boolean
16
+ }
17
+
18
+ /**
19
+ * Loads and dereferences an OpenAPI document, returning the raw `Document`.
20
+ *
21
+ * Accepts a file path string or an already-parsed document object. File paths are bundled via
22
+ * Redocly to resolve external `$ref`s. Swagger 2.0 documents are automatically up-converted
23
+ * to OpenAPI 3.0 via `swagger2openapi`.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * const document = await parseDocument('./openapi.yaml')
28
+ * const document = await parse(rawDocumentObject, { canBundle: false })
29
+ * ```
30
+ */
31
+ export async function parseDocument(pathOrApi: string | Document, { canBundle = true, enablePaths = true }: ParseOptions = {}): Promise<Document> {
32
+ if (typeof pathOrApi === 'string' && canBundle) {
33
+ const config = await loadConfig()
34
+ const bundleResults = await bundle({ ref: pathOrApi, config, base: pathOrApi })
35
+
36
+ return parseDocument(bundleResults.bundle.parsed as string, { canBundle, enablePaths })
37
+ }
38
+
39
+ const oasNormalize = new OASNormalize(pathOrApi, {
40
+ enablePaths,
41
+ colorizeErrors: true,
42
+ })
43
+ const document = (await oasNormalize.load()) as Document
44
+
45
+ if (isOpenApiV2Document(document)) {
46
+ const { openapi } = await swagger2openapi.convertObj(document, {
47
+ anchors: true,
48
+ })
49
+
50
+ return openapi as Document
51
+ }
52
+
53
+ return document
54
+ }
55
+
56
+ /**
57
+ * Deep-merges multiple OpenAPI documents into a single `Document`.
58
+ *
59
+ * Each document is parsed independently then recursively merged with `remeda`'s `mergeDeep`.
60
+ * Throws when the input array is empty.
61
+ *
62
+ * @example
63
+ * ```ts
64
+ * const document = await mergeDocuments(['./pets.yaml', './orders.yaml'])
65
+ * ```
66
+ */
67
+ export async function mergeDocuments(pathOrApi: Array<string | Document>): Promise<Document> {
68
+ const documents = await Promise.all(pathOrApi.map((p) => parseDocument(p, { enablePaths: false, canBundle: false })))
69
+
70
+ if (documents.length === 0) {
71
+ throw new Error('No OAS documents provided for merging.')
72
+ }
73
+
74
+ const seed: Document = {
75
+ openapi: MERGE_OPENAPI_VERSION,
76
+ info: { title: MERGE_DEFAULT_TITLE, version: MERGE_DEFAULT_VERSION },
77
+ paths: {},
78
+ components: { schemas: {} },
79
+ } as Document
80
+
81
+ const merged = documents.reduce((acc, current) => mergeDeep(acc, current as Document), seed)
82
+
83
+ return parseDocument(merged)
84
+ }
85
+
86
+ /**
87
+ * Creates a `Document` from an `AdapterSource`.
88
+ *
89
+ * Handles all three source types:
90
+ * - `{ type: 'path' }` — resolves and bundles a local file path or remote URL.
91
+ * - `{ type: 'paths' }` — merges multiple file paths into a single document.
92
+ * - `{ type: 'data' }` — parses an inline string (YAML/JSON) or raw object.
93
+ *
94
+ * @example
95
+ * ```ts
96
+ * const document = await parseFromConfig({ type: 'path', path: './openapi.yaml' })
97
+ * const document = await parseFromConfig({ type: 'data', data: '{"openapi":"3.0.0",...}' })
98
+ * ```
99
+ */
100
+ export function parseFromConfig(source: AdapterSource): Promise<Document> {
101
+ if (source.type === 'data') {
102
+ if (typeof source.data === 'object') {
103
+ return parseDocument(structuredClone(source.data) as Document)
104
+ }
105
+
106
+ try {
107
+ const api: string = yaml.parse(source.data as string)
108
+ return parseDocument(api)
109
+ } catch {
110
+ return parseDocument(source.data as string)
111
+ }
112
+ }
113
+
114
+ if (source.type === 'paths') {
115
+ return mergeDocuments(source.paths)
116
+ }
117
+
118
+ // type === 'path'
119
+ if (new URLPath(source.path).isURL) {
120
+ return parseDocument(source.path)
121
+ }
122
+
123
+ return parseDocument(path.resolve(path.dirname(source.path), source.path))
124
+ }
125
+
126
+ /**
127
+ * Validates an OpenAPI document using `oas-normalize` with colorized error output.
128
+ *
129
+ * @example
130
+ * ```ts
131
+ * await validateDocument(document)
132
+ * ```
133
+ */
134
+ export async function validateDocument(document: Document): Promise<void> {
135
+ try {
136
+ const oasNormalize = new OASNormalize(document, {
137
+ enablePaths: true,
138
+ colorizeErrors: true,
139
+ })
140
+
141
+ await oasNormalize.validate({
142
+ parser: {
143
+ validate: {
144
+ errors: { colorize: true },
145
+ },
146
+ },
147
+ })
148
+ } catch (_err) {
149
+ // Validation failures are non-fatal — mirror plugin-oas behavior
150
+ }
151
+ }
package/src/guards.ts ADDED
@@ -0,0 +1,68 @@
1
+ import type { OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'
2
+ import { isPlainObject } from 'remeda'
3
+ import type { DiscriminatorObject, SchemaObject } from './types.ts'
4
+
5
+ /**
6
+ * Returns `true` when `doc` is a Swagger 2.0 document (no `openapi` key).
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * if (isOpenApiV2Document(doc)) {
11
+ * // doc is OpenAPIV2.Document
12
+ * }
13
+ * ```
14
+ */
15
+ export function isOpenApiV2Document(doc: unknown): doc is OpenAPIV2.Document {
16
+ return !!doc && isPlainObject(doc) && !('openapi' in doc)
17
+ }
18
+
19
+ /**
20
+ * Returns `true` when a schema should be treated as nullable.
21
+ *
22
+ * Recognizes all nullable signals across OAS versions: `nullable: true` (OAS 3.0),
23
+ * `x-nullable: true` (vendor extension), `type: 'null'`, and `type: ['null', ...]` (OAS 3.1).
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * isNullable({ type: 'string', nullable: true }) // true
28
+ * isNullable({ type: ['string', 'null'] }) // true
29
+ * isNullable({ type: 'string' }) // false
30
+ * ```
31
+ */
32
+ export function isNullable(schema?: SchemaObject & { 'x-nullable'?: boolean }): boolean {
33
+ const explicitNullable = schema?.nullable ?? schema?.['x-nullable']
34
+ if (explicitNullable === true) return true
35
+
36
+ const schemaType = schema?.type
37
+ if (schemaType === 'null') return true
38
+ if (Array.isArray(schemaType)) return schemaType.includes('null')
39
+
40
+ return false
41
+ }
42
+
43
+ /**
44
+ * Returns `true` when `obj` is an OpenAPI `$ref` pointer object.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * isReference({ $ref: '#/components/schemas/Pet' }) // true
49
+ * isReference({ type: 'string' }) // false
50
+ * ```
51
+ */
52
+ export function isReference(obj?: unknown): obj is OpenAPIV3.ReferenceObject | OpenAPIV3_1.ReferenceObject {
53
+ return !!obj && typeof obj === 'object' && '$ref' in obj
54
+ }
55
+
56
+ /**
57
+ * Returns `true` when `obj` is a schema with a structured OAS 3.x `discriminator` object.
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * isDiscriminator({ discriminator: { propertyName: 'type', mapping: {} } }) // true
62
+ * isDiscriminator({ discriminator: 'type' }) // false (Swagger 2 string form)
63
+ * ```
64
+ */
65
+ export function isDiscriminator(obj?: unknown): obj is SchemaObject & { discriminator: DiscriminatorObject } {
66
+ const record = obj as Record<string, unknown>
67
+ return !!obj && !!record['discriminator'] && typeof record['discriminator'] !== 'string'
68
+ }