Skip to content

Commit

Permalink
[Cadl APIView] Version 0.3.5 (#5378)
Browse files Browse the repository at this point in the history
* Updates for cadl-apiview 0.3.5.

* Add and update tests.

* Final fixes.
  • Loading branch information
tjprescott authored Feb 10, 2023
1 parent d555cb2 commit 3201dd5
Show file tree
Hide file tree
Showing 10 changed files with 378 additions and 153 deletions.
9 changes: 9 additions & 0 deletions tools/apiview/emitters/cadl-apiview/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Release History

## Version 0.3.5 (Unreleased)
Support latest release of Cadl compiler.
**BREAKING CHANGE**: Removed the `--namespace` emitter option.
Added the `--service` emitter option to support filtering output for multi-service specs.
Emitter options `--output-file` and `--version` cannot be used with multi-service specs unless the
`--service` option is provided.
Added the `--include-global-namespace` option to permit including the global namespace in the token file.
Fixed issue where namespaces that are not proper subnamespaces may be included in the token file.

## Version 0.3.4 (01-13-2023)
Support latest release of Cadl compiler.

Expand Down
29 changes: 19 additions & 10 deletions tools/apiview/emitters/cadl-apiview/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,26 +68,35 @@ or via the command line with
--option "@azure-tools/cadl-apiview.output-file=my-custom-apiview.json"
```

### `output-file`
### `emitter-output-dir`

Configure the name of the output directory. Default is `cadl-output/@azure-tools/cadl-apiview`.

Configure the name of the output JSON token file relative to the `output-dir`.
### `include-global-namespace`

### `output-dir`
Normally, APIView will filter all namespaces and only output those in the service namespace and any
subnamespaces. This is to filter out types that come from the Cadl compiler and supporting libraries.
This setting, if `true`, tells APIView to output the contents of the global (empty) namespace, which
would normally be excluded.

Configure the name of the output directory. Default is `cadl-output/cadl-apiview`.
### `service`

### `namespace`
Filter output to a single service definition. If omitted, all service defintions will be
output as separate APIView token files.

### `output-file`

For Cadl specs, the namespace should be automatically resolved as the service namespace. If
that doesn't work, or for libraries (which have no service namespace) this option should be
specified to filter the global namespace. Any subnamespaces of the provided namespace will
also be emitted.
Configure the name of the output JSON token file relative to the `output-dir`. For multi-service
specs, this option cannot be supplied unless the `service` option is also set. If outputting
all services in a multi-service spec, the output filename will be the service root namespace with the
`-apiview.json` suffix. Otherwise, the default is `apiview.json`.

### `version`

For multi-versioned Cadl specs, this parameter is used to control which version to emit. This
is not required for single-version specs. For multi-versioned specs, the unprojected Cadl will
be rendered if this is not supplied.
be rendered if this is not supplied. For multi-service specs, this option cannot be supplied
unless the `service` option is also set.

## See also

Expand Down
19 changes: 10 additions & 9 deletions tools/apiview/emitters/cadl-apiview/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@azure-tools/cadl-apiview",
"version": "0.3.4",
"version": "0.3.5",
"author": "Microsoft Corporation",
"description": "Cadl library for emitting APIView token files from Cadl specifications",
"homepage": "https://github.com/Azure/azure-sdk-tools",
Expand Down Expand Up @@ -54,16 +54,17 @@
"!dist/test/**"
],
"dependencies": {
"@azure-tools/cadl-azure-core": "~0.25.0",
"@azure-tools/cadl-dpg": "~0.25.0",
"@cadl-lang/compiler": "~0.39.0",
"@cadl-lang/rest": "~0.39.0",
"@cadl-lang/versioning": "~0.39.0"
"@azure-tools/cadl-azure-core": "0.26.0",
"@azure-tools/cadl-autorest": "0.26.0",
"@azure-tools/cadl-dpg": "latest",
"@cadl-lang/compiler": "0.40.0",
"@cadl-lang/rest": "latest",
"@cadl-lang/versioning": "latest"
},
"devDependencies": {
"@cadl-lang/eslint-plugin": "~0.39.0",
"@cadl-lang/library-linter": "~0.39.0",
"@cadl-lang/prettier-plugin-cadl": "^0.39.0",
"@cadl-lang/eslint-plugin": "~0.40.0",
"@cadl-lang/library-linter": "~0.40.0",
"@cadl-lang/prettier-plugin-cadl": "^0.40.0",
"@cadl-lang/eslint-config-cadl": "~0.4.1",
"@types/mocha": "~9.1.0",
"@types/node": "~16.0.3",
Expand Down
30 changes: 24 additions & 6 deletions tools/apiview/emitters/cadl-apiview/src/apiview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
EnumMemberNode,
EnumSpreadMemberNode,
EnumStatementNode,
getNamespaceFullName,
IdentifierNode,
InterfaceStatementNode,
IntersectionExpressionNode,
Expand Down Expand Up @@ -90,11 +91,13 @@ export class ApiView {
indentSize: number = 2;
namespaceStack = new NamespaceStack();
typeDeclarations = new Set<string>();
includeGlobalNamespace: boolean;

constructor(name: string, packageName: string, versionString?: string) {
constructor(name: string, packageName: string, versionString?: string, includeGlobalNamespace?: boolean) {
this.name = name;
this.packageName = packageName;
this.versionString = versionString ?? "";
this.includeGlobalNamespace = includeGlobalNamespace ?? false;

this.emitHeader();
}
Expand Down Expand Up @@ -309,24 +312,39 @@ export class ApiView {
this.navigationItems.push(item);
}

shouldEmitNamespace(name: string): boolean {
if (name === "" && this.includeGlobalNamespace) {
return true;
}
if (name === this.packageName) {
return true;
}
if (!name.startsWith(this.packageName)) {
return false;
}
const suffix = name.substring(this.packageName.length);
return suffix.startsWith(".");
}

emit(program: Program) {
let allNamespaces = new Map<string, Namespace>();

// collect namespaces in program
navigateProgram(program, {
namespace(obj) {
const name = program.checker.getNamespaceString(obj);
const name = getNamespaceFullName(obj);
allNamespaces.set(name, obj);
},
});
allNamespaces = new Map([...allNamespaces].sort());

// Skip namespaces which are outside the root namespace.
for (const [name, ns] of allNamespaces.entries()) {
if (!name.startsWith(this.packageName)) {
if (!this.shouldEmitNamespace(name)) {
continue;
}
const nsModel = new NamespaceModel(name, ns, program);
// use a fake name to make the global namespace clear
const namespaceName = name == "" ? "::GLOBAL::" : name;
const nsModel = new NamespaceModel(namespaceName, ns, program);
if (nsModel.shouldEmit()) {
this.tokenizeNamespaceModel(nsModel);
this.buildNavigation(nsModel);
Expand Down Expand Up @@ -833,7 +851,7 @@ export class ApiView {
this.blankLines(1);
}
this.endGroup();
this.newline();
this.blankLines(1);
this.namespaceStack.pop();
}

Expand Down
163 changes: 98 additions & 65 deletions tools/apiview/emitters/cadl-apiview/src/emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,27 @@
import {
EmitContext,
emitFile,
getServiceNamespace,
getServiceTitle,
getServiceVersion,
getNamespaceFullName,
listServices,
Namespace,
NoTarget,
Program,
ProjectionApplication,
projectProgram,
resolvePath,
Service,
} from "@cadl-lang/compiler";
import { buildVersionProjections, getVersion } from "@cadl-lang/versioning";
import path from "path";
import { ApiView } from "./apiview.js";
import { ApiViewEmitterOptions, reportDiagnostic } from "./lib.js";

const defaultOptions = {
"output-file": "apiview.json",
} as const;

export interface ResolvedApiViewEmitterOptions {
emitterOutputDir?: string;
outputPath: string;
namespace?: string;
emitterOutputDir: string;
outputFile?: string;
service?: string;
version?: string;
includeGlobalNamespace: boolean;
}

export async function $onEmit(context: EmitContext<ApiViewEmitterOptions>) {
Expand All @@ -36,35 +33,28 @@ export async function $onEmit(context: EmitContext<ApiViewEmitterOptions>) {
}

export function resolveOptions(context: EmitContext<ApiViewEmitterOptions>): ResolvedApiViewEmitterOptions {
const resolvedOptions = { ...defaultOptions, ...context.options };
const resolvedOptions = { ...context.options };

return {
outputPath: resolvePath(
context.emitterOutputDir,
resolvedOptions["output-file"]
),
namespace: resolvedOptions["namespace"],
emitterOutputDir: context.emitterOutputDir,
outputFile: resolvedOptions["output-file"],
service: resolvedOptions["service"],
version: resolvedOptions["version"],
includeGlobalNamespace: resolvedOptions["include-global-namespace"] ?? false
};
}

function resolveServiceVersion(program: Program): string | undefined {
// FIXME: Fix this wonky workaround when getServiceVersion is fixed.
const value = getServiceVersion(program);
return value == "0000-00-00" ? undefined : value;
}

function resolveNamespaceString(program: Program, namespace: Namespace): string | undefined {
function resolveNamespaceString(namespace: Namespace): string | undefined {
// FIXME: Fix this wonky workaround when getNamespaceString is fixed.
const value = program.checker.getNamespaceString(namespace);
const value = getNamespaceFullName(namespace);
return value == "" ? undefined : value;
}

// TODO: Up-level this logic?
function resolveAllowedVersions(program: Program, namespace: Namespace): string[] {
function resolveAllowedVersions(program: Program, service: Service): string[] {
const allowed: string[] = [];
const serviceVersion = resolveServiceVersion(program);
const versions = getVersion(program, namespace)?.getVersions();
const serviceVersion = service.version;
const versions = getVersion(program, service.type)?.getVersions();
if (serviceVersion != undefined && versions != undefined) {
throw new Error("Cannot have serviceVersion with multi-API.");
}
Expand Down Expand Up @@ -115,54 +105,97 @@ function resolveProgramForVersion(program: Program, namespace: Namespace, versio
}
}

/**
* Ensures that single-value options are not used in multi-service specs unless the
* `--service` option is specified. Single-service specs need not pass this option.
*/
function validateMultiServiceOptions(program: Program, services: Service[], options: ResolvedApiViewEmitterOptions) {
for (const [name, val] of [["output-file", options.outputFile], ["version", options.version]]) {
if (val && !options.service && services.length > 1) {
reportDiagnostic(program, {
code: "invalid-option",
target: NoTarget,
format: {
name: name!
}
})
}
}
}

/**
* If the `--service` option is provided, ensures the service exists and returns the filtered list.
*/
function applyServiceFilter(program: Program, services: Service[], options: ResolvedApiViewEmitterOptions): Service[] {
if (!options.service) {
return services;
}
const filtered = services.filter( (x) => x.title === options.service);
if (!filtered.length) {
reportDiagnostic(program, {
code: "invalid-service",
target: NoTarget,
format: {
value: options.service
}
});
}
return filtered;
}

function createApiViewEmitter(program: Program, options: ResolvedApiViewEmitterOptions) {
return { emitApiView };

async function emitApiView() {
const serviceNs = getServiceNamespace(program);
if (!serviceNs) {
throw new Error("No namespace found");
}
const versionString = options.version ?? resolveServiceVersion(program);
const namespaceString = options.namespace ?? resolveNamespaceString(program, serviceNs);
if (namespaceString == undefined) {
let services = listServices(program);
if (!services.length) {
reportDiagnostic(program, {
code: "use-namespace-option",
code: "no-services-found",
target: NoTarget
});
})
return;
}
const allowedVersions = resolveAllowedVersions(program, serviceNs);
if (versionString) {
if (allowedVersions.filter((version) => version == versionString).length == 0) {
reportDiagnostic(program, {
code: "version-not-found",
target: NoTarget,
format: {
version: versionString,
allowed: allowedVersions.join(" | "),
},
})
return;
}
// applies the default "apiview.json" filename if not provided and there's only a single service
if (services.length == 1) {
options.outputFile = options.outputFile ?? "apiview.json"
}
// FIXME: Fix this wonky workaround when getServiceTitle is fixed.
let serviceTitle = getServiceTitle(program);
if (serviceTitle == "(title)") {
serviceTitle = namespaceString;
}
const resolvedProgram = resolveProgramForVersion(program, serviceNs, versionString);
const apiview = new ApiView(serviceTitle, namespaceString, versionString);
apiview.emit(resolvedProgram);
apiview.resolveMissingTypeReferences();
validateMultiServiceOptions(program, services, options);
services = applyServiceFilter(program, services, options);

for (const service of services) {
const versionString = options.version ?? service.version;
const namespaceString = resolveNamespaceString(service.type) ?? "Unknown"
const serviceTitle = service.title ? service.title : namespaceString;
const allowedVersions = resolveAllowedVersions(program, service);
if (versionString) {
if (allowedVersions.filter((version) => version == versionString).length == 0) {
reportDiagnostic(program, {
code: "version-not-found",
target: NoTarget,
format: {
version: versionString,
serviceTitle: serviceTitle,
allowed: allowedVersions.join(" | "),
},
})
return;
}
}
const resolvedProgram = resolveProgramForVersion(program, service.type, versionString);
const apiview = new ApiView(serviceTitle, namespaceString, versionString, options.includeGlobalNamespace);
apiview.emit(resolvedProgram);
apiview.resolveMissingTypeReferences();

if (!program.compilerOptions.noEmit && !program.hasError()) {
const outputFolder = path.dirname(options.outputPath);
await program.host.mkdirp(outputFolder);
await emitFile(program, {
path: options.outputPath,
content: JSON.stringify(apiview.asApiViewDocument()) + "\n"
});
if (!program.compilerOptions.noEmit && !program.hasError()) {
const outputFolder = path.dirname(options.emitterOutputDir);
await program.host.mkdirp(outputFolder);
const outputFile = options.outputFile ?? `${namespaceString}-apiview.json`;
const outputPath = resolvePath(outputFolder, outputFile);
await emitFile(program, {
path: outputPath,
content: JSON.stringify(apiview.asApiViewDocument()) + "\n"
});
}
}
}
}
Loading

0 comments on commit 3201dd5

Please sign in to comment.