@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,125 @@
1
+ import cookieParser from 'cookie-parser';
2
+ import express from 'express';
3
+ import type { Server } from 'http';
4
+ import { PrismApp } from '../app/PrismApp.ts';
5
+ import { getMetrics } from '../Metrics.ts';
6
+ import { corsMiddleware, type CorsConfig } from './corsMiddleware.ts';
7
+ import { mountPrismApp, } from './ExpressEndpointSetup.ts';
8
+ import { localhostOnlyMiddleware } from './localhostOnlyMiddleware.ts';
9
+ import { requestContextMiddleware } from './requestContextMiddleware.ts';
10
+ import { mountOpenAPIEndpoints, type OpenAPIConfig } from './openapi/OpenAPI.ts';
11
+ import { createListingEndpoints } from './EndpointListing.ts';
12
+ import { captureError } from '@facetlayer/streams';
13
+ import { logError, logInfo } from '../logging/index.ts';
14
+ import { validateAppOrThrow } from '../app/validateApp.ts';
15
+ import { setupWebMiddleware, type WebConfig } from './ViteIntegration.ts';
16
+
17
+ export type { WebConfig } from './ViteIntegration.ts';
18
+
19
+ export interface ServerSetupConfig {
20
+ port: number;
21
+ app: PrismApp;
22
+ openapiConfig?: OpenAPIConfig;
23
+ corsConfig?: CorsConfig;
24
+ /** Serve web files alongside the API. When provided, API endpoints are mounted at /api/. */
25
+ web?: WebConfig;
26
+ }
27
+
28
+ export function createExpressApp(config: ServerSetupConfig): express.Application {
29
+ const app = express();
30
+
31
+ app.use(corsMiddleware(config.corsConfig ?? {}));
32
+ app.use(express.json());
33
+ app.use(express.urlencoded({ extended: true }));
34
+ app.use(requestContextMiddleware);
35
+ app.use(cookieParser());
36
+
37
+ // Create the API router - all API endpoints live under /api/
38
+ const apiRouter = express.Router();
39
+
40
+ // Health check endpoint
41
+ apiRouter.get('/health', localhostOnlyMiddleware, (req, res) => {
42
+ res.json({ status: 'ok', timestamp: new Date().toISOString() });
43
+ });
44
+
45
+ // Prometheus metrics endpoint (localhost only)
46
+ apiRouter.get('/metrics', localhostOnlyMiddleware, async (req, res) => {
47
+ try {
48
+ const metrics = await getMetrics();
49
+ res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
50
+ res.end(metrics);
51
+ } catch (error) {
52
+ res.status(500).json({ error: 'Failed to retrieve metrics' });
53
+ }
54
+ });
55
+
56
+ if (config.openapiConfig) {
57
+ mountOpenAPIEndpoints(config.openapiConfig, apiRouter, config.app);
58
+ }
59
+
60
+ // Mount endpoint listing endpoints
61
+ const allEndpoints = config.app.listAllEndpoints();
62
+ const listingEndpoints = createListingEndpoints(allEndpoints);
63
+ for (const endpoint of listingEndpoints) {
64
+ const handler = async (req: express.Request, res: express.Response) => {
65
+ try {
66
+ const result = await endpoint.handler({});
67
+ if (result.sendHttpResponse) {
68
+ result.sendHttpResponse(res);
69
+ } else {
70
+ res.json(result);
71
+ }
72
+ } catch (error) {
73
+ logError('Unhandled error in endpoint', {
74
+ endpointPath: endpoint.path,
75
+ error: captureError(error),
76
+ });
77
+ res.status(500).json({ error: 'Internal server error' });
78
+ }
79
+ };
80
+ if (endpoint.method === 'GET') {
81
+ apiRouter.get(endpoint.path, handler);
82
+ }
83
+ }
84
+
85
+ mountPrismApp(apiRouter, config.app);
86
+
87
+ // API 404 handler (only for /api/* routes)
88
+ apiRouter.use((req: express.Request, res: express.Response) => {
89
+ res.status(404).json({ error: "Not found" });
90
+ });
91
+
92
+ // Mount the API router at /api
93
+ app.use('/api', apiRouter);
94
+
95
+ return app;
96
+ }
97
+
98
+ export async function startServer(config: ServerSetupConfig): Promise<Server> {
99
+ // Validate app configuration before starting
100
+ validateAppOrThrow(config.app);
101
+
102
+ const port = config.port;
103
+
104
+ const app = createExpressApp(config);
105
+
106
+ // Set up web serving if configured (after API routes)
107
+ if (config.web) {
108
+ await setupWebMiddleware(app, config.web);
109
+ }
110
+
111
+ const server = app.listen(port, () => {
112
+ logInfo(`Server now listening on port ${port}`);
113
+ });
114
+
115
+ // Graceful shutdown
116
+ process.on('SIGTERM', () => {
117
+ logInfo('SIGTERM received, shutting down gracefully');
118
+ server.close(() => {
119
+ logInfo('Server closed');
120
+ process.exit(0);
121
+ });
122
+ });
123
+
124
+ return server;
125
+ }
@@ -0,0 +1,178 @@
1
+ import express, { type NextFunction, type Request, type Response } from 'express';
2
+ import { z } from 'zod';
3
+ import { isHttpError, NotFoundError } from '../Errors.ts';
4
+ import { recordHttpRequest, recordHttpResponse } from '../Metrics.ts';
5
+ import { getCurrentRequestContext } from '../RequestContext.ts';
6
+ import { SseResponse } from './SseResponse.ts';
7
+ import { PrismApp, endpointKey } from '../app/PrismApp.ts';
8
+ import { logError } from '../logging/index.ts';
9
+
10
+ export { createEndpoint } from '../endpoints/createEndpoint.ts';
11
+
12
+ type EndpointRequireOption = 'authenticated-user';
13
+
14
+ export interface EndpointDefinition {
15
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
16
+ path: string;
17
+ handler: (input: any) => Promise<any> | any;
18
+ requestSchema?: z.ZodSchema;
19
+ responseSchema?: z.ZodSchema;
20
+ description?: string;
21
+ requires?: EndpointRequireOption[];
22
+ /**
23
+ * Unique identifier for this endpoint in the OpenAPI schema.
24
+ * If not provided, one will be generated from the method and path.
25
+ * Must be unique across all endpoints in the app.
26
+ */
27
+ operationId?: string;
28
+ }
29
+
30
+ export function getRequestDataFromReq(req: Request): any {
31
+ let result = {};
32
+
33
+ if (req.body) {
34
+ result = { ...result, ...req.body };
35
+ }
36
+
37
+ if (req.params) {
38
+ result = { ...result, ...req.params };
39
+ }
40
+
41
+ if (req.query) {
42
+ result = { ...result, ...req.query };
43
+ }
44
+
45
+ return result;
46
+ }
47
+
48
+ function getOneHandler(
49
+ prismApp: PrismApp,
50
+ endpoint: { method: string; path: string }
51
+ ) {
52
+ return async (req: Request, res: Response, next: NextFunction) => {
53
+ const startTime = Date.now();
54
+ const method = req.method;
55
+ const path = req.path;
56
+
57
+ try {
58
+ recordHttpRequest(method, endpoint.path);
59
+
60
+ // TODO: Authentication check
61
+
62
+ const inputData = getRequestDataFromReq(req);
63
+
64
+ const result = await prismApp.callEndpoint({ method: endpoint.method, path: endpoint.path, input: inputData });
65
+
66
+ // TODO: Handle SSE response
67
+
68
+ res.status(200).json(result);
69
+ recordHttpResponse(endpoint.method, endpoint.path, 200, Date.now() - startTime);
70
+ return;
71
+ } catch (error) {
72
+
73
+ const endTime = Date.now();
74
+ const duration = endTime - startTime;
75
+
76
+ let statusCode = 500;
77
+
78
+ if (isHttpError(error)) {
79
+ statusCode = error.statusCode;
80
+
81
+ if (error.statusCode >= 500 || error.statusCode == null) {
82
+ logError(
83
+ `Server error in endpoint ${method} ${path}`,
84
+ {
85
+ path,
86
+ method,
87
+ errorMessage: error.message,
88
+ stack: error.stack,
89
+ },
90
+ error as Error
91
+ );
92
+ }
93
+
94
+ logDebug(`response ${error.statusCode}: ${method} ${path}`);
95
+ res.status(error.statusCode).json({
96
+ message: error.message,
97
+ details: error.details,
98
+ });
99
+ } else {
100
+ console.error('Unhandled error in endpoint', {
101
+ path,
102
+ method,
103
+ errorMessage: error.message,
104
+ stack: error.stack,
105
+ });
106
+ logError(
107
+ `Unhandled error in endpoint handler ${method} ${path}`,
108
+ {
109
+ path,
110
+ method,
111
+ errorMessage: error.message,
112
+ stack: error.stack,
113
+ },
114
+ error as Error
115
+ );
116
+ logDebug(`response 500: ${method} ${path}`);
117
+ res.status(500).json({
118
+ message: 'Internal Server Error',
119
+ });
120
+ }
121
+
122
+ recordHttpResponse(method, endpoint.path, statusCode, duration);
123
+ }
124
+ };
125
+ }
126
+
127
+ export function mountPrismApp(
128
+ app: express.Application | express.Router,
129
+ prismApp: PrismApp,
130
+ ): void {
131
+ const router = app as express.Router;
132
+
133
+ // Register each endpoint with Express to support path parameters
134
+ const endpoints = prismApp.listAllEndpoints();
135
+
136
+ for (const endpoint of endpoints) {
137
+ const handler = getOneHandler(prismApp, endpoint);
138
+
139
+ switch (endpoint.method) {
140
+ case 'GET':
141
+ router.get(endpoint.path, handler);
142
+ break;
143
+ case 'POST':
144
+ router.post(endpoint.path, handler);
145
+ break;
146
+ case 'PUT':
147
+ router.put(endpoint.path, handler);
148
+ break;
149
+ case 'DELETE':
150
+ router.delete(endpoint.path, handler);
151
+ break;
152
+ case 'PATCH':
153
+ router.patch(endpoint.path, handler);
154
+ break;
155
+ }
156
+ }
157
+ }
158
+
159
+ export function mountMiddleware(
160
+ app: express.Application,
161
+ middleware: { path: string; handler: (req: Request, res: Response, next: NextFunction) => void }
162
+ ): void {
163
+ app.use(middleware.path, middleware.handler);
164
+ }
165
+
166
+ export function mountMiddlewares(
167
+ app: express.Application,
168
+ middlewares: {
169
+ path: string;
170
+ handler: (req: Request, res: Response, next: NextFunction) => void;
171
+ }[]
172
+ ): void {
173
+ middlewares.forEach(middleware => mountMiddleware(app, middleware));
174
+ }
175
+ function logDebug(_message: string) {
176
+ // Debug logging - silently ignore for now
177
+ }
178
+
@@ -0,0 +1,78 @@
1
+ import type { Response } from 'express';
2
+
3
+ /*
4
+ SseResponse
5
+
6
+ Helper object used when sending an SSE response.
7
+ */
8
+
9
+ export class SseResponse {
10
+ public response: Response;
11
+ private isResponseOpen: boolean = true;
12
+ private _onClose: () => void;
13
+
14
+ constructor(response: Response) {
15
+ this.response = response;
16
+ this.setupSseHeaders();
17
+ this.setupCloseHandlers();
18
+ }
19
+
20
+ private setupSseHeaders(): void {
21
+ this.response.writeHead(200, {
22
+ 'Content-Type': 'text/event-stream',
23
+ 'Cache-Control': 'no-cache',
24
+ Connection: 'keep-alive',
25
+ });
26
+ }
27
+
28
+ private setupCloseHandlers(): void {
29
+ this.response.on('close', () => {
30
+ this._triggerOnClose();
31
+ });
32
+
33
+ this.response.on('finish', () => {
34
+ this._triggerOnClose();
35
+ });
36
+ }
37
+
38
+ send(data: object): void {
39
+ if (!this.isResponseOpen) {
40
+ return;
41
+ }
42
+
43
+ const jsonData = JSON.stringify(data);
44
+ this.response.write(`event: item\ndata: ${jsonData}\n\n`);
45
+ }
46
+
47
+ isOpen(): boolean {
48
+ return this.isResponseOpen;
49
+ }
50
+
51
+ // close() - Can be called by the handler to close the response.
52
+ close(): void {
53
+ if (this.isResponseOpen) {
54
+ // Send done event before closing
55
+ this.response.write(`event: done\n\n`);
56
+ this.response.end();
57
+
58
+ this._triggerOnClose();
59
+ }
60
+ }
61
+
62
+ // Internal: Called when the response is closed.
63
+ _triggerOnClose(): void {
64
+ this.isResponseOpen = false;
65
+ if (this._onClose) {
66
+ this._onClose();
67
+ this._onClose = null;
68
+ }
69
+ }
70
+
71
+ // Adds a callback to be called when the response is closed.
72
+ onClose(callback: () => void): void {
73
+ if (this._onClose) {
74
+ throw new Error('usage error: alrady have onClose callback');
75
+ }
76
+ this._onClose = callback;
77
+ }
78
+ }
@@ -0,0 +1,72 @@
1
+ /*
2
+ * ViteIntegration
3
+ *
4
+ * Provides web serving capabilities for Prism apps. In development mode,
5
+ * uses Vite's dev server middleware (if available) for HMR and fast builds.
6
+ * Falls back to serving static files when Vite is not installed or in production.
7
+ */
8
+
9
+ import express from 'express';
10
+ import { existsSync } from 'fs';
11
+ import { join, resolve } from 'path';
12
+ import { logInfo } from '../logging/index.ts';
13
+
14
+ export interface WebConfig {
15
+ /** Directory containing web files (index.html, etc.). In production, serves from dir/dist if it exists. */
16
+ dir: string;
17
+ }
18
+
19
+ /**
20
+ * Sets up web file serving on the Express app. This should be called AFTER
21
+ * API routes are mounted so that /api/* takes priority.
22
+ *
23
+ * In development (NODE_ENV !== 'production'):
24
+ * - Tries to use Vite dev server in middleware mode
25
+ * - Falls back to express.static if Vite is not installed
26
+ *
27
+ * In production:
28
+ * - Serves static files from dir/dist (or dir if dist doesn't exist)
29
+ * - SPA fallback: unmatched GET requests serve index.html
30
+ */
31
+ export async function setupWebMiddleware(
32
+ expressApp: express.Application,
33
+ webConfig: WebConfig,
34
+ ): Promise<void> {
35
+ const isDev = process.env.NODE_ENV !== 'production';
36
+ const webDir = resolve(webConfig.dir);
37
+
38
+ if (isDev) {
39
+ try {
40
+ // Dynamic import - vite is an optional peer dependency
41
+ const vite: any = await (Function('return import("vite")')());
42
+ const viteServer = await vite.createServer({
43
+ root: webDir,
44
+ server: { middlewareMode: true },
45
+ appType: 'spa',
46
+ });
47
+ expressApp.use(viteServer.middlewares);
48
+ logInfo(`Vite dev server middleware attached for ${webDir}`);
49
+ return;
50
+ } catch {
51
+ // Vite not available, fall back to static serving
52
+ }
53
+ }
54
+
55
+ // Static file serving (production, or dev without Vite)
56
+ const distDir = join(webDir, 'dist');
57
+ const serveDir = (!isDev && existsSync(distDir)) ? distDir : webDir;
58
+
59
+ expressApp.use(express.static(serveDir));
60
+
61
+ // SPA fallback: serve index.html for unmatched GET requests
62
+ const indexPath = join(serveDir, 'index.html');
63
+ expressApp.get('*', (req, res) => {
64
+ if (existsSync(indexPath)) {
65
+ res.sendFile(indexPath);
66
+ } else {
67
+ res.status(404).send('Not found');
68
+ }
69
+ });
70
+
71
+ logInfo(`Serving static files from ${serveDir}`);
72
+ }
@@ -0,0 +1,250 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { z } from 'zod';
3
+ import { validateServicesForOpenapi } from '../openapi/validateServicesForOpenapi.ts';
4
+ import { generateOpenAPISchema } from '../openapi/OpenAPI.ts';
5
+ import { ServiceDefinition } from '../../ServiceDefinition.ts';
6
+ import { createEndpoint } from '../../endpoints/createEndpoint.ts';
7
+ import { EndpointDefinition } from '../ExpressEndpointSetup.ts';
8
+
9
+ describe('validateServicesForOpenapi', () => {
10
+ it('should return empty array for valid schemas', () => {
11
+ const services: ServiceDefinition[] = [
12
+ {
13
+ name: 'test-service',
14
+ endpoints: [
15
+ createEndpoint({
16
+ method: 'GET',
17
+ path: '/test',
18
+ requestSchema: z.object({ id: z.string() }),
19
+ responseSchema: z.object({ result: z.string() }),
20
+ handler: async () => ({ result: 'ok' }),
21
+ }),
22
+ ],
23
+ },
24
+ ];
25
+
26
+ const result = validateServicesForOpenapi(services);
27
+
28
+ expect(result.problemEndpoints).toEqual([]);
29
+ });
30
+
31
+ it('should detect z.lazy() schemas as problematic when not using createEndpoint', () => {
32
+ // Test the raw validation function with endpoints that haven't been sanitized
33
+ interface TreeNode {
34
+ name: string;
35
+ children?: TreeNode[];
36
+ }
37
+
38
+ const TreeNodeSchema: z.ZodType<TreeNode> = z.lazy(() =>
39
+ z.object({
40
+ name: z.string(),
41
+ children: z.array(TreeNodeSchema).optional(),
42
+ })
43
+ );
44
+
45
+ // Create endpoint directly without createEndpoint to test raw validation
46
+ const rawEndpoint: EndpointDefinition = {
47
+ method: 'GET',
48
+ path: '/codebase/scan',
49
+ requestSchema: z.object({ directory: z.string() }),
50
+ responseSchema: z.object({ tree: TreeNodeSchema }),
51
+ handler: async () => ({ tree: { name: 'root' } }),
52
+ };
53
+
54
+ const services: ServiceDefinition[] = [
55
+ {
56
+ name: 'codebase',
57
+ endpoints: [rawEndpoint],
58
+ },
59
+ ];
60
+
61
+ const result = validateServicesForOpenapi(services);
62
+
63
+ expect(result.problemEndpoints).toHaveLength(1);
64
+ expect(result.problemEndpoints[0].path).toBe('/codebase/scan');
65
+ expect(result.problemEndpoints[0].method).toBe('GET');
66
+ expect(result.problemEndpoints[0].error.errorMessage).toContain('Unknown zod object type');
67
+ });
68
+
69
+ it('should return empty when createEndpoint sanitizes invalid schemas', () => {
70
+ // When using createEndpoint, invalid schemas are removed so validation passes
71
+ interface TreeNode {
72
+ name: string;
73
+ children?: TreeNode[];
74
+ }
75
+
76
+ const TreeNodeSchema: z.ZodType<TreeNode> = z.lazy(() =>
77
+ z.object({
78
+ name: z.string(),
79
+ children: z.array(TreeNodeSchema).optional(),
80
+ })
81
+ );
82
+
83
+ const services: ServiceDefinition[] = [
84
+ {
85
+ name: 'codebase',
86
+ endpoints: [
87
+ createEndpoint({
88
+ method: 'GET',
89
+ path: '/codebase/scan',
90
+ requestSchema: z.object({ directory: z.string() }),
91
+ responseSchema: z.object({ tree: TreeNodeSchema }),
92
+ handler: async () => ({ tree: { name: 'root' } }),
93
+ }),
94
+ ],
95
+ },
96
+ ];
97
+
98
+ // createEndpoint removes the invalid schemas, so validation passes
99
+ const result = validateServicesForOpenapi(services);
100
+ expect(result.problemEndpoints).toEqual([]);
101
+ });
102
+
103
+ it('should handle services with no endpoints', () => {
104
+ const services: ServiceDefinition[] = [
105
+ {
106
+ name: 'empty-service',
107
+ endpoints: [],
108
+ },
109
+ ];
110
+
111
+ const result = validateServicesForOpenapi(services);
112
+
113
+ expect(result.problemEndpoints).toEqual([]);
114
+ });
115
+
116
+ it('should handle undefined endpoints array', () => {
117
+ const services: ServiceDefinition[] = [
118
+ {
119
+ name: 'no-endpoints-service',
120
+ } as ServiceDefinition,
121
+ ];
122
+
123
+ const result = validateServicesForOpenapi(services);
124
+
125
+ expect(result.problemEndpoints).toEqual([]);
126
+ });
127
+ });
128
+
129
+ describe('generateOpenAPISchema', () => {
130
+ it('should succeed when createEndpoint sanitizes invalid schemas', () => {
131
+ // createEndpoint removes invalid schemas, so OpenAPI generation succeeds
132
+ interface TreeNode {
133
+ name: string;
134
+ children?: TreeNode[];
135
+ }
136
+
137
+ const TreeNodeSchema: z.ZodType<TreeNode> = z.lazy(() =>
138
+ z.object({
139
+ name: z.string(),
140
+ children: z.array(TreeNodeSchema).optional(),
141
+ })
142
+ );
143
+
144
+ const services: ServiceDefinition[] = [
145
+ {
146
+ name: 'test',
147
+ endpoints: [
148
+ createEndpoint({
149
+ method: 'GET',
150
+ path: '/tree',
151
+ responseSchema: z.object({ tree: TreeNodeSchema }),
152
+ handler: async () => ({ tree: { name: 'root' } }),
153
+ }),
154
+ ],
155
+ },
156
+ ];
157
+
158
+ // Should not throw because createEndpoint removed the invalid schema
159
+ const schema = generateOpenAPISchema(services, {
160
+ version: '1.0.0',
161
+ title: 'Test',
162
+ description: 'Test',
163
+ });
164
+
165
+ expect(schema.openapi).toBe('3.1.0');
166
+ expect(schema.paths!['/tree']).toBeDefined();
167
+ });
168
+
169
+ it('should throw for raw endpoints with invalid schemas', () => {
170
+ // Test that raw endpoints (not sanitized by createEndpoint) still throw
171
+ interface TreeNode {
172
+ name: string;
173
+ children?: TreeNode[];
174
+ }
175
+
176
+ const TreeNodeSchema: z.ZodType<TreeNode> = z.lazy(() =>
177
+ z.object({
178
+ name: z.string(),
179
+ children: z.array(TreeNodeSchema).optional(),
180
+ })
181
+ );
182
+
183
+ const rawEndpoint: EndpointDefinition = {
184
+ method: 'GET',
185
+ path: '/tree',
186
+ responseSchema: z.object({ tree: TreeNodeSchema }),
187
+ handler: async () => ({ tree: { name: 'root' } }),
188
+ };
189
+
190
+ const services: ServiceDefinition[] = [
191
+ {
192
+ name: 'test',
193
+ endpoints: [rawEndpoint],
194
+ },
195
+ ];
196
+
197
+ expect(() =>
198
+ generateOpenAPISchema(services, {
199
+ version: '1.0.0',
200
+ title: 'Test',
201
+ description: 'Test',
202
+ })
203
+ ).toThrow('Unknown zod object type');
204
+ });
205
+
206
+ it('should succeed with valid non-recursive schemas', () => {
207
+ const services: ServiceDefinition[] = [
208
+ {
209
+ name: 'test',
210
+ endpoints: [
211
+ createEndpoint({
212
+ method: 'GET',
213
+ path: '/users',
214
+ requestSchema: z.object({ id: z.string() }),
215
+ responseSchema: z.object({
216
+ user: z.object({
217
+ id: z.string(),
218
+ name: z.string(),
219
+ email: z.string(),
220
+ }),
221
+ }),
222
+ handler: async () => ({
223
+ user: { id: '1', name: 'Test', email: 'test@example.com' },
224
+ }),
225
+ }),
226
+ createEndpoint({
227
+ method: 'POST',
228
+ path: '/users',
229
+ requestSchema: z.object({
230
+ name: z.string(),
231
+ email: z.string(),
232
+ }),
233
+ responseSchema: z.object({ id: z.string() }),
234
+ handler: async () => ({ id: '1' }),
235
+ }),
236
+ ],
237
+ },
238
+ ];
239
+
240
+ const schema = generateOpenAPISchema(services, {
241
+ version: '1.0.0',
242
+ title: 'Test API',
243
+ description: 'Test API Description',
244
+ });
245
+
246
+ expect(schema.openapi).toBe('3.1.0');
247
+ expect(schema.info.title).toBe('Test API');
248
+ expect(schema.paths!['/users']).toBeDefined();
249
+ });
250
+ });