From 1e26d562e71c11fdcfb84f8801879deca760ae58 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Fri, 13 Dec 2024 15:36:52 +0800 Subject: [PATCH 1/2] initial --- packages/openapi3/package.json | 4 +++ packages/openapi3/src/json-schema-module.ts | 31 +++++++++++++++++++ .../openapi3/src/openapi-spec-mappings.ts | 2 ++ packages/openapi3/src/openapi.ts | 13 +++++++- packages/openapi3/src/schema-emitter-3-0.ts | 2 +- packages/openapi3/src/schema-emitter-3-1.ts | 15 +++++++-- packages/openapi3/src/schema-emitter.ts | 9 ++++++ .../openapi3/test/scalar-constraints.test.ts | 4 ++- packages/openapi3/test/test-host.ts | 6 ++-- pnpm-lock.yaml | 3 ++ 10 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 packages/openapi3/src/json-schema-module.ts diff --git a/packages/openapi3/package.json b/packages/openapi3/package.json index 985e94510c..6fcea465c1 100644 --- a/packages/openapi3/package.json +++ b/packages/openapi3/package.json @@ -70,6 +70,9 @@ "peerDependenciesMeta": { "@typespec/xml": { "optional": true + }, + "@typespec/json-schema": { + "optional": true } }, "devDependencies": { @@ -83,6 +86,7 @@ "@typespec/tspd": "workspace:~", "@typespec/versioning": "workspace:~", "@typespec/xml": "workspace:~", + "@typespec/json-schema": "workspace:~", "@vitest/coverage-v8": "^2.1.5", "@vitest/ui": "^2.1.2", "c8": "^10.1.2", diff --git a/packages/openapi3/src/json-schema-module.ts b/packages/openapi3/src/json-schema-module.ts new file mode 100644 index 0000000000..d5ef7f0179 --- /dev/null +++ b/packages/openapi3/src/json-schema-module.ts @@ -0,0 +1,31 @@ +import { Program, Type } from "@typespec/compiler"; +import { CommonOpenAPI3Schema } from "./types.js"; + +export interface JsonSchemaModule { + attachJsonSchemaObject( + applyConstraint: (fn: (p: Program, t: Type) => any, key: keyof CommonOpenAPI3Schema) => void, + ): void; +} + +export async function resolveJsonSchemaModule(): Promise { + const jsonSchema = await tryImportJsonSchema(); + if (jsonSchema === undefined) return undefined; + + return { + attachJsonSchemaObject: (applyConstraint: any) => { + applyConstraint(jsonSchema.getContentEncoding, "contentEncoding"); + applyConstraint(jsonSchema.getContentMediaType, "contentMediaType"); + }, + }; + + async function tryImportJsonSchema(): Promise< + typeof import("@typespec/json-schema") | undefined + > { + try { + const module = await import("@typespec/json-schema"); + return module; + } catch { + return undefined; + } + } +} diff --git a/packages/openapi3/src/openapi-spec-mappings.ts b/packages/openapi3/src/openapi-spec-mappings.ts index 53e563023c..d0a2be7a70 100644 --- a/packages/openapi3/src/openapi-spec-mappings.ts +++ b/packages/openapi3/src/openapi-spec-mappings.ts @@ -2,6 +2,7 @@ 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 { JsonSchemaModule } from "./json-schema-module.js"; import { OpenAPI3EmitterOptions, OpenAPIVersion } from "./lib.js"; import { ResolvedOpenAPI3EmitterOptions } from "./openapi.js"; import { createSchemaEmitter3_0 } from "./schema-emitter-3-0.js"; @@ -17,6 +18,7 @@ export type CreateSchemaEmitter = (props: { visibilityUsage: VisibilityUsageTracker; options: ResolvedOpenAPI3EmitterOptions; xmlModule: XmlModule | undefined; + jsonSchemaModule: JsonSchemaModule | undefined; }) => AssetEmitter; export interface OpenApiSpecSpecificProps { diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 673053e5b7..91f2105b00 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -88,6 +88,7 @@ import { stringify } from "yaml"; import { getRef } from "./decorators.js"; import { applyEncoding } from "./encoding.js"; import { getExampleOrExamples, OperationExamples, resolveOperationExamples } from "./examples.js"; +import { JsonSchemaModule, resolveJsonSchemaModule } from "./json-schema-module.js"; import { createDiagnostic, FileType, OpenAPI3EmitterOptions, OpenAPIVersion } from "./lib.js"; import { getOpenApiSpecProps } from "./openapi-spec-mappings.js"; import { getOpenAPI3StatusCodes } from "./status-codes.js"; @@ -312,6 +313,7 @@ function createOAPIEmitter( allHttpAuthentications: HttpAuth[], defaultAuth: AuthenticationReference, xmlModule: XmlModule | undefined, + jsonSchemaModule: JsonSchemaModule | undefined, version?: string, ) { diagnostics = createDiagnosticCollector(); @@ -334,6 +336,7 @@ function createOAPIEmitter( visibilityUsage, options, xmlModule, + jsonSchemaModule, }); const securitySchemes = getOpenAPISecuritySchemes(allHttpAuthentications); @@ -638,7 +641,15 @@ function createOAPIEmitter( const auth = (serviceAuth = resolveAuthentication(httpService)); const xmlModule = await resolveXmlModule(); - initializeEmitter(service, auth.schemes, auth.defaultAuth, xmlModule, version); + const jsonSchemaModule = await resolveJsonSchemaModule(); + initializeEmitter( + service, + auth.schemes, + auth.defaultAuth, + xmlModule, + jsonSchemaModule, + version, + ); reportIfNoRoutes(program, httpService.operations); for (const op of resolveOperations(httpService.operations)) { diff --git a/packages/openapi3/src/schema-emitter-3-0.ts b/packages/openapi3/src/schema-emitter-3-0.ts index f6ea8f0460..8df8a850e2 100644 --- a/packages/openapi3/src/schema-emitter-3-0.ts +++ b/packages/openapi3/src/schema-emitter-3-0.ts @@ -41,7 +41,7 @@ function createWrappedSchemaEmitterClass( ): typeof TypeEmitter, OpenAPI3EmitterOptions> { return class extends OpenAPI3SchemaEmitter { constructor(emitter: AssetEmitter, OpenAPI3EmitterOptions>) { - super(emitter, metadataInfo, visibilityUsage, options, xmlModule); + super(emitter, metadataInfo, visibilityUsage, options, xmlModule, undefined); } }; } diff --git a/packages/openapi3/src/schema-emitter-3-1.ts b/packages/openapi3/src/schema-emitter-3-1.ts index 977f15752a..24bd98b3e9 100644 --- a/packages/openapi3/src/schema-emitter-3-1.ts +++ b/packages/openapi3/src/schema-emitter-3-1.ts @@ -26,11 +26,12 @@ import { import { MetadataInfo } from "@typespec/http"; import { shouldInline } from "@typespec/openapi"; import { getOneOf } from "./decorators.js"; +import { JsonSchemaModule } from "./json-schema-module.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 { CommonOpenAPI3Schema, 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"; @@ -40,10 +41,11 @@ function createWrappedSchemaEmitterClass( visibilityUsage: VisibilityUsageTracker, options: ResolvedOpenAPI3EmitterOptions, xmlModule: XmlModule | undefined, + jsonSchemaModule: JsonSchemaModule | undefined, ): typeof TypeEmitter, OpenAPI3EmitterOptions> { return class extends OpenAPI31SchemaEmitter { constructor(emitter: AssetEmitter, OpenAPI3EmitterOptions>) { - super(emitter, metadataInfo, visibilityUsage, options, xmlModule); + super(emitter, metadataInfo, visibilityUsage, options, xmlModule, jsonSchemaModule); } }; } @@ -56,6 +58,7 @@ export const createSchemaEmitter3_1: CreateSchemaEmitter = ({ program, context, rest.visibilityUsage, rest.options, rest.xmlModule, + rest.jsonSchemaModule, ), context, ); @@ -254,4 +257,12 @@ export class OpenAPI31SchemaEmitter extends OpenAPI3SchemaEmitterBase any, key: keyof CommonOpenAPI3Schema) => void, + ) { + if (this._jsonSchemaModule) { + this._jsonSchemaModule.attachJsonSchemaObject(applyConstraint); + } + } } diff --git a/packages/openapi3/src/schema-emitter.ts b/packages/openapi3/src/schema-emitter.ts index ca3c5cb187..87ab368294 100644 --- a/packages/openapi3/src/schema-emitter.ts +++ b/packages/openapi3/src/schema-emitter.ts @@ -66,6 +66,7 @@ import { import { attachExtensions } from "./attach-extensions.js"; import { getOneOf, getRef } from "./decorators.js"; import { applyEncoding } from "./encoding.js"; +import { JsonSchemaModule } from "./json-schema-module.js"; import { OpenAPI3EmitterOptions, reportDiagnostic } from "./lib.js"; import { ResolvedOpenAPI3EmitterOptions } from "./openapi.js"; import { getSchemaForStdScalars } from "./std-scalar-schemas.js"; @@ -89,18 +90,21 @@ export class OpenAPI3SchemaEmitterBase< protected _visibilityUsage: VisibilityUsageTracker; protected _options: ResolvedOpenAPI3EmitterOptions; protected _xmlModule: XmlModule | undefined; + protected _jsonSchemaModule: JsonSchemaModule | undefined; constructor( emitter: AssetEmitter, OpenAPI3EmitterOptions>, metadataInfo: MetadataInfo, visibilityUsage: VisibilityUsageTracker, options: ResolvedOpenAPI3EmitterOptions, xmlModule: XmlModule | undefined, + jsonSchemaModule: JsonSchemaModule | undefined, ) { super(emitter); this._metadataInfo = metadataInfo; this._visibilityUsage = visibilityUsage; this._options = options; this._xmlModule = xmlModule; + this._jsonSchemaModule = jsonSchemaModule; } modelDeclarationReferenceContext(model: Model, name: string): Context { @@ -606,6 +610,10 @@ export class OpenAPI3SchemaEmitterBase< refSchema?: Schema, ) {} + applyJsonSchemaConstraints( + applyConstraint: (fn: (p: Program, t: Type) => any, key: keyof CommonOpenAPI3Schema) => void, + ) {} + applyConstraints( type: Scalar | Model | ModelProperty | Union | Enum, original: Schema, @@ -628,6 +636,7 @@ export class OpenAPI3SchemaEmitterBase< applyConstraint(getPattern, "pattern"); applyConstraint(getMinItems, "minItems"); applyConstraint(getMaxItems, "maxItems"); + this.applyJsonSchemaConstraints(applyConstraint); if (isSecret(program, type)) { schema.format = "password"; diff --git a/packages/openapi3/test/scalar-constraints.test.ts b/packages/openapi3/test/scalar-constraints.test.ts index cec4b3acdd..a6bfffa8fc 100644 --- a/packages/openapi3/test/scalar-constraints.test.ts +++ b/packages/openapi3/test/scalar-constraints.test.ts @@ -220,13 +220,15 @@ worksFor(["3.1.0"], ({ oapiForModel }) => { strictEqual(schema.maxLength, 2); strictEqual(schema.pattern, "a|b"); strictEqual(schema.format, "ipv4"); + strictEqual(schema.contentEncoding, "base64"); } const decorators = ` @minLength(1) @maxLength(2) @pattern("a|b") - @format("ipv4")`; + @format("ipv4") + @contentEncoding("base64")`; it("on scalar declaration", async () => { const schemas = await oapiForModel( diff --git a/packages/openapi3/test/test-host.ts b/packages/openapi3/test/test-host.ts index ed6bd7b743..a7fc661b6c 100644 --- a/packages/openapi3/test/test-host.ts +++ b/packages/openapi3/test/test-host.ts @@ -6,6 +6,7 @@ import { resolveVirtualPath, } from "@typespec/compiler/testing"; import { HttpTestLibrary } from "@typespec/http/testing"; +import { JsonSchemaTestLibrary } from "@typespec/json-schema/testing"; import { OpenAPITestLibrary } from "@typespec/openapi/testing"; import { RestTestLibrary } from "@typespec/rest/testing"; import { VersioningTestLibrary } from "@typespec/versioning/testing"; @@ -22,6 +23,7 @@ export async function createOpenAPITestHost() { RestTestLibrary, VersioningTestLibrary, XmlTestLibrary, + JsonSchemaTestLibrary, OpenAPITestLibrary, OpenAPI3TestLibrary, ], @@ -94,9 +96,9 @@ export async function openApiFor( const outPath = resolveVirtualPath("{version}.openapi.json"); host.addTypeSpecFile( "./main.tsp", - `import "@typespec/http"; import "@typespec/rest"; import "@typespec/openapi"; import "@typespec/openapi3";import "@typespec/xml"; ${ + `import "@typespec/http"; import "@typespec/rest"; import "@typespec/openapi"; import "@typespec/openapi3";import "@typespec/xml";import "@typespec/json-schema"; ${ versions ? `import "@typespec/versioning"; using TypeSpec.Versioning;` : "" - }using TypeSpec.Rest;using TypeSpec.Http;using TypeSpec.OpenAPI;using TypeSpec.Xml;${code}`, + }using TypeSpec.Rest;using TypeSpec.Http;using TypeSpec.OpenAPI;using TypeSpec.Xml;using TypeSpec.JsonSchema;${code}`, ); const diagnostics = await host.diagnose("./main.tsp", { noEmit: false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ed5f8e078..7afb56d2cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -896,6 +896,9 @@ importers: '@typespec/http': specifier: workspace:~ version: link:../http + '@typespec/json-schema': + specifier: workspace:~ + version: link:../json-schema '@typespec/library-linter': specifier: workspace:~ version: link:../library-linter From f2e4377e65e26b12d10753a9a36de84ceaf2a28d Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Fri, 13 Dec 2024 17:18:25 +0800 Subject: [PATCH 2/2] update cases --- .../openapi3/test/scalar-constraints.test.ts | 18 ++++++++++++++++-- packages/openapi3/test/test-host.ts | 2 +- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/openapi3/test/scalar-constraints.test.ts b/packages/openapi3/test/scalar-constraints.test.ts index a6bfffa8fc..f830f0fc51 100644 --- a/packages/openapi3/test/scalar-constraints.test.ts +++ b/packages/openapi3/test/scalar-constraints.test.ts @@ -220,7 +220,8 @@ worksFor(["3.1.0"], ({ oapiForModel }) => { strictEqual(schema.maxLength, 2); strictEqual(schema.pattern, "a|b"); strictEqual(schema.format, "ipv4"); - strictEqual(schema.contentEncoding, "base64"); + strictEqual(schema.contentEncoding, "base64url"); + strictEqual(schema.contentMediaType, "application/jwt"); } const decorators = ` @@ -228,7 +229,8 @@ worksFor(["3.1.0"], ({ oapiForModel }) => { @maxLength(2) @pattern("a|b") @format("ipv4") - @contentEncoding("base64")`; + @JsonSchema.contentEncoding("base64url") + @JsonSchema.contentMediaType("application/jwt")`; it("on scalar declaration", async () => { const schemas = await oapiForModel( @@ -252,5 +254,17 @@ worksFor(["3.1.0"], ({ oapiForModel }) => { assertStringConstraints(schemas.schemas.Test); }); + + it("on property", async () => { + const schemas = await oapiForModel( + "Test", + `model Test { + ${decorators} + prop: string; + }`, + ); + + assertStringConstraints(schemas.schemas.Test.properties.prop); + }); }); }); diff --git a/packages/openapi3/test/test-host.ts b/packages/openapi3/test/test-host.ts index a7fc661b6c..761d88a1f6 100644 --- a/packages/openapi3/test/test-host.ts +++ b/packages/openapi3/test/test-host.ts @@ -98,7 +98,7 @@ export async function openApiFor( "./main.tsp", `import "@typespec/http"; import "@typespec/rest"; import "@typespec/openapi"; import "@typespec/openapi3";import "@typespec/xml";import "@typespec/json-schema"; ${ versions ? `import "@typespec/versioning"; using TypeSpec.Versioning;` : "" - }using TypeSpec.Rest;using TypeSpec.Http;using TypeSpec.OpenAPI;using TypeSpec.Xml;using TypeSpec.JsonSchema;${code}`, + }using TypeSpec.Rest;using TypeSpec.Http;using TypeSpec.OpenAPI;using TypeSpec.Xml;${code}`, ); const diagnostics = await host.diagnose("./main.tsp", { noEmit: false,