Skip to content

Commit 6d475c0

Browse files
heiskrCopilot
andauthored
Replace json-schema-merge-allof with a local helper (#63246)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35abb1df-6285-443c-892a-30c594d2d64c Copilot-Session: 946d101e-639b-48d2-a35c-b51badaa245a
1 parent 2deb965 commit 6d475c0

6 files changed

Lines changed: 405 additions & 114 deletions

File tree

‎package-lock.json‎

Lines changed: 0 additions & 84 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,6 @@
318318
"husky": "^9.1.7",
319319
"is-svg": "6.0.0",
320320
"jiti": "^2.6.1",
321-
"json-schema-merge-allof": "^0.8.1",
322321
"lint-staged": "^17.0.4",
323322
"lowdb": "7.0.1",
324323
"markdownlint": "^0.34.0",
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
type Schema = Record<string, unknown>
2+
3+
// Keywords whose value is a map of name to schema.
4+
const SCHEMA_MAP_KEYWORDS = new Set([
5+
'properties',
6+
'patternProperties',
7+
'definitions',
8+
'$defs',
9+
'dependentSchemas',
10+
])
11+
12+
// Keywords whose value is a single schema.
13+
const SINGLE_SCHEMA_KEYWORDS = new Set([
14+
'additionalProperties',
15+
'additionalItems',
16+
'unevaluatedItems',
17+
'unevaluatedProperties',
18+
'contains',
19+
'propertyNames',
20+
'not',
21+
'if',
22+
'then',
23+
'else',
24+
])
25+
26+
// Keywords whose value is an array of schemas.
27+
const SCHEMA_ARRAY_KEYWORDS = new Set(['anyOf', 'oneOf', 'prefixItems'])
28+
29+
// Keywords that only describe a schema. When two `allOf` members disagree on
30+
// one of these, the first definition wins instead of being treated as a
31+
// conflict, because the choice cannot make the rendered docs wrong.
32+
const ANNOTATION_KEYWORDS = new Set([
33+
'title',
34+
'description',
35+
'$comment',
36+
'example',
37+
'examples',
38+
'default',
39+
'deprecated',
40+
'readOnly',
41+
'writeOnly',
42+
])
43+
44+
function isSchemaObject(value: unknown): value is Schema {
45+
return typeof value === 'object' && value !== null && !Array.isArray(value)
46+
}
47+
48+
// Plain assignment would treat a key like `__proto__` as the prototype rather
49+
// than a property, so keys that come from the schema are defined explicitly.
50+
function setOwn(target: Schema, key: string, value: unknown): void {
51+
Object.defineProperty(target, key, {
52+
value,
53+
enumerable: true,
54+
writable: true,
55+
configurable: true,
56+
})
57+
}
58+
59+
function isDeepEqual(a: unknown, b: unknown): boolean {
60+
if (a === b) return true
61+
if (Array.isArray(a) && Array.isArray(b)) {
62+
return a.length === b.length && a.every((item, index) => isDeepEqual(item, b[index]))
63+
}
64+
if (isSchemaObject(a) && isSchemaObject(b)) {
65+
const aKeys = Object.keys(a)
66+
const bKeys = Object.keys(b)
67+
return (
68+
aKeys.length === bKeys.length &&
69+
aKeys.every((key) => Object.hasOwn(b, key) && isDeepEqual(a[key], b[key]))
70+
)
71+
}
72+
return false
73+
}
74+
75+
/**
76+
* Combines `source` into `target`, treating the two as an intersection of
77+
* constraints. Keywords already on `target` win, so the schema that owns the
78+
* `allOf` takes precedence over its members and earlier members take
79+
* precedence over later ones.
80+
*
81+
* Only the cases the GitHub OpenAPI descriptions actually use are merged:
82+
* identical values, `properties`, `required`, and annotations. Anything else
83+
* throws rather than guessing, so a future description that needs real
84+
* conflict resolution fails the build loudly instead of quietly publishing the
85+
* wrong request body parameters.
86+
*/
87+
function mergeInto(target: Schema, source: Schema, path: string): void {
88+
for (const [key, value] of Object.entries(source)) {
89+
if (!Object.hasOwn(target, key)) {
90+
setOwn(target, key, value)
91+
continue
92+
}
93+
94+
const existing = target[key]
95+
if (isDeepEqual(existing, value)) continue
96+
97+
if (key === 'properties' && isSchemaObject(existing) && isSchemaObject(value)) {
98+
for (const [name, propertySchema] of Object.entries(value)) {
99+
if (!Object.hasOwn(existing, name)) {
100+
setOwn(existing, name, propertySchema)
101+
continue
102+
}
103+
const existingProperty = existing[name]
104+
if (isSchemaObject(existingProperty) && isSchemaObject(propertySchema)) {
105+
mergeInto(existingProperty, propertySchema, `${path}/properties/${name}`)
106+
} else if (!isDeepEqual(existingProperty, propertySchema)) {
107+
throw new Error(
108+
`Cannot merge allOf: conflicting definitions of property "${name}" at ${path}/properties`,
109+
)
110+
}
111+
}
112+
continue
113+
}
114+
115+
if (key === 'required' && Array.isArray(existing) && Array.isArray(value)) {
116+
target[key] = [...new Set([...existing, ...value])]
117+
continue
118+
}
119+
120+
if (ANNOTATION_KEYWORDS.has(key)) continue
121+
122+
throw new Error(
123+
`Cannot merge allOf: conflicting "${key}" keyword at ${path}. ` +
124+
`This schema needs a merge strategy for "${key}" adding to merge-all-of.ts.`,
125+
)
126+
}
127+
}
128+
129+
function resolveKeyword(key: string, value: unknown, path: string): unknown {
130+
if (SCHEMA_MAP_KEYWORDS.has(key) && isSchemaObject(value)) {
131+
const resolved: Schema = {}
132+
for (const [name, subSchema] of Object.entries(value)) {
133+
setOwn(resolved, name, resolveSchema(subSchema, `${path}/${name}`))
134+
}
135+
return resolved
136+
}
137+
138+
if (SCHEMA_ARRAY_KEYWORDS.has(key) && Array.isArray(value)) {
139+
return value.map((item, index) => resolveSchema(item, `${path}/${index}`))
140+
}
141+
142+
// `items` is a single schema in current drafts and an array in draft-04.
143+
if (key === 'items') {
144+
if (Array.isArray(value)) {
145+
return value.map((item, index) => resolveSchema(item, `${path}/${index}`))
146+
}
147+
return resolveSchema(value, path)
148+
}
149+
150+
if (SINGLE_SCHEMA_KEYWORDS.has(key)) return resolveSchema(value, path)
151+
152+
// Anything else holds instance data rather than a schema, such as `enum`,
153+
// `const`, or `default`. It is copied through untouched so that a value or a
154+
// property that happens to be named `allOf` survives.
155+
return value
156+
}
157+
158+
function resolveSchema(schema: unknown, path: string): unknown {
159+
if (!isSchemaObject(schema)) return schema
160+
161+
const resolved: Schema = {}
162+
for (const [key, value] of Object.entries(schema)) {
163+
if (key !== 'allOf') setOwn(resolved, key, resolveKeyword(key, value, `${path}/${key}`))
164+
}
165+
166+
if (Object.hasOwn(schema, 'allOf')) {
167+
const members = schema.allOf
168+
if (!Array.isArray(members)) {
169+
throw new Error(`Cannot merge allOf: "allOf" at ${path} is not an array`)
170+
}
171+
for (const [index, member] of members.entries()) {
172+
const memberPath = `${path}/allOf/${index}`
173+
const resolvedMember = resolveSchema(member, memberPath)
174+
if (!isSchemaObject(resolvedMember)) {
175+
throw new Error(`Cannot merge allOf: member at ${memberPath} is not an object schema`)
176+
}
177+
mergeInto(resolved, resolvedMember, path)
178+
}
179+
}
180+
181+
return resolved
182+
}
183+
184+
/**
185+
* Flattens every `allOf` in a JSON schema so that consumers only have to walk
186+
* `properties`. Replaces the unmaintained `json-schema-merge-allof` package.
187+
*
188+
* The returned schema is a deep copy, so callers are free to mutate it without
189+
* touching the OpenAPI operation it came from.
190+
*/
191+
export function mergeAllOf(schema: unknown): unknown {
192+
return resolveSchema(structuredClone(schema), '#')
193+
}

‎src/rest/scripts/utils/operation.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { STATUS_CODES } from 'node:http'
22

33
import { get, isPlainObject } from 'lodash-es'
44
import { parseTemplate } from 'url-template'
5-
import mergeAllOf from 'json-schema-merge-allof'
65

6+
import { mergeAllOf } from '@/rest/scripts/utils/merge-all-of'
77
import { renderContent } from './render-content'
88
import getCodeSamples from './create-rest-examples'
99
import operationSchema from './operation-schema'
@@ -188,7 +188,7 @@ export default class Operation {
188188
// Operation Id: markdown/render-raw
189189
const contentType = Object.keys(this.#operation.requestBody.content)[0]
190190
const schema = get(this.#operation, `requestBody.content.${contentType}.schema`, {})
191-
const mergedAllofSchema = mergeAllOf(schema as Parameters<typeof mergeAllOf>[0])
191+
const mergedAllofSchema = mergeAllOf(schema)
192192
try {
193193
this.bodyParameters = isPlainObject(schema)
194194
? await getBodyParams(mergedAllofSchema as Parameters<typeof getBodyParams>[0], true)

0 commit comments

Comments
 (0)