Skip to content

Commit 0a700b1

Browse files
authored
feat: support for reusable pathItems definitions in components for OAS 3.1 (#26)
1 parent 83f9c5c commit 0a700b1

File tree

10 files changed

+205
-40
lines changed

10 files changed

+205
-40
lines changed

src/define-origins-and-resolve-ref.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
ResolveOptions,
2323
RichReference,
2424
} from './types'
25-
import { resolveSpec, SPEC_TYPE_GRAPH_API, SPEC_TYPE_OPEN_API_31 } from './spec-type'
25+
import { resolveSpec } from './spec-type'
2626
import { ErrorMessage } from './errors'
2727
import { createCycledJsoHandlerHook } from './cycle-jso'
2828
import { JSON_SCHEMA_PROPERTY_ALL_OF, JSON_SCHEMA_PROPERTY_REF } from './rules/jsonschema.const'
@@ -60,12 +60,14 @@ const IMPOSSIBLE_ORIGIN_PARENT: ChainItem = { parent: undefined, value: 'ERROR!!
6060

6161
export const defineOriginsAndResolveRef = (value: unknown, options?: ResolveOptions) => {
6262
const spec = resolveSpec(value)
63+
const source = options?.source ?? value
64+
6365
const internalOptions = {
6466
resolveRef: DEFAULT_OPTION_RESOLVE_REF,
6567
originsAlreadyDefined: !!options?.originsFlag,
6668
...options,
6769
originsFlag: options?.originsAlreadyDefined ? undefined : options?.originsFlag,
68-
source: options?.source ?? value,
70+
source,
6971
ignoreSymbols: new Set([
7072
...(options?.originsFlag ? [options.originsFlag] : []),
7173
...(options?.inlineRefsFlag ? [options.inlineRefsFlag] : []),
@@ -140,7 +142,13 @@ export const deDefineOriginsAndResolvedRefSymbols = (value: unknown, options?: R
140142
const createDefineOriginsAndResolveRefHook: (rootJso: unknown, options: InternalResolveOptions, cycleJsoHook: SyncCloneHook<DefineOriginsAndResolveRefState>) => DefineOriginsAndResolveRefSyncCloneHook = (rootJso, options, cycleJsoHook) => {
141143
const cyclingGuard: Set<unknown> = new Set()
142144
const syntheticTitleCache: Map<string, Record<PropertyKey, unknown>> = new Map()
143-
const defineOriginsAndResolveRefHook: DefineOriginsAndResolveRefSyncCloneHook = ({ key, value, state, path, rules, }) => {
145+
const defineOriginsAndResolveRefHook: DefineOriginsAndResolveRefSyncCloneHook = ({
146+
key,
147+
value,
148+
state,
149+
path,
150+
rules,
151+
}) => {
144152
if (state.ignoreTreeUnderSymbols) {
145153
return { value }
146154
}
@@ -189,7 +197,7 @@ const createDefineOriginsAndResolveRefHook: (rootJso: unknown, options: Internal
189197
}
190198
const reference = parseRef($ref)
191199

192-
const processWrapRefWithAllOfReference = (resolvedRefWithSibling: ResolvedRefWithSiblings) => {
200+
const processWrapRefWithAllOfReference = (resolvedRefWithSibling: ResolvedRefWithSiblings) => {
193201
const {
194202
refValue,
195203
origin,
@@ -282,9 +290,9 @@ const createDefineOriginsAndResolveRefHook: (rootJso: unknown, options: Internal
282290
}
283291
}
284292

285-
const processResolvedReference = (resolvedRefWithSibling: ResolvedRefWithSiblings) => {
293+
const processResolvedReference = (resolvedRefWithSibling: ResolvedRefWithSiblings) => {
286294
if (hasChildrenOrigins(resolvedRefWithSibling)) {
287-
return processReferenceWithChildren(resolvedRefWithSibling)
295+
return processReferenceWithChildren(resolvedRefWithSibling)
288296
}
289297
return processWrapRefWithAllOfReference(resolvedRefWithSibling)
290298
}

src/normalize.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
NormalizeOptions,
1111
} from './types'
1212
import { deDefineOriginsAndResolvedRefSymbols, defineOriginsAndResolveRef } from './define-origins-and-resolve-ref'
13-
import { validate } from './validate'
13+
import { preValidate, validate } from './validate'
1414
import { merge } from './merge'
1515
import { cleanUpSynthetic, deCleanUpSynthetic, deUnify, unify } from './unify'
1616
import { deHash, hash } from './hash'
@@ -19,7 +19,8 @@ import { removeOasExtensions } from './remove-oas-extensions'
1919
export const normalize = (value: unknown, options: NormalizeOptions = {}) => {
2020
const optionsWithDefaults = createOptionsWithDefaults(options)
2121
let spec = value
22-
if (optionsWithDefaults.resolveRef || (!optionsWithDefaults.originsAlreadyDefined && optionsWithDefaults.originsFlag)) { spec = defineOriginsAndResolveRef(spec, optionsWithDefaults) }
22+
if (optionsWithDefaults.validate) { preValidate(spec, options) }
23+
if (optionsWithDefaults.resolveRef || (!optionsWithDefaults.originsAlreadyDefined && optionsWithDefaults.originsFlag)) {spec = defineOriginsAndResolveRef(spec, optionsWithDefaults)}
2324
if (optionsWithDefaults.validate) { spec = validate(spec, optionsWithDefaults) }
2425
if (optionsWithDefaults.mergeAllOf) { spec = merge(spec, optionsWithDefaults) }
2526
if (optionsWithDefaults.unify) { spec = unify(spec, optionsWithDefaults) }

src/rules/jsonschema.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,6 @@ export const jsonSchemaRules: (
438438
hashStrategy: CURRENT_DATA_LEVEL,
439439
},
440440
'/properties': {
441-
442441
'/*': () => ({
443442
...self(),
444443
newDataLayer: true,

src/rules/openapi.const.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export const OPEN_API_PROPERTY_SCHEMAS = 'schemas'
4747
export const OPEN_API_PROPERTY_SCHEMA = 'schema'
4848
export const OPEN_API_PROPERTY_LINKS = 'links'
4949
export const OPEN_API_PROPERTY_SECURITY_SCHEMAS = 'securitySchemes'
50+
export const OPEN_API_PROPERTY_PATH_ITEMS = 'pathItems'
5051

5152
export const OPEN_API_PROPERTY_DESCRIPTION = 'description'
5253
export const OPEN_API_PROPERTY_SUMMARY = 'summary'

src/rules/openapi.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,7 @@ const customFor30JsonSchemaRulesFactory = (): NormalizationRules => {
436436
...customFor30JsonSchemaRules,
437437
merge: resolvers.itemsMergeResolver,
438438
hashStrategy: CURRENT_DATA_LEVEL,
439-
newDataLayer: true
439+
newDataLayer: true,
440440
}),
441441
'/additionalItems': {
442442
validate: () => false,
@@ -704,10 +704,7 @@ const openApiPathItemRules = (version: OpenApiSpecVersion): NormalizationRules =
704704
validate: checkType(TYPE_OBJECT),
705705
}),
706706
'/parameters': openApiParametersRules(version),
707-
referenceHandler: referenceObjectRuleFunction({
708-
version,
709-
allowedOverrides: [OPEN_API_PROPERTY_SUMMARY, OPEN_API_PROPERTY_DESCRIPTION],
710-
}),
707+
referenceHandler: referenceObjectResolver(),
711708
validate: checkType(TYPE_OBJECT),
712709
unify: pathItemsUnification,
713710
})
@@ -822,6 +819,18 @@ export const openApiRules = (version: OpenApiSpecVersion): NormalizationRules =>
822819
...openApiExtensionRulesFunction(() => openApiPathItemRules(version)),
823820
},
824821
},
822+
/**
823+
* Note: For OAS 3.0, `components.pathItems` is not a valid property.
824+
* We intentionally keep these rules and do not delete this path here
825+
* because invalid `components.pathItems` entries are pre-processed and
826+
* handled during pre-validation step.
827+
* Additionally, the reference resolver contains checks that guard against
828+
* misuse in OAS 3.0. See: validate.ts
829+
*/
830+
'/pathItems': ({
831+
...openApiExtensionRulesFunction(openApiPathItemRules(version)),
832+
validate: checkType(TYPE_OBJECT),
833+
}),
825834
...openApiExamplesRules(version),
826835
...openApiExtensionRules,
827836
validate: checkType(TYPE_OBJECT),

src/validate.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,33 @@ import {
77
ValidateSyncCloneHook,
88
} from './types'
99
import { isArray, isObject, JSON_ROOT_KEY, syncClone } from '@netcracker/qubership-apihub-json-crawl'
10-
import { resolveSpec } from './spec-type'
10+
import { resolveSpec, SPEC_TYPE_OPEN_API_30 } from './spec-type'
1111
import { createCycledJsoHandlerHook } from './cycle-jso'
1212
import { RULES } from './rules'
1313
import { cleanSeveralOrigins } from './origins'
14+
import { OPEN_API_PROPERTY_COMPONENTS, OPEN_API_PROPERTY_PATH_ITEMS } from './rules/openapi.const'
15+
16+
/**
17+
* Preprocesses an OpenAPI specification prior to reference resolution/origin definition.
18+
*
19+
* For OpenAPI 3.0 documents, `components.pathItems` is not a valid field (it was
20+
* added in OAS 3.1). Some tools may still emit it. To keep the input compliant,
21+
* deterministic, and to avoid misinterpreting invalid nodes as real API paths,
22+
* this function removes `components.pathItems` for OAS 3.0 and emits an optional
23+
* validation message via `options.onValidateError`.
24+
*/
25+
export function preValidate(source: unknown, options?: ValidateOptions & ResolveOptions): void {
26+
const spec = resolveSpec(source)
27+
if (spec.type !== SPEC_TYPE_OPEN_API_30 || !isObject(source)) {
28+
return
29+
}
30+
if (OPEN_API_PROPERTY_COMPONENTS in source && isObject(source.components)) {
31+
const components = source.components as Record<string, unknown>
32+
if (Reflect.deleteProperty(components, OPEN_API_PROPERTY_PATH_ITEMS)) {
33+
(options as ValidateOptions)?.onValidateError?.(`Invalid property 'components.pathItems' for OpenAPI 3.0. The property has been removed to maintain 3.0 compliance.`, ['components', 'pathItems'], 'pathItems')
34+
}
35+
}
36+
}
1437

1538
const createValidationHook: (options: InternalValidationOptions) => ValidateSyncCloneHook = (options) => {
1639
const validateHook: ValidateSyncCloneHook = ({ key, path, value, rules, state }) => {

test/oas/path-items.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { normalize } from '../../src'
2+
import pathItemsOas30 from '../resources/pathitems/pathItems-oas-30.json'
3+
import pathItemsOas31 from '../resources/pathitems/pathItems-oas-31.json'
4+
5+
const OPTIONS = { resolveRef: true, validate: true }
6+
describe('OAS 3.1 Path Item Object', () => {
7+
it('validation must pass for the Item Object in components', () => {
8+
const result = normalize(pathItemsOas31, OPTIONS) as any
9+
10+
expect(result.components.pathItems.componentsPathItem).not.toEqual({})
11+
})
12+
13+
it('could define Path Item Object via ref', () => {
14+
const result = normalize(pathItemsOas31, OPTIONS) as any
15+
16+
expect(result.paths['/path1'].post).toBe(result.components.pathItems.componentsPathItem.post)
17+
})
18+
})
19+
20+
describe('OAS 3.0 Path Item Object', () => {
21+
let baseSpec: any
22+
23+
beforeEach(() => {
24+
baseSpec = JSON.parse(JSON.stringify(pathItemsOas30))
25+
})
26+
27+
it('validation should discard pathItems section in components', () => {
28+
const result = normalize(baseSpec, OPTIONS) as any
29+
30+
expect(result).not.toHaveProperty(['components', 'pathItems'])
31+
})
32+
33+
it('validation must raise error for the pathItem section in components', () => {
34+
const errors: string[] = []
35+
const result = normalize(baseSpec, {...OPTIONS, onValidateError: message => errors.push(message) }) as any
36+
37+
expect(errors).toMatchObject([
38+
expect.stringMatching(/Invalid/),
39+
expect.stringMatching(/match/),
40+
])
41+
})
42+
43+
it('could not resolve Path Item from components via reference object', () => {
44+
const result = normalize(baseSpec, OPTIONS) as any
45+
46+
expect(result).toHaveProperty(['paths', '/path1'], {})
47+
})
48+
})

0 commit comments

Comments
 (0)