@nxgt/typespec 0.13.1 → 0.14.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.
@@ -2,7 +2,9 @@
2
2
 
3
3
  `@nxgt/typespec` is a TypeSpec emitter as well as a library: it compiles a
4
4
  spec to OpenAPI with `@typespec/openapi3`, and writes each `@queryMethod` as a
5
- real `QUERY` when the document is OpenAPI 3.2.
5
+ real `QUERY` when the document is OpenAPI 3.2. It also emits a shared
6
+ response or header once under `components`, and `servers` on one path
7
+ ([below](#shared-responses-shared-headers-and-path-servers)).
6
8
 
7
9
  ## The smallest setup
8
10
 
@@ -98,6 +100,150 @@ where `@typespec/openapi3` wrote `tsp-output/@typespec/openapi3`. Set
98
100
  `@typespec/openapi3` is loaded only when emitting, so a spec that only
99
101
  imports the library, `tsp compile --no-emit`, does not need it installed.
100
102
 
103
+ ## Shared responses, shared headers and path servers
104
+
105
+ `@typespec/openapi3` writes every response and header in full at each
106
+ operation, and `@server` only reaches the document's `servers`. Three
107
+ decorators ask the emitter for the rest, with no post-processing of the JSON.
108
+ They need this emitter: with `@typespec/openapi3` in `emit` each is the error
109
+ `shared-needs-nxgt-emitter`, since it would write the copies or drop the
110
+ servers without a word.
111
+
112
+ ```tsp
113
+ import "@typespec/http";
114
+ import "@nxgt/typespec";
115
+
116
+ using Http;
117
+ using Nxgt;
118
+
119
+ @service(#{ title: "Example" })
120
+ @server("https://api.example.com", "Main")
121
+ namespace Example;
122
+
123
+ model NoStore {
124
+ @sharedHeader("NoStore")
125
+ @header("cache-control")
126
+ cacheControl: "no-store";
127
+ }
128
+
129
+ @sharedResponse
130
+ @error
131
+ model Unauthorized {
132
+ ...NoStore;
133
+ @statusCode code: 401;
134
+ message: string;
135
+ }
136
+
137
+ @route("/items") @get op listItems(): { @body items: string[] } | Unauthorized;
138
+
139
+ @route("/users")
140
+ @pathServer("https://users.example.com", "Users")
141
+ @get op listUsers(): { @body users: string[] } | Unauthorized;
142
+ ```
143
+
144
+ ```yaml
145
+ # openapi.yaml (extract)
146
+ paths:
147
+ /items:
148
+ get:
149
+ responses:
150
+ '401':
151
+ $ref: '#/components/responses/Unauthorized'
152
+ /users:
153
+ servers:
154
+ - url: https://users.example.com
155
+ description: Users
156
+ get:
157
+ responses:
158
+ '401':
159
+ $ref: '#/components/responses/Unauthorized'
160
+ components:
161
+ headers:
162
+ NoStore:
163
+ required: true
164
+ schema: { type: string, enum: [no-store] }
165
+ responses:
166
+ Unauthorized:
167
+ description: Access is unauthorized.
168
+ headers:
169
+ cache-control:
170
+ $ref: '#/components/headers/NoStore'
171
+ content:
172
+ application/json:
173
+ schema:
174
+ $ref: '#/components/schemas/Unauthorized'
175
+ servers:
176
+ - url: https://api.example.com
177
+ description: Main
178
+ ```
179
+
180
+ | Decorator | On | Result |
181
+ | --- | --- | --- |
182
+ | `@sharedResponse(name?)` | a response model | each reply of that model, whatever the operation, is one entry of `components.responses` (named after the model unless `name` is given) and a `$ref` at each use |
183
+ | `@sharedHeader(name?)` | a model property that is a `@header` | the header is one entry of `components.headers` (named after the property unless `name` is given) and a `$ref` at each response that carries it |
184
+ | `@pathServer(url, description?, parameters?)` | an operation | `servers` on the operation's path item; the other paths keep the document's. Repeated on the operations of one path, the servers add up |
185
+
186
+ What to know:
187
+
188
+ - **A response header only.** `components.headers` holds response headers.
189
+ `@sharedHeader` on a request `@header` or on a body property is the error
190
+ `shared-header-misplaced`; the mark never stays in the document.
191
+ - **A shared response is the whole reply**: description, headers and content.
192
+ A model with several statuses (`@statusCode code: 400 | 404`) is one
193
+ component per status, `Name400` and `Name404`, since their descriptions
194
+ differ.
195
+ Two shared responses, or two shared headers, of one name that differ are the
196
+ error `shared-component-conflict`: the first is the component, the other
197
+ stays inline. A name is letters,
198
+ digits, `.`, `_` and `-` (`invalid-component-name` otherwise).
199
+ - **A `@pathServer` is for a path item, not an operation**: `get` and `post`
200
+ of one route share it. The same server on several operations of one path is
201
+ written once; one URL with a different description or variables on another
202
+ is the error `path-server-conflict`, and the first is the one emitted.
203
+ - **Server variables** are `@server`'s third parameter: each property is a
204
+ variable, its default the variable's `default` (`""` without one), its
205
+ `@doc` the `description`, and a literal, enum or union of literals type its
206
+ `enum`. As with `@server`, a variable is a string (a string, a string enum
207
+ or a union of strings: `path-server-invalid-variable` otherwise), and each
208
+ `{name}` of the URL needs its property (`path-server-missing-variable`).
209
+
210
+ ```tsp
211
+ @route("/jmap")
212
+ @pathServer("{origin}{basePath}", "Mail", {
213
+ @doc("The mail origin") origin: string = "https://mail.example.com",
214
+ @doc("Where it is mounted") basePath: string = "/jmap",
215
+ })
216
+ @get op session(): string;
217
+ ```
218
+
219
+ ```yaml
220
+ /jmap:
221
+ servers:
222
+ - url: '{origin}{basePath}'
223
+ description: Mail
224
+ variables:
225
+ origin:
226
+ default: https://mail.example.com
227
+ description: The mail origin
228
+ basePath:
229
+ default: /jmap
230
+ description: Where it is mounted
231
+ ```
232
+ - **Several services** keep their own: each decorator marks the object the
233
+ emitter writes for its own operation or header, and the emitter reads and
234
+ takes off those marks per document, so the same path in two services, a
235
+ dotted service name or a nested service never mixes them.
236
+ - **Operations of a `@sharedRoute`** merge into one in the document; their
237
+ servers add up, and each one's shared responses are hoisted.
238
+ - **`@typespec/versioning` is not verified.** A shared response is marked on
239
+ the operation at emit time, and nothing here tests a versioned service,
240
+ whose operations the versioning library clones: shared responses on a
241
+ versioned service may stay inline, and a `@pathServer`'s servers may be missing.
242
+ - **Nothing changes without them.** A spec that uses none of the three emits
243
+ byte-identical output, and the hoisting is the same in 3.1 and 3.2.
244
+ - `@nxgt/openapi-codegen` generates a document with `$ref` responses and
245
+ headers as it does the inline copies.
246
+
101
247
  ## What follows downstream
102
248
 
103
249
  | Step | What it does with the `query` operation |
@@ -67,9 +67,12 @@ components:
67
67
  | `description` | The rule in words, and what the pattern leaves out. |
68
68
 
69
69
  A generator that knows nothing of `x-nxgt-scalar` still validates the pattern
70
- and the bounds. A later `@nxgt/openapi-codegen` release will map it to the exact
71
- Zod schema from `@nxgt/zod` ([roadmap](../roadmap.md)); until then, what a
72
- scalar leaves out is not checked.
70
+ and the bounds. `@nxgt/openapi-codegen` with
71
+ [`scalars: 'exact'`](https://github.com/softistx/nxgt-http/blob/develop/packages/openapi-codegen/docs/guide/options.md#scalars) maps it to the exact Zod
72
+ schema from `@nxgt/zod`, the checksum, the list or the `Intl` rule included;
73
+ with the default `'pattern'`, what a scalar leaves out is not checked. The
74
+ codecs (`dateTime`, `timestamp`, `bigInt`, `long`) keep their pattern under
75
+ `'exact'` too.
73
76
 
74
77
  ## Names
75
78
 
package/docs/roadmap.md CHANGED
@@ -10,9 +10,7 @@ Nothing in progress.
10
10
 
11
11
  ## Next
12
12
 
13
- - **Exact scalar rules in the generated code.** `@nxgt/openapi-codegen` maps
14
- `x-nxgt-scalar` to the exact Zod schema from `@nxgt/zod`: the checksums,
15
- the ISO and IANA lists and the `Intl` rules the patterns leave out.
13
+ Nothing planned beyond what is under Later.
16
14
 
17
15
  ## Later
18
16
 
@@ -27,6 +25,23 @@ Nothing in progress.
27
25
 
28
26
  ## Shipped
29
27
 
28
+ ### 0.14.0
29
+
30
+ - **Shared responses, shared headers and path servers.** `@sharedResponse`
31
+ and `@sharedHeader` emit a response and a header once under
32
+ `components.responses` and `components.headers`, a `$ref` at each use, and
33
+ `@pathServer` puts `servers` on one path item, from the spec alone with no
34
+ post-processing, with `@server`'s variables.
35
+
36
+ ### 0.13.2
37
+
38
+ - **Exact scalar rules in the generated code.** `@nxgt/openapi-codegen`
39
+ with `scalars: 'exact'` maps `x-nxgt-scalar` to the exact Zod schema from
40
+ `@nxgt/zod`: the checksums, the ISO and IANA lists, the `Intl` rules and the
41
+ refusal of `-0` the patterns leave out. The codecs keep their pattern until
42
+ `@nxgt/zod` offers their wire schemas. The guides and the troubleshooting
43
+ entries say so.
44
+
30
45
  ### 0.13.0
31
46
 
32
47
  - **The emitter `@nxgt/typespec`.** `emit: ["@nxgt/typespec"]` runs
@@ -361,6 +361,98 @@ options:
361
361
  openapi-versions: ['3.2.0']
362
362
  ```
363
363
 
364
+ ## `The server URL https://{tenant}.example.com of listUsers has {tenant}, which is not a property of its parameters. Add the variable, or take the braces out.`
365
+
366
+ **When:** `tsp compile`; the error `path-server-missing-variable` on a
367
+ `@pathServer` whose URL has a `{name}` its third parameter lacks.
368
+
369
+ **Fix:** add the variable, as `@server` asks:
370
+
371
+ ```tsp
372
+ @pathServer("https://{tenant}.example.com", "Tenant", { tenant: string = "main" })
373
+ ```
374
+
375
+ ## `The variable port of the @pathServer of listUsers is not a string, a string enum or a union of strings: an OpenAPI server variable is a string.`
376
+
377
+ **When:** `tsp compile`; the error `path-server-invalid-variable`, for a
378
+ variable typed `int32`, `boolean`… The default of a server variable is a string.
379
+
380
+ **Fix:** type it as a string: `port: string = "443"`, or `"443" | "8443"`.
381
+
382
+ ## `Trace is marked @sharedHeader outside a response header: it applies to a @header of a reply only, not to a request header or a body property.`
383
+
384
+ **When:** `tsp compile`; the error `shared-header-misplaced`, for a
385
+ `@sharedHeader` property that ends up in a request (a `@header` parameter) or
386
+ in a body schema.
387
+
388
+ **Why:** `components.headers` holds response headers; a request header is a
389
+ parameter and a body property is a schema.
390
+
391
+ **Fix:** take `@sharedHeader` off, or keep it on a `@header` of a reply model.
392
+
393
+ ## `Operations sharing the route GET /m reply 400 with different responses, one of them a @sharedResponse: @sharedRoute merges them into one reply, which cannot be a component. Give them the same shared response, or none.`
394
+
395
+ **When:** `tsp compile`; the error `shared-response-merged`, for operations
396
+ with `@sharedRoute` that answer one status with different responses (two shared
397
+ ones of different names, or a shared one and a plain one).
398
+
399
+ **Why:** the operations are one in the document, with one reply per status,
400
+ which cannot be a component and something else.
401
+
402
+ **Fix:** answer that status with the same `@sharedResponse` in each, or with
403
+ none:
404
+
405
+ ```tsp
406
+ @route("/m") @sharedRoute @get op one(): string | Gone;
407
+ @route("/m") @sharedRoute @get op two(): string | Gone;
408
+ ```
409
+
410
+ ## `Two shared responses named Failure differ: a component is emitted once. Give each its own name, or make them the same.`
411
+
412
+ **When:** `tsp compile`; the error `shared-component-conflict`, for two
413
+ `@sharedResponse` models (or two `@sharedHeader` properties, as `headers`) that
414
+ take one name and do not emit the same response or header.
415
+
416
+ **Why:** a component has one definition, and the emitter will not pick one.
417
+ The first is the component; the other stays inline in the document.
418
+
419
+ **Fix:** give each its own name, or make them one model:
420
+
421
+ ```tsp
422
+ @sharedResponse("BadInput") @error model A { @statusCode code: 400; a: string; }
423
+ @sharedResponse("Refused") @error model B { @statusCode code: 400; b: string; }
424
+ ```
425
+
426
+ ## `Two @pathServer of /users give the server https://a.example.com a description or variables that differ. Give the path one definition of it.`
427
+
428
+ **When:** `tsp compile`; the error `path-server-conflict`. The first definition is emitted.
429
+
430
+ **Why:** operations of one path share its `servers`; the same URL cannot be
431
+ described two ways. The first definition is the one emitted.
432
+
433
+ **Fix:** give every `@pathServer` of that URL on the path the same
434
+ description and variables, or put it on one operation.
435
+
436
+ ## `"not ok" is not a name OpenAPI allows for a component: use letters, digits, '.', '_' and '-'.`
437
+
438
+ **When:** `tsp compile`; the error `invalid-component-name`, on a name given to
439
+ `@sharedResponse` or `@sharedHeader` (or a model or property name used as one).
440
+
441
+ **Fix:** pass a name of letters, digits, `.`, `_` and `-`:
442
+ `@sharedResponse("NotOk")`.
443
+
444
+ ## `Unauthorized is marked @sharedResponse: emit with @nxgt/typespec, which writes it; @typespec/openapi3 would write it inline or drop it.`
445
+
446
+ **When:** `tsp compile` with `@typespec/openapi3` in `emit`; the error
447
+ `shared-needs-nxgt-emitter` for each `@sharedResponse`, `@sharedHeader` and
448
+ `@pathServer`.
449
+
450
+ **Why:** only the emitter `@nxgt/typespec` reads them: `@typespec/openapi3`
451
+ would write the copies, leak `x-nxgt-component` on a header, or leave out the
452
+ path's `servers`.
453
+
454
+ **Fix:** emit with `@nxgt/typespec` ([The emitter](guide/emitter.md)).
455
+
364
456
  ## `The emitter @nxgt/typespec runs @typespec/openapi3, which is not installed: add it beside @nxgt/typespec.`
365
457
 
366
458
  **When:** `tsp compile` with `@nxgt/typespec` in `emit`; the error
@@ -628,12 +720,17 @@ one.
628
720
 
629
721
  **Why:** the scalar's pattern checks the shape. A list (`countryCode`,
630
722
  `currency`, `timeZone`, `locale`) or a checksum (`iban`, ISBN) does not fit in
631
- a pattern. `x-nxgt-scalar` names the exact rule, but `@nxgt/openapi-codegen`
632
- does not map it to the `@nxgt/zod` schema yet ([roadmap](roadmap.md)).
633
-
634
- **Fix:** until it does, check the value in the handler against the exact
635
- rule, the list or the checksum, from `@nxgt/zod` or your own table, and answer
636
- `400` as the generated validator does:
723
+ a pattern. `x-nxgt-scalar` names the exact rule, and `@nxgt/openapi-codegen`
724
+ maps it to the `@nxgt/zod` schema only with `scalars: 'exact'`; the default is
725
+ `'pattern'`.
726
+
727
+ **Fix:** generate with
728
+ [`scalars: 'exact'`](https://github.com/softistx/nxgt-http/blob/develop/packages/openapi-codegen/docs/guide/options.md#scalars)
729
+ (`bun add @nxgt/zod`, then `generate({ …, scalars: 'exact' })`): `iban` is
730
+ checked by `ibanSchema`, `countryCode` by its list of assigned codes. If you
731
+ cannot, check the value in the handler against the exact rule, the list or the
732
+ checksum, from `@nxgt/zod` or your own table, and answer `400` as the generated
733
+ validator does:
637
734
 
638
735
  ```ts
639
736
  if (!knownCountries.has(body.country)) {
@@ -651,7 +748,8 @@ where `@nxgt/graphql-scalars` refuses it.
651
748
  tell from `0`. For `nonNegativeInt`, `nonPositiveInt`, `port`, the bound holds,
652
749
  so the value passes (`positiveInt` and `negativeInt` refuse it by their bound).
653
750
 
654
- **Fix:** normalise in the handler when the sign matters:
751
+ **Fix:** generate with [`scalars: 'exact'`](https://github.com/softistx/nxgt-http/blob/develop/packages/openapi-codegen/docs/guide/options.md#scalars), whose schemas refuse `-0` for
752
+ an integer scalar. Otherwise normalise in the handler when the sign matters:
655
753
 
656
754
  ```ts
657
755
  const quantity = Object.is(body.quantity, -0) ? 0 : body.quantity;
@@ -0,0 +1,65 @@
1
+ import "../dist/index.js";
2
+
3
+ using TypeSpec.Reflection;
4
+
5
+ namespace Nxgt;
6
+
7
+ /**
8
+ * Emits a response model once, under `components.responses`, with a `$ref`
9
+ * at each operation that returns it, instead of a full copy at each. The
10
+ * name is the model's unless one is given. Two shared responses of one name
11
+ * that differ are the error `shared-component-conflict`. Only
12
+ * `@nxgt/typespec` writes it: `@typespec/openapi3` would write the copies.
13
+ *
14
+ * @example
15
+ * ```typespec
16
+ * @sharedResponse
17
+ * @error model Unauthorized { @statusCode code: 401; message: string; }
18
+ * ```
19
+ */
20
+ extern dec sharedResponse(target: Model, name?: valueof string);
21
+
22
+ /**
23
+ * Emits a response header once, under `components.headers`, with a `$ref`
24
+ * at each response that carries it. The name is the property's unless one is
25
+ * given. For a response header only; a request header is a parameter.
26
+ *
27
+ * @example
28
+ * ```typespec
29
+ * model NoStore {
30
+ * @sharedHeader("NoStore") @header("cache-control") cacheControl: "no-store";
31
+ * }
32
+ * ```
33
+ */
34
+ extern dec sharedHeader(target: ModelProperty, name?: valueof string);
35
+
36
+ /**
37
+ * Puts `servers` on the path item of the operation: the other paths keep the
38
+ * document's. Repeated on the operations of one path, the servers add up; the
39
+ * same server twice is one, and one URL with two different definitions is the
40
+ * error `path-server-conflict`. The third parameter is `@server`'s: each
41
+ * property is a variable of the URL, its default the variable's `default`
42
+ * and its doc comment the `description`.
43
+ *
44
+ * @param url The server's URL, with `{variable}` placeholders if any.
45
+ * @param description What the server is.
46
+ * @param parameters The URL's variables.
47
+ *
48
+ * @example
49
+ * ```typespec
50
+ * @route("/files")
51
+ * @pathServer("{origin}{basePath}", "Files", {
52
+ * @doc("The server's origin")
53
+ * origin: string = "https://files.example.com",
54
+ * @doc("Where the API is mounted")
55
+ * basePath: string = "/files",
56
+ * })
57
+ * @get op listFiles(): string[];
58
+ * ```
59
+ */
60
+ extern dec pathServer(
61
+ target: Operation,
62
+ url: valueof string,
63
+ description?: valueof string,
64
+ parameters?: Record<unknown>
65
+ );
package/lib/main.tsp CHANGED
@@ -11,3 +11,4 @@ import "./headers.tsp";
11
11
  import "./sorting.tsp";
12
12
  import "./requests.tsp";
13
13
  import "./query-method.tsp";
14
+ import "./components.tsp";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/typespec",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -61,8 +61,9 @@
61
61
  }
62
62
  },
63
63
  "devDependencies": {
64
- "@nxgt/openapi-codegen": "^0.9.2",
64
+ "@nxgt/openapi-codegen": "^0.11.0",
65
65
  "@nxgt/openapi-hono": "^0.3.3",
66
+ "@nxgt/zod": "^0.1.3",
66
67
  "@types/bun": "^1.4.0",
67
68
  "@typespec/compiler": "1.17.0",
68
69
  "@typespec/http": "1.17.0",