@nxgt/typespec 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ # Authentication
2
+
3
+ How an operation says it needs a session, and what it answers without one,
4
+ as [`@nxgt/janus`](https://www.npmjs.com/package/@nxgt/janus) serves it.
5
+
6
+ ## Declaring it
7
+
8
+ ```tsp
9
+ import "@typespec/http";
10
+ import "@nxgt/typespec";
11
+
12
+ using Http;
13
+ using Nxgt;
14
+
15
+ namespace Blog;
16
+
17
+ @route("/posts/{postId}/comments")
18
+ interface Comments {
19
+ @get list(@path postId: string): Comment[];
20
+
21
+ @useAuth(JanusAuth)
22
+ @delete delete(@path postId: string, @path commentId: string):
23
+ NoContentResponse | AuthenticationRequired | AccessDenied | ErrorWithoutBody<404>;
24
+ }
25
+ ```
26
+
27
+ `@useAuth` is TypeSpec's own, from `@typespec/http`. On an interface or a
28
+ namespace, it covers every operation inside; `@useAuth(NoAuth)` opens one
29
+ of them again.
30
+
31
+ ## The schemes
32
+
33
+ `@nxgt/janus` reads a session token from three places, in this order, and
34
+ takes the first it finds:
35
+
36
+ | Scheme | Where the token is | Emitted as |
37
+ | --- | --- | --- |
38
+ | `BearerAuth` | `Authorization: Bearer <token>` | `type: http`, `scheme: Bearer` |
39
+ | `SessionTokenAuth` | the `X-Session-Token` header | `type: apiKey`, `in: header` |
40
+ | `SessionCookieAuth` | the `janus-session` cookie | `type: apiKey`, `in: cookie` |
41
+
42
+ `JanusAuth` is the three, as alternatives: any one of them is enough. Each
43
+ is also usable alone, `@useAuth(BearerAuth)` for an API that only takes
44
+ the header. A janus app that renames its cookie declares its own:
45
+
46
+ ```tsp
47
+ model AdminCookieAuth is ApiKeyAuth<ApiKeyLocation.cookie, "admin-session">;
48
+ ```
49
+
50
+ The spec lists them under `securitySchemes`, for its readers and for the
51
+ clients generated from it. `@nxgt/openapi-codegen` does not read them:
52
+ checking the session is the guard's work, not the validators'.
53
+
54
+ ## The refusals, without a body
55
+
56
+ The guards of [`@nxgt/janus-hono`](https://www.npmjs.com/package/@nxgt/janus-hono)
57
+ refuse a request with a status and nothing else:
58
+
59
+ | Guard | Refuses with | Declare |
60
+ | --- | --- | --- |
61
+ | `session(auth, { required: true })` | 401: no session, or one the server does not know | `AuthenticationRequired` |
62
+ | `fresh()` | 401: no session | `AuthenticationRequired` |
63
+ | `permission()` | 401: no session | `AuthenticationRequired` |
64
+ | `permission()` | 404: the object does not exist | `ErrorWithoutBody<404>` |
65
+ | `permission()` | 403: its user may not do this | `AccessDenied` |
66
+
67
+ The generated `hono.ts` types those replies without a body, so the route
68
+ answers them as the guard does:
69
+
70
+ ```ts
71
+ createRoutes(app).delete('/posts/{postId}/comments/{commentId}', async (c) => {
72
+ // … permission() has let the request through
73
+ return c.body(null, 204);
74
+ });
75
+ ```
76
+
77
+ A handler that sends one of the guards' replies with a body does not
78
+ type-check. With
79
+ `validateResponses`, `@nxgt/openapi-hono` checks a reply's body only where
80
+ the spec declares one, so it lets a body through on these at run time.
81
+
82
+ The errors `janusErrors()` answers are another shape: `fresh()`'s step-up
83
+ 403 and janus's own errors carry its `{ code }` body, which this library does
84
+ not declare.
85
+
86
+ ## Beside a handler's refusal
87
+
88
+ A handler that refuses on its own, after the guard, answers with the
89
+ envelope: `Unauthorized` or `Forbidden` ([Error replies](errors.md)). An
90
+ operation can declare the guard's 401 and the handler's 403:
91
+
92
+ ```tsp
93
+ @useAuth(JanusAuth)
94
+ @post create(@body post: Create<Post>): {
95
+ @statusCode _: 201;
96
+ @body post: Post;
97
+ } | BadRequest | AuthenticationRequired | Forbidden;
98
+ ```
99
+
100
+ Not both replies of one status, though: an operation that declares
101
+ `AuthenticationRequired` and `Unauthorized` gets a single 401, with the
102
+ envelope, and the reply without a body is lost without a warning. Declare
103
+ the one the route sends.
@@ -0,0 +1,117 @@
1
+ # Scalars and columns
2
+
3
+ Two scalars for the strings every API has, and the columns
4
+ [`@nxgt/drizzle`](https://www.npmjs.com/package/@nxgt/drizzle) stamps a row
5
+ with, as models to spread.
6
+
7
+ ## Scalars
8
+
9
+ ```tsp
10
+ import "@typespec/http";
11
+ import "@nxgt/typespec";
12
+
13
+ using Http;
14
+ using Nxgt;
15
+
16
+ namespace Blog;
17
+
18
+ model Author {
19
+ @visibility(Lifecycle.Read) id: uuid;
20
+ email: email;
21
+ site?: url;
22
+ }
23
+
24
+ @route("/authors/{authorId}")
25
+ @get op readAuthor(@path authorId: uuid): Author;
26
+ ```
27
+
28
+ | Scalar | Emitted as | Generated | Refused |
29
+ | --- | --- | --- | --- |
30
+ | `uuid` | `$ref` to the schema `Uuid`: `type: string`, `format: uuid` | `type Uuid = string`, `zUuid = z.guid()` | `42`, in a path, a query or a body |
31
+ | `email` | `$ref` to the schema `Email`: `format: email` | `type Email = string`, `zEmail = z.email()` | `not an address` |
32
+ | `url` | TypeSpec's own: `format: uri`, inline | `z.url()` | `not a url` |
33
+
34
+ `z.guid()` checks the shape of a UUID, as JSON Schema's `uuid` does, not
35
+ the version and variant bits `z.uuid()` also checks.
36
+
37
+ ## Columns
38
+
39
+ `@nxgt/drizzle/pg` declares a table's stamps with its column helpers. The
40
+ models here are the same columns, as the API sends them:
41
+
42
+ ```ts
43
+ pgTable('posts', {
44
+ id: id(),
45
+ title: text('title').notNull(),
46
+ ...timestamps(),
47
+ ...version(),
48
+ ...actors(),
49
+ });
50
+ ```
51
+
52
+ ```tsp
53
+ model Post {
54
+ @visibility(Lifecycle.Read) id: uuid;
55
+ title: string;
56
+ ...Timestamps;
57
+ ...Versioned;
58
+ ...Actors;
59
+ }
60
+ ```
61
+
62
+ | Model | Properties | Visibility | drizzle |
63
+ | --- | --- | --- | --- |
64
+ | `Timestamps` | `createdAt`, `updatedAt: utcDateTime` | read | `timestamps()` |
65
+ | `SoftDelete` | `deletedAt: utcDateTime \| null` | read | `softDelete()` |
66
+ | `Versioned` | `version: integer`, at least 0 | read, update | `version()` |
67
+ | `Actors<Id = uuid>` | `createdBy`, `updatedBy`, `deletedBy: Id \| null` | read | `actors()` |
68
+
69
+ The server sets every read-only column, so `Create<Post>` and
70
+ `MergePatchUpdate<Post>` leave them out, and the generated `CreatePost` and
71
+ `PostMergePatchUpdate` have no `createdAt` a client could send.
72
+ `Actors<string>` or `Actors<integer>` match `actors('text')` or
73
+ `actors('integer')`.
74
+
75
+ ## The version in an update
76
+
77
+ `version` is read with the row and sent back in an update. `@nxgt/drizzle`
78
+ takes it as a condition: it writes the update only while the row is still at
79
+ that version, and throws `OptimisticLockError` otherwise. Declare the
80
+ `Conflict` it becomes:
81
+
82
+ ```tsp
83
+ @patch update(@path postId: uuid, @body post: MergePatchUpdate<Post>):
84
+ Post | BadRequest | NotFound | Conflict;
85
+ ```
86
+
87
+ ```ts
88
+ import { OptimisticLockError } from '@nxgt/drizzle';
89
+ import { Hono } from 'hono';
90
+ import { createRoutes } from './generated/hono';
91
+ import type { ConflictBody } from './generated/types';
92
+
93
+ const app = new Hono();
94
+ createRoutes(app).patch('/posts/{postId}', async (c) => {
95
+ try {
96
+ const post = await posts.update(c.req.valid('param').postId, c.req.valid('json'));
97
+ return c.json(post, 200);
98
+ } catch (error) {
99
+ if (!(error instanceof OptimisticLockError)) throw error;
100
+ const body: ConflictBody = {
101
+ status: 409,
102
+ message: 'errors.version-conflict',
103
+ timestamp: new Date().toISOString(),
104
+ };
105
+ return c.json(body, 409);
106
+ }
107
+ });
108
+ ```
109
+
110
+ In a merge patch, every property is optional, `version` too: an update
111
+ without it is not checked against the version.
112
+
113
+ ## Names
114
+
115
+ `Uuid` and `Email` are schemas of their own, and their names are global: a
116
+ model named `Email` in a spec that also uses `email` collides with it
117
+ ([troubleshooting](../troubleshooting.md)).
@@ -0,0 +1,190 @@
1
+ # Error replies
2
+
3
+ `@nxgt/typespec` declares the error replies the `@nxgt/*` packages send, so a
4
+ spec names them instead of describing them again.
5
+
6
+ ## A whole spec
7
+
8
+ ```yaml
9
+ # api/tspconfig.yaml
10
+ emit:
11
+ - '@typespec/openapi3'
12
+ options:
13
+ '@typespec/openapi3':
14
+ openapi-versions: ['3.1.0']
15
+ emitter-output-dir: '{project-root}/../openapi'
16
+ output-file: openapi.yaml
17
+ ```
18
+
19
+ The spec is split by role, as it grows: `main.tsp` assembles, `models/`
20
+ holds the shapes and `routes/` the operations and their errors.
21
+
22
+ ```tsp
23
+ // api/main.tsp
24
+ import "@typespec/http";
25
+ import "./models/post.tsp";
26
+ import "./routes/posts.tsp";
27
+
28
+ using Http;
29
+
30
+ @service(#{ title: "Blog" })
31
+ namespace Blog;
32
+ ```
33
+
34
+ ```tsp
35
+ // api/models/post.tsp
36
+ namespace Blog;
37
+
38
+ model Post {
39
+ @visibility(Lifecycle.Read) id: string;
40
+ @minLength(1) title: string;
41
+ body: string;
42
+ }
43
+ ```
44
+
45
+ ```tsp
46
+ // api/routes/posts.tsp
47
+ import "@typespec/http";
48
+ import "@nxgt/typespec";
49
+
50
+ using Http;
51
+ using Nxgt;
52
+
53
+ namespace Blog;
54
+
55
+ @route("/posts")
56
+ interface Posts {
57
+ @get read(@path postId: string): Post | NotFound;
58
+ }
59
+ ```
60
+
61
+ ```ts
62
+ // openapi-codegen.config.ts
63
+ import { defineConfig } from '@nxgt/openapi-codegen';
64
+
65
+ export default defineConfig({
66
+ input: 'openapi/openapi.yaml',
67
+ output: 'src/generated',
68
+ hono: true,
69
+ });
70
+ ```
71
+
72
+ ```sh
73
+ bunx --no-install tsp compile api && bunx nxgt-openapi generate
74
+ ```
75
+
76
+ A Hono handler then answers the 404 with the generated type. The generated
77
+ `hono.ts` imports
78
+ [`@nxgt/openapi-hono`](https://github.com/softistx/nxgt-http/blob/develop/packages/openapi-hono/README.md),
79
+ and its validators `zod`: install them with `hono`.
80
+
81
+ ```sh
82
+ bun add @nxgt/openapi-hono hono zod
83
+ ```
84
+
85
+
86
+ ```ts
87
+ import { Hono } from 'hono';
88
+ import { createRoutes } from './generated/hono';
89
+ import type { NotFoundBody } from './generated/types';
90
+
91
+ const app = new Hono();
92
+ createRoutes(app).get('/posts/{postId}', (c) => {
93
+ const body: NotFoundBody = {
94
+ status: 404,
95
+ message: 'errors.not-found',
96
+ timestamp: new Date().toISOString(),
97
+ };
98
+ return c.json(body, 404);
99
+ });
100
+ ```
101
+
102
+ ## The envelope
103
+
104
+ Every error body is `ErrorBody<Status>`, the envelope of
105
+ `@nxgt/openapi-hono`'s own replies, which a handler answers with too:
106
+
107
+ ```tsp
108
+ model ErrorBody<Status extends integer> {
109
+ status: Status; // the reply's status, repeated
110
+ message: string; // an i18n key: errors.not-found
111
+ timestamp: utcDateTime;
112
+ }
113
+ ```
114
+
115
+ `message` is a key for the client to translate, never a sentence. `status`
116
+ is a literal, so the generated type narrows on it:
117
+
118
+ ```ts
119
+ export interface NotFoundBody {
120
+ status: 404;
121
+ message: string;
122
+ timestamp: string;
123
+ }
124
+ ```
125
+
126
+ ## The 400
127
+
128
+ `@nxgt/openapi-hono` refuses a request its validators reject with a 400 that
129
+ lists every value refused:
130
+
131
+ ```json
132
+ {
133
+ "status": 400,
134
+ "message": "errors.validation-failed",
135
+ "timestamp": "2026-09-29T08:00:00.000Z",
136
+ "issues": [
137
+ { "target": "json", "path": ["title"], "code": "too_small", "message": "Too small" }
138
+ ]
139
+ }
140
+ ```
141
+
142
+ A handler may send a 400 of its own, without `issues`. `BadRequestBody`
143
+ describes both, with `issues` optional:
144
+
145
+ ```tsp
146
+ @patch update(@path postId: string, @body post: MergePatchUpdate<Post>): Post | BadRequest;
147
+ ```
148
+
149
+ The generator declares the validators' 400 itself, as `ValidationErrorBody`,
150
+ on every operation that takes a parameter or a body, whether the spec says
151
+ so or not. Beside `BadRequest`, the 400 is typed
152
+ `BadRequestBody | ValidationErrorBody`. An app that answers a refused request
153
+ its own way, through `onValidationError`, turns that off with the generator's
154
+ [`validationErrors: false`](https://github.com/softistx/nxgt-http/blob/develop/packages/openapi-codegen/docs/guide/options.md#validationerrors).
155
+
156
+ ## Each response
157
+
158
+ | Response | Status | Body | Sent when |
159
+ | --- | --- | --- | --- |
160
+ | `BadRequest` | 400 | `BadRequestBody` | the validators or a handler refuse the request |
161
+ | `Unauthorized` | 401 | `UnauthorizedBody` | no credentials, or invalid ones; a `@nxgt/janus-hono` guard sends `AuthenticationRequired` instead ([Authentication](auth.md)) |
162
+ | `Forbidden` | 403 | `ForbiddenBody` | authenticated, but not allowed; a `@nxgt/janus-hono` guard sends `AccessDenied` instead, and `janusErrors()` its own `{ code }` body ([Authentication](auth.md)) |
163
+ | `NotFound` | 404 | `NotFoundBody` | nothing is there |
164
+ | `Conflict` | 409 | `ConflictBody` | the request conflicts with the resource's state, such as a stale `version` |
165
+ | `UnprocessableEntity` | 422 | `UnprocessableEntityBody` | well-formed, but not something the server can act on |
166
+ | `TooManyRequests` | 429 | `TooManyRequestsBody` | too many requests; with the `RateLimit-*` headers and `Retry-After` ([Headers](headers.md)) |
167
+ | `InternalServerError` | 500 | `InternalServerErrorBody` | the server failed; with `validateResponses`, `@nxgt/openapi-hono` sends it with `errors.response-validation-failed` when a reply breaks the spec |
168
+ | `ErrorResponse<Status>` | any | `ErrorBody<Status>` | any other status |
169
+
170
+ Name them in an operation's return type, beside the success:
171
+
172
+ ```tsp
173
+ @get read(@path postId: string): Post | NotFound | Unauthorized | Forbidden;
174
+ ```
175
+
176
+ ## Another status, named
177
+
178
+ `ErrorResponse<503>` works, but its body is emitted inline and generated as
179
+ `<Operation>503Response`. For a named schema, declare the body and the
180
+ response once:
181
+
182
+ ```tsp
183
+ model ServiceUnavailableBody is ErrorBody<503>;
184
+
185
+ @error
186
+ model ServiceUnavailable {
187
+ @statusCode _: 503;
188
+ @body body: ServiceUnavailableBody;
189
+ }
190
+ ```
@@ -0,0 +1,96 @@
1
+ # Headers
2
+
3
+ The headers of an idempotent write and of a rate limit, with the replies
4
+ that go with them.
5
+
6
+ ## An idempotent write
7
+
8
+ A client that may retry a write sends an `Idempotency-Key` with it: the
9
+ server writes once, and answers a retry with the first answer.
10
+
11
+ ```tsp
12
+ import "@typespec/http";
13
+ import "@nxgt/typespec";
14
+
15
+ using Http;
16
+ using Nxgt;
17
+
18
+ namespace Blog;
19
+
20
+ @route("/posts")
21
+ interface Posts {
22
+ @post create(...IdempotencyKeyHeader, @body post: Create<Post>): {
23
+ @statusCode _: 201;
24
+ ...IdempotentReplayedHeader;
25
+ @body post: Post;
26
+ } | BadRequest | IdempotencyInProgress | IdempotencyKeyReused;
27
+ }
28
+ ```
29
+
30
+ - `...IdempotencyKeyHeader` adds the optional `Idempotency-Key` header, 1 to
31
+ 255 characters. The generated validators check it: an empty key, or a
32
+ longer one, is a 400. A handler reads it as
33
+ `c.req.valid('header')['idempotency-key']`.
34
+ - `...IdempotentReplayedHeader` declares `Idempotent-Replayed: true` on the
35
+ reply that replays an earlier one.
36
+ - `IdempotencyInProgress` is the 409 of a key whose first request still
37
+ runs, with `Retry-After` and the `ConflictBody` envelope. The client
38
+ retries as it is.
39
+ - `IdempotencyKeyReused` is the 422 of a key sent again with another body,
40
+ with the `UnprocessableEntityBody` envelope. The client takes a new key.
41
+
42
+ Both carry the `RateLimit-*` headers, as a route that also has a rate limit
43
+ answers them. An operation that declares `Conflict` too, for its optimistic
44
+ lock, gets one 409 with the description of the first: declare one of the
45
+ two, and tell them apart by the `message` key.
46
+
47
+ ## A rate limit
48
+
49
+ ```tsp
50
+ @post create(@body post: Create<Post>): {
51
+ @statusCode _: 201;
52
+ ...RateLimitHeaders;
53
+ @body post: Post;
54
+ } | BadRequest | TooManyRequests;
55
+ ```
56
+
57
+ | Header | Value |
58
+ | --- | --- |
59
+ | `RateLimit-Limit` | how many requests a full window allows |
60
+ | `RateLimit-Remaining` | how many more are allowed now, after this one |
61
+ | `RateLimit-Reset` | seconds until the window is full again, rounded up |
62
+ | `Retry-After` | seconds to wait before trying again: a delay, never a date |
63
+
64
+ `TooManyRequests`, `IdempotencyInProgress` and `IdempotencyKeyReused`
65
+ carry the three `RateLimit-*` headers already. `...RateLimitHeaders` adds
66
+ them to a reply the spec declares inline, such as the 201 above.
67
+
68
+ ## What is checked
69
+
70
+ The generator checks a request's headers, as it checks its query: the
71
+ `Idempotency-Key` arrives valid, or the request is a 400. It does not check
72
+ a reply's headers: they are in the spec for a client to read, and the
73
+ handler sets them:
74
+
75
+ ```ts
76
+ import { Hono } from 'hono';
77
+ import { createRoutes } from './generated/hono';
78
+
79
+ const app = new Hono();
80
+ createRoutes(app).post('/posts', async (c) => {
81
+ const key = c.req.valid('header')['idempotency-key'];
82
+ // … look the key up; on a replay, answer the post it wrote:
83
+ const post = await replayed(key);
84
+ c.header('Idempotent-Replayed', 'true');
85
+ return c.json(post, 201);
86
+ });
87
+ ```
88
+
89
+ Every header here is optional, in the spec as on the wire: a route without
90
+ an idempotency store or a rate limiter declares none of them.
91
+
92
+ ## Not yet
93
+
94
+ `ETag`, `If-None-Match` and `If-Match` are not declared here: the
95
+ `@nxgt/*` packages do not send an `ETag` the same way yet. The optimistic
96
+ lock is the body's `version` ([Scalars and columns](columns.md)).
@@ -0,0 +1,113 @@
1
+ # Operation ids
2
+
3
+ An OpenAPI operation id is what `@nxgt/openapi-codegen` names everything
4
+ after: the client's method, the `operations` entry, and the prefix of the
5
+ operation's types (`ListPostsQuery`, `zGetPostParam`).
6
+
7
+ ## What TypeSpec does alone
8
+
9
+ Without an `@operationId`, `@typespec/openapi3` names an operation after its
10
+ interface and its method, joined by `_`:
11
+
12
+ ```tsp
13
+ @route("/posts")
14
+ interface Posts {
15
+ @get list(@query status?: PostStatus): Post[]; // Posts_list
16
+ @post create(@body post: Create<Post>): Post; // Posts_create
17
+ }
18
+ ```
19
+
20
+ The client then calls `Posts_list`, and its types are `PostsListQuery`.
21
+ Writing an `@operationId` on each operation fixes that, one line per
22
+ operation.
23
+
24
+ ## `@operationIds`
25
+
26
+ Put it on the interface, once:
27
+
28
+ ```tsp
29
+ import "@typespec/http";
30
+ import "@typespec/openapi";
31
+ import "@nxgt/typespec";
32
+
33
+ using Http;
34
+ using OpenAPI;
35
+ using Nxgt;
36
+
37
+ namespace Blog;
38
+
39
+ @route("/posts")
40
+ @operationIds
41
+ interface Posts {
42
+ @get list(@query status?: PostStatus): Post[]; // listPosts
43
+ @get @operationId("getPost") read(@path postId: string): Post | NotFound; // getPost
44
+ @post create(@body post: Create<Post>): Post | BadRequest; // createPosts
45
+ @patch update(@path postId: string, @body post: MergePatchUpdate<Post>): Post | NotFound; // updatePosts
46
+ @delete delete(@path postId: string): NoContentResponse | NotFound; // deletePosts
47
+ }
48
+ ```
49
+
50
+ - Each operation is named `<operation><Interface>`: the method's name, then
51
+ the interface's, as written.
52
+ - An operation with its own `@operationId` keeps it, whether it is written
53
+ before or after `@operationIds`. `import "@typespec/openapi"` and
54
+ `using OpenAPI` are only needed for that `@operationId`.
55
+ - An interface that `extends` a marked one is marked too, and names the
56
+ operations it inherits after itself: `interface Drafts extends Posts {}`
57
+ gives `listDrafts`. An inherited `@operationId` is copied as is, though,
58
+ so `Drafts` would name its `read` `getPost` too: see below.
59
+
60
+ The generated `operations` then has one entry per id:
61
+
62
+ ```ts
63
+ import { operations } from './generated/operations';
64
+
65
+ Object.keys(operations);
66
+ // ['listPosts', 'createPosts', 'getPost', 'updatePosts', 'deletePosts']
67
+ ```
68
+
69
+ ## On a template
70
+
71
+ On an interface template, `@operationIds` names the operations of each
72
+ interface that extends an instance, after that interface:
73
+
74
+ ```tsp
75
+ @operationIds
76
+ interface Resource<Item> {
77
+ @get list(): Item[];
78
+ }
79
+
80
+ @route("/pets")
81
+ interface Pets extends Resource<Pet> {} // listPets
82
+
83
+ @route("/orders")
84
+ interface Orders extends Resource<Order> {} // listOrders
85
+ ```
86
+
87
+ ## Two operations named alike
88
+
89
+ An id must be unique, and four cases break that:
90
+
91
+ - the namespace is not part of the id, so `Store.Pets` and `Shelter.Pets`
92
+ would both name their `list` `listPets`;
93
+ - an `@operationId` is copied by `extends`, so `Drafts extends Posts` would
94
+ have a second `getPost`;
95
+ - an `@operationId` written on another operation, in any interface, takes
96
+ the id `@operationIds` gives;
97
+ - an operation declared in the service namespace is named after itself, so
98
+ `op listPets()` takes the id of `Pets.list`.
99
+
100
+ `@operationIds` refuses each with `duplicate-operation-id`, where
101
+ `@typespec/openapi3` would emit both without a word. Give one of them its own
102
+ `@operationId`; an inherited operation takes one by being declared again in
103
+ the interface that extends ([troubleshooting](../troubleshooting.md)).
104
+
105
+ ## Without the library
106
+
107
+ The emitter's own option, `operation-id-strategy`, only chooses between
108
+ `parent-container` (`Posts_list`, the default), `fqn` (with the namespace)
109
+ and `explicit-only`. None of them gives `listPosts`.
110
+
111
+ `@operationIds` only names the operations of the interfaces it marks: the
112
+ strategy still names every other one. The check for two operations named
113
+ alike reads their ids as the default strategy gives them.