Skip to content

Commit 7e4f1ad

Browse files
feat(examples): add examples.yaml JSON schema and examples-validate tool
Add @azure-tools/typespec-azure-examples, the foundational piece of the Unified Examples Format epic (#4831, issue #4832): - examples.yaml JSON Schema authored in TypeSpec (schema/examples-yaml.tsp) and compiled via @typespec/json-schema. - examples-validate CLI + programmatic API that runs structural (ajv) validation plus the RFC section 3 semantic rules: metadata keys, integer-only status codes, quoted since in service.yaml, per-lineage base/uniqueness, one-interface-per-file, and {api-version}-only placeholder.
1 parent 0619ead commit 7e4f1ad

24 files changed

Lines changed: 1427 additions & 0 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: feature
3+
packages:
4+
- "@azure-tools/typespec-azure-examples"
5+
---
6+
7+
Add `@azure-tools/typespec-azure-examples` with the `examples.yaml` JSON Schema and the `examples-validate` tool for the unified examples format.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
dist/
2+
temp/
3+
schema/dist/
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// @ts-check
2+
3+
import { readFile, writeFile } from "fs/promises";
4+
import { dirname, resolve } from "path";
5+
import { fileURLToPath } from "url";
6+
7+
const __dirname = dirname(fileURLToPath(import.meta.url));
8+
9+
const jsonFilename = resolve(__dirname, "../schema/dist/ExamplesYaml.json");
10+
const jsFilename = resolve(__dirname, "../schema/dist/schema.js");
11+
console.log("Reading json schema at:", jsonFilename);
12+
const content = await readFile(jsonFilename);
13+
14+
const json = JSON.parse(content.toString());
15+
16+
const jsContent = `
17+
const schema = ${JSON.stringify(json)};
18+
export default schema;
19+
`;
20+
console.log("Writing json schema in js file:", jsFilename);
21+
await writeFile(jsFilename, jsContent);
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# @azure-tools/typespec-azure-examples
2+
3+
Tooling for the Azure **unified examples format** (`examples.yaml`): the published JSON Schema
4+
and the `examples-validate` CLI.
5+
6+
The unified examples format replaces the ~282K per-version `x-ms-examples` JSON files with a
7+
single version-aware `examples.yaml` per service (or `examples/<Interface>.yaml` for large
8+
services). See the RFC: _Unified Examples Format_.
9+
10+
## Format
11+
12+
```yaml
13+
$schema: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/schemas/examples.schema.yaml
14+
$namespace: Microsoft.EventGrid
15+
16+
CaCertificates.get:
17+
- request:
18+
path:
19+
subscriptionId: 8f6b6269-84f2-4d09-9e31-1127efcd1e40
20+
resourceGroupName: myResourceGroup
21+
responses:
22+
200:
23+
body:
24+
name: exampleCaCertificate
25+
properties:
26+
provisioningState: Succeeded
27+
- since: "2023-12-15-preview"
28+
request:
29+
path:
30+
subscriptionId: 8f6b6269-84f2-4d09-9e31-1127efcd1e40
31+
resourceGroupName: myResourceGroup
32+
responses:
33+
200:
34+
body:
35+
name: exampleCaCertificate
36+
properties:
37+
provisioningState: Succeeded
38+
delegatedIdentityTokenExpirationTimeInUtc: "2023-10-12T23:06:43+00:00"
39+
```
40+
41+
- File metadata uses `$`-prefixed keys (`$schema`, `$namespace`); every bare top-level key is an
42+
operation, identified by its interface-relative name (`Interface.operation`).
43+
- Each operation maps to a list of example variants. The base variant has no `since`; later
44+
variants carry a quoted `since` and restate the full request/response.
45+
- Response status codes are bare integer keys. `api-version` is implicit; use the
46+
`{api-version}` placeholder where a version must be embedded in a value.
47+
48+
## `examples-validate`
49+
50+
Validate a service's example files against the JSON Schema and the format rules:
51+
52+
```bash
53+
examples-validate <service-dir>
54+
```
55+
56+
It discovers `examples.yaml` and `examples/*.yaml` in the directory, reads the adjacent
57+
`service.yaml` for version metadata, and reports diagnostics. It exits non-zero if any error is
58+
found (use `--warn-as-error` to also fail on warnings).
59+
60+
### Rules enforced
61+
62+
- Only `$schema`/`$namespace` may be `$`-prefixed; other bare keys are operations that must be a
63+
list of examples.
64+
- Response keys are integer status codes; range keys (`2XX`) and `default` are rejected.
65+
- `since` must be a quoted string and a version listed in `service.yaml`.
66+
- Per lineage (entries grouped by `title`; untitled entries form the default lineage): at most one
67+
entry without `since`, and `since` values are unique.
68+
- An operation's full example set lives in a single file, and each interface appears in exactly
69+
one file.
70+
- `{api-version}` is the only supported placeholder, and `api-version` must not appear as a
71+
request parameter.
72+
73+
## API
74+
75+
```ts
76+
import {
77+
validateExamplesDir,
78+
validateExampleFiles,
79+
loadExampleFile,
80+
} from "@azure-tools/typespec-azure-examples";
81+
82+
const { diagnostics } = await validateExamplesDir("path/to/service");
83+
```
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
{
2+
"name": "@azure-tools/typespec-azure-examples",
3+
"version": "0.1.0",
4+
"author": "Microsoft Corporation",
5+
"description": "Tooling for the Azure unified examples format (examples.yaml): JSON Schema and the examples-validate CLI",
6+
"homepage": "https://azure.github.io/typespec-azure",
7+
"readme": "https://github.com/Azure/typespec-azure/blob/main/README.md",
8+
"license": "MIT",
9+
"repository": {
10+
"type": "git",
11+
"url": "git+https://github.com/Azure/typespec-azure.git"
12+
},
13+
"bugs": {
14+
"url": "https://github.com/Azure/typespec-azure/issues"
15+
},
16+
"keywords": [
17+
"typespec",
18+
"azure",
19+
"examples"
20+
],
21+
"type": "module",
22+
"main": "dist/src/index.js",
23+
"exports": {
24+
".": {
25+
"types": "./dist/src/index.d.ts",
26+
"default": "./dist/src/index.js"
27+
}
28+
},
29+
"bin": {
30+
"examples-validate": "./dist/src/cli.js"
31+
},
32+
"engines": {
33+
"node": ">=22.0.0"
34+
},
35+
"scripts": {
36+
"clean": "rimraf ./dist ./schema/dist",
37+
"build": "npm run regen-examples-yaml-schema && tsc -p tsconfig.build.json",
38+
"watch": "tsc -p tsconfig.build.json --watch",
39+
"regen-examples-yaml-schema": "tsp compile ./schema/examples-yaml.tsp --warn-as-error && node ./.scripts/schema-json-to-js.js",
40+
"test": "vitest run",
41+
"test:watch": "vitest -w",
42+
"test:ci": "vitest run --coverage --reporter=junit --reporter=default",
43+
"lint": "oxlint . --deny-warnings",
44+
"lint:fix": "oxlint . --fix"
45+
},
46+
"files": [
47+
"schema/dist/schema.js",
48+
"schema/dist/ExamplesYaml.json",
49+
"dist/**",
50+
"!dist/test/**"
51+
],
52+
"dependencies": {
53+
"ajv": "catalog:",
54+
"picocolors": "catalog:",
55+
"yaml": "catalog:",
56+
"yargs": "catalog:"
57+
},
58+
"devDependencies": {
59+
"@types/node": "catalog:",
60+
"@types/yargs": "catalog:",
61+
"@typespec/compiler": "workspace:^",
62+
"@typespec/json-schema": "workspace:^",
63+
"@vitest/coverage-v8": "catalog:",
64+
"rimraf": "catalog:",
65+
"typescript": "catalog:",
66+
"vitest": "catalog:"
67+
}
68+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist/
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Source of truth for the `examples.yaml` unified examples format emitted/consumed by
3+
* Azure tooling. The JSON Schema generated from this file is published for editor and CI
4+
* validation and is used by the `examples-validate` tool (via ajv).
5+
*
6+
* See the RFC: Unified Examples Format (§3–§4).
7+
*
8+
* Note: because YAML permits arbitrary string keys, status codes, headers, query params, and
9+
* map-shaped bodies are modeled as `Record<...>`. Rules that JSON Schema cannot express
10+
* (integer-only status codes, quoted `since`, `since` ∈ `service.yaml`, per-lineage
11+
* uniqueness, file placement, `{api-version}`-only placeholder) are enforced by the
12+
* `examples-validate` semantic rules.
13+
*/
14+
import "@typespec/json-schema";
15+
16+
using JsonSchema;
17+
18+
/**
19+
* Top-level `examples.yaml` file: `$schema`/`$namespace` metadata plus one entry per
20+
* operation, keyed by the interface-relative operation name (e.g. `CaCertificates.get`).
21+
* Each operation maps to a list of example variants.
22+
*
23+
* The indexer is widened to `Example[] | string` so the `$`-prefixed string metadata keys
24+
* are structurally valid. The rules that a bare (non-`$`) key must be a list of examples and
25+
* that the only allowed metadata keys are `$schema`/`$namespace` are enforced by the
26+
* `examples-validate` semantic rules.
27+
*/
28+
@jsonSchema("examples.yaml")
29+
model ExamplesYaml is Record<Example[] | string> {
30+
/** Schema URL. Gives editors autocomplete and inline validation while authoring. */
31+
$schema?: string;
32+
33+
/**
34+
* Service namespace (e.g. `Microsoft.EventGrid`), prepended to each operation key to form
35+
* the fully-qualified operation identity used for Swagger linkage (`x-id`).
36+
*/
37+
$namespace?: string;
38+
}
39+
40+
/** A single example variant representing one complete API interaction. */
41+
model Example {
42+
/**
43+
* Optional human-readable title. Omit for the single-example case; provide it only to
44+
* disambiguate multiple distinct examples on the same operation. Entries sharing a `title`
45+
* form one lineage; untitled entries all belong to the single default lineage.
46+
*/
47+
title?: string;
48+
49+
/** Longer description of what this example demonstrates. */
50+
description?: string;
51+
52+
/**
53+
* Quoted version from which this variant applies (must be a version listed in the service's
54+
* `service.yaml`). Omit to apply from the earliest version.
55+
*/
56+
since?: string;
57+
58+
/** The request of the API interaction. */
59+
request: ExampleRequest;
60+
61+
/** Responses keyed by integer status code (e.g. `200`, `404`). */
62+
responses: Record<ExampleResponse>;
63+
}
64+
65+
/** The request portion of an example, split by parameter location. */
66+
model ExampleRequest {
67+
/** Path parameters. `api-version` is implicit and MUST NOT be included. */
68+
path?: Record<unknown>;
69+
70+
/** Query parameters (e.g. `$top`, `$filter`). */
71+
query?: Record<unknown>;
72+
73+
/** Request headers (e.g. `If-Match`). */
74+
headers?: Record<unknown>;
75+
76+
/** Request body. Structure matches the operation's request schema. */
77+
body?: unknown;
78+
}
79+
80+
/** A single response of an example, keyed in the parent map by integer status code. */
81+
model ExampleResponse {
82+
/** Response headers (e.g. `Location`, `Azure-AsyncOperation`). */
83+
headers?: Record<unknown>;
84+
85+
/** Response body. Structure matches the operation's response schema. */
86+
body?: unknown;
87+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
emit:
2+
- "@typespec/json-schema"
3+
4+
options:
5+
"@typespec/json-schema":
6+
emitter-output-dir: "{project-root}/dist"
7+
file-type: json
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/usr/bin/env node
2+
/* eslint-disable no-console */
3+
import { resolve } from "path";
4+
import yargs from "yargs";
5+
import { hideBin } from "yargs/helpers";
6+
import { validateExamplesDir } from "./discover.js";
7+
import { formatDiagnostics, formatSummary } from "./reporter.js";
8+
9+
async function main(): Promise<void> {
10+
const args = await yargs(hideBin(process.argv))
11+
.scriptName("examples-validate")
12+
.usage("$0 [dir]", "Validate unified examples format files (examples.yaml)")
13+
.positional("dir", {
14+
type: "string",
15+
describe: "Service directory containing examples.yaml / examples/*.yaml and service.yaml",
16+
default: ".",
17+
})
18+
.option("warn-as-error", {
19+
type: "boolean",
20+
default: false,
21+
describe: "Treat warnings as errors (non-zero exit)",
22+
})
23+
.strict()
24+
.help()
25+
.parse();
26+
27+
const dir = resolve(process.cwd(), args.dir as string);
28+
const { diagnostics, files } = await validateExamplesDir(dir);
29+
30+
if (files.length === 0) {
31+
console.error(
32+
`No examples files found in ${dir} (looked for examples.yaml and examples/*.yaml).`,
33+
);
34+
process.exit(1);
35+
}
36+
37+
if (diagnostics.length > 0) {
38+
console.log(formatDiagnostics(diagnostics));
39+
console.log("");
40+
}
41+
console.log(formatSummary(diagnostics));
42+
43+
const hasError = diagnostics.some((d) => d.severity === "error");
44+
const hasWarning = diagnostics.some((d) => d.severity === "warning");
45+
process.exit(hasError || (args["warn-as-error"] && hasWarning) ? 1 : 0);
46+
}
47+
48+
main().catch((error) => {
49+
console.error(error);
50+
process.exit(1);
51+
});

0 commit comments

Comments
 (0)