fork of hey-api/openapi-ts because I need some additional things
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #2140 from hey-api/fix/validate-operation-id

authored by

Lubos and committed by
GitHub
be3cb068 e0306d12

+407 -172
+5
.changeset/purple-ducks-poke.md
··· 1 + --- 2 + '@hey-api/openapi-ts': patch 3 + --- 4 + 5 + fix(parser): validate operationId keyword
+22
packages/openapi-ts-tests/test/spec/2.0.x/invalid/operationId-unique.yaml
··· 1 + swagger: 2.0 2 + info: 3 + title: Invalid OpenAPI 2.0 operationId unique example 4 + version: 1 5 + paths: 6 + /foo: 7 + get: 8 + operationId: foo 9 + produces: 10 + - application/json 11 + responses: 12 + '200': 13 + description: OK 14 + schema: 15 + type: string 16 + post: 17 + operationId: foo 18 + responses: 19 + '200': 20 + description: OK 21 + schema: 22 + type: string
+24
packages/openapi-ts-tests/test/spec/3.0.x/invalid/operationId-unique.yaml
··· 1 + openapi: 3.0.4 2 + info: 3 + title: Invalid OpenAPI 3.0.4 operationId unique example 4 + version: 1 5 + paths: 6 + /foo: 7 + get: 8 + operationId: foo 9 + responses: 10 + '200': 11 + content: 12 + '*/*': 13 + schema: 14 + type: string 15 + description: OK 16 + post: 17 + operationId: foo 18 + responses: 19 + '200': 20 + content: 21 + '*/*': 22 + schema: 23 + type: string 24 + description: OK
+24
packages/openapi-ts-tests/test/spec/3.1.x/invalid/operationId-unique.yaml
··· 1 + openapi: 3.1.1 2 + info: 3 + title: Invalid OpenAPI 3.1.1 operationId unique example 4 + version: 1 5 + paths: 6 + /foo: 7 + get: 8 + operationId: foo 9 + responses: 10 + '200': 11 + content: 12 + '*/*': 13 + schema: 14 + type: string 15 + description: OK 16 + post: 17 + operationId: foo 18 + responses: 19 + '200': 20 + content: 21 + '*/*': 22 + schema: 23 + type: string 24 + description: OK
+39 -6
packages/openapi-ts/src/initConfigs.ts
··· 47 47 ...userConfig.input, 48 48 }; 49 49 50 + // watch only remote files 50 51 if (input.watch !== undefined) { 51 52 input.watch = getWatch(input); 52 53 } ··· 55 56 ...input, 56 57 path: userConfig.input as Record<string, unknown>, 57 58 }; 59 + } 60 + 61 + if (input.validate_EXPERIMENTAL === true) { 62 + input.validate_EXPERIMENTAL = 'warn'; 58 63 } 59 64 60 65 if ( ··· 277 282 return watch; 278 283 }; 279 284 285 + const mergeObjects = ( 286 + objA: Record<string, unknown> | undefined, 287 + objB: Record<string, unknown> | undefined, 288 + ): Record<string, unknown> => { 289 + const a = objA || {}; 290 + const b = objB || {}; 291 + return { 292 + ...a, 293 + ...b, 294 + }; 295 + }; 296 + 297 + const mergeConfigs = ( 298 + configA: UserConfig | undefined, 299 + configB: UserConfig | undefined, 300 + ): UserConfig => { 301 + const a: Partial<UserConfig> = configA || {}; 302 + const b: Partial<UserConfig> = configB || {}; 303 + const merged: UserConfig = { 304 + ...(a as UserConfig), 305 + ...(b as UserConfig), 306 + }; 307 + if (typeof merged.logs === 'object') { 308 + merged.logs = mergeObjects( 309 + a.logs as Record<string, unknown>, 310 + b.logs as Record<string, unknown>, 311 + ); 312 + } 313 + return merged; 314 + }; 315 + 280 316 /** 281 317 * @internal 282 318 */ ··· 294 330 name: 'openapi-ts', 295 331 }); 296 332 297 - const userConfigs: UserConfig[] = Array.isArray(userConfig) 333 + const userConfigs: ReadonlyArray<UserConfig> = Array.isArray(userConfig) 298 334 ? userConfig 299 335 : Array.isArray(configFromFile) 300 - ? configFromFile.map((config) => ({ 301 - ...config, 302 - ...userConfig, 303 - })) 304 - : [{ ...(configFromFile ?? {}), ...userConfig }]; 336 + ? configFromFile.map((config) => mergeConfigs(config, userConfig)) 337 + : [mergeConfigs(configFromFile, userConfig)]; 305 338 306 339 return userConfigs.map((userConfig) => { 307 340 const {
+57
packages/openapi-ts/src/openApi/2.0.x/parser/__tests__/graph.test.ts
··· 1 + import path from 'node:path'; 2 + 3 + import { describe, expect, it } from 'vitest'; 4 + 5 + import { specFileToJson } from '../../../__tests__/utils'; 6 + import type { ValidatorResult } from '../../../shared/utils/validator'; 7 + import { createGraph } from '../graph'; 8 + 9 + const specsFolder = path.join( 10 + __dirname, 11 + '..', 12 + '..', 13 + '..', 14 + '..', 15 + '..', 16 + '..', 17 + 'openapi-ts-tests', 18 + 'test', 19 + 'spec', 20 + '2.0.x', 21 + 'invalid', 22 + ); 23 + 24 + describe('validate', () => { 25 + const scenarios: Array< 26 + ValidatorResult & { 27 + description: string; 28 + file: string; 29 + } 30 + > = [ 31 + { 32 + description: 'operationId must be unique', 33 + file: path.join(specsFolder, 'operationId-unique.yaml'), 34 + issues: [ 35 + { 36 + code: 'duplicate_key', 37 + context: { 38 + key: 'operationId', 39 + value: 'foo', 40 + }, 41 + message: 42 + 'Duplicate `operationId` found. Each `operationId` must be unique.', 43 + path: ['paths', '/foo', 'post', 'operationId'], 44 + severity: 'error', 45 + }, 46 + ], 47 + valid: false, 48 + }, 49 + ]; 50 + 51 + it.each(scenarios)('$description', ({ file, issues, valid }) => { 52 + const spec = specFileToJson(file); 53 + const result = createGraph({ spec, validate: true }); 54 + expect(result.valid).toBe(valid); 55 + expect(result.issues).toEqual(issues); 56 + }); 57 + });
+34 -11
packages/openapi-ts/src/openApi/2.0.x/parser/graph.ts
··· 5 5 } from '../../shared/utils/graph'; 6 6 import { httpMethods } from '../../shared/utils/operation'; 7 7 import type { 8 - ValidatorError, 8 + ValidatorIssue, 9 9 ValidatorResult, 10 10 } from '../../shared/utils/validator'; 11 11 import type { ··· 66 66 67 67 export const createGraph = ({ 68 68 spec, 69 + validate, 69 70 }: { 70 71 spec: OpenApiV2_0_X; 71 72 validate: boolean; ··· 79 80 responses: new Map(), 80 81 schemas: new Map(), 81 82 }; 82 - const errors: Array<ValidatorError> = []; 83 + const issues: Array<ValidatorIssue> = []; 84 + const operationIds = new Map(); 83 85 84 86 if (spec.definitions) { 85 87 for (const [key, schema] of Object.entries(spec.definitions)) { ··· 110 112 continue; 111 113 } 112 114 115 + const operationKey = `${method.toUpperCase()} ${path}`; 116 + 117 + if (validate && operation.operationId) { 118 + if (!operationIds.has(operation.operationId)) { 119 + operationIds.set(operation.operationId, operationKey); 120 + } else { 121 + issues.push({ 122 + code: 'duplicate_key', 123 + context: { 124 + key: 'operationId', 125 + value: operation.operationId, 126 + }, 127 + message: 128 + 'Duplicate `operationId` found. Each `operationId` must be unique.', 129 + path: ['paths', path, method, 'operationId'], 130 + severity: 'error', 131 + }); 132 + } 133 + } 134 + 113 135 const dependencies = new Set<string>(); 114 136 115 137 if (operation.responses) { ··· 132 154 } 133 155 } 134 156 135 - graph.operations.set( 136 - addNamespace('operation', `${method.toUpperCase()} ${path}`), 137 - { 138 - dependencies, 139 - deprecated: Boolean(operation.deprecated), 140 - tags: new Set(operation.tags), 141 - }, 142 - ); 157 + graph.operations.set(addNamespace('operation', operationKey), { 158 + dependencies, 159 + deprecated: Boolean(operation.deprecated), 160 + tags: new Set(operation.tags), 161 + }); 143 162 } 144 163 } 145 164 } 146 165 147 - return { errors, graph, valid: !errors.length }; 166 + return { 167 + graph, 168 + issues, 169 + valid: !issues.some((issue) => issue.severity === 'error'), 170 + }; 148 171 };
+1 -2
packages/openapi-ts/src/openApi/2.0.x/parser/index.ts
··· 33 33 if (shouldFilterSpec || context.config.input.validate_EXPERIMENTAL) { 34 34 const result = createGraph({ 35 35 spec: context.spec, 36 - validate: context.config.input.validate_EXPERIMENTAL, 36 + validate: Boolean(context.config.input.validate_EXPERIMENTAL), 37 37 }); 38 38 graph = result.graph; 39 39 handleValidatorResult({ context, result }); ··· 51 51 52 52 const state: State = { 53 53 ids: new Map(), 54 - operationIds: new Map(), 55 54 }; 56 55 const securitySchemesMap = new Map<string, SecuritySchemeObject>(); 57 56
+1 -12
packages/openapi-ts/src/openApi/2.0.x/parser/operation.ts
··· 1 1 import type { IR, IRBodyObject } from '../../../ir/types'; 2 2 import type { State } from '../../shared/types/state'; 3 - import { 4 - ensureUniqueOperationId, 5 - operationToId, 6 - } from '../../shared/utils/operation'; 3 + import { operationToId } from '../../shared/utils/operation'; 7 4 import type { 8 5 OperationObject, 9 6 ParameterObject, ··· 351 348 securitySchemesMap: Map<string, SecuritySchemeObject>; 352 349 state: State; 353 350 }) => { 354 - ensureUniqueOperationId({ 355 - context, 356 - id: operation.operationId, 357 - method, 358 - operationIds: state.operationIds, 359 - path, 360 - }); 361 - 362 351 if (!context.ir.paths) { 363 352 context.ir.paths = {}; 364 353 }
+29 -11
packages/openapi-ts/src/openApi/3.0.x/parser/__tests__/graph.test.ts
··· 30 30 > = [ 31 31 { 32 32 description: 'servers must be array', 33 - errors: [ 33 + file: path.join(specsFolder, 'servers-array.yaml'), 34 + issues: [ 34 35 { 35 36 code: 'invalid_type', 36 - message: '`servers` must be an array', 37 + message: '`servers` must be an array.', 37 38 path: [], 38 39 severity: 'error', 39 40 }, 40 41 ], 41 - file: path.join(specsFolder, 'servers-array.yaml'), 42 42 valid: false, 43 43 }, 44 44 { 45 45 description: 'servers entry must be object', 46 - errors: [ 46 + file: path.join(specsFolder, 'servers-entry.yaml'), 47 + issues: [ 47 48 { 48 49 code: 'invalid_type', 49 50 context: { 50 51 actual: 'string', 51 52 expected: 'object', 52 53 }, 53 - message: 'Each entry in `servers` must be an object', 54 + message: 'Each entry in `servers` must be an object.', 54 55 path: ['servers', 0], 55 56 severity: 'error', 56 57 }, 57 58 ], 58 - file: path.join(specsFolder, 'servers-entry.yaml'), 59 59 valid: false, 60 60 }, 61 61 { 62 62 description: 'servers entry required fields', 63 - errors: [ 63 + file: path.join(specsFolder, 'servers-required.yaml'), 64 + issues: [ 64 65 { 65 66 code: 'missing_required_field', 66 67 context: { 67 68 field: 'url', 68 69 }, 69 - message: 'Missing required field `url` in server object', 70 + message: 'Missing required field `url` in server object.', 70 71 path: ['servers', 0], 71 72 severity: 'error', 72 73 }, 73 74 ], 74 - file: path.join(specsFolder, 'servers-required.yaml'), 75 + valid: false, 76 + }, 77 + { 78 + description: 'operationId must be unique', 79 + file: path.join(specsFolder, 'operationId-unique.yaml'), 80 + issues: [ 81 + { 82 + code: 'duplicate_key', 83 + context: { 84 + key: 'operationId', 85 + value: 'foo', 86 + }, 87 + message: 88 + 'Duplicate `operationId` found. Each `operationId` must be unique.', 89 + path: ['paths', '/foo', 'post', 'operationId'], 90 + severity: 'error', 91 + }, 92 + ], 75 93 valid: false, 76 94 }, 77 95 ]; 78 96 79 - it.each(scenarios)('$description', ({ errors, file, valid }) => { 97 + it.each(scenarios)('$description', ({ file, issues, valid }) => { 80 98 const spec = specFileToJson(file); 81 99 const result = createGraph({ spec, validate: true }); 82 100 expect(result.valid).toBe(valid); 83 - expect(result.errors).toEqual(errors); 101 + expect(result.issues).toEqual(issues); 84 102 }); 85 103 });
+39 -17
packages/openapi-ts/src/openApi/3.0.x/parser/graph.ts
··· 2 2 import { addNamespace, stringToNamespace } from '../../shared/utils/graph'; 3 3 import { httpMethods } from '../../shared/utils/operation'; 4 4 import type { 5 - ValidatorError, 5 + ValidatorIssue, 6 6 ValidatorResult, 7 7 } from '../../shared/utils/validator'; 8 8 import type { ··· 92 92 responses: new Map(), 93 93 schemas: new Map(), 94 94 }; 95 - const errors: Array<ValidatorError> = []; 95 + const issues: Array<ValidatorIssue> = []; 96 + const operationIds = new Map(); 96 97 97 98 if (spec.components) { 98 99 // TODO: add other components ··· 189 190 continue; 190 191 } 191 192 193 + const operationKey = `${method.toUpperCase()} ${path}`; 194 + 195 + if (validate && operation.operationId) { 196 + if (!operationIds.has(operation.operationId)) { 197 + operationIds.set(operation.operationId, operationKey); 198 + } else { 199 + issues.push({ 200 + code: 'duplicate_key', 201 + context: { 202 + key: 'operationId', 203 + value: operation.operationId, 204 + }, 205 + message: 206 + 'Duplicate `operationId` found. Each `operationId` must be unique.', 207 + path: ['paths', path, method, 'operationId'], 208 + severity: 'error', 209 + }); 210 + } 211 + } 212 + 192 213 const dependencies = new Set<string>(); 193 214 194 215 if (operation.requestBody) { ··· 231 252 } 232 253 } 233 254 234 - graph.operations.set( 235 - addNamespace('operation', `${method.toUpperCase()} ${path}`), 236 - { 237 - dependencies, 238 - deprecated: Boolean(operation.deprecated), 239 - tags: new Set(operation.tags), 240 - }, 241 - ); 255 + graph.operations.set(addNamespace('operation', operationKey), { 256 + dependencies, 257 + deprecated: Boolean(operation.deprecated), 258 + tags: new Set(operation.tags), 259 + }); 242 260 } 243 261 } 244 262 } ··· 246 264 if (validate) { 247 265 if (spec.servers) { 248 266 if (typeof spec.servers !== 'object' || !Array.isArray(spec.servers)) { 249 - errors.push({ 267 + issues.push({ 250 268 code: 'invalid_type', 251 - message: '`servers` must be an array', 269 + message: '`servers` must be an array.', 252 270 path: [], 253 271 severity: 'error', 254 272 }); ··· 257 275 for (let index = 0; index < spec.servers.length; index++) { 258 276 const server = spec.servers[index]; 259 277 if (!server || typeof server !== 'object') { 260 - errors.push({ 278 + issues.push({ 261 279 code: 'invalid_type', 262 280 context: { 263 281 actual: typeof server, 264 282 expected: 'object', 265 283 }, 266 - message: 'Each entry in `servers` must be an object', 284 + message: 'Each entry in `servers` must be an object.', 267 285 path: ['servers', index], 268 286 severity: 'error', 269 287 }); 270 288 } else { 271 289 if (!server.url) { 272 - errors.push({ 290 + issues.push({ 273 291 code: 'missing_required_field', 274 292 context: { 275 293 field: 'url', 276 294 }, 277 - message: 'Missing required field `url` in server object', 295 + message: 'Missing required field `url` in server object.', 278 296 path: ['servers', index], 279 297 severity: 'error', 280 298 }); ··· 284 302 } 285 303 } 286 304 287 - return { errors, graph, valid: !errors.length }; 305 + return { 306 + graph, 307 + issues, 308 + valid: !issues.some((issue) => issue.severity === 'error'), 309 + }; 288 310 };
+1 -2
packages/openapi-ts/src/openApi/3.0.x/parser/index.ts
··· 32 32 if (shouldFilterSpec || context.config.input.validate_EXPERIMENTAL) { 33 33 const result = createGraph({ 34 34 spec: context.spec, 35 - validate: context.config.input.validate_EXPERIMENTAL, 35 + validate: Boolean(context.config.input.validate_EXPERIMENTAL), 36 36 }); 37 37 graph = result.graph; 38 38 handleValidatorResult({ context, result }); ··· 50 50 51 51 const state: State = { 52 52 ids: new Map(), 53 - operationIds: new Map(), 54 53 }; 55 54 const securitySchemesMap = new Map<string, SecuritySchemeObject>(); 56 55
+1 -12
packages/openapi-ts/src/openApi/3.0.x/parser/operation.ts
··· 1 1 import type { IR } from '../../../ir/types'; 2 2 import type { State } from '../../shared/types/state'; 3 - import { 4 - ensureUniqueOperationId, 5 - operationToId, 6 - } from '../../shared/utils/operation'; 3 + import { operationToId } from '../../shared/utils/operation'; 7 4 import type { 8 5 OperationObject, 9 6 PathItemObject, ··· 242 239 securitySchemesMap: Map<string, SecuritySchemeObject>; 243 240 state: State; 244 241 }) => { 245 - ensureUniqueOperationId({ 246 - context, 247 - id: operation.operationId, 248 - method, 249 - operationIds: state.operationIds, 250 - path, 251 - }); 252 - 253 242 if (!context.ir.paths) { 254 243 context.ir.paths = {}; 255 244 }
+29 -11
packages/openapi-ts/src/openApi/3.1.x/parser/__tests__/graph.test.ts
··· 30 30 > = [ 31 31 { 32 32 description: 'servers must be array', 33 - errors: [ 33 + file: path.join(specsFolder, 'servers-array.yaml'), 34 + issues: [ 34 35 { 35 36 code: 'invalid_type', 36 - message: '`servers` must be an array', 37 + message: '`servers` must be an array.', 37 38 path: [], 38 39 severity: 'error', 39 40 }, 40 41 ], 41 - file: path.join(specsFolder, 'servers-array.yaml'), 42 42 valid: false, 43 43 }, 44 44 { 45 45 description: 'servers entry must be object', 46 - errors: [ 46 + file: path.join(specsFolder, 'servers-entry.yaml'), 47 + issues: [ 47 48 { 48 49 code: 'invalid_type', 49 50 context: { 50 51 actual: 'string', 51 52 expected: 'object', 52 53 }, 53 - message: 'Each entry in `servers` must be an object', 54 + message: 'Each entry in `servers` must be an object.', 54 55 path: ['servers', 0], 55 56 severity: 'error', 56 57 }, 57 58 ], 58 - file: path.join(specsFolder, 'servers-entry.yaml'), 59 59 valid: false, 60 60 }, 61 61 { 62 62 description: 'servers entry required fields', 63 - errors: [ 63 + file: path.join(specsFolder, 'servers-required.yaml'), 64 + issues: [ 64 65 { 65 66 code: 'missing_required_field', 66 67 context: { 67 68 field: 'url', 68 69 }, 69 - message: 'Missing required field `url` in server object', 70 + message: 'Missing required field `url` in server object.', 70 71 path: ['servers', 0], 71 72 severity: 'error', 72 73 }, 73 74 ], 74 - file: path.join(specsFolder, 'servers-required.yaml'), 75 + valid: false, 76 + }, 77 + { 78 + description: 'operationId must be unique', 79 + file: path.join(specsFolder, 'operationId-unique.yaml'), 80 + issues: [ 81 + { 82 + code: 'duplicate_key', 83 + context: { 84 + key: 'operationId', 85 + value: 'foo', 86 + }, 87 + message: 88 + 'Duplicate `operationId` found. Each `operationId` must be unique.', 89 + path: ['paths', '/foo', 'post', 'operationId'], 90 + severity: 'error', 91 + }, 92 + ], 75 93 valid: false, 76 94 }, 77 95 ]; 78 96 79 - it.each(scenarios)('$description', ({ errors, file, valid }) => { 97 + it.each(scenarios)('$description', ({ file, issues, valid }) => { 80 98 const spec = specFileToJson(file); 81 99 const result = createGraph({ spec, validate: true }); 82 100 expect(result.valid).toBe(valid); 83 - expect(result.errors).toEqual(errors); 101 + expect(result.issues).toEqual(issues); 84 102 }); 85 103 });
+39 -17
packages/openapi-ts/src/openApi/3.1.x/parser/graph.ts
··· 2 2 import { addNamespace, stringToNamespace } from '../../shared/utils/graph'; 3 3 import { httpMethods } from '../../shared/utils/operation'; 4 4 import type { 5 - ValidatorError, 5 + ValidatorIssue, 6 6 ValidatorResult, 7 7 } from '../../shared/utils/validator'; 8 8 import type { ··· 97 97 responses: new Map(), 98 98 schemas: new Map(), 99 99 }; 100 - const errors: Array<ValidatorError> = []; 100 + const issues: Array<ValidatorIssue> = []; 101 + const operationIds = new Map(); 101 102 102 103 if (spec.components) { 103 104 // TODO: add other components ··· 194 195 continue; 195 196 } 196 197 198 + const operationKey = `${method.toUpperCase()} ${path}`; 199 + 200 + if (validate && operation.operationId) { 201 + if (!operationIds.has(operation.operationId)) { 202 + operationIds.set(operation.operationId, operationKey); 203 + } else { 204 + issues.push({ 205 + code: 'duplicate_key', 206 + context: { 207 + key: 'operationId', 208 + value: operation.operationId, 209 + }, 210 + message: 211 + 'Duplicate `operationId` found. Each `operationId` must be unique.', 212 + path: ['paths', path, method, 'operationId'], 213 + severity: 'error', 214 + }); 215 + } 216 + } 217 + 197 218 const dependencies = new Set<string>(); 198 219 199 220 if (operation.requestBody) { ··· 236 257 } 237 258 } 238 259 239 - graph.operations.set( 240 - addNamespace('operation', `${method.toUpperCase()} ${path}`), 241 - { 242 - dependencies, 243 - deprecated: Boolean(operation.deprecated), 244 - tags: new Set(operation.tags), 245 - }, 246 - ); 260 + graph.operations.set(addNamespace('operation', operationKey), { 261 + dependencies, 262 + deprecated: Boolean(operation.deprecated), 263 + tags: new Set(operation.tags), 264 + }); 247 265 } 248 266 } 249 267 } ··· 251 269 if (validate) { 252 270 if (spec.servers) { 253 271 if (typeof spec.servers !== 'object' || !Array.isArray(spec.servers)) { 254 - errors.push({ 272 + issues.push({ 255 273 code: 'invalid_type', 256 - message: '`servers` must be an array', 274 + message: '`servers` must be an array.', 257 275 path: [], 258 276 severity: 'error', 259 277 }); ··· 262 280 for (let index = 0; index < spec.servers.length; index++) { 263 281 const server = spec.servers[index]; 264 282 if (!server || typeof server !== 'object') { 265 - errors.push({ 283 + issues.push({ 266 284 code: 'invalid_type', 267 285 context: { 268 286 actual: typeof server, 269 287 expected: 'object', 270 288 }, 271 - message: 'Each entry in `servers` must be an object', 289 + message: 'Each entry in `servers` must be an object.', 272 290 path: ['servers', index], 273 291 severity: 'error', 274 292 }); 275 293 } else { 276 294 if (!server.url) { 277 - errors.push({ 295 + issues.push({ 278 296 code: 'missing_required_field', 279 297 context: { 280 298 field: 'url', 281 299 }, 282 - message: 'Missing required field `url` in server object', 300 + message: 'Missing required field `url` in server object.', 283 301 path: ['servers', index], 284 302 severity: 'error', 285 303 }); ··· 289 307 } 290 308 } 291 309 292 - return { errors, graph, valid: !errors.length }; 310 + return { 311 + graph, 312 + issues, 313 + valid: !issues.some((issue) => issue.severity === 'error'), 314 + }; 293 315 };
+1 -2
packages/openapi-ts/src/openApi/3.1.x/parser/index.ts
··· 32 32 if (shouldFilterSpec || context.config.input.validate_EXPERIMENTAL) { 33 33 const result = createGraph({ 34 34 spec: context.spec, 35 - validate: context.config.input.validate_EXPERIMENTAL, 35 + validate: Boolean(context.config.input.validate_EXPERIMENTAL), 36 36 }); 37 37 graph = result.graph; 38 38 handleValidatorResult({ context, result }); ··· 50 50 51 51 const state: State = { 52 52 ids: new Map(), 53 - operationIds: new Map(), 54 53 }; 55 54 const securitySchemesMap = new Map<string, SecuritySchemeObject>(); 56 55
+1 -12
packages/openapi-ts/src/openApi/3.1.x/parser/operation.ts
··· 1 1 import type { IR } from '../../../ir/types'; 2 2 import type { State } from '../../shared/types/state'; 3 - import { 4 - ensureUniqueOperationId, 5 - operationToId, 6 - } from '../../shared/utils/operation'; 3 + import { operationToId } from '../../shared/utils/operation'; 7 4 import type { 8 5 OperationObject, 9 6 PathItemObject, ··· 227 224 securitySchemesMap: Map<string, SecuritySchemeObject>; 228 225 state: State; 229 226 }) => { 230 - ensureUniqueOperationId({ 231 - context, 232 - id: operation.operationId, 233 - method, 234 - operationIds: state.operationIds, 235 - path, 236 - }); 237 - 238 227 if (!context.ir.paths) { 239 228 context.ir.paths = {}; 240 229 }
-1
packages/openapi-ts/src/openApi/shared/types/state.d.ts
··· 1 1 export interface State { 2 2 ids: Map<string, string>; 3 - operationIds: Map<string, string>; 4 3 }
-36
packages/openapi-ts/src/openApi/shared/utils/operation.ts
··· 15 15 ] as const; 16 16 17 17 /** 18 - * Verifies that operation ID is unique. For now, we only warn when this isn't 19 - * true as people like to not follow this part of the specification. In the 20 - * future, we should add a strict check and throw on duplicate identifiers. 21 - */ 22 - export const ensureUniqueOperationId = ({ 23 - context, 24 - id, 25 - method, 26 - operationIds, 27 - path, 28 - }: { 29 - context: IR.Context; 30 - id: string | undefined; 31 - method: IR.OperationObject['method']; 32 - operationIds: Map<string, string>; 33 - path: keyof IR.PathsObject; 34 - }) => { 35 - if (!id) { 36 - return; 37 - } 38 - 39 - const operationKey = `${method.toUpperCase()} ${path}`; 40 - 41 - if (operationIds.has(id)) { 42 - if (context.config.logs.level !== 'silent') { 43 - // TODO: parser - support throw on duplicate 44 - console.warn( 45 - `❗️ Duplicate operationId: ${id} in ${operationKey}. Please ensure your operation IDs are unique. This behavior is not supported and will likely lead to unexpected results.`, 46 - ); 47 - } 48 - } else { 49 - operationIds.set(id, operationKey); 50 - } 51 - }; 52 - 53 - /** 54 18 * Returns an operation ID to use across the application. By default, we try 55 19 * to use the provided ID. If it's not provided or the SDK is configured 56 20 * to exclude it, we generate operation ID from its location.
+57 -18
packages/openapi-ts/src/openApi/shared/utils/validator.ts
··· 2 2 3 3 import type { IR } from '../../../ir/types'; 4 4 5 - export interface ValidatorError { 5 + export interface ValidatorIssue { 6 6 /** 7 - * Machine-readable error code 7 + * Machine-readable issue code 8 8 * 9 9 * @example 10 10 * 'invalid_type' 11 11 */ 12 - code: 'invalid_type' | 'missing_required_field'; 12 + code: 'duplicate_key' | 'invalid_type' | 'missing_required_field'; 13 13 /** 14 14 * Optional additional data. 15 15 * ··· 18 18 */ 19 19 context?: Record<string, any>; 20 20 /** 21 - * Human-readable error summary. 21 + * Human-readable issue summary. 22 22 */ 23 23 message: string; 24 24 /** 25 - * JSONPath-like array to pinpoint error location. 25 + * JSONPath-like array to pinpoint issue location. 26 26 */ 27 27 path: ReadonlyArray<string | number>; 28 28 /** ··· 32 32 } 33 33 34 34 export interface ValidatorResult { 35 - errors: ReadonlyArray<ValidatorError>; 35 + issues: ReadonlyArray<ValidatorIssue>; 36 36 valid: boolean; 37 37 } 38 38 39 - const formatValidatorError = (error: ValidatorError): string => { 40 - const pathStr = error.path 41 - .map((segment) => (typeof segment === 'number' ? `[${segment}]` : segment)) 42 - .join('') 43 - .replace(/\.\[/g, '['); 39 + const isSimpleKey = (key: string) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key); 40 + 41 + const formatPath = (path: ReadonlyArray<string | number>): string => 42 + path 43 + .map((segment, i) => { 44 + if (typeof segment === 'number') { 45 + return `[${segment}]`; 46 + } 47 + 48 + if (i === 0) { 49 + // first segment no dot or brackets 50 + return segment; 51 + } 52 + 53 + return isSimpleKey(segment) 54 + ? `.${segment}` 55 + : `['${segment.replace(/"/g, "\\'")}']`; 56 + }) 57 + .join(''); 58 + 59 + const formatValidatorIssue = (issue: ValidatorIssue): string => { 60 + const pathStr = formatPath(issue.path); 44 61 const level = 45 - error.severity === 'error' ? colors.bold.red : colors.bold.yellow; 62 + issue.severity === 'error' ? colors.bold.red : colors.bold.yellow; 46 63 47 - const highlightedMessage = error.message.replace(/`([^`]+)`/g, (_, code) => 64 + const highlightedMessage = issue.message.replace(/`([^`]+)`/g, (_, code) => 48 65 colors.yellow(`\`${code}\``), 49 66 ); 50 67 51 - return `${level(`[${error.severity.toUpperCase()}]`)} ${colors.cyan(pathStr)}: ${highlightedMessage}`; 68 + return `${level(`[${issue.severity.toUpperCase()}]`)} ${colors.cyan(pathStr)}: ${highlightedMessage}`; 69 + }; 70 + 71 + const shouldPrint = ({ 72 + context, 73 + issue, 74 + }: { 75 + context: IR.Context; 76 + issue: ValidatorIssue; 77 + }) => { 78 + if (context.config.logs.level === 'silent') { 79 + return false; 80 + } 81 + 82 + if (issue.severity === 'error') { 83 + return context.config.logs.level !== 'warn'; 84 + } 85 + 86 + return true; 52 87 }; 53 88 54 89 export const handleValidatorResult = ({ ··· 58 93 context: IR.Context; 59 94 result: ValidatorResult; 60 95 }) => { 61 - if (!context.config.input.validate_EXPERIMENTAL || result.valid) { 96 + if (!context.config.input.validate_EXPERIMENTAL) { 62 97 return; 63 98 } 64 99 65 - for (const error of result.errors) { 66 - console.log(formatValidatorError(error)); 100 + for (const issue of result.issues) { 101 + if (shouldPrint({ context, issue })) { 102 + console.log(formatValidatorIssue(issue)); 103 + } 67 104 } 68 105 69 - process.exit(1); 106 + if (!result.valid) { 107 + process.exit(1); 108 + } 70 109 };
+3 -2
packages/openapi-ts/src/types/input.d.ts
··· 91 91 * **This is an experimental feature.** 92 92 * 93 93 * Validate the input before generating output? This is an experimental, 94 - * lightweight feature and support will be added on an ad hoc basis. 94 + * lightweight feature and support will be added on an ad hoc basis. Setting 95 + * `validate_EXPERIMENTAL` to `true` is the same as `warn`. 95 96 * 96 97 * @default false 97 98 */ 98 - validate_EXPERIMENTAL?: boolean; 99 + validate_EXPERIMENTAL?: boolean | 'strict' | 'warn'; 99 100 /** 100 101 * **Requires `path` to start with `https://get.heyapi.dev` or be undefined** 101 102 *