@nxgt/typespec 0.1.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/LICENSE +21 -0
- package/README.md +458 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +75 -0
- package/dist/index.js.map +12 -0
- package/dist/lib.d.ts +6 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/operation-ids.d.ts +20 -0
- package/dist/operation-ids.d.ts.map +1 -0
- package/docs/README.md +15 -0
- package/docs/guide/auth.md +103 -0
- package/docs/guide/columns.md +117 -0
- package/docs/guide/errors.md +190 -0
- package/docs/guide/headers.md +96 -0
- package/docs/guide/operation-ids.md +113 -0
- package/docs/guide/pagination.md +138 -0
- package/docs/guide/resources.md +112 -0
- package/docs/roadmap.md +38 -0
- package/docs/troubleshooting.md +109 -0
- package/lib/auth.tsp +43 -0
- package/lib/columns.tsp +50 -0
- package/lib/errors.tsp +148 -0
- package/lib/headers.tsp +76 -0
- package/lib/main.tsp +11 -0
- package/lib/operation-ids.tsp +22 -0
- package/lib/pagination.tsp +62 -0
- package/lib/resources.tsp +63 -0
- package/lib/scalars.tsp +12 -0
- package/package.json +64 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Authentication
|
|
2
|
+
|
|
3
|
+
How an operation says it needs a session, and what it answers without one,
|
|
4
|
+
as [`@nxgt/janus`](https://www.npmjs.com/package/@nxgt/janus) serves it.
|
|
5
|
+
|
|
6
|
+
## Declaring it
|
|
7
|
+
|
|
8
|
+
```tsp
|
|
9
|
+
import "@typespec/http";
|
|
10
|
+
import "@nxgt/typespec";
|
|
11
|
+
|
|
12
|
+
using Http;
|
|
13
|
+
using Nxgt;
|
|
14
|
+
|
|
15
|
+
namespace Blog;
|
|
16
|
+
|
|
17
|
+
@route("/posts/{postId}/comments")
|
|
18
|
+
interface Comments {
|
|
19
|
+
@get list(@path postId: string): Comment[];
|
|
20
|
+
|
|
21
|
+
@useAuth(JanusAuth)
|
|
22
|
+
@delete delete(@path postId: string, @path commentId: string):
|
|
23
|
+
NoContentResponse | AuthenticationRequired | AccessDenied | ErrorWithoutBody<404>;
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`@useAuth` is TypeSpec's own, from `@typespec/http`. On an interface or a
|
|
28
|
+
namespace, it covers every operation inside; `@useAuth(NoAuth)` opens one
|
|
29
|
+
of them again.
|
|
30
|
+
|
|
31
|
+
## The schemes
|
|
32
|
+
|
|
33
|
+
`@nxgt/janus` reads a session token from three places, in this order, and
|
|
34
|
+
takes the first it finds:
|
|
35
|
+
|
|
36
|
+
| Scheme | Where the token is | Emitted as |
|
|
37
|
+
| --- | --- | --- |
|
|
38
|
+
| `BearerAuth` | `Authorization: Bearer <token>` | `type: http`, `scheme: Bearer` |
|
|
39
|
+
| `SessionTokenAuth` | the `X-Session-Token` header | `type: apiKey`, `in: header` |
|
|
40
|
+
| `SessionCookieAuth` | the `janus-session` cookie | `type: apiKey`, `in: cookie` |
|
|
41
|
+
|
|
42
|
+
`JanusAuth` is the three, as alternatives: any one of them is enough. Each
|
|
43
|
+
is also usable alone, `@useAuth(BearerAuth)` for an API that only takes
|
|
44
|
+
the header. A janus app that renames its cookie declares its own:
|
|
45
|
+
|
|
46
|
+
```tsp
|
|
47
|
+
model AdminCookieAuth is ApiKeyAuth<ApiKeyLocation.cookie, "admin-session">;
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The spec lists them under `securitySchemes`, for its readers and for the
|
|
51
|
+
clients generated from it. `@nxgt/openapi-codegen` does not read them:
|
|
52
|
+
checking the session is the guard's work, not the validators'.
|
|
53
|
+
|
|
54
|
+
## The refusals, without a body
|
|
55
|
+
|
|
56
|
+
The guards of [`@nxgt/janus-hono`](https://www.npmjs.com/package/@nxgt/janus-hono)
|
|
57
|
+
refuse a request with a status and nothing else:
|
|
58
|
+
|
|
59
|
+
| Guard | Refuses with | Declare |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| `session(auth, { required: true })` | 401: no session, or one the server does not know | `AuthenticationRequired` |
|
|
62
|
+
| `fresh()` | 401: no session | `AuthenticationRequired` |
|
|
63
|
+
| `permission()` | 401: no session | `AuthenticationRequired` |
|
|
64
|
+
| `permission()` | 404: the object does not exist | `ErrorWithoutBody<404>` |
|
|
65
|
+
| `permission()` | 403: its user may not do this | `AccessDenied` |
|
|
66
|
+
|
|
67
|
+
The generated `hono.ts` types those replies without a body, so the route
|
|
68
|
+
answers them as the guard does:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
createRoutes(app).delete('/posts/{postId}/comments/{commentId}', async (c) => {
|
|
72
|
+
// … permission() has let the request through
|
|
73
|
+
return c.body(null, 204);
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A handler that sends one of the guards' replies with a body does not
|
|
78
|
+
type-check. With
|
|
79
|
+
`validateResponses`, `@nxgt/openapi-hono` checks a reply's body only where
|
|
80
|
+
the spec declares one, so it lets a body through on these at run time.
|
|
81
|
+
|
|
82
|
+
The errors `janusErrors()` answers are another shape: `fresh()`'s step-up
|
|
83
|
+
403 and janus's own errors carry its `{ code }` body, which this library does
|
|
84
|
+
not declare.
|
|
85
|
+
|
|
86
|
+
## Beside a handler's refusal
|
|
87
|
+
|
|
88
|
+
A handler that refuses on its own, after the guard, answers with the
|
|
89
|
+
envelope: `Unauthorized` or `Forbidden` ([Error replies](errors.md)). An
|
|
90
|
+
operation can declare the guard's 401 and the handler's 403:
|
|
91
|
+
|
|
92
|
+
```tsp
|
|
93
|
+
@useAuth(JanusAuth)
|
|
94
|
+
@post create(@body post: Create<Post>): {
|
|
95
|
+
@statusCode _: 201;
|
|
96
|
+
@body post: Post;
|
|
97
|
+
} | BadRequest | AuthenticationRequired | Forbidden;
|
|
98
|
+
```
|
|
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.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Scalars and columns
|
|
2
|
+
|
|
3
|
+
Two scalars for the strings every API has, and the columns
|
|
4
|
+
[`@nxgt/drizzle`](https://www.npmjs.com/package/@nxgt/drizzle) stamps a row
|
|
5
|
+
with, as models to spread.
|
|
6
|
+
|
|
7
|
+
## Scalars
|
|
8
|
+
|
|
9
|
+
```tsp
|
|
10
|
+
import "@typespec/http";
|
|
11
|
+
import "@nxgt/typespec";
|
|
12
|
+
|
|
13
|
+
using Http;
|
|
14
|
+
using Nxgt;
|
|
15
|
+
|
|
16
|
+
namespace Blog;
|
|
17
|
+
|
|
18
|
+
model Author {
|
|
19
|
+
@visibility(Lifecycle.Read) id: uuid;
|
|
20
|
+
email: email;
|
|
21
|
+
site?: url;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@route("/authors/{authorId}")
|
|
25
|
+
@get op readAuthor(@path authorId: uuid): Author;
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
| Scalar | Emitted as | Generated | Refused |
|
|
29
|
+
| --- | --- | --- | --- |
|
|
30
|
+
| `uuid` | `$ref` to the schema `Uuid`: `type: string`, `format: uuid` | `type Uuid = string`, `zUuid = z.guid()` | `42`, in a path, a query or a body |
|
|
31
|
+
| `email` | `$ref` to the schema `Email`: `format: email` | `type Email = string`, `zEmail = z.email()` | `not an address` |
|
|
32
|
+
| `url` | TypeSpec's own: `format: uri`, inline | `z.url()` | `not a url` |
|
|
33
|
+
|
|
34
|
+
`z.guid()` checks the shape of a UUID, as JSON Schema's `uuid` does, not
|
|
35
|
+
the version and variant bits `z.uuid()` also checks.
|
|
36
|
+
|
|
37
|
+
## Columns
|
|
38
|
+
|
|
39
|
+
`@nxgt/drizzle/pg` declares a table's stamps with its column helpers. The
|
|
40
|
+
models here are the same columns, as the API sends them:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
pgTable('posts', {
|
|
44
|
+
id: id(),
|
|
45
|
+
title: text('title').notNull(),
|
|
46
|
+
...timestamps(),
|
|
47
|
+
...version(),
|
|
48
|
+
...actors(),
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```tsp
|
|
53
|
+
model Post {
|
|
54
|
+
@visibility(Lifecycle.Read) id: uuid;
|
|
55
|
+
title: string;
|
|
56
|
+
...Timestamps;
|
|
57
|
+
...Versioned;
|
|
58
|
+
...Actors;
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
| Model | Properties | Visibility | drizzle |
|
|
63
|
+
| --- | --- | --- | --- |
|
|
64
|
+
| `Timestamps` | `createdAt`, `updatedAt: utcDateTime` | read | `timestamps()` |
|
|
65
|
+
| `SoftDelete` | `deletedAt: utcDateTime \| null` | read | `softDelete()` |
|
|
66
|
+
| `Versioned` | `version: integer`, at least 0 | read, update | `version()` |
|
|
67
|
+
| `Actors<Id = uuid>` | `createdBy`, `updatedBy`, `deletedBy: Id \| null` | read | `actors()` |
|
|
68
|
+
|
|
69
|
+
The server sets every read-only column, so `Create<Post>` and
|
|
70
|
+
`MergePatchUpdate<Post>` leave them out, and the generated `CreatePost` and
|
|
71
|
+
`PostMergePatchUpdate` have no `createdAt` a client could send.
|
|
72
|
+
`Actors<string>` or `Actors<integer>` match `actors('text')` or
|
|
73
|
+
`actors('integer')`.
|
|
74
|
+
|
|
75
|
+
## The version in an update
|
|
76
|
+
|
|
77
|
+
`version` is read with the row and sent back in an update. `@nxgt/drizzle`
|
|
78
|
+
takes it as a condition: it writes the update only while the row is still at
|
|
79
|
+
that version, and throws `OptimisticLockError` otherwise. Declare the
|
|
80
|
+
`Conflict` it becomes:
|
|
81
|
+
|
|
82
|
+
```tsp
|
|
83
|
+
@patch update(@path postId: uuid, @body post: MergePatchUpdate<Post>):
|
|
84
|
+
Post | BadRequest | NotFound | Conflict;
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { OptimisticLockError } from '@nxgt/drizzle';
|
|
89
|
+
import { Hono } from 'hono';
|
|
90
|
+
import { createRoutes } from './generated/hono';
|
|
91
|
+
import type { ConflictBody } from './generated/types';
|
|
92
|
+
|
|
93
|
+
const app = new Hono();
|
|
94
|
+
createRoutes(app).patch('/posts/{postId}', async (c) => {
|
|
95
|
+
try {
|
|
96
|
+
const post = await posts.update(c.req.valid('param').postId, c.req.valid('json'));
|
|
97
|
+
return c.json(post, 200);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (!(error instanceof OptimisticLockError)) throw error;
|
|
100
|
+
const body: ConflictBody = {
|
|
101
|
+
status: 409,
|
|
102
|
+
message: 'errors.version-conflict',
|
|
103
|
+
timestamp: new Date().toISOString(),
|
|
104
|
+
};
|
|
105
|
+
return c.json(body, 409);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
In a merge patch, every property is optional, `version` too: an update
|
|
111
|
+
without it is not checked against the version.
|
|
112
|
+
|
|
113
|
+
## Names
|
|
114
|
+
|
|
115
|
+
`Uuid` and `Email` are schemas of their own, and their names are global: a
|
|
116
|
+
model named `Email` in a spec that also uses `email` collides with it
|
|
117
|
+
([troubleshooting](../troubleshooting.md)).
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# Error replies
|
|
2
|
+
|
|
3
|
+
`@nxgt/typespec` declares the error replies the `@nxgt/*` packages send, so a
|
|
4
|
+
spec names them instead of describing them again.
|
|
5
|
+
|
|
6
|
+
## A whole spec
|
|
7
|
+
|
|
8
|
+
```yaml
|
|
9
|
+
# api/tspconfig.yaml
|
|
10
|
+
emit:
|
|
11
|
+
- '@typespec/openapi3'
|
|
12
|
+
options:
|
|
13
|
+
'@typespec/openapi3':
|
|
14
|
+
openapi-versions: ['3.1.0']
|
|
15
|
+
emitter-output-dir: '{project-root}/../openapi'
|
|
16
|
+
output-file: openapi.yaml
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The spec is split by role, as it grows: `main.tsp` assembles, `models/`
|
|
20
|
+
holds the shapes and `routes/` the operations and their errors.
|
|
21
|
+
|
|
22
|
+
```tsp
|
|
23
|
+
// api/main.tsp
|
|
24
|
+
import "@typespec/http";
|
|
25
|
+
import "./models/post.tsp";
|
|
26
|
+
import "./routes/posts.tsp";
|
|
27
|
+
|
|
28
|
+
using Http;
|
|
29
|
+
|
|
30
|
+
@service(#{ title: "Blog" })
|
|
31
|
+
namespace Blog;
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```tsp
|
|
35
|
+
// api/models/post.tsp
|
|
36
|
+
namespace Blog;
|
|
37
|
+
|
|
38
|
+
model Post {
|
|
39
|
+
@visibility(Lifecycle.Read) id: string;
|
|
40
|
+
@minLength(1) title: string;
|
|
41
|
+
body: string;
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```tsp
|
|
46
|
+
// api/routes/posts.tsp
|
|
47
|
+
import "@typespec/http";
|
|
48
|
+
import "@nxgt/typespec";
|
|
49
|
+
|
|
50
|
+
using Http;
|
|
51
|
+
using Nxgt;
|
|
52
|
+
|
|
53
|
+
namespace Blog;
|
|
54
|
+
|
|
55
|
+
@route("/posts")
|
|
56
|
+
interface Posts {
|
|
57
|
+
@get read(@path postId: string): Post | NotFound;
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// openapi-codegen.config.ts
|
|
63
|
+
import { defineConfig } from '@nxgt/openapi-codegen';
|
|
64
|
+
|
|
65
|
+
export default defineConfig({
|
|
66
|
+
input: 'openapi/openapi.yaml',
|
|
67
|
+
output: 'src/generated',
|
|
68
|
+
hono: true,
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
bunx --no-install tsp compile api && bunx nxgt-openapi generate
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
A Hono handler then answers the 404 with the generated type. The generated
|
|
77
|
+
`hono.ts` imports
|
|
78
|
+
[`@nxgt/openapi-hono`](https://github.com/softistx/nxgt-http/blob/develop/packages/openapi-hono/README.md),
|
|
79
|
+
and its validators `zod`: install them with `hono`.
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
bun add @nxgt/openapi-hono hono zod
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { Hono } from 'hono';
|
|
88
|
+
import { createRoutes } from './generated/hono';
|
|
89
|
+
import type { NotFoundBody } from './generated/types';
|
|
90
|
+
|
|
91
|
+
const app = new Hono();
|
|
92
|
+
createRoutes(app).get('/posts/{postId}', (c) => {
|
|
93
|
+
const body: NotFoundBody = {
|
|
94
|
+
status: 404,
|
|
95
|
+
message: 'errors.not-found',
|
|
96
|
+
timestamp: new Date().toISOString(),
|
|
97
|
+
};
|
|
98
|
+
return c.json(body, 404);
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## The envelope
|
|
103
|
+
|
|
104
|
+
Every error body is `ErrorBody<Status>`, the envelope of
|
|
105
|
+
`@nxgt/openapi-hono`'s own replies, which a handler answers with too:
|
|
106
|
+
|
|
107
|
+
```tsp
|
|
108
|
+
model ErrorBody<Status extends integer> {
|
|
109
|
+
status: Status; // the reply's status, repeated
|
|
110
|
+
message: string; // an i18n key: errors.not-found
|
|
111
|
+
timestamp: utcDateTime;
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`message` is a key for the client to translate, never a sentence. `status`
|
|
116
|
+
is a literal, so the generated type narrows on it:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
export interface NotFoundBody {
|
|
120
|
+
status: 404;
|
|
121
|
+
message: string;
|
|
122
|
+
timestamp: string;
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## The 400
|
|
127
|
+
|
|
128
|
+
`@nxgt/openapi-hono` refuses a request its validators reject with a 400 that
|
|
129
|
+
lists every value refused:
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"status": 400,
|
|
134
|
+
"message": "errors.validation-failed",
|
|
135
|
+
"timestamp": "2026-09-29T08:00:00.000Z",
|
|
136
|
+
"issues": [
|
|
137
|
+
{ "target": "json", "path": ["title"], "code": "too_small", "message": "Too small" }
|
|
138
|
+
]
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
A handler may send a 400 of its own, without `issues`. `BadRequestBody`
|
|
143
|
+
describes both, with `issues` optional:
|
|
144
|
+
|
|
145
|
+
```tsp
|
|
146
|
+
@patch update(@path postId: string, @body post: MergePatchUpdate<Post>): Post | BadRequest;
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The generator declares the validators' 400 itself, as `ValidationErrorBody`,
|
|
150
|
+
on every operation that takes a parameter or a body, whether the spec says
|
|
151
|
+
so or not. Beside `BadRequest`, the 400 is typed
|
|
152
|
+
`BadRequestBody | ValidationErrorBody`. An app that answers a refused request
|
|
153
|
+
its own way, through `onValidationError`, turns that off with the generator's
|
|
154
|
+
[`validationErrors: false`](https://github.com/softistx/nxgt-http/blob/develop/packages/openapi-codegen/docs/guide/options.md#validationerrors).
|
|
155
|
+
|
|
156
|
+
## Each response
|
|
157
|
+
|
|
158
|
+
| Response | Status | Body | Sent when |
|
|
159
|
+
| --- | --- | --- | --- |
|
|
160
|
+
| `BadRequest` | 400 | `BadRequestBody` | the validators or a handler refuse the request |
|
|
161
|
+
| `Unauthorized` | 401 | `UnauthorizedBody` | no credentials, or invalid ones; a `@nxgt/janus-hono` guard sends `AuthenticationRequired` instead ([Authentication](auth.md)) |
|
|
162
|
+
| `Forbidden` | 403 | `ForbiddenBody` | authenticated, but not allowed; a `@nxgt/janus-hono` guard sends `AccessDenied` instead, and `janusErrors()` its own `{ code }` body ([Authentication](auth.md)) |
|
|
163
|
+
| `NotFound` | 404 | `NotFoundBody` | nothing is there |
|
|
164
|
+
| `Conflict` | 409 | `ConflictBody` | the request conflicts with the resource's state, such as a stale `version` |
|
|
165
|
+
| `UnprocessableEntity` | 422 | `UnprocessableEntityBody` | well-formed, but not something the server can act on |
|
|
166
|
+
| `TooManyRequests` | 429 | `TooManyRequestsBody` | too many requests; with the `RateLimit-*` headers and `Retry-After` ([Headers](headers.md)) |
|
|
167
|
+
| `InternalServerError` | 500 | `InternalServerErrorBody` | the server failed; with `validateResponses`, `@nxgt/openapi-hono` sends it with `errors.response-validation-failed` when a reply breaks the spec |
|
|
168
|
+
| `ErrorResponse<Status>` | any | `ErrorBody<Status>` | any other status |
|
|
169
|
+
|
|
170
|
+
Name them in an operation's return type, beside the success:
|
|
171
|
+
|
|
172
|
+
```tsp
|
|
173
|
+
@get read(@path postId: string): Post | NotFound | Unauthorized | Forbidden;
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Another status, named
|
|
177
|
+
|
|
178
|
+
`ErrorResponse<503>` works, but its body is emitted inline and generated as
|
|
179
|
+
`<Operation>503Response`. For a named schema, declare the body and the
|
|
180
|
+
response once:
|
|
181
|
+
|
|
182
|
+
```tsp
|
|
183
|
+
model ServiceUnavailableBody is ErrorBody<503>;
|
|
184
|
+
|
|
185
|
+
@error
|
|
186
|
+
model ServiceUnavailable {
|
|
187
|
+
@statusCode _: 503;
|
|
188
|
+
@body body: ServiceUnavailableBody;
|
|
189
|
+
}
|
|
190
|
+
```
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Headers
|
|
2
|
+
|
|
3
|
+
The headers of an idempotent write and of a rate limit, with the replies
|
|
4
|
+
that go with them.
|
|
5
|
+
|
|
6
|
+
## An idempotent write
|
|
7
|
+
|
|
8
|
+
A client that may retry a write sends an `Idempotency-Key` with it: the
|
|
9
|
+
server writes once, and answers a retry with the first answer.
|
|
10
|
+
|
|
11
|
+
```tsp
|
|
12
|
+
import "@typespec/http";
|
|
13
|
+
import "@nxgt/typespec";
|
|
14
|
+
|
|
15
|
+
using Http;
|
|
16
|
+
using Nxgt;
|
|
17
|
+
|
|
18
|
+
namespace Blog;
|
|
19
|
+
|
|
20
|
+
@route("/posts")
|
|
21
|
+
interface Posts {
|
|
22
|
+
@post create(...IdempotencyKeyHeader, @body post: Create<Post>): {
|
|
23
|
+
@statusCode _: 201;
|
|
24
|
+
...IdempotentReplayedHeader;
|
|
25
|
+
@body post: Post;
|
|
26
|
+
} | BadRequest | IdempotencyInProgress | IdempotencyKeyReused;
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- `...IdempotencyKeyHeader` adds the optional `Idempotency-Key` header, 1 to
|
|
31
|
+
255 characters. The generated validators check it: an empty key, or a
|
|
32
|
+
longer one, is a 400. A handler reads it as
|
|
33
|
+
`c.req.valid('header')['idempotency-key']`.
|
|
34
|
+
- `...IdempotentReplayedHeader` declares `Idempotent-Replayed: true` on the
|
|
35
|
+
reply that replays an earlier one.
|
|
36
|
+
- `IdempotencyInProgress` is the 409 of a key whose first request still
|
|
37
|
+
runs, with `Retry-After` and the `ConflictBody` envelope. The client
|
|
38
|
+
retries as it is.
|
|
39
|
+
- `IdempotencyKeyReused` is the 422 of a key sent again with another body,
|
|
40
|
+
with the `UnprocessableEntityBody` envelope. The client takes a new key.
|
|
41
|
+
|
|
42
|
+
Both carry the `RateLimit-*` headers, as a route that also has a rate limit
|
|
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.
|
|
46
|
+
|
|
47
|
+
## A rate limit
|
|
48
|
+
|
|
49
|
+
```tsp
|
|
50
|
+
@post create(@body post: Create<Post>): {
|
|
51
|
+
@statusCode _: 201;
|
|
52
|
+
...RateLimitHeaders;
|
|
53
|
+
@body post: Post;
|
|
54
|
+
} | BadRequest | TooManyRequests;
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
| Header | Value |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| `RateLimit-Limit` | how many requests a full window allows |
|
|
60
|
+
| `RateLimit-Remaining` | how many more are allowed now, after this one |
|
|
61
|
+
| `RateLimit-Reset` | seconds until the window is full again, rounded up |
|
|
62
|
+
| `Retry-After` | seconds to wait before trying again: a delay, never a date |
|
|
63
|
+
|
|
64
|
+
`TooManyRequests`, `IdempotencyInProgress` and `IdempotencyKeyReused`
|
|
65
|
+
carry the three `RateLimit-*` headers already. `...RateLimitHeaders` adds
|
|
66
|
+
them to a reply the spec declares inline, such as the 201 above.
|
|
67
|
+
|
|
68
|
+
## What is checked
|
|
69
|
+
|
|
70
|
+
The generator checks a request's headers, as it checks its query: the
|
|
71
|
+
`Idempotency-Key` arrives valid, or the request is a 400. It does not check
|
|
72
|
+
a reply's headers: they are in the spec for a client to read, and the
|
|
73
|
+
handler sets them:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { Hono } from 'hono';
|
|
77
|
+
import { createRoutes } from './generated/hono';
|
|
78
|
+
|
|
79
|
+
const app = new Hono();
|
|
80
|
+
createRoutes(app).post('/posts', async (c) => {
|
|
81
|
+
const key = c.req.valid('header')['idempotency-key'];
|
|
82
|
+
// … look the key up; on a replay, answer the post it wrote:
|
|
83
|
+
const post = await replayed(key);
|
|
84
|
+
c.header('Idempotent-Replayed', 'true');
|
|
85
|
+
return c.json(post, 201);
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Every header here is optional, in the spec as on the wire: a route without
|
|
90
|
+
an idempotency store or a rate limiter declares none of them.
|
|
91
|
+
|
|
92
|
+
## Not yet
|
|
93
|
+
|
|
94
|
+
`ETag`, `If-None-Match` and `If-Match` are not declared here: the
|
|
95
|
+
`@nxgt/*` packages do not send an `ETag` the same way yet. The optimistic
|
|
96
|
+
lock is the body's `version` ([Scalars and columns](columns.md)).
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Operation ids
|
|
2
|
+
|
|
3
|
+
An OpenAPI operation id is what `@nxgt/openapi-codegen` names everything
|
|
4
|
+
after: the client's method, the `operations` entry, and the prefix of the
|
|
5
|
+
operation's types (`ListPostsQuery`, `zGetPostParam`).
|
|
6
|
+
|
|
7
|
+
## What TypeSpec does alone
|
|
8
|
+
|
|
9
|
+
Without an `@operationId`, `@typespec/openapi3` names an operation after its
|
|
10
|
+
interface and its method, joined by `_`:
|
|
11
|
+
|
|
12
|
+
```tsp
|
|
13
|
+
@route("/posts")
|
|
14
|
+
interface Posts {
|
|
15
|
+
@get list(@query status?: PostStatus): Post[]; // Posts_list
|
|
16
|
+
@post create(@body post: Create<Post>): Post; // Posts_create
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The client then calls `Posts_list`, and its types are `PostsListQuery`.
|
|
21
|
+
Writing an `@operationId` on each operation fixes that, one line per
|
|
22
|
+
operation.
|
|
23
|
+
|
|
24
|
+
## `@operationIds`
|
|
25
|
+
|
|
26
|
+
Put it on the interface, once:
|
|
27
|
+
|
|
28
|
+
```tsp
|
|
29
|
+
import "@typespec/http";
|
|
30
|
+
import "@typespec/openapi";
|
|
31
|
+
import "@nxgt/typespec";
|
|
32
|
+
|
|
33
|
+
using Http;
|
|
34
|
+
using OpenAPI;
|
|
35
|
+
using Nxgt;
|
|
36
|
+
|
|
37
|
+
namespace Blog;
|
|
38
|
+
|
|
39
|
+
@route("/posts")
|
|
40
|
+
@operationIds
|
|
41
|
+
interface Posts {
|
|
42
|
+
@get list(@query status?: PostStatus): Post[]; // listPosts
|
|
43
|
+
@get @operationId("getPost") read(@path postId: string): Post | NotFound; // getPost
|
|
44
|
+
@post create(@body post: Create<Post>): Post | BadRequest; // createPosts
|
|
45
|
+
@patch update(@path postId: string, @body post: MergePatchUpdate<Post>): Post | NotFound; // updatePosts
|
|
46
|
+
@delete delete(@path postId: string): NoContentResponse | NotFound; // deletePosts
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- Each operation is named `<operation><Interface>`: the method's name, then
|
|
51
|
+
the interface's, as written.
|
|
52
|
+
- An operation with its own `@operationId` keeps it, whether it is written
|
|
53
|
+
before or after `@operationIds`. `import "@typespec/openapi"` and
|
|
54
|
+
`using OpenAPI` are only needed for that `@operationId`.
|
|
55
|
+
- An interface that `extends` a marked one is marked too, and names the
|
|
56
|
+
operations it inherits after itself: `interface Drafts extends Posts {}`
|
|
57
|
+
gives `listDrafts`. An inherited `@operationId` is copied as is, though,
|
|
58
|
+
so `Drafts` would name its `read` `getPost` too: see below.
|
|
59
|
+
|
|
60
|
+
The generated `operations` then has one entry per id:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { operations } from './generated/operations';
|
|
64
|
+
|
|
65
|
+
Object.keys(operations);
|
|
66
|
+
// ['listPosts', 'createPosts', 'getPost', 'updatePosts', 'deletePosts']
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## On a template
|
|
70
|
+
|
|
71
|
+
On an interface template, `@operationIds` names the operations of each
|
|
72
|
+
interface that extends an instance, after that interface:
|
|
73
|
+
|
|
74
|
+
```tsp
|
|
75
|
+
@operationIds
|
|
76
|
+
interface Resource<Item> {
|
|
77
|
+
@get list(): Item[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@route("/pets")
|
|
81
|
+
interface Pets extends Resource<Pet> {} // listPets
|
|
82
|
+
|
|
83
|
+
@route("/orders")
|
|
84
|
+
interface Orders extends Resource<Order> {} // listOrders
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Two operations named alike
|
|
88
|
+
|
|
89
|
+
An id must be unique, and four cases break that:
|
|
90
|
+
|
|
91
|
+
- the namespace is not part of the id, so `Store.Pets` and `Shelter.Pets`
|
|
92
|
+
would both name their `list` `listPets`;
|
|
93
|
+
- an `@operationId` is copied by `extends`, so `Drafts extends Posts` would
|
|
94
|
+
have a second `getPost`;
|
|
95
|
+
- an `@operationId` written on another operation, in any interface, takes
|
|
96
|
+
the id `@operationIds` gives;
|
|
97
|
+
- an operation declared in the service namespace is named after itself, so
|
|
98
|
+
`op listPets()` takes the id of `Pets.list`.
|
|
99
|
+
|
|
100
|
+
`@operationIds` refuses each with `duplicate-operation-id`, where
|
|
101
|
+
`@typespec/openapi3` would emit both without a word. Give one of them its own
|
|
102
|
+
`@operationId`; an inherited operation takes one by being declared again in
|
|
103
|
+
the interface that extends ([troubleshooting](../troubleshooting.md)).
|
|
104
|
+
|
|
105
|
+
## Without the library
|
|
106
|
+
|
|
107
|
+
The emitter's own option, `operation-id-strategy`, only chooses between
|
|
108
|
+
`parent-container` (`Posts_list`, the default), `fqn` (with the namespace)
|
|
109
|
+
and `explicit-only`. None of them gives `listPosts`.
|
|
110
|
+
|
|
111
|
+
`@operationIds` only names the operations of the interfaces it marks: the
|
|
112
|
+
strategy still names every other one. The check for two operations named
|
|
113
|
+
alike reads their ids as the default strategy gives them.
|