@kubb/adapter-oas 5.0.0-alpha.4 → 5.0.0-alpha.41

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@kubb/adapter-oas",
3
- "version": "5.0.0-alpha.4",
4
- "description": "OpenAPI / Swagger adapter for Kubb converts OAS input into a @kubb/ast RootNode.",
3
+ "version": "5.0.0-alpha.41",
4
+ "description": "OpenAPI / Swagger adapter for Kubb converts OAS input into a @kubb/ast RootNode.",
5
5
  "keywords": [
6
6
  "openapi",
7
7
  "swagger",
@@ -36,18 +36,16 @@
36
36
  "!/**/__snapshots__/**"
37
37
  ],
38
38
  "dependencies": {
39
- "@kubb/fabric-core": "0.13.3",
40
- "@redocly/openapi-core": "^2.22.1",
39
+ "@redocly/openapi-core": "^2.28.1",
41
40
  "@stoplight/yaml": "^4.3.0",
42
41
  "fflate": "^0.8.2",
43
42
  "jsonpointer": "^5.0.1",
44
43
  "oas": "^31.1.2",
45
- "oas-normalize": "^16.0.2",
44
+ "oas-normalize": "^16.0.4",
46
45
  "openapi-types": "^12.1.3",
47
- "remeda": "^2.33.6",
46
+ "remeda": "^2.33.7",
48
47
  "swagger2openapi": "^7.0.8",
49
- "@kubb/ast": "5.0.0-alpha.4",
50
- "@kubb/core": "5.0.0-alpha.4"
48
+ "@kubb/core": "5.0.0-alpha.41"
51
49
  },
52
50
  "devDependencies": {
53
51
  "@types/swagger2openapi": "^7.0.4",
package/src/adapter.ts CHANGED
@@ -1,121 +1,126 @@
1
- import path from 'node:path'
2
- import { createRoot } from '@kubb/ast'
3
- import type { AdapterSource } from '@kubb/core'
4
- import { defineAdapter } from '@kubb/core'
5
- import { resolveServerUrl } from './oas/resolveServerUrl.ts'
6
- import { parseFromConfig } from './oas/utils.ts'
7
- import { createOasParser } from './parser.ts'
8
- import type { OasAdapter } from './types.ts'
9
- import { getImports } from './utils.ts'
1
+ import { ast, createAdapter } from '@kubb/core'
2
+ import { DEFAULT_PARSER_OPTIONS } from './constants.ts'
3
+ import { applyDiscriminatorInheritance } from './discriminator.ts'
4
+ import { parseFromConfig, validateDocument } from './factory.ts'
5
+ import { parseOas } from './parser.ts'
6
+ import { resolveServerUrl } from './resolvers.ts'
7
+ import type { AdapterOas, Document } from './types.ts'
10
8
 
11
- export const adapterOasName = 'oas' satisfies OasAdapter['name']
9
+ /**
10
+ * Stable string identifier for the OAS adapter used in Kubb's adapter registry.
11
+ */
12
+ export const adapterOasName = 'oas' satisfies AdapterOas['name']
12
13
 
13
14
  /**
14
- * Creates an OpenAPI / Swagger adapter for Kubb.
15
+ * Creates the default OpenAPI / Swagger adapter for Kubb.
15
16
  *
16
- * This is the default adapter you can omit it from your config when using
17
- * an OpenAPI spec, but supplying it explicitly lets you pass options.
17
+ * Parses the spec, optionally validates it, resolves the base URL, and converts
18
+ * everything into an `InputNode` that downstream plugins consume.
18
19
  *
19
20
  * @example
20
21
  * ```ts
21
22
  * import { defineConfig } from '@kubb/core'
22
23
  * import { adapterOas } from '@kubb/adapter-oas'
24
+ * import { pluginTs } from '@kubb/plugin-ts'
23
25
  *
24
26
  * export default defineConfig({
25
- * adapter: adapterOas({ validate: true, dateType: 'date' }),
26
- * input: { path: './openapi.yaml' },
27
- * plugins: [pluginTs(), pluginZod()],
27
+ * adapter: adapterOas({ dateType: 'date', serverIndex: 0 }),
28
+ * input: { path: './openapi.yaml' },
29
+ * plugins: [pluginTs()],
28
30
  * })
29
31
  * ```
30
32
  */
31
- export const adapterOas = defineAdapter<OasAdapter>((options) => {
33
+ export const adapterOas = createAdapter<AdapterOas>((options) => {
32
34
  const {
33
35
  validate = true,
34
- oasClass,
35
36
  contentType,
36
37
  serverIndex,
37
38
  serverVariables,
38
39
  discriminator = 'strict',
39
- collisionDetection = false,
40
- dateType = 'string',
41
- integerType = 'number',
42
- unknownType = 'any',
43
- emptySchemaType = unknownType,
40
+ dateType = DEFAULT_PARSER_OPTIONS.dateType,
41
+ integerType = DEFAULT_PARSER_OPTIONS.integerType,
42
+ unknownType = DEFAULT_PARSER_OPTIONS.unknownType,
43
+ enumSuffix = DEFAULT_PARSER_OPTIONS.enumSuffix,
44
+ emptySchemaType = unknownType || DEFAULT_PARSER_OPTIONS.emptySchemaType,
44
45
  } = options
45
46
 
46
- // Mutable Map shared between `options` and each `parse()` call.
47
- // Populated (and replaced) on every parse so consumers always see the latest state.
48
- const nameMapping = new Map<string, string>()
47
+ // Let-binding so parse() can replace it with a simple reassignment (no clear+loop).
48
+ let nameMapping = new Map<string, string>()
49
+ let parsedDocument: Document | null
50
+ let inputNode: ast.InputNode | null
49
51
 
50
52
  return {
51
53
  name: adapterOasName,
52
- options: {
53
- validate,
54
- oasClass,
55
- contentType,
56
- serverIndex,
57
- serverVariables,
58
- discriminator,
59
- collisionDetection,
60
- dateType,
61
- integerType,
62
- unknownType,
63
- emptySchemaType,
64
- nameMapping,
54
+ get options() {
55
+ return {
56
+ validate,
57
+ contentType,
58
+ serverIndex,
59
+ serverVariables,
60
+ discriminator,
61
+ dateType,
62
+ integerType,
63
+ unknownType,
64
+ emptySchemaType,
65
+ enumSuffix,
66
+ nameMapping,
67
+ }
68
+ },
69
+ get document() {
70
+ return parsedDocument
71
+ },
72
+ get inputNode() {
73
+ return inputNode
65
74
  },
66
75
  getImports(node, resolve) {
67
- return getImports({ node, nameMapping, resolve })
76
+ return ast.collectImports({
77
+ node,
78
+ nameMapping,
79
+ resolve: (schemaName) => {
80
+ const result = resolve(schemaName)
81
+ if (!result) return
82
+
83
+ return ast.createImport({ name: [result.name], path: result.path })
84
+ },
85
+ })
68
86
  },
69
87
  async parse(source) {
70
- const fakeConfig = sourceToFakeConfig(source)
71
- const oas = await parseFromConfig(fakeConfig, oasClass)
72
-
73
- oas.setOptions({ contentType, discriminator, collisionDetection })
88
+ const document = await parseFromConfig(source)
74
89
 
75
90
  if (validate) {
76
- try {
77
- await oas.validate()
78
- } catch (_err) {
79
- // Validation failures are non-fatal — mirror plugin-oas behavior
80
- }
91
+ await validateDocument(document)
81
92
  }
82
93
 
83
- const server = serverIndex !== undefined ? oas.api.servers?.at(serverIndex) : undefined
94
+ const server = serverIndex !== undefined ? document.servers?.at(serverIndex) : undefined
84
95
  const baseURL = server?.url ? resolveServerUrl(server, serverVariables) : undefined
85
96
 
86
- const parser = createOasParser(oas, { contentType, collisionDetection })
97
+ const { root: parsedRoot, nameMapping: parsedNameMapping } = parseOas(document, {
98
+ contentType,
99
+ dateType,
100
+ integerType,
101
+ unknownType,
102
+ emptySchemaType,
103
+ enumSuffix,
104
+ })
87
105
 
88
- // Sync the adapter's shared nameMapping with the one computed by the parser.
89
- nameMapping.clear()
90
- for (const [key, value] of parser.nameMapping) {
91
- nameMapping.set(key, value)
92
- }
106
+ const node = discriminator === 'inherit' ? applyDiscriminatorInheritance(parsedRoot) : parsedRoot
93
107
 
94
- const root = parser.parse({ dateType, integerType, unknownType, emptySchemaType })
108
+ // This must happen after parseOas() because legacy enum remapping is finalized there.
109
+ nameMapping = parsedNameMapping
110
+ // Expose the raw document so consumers (e.g. plugin-redoc) can access it.
111
+ parsedDocument = document
95
112
 
96
- return createRoot({
97
- ...root,
113
+ inputNode = ast.createInput({
114
+ ...node,
98
115
  meta: {
99
- title: oas.api.info?.title,
100
- version: oas.api.info?.version,
116
+ title: document.info?.title,
117
+ description: document.info?.description,
118
+ version: document.info?.version,
101
119
  baseURL,
102
120
  },
103
121
  })
122
+
123
+ return inputNode
104
124
  },
105
125
  }
106
126
  })
107
-
108
- // TODO: remove once parseFromConfig accepts AdapterSource directly
109
- function sourceToFakeConfig(source: AdapterSource): Parameters<typeof parseFromConfig>[0] {
110
- switch (source.type) {
111
- case 'path':
112
- return { root: path.dirname(source.path), input: { path: source.path } } as Parameters<typeof parseFromConfig>[0]
113
- case 'data':
114
- return { root: process.cwd(), input: { data: source.data } } as Parameters<typeof parseFromConfig>[0]
115
- case 'paths':
116
- return {
117
- root: source.paths[0] ? path.dirname(source.paths[0]) : process.cwd(),
118
- input: source.paths.map((p) => ({ path: p })),
119
- } as Parameters<typeof parseFromConfig>[0]
120
- }
121
- }
package/src/constants.ts CHANGED
@@ -1,41 +1,71 @@
1
- import type { MediaType, SchemaType } from '@kubb/ast/types'
2
- import type { HttpMethods as OASHttpMethods } from 'oas/types'
1
+ import { ast } from '@kubb/core'
3
2
 
4
- // ─── Merge defaults ────────────────────────────────────────────────────────────
3
+ /**
4
+ * Default parser options applied when no explicit options are provided.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import { DEFAULT_PARSER_OPTIONS } from '@kubb/adapter-oas'
9
+ *
10
+ * const parser = createOasParser(oas)
11
+ * const root = parser.parse({ ...DEFAULT_PARSER_OPTIONS, dateType: 'date' })
12
+ * ```
13
+ */
14
+ export const DEFAULT_PARSER_OPTIONS = {
15
+ dateType: 'string',
16
+ integerType: 'number',
17
+ unknownType: 'any',
18
+ emptySchemaType: 'any',
19
+ enumSuffix: 'enum',
20
+ } as const satisfies ast.ParserOptions
5
21
 
6
22
  /**
7
- * OpenAPI version string written into merged document stubs.
23
+ * OpenAPI version string written into the stub document created during multi-spec merges.
8
24
  */
9
25
  export const MERGE_OPENAPI_VERSION = '3.0.0' as const
10
26
 
11
27
  /**
12
- * Fallback `info.title` used when merging multiple API documents.
28
+ * Fallback `info.title` placed in the stub document when merging multiple API files.
13
29
  */
14
30
  export const MERGE_DEFAULT_TITLE = 'Merged API' as const
15
31
 
16
32
  /**
17
- * Fallback `info.version` used when merging multiple API documents.
33
+ * Fallback `info.version` placed in the stub document when merging multiple API files.
18
34
  */
19
35
  export const MERGE_DEFAULT_VERSION = '1.0.0' as const
20
36
 
21
- // ─── Schema analysis ───────────────────────────────────────────────────────────
22
-
23
37
  /**
24
- * JSON Schema keywords that indicate structural composition.
25
- * A schema fragment containing any of these keys must not be inlined into its
26
- * parent during `allOf` flattening it carries semantic meaning of its own.
38
+ * Set of JSON Schema keywords that prevent a schema fragment from being inlined during `allOf` flattening.
39
+ *
40
+ * A fragment that contains any of these keys carries structural meaning of its own and must stay as a separate
41
+ * intersection member rather than being merged into the parent.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * import { structuralKeys } from '@kubb/adapter-oas'
46
+ *
47
+ * const isStructural = Object.keys(fragment).some((key) => structuralKeys.has(key))
48
+ * // true when fragment has e.g. 'properties' or 'oneOf'
49
+ * ```
27
50
  */
28
- export const structuralKeys = new Set<string>(['properties', 'items', 'additionalProperties', 'oneOf', 'anyOf', 'allOf', 'not'])
51
+ export const structuralKeys = new Set(['properties', 'items', 'additionalProperties', 'oneOf', 'anyOf', 'allOf', 'not'] as const)
29
52
 
30
53
  /**
31
- * Maps OAS/JSON Schema `format` strings to their Kubb `SchemaType` equivalents.
54
+ * Static map from OAS `format` strings to Kubb `SchemaType` values.
32
55
  *
33
- * Only formats that need a type different from the raw OAS `type` are listed.
34
- * `int64`, `date-time`, `date`, and `time` are handled separately because their
35
- * mapping depends on runtime parser options.
56
+ * Only formats whose AST type differs from the OAS `type` field appear here.
57
+ * Formats that depend on runtime options (`int64`, `date-time`, `date`, `time`) are handled separately
58
+ * in the parser. `ipv4` and `ipv6` map to their own dedicated schema types; `hostname` and
59
+ * `idn-hostname` map to `'url'` as the closest generic string-format type.
36
60
  *
37
- * Note: `ipv4`, `ipv6`, and `hostname` map to `'url'` — the closest supported
38
- * scalar type in the Kubb AST, even though these are not strictly URLs.
61
+ * @example
62
+ * ```ts
63
+ * import { formatMap } from '@kubb/adapter-oas'
64
+ *
65
+ * formatMap['uuid'] // 'uuid'
66
+ * formatMap['binary'] // 'blob'
67
+ * formatMap['float'] // 'number'
68
+ * ```
39
69
  */
40
70
  export const formatMap = {
41
71
  uuid: 'uuid',
@@ -44,8 +74,8 @@ export const formatMap = {
44
74
  uri: 'url',
45
75
  'uri-reference': 'url',
46
76
  url: 'url',
47
- ipv4: 'url',
48
- ipv6: 'url',
77
+ ipv4: 'ipv4',
78
+ ipv6: 'ipv6',
49
79
  hostname: 'url',
50
80
  'idn-hostname': 'url',
51
81
  binary: 'blob',
@@ -55,56 +85,26 @@ export const formatMap = {
55
85
  int32: 'integer',
56
86
  float: 'number',
57
87
  double: 'number',
58
- } as const satisfies Record<string, SchemaType>
59
-
60
- /**
61
- * Exhaustive list of media types that Kubb recognizes.
62
- * Kept as a module-level constant to avoid re-allocating the array on every call.
63
- */
64
- export const knownMediaTypes = [
65
- 'application/json',
66
- 'application/xml',
67
- 'application/x-www-form-urlencoded',
68
- 'application/octet-stream',
69
- 'application/pdf',
70
- 'application/zip',
71
- 'application/graphql',
72
- 'multipart/form-data',
73
- 'text/plain',
74
- 'text/html',
75
- 'text/csv',
76
- 'text/xml',
77
- 'image/png',
78
- 'image/jpeg',
79
- 'image/gif',
80
- 'image/webp',
81
- 'image/svg+xml',
82
- 'audio/mpeg',
83
- 'video/mp4',
84
- ] as const satisfies ReadonlyArray<MediaType>
88
+ } as const satisfies Record<string, ast.SchemaType>
85
89
 
86
90
  /**
87
- * Vendor extension keys used to attach human-readable labels to enum values.
88
- * 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
+ * ```
89
99
  */
90
100
  export const enumExtensionKeys = ['x-enumNames', 'x-enum-varnames'] as const
91
101
 
92
- // ─── HTTP ──────────────────────────────────────────────────────────────────────
93
-
94
102
  /**
95
- * Canonical HTTP method names for the Kubb OAS layer.
96
- * Keys are uppercase (used in generated code); values are the lowercase strings
97
- * that the `oas` library uses internally.
98
- *
99
- * TODO(v5): remove — use `httpMethods` from `@kubb/ast`
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()`.
100
105
  */
101
- export const httpMethods = {
102
- GET: 'get',
103
- POST: 'post',
104
- PUT: 'put',
105
- PATCH: 'patch',
106
- DELETE: 'delete',
107
- HEAD: 'head',
108
- OPTIONS: 'options',
109
- TRACE: 'trace',
110
- } as const satisfies Record<Uppercase<OASHttpMethods>, OASHttpMethods>
106
+ export const typeOptionMap = new Map<'any' | 'unknown' | 'void', ast.ScalarSchemaType>([
107
+ ['any', ast.schemaTypes.any],
108
+ ['unknown', ast.schemaTypes.unknown],
109
+ ['void', ast.schemaTypes.void],
110
+ ])
@@ -0,0 +1,98 @@
1
+ import { ast } from '@kubb/core'
2
+
3
+ type DiscriminatorTarget = { propertyName: string; enumValues: Array<string | number | boolean> }
4
+
5
+ /**
6
+ * Injects discriminator enum values into child schemas so they know which value identifies them.
7
+ *
8
+ * Finds every union schema in `input.schemas` that has a `discriminatorPropertyName`, collects the
9
+ * enum value each union member is mapped to, then adds (or replaces) that property on the matching
10
+ * child object schema.
11
+ *
12
+ * Returns a new `InputNode` — the original is never mutated.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * const { root } = parseOas(document, options)
17
+ * const next = applyDiscriminatorInheritance(root)
18
+ * ```
19
+ */
20
+ export function applyDiscriminatorInheritance(root: ast.InputNode): ast.InputNode {
21
+ const childMap = new Map<string, DiscriminatorTarget>()
22
+
23
+ for (const schema of root.schemas) {
24
+ // Case 1: top-level schema is a union (oneOf/anyOf with discriminator)
25
+ // Case 2: top-level schema is an intersection wrapping a union (oneOf/anyOf + shared properties)
26
+ let unionNode = ast.narrowSchema(schema, 'union')
27
+
28
+ if (!unionNode) {
29
+ const intersectionMembers = ast.narrowSchema(schema, 'intersection')?.members
30
+ if (intersectionMembers) {
31
+ for (const m of intersectionMembers) {
32
+ const u = ast.narrowSchema(m, 'union')
33
+ if (u) {
34
+ unionNode = u
35
+ break
36
+ }
37
+ }
38
+ }
39
+ }
40
+
41
+ if (!unionNode?.discriminatorPropertyName || !unionNode.members) continue
42
+
43
+ const { discriminatorPropertyName, members } = unionNode
44
+
45
+ for (const member of members) {
46
+ // Members with a discriminant value are intersections: [RefSchemaNode, ObjectSchemaNode]
47
+ const intersectionNode = ast.narrowSchema(member, 'intersection')
48
+ if (!intersectionNode?.members) continue
49
+
50
+ let refNode: ReturnType<typeof ast.narrowSchema<'ref'>> | undefined
51
+ let objNode: ReturnType<typeof ast.narrowSchema<'object'>> | undefined
52
+
53
+ for (const m of intersectionNode.members) {
54
+ refNode ??= ast.narrowSchema(m, 'ref')
55
+ objNode ??= ast.narrowSchema(m, 'object')
56
+ }
57
+
58
+ if (!refNode?.name || !objNode) continue
59
+
60
+ const prop = objNode.properties.find((p) => p.name === discriminatorPropertyName)
61
+ const enumNode = prop ? ast.narrowSchema(prop.schema, 'enum') : undefined
62
+ if (!enumNode?.enumValues?.length) continue
63
+
64
+ const enumValues = enumNode.enumValues.filter((v): v is string | number | boolean => v !== null)
65
+ if (!enumValues.length) continue
66
+
67
+ const existing = childMap.get(refNode.name)
68
+ if (existing) {
69
+ existing.enumValues.push(...enumValues)
70
+ } else {
71
+ childMap.set(refNode.name, { propertyName: discriminatorPropertyName, enumValues: [...enumValues] })
72
+ }
73
+ }
74
+ }
75
+
76
+ if (childMap.size === 0) return root
77
+
78
+ return ast.transform(root, {
79
+ schema(node, { parent }) {
80
+ if (parent?.kind !== 'Input' || !node.name) return
81
+
82
+ const entry = childMap.get(node.name)
83
+ if (!entry) return
84
+
85
+ const objectNode = ast.narrowSchema(node, 'object')
86
+ if (!objectNode) return
87
+
88
+ const { propertyName, enumValues } = entry
89
+ const enumSchema = ast.createSchema({ type: 'enum', enumValues })
90
+ const newProp = ast.createProperty({ name: propertyName, required: true, schema: enumSchema })
91
+
92
+ const existingIdx = objectNode.properties.findIndex((p) => p.name === propertyName)
93
+ const newProperties = existingIdx >= 0 ? objectNode.properties.map((p, i) => (i === existingIdx ? newProp : p)) : [...objectNode.properties, newProp]
94
+
95
+ return { ...objectNode, properties: newProperties }
96
+ },
97
+ })
98
+ }
package/src/factory.ts ADDED
@@ -0,0 +1,162 @@
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
+ export type ValidateDocumentOptions = {
19
+ throwOnError?: boolean
20
+ }
21
+
22
+ /**
23
+ * Loads and dereferences an OpenAPI document, returning the raw `Document`.
24
+ *
25
+ * Accepts a file path string or an already-parsed document object. File paths are bundled via
26
+ * Redocly to resolve external `$ref`s. Swagger 2.0 documents are automatically up-converted
27
+ * to OpenAPI 3.0 via `swagger2openapi`.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * const document = await parseDocument('./openapi.yaml')
32
+ * const document = await parse(rawDocumentObject, { canBundle: false })
33
+ * ```
34
+ */
35
+ export async function parseDocument(pathOrApi: string | Document, { canBundle = true, enablePaths = true }: ParseOptions = {}): Promise<Document> {
36
+ if (typeof pathOrApi === 'string' && canBundle) {
37
+ const config = await loadConfig()
38
+ const bundleResults = await bundle({ ref: pathOrApi, config, base: pathOrApi })
39
+
40
+ return parseDocument(bundleResults.bundle.parsed as string, { canBundle, enablePaths })
41
+ }
42
+
43
+ const oasNormalize = new OASNormalize(pathOrApi, {
44
+ enablePaths,
45
+ colorizeErrors: true,
46
+ })
47
+ const document = (await oasNormalize.load()) as Document
48
+
49
+ if (isOpenApiV2Document(document)) {
50
+ const { openapi } = await swagger2openapi.convertObj(document, {
51
+ anchors: true,
52
+ })
53
+
54
+ return openapi as Document
55
+ }
56
+
57
+ return document
58
+ }
59
+
60
+ /**
61
+ * Deep-merges multiple OpenAPI documents into a single `Document`.
62
+ *
63
+ * Each document is parsed independently then recursively merged with `remeda`'s `mergeDeep`.
64
+ * Throws when the input array is empty.
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * const document = await mergeDocuments(['./pets.yaml', './orders.yaml'])
69
+ * ```
70
+ */
71
+ export async function mergeDocuments(pathOrApi: Array<string | Document>): Promise<Document> {
72
+ const documents: Document[] = []
73
+ for (const p of pathOrApi) {
74
+ documents.push(await parseDocument(p, { enablePaths: false, canBundle: false }))
75
+ }
76
+
77
+ if (documents.length === 0) {
78
+ throw new Error('No OAS documents provided for merging.')
79
+ }
80
+
81
+ const seed: Document = {
82
+ openapi: MERGE_OPENAPI_VERSION,
83
+ info: { title: MERGE_DEFAULT_TITLE, version: MERGE_DEFAULT_VERSION },
84
+ paths: {},
85
+ components: { schemas: {} },
86
+ } as Document
87
+
88
+ const merged = documents.reduce((acc, current) => mergeDeep(acc, current as Document), seed)
89
+
90
+ return parseDocument(merged)
91
+ }
92
+
93
+ /**
94
+ * Creates a `Document` from an `AdapterSource`.
95
+ *
96
+ * Handles all three source types:
97
+ * - `{ type: 'path' }` — resolves and bundles a local file path or remote URL.
98
+ * - `{ type: 'paths' }` — merges multiple file paths into a single document.
99
+ * - `{ type: 'data' }` — parses an inline string (YAML/JSON) or raw object.
100
+ *
101
+ * @example
102
+ * ```ts
103
+ * const document = await parseFromConfig({ type: 'path', path: './openapi.yaml' })
104
+ * const document = await parseFromConfig({ type: 'data', data: '{"openapi":"3.0.0",...}' })
105
+ * ```
106
+ */
107
+ export function parseFromConfig(source: AdapterSource): Promise<Document> {
108
+ if (source.type === 'data') {
109
+ if (typeof source.data === 'object') {
110
+ return parseDocument(structuredClone(source.data) as Document)
111
+ }
112
+
113
+ try {
114
+ const api: string = yaml.parse(source.data as string)
115
+ return parseDocument(api)
116
+ } catch {
117
+ return parseDocument(source.data as string)
118
+ }
119
+ }
120
+
121
+ if (source.type === 'paths') {
122
+ return mergeDocuments(source.paths)
123
+ }
124
+
125
+ // type === 'path'
126
+ if (new URLPath(source.path).isURL) {
127
+ return parseDocument(source.path)
128
+ }
129
+
130
+ return parseDocument(path.resolve(path.dirname(source.path), source.path))
131
+ }
132
+
133
+ /**
134
+ * Validates an OpenAPI document using `oas-normalize` with colorized error output.
135
+ *
136
+ * @example
137
+ * ```ts
138
+ * await validateDocument(document)
139
+ * ```
140
+ */
141
+ export async function validateDocument(document: Document, { throwOnError = false }: ValidateDocumentOptions = {}): Promise<void> {
142
+ try {
143
+ const oasNormalize = new OASNormalize(document, {
144
+ enablePaths: true,
145
+ colorizeErrors: true,
146
+ })
147
+
148
+ await oasNormalize.validate({
149
+ parser: {
150
+ validate: {
151
+ errors: { colorize: true },
152
+ },
153
+ },
154
+ })
155
+ } catch (error) {
156
+ if (throwOnError) {
157
+ throw error
158
+ }
159
+
160
+ // Validation failures are non-fatal — mirror plugin-oas behavior
161
+ }
162
+ }