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 `@azure-tools/typespec-azure-examples` with the `examples.yaml` JSON Schema and the `examples-validate` tool for the unified examples format.
3 changes: 3 additions & 0 deletions packages/typespec-azure-examples/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dist/
temp/
schema/dist/
21 changes: 21 additions & 0 deletions packages/typespec-azure-examples/.scripts/schema-json-to-js.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// @ts-check

import { readFile, writeFile } from "fs/promises";
import { dirname, resolve } from "path";
import { fileURLToPath } from "url";

const __dirname = dirname(fileURLToPath(import.meta.url));

const jsonFilename = resolve(__dirname, "../schema/dist/ExamplesYaml.json");
const jsFilename = resolve(__dirname, "../schema/dist/schema.js");
console.log("Reading json schema at:", jsonFilename);
const content = await readFile(jsonFilename);

const json = JSON.parse(content.toString());

const jsContent = `
const schema = ${JSON.stringify(json)};
export default schema;
`;
console.log("Writing json schema in js file:", jsFilename);
await writeFile(jsFilename, jsContent);
83 changes: 83 additions & 0 deletions packages/typespec-azure-examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# @azure-tools/typespec-azure-examples

Tooling for the Azure **unified examples format** (`examples.yaml`): the published JSON Schema
and the `examples-validate` 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
services). See the RFC: _Unified Examples Format_.

## Format

```yaml
$schema: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/schemas/examples.schema.yaml
$namespace: Microsoft.EventGrid

CaCertificates.get:
- request:
path:
subscriptionId: 8f6b6269-84f2-4d09-9e31-1127efcd1e40
resourceGroupName: myResourceGroup
responses:
200:
body:
name: exampleCaCertificate
properties:
provisioningState: Succeeded
- since: "2023-12-15-preview"
request:
path:
subscriptionId: 8f6b6269-84f2-4d09-9e31-1127efcd1e40
resourceGroupName: myResourceGroup
responses:
200:
body:
name: exampleCaCertificate
properties:
provisioningState: Succeeded
delegatedIdentityTokenExpirationTimeInUtc: "2023-10-12T23:06:43+00:00"
```

- File metadata uses `$`-prefixed keys (`$schema`, `$namespace`); every bare top-level key is an
operation, identified by its interface-relative name (`Interface.operation`).
- Each operation maps to a list of example variants. The base variant has no `since`; later
variants carry a quoted `since` and restate the full request/response.
- Response status codes are bare integer keys. `api-version` is implicit; use the
`{api-version}` placeholder where a version must be embedded in a value.

## `examples-validate`

Validate a service's example files against the JSON Schema and the format rules:

```bash
examples-validate <service-dir>
```

It discovers `examples.yaml` and `examples/*.yaml` in the directory, reads the adjacent
`service.yaml` for version metadata, and reports diagnostics. It exits non-zero if any error is
found (use `--warn-as-error` to also fail on warnings).

### Rules enforced

- Only `$schema`/`$namespace` may be `$`-prefixed; other bare keys are operations that must be a
list of examples.
- Response keys are integer status codes; range keys (`2XX`) and `default` are rejected.
- `since` must be a quoted string and a version listed in `service.yaml`.
- Per lineage (entries grouped by `title`; untitled entries form the default lineage): at most one
entry without `since`, and `since` values are unique.
- An operation's full example set lives in a single file, and each interface appears in exactly
one file.
- `{api-version}` is the only supported placeholder, and `api-version` must not appear as a
request parameter.

## API

```ts
import {
validateExamplesDir,
validateExampleFiles,
loadExampleFile,
} from "@azure-tools/typespec-azure-examples";

const { diagnostics } = await validateExamplesDir("path/to/service");
```
68 changes: 68 additions & 0 deletions packages/typespec-azure-examples/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"name": "@azure-tools/typespec-azure-examples",
"version": "0.1.0",
"author": "Microsoft Corporation",
"description": "Tooling for the Azure unified examples format (examples.yaml): JSON Schema and the examples-validate CLI",
"homepage": "https://azure.github.io/typespec-azure",
"readme": "https://github.com/Azure/typespec-azure/blob/main/README.md",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/Azure/typespec-azure.git"
},
"bugs": {
"url": "https://github.com/Azure/typespec-azure/issues"
},
"keywords": [
"typespec",
"azure",
"examples"
],
"type": "module",
"main": "dist/src/index.js",
"exports": {
".": {
"types": "./dist/src/index.d.ts",
"default": "./dist/src/index.js"
}
},
"bin": {
"examples-validate": "./dist/src/cli.js"
},
"engines": {
"node": ">=22.0.0"
},
"scripts": {
"clean": "rimraf ./dist ./schema/dist",
"build": "npm run regen-examples-yaml-schema && tsc -p tsconfig.build.json",
"watch": "tsc -p tsconfig.build.json --watch",
"regen-examples-yaml-schema": "tsp compile ./schema/examples-yaml.tsp --warn-as-error && node ./.scripts/schema-json-to-js.js",
"test": "vitest run",
"test:watch": "vitest -w",
"test:ci": "vitest run --coverage --reporter=junit --reporter=default",
"lint": "oxlint . --deny-warnings",
"lint:fix": "oxlint . --fix"
},
"files": [
"schema/dist/schema.js",
"schema/dist/ExamplesYaml.json",
"dist/**",
"!dist/test/**"
],
"dependencies": {
"ajv": "catalog:",
"picocolors": "catalog:",
"yaml": "catalog:",
"yargs": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@types/yargs": "catalog:",
"@typespec/compiler": "workspace:^",
"@typespec/json-schema": "workspace:^",
"@vitest/coverage-v8": "catalog:",
"rimraf": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}
1 change: 1 addition & 0 deletions packages/typespec-azure-examples/schema/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist/
87 changes: 87 additions & 0 deletions packages/typespec-azure-examples/schema/examples-yaml.tsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Source of truth for the `examples.yaml` unified examples format emitted/consumed by
* Azure tooling. The JSON Schema generated from this file is published for editor and CI
* validation and is used by the `examples-validate` tool (via ajv).
*
* See the RFC: Unified Examples Format (§3–§4).
*
* Note: because YAML permits arbitrary string keys, status codes, headers, query params, and
* map-shaped bodies are modeled as `Record<...>`. Rules that JSON Schema cannot express
* (integer-only status codes, quoted `since`, `since` ∈ `service.yaml`, per-lineage
* uniqueness, file placement, `{api-version}`-only placeholder) are enforced by the
* `examples-validate` semantic rules.
*/
import "@typespec/json-schema";

using JsonSchema;

/**
* Top-level `examples.yaml` file: `$schema`/`$namespace` metadata plus one entry per
* operation, keyed by the interface-relative operation name (e.g. `CaCertificates.get`).
* Each operation maps to a list of example variants.
*
* The indexer is widened to `Example[] | string` so the `$`-prefixed string metadata keys
* are structurally valid. The rules that a bare (non-`$`) key must be a list of examples and
* that the only allowed metadata keys are `$schema`/`$namespace` are enforced by the
* `examples-validate` semantic rules.
*/
@jsonSchema("examples.yaml")
model ExamplesYaml is Record<Example[] | string> {
/** Schema URL. Gives editors autocomplete and inline validation while authoring. */
$schema?: string;

/**
* Service namespace (e.g. `Microsoft.EventGrid`), prepended to each operation key to form
* the fully-qualified operation identity used for Swagger linkage (`x-id`).
*/
$namespace?: string;
}

/** A single example variant representing one complete API interaction. */
model Example {
/**
* Optional human-readable title. Omit for the single-example case; provide it only to
* disambiguate multiple distinct examples on the same operation. Entries sharing a `title`
* form one lineage; untitled entries all belong to the single default lineage.
*/
title?: string;

/** Longer description of what this example demonstrates. */
description?: string;

/**
* Quoted version from which this variant applies (must be a version listed in the service's
* `service.yaml`). Omit to apply from the earliest version.
*/
since?: string;

/** The request of the API interaction. */
request: ExampleRequest;

/** Responses keyed by integer status code (e.g. `200`, `404`). */
responses: Record<ExampleResponse>;
}

/** The request portion of an example, split by parameter location. */
model ExampleRequest {
/** Path parameters. `api-version` is implicit and MUST NOT be included. */
path?: Record<unknown>;

/** Query parameters (e.g. `$top`, `$filter`). */
query?: Record<unknown>;

/** Request headers (e.g. `If-Match`). */
headers?: Record<unknown>;

/** Request body. Structure matches the operation's request schema. */
body?: unknown;
}

/** A single response of an example, keyed in the parent map by integer status code. */
model ExampleResponse {
/** Response headers (e.g. `Location`, `Azure-AsyncOperation`). */
headers?: Record<unknown>;

/** Response body. Structure matches the operation's response schema. */
body?: unknown;
}
7 changes: 7 additions & 0 deletions packages/typespec-azure-examples/schema/tspconfig.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
emit:
- "@typespec/json-schema"

options:
"@typespec/json-schema":
emitter-output-dir: "{project-root}/dist"
file-type: json
51 changes: 51 additions & 0 deletions packages/typespec-azure-examples/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env node
/* eslint-disable no-console */
import { resolve } from "path";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { validateExamplesDir } from "./discover.js";
import { formatDiagnostics, formatSummary } from "./reporter.js";

async function main(): Promise<void> {
const args = await yargs(hideBin(process.argv))
.scriptName("examples-validate")
.usage("$0 [dir]", "Validate unified examples format files (examples.yaml)")
.positional("dir", {
type: "string",
describe: "Service directory containing examples.yaml / examples/*.yaml and service.yaml",
default: ".",
})
.option("warn-as-error", {
type: "boolean",
default: false,
describe: "Treat warnings as errors (non-zero exit)",
})
.strict()
.help()
.parse();

const dir = resolve(process.cwd(), args.dir as string);
const { diagnostics, files } = await validateExamplesDir(dir);

if (files.length === 0) {
console.error(
`No examples files found in ${dir} (looked for examples.yaml and examples/*.yaml).`,
);
process.exit(1);
}

if (diagnostics.length > 0) {
console.log(formatDiagnostics(diagnostics));
console.log("");
}
console.log(formatSummary(diagnostics));

const hasError = diagnostics.some((d) => d.severity === "error");
const hasWarning = diagnostics.some((d) => d.severity === "warning");
process.exit(hasError || (args["warn-as-error"] && hasWarning) ? 1 : 0);
}

main().catch((error) => {
console.error(error);
process.exit(1);
});
Loading
Loading