@nxgt/typespec 0.1.0 → 0.3.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/README.md CHANGED
@@ -292,10 +292,14 @@ interface Posts extends Resource<Post, PostFilters, SortField = "createdAt" | "t
292
292
  | Operation | Route | Answers |
293
293
  | --- | --- | --- |
294
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` |
295
+ | `readPost` | `GET /posts/{id}` | `Post \| NotFound` |
296
+ | `createPost` | `POST /posts`, `Create<Post>` | 201 `Post \| BadRequest \| Conflict` |
297
+ | `updatePost` | `PATCH /posts/{id}`, `MergePatchUpdate<Post>` | `Post \| BadRequest \| NotFound \| Conflict` |
298
+ | `deletePost` | `DELETE /posts/{id}` | 204, `NotFound` |
299
+
300
+ `list` is named after the interface, the four others after the item. Two resources of one item then share their ids,
301
+ which is an error, `duplicate-operation-id`: give the second its own
302
+ `@operationId`s ([troubleshooting](docs/troubleshooting.md)).
299
303
 
300
304
  `orderBy` takes one of the `SortField`s (`SortField` is `"id"` by default), and `direction`
301
305
  `asc` (the default) or `desc`, as `@nxgt/drizzle` sorts a page. `{id}`
@@ -336,6 +340,14 @@ of a marked interface that has another operation's id is an error,
336
340
  | --- | --- | --- |
337
341
  | `@operationIds` | an interface | names each operation `<operation><Interface>`, unless it has an `@operationId` |
338
342
 
343
+ ### Diagnostics
344
+
345
+ | Code | Reported when |
346
+ | --- | --- |
347
+ | `duplicate-operation-id` | error: an operation of an `@operationIds` interface has another operation's id |
348
+ | `duplicate-status-reply` | error: an operation declares a reply without a body and one with a body of one status, which the emitter merges, losing the one without a body |
349
+ | `merged-status-reply` | warning: an operation declares two replies with a body of one status code, which the emitter merges under the first one's description |
350
+
339
351
  ### Models
340
352
 
341
353
  | Model | What it is |
@@ -415,14 +427,17 @@ envelope and the rate limit's headers.
415
427
  @body body: ServiceUnavailableBody;
416
428
  }
417
429
  ```
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.
430
+ - **One status, one reply.** `@typespec/openapi3` merges two replies of one
431
+ status into one. With `AuthenticationRequired` and `Unauthorized`, the 401
432
+ keeps the envelope and the reply without a body is lost: the library
433
+ refuses it, `duplicate-status-reply`. With `Conflict` beside
434
+ `IdempotencyInProgress`, or `UnprocessableEntity` beside
435
+ `IdempotencyKeyReused`, both bodies stay, under the first reply's
436
+ description: the library warns, `merged-status-reply`. Declare the one the
437
+ route sends; for the idempotent write, the `message` key tells them apart.
438
+ Two plain bodies, `Post | Draft`, bodies of different content types, and
439
+ two `@error` models without a `@statusCode` pass
440
+ ([troubleshooting](docs/troubleshooting.md)).
426
441
  - **Both versions at once write two folders.** With
427
442
  `openapi-versions: ['3.1.0', '3.2.0']`, the emitter writes
428
443
  `3.1.0/openapi.yaml` and `3.2.0/openapi.yaml`: point the generator's input
package/dist/index.d.ts CHANGED
@@ -7,12 +7,15 @@
7
7
  * export would declare it a second time, in the global namespace.
8
8
  */
9
9
  import type { Program } from '@typespec/compiler';
10
- import { operationIds } from './operation-ids';
10
+ import { itemOperation, operationIds } from './operation-ids';
11
11
  export { $lib } from './lib';
12
12
  export declare function $onValidate(program: Program): void;
13
13
  export declare const $decorators: {
14
14
  Nxgt: {
15
15
  operationIds: typeof operationIds;
16
16
  };
17
+ 'Nxgt.Private': {
18
+ itemOperation: typeof itemOperation;
19
+ };
17
20
  };
18
21
  //# sourceMappingURL=index.d.ts.map
@@ -1 +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"}
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;AAElD,OAAO,EACN,aAAa,EACb,YAAY,EAEZ,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC;AAE7B,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAGlD;AAED,eAAO,MAAM,WAAW;;;;;;;CAGvB,CAAC"}
package/dist/index.js CHANGED
@@ -1,13 +1,8 @@
1
- // src/operation-ids.ts
2
- import {
3
- isTemplateInstance,
4
- navigateProgram
5
- } from "@typespec/compiler";
1
+ // src/one-reply-per-status.ts
2
+ import { isVoidType } from "@typespec/compiler";
6
3
  import {
7
- getOperationId,
8
- resolveOperationId,
9
- setOperationId
10
- } from "@typespec/openapi";
4
+ getAllHttpServices
5
+ } from "@typespec/http";
11
6
 
12
7
  // src/lib.ts
13
8
  import { createTypeSpecLibrary, paramMessage } from "@typespec/compiler";
@@ -19,17 +14,99 @@ var $lib = createTypeSpecLibrary({
19
14
  messages: {
20
15
  default: paramMessage`Two operations are named ${"id"}: an OpenAPI operation id must be unique.`
21
16
  }
17
+ },
18
+ "duplicate-status-reply": {
19
+ severity: "error",
20
+ messages: {
21
+ default: paramMessage`${"operation"} declares a reply without a body and one with a body of status ${"status"}: the emitter merges them into one, and the reply without a body is lost. Declare the one the route sends.`
22
+ }
23
+ },
24
+ "merged-status-reply": {
25
+ severity: "warning",
26
+ messages: {
27
+ default: paramMessage`${"operation"} declares two replies with a body of status ${"status"}: the emitter merges their bodies under the first one's description. Declare the one the route sends.`
28
+ }
22
29
  }
23
30
  },
24
31
  state: {
25
- operationIds: { description: "The interfaces marked with @operationIds" }
32
+ operationIds: { description: "The interfaces marked with @operationIds" },
33
+ itemOperations: {
34
+ description: "The operations named after an item, and the item's model"
35
+ }
26
36
  }
27
37
  });
28
38
 
39
+ // src/one-reply-per-status.ts
40
+ function statusOf(code) {
41
+ return typeof code === "object" ? `${code.start}-${code.end}` : `${code}`;
42
+ }
43
+ function mergeOf({
44
+ statusCodes,
45
+ responses
46
+ }) {
47
+ const bodies = responses.flatMap(({ body }) => body && !isVoidType(body.type) ? [body] : []);
48
+ if (bodies.length > 0 && bodies.length < responses.length) {
49
+ return "duplicate-status-reply";
50
+ }
51
+ if (typeof statusCodes !== "number")
52
+ return;
53
+ const seen = new Set;
54
+ for (const body of bodies) {
55
+ if (body.contentTypes.some((type) => seen.has(type))) {
56
+ return "merged-status-reply";
57
+ }
58
+ for (const type of body.contentTypes)
59
+ seen.add(type);
60
+ }
61
+ return;
62
+ }
63
+ function validateOneReplyPerStatus(program) {
64
+ const [services] = getAllHttpServices(program);
65
+ const checked = new Set;
66
+ for (const service of services) {
67
+ for (const operation of service.operations) {
68
+ if (checked.has(operation.operation))
69
+ continue;
70
+ checked.add(operation.operation);
71
+ for (const response of operation.responses) {
72
+ const code = mergeOf(response);
73
+ if (code === undefined)
74
+ continue;
75
+ $lib.reportDiagnostic(program, {
76
+ code,
77
+ format: {
78
+ operation: operation.operation.name,
79
+ status: statusOf(response.statusCodes)
80
+ },
81
+ target: operation.operation
82
+ });
83
+ }
84
+ }
85
+ }
86
+ }
87
+
29
88
  // src/operation-ids.ts
89
+ import {
90
+ getFriendlyName,
91
+ isTemplateInstance,
92
+ navigateProgram
93
+ } from "@typespec/compiler";
94
+ import {
95
+ getOperationId,
96
+ resolveOperationId,
97
+ setOperationId
98
+ } from "@typespec/openapi";
30
99
  function operationIds(context, target) {
31
100
  context.program.stateSet($lib.stateKeys.operationIds).add(target);
32
101
  }
102
+ function itemOperation(context, target, item) {
103
+ context.program.stateMap($lib.stateKeys.itemOperations).set(target, item);
104
+ }
105
+ function nounOf(program, operation) {
106
+ const item = program.stateMap($lib.stateKeys.itemOperations).get(operation);
107
+ const named = item && (getFriendlyName(program, item) ?? (item.name !== "" && !isTemplateInstance(item) ? item.name : undefined));
108
+ return named ?? operation.interface?.name;
109
+ }
33
110
  function isMarked(program, target) {
34
111
  return program.stateSet($lib.stateKeys.operationIds).has(target) || target.sourceInterfaces.some((source) => isMarked(program, source));
35
112
  }
@@ -43,7 +120,7 @@ function validateOperationIds(program) {
43
120
  operation(operation) {
44
121
  const named = isNamed(program, operation);
45
122
  const own = getOperationId(program, operation);
46
- const id = own ?? (named ? `${operation.name}${operation.interface?.name}` : resolveOperationId(program, operation));
123
+ const id = own ?? (named ? `${operation.name}${nounOf(program, operation)}` : resolveOperationId(program, operation));
47
124
  if (seen.has(id) && (named || seen.get(id))) {
48
125
  $lib.reportDiagnostic(program, {
49
126
  code: "duplicate-operation-id",
@@ -61,9 +138,11 @@ function validateOperationIds(program) {
61
138
  // src/index.ts
62
139
  function $onValidate(program) {
63
140
  validateOperationIds(program);
141
+ validateOneReplyPerStatus(program);
64
142
  }
65
143
  var $decorators = {
66
- Nxgt: { operationIds }
144
+ Nxgt: { operationIds },
145
+ "Nxgt.Private": { itemOperation }
67
146
  };
68
147
  export {
69
148
  $decorators,
@@ -71,5 +150,5 @@ export {
71
150
  $onValidate
72
151
  };
73
152
 
74
- //# debugId=2D92CF45DEECB16A64756E2164756E21
153
+ //# debugId=D1BB8C6D97A7E43C64756E2164756E21
75
154
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../src/operation-ids.ts", "../src/lib.ts", "../src/index.ts"],
3
+ "sources": ["../src/one-reply-per-status.ts", "../src/lib.ts", "../src/operation-ids.ts", "../src/index.ts"],
4
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"
5
+ "/**\n * One reply per status: `@typespec/openapi3` merges two replies of one\n * status into one without a word. A reply without a body beside one with a\n * body is lost in the merge, an error; a `void` body is none, as the emitter\n * sees it. Two bodies of one content type are merged under the first reply's\n * description, a warning where the status is a code. Under `*` or a range,\n * where undocumented `@error` models share one description, they pass.\n * Bodies of different content types are one reply, negotiated, and pass.\n */\nimport { isVoidType, type Operation, type Program } from '@typespec/compiler';\nimport {\n\tgetAllHttpServices,\n\ttype HttpOperationResponse,\n\ttype HttpStatusCodesEntry,\n} from '@typespec/http';\nimport { $lib } from './lib';\n\nfunction statusOf(code: HttpStatusCodesEntry): string {\n\treturn typeof code === 'object' ? `${code.start}-${code.end}` : `${code}`;\n}\n\n/** What the emitter's merge costs: a reply, a description, or nothing. */\nfunction mergeOf({\n\tstatusCodes,\n\tresponses,\n}: HttpOperationResponse):\n\t| 'duplicate-status-reply'\n\t| 'merged-status-reply'\n\t| undefined {\n\tconst bodies = responses.flatMap(({ body }) =>\n\t\tbody && !isVoidType(body.type) ? [body] : [],\n\t);\n\tif (bodies.length > 0 && bodies.length < responses.length) {\n\t\treturn 'duplicate-status-reply';\n\t}\n\tif (typeof statusCodes !== 'number') return undefined;\n\tconst seen = new Set<string>();\n\tfor (const body of bodies) {\n\t\tif (body.contentTypes.some((type) => seen.has(type))) {\n\t\t\treturn 'merged-status-reply';\n\t\t}\n\t\tfor (const type of body.contentTypes) seen.add(type);\n\t}\n\treturn undefined;\n}\n\n/**\n * Checks each operation of each service, as the emitter sees it, once: a\n * service nested in another is listed in both. The services' own\n * diagnostics are the emitter's to report.\n */\nexport function validateOneReplyPerStatus(program: Program): void {\n\tconst [services] = getAllHttpServices(program);\n\tconst checked = new Set<Operation>();\n\tfor (const service of services) {\n\t\tfor (const operation of service.operations) {\n\t\t\tif (checked.has(operation.operation)) continue;\n\t\t\tchecked.add(operation.operation);\n\t\t\tfor (const response of operation.responses) {\n\t\t\t\tconst code = mergeOf(response);\n\t\t\t\tif (code === undefined) continue;\n\t\t\t\t$lib.reportDiagnostic(program, {\n\t\t\t\t\tcode,\n\t\t\t\t\tformat: {\n\t\t\t\t\t\toperation: operation.operation.name,\n\t\t\t\t\t\tstatus: statusOf(response.statusCodes),\n\t\t\t\t\t},\n\t\t\t\t\ttarget: operation.operation,\n\t\t\t\t});\n\t\t\t}\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\t'duplicate-status-reply': {\n\t\t\tseverity: 'error',\n\t\t\tmessages: {\n\t\t\t\tdefault: paramMessage`${'operation'} declares a reply without a body and one with a body of status ${'status'}: the emitter merges them into one, and the reply without a body is lost. Declare the one the route sends.`,\n\t\t\t},\n\t\t},\n\t\t'merged-status-reply': {\n\t\t\tseverity: 'warning',\n\t\t\tmessages: {\n\t\t\t\tdefault: paramMessage`${'operation'} declares two replies with a body of status ${'status'}: the emitter merges their bodies under the first one's description. Declare the one the route sends.`,\n\t\t\t},\n\t\t},\n\t},\n\tstate: {\n\t\toperationIds: { description: 'The interfaces marked with @operationIds' },\n\t\titemOperations: {\n\t\t\tdescription: \"The operations named after an item, and the item's model\",\n\t\t},\n\t},\n});\n",
7
+ "/**\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\tgetFriendlyName,\n\ttype Interface,\n\tisTemplateInstance,\n\ttype Model,\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/**\n * Names the operation after one item, `<operation><Item>`, instead of its\n * interface, where `@operationIds` names it: `Resource` marks `read`,\n * `create`, `update` and `delete`, so `Authors` has `readAuthor`, but\n * `listAuthors`. Not public: `Resource`'s alone.\n */\nexport function itemOperation(\n\tcontext: DecoratorContext,\n\ttarget: Operation,\n\titem: Model,\n): void {\n\tcontext.program.stateMap($lib.stateKeys.itemOperations).set(target, item);\n}\n\n/**\n * The name `@operationIds` puts after the operation's: its item's, or its\n * interface's. An item without a name of its own, anonymous or an instance\n * such as `Versioned<Author>`, whose name would be `Versioned` alone, falls\n * back to the interface.\n */\nfunction nounOf(program: Program, operation: Operation): string | undefined {\n\tconst item: Model | undefined = program\n\t\t.stateMap($lib.stateKeys.itemOperations)\n\t\t.get(operation);\n\tconst named =\n\t\titem &&\n\t\t(getFriendlyName(program, item) ??\n\t\t\t(item.name !== '' && !isTemplateInstance(item) ? item.name : undefined));\n\treturn named ?? operation.interface?.name;\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}${nounOf(program, operation)}`\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",
8
+ "/**\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 { validateOneReplyPerStatus } from './one-reply-per-status';\nimport {\n\titemOperation,\n\toperationIds,\n\tvalidateOperationIds,\n} from './operation-ids';\n\nexport { $lib } from './lib';\n\nexport function $onValidate(program: Program): void {\n\tvalidateOperationIds(program);\n\tvalidateOneReplyPerStatus(program);\n}\n\nexport const $decorators = {\n\tNxgt: { operationIds },\n\t'Nxgt.Private': { itemOperation },\n};\n"
8
9
  ],
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",
10
+ "mappings": "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",
11
+ "debugId": "D1BB8C6D97A7E43C64756E2164756E21",
11
12
  "names": []
12
13
  }
package/dist/lib.d.ts CHANGED
@@ -2,5 +2,11 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
2
2
  'duplicate-operation-id': {
3
3
  readonly default: import("@typespec/compiler").CallableMessage<["id"]>;
4
4
  };
5
- }, Record<string, any>, "operationIds">;
5
+ 'duplicate-status-reply': {
6
+ readonly default: import("@typespec/compiler").CallableMessage<["operation", "status"]>;
7
+ };
8
+ 'merged-status-reply': {
9
+ readonly default: import("@typespec/compiler").CallableMessage<["operation", "status"]>;
10
+ };
11
+ }, Record<string, any>, "operationIds" | "itemOperations">;
6
12
  //# sourceMappingURL=lib.d.ts.map
package/dist/lib.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"lib.d.ts","sourceRoot":"","sources":["../src/lib.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,IAAI;;;;uCAaf,CAAC"}
1
+ {"version":3,"file":"lib.d.ts","sourceRoot":"","sources":["../src/lib.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,IAAI;;;;;;;;;;0DA4Bf,CAAC"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * One reply per status: `@typespec/openapi3` merges two replies of one
3
+ * status into one without a word. A reply without a body beside one with a
4
+ * body is lost in the merge, an error; a `void` body is none, as the emitter
5
+ * sees it. Two bodies of one content type are merged under the first reply's
6
+ * description, a warning where the status is a code. Under `*` or a range,
7
+ * where undocumented `@error` models share one description, they pass.
8
+ * Bodies of different content types are one reply, negotiated, and pass.
9
+ */
10
+ import { type Program } from '@typespec/compiler';
11
+ /**
12
+ * Checks each operation of each service, as the emitter sees it, once: a
13
+ * service nested in another is listed in both. The services' own
14
+ * diagnostics are the emitter's to report.
15
+ */
16
+ export declare function validateOneReplyPerStatus(program: Program): void;
17
+ //# sourceMappingURL=one-reply-per-status.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"one-reply-per-status.d.ts","sourceRoot":"","sources":["../src/one-reply-per-status.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAA8B,KAAK,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAqC9E;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAqBhE"}
@@ -2,13 +2,20 @@
2
2
  * `@operationIds`: names each operation of an interface after its method and
3
3
  * the interface, `<operation><Interface>`, such as `listPets`.
4
4
  */
5
- import { type DecoratorContext, type Interface, type Program } from '@typespec/compiler';
5
+ import { type DecoratorContext, type Interface, type Model, type Operation, type Program } from '@typespec/compiler';
6
6
  /**
7
7
  * Marks the interface; its ids are set once the program is checked. On a
8
8
  * template, TypeSpec runs it on each instance, which `isMarked` follows to
9
9
  * the interfaces extending it.
10
10
  */
11
11
  export declare function operationIds(context: DecoratorContext, target: Interface): void;
12
+ /**
13
+ * Names the operation after one item, `<operation><Item>`, instead of its
14
+ * interface, where `@operationIds` names it: `Resource` marks `read`,
15
+ * `create`, `update` and `delete`, so `Authors` has `readAuthor`, but
16
+ * `listAuthors`. Not public: `Resource`'s alone.
17
+ */
18
+ export declare function itemOperation(context: DecoratorContext, target: Operation, item: Model): void;
12
19
  /**
13
20
  * Sets the ids, after every decorator, so the order of `@operationId` and
14
21
  * `@operationIds` in the source does not matter. Two operations with one id,
@@ -1 +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"}
1
+ {"version":3,"file":"operation-ids.d.ts","sourceRoot":"","sources":["../src/operation-ids.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EACN,KAAK,gBAAgB,EAErB,KAAK,SAAS,EAEd,KAAK,KAAK,EAEV,KAAK,SAAS,EACd,KAAK,OAAO,EACZ,MAAM,oBAAoB,CAAC;AAQ5B;;;;GAIG;AACH,wBAAgB,YAAY,CAC3B,OAAO,EAAE,gBAAgB,EACzB,MAAM,EAAE,SAAS,GACf,IAAI,CAEN;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAC5B,OAAO,EAAE,gBAAgB,EACzB,MAAM,EAAE,SAAS,EACjB,IAAI,EAAE,KAAK,GACT,IAAI,CAEN;AAyCD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CA2B3D"}
@@ -97,7 +97,7 @@ operation can declare the guard's 401 and the handler's 403:
97
97
  } | BadRequest | AuthenticationRequired | Forbidden;
98
98
  ```
99
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.
100
+ Not both replies of one status, though: the emitter would merge
101
+ `AuthenticationRequired` and `Unauthorized` into a single 401, with the
102
+ envelope, and lose the reply without a body. The library refuses it with
103
+ `duplicate-status-reply`: declare the one the route sends.
@@ -41,8 +41,9 @@ interface Posts {
41
41
 
42
42
  Both carry the `RateLimit-*` headers, as a route that also has a rate limit
43
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.
44
+ lock, gets one 409 with the description of the first, and the library warns,
45
+ `merged-status-reply`: declare one of the two, and tell them apart by the
46
+ `message` key ([troubleshooting](../troubleshooting.md)).
46
47
 
47
48
  ## A rate limit
48
49
 
@@ -84,9 +84,13 @@ interface Pets extends Resource<Pet> {} // listPets
84
84
  interface Orders extends Resource<Order> {} // listOrders
85
85
  ```
86
86
 
87
+ The library's own `Resource` goes one step further: `list` is named after
88
+ the interface, and `read`, `create`, `update` and `delete` after the item,
89
+ `readPet` ([Resources](resources.md)).
90
+
87
91
  ## Two operations named alike
88
92
 
89
- An id must be unique, and four cases break that:
93
+ An id must be unique, and five cases break that:
90
94
 
91
95
  - the namespace is not part of the id, so `Store.Pets` and `Shelter.Pets`
92
96
  would both name their `list` `listPets`;
@@ -95,7 +99,10 @@ An id must be unique, and four cases break that:
95
99
  - an `@operationId` written on another operation, in any interface, takes
96
100
  the id `@operationIds` gives;
97
101
  - an operation declared in the service namespace is named after itself, so
98
- `op listPets()` takes the id of `Pets.list`.
102
+ `op listPets()` takes the id of `Pets.list`;
103
+ - two interfaces extend `Resource` with one item, so `Authors` and
104
+ `ArchivedAuthors` would both name their `read` `readAuthor`
105
+ ([Resources](resources.md#two-resources-of-one-item)).
99
106
 
100
107
  `@operationIds` refuses each with `duplicate-operation-id`, where
101
108
  `@typespec/openapi3` would emit both without a word. Give one of them its own
@@ -33,14 +33,55 @@ interface Authors extends Resource<Author, AuthorFilters, SortField = "name" | "
33
33
  | Operation | Route | Body | Answers |
34
34
  | --- | --- | --- | --- |
35
35
  | `listAuthors` | `GET /authors` | | `Page<Author>` |
36
- | `readAuthors` | `GET /authors/{id}` | | `Author`, `NotFound` |
37
- | `createAuthors` | `POST /authors` | `Create<Author>` | 201 `Author`, `BadRequest`, `Conflict` |
38
- | `updateAuthors` | `PATCH /authors/{id}` | `MergePatchUpdate<Author>` | `Author`, `BadRequest`, `NotFound`, `Conflict` |
39
- | `deleteAuthors` | `DELETE /authors/{id}` | | 204, `NotFound` |
36
+ | `readAuthor` | `GET /authors/{id}` | | `Author`, `NotFound` |
37
+ | `createAuthor` | `POST /authors` | `Create<Author>` | 201 `Author`, `BadRequest`, `Conflict` |
38
+ | `updateAuthor` | `PATCH /authors/{id}` | `MergePatchUpdate<Author>` | `Author`, `BadRequest`, `NotFound`, `Conflict` |
39
+ | `deleteAuthor` | `DELETE /authors/{id}` | | 204, `NotFound` |
40
+
41
+ The template carries `@operationIds`. `list` is named after the interface
42
+ that extends it, which holds the many, and `read`, `create`, `update` and
43
+ `delete` after `Item`, the one: `listAuthors`, `readAuthor`. The item's
44
+ name is its `@friendlyName`, if it has one. An item without a name of its
45
+ own, an anonymous model or an instance such as `Draft<Author>`, falls back to
46
+ the interface: `readDrafts`. An operation the extending interface declares
47
+ again is named after the interface too, unless it has its own `@operationId`. The generated `operations` has
48
+ one entry per id:
40
49
 
41
- The template carries `@operationIds`, so each operation is named after the
42
- interface that extends it. The generator's own 400 is on every operation
43
- that takes a parameter or a body, as always.
50
+ ```ts
51
+ import { operations } from './generated/operations';
52
+
53
+ Object.keys(operations);
54
+ // ['listAuthors', 'createAuthor', 'readAuthor', 'updateAuthor', 'deleteAuthor']
55
+ ```
56
+
57
+ The generator's own 400 is on every operation that takes a parameter or a
58
+ body, as always.
59
+
60
+ ## Two resources of one item
61
+
62
+ Two interfaces extending `Resource` with one `Item` share `readAuthor`,
63
+ `createAuthor`, `updateAuthor` and `deleteAuthor`, which is an error,
64
+ `duplicate-operation-id`. Declare the operations of the second again, each
65
+ with its own `@operationId`, as an interface that extends overrides the
66
+ template's. Use the resource's `Id` in place of `uuid`, if it names one:
67
+
68
+ ```tsp
69
+ import "@typespec/openapi";
70
+
71
+ using OpenAPI;
72
+
73
+ @route("/archived-authors")
74
+ interface ArchivedAuthors extends Resource<Author> {
75
+ @get @operationId("readArchivedAuthor") read(@path id: uuid): Author | NotFound;
76
+ @post @operationId("createArchivedAuthor") create(@body item: Create<Author>): {
77
+ @statusCode _: 201;
78
+ @body item: Author;
79
+ } | BadRequest | Conflict;
80
+ @patch @operationId("updateArchivedAuthor")
81
+ update(@path id: uuid, @body item: MergePatchUpdate<Author>): Author | BadRequest | NotFound | Conflict;
82
+ @delete @operationId("deleteArchivedAuthor") delete(@path id: uuid): NoContentResponse | NotFound;
83
+ }
84
+ ```
44
85
 
45
86
  ## The template's arguments
46
87
 
@@ -110,3 +151,7 @@ interface Comments extends Resource<Comment> {
110
151
  Add `@useAuth(JanusAuth)` on the interface to protect all five, and name the
111
152
  guards' replies in the operations you declare again
112
153
  ([Authentication](auth.md)).
154
+
155
+ A `read`, `create`, `update` or `delete` declared again is named after the
156
+ interface, `readComments`, not after the item: give it
157
+ `@operationId("readComment")` to keep the item's name.
package/docs/roadmap.md CHANGED
@@ -6,6 +6,28 @@ generated by `@nxgt/openapi-codegen` in CI before it ships.
6
6
 
7
7
  ## Now
8
8
 
9
+ - **Resource ids in the singular.** `Resource` names `read`, `create`,
10
+ `update` and `delete` after its item, `readPost`, and `list` after the
11
+ interface, `listPosts`, instead of `readPosts`.
12
+
13
+ ## Later
14
+
15
+ - **`ETag` and conditional requests.** `ETag`, with `If-None-Match` and
16
+ `If-Match`, once the `@nxgt/*` packages send one the same way. The
17
+ optimistic lock is the body's `version` today (`Versioned`).
18
+
19
+ ## Shipped
20
+
21
+ ### 0.2.0
22
+
23
+ - **One reply per status.** An operation that declares a reply without a
24
+ body beside one with a body, such as `AuthenticationRequired` and
25
+ `Unauthorized`, is an error, `duplicate-status-reply`, and two replies with
26
+ a body of one status code a warning, `merged-status-reply`, instead of
27
+ replies the emitter merges without a word.
28
+
29
+ ### 0.1.0
30
+
9
31
  - **Error replies.** The envelope `@nxgt/openapi-hono` sends, and the
10
32
  400, 401, 403, 404, 409, 422, 429 and 500 responses, with any other status
11
33
  through `ErrorResponse<Status>`.
@@ -24,15 +46,3 @@ generated by `@nxgt/openapi-codegen` in CI before it ships.
24
46
  by `orderBy` and `direction`.
25
47
  - **Operation ids.** `@operationIds` names an interface's operations
26
48
  `listPosts`, `createPosts`, instead of `Posts_list`.
27
-
28
- ## Next
29
-
30
- - **One reply per status.** An error when an operation declares two replies
31
- of one status, such as `AuthenticationRequired` and `Unauthorized`, which
32
- the emitter merges without a warning today.
33
-
34
- ## Later
35
-
36
- - **`ETag` and conditional requests.** `ETag`, with `If-None-Match` and
37
- `If-Match`, once the `@nxgt/*` packages send one the same way. The
38
- optimistic lock is the body's `version` today (`Versioned`).
@@ -60,17 +60,22 @@ operation:
60
60
  `extends` copies it as is;
61
61
  - an `@operationId` written elsewhere takes the id `@operationIds` gives;
62
62
  - an operation declared in the service namespace is named after itself:
63
- `op listPets()` takes the id of `Pets.list`.
63
+ `op listPets()` takes the id of `Pets.list`;
64
+ - two interfaces extend `Resource` with one item: `Authors` and
65
+ `ArchivedAuthors` both give `readAuthor`, as `Resource` names `read`,
66
+ `create`, `update` and `delete` after the item.
64
67
 
65
68
  ```text
66
69
  error @nxgt/typespec/duplicate-operation-id: Two operations are named listPets: an OpenAPI operation id must be unique.
67
70
  ```
68
71
 
69
- **Why:** `@operationIds` names each operation `<operation><Interface>`, without
70
- the namespace. `@typespec/openapi3` would emit both ids without a word, and
72
+ **Why:** `@operationIds` names each operation `<operation><Interface>`
73
+ (`<operation><Item>` for `Resource`'s `read`, `create`, `update` and
74
+ `delete`), without the namespace. `@typespec/openapi3` would emit both ids without a word, and
71
75
  the generator would then refuse the spec.
72
76
 
73
- **Fix:** give one of them its own `@operationId`. An inherited operation takes
77
+ **Fix:** give one of them its own `@operationId`, for a `Resource` in the
78
+ interface that extends it ([Resources](guide/resources.md#two-resources-of-one-item)). An inherited operation takes
74
79
  one by being declared again in the interface that extends:
75
80
 
76
81
  ```tsp
@@ -84,6 +89,61 @@ interface Drafts extends Posts {
84
89
  }
85
90
  ```
86
91
 
92
+ ## `declares a reply without a body and one with a body of status …`
93
+
94
+ **When:** an operation declares, for one status, a reply without a body and
95
+ one with a body, such as the guard's 401 and the handler's:
96
+
97
+ ```tsp
98
+ @get list(): Post[] | AuthenticationRequired | Unauthorized;
99
+ ```
100
+
101
+ ```text
102
+ error @nxgt/typespec/duplicate-status-reply: list declares a reply without a body and one with a body of status 401: the emitter merges them into one, and the reply without a body is lost. Declare the one the route sends.
103
+ ```
104
+
105
+ **Why:** OpenAPI has one reply per status. `@typespec/openapi3` merges the two
106
+ without a warning: the 401 keeps the envelope, and a client never learns the
107
+ guard's reply has no body.
108
+
109
+ **Fix:** declare the one the route sends. A route behind a
110
+ `@nxgt/janus-hono` guard sends `AuthenticationRequired`; a handler that
111
+ answers with the envelope, `Unauthorized`:
112
+
113
+ ```tsp
114
+ @get list(): Post[] | AuthenticationRequired;
115
+ ```
116
+
117
+ ## `declares two replies with a body of status …`
118
+
119
+ **When:** a warning: an operation declares two replies with a body for one
120
+ status code, such as the idempotent write's 409 beside the optimistic
121
+ lock's, or its 422, `IdempotencyKeyReused`, beside `UnprocessableEntity`:
122
+
123
+ ```tsp
124
+ @post create(@body post: Post): Post | IdempotencyInProgress | Conflict;
125
+ ```
126
+
127
+ ```text
128
+ warning @nxgt/typespec/merged-status-reply: create declares two replies with a body of status 409: the emitter merges their bodies under the first one's description. Declare the one the route sends.
129
+ ```
130
+
131
+ **Why:** `@typespec/openapi3` keeps both bodies, as one reply whose body is
132
+ either, under the first one's description: the second's is gone, and a
133
+ header either reply declares required becomes optional.
134
+
135
+ **Fix:** declare the one the route sends. Both carry the envelope, and the
136
+ `message` key tells them apart:
137
+
138
+ ```tsp
139
+ @post create(@body post: Post): Post | IdempotencyInProgress;
140
+ ```
141
+
142
+ Two plain bodies, `Post | Draft`, are one reply whose body is either; two
143
+ `@error` models without a `@statusCode` share `default`, and one
144
+ description; bodies of different content types are negotiated. All three
145
+ pass.
146
+
87
147
  ## `Unknown decorator @operationId`
88
148
 
89
149
  **When:** a spec writes `@operationId` next to `@operationIds`.
@@ -20,3 +20,11 @@ namespace Nxgt;
20
20
  * ```
21
21
  */
22
22
  extern dec operationIds(target: Interface);
23
+
24
+ namespace Private {
25
+ /**
26
+ * Names the operation after `item`, `<operation><Item>`, where
27
+ * `@operationIds` names it. `Resource`'s alone: not public.
28
+ */
29
+ extern dec itemOperation(target: Operation, item: Model);
30
+ }
package/lib/resources.tsp CHANGED
@@ -23,8 +23,9 @@ model SortParameters<Field extends string> {
23
23
  * The five operations of a resource, at `/<route>` and `/<route>/{id}`:
24
24
  * `list` pages by offset, with the `Filters` the spec writes and a sort by
25
25
  * `SortField`; `read`, `create`, `update` (a merge patch) and `delete`.
26
- * Extend it with `@route`; `@operationIds` names the operations after the
27
- * interface that extends it: `listPosts`, `readPosts`…
26
+ * Extend it with `@route`; `@operationIds` names `list` after the interface
27
+ * that extends it, and the others after `Item`: `listPosts`, `readPost`,
28
+ * `createPost`, `updatePost`, `deletePost`.
28
29
  *
29
30
  * @template Item The resource's model.
30
31
  * @template Filters A model of `@query` properties, spread into `list`.
@@ -43,10 +44,12 @@ interface Resource<
43
44
  list(...Filters, ...PageParameters, ...SortParameters<SortField>): Page<Item>;
44
45
 
45
46
  /** One resource. */
47
+ @Private.itemOperation(Item)
46
48
  @get
47
49
  read(@path id: Id): Item | NotFound;
48
50
 
49
51
  /** Creates a resource. */
52
+ @Private.itemOperation(Item)
50
53
  @post
51
54
  create(@body item: Create<Item>): {
52
55
  @statusCode _: 201;
@@ -54,10 +57,12 @@ interface Resource<
54
57
  } | BadRequest | Conflict;
55
58
 
56
59
  /** Updates a resource with a merge patch. */
60
+ @Private.itemOperation(Item)
57
61
  @patch
58
62
  update(@path id: Id, @body item: MergePatchUpdate<Item>): Item | BadRequest | NotFound | Conflict;
59
63
 
60
64
  /** Deletes a resource. */
65
+ @Private.itemOperation(Item)
61
66
  @delete
62
67
  delete(@path id: Id): NoContentResponse | NotFound;
63
68
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/typespec",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",