@facetlayer/prism-framework 0.4.0 → 0.4.1

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.
Files changed (130) hide show
  1. package/README.md +176 -8
  2. package/dist/Errors.d.ts +38 -0
  3. package/dist/Errors.d.ts.map +1 -0
  4. package/dist/Metrics.d.ts +5 -0
  5. package/dist/Metrics.d.ts.map +1 -0
  6. package/dist/RequestContext.d.ts +17 -0
  7. package/dist/RequestContext.d.ts.map +1 -0
  8. package/dist/ServiceDefinition.d.ts +16 -0
  9. package/dist/ServiceDefinition.d.ts.map +1 -0
  10. package/dist/app/PrismApp.d.ts +31 -0
  11. package/dist/app/PrismApp.d.ts.map +1 -0
  12. package/dist/app/callEndpoint.d.ts +13 -0
  13. package/dist/app/callEndpoint.d.ts.map +1 -0
  14. package/dist/app/validateApp.d.ts +20 -0
  15. package/dist/app/validateApp.d.ts.map +1 -0
  16. package/dist/authorization/AuthSource.d.ts +8 -0
  17. package/dist/authorization/AuthSource.d.ts.map +1 -0
  18. package/dist/authorization/Authorization.d.ts +24 -0
  19. package/dist/authorization/Authorization.d.ts.map +1 -0
  20. package/dist/authorization/Resource.d.ts +5 -0
  21. package/dist/authorization/Resource.d.ts.map +1 -0
  22. package/dist/authorization/index.d.ts +5 -0
  23. package/dist/authorization/index.d.ts.map +1 -0
  24. package/dist/cli.js +1 -1
  25. package/dist/databases/DatabaseInitializationOptions.d.ts +9 -0
  26. package/dist/databases/DatabaseInitializationOptions.d.ts.map +1 -0
  27. package/dist/databases/DatabaseSetup.d.ts +3 -0
  28. package/dist/databases/DatabaseSetup.d.ts.map +1 -0
  29. package/dist/endpoints/createEndpoint.d.ts +4 -0
  30. package/dist/endpoints/createEndpoint.d.ts.map +1 -0
  31. package/dist/endpoints/getEffectiveOperationId.d.ts +19 -0
  32. package/dist/endpoints/getEffectiveOperationId.d.ts.map +1 -0
  33. package/dist/env/Env.d.ts +2 -0
  34. package/dist/env/Env.d.ts.map +1 -0
  35. package/dist/index.d.ts +34 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +1364 -0
  38. package/dist/launch/launchConfig.d.ts +18 -0
  39. package/dist/launch/launchConfig.d.ts.map +1 -0
  40. package/dist/logging/index.d.ts +9 -0
  41. package/dist/logging/index.d.ts.map +1 -0
  42. package/dist/sse/ConnectionManager.d.ts +23 -0
  43. package/dist/sse/ConnectionManager.d.ts.map +1 -0
  44. package/dist/stdin/StdinServer.d.ts +38 -0
  45. package/dist/stdin/StdinServer.d.ts.map +1 -0
  46. package/dist/web/EndpointListing.d.ts +3 -0
  47. package/dist/web/EndpointListing.d.ts.map +1 -0
  48. package/dist/web/ExpressAppSetup.d.ts +18 -0
  49. package/dist/web/ExpressAppSetup.d.ts.map +1 -0
  50. package/dist/web/ExpressEndpointSetup.d.ts +31 -0
  51. package/dist/web/ExpressEndpointSetup.d.ts.map +1 -0
  52. package/dist/web/SseResponse.d.ts +15 -0
  53. package/dist/web/SseResponse.d.ts.map +1 -0
  54. package/dist/web/ViteIntegration.d.ts +19 -0
  55. package/dist/web/ViteIntegration.d.ts.map +1 -0
  56. package/dist/web/corsMiddleware.d.ts +14 -0
  57. package/dist/web/corsMiddleware.d.ts.map +1 -0
  58. package/dist/web/localhostOnlyMiddleware.d.ts +3 -0
  59. package/dist/web/localhostOnlyMiddleware.d.ts.map +1 -0
  60. package/dist/web/openapi/OpenAPI.d.ts +37 -0
  61. package/dist/web/openapi/OpenAPI.d.ts.map +1 -0
  62. package/dist/web/openapi/validateServicesForOpenapi.d.ts +32 -0
  63. package/dist/web/openapi/validateServicesForOpenapi.d.ts.map +1 -0
  64. package/dist/web/requestContextMiddleware.d.ts +3 -0
  65. package/dist/web/requestContextMiddleware.d.ts.map +1 -0
  66. package/docs/authorization.md +281 -0
  67. package/docs/cors-setup.md +172 -0
  68. package/docs/creating-services.md +220 -0
  69. package/docs/database-setup.md +134 -0
  70. package/docs/endpoint-tools.md +1 -11
  71. package/docs/env-files.md +12 -1
  72. package/docs/error-handling.md +70 -0
  73. package/docs/getting-started.md +22 -12
  74. package/docs/launch-configuration.md +223 -0
  75. package/docs/overview.md +62 -0
  76. package/docs/server-setup.md +144 -0
  77. package/docs/source-directory-organization.md +115 -0
  78. package/docs/stdin-protocol.md +176 -0
  79. package/package.json +42 -9
  80. package/src/Errors.ts +120 -0
  81. package/src/Metrics.ts +53 -0
  82. package/src/RequestContext.ts +36 -0
  83. package/src/ServiceDefinition.ts +35 -0
  84. package/src/__tests__/Authorization.test.ts +350 -0
  85. package/src/__tests__/Errors.test.ts +378 -0
  86. package/src/__tests__/ListEndpoints.test.ts +98 -0
  87. package/src/__tests__/PrismApp.test.ts +274 -0
  88. package/src/__tests__/RequestContext.test.ts +295 -0
  89. package/src/__tests__/SseResponse.test.ts +189 -0
  90. package/src/__tests__/StdinServer.test.ts +304 -0
  91. package/src/__tests__/corsMiddleware.test.ts +293 -0
  92. package/src/__tests__/createEndpoint.test.ts +412 -0
  93. package/src/__tests__/validateApp.test.ts +206 -0
  94. package/src/app/PrismApp.ts +117 -0
  95. package/src/app/callEndpoint.ts +55 -0
  96. package/src/app/validateApp.ts +78 -0
  97. package/src/authorization/AuthSource.ts +14 -0
  98. package/src/authorization/Authorization.ts +78 -0
  99. package/src/authorization/Resource.ts +8 -0
  100. package/src/authorization/index.ts +4 -0
  101. package/src/databases/DatabaseInitializationOptions.ts +9 -0
  102. package/src/databases/DatabaseSetup.ts +19 -0
  103. package/src/endpoints/createEndpoint.ts +39 -0
  104. package/src/endpoints/getEffectiveOperationId.ts +90 -0
  105. package/src/env/Env.ts +23 -0
  106. package/src/index.ts +78 -0
  107. package/src/launch/launchConfig.ts +59 -0
  108. package/src/list-endpoints-command.ts +1 -1
  109. package/src/logging/index.ts +25 -0
  110. package/src/sse/ConnectionManager.ts +79 -0
  111. package/src/stdin/StdinServer.ts +129 -0
  112. package/src/web/EndpointListing.ts +166 -0
  113. package/src/web/ExpressAppSetup.ts +125 -0
  114. package/src/web/ExpressEndpointSetup.ts +178 -0
  115. package/src/web/SseResponse.ts +78 -0
  116. package/src/web/ViteIntegration.ts +72 -0
  117. package/src/web/__tests__/OpenAPI.invalidZodSchemas.test.ts +250 -0
  118. package/src/web/corsMiddleware.ts +63 -0
  119. package/src/web/localhostOnlyMiddleware.ts +19 -0
  120. package/src/web/openapi/OpenAPI.ts +248 -0
  121. package/src/web/openapi/validateServicesForOpenapi.ts +76 -0
  122. package/src/web/requestContextMiddleware.ts +25 -0
  123. package/.claude/settings.local.json +0 -20
  124. package/CHANGELOG +0 -28
  125. package/CLAUDE.md +0 -44
  126. package/build.mts +0 -8
  127. package/test/call-command.test.ts +0 -96
  128. package/test/generate-api-clients.test.ts +0 -33
  129. package/test/generate-api-clients.test.ts.disabled +0 -75
  130. package/tsconfig.json +0 -21
@@ -0,0 +1,293 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { corsMiddleware, CorsConfig } from '../web/corsMiddleware.ts';
3
+ import { Request, Response, NextFunction } from 'express';
4
+
5
+ function createMockRequest(options: { origin?: string; method?: string } = {}): Partial<Request> {
6
+ return {
7
+ headers: {
8
+ origin: options.origin,
9
+ },
10
+ method: options.method ?? 'GET',
11
+ };
12
+ }
13
+
14
+ function createMockResponse(): { res: Partial<Response>; headers: Record<string, string> } {
15
+ const headers: Record<string, string> = {};
16
+ const res: Partial<Response> = {
17
+ header: vi.fn((key: string, value: string) => {
18
+ headers[key] = value;
19
+ return res as Response;
20
+ }),
21
+ sendStatus: vi.fn(),
22
+ };
23
+ return { res, headers };
24
+ }
25
+
26
+ describe('corsMiddleware', () => {
27
+ describe('default configuration', () => {
28
+ it('should work with no config (empty object)', () => {
29
+ const middleware = corsMiddleware();
30
+ const req = createMockRequest();
31
+ const { res, headers } = createMockResponse();
32
+ const next = vi.fn();
33
+
34
+ middleware(req as Request, res as Response, next as NextFunction);
35
+
36
+ expect(next).toHaveBeenCalled();
37
+ expect(headers['Access-Control-Allow-Credentials']).toBe('true');
38
+ expect(headers['Access-Control-Allow-Methods']).toBe('GET, POST, PUT, DELETE, OPTIONS, PATCH');
39
+ });
40
+
41
+ it('should work with undefined config', () => {
42
+ const middleware = corsMiddleware(undefined);
43
+ const req = createMockRequest();
44
+ const { res } = createMockResponse();
45
+ const next = vi.fn();
46
+
47
+ middleware(req as Request, res as Response, next as NextFunction);
48
+
49
+ expect(next).toHaveBeenCalled();
50
+ });
51
+
52
+ it('should work with empty config object', () => {
53
+ const middleware = corsMiddleware({});
54
+ const req = createMockRequest();
55
+ const { res } = createMockResponse();
56
+ const next = vi.fn();
57
+
58
+ middleware(req as Request, res as Response, next as NextFunction);
59
+
60
+ expect(next).toHaveBeenCalled();
61
+ });
62
+ });
63
+
64
+ describe('CORS headers', () => {
65
+ it('should set standard CORS headers', () => {
66
+ const middleware = corsMiddleware({});
67
+ const req = createMockRequest();
68
+ const { res, headers } = createMockResponse();
69
+ const next = vi.fn();
70
+
71
+ middleware(req as Request, res as Response, next as NextFunction);
72
+
73
+ expect(headers['Access-Control-Allow-Credentials']).toBe('true');
74
+ expect(headers['Access-Control-Allow-Methods']).toBe('GET, POST, PUT, DELETE, OPTIONS, PATCH');
75
+ expect(headers['Access-Control-Allow-Headers']).toContain('Content-Type');
76
+ expect(headers['Access-Control-Allow-Headers']).toContain('Authorization');
77
+ expect(headers['Access-Control-Max-Age']).toBe('86400');
78
+ });
79
+ });
80
+
81
+ describe('webBaseUrl configuration', () => {
82
+ it('should set ACAO header to webBaseUrl for requests without origin', () => {
83
+ const config: CorsConfig = { webBaseUrl: 'example.com' };
84
+ const middleware = corsMiddleware(config);
85
+ const req = createMockRequest({ origin: undefined });
86
+ const { res, headers } = createMockResponse();
87
+ const next = vi.fn();
88
+
89
+ middleware(req as Request, res as Response, next as NextFunction);
90
+
91
+ expect(headers['Access-Control-Allow-Origin']).toBe('example.com');
92
+ });
93
+
94
+ it('should allow origin matching https://webBaseUrl', () => {
95
+ const config: CorsConfig = { webBaseUrl: 'example.com' };
96
+ const middleware = corsMiddleware(config);
97
+ const req = createMockRequest({ origin: 'https://example.com' });
98
+ const { res, headers } = createMockResponse();
99
+ const next = vi.fn();
100
+
101
+ middleware(req as Request, res as Response, next as NextFunction);
102
+
103
+ expect(headers['Access-Control-Allow-Origin']).toBe('https://example.com');
104
+ });
105
+
106
+ it('should not set ACAO for non-matching origins', () => {
107
+ const config: CorsConfig = { webBaseUrl: 'example.com' };
108
+ const middleware = corsMiddleware(config);
109
+ const req = createMockRequest({ origin: 'https://evil.com' });
110
+ const { res, headers } = createMockResponse();
111
+ const next = vi.fn();
112
+
113
+ middleware(req as Request, res as Response, next as NextFunction);
114
+
115
+ expect(headers['Access-Control-Allow-Origin']).toBeUndefined();
116
+ });
117
+ });
118
+
119
+ describe('enableTestEndpoints configuration', () => {
120
+ it('should allow localhost origins when enableTestEndpoints is true', () => {
121
+ const config: CorsConfig = { enableTestEndpoints: true };
122
+ const middleware = corsMiddleware(config);
123
+ const req = createMockRequest({ origin: 'http://localhost:3000' });
124
+ const { res, headers } = createMockResponse();
125
+ const next = vi.fn();
126
+
127
+ middleware(req as Request, res as Response, next as NextFunction);
128
+
129
+ expect(headers['Access-Control-Allow-Origin']).toBe('http://localhost:3000');
130
+ });
131
+
132
+ it('should allow any localhost port when enableTestEndpoints is true', () => {
133
+ const config: CorsConfig = { enableTestEndpoints: true };
134
+ const middleware = corsMiddleware(config);
135
+ const ports = [3000, 4000, 5173, 8080];
136
+
137
+ for (const port of ports) {
138
+ const req = createMockRequest({ origin: `http://localhost:${port}` });
139
+ const { res, headers } = createMockResponse();
140
+ const next = vi.fn();
141
+
142
+ middleware(req as Request, res as Response, next as NextFunction);
143
+
144
+ expect(headers['Access-Control-Allow-Origin']).toBe(`http://localhost:${port}`);
145
+ }
146
+ });
147
+
148
+ it('should not allow localhost origins when enableTestEndpoints is false', () => {
149
+ const config: CorsConfig = { enableTestEndpoints: false };
150
+ const middleware = corsMiddleware(config);
151
+ const req = createMockRequest({ origin: 'http://localhost:3000' });
152
+ const { res, headers } = createMockResponse();
153
+ const next = vi.fn();
154
+
155
+ middleware(req as Request, res as Response, next as NextFunction);
156
+
157
+ expect(headers['Access-Control-Allow-Origin']).toBeUndefined();
158
+ });
159
+
160
+ it('should not allow localhost origins by default', () => {
161
+ const config: CorsConfig = {};
162
+ const middleware = corsMiddleware(config);
163
+ const req = createMockRequest({ origin: 'http://localhost:3000' });
164
+ const { res, headers } = createMockResponse();
165
+ const next = vi.fn();
166
+
167
+ middleware(req as Request, res as Response, next as NextFunction);
168
+
169
+ expect(headers['Access-Control-Allow-Origin']).toBeUndefined();
170
+ });
171
+ });
172
+
173
+ describe('OPTIONS preflight requests', () => {
174
+ it('should respond with 200 for OPTIONS requests', () => {
175
+ const middleware = corsMiddleware({});
176
+ const req = createMockRequest({ method: 'OPTIONS' });
177
+ const { res } = createMockResponse();
178
+ const next = vi.fn();
179
+
180
+ middleware(req as Request, res as Response, next as NextFunction);
181
+
182
+ expect(res.sendStatus).toHaveBeenCalledWith(200);
183
+ expect(next).not.toHaveBeenCalled();
184
+ });
185
+
186
+ it('should not call next() for OPTIONS requests', () => {
187
+ const middleware = corsMiddleware({});
188
+ const req = createMockRequest({ method: 'OPTIONS' });
189
+ const { res } = createMockResponse();
190
+ const next = vi.fn();
191
+
192
+ middleware(req as Request, res as Response, next as NextFunction);
193
+
194
+ expect(next).not.toHaveBeenCalled();
195
+ });
196
+
197
+ it('should call next() for non-OPTIONS requests', () => {
198
+ const middleware = corsMiddleware({});
199
+ const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
200
+
201
+ for (const method of methods) {
202
+ const req = createMockRequest({ method });
203
+ const { res } = createMockResponse();
204
+ const next = vi.fn();
205
+
206
+ middleware(req as Request, res as Response, next as NextFunction);
207
+
208
+ expect(next).toHaveBeenCalled();
209
+ }
210
+ });
211
+ });
212
+
213
+ describe('allowLocalhost configuration', () => {
214
+ it('should allow localhost origins when allowLocalhost is true', () => {
215
+ const config: CorsConfig = { allowLocalhost: true };
216
+ const middleware = corsMiddleware(config);
217
+ const req = createMockRequest({ origin: 'http://localhost:3000' });
218
+ const { res, headers } = createMockResponse();
219
+ const next = vi.fn();
220
+
221
+ middleware(req as Request, res as Response, next as NextFunction);
222
+
223
+ expect(headers['Access-Control-Allow-Origin']).toBe('http://localhost:3000');
224
+ });
225
+
226
+ it('should allow any localhost port when allowLocalhost is true', () => {
227
+ const config: CorsConfig = { allowLocalhost: true };
228
+ const middleware = corsMiddleware(config);
229
+ const ports = [3000, 4000, 5173, 8080];
230
+
231
+ for (const port of ports) {
232
+ const req = createMockRequest({ origin: `http://localhost:${port}` });
233
+ const { res, headers } = createMockResponse();
234
+ const next = vi.fn();
235
+
236
+ middleware(req as Request, res as Response, next as NextFunction);
237
+
238
+ expect(headers['Access-Control-Allow-Origin']).toBe(`http://localhost:${port}`);
239
+ }
240
+ });
241
+
242
+ it('should not allow localhost origins when allowLocalhost is false', () => {
243
+ const config: CorsConfig = { allowLocalhost: false };
244
+ const middleware = corsMiddleware(config);
245
+ const req = createMockRequest({ origin: 'http://localhost:3000' });
246
+ const { res, headers } = createMockResponse();
247
+ const next = vi.fn();
248
+
249
+ middleware(req as Request, res as Response, next as NextFunction);
250
+
251
+ expect(headers['Access-Control-Allow-Origin']).toBeUndefined();
252
+ });
253
+
254
+ it('should take precedence over enableTestEndpoints', () => {
255
+ // allowLocalhost: false should override enableTestEndpoints: true
256
+ const config: CorsConfig = { allowLocalhost: false, enableTestEndpoints: true };
257
+ const middleware = corsMiddleware(config);
258
+ const req = createMockRequest({ origin: 'http://localhost:3000' });
259
+ const { res, headers } = createMockResponse();
260
+ const next = vi.fn();
261
+
262
+ middleware(req as Request, res as Response, next as NextFunction);
263
+
264
+ expect(headers['Access-Control-Allow-Origin']).toBeUndefined();
265
+ });
266
+ });
267
+
268
+ describe('combined configuration', () => {
269
+ it('should handle webBaseUrl and enableTestEndpoints together', () => {
270
+ const config: CorsConfig = {
271
+ webBaseUrl: 'example.com',
272
+ enableTestEndpoints: true,
273
+ };
274
+ const middleware = corsMiddleware(config);
275
+
276
+ // Should allow webBaseUrl
277
+ {
278
+ const req = createMockRequest({ origin: 'https://example.com' });
279
+ const { res, headers } = createMockResponse();
280
+ middleware(req as Request, res as Response, vi.fn() as NextFunction);
281
+ expect(headers['Access-Control-Allow-Origin']).toBe('https://example.com');
282
+ }
283
+
284
+ // Should also allow localhost
285
+ {
286
+ const req = createMockRequest({ origin: 'http://localhost:3000' });
287
+ const { res, headers } = createMockResponse();
288
+ middleware(req as Request, res as Response, vi.fn() as NextFunction);
289
+ expect(headers['Access-Control-Allow-Origin']).toBe('http://localhost:3000');
290
+ }
291
+ });
292
+ });
293
+ });
@@ -0,0 +1,412 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { z } from 'zod';
3
+ import { createEndpoint, isValidOperationId, generateOperationIdFromPath, getEffectiveOperationId } from '../endpoints/createEndpoint.ts';
4
+ import { EndpointDefinition } from '../web/ExpressEndpointSetup.ts';
5
+
6
+ describe('createEndpoint', () => {
7
+ describe('valid endpoints', () => {
8
+ it('should return the endpoint definition unchanged for valid endpoints', () => {
9
+ const definition: EndpointDefinition = {
10
+ method: 'GET',
11
+ path: '/users',
12
+ description: 'Get all users',
13
+ responseSchema: z.array(z.object({ id: z.string(), name: z.string() })),
14
+ handler: async () => [{ id: '1', name: 'Test' }],
15
+ };
16
+
17
+ const result = createEndpoint(definition);
18
+
19
+ expect(result.method).toBe('GET');
20
+ expect(result.path).toBe('/users');
21
+ expect(result.description).toBe('Get all users');
22
+ expect(result.handler).toBe(definition.handler);
23
+ });
24
+
25
+ it('should allow endpoints with request and response schemas', () => {
26
+ const definition: EndpointDefinition = {
27
+ method: 'POST',
28
+ path: '/users',
29
+ requestSchema: z.object({ name: z.string(), email: z.string() }),
30
+ responseSchema: z.object({ id: z.string() }),
31
+ handler: async (input) => ({ id: 'new-id' }),
32
+ };
33
+
34
+ const result = createEndpoint(definition);
35
+
36
+ expect(result.handler).toBe(definition.handler);
37
+ });
38
+
39
+ it('should allow endpoints with path parameters', () => {
40
+ const definition: EndpointDefinition = {
41
+ method: 'GET',
42
+ path: '/users/:userId',
43
+ responseSchema: z.object({ id: z.string() }),
44
+ handler: async (input) => ({ id: input.userId }),
45
+ };
46
+
47
+ const result = createEndpoint(definition);
48
+
49
+ expect(result.handler).toBe(definition.handler);
50
+ });
51
+
52
+ it('should allow endpoints without schemas', () => {
53
+ const definition: EndpointDefinition = {
54
+ method: 'GET',
55
+ path: '/health',
56
+ handler: async () => ({ status: 'ok' }),
57
+ };
58
+
59
+ const result = createEndpoint(definition);
60
+
61
+ expect(result.handler).toBe(definition.handler);
62
+ });
63
+ });
64
+
65
+ describe('path validation', () => {
66
+ it('should allow endpoints with /api prefix', () => {
67
+ const originalHandler = async () => ({ message: 'test' });
68
+ const definition: EndpointDefinition = {
69
+ method: 'GET',
70
+ path: '/api/users',
71
+ handler: originalHandler,
72
+ };
73
+
74
+ const result = createEndpoint(definition);
75
+
76
+ expect(result.handler).toBe(originalHandler);
77
+ expect(result.path).toBe('/api/users');
78
+ });
79
+
80
+ it('should allow paths that contain /api anywhere', () => {
81
+ const originalHandler = async () => ({ message: 'test' });
82
+ const definition: EndpointDefinition = {
83
+ method: 'GET',
84
+ path: '/v1/api/users',
85
+ handler: originalHandler,
86
+ };
87
+
88
+ const result = createEndpoint(definition);
89
+
90
+ expect(result.handler).toBe(originalHandler);
91
+ });
92
+ });
93
+
94
+ describe('OpenAPI schema validation', () => {
95
+ it('should reject z.function() in request schema', () => {
96
+ const definition: EndpointDefinition = {
97
+ method: 'POST',
98
+ path: '/test-function',
99
+ requestSchema: z.object({
100
+ callback: z.function(),
101
+ }),
102
+ handler: async () => ({ success: true }),
103
+ };
104
+
105
+ const result = createEndpoint(definition);
106
+
107
+ // The replacement handler throws synchronously
108
+ expect(() => result.handler({})).toThrow(/Misconfigured endpoint/);
109
+ });
110
+
111
+ it('should reject z.instanceof() in request schema', () => {
112
+ const definition: EndpointDefinition = {
113
+ method: 'POST',
114
+ path: '/test-instanceof',
115
+ requestSchema: z.object({
116
+ date: z.instanceof(Date),
117
+ }),
118
+ handler: async () => ({ success: true }),
119
+ };
120
+
121
+ const result = createEndpoint(definition);
122
+
123
+ expect(() => result.handler({})).toThrow(/Misconfigured endpoint/);
124
+ });
125
+
126
+ it('should reject z.symbol() in response schema', () => {
127
+ const definition: EndpointDefinition = {
128
+ method: 'GET',
129
+ path: '/test-symbol',
130
+ responseSchema: z.object({
131
+ id: z.symbol(),
132
+ }),
133
+ handler: async () => ({ id: Symbol('test') }),
134
+ };
135
+
136
+ const result = createEndpoint(definition);
137
+
138
+ expect(() => result.handler({})).toThrow(/Misconfigured endpoint/);
139
+ });
140
+
141
+ it('should reject z.promise() in response schema', () => {
142
+ const definition: EndpointDefinition = {
143
+ method: 'GET',
144
+ path: '/test-promise',
145
+ responseSchema: z.promise(z.object({ data: z.string() })),
146
+ handler: async () => ({ data: 'test' }),
147
+ };
148
+
149
+ const result = createEndpoint(definition);
150
+
151
+ expect(() => result.handler({})).toThrow(/Misconfigured endpoint/);
152
+ });
153
+
154
+ it('should reject z.void() in response schema', () => {
155
+ const definition: EndpointDefinition = {
156
+ method: 'GET',
157
+ path: '/test-void',
158
+ responseSchema: z.void(),
159
+ handler: async () => undefined,
160
+ };
161
+
162
+ const result = createEndpoint(definition);
163
+
164
+ expect(() => result.handler({})).toThrow(/Misconfigured endpoint/);
165
+ });
166
+
167
+ it('should allow common Zod types that are OpenAPI compatible', () => {
168
+ const definition: EndpointDefinition = {
169
+ method: 'POST',
170
+ path: '/valid-schemas',
171
+ requestSchema: z.object({
172
+ name: z.string(),
173
+ age: z.number(),
174
+ active: z.boolean(),
175
+ tags: z.array(z.string()),
176
+ status: z.enum(['active', 'inactive']),
177
+ optional: z.string().optional(),
178
+ nullable: z.string().nullable(),
179
+ }),
180
+ responseSchema: z.object({
181
+ id: z.string(),
182
+ createdAt: z.string(),
183
+ }),
184
+ handler: async () => ({ id: '123', createdAt: new Date().toISOString() }),
185
+ };
186
+
187
+ const result = createEndpoint(definition);
188
+
189
+ // Handler should not be replaced for valid schemas
190
+ expect(result.handler).toBe(definition.handler);
191
+ });
192
+ });
193
+
194
+ describe('error message format', () => {
195
+ it('should include endpoint path in error message for schema validation', () => {
196
+ const definition: EndpointDefinition = {
197
+ method: 'GET',
198
+ path: '/schema-error-path',
199
+ responseSchema: z.symbol(),
200
+ handler: async () => Symbol('test'),
201
+ };
202
+
203
+ const result = createEndpoint(definition);
204
+
205
+ expect(() => result.handler({})).toThrow('/schema-error-path');
206
+ });
207
+ });
208
+
209
+ describe('operationId validation', () => {
210
+ it('should reject operationId "handler"', () => {
211
+ const definition: EndpointDefinition = {
212
+ method: 'GET',
213
+ path: '/test',
214
+ operationId: 'handler',
215
+ handler: async () => ({}),
216
+ };
217
+
218
+ const result = createEndpoint(definition);
219
+
220
+ expect(() => result.handler({})).toThrow('operationId "handler" is not allowed');
221
+ });
222
+
223
+ it('should reject operationId "anonymous"', () => {
224
+ const definition: EndpointDefinition = {
225
+ method: 'GET',
226
+ path: '/test',
227
+ operationId: 'anonymous',
228
+ handler: async () => ({}),
229
+ };
230
+
231
+ const result = createEndpoint(definition);
232
+
233
+ expect(() => result.handler({})).toThrow('operationId "anonymous" is not allowed');
234
+ });
235
+
236
+ it('should reject empty operationId', () => {
237
+ const definition: EndpointDefinition = {
238
+ method: 'GET',
239
+ path: '/test',
240
+ operationId: '',
241
+ handler: async () => ({}),
242
+ };
243
+
244
+ const result = createEndpoint(definition);
245
+
246
+ expect(() => result.handler({})).toThrow('operationId "" is not allowed');
247
+ });
248
+
249
+ it('should allow valid operationId', () => {
250
+ const originalHandler = async () => ({});
251
+ const definition: EndpointDefinition = {
252
+ method: 'GET',
253
+ path: '/test',
254
+ operationId: 'getTestData',
255
+ handler: originalHandler,
256
+ };
257
+
258
+ const result = createEndpoint(definition);
259
+
260
+ expect(result.handler).toBe(originalHandler);
261
+ });
262
+
263
+ it('should allow undefined operationId', () => {
264
+ const originalHandler = async () => ({});
265
+ const definition: EndpointDefinition = {
266
+ method: 'GET',
267
+ path: '/test',
268
+ handler: originalHandler,
269
+ };
270
+
271
+ const result = createEndpoint(definition);
272
+
273
+ expect(result.handler).toBe(originalHandler);
274
+ });
275
+ });
276
+ });
277
+
278
+ describe('isValidOperationId', () => {
279
+ it('should return true for undefined', () => {
280
+ expect(isValidOperationId(undefined)).toBe(true);
281
+ });
282
+
283
+ it('should return false for "handler"', () => {
284
+ expect(isValidOperationId('handler')).toBe(false);
285
+ });
286
+
287
+ it('should return false for "anonymous"', () => {
288
+ expect(isValidOperationId('anonymous')).toBe(false);
289
+ });
290
+
291
+ it('should return false for empty string', () => {
292
+ expect(isValidOperationId('')).toBe(false);
293
+ });
294
+
295
+ it('should return true for valid operationIds', () => {
296
+ expect(isValidOperationId('getUsers')).toBe(true);
297
+ expect(isValidOperationId('createUser')).toBe(true);
298
+ expect(isValidOperationId('deleteUserById')).toBe(true);
299
+ });
300
+ });
301
+
302
+ describe('generateOperationIdFromPath', () => {
303
+ it('should generate operationId from simple path', () => {
304
+ expect(generateOperationIdFromPath('GET', '/users')).toBe('getUsers');
305
+ });
306
+
307
+ it('should generate operationId from path with multiple segments', () => {
308
+ expect(generateOperationIdFromPath('POST', '/users/profile')).toBe('postUsersProfile');
309
+ });
310
+
311
+ it('should handle path parameters', () => {
312
+ expect(generateOperationIdFromPath('GET', '/users/:id')).toBe('getUsers_id');
313
+ });
314
+
315
+ it('should handle multiple path parameters', () => {
316
+ expect(generateOperationIdFromPath('PUT', '/users/:userId/posts/:postId')).toBe('putUsers_userIdPosts_postId');
317
+ });
318
+
319
+ it('should handle different HTTP methods', () => {
320
+ expect(generateOperationIdFromPath('DELETE', '/users/:id')).toBe('deleteUsers_id');
321
+ expect(generateOperationIdFromPath('PATCH', '/users/:id')).toBe('patchUsers_id');
322
+ });
323
+
324
+ it('should handle root path', () => {
325
+ expect(generateOperationIdFromPath('GET', '/')).toBe('get');
326
+ });
327
+
328
+ it('should convert hyphenated segments to camelCase', () => {
329
+ expect(generateOperationIdFromPath('GET', '/undo-redo-state')).toBe('getUndoRedoState');
330
+ });
331
+
332
+ it('should handle path with hyphenated segment after parameter', () => {
333
+ expect(generateOperationIdFromPath('GET', '/designs/:id/undo-redo-state')).toBe('getDesigns_idUndoRedoState');
334
+ });
335
+
336
+ it('should handle OpenAPI-style {id} path parameters', () => {
337
+ expect(generateOperationIdFromPath('GET', '/users/{id}')).toBe('getUsers_id');
338
+ });
339
+
340
+ it('should handle OpenAPI-style parameters with hyphenated paths', () => {
341
+ expect(generateOperationIdFromPath('GET', '/designer/designs/{id}/undo-redo-state')).toBe('getDesignerDesigns_idUndoRedoState');
342
+ });
343
+
344
+ it('should handle multiple hyphenated segments', () => {
345
+ expect(generateOperationIdFromPath('POST', '/user-profile/account-settings')).toBe('postUserProfileAccountSettings');
346
+ });
347
+
348
+ it('should handle hyphenated path parameters', () => {
349
+ expect(generateOperationIdFromPath('GET', '/users/:user-id/posts/:post-id')).toBe('getUsers_userIdPosts_postId');
350
+ });
351
+
352
+ it('should handle OpenAPI-style hyphenated path parameters', () => {
353
+ expect(generateOperationIdFromPath('GET', '/users/{user-id}/posts/{post-id}')).toBe('getUsers_userIdPosts_postId');
354
+ });
355
+ });
356
+
357
+ describe('getEffectiveOperationId', () => {
358
+ it('should use explicit operationId when provided', () => {
359
+ const definition: EndpointDefinition = {
360
+ method: 'GET',
361
+ path: '/users',
362
+ operationId: 'listAllUsers',
363
+ handler: async function getUsers() { return []; },
364
+ };
365
+
366
+ expect(getEffectiveOperationId(definition)).toBe('listAllUsers');
367
+ });
368
+
369
+ it('should use handler function name when operationId not provided', () => {
370
+ async function fetchUserProfile() { return {}; }
371
+ const definition: EndpointDefinition = {
372
+ method: 'GET',
373
+ path: '/users/:id',
374
+ handler: fetchUserProfile,
375
+ };
376
+
377
+ expect(getEffectiveOperationId(definition)).toBe('fetchUserProfile');
378
+ });
379
+
380
+ it('should fall back to auto-generated when handler name is "handler"', () => {
381
+ const definition: EndpointDefinition = {
382
+ method: 'GET',
383
+ path: '/users/:id',
384
+ handler: async function handler() { return {}; },
385
+ };
386
+
387
+ expect(getEffectiveOperationId(definition)).toBe('getUsers_id');
388
+ });
389
+
390
+ it('should fall back to auto-generated for arrow functions', () => {
391
+ const definition: EndpointDefinition = {
392
+ method: 'POST',
393
+ path: '/users',
394
+ handler: async () => ({}),
395
+ };
396
+
397
+ // Arrow functions have empty string as name
398
+ expect(getEffectiveOperationId(definition)).toBe('postUsers');
399
+ });
400
+
401
+ it('should ignore invalid explicit operationId and use handler name', () => {
402
+ async function createNewUser() { return {}; }
403
+ const definition: EndpointDefinition = {
404
+ method: 'POST',
405
+ path: '/users',
406
+ operationId: 'handler', // Invalid
407
+ handler: createNewUser,
408
+ };
409
+
410
+ expect(getEffectiveOperationId(definition)).toBe('createNewUser');
411
+ });
412
+ });