@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,138 @@
|
|
|
1
|
+
# Pagination
|
|
2
|
+
|
|
3
|
+
Two ways to list a collection a page at a time, with the query and the page
|
|
4
|
+
[`@nxgt/drizzle`](https://www.npmjs.com/package/@nxgt/drizzle) and
|
|
5
|
+
[`@nxgt/mongo`](https://www.npmjs.com/package/@nxgt/mongo) use:
|
|
6
|
+
|
|
7
|
+
| | Query | Page | Use it when |
|
|
8
|
+
| --- | --- | --- | --- |
|
|
9
|
+
| Offset | `...PageParameters` | `Page<Item>` | a client jumps to a page, and shows a total |
|
|
10
|
+
| Cursor | `...CursorPageParameters` | `CursorPage<Item>` | a client scrolls on, and the collection is large or changes under it |
|
|
11
|
+
|
|
12
|
+
## Offset
|
|
13
|
+
|
|
14
|
+
```tsp
|
|
15
|
+
import "@typespec/http";
|
|
16
|
+
import "@nxgt/typespec";
|
|
17
|
+
|
|
18
|
+
using Http;
|
|
19
|
+
using Nxgt;
|
|
20
|
+
|
|
21
|
+
namespace Blog;
|
|
22
|
+
|
|
23
|
+
@route("/posts")
|
|
24
|
+
interface Posts {
|
|
25
|
+
@get list(@query status?: PostStatus, ...PageParameters): Page<Post>;
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`...PageParameters` adds two query parameters:
|
|
30
|
+
|
|
31
|
+
- `page`: 1-based, 1 when absent;
|
|
32
|
+
- `pageSize`: 20 when absent.
|
|
33
|
+
|
|
34
|
+
Both must be integers of at least 1, or `@nxgt/openapi-hono` answers its
|
|
35
|
+
400. `Page<Post>` is the schema `PostPage`:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{ "items": [], "total": 42, "page": 3, "pageSize": 20, "pageCount": 3 }
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`pageCount` is `ceil(total / pageSize)`, 0 when nothing matches. A page past
|
|
42
|
+
the last one has no items and the same `total`.
|
|
43
|
+
|
|
44
|
+
## Cursor
|
|
45
|
+
|
|
46
|
+
```tsp
|
|
47
|
+
@route("/posts/{postId}/comments")
|
|
48
|
+
interface Comments {
|
|
49
|
+
@get list(
|
|
50
|
+
@path postId: string,
|
|
51
|
+
...CursorPageParameters,
|
|
52
|
+
): CursorPage<Comment> | BadRequest | NotFound;
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`...CursorPageParameters` adds:
|
|
57
|
+
|
|
58
|
+
- `after`: the previous page's `nextCursor`, absent for the first page;
|
|
59
|
+
- `limit`: 20 when absent, an integer of at least 1.
|
|
60
|
+
|
|
61
|
+
`CursorPage<Comment>` is the schema `CommentCursorPage`, with no total:
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{ "items": [], "nextCursor": "eyJpZCI6NDJ9" }
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`nextCursor` is `null` on the last page. It is opaque: a client sends it
|
|
68
|
+
back as it is.
|
|
69
|
+
|
|
70
|
+
## Serving it
|
|
71
|
+
|
|
72
|
+
The generated query arrives checked, with its defaults filled in, so it
|
|
73
|
+
goes to `@nxgt/drizzle` as it is. The page it returns is the generated type,
|
|
74
|
+
when the table's rows are what the spec's `Post` describes:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { Hono } from 'hono';
|
|
78
|
+
import { createRoutes } from './generated/hono';
|
|
79
|
+
import type { PostPage } from './generated/types';
|
|
80
|
+
|
|
81
|
+
const app = new Hono();
|
|
82
|
+
createRoutes(app).get('/posts', async (c) => {
|
|
83
|
+
const { page, pageSize } = c.req.valid('query');
|
|
84
|
+
const body: PostPage = await posts.paginate({ page, pageSize });
|
|
85
|
+
return c.json(body, 200);
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A cursor that was not written by the server, or for another ordering, makes
|
|
90
|
+
`@nxgt/drizzle` throw `InvalidCursorError`. That is the client's input:
|
|
91
|
+
declare `BadRequest` on the operation, as above, and answer it:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { InvalidCursorError } from '@nxgt/drizzle';
|
|
95
|
+
import type { BadRequestBody, CommentCursorPage } from './generated/types';
|
|
96
|
+
|
|
97
|
+
createRoutes(app).get('/posts/{postId}/comments', async (c) => {
|
|
98
|
+
const { after, limit } = c.req.valid('query');
|
|
99
|
+
try {
|
|
100
|
+
const body: CommentCursorPage = await comments.paginateByCursor({ after, limit });
|
|
101
|
+
return c.json(body, 200);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (!(error instanceof InvalidCursorError)) throw error;
|
|
104
|
+
const body: BadRequestBody = {
|
|
105
|
+
status: 400,
|
|
106
|
+
message: 'errors.invalid-cursor',
|
|
107
|
+
timestamp: new Date().toISOString(),
|
|
108
|
+
};
|
|
109
|
+
return c.json(body, 400);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## The largest page
|
|
115
|
+
|
|
116
|
+
Neither `pageSize` nor `limit` has a maximum in the spec. A server lowers one
|
|
117
|
+
above its own maximum to it, as `@nxgt/drizzle` does with 100 by default,
|
|
118
|
+
rather than refusing the request. A client reads the `pageSize` the page
|
|
119
|
+
reports, not the one it asked for.
|
|
120
|
+
|
|
121
|
+
## Names
|
|
122
|
+
|
|
123
|
+
`Page<Item>` is named `<Item>Page`, and `CursorPage<Item>` `<Item>CursorPage`,
|
|
124
|
+
after the item's model: `PostPage`, `CommentCursorPage`. Those names are
|
|
125
|
+
global, as the library's other schemas are: a model named `PostPage` in the
|
|
126
|
+
spec collides with `Page<Post>`
|
|
127
|
+
([troubleshooting](../troubleshooting.md)).
|
|
128
|
+
|
|
129
|
+
Give the item a name: a page of an anonymous type is named after what
|
|
130
|
+
TypeSpec calls it, `Page<Post[]>` gives `ArrayPage` and
|
|
131
|
+
`CursorPage<Cat | Dog>` gives `undefinedCursorPage`. Name the union, and the
|
|
132
|
+
page follows:
|
|
133
|
+
|
|
134
|
+
```tsp
|
|
135
|
+
union Pet { cat: Cat, dog: Dog }
|
|
136
|
+
|
|
137
|
+
@get list(...PageParameters): Page<Pet>; // PetPage
|
|
138
|
+
```
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Resources
|
|
2
|
+
|
|
3
|
+
`Resource` gives a resource its five operations in one line: list, read,
|
|
4
|
+
create, update and delete, with the replies each one can send.
|
|
5
|
+
|
|
6
|
+
## One resource
|
|
7
|
+
|
|
8
|
+
```tsp
|
|
9
|
+
import "@typespec/http";
|
|
10
|
+
import "@nxgt/typespec";
|
|
11
|
+
|
|
12
|
+
using Http;
|
|
13
|
+
using Nxgt;
|
|
14
|
+
|
|
15
|
+
namespace Blog;
|
|
16
|
+
|
|
17
|
+
model Author {
|
|
18
|
+
@visibility(Lifecycle.Read) id: uuid;
|
|
19
|
+
name: string;
|
|
20
|
+
email: email;
|
|
21
|
+
...Timestamps;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
model AuthorFilters {
|
|
25
|
+
/** Authors of this name. */
|
|
26
|
+
@query name?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@route("/authors")
|
|
30
|
+
interface Authors extends Resource<Author, AuthorFilters, SortField = "name" | "createdAt"> {}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
| Operation | Route | Body | Answers |
|
|
34
|
+
| --- | --- | --- | --- |
|
|
35
|
+
| `listAuthors` | `GET /authors` | | `Page<Author>` |
|
|
36
|
+
| `readAuthors` | `GET /authors/{id}` | | `Author`, `NotFound` |
|
|
37
|
+
| `createAuthors` | `POST /authors` | `Create<Author>` | 201 `Author`, `BadRequest`, `Conflict` |
|
|
38
|
+
| `updateAuthors` | `PATCH /authors/{id}` | `MergePatchUpdate<Author>` | `Author`, `BadRequest`, `NotFound`, `Conflict` |
|
|
39
|
+
| `deleteAuthors` | `DELETE /authors/{id}` | | 204, `NotFound` |
|
|
40
|
+
|
|
41
|
+
The template carries `@operationIds`, so each operation is named after the
|
|
42
|
+
interface that extends it. The generator's own 400 is on every operation
|
|
43
|
+
that takes a parameter or a body, as always.
|
|
44
|
+
|
|
45
|
+
## The template's arguments
|
|
46
|
+
|
|
47
|
+
`Resource<Item, Filters = {}, SortField = "id", Id = uuid>`. Name the ones
|
|
48
|
+
after `Filters`, as above, to skip those you keep:
|
|
49
|
+
|
|
50
|
+
- `Item`: the resource's model. `Create<Item>` and `MergePatchUpdate<Item>`
|
|
51
|
+
leave its read-only properties out, such as `id` and the `Timestamps`.
|
|
52
|
+
- `Filters`: a model of `@query` properties, spread into `list`. Each is
|
|
53
|
+
typed and checked, as any query parameter.
|
|
54
|
+
- `SortField`: the fields `list` may sort by, as a union of strings.
|
|
55
|
+
- `Id`: the type of `{id}`. `Id = integer` for a table whose id is
|
|
56
|
+
`@nxgt/drizzle`'s `id('identity')`.
|
|
57
|
+
|
|
58
|
+
## Filters and sort
|
|
59
|
+
|
|
60
|
+
`list` takes the filters, the offset page's `page` and `pageSize`, and
|
|
61
|
+
`SortParameters<SortField>`:
|
|
62
|
+
|
|
63
|
+
| Query | Default | Checked |
|
|
64
|
+
| --- | --- | --- |
|
|
65
|
+
| `orderBy` | none: the server's order, the primary key with `@nxgt/drizzle` | one of the `SortField`s |
|
|
66
|
+
| `direction` | `asc` | `asc` or `desc` |
|
|
67
|
+
|
|
68
|
+
`?orderBy=email` on `Authors` is a 400: `email` is not a `SortField`. The
|
|
69
|
+
handler passes them to `@nxgt/drizzle`:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { Hono } from 'hono';
|
|
73
|
+
import { createRoutes } from './generated/hono';
|
|
74
|
+
|
|
75
|
+
const app = new Hono();
|
|
76
|
+
createRoutes(app).get('/authors', async (c) => {
|
|
77
|
+
const { name, page, pageSize, orderBy, direction } = c.req.valid('query');
|
|
78
|
+
const found = await authors.paginate({
|
|
79
|
+
where: name === undefined ? {} : { name },
|
|
80
|
+
page,
|
|
81
|
+
pageSize,
|
|
82
|
+
// paginate takes the sort as an object of directions by field
|
|
83
|
+
...(orderBy === undefined ? {} : { orderBy: { [orderBy]: direction } }),
|
|
84
|
+
});
|
|
85
|
+
return c.json(found, 200);
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`paginateByCursor` takes them apart instead, `{ orderBy, direction }`, as
|
|
90
|
+
the query has them.
|
|
91
|
+
|
|
92
|
+
`SortParameters<Field>` works on its own too, in any list:
|
|
93
|
+
|
|
94
|
+
```tsp
|
|
95
|
+
@get list(...PageParameters, ...SortParameters<"createdAt" | "title">): Page<Post>;
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Changing an operation
|
|
99
|
+
|
|
100
|
+
An interface that extends `Resource` can declare one of its operations
|
|
101
|
+
again, and its own replaces the template's. A list paged by cursor:
|
|
102
|
+
|
|
103
|
+
```tsp
|
|
104
|
+
@route("/comments")
|
|
105
|
+
interface Comments extends Resource<Comment> {
|
|
106
|
+
@get list(...CursorPageParameters): CursorPage<Comment>;
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Add `@useAuth(JanusAuth)` on the interface to protect all five, and name the
|
|
111
|
+
guards' replies in the operations you declare again
|
|
112
|
+
([Authentication](auth.md)).
|
package/docs/roadmap.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
What `@nxgt/typespec` gives a spec, and what is coming. Each convention is the
|
|
4
|
+
shape the `@nxgt/*` packages already send, compiled to OpenAPI 3.1 and 3.2 and
|
|
5
|
+
generated by `@nxgt/openapi-codegen` in CI before it ships.
|
|
6
|
+
|
|
7
|
+
## Now
|
|
8
|
+
|
|
9
|
+
- **Error replies.** The envelope `@nxgt/openapi-hono` sends, and the
|
|
10
|
+
400, 401, 403, 404, 409, 422, 429 and 500 responses, with any other status
|
|
11
|
+
through `ErrorResponse<Status>`.
|
|
12
|
+
- **Pagination.** `PageParameters` and `Page<Item>` for an offset page,
|
|
13
|
+
`CursorPageParameters` and `CursorPage<Item>` for a cursor page, as
|
|
14
|
+
`@nxgt/drizzle` and `@nxgt/mongo` page.
|
|
15
|
+
- **Authentication.** `JanusAuth`, the bearer, header and cookie schemes
|
|
16
|
+
`@nxgt/janus` reads, and its guards' 401, 403 and 404 without a body.
|
|
17
|
+
- **Scalars and columns.** `uuid` and `email`, and `Timestamps`,
|
|
18
|
+
`SoftDelete`, `Versioned` and `Actors`, the columns `@nxgt/drizzle`
|
|
19
|
+
stamps a row with.
|
|
20
|
+
- **Headers.** `Idempotency-Key`, `Idempotent-Replayed`, the `RateLimit-*`
|
|
21
|
+
headers and `Retry-After`, and the idempotent write's 409 and 422.
|
|
22
|
+
- **Resources.** `Resource<Item, Filters, SortField, Id>`: list, read,
|
|
23
|
+
create, update and delete in one template, with typed filters and a sort
|
|
24
|
+
by `orderBy` and `direction`.
|
|
25
|
+
- **Operation ids.** `@operationIds` names an interface's operations
|
|
26
|
+
`listPosts`, `createPosts`, instead of `Posts_list`.
|
|
27
|
+
|
|
28
|
+
## Next
|
|
29
|
+
|
|
30
|
+
- **One reply per status.** An error when an operation declares two replies
|
|
31
|
+
of one status, such as `AuthenticationRequired` and `Unauthorized`, which
|
|
32
|
+
the emitter merges without a warning today.
|
|
33
|
+
|
|
34
|
+
## Later
|
|
35
|
+
|
|
36
|
+
- **`ETag` and conditional requests.** `ETag`, with `If-None-Match` and
|
|
37
|
+
`If-Match`, once the `@nxgt/*` packages send one the same way. The
|
|
38
|
+
optimistic lock is the body's `version` today (`Versioned`).
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
## `schema ValidationErrorBody and schema ValidationErrorBody would both generate ValidationErrorBody`
|
|
4
|
+
|
|
5
|
+
**When:** the spec declares a schema named `ValidationErrorBody`, for
|
|
6
|
+
instance to describe the 400 with its `issues`.
|
|
7
|
+
|
|
8
|
+
**Why:** `@nxgt/openapi-codegen` declares `ValidationErrorBody` itself, on
|
|
9
|
+
every operation that takes a parameter or a body, and refuses two schemas of
|
|
10
|
+
one name with `name_collision`.
|
|
11
|
+
|
|
12
|
+
**Fix:** answer `BadRequest`. Its `BadRequestBody` carries the `issues`,
|
|
13
|
+
optional, and the generator adds its own 400 beside it.
|
|
14
|
+
|
|
15
|
+
```tsp
|
|
16
|
+
@patch update(@path postId: string, @body post: MergePatchUpdate<Post>): Post | BadRequest;
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## `Couldn't resolve import "@nxgt/typespec"`
|
|
20
|
+
|
|
21
|
+
**When:** `tsp compile` runs on a spec that imports the library.
|
|
22
|
+
|
|
23
|
+
**Why:** the library is not installed where the spec is, or the project has
|
|
24
|
+
no `package.json` to resolve it from.
|
|
25
|
+
|
|
26
|
+
**Fix:** install it and its peers next to the spec's project, as
|
|
27
|
+
devDependencies, with the command in the
|
|
28
|
+
[README's Install section](../README.md#install).
|
|
29
|
+
|
|
30
|
+
## `Duplicate type name: 'NotFoundBody'`
|
|
31
|
+
|
|
32
|
+
**When:** `tsp compile` runs on a spec that declares its own model named like
|
|
33
|
+
one of the library's: `BadRequestBody` … `InternalServerErrorBody`,
|
|
34
|
+
`ValidationIssue`, `ValidationTarget`, `Uuid` or `Email` when the spec uses
|
|
35
|
+
`uuid` or `email`, or the page of one of its models,
|
|
36
|
+
such as `PostPage` beside `Page<Post>`, or `CommentCursorPage` beside
|
|
37
|
+
`CursorPage<Comment>`.
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
error @typespec/openapi/duplicate-type-name: Duplicate type name: 'NotFoundBody'. Check @friendlyName decorators and overlap with types in TypeSpec or service namespace.
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**Why:** the library names its schemas with `@friendlyName`, without a
|
|
44
|
+
namespace, so they share the spec's schema names.
|
|
45
|
+
|
|
46
|
+
**Fix:** reuse the library's model, or rename yours:
|
|
47
|
+
|
|
48
|
+
```tsp
|
|
49
|
+
@get read(@path postId: string): Post | NotFound;
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## `Two operations are named listPets: an OpenAPI operation id must be unique`
|
|
53
|
+
|
|
54
|
+
**When:** an operation of an `@operationIds` interface has the id of another
|
|
55
|
+
operation:
|
|
56
|
+
|
|
57
|
+
- two interfaces of one name, in two namespaces, share an operation name:
|
|
58
|
+
`Store.Pets` and `Shelter.Pets` both give `listPets`;
|
|
59
|
+
- an interface `extends` another, whose operation has an `@operationId`:
|
|
60
|
+
`extends` copies it as is;
|
|
61
|
+
- an `@operationId` written elsewhere takes the id `@operationIds` gives;
|
|
62
|
+
- an operation declared in the service namespace is named after itself:
|
|
63
|
+
`op listPets()` takes the id of `Pets.list`.
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
error @nxgt/typespec/duplicate-operation-id: Two operations are named listPets: an OpenAPI operation id must be unique.
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Why:** `@operationIds` names each operation `<operation><Interface>`, without
|
|
70
|
+
the namespace. `@typespec/openapi3` would emit both ids without a word, and
|
|
71
|
+
the generator would then refuse the spec.
|
|
72
|
+
|
|
73
|
+
**Fix:** give one of them its own `@operationId`. An inherited operation takes
|
|
74
|
+
one by being declared again in the interface that extends:
|
|
75
|
+
|
|
76
|
+
```tsp
|
|
77
|
+
import "@typespec/openapi";
|
|
78
|
+
|
|
79
|
+
using OpenAPI;
|
|
80
|
+
|
|
81
|
+
@route("/drafts")
|
|
82
|
+
interface Drafts extends Posts {
|
|
83
|
+
@get @operationId("getDraft") read(@path postId: string): Post | NotFound;
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## `Unknown decorator @operationId`
|
|
88
|
+
|
|
89
|
+
**When:** a spec writes `@operationId` next to `@operationIds`.
|
|
90
|
+
|
|
91
|
+
**Why:** `@operationIds` comes from `@nxgt/typespec`, but `@operationId`
|
|
92
|
+
comes from `@typespec/openapi`, which the file does not import.
|
|
93
|
+
|
|
94
|
+
**Fix:** import it in that file:
|
|
95
|
+
|
|
96
|
+
```tsp
|
|
97
|
+
import "@typespec/openapi";
|
|
98
|
+
|
|
99
|
+
using OpenAPI;
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## `unsupported_version` from the generator
|
|
103
|
+
|
|
104
|
+
**When:** you generate from what `tsp compile` emitted.
|
|
105
|
+
|
|
106
|
+
**Why:** `@typespec/openapi3` emits OpenAPI 3.0 by default.
|
|
107
|
+
|
|
108
|
+
**Fix:** in `tspconfig.yaml`, set `openapi-versions: ['3.1.0']` or
|
|
109
|
+
`['3.2.0']` under the `@typespec/openapi3` options.
|
package/lib/auth.tsp
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import "@typespec/http";
|
|
2
|
+
|
|
3
|
+
using Http;
|
|
4
|
+
|
|
5
|
+
namespace Nxgt;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A session token in the `X-Session-Token` header, as `@nxgt/janus` reads it
|
|
9
|
+
* after `Authorization: Bearer`.
|
|
10
|
+
*/
|
|
11
|
+
model SessionTokenAuth is ApiKeyAuth<ApiKeyLocation.header, "X-Session-Token">;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A session token in the `janus-session` cookie, as `@nxgt/janus` sets it for
|
|
15
|
+
* a browser.
|
|
16
|
+
*/
|
|
17
|
+
model SessionCookieAuth is ApiKeyAuth<ApiKeyLocation.cookie, "janus-session">;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Every way `@nxgt/janus` reads a session token, in its order:
|
|
21
|
+
* `Authorization: Bearer`, then `X-Session-Token`, then the `janus-session`
|
|
22
|
+
* cookie. Any one of them is enough. Use it with `@useAuth(JanusAuth)`.
|
|
23
|
+
*/
|
|
24
|
+
alias JanusAuth = BearerAuth | SessionTokenAuth | SessionCookieAuth;
|
|
25
|
+
|
|
26
|
+
// An error reply without a body, as the @nxgt/janus guards send one:
|
|
27
|
+
// session(auth, { required: true }) and permission() answer 401, 403 or 404
|
|
28
|
+
// with no body.
|
|
29
|
+
/** The request was refused; the reply has no body. */
|
|
30
|
+
@error
|
|
31
|
+
model ErrorWithoutBody<Status extends integer> {
|
|
32
|
+
@statusCode _: Status;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A handler's own 401, with the envelope, is Unauthorized.
|
|
36
|
+
/** The request carries no session, or one the server does not know. */
|
|
37
|
+
@error
|
|
38
|
+
model AuthenticationRequired is ErrorWithoutBody<401>;
|
|
39
|
+
|
|
40
|
+
// A handler's own 403, with the envelope, is Forbidden.
|
|
41
|
+
/** The session's user may not do this. */
|
|
42
|
+
@error
|
|
43
|
+
model AccessDenied is ErrorWithoutBody<403>;
|
package/lib/columns.tsp
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
namespace Nxgt;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `createdAt` and `updatedAt`, as `@nxgt/drizzle`'s `timestamps()` columns
|
|
5
|
+
* hold them. Set by the server: read-only. Spread it into a model.
|
|
6
|
+
*/
|
|
7
|
+
model Timestamps {
|
|
8
|
+
@visibility(Lifecycle.Read)
|
|
9
|
+
createdAt: utcDateTime;
|
|
10
|
+
|
|
11
|
+
@visibility(Lifecycle.Read)
|
|
12
|
+
updatedAt: utcDateTime;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `deletedAt`, as `@nxgt/drizzle`'s `softDelete()` column holds it: `null`
|
|
17
|
+
* until the row is deleted. Set by the server: read-only.
|
|
18
|
+
*/
|
|
19
|
+
model SoftDelete {
|
|
20
|
+
@visibility(Lifecycle.Read)
|
|
21
|
+
deletedAt: utcDateTime | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `version`, as `@nxgt/drizzle`'s `version()` column holds it: the optimistic
|
|
26
|
+
* lock. Read with the row, and sent back in an update, which the server
|
|
27
|
+
* refuses with a 409 once the row has moved on.
|
|
28
|
+
*/
|
|
29
|
+
model Versioned {
|
|
30
|
+
@visibility(Lifecycle.Read, Lifecycle.Update)
|
|
31
|
+
@minValue(0)
|
|
32
|
+
version: integer;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `createdBy`, `updatedBy` and `deletedBy`, as `@nxgt/drizzle`'s `actors()`
|
|
37
|
+
* columns hold them: the id of who wrote the row, `null` when nobody is
|
|
38
|
+
* known. Set by the server: read-only. `Id` is `uuid` by default; `string`
|
|
39
|
+
* or `integer` for `actors('text')` or `actors('integer')`.
|
|
40
|
+
*/
|
|
41
|
+
model Actors<Id = uuid> {
|
|
42
|
+
@visibility(Lifecycle.Read)
|
|
43
|
+
createdBy: Id | null;
|
|
44
|
+
|
|
45
|
+
@visibility(Lifecycle.Read)
|
|
46
|
+
updatedBy: Id | null;
|
|
47
|
+
|
|
48
|
+
@visibility(Lifecycle.Read)
|
|
49
|
+
deletedBy: Id | null;
|
|
50
|
+
}
|
package/lib/errors.tsp
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import "@typespec/http";
|
|
2
|
+
|
|
3
|
+
using Http;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* nxgt's HTTP conventions, as the `@nxgt/*` packages serve them.
|
|
7
|
+
*/
|
|
8
|
+
namespace Nxgt;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Where `@nxgt/openapi-hono` found a request value wrong: a request location,
|
|
12
|
+
* or `response` for a reply the spec does not declare.
|
|
13
|
+
*/
|
|
14
|
+
@friendlyName("ValidationTarget")
|
|
15
|
+
union ValidationTarget {
|
|
16
|
+
"param",
|
|
17
|
+
"query",
|
|
18
|
+
"header",
|
|
19
|
+
"json",
|
|
20
|
+
"form",
|
|
21
|
+
"body",
|
|
22
|
+
"response",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One value `@nxgt/openapi-hono` refused, and why. */
|
|
26
|
+
@friendlyName("ValidationIssue")
|
|
27
|
+
model ValidationIssue {
|
|
28
|
+
target: ValidationTarget;
|
|
29
|
+
|
|
30
|
+
/** Inside the target: `["items", 0, "name"]`, or `[]` for the whole of it. */
|
|
31
|
+
path: (string | integer)[];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Zod's issue code, or one of the engine's own: `invalid_json`,
|
|
35
|
+
* `invalid_form`, `invalid_content_type`, `missing_body`,
|
|
36
|
+
* `repeated_parameter`, `undeclared_status`.
|
|
37
|
+
*/
|
|
38
|
+
code: string;
|
|
39
|
+
|
|
40
|
+
message: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The body of an error reply, as `@nxgt/openapi-hono` sends it. */
|
|
44
|
+
model ErrorBody<Status extends integer> {
|
|
45
|
+
/** The reply's status, repeated in the body. */
|
|
46
|
+
status: Status;
|
|
47
|
+
|
|
48
|
+
/** An i18n key, such as `errors.not-found`, not a sentence. */
|
|
49
|
+
message: string;
|
|
50
|
+
|
|
51
|
+
/** When the error was answered. */
|
|
52
|
+
timestamp: utcDateTime;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The 400. `@nxgt/openapi-hono` sends it with the `issues` when its
|
|
57
|
+
* validators refuse a request; a handler sends it without them.
|
|
58
|
+
*/
|
|
59
|
+
@friendlyName("BadRequestBody")
|
|
60
|
+
model BadRequestBody {
|
|
61
|
+
...ErrorBody<400>;
|
|
62
|
+
|
|
63
|
+
/** Every value the validators refused, from every target at once. */
|
|
64
|
+
issues?: ValidationIssue[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@friendlyName("UnauthorizedBody")
|
|
68
|
+
model UnauthorizedBody is ErrorBody<401>;
|
|
69
|
+
@friendlyName("ForbiddenBody")
|
|
70
|
+
model ForbiddenBody is ErrorBody<403>;
|
|
71
|
+
@friendlyName("NotFoundBody")
|
|
72
|
+
model NotFoundBody is ErrorBody<404>;
|
|
73
|
+
@friendlyName("ConflictBody")
|
|
74
|
+
model ConflictBody is ErrorBody<409>;
|
|
75
|
+
@friendlyName("UnprocessableEntityBody")
|
|
76
|
+
model UnprocessableEntityBody is ErrorBody<422>;
|
|
77
|
+
@friendlyName("TooManyRequestsBody")
|
|
78
|
+
model TooManyRequestsBody is ErrorBody<429>;
|
|
79
|
+
@friendlyName("InternalServerErrorBody")
|
|
80
|
+
model InternalServerErrorBody is ErrorBody<500>;
|
|
81
|
+
|
|
82
|
+
/** The request is refused, by the validators or by a handler. */
|
|
83
|
+
@error
|
|
84
|
+
model BadRequest {
|
|
85
|
+
@statusCode _: 400;
|
|
86
|
+
@body body: BadRequestBody;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** No credentials, or credentials that are not valid. */
|
|
90
|
+
@error
|
|
91
|
+
model Unauthorized {
|
|
92
|
+
@statusCode _: 401;
|
|
93
|
+
@body body: UnauthorizedBody;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Authenticated, but not allowed. */
|
|
97
|
+
@error
|
|
98
|
+
model Forbidden {
|
|
99
|
+
@statusCode _: 403;
|
|
100
|
+
@body body: ForbiddenBody;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Nothing is there. */
|
|
104
|
+
@error
|
|
105
|
+
model NotFound {
|
|
106
|
+
@statusCode _: 404;
|
|
107
|
+
@body body: NotFoundBody;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The request conflicts with the resource's state, such as a version that moved on. */
|
|
111
|
+
@error
|
|
112
|
+
model Conflict {
|
|
113
|
+
@statusCode _: 409;
|
|
114
|
+
@body body: ConflictBody;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Well-formed, but not something the server can act on. */
|
|
118
|
+
@error
|
|
119
|
+
model UnprocessableEntity {
|
|
120
|
+
@statusCode _: 422;
|
|
121
|
+
@body body: UnprocessableEntityBody;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Too many requests. Retry after `Retry-After`. */
|
|
125
|
+
@error
|
|
126
|
+
model TooManyRequests {
|
|
127
|
+
@statusCode _: 429;
|
|
128
|
+
...RateLimitHeaders;
|
|
129
|
+
...RetryAfterHeader;
|
|
130
|
+
@body body: TooManyRequestsBody;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The server failed. With `validateResponses`, `@nxgt/openapi-hono` answers
|
|
135
|
+
* it too when a reply breaks the spec, with `errors.response-validation-failed`.
|
|
136
|
+
*/
|
|
137
|
+
@error
|
|
138
|
+
model InternalServerError {
|
|
139
|
+
@statusCode _: 500;
|
|
140
|
+
@body body: InternalServerErrorBody;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Any other status, with the same body. */
|
|
144
|
+
@error
|
|
145
|
+
model ErrorResponse<Status extends integer> {
|
|
146
|
+
@statusCode _: Status;
|
|
147
|
+
@body body: ErrorBody<Status>;
|
|
148
|
+
}
|