@kubb/adapter-oas 5.0.0-beta.53 → 5.0.0-beta.55

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-beta.53",
3
+ "version": "5.0.0-beta.55",
4
4
  "description": "OpenAPI and Swagger adapter for Kubb. Parses and validates OAS 2.0/3.x specifications into a @kubb/ast RootNode for downstream code generation plugins.",
5
5
  "keywords": [
6
6
  "adapter",
@@ -42,16 +42,15 @@
42
42
  "registry": "https://registry.npmjs.org/"
43
43
  },
44
44
  "dependencies": {
45
+ "@readme/openapi-parser": "^6.1.3",
45
46
  "api-ref-bundler": "0.5.1",
46
- "js-yaml": "^4.2.0",
47
- "oas": "^32.1.18",
48
- "oas-normalize": "^16.0.5",
49
47
  "swagger2openapi": "^7.0.8",
50
- "@kubb/ast": "5.0.0-beta.53",
51
- "@kubb/core": "5.0.0-beta.53"
48
+ "yaml": "^2.9.0",
49
+ "@kubb/ast": "5.0.0-beta.55",
50
+ "@kubb/core": "5.0.0-beta.55"
52
51
  },
53
52
  "devDependencies": {
54
- "@types/js-yaml": "^4.0.9",
53
+ "@types/json-schema": "^7.0.15",
55
54
  "@types/swagger2openapi": "^7.0.4",
56
55
  "openapi-types": "^12.1.3",
57
56
  "@internals/utils": "0.0.0"
@@ -60,6 +59,7 @@
60
59
  "node": ">=22"
61
60
  },
62
61
  "inlinedDependencies": {
62
+ "@types/json-schema": "7.0.15",
63
63
  "openapi-types": "12.1.3"
64
64
  },
65
65
  "scripts": {
package/src/adapter.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { ast, createAdapter } from '@kubb/core'
2
2
  import type { AdapterSource } from '@kubb/core'
3
- import BaseOas from 'oas'
4
3
  import { DEFAULT_PARSER_OPTIONS } from './constants.ts'
5
4
  import { assertInputExists, parseDocument, parseFromConfig, validateDocument } from './factory.ts'
6
5
  import { createSchemaParser } from './parser.ts'
7
6
  import { getSchemas } from './resolvers.ts'
8
7
  import { createInputStream, preScan, resolveBaseUrl } from './stream.ts'
9
8
  import type { AdapterOas, Document } from './types.ts'
9
+ import { collect, narrowSchema } from '@kubb/ast'
10
+ import { extractRefName } from '@kubb/ast/utils'
10
11
 
11
12
  /**
12
13
  * Canonical adapter name for `@kubb/adapter-oas`. Used for driver lookups.
@@ -74,7 +75,6 @@ export const adapterOas = createAdapter<AdapterOas>((options) => {
74
75
  // resulting document, so distinct configs (distinct documents) stay isolated.
75
76
  const documentCache = new WeakMap<AdapterSource, Promise<Document>>()
76
77
  const schemasCache = new WeakMap<Document, Promise<ReturnType<typeof getSchemas>['schemas']>>()
77
- const baseOasCache = new WeakMap<Document, BaseOas>()
78
78
  const schemaParserCache = new WeakMap<Document, ReturnType<typeof createSchemaParser>>()
79
79
  const preScanCache = new WeakMap<Document, ReturnType<typeof preScan>>()
80
80
 
@@ -105,15 +105,6 @@ export const adapterOas = createAdapter<AdapterOas>((options) => {
105
105
  return promise
106
106
  }
107
107
 
108
- function ensureBaseOas(document: Document): BaseOas {
109
- const cached = baseOasCache.get(document)
110
- if (cached) return cached
111
-
112
- const baseOas = new BaseOas(document)
113
- baseOasCache.set(document, baseOas)
114
- return baseOas
115
- }
116
-
117
108
  function ensureSchemaParser(document: Document): ReturnType<typeof createSchemaParser> {
118
109
  const cached = schemaParserCache.get(document)
119
110
  if (cached) return cached
@@ -128,28 +119,26 @@ export const adapterOas = createAdapter<AdapterOas>((options) => {
128
119
  schemas: ReturnType<typeof getSchemas>['schemas'],
129
120
  parseSchema: ReturnType<typeof ensureSchemaParser>['parseSchema'],
130
121
  parseOperation: ReturnType<typeof ensureSchemaParser>['parseOperation'],
131
- baseOas: BaseOas,
132
122
  ): ReturnType<typeof preScan> {
133
123
  const cached = preScanCache.get(document)
134
124
  if (cached) return cached
135
125
 
136
- const result = preScan({ schemas, parseSchema, parseOperation, baseOas, parserOptions, discriminator, dedupe })
126
+ const result = preScan({ schemas, parseSchema, parseOperation, document, parserOptions, discriminator, dedupe })
137
127
  preScanCache.set(document, result)
138
128
  return result
139
129
  }
140
130
 
141
- async function createStream(source: AdapterSource): Promise<ast.InputStreamNode> {
131
+ async function createStream(source: AdapterSource): Promise<ast.InputNode<true>> {
142
132
  const document = await ensureDocument(source)
143
133
  const schemas = await ensureSchemas(document)
144
134
  const { parseSchema, parseOperation } = ensureSchemaParser(document)
145
- const baseOas = ensureBaseOas(document)
146
- const { refAliasMap, enumNames, circularNames, discriminatorChildMap, dedupePlan } = ensurePreScan(document, schemas, parseSchema, parseOperation, baseOas)
135
+ const { refAliasMap, enumNames, circularNames, discriminatorChildMap, dedupePlan } = ensurePreScan(document, schemas, parseSchema, parseOperation)
147
136
 
148
137
  return createInputStream({
149
138
  schemas,
150
139
  parseSchema,
151
140
  parseOperation,
152
- baseOas,
141
+ document,
153
142
  parserOptions,
154
143
  refAliasMap,
155
144
  discriminatorChildMap,
@@ -192,10 +181,13 @@ export const adapterOas = createAdapter<AdapterOas>((options) => {
192
181
  await validateDocument(document, options)
193
182
  },
194
183
  getImports(node, resolve) {
195
- return ast.collectImports({
196
- node,
197
- nameMapping,
198
- resolve: (schemaName) => {
184
+ return collect(node, {
185
+ schema(schemaNode) {
186
+ const schemaRef = narrowSchema(schemaNode, 'ref')
187
+ if (!schemaRef?.ref) return null
188
+
189
+ const rawName = extractRefName(schemaRef.ref)
190
+ const schemaName = nameMapping.get(rawName) ?? rawName
199
191
  const result = resolve(schemaName)
200
192
  if (!result) return null
201
193
 
package/src/bundler.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { read } from '@internals/utils'
2
2
  import { bundle } from 'api-ref-bundler'
3
- import yaml from 'js-yaml'
3
+ import { parse } from 'yaml'
4
4
  import type { Document } from './types.ts'
5
5
 
6
6
  const urlRegExp = /^https?:\/+/i
@@ -29,7 +29,7 @@ async function resolveSource(sourcePath: string): Promise<object | string> {
29
29
  return data
30
30
  }
31
31
 
32
- return yaml.load(data) as object
32
+ return parse(data) as object
33
33
  }
34
34
 
35
35
  /**
package/src/constants.ts CHANGED
@@ -31,6 +31,12 @@ export const DEFAULT_PARSER_OPTIONS = {
31
31
  */
32
32
  export const SCHEMA_REF_PREFIX = '#/components/schemas/' as const
33
33
 
34
+ /**
35
+ * HTTP methods that count as operations on an OpenAPI path item. Other keys
36
+ * (`parameters`, `summary`, `$ref`, vendor extensions) are skipped when iterating operations.
37
+ */
38
+ export const SUPPORTED_METHODS: ReadonlySet<string> = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'])
39
+
34
40
  /**
35
41
  * OpenAPI version string written into the stub document created during multi-spec merges.
36
42
  */
package/src/factory.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import path from 'node:path'
2
- import { exists, mergeDeep, URLPath } from '@internals/utils'
2
+ import { exists, mergeDeep, Url } from '@internals/utils'
3
3
  import { Diagnostics } from '@kubb/core'
4
4
  import type { AdapterSource } from '@kubb/core'
5
- import OASNormalize from 'oas-normalize'
5
+ import { compileErrors, validate } from '@readme/openapi-parser'
6
+ import { parse } from 'yaml'
6
7
  import { bundleDocument } from './bundler.ts'
7
8
  import { MERGE_DEFAULT_TITLE, MERGE_DEFAULT_VERSION, MERGE_OPENAPI_VERSION } from './constants.ts'
8
9
  import { isOpenApiV2Document } from './guards.ts'
@@ -10,7 +11,6 @@ import type { Document } from './types.ts'
10
11
 
11
12
  export type ParseOptions = {
12
13
  canBundle?: boolean
13
- enablePaths?: boolean
14
14
  }
15
15
 
16
16
  export type ValidateDocumentOptions = {
@@ -31,18 +31,16 @@ export type ValidateDocumentOptions = {
31
31
  * const document = await parse(rawDocumentObject, { canBundle: false })
32
32
  * ```
33
33
  */
34
- export async function parseDocument(pathOrApi: string | Document, { canBundle = true, enablePaths = true }: ParseOptions = {}): Promise<Document> {
34
+ export async function parseDocument(pathOrApi: string | Document, { canBundle = true }: ParseOptions = {}): Promise<Document> {
35
35
  if (typeof pathOrApi === 'string' && canBundle) {
36
36
  const bundled = await bundleDocument(pathOrApi)
37
37
 
38
- return parseDocument(bundled, { canBundle: false, enablePaths })
38
+ return parseDocument(bundled, { canBundle: false })
39
39
  }
40
40
 
41
- const oasNormalize = new OASNormalize(pathOrApi, {
42
- enablePaths,
43
- colorizeErrors: true,
44
- })
45
- const document = (await oasNormalize.load()) as Document
41
+ // A string here is always inline YAML/JSON content: file paths and URLs are read and parsed by
42
+ // `bundleDocument` first. `yaml.parse` also parses JSON, since JSON is a subset of YAML.
43
+ const document = (typeof pathOrApi === 'string' ? parse(pathOrApi) : pathOrApi) as Document
46
44
 
47
45
  if (isOpenApiV2Document(document)) {
48
46
  const { default: swagger2openapi } = await import('swagger2openapi')
@@ -59,7 +57,7 @@ export async function parseDocument(pathOrApi: string | Document, { canBundle =
59
57
  /**
60
58
  * Deep-merges multiple OpenAPI documents into a single `Document`.
61
59
  *
62
- * Each document is parsed independently then recursively merged via `mergeDeep` from `@internals/utils`.
60
+ * Each document is parsed independently, then deep-merged into one in array order.
63
61
  * Throws when the input array is empty.
64
62
  *
65
63
  * @example
@@ -68,7 +66,7 @@ export async function parseDocument(pathOrApi: string | Document, { canBundle =
68
66
  * ```
69
67
  */
70
68
  export async function mergeDocuments(pathOrApi: Array<string | Document>): Promise<Document> {
71
- const documents = await Promise.all(pathOrApi.map((p) => parseDocument(p, { enablePaths: false, canBundle: false })))
69
+ const documents = await Promise.all(pathOrApi.map((p) => parseDocument(p, { canBundle: false })))
72
70
 
73
71
  if (documents.length === 0) {
74
72
  throw new Diagnostics.Error({
@@ -123,7 +121,7 @@ export async function parseFromConfig(source: AdapterSource): Promise<Document>
123
121
  }
124
122
 
125
123
  // type === 'path'
126
- if (new URLPath(source.path).isURL) {
124
+ if (Url.canParse(source.path)) {
127
125
  return parseDocument(source.path)
128
126
  }
129
127
 
@@ -138,7 +136,7 @@ export async function parseFromConfig(source: AdapterSource): Promise<Document>
138
136
  * its parse error instead.
139
137
  */
140
138
  export async function assertInputExists(input: string): Promise<void> {
141
- if (new URLPath(input).isURL) {
139
+ if (Url.canParse(input)) {
142
140
  return
143
141
  }
144
142
  if (!(await exists(input))) {
@@ -153,7 +151,7 @@ export async function assertInputExists(input: string): Promise<void> {
153
151
  }
154
152
 
155
153
  /**
156
- * Validates an OpenAPI document using `oas-normalize` with colorized error output.
154
+ * Validates an OpenAPI document using `@readme/openapi-parser` with colorized error output.
157
155
  *
158
156
  * @example
159
157
  * ```ts
@@ -162,18 +160,16 @@ export async function assertInputExists(input: string): Promise<void> {
162
160
  */
163
161
  export async function validateDocument(document: Document, { throwOnError = false }: ValidateDocumentOptions = {}): Promise<void> {
164
162
  try {
165
- const oasNormalize = new OASNormalize(document, {
166
- enablePaths: true,
167
- colorizeErrors: true,
168
- })
169
-
170
- await oasNormalize.validate({
171
- parser: {
172
- validate: {
173
- errors: { colorize: true },
174
- },
163
+ // `validate` dereferences its input in place, so clone to keep the cached document intact.
164
+ const result = await validate(structuredClone(document), {
165
+ validate: {
166
+ errors: { colorize: true },
175
167
  },
176
168
  })
169
+
170
+ if (!result.valid) {
171
+ throw new Error(compileErrors(result))
172
+ }
177
173
  } catch (error) {
178
174
  if (throwOnError) {
179
175
  throw error
package/src/mime.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * MIME type fragments that mark a media type as JSON-like.
3
+ *
4
+ * Matches the substrings used by the `oas` library's `matchesMimeType.json`, so a content type is
5
+ * JSON when it contains any of these. The `+json` entry catches structured-syntax suffixes such as
6
+ * `application/vnd.api+json`.
7
+ */
8
+ const jsonMimeFragments = ['application/json', 'application/x-json', 'text/json', 'text/x-json', '+json'] as const
9
+
10
+ /**
11
+ * Returns `true` when a media type string is JSON-like.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * isJsonMimeType('application/json') // true
16
+ * isJsonMimeType('application/vnd.api+json') // true
17
+ * isJsonMimeType('multipart/form-data') // false
18
+ * ```
19
+ */
20
+ export function isJsonMimeType(mimeType: string): boolean {
21
+ return jsonMimeFragments.some((fragment) => mimeType.includes(fragment))
22
+ }
@@ -0,0 +1,194 @@
1
+ import { SUPPORTED_METHODS } from './constants.ts'
2
+ import { isReference } from './guards.ts'
3
+ import { isJsonMimeType } from './mime.ts'
4
+ import { resolveRef } from './refs.ts'
5
+ import type { Document, MediaTypeObject, OperationObject, PathItemObject, ReferenceObject, RequestBodyObject, ResponseObject } from './types.ts'
6
+
7
+ /**
8
+ * A single OpenAPI operation: its URL path, HTTP method, and the raw operation object.
9
+ *
10
+ * `schema` is a live reference into the document, so any in-place `$ref` resolution the resolvers
11
+ * perform is visible here too.
12
+ */
13
+ export type Operation = {
14
+ path: string
15
+ method: string
16
+ schema: OperationObject
17
+ }
18
+
19
+ /**
20
+ * The document plus the operation being read. Shared by the request/response accessors so they can
21
+ * resolve `$ref`s against the document.
22
+ */
23
+ type OperationContext = {
24
+ document: Document
25
+ operation: Operation
26
+ }
27
+
28
+ /**
29
+ * Slugifies a path for the `operationId` fallback: non-alphanumerics collapse to single dashes,
30
+ * with no leading or trailing dash.
31
+ */
32
+ function slugify(value: string): string {
33
+ return value
34
+ .replace(/[^a-zA-Z0-9]/g, '-')
35
+ .replace(/-{2,}/g, '-')
36
+ .replace(/^-|-$/g, '')
37
+ }
38
+
39
+ /**
40
+ * Returns the operation's `operationId`, falling back to `<method>_<slugified-path>` when absent.
41
+ */
42
+ export function getOperationId({ path, method, schema }: Operation): string {
43
+ const { operationId } = schema
44
+ if (typeof operationId === 'string' && operationId.length > 0) {
45
+ return operationId
46
+ }
47
+ return `${method}_${slugify(path).toLowerCase()}`
48
+ }
49
+
50
+ /**
51
+ * Returns the declared response status codes, skipping `x-` extensions and non-object entries.
52
+ */
53
+ export function getResponseStatusCodes({ schema }: Operation): Array<string> {
54
+ const responses = schema.responses as Record<string, unknown> | undefined
55
+ if (!responses || isReference(responses)) {
56
+ return []
57
+ }
58
+ return Object.keys(responses).filter((key) => !key.startsWith('x-') && !!responses[key] && typeof responses[key] === 'object')
59
+ }
60
+
61
+ /**
62
+ * Returns the response object for a status code, resolving a `$ref` in place. `false` when absent.
63
+ */
64
+ export function getResponseByStatusCode({ document, operation, statusCode }: OperationContext & { statusCode: string | number }): ResponseObject | false {
65
+ const responses = operation.schema.responses as Record<string, ResponseObject | ReferenceObject> | undefined
66
+ if (!responses || isReference(responses)) {
67
+ return false
68
+ }
69
+ const response = responses[statusCode]
70
+ if (!response) {
71
+ return false
72
+ }
73
+ if (isReference(response)) {
74
+ const resolved = resolveRef<ResponseObject>(document, response.$ref)
75
+ responses[statusCode] = resolved as ResponseObject
76
+ if (!resolved || isReference(resolved)) {
77
+ return false
78
+ }
79
+ return resolved
80
+ }
81
+ return response
82
+ }
83
+
84
+ /**
85
+ * Resolves the request body (dereferencing a `$ref` in place) and returns its content map, or
86
+ * `undefined` when the operation has no request body.
87
+ */
88
+ function getRequestBodyContent({ document, operation }: OperationContext): Record<string, MediaTypeObject> | undefined {
89
+ const { schema } = operation
90
+ let requestBody = schema.requestBody as RequestBodyObject | ReferenceObject | undefined
91
+ if (!requestBody) {
92
+ return undefined
93
+ }
94
+ if (isReference(requestBody)) {
95
+ const resolved = resolveRef<RequestBodyObject>(document, requestBody.$ref)
96
+ ;(schema as { requestBody?: unknown }).requestBody = resolved
97
+ if (!resolved || isReference(resolved)) {
98
+ return undefined
99
+ }
100
+ requestBody = resolved
101
+ }
102
+ return requestBody.content
103
+ }
104
+
105
+ /**
106
+ * Returns the request body media type. With `mediaType` set, returns that entry or `false`.
107
+ * Otherwise picks the first JSON-like media type, then the first declared one, as a
108
+ * `[mediaType, object]` tuple.
109
+ */
110
+ export function getRequestContent({
111
+ document,
112
+ operation,
113
+ mediaType,
114
+ }: OperationContext & { mediaType?: string }): MediaTypeObject | false | [string, MediaTypeObject] {
115
+ const content = getRequestBodyContent({ document, operation })
116
+ if (!content) {
117
+ return false
118
+ }
119
+ if (mediaType) {
120
+ return mediaType in content ? content[mediaType]! : false
121
+ }
122
+ const mediaTypes = Object.keys(content)
123
+ const available = mediaTypes.find((mt) => isJsonMimeType(mt)) ?? mediaTypes[0]
124
+ return available ? [available, content[available]!] : false
125
+ }
126
+
127
+ /**
128
+ * Returns the primary request content type. Prefers a JSON-like media type (the last one wins,
129
+ * matching the previous behavior), then the first declared one, defaulting to `'application/json'`.
130
+ */
131
+ export function getRequestContentType({ document, operation }: OperationContext): string {
132
+ const content = getRequestBodyContent({ document, operation })
133
+ const mediaTypes = content ? Object.keys(content) : []
134
+
135
+ let result = mediaTypes[0] ?? 'application/json'
136
+ for (const mt of mediaTypes) {
137
+ if (isJsonMimeType(mt)) {
138
+ result = mt
139
+ }
140
+ }
141
+ return result
142
+ }
143
+
144
+ /**
145
+ * Builds an `Operation` for every supported HTTP method on every path, in document order.
146
+ * `x-` path keys and unresolvable path-item `$ref`s are skipped.
147
+ *
148
+ * @example
149
+ * ```ts
150
+ * for (const operation of getOperations(document)) {
151
+ * parseOperation(options, operation)
152
+ * }
153
+ * ```
154
+ */
155
+ export function getOperations(document: Document): Array<Operation> {
156
+ const operations: Array<Operation> = []
157
+ const paths = document.paths
158
+ if (!paths) {
159
+ return operations
160
+ }
161
+
162
+ for (const path of Object.keys(paths)) {
163
+ if (path.startsWith('x-')) {
164
+ continue
165
+ }
166
+
167
+ let pathItem = paths[path] as PathItemObject | ReferenceObject | undefined
168
+ if (!pathItem) {
169
+ continue
170
+ }
171
+ if (isReference(pathItem)) {
172
+ const resolved = resolveRef<PathItemObject>(document, pathItem.$ref)
173
+ ;(paths as Record<string, unknown>)[path] = resolved
174
+ if (!resolved || isReference(resolved)) {
175
+ continue
176
+ }
177
+ pathItem = resolved
178
+ }
179
+
180
+ const item = pathItem as Record<string, unknown>
181
+ for (const method of Object.keys(item)) {
182
+ if (!SUPPORTED_METHODS.has(method)) {
183
+ continue
184
+ }
185
+ const schema = item[method]
186
+ if (!schema || typeof schema !== 'object') {
187
+ continue
188
+ }
189
+ operations.push({ path, method, schema: schema as OperationObject })
190
+ }
191
+ }
192
+
193
+ return operations
194
+ }
package/src/parser.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { pascalCase, URLPath } from '@internals/utils'
1
+ import { pascalCase } from '@internals/utils'
2
2
  import { childName, enumPropName, extractRefName, findDiscriminator } from '@kubb/ast/utils'
3
3
  import { ast } from '@kubb/core'
4
- import BaseOas from 'oas'
5
4
  import { DEFAULT_PARSER_OPTIONS, enumExtensionKeys, SCHEMA_REF_PREFIX, typeOptionMap } from './constants.ts'
6
5
  import { oasDialect, type OasDialect } from './dialect.ts'
6
+ import { getOperationId, getOperations, getRequestContentType, getResponseByStatusCode, getResponseStatusCodes } from './operation.ts'
7
7
  import {
8
8
  buildSchemaNode,
9
9
  flattenSchema,
@@ -61,11 +61,18 @@ type SchemaContext = {
61
61
  }
62
62
 
63
63
  /**
64
- * One entry in this adapter's schema-dispatch table, a {@link ast.DispatchRule} specialized
65
- * to OAS schema context and Kubb `SchemaNode` output. See {@link ast.dispatch} for the contract
66
- * a future adapter (e.g. AsyncAPI) follows: define a context type and an ordered rules table.
64
+ * One entry in the ordered schema rule table: a predicate paired with a converter. A rule whose
65
+ * `match` returns `true` may still `convert` to `null` to defer to the next rule (e.g. a `format`
66
+ * that is not convertible falls through to plain `type` handling).
67
67
  */
68
- type SchemaRule = ast.DispatchRule<SchemaContext, ast.SchemaNode>
68
+ type SchemaRule = {
69
+ /** Identifies the rule when reading the table or debugging which branch ran. */
70
+ name: string
71
+ /** Returns `true` when this rule is responsible for the given context. */
72
+ match: (context: SchemaContext) => boolean
73
+ /** Produces a node for the context, or `null` to fall through to the next rule. */
74
+ convert: (context: SchemaContext) => ast.SchemaNode | null
75
+ }
69
76
 
70
77
  /**
71
78
  * Normalizes malformed `{ type: 'array', enum: [...] }` schemas by moving enum values into items.
@@ -772,7 +779,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
772
779
  }
773
780
 
774
781
  /**
775
- * Ordered schema-dispatch table. Order is significant: composition keywords (`$ref`, `allOf`,
782
+ * Ordered schema rule table. Order is significant: composition keywords (`$ref`, `allOf`,
776
783
  * `oneOf`/`anyOf`) take precedence over `const`/`format`, which take precedence over the plain
777
784
  * `type`. The first matching rule that produces a node wins. See {@link SchemaRule} for the
778
785
  * match/convert/fall-through contract.
@@ -815,10 +822,10 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
815
822
  ]
816
823
 
817
824
  /**
818
- * Central dispatcher that converts an OAS `SchemaObject` into a `SchemaNode`.
825
+ * Converts an OAS `SchemaObject` into a `SchemaNode`.
819
826
  *
820
- * Builds the per-schema context, then runs it through the ordered {@link schemaRules} table
821
- * via {@link ast.dispatch}. When no rule produces a node, falls back to the configured
827
+ * Builds the per-schema context, then walks the ordered {@link schemaRules} table and returns
828
+ * the first converter that produces a node. When none match, falls back to the configured
822
829
  * `emptySchemaType`.
823
830
  */
824
831
  function parseSchema({ schema, name }: { schema: SchemaObject; name?: string | null }, rawOptions?: Partial<ast.ParserOptions>): ast.SchemaNode {
@@ -845,8 +852,11 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
845
852
  options,
846
853
  }
847
854
 
848
- const node = ast.dispatch(schemaRules, schemaCtx)
849
- if (node) return node
855
+ for (const rule of schemaRules) {
856
+ if (!rule.match(schemaCtx)) continue
857
+ const node = rule.convert(schemaCtx)
858
+ if (node) return node
859
+ }
850
860
 
851
861
  const emptyType = typeOptionMap.get(options.emptySchemaType)!
852
862
  return ast.createSchema({
@@ -937,7 +947,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
937
947
  * Converts an OAS `Operation` into an `OperationNode`.
938
948
  */
939
949
  function parseOperation(options: ast.ParserOptions, operation: Operation): ast.OperationNode {
940
- const operationId = operation.getOperationId()
950
+ const operationId = getOperationId(operation)
941
951
  const operationName = operationId ? pascalCase(operationId) : undefined
942
952
  const parameters: Array<ast.ParameterNode> = getParameters(document, operation).map((param) =>
943
953
  parseParameter(options, param as unknown as Record<string, unknown>, operationName),
@@ -972,8 +982,8 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
972
982
  }
973
983
  : undefined
974
984
 
975
- const responses: Array<ast.ResponseNode> = operation.getResponseStatusCodes().map((statusCode) => {
976
- const responseObj = operation.getResponseByStatusCode(statusCode)
985
+ const responses: Array<ast.ResponseNode> = getResponseStatusCodes(operation).map((statusCode) => {
986
+ const responseObj = getResponseByStatusCode({ document, operation, statusCode })
977
987
 
978
988
  // Use `Status<code>` (matching plugin-ts's resolveResponseStatusName convention) so the
979
989
  // qualified names for nested enums don't collide with top-level component schemas that
@@ -998,7 +1008,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
998
1008
  // Body-less responses keep a single fallback entry so the response still resolves to a
999
1009
  // (void/any) schema, matching how `requestBody` only carries schemas inside `content`.
1000
1010
  if (content.length === 0) {
1001
- content.push({ contentType: operation.contentType || 'application/json', ...parseEntrySchema(ctx.contentType) })
1011
+ content.push({ contentType: getRequestContentType({ document, operation }) || 'application/json', ...parseEntrySchema(ctx.contentType) })
1002
1012
  }
1003
1013
 
1004
1014
  return ast.createResponse({
@@ -1008,17 +1018,24 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
1008
1018
  })
1009
1019
  })
1010
1020
 
1011
- const urlPath = new URLPath(operation.path)
1021
+ const pathItem = document.paths?.[operation.path]
1022
+ const pathItemDoc = pathItem && !dialect.isReference(pathItem) ? (pathItem as { summary?: unknown; description?: unknown }) : undefined
1023
+ const pickDoc = (key: 'summary' | 'description'): string | undefined => {
1024
+ const own = operation.schema[key]
1025
+ if (typeof own === 'string') return own
1026
+ const fallback = pathItemDoc?.[key]
1027
+ return typeof fallback === 'string' ? fallback : undefined
1028
+ }
1012
1029
 
1013
1030
  return ast.createOperation({
1014
1031
  operationId,
1015
1032
  protocol: 'http',
1016
1033
  method: operation.method.toUpperCase() as ast.HttpMethod,
1017
- path: urlPath.path,
1018
- tags: operation.getTags().map((tag) => tag.name),
1019
- summary: operation.getSummary() || undefined,
1020
- description: operation.getDescription() || undefined,
1021
- deprecated: operation.isDeprecated() || undefined,
1034
+ path: operation.path,
1035
+ tags: Array.isArray(operation.schema.tags) ? operation.schema.tags.map(String) : [],
1036
+ summary: pickDoc('summary') || undefined,
1037
+ description: pickDoc('description') || undefined,
1038
+ deprecated: operation.schema.deprecated || undefined,
1022
1039
  parameters,
1023
1040
  requestBody,
1024
1041
  responses,
@@ -1084,14 +1101,9 @@ export function parseOas(
1084
1101
 
1085
1102
  const schemas: Array<ast.SchemaNode> = Object.entries(schemaObjects).map(([name, schema]) => _parseSchema({ schema, name }, mergedOptions))
1086
1103
 
1087
- const baseOas = new BaseOas(document)
1088
- const paths = baseOas.getPaths()
1089
-
1090
- const operations: Array<ast.OperationNode> = Object.entries(paths).flatMap(([, methods]) =>
1091
- Object.entries(methods)
1092
- .map(([, operation]) => (operation ? _parseOperation(mergedOptions, operation) : null))
1093
- .filter((op): op is ast.OperationNode => op !== null),
1094
- )
1104
+ const operations: Array<ast.OperationNode> = getOperations(document)
1105
+ .map((operation) => _parseOperation(mergedOptions, operation))
1106
+ .filter((op): op is ast.OperationNode => op !== null)
1095
1107
 
1096
1108
  const root = ast.createInput({ schemas, operations })
1097
1109