@nxgt/typespec 0.7.0 → 0.9.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.
@@ -0,0 +1,277 @@
1
+ # Getting started
2
+
3
+ This page starts a new API project from the package's `tsp init` template,
4
+ then takes it from the spec to a running Hono route.
5
+
6
+ ## Start a project
7
+
8
+ You need Node.js for `tsp`, and [Bun](https://bun.sh) on the PATH for the
9
+ generator, `nxgt-openapi`, which runs on Bun
10
+ ([Compile and generate](#compile-and-generate)).
11
+
12
+ Run `tsp init` with the template, in an empty directory:
13
+
14
+ ```sh
15
+ mkdir petstore && cd petstore
16
+ npx --package=@typespec/compiler tsp init https://unpkg.com/@nxgt/typespec/templates/scaffolding.json
17
+ ```
18
+
19
+ `tsp init` asks for the template, the project's name and the emitters. To
20
+ answer them on the command line instead:
21
+
22
+ ```sh
23
+ npx --package=@typespec/compiler tsp init https://unpkg.com/@nxgt/typespec/templates/scaffolding.json \
24
+ --template nxgt -y --project-name petstore
25
+ ```
26
+
27
+ `--package=@typespec/compiler` matters: in a directory without the compiler,
28
+ a bare `npx tsp` runs `tsp`, an unrelated npm package. `bunx
29
+ --package=@typespec/compiler tsp init …` works too.
30
+
31
+ `tsp init` writes into the current directory, then runs `npm install`, so it
32
+ needs the network. The template ships from 0.9.0 on; an older version, or no
33
+ network, fails before anything is written
34
+ ([troubleshooting](../troubleshooting.md#init-template-invalid-json-unable-to-parse-scaffoldingjson-unexpected-token--is-not-valid-json)).
35
+
36
+ ## What it writes
37
+
38
+ | File | What it holds |
39
+ | --- | --- |
40
+ | `package.json` | the compiler, `@typespec/http`, `@typespec/openapi`, `@typespec/openapi3`, `@nxgt/typespec` and `@nxgt/openapi-codegen`, at their latest versions |
41
+ | `tspconfig.yaml` | the linter's recommended rules, and OpenAPI 3.1 written to `openapi/openapi.yaml` |
42
+ | `main.tsp` | the service, marked with `@operationIds`, and a `Users` resource |
43
+ | `openapi-codegen.config.ts` | the generator's input and output, with the Hono routes on |
44
+ | `README.md` | the commands below, and the scripts to add |
45
+ | `.gitignore` | `node_modules/`, and TypeSpec's default output |
46
+
47
+ `tspconfig.yaml` turns the linter on and emits OpenAPI 3.1, which
48
+ `@nxgt/openapi-codegen` reads (it refuses 3.0, the emitter's default):
49
+
50
+ ```yaml
51
+ # tspconfig.yaml
52
+ linter:
53
+ extends:
54
+ - "@nxgt/typespec/recommended"
55
+ emit:
56
+ - "@typespec/openapi3"
57
+ options:
58
+ "@typespec/openapi3":
59
+ emitter-output-dir: "{project-root}/openapi"
60
+ openapi-versions:
61
+ - 3.1.0
62
+ output-file: openapi.yaml
63
+ ```
64
+
65
+ `main.tsp` declares the service in the namespace `Api`, titled after the
66
+ project, and one resource with each convention in place: a paged, filtered and sorted
67
+ list, and the error aliases of each verb.
68
+
69
+ ```tsp
70
+ // main.tsp (excerpt)
71
+ @service(#{ title: "petstore" })
72
+ @operationIds
73
+ namespace Api;
74
+
75
+ model User {
76
+ @visibility(Lifecycle.Read)
77
+ id: uuid;
78
+
79
+ @minLength(1)
80
+ @maxLength(80)
81
+ name: string;
82
+
83
+ email: email;
84
+
85
+ ...Timestamps;
86
+ }
87
+
88
+ @route("/users")
89
+ @tag("Users")
90
+ interface Users {
91
+ @get
92
+ list(
93
+ ...UserFilters,
94
+ ...PageParameters,
95
+ ...SortParameters<"name" | "createdAt">,
96
+ ): Page<User> | ListErrors;
97
+
98
+ @get
99
+ get(@path id: uuid): User | GetErrors;
100
+
101
+ @post
102
+ create(@body user: Create<User>): {
103
+ @statusCode _: 201;
104
+ @body user: User;
105
+ } | CreateErrors;
106
+
107
+ @patch
108
+ update(@path id: uuid, @body user: MergePatchUpdate<User>): User | UpdateErrors;
109
+
110
+ @delete
111
+ delete(@path id: uuid): NoContentResponse | DeleteErrors;
112
+ }
113
+ ```
114
+
115
+ `@operationIds` names the operations `listUsers`, `getUser`, `createUser`,
116
+ `updateUser` and `deleteUser` ([Operation ids](operation-ids.md)).
117
+
118
+ ```ts
119
+ // openapi-codegen.config.ts
120
+ import { defineConfig } from '@nxgt/openapi-codegen';
121
+
122
+ export default defineConfig({
123
+ input: 'openapi/openapi.yaml',
124
+ output: 'src/generated',
125
+ hono: true,
126
+ });
127
+ ```
128
+
129
+ ## Compile and generate
130
+
131
+ Compile the spec to `openapi/openapi.yaml`, then generate the code from it.
132
+ `nxgt-openapi` runs on Bun: it needs `bun` on the PATH, even through `npx` or
133
+ an npm script, and fails with `env: 'bun': No such file or directory`
134
+ without it ([troubleshooting](../troubleshooting.md#env-bun-no-such-file-or-directory)).
135
+ `bunx nxgt-openapi generate` does the same as the `npx` line.
136
+
137
+ ```sh
138
+ npx tsp compile .
139
+ npx nxgt-openapi generate
140
+ ```
141
+
142
+ ```text
143
+ openapi/openapi.yaml → src/generated: 5 written, 0 unchanged
144
+ ```
145
+
146
+ `src/generated` then holds `types.ts`, `zod.ts`, `operations.ts`, `paths.ts`
147
+ and `hono.ts`. The linter runs in `tsp compile`; the template's spec passes
148
+ it.
149
+
150
+ ## Add the scripts
151
+
152
+ `tsp init` writes its own `package.json`, so the template cannot add
153
+ scripts. Add these two, so the code never lags the spec:
154
+
155
+ ```json
156
+ {
157
+ "scripts": {
158
+ "api": "tsp compile . && nxgt-openapi generate",
159
+ "api:check": "tsp compile . && nxgt-openapi generate --check"
160
+ }
161
+ }
162
+ ```
163
+
164
+ `api:check` exits with 1 and lists the files when the generated code is out
165
+ of date: run it in CI.
166
+
167
+ ## Serve it
168
+
169
+ The generated `hono.ts` binds the routes to the spec through
170
+ [`@nxgt/openapi-hono`](https://www.npmjs.com/package/@nxgt/openapi-hono).
171
+ Install it, Hono, and zod, which the generated validators import, with the
172
+ package manager `tsp init` used:
173
+
174
+ ```sh
175
+ npm install @nxgt/openapi-hono hono zod
176
+ ```
177
+
178
+ Then register a handler per operation. Each one reads its validated input,
179
+ and may only answer the replies the spec declares:
180
+
181
+ ```ts
182
+ // src/app.ts
183
+ import { Hono } from 'hono';
184
+ import { createRoutes } from './generated/hono';
185
+ import type { NotFoundBody } from './generated/types';
186
+
187
+ const app = new Hono();
188
+ const routes = createRoutes(app);
189
+
190
+ routes.operation('getUser', async (c) => {
191
+ const { id } = c.req.valid('param');
192
+ const user = await users.find(id);
193
+ if (!user) {
194
+ const body: NotFoundBody = {
195
+ status: 404,
196
+ message: 'errors.not-found',
197
+ timestamp: new Date().toISOString(),
198
+ };
199
+ return c.json(body, 404);
200
+ }
201
+ return c.json(user, 200);
202
+ });
203
+
204
+ routes.operation('listUsers', async (c) => {
205
+ const { name, page = 1, pageSize = 20, orderBy, direction } = c.req.valid('query');
206
+ return c.json(await users.page({ name, page, pageSize, orderBy, direction }), 200);
207
+ });
208
+
209
+ export default app;
210
+ ```
211
+
212
+ `users` stands for your data layer. Annotate an error body with its generated
213
+ type, as `NotFoundBody` above: an object literal passed straight to
214
+ `c.json(…, 404)` is inferred beside the handler's other returns, its fields
215
+ turn optional, and the handler fails with `TS2769: No overload matches this
216
+ call`. A `418`,
217
+ or a 404 without `timestamp`, does not compile either. More on the routes in the
218
+ [`@nxgt/openapi-hono` README](https://www.npmjs.com/package/@nxgt/openapi-hono),
219
+ and on serving a page in [Pagination](pagination.md#serving-it).
220
+
221
+ ## Rename the resource
222
+
223
+ `Users` is an example. To make it your own resource, rename the model, its
224
+ filters, the interface, the route and the tag in `main.tsp`, and the body
225
+ parameters with them:
226
+
227
+ ```tsp
228
+ model Pet {
229
+ @visibility(Lifecycle.Read)
230
+ id: uuid;
231
+
232
+ @minLength(1)
233
+ name: string;
234
+
235
+ ...Timestamps;
236
+ }
237
+
238
+ model PetFilters {
239
+ @query
240
+ name?: string;
241
+ }
242
+
243
+ @route("/pets")
244
+ @tag("Pets")
245
+ interface Pets {
246
+ @get
247
+ list(...PetFilters, ...PageParameters, ...SortParameters<"name" | "createdAt">): Page<Pet> | ListErrors;
248
+
249
+ @get
250
+ get(@path id: uuid): Pet | GetErrors;
251
+
252
+ @post
253
+ create(@body pet: Create<Pet>): {
254
+ @statusCode _: 201;
255
+ @body pet: Pet;
256
+ } | CreateErrors;
257
+
258
+ @patch
259
+ update(@path id: uuid, @body pet: MergePatchUpdate<Pet>): Pet | UpdateErrors;
260
+
261
+ @delete
262
+ delete(@path id: uuid): NoContentResponse | DeleteErrors;
263
+ }
264
+ ```
265
+
266
+ 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
269
+ the generated types, `Pet` and `PetPage`, follow. A name the singular rule
270
+ does not know takes `@operationIds(#{ singular: "…" })` on the interface
271
+ ([Operation ids](operation-ids.md)).
272
+
273
+ A second resource is a second model and interface, in the same namespace or
274
+ in files of their own ([README](../README.md#a-spec-split-across-files)).
275
+
276
+ Next: what each error alias holds, in [Error replies](errors.md), and the
277
+ rules the linter checks, in [Linter](linter.md).
@@ -0,0 +1,257 @@
1
+ # Linter
2
+
3
+ The linter warns when a spec strays from the conventions the library gives
4
+ it: a list that returns an array, a service without `@operationIds`, an error
5
+ reply without the envelope.
6
+
7
+ ## Turn it on
8
+
9
+ Extend the ruleset in `tspconfig.yaml`, beside the emitter:
10
+
11
+ ```yaml
12
+ # api/tspconfig.yaml
13
+ emit:
14
+ - '@typespec/openapi3'
15
+ linter:
16
+ extends:
17
+ - '@nxgt/typespec/recommended'
18
+ options:
19
+ '@typespec/openapi3':
20
+ openapi-versions: ['3.1.0']
21
+ ```
22
+
23
+ Nothing runs without it: importing `@nxgt/typespec` does not lint the spec.
24
+ Each rule is a warning, so `tsp compile` still emits:
25
+
26
+ ```text
27
+ warning @nxgt/typespec/list-returns-page: listAll returns an array: return Page<Item> or CursorPage<Item>, which can carry a total or a next cursor.
28
+ ```
29
+
30
+ `tsp compile api --warn-as-error` fails on any of them, for CI.
31
+
32
+ | Rule | Warns when |
33
+ | --- | --- |
34
+ | [`list-returns-page`](#list-returns-page) | a `list`, `search` or `query` returns an array |
35
+ | [`service-operation-ids`](#service-operation-ids) | a `@service` namespace has no `@operationIds` |
36
+ | [`error-body-shape`](#error-body-shape) | an error reply's body is not `status`, `message` and `timestamp` |
37
+
38
+ The library's own checks, such as `verb-method-mismatch` and
39
+ `duplicate-operation-id`, run whether or not the linter is on
40
+ ([Operation ids](operation-ids.md)).
41
+
42
+ ## `list-returns-page`
43
+
44
+ **Checks:** an operation whose name is `list`, `search` or `query`, alone or
45
+ followed by a capital letter (`listUsers`, `searchPosts`), with a success
46
+ reply whose body is an array. `listing` and `queryable` are not lists, and
47
+ are not checked. The name is checked as written, in an interface or not.
48
+
49
+ Only the operations the emitter writes are checked, by this rule and
50
+ `error-body-shape`: a template's declaration, its instance (`Read<Pet>`) and
51
+ the operations of an interface template are not, while an interface that
52
+ extends a template, `interface Users extends Crud<User> {}`, has its copied
53
+ operations checked.
54
+
55
+ **Why:** an array cannot grow a `total` or a `nextCursor` later without
56
+ breaking every client. `Page<Item>` and `CursorPage<Item>` can, and they are
57
+ the pages `@nxgt/drizzle` and `@nxgt/mongo` return.
58
+
59
+ Fails:
60
+
61
+ ```tsp
62
+ @route("/users")
63
+ interface Users {
64
+ @get list(): User[];
65
+ }
66
+ ```
67
+
68
+ ```text
69
+ warning @nxgt/typespec/list-returns-page: list returns an array: return Page<Item> or CursorPage<Item>, which can carry a total or a next cursor.
70
+ ```
71
+
72
+ Passes:
73
+
74
+ ```tsp
75
+ @route("/users")
76
+ interface Users {
77
+ @get list(...PageParameters): Page<User>;
78
+ @get @route("/search") search(...CursorPageParameters): CursorPage<User>;
79
+ }
80
+ ```
81
+
82
+ More on both pages in [Pagination](pagination.md).
83
+
84
+ ## `service-operation-ids`
85
+
86
+ **Checks:** a `@service` namespace that is not marked with `@operationIds`,
87
+ neither itself nor a namespace around it.
88
+
89
+ **Why:** without it, the emitter names each operation after its interface,
90
+ `Users_list`, and the generated client's methods and types take that name.
91
+ `@operationIds` gives `listUsers` ([Operation ids](operation-ids.md)).
92
+
93
+ Fails:
94
+
95
+ ```tsp
96
+ @service
97
+ namespace Shop {
98
+ @route("/health") @get op health(): void;
99
+ }
100
+ ```
101
+
102
+ ```text
103
+ warning @nxgt/typespec/service-operation-ids: The service Shop has no @operationIds: its ids are the emitter's, such as Users_list. Mark the namespace with @operationIds.
104
+ ```
105
+
106
+ Passes, marked on the service or on a namespace around it:
107
+
108
+ ```tsp
109
+ @service
110
+ @operationIds
111
+ namespace Shop {
112
+ @route("/health") @get op health(): void;
113
+ }
114
+
115
+ @operationIds
116
+ namespace Admin {
117
+ @service
118
+ namespace Billing {
119
+ @route("/invoices") @get op invoices(): void;
120
+ }
121
+ }
122
+ ```
123
+
124
+ `@operationIds` on an interface alone does not count: the rule asks for the
125
+ namespace.
126
+
127
+ ## `error-body-shape`
128
+
129
+ **Checks:** each reply of status 400 or above, of a range that starts at 400
130
+ or above (`5XX`), or of no status (`default`, an `@error` model without
131
+ `@statusCode`), that has a body. The body must be a model with a `status`, a
132
+ `message` and a `timestamp` property. A reply without a body passes, such as
133
+ `AuthenticationRequired`, `AccessDenied` or `ErrorWithoutBody<404>`.
134
+
135
+ **Why:** `@nxgt/openapi-hono` answers its own 400 and 500 with that envelope,
136
+ and a client written against it reads `message` from every error. A body of
137
+ another shape is one more case each client must handle.
138
+
139
+ Fails:
140
+
141
+ ```tsp
142
+ model Teapot {
143
+ @statusCode _: 418;
144
+ @body body: { reason: string };
145
+ }
146
+
147
+ @route("/users")
148
+ interface Users {
149
+ @post create(@body user: User): User | Teapot;
150
+ }
151
+ ```
152
+
153
+ ```text
154
+ warning @nxgt/typespec/error-body-shape: create answers 418 with a body that is not the nxgt envelope: declare BadRequest, NotFound or another of the library's errors, or a body that spreads or extends ErrorBody<Status>.
155
+ ```
156
+
157
+ A range is named by its bounds, `500-599`, and a reply without a status as
158
+ `*`.
159
+
160
+ Passes: one of the library's errors, `ErrorResponse<Status>`, or a body that
161
+ spreads or extends `ErrorBody<Status>` and adds its own fields
162
+ (`model TeapotBody extends ErrorBody<418> { reason: string; }` passes too):
163
+
164
+ ```tsp
165
+ model TeapotBody {
166
+ ...ErrorBody<418>;
167
+ reason: string;
168
+ }
169
+
170
+ @error
171
+ model Teapot {
172
+ @statusCode _: 418;
173
+ @body body: TeapotBody;
174
+ }
175
+
176
+ @route("/users")
177
+ interface Users {
178
+ @get @route("/{id}") get(@path id: uuid): User | NotFound | AuthenticationRequired;
179
+ @post create(@body user: User): User | BadRequest | Teapot | ErrorResponse<503>;
180
+ }
181
+ ```
182
+
183
+ The rule checks the property names, not their types. Each error is in
184
+ [Error replies](errors.md).
185
+
186
+ ## Turn a rule off
187
+
188
+ For the whole spec, in `tspconfig.yaml`, with the reason:
189
+
190
+ ```yaml
191
+ linter:
192
+ extends:
193
+ - '@nxgt/typespec/recommended'
194
+ disable:
195
+ '@nxgt/typespec/error-body-shape': 'the legacy routes answer their own errors'
196
+ ```
197
+
198
+ For one operation or one namespace, a `#suppress` line above it:
199
+
200
+ ```tsp
201
+ @route("/users")
202
+ interface Users {
203
+ #suppress "@nxgt/typespec/list-returns-page" "a fixed set of ten"
204
+ @get @route("/top") listTop(): User[];
205
+ }
206
+
207
+ #suppress "@nxgt/typespec/service-operation-ids" "its clients already call Health_check"
208
+ @service
209
+ namespace Health {
210
+ @route("/health") @get op check(): void;
211
+ }
212
+ ```
213
+
214
+ Or turn on only the rules you want, without the set:
215
+
216
+ ```yaml
217
+ linter:
218
+ enable:
219
+ '@nxgt/typespec/list-returns-page': true
220
+ ```
221
+
222
+ ## A whole spec
223
+
224
+ The rules pass on a spec that uses the library throughout:
225
+
226
+ ```tsp
227
+ // api/main.tsp
228
+ import "@typespec/http";
229
+ import "@nxgt/typespec";
230
+
231
+ using Http;
232
+ using Nxgt;
233
+
234
+ @service(#{ title: "Shop" })
235
+ @operationIds
236
+ namespace Shop;
237
+
238
+ model User {
239
+ @visibility(Lifecycle.Read) id: uuid;
240
+ name: string;
241
+ }
242
+
243
+ @route("/users")
244
+ interface Users {
245
+ @get list(...PageParameters): Page<User> | BadRequest; // listUsers
246
+ @get @route("/search") search(...CursorPageParameters): CursorPage<User>; // searchUsers
247
+ @get @route("/{id}") get(@path id: uuid): User | NotFound | AuthenticationRequired; // getUser
248
+ @post create(@body user: User): User | BadRequest | Conflict; // createUser
249
+ }
250
+ ```
251
+
252
+ ```sh
253
+ bunx --no-install tsp compile api --warn-as-error
254
+ ```
255
+
256
+ The messages, with their fixes, are also in
257
+ [troubleshooting](../troubleshooting.md).
package/docs/roadmap.md CHANGED
@@ -6,10 +6,7 @@ generated by `@nxgt/openapi-codegen` in CI before it ships.
6
6
 
7
7
  ## Now
8
8
 
9
- - **Error aliases by verb.** `ListErrors`, `GetErrors`, `CreateErrors`,
10
- `UpdateErrors`, `DeleteErrors` and `AuthErrors` name the errors each verb
11
- usually answers, so an operation writes `User | GetErrors` instead of the
12
- union. Each is exactly its union, and they combine.
9
+ Nothing in progress.
13
10
 
14
11
  ## Later
15
12
 
@@ -23,6 +20,29 @@ generated by `@nxgt/openapi-codegen` in CI before it ships.
23
20
 
24
21
  ## Shipped
25
22
 
23
+ ### 0.9.0
24
+
25
+ - **A `tsp init` template.** `tsp init` with the package's template
26
+ starts a project with the conventions in place: `@operationIds`, a paged
27
+ resource with its error aliases, the linter on, and the OpenAPI that
28
+ `@nxgt/openapi-codegen` generates from.
29
+
30
+ ### 0.8.0
31
+
32
+ - **A linter.** The ruleset `@nxgt/typespec/recommended`, which a spec
33
+ extends in `tspconfig.yaml`, warns of a list that returns an array
34
+ (`list-returns-page`), a service without `@operationIds`
35
+ (`service-operation-ids`), and an error reply without the envelope
36
+ (`error-body-shape`). Each rule can be turned off, for the spec or for one
37
+ operation.
38
+
39
+ ### 0.7.0
40
+
41
+ - **Error aliases by verb.** `ListErrors`, `GetErrors`, `CreateErrors`,
42
+ `UpdateErrors`, `DeleteErrors` and `AuthErrors` name the errors each verb
43
+ usually answers, so an operation writes `User | GetErrors` instead of the
44
+ union. Each is exactly its union, and they combine.
45
+
26
46
  ### 0.6.0
27
47
 
28
48
  - **Verbs name their methods.** An operation named after one of the