@prosopo/api-route 2.6.51 → 2.6.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,236 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ // This package ships one runtime value and otherwise consists of the contracts
16
+ // every API endpoint in the workspace implements. Its business logic *is* its
17
+ // types — in particular the conditional signature of processRequest — so these
18
+ // are the tests that actually cover it.
19
+
20
+ import type { Logger } from "@prosopo/logger";
21
+ import { assertType, describe, expectTypeOf, it } from "vitest";
22
+ import type { ZodType, z } from "zod";
23
+ import type {
24
+ ApiEndpoint,
25
+ ApiEndpointResponse,
26
+ ApiRouteLimit,
27
+ ApiRouteLimits,
28
+ ApiRoutes,
29
+ ApiRoutesProvider,
30
+ } from "../index.js";
31
+ import { ApiEndpointResponseStatus } from "../index.js";
32
+
33
+ type SiteKeySchema = ZodType<{ siteKey: string }>;
34
+
35
+ describe("ApiEndpoint.processRequest", () => {
36
+ it("takes the parsed arguments when the endpoint declares a schema", () => {
37
+ // The whole point of the conditional: an endpoint with a schema receives
38
+ // the inferred arguments, not a raw unknown it has to parse itself.
39
+ expectTypeOf<
40
+ ApiEndpoint<SiteKeySchema>["processRequest"]
41
+ >().parameters.toEqualTypeOf<
42
+ [z.infer<SiteKeySchema>, (Logger | undefined)?]
43
+ >();
44
+ });
45
+
46
+ it("takes no arguments at all when the schema is undefined", () => {
47
+ // The other branch. If this collapsed into the schema branch, every
48
+ // argument-free endpoint would be forced to accept a phantom first
49
+ // parameter.
50
+ expectTypeOf<
51
+ ApiEndpoint<undefined>["processRequest"]
52
+ >().parameters.toEqualTypeOf<[(Logger | undefined)?]>();
53
+ });
54
+
55
+ it("keeps the logger optional on both branches", () => {
56
+ // Callers that do not have a logger to hand must still be able to invoke
57
+ // an endpoint directly.
58
+ const withArgs: ApiEndpoint<SiteKeySchema>["processRequest"] =
59
+ async () => ({
60
+ status: ApiEndpointResponseStatus.SUCCESS,
61
+ });
62
+ expectTypeOf(withArgs).toBeCallableWith({ siteKey: "abc" });
63
+
64
+ const withoutArgs: ApiEndpoint<undefined>["processRequest"] = async () => ({
65
+ status: ApiEndpointResponseStatus.SUCCESS,
66
+ });
67
+ expectTypeOf(withoutArgs).toBeCallableWith();
68
+ });
69
+
70
+ it("always resolves to a response, never to void", () => {
71
+ // A void-returning endpoint would give the mounting layer nothing to
72
+ // serialise back to the client.
73
+ expectTypeOf<
74
+ ApiEndpoint<SiteKeySchema>["processRequest"]
75
+ >().returns.toEqualTypeOf<Promise<ApiEndpointResponse>>();
76
+ expectTypeOf<
77
+ ApiEndpoint<undefined>["processRequest"]
78
+ >().returns.toEqualTypeOf<Promise<ApiEndpointResponse>>();
79
+ });
80
+
81
+ it("rejects arguments that do not match the declared schema", () => {
82
+ const endpoint: ApiEndpoint<SiteKeySchema> = {
83
+ getRequestArgsSchema: (): SiteKeySchema => {
84
+ throw new Error("not called");
85
+ },
86
+ processRequest: async (): Promise<ApiEndpointResponse> => ({
87
+ status: ApiEndpointResponseStatus.SUCCESS,
88
+ }),
89
+ };
90
+ // @ts-expect-error siteKey is required by the schema
91
+ endpoint.processRequest({});
92
+ });
93
+
94
+ it("rejects an argument-free endpoint being called with arguments", () => {
95
+ const endpoint: ApiEndpoint<undefined> = {
96
+ getRequestArgsSchema: (): undefined => undefined,
97
+ processRequest: async (): Promise<ApiEndpointResponse> => ({
98
+ status: ApiEndpointResponseStatus.SUCCESS,
99
+ }),
100
+ };
101
+ // @ts-expect-error this endpoint declared no schema, so takes no args
102
+ endpoint.processRequest({ siteKey: "abc" });
103
+ });
104
+ });
105
+
106
+ describe("ApiEndpoint.getRequestArgsSchema", () => {
107
+ it("returns exactly the schema the endpoint was parameterised with", () => {
108
+ // Returning a widened ZodType would leave callers unable to infer the
109
+ // argument type from the schema they just fetched.
110
+ expectTypeOf<
111
+ ApiEndpoint<SiteKeySchema>["getRequestArgsSchema"]
112
+ >().returns.toEqualTypeOf<SiteKeySchema>();
113
+ expectTypeOf<
114
+ ApiEndpoint<undefined>["getRequestArgsSchema"]
115
+ >().returns.toEqualTypeOf<undefined>();
116
+ });
117
+
118
+ it("takes no arguments", () => {
119
+ expectTypeOf<
120
+ ApiEndpoint<undefined>["getRequestArgsSchema"]
121
+ >().parameters.toEqualTypeOf<[]>();
122
+ });
123
+ });
124
+
125
+ describe("ApiEndpointResponse", () => {
126
+ it("requires a status and nothing else", () => {
127
+ // A bare acknowledgement is a valid response.
128
+ assertType<ApiEndpointResponse>({
129
+ status: ApiEndpointResponseStatus.SUCCESS,
130
+ });
131
+ // @ts-expect-error status is mandatory
132
+ assertType<ApiEndpointResponse>({ data: {} });
133
+ });
134
+
135
+ it("keeps data and error independently optional", () => {
136
+ // Not modelled as a discriminated union, so an endpoint may legitimately
137
+ // return partial data alongside an error.
138
+ expectTypeOf<ApiEndpointResponse["data"]>().toEqualTypeOf<
139
+ object | undefined
140
+ >();
141
+ expectTypeOf<ApiEndpointResponse["error"]>().toEqualTypeOf<
142
+ string | undefined
143
+ >();
144
+ });
145
+
146
+ it("types the status as the enum, not a bare string", () => {
147
+ expectTypeOf<
148
+ ApiEndpointResponse["status"]
149
+ >().toEqualTypeOf<ApiEndpointResponseStatus>();
150
+ // @ts-expect-error an arbitrary string is not a status
151
+ assertType<ApiEndpointResponse>({ status: "OK" });
152
+ });
153
+
154
+ it("rejects an error that is not a string", () => {
155
+ assertType<ApiEndpointResponse>({
156
+ status: ApiEndpointResponseStatus.FAIL,
157
+ // @ts-expect-error error is a message, not an Error instance
158
+ error: new Error("boom"),
159
+ });
160
+ });
161
+ });
162
+
163
+ describe("ApiRoutes", () => {
164
+ it("is keyed by route string and valued by endpoints of any schema", () => {
165
+ expectTypeOf<ApiRoutes>().toEqualTypeOf<
166
+ Record<string, ApiEndpoint<ZodType | undefined>>
167
+ >();
168
+ });
169
+
170
+ it("accepts an empty record, for a provider with no endpoints yet", () => {
171
+ assertType<ApiRoutes>({});
172
+ });
173
+
174
+ it("rejects a plain function in place of an endpoint", () => {
175
+ // @ts-expect-error routes map to endpoint objects, not handlers
176
+ assertType<ApiRoutes>({ "/x": async (): Promise<void> => {} });
177
+ });
178
+ });
179
+
180
+ describe("ApiRoutesProvider", () => {
181
+ it("exposes the routes through a method, not a property", () => {
182
+ // A method lets a provider build its routes lazily, once its own
183
+ // dependencies are ready.
184
+ expectTypeOf<ApiRoutesProvider["getRoutes"]>().toEqualTypeOf<
185
+ () => ApiRoutes
186
+ >();
187
+ });
188
+ });
189
+
190
+ describe("ApiRouteLimits", () => {
191
+ it("requires a limit for every member of the route enum", () => {
192
+ // Partial coverage would leave a route silently unlimited.
193
+ enum Route {
194
+ Verify = "verify",
195
+ Submit = "submit",
196
+ }
197
+ assertType<ApiRouteLimits<Route>>({
198
+ [Route.Verify]: { windowMs: 1000, limit: 5 },
199
+ [Route.Submit]: { windowMs: 1000, limit: 5 },
200
+ });
201
+ // @ts-expect-error every route needs a limit
202
+ assertType<ApiRouteLimits<Route>>({
203
+ [Route.Verify]: { windowMs: 1000, limit: 5 },
204
+ });
205
+ });
206
+
207
+ it("requires both the window and the count on each limit", () => {
208
+ // A window without a count, or a count without a window, is not a rate
209
+ // limit — it is a config mistake that would otherwise reach production.
210
+ assertType<ApiRouteLimits<"verify">>({
211
+ // @ts-expect-error limit is required alongside windowMs
212
+ verify: { windowMs: 1000 },
213
+ });
214
+ assertType<ApiRouteLimits<"verify">>({
215
+ // @ts-expect-error windowMs is required alongside limit
216
+ verify: { limit: 5 },
217
+ });
218
+ });
219
+
220
+ it("exposes the entry type, so a consumer can name one limit", () => {
221
+ // Building a limits record entry by entry is the common case; without
222
+ // this export the shape has to be redeclared at every call site.
223
+ expectTypeOf<
224
+ ApiRouteLimits<"verify">["verify"]
225
+ >().toEqualTypeOf<ApiRouteLimit>();
226
+ assertType<ApiRouteLimit>({ windowMs: 1000, limit: 5 });
227
+ });
228
+
229
+ it("works with a numeric route enum as well as a string one", () => {
230
+ // The parameter is `string | number`, so both enum flavours are usable.
231
+ assertType<ApiRouteLimits<0 | 1>>({
232
+ 0: { windowMs: 1000, limit: 5 },
233
+ 1: { windowMs: 1000, limit: 5 },
234
+ });
235
+ });
236
+ });
@@ -0,0 +1,178 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ // This package is almost entirely types; the only runtime value it ships is the
16
+ // response status enum, and the only runtime behaviour is what its index
17
+ // re-exports. Both matter: the enum's string values travel over the wire to
18
+ // clients that compare them literally, and a dropped re-export is a breaking
19
+ // change that compiles fine inside this package. The contracts themselves are
20
+ // covered in apiRoute.test-d.ts.
21
+
22
+ import { describe, expect, it } from "vitest";
23
+ import { z } from "zod";
24
+ import * as api from "../index.js";
25
+ import type {
26
+ ApiEndpoint,
27
+ ApiEndpointResponse,
28
+ ApiRoutes,
29
+ ApiRoutesProvider,
30
+ } from "../index.js";
31
+ import { ApiEndpointResponseStatus } from "../index.js";
32
+
33
+ describe("ApiEndpointResponseStatus", () => {
34
+ it("keeps the exact strings clients compare against", () => {
35
+ // These cross the wire. Renaming a member is invisible to this package's
36
+ // own compiler but breaks every consumer reading the JSON.
37
+ expect(ApiEndpointResponseStatus.SUCCESS).toBe("SUCCESS");
38
+ expect(ApiEndpointResponseStatus.FAIL).toBe("FAIL");
39
+ expect(ApiEndpointResponseStatus.PROCESSING).toBe("PROCESSING");
40
+ });
41
+
42
+ it("declares exactly three statuses", () => {
43
+ // A fourth would need handling in every consumer's switch; catching the
44
+ // addition here forces that to be a deliberate change.
45
+ expect(Object.keys(ApiEndpointResponseStatus)).toEqual([
46
+ "SUCCESS",
47
+ "FAIL",
48
+ "PROCESSING",
49
+ ]);
50
+ });
51
+
52
+ it("is a string enum, so it has no reverse numeric mapping", () => {
53
+ // Numeric enums gain reverse keys, which would make Object.keys above and
54
+ // any `key in Status` check behave differently.
55
+ expect(Object.values(ApiEndpointResponseStatus)).toEqual([
56
+ "SUCCESS",
57
+ "FAIL",
58
+ "PROCESSING",
59
+ ]);
60
+ });
61
+
62
+ it("distinguishes failure from a still-running request", () => {
63
+ // PROCESSING is not a terminal state; conflating it with FAIL would have
64
+ // callers give up on work that is still in flight.
65
+ expect(ApiEndpointResponseStatus.PROCESSING).not.toBe(
66
+ ApiEndpointResponseStatus.FAIL,
67
+ );
68
+ expect(ApiEndpointResponseStatus.PROCESSING).not.toBe(
69
+ ApiEndpointResponseStatus.SUCCESS,
70
+ );
71
+ });
72
+ });
73
+
74
+ describe("public export surface", () => {
75
+ it("re-exports the status enum from the package root", () => {
76
+ // Consumers import from "@prosopo/api-route", not the inner paths.
77
+ expect(api.ApiEndpointResponseStatus).toBe(ApiEndpointResponseStatus);
78
+ });
79
+
80
+ it("ships the enum as its only runtime export", () => {
81
+ // Everything else is type-only and must erase completely; a stray runtime
82
+ // value here would mean the package cannot be fully tree-shaken away.
83
+ expect(Object.keys(api)).toEqual(["ApiEndpointResponseStatus"]);
84
+ });
85
+ });
86
+
87
+ describe("endpoint contract", () => {
88
+ /** An endpoint whose request carries arguments. */
89
+ const schema = z.object({ siteKey: z.string() });
90
+
91
+ const endpointWithArgs: ApiEndpoint<typeof schema> = {
92
+ getRequestArgsSchema: () => schema,
93
+ processRequest: async (
94
+ args: z.infer<typeof schema>,
95
+ ): Promise<ApiEndpointResponse> => ({
96
+ status: ApiEndpointResponseStatus.SUCCESS,
97
+ data: { siteKey: args.siteKey },
98
+ }),
99
+ };
100
+
101
+ const endpointWithoutArgs: ApiEndpoint<undefined> = {
102
+ getRequestArgsSchema: () => undefined,
103
+ processRequest: async (): Promise<ApiEndpointResponse> => ({
104
+ status: ApiEndpointResponseStatus.SUCCESS,
105
+ }),
106
+ };
107
+
108
+ it("lets an implementation validate its own arguments with its schema", () => {
109
+ // The point of getRequestArgsSchema: a caller can parse untrusted input
110
+ // against it before ever invoking processRequest.
111
+ const parsed = endpointWithArgs
112
+ .getRequestArgsSchema()
113
+ .safeParse({ siteKey: "abc" });
114
+ expect(parsed.success).toBe(true);
115
+ });
116
+
117
+ it("rejects arguments the endpoint's own schema does not accept", () => {
118
+ const parsed = endpointWithArgs.getRequestArgsSchema().safeParse({});
119
+ expect(parsed.success).toBe(false);
120
+ });
121
+
122
+ it("allows an endpoint that takes no arguments at all", async () => {
123
+ // The `undefined` branch exists precisely so such an endpoint does not
124
+ // have to invent an empty schema.
125
+ expect(endpointWithoutArgs.getRequestArgsSchema()).toBeUndefined();
126
+ await expect(endpointWithoutArgs.processRequest()).resolves.toEqual({
127
+ status: ApiEndpointResponseStatus.SUCCESS,
128
+ });
129
+ });
130
+
131
+ it("carries the request arguments through to the response", async () => {
132
+ await expect(
133
+ endpointWithArgs.processRequest({ siteKey: "abc" }),
134
+ ).resolves.toEqual({
135
+ status: ApiEndpointResponseStatus.SUCCESS,
136
+ data: { siteKey: "abc" },
137
+ });
138
+ });
139
+
140
+ it("permits a response with neither data nor error", async () => {
141
+ // status is the only required field; a bare acknowledgement is valid.
142
+ const response: ApiEndpointResponse =
143
+ await endpointWithoutArgs.processRequest();
144
+ expect(response.data).toBeUndefined();
145
+ expect(response.error).toBeUndefined();
146
+ });
147
+
148
+ it("holds endpoints with differing schemas in one routes record", () => {
149
+ // ApiRoutes is keyed by string and valued by endpoints of any schema, so
150
+ // a provider can mix argument-taking and argument-free endpoints.
151
+ const routes: ApiRoutes = {
152
+ "/with": endpointWithArgs,
153
+ "/without": endpointWithoutArgs,
154
+ };
155
+ const provider: ApiRoutesProvider = { getRoutes: () => routes };
156
+
157
+ expect(Object.keys(provider.getRoutes())).toEqual(["/with", "/without"]);
158
+ });
159
+
160
+ it("tolerates a provider that exposes no routes", () => {
161
+ // Length 0: a provider registered before its endpoints exist must not be
162
+ // a special case for whatever iterates the record.
163
+ const provider: ApiRoutesProvider = { getRoutes: () => ({}) };
164
+ expect(Object.keys(provider.getRoutes())).toHaveLength(0);
165
+ });
166
+
167
+ it("surfaces a rejection rather than swallowing it into a FAIL response", async () => {
168
+ // Nothing in this contract catches; a throwing endpoint rejects, and the
169
+ // layer that mounts it is responsible for turning that into a response.
170
+ const throwing: ApiEndpoint<undefined> = {
171
+ getRequestArgsSchema: () => undefined,
172
+ processRequest: async (): Promise<ApiEndpointResponse> => {
173
+ throw new Error("boom");
174
+ },
175
+ };
176
+ await expect(throwing.processRequest()).rejects.toThrow("boom");
177
+ });
178
+ });