microsoft/typespec
Publicmirrored from https://github.com/microsoft/typespecAvailable
packages/openapi3/src/openapi.ts
1179lines · modecode
| 1 | import { |
| 2 | ArrayType, |
| 3 | checkIfServiceNamespace, |
| 4 | EmitOptionsFor, |
| 5 | EnumMemberType, |
| 6 | EnumType, |
| 7 | getAllTags, |
| 8 | getDoc, |
| 9 | getFormat, |
| 10 | getIntrinsicModelName, |
| 11 | getKnownValues, |
| 12 | getMaxLength, |
| 13 | getMaxValue, |
| 14 | getMinLength, |
| 15 | getMinValue, |
| 16 | getPattern, |
| 17 | getProperty, |
| 18 | getPropertyType, |
| 19 | getServiceHost, |
| 20 | getServiceNamespace, |
| 21 | getServiceNamespaceString, |
| 22 | getServiceTitle, |
| 23 | getServiceVersion, |
| 24 | getSummary, |
| 25 | getVisibility, |
| 26 | isErrorType, |
| 27 | isIntrinsic, |
| 28 | isNumericType, |
| 29 | isSecret, |
| 30 | isStringType, |
| 31 | isTemplate, |
| 32 | ModelType, |
| 33 | ModelTypeProperty, |
| 34 | NamespaceType, |
| 35 | OperationType, |
| 36 | Program, |
| 37 | resolvePath, |
| 38 | Type, |
| 39 | TypeNameOptions, |
| 40 | UnionType, |
| 41 | UnionTypeVariant, |
| 42 | } from "@cadl-lang/compiler"; |
| 43 | import { |
| 44 | getExtensions, |
| 45 | getExternalDocs, |
| 46 | getOperationId, |
| 47 | getParameterKey, |
| 48 | getTypeName, |
| 49 | shouldInline, |
| 50 | } from "@cadl-lang/openapi"; |
| 51 | import { |
| 52 | Discriminator, |
| 53 | getAllRoutes, |
| 54 | getContentTypes, |
| 55 | getDiscriminator, |
| 56 | http, |
| 57 | HttpOperationParameter, |
| 58 | HttpOperationParameters, |
| 59 | HttpOperationResponse, |
| 60 | OperationDetails, |
| 61 | } from "@cadl-lang/rest"; |
| 62 | import { buildVersionProjections } from "@cadl-lang/versioning"; |
| 63 | import { getOneOf, getRef } from "./decorators.js"; |
| 64 | import { OpenAPILibrary, reportDiagnostic } from "./lib.js"; |
| 65 | import { |
| 66 | OpenAPI3Discriminator, |
| 67 | OpenAPI3Operation, |
| 68 | OpenAPI3Parameter, |
| 69 | OpenAPI3ParameterType, |
| 70 | OpenAPI3Schema, |
| 71 | } from "./types.js"; |
| 72 | |
| 73 | const { |
| 74 | getHeaderFieldName, |
| 75 | getPathParamName, |
| 76 | getQueryParamName, |
| 77 | isStatusCode, |
| 78 | getStatusCodeDescription, |
| 79 | } = http; |
| 80 | |
| 81 | export async function $onEmit(p: Program, emitterOptions?: EmitOptionsFor<OpenAPILibrary>) { |
| 82 | const options: OpenAPIEmitterOptions = { |
| 83 | outputFile: p.compilerOptions.swaggerOutputFile || resolvePath("./openapi.json"), |
| 84 | }; |
| 85 | |
| 86 | const emitter = createOAPIEmitter(p, options); |
| 87 | await emitter.emitOpenAPI(); |
| 88 | } |
| 89 | |
| 90 | // NOTE: These functions aren't meant to be used directly as decorators but as a |
| 91 | // helper functions for other decorators. The security information given here |
| 92 | // will be inserted into the `security` and `securityDefinitions` sections of |
| 93 | // the emitted OpenAPI document. |
| 94 | |
| 95 | const securityDetailsKey = Symbol("securityDetails"); |
| 96 | interface SecurityDetails { |
| 97 | definitions: any; |
| 98 | requirements: any[]; |
| 99 | } |
| 100 | |
| 101 | function getSecurityDetails(program: Program, serviceNamespace: NamespaceType): SecurityDetails { |
| 102 | const definitions = program.stateMap(securityDetailsKey); |
| 103 | if (definitions.has(serviceNamespace)) { |
| 104 | return definitions.get(serviceNamespace)!; |
| 105 | } else { |
| 106 | const details = { definitions: {}, requirements: [] }; |
| 107 | definitions.set(serviceNamespace, details); |
| 108 | return details; |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | function getSecurityRequirements(program: Program, serviceNamespace: NamespaceType) { |
| 113 | return getSecurityDetails(program, serviceNamespace).requirements; |
| 114 | } |
| 115 | |
| 116 | function getSecurityDefinitions(program: Program, serviceNamespace: NamespaceType) { |
| 117 | return getSecurityDetails(program, serviceNamespace).definitions; |
| 118 | } |
| 119 | |
| 120 | export function addSecurityRequirement( |
| 121 | program: Program, |
| 122 | namespace: NamespaceType, |
| 123 | name: string, |
| 124 | scopes: string[] |
| 125 | ): void { |
| 126 | if (!checkIfServiceNamespace(program, namespace)) { |
| 127 | reportDiagnostic(program, { |
| 128 | code: "security-service-namespace", |
| 129 | target: namespace, |
| 130 | }); |
| 131 | } |
| 132 | |
| 133 | const req: any = {}; |
| 134 | req[name] = scopes; |
| 135 | const requirements = getSecurityRequirements(program, namespace); |
| 136 | requirements.push(req); |
| 137 | } |
| 138 | |
| 139 | export function addSecurityDefinition( |
| 140 | program: Program, |
| 141 | namespace: NamespaceType, |
| 142 | name: string, |
| 143 | details: any |
| 144 | ): void { |
| 145 | if (!checkIfServiceNamespace(program, namespace)) { |
| 146 | reportDiagnostic(program, { |
| 147 | code: "security-service-namespace", |
| 148 | target: namespace, |
| 149 | }); |
| 150 | return; |
| 151 | } |
| 152 | |
| 153 | const definitions = getSecurityDefinitions(program, namespace); |
| 154 | definitions[name] = details; |
| 155 | } |
| 156 | |
| 157 | export interface OpenAPIEmitterOptions { |
| 158 | outputFile: string; |
| 159 | } |
| 160 | |
| 161 | function createOAPIEmitter(program: Program, options: OpenAPIEmitterOptions) { |
| 162 | let root: any; |
| 163 | let host: string | undefined; |
| 164 | |
| 165 | // Get the service namespace string for use in name shortening |
| 166 | let serviceNamespace: string | undefined; |
| 167 | let currentPath: any; |
| 168 | let currentEndpoint: OpenAPI3Operation; |
| 169 | |
| 170 | // Keep a list of all Types encountered that need schema definitions |
| 171 | let schemas = new Set<Type>(); |
| 172 | |
| 173 | // Map model properties that represent shared parameters to their parameter |
| 174 | // definition that will go in #/components/parameters. Inlined parameters do not go in |
| 175 | // this map. |
| 176 | let params: Map<ModelTypeProperty, any>; |
| 177 | |
| 178 | // De-dupe the per-endpoint tags that will be added into the #/tags |
| 179 | let tags: Set<string>; |
| 180 | |
| 181 | const typeNameOptions: TypeNameOptions = { |
| 182 | // shorten type names by removing Cadl and service namespace |
| 183 | namespaceFilter(ns) { |
| 184 | const name = program.checker.getNamespaceString(ns); |
| 185 | return name !== "Cadl" && name !== serviceNamespace; |
| 186 | }, |
| 187 | }; |
| 188 | |
| 189 | return { emitOpenAPI }; |
| 190 | |
| 191 | function initializeEmitter(serviceNamespaceType: NamespaceType, version?: string) { |
| 192 | root = { |
| 193 | openapi: "3.0.0", |
| 194 | info: { |
| 195 | title: getServiceTitle(program), |
| 196 | version: version ?? getServiceVersion(program), |
| 197 | description: getDoc(program, serviceNamespaceType), |
| 198 | }, |
| 199 | externalDocs: getExternalDocs(program, serviceNamespaceType), |
| 200 | tags: [], |
| 201 | paths: {}, |
| 202 | components: { |
| 203 | parameters: {}, |
| 204 | requestBodies: {}, |
| 205 | responses: {}, |
| 206 | schemas: {}, |
| 207 | examples: {}, |
| 208 | securitySchemes: {}, |
| 209 | }, |
| 210 | }; |
| 211 | host = getServiceHost(program); |
| 212 | if (host) { |
| 213 | root.servers = [ |
| 214 | { |
| 215 | url: "https://" + host, |
| 216 | }, |
| 217 | ]; |
| 218 | } |
| 219 | |
| 220 | serviceNamespace = getServiceNamespaceString(program); |
| 221 | currentPath = root.paths; |
| 222 | schemas = new Set(); |
| 223 | params = new Map(); |
| 224 | tags = new Set(); |
| 225 | } |
| 226 | |
| 227 | async function emitOpenAPI() { |
| 228 | const serviceNs = getServiceNamespace(program); |
| 229 | if (!serviceNs) { |
| 230 | return; |
| 231 | } |
| 232 | const versions = buildVersionProjections(program, serviceNs); |
| 233 | for (const record of versions) { |
| 234 | if (record.version) { |
| 235 | record.projections.push({ |
| 236 | projectionName: "atVersion", |
| 237 | arguments: [record.version], |
| 238 | }); |
| 239 | } |
| 240 | |
| 241 | if (record.projections.length > 0) { |
| 242 | program.enableProjections(record.projections); |
| 243 | } |
| 244 | |
| 245 | await emitOpenAPIFromVersion(serviceNs, record.version); |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | async function emitOpenAPIFromVersion(serviceNamespace: NamespaceType, version?: string) { |
| 250 | initializeEmitter(serviceNamespace, version); |
| 251 | try { |
| 252 | getAllRoutes(program).forEach(emitOperation); |
| 253 | emitReferences(); |
| 254 | emitTags(); |
| 255 | |
| 256 | // Clean up empty entries |
| 257 | for (const elem of Object.keys(root.components)) { |
| 258 | if (Object.keys(root.components[elem]).length === 0) { |
| 259 | delete root.components[elem]; |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | if (!program.compilerOptions.noEmit && !program.hasError()) { |
| 264 | // Write out the OpenAPI document to the output path |
| 265 | const outPath = version |
| 266 | ? resolvePath(options.outputFile.replace(".json", `.${version}.json`)) |
| 267 | : resolvePath(options.outputFile); |
| 268 | |
| 269 | await program.host.writeFile(outPath, prettierOutput(JSON.stringify(root, null, 2))); |
| 270 | } |
| 271 | } catch (err) { |
| 272 | if (err instanceof ErrorTypeFoundError) { |
| 273 | // Return early, there must be a parse error if an ErrorType was |
| 274 | // inserted into the Cadl output |
| 275 | return; |
| 276 | } else { |
| 277 | throw err; |
| 278 | } |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | function emitOperation(operation: OperationDetails): void { |
| 283 | const { path: fullPath, operation: op, groupName, verb, parameters } = operation; |
| 284 | |
| 285 | // If path contains a query string, issue msg and don't emit this endpoint |
| 286 | if (fullPath.indexOf("?") > 0) { |
| 287 | reportDiagnostic(program, { code: "path-query", target: op }); |
| 288 | return; |
| 289 | } |
| 290 | |
| 291 | if (!root.paths[fullPath]) { |
| 292 | root.paths[fullPath] = {}; |
| 293 | } |
| 294 | |
| 295 | currentPath = root.paths[fullPath]; |
| 296 | if (!currentPath[verb]) { |
| 297 | currentPath[verb] = {}; |
| 298 | } |
| 299 | currentEndpoint = currentPath[verb]; |
| 300 | |
| 301 | const currentTags = getAllTags(program, op); |
| 302 | if (currentTags) { |
| 303 | currentEndpoint.tags = currentTags; |
| 304 | for (const tag of currentTags) { |
| 305 | // Add to root tags if not already there |
| 306 | tags.add(tag); |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | const operationId = getOperationId(program, op); |
| 311 | if (operationId) { |
| 312 | currentEndpoint.operationId = operationId; |
| 313 | } else { |
| 314 | // Synthesize an operation ID |
| 315 | currentEndpoint.operationId = (groupName.length > 0 ? `${groupName}_` : "") + op.name; |
| 316 | } |
| 317 | applyExternalDocs(op, currentEndpoint); |
| 318 | |
| 319 | // Set up basic endpoint fields |
| 320 | currentEndpoint.summary = getSummary(program, op); |
| 321 | currentEndpoint.description = getDoc(program, op); |
| 322 | currentEndpoint.parameters = []; |
| 323 | currentEndpoint.responses = {}; |
| 324 | |
| 325 | emitEndpointParameters(parameters.parameters); |
| 326 | emitRequestBody(op, op.parameters, parameters); |
| 327 | emitResponses(operation.responses); |
| 328 | |
| 329 | attachExtensions(program, op, currentEndpoint); |
| 330 | } |
| 331 | |
| 332 | function emitResponses(responses: HttpOperationResponse[]) { |
| 333 | for (const response of responses) { |
| 334 | emitResponseObject(response); |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | function isBinaryPayload(body: Type, contentType: string) { |
| 339 | return ( |
| 340 | body.kind === "Model" && |
| 341 | body.name === "bytes" && |
| 342 | contentType !== "application/json" && |
| 343 | contentType !== "text/plain" |
| 344 | ); |
| 345 | } |
| 346 | |
| 347 | function getOpenAPIStatuscode(response: HttpOperationResponse): string { |
| 348 | switch (response.statusCode) { |
| 349 | case "*": |
| 350 | return "default"; |
| 351 | default: |
| 352 | return response.statusCode; |
| 353 | } |
| 354 | } |
| 355 | |
| 356 | function emitResponseObject(response: Readonly<HttpOperationResponse>) { |
| 357 | const statusCode = getOpenAPIStatuscode(response); |
| 358 | const openapiResponse = currentEndpoint.responses[statusCode] ?? { |
| 359 | description: response.description ?? getResponseDescriptionForStatusCode(statusCode), |
| 360 | }; |
| 361 | |
| 362 | for (const data of response.responses) { |
| 363 | if (data.headers && Object.keys(data.headers).length > 0) { |
| 364 | openapiResponse.headers ??= {}; |
| 365 | // OpenAPI can't represent different headers per content type. |
| 366 | // So we merge headers here, and report any duplicates. |
| 367 | // It may be possible in principle to not error for identically declared |
| 368 | // headers. |
| 369 | for (const [key, value] of Object.entries(data.headers)) { |
| 370 | if (openapiResponse.headers[key]) { |
| 371 | reportDiagnostic(program, { |
| 372 | code: "duplicate-header", |
| 373 | format: { header: key }, |
| 374 | target: response.type, |
| 375 | }); |
| 376 | continue; |
| 377 | } |
| 378 | openapiResponse.headers[key] = getResponseHeader(value); |
| 379 | } |
| 380 | } |
| 381 | |
| 382 | if (data.body !== undefined) { |
| 383 | openapiResponse.content ??= {}; |
| 384 | for (const contentType of data.body.contentTypes) { |
| 385 | const isBinary = isBinaryPayload(data.body.type, contentType); |
| 386 | const schema = isBinary |
| 387 | ? { type: "string", format: "binary" } |
| 388 | : getSchemaOrRef(data.body.type); |
| 389 | openapiResponse.content[contentType] = { schema }; |
| 390 | } |
| 391 | } |
| 392 | } |
| 393 | |
| 394 | currentEndpoint.responses[statusCode] = openapiResponse; |
| 395 | } |
| 396 | |
| 397 | function getResponseDescriptionForStatusCode(statusCode: string) { |
| 398 | if (statusCode === "default") { |
| 399 | return "An unexpected error response"; |
| 400 | } |
| 401 | return getStatusCodeDescription(statusCode) ?? "unknown"; |
| 402 | } |
| 403 | |
| 404 | function getResponseHeader(prop: ModelTypeProperty) { |
| 405 | const header: any = {}; |
| 406 | populateParameter(header, prop, "header"); |
| 407 | delete header.in; |
| 408 | delete header.name; |
| 409 | delete header.required; |
| 410 | return header; |
| 411 | } |
| 412 | |
| 413 | function getSchemaOrRef(type: Type): any { |
| 414 | const refUrl = getRef(program, type); |
| 415 | if (refUrl) { |
| 416 | return { |
| 417 | $ref: refUrl, |
| 418 | }; |
| 419 | } |
| 420 | |
| 421 | if (type.kind === "Model" && type.name === getIntrinsicModelName(program, type)) { |
| 422 | // if the model is one of the Cadl Intrinsic type. |
| 423 | // it's a base Cadl "primitive" that corresponds directly to an OpenAPI |
| 424 | // primitive. In such cases, we don't want to emit a ref and instead just |
| 425 | // emit the base type directly. |
| 426 | const builtIn = mapCadlIntrinsicModelToOpenAPI(type); |
| 427 | if (builtIn !== undefined) { |
| 428 | return builtIn; |
| 429 | } |
| 430 | } |
| 431 | |
| 432 | if (type.kind === "String" || type.kind === "Number" || type.kind === "Boolean") { |
| 433 | // For literal types, we just want to emit them directly as well. |
| 434 | return mapCadlTypeToOpenAPI(type); |
| 435 | } |
| 436 | const name = getTypeName(program, type, typeNameOptions); |
| 437 | |
| 438 | if (shouldInline(program, type)) { |
| 439 | const schema = getSchemaForType(type); |
| 440 | |
| 441 | if (schema === undefined && isErrorType(type)) { |
| 442 | // Exit early so that syntax errors are exposed. This error will |
| 443 | // be caught and handled in emitOpenAPI. |
| 444 | throw new ErrorTypeFoundError(); |
| 445 | } |
| 446 | |
| 447 | // helps to read output and correlate to Cadl |
| 448 | if (schema) { |
| 449 | schema["x-cadl-name"] = name; |
| 450 | } |
| 451 | return schema; |
| 452 | } else { |
| 453 | const placeholder = { |
| 454 | $ref: "#/components/schemas/" + encodeURIComponent(name), |
| 455 | }; |
| 456 | schemas.add(type); |
| 457 | return placeholder; |
| 458 | } |
| 459 | } |
| 460 | |
| 461 | function getParamPlaceholder(property: ModelTypeProperty) { |
| 462 | let spreadParam = false; |
| 463 | |
| 464 | if (property.sourceProperty) { |
| 465 | // chase our sources all the way back to the first place this property |
| 466 | // was defined. |
| 467 | spreadParam = true; |
| 468 | property = property.sourceProperty; |
| 469 | while (property.sourceProperty) { |
| 470 | property = property.sourceProperty; |
| 471 | } |
| 472 | } |
| 473 | |
| 474 | const refUrl = getRef(program, property); |
| 475 | if (refUrl) { |
| 476 | return { |
| 477 | $ref: refUrl, |
| 478 | }; |
| 479 | } |
| 480 | |
| 481 | if (params.has(property)) { |
| 482 | return params.get(property); |
| 483 | } |
| 484 | |
| 485 | const placeholder = {}; |
| 486 | // only parameters inherited by spreading or from interface are shared in #/parameters |
| 487 | // bt: not sure about the interface part of this comment? |
| 488 | |
| 489 | if (spreadParam) { |
| 490 | params.set(property, placeholder); |
| 491 | } |
| 492 | |
| 493 | return placeholder; |
| 494 | } |
| 495 | |
| 496 | function emitEndpointParameters(parameters: HttpOperationParameter[]) { |
| 497 | for (const { type, name, param } of parameters) { |
| 498 | // If param is a global parameter, just skip it |
| 499 | if (params.has(param)) { |
| 500 | currentEndpoint.parameters.push(params.get(param)); |
| 501 | continue; |
| 502 | } |
| 503 | |
| 504 | switch (type) { |
| 505 | case "path": |
| 506 | emitParameter(param, "path"); |
| 507 | break; |
| 508 | case "query": |
| 509 | emitParameter(param, "query"); |
| 510 | break; |
| 511 | case "header": |
| 512 | if (name !== "content-type") { |
| 513 | emitParameter(param, "header"); |
| 514 | } |
| 515 | break; |
| 516 | } |
| 517 | } |
| 518 | } |
| 519 | |
| 520 | function emitRequestBody( |
| 521 | op: OperationType, |
| 522 | parent: ModelType | undefined, |
| 523 | parameters: HttpOperationParameters |
| 524 | ) { |
| 525 | if (parameters.body === undefined) { |
| 526 | return; |
| 527 | } |
| 528 | |
| 529 | const bodyParam = parameters.body; |
| 530 | const bodyType = bodyParam.type; |
| 531 | |
| 532 | const requestBody: any = { |
| 533 | description: getDoc(program, bodyParam), |
| 534 | content: {}, |
| 535 | }; |
| 536 | |
| 537 | const contentTypeParam = parameters.parameters.find( |
| 538 | (p) => p.type === "header" && p.name === "content-type" |
| 539 | ); |
| 540 | const contentTypes = contentTypeParam |
| 541 | ? getContentTypes(program, contentTypeParam.param) |
| 542 | : ["application/json"]; |
| 543 | for (const contentType of contentTypes) { |
| 544 | const isBinary = isBinaryPayload(bodyType, contentType); |
| 545 | const bodySchema = isBinary ? { type: "string", format: "binary" } : getSchemaOrRef(bodyType); |
| 546 | const contentEntry: any = { |
| 547 | schema: bodySchema, |
| 548 | }; |
| 549 | requestBody.content[contentType] = contentEntry; |
| 550 | } |
| 551 | |
| 552 | currentEndpoint.requestBody = requestBody; |
| 553 | } |
| 554 | |
| 555 | function emitParameter(param: ModelTypeProperty, kind: OpenAPI3ParameterType) { |
| 556 | const ph = getParamPlaceholder(param); |
| 557 | currentEndpoint.parameters.push(ph); |
| 558 | |
| 559 | // If the parameter already has a $ref, don't bother populating it |
| 560 | if (!("$ref" in ph)) { |
| 561 | populateParameter(ph, param, kind); |
| 562 | } |
| 563 | } |
| 564 | |
| 565 | function populateParameter( |
| 566 | ph: OpenAPI3Parameter, |
| 567 | param: ModelTypeProperty, |
| 568 | kind: OpenAPI3ParameterType |
| 569 | ) { |
| 570 | ph.name = param.name; |
| 571 | ph.in = kind; |
| 572 | ph.required = !param.optional; |
| 573 | ph.description = getDoc(program, param); |
| 574 | |
| 575 | // Apply decorators to the schema for the parameter. |
| 576 | const schema = applyIntrinsicDecorators(param, getSchemaForType(param.type)); |
| 577 | if (param.type.kind === "Array") { |
| 578 | schema.items = getSchemaForType(param.type.elementType); |
| 579 | } |
| 580 | if (param.default) { |
| 581 | schema.default = getDefaultValue(param.default); |
| 582 | } |
| 583 | attachExtensions(program, param, ph); |
| 584 | // Description is already provided in the parameter itself. |
| 585 | delete schema.description; |
| 586 | ph.schema = schema; |
| 587 | } |
| 588 | |
| 589 | function emitReferences() { |
| 590 | for (const [property, param] of params) { |
| 591 | const key = getParameterKey( |
| 592 | program, |
| 593 | property, |
| 594 | param, |
| 595 | root.components.parameters, |
| 596 | typeNameOptions |
| 597 | ); |
| 598 | |
| 599 | root.components.parameters[key] = { ...param }; |
| 600 | for (const key of Object.keys(param)) { |
| 601 | delete param[key]; |
| 602 | } |
| 603 | |
| 604 | param["$ref"] = "#/components/parameters/" + encodeURIComponent(key); |
| 605 | } |
| 606 | |
| 607 | for (const type of schemas) { |
| 608 | const schemaForType = getSchemaForType(type); |
| 609 | if (schemaForType) { |
| 610 | const name = getTypeName(program, type, typeNameOptions, root.components.schemas); |
| 611 | root.components.schemas[name] = schemaForType; |
| 612 | } |
| 613 | } |
| 614 | } |
| 615 | |
| 616 | function emitTags() { |
| 617 | for (const tag of tags) { |
| 618 | root.tags.push({ name: tag }); |
| 619 | } |
| 620 | } |
| 621 | |
| 622 | function getSchemaForType(type: Type) { |
| 623 | const builtinType = mapCadlTypeToOpenAPI(type); |
| 624 | if (builtinType !== undefined) return builtinType; |
| 625 | |
| 626 | if (type.kind === "Array") { |
| 627 | return getSchemaForArray(type); |
| 628 | } else if (type.kind === "Model") { |
| 629 | return getSchemaForModel(type); |
| 630 | } else if (type.kind === "Union") { |
| 631 | return getSchemaForUnion(type); |
| 632 | } else if (type.kind === "UnionVariant") { |
| 633 | return getSchemaForUnionVariant(type); |
| 634 | } else if (type.kind === "Enum") { |
| 635 | return getSchemaForEnum(type); |
| 636 | } |
| 637 | |
| 638 | reportDiagnostic(program, { |
| 639 | code: "invalid-schema", |
| 640 | format: { type: type.kind }, |
| 641 | target: type, |
| 642 | }); |
| 643 | return undefined; |
| 644 | } |
| 645 | |
| 646 | function getSchemaForEnum(e: EnumType) { |
| 647 | const values = []; |
| 648 | if (e.members.length == 0) { |
| 649 | reportUnsupportedUnion("empty"); |
| 650 | return undefined; |
| 651 | } |
| 652 | const type = enumMemberType(e.members[0]); |
| 653 | for (const option of e.members) { |
| 654 | if (type !== enumMemberType(option)) { |
| 655 | reportUnsupportedUnion(); |
| 656 | continue; |
| 657 | } |
| 658 | |
| 659 | values.push(option.value ?? option.name); |
| 660 | } |
| 661 | |
| 662 | const schema: any = { type, description: getDoc(program, e) }; |
| 663 | if (values.length > 0) { |
| 664 | schema.enum = values; |
| 665 | } |
| 666 | |
| 667 | return schema; |
| 668 | function enumMemberType(member: EnumMemberType) { |
| 669 | if (typeof member.value === "number") { |
| 670 | return "number"; |
| 671 | } |
| 672 | return "string"; |
| 673 | } |
| 674 | |
| 675 | function reportUnsupportedUnion(messageId: "default" | "empty" = "default") { |
| 676 | reportDiagnostic(program, { code: "union-unsupported", messageId, target: e }); |
| 677 | } |
| 678 | } |
| 679 | |
| 680 | function getSchemaForUnion(union: UnionType) { |
| 681 | let type: string; |
| 682 | const nonNullOptions = union.options.filter((t) => !isNullType(t)); |
| 683 | const nullable = union.options.length != nonNullOptions.length; |
| 684 | if (nonNullOptions.length === 0) { |
| 685 | reportDiagnostic(program, { code: "union-null", target: union }); |
| 686 | return {}; |
| 687 | } |
| 688 | |
| 689 | const kind = nonNullOptions[0].kind; |
| 690 | switch (kind) { |
| 691 | case "String": |
| 692 | type = "string"; |
| 693 | break; |
| 694 | case "Number": |
| 695 | type = "number"; |
| 696 | break; |
| 697 | case "Boolean": |
| 698 | type = "boolean"; |
| 699 | break; |
| 700 | case "Model": |
| 701 | type = "model"; |
| 702 | break; |
| 703 | case "UnionVariant": |
| 704 | type = "model"; |
| 705 | break; |
| 706 | case "Array": |
| 707 | type = "array"; |
| 708 | break; |
| 709 | default: |
| 710 | reportUnsupportedUnionType(nonNullOptions[0]); |
| 711 | return {}; |
| 712 | } |
| 713 | |
| 714 | if (type === "model" || type === "array") { |
| 715 | if (nonNullOptions.length === 1) { |
| 716 | // Get the schema for the model type |
| 717 | const schema: any = getSchemaForType(nonNullOptions[0]); |
| 718 | if (nullable) { |
| 719 | schema["nullable"] = true; |
| 720 | } |
| 721 | |
| 722 | return schema; |
| 723 | } else { |
| 724 | const ofType = getOneOf(program, union) ? "oneOf" : "anyOf"; |
| 725 | const schema: any = { [ofType]: nonNullOptions.map((s) => getSchemaOrRef(s)) }; |
| 726 | return schema; |
| 727 | } |
| 728 | } |
| 729 | |
| 730 | const values = []; |
| 731 | for (const option of nonNullOptions) { |
| 732 | if (option.kind != kind) { |
| 733 | reportUnsupportedUnion(); |
| 734 | } |
| 735 | |
| 736 | // We already know it's not a model type |
| 737 | values.push((option as any).value); |
| 738 | } |
| 739 | |
| 740 | const schema: any = { type }; |
| 741 | if (values.length > 0) { |
| 742 | schema.enum = values; |
| 743 | } |
| 744 | if (nullable) { |
| 745 | schema["nullable"] = true; |
| 746 | } |
| 747 | |
| 748 | return schema; |
| 749 | |
| 750 | function reportUnsupportedUnionType(type: Type) { |
| 751 | reportDiagnostic(program, { |
| 752 | code: "union-unsupported", |
| 753 | messageId: "type", |
| 754 | format: { kind: type.kind }, |
| 755 | target: type, |
| 756 | }); |
| 757 | } |
| 758 | |
| 759 | function reportUnsupportedUnion() { |
| 760 | reportDiagnostic(program, { code: "union-unsupported", target: union }); |
| 761 | } |
| 762 | } |
| 763 | |
| 764 | function getSchemaForUnionVariant(variant: UnionTypeVariant) { |
| 765 | const schema: any = getSchemaForType(variant.type); |
| 766 | return schema; |
| 767 | } |
| 768 | |
| 769 | function getSchemaForArray(array: ArrayType) { |
| 770 | const target = array.elementType; |
| 771 | |
| 772 | return { |
| 773 | type: "array", |
| 774 | items: getSchemaOrRef(target), |
| 775 | }; |
| 776 | } |
| 777 | |
| 778 | function isNullType(type: Type): boolean { |
| 779 | return isIntrinsic(program, type) && getIntrinsicModelName(program, type) === "null"; |
| 780 | } |
| 781 | |
| 782 | function getDefaultValue(type: Type): any { |
| 783 | switch (type.kind) { |
| 784 | case "String": |
| 785 | return type.value; |
| 786 | case "Number": |
| 787 | return type.value; |
| 788 | case "Boolean": |
| 789 | return type.value; |
| 790 | case "Tuple": |
| 791 | return type.values.map(getDefaultValue); |
| 792 | default: |
| 793 | reportDiagnostic(program, { |
| 794 | code: "invalid-default", |
| 795 | format: { type: type.kind }, |
| 796 | target: type, |
| 797 | }); |
| 798 | } |
| 799 | } |
| 800 | |
| 801 | function includeDerivedModel(model: ModelType): boolean { |
| 802 | return ( |
| 803 | !isTemplate(model) && |
| 804 | (model.templateArguments === undefined || |
| 805 | model.templateArguments?.length === 0 || |
| 806 | model.derivedModels.length > 0) |
| 807 | ); |
| 808 | } |
| 809 | |
| 810 | function getSchemaForModel(model: ModelType) { |
| 811 | let modelSchema: OpenAPI3Schema & Required<Pick<OpenAPI3Schema, "properties">> = { |
| 812 | type: "object", |
| 813 | properties: {}, |
| 814 | description: getDoc(program, model), |
| 815 | }; |
| 816 | |
| 817 | const derivedModels = model.derivedModels.filter(includeDerivedModel); |
| 818 | // getSchemaOrRef on all children to push them into components.schemas |
| 819 | for (const child of derivedModels) { |
| 820 | getSchemaOrRef(child); |
| 821 | } |
| 822 | |
| 823 | const discriminator = getDiscriminator(program, model); |
| 824 | if (discriminator) { |
| 825 | if (!validateDiscriminator(discriminator, derivedModels)) { |
| 826 | // appropriate diagnostic is generated with the validate function |
| 827 | return {}; |
| 828 | } |
| 829 | |
| 830 | const openApiDiscriminator: OpenAPI3Discriminator = { ...discriminator }; |
| 831 | const mapping = getDiscriminatorMapping(discriminator, derivedModels); |
| 832 | if (mapping) { |
| 833 | openApiDiscriminator.mapping = mapping; |
| 834 | } |
| 835 | |
| 836 | modelSchema.discriminator = openApiDiscriminator; |
| 837 | modelSchema.properties[discriminator.propertyName] = { |
| 838 | type: "string", |
| 839 | description: `Discriminator property for ${model.name}.`, |
| 840 | }; |
| 841 | } |
| 842 | |
| 843 | applyExternalDocs(model, modelSchema); |
| 844 | |
| 845 | for (const [name, prop] of model.properties) { |
| 846 | if (!isSchemaProperty(prop)) { |
| 847 | continue; |
| 848 | } |
| 849 | |
| 850 | const description = getDoc(program, prop); |
| 851 | if (!prop.optional) { |
| 852 | if (!modelSchema.required) { |
| 853 | modelSchema.required = []; |
| 854 | } |
| 855 | modelSchema.required.push(name); |
| 856 | } |
| 857 | |
| 858 | // Apply decorators on the property to the type's schema |
| 859 | modelSchema.properties[name] = applyIntrinsicDecorators(prop, getSchemaOrRef(prop.type)); |
| 860 | if (description) { |
| 861 | modelSchema.properties[name].description = description; |
| 862 | } |
| 863 | |
| 864 | if (prop.default) { |
| 865 | modelSchema.properties[name].default = getDefaultValue(prop.default); |
| 866 | } |
| 867 | |
| 868 | // Should the property be marked as readOnly? |
| 869 | const vis = getVisibility(program, prop); |
| 870 | if (vis && vis.includes("read") && vis.length == 1) { |
| 871 | modelSchema.properties[name].readOnly = true; |
| 872 | } |
| 873 | |
| 874 | // Attach any additional OpenAPI extensions |
| 875 | attachExtensions(program, prop, modelSchema.properties[name]); |
| 876 | } |
| 877 | |
| 878 | // Special case: if a model type extends a single *templated* base type and |
| 879 | // has no properties of its own, absorb the definition of the base model |
| 880 | // into this schema definition. The assumption here is that any model type |
| 881 | // defined like this is just meant to rename the underlying instance of a |
| 882 | // templated type. |
| 883 | if ( |
| 884 | model.baseModel && |
| 885 | model.baseModel.templateArguments && |
| 886 | model.baseModel.templateArguments.length > 0 && |
| 887 | Object.keys(modelSchema.properties).length === 0 |
| 888 | ) { |
| 889 | // Take the base model schema but carry across the documentation property |
| 890 | // that we set before |
| 891 | const baseSchema = getSchemaForType(model.baseModel); |
| 892 | modelSchema = { |
| 893 | ...baseSchema, |
| 894 | description: modelSchema.description, |
| 895 | }; |
| 896 | } else if (model.baseModel) { |
| 897 | modelSchema.allOf = [getSchemaOrRef(model.baseModel)]; |
| 898 | } |
| 899 | |
| 900 | // Attach any OpenAPI extensions |
| 901 | attachExtensions(program, model, modelSchema); |
| 902 | return modelSchema; |
| 903 | } |
| 904 | |
| 905 | function attachExtensions(program: Program, type: Type, emitObject: any) { |
| 906 | // Attach any OpenAPI extensions |
| 907 | const extensions = getExtensions(program, type); |
| 908 | if (extensions) { |
| 909 | for (const key of extensions.keys()) { |
| 910 | emitObject[key] = extensions.get(key); |
| 911 | } |
| 912 | } |
| 913 | } |
| 914 | |
| 915 | function validateDiscriminator( |
| 916 | discriminator: Discriminator, |
| 917 | childModels: readonly ModelType[] |
| 918 | ): boolean { |
| 919 | const { propertyName } = discriminator; |
| 920 | const retVals = childModels.map((t) => { |
| 921 | const prop = getProperty(t, propertyName); |
| 922 | if (!prop) { |
| 923 | reportDiagnostic(program, { code: "discriminator", messageId: "missing", target: t }); |
| 924 | return false; |
| 925 | } |
| 926 | let retval = true; |
| 927 | if (!isOasString(prop.type)) { |
| 928 | reportDiagnostic(program, { code: "discriminator", messageId: "type", target: prop }); |
| 929 | retval = false; |
| 930 | } |
| 931 | if (prop.optional) { |
| 932 | reportDiagnostic(program, { code: "discriminator", messageId: "required", target: prop }); |
| 933 | retval = false; |
| 934 | } |
| 935 | return retval; |
| 936 | }); |
| 937 | // Map of discriminator value to the model in which it is declared |
| 938 | const discriminatorValues = new Map<string, string>(); |
| 939 | for (const t of childModels) { |
| 940 | // Get the discriminator property directly in the child model |
| 941 | const prop = t.properties?.get(propertyName); |
| 942 | // Issue warning diagnostic if discriminator property missing or is not a string literal |
| 943 | if (!prop || !isStringLiteral(prop.type)) { |
| 944 | reportDiagnostic(program, { |
| 945 | code: "discriminator-value", |
| 946 | messageId: "literal", |
| 947 | target: prop || t, |
| 948 | }); |
| 949 | } |
| 950 | if (prop) { |
| 951 | const vals = getStringValues(prop.type); |
| 952 | vals.forEach((val) => { |
| 953 | if (discriminatorValues.has(val)) { |
| 954 | reportDiagnostic(program, { |
| 955 | code: "discriminator", |
| 956 | messageId: "duplicate", |
| 957 | format: { val: val, model1: discriminatorValues.get(val)!, model2: t.name }, |
| 958 | target: prop, |
| 959 | }); |
| 960 | retVals.push(false); |
| 961 | } else { |
| 962 | discriminatorValues.set(val, t.name); |
| 963 | } |
| 964 | }); |
| 965 | } |
| 966 | } |
| 967 | return retVals.every((v) => v); |
| 968 | } |
| 969 | |
| 970 | function getDiscriminatorMapping( |
| 971 | discriminator: any, |
| 972 | derivedModels: readonly ModelType[] |
| 973 | ): Record<string, string> | undefined { |
| 974 | const { propertyName } = discriminator; |
| 975 | const getMapping = (t: ModelType): any => { |
| 976 | const prop = t.properties?.get(propertyName); |
| 977 | if (prop) { |
| 978 | return getStringValues(prop.type).flatMap((v) => [{ [v]: getSchemaOrRef(t).$ref }]); |
| 979 | } |
| 980 | return undefined; |
| 981 | }; |
| 982 | const mappings = derivedModels.flatMap(getMapping).filter((v) => v); // only defined values |
| 983 | return mappings.length > 0 ? mappings.reduce((a, s) => ({ ...a, ...s }), {}) : undefined; |
| 984 | } |
| 985 | |
| 986 | // An openapi "string" can be defined in several different ways in Cadl |
| 987 | function isOasString(type: Type): boolean { |
| 988 | if (type.kind === "String") { |
| 989 | // A string literal |
| 990 | return true; |
| 991 | } else if (type.kind === "Model" && type.name === "string") { |
| 992 | // string type |
| 993 | return true; |
| 994 | } else if (type.kind === "Union") { |
| 995 | // A union where all variants are an OasString |
| 996 | return type.options.every((o) => isOasString(o)); |
| 997 | } |
| 998 | return false; |
| 999 | } |
| 1000 | |
| 1001 | function isStringLiteral(type: Type): boolean { |
| 1002 | return ( |
| 1003 | type.kind === "String" || |
| 1004 | (type.kind === "Union" && type.options.every((o) => o.kind === "String")) |
| 1005 | ); |
| 1006 | } |
| 1007 | |
| 1008 | // Return any string literal values for type |
| 1009 | function getStringValues(type: Type): string[] { |
| 1010 | if (type.kind === "String") { |
| 1011 | return [type.value]; |
| 1012 | } else if (type.kind === "Union") { |
| 1013 | return type.options.flatMap(getStringValues).filter((v) => v); |
| 1014 | } |
| 1015 | return []; |
| 1016 | } |
| 1017 | |
| 1018 | /** |
| 1019 | * A "schema property" here is a property that is emitted to OpenAPI schema. |
| 1020 | * |
| 1021 | * Headers, parameters, status codes are not schema properties even they are |
| 1022 | * represented as properties in Cadl. |
| 1023 | */ |
| 1024 | function isSchemaProperty(property: ModelTypeProperty) { |
| 1025 | const headerInfo = getHeaderFieldName(program, property); |
| 1026 | const queryInfo = getQueryParamName(program, property); |
| 1027 | const pathInfo = getPathParamName(program, property); |
| 1028 | const statusCodeinfo = isStatusCode(program, property); |
| 1029 | return !(headerInfo || queryInfo || pathInfo || statusCodeinfo); |
| 1030 | } |
| 1031 | |
| 1032 | function applyIntrinsicDecorators(cadlType: ModelType | ModelTypeProperty, target: any): any { |
| 1033 | const newTarget = { ...target }; |
| 1034 | const docStr = getDoc(program, cadlType); |
| 1035 | const isString = isStringType(program, getPropertyType(cadlType)); |
| 1036 | const isNumeric = isNumericType(program, getPropertyType(cadlType)); |
| 1037 | |
| 1038 | if (isString && !target.documentation && docStr) { |
| 1039 | newTarget.description = docStr; |
| 1040 | } |
| 1041 | |
| 1042 | const summaryStr = getSummary(program, cadlType); |
| 1043 | if (isString && !target.summary && summaryStr) { |
| 1044 | newTarget.summary = summaryStr; |
| 1045 | } |
| 1046 | |
| 1047 | const formatStr = getFormat(program, cadlType); |
| 1048 | if (isString && !target.format && formatStr) { |
| 1049 | newTarget.format = formatStr; |
| 1050 | } |
| 1051 | |
| 1052 | const pattern = getPattern(program, cadlType); |
| 1053 | if (isString && !target.pattern && pattern) { |
| 1054 | newTarget.pattern = pattern; |
| 1055 | } |
| 1056 | |
| 1057 | const minLength = getMinLength(program, cadlType); |
| 1058 | if (isString && !target.minLength && minLength !== undefined) { |
| 1059 | newTarget.minLength = minLength; |
| 1060 | } |
| 1061 | |
| 1062 | const maxLength = getMaxLength(program, cadlType); |
| 1063 | if (isString && !target.maxLength && maxLength !== undefined) { |
| 1064 | newTarget.maxLength = maxLength; |
| 1065 | } |
| 1066 | |
| 1067 | const minValue = getMinValue(program, cadlType); |
| 1068 | if (isNumeric && !target.minimum && minValue !== undefined) { |
| 1069 | newTarget.minimum = minValue; |
| 1070 | } |
| 1071 | |
| 1072 | const maxValue = getMaxValue(program, cadlType); |
| 1073 | if (isNumeric && !target.maximum && maxValue !== undefined) { |
| 1074 | newTarget.maximum = maxValue; |
| 1075 | } |
| 1076 | |
| 1077 | if (isSecret(program, cadlType)) { |
| 1078 | newTarget.format = "password"; |
| 1079 | } |
| 1080 | |
| 1081 | if (isString) { |
| 1082 | const values = getKnownValues(program, cadlType); |
| 1083 | if (values) { |
| 1084 | return { |
| 1085 | oneOf: [newTarget, getSchemaForEnum(values)], |
| 1086 | }; |
| 1087 | } |
| 1088 | } |
| 1089 | |
| 1090 | return newTarget; |
| 1091 | } |
| 1092 | |
| 1093 | function applyExternalDocs(cadlType: Type, target: Record<string, unknown>) { |
| 1094 | const externalDocs = getExternalDocs(program, cadlType); |
| 1095 | if (externalDocs) { |
| 1096 | target.externalDocs = externalDocs; |
| 1097 | } |
| 1098 | } |
| 1099 | |
| 1100 | // Map an Cadl type to an OA schema. Returns undefined when the resulting |
| 1101 | // OA schema is just a regular object schema. |
| 1102 | function mapCadlTypeToOpenAPI(cadlType: Type): any { |
| 1103 | switch (cadlType.kind) { |
| 1104 | case "Number": |
| 1105 | return { type: "number", enum: [cadlType.value] }; |
| 1106 | case "String": |
| 1107 | return { type: "string", enum: [cadlType.value] }; |
| 1108 | case "Boolean": |
| 1109 | return { type: "boolean", enum: [cadlType.value] }; |
| 1110 | case "Model": |
| 1111 | return mapCadlIntrinsicModelToOpenAPI(cadlType); |
| 1112 | } |
| 1113 | } |
| 1114 | |
| 1115 | /** |
| 1116 | * Map Cadl intrinsic models to open api definitions |
| 1117 | */ |
| 1118 | function mapCadlIntrinsicModelToOpenAPI(cadlType: ModelType): any | undefined { |
| 1119 | if (!isIntrinsic(program, cadlType)) { |
| 1120 | return undefined; |
| 1121 | } |
| 1122 | const name = getIntrinsicModelName(program, cadlType); |
| 1123 | switch (name) { |
| 1124 | case "bytes": |
| 1125 | return { type: "string", format: "byte" }; |
| 1126 | case "int8": |
| 1127 | return applyIntrinsicDecorators(cadlType, { type: "integer", format: "int8" }); |
| 1128 | case "int16": |
| 1129 | return applyIntrinsicDecorators(cadlType, { type: "integer", format: "int16" }); |
| 1130 | case "int32": |
| 1131 | return applyIntrinsicDecorators(cadlType, { type: "integer", format: "int32" }); |
| 1132 | case "int64": |
| 1133 | return applyIntrinsicDecorators(cadlType, { type: "integer", format: "int64" }); |
| 1134 | case "safeint": |
| 1135 | return applyIntrinsicDecorators(cadlType, { type: "integer", format: "int64" }); |
| 1136 | case "uint8": |
| 1137 | return applyIntrinsicDecorators(cadlType, { type: "integer", format: "uint8" }); |
| 1138 | case "uint16": |
| 1139 | return applyIntrinsicDecorators(cadlType, { type: "integer", format: "uint16" }); |
| 1140 | case "uint32": |
| 1141 | return applyIntrinsicDecorators(cadlType, { type: "integer", format: "uint32" }); |
| 1142 | case "uint64": |
| 1143 | return applyIntrinsicDecorators(cadlType, { type: "integer", format: "uint64" }); |
| 1144 | case "float64": |
| 1145 | return applyIntrinsicDecorators(cadlType, { type: "number", format: "double" }); |
| 1146 | case "float32": |
| 1147 | return applyIntrinsicDecorators(cadlType, { type: "number", format: "float" }); |
| 1148 | case "string": |
| 1149 | return applyIntrinsicDecorators(cadlType, { type: "string" }); |
| 1150 | case "boolean": |
| 1151 | return { type: "boolean" }; |
| 1152 | case "plainDate": |
| 1153 | return { type: "string", format: "date" }; |
| 1154 | case "zonedDateTime": |
| 1155 | return { type: "string", format: "date-time" }; |
| 1156 | case "plainTime": |
| 1157 | return { type: "string", format: "time" }; |
| 1158 | case "duration": |
| 1159 | return { type: "string", format: "duration" }; |
| 1160 | case "Map": |
| 1161 | // We assert on valType because Map types always have a type |
| 1162 | const valType = cadlType.properties.get("v"); |
| 1163 | return { |
| 1164 | type: "object", |
| 1165 | additionalProperties: getSchemaOrRef(valType!.type), |
| 1166 | }; |
| 1167 | } |
| 1168 | } |
| 1169 | } |
| 1170 | |
| 1171 | function prettierOutput(output: string) { |
| 1172 | return output + "\n"; |
| 1173 | } |
| 1174 | |
| 1175 | class ErrorTypeFoundError extends Error { |
| 1176 | constructor() { |
| 1177 | super("Error type found in evaluated Cadl output"); |
| 1178 | } |
| 1179 | } |
| 1180 | |