diff --git a/eng/common/pipelines/ci.yml b/eng/common/pipelines/ci.yml index 8cea470cf89..357239124bf 100644 --- a/eng/common/pipelines/ci.yml +++ b/eng/common/pipelines/ci.yml @@ -8,6 +8,7 @@ pr: include: - main - release/* + - feature/* extends: template: /eng/common/pipelines/templates/1es-redirect.yml diff --git a/packages/openapi3/README.md b/packages/openapi3/README.md index cd139f049bf..795af3c4e75 100644 --- a/packages/openapi3/README.md +++ b/packages/openapi3/README.md @@ -74,6 +74,10 @@ Example Multiple service with versioning - `openapi.Org1.Service2.v1.0.yaml` - `openapi.Org1.Service2.v1.1.yaml` +### `openapi-versions` + +**Type:** `array` + ### `new-line` **Type:** `"crlf" | "lf"` diff --git a/packages/openapi3/src/attach-extensions.ts b/packages/openapi3/src/attach-extensions.ts new file mode 100644 index 00000000000..f86ae817f97 --- /dev/null +++ b/packages/openapi3/src/attach-extensions.ts @@ -0,0 +1,12 @@ +import { Program, Type } from "@typespec/compiler"; +import { getExtensions } from "@typespec/openapi"; + +export function attachExtensions(program: Program, type: Type, emitObject: any) { + // Attach any OpenAPI extensions + const extensions = getExtensions(program, type); + if (extensions) { + for (const key of extensions.keys()) { + emitObject[key] = extensions.get(key); + } + } +} diff --git a/packages/openapi3/src/encoding.ts b/packages/openapi3/src/encoding.ts index 6cf027c42df..5f671a3eb1b 100644 --- a/packages/openapi3/src/encoding.ts +++ b/packages/openapi3/src/encoding.ts @@ -2,14 +2,14 @@ import { type ModelProperty, Program, type Scalar, getEncode } from "@typespec/c import { ObjectBuilder } from "@typespec/compiler/emitter-framework"; import type { ResolvedOpenAPI3EmitterOptions } from "./openapi.js"; import { getSchemaForStdScalars } from "./std-scalar-schemas.js"; -import type { OpenAPI3Schema } from "./types.js"; +import type { OpenAPI3Schema, OpenAPISchema3_1 } from "./types.js"; export function applyEncoding( program: Program, typespecType: Scalar | ModelProperty, target: OpenAPI3Schema, options: ResolvedOpenAPI3EmitterOptions, -): OpenAPI3Schema { +): OpenAPI3Schema & OpenAPISchema3_1 { const encodeData = getEncode(program, typespecType); if (encodeData) { const newTarget = new ObjectBuilder(target); diff --git a/packages/openapi3/src/openapi-spec-mappings.ts b/packages/openapi3/src/openapi-spec-mappings.ts new file mode 100644 index 00000000000..53e563023cc --- /dev/null +++ b/packages/openapi3/src/openapi-spec-mappings.ts @@ -0,0 +1,79 @@ +import { EmitContext, Namespace, Program } from "@typespec/compiler"; +import { AssetEmitter } from "@typespec/compiler/emitter-framework"; +import { MetadataInfo } from "@typespec/http"; +import { getExternalDocs, resolveInfo } from "@typespec/openapi"; +import { OpenAPI3EmitterOptions, OpenAPIVersion } from "./lib.js"; +import { ResolvedOpenAPI3EmitterOptions } from "./openapi.js"; +import { createSchemaEmitter3_0 } from "./schema-emitter-3-0.js"; +import { createSchemaEmitter3_1 } from "./schema-emitter-3-1.js"; +import { OpenAPI3Schema, OpenAPISchema3_1, SupportedOpenAPIDocuments } from "./types.js"; +import { VisibilityUsageTracker } from "./visibility-usage.js"; +import { XmlModule } from "./xml-module.js"; + +export type CreateSchemaEmitter = (props: { + program: Program; + context: EmitContext; + metadataInfo: MetadataInfo; + visibilityUsage: VisibilityUsageTracker; + options: ResolvedOpenAPI3EmitterOptions; + xmlModule: XmlModule | undefined; +}) => AssetEmitter; + +export interface OpenApiSpecSpecificProps { + createRootDoc: ( + program: Program, + serviceType: Namespace, + serviceVersion?: string, + ) => SupportedOpenAPIDocuments; + + createSchemaEmitter: CreateSchemaEmitter; +} + +export function getOpenApiSpecProps(specVersion: OpenAPIVersion): OpenApiSpecSpecificProps { + switch (specVersion) { + case "3.0.0": + return { + createRootDoc(program, serviceType, serviceVersion) { + return createRoot(program, serviceType, specVersion, serviceVersion); + }, + createSchemaEmitter: createSchemaEmitter3_0, + }; + case "3.1.0": + return { + createRootDoc(program, serviceType, serviceVersion) { + return createRoot(program, serviceType, specVersion, serviceVersion); + }, + createSchemaEmitter: createSchemaEmitter3_1, + }; + } +} + +function createRoot( + program: Program, + serviceType: Namespace, + specVersion: OpenAPIVersion, + serviceVersion?: string, +): SupportedOpenAPIDocuments { + const info = resolveInfo(program, serviceType); + + return { + openapi: specVersion, + info: { + title: "(title)", + ...info, + version: serviceVersion ?? info?.version ?? "0.0.0", + }, + externalDocs: getExternalDocs(program, serviceType), + tags: [], + paths: {}, + security: undefined, + components: { + parameters: {}, + requestBodies: {}, + responses: {}, + schemas: {}, + examples: {}, + securitySchemes: {}, + }, + }; +} diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 60c0ed4c1b1..f783381cce5 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -80,7 +80,6 @@ import { getParameterKey, getTagsMetadata, isReadonlyProperty, - resolveInfo, resolveOperationId, shouldInline, } from "@typespec/openapi"; @@ -90,10 +89,9 @@ import { getRef } from "./decorators.js"; import { applyEncoding } from "./encoding.js"; import { getExampleOrExamples, OperationExamples, resolveOperationExamples } from "./examples.js"; import { createDiagnostic, FileType, OpenAPI3EmitterOptions, OpenAPIVersion } from "./lib.js"; -import { getDefaultValue, isBytesKeptRaw, OpenAPI3SchemaEmitter } from "./schema-emitter.js"; +import { getOpenApiSpecProps } from "./openapi-spec-mappings.js"; import { getOpenAPI3StatusCodes } from "./status-codes.js"; import { - OpenAPI3Document, OpenAPI3Encoding, OpenAPI3Header, OpenAPI3MediaType, @@ -111,9 +109,17 @@ import { OpenAPI3StatusCode, OpenAPI3Tag, OpenAPI3VersionedServiceRecord, + OpenAPISchema3_1, Refable, + SupportedOpenAPIDocuments, } from "./types.js"; -import { deepEquals, isSharedHttpOperation, SharedHttpOperation } from "./util.js"; +import { + deepEquals, + getDefaultValue, + isBytesKeptRaw, + isSharedHttpOperation, + SharedHttpOperation, +} from "./util.js"; import { resolveVisibilityUsage, VisibilityUsageTracker } from "./visibility-usage.js"; import { resolveXmlModule, XmlModule } from "./xml-module.js"; @@ -223,10 +229,11 @@ function createOAPIEmitter( options: ResolvedOpenAPI3EmitterOptions, specVersion: OpenAPIVersion = "3.0.0", ) { + const { createRootDoc, createSchemaEmitter } = getOpenApiSpecProps(specVersion); let program = context.program; - let schemaEmitter: AssetEmitter; + let schemaEmitter: AssetEmitter; - let root: OpenAPI3Document; + let root: SupportedOpenAPIDocuments; let diagnostics: DiagnosticCollector; let currentService: Service; let serviceAuth: HttpServiceAuthentication; @@ -320,40 +327,23 @@ function createOAPIEmitter( options.omitUnreachableTypes, ); - schemaEmitter = createAssetEmitter( + schemaEmitter = createSchemaEmitter({ program, - class extends OpenAPI3SchemaEmitter { - constructor(emitter: AssetEmitter, OpenAPI3EmitterOptions>) { - super(emitter, metadataInfo, visibilityUsage, options, xmlModule); - } - } as any, context, - ); + metadataInfo, + visibilityUsage, + options, + xmlModule, + }); const securitySchemes = getOpenAPISecuritySchemes(allHttpAuthentications); const security = getOpenAPISecurity(defaultAuth); - const info = resolveInfo(program, service.type); - root = { - openapi: "3.0.0", - info: { - title: "(title)", - ...info, - version: version ?? info?.version ?? "0.0.0", - }, - externalDocs: getExternalDocs(program, service.type), - tags: [], - paths: {}, - security: security.length > 0 ? security : undefined, - components: { - parameters: {}, - requestBodies: {}, - responses: {}, - schemas: {}, - examples: {}, - securitySchemes: securitySchemes, - }, - }; + root = createRootDoc(program, service.type, version); + if (security.length > 0) { + root.security = security; + } + root.components!.securitySchemes = securitySchemes; const servers = getServers(program, service.type); if (servers) { @@ -642,7 +632,7 @@ function createOAPIEmitter( async function getOpenApiFromVersion( service: Service, version?: string, - ): Promise<[OpenAPI3Document, Readonly] | undefined> { + ): Promise<[SupportedOpenAPIDocuments, Readonly] | undefined> { try { const httpService = ignoreDiagnostics(getHttpService(program, service.type)); const auth = (serviceAuth = resolveAuthentication(httpService)); @@ -1827,7 +1817,7 @@ function createOAPIEmitter( } } -function serializeDocument(root: OpenAPI3Document, fileType: FileType): string { +function serializeDocument(root: SupportedOpenAPIDocuments, fileType: FileType): string { sortOpenAPIDocument(root); switch (fileType) { case "json": @@ -1860,7 +1850,7 @@ function sortObjectByKeys>(obj: T): T { }, {}); } -function sortOpenAPIDocument(doc: OpenAPI3Document): void { +function sortOpenAPIDocument(doc: SupportedOpenAPIDocuments): void { doc.paths = sortObjectByKeys(doc.paths); if (doc.components?.schemas) { doc.components.schemas = sortObjectByKeys(doc.components.schemas); diff --git a/packages/openapi3/src/schema-emitter-3-0.ts b/packages/openapi3/src/schema-emitter-3-0.ts new file mode 100644 index 00000000000..f6ea8f04604 --- /dev/null +++ b/packages/openapi3/src/schema-emitter-3-0.ts @@ -0,0 +1,268 @@ +import { + compilerAssert, + Enum, + getExamples, + getMaxValueExclusive, + getMinValueExclusive, + IntrinsicType, + isNullType, + Model, + ModelProperty, + Scalar, + serializeValueAsJson, + Type, + Union, +} from "@typespec/compiler"; +import { + AssetEmitter, + createAssetEmitter, + EmitterOutput, + ObjectBuilder, + Placeholder, + TypeEmitter, +} from "@typespec/compiler/emitter-framework"; +import { MetadataInfo } from "@typespec/http"; +import { shouldInline } from "@typespec/openapi"; +import { getOneOf } from "./decorators.js"; +import { OpenAPI3EmitterOptions, reportDiagnostic } from "./lib.js"; +import { CreateSchemaEmitter } from "./openapi-spec-mappings.js"; +import { ResolvedOpenAPI3EmitterOptions } from "./openapi.js"; +import { Builders, OpenAPI3SchemaEmitterBase } from "./schema-emitter.js"; +import { JsonType, OpenAPI3Schema } from "./types.js"; +import { isBytesKeptRaw, isLiteralType, literalType } from "./util.js"; +import { VisibilityUsageTracker } from "./visibility-usage.js"; +import { XmlModule } from "./xml-module.js"; + +function createWrappedSchemaEmitterClass( + metadataInfo: MetadataInfo, + visibilityUsage: VisibilityUsageTracker, + options: ResolvedOpenAPI3EmitterOptions, + xmlModule: XmlModule | undefined, +): typeof TypeEmitter, OpenAPI3EmitterOptions> { + return class extends OpenAPI3SchemaEmitter { + constructor(emitter: AssetEmitter, OpenAPI3EmitterOptions>) { + super(emitter, metadataInfo, visibilityUsage, options, xmlModule); + } + }; +} + +export const createSchemaEmitter3_0: CreateSchemaEmitter = ({ program, context, ...rest }) => { + return createAssetEmitter( + program, + createWrappedSchemaEmitterClass( + rest.metadataInfo, + rest.visibilityUsage, + rest.options, + rest.xmlModule, + ), + context, + ); +}; + +/** + * OpenAPI 3.0 schema emitter. Deals with emitting content of `components/schemas` section. + */ +export class OpenAPI3SchemaEmitter extends OpenAPI3SchemaEmitterBase { + #applySchemaExamples( + type: Model | Scalar | Union | Enum | ModelProperty, + target: ObjectBuilder, + ) { + const program = this.emitter.getProgram(); + const examples = getExamples(program, type); + if (examples.length > 0) { + target.set("example", serializeValueAsJson(program, examples[0].value, type)); + } + } + + applyCustomConstraints( + type: Scalar | Model | ModelProperty | Union | Enum, + target: ObjectBuilder, + refSchema?: OpenAPI3Schema, + ) { + const program = this.emitter.getProgram(); + + const minValueExclusive = getMinValueExclusive(program, type); + if (minValueExclusive !== undefined) { + target.minimum = minValueExclusive; + target.exclusiveMinimum = true; + } + + const maxValueExclusive = getMaxValueExclusive(program, type); + if (maxValueExclusive !== undefined) { + target.maximum = maxValueExclusive; + target.exclusiveMaximum = true; + } + + this.#applySchemaExamples(type, target); + } + + enumSchema(en: Enum): OpenAPI3Schema { + const program = this.emitter.getProgram(); + if (en.members.size === 0) { + reportDiagnostic(program, { code: "empty-enum", target: en }); + return {}; + } + + const enumTypes = new Set(); + const enumValues = new Set(); + for (const member of en.members.values()) { + enumTypes.add(typeof member.value === "number" ? "number" : "string"); + enumValues.add(member.value ?? member.name); + } + + if (enumTypes.size > 1) { + reportDiagnostic(program, { code: "enum-unique-type", target: en }); + } + + const schema: OpenAPI3Schema = { + type: enumTypes.values().next().value!, + enum: [...enumValues], + }; + + return this.applyConstraints(en, schema); + } + + unionSchema(union: Union): ObjectBuilder { + const program = this.emitter.getProgram(); + if (union.variants.size === 0) { + reportDiagnostic(program, { code: "empty-union", target: union }); + return new ObjectBuilder({}); + } + const variants = Array.from(union.variants.values()); + const literalVariantEnumByType: Record = {}; + const ofType = getOneOf(program, union) ? "oneOf" : "anyOf"; + const schemaMembers: { schema: any; type: Type | null }[] = []; + let nullable = false; + const isMultipart = this.getContentType().startsWith("multipart/"); + + for (const variant of variants) { + if (isNullType(variant.type)) { + nullable = true; + continue; + } + + if (isMultipart && isBytesKeptRaw(program, variant.type)) { + schemaMembers.push({ schema: { type: "string", format: "binary" }, type: variant.type }); + continue; + } + + if (isLiteralType(variant.type)) { + if (!literalVariantEnumByType[variant.type.kind]) { + const enumValue: any[] = [variant.type.value]; + literalVariantEnumByType[variant.type.kind] = enumValue; + schemaMembers.push({ + schema: { type: literalType(variant.type), enum: enumValue }, + type: null, + }); + } else { + literalVariantEnumByType[variant.type.kind].push(variant.type.value); + } + } else { + const enumSchema = this.emitter.emitTypeReference(variant.type, { + referenceContext: isMultipart ? { contentType: "application/json" } : {}, + }); + compilerAssert(enumSchema.kind === "code", "Unexpected enum schema. Should be kind: code"); + schemaMembers.push({ schema: enumSchema.value, type: variant.type }); + } + } + + const wrapWithObjectBuilder = ( + schemaMember: { schema: any; type: Type | null }, + { mergeUnionWideConstraints }: { mergeUnionWideConstraints: boolean }, + ): ObjectBuilder => { + // we can just return the single schema member after applying nullable + const schema = schemaMember.schema; + const type = schemaMember.type; + const additionalProps: Partial = mergeUnionWideConstraints + ? this.applyConstraints(union, {}) + : {}; + + if (mergeUnionWideConstraints && nullable) { + additionalProps.nullable = true; + } + + if (Object.keys(additionalProps).length === 0) { + return new ObjectBuilder(schema); + } else { + if ( + (schema instanceof Placeholder || "$ref" in schema) && + !(type && shouldInline(program, type)) + ) { + if (type && (type.kind === "Model" || type.kind === "Scalar")) { + return new ObjectBuilder({ + type: "object", + allOf: Builders.array([schema]), + ...additionalProps, + }); + } else { + return new ObjectBuilder({ allOf: Builders.array([schema]), ...additionalProps }); + } + } else { + const merged = new ObjectBuilder(schema); + for (const [key, value] of Object.entries(additionalProps)) { + merged.set(key, value); + } + return merged; + } + } + }; + + const checkMerge = (schemaMembers: { schema: any; type: Type | null }[]): boolean => { + if (nullable) { + for (const m of schemaMembers) { + if (m.schema instanceof Placeholder || "$ref" in m.schema) { + return true; + } + } + } + return false; + }; + + if (schemaMembers.length === 0) { + if (nullable) { + // This union is equivalent to just `null` but OA3 has no way to specify + // null as a value, so we throw an error. + reportDiagnostic(program, { code: "union-null", target: union }); + return new ObjectBuilder({}); + } else { + // completely empty union can maybe only happen with bugs? + compilerAssert(false, "Attempting to emit an empty union"); + } + } + + if (schemaMembers.length === 1) { + return wrapWithObjectBuilder(schemaMembers[0], { mergeUnionWideConstraints: true }); + } + + const isMerge = checkMerge(schemaMembers); + const schema: OpenAPI3Schema = { + [ofType]: schemaMembers.map((m) => + wrapWithObjectBuilder(m, { mergeUnionWideConstraints: isMerge }), + ), + }; + + if (!isMerge && nullable) { + schema.nullable = true; + } + + this.applyDiscriminator(union, schema); + + return this.applyConstraints(union, schema); + } + + intrinsic(intrinsic: IntrinsicType, name: string): EmitterOutput { + switch (name) { + case "unknown": + return {}; + case "null": + return { nullable: true }; + } + + reportDiagnostic(this.emitter.getProgram(), { + code: "invalid-schema", + format: { type: name }, + target: intrinsic, + }); + return {}; + } +} diff --git a/packages/openapi3/src/schema-emitter-3-1.ts b/packages/openapi3/src/schema-emitter-3-1.ts new file mode 100644 index 00000000000..ccd65b97be8 --- /dev/null +++ b/packages/openapi3/src/schema-emitter-3-1.ts @@ -0,0 +1,242 @@ +import { + compilerAssert, + Enum, + getExamples, + getMaxValueExclusive, + getMinValueExclusive, + IntrinsicType, + Model, + ModelProperty, + Program, + Scalar, + serializeValueAsJson, + Type, + Union, +} from "@typespec/compiler"; +import { + AssetEmitter, + createAssetEmitter, + EmitterOutput, + ObjectBuilder, + Placeholder, + TypeEmitter, +} from "@typespec/compiler/emitter-framework"; +import { MetadataInfo } from "@typespec/http"; +import { shouldInline } from "@typespec/openapi"; +import { getOneOf } from "./decorators.js"; +import { OpenAPI3EmitterOptions, reportDiagnostic } from "./lib.js"; +import { CreateSchemaEmitter } from "./openapi-spec-mappings.js"; +import { ResolvedOpenAPI3EmitterOptions } from "./openapi.js"; +import { Builders, OpenAPI3SchemaEmitterBase } from "./schema-emitter.js"; +import { JsonType, OpenAPISchema3_1 } from "./types.js"; +import { isBytesKeptRaw, isLiteralType, literalType } from "./util.js"; +import { VisibilityUsageTracker } from "./visibility-usage.js"; +import { XmlModule } from "./xml-module.js"; + +function createWrappedSchemaEmitterClass( + metadataInfo: MetadataInfo, + visibilityUsage: VisibilityUsageTracker, + options: ResolvedOpenAPI3EmitterOptions, + xmlModule: XmlModule | undefined, +): typeof TypeEmitter, OpenAPI3EmitterOptions> { + return class extends OpenAPI31SchemaEmitter { + constructor(emitter: AssetEmitter, OpenAPI3EmitterOptions>) { + super(emitter, metadataInfo, visibilityUsage, options, xmlModule); + } + }; +} + +export const createSchemaEmitter3_1: CreateSchemaEmitter = ({ program, context, ...rest }) => { + return createAssetEmitter( + program, + createWrappedSchemaEmitterClass( + rest.metadataInfo, + rest.visibilityUsage, + rest.options, + rest.xmlModule, + ), + context, + ); +}; + +/** + * OpenAPI 3.1 schema emitter. Deals with emitting content of `components/schemas` section. + */ +export class OpenAPI31SchemaEmitter extends OpenAPI3SchemaEmitterBase { + #applySchemaExamples( + program: Program, + type: Model | Scalar | Union | Enum | ModelProperty, + target: ObjectBuilder, + ) { + const examples = getExamples(program, type); + if (examples.length > 0) { + target.set( + "examples", + examples.map((example) => serializeValueAsJson(program, example.value, type)), + ); + } + } + + applyCustomConstraints( + type: Scalar | Model | ModelProperty | Union | Enum, + target: ObjectBuilder, + refSchema?: OpenAPISchema3_1, + ) { + const program = this.emitter.getProgram(); + + const minValueExclusive = getMinValueExclusive(program, type); + if (minValueExclusive !== undefined) { + target.minimum = undefined; + target.exclusiveMinimum = minValueExclusive; + } + + const maxValueExclusive = getMaxValueExclusive(program, type); + if (maxValueExclusive !== undefined) { + target.maximum = undefined; + target.exclusiveMaximum = maxValueExclusive; + } + + this.#applySchemaExamples(program, type, target); + } + + enumSchema(en: Enum): OpenAPISchema3_1 { + const program = this.emitter.getProgram(); + if (en.members.size === 0) { + reportDiagnostic(program, { code: "empty-enum", target: en }); + return {}; + } + + const enumTypes = new Set(); + const enumValues = new Set(); + for (const member of en.members.values()) { + enumTypes.add(typeof member.value === "number" ? "number" : "string"); + enumValues.add(member.value ?? member.name); + } + + if (enumTypes.size > 1) { + reportDiagnostic(program, { code: "enum-unique-type", target: en }); + } + + const schema: OpenAPISchema3_1 = { + type: enumTypes.values().next().value!, + enum: [...enumValues], + }; + + return this.applyConstraints(en, schema); + } + + unionSchema(union: Union): ObjectBuilder { + const program = this.emitter.getProgram(); + if (union.variants.size === 0) { + reportDiagnostic(program, { code: "empty-union", target: union }); + return new ObjectBuilder({}); + } + const variants = Array.from(union.variants.values()); + const literalVariantEnumByType: Record = {}; + const ofType = getOneOf(program, union) ? "oneOf" : "anyOf"; + const schemaMembers: { schema: any; type: Type | null }[] = []; + const isMultipart = this.getContentType().startsWith("multipart/"); + + // 1. Iterate over all the union variants to generate a schema for each one. + for (const variant of variants) { + // 2. Special handling for multipart - want to treat as binary + if (isMultipart && isBytesKeptRaw(program, variant.type)) { + schemaMembers.push({ schema: { type: "string", format: "binary" }, type: variant.type }); + continue; + } + + // 3.a. Literal types are actual values (though not Value types) + if (isLiteralType(variant.type)) { + // Create schemas grouped by kind (boolean, string, numeric) + // and add the literals seen to each respective `enum` array + if (!literalVariantEnumByType[variant.type.kind]) { + const enumValue: any[] = [variant.type.value]; + literalVariantEnumByType[variant.type.kind] = enumValue; + schemaMembers.push({ + schema: { type: literalType(variant.type), enum: enumValue }, + type: null, + }); + } else { + literalVariantEnumByType[variant.type.kind].push(variant.type.value); + } + } else { + // 3.b. Anything else, we get the schema for that type. + const enumSchema = this.emitter.emitTypeReference(variant.type, { + referenceContext: isMultipart ? { contentType: "application/json" } : {}, + }); + compilerAssert(enumSchema.kind === "code", "Unexpected enum schema. Should be kind: code"); + schemaMembers.push({ schema: enumSchema.value, type: variant.type }); + } + } + + const wrapWithObjectBuilder = ( + schemaMember: { schema: any; type: Type | null }, + { mergeUnionWideConstraints }: { mergeUnionWideConstraints: boolean }, + ): ObjectBuilder => { + const schema = schemaMember.schema; + const type = schemaMember.type; + const additionalProps: Partial = mergeUnionWideConstraints + ? this.applyConstraints(union, {}) + : {}; + + if (Object.keys(additionalProps).length === 0) { + return new ObjectBuilder(schema); + } else { + if ( + (schema instanceof Placeholder || "$ref" in schema) && + !(type && shouldInline(program, type)) + ) { + if (type && (type.kind === "Model" || type.kind === "Scalar")) { + return new ObjectBuilder({ + type: "object", + allOf: Builders.array([schema]), + ...additionalProps, + }); + } else { + return new ObjectBuilder({ allOf: Builders.array([schema]), ...additionalProps }); + } + } else { + const merged = new ObjectBuilder(schema); + for (const [key, value] of Object.entries(additionalProps)) { + merged.set(key, value); + } + return merged; + } + } + }; + + if (schemaMembers.length === 0) { + compilerAssert(false, "Attempting to emit an empty union"); + } + + if (schemaMembers.length === 1) { + return wrapWithObjectBuilder(schemaMembers[0], { mergeUnionWideConstraints: true }); + } + + const schema: OpenAPISchema3_1 = { + [ofType]: schemaMembers.map((m) => + wrapWithObjectBuilder(m, { mergeUnionWideConstraints: false }), + ), + }; + + this.applyDiscriminator(union, schema); + + return this.applyConstraints(union, schema); + } + + intrinsic(intrinsic: IntrinsicType, name: string): EmitterOutput { + switch (name) { + case "unknown": + return {}; + case "null": + return { type: "null" }; + } + + reportDiagnostic(this.emitter.getProgram(), { + code: "invalid-schema", + format: { type: name }, + target: intrinsic, + }); + return {}; + } +} diff --git a/packages/openapi3/src/schema-emitter.ts b/packages/openapi3/src/schema-emitter.ts index 4ecf08b542a..ca3c5cb1870 100644 --- a/packages/openapi3/src/schema-emitter.ts +++ b/packages/openapi3/src/schema-emitter.ts @@ -4,7 +4,6 @@ import { Enum, EnumMember, IntrinsicScalarName, - IntrinsicType, Model, ModelProperty, NumericLiteral, @@ -17,7 +16,6 @@ import { TypeNameOptions, Union, UnionVariant, - Value, compilerAssert, explainStringTemplateNotSerializable, getDeprecated, @@ -25,17 +23,14 @@ import { getDiscriminator, getDoc, getEncode, - getExamples, getFormat, getKnownValues, getMaxItems, getMaxLength, getMaxValue, - getMaxValueExclusive, getMinItems, getMinLength, getMinValue, - getMinValueExclusive, getNamespaceFullName, getPattern, getSummary, @@ -43,11 +38,8 @@ import { ignoreDiagnostics, isArrayModelType, isNeverType, - isNullType, isSecret, - isTemplateDeclaration, resolveEncodedName, - serializeValueAsJson, } from "@typespec/compiler"; import { ArrayBuilder, @@ -66,37 +58,37 @@ import { import { MetadataInfo, Visibility, getVisibilitySuffix } from "@typespec/http"; import { checkDuplicateTypeName, - getExtensions, getExternalDocs, getOpenAPITypeName, isReadonlyProperty, shouldInline, } from "@typespec/openapi"; +import { attachExtensions } from "./attach-extensions.js"; import { getOneOf, getRef } from "./decorators.js"; import { applyEncoding } from "./encoding.js"; import { OpenAPI3EmitterOptions, reportDiagnostic } from "./lib.js"; import { ResolvedOpenAPI3EmitterOptions } from "./openapi.js"; import { getSchemaForStdScalars } from "./std-scalar-schemas.js"; import { - JsonType, - OpenAPI3Discriminator, + CommonOpenAPI3Schema, OpenAPI3Schema, OpenAPI3SchemaProperty, + OpenAPISchema3_1, } from "./types.js"; +import { getDefaultValue, includeDerivedModel, isBytesKeptRaw, isStdType } from "./util.js"; import { VisibilityUsageTracker } from "./visibility-usage.js"; import { XmlModule } from "./xml-module.js"; /** - * OpenAPI3 schema emitter. Deals with emitting content of `components/schemas` section. + * Base OpenAPI3 schema emitter. Deals with emitting content of `components/schemas` section. */ -export class OpenAPI3SchemaEmitter extends TypeEmitter< - Record, - OpenAPI3EmitterOptions -> { - #metadataInfo: MetadataInfo; - #visibilityUsage: VisibilityUsageTracker; - #options: ResolvedOpenAPI3EmitterOptions; - #xmlModule: XmlModule | undefined; +export class OpenAPI3SchemaEmitterBase< + Schema extends OpenAPI3Schema | OpenAPISchema3_1, +> extends TypeEmitter, OpenAPI3EmitterOptions> { + protected _metadataInfo: MetadataInfo; + protected _visibilityUsage: VisibilityUsageTracker; + protected _options: ResolvedOpenAPI3EmitterOptions; + protected _xmlModule: XmlModule | undefined; constructor( emitter: AssetEmitter, OpenAPI3EmitterOptions>, metadataInfo: MetadataInfo, @@ -105,10 +97,10 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< xmlModule: XmlModule | undefined, ) { super(emitter); - this.#metadataInfo = metadataInfo; - this.#visibilityUsage = visibilityUsage; - this.#options = options; - this.#xmlModule = xmlModule; + this._metadataInfo = metadataInfo; + this._visibilityUsage = visibilityUsage; + this._options = options; + this._xmlModule = xmlModule; } modelDeclarationReferenceContext(model: Model, name: string): Context { @@ -134,10 +126,10 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< reduceContext(type: Type): Context { const visibility = this.#getVisibilityContext(); const patch: Record = {}; - if (visibility !== Visibility.Read && !this.#metadataInfo.isTransformed(type, visibility)) { + if (visibility !== Visibility.Read && !this._metadataInfo.isTransformed(type, visibility)) { patch.visibility = Visibility.Read; } - const contentType = this.#getContentType(); + const contentType = this.getContentType(); if (contentType === "application/json") { patch.contentType = undefined; @@ -146,6 +138,20 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< return patch; } + applyDiscriminator(type: Model | Union, schema: Schema): void { + 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(getDiscriminatedUnion(type, discriminator)); + if (discriminatedUnion.variants.size > 0) { + schema.discriminator.mapping = this.getDiscriminatorMapping(discriminatedUnion); + } + } + } + modelDeclaration(model: Model, _: string): EmitterOutput { const program = this.emitter.getProgram(); const visibility = this.#getVisibilityContext(); @@ -165,28 +171,17 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< this.emitter.emitTypeReference(child); } - const discriminator = getDiscriminator(program, model); - if (discriminator) { - const [union] = getDiscriminatedUnion(model, discriminator); - - const openApiDiscriminator: OpenAPI3Discriminator = { ...discriminator }; - if (union.variants.size > 0) { - openApiDiscriminator.mapping = this.#getDiscriminatorMapping(union); - } - - schema.discriminator = openApiDiscriminator; - } - + this.applyDiscriminator(model, schema as any); this.#applyExternalDocs(model, schema); if (model.baseModel) { - schema.set("allOf", B.array([this.emitter.emitTypeReference(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 isMultipart = this.getContentType().startsWith("multipart/"); const name = isMultipart ? baseName + "MultiPart" : baseName; - return this.#createDeclaration(model, name, this.#applyConstraints(model, schema)); + return this.#createDeclaration(model, name, this.applyConstraints(model, schema as any)); } #applyExternalDocs(typespecType: Type, target: Record) { @@ -215,7 +210,7 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< return this.emitter.getContext().ignoreMetadataAnnotations; } - #getContentType(): string { + getContentType(): string { return this.emitter.getContext().contentType ?? "application/json"; } @@ -248,7 +243,7 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< items: this.emitter.emitTypeReference(elementType), }); - return this.#createDeclaration(array, name, this.#applyConstraints(array, schema)); + return this.#createDeclaration(array, name, this.applyConstraints(array, schema as any)); } arrayDeclarationReferenceContext(array: Model, name: string, elementType: Type): Context { @@ -283,16 +278,16 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< } if ( - !this.#metadataInfo.isPayloadProperty(prop, visibility, this.#ignoreMetadataAnnotations()) + !this._metadataInfo.isPayloadProperty(prop, visibility, this.#ignoreMetadataAnnotations()) ) { continue; } - if (!this.#metadataInfo.isOptional(prop, visibility)) { + if (!this._metadataInfo.isOptional(prop, visibility)) { const encodedName = resolveEncodedName( this.emitter.getProgram(), prop, - this.#getContentType(), + this.getContentType(), ); requiredProps.push(encodedName); @@ -313,7 +308,7 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< const program = this.emitter.getProgram(); const props = new ObjectBuilder(); const visibility = this.emitter.getContext().visibility; - const contentType = this.#getContentType(); + const contentType = this.getContentType(); for (const prop of model.properties.values()) { if (isNeverType(prop.type)) { @@ -321,7 +316,7 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< continue; } if ( - !this.#metadataInfo.isPayloadProperty(prop, visibility, this.#ignoreMetadataAnnotations()) + !this._metadataInfo.isPayloadProperty(prop, visibility, this.#ignoreMetadataAnnotations()) ) { continue; } @@ -347,7 +342,7 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< modelPropertyLiteral(prop: ModelProperty): EmitterOutput { const program = this.emitter.getProgram(); - const isMultipart = this.#getContentType().startsWith("multipart/"); + const isMultipart = this.getContentType().startsWith("multipart/"); if (isMultipart) { if (isBytesKeptRaw(program, prop.type) && getEncode(program, prop) === undefined) { return { type: "string", format: "binary" }; @@ -386,7 +381,11 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< const schema = this.#applyEncoding(prop, refSchema.value as any); // Apply decorators on the property to the type's schema - const additionalProps: Partial = this.#applyConstraints(prop, {}, schema); + const additionalProps: Partial = this.applyConstraints( + prop, + {} as Schema, + schema, + ) as any; if (prop.defaultValue) { additionalProps.default = getDefaultValue(program, prop.defaultValue, prop); } @@ -396,7 +395,7 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< } // Attach any additional OpenAPI extensions - this.#attachExtensions(program, prop, additionalProps); + attachExtensions(program, prop, additionalProps); if (schema && isRef && !(prop.type.kind === "Model" && isArrayModelType(program, prop.type))) { if (Object.keys(additionalProps).length === 0) { @@ -451,33 +450,12 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< enumDeclaration(en: Enum, name: string): EmitterOutput { const baseName = getOpenAPITypeName(this.emitter.getProgram(), en, this.#typeNameOptions()); - return this.#createDeclaration(en, baseName, new ObjectBuilder(this.#enumSchema(en))); + return this.#createDeclaration(en, baseName, new ObjectBuilder(this.enumSchema(en))); } - #enumSchema(en: Enum): OpenAPI3Schema { - const program = this.emitter.getProgram(); - if (en.members.size === 0) { - reportDiagnostic(program, { code: "empty-enum", target: en }); - return {}; - } - - const enumTypes = new Set(); - const enumValues = new Set(); - for (const member of en.members.values()) { - enumTypes.add(typeof member.value === "number" ? "number" : "string"); - enumValues.add(member.value ?? member.name); - } - - if (enumTypes.size > 1) { - reportDiagnostic(program, { code: "enum-unique-type", target: en }); - } - - const schema: OpenAPI3Schema = { - type: enumTypes.values().next().value!, - enum: [...enumValues], - }; - - return this.#applyConstraints(en, schema); + enumSchema(en: Enum): Schema { + // Enums are handled differently between 3.x versions due to the differences in `type` + throw new Error("Method not implemented."); } enumMember(member: EnumMember): EmitterOutput> { @@ -497,150 +475,18 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< } unionDeclaration(union: Union, name: string): EmitterOutput { - const schema = this.#unionSchema(union); + const schema = this.unionSchema(union); const baseName = getOpenAPITypeName(this.emitter.getProgram(), union, this.#typeNameOptions()); return this.#createDeclaration(union, baseName, schema); } - #unionSchema(union: Union): ObjectBuilder { - const program = this.emitter.getProgram(); - if (union.variants.size === 0) { - reportDiagnostic(program, { code: "empty-union", target: union }); - return new ObjectBuilder({}); - } - const variants = Array.from(union.variants.values()); - const literalVariantEnumByType: Record = {}; - const ofType = getOneOf(program, union) ? "oneOf" : "anyOf"; - const schemaMembers: { schema: any; type: Type | null }[] = []; - let nullable = false; - const discriminator = getDiscriminator(program, union); - const isMultipart = this.#getContentType().startsWith("multipart/"); - - for (const variant of variants) { - if (isNullType(variant.type)) { - nullable = true; - continue; - } - - if (isMultipart && isBytesKeptRaw(program, variant.type)) { - schemaMembers.push({ schema: { type: "string", format: "binary" }, type: variant.type }); - continue; - } - - if (isLiteralType(variant.type)) { - if (!literalVariantEnumByType[variant.type.kind]) { - const enumValue: any[] = [variant.type.value]; - literalVariantEnumByType[variant.type.kind] = enumValue; - schemaMembers.push({ - schema: { type: literalType(variant.type), enum: enumValue }, - type: null, - }); - } else { - literalVariantEnumByType[variant.type.kind].push(variant.type.value); - } - } else { - const enumSchema = this.emitter.emitTypeReference(variant.type, { - referenceContext: isMultipart ? { contentType: "application/json" } : {}, - }); - compilerAssert(enumSchema.kind === "code", "Unexpected enum schema. Should be kind: code"); - schemaMembers.push({ schema: enumSchema.value, type: variant.type }); - } - } - - const wrapWithObjectBuilder = ( - schemaMember: { schema: any; type: Type | null }, - { mergeUnionWideConstraints }: { mergeUnionWideConstraints: boolean }, - ): ObjectBuilder => { - // we can just return the single schema member after applying nullable - const schema = schemaMember.schema; - const type = schemaMember.type; - const additionalProps: Partial = mergeUnionWideConstraints - ? this.#applyConstraints(union, {}) - : {}; - - if (mergeUnionWideConstraints && nullable) { - additionalProps.nullable = true; - } - - if (Object.keys(additionalProps).length === 0) { - return new ObjectBuilder(schema); - } else { - if ( - (schema instanceof Placeholder || "$ref" in schema) && - !(type && shouldInline(program, type)) - ) { - if (type && (type.kind === "Model" || type.kind === "Scalar")) { - return new ObjectBuilder({ - type: "object", - allOf: B.array([schema]), - ...additionalProps, - }); - } else { - return new ObjectBuilder({ allOf: B.array([schema]), ...additionalProps }); - } - } else { - const merged = new ObjectBuilder(schema); - for (const [key, value] of Object.entries(additionalProps)) { - merged.set(key, value); - } - return merged; - } - } - }; - - const checkMerge = (schemaMembers: { schema: any; type: Type | null }[]): boolean => { - if (nullable) { - for (const m of schemaMembers) { - if (m.schema instanceof Placeholder || "$ref" in m.schema) { - return true; - } - } - } - return false; - }; - - if (schemaMembers.length === 0) { - if (nullable) { - // This union is equivalent to just `null` but OA3 has no way to specify - // null as a value, so we throw an error. - reportDiagnostic(program, { code: "union-null", target: union }); - return new ObjectBuilder({}); - } else { - // completely empty union can maybe only happen with bugs? - compilerAssert(false, "Attempting to emit an empty union"); - } - } - - if (schemaMembers.length === 1) { - return wrapWithObjectBuilder(schemaMembers[0], { mergeUnionWideConstraints: true }); - } - - const isMerge = checkMerge(schemaMembers); - const schema: OpenAPI3Schema = { - [ofType]: schemaMembers.map((m) => - wrapWithObjectBuilder(m, { mergeUnionWideConstraints: isMerge }), - ), - }; - - if (!isMerge && nullable) { - schema.nullable = true; - } - - if (discriminator) { - // the decorator validates that all the variants will be a model type - // with the discriminator field present. - schema.discriminator = { ...discriminator }; - // Diagnostic already reported in compiler for unions - const discriminatedUnion = ignoreDiagnostics(getDiscriminatedUnion(union, discriminator)); - if (discriminatedUnion.variants.size > 0) { - schema.discriminator.mapping = this.#getDiscriminatorMapping(discriminatedUnion); - } - } - - return this.#applyConstraints(union, schema); + unionSchema(union: Union): ObjectBuilder { + // Unions are handled differently between 3.x versions + // mostly due to how nullable properties are handled. + throw new Error("Method not implemented."); } - #getDiscriminatorMapping(union: DiscriminatedUnion) { + getDiscriminatorMapping(union: DiscriminatedUnion) { const mapping: Record | undefined = {}; for (const [key, model] of union.variants.entries()) { const ref = this.emitter.emitTypeReference(model); @@ -651,7 +497,7 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< } unionLiteral(union: Union): EmitterOutput { - return this.#unionSchema(union); + return this.unionSchema(union); } unionVariants(union: Union): EmitterOutput { @@ -670,16 +516,6 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< return this.modelPropertyLiteral(prop); } - #attachExtensions(program: Program, type: Type, emitObject: OpenAPI3Schema) { - // Attach any OpenAPI extensions - const extensions = getExtensions(program, type); - if (extensions) { - for (const key of extensions.keys()) { - emitObject[key] = extensions.get(key); - } - } - } - reference( targetDeclaration: Declaration>, pathUp: Scope>[], @@ -720,13 +556,15 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< return super.circularReference(target, scope, cycle); } - scalarDeclaration(scalar: Scalar, name: string): EmitterOutput { - const isStd = this.#isStdType(scalar); + scalarDeclaration(scalar: Scalar, name: string): EmitterOutput { + 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)); + return isStd + ? schema + : (this.#createDeclaration(scalar, baseName, new ObjectBuilder(schema)) as any); } scalarInstantiation( @@ -739,15 +577,18 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< tupleLiteral(tuple: Tuple): EmitterOutput> { return { type: "array", items: {} }; } - #getSchemaForScalar(scalar: Scalar): OpenAPI3Schema { - let result: OpenAPI3Schema = {}; - const isStd = this.#isStdType(scalar); + #getSchemaForScalar(scalar: Scalar): Schema { + let result: Schema = {} as Schema; + 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)); + const withDecorators = this.#applyEncoding( + scalar, + this.applyConstraints(scalar, result) as any, + ); 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; @@ -755,36 +596,31 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< return withDecorators; } - #getSchemaForStdScalars(scalar: Scalar & { name: IntrinsicScalarName }): OpenAPI3Schema { - return getSchemaForStdScalars(scalar, this.#options); + #getSchemaForStdScalars(scalar: Scalar & { name: IntrinsicScalarName }): Schema { + return getSchemaForStdScalars(scalar, this._options) as Schema; } - #applySchemaExamples( - type: Model | Scalar | Union | Enum | ModelProperty, - target: ObjectBuilder, - ) { - const program = this.emitter.getProgram(); - const examples = getExamples(program, type); - if (examples.length > 0) { - target.set("example", serializeValueAsJson(program, examples[0].value, type)); - } - } + applyCustomConstraints( + type: Scalar | Model | ModelProperty | Union | Enum, + target: Schema, + refSchema?: Schema, + ) {} - #applyConstraints( + applyConstraints( type: Scalar | Model | ModelProperty | Union | Enum, - original: OpenAPI3Schema, - refSchema?: OpenAPI3Schema, - ): ObjectBuilder { - const schema = new ObjectBuilder(original); + original: Schema, + refSchema?: Schema, + ): ObjectBuilder { + // Apply common constraints + const schema = new ObjectBuilder(original); const program = this.emitter.getProgram(); - const applyConstraint = (fn: (p: Program, t: Type) => any, key: keyof OpenAPI3Schema) => { + const applyConstraint = (fn: (p: Program, t: Type) => any, key: keyof CommonOpenAPI3Schema) => { const value = fn(program, type); if (value !== undefined) { schema[key] = value; } }; - this.#applySchemaExamples(type, schema); applyConstraint(getMinLength, "minLength"); applyConstraint(getMaxLength, "maxLength"); applyConstraint(getMinValue, "minimum"); @@ -793,25 +629,13 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< applyConstraint(getMinItems, "minItems"); applyConstraint(getMaxItems, "maxItems"); - const minValueExclusive = getMinValueExclusive(program, type); - if (minValueExclusive !== undefined) { - schema.minimum = minValueExclusive; - schema.exclusiveMinimum = true; - } - - const maxValueExclusive = getMaxValueExclusive(program, type); - if (maxValueExclusive !== undefined) { - schema.maximum = maxValueExclusive; - schema.exclusiveMaximum = true; - } - 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 (!this.#isStdType(type) || type.name !== "url") { + if (!isStdType(program, type) || type.name !== "url") { applyConstraint(getFormat, "format"); } @@ -822,16 +646,38 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< "deprecated", ); - if (this.#xmlModule) { + this.applyCustomConstraints(type, schema as any, refSchema); + + this.applyXml(type, schema as any, refSchema); + attachExtensions(program, type, schema); + + const values = getKnownValues(program, type as any); + if (values) { + return new ObjectBuilder({ + oneOf: [schema, this.enumSchema(values)], + }); + } + + return new ObjectBuilder(schema); + } + + applyXml( + type: Scalar | Model | ModelProperty | Union | Enum, + schema: Schema, + refSchema?: Schema, + ): void { + const program = this.emitter.getProgram(); + + if (this._xmlModule) { switch (type.kind) { case "Scalar": case "Model": - this.#xmlModule.attachXmlObjectForScalarOrModel(program, type, schema); + this._xmlModule.attachXmlObjectForScalarOrModel(program, type, schema); break; case "ModelProperty": - this.#xmlModule.attachXmlObjectForModelProperty( + this._xmlModule.attachXmlObjectForModelProperty( program, - this.#options, + this._options, type, schema, refSchema, @@ -839,21 +685,10 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< break; } } - - this.#attachExtensions(program, type, schema); - - const values = getKnownValues(program, type as any); - if (values) { - return new ObjectBuilder({ - oneOf: [schema, this.#enumSchema(values)], - }); - } - - return new ObjectBuilder(schema); } #inlineType(type: Type, schema: ObjectBuilder) { - if (this.#options.includeXTypeSpecName !== "never") { + if (this._options.includeXTypeSpecName !== "never") { schema.set("x-typespec-name", getTypeName(type, this.#typeNameOptions())); } return schema; @@ -876,7 +711,7 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< schema.set("title", title); } - const usage = this.#visibilityUsage.getUsage(type); + const usage = this._visibilityUsage.getUsage(type); const shouldAddSuffix = usage !== undefined && usage.size > 1; const visibility = this.#getVisibilityContext(); @@ -894,31 +729,16 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< return decl; } - #isStdType(type: Type): type is Scalar & { name: IntrinsicScalarName } { - return this.emitter.getProgram().checker.isStdType(type); - } - #applyEncoding( typespecType: Scalar | ModelProperty, - target: OpenAPI3Schema | Placeholder, - ): OpenAPI3Schema { - return applyEncoding(this.emitter.getProgram(), typespecType, target as any, this.#options); - } - - intrinsic(intrinsic: IntrinsicType, name: string): EmitterOutput { - switch (name) { - case "unknown": - return {}; - case "null": - return { nullable: true }; - } - - reportDiagnostic(this.emitter.getProgram(), { - code: "invalid-schema", - format: { type: name }, - target: intrinsic, - }); - return {}; + target: Schema | Placeholder, + ): Schema { + return applyEncoding( + this.emitter.getProgram(), + typespecType, + target as any, + this._options, + ) as Schema; } programContext(program: Program): Context { @@ -927,31 +747,7 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< } } -function isLiteralType(type: Type): type is StringLiteral | NumericLiteral | BooleanLiteral { - return type.kind === "Boolean" || type.kind === "String" || type.kind === "Number"; -} - -function literalType(type: StringLiteral | NumericLiteral | BooleanLiteral) { - switch (type.kind) { - case "String": - return "string"; - case "Number": - return "number"; - case "Boolean": - return "boolean"; - } -} - -function includeDerivedModel(model: Model): boolean { - return ( - !isTemplateDeclaration(model) && - (model.templateMapper?.args === undefined || - model.templateMapper.args?.length === 0 || - model.derivedModels.length > 0) - ); -} - -const B = { +export const Builders = { array: (items: T[]): ArrayBuilder => { const builder = new ArrayBuilder(); for (const item of items) { @@ -967,15 +763,3 @@ const B = { return builder; }, } as const; - -export function getDefaultValue( - program: Program, - defaultType: Value, - modelProperty: ModelProperty, -): any { - return serializeValueAsJson(program, defaultType, modelProperty); -} - -export function isBytesKeptRaw(program: Program, type: Type) { - return type.kind === "Scalar" && type.name === "bytes" && getEncode(program, type) === undefined; -} diff --git a/packages/openapi3/src/std-scalar-schemas.ts b/packages/openapi3/src/std-scalar-schemas.ts index 0e66bf05bd6..611875181fb 100644 --- a/packages/openapi3/src/std-scalar-schemas.ts +++ b/packages/openapi3/src/std-scalar-schemas.ts @@ -1,11 +1,11 @@ import type { IntrinsicScalarName, Scalar } from "@typespec/compiler"; import type { ResolvedOpenAPI3EmitterOptions } from "./openapi.js"; -import type { OpenAPI3Schema } from "./types.js"; +import type { OpenAPI3Schema, OpenAPISchema3_1 } from "./types.js"; export function getSchemaForStdScalars( scalar: Scalar & { name: IntrinsicScalarName }, options: ResolvedOpenAPI3EmitterOptions, -): OpenAPI3Schema { +): OpenAPI3Schema & OpenAPISchema3_1 { switch (scalar.name) { case "bytes": return { type: "string", format: "byte" }; diff --git a/packages/openapi3/src/types.ts b/packages/openapi3/src/types.ts index 239fbbd6c5a..ab7be58ffe0 100644 --- a/packages/openapi3/src/types.ts +++ b/packages/openapi3/src/types.ts @@ -1,6 +1,10 @@ import { Diagnostic, Service } from "@typespec/compiler"; import { Contact, ExtensionKey, License } from "@typespec/openapi"; +export type CommonOpenAPI3Schema = OpenAPI3Schema & OpenAPISchema3_1; + +export type SupportedOpenAPIDocuments = OpenAPI3Document | OpenAPIDocument3_1; + export type Extensions = { [key in ExtensionKey]?: any; }; @@ -56,7 +60,7 @@ export interface OpenAPI3UnversionedServiceRecord { readonly versioned: false; /** The OpenAPI 3 document */ - readonly document: OpenAPI3Document; + readonly document: SupportedOpenAPIDocuments; /** The diagnostics created for this document */ readonly diagnostics: readonly Diagnostic[]; @@ -79,7 +83,7 @@ export interface OpenAPI3VersionedServiceRecord { export interface OpenAPI3VersionedDocumentRecord { /** The OpenAPI document*/ - readonly document: OpenAPI3Document; + readonly document: SupportedOpenAPIDocuments; /** The service that generated this OpenAPI document. */ readonly service: Service; @@ -695,3 +699,391 @@ export interface Ref { } export type Refable = Ref | T; + +export type JsonSchemaType = + | "array" + | "boolean" + | "integer" + | "null" + | "number" + | "object" + | "string"; + +export type JsonSchema = AdditionalVocabularies & { + /** + * The JSON type for the schema. + * If the value is an array, then an instance validates successfully if its type + * matches any of the types indicated by the string in the array. + * + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-type + */ + type?: JsonSchemaType | JsonSchemaType[]; + + /** + * The extending format for the previously mentioned type. + */ + format?: string; + + /** + * This provides an enumeration of all possible values that are valid + * for the instance property. This MUST be an array, and each item in + * the array represents a possible value for the instance value. If + * this attribute is defined, the instance value MUST be one of the + * values in the array in order for the schema to be valid. + * + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-enum + */ + enum?: (string | number | boolean | null)[]; + + /** + * Functionally equivalent to "enum" with a single value. + * An instance validates successfully if its value is equal to the value of this keyword. + * + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-const + */ + const?: unknown; + + /** + * Must be strictly greater than 0. + * A numeric instance is valid only if division by this keyword's value results in an integer. + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-multipleof + */ + multipleOf?: number; + + /** + * Representing an inclusive upper limit for a numeric instance. + * This keyword validates only if the instance is less than or exactly equal to "maximum". + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-maximum + */ + maximum?: number; + + /** + * Representing an exclusive upper limit for a numeric instance. + * This keyword validates only if the instance is strictly less than (not equal to) to "exclusiveMaximum". + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-exclusivemaximum + */ + exclusiveMaximum?: number; + + /** + * Representing an inclusive lower limit for a numeric instance. + * This keyword validates only if the instance is greater than or exactly equal to "minimum". + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-minimum + */ + minimum?: number; + + /** + * Representing an exclusive lower limit for a numeric instance. + * This keyword validates only if the instance is strictly greater than (not equal to) to "exclusiveMinimum". + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-exclusiveminimum + */ + exclusiveMinimum?: number; + + /** + * Must be a non-negative integer. + * A string instance is valid against this keyword if its length is less than, or equal to, the value of this keyword. + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-maxlength + */ + maxLength?: number; + + /** + * Must be a non-negative integer. + * A string instance is valid against this keyword if its length is greater than, or equal to, the value of this keyword. + * Omitting this keyword has the same behavior as a value of 0. + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-minlength + */ + minLength?: number; + + /** + * Should be a valid regular expression, according to the ECMA 262 regular expression dialect. + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-pattern + */ + pattern?: string; + + /** + * Must be a non-negative integer. + * An array instance is valid against "maxItems" if its size is less than, or equal to, the value of this keyword. + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-maxitems + */ + maxItems?: number; + + /** + * Must be a non-negative integer. + * An array instance is valid against "maxItems" if its size is greater than, or equal to, the value of this keyword. + * Omitting this keyword has the same behavior as a value of 0. + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-minitems + */ + minItems?: number; + + /** + * If this keyword has boolean value false, the instance validates successfully. + * If it has boolean value true, the instance validates successfully if all of its elements are unique. + * Omitting this keyword has the same behavior as a value of false. + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-uniqueitems + */ + uniqueItems?: boolean; + + /** + * Must be a non-negative integer. + * If "contains" is not present within the same schema object, then this has no effect. + * An array instance is valid against "maxContains" if the number of elements that are' + * valid against the schema defined by "contains" is less than, or equal to, the value of this keyword. + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-maxcontains + */ + maxContains?: number; + + /** + * Must be a non-negative integer. + * If "contains" is not present within the same schema object, then this has no effect. + * An array instance is valid against "minContains" if the number of elements that are' + * valid against the schema defined by "contains" is greater than, or equal to, the value of this keyword. + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-maxcontains + */ + minContains?: number; + + /** + * Must be a non-negative integer. + * An object instance is valid against "maxProperties" if its number of properties is less than, or equal to, the value of this keyword. + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-maxproperties + */ + maxProperties?: number; + + /** + * Must be a non-negative integer. + * An object instance is valid against "maxProperties" if its number of properties is greater than, + * or equal to, the value of this keyword. + * Omitting this keyword has the same behavior as a value of 0. + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-minproperties + */ + minProperties?: number; + + /** + * Elements of this array must be unique. + * An object instance is valid against this keyword if every item in the array is the name of a property in the instance. + * Omitting this keyword has the same behavior as an empty array. + * + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-required + */ + required?: Array; + + /** + * This attribute is a string that provides a short description of the instance property. + * + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-title-and-description + */ + title?: string; + + /** + * This attribute is a string that provides a full description of the schema + * + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-title-and-description + */ + description?: string; + + /** + * Declares the value of the property that the server will use if none is provided, + * for example a "count" to control the number of results per page might default to 100 if not supplied by the client in the request. + * + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-default + */ + default?: string | boolean | number | null | Record; + + /** + * Specifies that a schema is deprecated and SHOULD be transitioned out of usage. + * Default value is false. + * + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-deprecated + */ + deprecated?: boolean; + + /** + * An array of free-form properties to include examples of an instance for this schema. + * To represent examples that cannot be naturally represented in JSON or YAML, a string value can be used to contain the example with escaping where necessary. + * + * @see https://json-schema.org/draft/2020-12/json-schema-validation#name-examples + */ + examples?: any[]; + + /** + * A collection of schemas that this schema also must conform to. + * + * An instance validates successfully against this keyword if it validates successfully against all schemas defined by this keyword's value. + * + * @see https://json-schema.org/draft/2020-12/json-schema-core#name-allof + */ + allOf?: Refable>[]; + + /** + * A collection of schemas that this schema may conform to one or more of. + * + * An instance validates successfully against this keyword if it validates successfully against at least one schema defined by this keyword's value. + * + * @see https://json-schema.org/draft/2020-12/json-schema-core#name-anyof + */ + anyOf?: Refable>[]; + + /** + * A collection of schemas that this schema may conform to only one of. + * + * An instance validates successfully against this keyword if it validates successfully against exactly one schema defined by this keyword's value. + * + * @see https://json-schema.org/draft/2020-12/json-schema-core#name-oneof + */ + oneOf?: Refable>[]; + + /** + * An instance is valid against this keyword if it fails to validate successfully against the schema defined by this keyword. + * @see https://json-schema.org/draft/2020-12/json-schema-core#name-not + */ + not?: Refable>; + + /** + * Validation succeeds if each element of the instance validates against the schema at the same position, if any. + * This keyword does not constrain the length of the array. + * If the array is longer than this keyword's value, this keyword validates only the prefix of matching length. + * + * @see https://json-schema.org/draft/2020-12/json-schema-core#name-prefixitems + */ + prefixItems?: Refable>[]; + + /** + * This keyword applies its subschema to all instance elements at indexes greater than the lenfth of the "prefixItems" array. + * + * @see https://json-schema.org/draft/2020-12/json-schema-core#name-items + * */ + items?: Refable>; + + /** + * An array instance validates successfully against this keyword if at least one of its elements is valid against the given schema, + * except when `minContains` is present and has a value of 0. + * + * @see https://json-schema.org/draft/2020-12/json-schema-core#name-contains + */ + contains?: Refable>; + + /** + * This attribute is an object with property definitions that define the + * valid values of instance object property values. When the instance + * value is an object, the property values of the instance object MUST + * conform to the property definitions in this object. In this object, + * each property definition's value MUST be a schema, and the property's + * name MUST be the name of the instance property that it defines. The + * instance property value MUST be valid according to the schema from + * the property definition. Properties are considered unordered, the + * order of the instance properties MAY be in any order. + * + * @see https://json-schema.org/draft/2020-12/json-schema-core#name-properties + */ + properties?: Record< + string, + Refable> | JsonSchema + >; + + /** + * indicates that additional unlisted properties can exist in this schema + * + * @see https://json-schema.org/draft/2020-12/json-schema-core#name-additionalproperties + */ + additionalProperties?: boolean | Refable>; + + /** + * Property is readonly. + */ + readOnly?: boolean; + + /** Adds support for polymorphism. The discriminator is an object name that is used to differentiate between other schemas which may satisfy the payload description */ + discriminator?: OpenAPI3Discriminator; + + /** Additional external documentation for this schema. */ + externalDocs?: OpenAPI3ExternalDocs; +}; + +export type OpenAPISchema3_1 = JsonSchema< + { + /** + * Adds support for polymorphism. + * The discriminator is an object name that is used to differentiate between other schemas which may satisfy the payload description + * + * @see https://spec.openapis.org/oas/v3.1.1.html#discriminator-object + */ + discriminator?: OpenAPI3Discriminator; + + /** + * Additional external documentation for this schema. + * + * @see https://spec.openapis.org/oas/v3.1.1.html#external-documentation-object + */ + externalDocs?: OpenAPI3ExternalDocs; + + /** + * This MAY be used only on properties schemas. + * It has no effect on root schemas. + * Adds additional metadata to describe the XML representation of this property. + */ + xml?: OpenAPI3XmlSchema; + } & Extensions +>; + +export interface OpenAPIDocument3_1 extends Extensions { + openapi: "3.1.0"; + + /** + * Provides metadata about the API. The metadata can be used by the clients if needed. + */ + info: OpenAPI3Info; + + /** + * The default value for the $schema keyword within Schema Objects contained within this document. + * This MUST be in the form of a URI. + */ + jsonSchemaDialect?: string; + + /** Additional external documentation. */ + externalDocs?: OpenAPI3ExternalDocs; + + /** The available paths and operations for the API */ + paths: Record; + + /** + * The incoming webhooks that may be received as part of this API. + * The key name is a unique string to refer to each webhook. + */ + webhooks?: Record; + + /** + * An array of Server Objects, which provide connectivity information to a target server. + * If the servers property is not provided, or is an empty array, the default value would be a Server Object with a url value of /. + */ + servers?: OpenAPI3Server[]; + + /** + * A list of tags used by the specification with additional metadata. + * The order of the tags can be used to reflect on their order by the parsing tools. + * Not all tags that are used by the Operation Object must be declared. + * The tags that are not declared MAY be organized randomly or based on the tools' logic. Each tag name in the list MUST be unique. + */ + tags?: OpenAPI3Tag[]; + + /** An element to hold various schemas for the specification. */ + components?: OpenAPIComponents3_1; + + /** + * A declaration of which security mechanisms can be used across the API. + * The list of values includes alternative security requirement objects that can be used. + * Only one of the security requirement objects need to be satisfied to authorize a request. + * Individual operations can override this definition. + */ + security?: Record[]; +} + +export interface OpenAPIComponents3_1 extends Extensions { + schemas?: Record; + responses?: Record>; + parameters?: Record>; + examples?: Record>; + requestBodies?: Record>; + headers?: Record>; + securitySchemes?: Record>; + links?: Record>; + callbacks?: Record>; + pathItems?: Record; +} diff --git a/packages/openapi3/src/util.ts b/packages/openapi3/src/util.ts index d4c02ceae35..485e158b490 100644 --- a/packages/openapi3/src/util.ts +++ b/packages/openapi3/src/util.ts @@ -1,5 +1,19 @@ +import { + BooleanLiteral, + getEncode, + IntrinsicScalarName, + isTemplateDeclaration, + Model, + ModelProperty, + NumericLiteral, + Program, + Scalar, + serializeValueAsJson, + StringLiteral, + Type, + Value, +} from "@typespec/compiler"; import { HttpOperation } from "@typespec/http"; - /** * Checks if two objects are deeply equal. * @@ -87,3 +101,46 @@ export function isSharedHttpOperation( ): operation is SharedHttpOperation { return (operation as SharedHttpOperation).kind === "shared"; } + +export function isStdType( + program: Program, + type: Type, +): type is Scalar & { name: IntrinsicScalarName } { + return program.checker.isStdType(type); +} + +export function isLiteralType(type: Type): type is StringLiteral | NumericLiteral | BooleanLiteral { + return type.kind === "Boolean" || type.kind === "String" || type.kind === "Number"; +} + +export function literalType(type: StringLiteral | NumericLiteral | BooleanLiteral) { + switch (type.kind) { + case "String": + return "string"; + case "Number": + return "number"; + case "Boolean": + return "boolean"; + } +} + +export function includeDerivedModel(model: Model): boolean { + return ( + !isTemplateDeclaration(model) && + (model.templateMapper?.args === undefined || + model.templateMapper.args?.length === 0 || + model.derivedModels.length > 0) + ); +} + +export function getDefaultValue( + program: Program, + defaultType: Value, + modelProperty: ModelProperty, +): any { + return serializeValueAsJson(program, defaultType, modelProperty); +} + +export function isBytesKeptRaw(program: Program, type: Type) { + return type.kind === "Scalar" && type.name === "bytes" && getEncode(program, type) === undefined; +} diff --git a/packages/openapi3/src/xml-module.ts b/packages/openapi3/src/xml-module.ts index ac9c4e5e197..03c50fb5862 100644 --- a/packages/openapi3/src/xml-module.ts +++ b/packages/openapi3/src/xml-module.ts @@ -12,20 +12,20 @@ import { ArrayBuilder, ObjectBuilder } from "@typespec/compiler/emitter-framewor import { reportDiagnostic } from "./lib.js"; import { ResolvedOpenAPI3EmitterOptions } from "./openapi.js"; import { getSchemaForStdScalars } from "./std-scalar-schemas.js"; -import { OpenAPI3Schema, OpenAPI3XmlSchema } from "./types.js"; +import { OpenAPI3Schema, OpenAPI3XmlSchema, OpenAPISchema3_1 } from "./types.js"; export interface XmlModule { attachXmlObjectForScalarOrModel( program: Program, type: Scalar | Model, - emitObject: OpenAPI3Schema, + emitObject: OpenAPI3Schema | OpenAPISchema3_1, ): void; attachXmlObjectForModelProperty( program: Program, options: ResolvedOpenAPI3EmitterOptions, prop: ModelProperty, - emitObject: OpenAPI3Schema, + emitObject: OpenAPI3Schema | OpenAPISchema3_1, ref?: Record, ): void; } @@ -38,7 +38,7 @@ export async function resolveXmlModule(): Promise { attachXmlObjectForScalarOrModel: ( program: Program, type: Scalar | Model, - emitObject: OpenAPI3Schema, + emitObject: OpenAPI3Schema | OpenAPISchema3_1, ) => { const isXmlModel = isXmlModelChecker(program, type, []); if (!isXmlModel) { diff --git a/packages/openapi3/test/additional-properties.test.ts b/packages/openapi3/test/additional-properties.test.ts index 0fd065f5d94..52d3662382f 100644 --- a/packages/openapi3/test/additional-properties.test.ts +++ b/packages/openapi3/test/additional-properties.test.ts @@ -1,8 +1,8 @@ import { deepStrictEqual, ok } from "assert"; import { describe, it } from "vitest"; -import { oapiForModel } from "./test-host.js"; +import { worksFor } from "./works-for.js"; -describe("openapi3: Additional properties", () => { +worksFor(["3.0.0", "3.1.0"], ({ oapiForModel }) => { describe("extends Record", () => { it("doesn't set additionalProperties on model itself", async () => { const res = await oapiForModel("Pet", `model Pet extends Record {};`); diff --git a/packages/openapi3/test/array.test.ts b/packages/openapi3/test/array.test.ts index 8ce34be1bde..eb04eeb8715 100644 --- a/packages/openapi3/test/array.test.ts +++ b/packages/openapi3/test/array.test.ts @@ -1,8 +1,8 @@ import { deepStrictEqual, ok, strictEqual } from "assert"; -import { describe, it } from "vitest"; -import { oapiForModel, openApiFor } from "./test-host.js"; +import { it } from "vitest"; +import { worksFor } from "./works-for.js"; -describe("openapi3: Array", () => { +worksFor(["3.0.0", "3.1.0"], ({ oapiForModel, openApiFor }) => { it("defines array inline", async () => { const res = await oapiForModel( "Pet", diff --git a/packages/openapi3/test/circular-references.test.ts b/packages/openapi3/test/circular-references.test.ts index aa2ded70a41..b64934437f6 100644 --- a/packages/openapi3/test/circular-references.test.ts +++ b/packages/openapi3/test/circular-references.test.ts @@ -1,8 +1,8 @@ import { deepStrictEqual } from "assert"; -import { describe, it } from "vitest"; -import { oapiForModel } from "./test-host.js"; +import { it } from "vitest"; +import { worksFor } from "./works-for.js"; -describe("openapi3: circular reference", () => { +worksFor(["3.0.0", "3.1.0"], ({ oapiForModel }) => { it("can reference itself via a property", async () => { const res = await oapiForModel( "Pet", diff --git a/packages/openapi3/test/discriminator.test.ts b/packages/openapi3/test/discriminator.test.ts index 88192cd26f0..e6629d155e9 100644 --- a/packages/openapi3/test/discriminator.test.ts +++ b/packages/openapi3/test/discriminator.test.ts @@ -1,9 +1,9 @@ import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual, ok } from "assert"; -import { describe, it } from "vitest"; -import { checkFor, openApiFor } from "./test-host.js"; +import { it } from "vitest"; +import { worksFor } from "./works-for.js"; -describe("openapi3: polymorphic model inheritance with discriminator", () => { +worksFor(["3.0.0", "3.1.0"], ({ checkFor, openApiFor }) => { it("discriminator can be simple literals", async () => { const openApi = await openApiFor(` @discriminator("kind") diff --git a/packages/openapi3/test/documentation.test.ts b/packages/openapi3/test/documentation.test.ts index 87292fc8c7e..61213d28d2d 100644 --- a/packages/openapi3/test/documentation.test.ts +++ b/packages/openapi3/test/documentation.test.ts @@ -1,8 +1,8 @@ import { deepStrictEqual, strictEqual } from "assert"; -import { describe, it } from "vitest"; -import { openApiFor } from "./test-host.js"; +import { it } from "vitest"; +import { worksFor } from "./works-for.js"; -describe("openapi3: documentation", () => { +worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { it("supports summary and description", async () => { const openApi = await openApiFor(` @summary("This is a summary") diff --git a/packages/openapi3/test/examples.test.ts b/packages/openapi3/test/examples.test.ts index 064e2da47da..25c90a36b27 100644 --- a/packages/openapi3/test/examples.test.ts +++ b/packages/openapi3/test/examples.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { OpenAPI3Document } from "../src/types.js"; import { openApiFor } from "./test-host.js"; +import { worksFor } from "./works-for.js"; describe("schema examples", () => { it("apply example on model", async () => { @@ -52,7 +53,7 @@ describe("schema examples", () => { }); }); -describe("operation examples", () => { +worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { it("set example on the request body", async () => { const res: OpenAPI3Document = await openApiFor( ` diff --git a/packages/openapi3/test/info.test.ts b/packages/openapi3/test/info.test.ts index 174f99a45c2..2f03239a738 100644 --- a/packages/openapi3/test/info.test.ts +++ b/packages/openapi3/test/info.test.ts @@ -1,8 +1,8 @@ import { deepStrictEqual, strictEqual } from "assert"; -import { describe, it } from "vitest"; -import { openApiFor } from "./test-host.js"; +import { it } from "vitest"; +import { worksFor } from "./works-for.js"; -describe("openapi3: info", () => { +worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { it("set the service title with @service", async () => { const res = await openApiFor( ` diff --git a/packages/openapi3/test/metadata.test.ts b/packages/openapi3/test/metadata.test.ts index de877882cb0..9a49956ab06 100644 --- a/packages/openapi3/test/metadata.test.ts +++ b/packages/openapi3/test/metadata.test.ts @@ -1,8 +1,8 @@ import { deepStrictEqual } from "assert"; -import { describe, it } from "vitest"; -import { openApiFor } from "./test-host.js"; +import { it } from "vitest"; +import { worksFor } from "./works-for.js"; -describe("openapi3: metadata", () => { +worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { it("will expose all properties on unreferenced models but filter properties on referenced models", async () => { const res = await openApiFor(` model M { diff --git a/packages/openapi3/test/models.test.ts b/packages/openapi3/test/models.test.ts index 70c198105fe..441add9f716 100644 --- a/packages/openapi3/test/models.test.ts +++ b/packages/openapi3/test/models.test.ts @@ -1,9 +1,9 @@ import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual, ok, strictEqual } from "assert"; import { describe, expect, it } from "vitest"; -import { diagnoseOpenApiFor, oapiForModel, openApiFor } from "./test-host.js"; +import { worksFor } from "./works-for.js"; -describe("openapi3: models", () => { +worksFor(["3.0.0", "3.1.0"], ({ diagnoseOpenApiFor, oapiForModel, openApiFor }) => { it("defines models", async () => { const res = await oapiForModel( "Foo", @@ -236,29 +236,6 @@ describe("openapi3: models", () => { }); }); - it("specify default value on nullable property", async () => { - const res = await oapiForModel( - "Foo", - ` - model Foo { - optional?: string | null = null; - }; - `, - ); - - ok(res.schemas.Foo, "expected definition named Foo"); - deepStrictEqual(res.schemas.Foo, { - type: "object", - properties: { - optional: { - type: "string", - nullable: true, - default: null, - }, - }, - }); - }); - it("scalar used as a default value", async () => { const res = await oapiForModel( "Pet", @@ -614,54 +591,6 @@ describe("openapi3: models", () => { }); }); - it("defines nullable properties", async () => { - const res = await oapiForModel( - "Pet", - ` - model Pet { - name: string | null; - }; - `, - ); - ok(res.isRef); - deepStrictEqual(res.schemas.Pet, { - type: "object", - properties: { - name: { - type: "string", - nullable: true, - }, - }, - required: ["name"], - }); - }); - - it("defines nullable array", async () => { - const res = await oapiForModel( - "Pet", - ` - model Pet { - name: int32[] | null; - }; - `, - ); - ok(res.isRef); - deepStrictEqual(res.schemas.Pet, { - type: "object", - properties: { - name: { - type: "array", - items: { - type: "integer", - format: "int32", - }, - nullable: true, - }, - }, - required: ["name"], - }); - }); - it("defines request bodies as unions of models", async () => { const openApi = await openApiFor(` model Cat { @@ -1078,3 +1007,154 @@ describe("openapi3: models", () => { }); }); }); + +worksFor(["3.0.0"], ({ oapiForModel }) => { + it("specify default value on nullable property", async () => { + const res = await oapiForModel( + "Foo", + ` + model Foo { + optional?: string | null = null; + }; + `, + ); + + ok(res.schemas.Foo, "expected definition named Foo"); + deepStrictEqual(res.schemas.Foo, { + type: "object", + properties: { + optional: { + type: "string", + nullable: true, + default: null, + }, + }, + }); + }); + + it("defines nullable properties", async () => { + const res = await oapiForModel( + "Pet", + ` + model Pet { + name: string | null; + }; + `, + ); + ok(res.isRef); + deepStrictEqual(res.schemas.Pet, { + type: "object", + properties: { + name: { + type: "string", + nullable: true, + }, + }, + required: ["name"], + }); + }); + + it("defines nullable array", async () => { + const res = await oapiForModel( + "Pet", + ` + model Pet { + name: int32[] | null; + }; + `, + ); + ok(res.isRef); + deepStrictEqual(res.schemas.Pet, { + type: "object", + properties: { + name: { + type: "array", + items: { + type: "integer", + format: "int32", + }, + nullable: true, + }, + }, + required: ["name"], + }); + }); +}); + +worksFor(["3.1.0"], ({ oapiForModel }) => { + // eslint-disable-next-line vitest/no-identical-title + it("specify default value on nullable property", async () => { + const res = await oapiForModel( + "Foo", + ` + model Foo { + optional?: string | null = null; + }; + `, + ); + + ok(res.schemas.Foo, "expected definition named Foo"); + deepStrictEqual(res.schemas.Foo, { + type: "object", + properties: { + optional: { + anyOf: [{ type: "string" }, { type: "null" }], + default: null, + }, + }, + }); + }); + + // eslint-disable-next-line vitest/no-identical-title + it("defines nullable properties", async () => { + const res = await oapiForModel( + "Pet", + ` + model Pet { + name: string | null; + }; + `, + ); + ok(res.isRef); + deepStrictEqual(res.schemas.Pet, { + type: "object", + properties: { + name: { + anyOf: [{ type: "string" }, { type: "null" }], + }, + }, + required: ["name"], + }); + }); + + // eslint-disable-next-line vitest/no-identical-title + it("defines nullable array", async () => { + const res = await oapiForModel( + "Pet", + ` + model Pet { + name: int32[] | null; + }; + `, + ); + ok(res.isRef); + deepStrictEqual(res.schemas.Pet, { + type: "object", + properties: { + name: { + anyOf: [ + { + type: "array", + items: { + type: "integer", + format: "int32", + }, + }, + { type: "null" }, + ], + }, + }, + required: ["name"], + }); + }); +}); diff --git a/packages/openapi3/test/multipart.test.ts b/packages/openapi3/test/multipart.test.ts index 344d7a3b1eb..d4b3051b70f 100644 --- a/packages/openapi3/test/multipart.test.ts +++ b/packages/openapi3/test/multipart.test.ts @@ -1,227 +1,20 @@ import { deepStrictEqual } from "assert"; import { describe, expect, it } from "vitest"; import { OpenAPI3Encoding, OpenAPI3Schema } from "../src/types.js"; -import { openApiFor } from "./test-host.js"; +import { worksFor } from "./works-for.js"; -it("create dedicated model for multipart", async () => { - const res = await openApiFor( - ` +worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { + it("create dedicated model for multipart", async () => { + const res = await openApiFor( + ` model Form { name: HttpPart, profileImage: HttpPart } op upload(@header contentType: "multipart/form-data", @multipartBody body: Form): void; `, - ); - const op = res.paths["/"].post; - deepStrictEqual(op.requestBody.content["multipart/form-data"], { - schema: { - $ref: "#/components/schemas/Form", - }, - }); -}); - -it("part of type `bytes` produce `type: string, format: binary`", async () => { - const res = await openApiFor( - ` - op upload(@header contentType: "multipart/form-data", @multipartBody body: { profileImage: HttpPart }): void; - `, - ); - const op = res.paths["/"].post; - deepStrictEqual(op.requestBody.content["multipart/form-data"], { - schema: { - type: "object", - properties: { - profileImage: { - format: "binary", - type: "string", - }, - }, - required: ["profileImage"], - }, - }); -}); - -it("part of type union `HttpPart` produce `type: string, format: binary`", async () => { - const res = await openApiFor( - ` - op upload(@header contentType: "multipart/form-data", @multipartBody _: {profileImage: HttpPart}): void; - `, - ); - const op = res.paths["/"].post; - deepStrictEqual(op.requestBody.content["multipart/form-data"], { - schema: { - type: "object", - properties: { - profileImage: { - anyOf: [ - { - type: "string", - format: "binary", - }, - { - type: "object", - properties: { - content: { type: "string", format: "byte" }, - }, - required: ["content"], - }, - ], - }, - }, - required: ["profileImage"], - }, - encoding: { - profileImage: { - contentType: "application/octet-stream, application/json", - }, - }, - }); -}); - -it("part of type `bytes[]` produce `type: array, items: {type: string, format: binary}`", async () => { - const res = await openApiFor( - ` - op upload(@header contentType: "multipart/form-data", @multipartBody _: { profileImage: HttpPart[]}): void; - `, - ); - const op = res.paths["/"].post; - deepStrictEqual(op.requestBody.content["multipart/form-data"], { - schema: { - type: "object", - properties: { - profileImage: { - type: "array", - items: { type: "string", format: "binary" }, - }, - }, - required: ["profileImage"], - }, - }); -}); - -it("part of type `string` produce `type: string`", async () => { - const res = await openApiFor( - ` - op upload(@header contentType: "multipart/form-data", @multipartBody _: { name: HttpPart }): void; - `, - ); - const op = res.paths["/"].post; - deepStrictEqual(op.requestBody.content["multipart/form-data"], { - schema: { - type: "object", - properties: { - name: { - type: "string", - }, - }, - required: ["name"], - }, - }); -}); - -it("part of type `object` produce an object", async () => { - const res = await openApiFor( - ` - op upload(@header contentType: "multipart/form-data", @multipartBody _: { address: HttpPart<{city: string, street: string}>}): void; - `, - ); - const op = res.paths["/"].post; - deepStrictEqual(op.requestBody.content["multipart/form-data"], { - schema: { - type: "object", - properties: { - address: { - type: "object", - properties: { - city: { - type: "string", - }, - street: { - type: "string", - }, - }, - required: ["city", "street"], - }, - }, - required: ["address"], - }, - }); -}); - -it("bytes inside a json part will be treated as base64 encoded by default(same as for a json body)", async () => { - const res = await openApiFor( - ` - op upload(@header contentType: "multipart/form-data", @multipartBody _: { address: HttpPart<{city: string, icon: bytes}> }): void; - `, - ); - const op = res.paths["/"].post; - deepStrictEqual( - op.requestBody.content["multipart/form-data"].schema.properties.address.properties.icon, - { - type: "string", - format: "byte", - }, - ); -}); - -describe("part mapping", () => { - it.each([ - [`string`, { type: "string" }], - [`bytes`, { type: "string", format: "binary" }], - [`string[]`, { type: "array", items: { type: "string" } }, { contentType: "application/json" }], - [ - `bytes[]`, - { type: "array", items: { type: "string", format: "byte" } }, - { contentType: "application/json" }, - ], - [ - `{@header contentType: "image/png", @body image: bytes}`, - { type: "string", format: "binary" }, - { contentType: "image/png" }, - ], - [`File`, { type: "string", format: "binary" }, { contentType: "*/*" }], - [ - `{@header extra: string, @body body: string}`, - { type: "string" }, - { - headers: { - extra: { - required: true, - schema: { - type: "string", - }, - }, - }, - }, - ], - ] satisfies [string, OpenAPI3Schema, OpenAPI3Encoding?][])( - `HttpPart<%s>`, - async (type: string, expectedSchema: OpenAPI3Schema, expectedEncoding?: OpenAPI3Encoding) => { - const res = await openApiFor( - ` - op upload(@header contentType: "multipart/form-data", @multipartBody _: { part: HttpPart<${type}> }): void; - `, - ); - const content = res.paths["/"].post.requestBody.content["multipart/form-data"]; - expect(content.schema.properties.part).toEqual(expectedSchema); - - if (expectedEncoding || content.encoding?.part) { - expect(content.encoding?.part).toEqual(expectedEncoding); - } - }, - ); -}); - -describe("legacy implicit form", () => { - it("add MultiPart suffix to model name", async () => { - const res = await openApiFor( - ` - model Form { @header contentType: "multipart/form-data", name: string, profileImage: bytes } - op upload(...Form): void; - `, ); const op = res.paths["/"].post; deepStrictEqual(op.requestBody.content["multipart/form-data"], { schema: { - $ref: "#/components/schemas/FormMultiPart", + $ref: "#/components/schemas/Form", }, }); }); @@ -229,8 +22,8 @@ describe("legacy implicit form", () => { it("part of type `bytes` produce `type: string, format: binary`", async () => { const res = await openApiFor( ` - op upload(@header contentType: "multipart/form-data", profileImage: bytes): void; - `, + op upload(@header contentType: "multipart/form-data", @multipartBody body: { profileImage: HttpPart }): void; + `, ); const op = res.paths["/"].post; deepStrictEqual(op.requestBody.content["multipart/form-data"], { @@ -247,11 +40,11 @@ describe("legacy implicit form", () => { }); }); - it("part of type union `bytes | {content: bytes}` produce `type: string, format: binary`", async () => { + it("part of type union `HttpPart` produce `type: string, format: binary`", async () => { const res = await openApiFor( ` - op upload(@header contentType: "multipart/form-data", profileImage: bytes | {content: bytes}): void; - `, + op upload(@header contentType: "multipart/form-data", @multipartBody _: {profileImage: HttpPart}): void; + `, ); const op = res.paths["/"].post; deepStrictEqual(op.requestBody.content["multipart/form-data"], { @@ -276,26 +69,10 @@ describe("legacy implicit form", () => { }, required: ["profileImage"], }, - }); - }); - - it("part of union type doesn't conflict if used in json as well`", async () => { - const res = await openApiFor( - ` - union MyUnion {string, int32} - op upload(@header contentType: "multipart/form-data", profileImage: MyUnion): void; - `, - ); - const op = res.paths["/"].post; - deepStrictEqual(op.requestBody.content["multipart/form-data"], { - schema: { - type: "object", - properties: { - profileImage: { - $ref: "#/components/schemas/MyUnion", - }, + encoding: { + profileImage: { + contentType: "application/octet-stream, application/json", }, - required: ["profileImage"], }, }); }); @@ -303,8 +80,8 @@ describe("legacy implicit form", () => { it("part of type `bytes[]` produce `type: array, items: {type: string, format: binary}`", async () => { const res = await openApiFor( ` - op upload(@header contentType: "multipart/form-data", profileImage: bytes[]): void; - `, + op upload(@header contentType: "multipart/form-data", @multipartBody _: { profileImage: HttpPart[]}): void; + `, ); const op = res.paths["/"].post; deepStrictEqual(op.requestBody.content["multipart/form-data"], { @@ -324,8 +101,8 @@ describe("legacy implicit form", () => { it("part of type `string` produce `type: string`", async () => { const res = await openApiFor( ` - op upload(@header contentType: "multipart/form-data", name: string): void; - `, + op upload(@header contentType: "multipart/form-data", @multipartBody _: { name: HttpPart }): void; + `, ); const op = res.paths["/"].post; deepStrictEqual(op.requestBody.content["multipart/form-data"], { @@ -344,8 +121,8 @@ describe("legacy implicit form", () => { it("part of type `object` produce an object", async () => { const res = await openApiFor( ` - op upload(@header contentType: "multipart/form-data", address: {city: string, street: string}): void; - `, + op upload(@header contentType: "multipart/form-data", @multipartBody _: { address: HttpPart<{city: string, street: string}>}): void; + `, ); const op = res.paths["/"].post; deepStrictEqual(op.requestBody.content["multipart/form-data"], { @@ -373,8 +150,8 @@ describe("legacy implicit form", () => { it("bytes inside a json part will be treated as base64 encoded by default(same as for a json body)", async () => { const res = await openApiFor( ` - op upload(@header contentType: "multipart/form-data", address: {city: string, icon: bytes}): void; - `, + op upload(@header contentType: "multipart/form-data", @multipartBody _: { address: HttpPart<{city: string, icon: bytes}> }): void; + `, ); const op = res.paths["/"].post; deepStrictEqual( @@ -386,9 +163,237 @@ describe("legacy implicit form", () => { ); }); - it("enum used in both a json payload and multipart part shouldn't create 2 models", async () => { - const res = await openApiFor( - ` + describe("part mapping", () => { + it.each([ + [`string`, { type: "string" }], + [`bytes`, { type: "string", format: "binary" }], + [ + `string[]`, + { type: "array", items: { type: "string" } }, + { contentType: "application/json" }, + ], + [ + `bytes[]`, + { type: "array", items: { type: "string", format: "byte" } }, + { contentType: "application/json" }, + ], + [ + `{@header contentType: "image/png", @body image: bytes}`, + { type: "string", format: "binary" }, + { contentType: "image/png" }, + ], + [`File`, { type: "string", format: "binary" }, { contentType: "*/*" }], + [ + `{@header extra: string, @body body: string}`, + { type: "string" }, + { + headers: { + extra: { + required: true, + schema: { + type: "string", + }, + }, + }, + }, + ], + ] satisfies [string, OpenAPI3Schema, OpenAPI3Encoding?][])( + `HttpPart<%s>`, + async (type: string, expectedSchema: OpenAPI3Schema, expectedEncoding?: OpenAPI3Encoding) => { + const res = await openApiFor( + ` + op upload(@header contentType: "multipart/form-data", @multipartBody _: { part: HttpPart<${type}> }): void; + `, + ); + const content = res.paths["/"].post.requestBody.content["multipart/form-data"]; + expect(content.schema.properties.part).toEqual(expectedSchema); + + if (expectedEncoding || content.encoding?.part) { + expect(content.encoding?.part).toEqual(expectedEncoding); + } + }, + ); + }); + + describe("legacy implicit form", () => { + it("add MultiPart suffix to model name", async () => { + const res = await openApiFor( + ` + model Form { @header contentType: "multipart/form-data", name: string, profileImage: bytes } + op upload(...Form): void; + `, + ); + const op = res.paths["/"].post; + deepStrictEqual(op.requestBody.content["multipart/form-data"], { + schema: { + $ref: "#/components/schemas/FormMultiPart", + }, + }); + }); + + it("part of type `bytes` produce `type: string, format: binary`", async () => { + const res = await openApiFor( + ` + op upload(@header contentType: "multipart/form-data", profileImage: bytes): void; + `, + ); + const op = res.paths["/"].post; + deepStrictEqual(op.requestBody.content["multipart/form-data"], { + schema: { + type: "object", + properties: { + profileImage: { + format: "binary", + type: "string", + }, + }, + required: ["profileImage"], + }, + }); + }); + + it("part of type union `bytes | {content: bytes}` produce `type: string, format: binary`", async () => { + const res = await openApiFor( + ` + op upload(@header contentType: "multipart/form-data", profileImage: bytes | {content: bytes}): void; + `, + ); + const op = res.paths["/"].post; + deepStrictEqual(op.requestBody.content["multipart/form-data"], { + schema: { + type: "object", + properties: { + profileImage: { + anyOf: [ + { + type: "string", + format: "binary", + }, + { + type: "object", + properties: { + content: { type: "string", format: "byte" }, + }, + required: ["content"], + }, + ], + }, + }, + required: ["profileImage"], + }, + }); + }); + + it("part of union type doesn't conflict if used in json as well`", async () => { + const res = await openApiFor( + ` + union MyUnion {string, int32} + op upload(@header contentType: "multipart/form-data", profileImage: MyUnion): void; + `, + ); + const op = res.paths["/"].post; + deepStrictEqual(op.requestBody.content["multipart/form-data"], { + schema: { + type: "object", + properties: { + profileImage: { + $ref: "#/components/schemas/MyUnion", + }, + }, + required: ["profileImage"], + }, + }); + }); + + it("part of type `bytes[]` produce `type: array, items: {type: string, format: binary}`", async () => { + const res = await openApiFor( + ` + op upload(@header contentType: "multipart/form-data", profileImage: bytes[]): void; + `, + ); + const op = res.paths["/"].post; + deepStrictEqual(op.requestBody.content["multipart/form-data"], { + schema: { + type: "object", + properties: { + profileImage: { + type: "array", + items: { type: "string", format: "binary" }, + }, + }, + required: ["profileImage"], + }, + }); + }); + + it("part of type `string` produce `type: string`", async () => { + const res = await openApiFor( + ` + op upload(@header contentType: "multipart/form-data", name: string): void; + `, + ); + const op = res.paths["/"].post; + deepStrictEqual(op.requestBody.content["multipart/form-data"], { + schema: { + type: "object", + properties: { + name: { + type: "string", + }, + }, + required: ["name"], + }, + }); + }); + + it("part of type `object` produce an object", async () => { + const res = await openApiFor( + ` + op upload(@header contentType: "multipart/form-data", address: {city: string, street: string}): void; + `, + ); + const op = res.paths["/"].post; + deepStrictEqual(op.requestBody.content["multipart/form-data"], { + schema: { + type: "object", + properties: { + address: { + type: "object", + properties: { + city: { + type: "string", + }, + street: { + type: "string", + }, + }, + required: ["city", "street"], + }, + }, + required: ["address"], + }, + }); + }); + + it("bytes inside a json part will be treated as base64 encoded by default(same as for a json body)", async () => { + const res = await openApiFor( + ` + op upload(@header contentType: "multipart/form-data", address: {city: string, icon: bytes}): void; + `, + ); + const op = res.paths["/"].post; + deepStrictEqual( + op.requestBody.content["multipart/form-data"].schema.properties.address.properties.icon, + { + type: "string", + format: "byte", + }, + ); + }); + + it("enum used in both a json payload and multipart part shouldn't create 2 models", async () => { + const res = await openApiFor( + ` enum FilePurpose { one, two, @@ -399,7 +404,11 @@ describe("legacy implicit form", () => { @post uploadFile(@header contentType: "multipart/form-data", purpose: FilePurpose): string; } `, - ); - deepStrictEqual(res.components.schemas.FilePurpose, { type: "string", enum: ["one", "two"] }); + ); + deepStrictEqual(res.components.schemas.FilePurpose, { + type: "string", + enum: ["one", "two"], + }); + }); }); }); diff --git a/packages/openapi3/test/no-service-found.test.ts b/packages/openapi3/test/no-service-found.test.ts index 7bba50e69db..0b1e8607523 100644 --- a/packages/openapi3/test/no-service-found.test.ts +++ b/packages/openapi3/test/no-service-found.test.ts @@ -1,8 +1,8 @@ import { expectDiagnosticEmpty, expectDiagnostics } from "@typespec/compiler/testing"; -import { describe, it } from "vitest"; -import { diagnoseOpenApiFor } from "./test-host.js"; +import { it } from "vitest"; +import { worksFor } from "./works-for.js"; -describe("openapi3: no-service-found diagnostic", () => { +worksFor(["3.0.0", "3.1.0"], ({ diagnoseOpenApiFor }) => { it("does not emit warning if a non-service namespace has no routes", async () => { const diagnostics = await diagnoseOpenApiFor( ` diff --git a/packages/openapi3/test/nullable-properties.test.ts b/packages/openapi3/test/nullable-properties.test.ts index e5dc42ed121..6ce8ab114b2 100644 --- a/packages/openapi3/test/nullable-properties.test.ts +++ b/packages/openapi3/test/nullable-properties.test.ts @@ -1,9 +1,9 @@ import { deepStrictEqual, ok } from "assert"; import { describe, expect, it } from "vitest"; -import { OpenAPI3SchemaProperty } from "../src/types.js"; -import { oapiForModel, openApiFor } from "./test-host.js"; +import { OpenAPI3SchemaProperty, OpenAPISchema3_1, Refable } from "../src/types.js"; +import { worksFor } from "./works-for.js"; -describe("openapi3: nullable properties", () => { +worksFor(["3.0.0"], ({ oapiForModel, openApiFor }) => { it("makes nullable schema when union with null", async () => { const res = await openApiFor( ` @@ -72,3 +72,70 @@ describe("openapi3: nullable properties", () => { }); }); }); + +worksFor(["3.1.0"], ({ oapiForModel, openApiFor }) => { + // eslint-disable-next-line vitest/no-identical-title + it("makes nullable schema when union with null", async () => { + const res = await openApiFor( + ` + model Thing { + id: string; + properties: Thing | null; + } + op doStuff(): Thing; + `, + ); + deepStrictEqual(res.components.schemas.Thing.properties.properties, { + anyOf: [{ $ref: "#/components/schemas/Thing" }, { type: "null" }], + }); + }); + + // eslint-disable-next-line vitest/no-identical-title + it("handles a nullable enum", async () => { + const res = await oapiForModel( + "X", + ` + enum A { + a: 1 + } + + model X { + prop: A | null + } + `, + ); + deepStrictEqual(res.schemas.X.properties.prop, { + anyOf: [{ $ref: "#/components/schemas/A" }, { type: "null" }], + }); + }); + + // eslint-disable-next-line vitest/no-identical-title + describe("when used in circular references", () => { + async function expectInCircularReference(ref: string, value: Refable) { + const res = await openApiFor( + ` + model Test { + children: ${ref} | null; + } + + op test(filters: ${ref}): {}[]; + `, + ); + expect(res.components.schemas.Test.properties.children).toEqual(value); + } + it("keep nullable array inline", async () => { + await expectInCircularReference("Test[]", { + anyOf: [{ type: "array", items: { $ref: "#/components/schemas/Test" } }, { type: "null" }], + }); + }); + + it("keep nullable Record inline", async () => { + await expectInCircularReference("Record", { + anyOf: [ + { type: "object", additionalProperties: { $ref: "#/components/schemas/Test" } }, + { type: "null" }, + ], + }); + }); + }); +}); diff --git a/packages/openapi3/test/openapi-output.test.ts b/packages/openapi3/test/openapi-output.test.ts index 4a5add228ca..68784de0193 100644 --- a/packages/openapi3/test/openapi-output.test.ts +++ b/packages/openapi3/test/openapi-output.test.ts @@ -1,134 +1,111 @@ -import { resolvePath } from "@typespec/compiler"; -import { expectDiagnosticEmpty } from "@typespec/compiler/testing"; import { deepStrictEqual, ok, strictEqual } from "assert"; import { describe, it } from "vitest"; -import { OpenAPI3EmitterOptions } from "../src/lib.js"; -import { OpenAPI3Document } from "../src/types.js"; -import { createOpenAPITestRunner, oapiForModel, openApiFor } from "./test-host.js"; - -async function openapiWithOptions( - code: string, - options: OpenAPI3EmitterOptions, -): Promise { - const runner = await createOpenAPITestRunner(); - - const outPath = resolvePath("/openapi.json"); - - const diagnostics = await runner.diagnose(code, { - noEmit: false, - emit: ["@typespec/openapi3"], - options: { "@typespec/openapi3": { ...options, "output-file": outPath } }, - }); - - expectDiagnosticEmpty(diagnostics); - - const content = runner.fs.get(outPath)!; - return JSON.parse(content); -} +import { worksFor } from "./works-for.js"; -describe("openapi3: types included", () => { - it("emit unreferenced types by default", async () => { - const output = await openapiWithOptions( - ` +worksFor(["3.0.0", "3.1.0"], ({ oapiForModel, openApiFor, openapiWithOptions }) => { + describe("openapi3: types included", () => { + it("emit unreferenced types by default", async () => { + const output = await openapiWithOptions( + ` model NotReferenced {name: string} model Referenced {name: string} op test(): Referenced; `, - {}, - ); - deepStrictEqual(Object.keys(output.components!.schemas!), ["NotReferenced", "Referenced"]); - }); + {}, + ); + deepStrictEqual(Object.keys(output.components!.schemas!), ["NotReferenced", "Referenced"]); + }); - it("emit only referenced types when using omit-unreachable-types", async () => { - const output = await openapiWithOptions( - ` + it("emit only referenced types when using omit-unreachable-types", async () => { + const output = await openapiWithOptions( + ` model NotReferenced {name: string} model Referenced {name: string} op test(): Referenced; `, - { - "omit-unreachable-types": true, - }, - ); - deepStrictEqual(Object.keys(output.components!.schemas!), ["Referenced"]); + { + "omit-unreachable-types": true, + }, + ); + deepStrictEqual(Object.keys(output.components!.schemas!), ["Referenced"]); + }); }); -}); -describe("openapi3: x-typespec-name", () => { - it("doesn't include x-typespec-name by default", async () => { - const output = await openapiWithOptions( - ` + describe("openapi3: x-typespec-name", () => { + it("doesn't include x-typespec-name by default", async () => { + const output = await openapiWithOptions( + ` model Foo {names: string[]} `, - {}, - ); - ok(!("x-typespec-name" in output.components!.schemas!.Foo.properties!.names)); - }); + {}, + ); + ok(!("x-typespec-name" in output.components!.schemas!.Foo.properties!.names)); + }); - it(`doesn't include x-typespec-name when option include-x-typespec-name: "never"`, async () => { - const output = await openapiWithOptions( - ` + it(`doesn't include x-typespec-name when option include-x-typespec-name: "never"`, async () => { + const output = await openapiWithOptions( + ` model Foo {names: string[]} `, - { "include-x-typespec-name": "never" }, - ); - ok(!("x-typespec-name" in output.components!.schemas!.Foo.properties!.names)); - }); + { "include-x-typespec-name": "never" }, + ); + ok(!("x-typespec-name" in output.components!.schemas!.Foo.properties!.names)); + }); - it(`include x-typespec-name when option include-x-typespec-name: "inline-only"`, async () => { - const output = await openapiWithOptions( - ` + it(`include x-typespec-name when option include-x-typespec-name: "inline-only"`, async () => { + const output = await openapiWithOptions( + ` model Foo {names: string[]} `, - { "include-x-typespec-name": "inline-only" }, - ); - const prop: any = output.components!.schemas!.Foo.properties!.names; - strictEqual(prop["x-typespec-name"], `string[]`); + { "include-x-typespec-name": "inline-only" }, + ); + const prop: any = output.components!.schemas!.Foo.properties!.names; + strictEqual(prop["x-typespec-name"], `string[]`); + }); }); -}); -describe("openapi3: literals", () => { - const cases = [ - ["1", { type: "number", enum: [1] }], - ['"hello"', { type: "string", enum: ["hello"] }], - ["false", { type: "boolean", enum: [false] }], - ["true", { type: "boolean", enum: [true] }], - ]; - - for (const test of cases) { - it("knows schema for " + test[0], async () => { - const res = await oapiForModel( - "Pet", - ` + describe("openapi3: literals", () => { + const cases = [ + ["1", { type: "number", enum: [1] }], + ['"hello"', { type: "string", enum: ["hello"] }], + ["false", { type: "boolean", enum: [false] }], + ["true", { type: "boolean", enum: [true] }], + ]; + + for (const test of cases) { + it("knows schema for " + test[0], async () => { + const res = await oapiForModel( + "Pet", + ` model Pet { name: ${test[0]} }; `, - ); + ); - const schema = res.schemas.Pet.properties.name; - deepStrictEqual(schema, test[1]); - }); - } -}); + const schema = res.schemas.Pet.properties.name; + deepStrictEqual(schema, test[1]); + }); + } + }); -describe("openapi3: operations", () => { - it("define operations with param with defaults", async () => { - const res = await openApiFor( - ` + describe("openapi3: operations", () => { + it("define operations with param with defaults", async () => { + const res = await openApiFor( + ` @route("/") @get() op read(@query queryWithDefault?: string = "defaultValue"): string; `, - ); + ); - strictEqual(res.paths["/"].get.operationId, "read"); - deepStrictEqual(res.paths["/"].get.parameters[0].schema.default, "defaultValue"); - }); + strictEqual(res.paths["/"].get.operationId, "read"); + deepStrictEqual(res.paths["/"].get.parameters[0].schema.default, "defaultValue"); + }); - it("define operations with param with decorators", async () => { - const res = await openApiFor( - ` + it("define operations with param with decorators", async () => { + const res = await openApiFor( + ` @get @route("/thing/{name}") op getThing( @@ -140,78 +117,78 @@ describe("openapi3: operations", () => { @query count: int32 ): string; `, - ); + ); - const getThing = res.paths["/thing/{name}"].get; + const getThing = res.paths["/thing/{name}"].get; - strictEqual(getThing.operationId, "getThing"); + strictEqual(getThing.operationId, "getThing"); - ok(getThing); - ok(getThing.parameters[0].schema); - ok(getThing.parameters[0].schema.pattern); - strictEqual(getThing.parameters[0].schema.pattern, "^[a-zA-Z0-9-]{3,24}$"); + ok(getThing); + ok(getThing.parameters[0].schema); + ok(getThing.parameters[0].schema.pattern); + strictEqual(getThing.parameters[0].schema.pattern, "^[a-zA-Z0-9-]{3,24}$"); - ok(getThing.parameters[1].schema); - ok(getThing.parameters[1].schema.minimum); - ok(getThing.parameters[1].schema.maximum); - strictEqual(getThing.parameters[1].schema.minimum, 1); - strictEqual(getThing.parameters[1].schema.maximum, 10); - }); + ok(getThing.parameters[1].schema); + ok(getThing.parameters[1].schema.minimum); + ok(getThing.parameters[1].schema.maximum); + strictEqual(getThing.parameters[1].schema.minimum, 1); + strictEqual(getThing.parameters[1].schema.maximum, 10); + }); - it("deprecate operations with #deprecated", async () => { - const res = await openApiFor( - ` + it("deprecate operations with #deprecated", async () => { + const res = await openApiFor( + ` #deprecated "use something else" op read(@query query: string): string; `, - ); + ); - strictEqual(res.paths["/"].get.deprecated, true); + strictEqual(res.paths["/"].get.deprecated, true); + }); }); -}); -describe("openapi3: request", () => { - describe("binary request", () => { - it("bytes request should default to application/json byte", async () => { - const res = await openApiFor(` + describe("openapi3: request", () => { + describe("binary request", () => { + it("bytes request should default to application/json byte", async () => { + const res = await openApiFor(` @post op read(@body body: bytes): {}; `); - const requestBody = res.paths["/"].post.requestBody; - ok(requestBody); - strictEqual(requestBody.content["application/json"].schema.type, "string"); - strictEqual(requestBody.content["application/json"].schema.format, "byte"); - }); + const requestBody = res.paths["/"].post.requestBody; + ok(requestBody); + strictEqual(requestBody.content["application/json"].schema.type, "string"); + strictEqual(requestBody.content["application/json"].schema.format, "byte"); + }); - it("bytes request should respect @header contentType and use binary format when not json or text", async () => { - const res = await openApiFor(` + it("bytes request should respect @header contentType and use binary format when not json or text", async () => { + const res = await openApiFor(` @post op read(@header contentType: "image/png", @body body: bytes): {}; `); - const requestBody = res.paths["/"].post.requestBody; - ok(requestBody); - strictEqual(requestBody.content["image/png"].schema.type, "string"); - strictEqual(requestBody.content["image/png"].schema.format, "binary"); + const requestBody = res.paths["/"].post.requestBody; + ok(requestBody); + strictEqual(requestBody.content["image/png"].schema.type, "string"); + strictEqual(requestBody.content["image/png"].schema.format, "binary"); + }); }); }); -}); -describe("openapi3: extension decorator", () => { - it("adds an arbitrary extension to a model", async () => { - const oapi = await openApiFor(` + describe("openapi3: extension decorator", () => { + it("adds an arbitrary extension to a model", async () => { + const oapi = await openApiFor(` @extension("x-model-extension", "foobar") model Pet { name: string; } @get() op read(): Pet; `); - ok(oapi.components.schemas.Pet); - strictEqual(oapi.components.schemas.Pet["x-model-extension"], "foobar"); - }); + ok(oapi.components.schemas.Pet); + strictEqual(oapi.components.schemas.Pet["x-model-extension"], "foobar"); + }); - it("adds an arbitrary extension to an operation", async () => { - const oapi = await openApiFor( - ` + it("adds an arbitrary extension to an operation", async () => { + const oapi = await openApiFor( + ` model Pet { name: string; } @@ -219,14 +196,14 @@ describe("openapi3: extension decorator", () => { @extension("x-operation-extension", "barbaz") op list(): Pet[]; `, - ); - ok(oapi.paths["/"].get); - strictEqual(oapi.paths["/"].get["x-operation-extension"], "barbaz"); - }); + ); + ok(oapi.paths["/"].get); + strictEqual(oapi.paths["/"].get["x-operation-extension"], "barbaz"); + }); - it("adds an arbitrary extension to a parameter", async () => { - const oapi = await openApiFor( - ` + it("adds an arbitrary extension to a parameter", async () => { + const oapi = await openApiFor( + ` model Pet { name: string; } @@ -239,30 +216,30 @@ describe("openapi3: extension decorator", () => { @get() op get(... PetId): Pet; `, - ); - ok(oapi.paths["/Pets/{petId}"].get); - strictEqual( - oapi.paths["/Pets/{petId}"].get.parameters[0]["$ref"], - "#/components/parameters/PetId", - ); - strictEqual(oapi.components.parameters.PetId.name, "petId"); - strictEqual(oapi.components.parameters.PetId["x-parameter-extension"], "foobaz"); - }); + ); + ok(oapi.paths["/Pets/{petId}"].get); + strictEqual( + oapi.paths["/Pets/{petId}"].get.parameters[0]["$ref"], + "#/components/parameters/PetId", + ); + strictEqual(oapi.components.parameters.PetId.name, "petId"); + strictEqual(oapi.components.parameters.PetId["x-parameter-extension"], "foobaz"); + }); - it("adds an extension to a namespace", async () => { - const oapi = await openApiFor( - ` + it("adds an extension to a namespace", async () => { + const oapi = await openApiFor( + ` @extension("x-namespace-extension", "foobar") @service namespace Service {}; `, - ); + ); - strictEqual(oapi["x-namespace-extension"], "foobar"); - }); + strictEqual(oapi["x-namespace-extension"], "foobar"); + }); - it("check format and pattern decorator on model", async () => { - const oapi = await openApiFor( - ` + it("check format and pattern decorator on model", async () => { + const oapi = await openApiFor( + ` model Pet extends PetId { @pattern("^[a-zA-Z0-9-]{3,24}$") name: string; @@ -277,22 +254,22 @@ describe("openapi3: extension decorator", () => { @get() op get(... PetId): Pet; `, - ); - ok(oapi.paths["/Pets/{petId}"].get); - strictEqual( - oapi.paths["/Pets/{petId}"].get.parameters[0]["$ref"], - "#/components/parameters/PetId", - ); - strictEqual(oapi.components.parameters.PetId.name, "petId"); - strictEqual(oapi.components.schemas.Pet.properties.name.pattern, "^[a-zA-Z0-9-]{3,24}$"); - strictEqual(oapi.components.parameters.PetId.schema.format, "UUID"); - strictEqual(oapi.components.parameters.PetId.schema.pattern, "^[a-zA-Z0-9-]{3,24}$"); + ); + ok(oapi.paths["/Pets/{petId}"].get); + strictEqual( + oapi.paths["/Pets/{petId}"].get.parameters[0]["$ref"], + "#/components/parameters/PetId", + ); + strictEqual(oapi.components.parameters.PetId.name, "petId"); + strictEqual(oapi.components.schemas.Pet.properties.name.pattern, "^[a-zA-Z0-9-]{3,24}$"); + strictEqual(oapi.components.parameters.PetId.schema.format, "UUID"); + strictEqual(oapi.components.parameters.PetId.schema.pattern, "^[a-zA-Z0-9-]{3,24}$"); + }); }); -}); -describe("openapi3: useRef decorator", () => { - it("adds a ref extension to a model", async () => { - const oapi = await openApiFor(` + describe("openapi3: useRef decorator", () => { + it("adds a ref extension to a model", async () => { + const oapi = await openApiFor(` @test @useRef("../common.json#/definitions/Foo") model Foo { @statusCode @@ -301,12 +278,12 @@ describe("openapi3: useRef decorator", () => { } @get op get(): Foo; `); - ok(oapi.paths["/"].get); - strictEqual(oapi.paths["/"].get.responses["200"]["$ref"], "../common.json#/definitions/Foo"); - }); + ok(oapi.paths["/"].get); + strictEqual(oapi.paths["/"].get.responses["200"]["$ref"], "../common.json#/definitions/Foo"); + }); - it("adds a ref extension to a multiple models", async () => { - const oapi = await openApiFor(` + it("adds a ref extension to a multiple models", async () => { + const oapi = await openApiFor(` @test @useRef("../common.json#/definitions/Foo") model Foo { @statusCode @@ -321,8 +298,9 @@ describe("openapi3: useRef decorator", () => { } @get op get(): Foo | Foo2; `); - ok(oapi.paths["/"].get); - strictEqual(oapi.paths["/"].get.responses["200"]["$ref"], "../common.json#/definitions/Foo"); - strictEqual(oapi.paths["/"].get.responses["201"]["$ref"], "https://example.com/example.yml"); + ok(oapi.paths["/"].get); + strictEqual(oapi.paths["/"].get.responses["200"]["$ref"], "../common.json#/definitions/Foo"); + strictEqual(oapi.paths["/"].get.responses["201"]["$ref"], "https://example.com/example.yml"); + }); }); }); diff --git a/packages/openapi3/test/output-spec-versions.test.ts b/packages/openapi3/test/output-spec-versions.test.ts index c364e225d40..2ced94b0a3f 100644 --- a/packages/openapi3/test/output-spec-versions.test.ts +++ b/packages/openapi3/test/output-spec-versions.test.ts @@ -50,3 +50,12 @@ it("defaults to 3.0.0 if not specified", async () => { const doc = JSON.parse(content); expect(doc.openapi).toBe("3.0.0"); }); + +it("supports 3.1.0", async () => { + await compileOpenAPI({ "openapi-versions": ["3.1.0"], "file-type": "json" }); + const outPath = resolvePath(outputDir, "openapi.json"); + const content = runner.fs.get(outPath); + ok(content, `Expected ${outPath} to exist.`); + const doc = JSON.parse(content); + expect(doc.openapi).toBe("3.1.0"); +}); diff --git a/packages/openapi3/test/overloads.test.ts b/packages/openapi3/test/overloads.test.ts index d0b3d55fe74..2d017fa6db5 100644 --- a/packages/openapi3/test/overloads.test.ts +++ b/packages/openapi3/test/overloads.test.ts @@ -1,9 +1,9 @@ import { deepStrictEqual, ok, strictEqual } from "assert"; import { beforeEach, describe, it } from "vitest"; import { OpenAPI3Document } from "../src/types.js"; -import { openApiFor } from "./test-host.js"; +import { worksFor } from "./works-for.js"; -describe("openapi3: overloads", () => { +worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { describe("overloads use same endpoint", () => { let res: OpenAPI3Document; beforeEach(async () => { diff --git a/packages/openapi3/test/parameters.test.ts b/packages/openapi3/test/parameters.test.ts index 4f7454f78a6..2340c56d608 100644 --- a/packages/openapi3/test/parameters.test.ts +++ b/packages/openapi3/test/parameters.test.ts @@ -2,68 +2,71 @@ import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual, ok, strictEqual } from "assert"; import { describe, expect, it } from "vitest"; import { OpenAPI3PathParameter, OpenAPI3QueryParameter } from "../src/types.js"; -import { diagnoseOpenApiFor, openApiFor } from "./test-host.js"; - -describe("query parameters", () => { - async function getQueryParam(code: string): Promise { - const res = await openApiFor(code); - const param = res.paths[`/`].get.parameters[0]; - strictEqual(param.in, "query"); - return param; - } - - it("create a query param", async () => { - const param = await getQueryParam( - `op test(@query myParam: string): void; +import { worksFor } from "./works-for.js"; + +worksFor(["3.0.0", "3.1.0"], ({ diagnoseOpenApiFor, openApiFor }) => { + describe("query parameters", () => { + async function getQueryParam(code: string): Promise { + const res = await openApiFor(code); + const param = res.paths[`/`].get.parameters[0]; + strictEqual(param.in, "query"); + return param; + } + + it("create a query param", async () => { + const param = await getQueryParam( + `op test(@query myParam: string): void; `, - ); - strictEqual(param.name, "myParam"); - deepStrictEqual(param.schema, { type: "string" }); - }); + ); + strictEqual(param.name, "myParam"); + deepStrictEqual(param.schema, { type: "string" }); + }); - it("create a query param with a different name", async () => { - const param = await getQueryParam( - ` + it("create a query param with a different name", async () => { + const param = await getQueryParam( + ` op test(@query("$select") select: string): void; `, - ); - strictEqual(param.in, "query"); - strictEqual(param.name, "$select"); - }); - - describe("doesn't set explode if explode: true (Openapi3.0 inverse default)", () => { - it("with option", async () => { - const param = await getQueryParam(`op test(@query(#{explode: true}) myParam: string): void;`); - expect(param).not.toHaveProperty("explode"); + ); + strictEqual(param.in, "query"); + strictEqual(param.name, "$select"); }); - it("with uri template", async () => { - const param = await getQueryParam(`@route("{?myParam*}") op test(myParam: string): void;`); - expect(param).not.toHaveProperty("explode"); - }); - }); + describe("doesn't set explode if explode: true (Openapi3.0 inverse default)", () => { + it("with option", async () => { + const param = await getQueryParam( + `op test(@query(#{explode: true}) myParam: string): void;`, + ); + expect(param).not.toHaveProperty("explode"); + }); - describe("set explode: false if explode is not set", () => { - it("with option", async () => { - const param = await getQueryParam( - `op test(@query(#{explode: false}) myParam: string): void;`, - ); - expect(param).toMatchObject({ - explode: false, + it("with uri template", async () => { + const param = await getQueryParam(`@route("{?myParam*}") op test(myParam: string): void;`); + expect(param).not.toHaveProperty("explode"); }); }); - it("with uri template", async () => { - const param = await getQueryParam(`@route("{?myParam}") op test(myParam: string): void;`); - expect(param).toMatchObject({ - explode: false, + describe("set explode: false if explode is not set", () => { + it("with option", async () => { + const param = await getQueryParam( + `op test(@query(#{explode: false}) myParam: string): void;`, + ); + expect(param).toMatchObject({ + explode: false, + }); + }); + + it("with uri template", async () => { + const param = await getQueryParam(`@route("{?myParam}") op test(myParam: string): void;`); + expect(param).toMatchObject({ + explode: false, + }); }); }); - }); - it("LEGACY: specify the format", async () => { - const res = await openApiFor( - ` + it("LEGACY: specify the format", async () => { + const res = await openApiFor( + ` #suppress "deprecated" "test" op test( @query({name: "$multi", format: "multi"}) multis: string[], @@ -74,112 +77,112 @@ describe("query parameters", () => { @query({name: "$pipes", format: "pipes"}) pipes: string[] ): void; `, - ); - const params = res.paths["/"].get.parameters; - deepStrictEqual(params[0], { - in: "query", - name: "$multi", - required: true, - schema: { - type: "array", - items: { - type: "string", + ); + const params = res.paths["/"].get.parameters; + deepStrictEqual(params[0], { + in: "query", + name: "$multi", + required: true, + schema: { + type: "array", + items: { + type: "string", + }, }, - }, - }); - deepStrictEqual(params[1], { - in: "query", - name: "$csv", - explode: false, - schema: { - type: "array", - items: { - type: "string", + }); + deepStrictEqual(params[1], { + in: "query", + name: "$csv", + explode: false, + schema: { + type: "array", + items: { + type: "string", + }, }, - }, - required: true, - }); - deepStrictEqual(params[2], { - in: "query", - name: "$tsv", - schema: { - type: "string", - }, - required: true, - }); - deepStrictEqual(params[3], { - in: "query", - name: "$ssv", - style: "spaceDelimited", - required: true, - schema: { - type: "array", - items: { + required: true, + }); + deepStrictEqual(params[2], { + in: "query", + name: "$tsv", + schema: { type: "string", }, - }, - explode: false, - }); - deepStrictEqual(params[4], { - in: "query", - name: "$pipes", - style: "pipeDelimited", - required: true, - schema: { - type: "array", - items: { - type: "string", + required: true, + }); + deepStrictEqual(params[3], { + in: "query", + name: "$ssv", + style: "spaceDelimited", + required: true, + schema: { + type: "array", + items: { + type: "string", + }, }, - }, - explode: false, + explode: false, + }); + deepStrictEqual(params[4], { + in: "query", + name: "$pipes", + style: "pipeDelimited", + required: true, + schema: { + type: "array", + items: { + type: "string", + }, + }, + explode: false, + }); }); - }); - it("create a query param that is a model property", async () => { - const res = await openApiFor( - ` + it("create a query param that is a model property", async () => { + const res = await openApiFor( + ` op test(@query id: UserContext.id): void; model UserContext { id: string; } `, - ); - deepStrictEqual(res.paths["/"].get.parameters[0], { - in: "query", - name: "id", - explode: false, - required: true, - schema: { - type: "string", - }, + ); + deepStrictEqual(res.paths["/"].get.parameters[0], { + in: "query", + name: "id", + explode: false, + required: true, + schema: { + type: "string", + }, + }); }); - }); - it("create an header param", async () => { - const res = await openApiFor( - ` + it("create an header param", async () => { + const res = await openApiFor( + ` op test(@header arg1: string): void; `, - ); - strictEqual(res.paths["/"].get.parameters[0].in, "header"); - strictEqual(res.paths["/"].get.parameters[0].name, "arg1"); - deepStrictEqual(res.paths["/"].get.parameters[0].schema, { type: "string" }); - }); + ); + strictEqual(res.paths["/"].get.parameters[0].in, "header"); + strictEqual(res.paths["/"].get.parameters[0].name, "arg1"); + deepStrictEqual(res.paths["/"].get.parameters[0].schema, { type: "string" }); + }); - it("create an header param with a different name", async () => { - const res = await openApiFor( - ` + it("create an header param with a different name", async () => { + const res = await openApiFor( + ` op test(@header("foo-bar") foo: string): void; `, - ); - strictEqual(res.paths["/"].get.parameters[0].in, "header"); - strictEqual(res.paths["/"].get.parameters[0].name, "foo-bar"); - }); + ); + strictEqual(res.paths["/"].get.parameters[0].in, "header"); + strictEqual(res.paths["/"].get.parameters[0].name, "foo-bar"); + }); - it("create a header param of array type", async () => { - const res = await openApiFor( - ` + it("create a header param of array type", async () => { + const res = await openApiFor( + ` op test( @header({name: "$csv", format: "csv"}) csvs: string[], #suppress "@typespec/openapi3/invalid-format" "test" @@ -192,89 +195,89 @@ describe("query parameters", () => { @header({name: "$pipes", format: "pipes"}) pipes: string[] ): void; `, - ); - const params = res.paths["/"].get.parameters; - deepStrictEqual(params[0], { - in: "header", - name: "$csv", - style: "simple", - schema: { - type: "array", - items: { + ); + const params = res.paths["/"].get.parameters; + deepStrictEqual(params[0], { + in: "header", + name: "$csv", + style: "simple", + schema: { + type: "array", + items: { + type: "string", + }, + }, + required: true, + }); + deepStrictEqual(params[1], { + in: "header", + name: "$multi", + required: true, + schema: { + type: "string", + }, + }); + deepStrictEqual(params[2], { + in: "header", + name: "$tsv", + schema: { + type: "string", + }, + required: true, + }); + deepStrictEqual(params[3], { + in: "header", + name: "$ssv", + required: true, + schema: { + type: "string", + }, + }); + deepStrictEqual(params[4], { + in: "header", + name: "$pipes", + required: true, + schema: { type: "string", }, - }, - required: true, - }); - deepStrictEqual(params[1], { - in: "header", - name: "$multi", - required: true, - schema: { - type: "string", - }, - }); - deepStrictEqual(params[2], { - in: "header", - name: "$tsv", - schema: { - type: "string", - }, - required: true, - }); - deepStrictEqual(params[3], { - in: "header", - name: "$ssv", - required: true, - schema: { - type: "string", - }, - }); - deepStrictEqual(params[4], { - in: "header", - name: "$pipes", - required: true, - schema: { - type: "string", - }, + }); }); - }); - it("create a cookie param", async () => { - const res = await openApiFor( - ` + it("create a cookie param", async () => { + const res = await openApiFor( + ` op test(@cookie arg1: string): void; `, - ); - strictEqual(res.paths["/"].get.parameters[0].in, "cookie"); - strictEqual(res.paths["/"].get.parameters[0].name, "arg1"); - deepStrictEqual(res.paths["/"].get.parameters[0].schema, { type: "string" }); - }); + ); + strictEqual(res.paths["/"].get.parameters[0].in, "cookie"); + strictEqual(res.paths["/"].get.parameters[0].name, "arg1"); + deepStrictEqual(res.paths["/"].get.parameters[0].schema, { type: "string" }); + }); - it("create a cookie param with a different name", async () => { - const res = await openApiFor( - ` + it("create a cookie param with a different name", async () => { + const res = await openApiFor( + ` op test(@cookie("foo_bar") foo: string): void; `, - ); - strictEqual(res.paths["/"].get.parameters[0].in, "cookie"); - strictEqual(res.paths["/"].get.parameters[0].name, "foo_bar"); - }); + ); + strictEqual(res.paths["/"].get.parameters[0].in, "cookie"); + strictEqual(res.paths["/"].get.parameters[0].name, "foo_bar"); + }); - // Regression test for https://github.com/microsoft/typespec/issues/414 - it("@doc set the description on the parameter not its schema", async () => { - const res = await openApiFor( - ` + // Regression test for https://github.com/microsoft/typespec/issues/414 + it("@doc set the description on the parameter not its schema", async () => { + const res = await openApiFor( + ` op test(@query @doc("my-doc") arg1: string): void; `, - ); - strictEqual(res.paths["/"].get.parameters[0].description, "my-doc"); - strictEqual(res.paths["/"].get.parameters[0].schema.description, undefined); - }); + ); + strictEqual(res.paths["/"].get.parameters[0].description, "my-doc"); + strictEqual(res.paths["/"].get.parameters[0].schema.description, undefined); + }); - it("errors on duplicate parameter keys", async () => { - const diagnostics = await diagnoseOpenApiFor( - ` + it("errors on duplicate parameter keys", async () => { + const diagnostics = await diagnoseOpenApiFor( + ` model P { @query id: string; } @@ -290,19 +293,19 @@ describe("query parameters", () => { @route("/test2") op test2(...Q): void; `, - { "omit-unreachable-types": true }, - ); - - expectDiagnostics(diagnostics, [ - { - code: "@typespec/openapi/duplicate-type-name", - message: /parameter/, - }, - ]); - }); + { "omit-unreachable-types": true }, + ); + + expectDiagnostics(diagnostics, [ + { + code: "@typespec/openapi/duplicate-type-name", + message: /parameter/, + }, + ]); + }); - it("encodes parameter keys in references", async () => { - const oapi = await openApiFor(` + it("encodes parameter keys in references", async () => { + const oapi = await openApiFor(` model Pet extends Pet$Id { name: string; } @@ -316,54 +319,54 @@ describe("query parameters", () => { op get(... Pet$Id): Pet; `); - ok(oapi.paths["/Pets/{petId}"].get); - strictEqual( - oapi.paths["/Pets/{petId}"].get.parameters[0]["$ref"], - "#/components/parameters/Pet%24Id", - ); - strictEqual(oapi.components.parameters["Pet$Id"].name, "petId"); - }); + ok(oapi.paths["/Pets/{petId}"].get); + strictEqual( + oapi.paths["/Pets/{petId}"].get.parameters[0]["$ref"], + "#/components/parameters/Pet%24Id", + ); + strictEqual(oapi.components.parameters["Pet$Id"].name, "petId"); + }); - it("inline spread of parameters from anonymous model", async () => { - const oapi = await openApiFor( - ` + it("inline spread of parameters from anonymous model", async () => { + const oapi = await openApiFor( + ` op template(...TParameters): TReturn; op instantiation is template<{@path id: string}, void>; `, - ); + ); - ok(oapi.paths["/{id}"].get); + ok(oapi.paths["/{id}"].get); - deepStrictEqual(oapi.paths["/{id}"].get.parameters, [ - { - name: "id", - in: "path", - required: true, - schema: { - type: "string", + deepStrictEqual(oapi.paths["/{id}"].get.parameters, [ + { + name: "id", + in: "path", + required: true, + schema: { + type: "string", + }, }, - }, - ]); - }); + ]); + }); - it("omit parameters with type never", async () => { - const res = await openApiFor( - ` + it("omit parameters with type never", async () => { + const res = await openApiFor( + ` op test(@query select: never, @query top: int32): void; `, - ); - strictEqual(res.paths["/"].get.parameters.length, 1); - strictEqual(res.paths["/"].get.parameters[0].in, "query"); - strictEqual(res.paths["/"].get.parameters[0].name, "top"); - }); + ); + strictEqual(res.paths["/"].get.parameters.length, 1); + strictEqual(res.paths["/"].get.parameters[0].in, "query"); + strictEqual(res.paths["/"].get.parameters[0].name, "top"); + }); - it("omit request body if type is void", async () => { - const res = await openApiFor(`op test(@body foo: void ): void;`); - strictEqual(res.paths["/"].post.requestBody, undefined); - }); + it("omit request body if type is void", async () => { + const res = await openApiFor(`op test(@body foo: void ): void;`); + strictEqual(res.paths["/"].post.requestBody, undefined); + }); - it("using @body ignore any metadata property underneath", async () => { - const res = await openApiFor(`@get op read( + it("using @body ignore any metadata property underneath", async () => { + const res = await openApiFor(`@get op read( @body body: { #suppress "@typespec/http/metadata-ignored" @header header: string, @@ -375,30 +378,30 @@ describe("query parameters", () => { @statusCode code: 201, } ): void;`); - expect(res.paths["/"].get.requestBody.content["application/json"].schema).toEqual({ - type: "object", - properties: { - header: { type: "string" }, - cookie: { type: "string" }, - query: { type: "string" }, - code: { type: "number", enum: [201] }, - }, - required: ["header", "cookie", "query", "code"], + expect(res.paths["/"].get.requestBody.content["application/json"].schema).toEqual({ + type: "object", + properties: { + header: { type: "string" }, + cookie: { type: "string" }, + query: { type: "string" }, + code: { type: "number", enum: [201] }, + }, + required: ["header", "cookie", "query", "code"], + }); }); - }); - describe("request parameters resolving to no property in the body produce no body", () => { - it.each(["()", "(@header prop: string)", `(@visibility("none") prop: string)`])( - "%s", - async (params) => { - const res = await openApiFor(`op test${params}: void;`); - strictEqual(res.paths["/"].get.requestBody, undefined); - }, - ); - }); + describe("request parameters resolving to no property in the body produce no body", () => { + it.each(["()", "(@header prop: string)", `(@visibility("none") prop: string)`])( + "%s", + async (params) => { + const res = await openApiFor(`op test${params}: void;`); + strictEqual(res.paths["/"].get.requestBody, undefined); + }, + ); + }); - it("property in body with only metadata properties should still be included", async () => { - const res = await openApiFor(`op read( + it("property in body with only metadata properties should still be included", async () => { + const res = await openApiFor(`op read( headers: { @header header1: string; @header header2: string; @@ -409,19 +412,19 @@ describe("query parameters", () => { }; name: string; ): void;`); - expect(res.paths["/"].post.requestBody.content["application/json"].schema).toEqual({ - type: "object", - properties: { - headers: { type: "object" }, - cookies: { type: "object" }, - name: { type: "string" }, - }, - required: ["headers", "cookies", "name"], + expect(res.paths["/"].post.requestBody.content["application/json"].schema).toEqual({ + type: "object", + properties: { + headers: { type: "object" }, + cookies: { type: "object" }, + name: { type: "string" }, + }, + required: ["headers", "cookies", "name"], + }); }); - }); - it("property in body with only metadata properties and @bodyIgnore should not be included", async () => { - const res = await openApiFor(`op read( + it("property in body with only metadata properties and @bodyIgnore should not be included", async () => { + const res = await openApiFor(`op read( @bodyIgnore headers: { @header header1: string; @header header2: string; @@ -432,203 +435,218 @@ describe("query parameters", () => { }; name: string; ): void;`); - expect(res.paths["/"].post.requestBody.content["application/json"].schema).toEqual({ - type: "object", - properties: { - name: { type: "string" }, - }, - required: ["name"], + expect(res.paths["/"].post.requestBody.content["application/json"].schema).toEqual({ + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + }); }); - }); - describe("content type parameter", () => { - it("header named with 'Content-Type' gets resolved as content type for operation.", async () => { - const res = await openApiFor( - ` + describe("content type parameter", () => { + it("header named with 'Content-Type' gets resolved as content type for operation.", async () => { + const res = await openApiFor( + ` op test( @header("Content-Type") explicitContentType: "application/octet-stream", @body foo: string ): void; `, - ); - ok(res.paths["/"].post.requestBody.content["application/octet-stream"]); - deepStrictEqual(res.paths["/"].post.requestBody.content["application/octet-stream"].schema, { - type: "string", + ); + ok(res.paths["/"].post.requestBody.content["application/octet-stream"]); + deepStrictEqual( + res.paths["/"].post.requestBody.content["application/octet-stream"].schema, + { + type: "string", + }, + ); }); - }); - it("header named contentType gets resolved as content type for operation.", async () => { - const res = await openApiFor( - ` + it("header named contentType gets resolved as content type for operation.", async () => { + const res = await openApiFor( + ` op test( @header contentType: "application/octet-stream", @body foo: string ): void; `, - ); - ok(res.paths["/"].post.requestBody.content["application/octet-stream"]); - deepStrictEqual(res.paths["/"].post.requestBody.content["application/octet-stream"].schema, { - type: "string", + ); + ok(res.paths["/"].post.requestBody.content["application/octet-stream"]); + deepStrictEqual( + res.paths["/"].post.requestBody.content["application/octet-stream"].schema, + { + type: "string", + }, + ); + strictEqual(res.paths["/"].post.parameters.length, 0); }); - strictEqual(res.paths["/"].post.parameters.length, 0); - }); - it("query named contentType doesn't get resolved as the content type parameter.", async () => { - const res = await openApiFor( - ` + it("query named contentType doesn't get resolved as the content type parameter.", async () => { + const res = await openApiFor( + ` op test( @query contentType: "application/octet-stream", @body foo: string ): void; `, - ); - strictEqual(res.paths["/"].post.requestBody.content["application/octet-stream"], undefined); - ok(res.paths["/"].post.requestBody.content["application/json"]); + ); + strictEqual(res.paths["/"].post.requestBody.content["application/octet-stream"], undefined); + ok(res.paths["/"].post.requestBody.content["application/json"]); + }); }); }); -}); -describe("path parameters", () => { - async function getPathParam(code: string, name = "myParam"): Promise { - const res = await openApiFor(code); - return res.paths[`/{${name}}`].get.parameters[0]; - } + describe("path parameters", () => { + async function getPathParam(code: string, name = "myParam"): Promise { + const res = await openApiFor(code); + return res.paths[`/{${name}}`].get.parameters[0]; + } - it("figure out the route parameter from the name of the param", async () => { - const res = await openApiFor(`op test(@path myParam: string): void;`); - expect(res.paths).toHaveProperty("/{myParam}"); - }); + it("figure out the route parameter from the name of the param", async () => { + const res = await openApiFor(`op test(@path myParam: string): void;`); + expect(res.paths).toHaveProperty("/{myParam}"); + }); - it("uses explicit name provided from @path", async () => { - const res = await openApiFor(`op test(@path("my-custom-path") myParam: string): void;`); - expect(res.paths).toHaveProperty("/{my-custom-path}"); - }); + it("uses explicit name provided from @path", async () => { + const res = await openApiFor(`op test(@path("my-custom-path") myParam: string): void;`); + expect(res.paths).toHaveProperty("/{my-custom-path}"); + }); - it("inline adds an arbitrary extension to a parameter", async () => { - const oapi = await openApiFor( - ` + it("inline adds an arbitrary extension to a parameter", async () => { + const oapi = await openApiFor( + ` op get( @path @extension("x-parameter-extension", "foobaz") petId: string; ): void; `, - ); - strictEqual(oapi.paths["/{petId}"].get.parameters[0]["x-parameter-extension"], "foobaz"); - }); - - describe("set explode: true", () => { - it("with option", async () => { - const param = await getPathParam(`op test(@path(#{explode: true}) myParam: string[]): void;`); - expect(param).toMatchObject({ - explode: true, - schema: { - type: "array", - items: { type: "string" }, - }, + ); + strictEqual(oapi.paths["/{petId}"].get.parameters[0]["x-parameter-extension"], "foobaz"); + }); + + describe("set explode: true", () => { + it("with option", async () => { + const param = await getPathParam( + `op test(@path(#{explode: true}) myParam: string[]): void;`, + ); + expect(param).toMatchObject({ + explode: true, + schema: { + type: "array", + items: { type: "string" }, + }, + }); }); - }); - it("with uri template", async () => { - const param = await getPathParam(`@route("{myParam*}") op test(myParam: string[]): void;`); - expect(param).toMatchObject({ - explode: true, - schema: { - type: "array", - items: { type: "string" }, - }, + it("with uri template", async () => { + const param = await getPathParam(`@route("{myParam*}") op test(myParam: string[]): void;`); + expect(param).toMatchObject({ + explode: true, + schema: { + type: "array", + items: { type: "string" }, + }, + }); }); }); - }); - describe("set style: simple", () => { - it("with option", async () => { - const param = await getPathParam(`op test(@path(#{style: "simple"}) myParam: string): void;`); - expect(param).not.toHaveProperty("style"); - }); + describe("set style: simple", () => { + it("with option", async () => { + const param = await getPathParam( + `op test(@path(#{style: "simple"}) myParam: string): void;`, + ); + expect(param).not.toHaveProperty("style"); + }); - it("with uri template", async () => { - const param = await getPathParam(`@route("{myParam}") op test(myParam: string): void;`); - expect(param).not.toHaveProperty("style"); + it("with uri template", async () => { + const param = await getPathParam(`@route("{myParam}") op test(myParam: string): void;`); + expect(param).not.toHaveProperty("style"); + }); }); - }); - describe("set style: label", () => { - it("with option", async () => { - const param = await getPathParam(`op test(@path(#{style: "label"}) myParam: string): void;`); - expect(param).toMatchObject({ - style: "label", + describe("set style: label", () => { + it("with option", async () => { + const param = await getPathParam( + `op test(@path(#{style: "label"}) myParam: string): void;`, + ); + expect(param).toMatchObject({ + style: "label", + }); }); - }); - it("with uri template", async () => { - const param = await getPathParam(`@route("{.myParam}") op test(myParam: string): void;`); - expect(param).toMatchObject({ - style: "label", + it("with uri template", async () => { + const param = await getPathParam(`@route("{.myParam}") op test(myParam: string): void;`); + expect(param).toMatchObject({ + style: "label", + }); }); }); - }); - describe("set style: matrix", () => { - it("with option", async () => { - const param = await getPathParam(`op test(@path(#{style: "matrix"}) myParam: string): void;`); - expect(param).toMatchObject({ - style: "matrix", + describe("set style: matrix", () => { + it("with option", async () => { + const param = await getPathParam( + `op test(@path(#{style: "matrix"}) myParam: string): void;`, + ); + expect(param).toMatchObject({ + style: "matrix", + }); }); - }); - it("with uri template", async () => { - const param = await getPathParam(`@route("{;myParam}") op test(myParam: string): void;`); - expect(param).toMatchObject({ - style: "matrix", + it("with uri template", async () => { + const param = await getPathParam(`@route("{;myParam}") op test(myParam: string): void;`); + expect(param).toMatchObject({ + style: "matrix", + }); }); }); - }); - describe("emit diagnostic when using style: path", () => { - it("with option", async () => { - const diagnostics = await diagnoseOpenApiFor( - `op test(@path(#{style: "path"}) myParam: string): void;`, - ); - expectDiagnostics(diagnostics, { code: "@typespec/openapi3/invalid-style" }); - }); + describe("emit diagnostic when using style: path", () => { + it("with option", async () => { + const diagnostics = await diagnoseOpenApiFor( + `op test(@path(#{style: "path"}) myParam: string): void;`, + ); + expectDiagnostics(diagnostics, { code: "@typespec/openapi3/invalid-style" }); + }); - it("with uri template", async () => { - const diagnostics = await diagnoseOpenApiFor( - `@route("{/myParam}") op test(myParam: string): void;`, - ); - expectDiagnostics(diagnostics, { code: "@typespec/openapi3/invalid-style" }); + it("with uri template", async () => { + const diagnostics = await diagnoseOpenApiFor( + `@route("{/myParam}") op test(myParam: string): void;`, + ); + expectDiagnostics(diagnostics, { code: "@typespec/openapi3/invalid-style" }); + }); }); - }); - describe("emit diagnostic when using style: fragment", () => { - it("with option", async () => { - const diagnostics = await diagnoseOpenApiFor( - `op test(@path(#{style: "fragment"}) myParam: string): void;`, - ); - expectDiagnostics(diagnostics, { code: "@typespec/openapi3/invalid-style" }); - }); + describe("emit diagnostic when using style: fragment", () => { + it("with option", async () => { + const diagnostics = await diagnoseOpenApiFor( + `op test(@path(#{style: "fragment"}) myParam: string): void;`, + ); + expectDiagnostics(diagnostics, { code: "@typespec/openapi3/invalid-style" }); + }); - it("with uri template", async () => { - const diagnostics = await diagnoseOpenApiFor( - `@route("{#myParam}") op test(myParam: string): void;`, - ); - expectDiagnostics(diagnostics, { code: "@typespec/openapi3/invalid-style" }); + it("with uri template", async () => { + const diagnostics = await diagnoseOpenApiFor( + `@route("{#myParam}") op test(myParam: string): void;`, + ); + expectDiagnostics(diagnostics, { code: "@typespec/openapi3/invalid-style" }); + }); }); - }); - describe("emit diagnostic when using reserved expansion", () => { - it("with option", async () => { - const diagnostics = await diagnoseOpenApiFor( - `op test(@path(#{allowReserved: true}) myParam: string): void;`, - ); - expectDiagnostics(diagnostics, { code: "@typespec/openapi3/path-reserved-expansion" }); - }); + describe("emit diagnostic when using reserved expansion", () => { + it("with option", async () => { + const diagnostics = await diagnoseOpenApiFor( + `op test(@path(#{allowReserved: true}) myParam: string): void;`, + ); + expectDiagnostics(diagnostics, { code: "@typespec/openapi3/path-reserved-expansion" }); + }); - it("with uri template", async () => { - const diagnostics = await diagnoseOpenApiFor( - `@route("{+myParam}") op test(myParam: string): void;`, - ); - expectDiagnostics(diagnostics, { code: "@typespec/openapi3/path-reserved-expansion" }); + it("with uri template", async () => { + const diagnostics = await diagnoseOpenApiFor( + `@route("{+myParam}") op test(myParam: string): void;`, + ); + expectDiagnostics(diagnostics, { code: "@typespec/openapi3/path-reserved-expansion" }); + }); }); }); }); diff --git a/packages/openapi3/test/primitive-types.test.ts b/packages/openapi3/test/primitive-types.test.ts index 634d07f3200..f8d9ca4f508 100644 --- a/packages/openapi3/test/primitive-types.test.ts +++ b/packages/openapi3/test/primitive-types.test.ts @@ -1,13 +1,12 @@ import { deepStrictEqual, ok, strictEqual } from "assert"; import { describe, it } from "vitest"; import { OpenAPI3Schema } from "../src/types.js"; -import { oapiForModel, openApiFor } from "./test-host.js"; +import { worksFor } from "./works-for.js"; -describe("openapi3: primitives", () => { +worksFor(["3.0.0", "3.1.0"], ({ oapiForModel, openApiFor }) => { describe("handle TypeSpec intrinsic types", () => { const cases = [ ["unknown", {}], - ["null", { nullable: true }], ["numeric", { type: "number" }], ["integer", { type: "integer" }], ["int8", { type: "integer", format: "int8" }], @@ -330,3 +329,31 @@ describe("openapi3: primitives", () => { }); }); }); + +worksFor(["3.0.0"], ({ oapiForModel }) => { + it("handle null type as nullable", async () => { + const res = await oapiForModel( + "Pet", + ` + model Pet { name: null }; + `, + ); + + const schema = res.schemas.Pet.properties.name; + deepStrictEqual(schema, { nullable: true }); + }); +}); + +worksFor(["3.1.0"], ({ oapiForModel }) => { + it("handle null type as null", async () => { + const res = await oapiForModel( + "Pet", + ` + model Pet { name: null }; + `, + ); + + const schema = res.schemas.Pet.properties.name; + deepStrictEqual(schema, { type: "null" }); + }); +}); diff --git a/packages/openapi3/test/record.test.ts b/packages/openapi3/test/record.test.ts index 076fe300d99..04a5529f4de 100644 --- a/packages/openapi3/test/record.test.ts +++ b/packages/openapi3/test/record.test.ts @@ -1,8 +1,8 @@ import { deepStrictEqual, ok } from "assert"; -import { describe, it } from "vitest"; -import { oapiForModel } from "./test-host.js"; +import { it } from "vitest"; +import { worksFor } from "./works-for.js"; -describe("openapi3: Record", () => { +worksFor(["3.0.0", "3.1.0"], ({ oapiForModel }) => { it("defines record inline", async () => { const res = await oapiForModel( "Pet", diff --git a/packages/openapi3/test/response-descriptions.test.ts b/packages/openapi3/test/response-descriptions.test.ts index e744c2e54cb..4935047cc62 100644 --- a/packages/openapi3/test/response-descriptions.test.ts +++ b/packages/openapi3/test/response-descriptions.test.ts @@ -1,8 +1,8 @@ import { strictEqual } from "assert"; -import { describe, it } from "vitest"; -import { openApiFor } from "./test-host.js"; +import { it } from "vitest"; +import { worksFor } from "./works-for.js"; -describe("openapi3: response descriptions", () => { +worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { it("use a default message by status code if not specified", async () => { const res = await openApiFor( ` diff --git a/packages/openapi3/test/return-types.test.ts b/packages/openapi3/test/return-types.test.ts index d647cde7b2c..b36acdb057c 100644 --- a/packages/openapi3/test/return-types.test.ts +++ b/packages/openapi3/test/return-types.test.ts @@ -1,9 +1,9 @@ import { expectDiagnosticEmpty, expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual, ok, strictEqual } from "assert"; import { describe, expect, it } from "vitest"; -import { checkFor, openApiFor } from "./test-host.js"; +import { worksFor } from "./works-for.js"; -describe("openapi3: return types", () => { +worksFor(["3.0.0", "3.1.0"], ({ checkFor, openApiFor }) => { it("model used with @body and without shouldn't conflict if it contains no metadata", async () => { const res = await openApiFor( ` diff --git a/packages/openapi3/test/scalar-constraints.test.ts b/packages/openapi3/test/scalar-constraints.test.ts index 12ca28cf682..cec4b3acdd4 100644 --- a/packages/openapi3/test/scalar-constraints.test.ts +++ b/packages/openapi3/test/scalar-constraints.test.ts @@ -1,8 +1,9 @@ +/* eslint-disable vitest/no-identical-title */ import { strictEqual } from "assert"; import { describe, it } from "vitest"; -import { oapiForModel } from "./test-host.js"; +import { worksFor } from "./works-for.js"; -describe("scalar constraints", () => { +worksFor(["3.0.0"], ({ oapiForModel }) => { describe("numeric constraints", () => { const scalarNumberTypes = [ "int8", @@ -126,3 +127,128 @@ describe("scalar constraints", () => { }); }); }); + +worksFor(["3.1.0"], ({ oapiForModel }) => { + describe("numeric constraints", () => { + const scalarNumberTypes = [ + "int8", + "int16", + "int32", + "uint8", + "uint16", + "uint32", + "integer", + "float32", + "float64", + "numeric", + "float", + "safeint", + ]; + + describe("@minValue/@maxValue", () => { + for (const numType of scalarNumberTypes) { + it(numType, async () => { + const schemas = await oapiForModel( + "Test", + ` + @minValue(1) + @maxValue(2) + scalar Test extends ${numType}; + `, + ); + + strictEqual(schemas.schemas.Test.minimum, 1); + strictEqual(schemas.schemas.Test.maximum, 2); + }); + } + + it("can be applied on a union", async () => { + const schemas = await oapiForModel( + "Test", + ` + @minValue(1) + @maxValue(2) + union Test {int32, string, null}; + `, + ); + + strictEqual(schemas.schemas.Test.minimum, 1); + strictEqual(schemas.schemas.Test.maximum, 2); + }); + }); + + describe("@minValueExclusive/@maxValueExclusive", () => { + for (const numType of scalarNumberTypes) { + it(numType, async () => { + const schemas = await oapiForModel( + "Test", + ` + @minValueExclusive(1) + @maxValueExclusive(2) + scalar Test extends ${numType}; + `, + ); + + strictEqual(schemas.schemas.Test.minimum, undefined); + strictEqual(schemas.schemas.Test.exclusiveMinimum, 1); + strictEqual(schemas.schemas.Test.maximum, undefined); + strictEqual(schemas.schemas.Test.exclusiveMaximum, 2); + }); + + it("can be applied on a union", async () => { + const schemas = await oapiForModel( + "Test", + ` + @minValueExclusive(1) + @maxValueExclusive(2) + union Test {int32, string, null}; + `, + ); + + strictEqual(schemas.schemas.Test.minimum, undefined); + strictEqual(schemas.schemas.Test.exclusiveMinimum, 1); + strictEqual(schemas.schemas.Test.maximum, undefined); + strictEqual(schemas.schemas.Test.exclusiveMaximum, 2); + }); + } + }); + }); + + describe("string constraints", () => { + function assertStringConstraints(schema: any) { + strictEqual(schema.minLength, 1); + strictEqual(schema.maxLength, 2); + strictEqual(schema.pattern, "a|b"); + strictEqual(schema.format, "ipv4"); + } + + const decorators = ` + @minLength(1) + @maxLength(2) + @pattern("a|b") + @format("ipv4")`; + + it("on scalar declaration", async () => { + const schemas = await oapiForModel( + "Test", + ` + ${decorators} + scalar Test extends string; + `, + ); + + assertStringConstraints(schemas.schemas.Test); + }); + it("on union declaration", async () => { + const schemas = await oapiForModel( + "Test", + ` + ${decorators} + union Test {string, int32, null}; + `, + ); + + assertStringConstraints(schemas.schemas.Test); + }); + }); +}); diff --git a/packages/openapi3/test/security.test.ts b/packages/openapi3/test/security.test.ts index 67f66679401..b70b3bcccc0 100644 --- a/packages/openapi3/test/security.test.ts +++ b/packages/openapi3/test/security.test.ts @@ -1,9 +1,9 @@ import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual } from "assert"; -import { describe, expect, it } from "vitest"; -import { diagnoseOpenApiFor, openApiFor } from "./test-host.js"; +import { expect, it } from "vitest"; +import { worksFor } from "./works-for.js"; -describe("openapi3: security", () => { +worksFor(["3.0.0", "3.1.0"], ({ diagnoseOpenApiFor, openApiFor }) => { it("set a basic auth", async () => { const res = await openApiFor( ` diff --git a/packages/openapi3/test/servers.test.ts b/packages/openapi3/test/servers.test.ts index 7807b609b3e..cdab3fde4f8 100644 --- a/packages/openapi3/test/servers.test.ts +++ b/packages/openapi3/test/servers.test.ts @@ -1,9 +1,9 @@ import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual } from "assert"; -import { describe, it } from "vitest"; -import { diagnoseOpenApiFor, openApiFor } from "./test-host.js"; +import { it } from "vitest"; +import { worksFor } from "./works-for.js"; -describe("openapi3: servers", () => { +worksFor(["3.0.0", "3.1.0"], ({ diagnoseOpenApiFor, openApiFor }) => { it("set a basic server(url)", async () => { const res = await openApiFor( ` diff --git a/packages/openapi3/test/shared-routes.test.ts b/packages/openapi3/test/shared-routes.test.ts index 4a8950a45f8..d73c5b4c429 100644 --- a/packages/openapi3/test/shared-routes.test.ts +++ b/packages/openapi3/test/shared-routes.test.ts @@ -1,9 +1,9 @@ import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual } from "assert"; -import { describe, it } from "vitest"; -import { diagnoseOpenApiFor, openApiFor } from "./test-host.js"; +import { it } from "vitest"; +import { worksFor } from "./works-for.js"; -describe("openapi3: shared routes", () => { +worksFor(["3.0.0", "3.1.0"], ({ diagnoseOpenApiFor, openApiFor }) => { it("emits warning for routes containing query parameters", async () => { const diagnostics = await diagnoseOpenApiFor( ` diff --git a/packages/openapi3/test/status-codes.test.ts b/packages/openapi3/test/status-codes.test.ts index 491ced884a9..c5abe8abb4c 100644 --- a/packages/openapi3/test/status-codes.test.ts +++ b/packages/openapi3/test/status-codes.test.ts @@ -1,9 +1,9 @@ import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual } from "assert"; -import { describe, it } from "vitest"; -import { diagnoseOpenApiFor, openApiFor } from "./test-host.js"; +import { it } from "vitest"; +import { worksFor } from "./works-for.js"; -describe("openapi3: response status codes", () => { +worksFor(["3.0.0", "3.1.0"], ({ diagnoseOpenApiFor, openApiFor }) => { async function expectStatusCodes(code: string, statusCodes: string[]) { const res = await openApiFor(code); deepStrictEqual(Object.keys(res.paths["/"].get.responses), statusCodes); diff --git a/packages/openapi3/test/string-template.test.ts b/packages/openapi3/test/string-template.test.ts index 0412a6112e4..e89d72cad8d 100644 --- a/packages/openapi3/test/string-template.test.ts +++ b/packages/openapi3/test/string-template.test.ts @@ -1,9 +1,9 @@ import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual } from "assert"; import { describe, it } from "vitest"; -import { emitOpenApiWithDiagnostics, openApiFor } from "./test-host.js"; +import { worksFor } from "./works-for.js"; -describe("openapi3: string templates", () => { +worksFor(["3.0.0", "3.1.0"], ({ emitOpenApiWithDiagnostics, openApiFor }) => { describe("handle interpolating literals", () => { it("string", async () => { const schemas = await openApiFor(` diff --git a/packages/openapi3/test/tagmetadata.test.ts b/packages/openapi3/test/tagmetadata.test.ts index d5380a23ac2..8b7e4ea1c08 100644 --- a/packages/openapi3/test/tagmetadata.test.ts +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -1,9 +1,8 @@ import { deepStrictEqual } from "assert"; -import { describe, it } from "vitest"; +import { it } from "vitest"; +import { worksFor } from "./works-for.js"; -import { openApiFor } from "./test-host.js"; - -describe("emit results when set value with @tagMetadata decorator", () => { +worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { const testCases: [string, string, string, any][] = [ [ "set tag metadata", diff --git a/packages/openapi3/test/test-host.ts b/packages/openapi3/test/test-host.ts index 2be1f42e0fd..ed6bd7b743b 100644 --- a/packages/openapi3/test/test-host.ts +++ b/packages/openapi3/test/test-host.ts @@ -1,4 +1,4 @@ -import { Diagnostic, interpolatePath } from "@typespec/compiler"; +import { Diagnostic, interpolatePath, resolvePath } from "@typespec/compiler"; import { createTestHost, createTestWrapper, @@ -29,8 +29,9 @@ export async function createOpenAPITestHost() { } export async function createOpenAPITestRunner({ + emitterOptions, withVersioning, -}: { withVersioning?: boolean } = {}) { +}: { withVersioning?: boolean; emitterOptions?: OpenAPI3EmitterOptions } = {}) { const host = await createOpenAPITestHost(); const importAndUsings = ` import "@typespec/http"; @@ -49,6 +50,9 @@ export async function createOpenAPITestRunner({ wrapper: (code) => `${importAndUsings} ${code}`, compilerOptions: { emit: ["@typespec/openapi3"], + options: { + "@typespec/openapi3": { ...emitterOptions }, + }, }, }); } @@ -112,20 +116,32 @@ export async function openApiFor( } } -export async function checkFor(code: string) { +export async function checkFor(code: string, options: OpenAPI3EmitterOptions = {}) { const host = await createOpenAPITestRunner(); - return await host.diagnose(code); + return await host.diagnose(code, { + noEmit: true, + emit: ["@typespec/openapi3"], + options: { "@typespec/openapi3": { ...options } }, + }); } -export async function oapiForModel(name: string, modelDef: string) { - const oapi = await openApiFor(` +export async function oapiForModel( + name: string, + modelDef: string, + options: OpenAPI3EmitterOptions = {}, +) { + const oapi = await openApiFor( + ` ${modelDef}; @service({title: "Testing model"}) @route("/") namespace root { op read(): { @body body: ${name} }; } - `); + `, + undefined, + options, + ); const useSchema = oapi.paths["/"].get.responses[200].content["application/json"].schema; @@ -135,3 +151,23 @@ export async function oapiForModel(name: string, modelDef: string) { schemas: oapi.components.schemas || {}, }; } + +export async function openapiWithOptions( + code: string, + options: OpenAPI3EmitterOptions, +): Promise { + const runner = await createOpenAPITestRunner(); + + const outPath = resolvePath("/openapi.json"); + + const diagnostics = await runner.diagnose(code, { + noEmit: false, + emit: ["@typespec/openapi3"], + options: { "@typespec/openapi3": { ...options, "output-file": outPath } }, + }); + + expectDiagnosticEmpty(diagnostics); + + const content = runner.fs.get(outPath)!; + return JSON.parse(content); +} diff --git a/packages/openapi3/test/union-schema.test.ts b/packages/openapi3/test/union-schema.test.ts index 7af9f0136d1..c8aad08112b 100644 --- a/packages/openapi3/test/union-schema.test.ts +++ b/packages/openapi3/test/union-schema.test.ts @@ -1,9 +1,9 @@ import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual, ok, strictEqual } from "assert"; import { describe, expect, it } from "vitest"; -import { diagnoseOpenApiFor, oapiForModel, openApiFor } from "./test-host.js"; +import { worksFor } from "./works-for.js"; -describe("openapi3: union type", () => { +worksFor(["3.0.0", "3.1.0"], ({ diagnoseOpenApiFor, oapiForModel, openApiFor }) => { it("handles discriminated unions", async () => { const res = await openApiFor( ` @@ -160,51 +160,6 @@ describe("openapi3: union type", () => { }); }); - it("defines nullable properties with multiple variants", async () => { - const res = await oapiForModel( - "Pet", - ` - model Pet { - name: int32 | string | null; - }; - `, - ); - ok(res.isRef); - ok(res.schemas.Pet.properties.name.nullable); - deepStrictEqual(res.schemas.Pet.properties.name.anyOf, [ - { - type: "integer", - format: "int32", - }, - { - type: "string", - }, - ]); - }); - - it("defines enums with a nullable variant", async () => { - const res = await oapiForModel( - "Pet", - ` - model Pet { - type: "cat" | "dog" | null; - }; - `, - ); - ok(res.isRef); - deepStrictEqual(res.schemas.Pet, { - type: "object", - properties: { - type: { - type: "string", - enum: ["cat", "dog"], - nullable: true, - }, - }, - required: ["type"], - }); - }); - it("handles unions of heterogenous types", async () => { const res = await oapiForModel( "X", @@ -463,15 +418,6 @@ describe("openapi3: union type", () => { }); }); - it("throws diagnostics for null enum definitions", async () => { - const diagnostics = await diagnoseOpenApiFor(`union Pet {null}`); - - expectDiagnostics(diagnostics, { - code: "@typespec/openapi3/union-null", - message: "Cannot have a union containing only null types.", - }); - }); - it("supports description on unions that reduce to enums", async () => { const res = await oapiForModel( "Foo", @@ -527,70 +473,234 @@ describe("openapi3: union type", () => { strictEqual(variant.description, undefined); } }); +}); + +worksFor(["3.0.0"], ({ diagnoseOpenApiFor, oapiForModel, openApiFor }) => { + describe("openapi 3.0.0 union with null", () => { + it("defines nullable properties with multiple variants", async () => { + const res = await oapiForModel( + "Pet", + ` + model Pet { + name: int32 | string | null; + }; + `, + ); + ok(res.isRef); + ok(res.schemas.Pet.properties.name.nullable); + deepStrictEqual(res.schemas.Pet.properties.name.anyOf, [ + { + type: "integer", + format: "int32", + }, + { + type: "string", + }, + ]); + }); - it("type property should always be set when nullable property is present", async () => { - const openApi = await openApiFor(` + it("defines enums with a nullable variant", async () => { + const res = await oapiForModel( + "Pet", + ` + model Pet { + type: "cat" | "dog" | null; + }; + `, + ); + ok(res.isRef); + deepStrictEqual(res.schemas.Pet, { + type: "object", + properties: { + type: { + type: "string", + enum: ["cat", "dog"], + nullable: true, + }, + }, + required: ["type"], + }); + }); + + it("type property should always be set when nullable property is present", async () => { + const openApi = await openApiFor(` scalar MyStr extends string; model Foo {}; model A { x: MyStr |Foo| null; } `); - deepStrictEqual(openApi.components.schemas.A.properties, { - x: { - anyOf: [ - { - type: "object", - allOf: [{ $ref: "#/components/schemas/MyStr" }], - nullable: true, - }, - { - type: "object", - allOf: [{ $ref: "#/components/schemas/Foo" }], - nullable: true, - }, - ], - }, + deepStrictEqual(openApi.components.schemas.A.properties, { + x: { + anyOf: [ + { + type: "object", + allOf: [{ $ref: "#/components/schemas/MyStr" }], + nullable: true, + }, + { + type: "object", + allOf: [{ $ref: "#/components/schemas/Foo" }], + nullable: true, + }, + ], + }, + }); + }); + + it("scalar type property should always be set when nullable property is present", async () => { + const openApi = await openApiFor(` + model Foo {}; + model A { + x: Foo |string| null; + } + `); + deepStrictEqual(openApi.components.schemas.A.properties, { + x: { + anyOf: [ + { + type: "object", + allOf: [{ $ref: "#/components/schemas/Foo" }], + nullable: true, + }, + { + type: "string", + nullable: true, + }, + ], + }, + }); + }); + + describe("null and another single variant produce allOf", () => { + it.each([ + ["model", "model Other {}"], + ["enum", "enum Other {a, b}"], + ])("%s variant", async (_, code) => { + const openApi = await openApiFor(` + union Test { Other, null } + ${code} + `); + + expect(openApi.components.schemas.Test).toMatchObject({ + allOf: [{ $ref: "#/components/schemas/Other" }], + nullable: true, + }); + }); + }); + + it("throws diagnostics for null enum definitions", async () => { + const diagnostics = await diagnoseOpenApiFor(`union Pet {null}`); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi3/union-null", + message: "Cannot have a union containing only null types.", + }); }); }); +}); - it("scalar type property should always be set when nullable property is present", async () => { - const openApi = await openApiFor(` - model Foo {}; - model A { - x: Foo |string| null; - } - `); - deepStrictEqual(openApi.components.schemas.A.properties, { - x: { - anyOf: [ - { - type: "object", - allOf: [{ $ref: "#/components/schemas/Foo" }], - nullable: true, - }, - { - type: "string", - nullable: true, +worksFor(["3.1.0"], ({ oapiForModel, openApiFor }) => { + describe("openapi 3.1.0 union with null", () => { + it("defines nullable properties with multiple variants", async () => { + const res = await oapiForModel( + "Pet", + ` + model Pet { + name: int32 | string | null; + }; + `, + ); + ok(res.isRef); + deepStrictEqual(res.schemas.Pet.properties.name.anyOf, [ + { + type: "integer", + format: "int32", + }, + { + type: "string", + }, + { + type: "null", + }, + ]); + }); + + it("defines enums with a nullable variant", async () => { + const res = await oapiForModel( + "Pet", + ` + model Pet { + type: "cat" | "dog" | null; + }; + `, + ); + ok(res.isRef); + deepStrictEqual(res.schemas.Pet, { + type: "object", + properties: { + type: { + anyOf: [ + { + type: "string", + enum: ["cat", "dog"], + }, + { + type: "null", + }, + ], }, - ], - }, + }, + required: ["type"], + }); }); - }); - describe("null and another single variant produce allOf", () => { - it.each([ - ["model", "model Other {}"], - ["enum", "enum Other {a, b}"], - ])("%s variant", async (_, code) => { + it("supports refs and nullable property present", async () => { const openApi = await openApiFor(` + scalar MyStr extends string; + model Foo {}; + model A { + x: MyStr | Foo | null; + } + `); + deepStrictEqual(openApi.components.schemas.A.properties, { + x: { + anyOf: [ + { + $ref: "#/components/schemas/MyStr", + }, + { + $ref: "#/components/schemas/Foo", + }, + { + type: "null", + }, + ], + }, + }); + }); + + describe("null and another single variant produce anyOf", () => { + it.each([ + ["model", "model Other {}"], + ["enum", "enum Other {a, b}"], + ])("%s variant", async (_, code) => { + const openApi = await openApiFor(` union Test { Other, null } ${code} `); - expect(openApi.components.schemas.Test).toMatchObject({ - allOf: [{ $ref: "#/components/schemas/Other" }], - nullable: true, + expect(openApi.components.schemas.Test).toMatchObject({ + anyOf: [{ $ref: "#/components/schemas/Other" }, { type: "null" }], + }); + }); + }); + + it("supports null enum definitions", async () => { + const openApi = await openApiFor(`union Pet {null}`); + + deepStrictEqual(openApi.components.schemas.Pet, { + type: "null", }); }); }); diff --git a/packages/openapi3/test/versioning.test.ts b/packages/openapi3/test/versioning.test.ts index 400a4cf8f3e..10b59a3ff07 100644 --- a/packages/openapi3/test/versioning.test.ts +++ b/packages/openapi3/test/versioning.test.ts @@ -1,10 +1,11 @@ -import { DecoratorContext, Namespace, getNamespaceFullName } from "@typespec/compiler"; +import { DecoratorContext, getNamespaceFullName, Namespace } from "@typespec/compiler"; import { createTestWrapper, expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual, strictEqual } from "assert"; import { describe, it } from "vitest"; -import { createOpenAPITestHost, createOpenAPITestRunner, openApiFor } from "./test-host.js"; +import { createOpenAPITestHost, createOpenAPITestRunner } from "./test-host.js"; +import { worksFor } from "./works-for.js"; -describe("openapi3: versioning", () => { +worksFor(["3.0.0", "3.1.0"], ({ openApiFor, version: specVersion }) => { it("works with models", async () => { const { v1, v2, v3 } = await openApiFor( ` @@ -120,7 +121,10 @@ describe("openapi3: versioning", () => { const runner = createTestWrapper(host, { autoImports: [...host.libraries.map((x) => x.name), "./test.js"], autoUsings: ["TypeSpec.Rest", "TypeSpec.Http", "TypeSpec.OpenAPI", "TypeSpec.Versioning"], - compilerOptions: { emit: ["@typespec/openapi3"] }, + compilerOptions: { + emit: ["@typespec/openapi3"], + options: { "@typespec/openapi3": { "openapi-versions": [specVersion] } }, + }, }); await runner.compile(` @@ -152,7 +156,10 @@ describe("openapi3: versioning", () => { it("doesn't throw errors when using UpdateableProperties", async () => { // if this test throws a duplicate name diagnostic, check that getEffectiveType // is returning the projected type. - const runner = await createOpenAPITestRunner({ withVersioning: true }); + const runner = await createOpenAPITestRunner({ + withVersioning: true, + emitterOptions: { "openapi-versions": [specVersion] }, + }); await runner.compile(` @versioned(Library.Versions) namespace Library { @@ -179,7 +186,10 @@ describe("openapi3: versioning", () => { describe("versioned resource", () => { it("reports diagnostic without crashing for mismatched versions", async () => { - const runner = await createOpenAPITestRunner({ withVersioning: true }); + const runner = await createOpenAPITestRunner({ + withVersioning: true, + emitterOptions: { "openapi-versions": [specVersion] }, + }); const diagnostics = await runner.diagnose(` @versioned(Versions) @service @@ -216,7 +226,10 @@ describe("openapi3: versioning", () => { }); it("succeeds for aligned versions", async () => { - const runner = await createOpenAPITestRunner({ withVersioning: true }); + const runner = await createOpenAPITestRunner({ + withVersioning: true, + emitterOptions: { "openapi-versions": [specVersion] }, + }); await runner.compile(` @versioned(Versions) @service diff --git a/packages/openapi3/test/works-for.ts b/packages/openapi3/test/works-for.ts new file mode 100644 index 00000000000..3bcd0d71eaa --- /dev/null +++ b/packages/openapi3/test/works-for.ts @@ -0,0 +1,53 @@ +import { describe } from "vitest"; +import { OpenAPIVersion } from "../src/lib.js"; +import { + checkFor, + diagnoseOpenApiFor, + emitOpenApiWithDiagnostics, + oapiForModel, + openApiFor, + openapiWithOptions, +} from "./test-host.js"; + +export const OpenAPISpecHelpers: Record = { + "3.0.0": createSpecHelpers("3.0.0"), + "3.1.0": createSpecHelpers("3.1.0"), +}; + +export type SpecHelper = { + version: OpenAPIVersion; + oapiForModel: typeof oapiForModel; + openApiFor: typeof openApiFor; + openapiWithOptions: typeof openapiWithOptions; + checkFor: typeof checkFor; + diagnoseOpenApiFor: typeof diagnoseOpenApiFor; + emitOpenApiWithDiagnostics: typeof emitOpenApiWithDiagnostics; +}; + +export type WorksForCb = (specHelpers: SpecHelper) => void; + +function createSpecHelpers(version: OpenAPIVersion): SpecHelper { + return { + version, + oapiForModel: (...[name, modelDef, options]: Parameters) => + oapiForModel(name, modelDef, { ...options, "openapi-versions": [version] }), + openApiFor: (...[code, versions, options]: Parameters) => + openApiFor(code, versions, { ...options, "openapi-versions": [version] }), + openapiWithOptions: (...[code, options]: Parameters) => + openapiWithOptions(code, { ...options, "openapi-versions": [version] }), + checkFor: (...[code, options]: Parameters) => + checkFor(code, { ...options, "openapi-versions": [version] }), + diagnoseOpenApiFor: (...[code, options]: Parameters) => + diagnoseOpenApiFor(code, { ...options, "openapi-versions": [version] }), + emitOpenApiWithDiagnostics: ( + ...[code, options]: Parameters + ) => emitOpenApiWithDiagnostics(code, { ...options, "openapi-versions": [version] }), + }; +} + +export function worksFor(versions: OpenAPIVersion[], cb: WorksForCb) { + const specsHelpers = versions.map((version) => OpenAPISpecHelpers[version]); + describe.each(specsHelpers)("openapi $version", (specHelpers) => { + cb(specHelpers); + }); +} diff --git a/packages/openapi3/test/xml-models.test.ts b/packages/openapi3/test/xml-models.test.ts index a6962a182a8..3b2bbd0f6b3 100644 --- a/packages/openapi3/test/xml-models.test.ts +++ b/packages/openapi3/test/xml-models.test.ts @@ -1,217 +1,199 @@ import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual } from "assert"; import { describe, expect, it } from "vitest"; -import { createOpenAPITestRunner, emitOpenApiWithDiagnostics, oapiForModel } from "./test-host.js"; +import { createOpenAPITestRunner } from "./test-host.js"; +import { worksFor } from "./works-for.js"; -describe("@name", () => { - it("set xml.name for schema", async () => { - const res = await oapiForModel( - "Book", - ` +worksFor(["3.0.0", "3.1.0"], ({ emitOpenApiWithDiagnostics, oapiForModel }) => { + describe("@name", () => { + it("set xml.name for schema", async () => { + const res = await oapiForModel( + "Book", + ` @name("xmlBook") model Book { content: string; };`, - ); + ); - expect(res.schemas.Book).toMatchInlineSnapshot( - ` - { - "properties": { - "content": { - "type": "string", + deepStrictEqual(res.schemas.Book, { + properties: { + content: { + type: "string", }, }, - "required": [ - "content", - ], - "type": "object", - "xml": { - "name": "xmlBook", + required: ["content"], + type: "object", + xml: { + name: "xmlBook", }, - } - `, - ); - }); + }); + }); - it("set the element value for array property via @name", async () => { - const res = await oapiForModel( - "Book", - ` + it("set the element value for array property via @name", async () => { + const res = await oapiForModel( + "Book", + ` @name("xmlBook") model Book { tags: string[]; };`, - ); + ); - expect(res.schemas.Book).toMatchInlineSnapshot(` - { - "properties": { - "tags": { - "items": { - "type": "string", - "xml": { - "name": "string", + deepStrictEqual(res.schemas.Book, { + properties: { + tags: { + items: { + type: "string", + xml: { + name: "string", }, }, - "type": "array", - "xml": { - "wrapped": true, + type: "array", + xml: { + wrapped: true, }, }, }, - "required": [ - "tags", - ], - "type": "object", - "xml": { - "name": "xmlBook", + required: ["tags"], + type: "object", + xml: { + name: "xmlBook", }, - } - `); - }); + }); + }); - it.each([ - ["string", "string", "string"], - ["array", "string[]", "array"], - ["number", "numeric", "number"], - ["enum", `"a" | "b"`, "string"], - ])(`%s => %s`, async (_, type, output) => { - const res = await oapiForModel( - "Book", - `model Book { + it.each([ + ["string", "string", "string"], + ["array", "string[]", "array"], + ["number", "numeric", "number"], + ["enum", `"a" | "b"`, "string"], + ])(`%s => %s`, async (_, type, output) => { + const res = await oapiForModel( + "Book", + `model Book { @name("xmlcontent") content: ${type}; };`, - ); - expect(res.schemas.Book).toMatchObject({ - properties: { - content: { type: `${output}`, xml: { name: "xmlcontent" } }, - }, - required: ["content"], + ); + expect(res.schemas.Book).toMatchObject({ + properties: { + content: { type: `${output}`, xml: { name: "xmlcontent" } }, + }, + required: ["content"], + }); }); - }); - it.each([ - ["object", "unknown"], - ["Union", `string | numeric`], - ])(`%s => %s`, async (_, type) => { - const res = await oapiForModel( - "Book", - `model Book { + it.each([ + ["object", "unknown"], + ["Union", `string | numeric`], + ])(`%s => %s`, async (_, type) => { + const res = await oapiForModel( + "Book", + `model Book { @name("xmlcontent") content: ${type}; };`, - ); - expect(res.schemas.Book).toMatchObject({ - properties: { - content: { xml: { name: "xmlcontent" } }, - }, - required: ["content"], + ); + expect(res.schemas.Book).toMatchObject({ + properties: { + content: { xml: { name: "xmlcontent" } }, + }, + required: ["content"], + }); }); - }); - it("set the value on scalar via @name", async () => { - const res = await oapiForModel( - "Book", - ` + it("set the value on scalar via @name", async () => { + const res = await oapiForModel( + "Book", + ` @name("xmlBook") scalar Book extends string;`, - ); + ); - expect(res.schemas.Book).toMatchInlineSnapshot(` - { - "type": "string", - "xml": { - "name": "xmlBook", + deepStrictEqual(res.schemas.Book, { + type: "string", + xml: { + name: "xmlBook", }, - } - `); - }); + }); + }); - it("compare with the json name", async () => { - const res = await oapiForModel( - "Book", - ` + it("compare with the json name", async () => { + const res = await oapiForModel( + "Book", + ` model Book { @name("xmlContent") @encodedName("application/json", "jsonContent") content: string; };`, - ); + ); - expect(res.schemas.Book).toMatchInlineSnapshot(` - { - "properties": { - "jsonContent": { - "type": "string", - "xml": { - "name": "xmlContent", + deepStrictEqual(res.schemas.Book, { + properties: { + jsonContent: { + type: "string", + xml: { + name: "xmlContent", }, }, }, - "required": [ - "jsonContent", - ], - "type": "object", - } - `); - }); + required: ["jsonContent"], + type: "object", + }); + }); - it("set the json name and no xml name", async () => { - const res = await oapiForModel( - "Book", - ` + it("set the json name and no xml name", async () => { + const res = await oapiForModel( + "Book", + ` model Book { @encodedName("application/json", "jsonContent") content: string; };`, - ); + ); - expect(res.schemas.Book).toMatchInlineSnapshot(` - { - "properties": { - "jsonContent": { - "type": "string", + deepStrictEqual(res.schemas.Book, { + properties: { + jsonContent: { + type: "string", }, }, - "required": [ - "jsonContent", - ], - "type": "object", - } - `); + required: ["jsonContent"], + type: "object", + }); + }); }); -}); -describe("@attribute", () => { - it.each([ - ["numeric", "numeric"], - ["string", "string"], - ["unknown", "unknown"], - ["Union", `string | numeric`], - ["enum", `"a" | "b"`], - ])(`%s => %s`, async (_, type) => { - const res = await oapiForModel( - "Book", - `model Book { + describe("@attribute", () => { + it.each([ + ["numeric", "numeric"], + ["string", "string"], + ["unknown", "unknown"], + ["Union", `string | numeric`], + ["enum", `"a" | "b"`], + ])(`%s => %s`, async (_, type) => { + const res = await oapiForModel( + "Book", + `model Book { @attribute id: ${type}; };`, - ); - expect(res.schemas.Book).toMatchObject({ - properties: { - id: { xml: { attribute: true } }, - }, + ); + expect(res.schemas.Book).toMatchObject({ + properties: { + id: { xml: { attribute: true } }, + }, + }); }); - }); - describe("warning and change the type to be type: string if attribute is object/objects", () => { - it.each([ - ["one", "Tag"], - ["ones", "Tag[]"], - ])(`%s => %s`, async (_, type) => { - const [res, diagnostics] = await emitOpenApiWithDiagnostics(` + describe("warning and change the type to be type: string if attribute is object/objects", () => { + it.each([ + ["one", "Tag"], + ["ones", "Tag[]"], + ])(`%s => %s`, async (_, type) => { + const [res, diagnostics] = await emitOpenApiWithDiagnostics(` model Tag { name: string; } @@ -221,106 +203,98 @@ describe("@attribute", () => { } `); - expectDiagnostics(diagnostics, { - code: "@typespec/openapi3/xml-attribute-invalid-property-type", - message: `XML \`@attribute\` can only be primitive types in the OpenAPI 3 emitter, Property 'tags' type will be changed to type: string.`, - }); - deepStrictEqual( - res.components?.schemas?.Pet, + expectDiagnostics(diagnostics, { + code: "@typespec/openapi3/xml-attribute-invalid-property-type", + message: `XML \`@attribute\` can only be primitive types in the OpenAPI 3 emitter, Property 'tags' type will be changed to type: string.`, + }); + deepStrictEqual( + res.components?.schemas?.Pet, - { - properties: { - tags: { - type: "string", - xml: { - attribute: true, + { + properties: { + tags: { + type: "string", + xml: { + attribute: true, + }, }, }, + required: ["tags"], + type: "object", }, - required: ["tags"], - type: "object", - }, - ); + ); + }); }); }); -}); -describe("@unwrapped", () => { - it("warning if unwrapped not array", async () => { - const runner = await createOpenAPITestRunner(); - const diagnostics = await runner.diagnose( - `model Book { + describe("@unwrapped", () => { + it("warning if unwrapped not array", async () => { + const runner = await createOpenAPITestRunner(); + const diagnostics = await runner.diagnose( + `model Book { @unwrapped id: string; };`, - ); - expectDiagnostics(diagnostics, { - code: "@typespec/openapi3/xml-unwrapped-invalid-property-type", - message: `XML \`@unwrapped\` can only used on array properties or primitive ones in the OpenAPI 3 emitter, Property 'id' will be ignored.`, + ); + expectDiagnostics(diagnostics, { + code: "@typespec/openapi3/xml-unwrapped-invalid-property-type", + message: `XML \`@unwrapped\` can only used on array properties or primitive ones in the OpenAPI 3 emitter, Property 'id' will be ignored.`, + }); }); }); -}); -describe("@ns", () => { - it("provide the namespace and prefix", async () => { - const res = await oapiForModel( - "Book", - ` + describe("@ns", () => { + it("provide the namespace and prefix", async () => { + const res = await oapiForModel( + "Book", + ` @ns("https://example.com/ns1", "ns1") model Book { id: string; };`, - ); - expect(res.schemas.Book).toMatchInlineSnapshot(` - { - "properties": { - "id": { - "type": "string", + ); + deepStrictEqual(res.schemas.Book, { + properties: { + id: { + type: "string", }, }, - "required": [ - "id", - ], - "type": "object", - "xml": { - "namespace": "https://example.com/ns1", - "prefix": "ns1", + required: ["id"], + type: "object", + xml: { + namespace: "https://example.com/ns1", + prefix: "ns1", }, - } - `); - }); + }); + }); - it("provide the namespace and prefix using string", async () => { - const res = await oapiForModel( - "Book", - `model Book { + it("provide the namespace and prefix using string", async () => { + const res = await oapiForModel( + "Book", + `model Book { @ns("https://example.com/ns1", "ns1") id: string; };`, - ); - expect(res.schemas.Book).toMatchInlineSnapshot(` - { - "properties": { - "id": { - "type": "string", - "xml": { - "namespace": "https://example.com/ns1", - "prefix": "ns1", + ); + deepStrictEqual(res.schemas.Book, { + properties: { + id: { + type: "string", + xml: { + namespace: "https://example.com/ns1", + prefix: "ns1", }, }, }, - "required": [ - "id", - ], - "type": "object", - } - `); - }); + required: ["id"], + type: "object", + }); + }); - it("provide the namespace and prefix using enum on model", async () => { - const res = await oapiForModel( - "Book", - ` + it("provide the namespace and prefix using enum on model", async () => { + const res = await oapiForModel( + "Book", + ` @nsDeclarations enum Namespaces { smp:"http://example.com/schema", @@ -330,30 +304,26 @@ describe("@ns", () => { model Book { id: string; };`, - ); - expect(res.schemas.Book).toMatchInlineSnapshot(` - { - "properties": { - "id": { - "type": "string", + ); + deepStrictEqual(res.schemas.Book, { + properties: { + id: { + type: "string", }, }, - "required": [ - "id", - ], - "type": "object", - "xml": { - "namespace": "http://example.com/schema", - "prefix": "smp", + required: ["id"], + type: "object", + xml: { + namespace: "http://example.com/schema", + prefix: "smp", }, - } - `); - }); + }); + }); - it("provide the namespace and prefix using enum on model and properties", async () => { - const res = await oapiForModel( - "Book", - ` + it("provide the namespace and prefix using enum on model and properties", async () => { + const res = await oapiForModel( + "Book", + ` @nsDeclarations enum Namespaces { smp:"http://example.com/schema", @@ -368,219 +338,205 @@ describe("@ns", () => { @ns(Namespaces.ns2) author: string; };`, - ); - expect(res.schemas.Book).toMatchInlineSnapshot(` - { - "properties": { - "author": { - "type": "string", - "xml": { - "namespace": "http://example.com/ns2", - "prefix": "ns2", + ); + deepStrictEqual(res.schemas.Book, { + properties: { + author: { + type: "string", + xml: { + namespace: "http://example.com/ns2", + prefix: "ns2", }, }, - "id": { - "type": "string", + id: { + type: "string", }, - "title": { - "type": "string", - "xml": { - "namespace": "http://example.com/schema", - "prefix": "smp", + title: { + type: "string", + xml: { + namespace: "http://example.com/schema", + prefix: "smp", }, }, }, - "required": [ - "id", - "title", - "author", - ], - "type": "object", - "xml": { - "namespace": "http://example.com/schema", - "prefix": "smp", + required: ["id", "title", "author"], + type: "object", + xml: { + namespace: "http://example.com/schema", + prefix: "smp", }, - } - `); + }); + }); }); -}); -describe("Array of primitive types", () => { - it("unwrapped tags array in the xmlBook model.", async () => { - const res = await oapiForModel( - "Book", - ` + describe("Array of primitive types", () => { + it("unwrapped tags array in the xmlBook model.", async () => { + const res = await oapiForModel( + "Book", + ` @name("xmlBook") model Book { @unwrapped tags: string[]; };`, - ); + ); - expect(res.schemas.Book).toMatchInlineSnapshot(` - { - "properties": { - "tags": { - "items": { - "type": "string", - "xml": { - "name": "tags", + deepStrictEqual(res.schemas.Book, { + properties: { + tags: { + items: { + type: "string", + xml: { + name: "tags", }, }, - "type": "array", + type: "array", }, }, - "required": [ - "tags", - ], - "type": "object", - "xml": { - "name": "xmlBook", + required: ["tags"], + type: "object", + xml: { + name: "xmlBook", }, - } - `); - }); + }); + }); - it("wrapped tags array in the xmlBook model.", async () => { - const res = await oapiForModel( - "Book", - ` + it("wrapped tags array in the xmlBook model.", async () => { + const res = await oapiForModel( + "Book", + ` @name("xmlBook") model Book { @name("ItemsTags") tags: string[]; };`, - ); - - expect(res.schemas.Book).toMatchInlineSnapshot(` - { - "properties": { - "tags": { - "items": { - "type": "string", - "xml": { - "name": "string", - }, - }, - "type": "array", - "xml": { - "name": "ItemsTags", - "wrapped": true, - }, - }, - }, - "required": [ - "tags", - ], - "type": "object", - "xml": { - "name": "xmlBook", - }, - } - `); - }); - - describe("scalar, @xml.unwrapped=true, and rename xml name.", () => { - it.each([ - ["string", "string"], - ["numeric", "number"], - ["integer", "integer"], - ["float", "number"], - ["boolean", "boolean"], - ])(`%s => %s`, async (target, type) => { - const res = await oapiForModel( - "Book", - `@name("ItemsName") - scalar tag extends ${target}; - - @name("xmlBook") - model Book { - @unwrapped - tags: tag[] - };`, ); - expect(res.schemas.tag).toMatchObject({ - type: `${type}`, - xml: { - name: "ItemsName", - }, - }); - expect(res.schemas.Book).toMatchObject({ - type: "object", + deepStrictEqual(res.schemas.Book, { properties: { tags: { - type: "array", items: { - type: `${type}`, - xml: { name: "tags" }, + type: "string", + xml: { + name: "string", + }, + }, + type: "array", + xml: { + name: "ItemsTags", + wrapped: true, }, }, }, required: ["tags"], + type: "object", xml: { name: "xmlBook", }, }); }); - }); - describe("scalar, @xml.unwrapped=false, and rename xml name.", () => { - it.each([ - ["string", "string"], - ["numeric", "number"], - ["integer", "integer"], - ["float", "number"], - ["boolean", "boolean"], - ])(`%s => %s`, async (target, type) => { - const res = await oapiForModel( - "Book", - `@name("ItemsName") + describe("scalar, @xml.unwrapped=true, and rename xml name.", () => { + it.each([ + ["string", "string"], + ["numeric", "number"], + ["integer", "integer"], + ["float", "number"], + ["boolean", "boolean"], + ])(`%s => %s`, async (target, type) => { + const res = await oapiForModel( + "Book", + `@name("ItemsName") scalar tag extends ${target}; @name("xmlBook") model Book { - @name("ItemsTags") + @unwrapped tags: tag[] };`, - ); - expect(res.schemas.tag).toMatchObject({ - type: `${type}`, - xml: { - name: "ItemsName", - }, - }); + ); + expect(res.schemas.tag).toMatchObject({ + type: `${type}`, + xml: { + name: "ItemsName", + }, + }); - expect(res.schemas.Book).toMatchObject({ - type: "object", - properties: { - tags: { - type: "array", - xml: { - name: "ItemsTags", - wrapped: true, + expect(res.schemas.Book).toMatchObject({ + type: "object", + properties: { + tags: { + type: "array", + items: { + type: `${type}`, + xml: { name: "tags" }, + }, }, - items: { - type: `${type}`, + }, + required: ["tags"], + xml: { + name: "xmlBook", + }, + }); + }); + }); + + describe("scalar, @xml.unwrapped=false, and rename xml name.", () => { + it.each([ + ["string", "string"], + ["numeric", "number"], + ["integer", "integer"], + ["float", "number"], + ["boolean", "boolean"], + ])(`%s => %s`, async (target, type) => { + const res = await oapiForModel( + "Book", + `@name("ItemsName") + scalar tag extends ${target}; + + @name("xmlBook") + model Book { + @name("ItemsTags") + tags: tag[] + };`, + ); + expect(res.schemas.tag).toMatchObject({ + type: `${type}`, + xml: { + name: "ItemsName", + }, + }); + + expect(res.schemas.Book).toMatchObject({ + type: "object", + properties: { + tags: { + type: "array", xml: { - name: "ItemsName", + name: "ItemsTags", + wrapped: true, + }, + items: { + type: `${type}`, + xml: { + name: "ItemsName", + }, }, }, }, - }, - required: ["tags"], - xml: { - name: "xmlBook", - }, + required: ["tags"], + xml: { + name: "xmlBook", + }, + }); }); }); }); -}); -describe("Complex array types", () => { - it("unwrapped the tags object array in the XmlPet model.", async () => { - const res = await oapiForModel( - "Pet", - ` + describe("Complex array types", () => { + it("unwrapped the tags object array in the XmlPet model.", async () => { + const res = await oapiForModel( + "Pet", + ` @name("XmlPet") model Pet { @unwrapped @@ -591,56 +547,48 @@ describe("Complex array types", () => { model Tag { name: string; }`, - ); + ); - expect(res.schemas.Tag).toMatchInlineSnapshot(` - { - "properties": { - "name": { - "type": "string", + deepStrictEqual(res.schemas.Tag, { + properties: { + name: { + type: "string", }, }, - "required": [ - "name", - ], - "type": "object", - "xml": { - "name": "XmlTag", + required: ["name"], + type: "object", + xml: { + name: "XmlTag", }, - } - `); - expect(res.schemas.Pet).toMatchInlineSnapshot(` - { - "properties": { - "tags": { - "items": { - "allOf": [ + }); + deepStrictEqual(res.schemas.Pet, { + properties: { + tags: { + items: { + allOf: [ { - "$ref": "#/components/schemas/Tag", + $ref: "#/components/schemas/Tag", }, ], - "xml": { - "name": "tags", + xml: { + name: "tags", }, }, - "type": "array", + type: "array", }, }, - "required": [ - "tags", - ], - "type": "object", - "xml": { - "name": "XmlPet", + required: ["tags"], + type: "object", + xml: { + name: "XmlPet", }, - } - `); - }); + }); + }); - it("wrapped the tags object array in the XmlPet model.", async () => { - const res = await oapiForModel( - "Pet", - ` + it("wrapped the tags object array in the XmlPet model.", async () => { + const res = await oapiForModel( + "Pet", + ` @name("XmlPet") model Pet { @name("ItemsTags") @@ -651,61 +599,53 @@ describe("Complex array types", () => { model Tag { name: string; }`, - ); + ); - expect(res.schemas.Tag).toMatchInlineSnapshot(` - { - "properties": { - "name": { - "type": "string", + deepStrictEqual(res.schemas.Tag, { + properties: { + name: { + type: "string", }, }, - "required": [ - "name", - ], - "type": "object", - "xml": { - "name": "XmlTag", + required: ["name"], + type: "object", + xml: { + name: "XmlTag", }, - } - `); + }); - expect(res.schemas.Pet).toMatchInlineSnapshot(` - { - "properties": { - "tags": { - "items": { - "allOf": [ + deepStrictEqual(res.schemas.Pet, { + properties: { + tags: { + items: { + allOf: [ { - "$ref": "#/components/schemas/Tag", + $ref: "#/components/schemas/Tag", }, ], - "xml": { - "name": "XmlTag", + xml: { + name: "XmlTag", }, }, - "type": "array", - "xml": { - "name": "ItemsTags", - "wrapped": true, + type: "array", + xml: { + name: "ItemsTags", + wrapped: true, }, }, }, - "required": [ - "tags", - ], - "type": "object", - "xml": { - "name": "XmlPet", + required: ["tags"], + type: "object", + xml: { + name: "XmlPet", }, - } - `); - }); + }); + }); - it("unwrapped and renamed Tags object array in xmlPet Model.", async () => { - const res = await oapiForModel( - "Pet", - ` + it("unwrapped and renamed Tags object array in xmlPet Model.", async () => { + const res = await oapiForModel( + "Pet", + ` @name("XmlPet") model Pet { @name("ItemsTags") @@ -717,55 +657,47 @@ describe("Complex array types", () => { model Tag { name: string; }`, - ); - expect(res.schemas.Tag).toMatchInlineSnapshot(` - { - "properties": { - "name": { - "type": "string", + ); + deepStrictEqual(res.schemas.Tag, { + properties: { + name: { + type: "string", }, }, - "required": [ - "name", - ], - "type": "object", - "xml": { - "name": "XmlTag", + required: ["name"], + type: "object", + xml: { + name: "XmlTag", }, - } - `); - expect(res.schemas.Pet).toMatchInlineSnapshot(` - { - "properties": { - "tags": { - "items": { - "allOf": [ + }); + deepStrictEqual(res.schemas.Pet, { + properties: { + tags: { + items: { + allOf: [ { - "$ref": "#/components/schemas/Tag", + $ref: "#/components/schemas/Tag", }, ], - "xml": { - "name": "ItemsTags", + xml: { + name: "ItemsTags", }, }, - "type": "array", + type: "array", }, }, - "required": [ - "tags", - ], - "type": "object", - "xml": { - "name": "XmlPet", + required: ["tags"], + type: "object", + xml: { + name: "XmlPet", }, - } - `); - }); + }); + }); - it("rename all names in array model.", async () => { - const res = await oapiForModel( - "Pet", - ` + it("rename all names in array model.", async () => { + const res = await oapiForModel( + "Pet", + ` @name("XmlPet") model Pet { @name("ItemsTags") @@ -776,472 +708,464 @@ describe("Complex array types", () => { model Tag { name: string; }`, - ); + ); - expect(res.schemas.Tag).toMatchInlineSnapshot(` - { - "properties": { - "name": { - "type": "string", + deepStrictEqual(res.schemas.Tag, { + properties: { + name: { + type: "string", }, }, - "required": [ - "name", - ], - "type": "object", - "xml": { - "name": "XmlTag", + required: ["name"], + type: "object", + xml: { + name: "XmlTag", }, - } - `); - expect(res.schemas.Pet).toMatchInlineSnapshot(` - { - "properties": { - "tags": { - "items": { - "allOf": [ + }); + deepStrictEqual(res.schemas.Pet, { + properties: { + tags: { + items: { + allOf: [ { - "$ref": "#/components/schemas/Tag", + $ref: "#/components/schemas/Tag", }, ], - "xml": { - "name": "XmlTag", + xml: { + name: "XmlTag", }, }, - "type": "array", - "xml": { - "name": "ItemsTags", - "wrapped": true, + type: "array", + xml: { + name: "ItemsTags", + wrapped: true, }, }, }, - "required": [ - "tags", - ], - "type": "object", - "xml": { - "name": "XmlPet", + required: ["tags"], + type: "object", + xml: { + name: "XmlPet", }, - } - `); + }); + }); }); -}); -describe("set xml name in items if that object is used in an xml payload.", () => { - const testCases: [string, string, string, any][] = [ - [ - "@name model, is arrays: true", - "model Book { author: Author[]; }", - `@name("xmlAuthor") model Author { id:string; }`, - { - Book: { - type: "object", - properties: { - author: { - type: "array", - items: { - allOf: [{ $ref: "#/components/schemas/Author" }], - xml: { name: "xmlAuthor" }, - }, - xml: { - wrapped: true, + describe("set xml name in items if that object is used in an xml payload.", () => { + const testCases: [string, string, string, any][] = [ + [ + "@name model, is arrays: true", + "model Book { author: Author[]; }", + `@name("xmlAuthor") model Author { id:string; }`, + { + Book: { + type: "object", + properties: { + author: { + type: "array", + items: { + allOf: [{ $ref: "#/components/schemas/Author" }], + xml: { name: "xmlAuthor" }, + }, + xml: { + wrapped: true, + }, }, }, + required: ["author"], }, - required: ["author"], - }, - Author: { - type: "object", - properties: { - id: { - type: "string", + Author: { + type: "object", + properties: { + id: { + type: "string", + }, }, + xml: { + name: "xmlAuthor", + }, + required: ["id"], }, - xml: { - name: "xmlAuthor", - }, - required: ["id"], }, - }, - ], - [ - "@name model property, is arrays: true", - "model Book { author: Author[]; }", - `model Author { @name("xmlId") id:string; }`, - { - Book: { - type: "object", - properties: { - author: { - type: "array", - items: { - allOf: [{ $ref: "#/components/schemas/Author" }], - xml: { name: "Author" }, - }, - xml: { - wrapped: true, + ], + [ + "@name model property, is arrays: true", + "model Book { author: Author[]; }", + `model Author { @name("xmlId") id:string; }`, + { + Book: { + type: "object", + properties: { + author: { + type: "array", + items: { + allOf: [{ $ref: "#/components/schemas/Author" }], + xml: { name: "Author" }, + }, + xml: { + wrapped: true, + }, }, }, + required: ["author"], }, - required: ["author"], - }, - Author: { - type: "object", - properties: { - id: { - type: "string", - xml: { - name: "xmlId", + Author: { + type: "object", + properties: { + id: { + type: "string", + xml: { + name: "xmlId", + }, }, }, + required: ["id"], }, - required: ["id"], }, - }, - ], - [ - "@attribute, is arrays: true", - "model Book { author: Author[]; }", - `model Author { @attribute id:string; }`, - { - Book: { - type: "object", - properties: { - author: { - type: "array", - items: { - allOf: [{ $ref: "#/components/schemas/Author" }], - xml: { name: "Author" }, - }, - xml: { - wrapped: true, + ], + [ + "@attribute, is arrays: true", + "model Book { author: Author[]; }", + `model Author { @attribute id:string; }`, + { + Book: { + type: "object", + properties: { + author: { + type: "array", + items: { + allOf: [{ $ref: "#/components/schemas/Author" }], + xml: { name: "Author" }, + }, + xml: { + wrapped: true, + }, }, }, + required: ["author"], }, - required: ["author"], - }, - Author: { - type: "object", - properties: { - id: { - type: "string", - xml: { - attribute: true, + Author: { + type: "object", + properties: { + id: { + type: "string", + xml: { + attribute: true, + }, }, }, + required: ["id"], }, - required: ["id"], }, - }, - ], - [ - "@attribute deeply, is arrays: true", - "model Book { author: Author[]; }", - `model Author { card: Card[]; } model Card { @attribute id:string;}`, - { - Book: { - type: "object", - properties: { - author: { - type: "array", - items: { - allOf: [{ $ref: "#/components/schemas/Author" }], - xml: { name: "Author" }, - }, - xml: { - wrapped: true, + ], + [ + "@attribute deeply, is arrays: true", + "model Book { author: Author[]; }", + `model Author { card: Card[]; } model Card { @attribute id:string;}`, + { + Book: { + type: "object", + properties: { + author: { + type: "array", + items: { + allOf: [{ $ref: "#/components/schemas/Author" }], + xml: { name: "Author" }, + }, + xml: { + wrapped: true, + }, }, }, + required: ["author"], }, - required: ["author"], - }, - Author: { - type: "object", - properties: { - card: { - type: "array", - items: { - allOf: [{ $ref: "#/components/schemas/Card" }], - xml: { name: "Card" }, - }, - xml: { - wrapped: true, + Author: { + type: "object", + properties: { + card: { + type: "array", + items: { + allOf: [{ $ref: "#/components/schemas/Card" }], + xml: { name: "Card" }, + }, + xml: { + wrapped: true, + }, }, }, + required: ["card"], }, - required: ["card"], - }, - Card: { - properties: { - id: { - type: "string", - xml: { - attribute: true, + Card: { + properties: { + id: { + type: "string", + xml: { + attribute: true, + }, }, }, + required: ["id"], + type: "object", }, - required: ["id"], - type: "object", }, - }, - ], - [ - "circular reference child, is arrays: true", - "model Book { author: Author[]; }", - `model Author { @attribute id: string; card: Card[]; } model Card { author:Author[];}`, - { - Book: { - type: "object", - properties: { - author: { - type: "array", - items: { - allOf: [{ $ref: "#/components/schemas/Author" }], - xml: { name: "Author" }, - }, - xml: { - wrapped: true, + ], + [ + "circular reference child, is arrays: true", + "model Book { author: Author[]; }", + `model Author { @attribute id: string; card: Card[]; } model Card { author:Author[];}`, + { + Book: { + type: "object", + properties: { + author: { + type: "array", + items: { + allOf: [{ $ref: "#/components/schemas/Author" }], + xml: { name: "Author" }, + }, + xml: { + wrapped: true, + }, }, }, + required: ["author"], }, - required: ["author"], - }, - Author: { - type: "object", - properties: { - card: { - type: "array", - items: { - allOf: [{ $ref: "#/components/schemas/Card" }], - xml: { name: "Card" }, + Author: { + type: "object", + properties: { + card: { + type: "array", + items: { + allOf: [{ $ref: "#/components/schemas/Card" }], + xml: { name: "Card" }, + }, + xml: { + wrapped: true, + }, }, - xml: { - wrapped: true, + id: { + type: "string", + xml: { + attribute: true, + }, }, }, - id: { - type: "string", - xml: { - attribute: true, + required: ["id", "card"], + }, + Card: { + properties: { + author: { + items: { + $ref: "#/components/schemas/Author", + }, + type: "array", }, }, + required: ["author"], + type: "object", }, - required: ["id", "card"], }, - Card: { - properties: { - author: { - items: { - $ref: "#/components/schemas/Author", + ], + [ + "circular reference root, is arrays: true", + "model Book { author: Author[]; }", + `model Author { @attribute id: string; book?: Book[]; }`, + { + Book: { + type: "object", + properties: { + author: { + type: "array", + items: { + allOf: [{ $ref: "#/components/schemas/Author" }], + xml: { name: "Author" }, + }, + xml: { + wrapped: true, + }, }, - type: "array", }, + required: ["author"], }, - required: ["author"], - type: "object", - }, - }, - ], - [ - "circular reference root, is arrays: true", - "model Book { author: Author[]; }", - `model Author { @attribute id: string; book?: Book[]; }`, - { - Book: { - type: "object", - properties: { - author: { - type: "array", - items: { - allOf: [{ $ref: "#/components/schemas/Author" }], - xml: { name: "Author" }, + Author: { + type: "object", + properties: { + book: { + type: "array", + items: { + allOf: [{ $ref: "#/components/schemas/Book" }], + xml: { name: "Book" }, + }, + xml: { + wrapped: true, + }, }, - xml: { - wrapped: true, + id: { + type: "string", + xml: { + attribute: true, + }, }, }, + required: ["id"], }, - required: ["author"], }, - Author: { - type: "object", - properties: { - book: { - type: "array", - items: { - allOf: [{ $ref: "#/components/schemas/Book" }], - xml: { name: "Book" }, - }, - xml: { - wrapped: true, + ], + [ + "@name model, is arrays: false", + "model Book { author: Author; }", + `@name("XmlAuthor") model Author { name: string; }`, + { + Book: { + type: "object", + properties: { + author: { + allOf: [{ $ref: "#/components/schemas/Author" }], + xml: { name: "author" }, }, }, - id: { - type: "string", - xml: { - attribute: true, + required: ["author"], + }, + Author: { + type: "object", + properties: { + name: { + type: "string", }, }, - }, - required: ["id"], - }, - }, - ], - [ - "@name model, is arrays: false", - "model Book { author: Author; }", - `@name("XmlAuthor") model Author { name: string; }`, - { - Book: { - type: "object", - properties: { - author: { - allOf: [{ $ref: "#/components/schemas/Author" }], - xml: { name: "author" }, + xml: { + name: "XmlAuthor", }, + required: ["name"], }, - required: ["author"], }, - Author: { - type: "object", - properties: { - name: { - type: "string", + ], + [ + "@name model property, is arrays: false", + "model Book { author: Author; }", + `model Author { @name("xmlId") name: string; }`, + { + Book: { + type: "object", + properties: { + author: { + allOf: [{ $ref: "#/components/schemas/Author" }], + xml: { name: "author" }, + }, }, + required: ["author"], }, - xml: { - name: "XmlAuthor", - }, - required: ["name"], - }, - }, - ], - [ - "@name model property, is arrays: false", - "model Book { author: Author; }", - `model Author { @name("xmlId") name: string; }`, - { - Book: { - type: "object", - properties: { - author: { - allOf: [{ $ref: "#/components/schemas/Author" }], - xml: { name: "author" }, + Author: { + type: "object", + properties: { + name: { + type: "string", + xml: { + name: "xmlId", + }, + }, }, + required: ["name"], }, - required: ["author"], }, - Author: { - type: "object", - properties: { - name: { - type: "string", - xml: { - name: "xmlId", + ], + [ + "@attribute, is arrays: false", + "model Book { author: Author; }", + "model Author { @attribute name: string; }", + { + Book: { + type: "object", + properties: { + author: { + allOf: [{ $ref: "#/components/schemas/Author" }], + xml: { name: "author" }, }, }, + required: ["author"], }, - required: ["name"], - }, - }, - ], - [ - "@attribute, is arrays: false", - "model Book { author: Author; }", - "model Author { @attribute name: string; }", - { - Book: { - type: "object", - properties: { - author: { - allOf: [{ $ref: "#/components/schemas/Author" }], - xml: { name: "author" }, + Author: { + type: "object", + properties: { + name: { + type: "string", + xml: { + attribute: true, + }, + }, }, + required: ["name"], }, - required: ["author"], }, - Author: { - type: "object", - properties: { - name: { - type: "string", - xml: { - attribute: true, + ], + [ + "circular reference root, is arrays: false", + "model Book { author: Author; }", + `model Author { @attribute id: string; book?: Book; }`, + { + Book: { + type: "object", + properties: { + author: { + allOf: [{ $ref: "#/components/schemas/Author" }], + xml: { name: "author" }, }, }, + required: ["author"], }, - required: ["name"], - }, - }, - ], - [ - "circular reference root, is arrays: false", - "model Book { author: Author; }", - `model Author { @attribute id: string; book?: Book; }`, - { - Book: { - type: "object", - properties: { - author: { - allOf: [{ $ref: "#/components/schemas/Author" }], - xml: { name: "author" }, + Author: { + type: "object", + properties: { + id: { + type: "string", + xml: { attribute: true }, + }, + book: { + allOf: [{ $ref: "#/components/schemas/Book" }], + xml: { name: "book" }, + }, }, + required: ["id"], }, - required: ["author"], }, - Author: { - type: "object", - properties: { - id: { - type: "string", - xml: { attribute: true }, - }, - book: { - allOf: [{ $ref: "#/components/schemas/Book" }], - xml: { name: "book" }, + ], + [ + "scalar, is arrays: false", + "model Book { author: Author; }", + `@name("XmlAuthor") scalar Author extends string;`, + { + Book: { + type: "object", + properties: { + author: { + allOf: [{ $ref: "#/components/schemas/Author" }], + xml: { name: "author" }, + }, }, + required: ["author"], }, - required: ["id"], - }, - }, - ], - [ - "scalar, is arrays: false", - "model Book { author: Author; }", - `@name("XmlAuthor") scalar Author extends string;`, - { - Book: { - type: "object", - properties: { - author: { - allOf: [{ $ref: "#/components/schemas/Author" }], - xml: { name: "author" }, - }, + Author: { + type: "string", + xml: { name: "XmlAuthor" }, }, - required: ["author"], - }, - Author: { - type: "string", - xml: { name: "XmlAuthor" }, }, - }, - ], - ]; - it.each(testCases)("%s", async (_, mainModel, refModel, expected) => { - const res = await oapiForModel( - "Book", - `${mainModel} + ], + ]; + it.each(testCases)("%s", async (_, mainModel, refModel, expected) => { + const res = await oapiForModel( + "Book", + `${mainModel} ${refModel}`, - ); + ); - deepStrictEqual(res?.schemas, expected); + deepStrictEqual(res?.schemas, expected); + }); }); -}); -it("test.", async () => { - const res = await oapiForModel( - "Author", - ` + it("test.", async () => { + const res = await oapiForModel( + "Author", + ` model Book { author: Author[]; } @@ -1249,19 +1173,18 @@ it("test.", async () => { model Author { book?: Book[]; }`, - ); + ); - expect(res.schemas.Author).toMatchInlineSnapshot(` - { - "properties": { - "book": { - "items": { - "$ref": "#/components/schemas/Book", + deepStrictEqual(res.schemas.Author, { + properties: { + book: { + items: { + $ref: "#/components/schemas/Book", }, - "type": "array", + type: "array", }, }, - "type": "object", - } - `); + type: "object", + }); + }); }); diff --git a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md index 9449b86098d..9fe02c3b99c 100644 --- a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md @@ -68,6 +68,10 @@ Example Multiple service with versioning - `openapi.Org1.Service2.v1.0.yaml` - `openapi.Org1.Service2.v1.1.yaml` +### `openapi-versions` + +**Type:** `array` + ### `new-line` **Type:** `"crlf" | "lf"`