diff --git a/.chronus/changes/add-examples-resolve-tool-2026-07-30-14-05-00.md b/.chronus/changes/add-examples-resolve-tool-2026-07-30-14-05-00.md new file mode 100644 index 0000000000..a2f9cc67d7 --- /dev/null +++ b/.chronus/changes/add-examples-resolve-tool-2026-07-30-14-05-00.md @@ -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. diff --git a/packages/typespec-azure-examples/README.md b/packages/typespec-azure-examples/README.md index 99f1369b2b..978c226eea 100644 --- a/packages/typespec-azure-examples/README.md +++ b/packages/typespec-azure-examples/README.md @@ -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/.yaml` for large @@ -97,6 +97,22 @@ What it does: Options: `--out ` (default `.`), `--namespace `, `--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 `); a lineage with no applicable entry at the target version is omitted. + +Options: `--api-version ` (required), `--out `. + ## API ```ts @@ -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"); ``` diff --git a/packages/typespec-azure-examples/package.json b/packages/typespec-azure-examples/package.json index f219aa4f8f..e92a3f69cb 100644 --- a/packages/typespec-azure-examples/package.json +++ b/packages/typespec-azure-examples/package.json @@ -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" diff --git a/packages/typespec-azure-examples/src/index.ts b/packages/typespec-azure-examples/src/index.ts index f639502469..00a808e210 100644 --- a/packages/typespec-azure-examples/src/index.ts +++ b/packages/typespec-azure-examples/src/index.ts @@ -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, diff --git a/packages/typespec-azure-examples/src/resolve-cli.ts b/packages/typespec-azure-examples/src/resolve-cli.ts new file mode 100644 index 0000000000..b0b959fafc --- /dev/null +++ b/packages/typespec-azure-examples/src/resolve-cli.ts @@ -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 { + const args = await yargs(hideBin(process.argv)) + .scriptName("examples-resolve") + .usage("$0 ", "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); +}); diff --git a/packages/typespec-azure-examples/src/resolve/index.ts b/packages/typespec-azure-examples/src/resolve/index.ts new file mode 100644 index 0000000000..93ecd380e6 --- /dev/null +++ b/packages/typespec-azure-examples/src/resolve/index.ts @@ -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"; diff --git a/packages/typespec-azure-examples/src/resolve/materialize.ts b/packages/typespec-azure-examples/src/resolve/materialize.ts new file mode 100644 index 0000000000..ae257405ed --- /dev/null +++ b/packages/typespec-azure-examples/src/resolve/materialize.ts @@ -0,0 +1,21 @@ +/** Substitute the `{api-version}` placeholder with a concrete version when materializing. */ +export function substituteApiVersion(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 = {}; + for (const [key, item] of Object.entries(value)) { + out[key] = substitute(item, apiVersion); + } + return out; + } + return value; +} diff --git a/packages/typespec-azure-examples/src/resolve/resolve-dir.ts b/packages/typespec-azure-examples/src/resolve/resolve-dir.ts new file mode 100644 index 0000000000..7f1b99c2a3 --- /dev/null +++ b/packages/typespec-azure-examples/src/resolve/resolve-dir.ts @@ -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 { + 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)) }; +} diff --git a/packages/typespec-azure-examples/src/resolve/resolve.ts b/packages/typespec-azure-examples/src/resolve/resolve.ts new file mode 100644 index 0000000000..79f5e6db0e --- /dev/null +++ b/packages/typespec-azure-examples/src/resolve/resolve.ts @@ -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 { + const lineages = new Map(); + 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; +} diff --git a/packages/typespec-azure-examples/src/resolve/select.ts b/packages/typespec-azure-examples/src/resolve/select.ts new file mode 100644 index 0000000000..915c96c373 --- /dev/null +++ b/packages/typespec-azure-examples/src/resolve/select.ts @@ -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( + 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; +} diff --git a/packages/typespec-azure-examples/test/resolve/materialize.test.ts b/packages/typespec-azure-examples/test/resolve/materialize.test.ts new file mode 100644 index 0000000000..d02d6ed981 --- /dev/null +++ b/packages/typespec-azure-examples/test/resolve/materialize.test.ts @@ -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, + }); + }); +}); diff --git a/packages/typespec-azure-examples/test/resolve/resolve.e2e.test.ts b/packages/typespec-azure-examples/test/resolve/resolve.e2e.test.ts new file mode 100644 index 0000000000..a196c2a526 --- /dev/null +++ b/packages/typespec-azure-examples/test/resolve/resolve.e2e.test.ts @@ -0,0 +1,54 @@ +import { mkdir, mkdtemp, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { describe, expect, it } from "vitest"; +import { resolveExamplesDir } from "../../src/resolve/index.js"; + +const serviceYaml = ` +versions: + - version: "2023-01-01" + source: typespec + - version: "2024-06-01" + source: typespec +`; + +const examplesYaml = ` +$namespace: Microsoft.Test +Things.get: + - request: { path: { id: "1" } } + responses: + 200: + body: { tier: base, nextLink: "https://h/x?api-version={api-version}" } + - since: "2024-06-01" + request: { path: { id: "1" } } + responses: + 200: + body: { tier: premium, nextLink: "https://h/x?api-version={api-version}" } +`; + +async function fixture(): Promise { + const dir = await mkdtemp(join(tmpdir(), "examples-resolve-")); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "service.yaml"), serviceYaml); + await writeFile(join(dir, "examples.yaml"), examplesYaml); + return dir; +} + +describe("resolveExamplesDir (end-to-end)", () => { + it("reads service.yaml order and resolves the base for the earliest version", async () => { + const dir = await fixture(); + const result = await resolveExamplesDir(dir, "2023-01-01"); + expect(result.order).toEqual(["2023-01-01", "2024-06-01"]); + expect(result.diagnostics).toEqual([]); + expect(result.examples).toHaveLength(1); + expect((result.examples[0].responses as any)["200"].body.tier).toBe("base"); + }); + + it("resolves the since entry for the later version and materializes api-version", async () => { + const dir = await fixture(); + const result = await resolveExamplesDir(dir, "2024-06-01"); + const body = (result.examples[0].responses as any)["200"].body; + expect(body.tier).toBe("premium"); + expect(body.nextLink).toBe("https://h/x?api-version=2024-06-01"); + }); +}); diff --git a/packages/typespec-azure-examples/test/resolve/resolve.test.ts b/packages/typespec-azure-examples/test/resolve/resolve.test.ts new file mode 100644 index 0000000000..e06bf26ba9 --- /dev/null +++ b/packages/typespec-azure-examples/test/resolve/resolve.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { loadExampleFile } from "../../src/loader.js"; +import { resolveExampleFiles } from "../../src/resolve/resolve.js"; + +const order = ["2023-01-01", "2024-06-01"]; + +function resolve(content: string, apiVersion: string) { + return resolveExampleFiles([loadExampleFile("examples.yaml", content)], apiVersion, order); +} + +describe("resolveExampleFiles", () => { + const doc = ` +Things.get: + - request: { path: { id: "1" } } + responses: + 200: + body: { nextLink: "https://h/x?api-version={api-version}", tier: base } + - since: "2024-06-01" + request: { path: { id: "1" } } + responses: + 200: + body: { nextLink: "https://h/x?api-version={api-version}", tier: premium } +`; + + it("resolves the base entry and materializes the api-version for an early target", () => { + const result = resolve(doc, "2023-01-01"); + expect(result.diagnostics).toEqual([]); + expect(result.examples).toHaveLength(1); + const example = result.examples[0] as any; + expect(example.operation).toBe("Things.get"); + expect(example.responses["200"].body.tier).toBe("base"); + expect(example.responses["200"].body.nextLink).toBe("https://h/x?api-version=2023-01-01"); + }); + + it("resolves the since entry for the later target", () => { + const example = resolve(doc, "2024-06-01").examples[0] as any; + expect(example.responses["200"].body.tier).toBe("premium"); + expect(example.responses["200"].body.nextLink).toBe("https://h/x?api-version=2024-06-01"); + }); + + it("emits one resolved example per lineage (title)", () => { + const multi = ` +Foo.create: + - title: With WebHook + request: { path: {} } + responses: { 200: { body: { kind: webhook } } } + - title: With Queue + request: { path: {} } + responses: { 200: { body: { kind: queue } } } +`; + const result = resolve(multi, "2023-01-01"); + expect(result.examples.map((e) => e.title).sort()).toEqual(["With Queue", "With WebHook"]); + }); + + it("omits a lineage that has no applicable entry at the target", () => { + const laterOnly = ` +Foo.get: + - since: "2024-06-01" + request: { path: {} } + responses: { 200: {} } +`; + expect(resolve(laterOnly, "2023-01-01").examples).toEqual([]); + }); + + it("reports an error when the target version is unknown", () => { + const result = resolve(doc, "2099-01-01"); + expect(result.diagnostics.map((d) => d.code)).toContain("unknown-target-version"); + expect(result.examples).toEqual([]); + }); +}); diff --git a/packages/typespec-azure-examples/test/resolve/select.test.ts b/packages/typespec-azure-examples/test/resolve/select.test.ts new file mode 100644 index 0000000000..ab694855e6 --- /dev/null +++ b/packages/typespec-azure-examples/test/resolve/select.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { selectApplicable } from "../../src/resolve/select.js"; + +const order = ["2023-01-01", "2024-06-01", "2025-01-01"]; + +describe("selectApplicable", () => { + it("picks the base entry when the target predates every since", () => { + const entries = [{ id: "base" }, { id: "v2", since: "2024-06-01" }]; + expect(selectApplicable(entries, "2023-01-01", order)?.id).toBe("base"); + }); + + it("picks the greatest since that is <= the target", () => { + const entries = [ + { id: "base" }, + { id: "v2", since: "2024-06-01" }, + { id: "v3", since: "2025-01-01" }, + ]; + expect(selectApplicable(entries, "2024-06-01", order)?.id).toBe("v2"); + expect(selectApplicable(entries, "2025-01-01", order)?.id).toBe("v3"); + }); + + it("prefers a since equal to the target over the base", () => { + const entries = [{ id: "base" }, { id: "exact", since: "2024-06-01" }]; + expect(selectApplicable(entries, "2024-06-01", order)?.id).toBe("exact"); + }); + + it("returns undefined when nothing applies (no base, all since newer)", () => { + const entries = [{ id: "v3", since: "2025-01-01" }]; + expect(selectApplicable(entries, "2023-01-01", order)).toBeUndefined(); + }); + + it("ignores entries whose since is not in the order", () => { + const entries = [{ id: "base" }, { id: "bogus", since: "1999-01-01" }]; + expect(selectApplicable(entries, "2025-01-01", order)?.id).toBe("base"); + }); + + it("returns undefined when the target is not in the order", () => { + expect(selectApplicable([{ id: "base" }], "2099-01-01", order)).toBeUndefined(); + }); +});