microsoft/typespec
Publicmirrored from https://github.com/microsoft/typespecAvailable
packages/openapi3/src/openapi.ts
1511lines · modecode
| 1 | import { |
| 2 | BooleanLiteral, |
| 3 | compilerAssert, |
| 4 | DiscriminatedUnion, |
| 5 | EmitContext, |
| 6 | emitFile, |
| 7 | Enum, |
| 8 | EnumMember, |
| 9 | getAllTags, |
| 10 | getAnyExtensionFromPath, |
| 11 | getDiscriminatedUnion, |
| 12 | getDiscriminator, |
| 13 | getDoc, |
| 14 | getFormat, |
| 15 | getKnownValues, |
| 16 | getMaxItems, |
| 17 | getMaxLength, |
| 18 | getMaxValue, |
| 19 | getMaxValueExclusive, |
| 20 | getMinItems, |
| 21 | getMinLength, |
| 22 | getMinValue, |
| 23 | getMinValueExclusive, |
| 24 | getNamespaceFullName, |
| 25 | getPattern, |
| 26 | getPropertyType, |
| 27 | getService, |
| 28 | getSummary, |
| 29 | ignoreDiagnostics, |
| 30 | IntrinsicScalarName, |
| 31 | IntrinsicType, |
| 32 | isDeprecated, |
| 33 | isErrorType, |
| 34 | isGlobalNamespace, |
| 35 | isNeverType, |
| 36 | isNullType, |
| 37 | isNumericType, |
| 38 | isSecret, |
| 39 | isStringType, |
| 40 | isTemplateDeclaration, |
| 41 | isTemplateDeclarationOrInstance, |
| 42 | listServices, |
| 43 | Model, |
| 44 | ModelProperty, |
| 45 | Namespace, |
| 46 | navigateTypesInNamespace, |
| 47 | NewLine, |
| 48 | NumericLiteral, |
| 49 | Program, |
| 50 | ProjectionApplication, |
| 51 | projectProgram, |
| 52 | resolvePath, |
| 53 | Scalar, |
| 54 | Service, |
| 55 | StringLiteral, |
| 56 | TwoLevelMap, |
| 57 | Type, |
| 58 | TypeNameOptions, |
| 59 | Union, |
| 60 | UnionVariant, |
| 61 | } from "@cadl-lang/compiler"; |
| 62 | |
| 63 | import { |
| 64 | checkDuplicateTypeName, |
| 65 | getExtensions, |
| 66 | getExternalDocs, |
| 67 | getOpenAPITypeName, |
| 68 | getParameterKey, |
| 69 | isReadonlyProperty, |
| 70 | resolveOperationId, |
| 71 | shouldInline, |
| 72 | } from "@cadl-lang/openapi"; |
| 73 | import { http } from "@cadl-lang/rest"; |
| 74 | import { |
| 75 | createMetadataInfo, |
| 76 | getAuthentication, |
| 77 | getHttpService, |
| 78 | getRequestVisibility, |
| 79 | getStatusCodeDescription, |
| 80 | getVisibilitySuffix, |
| 81 | HttpAuth, |
| 82 | HttpOperation, |
| 83 | HttpOperationParameter, |
| 84 | HttpOperationParameters, |
| 85 | HttpOperationResponse, |
| 86 | isContentTypeHeader, |
| 87 | isOverloadSameEndpoint, |
| 88 | MetadataInfo, |
| 89 | reportIfNoRoutes, |
| 90 | ServiceAuthentication, |
| 91 | Visibility, |
| 92 | } from "@cadl-lang/rest/http"; |
| 93 | import { buildVersionProjections } from "@cadl-lang/versioning"; |
| 94 | import yaml from "js-yaml"; |
| 95 | import { getOneOf, getRef } from "./decorators.js"; |
| 96 | import { FileType, OpenAPI3EmitterOptions, reportDiagnostic } from "./lib.js"; |
| 97 | import { |
| 98 | OpenAPI3Discriminator, |
| 99 | OpenAPI3Document, |
| 100 | OpenAPI3Header, |
| 101 | OpenAPI3OAuthFlows, |
| 102 | OpenAPI3Operation, |
| 103 | OpenAPI3Parameter, |
| 104 | OpenAPI3ParameterBase, |
| 105 | OpenAPI3Schema, |
| 106 | OpenAPI3SchemaProperty, |
| 107 | OpenAPI3SecurityScheme, |
| 108 | OpenAPI3Server, |
| 109 | OpenAPI3ServerVariable, |
| 110 | } from "./types.js"; |
| 111 | |
| 112 | const defaultFileType: FileType = "yaml"; |
| 113 | const defaultOptions = { |
| 114 | "new-line": "lf", |
| 115 | "omit-unreachable-types": false, |
| 116 | } as const; |
| 117 | |
| 118 | export async function $onEmit(context: EmitContext<OpenAPI3EmitterOptions>) { |
| 119 | const options = resolveOptions(context); |
| 120 | const emitter = createOAPIEmitter(context.program, options); |
| 121 | await emitter.emitOpenAPI(); |
| 122 | } |
| 123 | |
| 124 | function findFileTypeFromFilename(filename: string | undefined): FileType { |
| 125 | if (filename === undefined) { |
| 126 | return defaultFileType; |
| 127 | } |
| 128 | switch (getAnyExtensionFromPath(filename)) { |
| 129 | case ".yaml": |
| 130 | case ".yml": |
| 131 | return "yaml"; |
| 132 | case ".json": |
| 133 | return "json"; |
| 134 | default: |
| 135 | return defaultFileType; |
| 136 | } |
| 137 | } |
| 138 | export function resolveOptions( |
| 139 | context: EmitContext<OpenAPI3EmitterOptions> |
| 140 | ): ResolvedOpenAPI3EmitterOptions { |
| 141 | const resolvedOptions = { ...defaultOptions, ...context.options }; |
| 142 | |
| 143 | const fileType = |
| 144 | resolvedOptions["file-type"] ?? findFileTypeFromFilename(resolvedOptions["output-file"]); |
| 145 | |
| 146 | const outputFile = resolvedOptions["output-file"] ?? `openapi.${fileType}`; |
| 147 | return { |
| 148 | fileType, |
| 149 | newLine: resolvedOptions["new-line"], |
| 150 | omitUnreachableTypes: resolvedOptions["omit-unreachable-types"], |
| 151 | outputFile: resolvePath(context.emitterOutputDir, outputFile), |
| 152 | }; |
| 153 | } |
| 154 | |
| 155 | export interface ResolvedOpenAPI3EmitterOptions { |
| 156 | fileType: FileType; |
| 157 | outputFile: string; |
| 158 | newLine: NewLine; |
| 159 | omitUnreachableTypes: boolean; |
| 160 | } |
| 161 | |
| 162 | /** |
| 163 | * Represents a node that will hold a JSON reference. The value is computed |
| 164 | * at the end so that we can defer decisions about the name that is |
| 165 | * referenced. |
| 166 | */ |
| 167 | class Ref { |
| 168 | value?: string; |
| 169 | toJSON() { |
| 170 | compilerAssert(this.value, "Reference value never set."); |
| 171 | return this.value; |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | /** |
| 176 | * Represents a non-inlined schema that will be emitted as a definition. |
| 177 | * Computation of the OpenAPI schema object is deferred. |
| 178 | */ |
| 179 | interface PendingSchema { |
| 180 | /** The CADL type for the schema */ |
| 181 | type: Type; |
| 182 | |
| 183 | /** The visibility to apply when computing the schema */ |
| 184 | visibility: Visibility; |
| 185 | |
| 186 | /** |
| 187 | * The JSON reference to use to point to this schema. |
| 188 | * |
| 189 | * Note that its value will not be computed until all schemas have been |
| 190 | * computed as we will add a suffix to the name if more than one schema |
| 191 | * must be emitted for the type for different visibilities. |
| 192 | */ |
| 193 | ref: Ref; |
| 194 | } |
| 195 | |
| 196 | /** |
| 197 | * Represents a schema that is ready to emit as its OpenAPI representation |
| 198 | * has been produced. |
| 199 | */ |
| 200 | interface ProcessedSchema extends PendingSchema { |
| 201 | schema: OpenAPI3Schema | undefined; |
| 202 | } |
| 203 | |
| 204 | function createOAPIEmitter(program: Program, options: ResolvedOpenAPI3EmitterOptions) { |
| 205 | let root: OpenAPI3Document; |
| 206 | |
| 207 | // Get the service namespace string for use in name shortening |
| 208 | let serviceNamespace: string | undefined; |
| 209 | let currentPath: any; |
| 210 | let currentEndpoint: OpenAPI3Operation; |
| 211 | |
| 212 | let metadataInfo: MetadataInfo; |
| 213 | |
| 214 | // Keep a map of all Types+Visibility combinations that were encountered |
| 215 | // that need schema definitions. |
| 216 | let pendingSchemas = new TwoLevelMap<Type, Visibility, PendingSchema>(); |
| 217 | |
| 218 | // Reuse a single ref object per Type+Visibility combination. |
| 219 | let refs = new TwoLevelMap<Type, Visibility, Ref>(); |
| 220 | |
| 221 | // Keep track of inline types still in the process of having their schema computed |
| 222 | // This is used to detect cycles in inline types, which is an |
| 223 | let inProgressInlineTypes = new Set<Type>(); |
| 224 | |
| 225 | // Map model properties that represent shared parameters to their parameter |
| 226 | // definition that will go in #/components/parameters. Inlined parameters do not go in |
| 227 | // this map. |
| 228 | let params: Map<ModelProperty, any>; |
| 229 | |
| 230 | // De-dupe the per-endpoint tags that will be added into the #/tags |
| 231 | let tags: Set<string>; |
| 232 | |
| 233 | const typeNameOptions: TypeNameOptions = { |
| 234 | // shorten type names by removing Cadl and service namespace |
| 235 | namespaceFilter(ns) { |
| 236 | const name = getNamespaceFullName(ns); |
| 237 | return name !== "Cadl" && name !== serviceNamespace; |
| 238 | }, |
| 239 | }; |
| 240 | |
| 241 | return { emitOpenAPI }; |
| 242 | |
| 243 | function initializeEmitter(service: Service, version?: string) { |
| 244 | const auth = processAuth(service.type); |
| 245 | |
| 246 | root = { |
| 247 | openapi: "3.0.0", |
| 248 | info: { |
| 249 | title: service.title ?? "(title)", |
| 250 | version: version ?? service.version ?? "0000-00-00", |
| 251 | description: getDoc(program, service.type), |
| 252 | }, |
| 253 | externalDocs: getExternalDocs(program, service.type), |
| 254 | tags: [], |
| 255 | paths: {}, |
| 256 | security: auth?.security, |
| 257 | components: { |
| 258 | parameters: {}, |
| 259 | requestBodies: {}, |
| 260 | responses: {}, |
| 261 | schemas: {}, |
| 262 | examples: {}, |
| 263 | securitySchemes: auth?.securitySchemes ?? {}, |
| 264 | }, |
| 265 | }; |
| 266 | const servers = http.getServers(program, service.type); |
| 267 | if (servers) { |
| 268 | root.servers = resolveServers(servers); |
| 269 | } |
| 270 | |
| 271 | serviceNamespace = getNamespaceFullName(service.type); |
| 272 | currentPath = root.paths; |
| 273 | pendingSchemas = new TwoLevelMap(); |
| 274 | refs = new TwoLevelMap(); |
| 275 | metadataInfo = createMetadataInfo(program, { |
| 276 | canShareProperty: (p) => isReadonlyProperty(program, p), |
| 277 | }); |
| 278 | inProgressInlineTypes = new Set(); |
| 279 | params = new Map(); |
| 280 | tags = new Set(); |
| 281 | } |
| 282 | |
| 283 | function isValidServerVariableType(program: Program, type: Type): boolean { |
| 284 | switch (type.kind) { |
| 285 | case "String": |
| 286 | case "Union": |
| 287 | case "Scalar": |
| 288 | return ignoreDiagnostics( |
| 289 | program.checker.isTypeAssignableTo( |
| 290 | type.projectionBase ?? type, |
| 291 | program.checker.getStdType("string"), |
| 292 | type |
| 293 | ) |
| 294 | ); |
| 295 | case "Enum": |
| 296 | for (const member of type.members.values()) { |
| 297 | if (member.value && typeof member.value !== "string") { |
| 298 | return false; |
| 299 | } |
| 300 | } |
| 301 | return true; |
| 302 | default: |
| 303 | return false; |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | function validateValidServerVariable(program: Program, prop: ModelProperty) { |
| 308 | const isValid = isValidServerVariableType(program, prop.type); |
| 309 | |
| 310 | if (!isValid) { |
| 311 | reportDiagnostic(program, { |
| 312 | code: "invalid-server-variable", |
| 313 | format: { propName: prop.name }, |
| 314 | target: prop, |
| 315 | }); |
| 316 | } |
| 317 | return isValid; |
| 318 | } |
| 319 | |
| 320 | function resolveServers(servers: http.HttpServer[]): OpenAPI3Server[] { |
| 321 | return servers.map((server) => { |
| 322 | const variables: Record<string, OpenAPI3ServerVariable> = {}; |
| 323 | for (const [name, prop] of server.parameters) { |
| 324 | if (!validateValidServerVariable(program, prop)) { |
| 325 | continue; |
| 326 | } |
| 327 | |
| 328 | const variable: OpenAPI3ServerVariable = { |
| 329 | default: prop.default ? getDefaultValue(prop.default) : "", |
| 330 | description: getDoc(program, prop), |
| 331 | }; |
| 332 | |
| 333 | if (prop.type.kind === "Enum") { |
| 334 | variable.enum = getSchemaForEnum(prop.type).enum; |
| 335 | } else if (prop.type.kind === "Union") { |
| 336 | variable.enum = getSchemaForUnion(prop.type, Visibility.All).enum; |
| 337 | } else if (prop.type.kind === "String") { |
| 338 | variable.enum = [prop.type.value]; |
| 339 | } |
| 340 | attachExtensions(program, prop, variable); |
| 341 | variables[name] = variable; |
| 342 | } |
| 343 | return { |
| 344 | url: server.url, |
| 345 | description: server.description, |
| 346 | variables, |
| 347 | }; |
| 348 | }); |
| 349 | } |
| 350 | |
| 351 | async function emitOpenAPI() { |
| 352 | const services = listServices(program); |
| 353 | if (services.length === 0) { |
| 354 | services.push({ type: program.getGlobalNamespaceType() }); |
| 355 | } |
| 356 | for (const service of services) { |
| 357 | const commonProjections: ProjectionApplication[] = [ |
| 358 | { |
| 359 | projectionName: "target", |
| 360 | arguments: ["json"], |
| 361 | }, |
| 362 | ]; |
| 363 | const originalProgram = program; |
| 364 | const versions = buildVersionProjections(program, service.type); |
| 365 | for (const record of versions) { |
| 366 | const projectedProgram = (program = projectProgram(originalProgram, [ |
| 367 | ...commonProjections, |
| 368 | ...record.projections, |
| 369 | ])); |
| 370 | const projectedServiceNs: Namespace = projectedProgram.projector.projectedTypes.get( |
| 371 | service.type |
| 372 | ) as Namespace; |
| 373 | |
| 374 | await emitOpenAPIFromVersion( |
| 375 | projectedServiceNs === projectedProgram.getGlobalNamespaceType() |
| 376 | ? { type: projectedProgram.getGlobalNamespaceType() } |
| 377 | : getService(program, projectedServiceNs)!, |
| 378 | services.length > 1, |
| 379 | record.version |
| 380 | ); |
| 381 | } |
| 382 | } |
| 383 | } |
| 384 | |
| 385 | function resolveOutputFile(service: Service, multipleService: boolean, version?: string): string { |
| 386 | const suffix = []; |
| 387 | if (multipleService) { |
| 388 | suffix.push(getNamespaceFullName(service.type)); |
| 389 | } |
| 390 | if (version) { |
| 391 | suffix.push(version); |
| 392 | } |
| 393 | if (suffix.length === 0) { |
| 394 | return options.outputFile; |
| 395 | } |
| 396 | |
| 397 | const extension = getAnyExtensionFromPath(options.outputFile); |
| 398 | const filenameWithoutExtension = options.outputFile.slice(0, -extension.length); |
| 399 | return `${filenameWithoutExtension}.${suffix.join(".")}${extension}`; |
| 400 | } |
| 401 | |
| 402 | async function emitOpenAPIFromVersion( |
| 403 | service: Service, |
| 404 | multipleService: boolean, |
| 405 | version?: string |
| 406 | ) { |
| 407 | initializeEmitter(service, version); |
| 408 | try { |
| 409 | const httpService = ignoreDiagnostics(getHttpService(program, service.type)); |
| 410 | reportIfNoRoutes(program, httpService.operations); |
| 411 | |
| 412 | for (const operation of httpService.operations) { |
| 413 | if (operation.overloading !== undefined && isOverloadSameEndpoint(operation as any)) { |
| 414 | continue; |
| 415 | } else { |
| 416 | emitOperation(operation); |
| 417 | } |
| 418 | } |
| 419 | emitParameters(); |
| 420 | emitUnreferencedSchemas(service.type); |
| 421 | emitSchemas(); |
| 422 | emitTags(); |
| 423 | |
| 424 | // Clean up empty entries |
| 425 | if (root.components) { |
| 426 | for (const elem of Object.keys(root.components)) { |
| 427 | if (Object.keys(root.components[elem as any]).length === 0) { |
| 428 | delete root.components[elem as any]; |
| 429 | } |
| 430 | } |
| 431 | } |
| 432 | |
| 433 | if (!program.compilerOptions.noEmit && !program.hasError()) { |
| 434 | // Write out the OpenAPI document to the output path |
| 435 | |
| 436 | await emitFile(program, { |
| 437 | path: resolveOutputFile(service, multipleService, version), |
| 438 | content: serializeDocument(root, options.fileType), |
| 439 | newLine: options.newLine, |
| 440 | }); |
| 441 | } |
| 442 | } catch (err) { |
| 443 | if (err instanceof ErrorTypeFoundError) { |
| 444 | // Return early, there must be a parse error if an ErrorType was |
| 445 | // inserted into the Cadl output |
| 446 | return; |
| 447 | } else { |
| 448 | throw err; |
| 449 | } |
| 450 | } |
| 451 | } |
| 452 | |
| 453 | function emitOperation(operation: HttpOperation): void { |
| 454 | const { path: fullPath, operation: op, verb, parameters } = operation; |
| 455 | |
| 456 | // If path contains a query string, issue msg and don't emit this endpoint |
| 457 | if (fullPath.indexOf("?") > 0) { |
| 458 | reportDiagnostic(program, { code: "path-query", target: op }); |
| 459 | return; |
| 460 | } |
| 461 | |
| 462 | if (!root.paths[fullPath]) { |
| 463 | root.paths[fullPath] = {}; |
| 464 | } |
| 465 | |
| 466 | currentPath = root.paths[fullPath]; |
| 467 | if (!currentPath[verb]) { |
| 468 | currentPath[verb] = {}; |
| 469 | } |
| 470 | currentEndpoint = currentPath[verb]; |
| 471 | |
| 472 | const currentTags = getAllTags(program, op); |
| 473 | if (currentTags) { |
| 474 | currentEndpoint.tags = currentTags; |
| 475 | for (const tag of currentTags) { |
| 476 | // Add to root tags if not already there |
| 477 | tags.add(tag); |
| 478 | } |
| 479 | } |
| 480 | |
| 481 | currentEndpoint.operationId = resolveOperationId(program, op); |
| 482 | applyExternalDocs(op, currentEndpoint); |
| 483 | |
| 484 | // Set up basic endpoint fields |
| 485 | currentEndpoint.summary = getSummary(program, op); |
| 486 | currentEndpoint.description = getDoc(program, op); |
| 487 | currentEndpoint.parameters = []; |
| 488 | currentEndpoint.responses = {}; |
| 489 | |
| 490 | const visibility = getRequestVisibility(verb); |
| 491 | emitEndpointParameters(parameters.parameters, visibility); |
| 492 | emitRequestBody(parameters, visibility); |
| 493 | emitResponses(operation.responses); |
| 494 | |
| 495 | if (isDeprecated(program, op)) { |
| 496 | currentEndpoint.deprecated = true; |
| 497 | } |
| 498 | |
| 499 | attachExtensions(program, op, currentEndpoint); |
| 500 | } |
| 501 | |
| 502 | function emitResponses(responses: HttpOperationResponse[]) { |
| 503 | for (const response of responses) { |
| 504 | emitResponseObject(response); |
| 505 | } |
| 506 | } |
| 507 | |
| 508 | function isBinaryPayload(body: Type, contentType: string) { |
| 509 | return ( |
| 510 | body.kind === "Scalar" && |
| 511 | body.name === "bytes" && |
| 512 | contentType !== "application/json" && |
| 513 | contentType !== "text/plain" |
| 514 | ); |
| 515 | } |
| 516 | |
| 517 | function getOpenAPIStatuscode(response: HttpOperationResponse): string { |
| 518 | switch (response.statusCode) { |
| 519 | case "*": |
| 520 | return "default"; |
| 521 | default: |
| 522 | return response.statusCode; |
| 523 | } |
| 524 | } |
| 525 | |
| 526 | function emitResponseObject(response: Readonly<HttpOperationResponse>) { |
| 527 | const statusCode = getOpenAPIStatuscode(response); |
| 528 | const openapiResponse = currentEndpoint.responses[statusCode] ?? { |
| 529 | description: response.description ?? getResponseDescriptionForStatusCode(statusCode), |
| 530 | }; |
| 531 | |
| 532 | for (const data of response.responses) { |
| 533 | if (data.headers && Object.keys(data.headers).length > 0) { |
| 534 | openapiResponse.headers ??= {}; |
| 535 | // OpenAPI can't represent different headers per content type. |
| 536 | // So we merge headers here, and report any duplicates. |
| 537 | // It may be possible in principle to not error for identically declared |
| 538 | // headers. |
| 539 | for (const [key, value] of Object.entries(data.headers)) { |
| 540 | if (openapiResponse.headers[key]) { |
| 541 | reportDiagnostic(program, { |
| 542 | code: "duplicate-header", |
| 543 | format: { header: key }, |
| 544 | target: response.type, |
| 545 | }); |
| 546 | continue; |
| 547 | } |
| 548 | openapiResponse.headers[key] = getResponseHeader(value); |
| 549 | } |
| 550 | } |
| 551 | |
| 552 | if (data.body !== undefined) { |
| 553 | openapiResponse.content ??= {}; |
| 554 | for (const contentType of data.body.contentTypes) { |
| 555 | const isBinary = isBinaryPayload(data.body.type, contentType); |
| 556 | const schema = isBinary |
| 557 | ? { type: "string", format: "binary" } |
| 558 | : getSchemaOrRef(data.body.type, Visibility.Read); |
| 559 | openapiResponse.content[contentType] = { schema }; |
| 560 | } |
| 561 | } |
| 562 | } |
| 563 | |
| 564 | currentEndpoint.responses[statusCode] = openapiResponse; |
| 565 | } |
| 566 | |
| 567 | function getResponseDescriptionForStatusCode(statusCode: string) { |
| 568 | if (statusCode === "default") { |
| 569 | return "An unexpected error response."; |
| 570 | } |
| 571 | return getStatusCodeDescription(statusCode) ?? "unknown"; |
| 572 | } |
| 573 | |
| 574 | function getResponseHeader(prop: ModelProperty): OpenAPI3Header | undefined { |
| 575 | return getOpenAPIParameterBase(prop, Visibility.Read); |
| 576 | } |
| 577 | |
| 578 | function getSchemaOrRef(type: Type, visibility: Visibility): any { |
| 579 | const refUrl = getRef(program, type); |
| 580 | if (refUrl) { |
| 581 | return { |
| 582 | $ref: refUrl, |
| 583 | }; |
| 584 | } |
| 585 | |
| 586 | if (type.kind === "Scalar" && program.checker.isStdType(type)) { |
| 587 | return getSchemaForScalar(type); |
| 588 | } |
| 589 | |
| 590 | if (type.kind === "String" || type.kind === "Number" || type.kind === "Boolean") { |
| 591 | // For literal types, we just want to emit them directly as well. |
| 592 | return mapCadlTypeToOpenAPI(type, visibility); |
| 593 | } |
| 594 | |
| 595 | if (type.kind === "Intrinsic" && type.name === "unknown") { |
| 596 | return getSchemaForIntrinsicType(type); |
| 597 | } |
| 598 | |
| 599 | if (type.kind === "EnumMember") { |
| 600 | // Enum members are just the OA representation of their values. |
| 601 | if (typeof type.value === "number") { |
| 602 | return { type: "number", enum: [type.value] }; |
| 603 | } else { |
| 604 | return { type: "string", enum: [type.value ?? type.name] }; |
| 605 | } |
| 606 | } |
| 607 | |
| 608 | if (type.kind === "ModelProperty") { |
| 609 | return resolveProperty(type, visibility); |
| 610 | } |
| 611 | |
| 612 | type = metadataInfo.getEffectivePayloadType(type, visibility); |
| 613 | |
| 614 | const name = getOpenAPITypeName(program, type, typeNameOptions); |
| 615 | if (shouldInline(program, type)) { |
| 616 | const schema = getSchemaForInlineType(type, visibility, name); |
| 617 | |
| 618 | if (schema === undefined && isErrorType(type)) { |
| 619 | // Exit early so that syntax errors are exposed. This error will |
| 620 | // be caught and handled in emitOpenAPI. |
| 621 | throw new ErrorTypeFoundError(); |
| 622 | } |
| 623 | |
| 624 | // helps to read output and correlate to Cadl |
| 625 | if (schema) { |
| 626 | schema["x-cadl-name"] = name; |
| 627 | } |
| 628 | return schema; |
| 629 | } else { |
| 630 | // Use shared schema when type is not transformed by visibility. |
| 631 | if (!metadataInfo.isTransformed(type, visibility)) { |
| 632 | visibility = Visibility.All; |
| 633 | } |
| 634 | const pending = pendingSchemas.getOrAdd(type, visibility, () => ({ |
| 635 | type, |
| 636 | visibility, |
| 637 | ref: refs.getOrAdd(type, visibility, () => new Ref()), |
| 638 | })); |
| 639 | return { $ref: pending.ref }; |
| 640 | } |
| 641 | } |
| 642 | |
| 643 | function getSchemaForInlineType(type: Type, visibility: Visibility, name: string) { |
| 644 | if (inProgressInlineTypes.has(type)) { |
| 645 | reportDiagnostic(program, { |
| 646 | code: "inline-cycle", |
| 647 | format: { type: name }, |
| 648 | target: type, |
| 649 | }); |
| 650 | return {}; |
| 651 | } |
| 652 | inProgressInlineTypes.add(type); |
| 653 | const schema = getSchemaForType(type, visibility); |
| 654 | inProgressInlineTypes.delete(type); |
| 655 | return schema; |
| 656 | } |
| 657 | |
| 658 | function getParamPlaceholder(property: ModelProperty) { |
| 659 | let spreadParam = false; |
| 660 | |
| 661 | if (property.sourceProperty) { |
| 662 | // chase our sources all the way back to the first place this property |
| 663 | // was defined. |
| 664 | spreadParam = true; |
| 665 | property = property.sourceProperty; |
| 666 | while (property.sourceProperty) { |
| 667 | property = property.sourceProperty; |
| 668 | } |
| 669 | } |
| 670 | |
| 671 | const refUrl = getRef(program, property); |
| 672 | if (refUrl) { |
| 673 | return { |
| 674 | $ref: refUrl, |
| 675 | }; |
| 676 | } |
| 677 | |
| 678 | if (params.has(property)) { |
| 679 | return params.get(property); |
| 680 | } |
| 681 | |
| 682 | const placeholder = {}; |
| 683 | |
| 684 | // only parameters inherited by spreading from non-inlined type are shared in #/components/parameters |
| 685 | if (spreadParam && property.model && !shouldInline(program, property.model)) { |
| 686 | params.set(property, placeholder); |
| 687 | } |
| 688 | |
| 689 | return placeholder; |
| 690 | } |
| 691 | |
| 692 | function emitEndpointParameters(parameters: HttpOperationParameter[], visibility: Visibility) { |
| 693 | for (const httpOpParam of parameters) { |
| 694 | if (params.has(httpOpParam.param)) { |
| 695 | currentEndpoint.parameters.push(params.get(httpOpParam.param)); |
| 696 | continue; |
| 697 | } |
| 698 | if (httpOpParam.type === "header" && isContentTypeHeader(program, httpOpParam.param)) { |
| 699 | continue; |
| 700 | } |
| 701 | emitParameter(httpOpParam, visibility); |
| 702 | } |
| 703 | } |
| 704 | |
| 705 | function emitRequestBody(parameters: HttpOperationParameters, visibility: Visibility) { |
| 706 | const body = parameters.body; |
| 707 | if (body === undefined) { |
| 708 | return; |
| 709 | } |
| 710 | |
| 711 | const requestBody: any = { |
| 712 | description: body.parameter ? getDoc(program, body.parameter) : undefined, |
| 713 | content: {}, |
| 714 | }; |
| 715 | |
| 716 | const contentTypes = body.contentTypes.length > 0 ? body.contentTypes : ["application/json"]; |
| 717 | for (const contentType of contentTypes) { |
| 718 | const isBinary = isBinaryPayload(body.type, contentType); |
| 719 | const bodySchema = isBinary |
| 720 | ? { type: "string", format: "binary" } |
| 721 | : getSchemaOrRef(body.type, visibility); |
| 722 | const contentEntry: any = { |
| 723 | schema: bodySchema, |
| 724 | }; |
| 725 | requestBody.content[contentType] = contentEntry; |
| 726 | } |
| 727 | |
| 728 | currentEndpoint.requestBody = requestBody; |
| 729 | } |
| 730 | |
| 731 | function emitParameter(parameter: HttpOperationParameter, visibility: Visibility) { |
| 732 | if (isNeverType(parameter.param.type)) { |
| 733 | return; |
| 734 | } |
| 735 | const ph = getParamPlaceholder(parameter.param); |
| 736 | currentEndpoint.parameters.push(ph); |
| 737 | |
| 738 | // If the parameter already has a $ref, don't bother populating it |
| 739 | if (!("$ref" in ph)) { |
| 740 | populateParameter(ph, parameter, visibility); |
| 741 | } |
| 742 | } |
| 743 | |
| 744 | function getOpenAPIParameterBase( |
| 745 | param: ModelProperty, |
| 746 | visibility: Visibility |
| 747 | ): OpenAPI3ParameterBase | undefined { |
| 748 | const typeSchema = getSchemaForType(param.type, visibility); |
| 749 | if (!typeSchema) { |
| 750 | return undefined; |
| 751 | } |
| 752 | const schema = applyIntrinsicDecorators(param, typeSchema); |
| 753 | if (param.default) { |
| 754 | schema.default = getDefaultValue(param.default); |
| 755 | } |
| 756 | // Description is already provided in the parameter itself. |
| 757 | delete schema.description; |
| 758 | |
| 759 | const oaiParam: OpenAPI3ParameterBase = { |
| 760 | required: !param.optional, |
| 761 | description: getDoc(program, param), |
| 762 | schema, |
| 763 | }; |
| 764 | |
| 765 | attachExtensions(program, param, oaiParam); |
| 766 | |
| 767 | return oaiParam; |
| 768 | } |
| 769 | |
| 770 | function populateParameter( |
| 771 | ph: OpenAPI3Parameter, |
| 772 | parameter: HttpOperationParameter, |
| 773 | visibility: Visibility |
| 774 | ) { |
| 775 | ph.name = parameter.name; |
| 776 | ph.in = parameter.type; |
| 777 | if (parameter.type === "query") { |
| 778 | if (parameter.format === "csv") { |
| 779 | ph.style = "simple"; |
| 780 | } else if (parameter.format === "multi") { |
| 781 | ph.style = "form"; |
| 782 | ph.explode = true; |
| 783 | } |
| 784 | } else if (parameter.type === "header") { |
| 785 | if (parameter.format === "csv") { |
| 786 | ph.style = "simple"; |
| 787 | } |
| 788 | } |
| 789 | Object.assign(ph, getOpenAPIParameterBase(parameter.param, visibility)); |
| 790 | } |
| 791 | |
| 792 | function emitParameters() { |
| 793 | for (const [property, param] of params) { |
| 794 | const key = getParameterKey( |
| 795 | program, |
| 796 | property, |
| 797 | param, |
| 798 | root.components!.parameters!, |
| 799 | typeNameOptions |
| 800 | ); |
| 801 | |
| 802 | root.components!.parameters![key] = { ...param }; |
| 803 | for (const key of Object.keys(param)) { |
| 804 | delete param[key]; |
| 805 | } |
| 806 | |
| 807 | param.$ref = "#/components/parameters/" + encodeURIComponent(key); |
| 808 | } |
| 809 | } |
| 810 | |
| 811 | function emitUnreferencedSchemas(namespace: Namespace) { |
| 812 | if (options.omitUnreachableTypes) { |
| 813 | return; |
| 814 | } |
| 815 | const computeSchema = (x: Type) => getSchemaOrRef(x, Visibility.All); |
| 816 | |
| 817 | const skipSubNamespaces = isGlobalNamespace(program, namespace); |
| 818 | navigateTypesInNamespace( |
| 819 | namespace, |
| 820 | { |
| 821 | model: (x) => x.name !== "" && computeSchema(x), |
| 822 | scalar: computeSchema, |
| 823 | enum: computeSchema, |
| 824 | union: (x) => x.name !== undefined && computeSchema(x), |
| 825 | }, |
| 826 | { skipSubNamespaces } |
| 827 | ); |
| 828 | } |
| 829 | |
| 830 | function emitSchemas() { |
| 831 | // Process pending schemas. Note that getSchemaForType may pull in new |
| 832 | // pending schemas so we iterate until there are no pending schemas |
| 833 | // remaining. |
| 834 | const processedSchemas = new TwoLevelMap<Type, Visibility, ProcessedSchema>(); |
| 835 | while (pendingSchemas.size > 0) { |
| 836 | for (const [type, group] of pendingSchemas) { |
| 837 | for (const [visibility, pending] of group) { |
| 838 | processedSchemas.getOrAdd(type, visibility, () => ({ |
| 839 | ...pending, |
| 840 | schema: getSchemaForType(type, visibility), |
| 841 | })); |
| 842 | } |
| 843 | pendingSchemas.delete(type); |
| 844 | } |
| 845 | } |
| 846 | |
| 847 | // Emit the processed schemas. Only now can we compute the names as it |
| 848 | // depends on whether we have produced multiple schemas for a single |
| 849 | // CADL type. |
| 850 | for (const group of processedSchemas.values()) { |
| 851 | for (const [visibility, processed] of group) { |
| 852 | let name = getOpenAPITypeName(program, processed.type, typeNameOptions); |
| 853 | if (group.size > 1) { |
| 854 | name += getVisibilitySuffix(visibility); |
| 855 | } |
| 856 | checkDuplicateTypeName(program, processed.type, name, root.components!.schemas); |
| 857 | processed.ref.value = "#/components/schemas/" + encodeURIComponent(name); |
| 858 | if (processed.schema) { |
| 859 | root.components!.schemas![name] = processed.schema; |
| 860 | } |
| 861 | } |
| 862 | } |
| 863 | } |
| 864 | |
| 865 | function emitTags() { |
| 866 | for (const tag of tags) { |
| 867 | root.tags!.push({ name: tag }); |
| 868 | } |
| 869 | } |
| 870 | |
| 871 | function getSchemaForType(type: Type, visibility: Visibility): OpenAPI3Schema | undefined { |
| 872 | const builtinType = mapCadlTypeToOpenAPI(type, visibility); |
| 873 | if (builtinType !== undefined) return builtinType; |
| 874 | |
| 875 | switch (type.kind) { |
| 876 | case "Intrinsic": |
| 877 | return getSchemaForIntrinsicType(type); |
| 878 | case "Model": |
| 879 | return getSchemaForModel(type, visibility); |
| 880 | case "ModelProperty": |
| 881 | return getSchemaForType(type.type, visibility); |
| 882 | case "Scalar": |
| 883 | return getSchemaForScalar(type); |
| 884 | case "Union": |
| 885 | return getSchemaForUnion(type, visibility); |
| 886 | case "UnionVariant": |
| 887 | return getSchemaForUnionVariant(type, visibility); |
| 888 | case "Enum": |
| 889 | return getSchemaForEnum(type); |
| 890 | case "Tuple": |
| 891 | return { type: "array", items: {} }; |
| 892 | case "TemplateParameter": |
| 893 | // Note: This should never happen if it does there is a bug in the compiler. |
| 894 | reportDiagnostic(program, { |
| 895 | code: "invalid-schema", |
| 896 | format: { type: `${type.node.id.sv} (template parameter)` }, |
| 897 | target: type, |
| 898 | }); |
| 899 | return undefined; |
| 900 | } |
| 901 | |
| 902 | reportDiagnostic(program, { |
| 903 | code: "invalid-schema", |
| 904 | format: { type: type.kind }, |
| 905 | target: type, |
| 906 | }); |
| 907 | return undefined; |
| 908 | } |
| 909 | |
| 910 | function getSchemaForIntrinsicType(type: IntrinsicType): OpenAPI3Schema { |
| 911 | switch (type.name) { |
| 912 | case "unknown": |
| 913 | return {}; |
| 914 | } |
| 915 | |
| 916 | reportDiagnostic(program, { |
| 917 | code: "invalid-schema", |
| 918 | format: { type: type.name }, |
| 919 | target: type, |
| 920 | }); |
| 921 | return {}; |
| 922 | } |
| 923 | |
| 924 | function getSchemaForEnum(e: Enum) { |
| 925 | const values = []; |
| 926 | if (e.members.size === 0) { |
| 927 | reportUnsupportedUnion("empty"); |
| 928 | return undefined; |
| 929 | } |
| 930 | const type = enumMemberType(e.members.values().next().value); |
| 931 | for (const option of e.members.values()) { |
| 932 | if (type !== enumMemberType(option)) { |
| 933 | reportUnsupportedUnion(); |
| 934 | continue; |
| 935 | } |
| 936 | |
| 937 | values.push(option.value ?? option.name); |
| 938 | } |
| 939 | |
| 940 | const schema: any = { type, description: getDoc(program, e) }; |
| 941 | if (values.length > 0) { |
| 942 | schema.enum = values; |
| 943 | } |
| 944 | |
| 945 | return schema; |
| 946 | function enumMemberType(member: EnumMember) { |
| 947 | if (typeof member.value === "number") { |
| 948 | return "number"; |
| 949 | } |
| 950 | return "string"; |
| 951 | } |
| 952 | |
| 953 | function reportUnsupportedUnion(messageId: "default" | "empty" = "default") { |
| 954 | reportDiagnostic(program, { code: "union-unsupported", messageId, target: e }); |
| 955 | } |
| 956 | } |
| 957 | |
| 958 | /** |
| 959 | * A Cadl union maps to a variety of OA3 structures according to the following rules: |
| 960 | * |
| 961 | * * A union containing `null` makes a `nullable` schema comprised of the remaining |
| 962 | * union variants. |
| 963 | * * A union containing literal types are converted to OA3 enums. All literals of the |
| 964 | * same type are combined into single enums. |
| 965 | * * A union that contains multiple items (after removing null and combining like-typed |
| 966 | * literals into enums) is an `anyOf` union unless `oneOf` is applied to the union |
| 967 | * declaration. |
| 968 | */ |
| 969 | function getSchemaForUnion(union: Union, visibility: Visibility) { |
| 970 | const variants = Array.from(union.variants.values()); |
| 971 | const literalVariantEnumByType: Record<string, any> = {}; |
| 972 | const ofType = getOneOf(program, union) ? "oneOf" : "anyOf"; |
| 973 | const schemaMembers: { schema: any; type: Type | null }[] = []; |
| 974 | let nullable = false; |
| 975 | const discriminator = getDiscriminator(program, union); |
| 976 | |
| 977 | for (const variant of variants) { |
| 978 | if (isNullType(variant.type)) { |
| 979 | nullable = true; |
| 980 | continue; |
| 981 | } |
| 982 | |
| 983 | if (isLiteralType(variant.type)) { |
| 984 | if (!literalVariantEnumByType[variant.type.kind]) { |
| 985 | const enumSchema = mapCadlTypeToOpenAPI(variant.type, visibility); |
| 986 | literalVariantEnumByType[variant.type.kind] = enumSchema; |
| 987 | schemaMembers.push({ schema: enumSchema, type: null }); |
| 988 | } else { |
| 989 | literalVariantEnumByType[variant.type.kind].enum.push(variant.type.value); |
| 990 | } |
| 991 | continue; |
| 992 | } |
| 993 | |
| 994 | schemaMembers.push({ schema: getSchemaOrRef(variant.type, visibility), type: variant.type }); |
| 995 | } |
| 996 | |
| 997 | if (schemaMembers.length === 0) { |
| 998 | if (nullable) { |
| 999 | // This union is equivalent to just `null` but OA3 has no way to specify |
| 1000 | // null as a value, so we throw an error. |
| 1001 | reportDiagnostic(program, { code: "union-null", target: union }); |
| 1002 | return {}; |
| 1003 | } else { |
| 1004 | // completely empty union can maybe only happen with bugs? |
| 1005 | compilerAssert(false, "Attempting to emit an empty union"); |
| 1006 | } |
| 1007 | } |
| 1008 | |
| 1009 | if (schemaMembers.length === 1) { |
| 1010 | // we can just return the single schema member after applying nullable |
| 1011 | const schema = schemaMembers[0].schema; |
| 1012 | const type = schemaMembers[0].type; |
| 1013 | |
| 1014 | if (nullable) { |
| 1015 | if (schema.$ref) { |
| 1016 | // but we can't make a ref "nullable", so wrap in an allOf (for models) |
| 1017 | // or oneOf (for all other types) |
| 1018 | if (type && type.kind === "Model") { |
| 1019 | return { type: "object", allOf: [schema], nullable: true }; |
| 1020 | } else { |
| 1021 | return { oneOf: [schema], nullable: true }; |
| 1022 | } |
| 1023 | } else { |
| 1024 | schema.nullable = true; |
| 1025 | } |
| 1026 | } |
| 1027 | |
| 1028 | return schema; |
| 1029 | } |
| 1030 | |
| 1031 | const schema: any = { |
| 1032 | [ofType]: schemaMembers.map((m) => m.schema), |
| 1033 | }; |
| 1034 | |
| 1035 | if (nullable) { |
| 1036 | schema.nullable = true; |
| 1037 | } |
| 1038 | |
| 1039 | if (discriminator) { |
| 1040 | // the decorator validates that all the variants will be a model type |
| 1041 | // with the discriminator field present. |
| 1042 | schema.discriminator = discriminator; |
| 1043 | // Diagnostic already reported in compiler for unions |
| 1044 | const discriminatedUnion = ignoreDiagnostics(getDiscriminatedUnion(union, discriminator)); |
| 1045 | if (discriminatedUnion.variants.size > 0) { |
| 1046 | schema.discriminator.mapping = getDiscriminatorMapping(discriminatedUnion, visibility); |
| 1047 | } |
| 1048 | } |
| 1049 | |
| 1050 | return schema; |
| 1051 | } |
| 1052 | |
| 1053 | function getSchemaForUnionVariant(variant: UnionVariant, visibility: Visibility) { |
| 1054 | const schema: any = getSchemaForType(variant.type, visibility); |
| 1055 | return schema; |
| 1056 | } |
| 1057 | |
| 1058 | function isLiteralType(type: Type): type is StringLiteral | NumericLiteral | BooleanLiteral { |
| 1059 | return type.kind === "Boolean" || type.kind === "String" || type.kind === "Number"; |
| 1060 | } |
| 1061 | |
| 1062 | function getDefaultValue(type: Type): any { |
| 1063 | switch (type.kind) { |
| 1064 | case "String": |
| 1065 | return type.value; |
| 1066 | case "Number": |
| 1067 | return type.value; |
| 1068 | case "Boolean": |
| 1069 | return type.value; |
| 1070 | case "Tuple": |
| 1071 | return type.values.map(getDefaultValue); |
| 1072 | case "EnumMember": |
| 1073 | return type.value ?? type.name; |
| 1074 | default: |
| 1075 | reportDiagnostic(program, { |
| 1076 | code: "invalid-default", |
| 1077 | format: { type: type.kind }, |
| 1078 | target: type, |
| 1079 | }); |
| 1080 | } |
| 1081 | } |
| 1082 | |
| 1083 | function includeDerivedModel(model: Model): boolean { |
| 1084 | return ( |
| 1085 | !isTemplateDeclaration(model) && |
| 1086 | (model.templateArguments === undefined || |
| 1087 | model.templateArguments?.length === 0 || |
| 1088 | model.derivedModels.length > 0) |
| 1089 | ); |
| 1090 | } |
| 1091 | |
| 1092 | function getSchemaForModel(model: Model, visibility: Visibility) { |
| 1093 | let modelSchema: OpenAPI3Schema & Required<Pick<OpenAPI3Schema, "properties">> = { |
| 1094 | type: "object", |
| 1095 | properties: {}, |
| 1096 | description: getDoc(program, model), |
| 1097 | }; |
| 1098 | |
| 1099 | const derivedModels = model.derivedModels.filter(includeDerivedModel); |
| 1100 | // getSchemaOrRef on all children to push them into components.schemas |
| 1101 | for (const child of derivedModels) { |
| 1102 | getSchemaOrRef(child, visibility); |
| 1103 | } |
| 1104 | |
| 1105 | const discriminator = getDiscriminator(program, model); |
| 1106 | if (discriminator) { |
| 1107 | const [union] = getDiscriminatedUnion(model, discriminator); |
| 1108 | |
| 1109 | const openApiDiscriminator: OpenAPI3Discriminator = { ...discriminator }; |
| 1110 | if (union.variants.size > 0) { |
| 1111 | openApiDiscriminator.mapping = getDiscriminatorMapping(union, visibility); |
| 1112 | } |
| 1113 | |
| 1114 | modelSchema.discriminator = openApiDiscriminator; |
| 1115 | modelSchema.properties[discriminator.propertyName] = { |
| 1116 | type: "string", |
| 1117 | description: `Discriminator property for ${model.name}.`, |
| 1118 | }; |
| 1119 | } |
| 1120 | |
| 1121 | applyExternalDocs(model, modelSchema); |
| 1122 | |
| 1123 | for (const [name, prop] of model.properties) { |
| 1124 | if (!metadataInfo.isPayloadProperty(prop, visibility)) { |
| 1125 | continue; |
| 1126 | } |
| 1127 | |
| 1128 | if (isNeverType(prop.type)) { |
| 1129 | // If the property has a type of 'never', don't include it in the schema |
| 1130 | continue; |
| 1131 | } |
| 1132 | |
| 1133 | if (!metadataInfo.isOptional(prop, visibility)) { |
| 1134 | if (!modelSchema.required) { |
| 1135 | modelSchema.required = []; |
| 1136 | } |
| 1137 | modelSchema.required.push(name); |
| 1138 | } |
| 1139 | |
| 1140 | modelSchema.properties[name] = resolveProperty(prop, visibility); |
| 1141 | } |
| 1142 | |
| 1143 | // Special case: if a model type extends a single *templated* base type and |
| 1144 | // has no properties of its own, absorb the definition of the base model |
| 1145 | // into this schema definition. The assumption here is that any model type |
| 1146 | // defined like this is just meant to rename the underlying instance of a |
| 1147 | // templated type. |
| 1148 | if ( |
| 1149 | model.baseModel && |
| 1150 | isTemplateDeclarationOrInstance(model.baseModel) && |
| 1151 | Object.keys(modelSchema.properties).length === 0 |
| 1152 | ) { |
| 1153 | // Take the base model schema but carry across the documentation property |
| 1154 | // that we set before |
| 1155 | const baseSchema = getSchemaForType(model.baseModel, visibility); |
| 1156 | modelSchema = { |
| 1157 | ...(baseSchema as any), |
| 1158 | description: modelSchema.description, |
| 1159 | }; |
| 1160 | } else if (model.baseModel) { |
| 1161 | modelSchema.allOf = [getSchemaOrRef(model.baseModel, visibility)]; |
| 1162 | } |
| 1163 | |
| 1164 | // Attach any OpenAPI extensions |
| 1165 | attachExtensions(program, model, modelSchema); |
| 1166 | return modelSchema; |
| 1167 | } |
| 1168 | |
| 1169 | function resolveProperty(prop: ModelProperty, visibility: Visibility): OpenAPI3SchemaProperty { |
| 1170 | const description = getDoc(program, prop); |
| 1171 | |
| 1172 | const schema = getSchemaOrRef(prop.type, visibility); |
| 1173 | // Apply decorators on the property to the type's schema |
| 1174 | const additionalProps: Partial<OpenAPI3Schema> = applyIntrinsicDecorators(prop, {}); |
| 1175 | if (description) { |
| 1176 | additionalProps.description = description; |
| 1177 | } |
| 1178 | |
| 1179 | if (prop.default) { |
| 1180 | additionalProps.default = getDefaultValue(prop.default); |
| 1181 | } |
| 1182 | |
| 1183 | if (isReadonlyProperty(program, prop)) { |
| 1184 | additionalProps.readOnly = true; |
| 1185 | } |
| 1186 | |
| 1187 | // Attach any additional OpenAPI extensions |
| 1188 | attachExtensions(program, prop, additionalProps); |
| 1189 | if (schema && "$ref" in schema) { |
| 1190 | if (Object.keys(additionalProps).length === 0) { |
| 1191 | return schema; |
| 1192 | } else { |
| 1193 | return { |
| 1194 | allOf: [schema], |
| 1195 | ...additionalProps, |
| 1196 | }; |
| 1197 | } |
| 1198 | } else { |
| 1199 | return { ...schema, ...additionalProps }; |
| 1200 | } |
| 1201 | } |
| 1202 | |
| 1203 | function attachExtensions(program: Program, type: Type, emitObject: any) { |
| 1204 | // Attach any OpenAPI extensions |
| 1205 | const extensions = getExtensions(program, type); |
| 1206 | if (extensions) { |
| 1207 | for (const key of extensions.keys()) { |
| 1208 | emitObject[key] = extensions.get(key); |
| 1209 | } |
| 1210 | } |
| 1211 | } |
| 1212 | |
| 1213 | function getDiscriminatorMapping(union: DiscriminatedUnion, visibility: Visibility) { |
| 1214 | const mapping: Record<string, string> | undefined = {}; |
| 1215 | for (const [key, model] of union.variants.entries()) { |
| 1216 | mapping[key] = getSchemaOrRef(model, visibility).$ref; |
| 1217 | } |
| 1218 | return mapping; |
| 1219 | } |
| 1220 | |
| 1221 | function applyIntrinsicDecorators( |
| 1222 | cadlType: Scalar | ModelProperty, |
| 1223 | target: OpenAPI3Schema |
| 1224 | ): OpenAPI3Schema { |
| 1225 | const newTarget = { ...target }; |
| 1226 | const docStr = getDoc(program, cadlType); |
| 1227 | const isString = isStringType(program, getPropertyType(cadlType)); |
| 1228 | const isNumeric = isNumericType(program, getPropertyType(cadlType)); |
| 1229 | |
| 1230 | if (!target.description && docStr) { |
| 1231 | newTarget.description = docStr; |
| 1232 | } |
| 1233 | const formatStr = getFormat(program, cadlType); |
| 1234 | if (isString && !target.format && formatStr) { |
| 1235 | newTarget.format = formatStr; |
| 1236 | } |
| 1237 | |
| 1238 | const pattern = getPattern(program, cadlType); |
| 1239 | if (isString && !target.pattern && pattern) { |
| 1240 | newTarget.pattern = pattern; |
| 1241 | } |
| 1242 | |
| 1243 | const minLength = getMinLength(program, cadlType); |
| 1244 | if (isString && !target.minLength && minLength !== undefined) { |
| 1245 | newTarget.minLength = minLength; |
| 1246 | } |
| 1247 | |
| 1248 | const maxLength = getMaxLength(program, cadlType); |
| 1249 | if (isString && !target.maxLength && maxLength !== undefined) { |
| 1250 | newTarget.maxLength = maxLength; |
| 1251 | } |
| 1252 | |
| 1253 | const minValue = getMinValue(program, cadlType); |
| 1254 | if (isNumeric && !target.minimum && minValue !== undefined) { |
| 1255 | newTarget.minimum = minValue; |
| 1256 | } |
| 1257 | |
| 1258 | const minValueExclusive = getMinValueExclusive(program, cadlType); |
| 1259 | if (isNumeric && !target.exclusiveMinimum && minValueExclusive !== undefined) { |
| 1260 | newTarget.exclusiveMinimum = minValueExclusive; |
| 1261 | } |
| 1262 | |
| 1263 | const maxValue = getMaxValue(program, cadlType); |
| 1264 | if (isNumeric && !target.maximum && maxValue !== undefined) { |
| 1265 | newTarget.maximum = maxValue; |
| 1266 | } |
| 1267 | |
| 1268 | const maxValueExclusive = getMaxValueExclusive(program, cadlType); |
| 1269 | if (isNumeric && !target.exclusiveMaximum && maxValueExclusive !== undefined) { |
| 1270 | newTarget.exclusiveMaximum = maxValueExclusive; |
| 1271 | } |
| 1272 | |
| 1273 | const minItems = getMinItems(program, cadlType); |
| 1274 | if (!target.minItems && minItems !== undefined) { |
| 1275 | newTarget.minItems = minItems; |
| 1276 | } |
| 1277 | |
| 1278 | const maxItems = getMaxItems(program, cadlType); |
| 1279 | if (!target.maxItems && maxItems !== undefined) { |
| 1280 | newTarget.maxItems = maxItems; |
| 1281 | } |
| 1282 | |
| 1283 | if (isSecret(program, cadlType)) { |
| 1284 | newTarget.format = "password"; |
| 1285 | } |
| 1286 | |
| 1287 | if (isString) { |
| 1288 | const values = getKnownValues(program, cadlType); |
| 1289 | if (values) { |
| 1290 | return { |
| 1291 | oneOf: [newTarget, getSchemaForEnum(values)], |
| 1292 | }; |
| 1293 | } |
| 1294 | } |
| 1295 | |
| 1296 | attachExtensions(program, cadlType, newTarget); |
| 1297 | |
| 1298 | return newTarget; |
| 1299 | } |
| 1300 | |
| 1301 | function applyExternalDocs(cadlType: Type, target: Record<string, unknown>) { |
| 1302 | const externalDocs = getExternalDocs(program, cadlType); |
| 1303 | if (externalDocs) { |
| 1304 | target.externalDocs = externalDocs; |
| 1305 | } |
| 1306 | } |
| 1307 | |
| 1308 | // Map an Cadl type to an OA schema. Returns undefined when the resulting |
| 1309 | // OA schema is just a regular object schema. |
| 1310 | function mapCadlTypeToOpenAPI(cadlType: Type, visibility: Visibility): any { |
| 1311 | switch (cadlType.kind) { |
| 1312 | case "Number": |
| 1313 | return { type: "number", enum: [cadlType.value] }; |
| 1314 | case "String": |
| 1315 | return { type: "string", enum: [cadlType.value] }; |
| 1316 | case "Boolean": |
| 1317 | return { type: "boolean", enum: [cadlType.value] }; |
| 1318 | case "Model": |
| 1319 | return mapCadlIntrinsicModelToOpenAPI(cadlType, visibility); |
| 1320 | } |
| 1321 | } |
| 1322 | |
| 1323 | /** |
| 1324 | * Map Cadl intrinsic models to open api definitions |
| 1325 | */ |
| 1326 | function mapCadlIntrinsicModelToOpenAPI( |
| 1327 | cadlType: Model, |
| 1328 | visibility: Visibility |
| 1329 | ): any | undefined { |
| 1330 | if (cadlType.indexer) { |
| 1331 | if (isNeverType(cadlType.indexer.key)) { |
| 1332 | } else { |
| 1333 | const name = cadlType.indexer.key.name; |
| 1334 | if (name === "string") { |
| 1335 | return { |
| 1336 | type: "object", |
| 1337 | additionalProperties: getSchemaOrRef(cadlType.indexer.value!, visibility), |
| 1338 | }; |
| 1339 | } else if (name === "integer") { |
| 1340 | return { |
| 1341 | type: "array", |
| 1342 | items: getSchemaOrRef(cadlType.indexer.value!, visibility | Visibility.Item), |
| 1343 | }; |
| 1344 | } |
| 1345 | } |
| 1346 | } |
| 1347 | } |
| 1348 | |
| 1349 | function getSchemaForScalar(scalar: Scalar): OpenAPI3Schema { |
| 1350 | let result: OpenAPI3Schema = {}; |
| 1351 | if (program.checker.isStdType(scalar)) { |
| 1352 | result = getSchemaForStdScalars(scalar); |
| 1353 | } else if (scalar.baseScalar) { |
| 1354 | result = getSchemaForScalar(scalar.baseScalar); |
| 1355 | } |
| 1356 | return applyIntrinsicDecorators(scalar, result); |
| 1357 | } |
| 1358 | |
| 1359 | function getSchemaForStdScalars(scalar: Scalar & { name: IntrinsicScalarName }): OpenAPI3Schema { |
| 1360 | switch (scalar.name) { |
| 1361 | case "bytes": |
| 1362 | return { type: "string", format: "byte" }; |
| 1363 | case "int8": |
| 1364 | return { type: "integer", format: "int8" }; |
| 1365 | case "int16": |
| 1366 | return { type: "integer", format: "int16" }; |
| 1367 | case "int32": |
| 1368 | return { type: "integer", format: "int32" }; |
| 1369 | case "int64": |
| 1370 | return { type: "integer", format: "int64" }; |
| 1371 | case "safeint": |
| 1372 | return { type: "integer", format: "int64" }; |
| 1373 | case "uint8": |
| 1374 | return { type: "integer", format: "uint8" }; |
| 1375 | case "uint16": |
| 1376 | return { type: "integer", format: "uint16" }; |
| 1377 | case "uint32": |
| 1378 | return { type: "integer", format: "uint32" }; |
| 1379 | case "uint64": |
| 1380 | return { type: "integer", format: "uint64" }; |
| 1381 | case "float64": |
| 1382 | return { type: "number", format: "double" }; |
| 1383 | case "float32": |
| 1384 | return { type: "number", format: "float" }; |
| 1385 | case "string": |
| 1386 | return { type: "string" }; |
| 1387 | case "boolean": |
| 1388 | return { type: "boolean" }; |
| 1389 | case "plainDate": |
| 1390 | return { type: "string", format: "date" }; |
| 1391 | case "zonedDateTime": |
| 1392 | return { type: "string", format: "date-time" }; |
| 1393 | case "plainTime": |
| 1394 | return { type: "string", format: "time" }; |
| 1395 | case "duration": |
| 1396 | return { type: "string", format: "duration" }; |
| 1397 | case "url": |
| 1398 | return { type: "string", format: "uri" }; |
| 1399 | case "integer": |
| 1400 | case "numeric": |
| 1401 | case "float": |
| 1402 | return {}; // Waiting on design for more precise type https://github.com/microsoft/cadl/issues/1260 |
| 1403 | default: |
| 1404 | const _assertNever: never = scalar.name; |
| 1405 | return {}; |
| 1406 | } |
| 1407 | } |
| 1408 | |
| 1409 | function processAuth(serviceNamespace: Namespace): |
| 1410 | | { |
| 1411 | securitySchemes: Record<string, OpenAPI3SecurityScheme>; |
| 1412 | security: Record<string, string[]>[]; |
| 1413 | } |
| 1414 | | undefined { |
| 1415 | const authentication = getAuthentication(program, serviceNamespace); |
| 1416 | if (authentication) { |
| 1417 | return processServiceAuthentication(authentication); |
| 1418 | } |
| 1419 | return undefined; |
| 1420 | } |
| 1421 | |
| 1422 | function processServiceAuthentication(authentication: ServiceAuthentication): { |
| 1423 | securitySchemes: Record<string, OpenAPI3SecurityScheme>; |
| 1424 | security: Record<string, string[]>[]; |
| 1425 | } { |
| 1426 | const oaiSchemes: Record<string, OpenAPI3SecurityScheme> = {}; |
| 1427 | const security: Record<string, string[]>[] = []; |
| 1428 | for (const option of authentication.options) { |
| 1429 | const oai3SecurityOption: Record<string, string[]> = {}; |
| 1430 | for (const scheme of option.schemes) { |
| 1431 | const [oaiScheme, scopes] = getOpenAPI3Scheme(scheme); |
| 1432 | oaiSchemes[scheme.id] = oaiScheme; |
| 1433 | oai3SecurityOption[scheme.id] = scopes; |
| 1434 | } |
| 1435 | security.push(oai3SecurityOption); |
| 1436 | } |
| 1437 | return { securitySchemes: oaiSchemes, security }; |
| 1438 | } |
| 1439 | |
| 1440 | function getOpenAPI3Scheme(auth: HttpAuth): [OpenAPI3SecurityScheme, string[]] { |
| 1441 | switch (auth.type) { |
| 1442 | case "http": |
| 1443 | return [{ type: "http", scheme: auth.scheme, description: auth.description }, []]; |
| 1444 | case "apiKey": |
| 1445 | return [ |
| 1446 | { type: "apiKey", in: auth.in, name: auth.name, description: auth.description }, |
| 1447 | [], |
| 1448 | ]; |
| 1449 | case "oauth2": |
| 1450 | const flows: OpenAPI3OAuthFlows = {}; |
| 1451 | const scopes: string[] = []; |
| 1452 | for (const flow of auth.flows) { |
| 1453 | scopes.push(...flow.scopes.map((x) => x.value)); |
| 1454 | flows[flow.type] = { |
| 1455 | authorizationUrl: (flow as any).authorizationUrl, |
| 1456 | tokenUrl: (flow as any).tokenUrl, |
| 1457 | refreshUrl: flow.refreshUrl, |
| 1458 | scopes: Object.fromEntries(flow.scopes.map((x) => [x.value, x.description ?? ""])), |
| 1459 | }; |
| 1460 | } |
| 1461 | return [{ type: "oauth2", flows, description: auth.description }, scopes]; |
| 1462 | default: |
| 1463 | const _assertNever: never = auth; |
| 1464 | compilerAssert(false, "Unreachable"); |
| 1465 | } |
| 1466 | } |
| 1467 | } |
| 1468 | |
| 1469 | function serializeDocument(root: OpenAPI3Document, fileType: FileType): string { |
| 1470 | sortOpenAPIDocument(root); |
| 1471 | switch (fileType) { |
| 1472 | case "json": |
| 1473 | return prettierOutput(JSON.stringify(root, null, 2)); |
| 1474 | case "yaml": |
| 1475 | return yaml.dump(root, { |
| 1476 | noRefs: true, |
| 1477 | replacer: function (key, value) { |
| 1478 | return value instanceof Ref ? value.toJSON() : value; |
| 1479 | }, |
| 1480 | }); |
| 1481 | } |
| 1482 | } |
| 1483 | |
| 1484 | function prettierOutput(output: string) { |
| 1485 | return output + "\n"; |
| 1486 | } |
| 1487 | |
| 1488 | class ErrorTypeFoundError extends Error { |
| 1489 | constructor() { |
| 1490 | super("Error type found in evaluated Cadl output"); |
| 1491 | } |
| 1492 | } |
| 1493 | |
| 1494 | function sortObjectByKeys<T extends Record<string, unknown>>(obj: T): T { |
| 1495 | return Object.keys(obj) |
| 1496 | .sort() |
| 1497 | .reduce((sortedObj: any, key: string) => { |
| 1498 | sortedObj[key] = obj[key]; |
| 1499 | return sortedObj; |
| 1500 | }, {}); |
| 1501 | } |
| 1502 | |
| 1503 | function sortOpenAPIDocument(doc: OpenAPI3Document): void { |
| 1504 | doc.paths = sortObjectByKeys(doc.paths); |
| 1505 | if (doc.components?.schemas) { |
| 1506 | doc.components.schemas = sortObjectByKeys(doc.components.schemas); |
| 1507 | } |
| 1508 | if (doc.components?.parameters) { |
| 1509 | doc.components.parameters = sortObjectByKeys(doc.components.parameters); |
| 1510 | } |
| 1511 | } |