@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Steve Tsala
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,458 @@
1
+ # @nxgt/typespec
2
+
3
+ nxgt's HTTP conventions as a [TypeSpec](https://typespec.io) library, so an
4
+ API spec states them in one word instead of rewriting them. Compile the spec to
5
+ OpenAPI 3.1 or 3.2 with `@typespec/openapi3`, then generate the code with
6
+ [`@nxgt/openapi-codegen`](https://github.com/softistx/nxgt-http/blob/develop/packages/openapi-codegen/README.md).
7
+ Each shape here is one the `@nxgt/*` packages already send on the wire.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ bun add -d @nxgt/typespec @typespec/compiler @typespec/http @typespec/openapi @typespec/openapi3 @nxgt/openapi-codegen
13
+ ```
14
+
15
+ `@typespec/compiler`, `@typespec/http` and `@typespec/openapi` 1.16 or later
16
+ are peer dependencies,
17
+ and `typescript` 6 too, as for every `@nxgt` package. `@typespec/openapi3`
18
+ compiles the spec, and `@nxgt/openapi-codegen` generates the code from it.
19
+ Emit OpenAPI 3.1 or 3.2 in `tspconfig.yaml`: `@typespec/openapi3` emits 3.0
20
+ by default, and the generator refuses it. Every convention here is compiled
21
+ to both in CI, and generates the same code from either.
22
+
23
+ ```yaml
24
+ # api/tspconfig.yaml
25
+ emit:
26
+ - '@typespec/openapi3'
27
+ options:
28
+ '@typespec/openapi3':
29
+ openapi-versions: ['3.1.0']
30
+ emitter-output-dir: '{project-root}/../openapi'
31
+ output-file: openapi.yaml
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ### A spec split across files
37
+
38
+ A spec that grows is split by role: one file per resource's models, one per
39
+ resource's routes, and a `main.tsp` that only assembles them. Each file opens
40
+ the service's namespace again:
41
+
42
+ ```text
43
+ api/
44
+ main.tsp imports every file below, and declares the service
45
+ tspconfig.yaml
46
+ models/
47
+ author.tsp post.tsp comment.tsp
48
+ routes/
49
+ authors.tsp posts.tsp comments.tsp
50
+ ```
51
+
52
+ ```tsp
53
+ // api/main.tsp
54
+ import "@typespec/http";
55
+ import "./models/post.tsp";
56
+ import "./routes/posts.tsp";
57
+
58
+ using Http;
59
+
60
+ @service(#{ title: "Blog" })
61
+ namespace Blog;
62
+ ```
63
+
64
+ ```tsp
65
+ // api/models/post.tsp
66
+ namespace Blog;
67
+
68
+ model Post {
69
+ @visibility(Lifecycle.Read) id: string;
70
+ @minLength(1) title: string;
71
+ body: string;
72
+ }
73
+ ```
74
+
75
+ ### Error replies
76
+
77
+ Import the library where the routes are, and name the errors each operation
78
+ can answer:
79
+
80
+ ```tsp
81
+ // api/routes/posts.tsp
82
+ import "@typespec/http";
83
+ import "@nxgt/typespec";
84
+
85
+ using Http;
86
+ using Nxgt;
87
+
88
+ namespace Blog;
89
+
90
+ @route("/posts")
91
+ interface Posts {
92
+ @get read(@path postId: string): Post | NotFound | Unauthorized;
93
+ @post create(@body post: Create<Post>): {
94
+ @statusCode _: 201;
95
+ @body post: Post;
96
+ } | BadRequest | Conflict | ErrorResponse<503>;
97
+ }
98
+ ```
99
+
100
+ ```sh
101
+ bunx --no-install tsp compile api && bunx nxgt-openapi generate -i openapi/openapi.yaml -o src/generated
102
+ ```
103
+
104
+ Every error body has the envelope of `@nxgt/openapi-hono`'s own replies, its
105
+ 400 and its 500, and a handler answers with the same envelope: `status`,
106
+ `message`, an i18n key rather than a sentence, and `timestamp`. The generated
107
+ types require all three:
108
+
109
+ ```ts
110
+ // src/generated/types.ts (excerpt)
111
+ export interface NotFoundBody {
112
+ status: 404;
113
+ message: string;
114
+ timestamp: string;
115
+ }
116
+ ```
117
+
118
+ | Response | Status | Body |
119
+ | --- | --- | --- |
120
+ | `BadRequest` | 400 | `BadRequestBody`: the envelope, and `issues` when the validators refused the request |
121
+ | `Unauthorized` | 401 | `UnauthorizedBody` |
122
+ | `Forbidden` | 403 | `ForbiddenBody` |
123
+ | `NotFound` | 404 | `NotFoundBody` |
124
+ | `Conflict` | 409 | `ConflictBody` |
125
+ | `UnprocessableEntity` | 422 | `UnprocessableEntityBody` |
126
+ | `TooManyRequests` | 429 | `TooManyRequestsBody`, with the `RateLimit-*` headers and `Retry-After` |
127
+ | `InternalServerError` | 500 | `InternalServerErrorBody` |
128
+ | `ErrorResponse<Status>` | any | `ErrorBody<Status>` |
129
+
130
+ The generator also declares the validators' own 400, `ValidationErrorBody`,
131
+ on every operation that takes a parameter or a body. With `BadRequest`, a
132
+ 400 reply is typed `BadRequestBody | ValidationErrorBody`.
133
+
134
+ ### Pagination
135
+
136
+ Spread the query of a page into the operation's parameters, and answer the
137
+ page:
138
+
139
+ ```tsp
140
+ @route("/posts")
141
+ interface Posts {
142
+ @get list(@query status?: PostStatus, ...PageParameters): Page<Post>;
143
+ }
144
+
145
+ @route("/posts/{postId}/comments")
146
+ interface Comments {
147
+ @get list(@path postId: string, ...CursorPageParameters): CursorPage<Comment> | BadRequest | NotFound;
148
+ }
149
+ ```
150
+
151
+ The shapes are the ones
152
+ [`@nxgt/drizzle`](https://www.npmjs.com/package/@nxgt/drizzle) and
153
+ [`@nxgt/mongo`](https://www.npmjs.com/package/@nxgt/mongo) return, so a
154
+ handler answers their page as it is:
155
+
156
+ ```ts
157
+ // src/generated/types.ts (excerpt)
158
+ export interface PostPage {
159
+ items: Post[];
160
+ total: number;
161
+ page: number;
162
+ pageSize: number;
163
+ pageCount: number;
164
+ }
165
+
166
+ export interface CommentCursorPage {
167
+ items: Comment[];
168
+ nextCursor: string | null;
169
+ }
170
+ ```
171
+
172
+ | Query | Default | Checked |
173
+ | --- | --- | --- |
174
+ | `page` | 1 | an integer, at least 1 |
175
+ | `pageSize` | 20 | an integer, at least 1 |
176
+ | `after` | none: the first page | a string, the previous page's `nextCursor` |
177
+ | `limit` | 20 | an integer, at least 1 |
178
+
179
+ A `pageSize` or `limit` above the server's maximum is not refused: the
180
+ server lowers it, as `@nxgt/drizzle` does. A cursor the server did not write
181
+ makes `@nxgt/drizzle` throw `InvalidCursorError`: answer it with the
182
+ `BadRequest` declared above
183
+ ([Serving it](docs/guide/pagination.md#serving-it)).
184
+
185
+ ### Authentication
186
+
187
+ Name how a request authenticates with `@useAuth`, and the guards' refusals
188
+ beside the success:
189
+
190
+ ```tsp
191
+ @route("/posts/{postId}/comments/{commentId}")
192
+ interface Comments {
193
+ @useAuth(JanusAuth)
194
+ @delete delete(@path postId: string, @path commentId: string):
195
+ NoContentResponse | AuthenticationRequired | AccessDenied | ErrorWithoutBody<404>;
196
+ }
197
+ ```
198
+
199
+ `JanusAuth` is every way [`@nxgt/janus`](https://www.npmjs.com/package/@nxgt/janus)
200
+ reads a session token, and any one of them is enough:
201
+
202
+ | Scheme | Where the token is |
203
+ | --- | --- |
204
+ | `BearerAuth` | `Authorization: Bearer <token>` |
205
+ | `SessionTokenAuth` | the `X-Session-Token` header |
206
+ | `SessionCookieAuth` | the `janus-session` cookie |
207
+
208
+ The guards of [`@nxgt/janus-hono`](https://www.npmjs.com/package/@nxgt/janus-hono),
209
+ `session(auth, { required: true })` and `permission()`, refuse without a body:
210
+ `AuthenticationRequired` is their 401 and `AccessDenied` their 403, and
211
+ `ErrorWithoutBody<404>` the 404 of `permission()`. A handler's own refusal,
212
+ with the envelope, stays `Unauthorized` or `Forbidden`. The generated
213
+ `hono.ts` types the guards' replies as `c.body(null, 401)`. More in
214
+ [Authentication](docs/guide/auth.md).
215
+
216
+ ### Scalars and columns
217
+
218
+ Type an id or an address with a scalar, and spread the columns
219
+ [`@nxgt/drizzle`](https://www.npmjs.com/package/@nxgt/drizzle) stamps a row
220
+ with:
221
+
222
+ ```tsp
223
+ import "@nxgt/typespec";
224
+
225
+ using Nxgt;
226
+
227
+ namespace Blog;
228
+
229
+ model Post {
230
+ @visibility(Lifecycle.Read) id: uuid;
231
+ title: string;
232
+ contact?: email;
233
+ ...Timestamps; // createdAt, updatedAt: read-only
234
+ ...Versioned; // version: read, and sent back in an update
235
+ ...Actors; // createdBy, updatedBy, deletedBy: read-only
236
+ }
237
+ ```
238
+
239
+ | Name | Emitted as | Generated | drizzle |
240
+ | --- | --- | --- | --- |
241
+ | `uuid` | the schema `Uuid`, `format: uuid` | `Uuid`, `z.guid()` | `id()` |
242
+ | `email` | the schema `Email`, `format: email` | `Email`, `z.email()` | |
243
+ | `...Timestamps` | `createdAt`, `updatedAt`, read-only | left out of `Create<T>` and updates | `timestamps()` |
244
+ | `...SoftDelete` | `deletedAt: utcDateTime \| null`, read-only | | `softDelete()` |
245
+ | `...Versioned` | `version: integer`, read and update | kept in `MergePatchUpdate<T>` | `version()` |
246
+ | `...Actors<Id = uuid>` | `createdBy`, `updatedBy`, `deletedBy`, `Id \| null`, read-only | | `actors()` |
247
+
248
+ TypeSpec's own `url` is the address of a page, `format: uri`, generated as
249
+ `z.url()`. More in [Scalars and columns](docs/guide/columns.md).
250
+
251
+ ### Headers
252
+
253
+ Spread the headers of an idempotent write and of a rate limit into an
254
+ operation and its replies:
255
+
256
+ ```tsp
257
+ @post create(...IdempotencyKeyHeader, @body post: Create<Post>): {
258
+ @statusCode _: 201;
259
+ ...IdempotentReplayedHeader;
260
+ ...RateLimitHeaders;
261
+ @body post: Post;
262
+ } | BadRequest | IdempotencyInProgress | IdempotencyKeyReused | TooManyRequests;
263
+ ```
264
+
265
+ | Name | Header | Where |
266
+ | --- | --- | --- |
267
+ | `IdempotencyKeyHeader` | `Idempotency-Key`, 1 to 255 characters, optional | the request: checked |
268
+ | `IdempotentReplayedHeader` | `Idempotent-Replayed: true` | a reply that replays an earlier one |
269
+ | `RateLimitHeaders` | `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` | a rate-limited route's replies |
270
+ | `RetryAfterHeader` | `Retry-After`, in seconds | a 409 or a 429 |
271
+ | `IdempotencyInProgress` | 409, `ConflictBody`, `RateLimit-*`, `Retry-After` | the key's first request still runs |
272
+ | `IdempotencyKeyReused` | 422, `UnprocessableEntityBody`, `RateLimit-*` | the key was used with another body |
273
+
274
+ `TooManyRequests` carries the `RateLimit-*` headers and `Retry-After` too. The
275
+ generator checks the request's `Idempotency-Key`; a reply's headers are
276
+ documented, not checked. More in [Headers](docs/guide/headers.md).
277
+
278
+ ### Resources
279
+
280
+ One line gives a resource its five operations, with `@operationIds` names:
281
+
282
+ ```tsp
283
+ model PostFilters {
284
+ @query status?: PostStatus;
285
+ @query authorId?: uuid;
286
+ }
287
+
288
+ @route("/posts")
289
+ interface Posts extends Resource<Post, PostFilters, SortField = "createdAt" | "title"> {}
290
+ ```
291
+
292
+ | Operation | Route | Answers |
293
+ | --- | --- | --- |
294
+ | `listPosts` | `GET /posts`, with the filters, `page`, `pageSize`, `orderBy`, `direction` | `Page<Post>` |
295
+ | `readPosts` | `GET /posts/{id}` | `Post \| NotFound` |
296
+ | `createPosts` | `POST /posts`, `Create<Post>` | 201 `Post \| BadRequest \| Conflict` |
297
+ | `updatePosts` | `PATCH /posts/{id}`, `MergePatchUpdate<Post>` | `Post \| BadRequest \| NotFound \| Conflict` |
298
+ | `deletePosts` | `DELETE /posts/{id}` | 204, `NotFound` |
299
+
300
+ `orderBy` takes one of the `SortField`s (`SortField` is `"id"` by default), and `direction`
301
+ `asc` (the default) or `desc`, as `@nxgt/drizzle` sorts a page. `{id}`
302
+ is a `uuid`, or the `Id` you name. More in
303
+ [Resources](docs/guide/resources.md).
304
+
305
+ ### Operation ids
306
+
307
+ TypeSpec names an operation after its interface, `Posts_list`, and the
308
+ generator turns that id into the client's method and its types' prefix. Put
309
+ `@operationIds` on the interface to name each operation after its method and
310
+ the interface instead:
311
+
312
+ ```tsp
313
+ import "@typespec/openapi"; // for @operationId
314
+
315
+ using OpenAPI;
316
+
317
+ @route("/posts")
318
+ @operationIds
319
+ interface Posts {
320
+ @get list(): Post[]; // listPosts
321
+ @get @operationId("getPost") read(@path postId: string): Post | NotFound; // getPost
322
+ }
323
+ ```
324
+
325
+ An operation's own `@operationId` wins. An interface that extends a marked
326
+ one, or an instance of a marked template, is named after itself. An operation
327
+ of a marked interface that has another operation's id is an error,
328
+ `duplicate-operation-id`. More in
329
+ [Operation ids](docs/guide/operation-ids.md).
330
+
331
+ ## API
332
+
333
+ ### Decorators
334
+
335
+ | Decorator | On | What it does |
336
+ | --- | --- | --- |
337
+ | `@operationIds` | an interface | names each operation `<operation><Interface>`, unless it has an `@operationId` |
338
+
339
+ ### Models
340
+
341
+ | Model | What it is |
342
+ | --- | --- |
343
+ | `ErrorBody<Status>` | `{ status: Status, message: string, timestamp: utcDateTime }` |
344
+ | `BadRequestBody` | `ErrorBody<400>` and `issues?: ValidationIssue[]` |
345
+ | `UnauthorizedBody` … `InternalServerErrorBody` | `ErrorBody<401>` … `ErrorBody<500>`, one per response above |
346
+ | `PageParameters` | the query `page` and `pageSize`, to spread into an operation's parameters |
347
+ | `Page<Item>` | `{ items: Item[], total, page, pageSize, pageCount }`, the schema `<Item>Page` |
348
+ | `CursorPageParameters` | the query `after` and `limit` |
349
+ | `CursorPage<Item>` | `{ items: Item[], nextCursor: string \| null }`, the schema `<Item>CursorPage` |
350
+ | `ValidationIssue` | `{ target: ValidationTarget, path: (string \| integer)[], code: string, message: string }` |
351
+ | `ValidationTarget` | `"param" \| "query" \| "header" \| "json" \| "form" \| "body" \| "response"` |
352
+
353
+ ### Authentication
354
+
355
+ | Name | What it is |
356
+ | --- | --- |
357
+ | `JanusAuth` | `BearerAuth \| SessionTokenAuth \| SessionCookieAuth`, for `@useAuth` |
358
+ | `SessionTokenAuth` | an API key in the `X-Session-Token` header |
359
+ | `SessionCookieAuth` | an API key in the `janus-session` cookie |
360
+
361
+ ### Scalars and columns
362
+
363
+ | Name | What it is |
364
+ | --- | --- |
365
+ | `uuid` | a string, `format: uuid`; the schema `Uuid` |
366
+ | `email` | a string, `format: email`; the schema `Email` |
367
+ | `Timestamps` | `createdAt`, `updatedAt: utcDateTime`, read-only |
368
+ | `SoftDelete` | `deletedAt: utcDateTime \| null`, read-only |
369
+ | `Versioned` | `version: integer`, read and update |
370
+ | `Actors<Id = uuid>` | `createdBy`, `updatedBy`, `deletedBy: Id \| null`, read-only |
371
+
372
+ ### Headers
373
+
374
+ | Model | What it is |
375
+ | --- | --- |
376
+ | `IdempotencyKeyHeader` | `Idempotency-Key`, 1 to 255 characters, optional: to spread into parameters |
377
+ | `IdempotentReplayedHeader` | `Idempotent-Replayed: "true"`, optional: to spread into a reply |
378
+ | `RateLimitHeaders` | `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, integers, optional |
379
+ | `RetryAfterHeader` | `Retry-After`, seconds, optional |
380
+
381
+ ### Resources
382
+
383
+ | Name | What it is |
384
+ | --- | --- |
385
+ | `Resource<Item, Filters = {}, SortField = "id", Id = uuid>` | an interface template: `list`, `read`, `create`, `update`, `delete` |
386
+ | `SortParameters<Field>` | the query `orderBy?: Field` and `direction?: "asc" \| "desc" = "asc"` |
387
+
388
+ ### Responses
389
+
390
+ `AuthenticationRequired` (401) and `AccessDenied` (403), without a body, and
391
+ `ErrorWithoutBody<Status>` for any other status without a body.
392
+ `IdempotencyInProgress` (409) and `IdempotencyKeyReused` (422), with the
393
+ envelope and the rate limit's headers.
394
+
395
+ `BadRequest`, `Unauthorized`, `Forbidden`, `NotFound`, `Conflict`,
396
+ `UnprocessableEntity`, `TooManyRequests`, `InternalServerError`, each an
397
+ `@error` model with its `@statusCode` and body, and
398
+ `ErrorResponse<Status>` for any other status.
399
+
400
+ ## Traps
401
+
402
+ - **Do not declare a schema named `ValidationErrorBody`.** The generator
403
+ declares it itself, and two schemas of that name fail with
404
+ `name_collision`. `BadRequestBody` carries the `issues` instead.
405
+ - **`ErrorResponse<503>`'s body is emitted inline**, and generated as
406
+ `<Operation>503Response`. For a named schema, name the body and the
407
+ response:
408
+
409
+ ```tsp
410
+ model ServiceUnavailableBody is ErrorBody<503>;
411
+
412
+ @error
413
+ model ServiceUnavailable {
414
+ @statusCode _: 503;
415
+ @body body: ServiceUnavailableBody;
416
+ }
417
+ ```
418
+ - **One status, one reply.** An operation that declares both
419
+ `AuthenticationRequired` and `Unauthorized` gets a single 401, with the
420
+ envelope: the reply without a body is lost, without a warning. Declare the
421
+ one the route sends: the guard's, or the handler's. The same goes for
422
+ `Conflict` beside `IdempotencyInProgress`, and `UnprocessableEntity`
423
+ beside `IdempotencyKeyReused`: both keep one body, but the reply's
424
+ description is the first one's, and the body a union of the envelope with
425
+ itself. Declare one of each pair: the `message` key tells them apart.
426
+ - **Both versions at once write two folders.** With
427
+ `openapi-versions: ['3.1.0', '3.2.0']`, the emitter writes
428
+ `3.1.0/openapi.yaml` and `3.2.0/openapi.yaml`: point the generator's input
429
+ at one of them.
430
+ - **The library's schema names are global.** `BadRequestBody` …
431
+ `InternalServerErrorBody`, `ValidationIssue`, `ValidationTarget`, `Uuid`,
432
+ `Email`, and each `<Item>Page` and `<Item>CursorPage` are emitted under
433
+ those names, without a namespace, once the spec uses them. A model of the
434
+ same name in your spec then fails with `duplicate-type-name`
435
+ ([troubleshooting](docs/troubleshooting.md)).
436
+
437
+ ## Documentation
438
+
439
+ - [Error replies](docs/guide/errors.md): the envelope, each response, and
440
+ what the generator makes of them;
441
+ - [Pagination](docs/guide/pagination.md): offset and cursor pages, and
442
+ what a handler answers;
443
+ - [Authentication](docs/guide/auth.md): `JanusAuth`, and the guards'
444
+ replies without a body;
445
+ - [Scalars and columns](docs/guide/columns.md): `uuid`, `email`, and the
446
+ columns `@nxgt/drizzle` stamps;
447
+ - [Headers](docs/guide/headers.md): `Idempotency-Key`, the rate limit and
448
+ `Retry-After`;
449
+ - [Resources](docs/guide/resources.md): the five operations of a resource
450
+ in one line, with its filters and sort;
451
+ - [Operation ids](docs/guide/operation-ids.md): `@operationIds`, and what
452
+ the generated client calls each operation;
453
+ - [troubleshooting](docs/troubleshooting.md);
454
+ - [the roadmap](docs/roadmap.md): what is coming.
455
+
456
+ ## License
457
+
458
+ MIT
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The JavaScript side of the `@nxgt/typespec` library, which `lib/main.tsp`
3
+ * imports from `dist/`: its name and diagnostics, and the decorators a
4
+ * template cannot express. The conventions themselves are in `lib/`.
5
+ *
6
+ * A decorator is exported only through `$decorators`: a top-level `$name`
7
+ * export would declare it a second time, in the global namespace.
8
+ */
9
+ import type { Program } from '@typespec/compiler';
10
+ import { operationIds } from './operation-ids';
11
+ export { $lib } from './lib';
12
+ export declare function $onValidate(program: Program): void;
13
+ export declare const $decorators: {
14
+ Nxgt: {
15
+ operationIds: typeof operationIds;
16
+ };
17
+ };
18
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,YAAY,EAAwB,MAAM,iBAAiB,CAAC;AAErE,OAAO,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC;AAE7B,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAElD;AAED,eAAO,MAAM,WAAW;;;;CAEvB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,75 @@
1
+ // src/operation-ids.ts
2
+ import {
3
+ isTemplateInstance,
4
+ navigateProgram
5
+ } from "@typespec/compiler";
6
+ import {
7
+ getOperationId,
8
+ resolveOperationId,
9
+ setOperationId
10
+ } from "@typespec/openapi";
11
+
12
+ // src/lib.ts
13
+ import { createTypeSpecLibrary, paramMessage } from "@typespec/compiler";
14
+ var $lib = createTypeSpecLibrary({
15
+ name: "@nxgt/typespec",
16
+ diagnostics: {
17
+ "duplicate-operation-id": {
18
+ severity: "error",
19
+ messages: {
20
+ default: paramMessage`Two operations are named ${"id"}: an OpenAPI operation id must be unique.`
21
+ }
22
+ }
23
+ },
24
+ state: {
25
+ operationIds: { description: "The interfaces marked with @operationIds" }
26
+ }
27
+ });
28
+
29
+ // src/operation-ids.ts
30
+ function operationIds(context, target) {
31
+ context.program.stateSet($lib.stateKeys.operationIds).add(target);
32
+ }
33
+ function isMarked(program, target) {
34
+ return program.stateSet($lib.stateKeys.operationIds).has(target) || target.sourceInterfaces.some((source) => isMarked(program, source));
35
+ }
36
+ function isNamed(program, operation) {
37
+ const container = operation.interface;
38
+ return container !== undefined && !isTemplateInstance(container) && isMarked(program, container);
39
+ }
40
+ function validateOperationIds(program) {
41
+ const seen = new Map;
42
+ navigateProgram(program, {
43
+ operation(operation) {
44
+ const named = isNamed(program, operation);
45
+ const own = getOperationId(program, operation);
46
+ const id = own ?? (named ? `${operation.name}${operation.interface?.name}` : resolveOperationId(program, operation));
47
+ if (seen.has(id) && (named || seen.get(id))) {
48
+ $lib.reportDiagnostic(program, {
49
+ code: "duplicate-operation-id",
50
+ format: { id },
51
+ target: operation
52
+ });
53
+ }
54
+ seen.set(id, named || (seen.get(id) ?? false));
55
+ if (named && own === undefined)
56
+ setOperationId(program, operation, id);
57
+ }
58
+ });
59
+ }
60
+
61
+ // src/index.ts
62
+ function $onValidate(program) {
63
+ validateOperationIds(program);
64
+ }
65
+ var $decorators = {
66
+ Nxgt: { operationIds }
67
+ };
68
+ export {
69
+ $decorators,
70
+ $lib,
71
+ $onValidate
72
+ };
73
+
74
+ //# debugId=2D92CF45DEECB16A64756E2164756E21
75
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,12 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/operation-ids.ts", "../src/lib.ts", "../src/index.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * `@operationIds`: names each operation of an interface after its method and\n * the interface, `<operation><Interface>`, such as `listPets`.\n */\nimport {\n\ttype DecoratorContext,\n\ttype Interface,\n\tisTemplateInstance,\n\tnavigateProgram,\n\ttype Operation,\n\ttype Program,\n} from '@typespec/compiler';\nimport {\n\tgetOperationId,\n\tresolveOperationId,\n\tsetOperationId,\n} from '@typespec/openapi';\nimport { $lib } from './lib';\n\n/**\n * Marks the interface; its ids are set once the program is checked. On a\n * template, TypeSpec runs it on each instance, which `isMarked` follows to\n * the interfaces extending it.\n */\nexport function operationIds(\n\tcontext: DecoratorContext,\n\ttarget: Interface,\n): void {\n\tcontext.program.stateSet($lib.stateKeys.operationIds).add(target);\n}\n\n/** Marked itself, or extending a marked interface: an instance of a marked template included. */\nfunction isMarked(program: Program, target: Interface): boolean {\n\treturn (\n\t\tprogram.stateSet($lib.stateKeys.operationIds).has(target) ||\n\t\ttarget.sourceInterfaces.some((source) => isMarked(program, source))\n\t);\n}\n\n/**\n * Whether `@operationIds` names the operation's id, or checks it: its\n * interface is marked, and is not a template instance, which is never\n * emitted; only the interfaces extending it are.\n */\nfunction isNamed(program: Program, operation: Operation): boolean {\n\tconst container = operation.interface;\n\treturn (\n\t\tcontainer !== undefined &&\n\t\t!isTemplateInstance(container) &&\n\t\tisMarked(program, container)\n\t);\n}\n\n/**\n * Sets the ids, after every decorator, so the order of `@operationId` and\n * `@operationIds` in the source does not matter. Two operations with one id,\n * one of them in a marked interface, are an error: two interfaces of one name\n * in two namespaces, an `@operationId` that `extends` copied, one written\n * elsewhere, or an operation in the service namespace, named after itself. `@typespec/openapi3` would emit both without a word.\n */\nexport function validateOperationIds(program: Program): void {\n\tconst seen = new Map<string, boolean>();\n\tnavigateProgram(program, {\n\t\toperation(operation) {\n\t\t\tconst named = isNamed(program, operation);\n\t\t\tconst own = getOperationId(program, operation);\n\t\t\t// Elsewhere, only checked: the id `@typespec/openapi3` gives under its\n\t\t\t// default strategy; an operation in the service namespace is named\n\t\t\t// after itself.\n\t\t\tconst id =\n\t\t\t\town ??\n\t\t\t\t(named\n\t\t\t\t\t? `${operation.name}${operation.interface?.name}`\n\t\t\t\t\t: resolveOperationId(program, operation));\n\t\t\tif (seen.has(id) && (named || seen.get(id))) {\n\t\t\t\t$lib.reportDiagnostic(program, {\n\t\t\t\t\tcode: 'duplicate-operation-id',\n\t\t\t\t\tformat: { id },\n\t\t\t\t\ttarget: operation,\n\t\t\t\t});\n\t\t\t}\n\t\t\tseen.set(id, named || (seen.get(id) ?? false));\n\t\t\t// Only the ids it gives: an explicit id on any other operation would\n\t\t\t// override the emitter's `operation-id-strategy`.\n\t\t\tif (named && own === undefined) setOperationId(program, operation, id);\n\t\t},\n\t});\n}\n",
6
+ "/** The library's definition: its name, diagnostics and state keys. */\nimport { createTypeSpecLibrary, paramMessage } from '@typespec/compiler';\n\nexport const $lib = createTypeSpecLibrary({\n\tname: '@nxgt/typespec',\n\tdiagnostics: {\n\t\t'duplicate-operation-id': {\n\t\t\tseverity: 'error',\n\t\t\tmessages: {\n\t\t\t\tdefault: paramMessage`Two operations are named ${'id'}: an OpenAPI operation id must be unique.`,\n\t\t\t},\n\t\t},\n\t},\n\tstate: {\n\t\toperationIds: { description: 'The interfaces marked with @operationIds' },\n\t},\n});\n",
7
+ "/**\n * The JavaScript side of the `@nxgt/typespec` library, which `lib/main.tsp`\n * imports from `dist/`: its name and diagnostics, and the decorators a\n * template cannot express. The conventions themselves are in `lib/`.\n *\n * A decorator is exported only through `$decorators`: a top-level `$name`\n * export would declare it a second time, in the global namespace.\n */\nimport type { Program } from '@typespec/compiler';\nimport { operationIds, validateOperationIds } from './operation-ids';\n\nexport { $lib } from './lib';\n\nexport function $onValidate(program: Program): void {\n\tvalidateOperationIds(program);\n}\n\nexport const $decorators = {\n\tNxgt: { operationIds },\n};\n"
8
+ ],
9
+ "mappings": ";AAIA;AAAA;AAAA;AAAA;AAQA;AAAA;AAAA;AAAA;AAAA;;;ACXA;AAEO,IAAM,OAAO,sBAAsB;AAAA,EACzC,MAAM;AAAA,EACN,aAAa;AAAA,IACZ,0BAA0B;AAAA,MACzB,UAAU;AAAA,MACV,UAAU;AAAA,QACT,SAAS,wCAAwC;AAAA,MAClD;AAAA,IACD;AAAA,EACD;AAAA,EACA,OAAO;AAAA,IACN,cAAc,EAAE,aAAa,2CAA2C;AAAA,EACzE;AACD,CAAC;;;ADQM,SAAS,YAAY,CAC3B,SACA,QACO;AAAA,EACP,QAAQ,QAAQ,SAAS,KAAK,UAAU,YAAY,EAAE,IAAI,MAAM;AAAA;AAIjE,SAAS,QAAQ,CAAC,SAAkB,QAA4B;AAAA,EAC/D,OACC,QAAQ,SAAS,KAAK,UAAU,YAAY,EAAE,IAAI,MAAM,KACxD,OAAO,iBAAiB,KAAK,CAAC,WAAW,SAAS,SAAS,MAAM,CAAC;AAAA;AASpE,SAAS,OAAO,CAAC,SAAkB,WAA+B;AAAA,EACjE,MAAM,YAAY,UAAU;AAAA,EAC5B,OACC,cAAc,aACd,CAAC,mBAAmB,SAAS,KAC7B,SAAS,SAAS,SAAS;AAAA;AAWtB,SAAS,oBAAoB,CAAC,SAAwB;AAAA,EAC5D,MAAM,OAAO,IAAI;AAAA,EACjB,gBAAgB,SAAS;AAAA,IACxB,SAAS,CAAC,WAAW;AAAA,MACpB,MAAM,QAAQ,QAAQ,SAAS,SAAS;AAAA,MACxC,MAAM,MAAM,eAAe,SAAS,SAAS;AAAA,MAI7C,MAAM,KACL,QACC,QACE,GAAG,UAAU,OAAO,UAAU,WAAW,SACzC,mBAAmB,SAAS,SAAS;AAAA,MACzC,IAAI,KAAK,IAAI,EAAE,MAAM,SAAS,KAAK,IAAI,EAAE,IAAI;AAAA,QAC5C,KAAK,iBAAiB,SAAS;AAAA,UAC9B,MAAM;AAAA,UACN,QAAQ,EAAE,GAAG;AAAA,UACb,QAAQ;AAAA,QACT,CAAC;AAAA,MACF;AAAA,MACA,KAAK,IAAI,IAAI,UAAU,KAAK,IAAI,EAAE,KAAK,MAAM;AAAA,MAG7C,IAAI,SAAS,QAAQ;AAAA,QAAW,eAAe,SAAS,WAAW,EAAE;AAAA;AAAA,EAEvE,CAAC;AAAA;;;AEzEK,SAAS,WAAW,CAAC,SAAwB;AAAA,EACnD,qBAAqB,OAAO;AAAA;AAGtB,IAAM,cAAc;AAAA,EAC1B,MAAM,EAAE,aAAa;AACtB;",
10
+ "debugId": "2D92CF45DEECB16A64756E2164756E21",
11
+ "names": []
12
+ }
package/dist/lib.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
2
+ 'duplicate-operation-id': {
3
+ readonly default: import("@typespec/compiler").CallableMessage<["id"]>;
4
+ };
5
+ }, Record<string, any>, "operationIds">;
6
+ //# sourceMappingURL=lib.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lib.d.ts","sourceRoot":"","sources":["../src/lib.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,IAAI;;;;uCAaf,CAAC"}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `@operationIds`: names each operation of an interface after its method and
3
+ * the interface, `<operation><Interface>`, such as `listPets`.
4
+ */
5
+ import { type DecoratorContext, type Interface, type Program } from '@typespec/compiler';
6
+ /**
7
+ * Marks the interface; its ids are set once the program is checked. On a
8
+ * template, TypeSpec runs it on each instance, which `isMarked` follows to
9
+ * the interfaces extending it.
10
+ */
11
+ export declare function operationIds(context: DecoratorContext, target: Interface): void;
12
+ /**
13
+ * Sets the ids, after every decorator, so the order of `@operationId` and
14
+ * `@operationIds` in the source does not matter. Two operations with one id,
15
+ * one of them in a marked interface, are an error: two interfaces of one name
16
+ * in two namespaces, an `@operationId` that `extends` copied, one written
17
+ * elsewhere, or an operation in the service namespace, named after itself. `@typespec/openapi3` would emit both without a word.
18
+ */
19
+ export declare function validateOperationIds(program: Program): void;
20
+ //# sourceMappingURL=operation-ids.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"operation-ids.d.ts","sourceRoot":"","sources":["../src/operation-ids.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EACN,KAAK,gBAAgB,EACrB,KAAK,SAAS,EAId,KAAK,OAAO,EACZ,MAAM,oBAAoB,CAAC;AAQ5B;;;;GAIG;AACH,wBAAgB,YAAY,CAC3B,OAAO,EAAE,gBAAgB,EACzB,MAAM,EAAE,SAAS,GACf,IAAI,CAEN;AAwBD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA2B3D"}
package/docs/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # @nxgt/typespec documentation
2
+
3
+ The [package README](../README.md) is the short version.
4
+
5
+ | Page | Read it when |
6
+ | --- | --- |
7
+ | [Error replies](guide/errors.md) | declaring what an operation answers when it fails |
8
+ | [Pagination](guide/pagination.md) | listing a collection a page at a time |
9
+ | [Authentication](guide/auth.md) | protecting an operation with a `@nxgt/janus` session |
10
+ | [Scalars and columns](guide/columns.md) | typing ids and addresses, and the columns a row carries |
11
+ | [Headers](guide/headers.md) | making a write idempotent, or declaring a rate limit |
12
+ | [Resources](guide/resources.md) | declaring a resource's five operations at once |
13
+ | [Operation ids](guide/operation-ids.md) | naming the generated client's methods after the spec's |
14
+ | [Troubleshooting](troubleshooting.md) | the compiler or the generator refused the spec |
15
+ | [Roadmap](roadmap.md) | wondering what is coming |