@schemavaults/openapi-operations 0.3.0 → 0.4.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 +99 -11
- package/dist/auth-scheme.d.ts +42 -13
- package/dist/auth-scheme.js +30 -8
- package/dist/auth-scheme.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/nextjs/app-router-routes.d.ts +56 -0
- package/dist/nextjs/app-router-routes.js +182 -0
- package/dist/nextjs/app-router-routes.js.map +1 -0
- package/dist/openapi/build-openapi-document.d.ts +8 -0
- package/dist/openapi/build-openapi-document.js +6 -2
- package/dist/openapi/build-openapi-document.js.map +1 -1
- package/dist/openapi/runtime-responses.d.ts +25 -0
- package/dist/openapi/runtime-responses.js +88 -0
- package/dist/openapi/runtime-responses.js.map +1 -0
- package/dist/operation.d.ts +13 -2
- package/dist/operation.js.map +1 -1
- package/dist/runtime/create-operations-app.js +2 -2
- package/dist/runtime/create-operations-app.js.map +1 -1
- package/dist/runtime/error-schema.d.ts +41 -0
- package/dist/runtime/error-schema.js +50 -0
- package/dist/runtime/error-schema.js.map +1 -0
- package/dist/runtime/errors.d.ts +7 -0
- package/dist/runtime/errors.js +19 -0
- package/dist/runtime/errors.js.map +1 -1
- package/dist/runtime/index.d.ts +3 -2
- package/dist/runtime/index.js +3 -2
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/resolve-auth.d.ts +8 -1
- package/dist/runtime/resolve-auth.js +27 -1
- package/dist/runtime/resolve-auth.js.map +1 -1
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -105,20 +105,69 @@ Request bodies take two extra flags:
|
|
|
105
105
|
|
|
106
106
|
An `AuthSchemeDefinition` is a named OpenAPI security scheme plus docs metadata. Built-ins:
|
|
107
107
|
|
|
108
|
-
| Export | Scheme |
|
|
109
|
-
| --- | --- |
|
|
110
|
-
| `schemaVaultsAccessTokenBearerScheme` | `Authorization: Bearer <access token>` |
|
|
111
|
-
| `schemaVaultsAccessTokenCookieScheme(cookieName)` | first-party access token cookie |
|
|
112
|
-
| `schemaVaultsRefreshTokenCookieScheme(cookieName)` | auth-server session cookie |
|
|
113
|
-
| `oidcClientSecretBasicScheme` / `oidcClientSecretPostScheme` | OAuth 2.0 client authentication |
|
|
114
|
-
| `apiKeyHeaderScheme(name, header)` | static API key |
|
|
115
|
-
| `defineAuthScheme({...})` | anything else |
|
|
108
|
+
| Export | Scheme | Principal |
|
|
109
|
+
| --- | --- | --- |
|
|
110
|
+
| `schemaVaultsAccessTokenBearerScheme` | `Authorization: Bearer <access token>` | user |
|
|
111
|
+
| `schemaVaultsAccessTokenCookieScheme(cookieName)` | first-party access token cookie | user |
|
|
112
|
+
| `schemaVaultsRefreshTokenCookieScheme(cookieName)` | auth-server session cookie | user |
|
|
113
|
+
| `oidcClientSecretBasicScheme` / `oidcClientSecretPostScheme` | OAuth 2.0 client authentication | any |
|
|
114
|
+
| `apiKeyHeaderScheme(name, header)` | static API key | any |
|
|
115
|
+
| `defineAuthScheme({...})` | anything else | as declared |
|
|
116
116
|
|
|
117
117
|
Schemes describe *how* credentials are transported; verification is a per-host
|
|
118
118
|
`AuthResolver` (below). The OpenAPI document carries the standard `security`
|
|
119
119
|
requirement per operation and an `x-schemavaults-auth` extension with the route guard,
|
|
120
120
|
required scopes and organization role so docs can display them.
|
|
121
121
|
|
|
122
|
+
#### User-bearing schemes: non-nullable `ctx.auth.user`
|
|
123
|
+
|
|
124
|
+
`AuthPrincipal.user` is `TUser | null` because a principal may be a non-user credential
|
|
125
|
+
(client credentials, an API key). A scheme whose resolver *always* identifies a user
|
|
126
|
+
declares `principal: "user"` (the three SchemaVaults token schemes do), and when every
|
|
127
|
+
scheme an operation accepts is such a scheme, its handler gets `ctx.auth.user` typed as
|
|
128
|
+
`TUser`:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
const userSchemes = [schemaVaultsAccessTokenBearerScheme, accessTokenCookieScheme] as const;
|
|
132
|
+
|
|
133
|
+
export const me = defineOperation({
|
|
134
|
+
auth: requireAuth({ schemes: userSchemes }), // keep the tuple type: `as const`, no widening
|
|
135
|
+
handler: (ctx) => ctx.json(200, { uid: ctx.auth.user.uid }), // UserData, not UserData | null
|
|
136
|
+
// ...
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Keep the scheme list's tuple type (a helper returning `RequiredOperationAuth` without
|
|
141
|
+
its type parameter, or a scheme annotated as `AuthSchemeDefinition<"name">` instead of
|
|
142
|
+
`AuthSchemeDefinition<"name", "user">`, widens it back to nullable). The runtime enforces
|
|
143
|
+
the declaration: a `principal: "user"` scheme whose resolver returns a principal without
|
|
144
|
+
a user is refused with 401. For operations that also accept non-user schemes,
|
|
145
|
+
`requireUser(ctx.auth)` narrows and throws a 401 `OperationError` otherwise.
|
|
146
|
+
|
|
147
|
+
Resource servers verifying SchemaVaults access tokens do not write resolvers for these
|
|
148
|
+
schemes themselves: `createSchemaVaultsAuthResolvers()` from
|
|
149
|
+
`@schemavaults/auth-server-sdk/openapi-operations` returns them, keyed by scheme name,
|
|
150
|
+
built on the server SDK's `RouteGuardFactory` and the auth server's JWKS.
|
|
151
|
+
|
|
152
|
+
### The error envelope
|
|
153
|
+
|
|
154
|
+
Every error the runtime produces, and every `OperationError` a handler throws, is
|
|
155
|
+
`{ success: false, error, message, issues?, details? }`. `OperationErrorBodySchema` is
|
|
156
|
+
that envelope as a zod schema (registered as `components.schemas.OperationError`, with
|
|
157
|
+
`OperationValidationIssueSchema` as `OperationValidationIssue`), for the 404 / 409 / ...
|
|
158
|
+
responses a handler declares:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
responses: {
|
|
162
|
+
200: { description: "App", schema: AppSchema },
|
|
163
|
+
404: { description: "No such app", schema: OperationErrorBodySchema },
|
|
164
|
+
},
|
|
165
|
+
handler: (ctx) => {
|
|
166
|
+
if (!app) throw new OperationError(404, { error: "not_found", message: "No such app" });
|
|
167
|
+
// ...
|
|
168
|
+
},
|
|
169
|
+
```
|
|
170
|
+
|
|
122
171
|
## Generating the OpenAPI document
|
|
123
172
|
|
|
124
173
|
```ts
|
|
@@ -129,9 +178,21 @@ export const openApiDocument = buildOpenApiDocument({
|
|
|
129
178
|
servers: [{ url: "https://auth.schemavaults.com" }],
|
|
130
179
|
tags: [{ name: "apps", description: "Client applications" }],
|
|
131
180
|
operations: [getApp, health],
|
|
181
|
+
// Also document the responses the runtime produces on its own (below).
|
|
182
|
+
documentRuntimeResponses: true,
|
|
132
183
|
});
|
|
133
184
|
```
|
|
134
185
|
|
|
186
|
+
`buildOpenApiDocument` emits the responses each operation declares. The runtime also
|
|
187
|
+
answers on its own with 400 (validation of params / query / headers / body, missing
|
|
188
|
+
organization parameter), 401 (protected operations; with `WWW-Authenticate`), 403 (admin
|
|
189
|
+
route guard, required scopes, organization role), 415 (operations with a validated body)
|
|
190
|
+
and 500, all with the `OperationError` envelope. `documentRuntimeResponses: true` merges
|
|
191
|
+
those into every operation that can produce them; a response the operation declares for
|
|
192
|
+
the same status takes precedence. `runtimeErrorResponses(operation)` returns the set for
|
|
193
|
+
one operation (and `withRuntimeErrorResponses(operation)` a copy with them merged) when
|
|
194
|
+
you assemble responses yourself.
|
|
195
|
+
|
|
135
196
|
## Serving with Hono on Vercel / Next.js
|
|
136
197
|
|
|
137
198
|
```ts
|
|
@@ -212,15 +273,42 @@ dynamic segment is parsed by the app itself (Next.js' `params` are never read).
|
|
|
212
273
|
for the rest to Next.js. The factory validates the whole catalogue up front (unique
|
|
213
274
|
operations, a resolver for every scheme) and `api.app()` throws for an operation that
|
|
214
275
|
is not in it, so a route file cannot serve something the document does not describe.
|
|
215
|
-
The reverse (a documented operation with no route file) is a
|
|
216
|
-
|
|
276
|
+
The reverse (a documented operation with no route file, or one in the wrong folder) is a
|
|
277
|
+
file-layout question; `checkNextAppRouterRoutes()` from
|
|
278
|
+
`@schemavaults/openapi-operations/nextjs/app-router-routes` answers it for a `bun test`
|
|
279
|
+
or a generation script. Given the catalogue and the `app` directory it verifies that every
|
|
280
|
+
`operation.ts` / `operations.ts` under `app/api/**` exports operations that are in the
|
|
281
|
+
catalogue and declare the path its folder serves (`[id]` ↔ `{id}`, `(group)` segments
|
|
282
|
+
ignored, catch-all segments rejected), that a sibling `route.ts` exists, that every
|
|
283
|
+
catalogue entry comes from such a file, and that every `route.ts` serves a catalogued path:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
// src/lib/api/routes.test.ts
|
|
287
|
+
import { checkNextAppRouterRoutes } from "@schemavaults/openapi-operations/nextjs/app-router-routes";
|
|
288
|
+
|
|
289
|
+
test("route files and the catalogue agree", async () => {
|
|
290
|
+
const report = await checkNextAppRouterRoutes({
|
|
291
|
+
operations, // the catalogue
|
|
292
|
+
appDirectory: path.resolve(import.meta.dir, "../../app"),
|
|
293
|
+
ignoredRoutePaths: ["/api/openapi.json"], // route files that are not operations
|
|
294
|
+
});
|
|
295
|
+
expect(report.problems).toEqual([]);
|
|
296
|
+
});
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
`assertNextAppRouterRoutes()` throws an `Error` listing every problem instead, for
|
|
300
|
+
scripts. Options: `apiDirectory` (default `api`), `operationFileNames` (default
|
|
301
|
+
`["operation.ts", "operations.ts"]`), `routeFileName`, `ignoredRouteDirectories` (e.g.
|
|
302
|
+
`api/auth/[...nextauth]`), `importModule` (default dynamic `import()`), `catalogueLabel`.
|
|
217
303
|
|
|
218
304
|
Per request the app: resolves the context, tries each accepted scheme's resolver in
|
|
219
305
|
order (401 + `WWW-Authenticate` if none yields a principal), enforces the route guard
|
|
220
306
|
(403), required scopes (403 `insufficient_scope`), organization membership (403), then
|
|
221
307
|
validates params/query/headers/body (400 with zod issues, 415 on media type mismatch)
|
|
222
308
|
and finally calls the handler. Errors use the `{ success: false, error, message }`
|
|
223
|
-
envelope.
|
|
309
|
+
envelope (`OperationErrorBodySchema`). An `OperationError` thrown from a different copy of
|
|
310
|
+
this package (isolated installs can load it twice) is recognised structurally
|
|
311
|
+
(`isOperationError()`), so it still short-circuits with its status and body.
|
|
224
312
|
|
|
225
313
|
## Scripts
|
|
226
314
|
|
package/dist/auth-scheme.d.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import type { SecuritySchemeObject } from "openapi3-ts/oas31";
|
|
2
2
|
import type { OrganizationMembershipRoleType } from "@schemavaults/auth-common/organizations";
|
|
3
|
+
/**
|
|
4
|
+
* What kind of principal a scheme's resolver produces:
|
|
5
|
+
*
|
|
6
|
+
* - `"user"`: the credential always identifies a user, so a principal
|
|
7
|
+
* resolved from it carries a non-null `user`. When every scheme an
|
|
8
|
+
* operation accepts is a user scheme, its handler sees `ctx.auth.user`
|
|
9
|
+
* typed as `TUser` (not `TUser | null`), and the runtime fails closed
|
|
10
|
+
* (401) should a resolver ever return a principal without a user.
|
|
11
|
+
* - `"any"` (the default): the principal may or may not be a user (client
|
|
12
|
+
* credentials, API keys, ...); handlers narrow `ctx.auth.user` themselves.
|
|
13
|
+
*/
|
|
14
|
+
export type AuthPrincipalKind = "user" | "any";
|
|
15
|
+
export declare const AUTH_PRINCIPAL_KINDS: readonly ["user", "any"];
|
|
16
|
+
export declare function isValidAuthPrincipalKind(value: unknown): value is AuthPrincipalKind;
|
|
3
17
|
/**
|
|
4
18
|
* A named OpenAPI security scheme (`components.securitySchemes[name]`) plus
|
|
5
19
|
* the metadata the SchemaVaults docs UI uses to explain how to authenticate.
|
|
@@ -10,9 +24,11 @@ import type { OrganizationMembershipRoleType } from "@schemavaults/auth-common/o
|
|
|
10
24
|
* server (which can decrypt its own tokens) and on a third-party resource
|
|
11
25
|
* server (which verifies them against the auth server's JWKS).
|
|
12
26
|
*/
|
|
13
|
-
export interface AuthSchemeDefinition<TName extends string = string> {
|
|
27
|
+
export interface AuthSchemeDefinition<TName extends string = string, TPrincipal extends AuthPrincipalKind = AuthPrincipalKind> {
|
|
14
28
|
/** Key under `components.securitySchemes` and in `security` requirements. */
|
|
15
29
|
readonly name: TName;
|
|
30
|
+
/** What the resolver of this scheme produces; see {@link AuthPrincipalKind}. Default `"any"`. */
|
|
31
|
+
readonly principal?: TPrincipal;
|
|
16
32
|
/** Human readable label for docs. */
|
|
17
33
|
readonly title: string;
|
|
18
34
|
/** Longer explanation for docs (where to get the credential, lifetime, ...). */
|
|
@@ -26,7 +42,16 @@ export interface AuthSchemeDefinition<TName extends string = string> {
|
|
|
26
42
|
*/
|
|
27
43
|
readonly challenge?: string;
|
|
28
44
|
}
|
|
29
|
-
export declare function defineAuthScheme<const TName extends string>(definition: AuthSchemeDefinition<TName>): AuthSchemeDefinition<TName>;
|
|
45
|
+
export declare function defineAuthScheme<const TName extends string, const TPrincipal extends AuthPrincipalKind = "any">(definition: AuthSchemeDefinition<TName, TPrincipal>): AuthSchemeDefinition<TName, TPrincipal>;
|
|
46
|
+
/** Whether a principal resolved from the scheme is guaranteed to carry a user. */
|
|
47
|
+
export declare function schemeResolvesUser(scheme: AuthSchemeDefinition): boolean;
|
|
48
|
+
/** A scheme whose resolver always produces a user principal. */
|
|
49
|
+
export type UserAuthSchemeDefinition<TName extends string = string> = AuthSchemeDefinition<TName, "user">;
|
|
50
|
+
/**
|
|
51
|
+
* `true` when every scheme in the (non-empty) list is a user scheme, so a
|
|
52
|
+
* principal resolved through any of them carries a non-null user.
|
|
53
|
+
*/
|
|
54
|
+
export type AllSchemesResolveUser<TSchemes extends readonly AuthSchemeDefinition[]> = TSchemes extends readonly [] ? false : TSchemes[number] extends UserAuthSchemeDefinition ? true : false;
|
|
30
55
|
/**
|
|
31
56
|
* Route guard levels mirroring `@schemavaults/auth-server-sdk`'s
|
|
32
57
|
* `withAuthenticatedApiRouteGuard` / `withAdminApiRouteGuard`.
|
|
@@ -54,9 +79,9 @@ export interface OrganizationRoleRequirement {
|
|
|
54
79
|
* `x-schemavaults-auth` vendor extension so docs can show the route guard
|
|
55
80
|
* and organization role in addition to the scopes.
|
|
56
81
|
*/
|
|
57
|
-
export interface AuthRequirements {
|
|
82
|
+
export interface AuthRequirements<TSchemes extends readonly AuthSchemeDefinition[] = readonly AuthSchemeDefinition[]> {
|
|
58
83
|
/** Schemes accepted for this operation (any one of them satisfies it). */
|
|
59
|
-
readonly schemes:
|
|
84
|
+
readonly schemes: TSchemes;
|
|
60
85
|
/** Who may call once authenticated. Defaults to "authenticated". */
|
|
61
86
|
readonly routeGuard?: RouteGuardType;
|
|
62
87
|
/**
|
|
@@ -74,28 +99,32 @@ export interface PublicOperationAuth {
|
|
|
74
99
|
readonly type: "public";
|
|
75
100
|
readonly notes?: string;
|
|
76
101
|
}
|
|
77
|
-
export interface RequiredOperationAuth extends AuthRequirements {
|
|
102
|
+
export interface RequiredOperationAuth<TSchemes extends readonly AuthSchemeDefinition[] = readonly AuthSchemeDefinition[]> extends AuthRequirements<TSchemes> {
|
|
78
103
|
readonly type: "required";
|
|
79
104
|
}
|
|
80
105
|
export type OperationAuth = PublicOperationAuth | RequiredOperationAuth;
|
|
81
106
|
/** Marks an operation as callable without credentials. */
|
|
82
107
|
export declare function publicAccess(notes?: string): PublicOperationAuth;
|
|
83
|
-
/**
|
|
84
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Marks an operation as requiring one of the given schemes (+ extra checks).
|
|
110
|
+
* The scheme list type is preserved so that operations accepting only
|
|
111
|
+
* `principal: "user"` schemes get a non-nullable `ctx.auth.user`.
|
|
112
|
+
*/
|
|
113
|
+
export declare function requireAuth<const TSchemes extends readonly AuthSchemeDefinition[]>(requirements: AuthRequirements<TSchemes>): RequiredOperationAuth<TSchemes>;
|
|
85
114
|
export declare function isPublicOperationAuth(auth: OperationAuth): auth is PublicOperationAuth;
|
|
86
|
-
/** `Authorization: Bearer <access token>` issued by the
|
|
87
|
-
export declare const schemaVaultsAccessTokenBearerScheme: AuthSchemeDefinition<"schemavaults-access-token">;
|
|
115
|
+
/** `Authorization: Bearer <access token>` issued by the auth server. */
|
|
116
|
+
export declare const schemaVaultsAccessTokenBearerScheme: AuthSchemeDefinition<"schemavaults-access-token", "user">;
|
|
88
117
|
/**
|
|
89
118
|
* The first-party access-token cookie set by the auth server / resource
|
|
90
119
|
* server SDK (`AccessTokenCookieName(api_server_id)`). The cookie name is
|
|
91
120
|
* per API server, so the concrete name is filled in by the caller.
|
|
92
121
|
*/
|
|
93
|
-
export declare function schemaVaultsAccessTokenCookieScheme(cookieName: string): AuthSchemeDefinition<"schemavaults-access-token-cookie">;
|
|
122
|
+
export declare function schemaVaultsAccessTokenCookieScheme(cookieName: string): AuthSchemeDefinition<"schemavaults-access-token-cookie", "user">;
|
|
94
123
|
/** The auth server's own refresh-token cookie (only meaningful ON the auth server). */
|
|
95
|
-
export declare function schemaVaultsRefreshTokenCookieScheme(cookieName: string): AuthSchemeDefinition<"schemavaults-refresh-token-cookie">;
|
|
124
|
+
export declare function schemaVaultsRefreshTokenCookieScheme(cookieName: string): AuthSchemeDefinition<"schemavaults-refresh-token-cookie", "user">;
|
|
96
125
|
/** OAuth 2.0 `client_secret_basic` client authentication (RFC 6749 §2.3.1). */
|
|
97
|
-
export declare const oidcClientSecretBasicScheme: AuthSchemeDefinition<"oidc-client-secret-basic">;
|
|
126
|
+
export declare const oidcClientSecretBasicScheme: AuthSchemeDefinition<"oidc-client-secret-basic", "any">;
|
|
98
127
|
/** OAuth 2.0 `client_secret_post` client authentication (credentials in the form body). */
|
|
99
|
-
export declare const oidcClientSecretPostScheme: AuthSchemeDefinition<"oidc-client-secret-post">;
|
|
128
|
+
export declare const oidcClientSecretPostScheme: AuthSchemeDefinition<"oidc-client-secret-post", "any">;
|
|
100
129
|
/** A static API key in a header (e.g. cron / internal automation). */
|
|
101
130
|
export declare function apiKeyHeaderScheme<const TName extends string>(name: TName, headerName: string, description?: string): AuthSchemeDefinition<TName>;
|
package/dist/auth-scheme.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
export const AUTH_PRINCIPAL_KINDS = ["user", "any"];
|
|
2
|
+
export function isValidAuthPrincipalKind(value) {
|
|
3
|
+
return typeof value === "string" && AUTH_PRINCIPAL_KINDS.includes(value);
|
|
4
|
+
}
|
|
1
5
|
export function defineAuthScheme(definition) {
|
|
2
6
|
if (typeof definition.name !== "string" || definition.name.length === 0) {
|
|
3
7
|
throw new TypeError("An auth scheme needs a non-empty name");
|
|
@@ -5,8 +9,15 @@ export function defineAuthScheme(definition) {
|
|
|
5
9
|
if (!/^[A-Za-z0-9._-]+$/.test(definition.name)) {
|
|
6
10
|
throw new TypeError(`Auth scheme name "${definition.name}" must match /^[A-Za-z0-9._-]+$/ (it becomes an OpenAPI component key)`);
|
|
7
11
|
}
|
|
12
|
+
if (definition.principal !== undefined && !isValidAuthPrincipalKind(definition.principal)) {
|
|
13
|
+
throw new TypeError(`Auth scheme "${definition.name}" has an unknown principal kind "${String(definition.principal)}" (expected one of: ${AUTH_PRINCIPAL_KINDS.join(", ")})`);
|
|
14
|
+
}
|
|
8
15
|
return Object.freeze({ ...definition });
|
|
9
16
|
}
|
|
17
|
+
/** Whether a principal resolved from the scheme is guaranteed to carry a user. */
|
|
18
|
+
export function schemeResolvesUser(scheme) {
|
|
19
|
+
return scheme.principal === "user";
|
|
20
|
+
}
|
|
10
21
|
/**
|
|
11
22
|
* Route guard levels mirroring `@schemavaults/auth-server-sdk`'s
|
|
12
23
|
* `withAuthenticatedApiRouteGuard` / `withAdminApiRouteGuard`.
|
|
@@ -16,7 +27,11 @@ export const ROUTE_GUARD_TYPES = ["authenticated", "admin"];
|
|
|
16
27
|
export function publicAccess(notes) {
|
|
17
28
|
return notes === undefined ? { type: "public" } : { type: "public", notes };
|
|
18
29
|
}
|
|
19
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Marks an operation as requiring one of the given schemes (+ extra checks).
|
|
32
|
+
* The scheme list type is preserved so that operations accepting only
|
|
33
|
+
* `principal: "user"` schemes get a non-nullable `ctx.auth.user`.
|
|
34
|
+
*/
|
|
20
35
|
export function requireAuth(requirements) {
|
|
21
36
|
if (!Array.isArray(requirements.schemes) || requirements.schemes.length === 0) {
|
|
22
37
|
throw new TypeError("requireAuth() needs at least one auth scheme; use publicAccess() for open operations");
|
|
@@ -40,16 +55,21 @@ export function isPublicOperationAuth(auth) {
|
|
|
40
55
|
// ---------------------------------------------------------------------------
|
|
41
56
|
// Built-in SchemaVaults schemes
|
|
42
57
|
// ---------------------------------------------------------------------------
|
|
43
|
-
|
|
58
|
+
// The auth server may be any deployment of @schemavaults/auth-server,
|
|
59
|
+
// including white-label ones, so the docs copy never names SchemaVaults as
|
|
60
|
+
// the issuer. The scheme names (and challenge realms) are protocol
|
|
61
|
+
// identifiers and stay as they are.
|
|
62
|
+
/** `Authorization: Bearer <access token>` issued by the auth server. */
|
|
44
63
|
export const schemaVaultsAccessTokenBearerScheme = defineAuthScheme({
|
|
45
64
|
name: "schemavaults-access-token",
|
|
46
|
-
|
|
47
|
-
|
|
65
|
+
principal: "user",
|
|
66
|
+
title: "Access token (Bearer)",
|
|
67
|
+
description: "An access token issued by the auth server for this API server, sent as `Authorization: Bearer <token>`.",
|
|
48
68
|
securityScheme: {
|
|
49
69
|
type: "http",
|
|
50
70
|
scheme: "bearer",
|
|
51
71
|
bearerFormat: "JWT",
|
|
52
|
-
description: "Access token issued by the
|
|
72
|
+
description: "Access token issued by the auth server for this API server.",
|
|
53
73
|
},
|
|
54
74
|
challenge: 'Bearer realm="schemavaults"',
|
|
55
75
|
});
|
|
@@ -61,13 +81,14 @@ export const schemaVaultsAccessTokenBearerScheme = defineAuthScheme({
|
|
|
61
81
|
export function schemaVaultsAccessTokenCookieScheme(cookieName) {
|
|
62
82
|
return defineAuthScheme({
|
|
63
83
|
name: "schemavaults-access-token-cookie",
|
|
64
|
-
|
|
84
|
+
principal: "user",
|
|
85
|
+
title: "Access token (cookie)",
|
|
65
86
|
description: `The first-party HTTP-only access token cookie \`${cookieName}\` set after login.`,
|
|
66
87
|
securityScheme: {
|
|
67
88
|
type: "apiKey",
|
|
68
89
|
in: "cookie",
|
|
69
90
|
name: cookieName,
|
|
70
|
-
description: "First-party access token cookie set
|
|
91
|
+
description: "First-party access token cookie set after login.",
|
|
71
92
|
},
|
|
72
93
|
});
|
|
73
94
|
}
|
|
@@ -75,7 +96,8 @@ export function schemaVaultsAccessTokenCookieScheme(cookieName) {
|
|
|
75
96
|
export function schemaVaultsRefreshTokenCookieScheme(cookieName) {
|
|
76
97
|
return defineAuthScheme({
|
|
77
98
|
name: "schemavaults-refresh-token-cookie",
|
|
78
|
-
|
|
99
|
+
principal: "user",
|
|
100
|
+
title: "Auth server session (refresh token cookie)",
|
|
79
101
|
description: `The auth server's HTTP-only refresh token cookie \`${cookieName}\`; only the auth server itself can resolve it.`,
|
|
80
102
|
securityScheme: {
|
|
81
103
|
type: "apiKey",
|
package/dist/auth-scheme.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-scheme.js","sourceRoot":"","sources":["../src/auth-scheme.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"auth-scheme.js","sourceRoot":"","sources":["../src/auth-scheme.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,12 +2,13 @@ export { z, withOpenApi } from "./zod-openapi";
|
|
|
2
2
|
export type { ZodType, ZodObject, OpenApiSchemaMetadata } from "./zod-openapi";
|
|
3
3
|
export { HTTP_METHODS, HTTP_METHODS_WITH_REQUEST_BODY, isHttpMethod } from "./http-method";
|
|
4
4
|
export type { HttpMethod } from "./http-method";
|
|
5
|
-
export { defineAuthScheme, publicAccess, requireAuth, isPublicOperationAuth, ROUTE_GUARD_TYPES, schemaVaultsAccessTokenBearerScheme, schemaVaultsAccessTokenCookieScheme, schemaVaultsRefreshTokenCookieScheme, oidcClientSecretBasicScheme, oidcClientSecretPostScheme, apiKeyHeaderScheme, } from "./auth-scheme";
|
|
6
|
-
export type { AuthSchemeDefinition, AuthRequirements, OperationAuth, PublicOperationAuth, RequiredOperationAuth, OrganizationRoleRequirement, RouteGuardType, } from "./auth-scheme";
|
|
5
|
+
export { defineAuthScheme, AUTH_PRINCIPAL_KINDS, isValidAuthPrincipalKind, schemeResolvesUser, publicAccess, requireAuth, isPublicOperationAuth, ROUTE_GUARD_TYPES, schemaVaultsAccessTokenBearerScheme, schemaVaultsAccessTokenCookieScheme, schemaVaultsRefreshTokenCookieScheme, oidcClientSecretBasicScheme, oidcClientSecretPostScheme, apiKeyHeaderScheme, } from "./auth-scheme";
|
|
6
|
+
export type { AllSchemesResolveUser, AuthPrincipalKind, AuthSchemeDefinition, AuthRequirements, UserAuthSchemeDefinition, OperationAuth, PublicOperationAuth, RequiredOperationAuth, OrganizationRoleRequirement, RouteGuardType, } from "./auth-scheme";
|
|
7
7
|
export { defineOperation, createOperationDefiner, defineOperationGroup, defaultOperationId, assertUniqueOperations, operationHttpMethods, } from "./operation";
|
|
8
|
-
export type { AnyOperationDefinition, AuthPrincipal, EmptyResponseStatusOf, InferBody, InferParsed, OperationDefiner, OperationDefinition, OperationGroup, OperationHandlerContext, OperationHandlerResult, OperationInput, OperationRequestDefinition, RequestBodyContentType, RequestBodyDefinition, ResponseBodyOf, ResponseDefinition, ResponseStatusOf, ResponsesDefinition, } from "./operation";
|
|
8
|
+
export type { AnyOperationDefinition, AuthPrincipal, EmptyResponseStatusOf, InferBody, InferParsed, OperationDefiner, OperationDefinition, OperationGroup, OperationHandlerContext, OperationHandlerResult, OperationInput, OperationRequestDefinition, RequestBodyContentType, RequestBodyDefinition, ResolvedAuthPrincipal, ResponseBodyOf, ResponseDefinition, ResponseStatusOf, ResponsesDefinition, UserAuthPrincipal, } from "./operation";
|
|
9
9
|
export { buildOpenApiDocument, collectAuthSchemes, toRouteConfig, toSecuritySchemeComponent, } from "./openapi/build-openapi-document";
|
|
10
10
|
export type { BuildOpenApiDocumentOptions } from "./openapi/build-openapi-document";
|
|
11
|
+
export { runtimeErrorResponses, withRuntimeErrorResponses } from "./openapi/runtime-responses";
|
|
11
12
|
export type { OpenAPIObject } from "openapi3-ts/oas31";
|
|
12
13
|
export { SCHEMAVAULTS_AUTH_EXTENSION, SCHEMAVAULTS_SCHEME_TITLE_EXTENSION, SCHEMAVAULTS_SCHEME_CHALLENGE_EXTENSION, toSchemaVaultsAuthExtension, isSchemaVaultsAuthExtension, } from "./openapi/extensions";
|
|
13
14
|
export type { SchemaVaultsAuthExtension } from "./openapi/extensions";
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export { z, withOpenApi } from "./zod-openapi";
|
|
2
2
|
export { HTTP_METHODS, HTTP_METHODS_WITH_REQUEST_BODY, isHttpMethod } from "./http-method";
|
|
3
|
-
export { defineAuthScheme, publicAccess, requireAuth, isPublicOperationAuth, ROUTE_GUARD_TYPES, schemaVaultsAccessTokenBearerScheme, schemaVaultsAccessTokenCookieScheme, schemaVaultsRefreshTokenCookieScheme, oidcClientSecretBasicScheme, oidcClientSecretPostScheme, apiKeyHeaderScheme, } from "./auth-scheme";
|
|
3
|
+
export { defineAuthScheme, AUTH_PRINCIPAL_KINDS, isValidAuthPrincipalKind, schemeResolvesUser, publicAccess, requireAuth, isPublicOperationAuth, ROUTE_GUARD_TYPES, schemaVaultsAccessTokenBearerScheme, schemaVaultsAccessTokenCookieScheme, schemaVaultsRefreshTokenCookieScheme, oidcClientSecretBasicScheme, oidcClientSecretPostScheme, apiKeyHeaderScheme, } from "./auth-scheme";
|
|
4
4
|
export { defineOperation, createOperationDefiner, defineOperationGroup, defaultOperationId, assertUniqueOperations, operationHttpMethods, } from "./operation";
|
|
5
5
|
export { buildOpenApiDocument, collectAuthSchemes, toRouteConfig, toSecuritySchemeComponent, } from "./openapi/build-openapi-document";
|
|
6
|
+
export { runtimeErrorResponses, withRuntimeErrorResponses } from "./openapi/runtime-responses";
|
|
6
7
|
export { SCHEMAVAULTS_AUTH_EXTENSION, SCHEMAVAULTS_SCHEME_TITLE_EXTENSION, SCHEMAVAULTS_SCHEME_CHALLENGE_EXTENSION, toSchemaVaultsAuthExtension, isSchemaVaultsAuthExtension, } from "./openapi/extensions";
|
|
7
8
|
export { extractPathParameterNames, openApiPathToHonoPath, honoPathToOpenApiPath, isOpenApiPath, } from "./openapi/path-format";
|
|
8
9
|
export * from "./runtime";
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAG/C,OAAO,EAAE,YAAY,EAAE,8BAA8B,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAG3F,OAAO,EACL,gBAAgB,EAChB,YAAY,EACZ,WAAW,EACX,qBAAqB,EACrB,iBAAiB,EACjB,mCAAmC,EACnC,mCAAmC,EACnC,oCAAoC,EACpC,2BAA2B,EAC3B,0BAA0B,EAC1B,kBAAkB,GACnB,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAG/C,OAAO,EAAE,YAAY,EAAE,8BAA8B,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAG3F,OAAO,EACL,gBAAgB,EAChB,oBAAoB,EACpB,wBAAwB,EACxB,kBAAkB,EAClB,YAAY,EACZ,WAAW,EACX,qBAAqB,EACrB,iBAAiB,EACjB,mCAAmC,EACnC,mCAAmC,EACnC,oCAAoC,EACpC,2BAA2B,EAC3B,0BAA0B,EAC1B,kBAAkB,GACnB,MAAM,eAAe,CAAC;AAcvB,OAAO,EACL,eAAe,EACf,sBAAsB,EACtB,oBAAoB,EACpB,kBAAkB,EAClB,sBAAsB,EACtB,oBAAoB,GACrB,MAAM,aAAa,CAAC;AAwBrB,OAAO,EACL,oBAAoB,EACpB,kBAAkB,EAClB,aAAa,EACb,yBAAyB,GAC1B,MAAM,kCAAkC,CAAC;AAE1C,OAAO,EAAE,qBAAqB,EAAE,yBAAyB,EAAE,MAAM,6BAA6B,CAAC;AAG/F,OAAO,EACL,2BAA2B,EAC3B,mCAAmC,EACnC,uCAAuC,EACvC,2BAA2B,EAC3B,2BAA2B,GAC5B,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EACL,yBAAyB,EACzB,qBAAqB,EACrB,qBAAqB,EACrB,aAAa,GACd,MAAM,uBAAuB,CAAC;AAE/B,cAAc,WAAW,CAAC;AAI1B,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEpD,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { AnyOperationDefinition } from "../operation";
|
|
2
|
+
export interface CheckNextAppRouterRoutesOptions {
|
|
3
|
+
/** The catalogue `buildOpenApiDocument()` / the app factory are given. */
|
|
4
|
+
readonly operations: readonly AnyOperationDefinition[];
|
|
5
|
+
/** The Next.js `app` directory (absolute, or relative to the working directory), e.g. `src/app`. */
|
|
6
|
+
readonly appDirectory: string;
|
|
7
|
+
/** Sub-directory of `appDirectory` holding the API route files. Default `api`. */
|
|
8
|
+
readonly apiDirectory?: string;
|
|
9
|
+
/**
|
|
10
|
+
* File names (beside a `route.ts`) that declare the operations the route
|
|
11
|
+
* serves. Default `["operation.ts", "operations.ts"]`.
|
|
12
|
+
*/
|
|
13
|
+
readonly operationFileNames?: readonly string[];
|
|
14
|
+
/** Default `route.ts`. */
|
|
15
|
+
readonly routeFileName?: string;
|
|
16
|
+
/**
|
|
17
|
+
* OpenAPI paths served by route files that are not operations (the route
|
|
18
|
+
* serving the OpenAPI document itself, a webhook with its own handler,
|
|
19
|
+
* ...). Such a route file needs no operation file.
|
|
20
|
+
*/
|
|
21
|
+
readonly ignoredRoutePaths?: readonly string[];
|
|
22
|
+
/**
|
|
23
|
+
* Directories (relative to `appDirectory`, `/`-separated, e.g.
|
|
24
|
+
* `api/auth/[...nextauth]`) whose route files are not checked at all.
|
|
25
|
+
*/
|
|
26
|
+
readonly ignoredRouteDirectories?: readonly string[];
|
|
27
|
+
/** Loads an operation module; default `import(fileUrl)`. */
|
|
28
|
+
readonly importModule?: (file: string) => Promise<Record<string, unknown>>;
|
|
29
|
+
/** How the catalogue is named in messages. Default `the operations catalogue`. */
|
|
30
|
+
readonly catalogueLabel?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface NextAppRouterRoutesReport {
|
|
33
|
+
readonly ok: boolean;
|
|
34
|
+
/** Human readable problems; empty when `ok`. */
|
|
35
|
+
readonly problems: readonly string[];
|
|
36
|
+
/** Operation files that were checked (absolute paths). */
|
|
37
|
+
readonly operationFiles: readonly string[];
|
|
38
|
+
/** Route files that were checked (absolute paths). */
|
|
39
|
+
readonly routeFiles: readonly string[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Route directory (relative to the app directory) → OpenAPI path:
|
|
43
|
+
* `(group)` segments dropped, `[param]` → `{param}`. Returns null for
|
|
44
|
+
* catch-all segments (`[...slug]`, `[[...slug]]`), which OpenAPI cannot
|
|
45
|
+
* describe.
|
|
46
|
+
*/
|
|
47
|
+
export declare function nextRouteDirectoryToOpenApiPath(routeDirectory: string): string | null;
|
|
48
|
+
/** OpenAPI path → route directory relative to the app directory: `/api/items/{id}` → `api/items/[id]`. */
|
|
49
|
+
export declare function openApiPathToNextRouteDirectory(openApiPath: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* Checks the route files against the catalogue and reports every problem
|
|
52
|
+
* found (never throws for layout problems; see {@link assertNextAppRouterRoutes}).
|
|
53
|
+
*/
|
|
54
|
+
export declare function checkNextAppRouterRoutes(options: CheckNextAppRouterRoutesOptions): Promise<NextAppRouterRoutesReport>;
|
|
55
|
+
/** {@link checkNextAppRouterRoutes} that throws an Error listing every problem. */
|
|
56
|
+
export declare function assertNextAppRouterRoutes(options: CheckNextAppRouterRoutesOptions): Promise<NextAppRouterRoutesReport>;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consistency check between an operations catalogue and the Next.js App
|
|
3
|
+
* Router files serving it, for the "one `route.ts` per operation" layout
|
|
4
|
+
* (see `createOperationsAppFactory()`):
|
|
5
|
+
*
|
|
6
|
+
* - every `operation.ts` / `operations.ts` under the API directory exports
|
|
7
|
+
* at least one operation, has a sibling `route.ts`, and each exported
|
|
8
|
+
* operation is in the catalogue and declares the path its directory
|
|
9
|
+
* serves (`app/api/items/[id]` ↔ `/api/items/{id}`, route groups
|
|
10
|
+
* `(group)` ignored, catch-all segments rejected);
|
|
11
|
+
* - every catalogue entry comes from such a file;
|
|
12
|
+
* - every `route.ts` under the API directory serves a catalogued path (or
|
|
13
|
+
* one listed in `ignoredRoutePaths`, e.g. the `openapi.json` route).
|
|
14
|
+
*
|
|
15
|
+
* Runs under `bun test` / a `bun` script (it reads the file system and
|
|
16
|
+
* imports the operation modules), not inside a request handler.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
19
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
21
|
+
import { assertUniqueOperations } from "../operation";
|
|
22
|
+
const DEFAULT_OPERATION_FILE_NAMES = ["operation.ts", "operations.ts"];
|
|
23
|
+
const DEFAULT_ROUTE_FILE_NAME = "route.ts";
|
|
24
|
+
/**
|
|
25
|
+
* Route directory (relative to the app directory) → OpenAPI path:
|
|
26
|
+
* `(group)` segments dropped, `[param]` → `{param}`. Returns null for
|
|
27
|
+
* catch-all segments (`[...slug]`, `[[...slug]]`), which OpenAPI cannot
|
|
28
|
+
* describe.
|
|
29
|
+
*/
|
|
30
|
+
export function nextRouteDirectoryToOpenApiPath(routeDirectory) {
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const segment of routeDirectory.split(/[\\/]/).filter((part) => part.length > 0)) {
|
|
33
|
+
if (segment.startsWith("(") && segment.endsWith(")"))
|
|
34
|
+
continue;
|
|
35
|
+
if (segment.startsWith("[...") || segment.startsWith("[[..."))
|
|
36
|
+
return null;
|
|
37
|
+
const param = /^\[([^\]]+)\]$/.exec(segment);
|
|
38
|
+
out.push(param ? `{${param[1]}}` : segment);
|
|
39
|
+
}
|
|
40
|
+
return `/${out.join("/")}`;
|
|
41
|
+
}
|
|
42
|
+
/** OpenAPI path → route directory relative to the app directory: `/api/items/{id}` → `api/items/[id]`. */
|
|
43
|
+
export function openApiPathToNextRouteDirectory(openApiPath) {
|
|
44
|
+
return openApiPath
|
|
45
|
+
.split("/")
|
|
46
|
+
.filter((segment) => segment.length > 0)
|
|
47
|
+
.map((segment) => segment.replace(/^\{([^}]+)\}$/, "[$1]"))
|
|
48
|
+
.join("/");
|
|
49
|
+
}
|
|
50
|
+
function isOperationDefinition(value) {
|
|
51
|
+
if (typeof value !== "object" || value === null)
|
|
52
|
+
return false;
|
|
53
|
+
const candidate = value;
|
|
54
|
+
return (typeof candidate.method === "string" &&
|
|
55
|
+
typeof candidate.path === "string" &&
|
|
56
|
+
typeof candidate.operationId === "string" &&
|
|
57
|
+
typeof candidate.handler === "function");
|
|
58
|
+
}
|
|
59
|
+
function findFiles(directory, names) {
|
|
60
|
+
if (!existsSync(directory))
|
|
61
|
+
return [];
|
|
62
|
+
const found = [];
|
|
63
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
64
|
+
// `_private` folders and node_modules are never routes.
|
|
65
|
+
if (entry.name.startsWith("_") || entry.name === "node_modules")
|
|
66
|
+
continue;
|
|
67
|
+
const full = join(directory, entry.name);
|
|
68
|
+
if (entry.isDirectory())
|
|
69
|
+
found.push(...findFiles(full, names));
|
|
70
|
+
else if (names.has(entry.name))
|
|
71
|
+
found.push(full);
|
|
72
|
+
}
|
|
73
|
+
return found.sort();
|
|
74
|
+
}
|
|
75
|
+
function toPosix(path) {
|
|
76
|
+
return path.split(sep).join("/");
|
|
77
|
+
}
|
|
78
|
+
async function defaultImportModule(file) {
|
|
79
|
+
return (await import(pathToFileURL(file).href));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Checks the route files against the catalogue and reports every problem
|
|
83
|
+
* found (never throws for layout problems; see {@link assertNextAppRouterRoutes}).
|
|
84
|
+
*/
|
|
85
|
+
export async function checkNextAppRouterRoutes(options) {
|
|
86
|
+
const appDirectory = resolve(options.appDirectory);
|
|
87
|
+
const apiDirectory = join(appDirectory, options.apiDirectory ?? "api");
|
|
88
|
+
const operationFileNames = new Set(options.operationFileNames ?? DEFAULT_OPERATION_FILE_NAMES);
|
|
89
|
+
const routeFileName = options.routeFileName ?? DEFAULT_ROUTE_FILE_NAME;
|
|
90
|
+
const ignoredRoutePaths = new Set(options.ignoredRoutePaths ?? []);
|
|
91
|
+
const ignoredRouteDirectories = new Set((options.ignoredRouteDirectories ?? []).map((directory) => toPosix(directory).replace(/^\/+|\/+$/g, "")));
|
|
92
|
+
const importModule = options.importModule ?? defaultImportModule;
|
|
93
|
+
const catalogueLabel = options.catalogueLabel ?? "the operations catalogue";
|
|
94
|
+
const cwd = process.cwd();
|
|
95
|
+
const label = (file) => toPosix(relative(cwd, file));
|
|
96
|
+
const routeDirectoryOf = (file) => toPosix(relative(appDirectory, dirname(file)));
|
|
97
|
+
const isIgnoredDirectory = (file) => ignoredRouteDirectories.has(routeDirectoryOf(file));
|
|
98
|
+
const problems = [];
|
|
99
|
+
try {
|
|
100
|
+
assertUniqueOperations(options.operations);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
problems.push(`${catalogueLabel}: ${error instanceof Error ? error.message : String(error)}`);
|
|
104
|
+
}
|
|
105
|
+
const catalogued = new Set(options.operations);
|
|
106
|
+
const cataloguedById = new Map(options.operations.map((operation) => [operation.operationId, operation]));
|
|
107
|
+
const cataloguedPaths = new Set(options.operations.map((operation) => operation.path));
|
|
108
|
+
if (!existsSync(apiDirectory)) {
|
|
109
|
+
problems.push(`API directory ${label(apiDirectory)} does not exist`);
|
|
110
|
+
}
|
|
111
|
+
const operationFiles = findFiles(apiDirectory, operationFileNames).filter((file) => !isIgnoredDirectory(file));
|
|
112
|
+
const routeFiles = findFiles(apiDirectory, new Set([routeFileName])).filter((file) => !isIgnoredDirectory(file));
|
|
113
|
+
const discovered = new Set();
|
|
114
|
+
for (const file of operationFiles) {
|
|
115
|
+
const directory = dirname(file);
|
|
116
|
+
const fileLabel = label(file);
|
|
117
|
+
if (!existsSync(join(directory, routeFileName))) {
|
|
118
|
+
problems.push(`${fileLabel}: no sibling ${routeFileName} serves these operations`);
|
|
119
|
+
}
|
|
120
|
+
const expectedPath = nextRouteDirectoryToOpenApiPath(routeDirectoryOf(file));
|
|
121
|
+
if (expectedPath === null) {
|
|
122
|
+
problems.push(`${fileLabel}: catch-all segments cannot be described in OpenAPI; use explicit [param] segments`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
let mod;
|
|
126
|
+
try {
|
|
127
|
+
mod = await importModule(file);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
problems.push(`${fileLabel}: failed to import (${error instanceof Error ? error.message : String(error)})`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const exported = Object.entries(mod).filter((entry) => isOperationDefinition(entry[1]));
|
|
134
|
+
if (exported.length === 0) {
|
|
135
|
+
problems.push(`${fileLabel}: exports no operations (export the result of defineOperation())`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
for (const [exportName, operation] of exported) {
|
|
139
|
+
const id = operation.operationId;
|
|
140
|
+
if (operation.path !== expectedPath) {
|
|
141
|
+
problems.push(`${fileLabel}: export '${exportName}' (${id}) declares path '${operation.path}' but its directory maps to '${expectedPath}'`);
|
|
142
|
+
}
|
|
143
|
+
if (catalogued.has(operation)) {
|
|
144
|
+
discovered.add(operation);
|
|
145
|
+
}
|
|
146
|
+
else if (cataloguedById.has(id)) {
|
|
147
|
+
problems.push(`${fileLabel}: export '${exportName}' (${id}) is a different object from the '${id}' listed in ${catalogueLabel} (defined twice?)`);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
problems.push(`${fileLabel}: export '${exportName}' (${id}) is not listed in ${catalogueLabel}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
for (const operation of catalogued) {
|
|
155
|
+
if (!discovered.has(operation)) {
|
|
156
|
+
const fileNames = [...operationFileNames].join(" / ");
|
|
157
|
+
problems.push(`${catalogueLabel}: '${operation.operationId}' (${operation.method.toUpperCase()} ${operation.path}) is not exported by any ${fileNames} under ${label(apiDirectory)}; define operations next to the ${routeFileName} that serves them`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
for (const file of routeFiles) {
|
|
161
|
+
const routePath = nextRouteDirectoryToOpenApiPath(routeDirectoryOf(file));
|
|
162
|
+
if (routePath === null) {
|
|
163
|
+
problems.push(`${label(file)}: catch-all route files cannot serve documented operations; list its directory in ignoredRouteDirectories if it is not one`);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (cataloguedPaths.has(routePath) || ignoredRoutePaths.has(routePath))
|
|
167
|
+
continue;
|
|
168
|
+
problems.push(`${label(file)}: serves '${routePath}', which is not in ${catalogueLabel} (add it to ignoredRoutePaths if it is not an operation)`);
|
|
169
|
+
}
|
|
170
|
+
return { ok: problems.length === 0, problems, operationFiles, routeFiles };
|
|
171
|
+
}
|
|
172
|
+
/** {@link checkNextAppRouterRoutes} that throws an Error listing every problem. */
|
|
173
|
+
export async function assertNextAppRouterRoutes(options) {
|
|
174
|
+
const report = await checkNextAppRouterRoutes(options);
|
|
175
|
+
if (!report.ok) {
|
|
176
|
+
throw new Error(`Next.js App Router route files and the operations catalogue disagree:\n${report.problems
|
|
177
|
+
.map((problem) => ` - ${problem}`)
|
|
178
|
+
.join("\n")}`);
|
|
179
|
+
}
|
|
180
|
+
return report;
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=app-router-routes.js.map
|