Generic REST API bundle
Inherits hard rules from SKILL.md §Hard rules.
Use this bundle for non-Azure REST APIs that should emit OpenAPI 3.x and remain friendly to APIM, SDK generators, MCP tools, and agent/plugin consumers.
Recommended dependencies
{
"type": "module",
"private": true,
"scripts": {
"build": "tsp compile . --warn-as-error",
"watch": "tsp compile . --watch",
"format": "tsp format \"**/*.tsp\""
},
"dependencies": {
"@typespec/compiler": "<verified-compatible-version>",
"@typespec/http": "<verified-compatible-version>",
"@typespec/rest": "<verified-compatible-version>",
"@typespec/openapi3": "<verified-compatible-version>"
},
"devDependencies": {}
}Before committing, replace <verified-compatible-version> with versions verified from official TypeSpec release notes, the repository lockfile, or tsp init output. Keep TypeSpec packages on compatible release families.
Recommended tspconfig.yaml
warn-as-error: true
emit:
- "@typespec/openapi3"
options:
"@typespec/openapi3":
openapi-versions:
- "3.0.0"Use OpenAPI 3.1 only when every downstream tool is verified to support it.
Design rules
- Path segments use plural nouns and kebab-case:
/line-items, not/lineItems. - Property names use JSON-friendly camelCase.
- Operation names use verb-noun semantics:
listWidgets,createWidget,cancelOrder. - Avoid deep nesting beyond two resource levels unless the parent identity is required.
- Prefer opaque public identifiers over database IDs.
- Model errors explicitly with
@errorand status codes. - Include examples in
examples/and keep them aligned with emitted schemas.
Discriminated unions for polymorphism
TypeSpec unions emit as oneOf without discriminator by default. Agents and SDK generators cannot reliably deserialize discriminator-free oneOf. Use @discriminated to fix:
@discriminated(#{ envelope: "none", discriminatorPropertyName: "kind" })
union Event {
Click: ClickEvent,
View: ViewEvent,
}
model ClickEvent { kind: "Click"; element: string; }
model ViewEvent { kind: "View"; duration: int32; }Note: @discriminated (for unions) is a core TypeSpec standard-library decorator (the TypeSpec namespace from @typespec/compiler) - it is available without importing @typespec/http. The alternative for inheritance hierarchies is @discriminator("kind") on a base model (also core). For Azure data-plane work, follow the @azure-tools/typespec-azure-core discriminator guidance (extensible-union discriminator property).
Query parameters in routes
Do NOT embed query parameters in @route(...) strings - TypeSpec emits the path-query diagnostic.
- Wrong:
@route("/widgets?type=active") - Right:
@route("/widgets") op list(@query type: string)
For action verbs, prefer POST to a sub-route like /widgets/{id}:archive.
Response patterns
For single resources:
/** A single widget response. */
model WidgetResponse {
/** The returned widget. */
widget: Widget;
}For lists, decide whether the API needs pagination now. For public APIs, prefer a paged envelope even if the first implementation is small.
/** A page of widgets. */
model WidgetListResponse {
/** Current page of results. */
items: Widget[];
/** Token used to request the next page. */
nextLink?: url;
}Error pattern
/** Standard API error. */
@error
model ErrorResponse {
@statusCode statusCode: 400 | 401 | 403 | 404 | 409 | 422 | 429 | 500;
/** Stable machine-readable error code. */
code: string;
/** Human-readable error message safe to return to callers. */
message: string;
/** Optional correlation identifier for support. */
correlationId?: string;
}Security additions
Per-operation auth override. Override service-level auth for unauthenticated endpoints (health, ping, public discovery) using
NoAuthfrom@typespec/http:using Http; @useAuth(NoAuth) op health(): { status: "ok" };Rate-limit headers. Surface throttling on
429 Too Many Requests(and optionally200 OK) responses using the IETFdraft-ietf-httpapi-ratelimit-headersfamily plus standardRetry-After(the latter is standardized by RFC 9110). Two forms of the rate-limit headers exist:
(a) Legacy triple - RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset. Present in older drafts and widely deployed.
(b) Modern structured-field single header - RateLimit: "default";r=50;t=30 plus a separate RateLimit-Policy header (current draft).
Pick one form per API and document it. Example using the legacy triple:
@error
model TooManyRequests {
@statusCode statusCode: 429;
@header("Retry-After") retryAfter: int32;
@header("RateLimit-Limit") rateLimitLimit: int32;
@header("RateLimit-Remaining") rateLimitRemaining: int32;
@header("RateLimit-Reset") rateLimitReset: int32;
code: "TooManyRequests";
message: string;
}Error information disclosure. Error response bodies must NOT include stack traces, internal hostnames, SQL fragments, file paths, or raw exception messages. The contract should expose only
code(stable, machine-readable),message(sanitized, human-readable),target(optional field path), anddetails[]for sub-errors. Put internal correlation identifiers in headers (x-ms-correlation-id,traceparent), never in the response body.Idempotency key pattern. For POST operations that create resources or trigger non-idempotent side effects, accept an
Idempotency-Keyheader. Hard rule #24 extends this to PATCH: the same key semantics apply, but scope the replay key tokey + method + path + If-Match + body hash- two PATCHes sharing a key but carrying differentIf-Matchvalues are distinct conditional updates, not replays:op createWidget( @header("Idempotency-Key") @maxLength(64) @pattern("^[A-Za-z0-9_-]+$") idempotencyKey?: string, @body widget: Widget, ): WidgetResponse | ErrorResponse;Idempotency replay window. Server semantics for replay are a 24-hour window matching
Idempotency-Key + route + body hash. These semantics live indraft-ietf-httpapi-idempotency-key-header; the TypeSpec contract only commits to accepting the header - runtime enforcement (storage, hashing, replay-window expiry, conflict detection) is the server's responsibility.ReDoS warning. When using
@pattern(...), avoid nested quantifiers ((a+)+,(.*)*), unbounded alternation overlap, and patterns prone to catastrophic backtracking. Always anchor with^and$. Test patterns against pathological inputs (long strings of the trigger character) before shipping. A regex that compiles is not necessarily safe.Worked examples:
- Bad:
^(a+)+$- catastrophic backtracking on input likeaaaaaaaaaaX. Safe rewrite:^a+$. - Bad:
^(\w+,)*\w+$- overlapping repetition explodes on long unmatched inputs. Safe rewrite:^\w+(,\w+)*$.
- Bad:
PII / data classification. Mark sensitive properties with a
@docclassification note so downstream tooling, logs, and reviewers can identify them. Use values such as"PII - email","PII - phone","Secret - do not log", or"Confidential - financial". If your org defines a custom@dataClassificationdecorator, prefer it; otherwise rely on@docplus a separate data-inventory document.model UserProfile { /** PII - email. Do not log in plain text. */ email: string; /** Secret - do not log. */ apiToken: string; }
Mass-assignment defense
Every server-assigned field MUST carry @visibility(Lifecycle.Read) so it is stripped from Create and Update request payloads. The emitter generates separate request and response schemas based on these visibility lifecycle hints, which prevents clients from overposting fields like id, createdAt, tenantId, or status flags.
model Widget {
@visibility(Lifecycle.Read) id: string;
@visibility(Lifecycle.Read) createdAt: utcDateTime;
/** Caller-supplied display name. */
name: string;
}Audit every model used as a request body: any field the server populates (identity, timestamps, audit metadata, computed status) needs Lifecycle.Read. The emitter will then omit it from request schemas.
URL inputs and SSRF
If an API accepts URLs from clients (webhook callbacks, image-fetch URLs, file URLs, scraping targets), the url scalar provides ONLY syntax validation - it does NOT prevent Server-Side Request Forgery (SSRF). Treat URL parameters as untrusted and document the runtime contract in @doc:
/**
* Webhook callback URL. Server enforces an allow-list of hosts and
* refuses private IP ranges (RFC 1918, link-local, loopback,
* cloud-metadata endpoints).
*/
callbackUrl: url;The TypeSpec contract just commits the API to the policy - the gateway or service must enforce it at runtime (DNS resolution check, IP allow-list, host allow-list, redirect chain limits).
Cache-Control for sensitive responses
Responses containing PII, auth-scoped data, or per-user records should declare Cache-Control: no-store to prevent intermediary caches (CDNs, proxies, browser disk caches) from retaining bytes that may leak across users.
op getUserProfile(@path id: string): {
@statusCode statusCode: 200;
@header("Cache-Control") cacheControl: "no-store";
@body profile: UserProfile;
} | ErrorResponse;For public, non-personalized resources, use a normal Cache-Control directive (public, max-age=...) instead - no-store is the explicit signal that the response must not be persisted.
Sunset / Deprecation signaling
When deprecating an operation or model, combine the #deprecated TypeSpec directive with HTTP runtime signaling. The directive surfaces deprecation in tooling, docs, and IDE hovers; the headers signal it to live clients.
#deprecated "reason"- TypeSpec directive on the operation or model. Place above the declaration.Deprecationresponse header (RFC 9745, March 2025, which supersededdraft-ietf-httpapi-deprecation-header) on every response from a deprecated endpoint. Per RFC 9745 the value is a Structured-Field Date in the form@<unix-timestamp>(e.g.Deprecation: @1688169599) giving the moment deprecation took or takes effect - the older"true"/HTTP-date forms from the draft are no longer the standard.Sunsetresponse header (RFC 8594) carrying the planned retirement date as an HTTP-date.Linkresponse header withrel="successor-version"pointing at the replacement and/orrel="deprecation"pointing at migration documentation.
#deprecated "Use Widgets_listV2; retiring 2027-01-01. See https://docs.example.com/migrate/v2"
op listWidgets(): {
@statusCode statusCode: 200;
@header("Deprecation") deprecation: "@1798761600"; // RFC 9745 structured-field date (= 2027-01-01T00:00:00Z)
@header("Sunset") sunset: "Fri, 01 Jan 2027 00:00:00 GMT";
@header("Link") link: "<https://docs.example.com/migrate/v2>; rel=\"successor-version\", <https://docs.example.com/deprecation>; rel=\"deprecation\"";
@body widgets: WidgetListResponse;
};For property-level deprecation, use @removed(Versions.vX) in the versioning bundle (see bundles/lro-pagination-versioning/guide.md) - it's the only safe way to remove a property without breaking older clients. #deprecated on a property keeps it in the schema with a warning; @removed retires it cleanly in a specific version.
Operational checklist for shipping a deprecation:
- Add
#deprecateddirective and the three response headers in the same PR. - Publish the migration guide referenced by the
Linkheader before the deprecation ships. - Set
Sunsetat least 6 months out for partner APIs, 12 months for public APIs. - Track deprecated-endpoint traffic - do not retire while non-trivial calls remain.
Correlation / trace context
Every production API needs a request-correlation header so support, logs, and distributed traces can be linked across the call graph. Model it in the contract, do not leave it implicit.
Standards. W3C Trace Context defines traceparent (required, well-formed trace ID + parent span ID) and tracestate (optional, vendor extensions). RFC 9457-style Problem Details and most observability stacks (OpenTelemetry, Application Insights, AWS X-Ray, Honeycomb) recognize these headers.
import "@typespec/http";
using Http;
/**
* W3C Trace Context request/response headers (https://www.w3.org/TR/trace-context/).
* `traceparent` carries the trace ID; `tracestate` carries vendor extensions.
* Both flow in on the request and SHOULD be echoed on the response so clients
* can correlate without parsing the body.
*/
model TraceContextHeaders {
@header("traceparent")
@pattern("^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$")
traceparent?: string;
@header("tracestate")
tracestate?: string;
}
/**
* Optional client-supplied request ID for callers that do not implement
* W3C Trace Context. The server echoes it on the response and includes
* it in error-correlation logs.
*/
model ClientRequestIdHeader {
@header("x-ms-client-request-id")
@maxLength(64)
@pattern("^[A-Za-z0-9_-]+$")
clientRequestId?: string;
}Spread these into operations or interfaces that need them:
op getWidget(
@path id: string,
...TraceContextHeaders,
...ClientRequestIdHeader,
): WidgetResponse | ErrorResponse;Server semantics (enforced at runtime, not in the contract):
- If
traceparentis well-formed, propagate it into downstream calls. If malformed or absent, generate a new one and use it. - Echo
traceparentandx-ms-client-request-idon every response, including error responses. - Include both in structured log entries so a single support ticket can be traced end-to-end.
- Do NOT put correlation IDs in URL paths or query parameters - they end up in proxy/CDN logs and link previews.
File upload patterns
Two patterns work for client → API file transfer. Pick one per resource type and document it.
Pattern A - Pre-signed URL (preferred for files > a few MB). Client requests a short-lived signed URL from your API, then PUTs the file directly to blob storage. The API tier never streams large bodies.
/** Request for an upload URL. */
model UploadUrlRequest {
@doc("Logical filename. Used for content-disposition only.")
@maxLength(255)
filename: string;
@doc("MIME type of the upload. Validated server-side against an allow-list.")
@maxLength(127)
contentType: string;
@doc("Expected size in bytes. Capped server-side (see service limits).")
@minValue(1)
sizeBytes: int64;
}
/** Short-lived signed-URL grant. */
model UploadUrlGrant {
@doc("Opaque blob identifier to reference in subsequent API calls.")
blobId: string;
@doc("Pre-signed URL the client PUTs the file body to. Single-use.")
uploadUrl: url;
@doc("URL expiry as an absolute UTC timestamp. Typically 15 minutes.")
expiresAt: utcDateTime;
@doc("HTTP method the client must use against uploadUrl. Usually PUT.")
method: "PUT" | "POST";
@doc("Headers the client MUST include verbatim when uploading.")
requiredHeaders: Record<string>;
}
@route("/uploads")
interface Uploads {
/** Request a pre-signed upload URL. */
@post create(@body request: UploadUrlRequest): UploadUrlGrant | ErrorResponse;
}Runtime checks (enforced by your service, not the TypeSpec contract): allow-list of contentType, cap on sizeBytes, scoped storage credential (single blob, single method, short expiry), virus scan on the resulting blob before any read operation succeeds.
Pattern B - multipart/form-data (for small files or where the upload must be tied to a request body). Use @multipartBody:
import "@typespec/http";
using Http;
model AvatarUpload {
@doc("Image bytes. PNG or JPEG, max 2 MB enforced at runtime.")
file: HttpPart<File>;
@doc("Optional alt text.")
altText?: HttpPart<string>;
}
@route("/users/{userId}/avatar")
@put
op uploadAvatar(
@path userId: string,
@multipartBody body: AvatarUpload,
): { @statusCode statusCode: 204 } | ErrorResponse;The File type lives in Http. The emitter writes multipart/form-data and named parts into the OpenAPI document.
When to pick which:
- Pre-signed URL → media (images, video, documents), backups, anything > ~5 MB, anything where the API tier shouldn't proxy bytes.
- Multipart → form-style submissions tying a file to other fields in a single transaction, or files small enough that a 30-second timeout is comfortable.
Never accept a client-supplied URL and have the server fetch it ("send me the URL, I'll download"). That's a textbook SSRF pattern. If you must support it, see bundles/webhooks/guide.md for the egress-allowlist controls that apply.
Agent-consumable API checks
Before routing to App-as-Skill, MCP, or Microsoft 365 Copilot work:
- Every operation has a clear description and semantic name.
- Parameters are few, documented, constrained, and not overloaded.
- Request and response bodies are named models, not anonymous inline shapes.
- Error responses are predictable.
- Auth scheme is explicit.
- Destructive operations are clearly named and documented.