Skip to content
Merged
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
12 changes: 8 additions & 4 deletions .agents/skills/api/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ You are helping the user add or modify API endpoints in OpenMeter.
api/spec/packages/aip/src/
├── main.tsp # Top-level imports
├── openmeter.tsp # Service definition, routes, and interface wiring
├── konnect.tsp # Konnect-specific service definition
├── konnect.tsp # Konnect-specific service definition (must mirror openmeter.tsp's tags + route interfaces)
├── common/ # Shared types: errors, pagination, parameters
├── shared/ # Shared resources: ULID, request/response wrappers, tags
├── meters/ # Domain: models + operations
Expand All @@ -47,6 +47,8 @@ Each domain typically has:

Routes are wired in `api/spec/packages/aip/src/openmeter.tsp` via interface declarations with `@route` and `@tag` decorators.

`api/spec/packages/aip/src/konnect.tsp` is the parallel Konnect-flavoured service definition. The two files are **not** identical — Konnect has its own service metadata, namespace name, `@useAuth` configuration, security scheme models, and intentionally exposes a narrower subset of the OpenMeter surface. But for any domain that *is* exposed in both, every new domain `import`, `@tagMetadata(...)` entry, and `@route` / `@tag` interface must be added to both files in the same edit. Diff the two files before generating to spot accidental drift; existing differences are expected, but a tag/route you just added showing up in only one file is a bug.

## Workflow

Follow these steps in order:
Expand All @@ -57,8 +59,8 @@ For a new domain/resource:

1. Create a new directory under `api/spec/packages/aip/src/<domain>/`
2. Add `index.tsp`, model file(s), and `operations.tsp`
3. Import the domain in `api/spec/packages/aip/src/openmeter.tsp`
4. Wire up the route interface in `openmeter.tsp`
3. Import the domain in `api/spec/packages/aip/src/openmeter.tsp` **and** `api/spec/packages/aip/src/konnect.tsp` (unless the domain is intentionally OpenMeter-only — confirm with the user before excluding it from Konnect)
4. Wire up the route interface (and any new `@tagMetadata`) in **both** `openmeter.tsp` and `konnect.tsp`. After editing, run `diff openmeter.tsp konnect.tsp` and check that your new imports / tags / interfaces appear on both sides — pre-existing differences (service metadata, namespace name, `@useAuth`, security scheme models) are intentional and unrelated to your change.

For modifying an existing endpoint:

Expand Down Expand Up @@ -342,7 +344,9 @@ OpenMeter v3 APIs follow [Kong's AIP](https://kong-aip.netlify.app/list/) conven
| `rules/aip-158-pagination.md` | Page-based and cursor-based pagination |
| `rules/aip-160-filtering.md` | Filter query syntax, `Common.*FieldFilter` types, label dot-notation |
| `rules/aip-129-labels.md` | Label key constraints, PATCH-with-null semantics |
| `rules/aip-193-errors.md` | RFC-7807 error responses, `Common.ErrorResponses`, 403-before-404 rule |
| `rules/aip-193-errors.md` | AIP-193 RFC-7807 error responses, `invalid_parameters`, 403-before-404 rule |
| `rules/openmeter-error-types.md` | OpenMeter `Common.*` error types wiring AIP-193 onto operations |
| `rules/inline-errors.md` | Inline (partial / non-fatal) errors via `Shared.BaseError<T>` for 2xx responses |
| `rules/aip-composition.md` | Composition-over-inheritance (spread, `model is`, `@discriminator`) |
| `rules/aip-docs.md` | `@doc`/`/** */` requirements, `@operationId`, `@summary` |
| `rules/aip-181-stability.md` | `x-private` / `x-unstable` / `x-internal` stability markers |
Expand Down
17 changes: 3 additions & 14 deletions .agents/skills/api/rules/aip-193-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,7 @@ When a caller lacks access to a resource:
- Return `403 Forbidden` if the resource is owned by the caller's organization (they exist but can't touch it)
- Return `404 Not Found` otherwise — this prevents data-existence leakage across tenants

## OpenMeter Common types
## See also

Use `Common.ErrorResponses` (= `BadRequest | Unauthorized | Forbidden`) on every operation, then add specific types explicitly. Types with ★ are OpenMeter extensions beyond what AIP-193 documents by name.

| Type | Status | Source | When to add explicitly |
| ----------------------------- | ------ | ----------- | --------------------------------------------------- |
| `Common.NotFound` | 404 | AIP-193 | GET, PATCH, PUT, DELETE by ID |
| `Common.Conflict` | 409 | AIP-193 | Create operations that may conflict |
| `Common.Gone` | 410 | ★ OpenMeter | PUT/PATCH when the resource was soft-deleted |
| `Common.PayloadTooLarge` | 413 | ★ OpenMeter | Endpoints accepting large bodies or bulk operations |
| `Common.UnprocessableContent` | 422 | ★ OpenMeter | Semantically invalid requests |

```tsp
get(@path meterId: Shared.ULID): Shared.GetResponse<Meter> | Common.NotFound | Common.ErrorResponses;
```
- `rules/openmeter-error-types.md` — OpenMeter `Common.*` types that wire AIP-193 onto operations, including OpenMeter-only status codes (410, 413, 422).
- `rules/inline-errors.md` — inline errors returned **inside** a 2xx response body (partial successes, pre-flight validation on draft resources). AIP-193 itself only covers transport-level error responses.
85 changes: 85 additions & 0 deletions .agents/skills/api/rules/inline-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Inline (partial / non-fatal) errors

Some endpoints return errors **inside** a 2xx response body. These are **not** RFC-7807 problem details — RFC-7807 (see `aip-193-errors.md`) covers errors that fail the whole request. Inline errors live as a field on the response or resource model and describe per-item or pre-flight failures that did not stop the response from being produced.

## When to use

- **Partially successful responses** — the operation processed some items and reports per-item failures alongside the successful results (typically a `data[]` plus `errors[]` pair on the response model).
- **Not-yet-finalized resources** — the resource itself is in a non-final state (e.g. a draft) and exposes pre-flight findings that block promotion to the final state. The GET itself succeeds; the findings are part of the resource shape.

If the whole request fails, return an RFC-7807 response instead — see `aip-193-errors.md`.

## Required shape: `Shared.BaseError<T>`

All inline errors compose `Shared.BaseError<T>` from `api/spec/packages/aip/src/shared/errors.tsp`. It defines the three uniform fields every inline error must carry, so SDKs and UIs can render them generically:

| Field | Type | Required | Meaning |
| ------------ | ----------------- | -------- | ------------------------------------------------------------------ |
| `code` | `T` | yes | Machine-readable error code |
| `message` | `string` | yes | Human-readable description |
| `attributes` | `Record<unknown>` | optional | Additional structured context (field path, offending value, IDs) |

`T` is the type of `code`. Pick one:

- **Domain-scoped enum (preferred)** — codes are discoverable and stable, and clients can switch on them. Include an `Unknown: "unknown"` zero member for forward compatibility.
- **`string`** — only when the set of codes is genuinely open-ended (e.g. validation rules that grow over time without API churn).

## Composition pattern

Use `model … is Shared.BaseError<T>` and add domain-specific identifying fields as needed (e.g. the input that produced the error, a JSON path, a resource id). Do **not** redeclare `code`, `message`, or `attributes`.

```tsp
import "../shared/index.tsp";

namespace MyDomain;

/** Machine-readable code for a {operation} error. */
@friendlyName("MyOperationErrorCode")
enum MyOperationErrorCode {
Unknown: "unknown",
// domain-specific codes...
}

/** Inline error returned by {operation}. */
@friendlyName("MyOperationError")
model MyOperationError is Shared.BaseError<MyOperationErrorCode> {
/** Identifier from the request that produced this error. */
@visibility(Lifecycle.Read)
@summary("Subject")
subject?: string;
}
```

For an open-ended code set, pass `string`:

```tsp
@friendlyName("MyValidationError")
model MyValidationError is Shared.BaseError<string> {
/** JSON path to the offending field. */
@visibility(Lifecycle.Read)
@summary("Field")
field: string;
}
```

## Wiring into the response

Add the inline errors as a field on the response or resource model — typically named `errors` (partial responses) or a domain-specific name like `validation_errors` (pre-flight findings on a draft resource).

```tsp
@friendlyName("MyOperationResponse")
model MyOperationResponse {
@visibility(Lifecycle.Read)
data: MyOperationResult[];

/** Per-item failures encountered while processing the request. */
@visibility(Lifecycle.Read)
errors: MyOperationError[];
}
```

## Naming

- Model: `<Domain><Operation>Error` or `<Domain>ValidationError`.
- Code enum (when used): `<ModelName>Code` (drop the trailing `Error`, append `Code`).
- All fields use `@visibility(Lifecycle.Read)` — inline errors are always server-produced.
17 changes: 17 additions & 0 deletions .agents/skills/api/rules/openmeter-error-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# OpenMeter error response types

OpenMeter wires AIP-193 (`rules/aip-193-errors.md`) onto operations through the `Common.*` types in `api/spec/packages/aip/src/common/`. Use `Common.ErrorResponses` (= `BadRequest | Unauthorized | Forbidden`) on every operation, then add specific types explicitly. Types with ★ are OpenMeter extensions beyond what AIP-193 documents by name.

| Type | Status | Source | When to add explicitly |
| ----------------------------- | ------ | ----------- | --------------------------------------------------- |
| `Common.NotFound` | 404 | AIP-193 | GET, PATCH, PUT, DELETE by ID |
| `Common.Conflict` | 409 | AIP-193 | Create operations that may conflict |
| `Common.Gone` | 410 | ★ OpenMeter | PUT/PATCH when the resource was soft-deleted |
| `Common.PayloadTooLarge` | 413 | ★ OpenMeter | Endpoints accepting large bodies or bulk operations |
| `Common.UnprocessableContent` | 422 | ★ OpenMeter | Semantically invalid requests |

```tsp
get(@path meterId: Shared.ULID): Shared.GetResponse<Meter> | Common.NotFound | Common.ErrorResponses;
```

For inline errors returned **inside** a 2xx response body (partial successes, pre-flight validation on draft resources), see `rules/inline-errors.md`.
2 changes: 1 addition & 1 deletion api/spec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"generate": "pnpm --filter @openmeter/api-spec-legacy run generate && pnpm --filter @openmeter/api-spec-aip run generate",
"format": "prettier --list-different --find-config-path --write .",
"format": "prettier --list-different --find-config-path --write . && pnpm --filter @openmeter/api-spec-aip run format",
"lint": "prettier --check . && pnpm --filter @openmeter/api-spec-legacy run lint && pnpm --filter @openmeter/api-spec-aip run lint",
"lint:fix": "prettier --write ."
},
Expand Down
3 changes: 2 additions & 1 deletion api/spec/packages/aip/lib/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { defineLinter } from '@typespec/compiler'
import { casingErrorsRule, casingRule } from './rules/casing.js'
import { docDecoratorRule } from './rules/docs.js'
import { docDecoratorRule, docFormatRule } from './rules/docs.js'
import { friendlyNameRule } from './rules/friendly-name.js'
import { operationSummaryRule } from './rules/operation-summary.js'
import { operationIdKebabCaseRule } from './rules/operation-id.js'
Expand All @@ -13,6 +13,7 @@ const rules = [
casingRule,
casingErrorsRule,
docDecoratorRule,
docFormatRule,
friendlyNameRule,
noNullableRule,
operationSummaryRule,
Expand Down
145 changes: 144 additions & 1 deletion api/spec/packages/aip/lib/rules/docs.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
import { createRule, paramMessage, getDoc } from '@typespec/compiler'
import {
createRule,
defineCodeFix,
getDoc,
getSourceLocation,
paramMessage,
} from '@typespec/compiler'
import * as prettier from 'prettier'
import {
detectNewline,
extractMarkdownFromDocComment,
getIndentBefore,
wrapMarkdownAsDocComment,
} from './utils.js'

export const docDecoratorRule = createRule({
name: 'doc-decorator',
Expand Down Expand Up @@ -60,3 +73,133 @@ export const docDecoratorRule = createRule({
},
}),
})

/**
* Format a doc-comment Markdown body through Prettier.
* Returns the formatted Markdown body (no `/** *\/` framing).
* @param {string} markdown
* @param {{ printWidth?: number, proseWrap?: 'always' | 'never' | 'preserve' }} [options]
*/
async function formatDocMarkdown(markdown, options = {}) {
if (markdown.trim() === '') return ''
return await prettier.format(markdown, {
parser: 'markdown',
printWidth: options.printWidth ?? 80,
proseWrap: options.proseWrap ?? 'always',
})
}

/**
* Build a code fix that replaces a doc comment with a precomputed string.
* The Prettier work happens before this is constructed; the fix callback is
* sync and just emits the replacement.
*
* @param {import('@typespec/compiler').SourceLocation} location
* The full `/** ... *\/` source range.
* @param {string} newText The replacement text, including `/**` and `*\/`.
*/
function createFormatDocCommentCodeFix(location, newText) {
return defineCodeFix({
id: 'format-doc-comment',
label: 'Format doc comment',
fix(context) {
return context.replaceText(location, newText)
},
})
}

/**
* Collect every `DocNode` reachable from the program by walking semantic
* targets that can carry doc comments. We use the existing semantic listener
* surface (model/property/enum/etc.) rather than a private AST walker.
*/
function collectDocNodes(target, sink) {
const node = target.node
if (!node || !node.docs || node.docs.length === 0) return
for (const doc of node.docs) sink.push(doc)
}

export const docFormatRule = createRule({
name: 'doc-format',
severity: 'warning',
description:
'Format doc comment bodies as Markdown using Prettier (proseWrap=always).',
messages: {
default:
'Doc comment is not formatted. Apply the suggested fix to reformat as Markdown.',
},
// Async because Prettier 3.x's `format` is async.
async: true,
create: (context) => {
/** @type {import('@typespec/compiler').DocNode[]} */
const docNodes = []

const collect = (target) => collectDocNodes(target, docNodes)

return {
model: collect,
modelProperty: collect,
enum: collect,
enumMember: collect,
union: collect,
unionVariant: collect,
operation: collect,
interface: collect,
scalar: collect,
namespace: collect,

async exit() {
// Deduplicate: a doc may be visited via multiple semantic kinds.
const seen = new Set()
const work = []
for (const doc of docNodes) {
if (seen.has(doc)) continue
seen.add(doc)
work.push(processDoc(doc, context))
}
await Promise.all(work)
},
}
},
})

/**
* Compute a formatted replacement for a single DocNode and, if it differs
* from the source, report a diagnostic with an attached code fix.
* @param {import('@typespec/compiler').DocNode} doc
* @param {import('@typespec/compiler').LinterRuleContext<any>} context
*/
async function processDoc(doc, context) {
const location = getSourceLocation(doc)
const source = location.file.text
const raw = source.slice(location.pos, location.end)

// Defensive: only format actual `/** ... */` blocks.
if (!raw.startsWith('/**') || !raw.endsWith('*/')) return

const indent = getIndentBefore(source, location.pos)
const newline = detectNewline(source)

let markdown
try {
markdown = extractMarkdownFromDocComment(raw)
} catch {
return
}

let formatted
try {
formatted = await formatDocMarkdown(markdown)
} catch {
// If Prettier can't parse the body, leave it alone.
return
}

const replacement = wrapMarkdownAsDocComment(formatted, indent, newline)
if (replacement === raw) return

context.reportDiagnostic({
target: doc,
codefixes: [createFormatDocCommentCodeFix(location, replacement)],
})
}
Loading
Loading