@nxgt/typespec 0.1.0 → 0.2.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
@@ -336,6 +336,14 @@ of a marked interface that has another operation's id is an error,
336
336
  | --- | --- | --- |
337
337
  | `@operationIds` | an interface | names each operation `<operation><Interface>`, unless it has an `@operationId` |
338
338
 
339
+ ### Diagnostics
340
+
341
+ | Code | Reported when |
342
+ | --- | --- |
343
+ | `duplicate-operation-id` | error: an operation of an `@operationIds` interface has another operation's id |
344
+ | `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 |
345
+ | `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 |
346
+
339
347
  ### Models
340
348
 
341
349
  | Model | What it is |
@@ -415,14 +423,17 @@ envelope and the rate limit's headers.
415
423
  @body body: ServiceUnavailableBody;
416
424
  }
417
425
  ```
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
+ - **One status, one reply.** `@typespec/openapi3` merges two replies of one
427
+ status into one. With `AuthenticationRequired` and `Unauthorized`, the 401
428
+ keeps the envelope and the reply without a body is lost: the library
429
+ refuses it, `duplicate-status-reply`. With `Conflict` beside
430
+ `IdempotencyInProgress`, or `UnprocessableEntity` beside
431
+ `IdempotencyKeyReused`, both bodies stay, under the first reply's
432
+ description: the library warns, `merged-status-reply`. Declare the one the
433
+ route sends; for the idempotent write, the `message` key tells them apart.
434
+ Two plain bodies, `Post | Draft`, bodies of different content types, and
435
+ two `@error` models without a `@statusCode` pass
436
+ ([troubleshooting](docs/troubleshooting.md)).
426
437
  - **Both versions at once write two folders.** With
427
438
  `openapi-versions: ['3.1.0', '3.2.0']`, the emitter writes
428
439
  `3.1.0/openapi.yaml` and `3.2.0/openapi.yaml`: point the generator's input
@@ -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,EAAE,YAAY,EAAwB,MAAM,iBAAiB,CAAC;AAErE,OAAO,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC;AAE7B,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAGlD;AAED,eAAO,MAAM,WAAW;;;;CAEvB,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,6 +14,18 @@ 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: {
@@ -26,7 +33,65 @@ var $lib = createTypeSpecLibrary({
26
33
  }
27
34
  });
28
35
 
36
+ // src/one-reply-per-status.ts
37
+ function statusOf(code) {
38
+ return typeof code === "object" ? `${code.start}-${code.end}` : `${code}`;
39
+ }
40
+ function mergeOf({
41
+ statusCodes,
42
+ responses
43
+ }) {
44
+ const bodies = responses.flatMap(({ body }) => body && !isVoidType(body.type) ? [body] : []);
45
+ if (bodies.length > 0 && bodies.length < responses.length) {
46
+ return "duplicate-status-reply";
47
+ }
48
+ if (typeof statusCodes !== "number")
49
+ return;
50
+ const seen = new Set;
51
+ for (const body of bodies) {
52
+ if (body.contentTypes.some((type) => seen.has(type))) {
53
+ return "merged-status-reply";
54
+ }
55
+ for (const type of body.contentTypes)
56
+ seen.add(type);
57
+ }
58
+ return;
59
+ }
60
+ function validateOneReplyPerStatus(program) {
61
+ const [services] = getAllHttpServices(program);
62
+ const checked = new Set;
63
+ for (const service of services) {
64
+ for (const operation of service.operations) {
65
+ if (checked.has(operation.operation))
66
+ continue;
67
+ checked.add(operation.operation);
68
+ for (const response of operation.responses) {
69
+ const code = mergeOf(response);
70
+ if (code === undefined)
71
+ continue;
72
+ $lib.reportDiagnostic(program, {
73
+ code,
74
+ format: {
75
+ operation: operation.operation.name,
76
+ status: statusOf(response.statusCodes)
77
+ },
78
+ target: operation.operation
79
+ });
80
+ }
81
+ }
82
+ }
83
+ }
84
+
29
85
  // src/operation-ids.ts
86
+ import {
87
+ isTemplateInstance,
88
+ navigateProgram
89
+ } from "@typespec/compiler";
90
+ import {
91
+ getOperationId,
92
+ resolveOperationId,
93
+ setOperationId
94
+ } from "@typespec/openapi";
30
95
  function operationIds(context, target) {
31
96
  context.program.stateSet($lib.stateKeys.operationIds).add(target);
32
97
  }
@@ -61,6 +126,7 @@ function validateOperationIds(program) {
61
126
  // src/index.ts
62
127
  function $onValidate(program) {
63
128
  validateOperationIds(program);
129
+ validateOneReplyPerStatus(program);
64
130
  }
65
131
  var $decorators = {
66
132
  Nxgt: { operationIds }
@@ -71,5 +137,5 @@ export {
71
137
  $onValidate
72
138
  };
73
139
 
74
- //# debugId=2D92CF45DEECB16A64756E2164756E21
140
+ //# debugId=CF9FA1522FD0E32664756E2164756E21
75
141
  //# 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 * 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},\n});\n",
5
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\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
+ "/**\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 { operationIds, validateOperationIds } 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};\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": "CF9FA1522FD0E32664756E2164756E21",
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
+ '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
+ };
5
11
  }, Record<string, any>, "operationIds">;
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;;;;;;;;;;uCAyBf,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"}
@@ -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
 
package/docs/roadmap.md CHANGED
@@ -6,6 +6,22 @@ generated by `@nxgt/openapi-codegen` in CI before it ships.
6
6
 
7
7
  ## Now
8
8
 
9
+ - **One reply per status.** An operation that declares a reply without a
10
+ body beside one with a body, such as `AuthenticationRequired` and
11
+ `Unauthorized`, is an error, `duplicate-status-reply`, and two replies with
12
+ a body of one status code a warning, `merged-status-reply`, instead of
13
+ replies the emitter merges without a word.
14
+
15
+ ## Later
16
+
17
+ - **`ETag` and conditional requests.** `ETag`, with `If-None-Match` and
18
+ `If-Match`, once the `@nxgt/*` packages send one the same way. The
19
+ optimistic lock is the body's `version` today (`Versioned`).
20
+
21
+ ## Shipped
22
+
23
+ ### 0.1.0
24
+
9
25
  - **Error replies.** The envelope `@nxgt/openapi-hono` sends, and the
10
26
  400, 401, 403, 404, 409, 422, 429 and 500 responses, with any other status
11
27
  through `ErrorResponse<Status>`.
@@ -24,15 +40,3 @@ generated by `@nxgt/openapi-codegen` in CI before it ships.
24
40
  by `orderBy` and `direction`.
25
41
  - **Operation ids.** `@operationIds` names an interface's operations
26
42
  `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`).
@@ -84,6 +84,61 @@ interface Drafts extends Posts {
84
84
  }
85
85
  ```
86
86
 
87
+ ## `declares a reply without a body and one with a body of status …`
88
+
89
+ **When:** an operation declares, for one status, a reply without a body and
90
+ one with a body, such as the guard's 401 and the handler's:
91
+
92
+ ```tsp
93
+ @get list(): Post[] | AuthenticationRequired | Unauthorized;
94
+ ```
95
+
96
+ ```text
97
+ 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.
98
+ ```
99
+
100
+ **Why:** OpenAPI has one reply per status. `@typespec/openapi3` merges the two
101
+ without a warning: the 401 keeps the envelope, and a client never learns the
102
+ guard's reply has no body.
103
+
104
+ **Fix:** declare the one the route sends. A route behind a
105
+ `@nxgt/janus-hono` guard sends `AuthenticationRequired`; a handler that
106
+ answers with the envelope, `Unauthorized`:
107
+
108
+ ```tsp
109
+ @get list(): Post[] | AuthenticationRequired;
110
+ ```
111
+
112
+ ## `declares two replies with a body of status …`
113
+
114
+ **When:** a warning: an operation declares two replies with a body for one
115
+ status code, such as the idempotent write's 409 beside the optimistic
116
+ lock's, or its 422, `IdempotencyKeyReused`, beside `UnprocessableEntity`:
117
+
118
+ ```tsp
119
+ @post create(@body post: Post): Post | IdempotencyInProgress | Conflict;
120
+ ```
121
+
122
+ ```text
123
+ 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.
124
+ ```
125
+
126
+ **Why:** `@typespec/openapi3` keeps both bodies, as one reply whose body is
127
+ either, under the first one's description: the second's is gone, and a
128
+ header either reply declares required becomes optional.
129
+
130
+ **Fix:** declare the one the route sends. Both carry the envelope, and the
131
+ `message` key tells them apart:
132
+
133
+ ```tsp
134
+ @post create(@body post: Post): Post | IdempotencyInProgress;
135
+ ```
136
+
137
+ Two plain bodies, `Post | Draft`, are one reply whose body is either; two
138
+ `@error` models without a `@statusCode` share `default`, and one
139
+ description; bodies of different content types are negotiated. All three
140
+ pass.
141
+
87
142
  ## `Unknown decorator @operationId`
88
143
 
89
144
  **When:** a spec writes `@operationId` next to `@operationIds`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/typespec",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",