@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.
- package/README.md +176 -8
- package/dist/Errors.d.ts +38 -0
- package/dist/Errors.d.ts.map +1 -0
- package/dist/Metrics.d.ts +5 -0
- package/dist/Metrics.d.ts.map +1 -0
- package/dist/RequestContext.d.ts +17 -0
- package/dist/RequestContext.d.ts.map +1 -0
- package/dist/ServiceDefinition.d.ts +16 -0
- package/dist/ServiceDefinition.d.ts.map +1 -0
- package/dist/app/PrismApp.d.ts +31 -0
- package/dist/app/PrismApp.d.ts.map +1 -0
- package/dist/app/callEndpoint.d.ts +13 -0
- package/dist/app/callEndpoint.d.ts.map +1 -0
- package/dist/app/validateApp.d.ts +20 -0
- package/dist/app/validateApp.d.ts.map +1 -0
- package/dist/authorization/AuthSource.d.ts +8 -0
- package/dist/authorization/AuthSource.d.ts.map +1 -0
- package/dist/authorization/Authorization.d.ts +24 -0
- package/dist/authorization/Authorization.d.ts.map +1 -0
- package/dist/authorization/Resource.d.ts +5 -0
- package/dist/authorization/Resource.d.ts.map +1 -0
- package/dist/authorization/index.d.ts +5 -0
- package/dist/authorization/index.d.ts.map +1 -0
- package/dist/cli.js +1 -1
- package/dist/databases/DatabaseInitializationOptions.d.ts +9 -0
- package/dist/databases/DatabaseInitializationOptions.d.ts.map +1 -0
- package/dist/databases/DatabaseSetup.d.ts +3 -0
- package/dist/databases/DatabaseSetup.d.ts.map +1 -0
- package/dist/endpoints/createEndpoint.d.ts +4 -0
- package/dist/endpoints/createEndpoint.d.ts.map +1 -0
- package/dist/endpoints/getEffectiveOperationId.d.ts +19 -0
- package/dist/endpoints/getEffectiveOperationId.d.ts.map +1 -0
- package/dist/env/Env.d.ts +2 -0
- package/dist/env/Env.d.ts.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1364 -0
- package/dist/launch/launchConfig.d.ts +18 -0
- package/dist/launch/launchConfig.d.ts.map +1 -0
- package/dist/logging/index.d.ts +9 -0
- package/dist/logging/index.d.ts.map +1 -0
- package/dist/sse/ConnectionManager.d.ts +23 -0
- package/dist/sse/ConnectionManager.d.ts.map +1 -0
- package/dist/stdin/StdinServer.d.ts +38 -0
- package/dist/stdin/StdinServer.d.ts.map +1 -0
- package/dist/web/EndpointListing.d.ts +3 -0
- package/dist/web/EndpointListing.d.ts.map +1 -0
- package/dist/web/ExpressAppSetup.d.ts +18 -0
- package/dist/web/ExpressAppSetup.d.ts.map +1 -0
- package/dist/web/ExpressEndpointSetup.d.ts +31 -0
- package/dist/web/ExpressEndpointSetup.d.ts.map +1 -0
- package/dist/web/SseResponse.d.ts +15 -0
- package/dist/web/SseResponse.d.ts.map +1 -0
- package/dist/web/ViteIntegration.d.ts +19 -0
- package/dist/web/ViteIntegration.d.ts.map +1 -0
- package/dist/web/corsMiddleware.d.ts +14 -0
- package/dist/web/corsMiddleware.d.ts.map +1 -0
- package/dist/web/localhostOnlyMiddleware.d.ts +3 -0
- package/dist/web/localhostOnlyMiddleware.d.ts.map +1 -0
- package/dist/web/openapi/OpenAPI.d.ts +37 -0
- package/dist/web/openapi/OpenAPI.d.ts.map +1 -0
- package/dist/web/openapi/validateServicesForOpenapi.d.ts +32 -0
- package/dist/web/openapi/validateServicesForOpenapi.d.ts.map +1 -0
- package/dist/web/requestContextMiddleware.d.ts +3 -0
- package/dist/web/requestContextMiddleware.d.ts.map +1 -0
- package/docs/authorization.md +281 -0
- package/docs/cors-setup.md +172 -0
- package/docs/creating-services.md +220 -0
- package/docs/database-setup.md +134 -0
- package/docs/endpoint-tools.md +1 -11
- package/docs/env-files.md +12 -1
- package/docs/error-handling.md +70 -0
- package/docs/getting-started.md +22 -12
- package/docs/launch-configuration.md +223 -0
- package/docs/overview.md +62 -0
- package/docs/server-setup.md +144 -0
- package/docs/source-directory-organization.md +115 -0
- package/docs/stdin-protocol.md +176 -0
- package/package.json +42 -9
- package/src/Errors.ts +120 -0
- package/src/Metrics.ts +53 -0
- package/src/RequestContext.ts +36 -0
- package/src/ServiceDefinition.ts +35 -0
- package/src/__tests__/Authorization.test.ts +350 -0
- package/src/__tests__/Errors.test.ts +378 -0
- package/src/__tests__/ListEndpoints.test.ts +98 -0
- package/src/__tests__/PrismApp.test.ts +274 -0
- package/src/__tests__/RequestContext.test.ts +295 -0
- package/src/__tests__/SseResponse.test.ts +189 -0
- package/src/__tests__/StdinServer.test.ts +304 -0
- package/src/__tests__/corsMiddleware.test.ts +293 -0
- package/src/__tests__/createEndpoint.test.ts +412 -0
- package/src/__tests__/validateApp.test.ts +206 -0
- package/src/app/PrismApp.ts +117 -0
- package/src/app/callEndpoint.ts +55 -0
- package/src/app/validateApp.ts +78 -0
- package/src/authorization/AuthSource.ts +14 -0
- package/src/authorization/Authorization.ts +78 -0
- package/src/authorization/Resource.ts +8 -0
- package/src/authorization/index.ts +4 -0
- package/src/databases/DatabaseInitializationOptions.ts +9 -0
- package/src/databases/DatabaseSetup.ts +19 -0
- package/src/endpoints/createEndpoint.ts +39 -0
- package/src/endpoints/getEffectiveOperationId.ts +90 -0
- package/src/env/Env.ts +23 -0
- package/src/index.ts +78 -0
- package/src/launch/launchConfig.ts +59 -0
- package/src/list-endpoints-command.ts +1 -1
- package/src/logging/index.ts +25 -0
- package/src/sse/ConnectionManager.ts +79 -0
- package/src/stdin/StdinServer.ts +129 -0
- package/src/web/EndpointListing.ts +166 -0
- package/src/web/ExpressAppSetup.ts +125 -0
- package/src/web/ExpressEndpointSetup.ts +178 -0
- package/src/web/SseResponse.ts +78 -0
- package/src/web/ViteIntegration.ts +72 -0
- package/src/web/__tests__/OpenAPI.invalidZodSchemas.test.ts +250 -0
- package/src/web/corsMiddleware.ts +63 -0
- package/src/web/localhostOnlyMiddleware.ts +19 -0
- package/src/web/openapi/OpenAPI.ts +248 -0
- package/src/web/openapi/validateServicesForOpenapi.ts +76 -0
- package/src/web/requestContextMiddleware.ts +25 -0
- package/.claude/settings.local.json +0 -20
- package/CHANGELOG +0 -28
- package/CLAUDE.md +0 -44
- package/build.mts +0 -8
- package/test/call-command.test.ts +0 -96
- package/test/generate-api-clients.test.ts +0 -33
- package/test/generate-api-clients.test.ts.disabled +0 -75
- 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
|
+
});
|