UNPKG

@typespec/openapi3

Version:

TypeSpec library for emitting OpenAPI 3.0 and OpenAPI 3.1 from the TypeSpec REST protocol binding and converting OpenAPI3 to TypeSpec

608 lines 25.8 kB
import { ArrayBuilder, ObjectBuilder, Placeholder, TypeEmitter, } from "@typespec/asset-emitter"; import { compilerAssert, explainStringTemplateNotSerializable, getDeprecated, getDiscriminatedUnionFromInheritance, getDiscriminator, getDoc, getFormat, getMaxItems, getMaxLength, getMaxValue, getMinItems, getMinLength, getMinValue, getNamespaceFullName, getPattern, getSummary, getTypeName, ignoreDiagnostics, isArrayModelType, isNeverType, isSecret, resolveEncodedName, } from "@typespec/compiler"; import { capitalize } from "@typespec/compiler/casing"; import { $ } from "@typespec/compiler/typekit"; import { Visibility, getVisibilitySuffix } from "@typespec/http"; import { checkDuplicateTypeName, getExternalDocs, getOpenAPITypeName, isReadonlyProperty, shouldInline, } from "@typespec/openapi"; import { attachExtensions } from "./attach-extensions.js"; import { getOneOf, getRef } from "./decorators.js"; import { reportDiagnostic } from "./lib.js"; import { getSchemaForStdScalars } from "./std-scalar-schemas.js"; import { ensureValidComponentFixedFieldKey, getDefaultValue, includeDerivedModel, isStdType, } from "./util.js"; /** * Base OpenAPI3 schema emitter. Deals with emitting content of `components/schemas` section. */ export class OpenAPI3SchemaEmitterBase extends TypeEmitter { _metadataInfo; _visibilityUsage; _options; _jsonSchemaModule; _xmlModule; constructor(emitter, metadataInfo, visibilityUsage, options, optionalDependencies) { super(emitter); this._metadataInfo = metadataInfo; this._visibilityUsage = visibilityUsage; this._options = options; this._jsonSchemaModule = optionalDependencies.jsonSchemaModule; this._xmlModule = optionalDependencies.xmlModule; } modelDeclarationReferenceContext(model, name) { return this.reduceContext(model); } modelLiteralReferenceContext(model) { return this.reduceContext(model); } scalarDeclarationReferenceContext(scalar, name) { return this.reduceContext(scalar); } enumDeclarationReferenceContext(en, name) { return this.reduceContext(en); } unionDeclarationReferenceContext(union) { return this.reduceContext(union); } reduceContext(type) { const visibility = this.#getVisibilityContext(); const patch = {}; if (visibility !== Visibility.Read && !this._metadataInfo.isTransformed(type, visibility)) { patch.visibility = Visibility.Read; } const contentType = this.getContentType(); if (contentType === "application/json") { patch.contentType = undefined; } return patch; } applyDiscriminator(type, schema) { const program = this.emitter.getProgram(); const discriminator = getDiscriminator(program, type); if (discriminator) { // the decorator validates that all the variants will be a model type // with the discriminator field present. schema.discriminator = { ...discriminator }; const discriminatedUnion = ignoreDiagnostics(getDiscriminatedUnionFromInheritance(type, discriminator)); if (discriminatedUnion.variants.size > 0) { schema.discriminator.mapping = this.getDiscriminatorMapping(discriminatedUnion.variants); } } } shouldSealSchema(model) { // when an indexer is not present, we might be able to seal it if (!model.indexer) { const derivedModels = model.derivedModels.filter(includeDerivedModel); return !!this._options.sealObjectSchemas && !derivedModels.length; } return isNeverType(model.indexer.value); } applyModelIndexer(schema, model) { const shouldSeal = this.shouldSealSchema(model); if (!shouldSeal && !model.indexer) return; // if the schema is 'sealed' the model extends another model, // then we need to redefine any baseModel properties if (shouldSeal) { const props = new ObjectBuilder(schema.properties ?? {}); let baseModel = model.baseModel; while (baseModel) { const result = this.emitter.emitModelProperties(baseModel); baseModel = baseModel.baseModel; if (result.kind !== "code" || !(result.value instanceof ObjectBuilder)) continue; const baseProperties = result.value; for (const key of Object.keys(baseProperties)) { if (key in props) continue; // Here we are saying that this property will always validate as true for this schema. // This is because the `allOf` subSchema will contain the more specific validation // for this property. props.set(key, {}); } } if (Object.keys(props).length > 0) { schema.set("properties", props); } } const additionalPropertiesSchema = shouldSeal ? { not: {} } : this.emitter.emitTypeReference(model.indexer.value); schema.set("additionalProperties", additionalPropertiesSchema); } modelDeclaration(model, _) { const program = this.emitter.getProgram(); const visibility = this.#getVisibilityContext(); const schema = new ObjectBuilder({ type: "object", required: this.#requiredModelProperties(model, visibility), properties: this.emitter.emitModelProperties(model), }); this.applyModelIndexer(schema, model); const derivedModels = model.derivedModels.filter(includeDerivedModel); // getSchemaOrRef on all children to push them into components.schemas for (const child of derivedModels) { this.emitter.emitTypeReference(child); } this.applyDiscriminator(model, schema); this.#applyExternalDocs(model, schema); if (model.baseModel) { schema.set("allOf", Builders.array([this.emitter.emitTypeReference(model.baseModel)])); } const baseName = getOpenAPITypeName(program, model, this.#typeNameOptions()); const isMultipart = this.getContentType().startsWith("multipart/"); const name = isMultipart ? baseName + "MultiPart" : baseName; return this.#createDeclaration(model, name, this.applyConstraints(model, schema)); } #applyExternalDocs(typespecType, target) { const externalDocs = getExternalDocs(this.emitter.getProgram(), typespecType); if (externalDocs) { target.externalDocs = externalDocs; } } #typeNameOptions() { const serviceNamespaceName = this.emitter.getContext().serviceNamespaceName; return { // shorten type names by removing TypeSpec and service namespace namespaceFilter(ns) { const name = getNamespaceFullName(ns); return name !== serviceNamespaceName; }, }; } #getVisibilityContext() { return this.emitter.getContext().visibility ?? Visibility.Read; } #ignoreMetadataAnnotations() { return this.emitter.getContext().ignoreMetadataAnnotations; } getContentType() { return this.emitter.getContext().contentType ?? "application/json"; } modelLiteral(model) { const schema = new ObjectBuilder({ type: "object", properties: this.emitter.emitModelProperties(model), required: this.#requiredModelProperties(model, this.#getVisibilityContext()), }); this.applyModelIndexer(schema, model); return schema; } modelInstantiation(model, name) { name = name ?? getOpenAPITypeName(this.emitter.getProgram(), model, this.#typeNameOptions()); if (!name) { return this.modelLiteral(model); } return this.modelDeclaration(model, name); } unionInstantiation(union, name) { if (!name) { return this.unionLiteral(union); } return this.unionDeclaration(union, name); } arrayDeclaration(array, _, elementType) { const schema = new ObjectBuilder({ type: "array", items: this.emitter.emitTypeReference(elementType), }); const name = getOpenAPITypeName(this.emitter.getProgram(), array, this.#typeNameOptions()); return this.#createDeclaration(array, name, this.applyConstraints(array, schema)); } arrayLiteral(array, elementType) { return this.#inlineType(array, new ObjectBuilder({ type: "array", items: this.emitter.emitTypeReference(elementType), })); } arrayLiteralReferenceContext(array, elementType) { return { visibility: this.#getVisibilityContext() | Visibility.Item, }; } #requiredModelProperties(model, visibility) { const requiredProps = []; for (const prop of model.properties.values()) { if (isNeverType(prop.type)) { // If the property has a type of 'never', don't include it in the schema continue; } if (!this._metadataInfo.isPayloadProperty(prop, visibility, this.#ignoreMetadataAnnotations())) { continue; } if (!this._metadataInfo.isOptional(prop, visibility)) { const encodedName = resolveEncodedName(this.emitter.getProgram(), prop, this.getContentType()); requiredProps.push(encodedName); } } const discriminator = getDiscriminator(this.emitter.getProgram(), model); if (discriminator) { if (!requiredProps.includes(discriminator.propertyName)) { requiredProps.push(discriminator.propertyName); } } return requiredProps.length > 0 ? requiredProps : undefined; } modelProperties(model) { const program = this.emitter.getProgram(); const props = new ObjectBuilder(); const visibility = this.emitter.getContext().visibility; const contentType = this.getContentType(); for (const prop of model.properties.values()) { if (isNeverType(prop.type)) { // If the property has a type of 'never', don't include it in the schema continue; } if (!this._metadataInfo.isPayloadProperty(prop, visibility, this.#ignoreMetadataAnnotations())) { continue; } const result = this.emitter.emitModelProperty(prop); const encodedName = resolveEncodedName(program, prop, contentType); props.set(encodedName, result); } const discriminator = getDiscriminator(program, model); if (discriminator && !(discriminator.propertyName in props)) { props.set(discriminator.propertyName, { type: "string", description: `Discriminator property for ${model.name}.`, }); } if (Object.keys(props).length === 0) { return this.emitter.result.none(); } return props; } getRawBinarySchema() { throw new Error("Method not implemented."); } modelPropertyLiteral(prop) { const program = this.emitter.getProgram(); const refSchema = this.emitter.emitTypeReference(prop.type, { referenceContext: {}, }); if (refSchema.kind !== "code") { reportDiagnostic(program, { code: "invalid-model-property", format: { type: prop.type.kind, }, target: prop, }); return {}; } const isRef = refSchema.value instanceof Placeholder || "$ref" in refSchema.value; const schema = this.applyEncoding(prop, refSchema.value); // Apply decorators on the property to the type's schema const additionalProps = this.applyConstraints(prop, {}, schema); if (prop.defaultValue) { additionalProps.default = getDefaultValue(program, prop.defaultValue, prop); } if (isReadonlyProperty(program, prop)) { additionalProps.readOnly = true; } // Attach any additional OpenAPI extensions attachExtensions(program, prop, additionalProps); if (schema && isRef && !(prop.type.kind === "Model" && isArrayModelType(program, prop.type))) { if (Object.keys(additionalProps).length === 0) { return schema; } else { if (additionalProps.xml?.attribute) { return additionalProps; } else { return { allOf: [schema], ...additionalProps, }; } } } else { if (getOneOf(program, prop) && schema.anyOf) { schema.oneOf = schema.anyOf; delete schema.anyOf; } const merged = new ObjectBuilder(schema); for (const [key, value] of Object.entries(additionalProps)) { merged.set(key, value); } return merged; } } booleanLiteral(boolean) { return { type: "boolean", enum: [boolean.value] }; } stringLiteral(string) { return { type: "string", enum: [string.value] }; } stringTemplate(string) { if (string.stringValue !== undefined) { return { type: "string", enum: [string.stringValue] }; } const diagnostics = explainStringTemplateNotSerializable(string); this.emitter .getProgram() .reportDiagnostics(diagnostics.map((x) => ({ ...x, severity: "warning" }))); return { type: "string" }; } numericLiteral(number) { return { type: "number", enum: [number.value] }; } enumDeclaration(en, name) { const baseName = getOpenAPITypeName(this.emitter.getProgram(), en, this.#typeNameOptions()); return this.#createDeclaration(en, baseName, new ObjectBuilder(this.enumSchema(en))); } enumSchema(en) { // Enums are handled differently between 3.x versions due to the differences in `type` throw new Error("Method not implemented."); } enumMember(member) { return this.enumMemberReference(member); } enumMemberReference(member) { // would like to dispatch to the same `literal` codepaths but enum members aren't literal types switch (typeof member.value) { case "undefined": return { type: "string", enum: [member.name] }; case "string": return { type: "string", enum: [member.value] }; case "number": return { type: "number", enum: [member.value] }; } } unionDeclaration(union, name) { const schema = this.unionSchema(union); const baseName = getOpenAPITypeName(this.emitter.getProgram(), union, this.#typeNameOptions()); return this.#createDeclaration(union, baseName, schema); } unionSchema(union) { // Unions are handled differently between 3.x versions // mostly due to how nullable properties are handled. throw new Error("Method not implemented."); } discriminatedUnion(union) { const tk = $(this.emitter.getProgram()); let schema; if (union.options.envelope === "none") { const items = new ArrayBuilder(); for (const variant of union.variants.values()) { items.push(this.emitter.emitTypeReference(variant)); } schema = { type: "object", oneOf: items, discriminator: { propertyName: union.options.discriminatorPropertyName, mapping: this.getDiscriminatorMapping(union.variants), }, }; } else { const envelopeVariants = new Map(); for (const [name, variant] of union.variants) { const envelopeModel = tk.model.create({ name: union.type.name + capitalize(name), properties: { [union.options.discriminatorPropertyName]: tk.modelProperty.create({ name: union.options.discriminatorPropertyName, type: tk.literal.createString(name), }), [union.options.envelopePropertyName]: tk.modelProperty.create({ name: union.options.envelopePropertyName, type: variant, }), }, }); envelopeVariants.set(name, envelopeModel); } const items = new ArrayBuilder(); for (const variant of envelopeVariants.values()) { items.push(this.emitter.emitTypeReference(variant)); } schema = { type: "object", oneOf: items, discriminator: { propertyName: union.options.discriminatorPropertyName, mapping: this.getDiscriminatorMapping(envelopeVariants), }, }; } return this.applyConstraints(union.type, schema); } getDiscriminatorMapping(variants) { const mapping = {}; for (const [key, model] of variants.entries()) { const ref = this.emitter.emitTypeReference(model); compilerAssert(ref.kind === "code", "Unexpected ref schema. Should be kind: code"); mapping[key] = ref.value.$ref; } return mapping; } unionLiteral(union) { return this.unionSchema(union); } unionVariants(union) { const variants = new ArrayBuilder(); for (const variant of union.variants.values()) { variants.push(this.emitter.emitType(variant)); } return variants; } unionVariant(variant) { return this.emitter.emitTypeReference(variant.type); } modelPropertyReference(prop) { return this.modelPropertyLiteral(prop); } reference(targetDeclaration, pathUp, pathDown, commonScope) { if (targetDeclaration.value instanceof Placeholder) { // I don't think this is possible, confirm. throw new Error("Can't form reference to declaration that hasn't been created yet"); } // these will be undefined when creating a self-reference const currentSfScope = pathUp[pathUp.length - 1]; const targetSfScope = pathDown[0]; if (targetSfScope && currentSfScope && !targetSfScope.sourceFile.meta.shouldEmit) { currentSfScope.sourceFile.meta.bundledRefs.push(targetDeclaration); } return { $ref: `#/components/schemas/${targetDeclaration.name}` }; } circularReference(target, scope, cycle) { if (!cycle.containsDeclaration) { reportDiagnostic(this.emitter.getProgram(), { code: "inline-cycle", format: { type: cycle.toString(), }, target: cycle.first.type, }); return {}; } return super.circularReference(target, scope, cycle); } scalarDeclaration(scalar, name) { const isStd = isStdType(this.emitter.getProgram(), scalar); const schema = this.#getSchemaForScalar(scalar); const baseName = getOpenAPITypeName(this.emitter.getProgram(), scalar, this.#typeNameOptions()); // Don't create a declaration for std types return isStd ? schema : this.#createDeclaration(scalar, baseName, new ObjectBuilder(schema)); } scalarInstantiation(scalar, name) { return this.#getSchemaForScalar(scalar); } tupleLiteral(tuple) { return { type: "array", items: {} }; } #getSchemaForScalar(scalar) { let result = {}; const isStd = isStdType(this.emitter.getProgram(), scalar); if (isStd) { result = this.getSchemaForStdScalars(scalar); } else if (scalar.baseScalar) { result = this.#getSchemaForScalar(scalar.baseScalar); } const withDecorators = this.applyEncoding(scalar, this.applyConstraints(scalar, result)); if (isStd) { // Standard types are going to be inlined in the spec and we don't want the description of the scalar to show up delete withDecorators.description; } return withDecorators; } getSchemaForStdScalars(scalar) { return getSchemaForStdScalars(scalar, this._options); } applyCustomConstraints(type, target, refSchema) { } applyConstraints(type, original, refSchema) { // Apply common constraints const schema = new ObjectBuilder(original); const program = this.emitter.getProgram(); const applyConstraint = (fn, key) => { const value = fn(program, type); if (value !== undefined) { schema[key] = value; } }; applyConstraint(getMinLength, "minLength"); applyConstraint(getMaxLength, "maxLength"); applyConstraint(getMinValue, "minimum"); applyConstraint(getMaxValue, "maximum"); applyConstraint(getPattern, "pattern"); applyConstraint(getMinItems, "minItems"); applyConstraint(getMaxItems, "maxItems"); // apply json schema decorators const jsonSchemaModule = this._jsonSchemaModule; if (jsonSchemaModule) { applyConstraint(jsonSchemaModule.getMultipleOf, "multipleOf"); applyConstraint(jsonSchemaModule.getUniqueItems, "uniqueItems"); applyConstraint(jsonSchemaModule.getMinProperties, "minProperties"); applyConstraint(jsonSchemaModule.getMaxProperties, "maxProperties"); } if (isSecret(program, type)) { schema.format = "password"; } // the stdlib applies a format of "url" but json schema wants "uri", // so ignore this format if it's the built-in type. if (!isStdType(program, type) || type.name !== "url") { applyConstraint(getFormat, "format"); } applyConstraint(getDoc, "description"); applyConstraint(getSummary, "title"); applyConstraint((p, t) => (getDeprecated(p, t) !== undefined ? true : undefined), "deprecated"); this.applyCustomConstraints(type, schema, refSchema); this.applyXml(type, schema, refSchema); attachExtensions(program, type, schema); return new ObjectBuilder(schema); } applyXml(type, schema, refSchema) { const program = this.emitter.getProgram(); if (this._xmlModule) { switch (type.kind) { case "Scalar": case "Model": this._xmlModule.attachXmlObjectForScalarOrModel(program, type, schema); break; case "ModelProperty": this._xmlModule.attachXmlObjectForModelProperty(program, this._options, type, schema, refSchema); break; } } } #inlineType(type, schema) { if (this._options.includeXTypeSpecName !== "never") { schema.set("x-typespec-name", getTypeName(type, this.#typeNameOptions())); } return schema; } #createDeclaration(type, name, schema) { const skipNameValidation = type.kind === "Model" && type.templateMapper !== undefined; if (!skipNameValidation) { name = ensureValidComponentFixedFieldKey(this.emitter.getProgram(), type, name); } const refUrl = getRef(this.emitter.getProgram(), type); if (refUrl) { return { $ref: refUrl, }; } if (shouldInline(this.emitter.getProgram(), type)) { return this.#inlineType(type, schema); } const title = getSummary(this.emitter.getProgram(), type); if (title) { schema.set("title", title); } const usage = this._visibilityUsage.getUsage(type); const shouldAddSuffix = usage !== undefined && usage.size > 1; const visibility = this.#getVisibilityContext(); const fullName = name + (shouldAddSuffix ? getVisibilitySuffix(visibility, Visibility.Read) : ""); const decl = this.emitter.result.declaration(fullName, schema); checkDuplicateTypeName(this.emitter.getProgram(), type, fullName, Object.fromEntries(decl.scope.declarations.map((x) => [x.name, true]))); return decl; } applyEncoding(typespecType, target) { throw new Error("Method not implemented."); } programContext(program) { const sourceFile = this.emitter.createSourceFile("openapi"); return { scope: sourceFile.globalScope }; } } export const Builders = { array: (items) => { const builder = new ArrayBuilder(); for (const item of items) { builder.push(item); } return builder; }, object: (obj) => { const builder = new ObjectBuilder(); for (const [key, value] of Object.entries(obj)) { builder.set(key, value); } return builder; }, }; //# sourceMappingURL=schema-emitter.js.map