@nxgt/typespec 0.13.2 → 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 |
package/docs/roadmap.md CHANGED
@@ -25,6 +25,14 @@ Nothing planned beyond what is under Later.
25
25
 
26
26
  ## Shipped
27
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
+
28
36
  ### 0.13.2
29
37
 
30
38
  - **Exact scalar rules in the generated code.** `@nxgt/openapi-codegen`
@@ -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
@@ -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.2",
3
+ "version": "0.14.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -61,7 +61,7 @@
61
61
  }
62
62
  },
63
63
  "devDependencies": {
64
- "@nxgt/openapi-codegen": "^0.10.0",
64
+ "@nxgt/openapi-codegen": "^0.11.0",
65
65
  "@nxgt/openapi-hono": "^0.3.3",
66
66
  "@nxgt/zod": "^0.1.3",
67
67
  "@types/bun": "^1.4.0",