diff --git a/common/changes/@cadl-lang/openapi3/pick-versioned-dependency_2022-03-21-18-42.json b/common/changes/@cadl-lang/openapi3/pick-versioned-dependency_2022-03-21-18-42.json new file mode 100644 index 0000000000..af670647c3 --- /dev/null +++ b/common/changes/@cadl-lang/openapi3/pick-versioned-dependency_2022-03-21-18-42.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@cadl-lang/openapi3", + "comment": "Uptake change to allow versioned dependency with unversioned service", + "type": "minor" + } + ], + "packageName": "@cadl-lang/openapi3" +} \ No newline at end of file diff --git a/common/changes/@cadl-lang/versioning/pick-versioned-dependency_2022-03-21-18-42.json b/common/changes/@cadl-lang/versioning/pick-versioned-dependency_2022-03-21-18-42.json new file mode 100644 index 0000000000..584745d7a7 --- /dev/null +++ b/common/changes/@cadl-lang/versioning/pick-versioned-dependency_2022-03-21-18-42.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@cadl-lang/versioning", + "comment": "Enable ability to pick a specific version for a versioned dependency when service itself isn't versioned", + "type": "minor" + } + ], + "packageName": "@cadl-lang/versioning" +} \ No newline at end of file diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 4a82796946..b2eb15312c 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -241,10 +241,6 @@ function createOAPIEmitter(program: Program, options: OpenAPIEmitterOptions) { } const versions = getVersionRecords(program, serviceNs); for (const record of versions) { - record.projections.push({ - projectionName: "toTypescript", - arguments: [], - }); if (record.version) { record.projections.push({ projectionName: "atVersion", @@ -252,7 +248,7 @@ function createOAPIEmitter(program: Program, options: OpenAPIEmitterOptions) { }); } - if (record.version !== undefined) { + if (record.projections.length > 0) { program.enableProjections(record.projections); } diff --git a/packages/samples/test/output/use-versioned-lib/openapi.json b/packages/samples/test/output/use-versioned-lib/openapi.json new file mode 100644 index 0000000000..1bc0adbf7e --- /dev/null +++ b/packages/samples/test/output/use-versioned-lib/openapi.json @@ -0,0 +1,43 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Pet Store Service", + "version": "0000-00-00" + }, + "tags": [], + "paths": { + "/": { + "get": { + "operationId": "VersionedApi_read", + "parameters": [], + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Library.PetToy" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Library.PetToy": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + } + } + } +} diff --git a/packages/samples/use-versioned-lib/library.cadl b/packages/samples/use-versioned-lib/library.cadl new file mode 100644 index 0000000000..3409a2e61d --- /dev/null +++ b/packages/samples/use-versioned-lib/library.cadl @@ -0,0 +1,13 @@ +import "@cadl-lang/versioning"; + +@versioned(VERSIONS) +namespace Library; + +alias VERSIONS = "1.0" | "1.1"; + +model PetToy { + name: string; + + @added("1.1") + material: string; +} diff --git a/packages/samples/use-versioned-lib/main.cadl b/packages/samples/use-versioned-lib/main.cadl new file mode 100644 index 0000000000..4d0a4101a1 --- /dev/null +++ b/packages/samples/use-versioned-lib/main.cadl @@ -0,0 +1,11 @@ +import "@cadl-lang/versioning"; +import "@cadl-lang/rest"; +import "./library.cadl"; + +// Use version 1.0 of the Library +@serviceTitle("Pet Store Service") +@versionedDependency(Library, "1.0") +namespace VersionedApi; +using Cadl.Http; + +op read(): Library.PetToy; diff --git a/packages/versioning/src/lib.ts b/packages/versioning/src/lib.ts index 9746df5cb4..41320e8898 100644 --- a/packages/versioning/src/lib.ts +++ b/packages/versioning/src/lib.ts @@ -21,22 +21,16 @@ const libDef = { default: `The versioned decorator must be applied to a namespace`, }, }, - "versioned-dependency-not-on-namespace": { - severity: "error", - messages: { - default: `The versionedDependency decorator must be applied to a namespace`, - }, - }, - "versioned-dependency-not-to-namespace": { + "versioned-dependency-record-not-model": { severity: "error", messages: { - default: `The versionedDependency decorator must specify the dependency namespace`, + default: paramMessage`The versionedDependency decorator must provide a model mapping local versions to dependency '${"dependency"}' versions`, }, }, - "versioned-dependency-record-not-model": { + "versioned-dependency-not-string": { severity: "error", messages: { - default: `The versionedDependency decorator must provide a model mapping local versions to dependency versions`, + default: paramMessage`The versionedDependency decorator must provide a version of the dependency '${"dependency"}'.`, }, }, }, diff --git a/packages/versioning/src/versioning.ts b/packages/versioning/src/versioning.ts index 8a31d1795b..35c2e00f5b 100644 --- a/packages/versioning/src/versioning.ts +++ b/packages/versioning/src/versioning.ts @@ -1,9 +1,12 @@ import { DecoratorContext, NamespaceType, + navigateProgram, Program, ProjectionApplication, Type, + validateDecoratorParamType, + validateDecoratorTarget, } from "@cadl-lang/compiler"; import { reportDiagnostic } from "./lib.js"; const addedOnKey = Symbol(); @@ -129,35 +132,19 @@ export function $versionedDependency( { program }: DecoratorContext, referenceNamespace: Type, targetNamespace: Type, - versionRecord: Type + versionRecord: string | Type ) { - if (referenceNamespace.kind !== "Namespace") { - reportDiagnostic(program, { - code: "versioned-dependency-not-on-namespace", - target: referenceNamespace, - }); - return; - } - - if (targetNamespace.kind !== "Namespace") { - reportDiagnostic(program, { - code: "versioned-dependency-not-to-namespace", - target: referenceNamespace, - }); - return; - } - - if (versionRecord.kind !== "Model") { - reportDiagnostic(program, { - code: "versioned-dependency-record-not-model", - target: referenceNamespace, - }); + if ( + !validateDecoratorTarget(program, referenceNamespace, "@versionedDependency", "Namespace") || + !validateDecoratorParamType(program, referenceNamespace, targetNamespace, "Namespace") || + !validateDecoratorParamType(program, referenceNamespace, versionRecord, ["Model", "String"]) + ) { return; } let state = program.stateMap(versionDependencyKey).get(referenceNamespace) as Map< NamespaceType, - Map + string | Map >; if (!state) { @@ -165,27 +152,63 @@ export function $versionedDependency( program.stateMap(versionDependencyKey).set(referenceNamespace, state); } - let versionMap = state.get(targetNamespace); - if (!versionMap) { - versionMap = new Map(); - state.set(targetNamespace, versionMap); - } + if (typeof versionRecord === "string") { + state.set(targetNamespace, versionRecord); + } else { + let versionMap = state.get(targetNamespace); + if (!versionMap || !(versionMap instanceof Map)) { + versionMap = new Map(); + state.set(targetNamespace, versionMap); + } - for (const [name, prop] of versionRecord.properties) { - if (prop.type.kind !== "String") { - continue; + for (const [name, prop] of versionRecord.properties) { + if (prop.type.kind !== "String") { + continue; + } + versionMap.set(name, prop.type.value); } - versionMap.set(name, prop.type.value); } } export function getVersionDependencies( p: Program, namespace: NamespaceType -): Map> | undefined { +): Map | string> | undefined { return p.stateMap(versionDependencyKey).get(namespace); } +export function $onValidate(program: Program) { + navigateProgram(program, { + namespace: (namespace) => { + const version = getVersion(program, namespace); + const dependencies = getVersionDependencies(program, namespace); + if (dependencies === undefined) { + return; + } + + for (const [dependencyNs, value] of dependencies.entries()) { + if (version && version.length > 0) { + if (!(value instanceof Map)) { + reportDiagnostic(program, { + code: "versioned-dependency-record-not-model", + format: { dependency: program.checker!.getNamespaceString(dependencyNs) }, + target: namespace, + }); + } + } else { + if (typeof value !== "string") { + reportDiagnostic(program, { + code: "versioned-dependency-not-string", + format: { dependency: program.checker!.getNamespaceString(dependencyNs) }, + target: namespace, + }); + } + } + } + }, + }); +} + interface VersionRecord { version: string | undefined; projections: ProjectionApplication[]; @@ -193,25 +216,57 @@ interface VersionRecord { export function getVersionRecords(program: Program, rootNs: NamespaceType): VersionRecord[] { const versions = getVersions(program, rootNs); - if (!versions || versions.length === 0) { - return [{ version: undefined, projections: [] }]; - } const records: VersionRecord[] = []; const dependencies = getVersionDependencies(program, rootNs) ?? new Map(); - for (const version of versions) { - // TODO: find versioned dependencies - const projections = [{ scope: rootNs, projectionName: "v", arguments: [version] }]; - - for (const [dependencyNs, versionMap] of dependencies) { - if (!versionMap.has(version)) continue; - projections.push({ - scope: dependencyNs, - projectionName: "v", - arguments: [versionMap.get(version!)], - }); + + if (!versions || versions.length === 0) { + if (dependencies.size === 0) { + return [{ version: undefined, projections: [] }]; + } else { + for (const [dependencyNs, version] of dependencies) { + if (typeof version !== "string") { + const rootNsName = program.checker!.getNamespaceString(rootNs); + const dependencyNsName = program.checker!.getNamespaceString(dependencyNs); + throw new Error( + `Unexpected error: Namespace ${rootNsName} version dependency to ${dependencyNsName} should be a string.` + ); + } + + records.push({ + version: undefined, + projections: [ + { + scope: dependencyNs, + projectionName: "v", + arguments: [version], + }, + ], + }); + } } + } else { + for (const version of versions) { + // TODO: find versioned dependencies + const projections = [{ scope: rootNs, projectionName: "v", arguments: [version] }]; + + for (const [dependencyNs, versionMap] of dependencies) { + if (!(versionMap instanceof Map)) { + const rootNsName = program.checker!.getNamespaceString(rootNs); + const dependencyNsName = program.checker!.getNamespaceString(dependencyNs); + throw new Error( + `Unexpected error: Namespace ${rootNsName} version dependency to ${dependencyNsName} should be a mapping of version.` + ); + } + if (!versionMap.has(version)) continue; + projections.push({ + scope: dependencyNs, + projectionName: "v", + arguments: [versionMap.get(version!)], + }); + } - records.push({ version: version, projections }); + records.push({ version: version, projections }); + } } return records; diff --git a/packages/versioning/test/test-versioned-dependencies.ts b/packages/versioning/test/test-versioned-dependencies.ts new file mode 100644 index 0000000000..8d4fd23609 --- /dev/null +++ b/packages/versioning/test/test-versioned-dependencies.ts @@ -0,0 +1,111 @@ +import { ModelType, NamespaceType } from "@cadl-lang/compiler"; +import { BasicTestRunner, createTestWrapper, expectDiagnostics } from "@cadl-lang/compiler/testing"; +import { ok, strictEqual } from "assert"; +import { getVersionRecords } from "../src/versioning.js"; +import { createVersioningTestHost } from "./test-host.js"; + +describe("cadl: versioning: depdendencies", () => { + let runner: BasicTestRunner; + + beforeEach(async () => { + const host = await createVersioningTestHost(); + runner = createTestWrapper( + host, + (code) => ` + import "@cadl-lang/versioning"; + + @versioned("1" | "2") + namespace VersionedLib { + model Foo { + name: string; + @added("2") age: int32; + } + } + ${code}` + ); + }); + + function assertFooV1(foo: ModelType) { + ok(foo.properties.has("name")); + ok(!foo.properties.has("age"), "Age was added in version 2 and version 1 was selected."); + } + + function assertFooV2(Foo: ModelType) { + ok(Foo.properties.has("name")); + ok(Foo.properties.has("age"), "Age was added in version 2 and version 1 was selected."); + } + + describe("when project is not-versioned", () => { + it("use a versioned library given version", async () => { + const { MyService, Test } = (await runner.compile(` + @versionedDependency(VersionedLib, "1") + @test namespace MyService { + @test model Test extends VersionedLib.Foo {} + } + `)) as { MyService: NamespaceType; Test: ModelType }; + const versions = getVersionRecords(runner.program, MyService); + strictEqual(versions.length, 1); + strictEqual(versions[0].version, undefined); + strictEqual(versions[0].projections.length, 1); + + const projector = runner.program.enableProjections(versions[0].projections, Test); + const Foo = (projector.projectedTypes.get(Test) as any).baseModel; + + assertFooV1(Foo); + }); + + it("emit diagnostic if passing version mapping", async () => { + const diagnostics = await runner.diagnose(` + @versionedDependency(VersionedLib, {v1: "1", v2: "2"}) + namespace MyService { + model Test extends VersionedLib.Foo {} + } + `); + expectDiagnostics(diagnostics, { + code: "@cadl-lang/versioning/versioned-dependency-not-string", + message: + "The versionedDependency decorator must provide a version of the dependency 'VersionedLib'.", + }); + }); + }); + + describe("when project is versioned", () => { + it("use a versioned library given version", async () => { + const { MyService, Test } = (await runner.compile(` + @versioned("v1" | "v2") + @versionedDependency(VersionedLib, {v1: "1", v2: "2"}) + @test namespace MyService { + @test model Test extends VersionedLib.Foo {} + } + `)) as { MyService: NamespaceType; Test: ModelType }; + const versions = getVersionRecords(runner.program, MyService); + strictEqual(versions.length, 2); + strictEqual(versions[0].version, "v1"); + strictEqual(versions[1].version, "v2"); + + const projectorV1 = runner.program.enableProjections(versions[0].projections, Test); + const FooV1 = (projectorV1.projectedTypes.get(Test) as any).baseModel; + + assertFooV1(FooV1); + + const projectorV2 = runner.program.enableProjections(versions[1].projections, Test); + const FooV2 = (projectorV2.projectedTypes.get(Test) as any).baseModel; + assertFooV2(FooV2); + }); + + it("emit diagnostic if passing a specific version", async () => { + const diagnostics = await runner.diagnose(` + @versioned("v1" | "v2") + @versionedDependency(VersionedLib, "1") + namespace MyService { + model Test extends VersionedLib.Foo {} + } + `); + expectDiagnostics(diagnostics, { + code: "@cadl-lang/versioning/versioned-dependency-record-not-model", + message: + "The versionedDependency decorator must provide a model mapping local versions to dependency 'VersionedLib' versions", + }); + }); + }); +}); diff --git a/packages/versioning/test/testVersioning.ts b/packages/versioning/test/testVersioning.ts index 598d975625..6a5eec82ce 100644 --- a/packages/versioning/test/testVersioning.ts +++ b/packages/versioning/test/testVersioning.ts @@ -8,21 +8,21 @@ import { Type, UnionType, } from "@cadl-lang/compiler"; -import { TestHost } from "@cadl-lang/compiler/testing"; +import { BasicTestRunner, createTestWrapper } from "@cadl-lang/compiler/testing"; import { ok, strictEqual } from "assert"; import { createVersioningTestHost } from "./test-host.js"; describe("cadl: versioning", () => { - let host: TestHost; + let runner: BasicTestRunner; + beforeEach(async () => { - host = await createVersioningTestHost(); + const host = await createVersioningTestHost(); + runner = createTestWrapper(host, (code) => `import "@cadl-lang/versioning";\n${code}`); }); + describe("version compare", () => { it("compares arbitrary types in order", async () => { - host.addCadlFile( - "main.cadl", - ` - import "@cadl-lang/versioning"; + const { Test } = (await runner.compile(` @versioned("1" | "version two" | "3") namespace MyService; @@ -31,9 +31,7 @@ describe("cadl: versioning", () => { @added("version two") b: 1; @added("3") c: 1; } - ` - ); - const { Test } = (await host.compile("./main.cadl")) as { Test: ModelType }; + `)) as { Test: ModelType }; const v1 = project(Test, "1"); ok(v1.properties.has("a"), "v1 has a"); @@ -49,6 +47,7 @@ describe("cadl: versioning", () => { ok(v3.properties.has("c")); }); }); + describe("models", () => { it("can add models", async () => { const { @@ -181,18 +180,12 @@ describe("cadl: versioning", () => { }); async function versionedModel(versions: string[], model: string) { - host.addCadlFile( - "main.cadl", - ` - import "@cadl-lang/versioning"; + const { Test } = (await runner.compile(` @versioned(${versions.map((t) => JSON.stringify(t)).join(" | ")}) namespace MyService; @test ${model} - ` - ); - - const { Test } = (await host.compile("./main.cadl")) as { Test: ModelType }; + `)) as { Test: ModelType }; return { source: Test, @@ -317,18 +310,13 @@ describe("cadl: versioning", () => { }); async function versionedUnion(versions: string[], union: string) { - host.addCadlFile( - "main.cadl", - ` + const { Test } = (await runner.compile(` import "@cadl-lang/versioning"; @versioned(${versions.map((t) => JSON.stringify(t)).join(" | ")}) namespace MyService; @test ${union} - ` - ); - - const { Test } = (await host.compile("./main.cadl")) as { Test: UnionType }; + `)) as { Test: UnionType }; return { source: Test, @@ -385,18 +373,12 @@ describe("cadl: versioning", () => { }); async function versionedOperation(versions: string[], operation: string) { - host.addCadlFile( - "main.cadl", - ` - import "@cadl-lang/versioning"; - @versioned(${versions.map((t) => JSON.stringify(t)).join(" | ")}) - namespace MyService; - - @test ${operation} - ` - ); + const { Test } = (await runner.compile(` + @versioned(${versions.map((t) => JSON.stringify(t)).join(" | ")}) + namespace MyService; - const { Test } = (await host.compile("./main.cadl")) as { Test: OperationType }; + @test ${operation} + `)) as { Test: OperationType }; return { source: Test, @@ -491,18 +473,12 @@ describe("cadl: versioning", () => { }); async function versionedInterface(versions: string[], iface: string) { - host.addCadlFile( - "main.cadl", - ` - import "@cadl-lang/versioning"; - @versioned(${versions.map((t) => JSON.stringify(t)).join(" | ")}) - namespace MyService; - - @test ${iface} - ` - ); + const { Test } = (await runner.compile(` + @versioned(${versions.map((t) => JSON.stringify(t)).join(" | ")}) + namespace MyService; - const { Test } = (await host.compile("./main.cadl")) as { Test: InterfaceType }; + @test ${iface} + `)) as { Test: InterfaceType }; return { source: Test, @@ -613,18 +589,12 @@ describe("cadl: versioning", () => { }); async function versionedEnum(versions: string[], enumCode: string) { - host.addCadlFile( - "main.cadl", - ` - import "@cadl-lang/versioning"; - @versioned(${versions.map((t) => JSON.stringify(t)).join(" | ")}) - namespace MyService; - - @test ${enumCode} - ` - ); + const { Test } = (await runner.compile(` + @versioned(${versions.map((t) => JSON.stringify(t)).join(" | ")}) + namespace MyService; - const { Test } = (await host.compile("./main.cadl")) as { Test: EnumType }; + @test ${enumCode} + `)) as { Test: EnumType }; return { source: Test, @@ -719,7 +689,7 @@ describe("cadl: versioning", () => { projectionName: "v", direction, }; - const projector = host.program.enableProjections([projection], target); + const projector = runner.program.enableProjections([projection], target); return projector.projectedTypes.get(target) as T; } });