@nxgt/typespec 0.10.0 → 0.11.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
@@ -125,7 +125,7 @@ namespace Blog;
125
125
  @route("/posts")
126
126
  interface Posts {
127
127
  @get getPost(@path postId: string): Post | NotFound | Unauthorized;
128
- @post createPost(@body post: Create<Post>): {
128
+ @post createPost(@body post: CreateRequest<Post>): {
129
129
  @statusCode _: 201;
130
130
  @body post: Post;
131
131
  } | BadRequest | Conflict | ErrorResponse<503>;
@@ -175,8 +175,9 @@ an alias leaves out:
175
175
  interface Users {
176
176
  @get list(...PageParameters): Page<User> | ListErrors;
177
177
  @get get(@path id: uuid): User | GetErrors;
178
- @post create(@body user: User): User | CreateErrors | AuthErrors | TooManyRequests;
179
- @patch update(@path id: uuid, @body user: User): User | UpdateErrors;
178
+ @post create(@body user: CreateRequest<User>): User | CreateErrors | AuthErrors | TooManyRequests;
179
+ @put update(@path id: uuid, @body user: UpdateRequest<User>): User | UpdateErrors;
180
+ @patch patch(@path id: uuid, @body user: PatchRequest<User>): User | UpdateErrors;
180
181
  @delete delete(@path id: uuid): NoContentResponse | DeleteErrors;
181
182
  }
182
183
  ```
@@ -184,6 +185,26 @@ interface Users {
184
185
  An alias is exactly its union, so the OpenAPI it emits is the same as
185
186
  writing the responses out.
186
187
 
188
+ ### Request bodies
189
+
190
+ `CreateRequest<T>`, `UpdateRequest<T>` and `PatchRequest<T>` are TypeSpec's own
191
+ `Create`, `Update` and `MergePatchUpdate`, named so the generated types and
192
+ schemas read `CreatePostRequest`, `UpdatePostRequest` and `PatchPostRequest`:
193
+
194
+ ```tsp
195
+ @post createPost(@body post: CreateRequest<Post>): {
196
+ @statusCode _: 201;
197
+ @body post: Post;
198
+ } | BadRequest;
199
+ @put updatePost(@path postId: uuid, @body post: UpdateRequest<Post>): Post | BadRequest | NotFound;
200
+ @patch patchPost(@path postId: uuid, @body post: PatchRequest<Post>): Post | BadRequest | NotFound;
201
+ ```
202
+
203
+ A `PUT` body keeps each property as optional as the model declares it; a
204
+ `PATCH` body is a JSON Merge Patch, `application/merge-patch+json`, with
205
+ every property optional. `Create<Post, "New{name}">` names a body your own
206
+ way. See [Request bodies](docs/guide/requests.md).
207
+
187
208
  ### Pagination
188
209
 
189
210
  Spread the query of a page into the operation's parameters, and answer the
@@ -293,9 +314,9 @@ model Post {
293
314
  | --- | --- | --- | --- |
294
315
  | `uuid` | the schema `UUID`, `format: uuid`, a pattern for versions 1–8 | `UUID`, `z.guid().regex(…)` | `id()` |
295
316
  | `email` | `emailAddress`, the schema `EmailAddress`, `format: email` | `EmailAddress`, `z.string().regex(…)` | |
296
- | `...Timestamps` | `createdAt`, `updatedAt`, read-only | left out of `Create<T>` and updates | `timestamps()` |
317
+ | `...Timestamps` | `createdAt`, `updatedAt`, read-only | left out of `CreateRequest<T>` and updates | `timestamps()` |
297
318
  | `...SoftDelete` | `deletedAt: utcDateTime \| null`, read-only | | `softDelete()` |
298
- | `...Versioned` | `version: integer`, read and update | kept in `MergePatchUpdate<T>` | `version()` |
319
+ | `...Versioned` | `version: integer`, read and update | kept in `UpdateRequest<T>` and `PatchRequest<T>` | `version()` |
299
320
  | `...Actors<Id = uuid>` | `createdBy`, `updatedBy`, `deletedBy`, `Id \| null`, read-only | | `actors()` |
300
321
 
301
322
  Every scalar of [`@nxgt/graphql-scalars`](https://www.npmjs.com/package/@nxgt/graphql-scalars)
@@ -326,7 +347,7 @@ Spread the headers of an idempotent write and of a rate limit into an
326
347
  operation and its replies:
327
348
 
328
349
  ```tsp
329
- @post createPost(...IdempotencyKeyHeader, @body post: Create<Post>): {
350
+ @post createPost(...IdempotencyKeyHeader, @body post: CreateRequest<Post>): {
330
351
  @statusCode _: 201;
331
352
  ...IdempotentReplayedHeader;
332
353
  ...RateLimitHeaders;
@@ -507,6 +528,9 @@ In the ruleset `@nxgt/typespec/recommended`; each is a warning, and named
507
528
  | `Page<Item>` | `{ items: Item[], total, page, pageSize, pageCount }`, the schema `<Item>Page` |
508
529
  | `CursorPageParameters` | the query `after` and `limit` |
509
530
  | `CursorPage<Item>` | `{ items: Item[], nextCursor: string \| null }`, the schema `<Item>CursorPage` |
531
+ | `CreateRequest<T>` | `Create<T, "Create{name}Request">`: the body of a create, `CreatePostRequest` |
532
+ | `UpdateRequest<T>` | `Update<T, "Update{name}Request">`: the body of a `PUT`, `UpdatePostRequest` |
533
+ | `PatchRequest<T>` | `MergePatchUpdate<T, "Patch{name}Request">`: a merge patch, all optional, `PatchPostRequest` |
510
534
  | `ValidationIssue` | `{ target: ValidationTarget, path: (string \| integer)[], code: string, message: string }` |
511
535
  | `ValidationTarget` | `"param" \| "query" \| "header" \| "json" \| "form" \| "body" \| "response"` |
512
536
 
@@ -653,6 +677,8 @@ envelope and the rate limit's headers.
653
677
  `Retry-After`;
654
678
  - [Sorting](docs/guide/sorting.md): `orderBy` and `direction` beside a
655
679
  list's filters, and the `@nxgt/drizzle` call they map to;
680
+ - [Request bodies](docs/guide/requests.md): `CreateRequest`, `UpdateRequest`
681
+ and `PatchRequest`, and naming your own;
656
682
  - [Operation ids](docs/guide/operation-ids.md): `@operationIds`, each
657
683
  operation named as written or a verb with its resource, the method each
658
684
  verb is sent with, the options, and the ids it refuses;
package/docs/README.md CHANGED
@@ -5,6 +5,7 @@ The [package README](../README.md) is the short version.
5
5
  | Page | Read it when |
6
6
  | --- | --- |
7
7
  | [Getting started](guide/getting-started.md) | starting a new API project from the `tsp init` template, up to a Hono route |
8
+ | [Request bodies](guide/requests.md) | naming the body of a create, a `PUT` and a `PATCH`: `CreatePostRequest`, `UpdatePostRequest`, `PatchPostRequest` |
8
9
  | [Error replies](guide/errors.md) | declaring what an operation answers when it fails |
9
10
  | [Pagination](guide/pagination.md) | listing a collection a page at a time |
10
11
  | [Authentication](guide/auth.md) | protecting an operation with a `@nxgt/janus` session |
@@ -91,7 +91,7 @@ operation can declare the guard's 401 and the handler's 403:
91
91
 
92
92
  ```tsp
93
93
  @useAuth(JanusAuth)
94
- @post createPost(@body post: Create<Post>): {
94
+ @post createPost(@body post: CreateRequest<Post>): {
95
95
  @statusCode _: 201;
96
96
  @body post: Post;
97
97
  } | BadRequest | AuthenticationRequired | Forbidden;
@@ -80,9 +80,9 @@ model Post {
80
80
  | `Versioned` | `version: integer`, at least 0 | read, update | `version()` |
81
81
  | `Actors<Id = uuid>` | `createdBy`, `updatedBy`, `deletedBy: Id \| null` | read | `actors()` |
82
82
 
83
- The server sets every read-only column, so `Create<Post>` and
84
- `MergePatchUpdate<Post>` leave them out, and the generated `CreatePost` and
85
- `PostMergePatchUpdate` have no `createdAt` a client could send.
83
+ The server sets every read-only column, so `CreateRequest<Post>` and
84
+ `PatchRequest<Post>` leave them out, and the generated `CreatePostRequest` and
85
+ `PatchPostRequest` have no `createdAt` a client could send.
86
86
  `Actors<string>` or `Actors<integer>` match `actors('text')` or
87
87
  `actors('integer')`.
88
88
 
@@ -94,7 +94,7 @@ that version, and throws `OptimisticLockError` otherwise. Declare the
94
94
  `Conflict` it becomes:
95
95
 
96
96
  ```tsp
97
- @patch updatePost(@path postId: uuid, @body post: MergePatchUpdate<Post>):
97
+ @patch patchPost(@path postId: uuid, @body post: PatchRequest<Post>):
98
98
  Post | BadRequest | NotFound | Conflict;
99
99
  ```
100
100
 
@@ -143,7 +143,7 @@ A handler may send a 400 of its own, without `issues`. `BadRequestBody`
143
143
  describes both, with `issues` optional:
144
144
 
145
145
  ```tsp
146
- @patch updatePost(@path postId: string, @body post: MergePatchUpdate<Post>): Post | BadRequest;
146
+ @patch patchPost(@path postId: string, @body post: PatchRequest<Post>): Post | BadRequest;
147
147
  ```
148
148
 
149
149
  The generator declares the validators' 400 itself, as `ValidationErrorBody`,
@@ -99,13 +99,16 @@ interface Users {
99
99
  get(@path id: uuid): User | GetErrors;
100
100
 
101
101
  @post
102
- create(@body user: Create<User>): {
102
+ create(@body user: CreateRequest<User>): {
103
103
  @statusCode _: 201;
104
104
  @body user: User;
105
105
  } | CreateErrors;
106
106
 
107
+ @put
108
+ update(@path id: uuid, @body user: UpdateRequest<User>): User | UpdateErrors;
109
+
107
110
  @patch
108
- update(@path id: uuid, @body user: MergePatchUpdate<User>): User | UpdateErrors;
111
+ patch(@path id: uuid, @body user: PatchRequest<User>): User | UpdateErrors;
109
112
 
110
113
  @delete
111
114
  delete(@path id: uuid): NoContentResponse | DeleteErrors;
@@ -113,7 +116,10 @@ interface Users {
113
116
  ```
114
117
 
115
118
  `@operationIds` names the operations `listUsers`, `getUser`, `createUser`,
116
- `updateUser` and `deleteUser` ([Operation ids](operation-ids.md)).
119
+ `updateUser`, `patchUser` and `deleteUser` ([Operation ids](operation-ids.md)).
120
+ `update` replaces the whole user with a `PUT` (`UpdateRequest`), `patch`
121
+ changes some of it with a merge patch (`PatchRequest`); see
122
+ [Request bodies](requests.md).
117
123
 
118
124
  ```ts
119
125
  // openapi-codegen.config.ts
@@ -250,13 +256,16 @@ interface Pets {
250
256
  get(@path id: uuid): Pet | GetErrors;
251
257
 
252
258
  @post
253
- create(@body pet: Create<Pet>): {
259
+ create(@body pet: CreateRequest<Pet>): {
254
260
  @statusCode _: 201;
255
261
  @body pet: Pet;
256
262
  } | CreateErrors;
257
263
 
264
+ @put
265
+ update(@path id: uuid, @body pet: UpdateRequest<Pet>): Pet | UpdateErrors;
266
+
258
267
  @patch
259
- update(@path id: uuid, @body pet: MergePatchUpdate<Pet>): Pet | UpdateErrors;
268
+ patch(@path id: uuid, @body pet: PatchRequest<Pet>): Pet | UpdateErrors;
260
269
 
261
270
  @delete
262
271
  delete(@path id: uuid): NoContentResponse | DeleteErrors;
@@ -264,8 +273,8 @@ interface Pets {
264
273
  ```
265
274
 
266
275
  The operation names stay; `@operationIds` takes the resource from the
267
- interface, so the ids become `listPets`, `getPet`, `createPet`, `updatePet`
268
- and `deletePet`. Run `tsp compile .` and `nxgt-openapi generate` again, and
276
+ interface, so the ids become `listPets`, `getPet`, `createPet`, `updatePet`,
277
+ `patchPet` and `deletePet`. Run `tsp compile .` and `nxgt-openapi generate` again, and
269
278
  the generated types, `Pet` and `PetPage`, follow. A name the singular rule
270
279
  does not know takes `@operationIds(#{ singular: "…" })` on the interface
271
280
  ([Operation ids](operation-ids.md)).
@@ -19,7 +19,7 @@ namespace Blog;
19
19
 
20
20
  @route("/posts")
21
21
  interface Posts {
22
- @post createPost(...IdempotencyKeyHeader, @body post: Create<Post>): {
22
+ @post createPost(...IdempotencyKeyHeader, @body post: CreateRequest<Post>): {
23
23
  @statusCode _: 201;
24
24
  ...IdempotentReplayedHeader;
25
25
  @body post: Post;
@@ -48,7 +48,7 @@ lock, gets one 409 with the description of the first, and the library warns,
48
48
  ## A rate limit
49
49
 
50
50
  ```tsp
51
- @post createPost(@body post: Create<Post>): {
51
+ @post createPost(@body post: CreateRequest<Post>): {
52
52
  @statusCode _: 201;
53
53
  ...RateLimitHeaders;
54
54
  @body post: Post;
@@ -147,7 +147,7 @@ Give the operation its verb's method, or a name that is not a verb, which
147
147
  is then its id as written:
148
148
 
149
149
  ```tsp
150
- @patch @route("/{id}") update(@path id: uuid, @body user: User): User; // updateUser
150
+ @put @route("/{id}") update(@path id: uuid, @body user: User): User; // updateUser
151
151
  @post @route("/lookup") lookUpUser(@body id: uuid): User; // lookUpUser
152
152
  ```
153
153
 
@@ -0,0 +1,145 @@
1
+ # Request bodies
2
+
3
+ `CreateRequest<T>`, `UpdateRequest<T>` and `PatchRequest<T>` are TypeSpec's own
4
+ `Create`, `Update` and `MergePatchUpdate` with a name template, so the body of
5
+ each write is a component with a name you can read: `CreatePostRequest`.
6
+
7
+ ## Why names
8
+
9
+ TypeSpec's templates name what they emit from the model: `Create<Post>` is
10
+ the component `CreatePost` and `MergePatchUpdate<Post>` is
11
+ `PostMergePatchUpdate`. Two conventions in one spec, and a type in the
12
+ generated client that reads unlike the rest. The aliases give each body the
13
+ name `<Verb><Model>Request`, and `@nxgt/openapi-codegen` generates the types
14
+ and the Zod schemas under it.
15
+
16
+ ## The smallest example
17
+
18
+ ```tsp
19
+ import "@typespec/http";
20
+ import "@nxgt/typespec";
21
+
22
+ using Http;
23
+ using Nxgt;
24
+
25
+ namespace Blog;
26
+
27
+ model Post {
28
+ @visibility(Lifecycle.Read) id: uuid;
29
+ @minLength(1) title: string;
30
+ body: string;
31
+ tags?: string[];
32
+ ...Timestamps;
33
+ ...Versioned;
34
+ }
35
+
36
+ @route("/posts")
37
+ interface Posts {
38
+ @post createPost(@body post: CreateRequest<Post>): {
39
+ @statusCode _: 201;
40
+ @body post: Post;
41
+ } | BadRequest;
42
+
43
+ @put updatePost(@path postId: uuid, @body post: UpdateRequest<Post>):
44
+ Post | BadRequest | NotFound | Conflict;
45
+
46
+ @patch patchPost(@path postId: uuid, @body post: PatchRequest<Post>):
47
+ Post | BadRequest | NotFound | Conflict;
48
+ }
49
+ ```
50
+
51
+ | Alias | Is | Component | Content type | Properties |
52
+ | --- | --- | --- | --- | --- |
53
+ | `CreateRequest<T>` | `Create<T, "Create{name}Request">` | `CreatePostRequest` | `application/json` | those visible on create, as `T` declares them |
54
+ | `UpdateRequest<T>` | `Update<T, "Update{name}Request">` | `UpdatePostRequest` | `application/json` | those visible on update, as `T` declares them |
55
+ | `PatchRequest<T>` | `MergePatchUpdate<T, "Patch{name}Request">` | `PatchPostRequest` | `application/merge-patch+json` | those visible on update, all optional |
56
+
57
+ ```ts
58
+ import type {
59
+ CreatePostRequest,
60
+ PatchPostRequest,
61
+ UpdatePostRequest,
62
+ } from './generated/types';
63
+ ```
64
+
65
+ ```tsp
66
+ alias CreateRequest<T extends Reflection.Model> = Create<T, "Create{name}Request">;
67
+ alias UpdateRequest<T extends Reflection.Model> = Update<T, "Update{name}Request">;
68
+ alias PatchRequest<T extends Reflection.Model> = MergePatchUpdate<T, "Patch{name}Request">;
69
+ ```
70
+
71
+ Read-only properties (`id`, `Timestamps`, `Actors`) are in none of the three.
72
+ `version` from `Versioned` is read and update, so it is in
73
+ `UpdatePostRequest` and `PatchPostRequest`, and not in `CreatePostRequest`.
74
+
75
+ ## `PUT` replaces, `PATCH` merges
76
+
77
+ `UpdateRequest<Post>` is the whole resource: each property is as optional as
78
+ `Post` declares it, so `title` is required and `tags?` is not. Send it with
79
+ `@put`.
80
+
81
+ `PatchRequest<Post>` is a JSON Merge Patch: every property is optional, and a
82
+ nullable one is sent as `null` to clear it. The body's content type is
83
+ `application/merge-patch+json`, where the others are `application/json`.
84
+
85
+ ```yaml
86
+ # openapi.yaml (excerpt)
87
+ PUT:
88
+ requestBody:
89
+ content:
90
+ application/json:
91
+ schema:
92
+ $ref: '#/components/schemas/UpdatePostRequest'
93
+ PATCH:
94
+ requestBody:
95
+ content:
96
+ application/merge-patch+json:
97
+ schema:
98
+ $ref: '#/components/schemas/PatchPostRequest'
99
+ ```
100
+
101
+ A client sending a patch sets the header `Content-Type:
102
+ application/merge-patch+json`. The component `UpdatePostRequest` requires
103
+ `title`; `PatchPostRequest` requires nothing.
104
+
105
+ ## Nested models
106
+
107
+ A nested model the template filters is named by the same template; one it
108
+ leaves whole keeps its name.
109
+
110
+ ```tsp
111
+ model Address {
112
+ @visibility(Lifecycle.Read) id: uuid;
113
+ street: string;
114
+ }
115
+
116
+ model Author {
117
+ @visibility(Lifecycle.Read) id: uuid;
118
+ name: string;
119
+ address: Address;
120
+ }
121
+
122
+ @post createAuthor(@body author: CreateRequest<Author>): Author;
123
+ // CreateAuthorRequest.address is CreateAddressRequest, without `id`
124
+ ```
125
+
126
+ A model with no property the filter changes, such as a `Tag` with only
127
+ `name`, is referenced under its own name, `Tag`.
128
+
129
+ ## Your own name template
130
+
131
+ The aliases fix the convention. Another one is the same template, inline, and
132
+ `Create<T>` and `MergePatchUpdate<T>` stay usable:
133
+
134
+ ```tsp
135
+ @post createPost(@body post: Create<Post, "New{name}">): Post; // NewPost
136
+ @patch patchPost(@path postId: uuid, @body post: MergePatchUpdate<Post, "{name}Changes">): Post;
137
+ ```
138
+
139
+ `{name}` is the model's name. Without a template, TypeSpec names the
140
+ components `CreatePost` and `PostMergePatchUpdate`.
141
+
142
+ ## Next
143
+
144
+ The columns a request leaves out are on [Scalars and columns](columns.md); the
145
+ errors a write answers on [Error replies](errors.md).
package/docs/roadmap.md CHANGED
@@ -26,6 +26,14 @@ Nothing in progress.
26
26
 
27
27
  ## Shipped
28
28
 
29
+ ### 0.11.0
30
+
31
+ - **Request body aliases.** `CreateRequest<T>`, `UpdateRequest<T>` and
32
+ `PatchRequest<T>` are TypeSpec's `Create`, `Update` and `MergePatchUpdate`
33
+ named `Create<T>Request`, `Update<T>Request` and `Patch<T>Request`, so the
34
+ generated types and schemas share one convention. `Create<T>` and
35
+ `MergePatchUpdate<T>` stay usable.
36
+
29
37
  ### 0.10.0
30
38
 
31
39
  - **Every scalar of `@nxgt/graphql-scalars`.** The scalars, under the same
@@ -13,7 +13,7 @@ one name with `name_collision`.
13
13
  optional, and the generator adds its own 400 beside it.
14
14
 
15
15
  ```tsp
16
- @patch updatePost(@path postId: string, @body post: MergePatchUpdate<Post>): Post | BadRequest;
16
+ @patch patchPost(@path postId: string, @body post: PatchRequest<Post>): Post | BadRequest;
17
17
  ```
18
18
 
19
19
  ## `Couldn't resolve import "@nxgt/typespec"`
package/lib/main.tsp CHANGED
@@ -9,3 +9,4 @@ import "./scalars.tsp";
9
9
  import "./columns.tsp";
10
10
  import "./headers.tsp";
11
11
  import "./sorting.tsp";
12
+ import "./requests.tsp";
@@ -0,0 +1,36 @@
1
+ import "@typespec/http";
2
+
3
+ using Http;
4
+
5
+ namespace Nxgt;
6
+
7
+ /**
8
+ * The body of a create: the properties of `T` visible on create, named
9
+ * `Create<T>Request` (`CreatePostRequest`). A nested model filtered the same
10
+ * way is named alike (`CreateAddressRequest`); one left whole keeps its name.
11
+ */
12
+ alias CreateRequest<T extends Reflection.Model> = Create<
13
+ T,
14
+ "Create{name}Request"
15
+ >;
16
+
17
+ /**
18
+ * The body of a full update, as a `PUT` replaces the resource: the properties
19
+ * of `T` visible on update, each as optional as `T` declares it, named
20
+ * `Update<T>Request` (`UpdatePostRequest`).
21
+ */
22
+ alias UpdateRequest<T extends Reflection.Model> = Update<
23
+ T,
24
+ "Update{name}Request"
25
+ >;
26
+
27
+ /**
28
+ * The body of a partial update, as a JSON Merge Patch
29
+ * (`application/merge-patch+json`): the properties of `T` visible on update,
30
+ * all optional, a nullable one sent as `null` to clear it, named
31
+ * `Patch<T>Request` (`PatchPostRequest`).
32
+ */
33
+ alias PatchRequest<T extends Reflection.Model> = MergePatchUpdate<
34
+ T,
35
+ "Patch{name}Request"
36
+ >;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/typespec",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -50,16 +50,23 @@ interface Users {
50
50
 
51
51
  /** Creates a user. */
52
52
  @post
53
- create(@body user: Create<User>): {
53
+ create(@body user: CreateRequest<User>): {
54
54
  @statusCode _: 201;
55
55
  @body user: User;
56
56
  } | CreateErrors;
57
57
 
58
- /** Updates a user with a merge patch. */
59
- @patch
58
+ /** Replaces a user: the whole body, as a `PUT` sends it. */
59
+ @put
60
60
  update(
61
61
  @path id: uuid,
62
- @body user: MergePatchUpdate<User>,
62
+ @body user: UpdateRequest<User>,
63
+ ): User | UpdateErrors;
64
+
65
+ /** Updates some of a user's properties, with a merge patch. */
66
+ @patch
67
+ patch(
68
+ @path id: uuid,
69
+ @body user: PatchRequest<User>,
63
70
  ): User | UpdateErrors;
64
71
 
65
72
  /** Deletes a user. */