|
| 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 | +} |
0 commit comments