Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@azure-tools/typespec-azure-examples"
---

Add the `examples-resolve` tool that resolves the applicable example for each operation at a target API version (greatest `since <= target` per lineage, using the `service.yaml` order) and materializes the `{api-version}` placeholder.
21 changes: 20 additions & 1 deletion packages/typespec-azure-examples/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# @azure-tools/typespec-azure-examples

Tooling for the Azure **unified examples format** (`examples.yaml`): the published JSON Schema,
the `examples-validate` CLI, and the `examples-migrate` CLI.
the `examples-validate` CLI, the `examples-migrate` CLI, and the `examples-resolve` CLI.

The unified examples format replaces the ~282K per-version `x-ms-examples` JSON files with a
single version-aware `examples.yaml` per service (or `examples/<Interface>.yaml` for large
Expand Down Expand Up @@ -97,6 +97,22 @@ What it does:
Options: `--out <dir>` (default `.`), `--namespace <ns>`, `--split-by-interface`, `--dry-run`,
`--warn-as-error`.

## `examples-resolve`

Resolve the applicable example for each operation at a target API version:

```bash
examples-resolve < service-dir > --api-version 2024-06-01
```

It reads the linear version order from the adjacent `service.yaml`, discovers `examples.yaml` /
`examples/*.yaml`, and for each operation lineage selects the entry with the greatest `since` that
is `<=` the target (the base entry applies from the earliest version). The `{api-version}`
placeholder is substituted with the target version. Results are printed as JSON (or written with
`--out <file>`); a lineage with no applicable entry at the target version is omitted.

Options: `--api-version <v>` (required), `--out <file>`.

## API

```ts
Expand All @@ -105,9 +121,12 @@ import {
validateExampleFiles,
loadExampleFile,
migrate,
resolveExamplesDir,
} from "@azure-tools/typespec-azure-examples";

const { diagnostics } = await validateExamplesDir("path/to/service");

const { files } = await migrate("path/to/specs", { namespace: "Microsoft.EventGrid" });

const { examples } = await resolveExamplesDir("path/to/service", "2024-06-01");
```
3 changes: 2 additions & 1 deletion packages/typespec-azure-examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
},
"bin": {
"examples-validate": "./dist/src/cli.js",
"examples-migrate": "./dist/src/migrate-cli.js"
"examples-migrate": "./dist/src/migrate-cli.js",
"examples-resolve": "./dist/src/resolve-cli.js"
},
"engines": {
"node": ">=22.0.0"
Expand Down
1 change: 1 addition & 0 deletions packages/typespec-azure-examples/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export { formatDiagnostics, formatSummary } from "./reporter.js";
export { checkFilePlacement, checkSemantics, type SemanticContext } from "./rules.js";
export { ExamplesYamlSchema } from "./schema.js";
export * from "./migrate/index.js";
export * from "./resolve/index.js";
export type {
DiagnosticSeverity,
ExampleDiagnostic,
Expand Down
70 changes: 70 additions & 0 deletions packages/typespec-azure-examples/src/resolve-cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env node
/* eslint-disable no-console */
import { mkdir, writeFile } from "fs/promises";
import { dirname, resolve } from "path";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { formatDiagnostics, formatSummary } from "./reporter.js";
import { resolveExamplesDir } from "./resolve/index.js";

async function main(): Promise<void> {
const args = await yargs(hideBin(process.argv))
.scriptName("examples-resolve")
.usage("$0 <dir>", "Resolve the applicable examples for a target API version")
.positional("dir", {
type: "string",
describe: "Service directory containing examples.yaml / examples/*.yaml and service.yaml",
})
.demandCommand(0)
.option("api-version", {
type: "string",
demandOption: true,
describe: "Target API version to resolve for (must be listed in service.yaml)",
})
.option("out", {
type: "string",
describe: "Write the resolved examples JSON to this file instead of stdout",
})
.strict()
.help()
.parse();

const dir = resolve(process.cwd(), (args.dir as string | undefined) ?? ".");
const apiVersion = args["api-version"] as string;

const result = await resolveExamplesDir(dir, apiVersion);

if (result.diagnostics.length > 0) {
console.error(formatDiagnostics(result.diagnostics));
console.error("");
console.error(formatSummary(result.diagnostics));
}
if (result.diagnostics.some((d) => d.severity === "error")) {
process.exit(1);
}

const output = {
apiVersion: result.apiVersion,
examples: result.examples.map((e) => ({
operation: e.operation,
...(e.title ? { title: e.title } : {}),
request: e.request,
responses: e.responses,
})),
};
const json = JSON.stringify(output, null, 2);

if (args.out) {
const target = resolve(process.cwd(), args.out as string);
await mkdir(dirname(target), { recursive: true });
await writeFile(target, json + "\n", "utf-8");
console.error(`Wrote ${result.examples.length} resolved example(s) to ${target}`);
} else {
console.log(json);
}
}

main().catch((error) => {
console.error(error);
process.exit(1);
});
7 changes: 7 additions & 0 deletions packages/typespec-azure-examples/src/resolve/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* `examples-resolve`: resolve the applicable example for each operation at a target API version.
*/
export { substituteApiVersion } from "./materialize.js";
export { resolveExamplesDir, type ResolveDirResult } from "./resolve-dir.js";
export { resolveExampleFiles, type ResolveResult, type ResolvedExample } from "./resolve.js";
export { selectApplicable, type HasSince } from "./select.js";
21 changes: 21 additions & 0 deletions packages/typespec-azure-examples/src/resolve/materialize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/** Substitute the `{api-version}` placeholder with a concrete version when materializing. */
export function substituteApiVersion<T>(value: T, apiVersion: string): T {
return substitute(value, apiVersion) as T;
}

function substitute(value: unknown, apiVersion: string): unknown {
if (typeof value === "string") {
return value.split("{api-version}").join(apiVersion);
}
if (Array.isArray(value)) {
return value.map((item) => substitute(item, apiVersion));
}
if (value !== null && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
out[key] = substitute(item, apiVersion);
}
return out;
}
return value;
}
39 changes: 39 additions & 0 deletions packages/typespec-azure-examples/src/resolve/resolve-dir.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { readFile } from "fs/promises";
import { join, relative } from "path";
import { discoverExampleFiles } from "../discover.js";
import { loadExampleFile, parseServiceVersions } from "../loader.js";
import { resolveExampleFiles, type ResolveResult } from "./resolve.js";

/** Result of resolving a service directory for a target version. */
export interface ResolveDirResult extends ResolveResult {
/** The example files that were discovered. */
readonly files: string[];
/** The version order read from `service.yaml`. */
readonly order: string[];
}

/**
* Resolve a service directory for `apiVersion`: read the version order from `service.yaml`, discover
* `examples.yaml` / `examples/*.yaml`, and resolve + materialize the applicable examples.
*/
export async function resolveExamplesDir(
dir: string,
apiVersion: string,
): Promise<ResolveDirResult> {
let order: string[] = [];
try {
order = parseServiceVersions(await readFile(join(dir, "service.yaml"), "utf-8")).versions;
} catch {
order = [];
}

const filePaths = await discoverExampleFiles(dir);
const files = await Promise.all(
filePaths.map(async (path) =>
loadExampleFile(relative(dir, path), await readFile(path, "utf-8")),
),
);

const result = resolveExampleFiles(files, apiVersion, order);
return { ...result, order, files: filePaths.map((path) => relative(dir, path)) };
}
95 changes: 95 additions & 0 deletions packages/typespec-azure-examples/src/resolve/resolve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { LoadedExampleFile } from "../loader.js";
import type { ExampleDiagnostic } from "../types.js";
import { substituteApiVersion } from "./materialize.js";
import { selectApplicable } from "./select.js";

/** A single resolved (and materialized) example for a target version. */
export interface ResolvedExample {
/** The operation key (e.g. `CaCertificates.get`). */
readonly operation: string;
/** The lineage title, when the operation has more than one lineage. */
readonly title?: string;
readonly request?: unknown;
readonly responses?: unknown;
}

/** The outcome of resolving a set of example files for a target version. */
export interface ResolveResult {
readonly apiVersion: string;
readonly examples: ResolvedExample[];
readonly diagnostics: ExampleDiagnostic[];
}

/**
* Resolve the applicable example for every operation/lineage at `apiVersion`, using the linear
* `order` from `service.yaml`, and materialize the `{api-version}` placeholder. Operations whose
* lineage has no applicable entry at the target version are omitted.
*/
export function resolveExampleFiles(
files: readonly LoadedExampleFile[],
apiVersion: string,
order: readonly string[],
): ResolveResult {
const diagnostics: ExampleDiagnostic[] = [];
const examples: ResolvedExample[] = [];

if (!order.includes(apiVersion)) {
diagnostics.push({
code: "unknown-target-version",
message: `Target version "${apiVersion}" is not listed in service.yaml (${
order.length === 0 ? "no versions found" : order.join(", ")
}).`,
severity: "error",
file: "service.yaml",
});
return { apiVersion, examples, diagnostics };
}

for (const file of files) {
const data = file.data;
if (data === null || typeof data !== "object" || Array.isArray(data)) continue;

for (const [operation, value] of Object.entries(data)) {
if (operation.startsWith("$") || !Array.isArray(value)) continue;

for (const [title, entries] of groupByLineage(value)) {
const selected = selectApplicable(entries, apiVersion, order);
if (selected === undefined) continue;
examples.push({
operation,
...(title === "" ? {} : { title }),
request: substituteApiVersion(selected.request, apiVersion),
responses: substituteApiVersion(selected.responses, apiVersion),
});
}
}
}

examples.sort(
(a, b) =>
a.operation.localeCompare(b.operation) || (a.title ?? "").localeCompare(b.title ?? ""),
);

return { apiVersion, examples, diagnostics };
}

interface Variant {
readonly title?: unknown;
readonly since?: unknown;
readonly request?: unknown;
readonly responses?: unknown;
}

/** Group an operation's variants into lineages keyed by `title` (untitled => the default `""`). */
function groupByLineage(variants: readonly unknown[]): Map<string, Variant[]> {
const lineages = new Map<string, Variant[]>();
for (const variant of variants) {
if (variant === null || typeof variant !== "object" || Array.isArray(variant)) continue;
const v = variant as Variant;
const title = typeof v.title === "string" ? v.title : "";
const group = lineages.get(title);
if (group) group.push(v);
else lineages.set(title, [v]);
}
return lineages;
}
42 changes: 42 additions & 0 deletions packages/typespec-azure-examples/src/resolve/select.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Selection of the applicable example variant for a target API version, using the single linear
* version order from `service.yaml` (RFC §; no preview/stable special-casing).
*/

/** A variant with an optional `since` marker. */
export interface HasSince {
readonly since?: unknown;
}

/**
* Within a lineage, pick the entry with the greatest `since` that is `<=` the target version.
* The base entry (no `since`) applies from the earliest version. Returns `undefined` when nothing
* applies (e.g. every entry's `since` is newer than the target and there is no base).
*
* `apiVersion` and all `since` values are located in `order`; entries whose `since` is not in
* `order` are ignored. When `apiVersion` itself is not in `order`, nothing resolves.
*/
export function selectApplicable<T extends HasSince>(
entries: readonly T[],
apiVersion: string,
order: readonly string[],
): T | undefined {
const targetIndex = order.indexOf(apiVersion);
if (targetIndex < 0) return undefined;

let best: T | undefined;
let bestIndex = Number.NEGATIVE_INFINITY;

for (const entry of entries) {
const since = typeof entry.since === "string" ? entry.since : undefined;
// The base entry sorts below every real version so any `since <= target` wins over it.
const index = since === undefined ? -1 : order.indexOf(since);
if (since !== undefined && index < 0) continue;
if (index <= targetIndex && index > bestIndex) {
best = entry;
bestIndex = index;
}
}

return best;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { substituteApiVersion } from "../../src/resolve/materialize.js";

describe("substituteApiVersion", () => {
it("replaces the placeholder in nested strings", () => {
const input = {
responses: { "200": { body: { nextLink: "https://h/x?api-version={api-version}" } } },
};
expect(substituteApiVersion(input, "2024-06-01")).toEqual({
responses: { "200": { body: { nextLink: "https://h/x?api-version=2024-06-01" } } },
});
});

it("replaces multiple occurrences and recurses into arrays", () => {
expect(substituteApiVersion(["{api-version}", "a/{api-version}/{api-version}"], "v1")).toEqual([
"v1",
"a/v1/v1",
]);
});

it("leaves non-string values untouched", () => {
expect(substituteApiVersion({ n: 1, b: true, z: null }, "v1")).toEqual({
n: 1,
b: true,
z: null,
});
});
});
Loading