@cisco_open/linting-orchestrator 1.0.0-rc.4
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/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +43 -0
- package/build/cli/api-client.d.ts +170 -0
- package/build/cli/api-client.d.ts.map +1 -0
- package/build/cli/api-client.js +284 -0
- package/build/cli/api-client.js.map +1 -0
- package/build/cli/commands/agents.d.ts +7 -0
- package/build/cli/commands/agents.d.ts.map +1 -0
- package/build/cli/commands/agents.js +694 -0
- package/build/cli/commands/agents.js.map +1 -0
- package/build/cli/commands/completion.d.ts +9 -0
- package/build/cli/commands/completion.d.ts.map +1 -0
- package/build/cli/commands/completion.js +177 -0
- package/build/cli/commands/completion.js.map +1 -0
- package/build/cli/commands/config.d.ts +10 -0
- package/build/cli/commands/config.d.ts.map +1 -0
- package/build/cli/commands/config.js +284 -0
- package/build/cli/commands/config.js.map +1 -0
- package/build/cli/commands/health.d.ts +11 -0
- package/build/cli/commands/health.d.ts.map +1 -0
- package/build/cli/commands/health.js +38 -0
- package/build/cli/commands/health.js.map +1 -0
- package/build/cli/commands/help.d.ts +6 -0
- package/build/cli/commands/help.d.ts.map +1 -0
- package/build/cli/commands/help.js +20 -0
- package/build/cli/commands/help.js.map +1 -0
- package/build/cli/commands/history.d.ts +11 -0
- package/build/cli/commands/history.d.ts.map +1 -0
- package/build/cli/commands/history.js +50 -0
- package/build/cli/commands/history.js.map +1 -0
- package/build/cli/commands/jobs.d.ts +12 -0
- package/build/cli/commands/jobs.d.ts.map +1 -0
- package/build/cli/commands/jobs.js +84 -0
- package/build/cli/commands/jobs.js.map +1 -0
- package/build/cli/commands/lint.d.ts +15 -0
- package/build/cli/commands/lint.d.ts.map +1 -0
- package/build/cli/commands/lint.js +384 -0
- package/build/cli/commands/lint.js.map +1 -0
- package/build/cli/commands/ps.d.ts +8 -0
- package/build/cli/commands/ps.d.ts.map +1 -0
- package/build/cli/commands/ps.js +74 -0
- package/build/cli/commands/ps.js.map +1 -0
- package/build/cli/commands/reproduce.d.ts +9 -0
- package/build/cli/commands/reproduce.d.ts.map +1 -0
- package/build/cli/commands/reproduce.js +31 -0
- package/build/cli/commands/reproduce.js.map +1 -0
- package/build/cli/commands/reset.d.ts +5 -0
- package/build/cli/commands/reset.d.ts.map +1 -0
- package/build/cli/commands/reset.js +13 -0
- package/build/cli/commands/reset.js.map +1 -0
- package/build/cli/commands/results.d.ts +13 -0
- package/build/cli/commands/results.d.ts.map +1 -0
- package/build/cli/commands/results.js +129 -0
- package/build/cli/commands/results.js.map +1 -0
- package/build/cli/commands/rulesets/check.d.ts +12 -0
- package/build/cli/commands/rulesets/check.d.ts.map +1 -0
- package/build/cli/commands/rulesets/check.js +226 -0
- package/build/cli/commands/rulesets/check.js.map +1 -0
- package/build/cli/commands/rulesets/index.d.ts +5 -0
- package/build/cli/commands/rulesets/index.d.ts.map +1 -0
- package/build/cli/commands/rulesets/index.js +6 -0
- package/build/cli/commands/rulesets/index.js.map +1 -0
- package/build/cli/commands/rulesets/view.d.ts +16 -0
- package/build/cli/commands/rulesets/view.d.ts.map +1 -0
- package/build/cli/commands/rulesets/view.js +100 -0
- package/build/cli/commands/rulesets/view.js.map +1 -0
- package/build/cli/commands/start.d.ts +16 -0
- package/build/cli/commands/start.d.ts.map +1 -0
- package/build/cli/commands/start.js +167 -0
- package/build/cli/commands/start.js.map +1 -0
- package/build/cli/commands/status.d.ts +9 -0
- package/build/cli/commands/status.d.ts.map +1 -0
- package/build/cli/commands/status.js +46 -0
- package/build/cli/commands/status.js.map +1 -0
- package/build/cli/commands/stop.d.ts +11 -0
- package/build/cli/commands/stop.d.ts.map +1 -0
- package/build/cli/commands/stop.js +78 -0
- package/build/cli/commands/stop.js.map +1 -0
- package/build/cli/config-manager.d.ts +134 -0
- package/build/cli/config-manager.d.ts.map +1 -0
- package/build/cli/config-manager.js +288 -0
- package/build/cli/config-manager.js.map +1 -0
- package/build/cli/formatters.d.ts +62 -0
- package/build/cli/formatters.d.ts.map +1 -0
- package/build/cli/formatters.js +715 -0
- package/build/cli/formatters.js.map +1 -0
- package/build/cli/history-manager.d.ts +97 -0
- package/build/cli/history-manager.d.ts.map +1 -0
- package/build/cli/history-manager.js +201 -0
- package/build/cli/history-manager.js.map +1 -0
- package/build/cli/index.d.ts +16 -0
- package/build/cli/index.d.ts.map +1 -0
- package/build/cli/index.js +335 -0
- package/build/cli/index.js.map +1 -0
- package/build/cli/list-rulesets.d.ts +15 -0
- package/build/cli/list-rulesets.d.ts.map +1 -0
- package/build/cli/list-rulesets.js +193 -0
- package/build/cli/list-rulesets.js.map +1 -0
- package/build/cli/utils/connection-error.d.ts +9 -0
- package/build/cli/utils/connection-error.d.ts.map +1 -0
- package/build/cli/utils/connection-error.js +30 -0
- package/build/cli/utils/connection-error.js.map +1 -0
- package/build/cli/utils/embedded-server.d.ts +21 -0
- package/build/cli/utils/embedded-server.d.ts.map +1 -0
- package/build/cli/utils/embedded-server.js +61 -0
- package/build/cli/utils/embedded-server.js.map +1 -0
- package/build/cli/utils/mode-validator.d.ts +13 -0
- package/build/cli/utils/mode-validator.d.ts.map +1 -0
- package/build/cli/utils/mode-validator.js +31 -0
- package/build/cli/utils/mode-validator.js.map +1 -0
- package/build/cli/utils/port-checker.d.ts +20 -0
- package/build/cli/utils/port-checker.d.ts.map +1 -0
- package/build/cli/utils/port-checker.js +49 -0
- package/build/cli/utils/port-checker.js.map +1 -0
- package/build/config.d.ts +57 -0
- package/build/config.d.ts.map +1 -0
- package/build/config.js +527 -0
- package/build/config.js.map +1 -0
- package/build/document-accessor.d.ts +79 -0
- package/build/document-accessor.d.ts.map +1 -0
- package/build/document-accessor.js +148 -0
- package/build/document-accessor.js.map +1 -0
- package/build/formatters/reproduce-markdown.d.ts +14 -0
- package/build/formatters/reproduce-markdown.d.ts.map +1 -0
- package/build/formatters/reproduce-markdown.js +182 -0
- package/build/formatters/reproduce-markdown.js.map +1 -0
- package/build/formatters/sarif-builder.d.ts +86 -0
- package/build/formatters/sarif-builder.d.ts.map +1 -0
- package/build/formatters/sarif-builder.js +276 -0
- package/build/formatters/sarif-builder.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +174 -0
- package/build/index.js.map +1 -0
- package/build/logger.d.ts +38 -0
- package/build/logger.d.ts.map +1 -0
- package/build/logger.js +105 -0
- package/build/logger.js.map +1 -0
- package/build/mock-server.d.ts +2 -0
- package/build/mock-server.d.ts.map +1 -0
- package/build/mock-server.js +290 -0
- package/build/mock-server.js.map +1 -0
- package/build/orchestrator.d.ts +149 -0
- package/build/orchestrator.d.ts.map +1 -0
- package/build/orchestrator.js +874 -0
- package/build/orchestrator.js.map +1 -0
- package/build/ruleset-loader.d.ts +79 -0
- package/build/ruleset-loader.d.ts.map +1 -0
- package/build/ruleset-loader.js +514 -0
- package/build/ruleset-loader.js.map +1 -0
- package/build/schemas.d.ts +2568 -0
- package/build/schemas.d.ts.map +1 -0
- package/build/schemas.js +674 -0
- package/build/schemas.js.map +1 -0
- package/build/server.d.ts +39 -0
- package/build/server.d.ts.map +1 -0
- package/build/server.js +834 -0
- package/build/server.js.map +1 -0
- package/build/storage/memory-storage.d.ts +190 -0
- package/build/storage/memory-storage.d.ts.map +1 -0
- package/build/storage/memory-storage.js +629 -0
- package/build/storage/memory-storage.js.map +1 -0
- package/build/storage/redis-storage.d.ts +134 -0
- package/build/storage/redis-storage.d.ts.map +1 -0
- package/build/storage/redis-storage.js +236 -0
- package/build/storage/redis-storage.js.map +1 -0
- package/build/storage/storage-adapter.d.ts +189 -0
- package/build/storage/storage-adapter.d.ts.map +1 -0
- package/build/storage/storage-adapter.js +36 -0
- package/build/storage/storage-adapter.js.map +1 -0
- package/build/types.d.ts +981 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +5 -0
- package/build/types.js.map +1 -0
- package/build/utils/version.d.ts +40 -0
- package/build/utils/version.d.ts.map +1 -0
- package/build/utils/version.js +94 -0
- package/build/utils/version.js.map +1 -0
- package/build/validation.d.ts +95 -0
- package/build/validation.d.ts.map +1 -0
- package/build/validation.js +150 -0
- package/build/validation.js.map +1 -0
- package/build/worker-pool.d.ts +137 -0
- package/build/worker-pool.d.ts.map +1 -0
- package/build/worker-pool.js +549 -0
- package/build/worker-pool.js.map +1 -0
- package/build/worker.d.ts +2 -0
- package/build/worker.d.ts.map +1 -0
- package/build/worker.js +427 -0
- package/build/worker.js.map +1 -0
- package/package.json +110 -0
- package/rulesets/CHANGELOG.md +25 -0
- package/rulesets/config/rulesets.yaml +96 -0
- package/rulesets/sources/README.md +47 -0
- package/rulesets/sources/example/oas-recommended/v1.0.0/ruleset.yaml +6 -0
- package/rulesets/sources/example/oas-recommended/v2.0.0/ruleset.yaml +14 -0
package/build/server.js
ADDED
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
// Copyright 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
/**
|
|
5
|
+
* Orchestrator server - Exportable server module for all deployment modes
|
|
6
|
+
*
|
|
7
|
+
* This module provides the core server functionality that can be:
|
|
8
|
+
* - Imported and run in-process (embedded mode - embedded in CLI)
|
|
9
|
+
* - Run as standalone process (standalone mode - dedicated server)
|
|
10
|
+
* - Integrated with MCP (companion mode - MCP-managed)
|
|
11
|
+
*
|
|
12
|
+
* @module server
|
|
13
|
+
*/
|
|
14
|
+
import Fastify from 'fastify';
|
|
15
|
+
import swagger from '@fastify/swagger';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import crypto from 'crypto';
|
|
18
|
+
import { API_VERSION, SPECTRAL_CORE_VERSION, SPECTRAL_RULESETS_VERSION, SPECTRAL_CLI_VERSION } from './utils/version.js';
|
|
19
|
+
import { loadConfig, resolveResolverPath } from './config.js';
|
|
20
|
+
import { Orchestrator, CapacityExceededError } from './orchestrator.js';
|
|
21
|
+
import { WorkerPoolManager } from './worker-pool.js';
|
|
22
|
+
import { RulesetLoader } from './ruleset-loader.js';
|
|
23
|
+
import { LocalDocumentStore, PassThroughDocumentStore, } from '@cisco_open/linting-document-store';
|
|
24
|
+
import { MemoryLintStorage } from './storage/memory-storage.js';
|
|
25
|
+
import { SarifBuilder } from './formatters/sarif-builder.js';
|
|
26
|
+
import { generateReproductionMarkdown } from './formatters/reproduce-markdown.js';
|
|
27
|
+
import { lintJobRequestSchema, jobIdParamSchema, documentIdParamSchema, errorHandler, createErrorResponse, sanitizeDocumentId, sanitizeRulesetName } from './validation.js';
|
|
28
|
+
import { sharedSchemas, DocumentListQuerySchema, JobListQuerySchema, RulesetNameParamSchema, RulesetVersionQuerySchema, } from './schemas.js';
|
|
29
|
+
/**
|
|
30
|
+
* Start orchestrator server with given configuration
|
|
31
|
+
*
|
|
32
|
+
* This function is used by:
|
|
33
|
+
* - src/index.ts (standalone server entrypoint)
|
|
34
|
+
* - src/cli/commands/start.ts (embedded server)
|
|
35
|
+
* - Tests (integration testing)
|
|
36
|
+
*
|
|
37
|
+
* @param customConfig Optional configuration to override defaults
|
|
38
|
+
* @returns ServerInstance with shutdown capability
|
|
39
|
+
*/
|
|
40
|
+
export async function startServer(customConfig) {
|
|
41
|
+
console.log('🚀 Starting the Orchestrator service...\n');
|
|
42
|
+
// 1. Load configuration
|
|
43
|
+
const config = customConfig
|
|
44
|
+
? { ...await loadConfig(), ...customConfig }
|
|
45
|
+
: await loadConfig();
|
|
46
|
+
console.log('✅ Configuration loaded');
|
|
47
|
+
console.log(` Mode: ${config.mode || 'standalone'}`);
|
|
48
|
+
console.log(` Port: ${config.server?.port || 3003}`);
|
|
49
|
+
console.log(` Directory: ${config.documentStore.baseDir}\n`);
|
|
50
|
+
// 2. Initialize storage
|
|
51
|
+
const storage = new MemoryLintStorage();
|
|
52
|
+
await storage.initialize({});
|
|
53
|
+
console.log('✅ Storage initialized\n');
|
|
54
|
+
// 3. Initialize ruleset loader
|
|
55
|
+
const rulesetsConfigPath = path.join(config.rulesets.directory, 'config', 'rulesets.yaml');
|
|
56
|
+
console.log(` Loading rulesets from: ${rulesetsConfigPath}`);
|
|
57
|
+
const rulesetLoader = new RulesetLoader({
|
|
58
|
+
configPath: rulesetsConfigPath,
|
|
59
|
+
sourcesBasePath: path.join(config.rulesets.directory, 'sources')
|
|
60
|
+
});
|
|
61
|
+
await rulesetLoader.initialize();
|
|
62
|
+
console.log('✅ Ruleset loader initialized\n');
|
|
63
|
+
// 4. Initialize document store (based on config type)
|
|
64
|
+
let documentStore;
|
|
65
|
+
if (config.documentStore.type === 'passthrough') {
|
|
66
|
+
// PassThrough mode - read from external document store (e.g., MCP)
|
|
67
|
+
documentStore = new PassThroughDocumentStore({
|
|
68
|
+
uploadsDir: config.documentStore.baseDir,
|
|
69
|
+
httpFallbackUrl: config.documentStore.fallbackHttp
|
|
70
|
+
});
|
|
71
|
+
console.log(' Using PassThroughDocumentStore (companion mode)');
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
// Standalone mode - use local storage with cache
|
|
75
|
+
documentStore = new LocalDocumentStore({
|
|
76
|
+
uploadsDir: config.documentStore.baseDir || './uploads',
|
|
77
|
+
quotaGB: 10,
|
|
78
|
+
cacheMaxSize: 100,
|
|
79
|
+
cacheTTLHours: 1
|
|
80
|
+
});
|
|
81
|
+
console.log(' Using LocalDocumentStore (standalone/embedded mode)');
|
|
82
|
+
}
|
|
83
|
+
await documentStore.initialize();
|
|
84
|
+
console.log('✅ Document store initialized\n');
|
|
85
|
+
// 5. Initialize worker pool
|
|
86
|
+
const resolverPath = resolveResolverPath(config.resolver);
|
|
87
|
+
const workerPool = new WorkerPoolManager(config.workerPool, rulesetLoader, documentStore, resolverPath);
|
|
88
|
+
await workerPool.initialize();
|
|
89
|
+
console.log('✅ Worker pool initialized');
|
|
90
|
+
if (resolverPath) {
|
|
91
|
+
console.log(` Resolver: ${config.resolver} (${resolverPath})`);
|
|
92
|
+
}
|
|
93
|
+
console.log('');
|
|
94
|
+
// 6. Initialize orchestrator
|
|
95
|
+
const maxIssuesPerJob = process.env.MAX_ISSUES_PER_JOB
|
|
96
|
+
? parseInt(process.env.MAX_ISSUES_PER_JOB, 10)
|
|
97
|
+
: 100000;
|
|
98
|
+
const orchestrator = new Orchestrator(workerPool, storage, rulesetLoader, documentStore, {
|
|
99
|
+
workerPool: config.workerPool,
|
|
100
|
+
reportService: config.reportService,
|
|
101
|
+
maxIssuesPerJob
|
|
102
|
+
});
|
|
103
|
+
await orchestrator.initialize();
|
|
104
|
+
console.log('✅ Orchestrator initialized\n');
|
|
105
|
+
// 7. Initialize SARIF builder
|
|
106
|
+
const sarifBuilder = new SarifBuilder();
|
|
107
|
+
console.log('✅ SARIF builder initialized\n');
|
|
108
|
+
// 8. Generate runtime session ID (for client history management)
|
|
109
|
+
const runtimeSessionId = crypto.randomUUID();
|
|
110
|
+
console.log(`🔑 Runtime Session ID: ${runtimeSessionId}\n`);
|
|
111
|
+
// 8. Create HTTP server
|
|
112
|
+
const maxDocumentBytes = (config.server?.maxDocumentSizeMB || 20) * 1024 * 1024;
|
|
113
|
+
const fastify = Fastify({
|
|
114
|
+
logger: config.logging?.level === 'debug',
|
|
115
|
+
bodyLimit: maxDocumentBytes,
|
|
116
|
+
ajv: {
|
|
117
|
+
customOptions: {
|
|
118
|
+
// Register 'example' as a known keyword so AJV strict mode doesn't reject it.
|
|
119
|
+
// This allows the same schemas to feed both AJV validation and @fastify/swagger
|
|
120
|
+
// OpenAPI generation without duplication.
|
|
121
|
+
keywords: ['example'],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
// Register @fastify/swagger to auto-generate OpenAPI spec from route schemas
|
|
126
|
+
await fastify.register(swagger, {
|
|
127
|
+
openapi: {
|
|
128
|
+
openapi: '3.0.3',
|
|
129
|
+
info: {
|
|
130
|
+
title: 'Linting Orchestrator API',
|
|
131
|
+
version: API_VERSION,
|
|
132
|
+
description: 'The linting orchestrator uses Spectral and custom rule engines.\n' +
|
|
133
|
+
'It provides a REST API for uploading documents, submitting lint jobs, and retrieving results.\n\n' +
|
|
134
|
+
'**Deployment Modes:**\n' +
|
|
135
|
+
'- **Light Mode** (Embedded): CLI embeds server, zero-config, perfect for CI/CD\n' +
|
|
136
|
+
'- **Standalone Mode**: Independent server process, long-running, multi-user\n' +
|
|
137
|
+
'- **Companion Mode**: Runs alongside the MCP OpenAPI Analyzer for integrated document lifecycle',
|
|
138
|
+
contact: { name: 'Cisco DevNet' },
|
|
139
|
+
license: { name: 'Apache-2.0', url: 'https://opensource.org/licenses/Apache-2.0' },
|
|
140
|
+
},
|
|
141
|
+
servers: [
|
|
142
|
+
{ url: `http://localhost:${config.server?.port || 3003}`, description: 'Local development server (default port)' },
|
|
143
|
+
],
|
|
144
|
+
tags: [
|
|
145
|
+
{ name: 'Health', description: 'Server health and status' },
|
|
146
|
+
{ name: 'Documents', description: 'Upload and manage documents' },
|
|
147
|
+
{ name: 'Linting', description: 'Submit lint jobs and retrieve results' },
|
|
148
|
+
{ name: 'Jobs', description: 'List and query lint jobs' },
|
|
149
|
+
{ name: 'Reports', description: 'Generate reports from lint results' },
|
|
150
|
+
{ name: 'Rulesets', description: 'Browse available rulesets and rules' },
|
|
151
|
+
{ name: 'Cache', description: 'Cache management' },
|
|
152
|
+
{ name: 'Statistics', description: 'Server and storage statistics' },
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
// Use $id as the schema component name instead of the default "def-N" naming
|
|
156
|
+
refResolver: {
|
|
157
|
+
buildLocalReference(json, _baseUri, _fragment, i) {
|
|
158
|
+
if (json.$id) {
|
|
159
|
+
return json.$id;
|
|
160
|
+
}
|
|
161
|
+
if (json.title) {
|
|
162
|
+
return json.title;
|
|
163
|
+
}
|
|
164
|
+
return `def-${i}`;
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
// Register shared JSON Schemas so routes can $ref them
|
|
169
|
+
for (const schema of sharedSchemas) {
|
|
170
|
+
fastify.addSchema(schema);
|
|
171
|
+
}
|
|
172
|
+
// Add runtime session ID header to all responses
|
|
173
|
+
fastify.addHook('onSend', async (_request, reply) => {
|
|
174
|
+
reply.header('X-Spectify-Session-Id', runtimeSessionId);
|
|
175
|
+
reply.header('X-Spectify-Version', API_VERSION);
|
|
176
|
+
});
|
|
177
|
+
// Set global error handler with document size context
|
|
178
|
+
fastify.setErrorHandler((error, request, reply) => {
|
|
179
|
+
// Handle body too large errors with clear message
|
|
180
|
+
if (error.code === 'FST_ERR_CTP_BODY_TOO_LARGE') {
|
|
181
|
+
const sizeLimitMB = (config.server?.maxDocumentSizeMB || 20);
|
|
182
|
+
reply.code(413).send({
|
|
183
|
+
error: 'Payload Too Large',
|
|
184
|
+
message: `Document exceeds maximum size of ${sizeLimitMB}MB. Configure server.maxDocumentSizeMB in config.yaml to increase.`,
|
|
185
|
+
statusCode: 413
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// Delegate to standard error handler
|
|
190
|
+
errorHandler(error, request, reply);
|
|
191
|
+
});
|
|
192
|
+
// Health check endpoint
|
|
193
|
+
const serverStartTime = new Date();
|
|
194
|
+
fastify.get('/health', {
|
|
195
|
+
schema: {
|
|
196
|
+
description: 'Returns server health status, uptime, configuration, and orchestrator statistics.',
|
|
197
|
+
tags: ['Health'],
|
|
198
|
+
summary: 'Health check',
|
|
199
|
+
operationId: 'getHealth',
|
|
200
|
+
response: {
|
|
201
|
+
200: { $ref: 'HealthResponse#', description: 'Server is healthy' },
|
|
202
|
+
500: { $ref: 'ErrorResponse#', description: 'Internal server error' },
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
}, async () => {
|
|
206
|
+
const stats = orchestrator.getStats();
|
|
207
|
+
const uptimeSeconds = Math.floor((Date.now() - serverStartTime.getTime()) / 1000);
|
|
208
|
+
const baseDir = config.documentStore?.baseDir || './uploads';
|
|
209
|
+
const health = {
|
|
210
|
+
status: 'ok',
|
|
211
|
+
version: API_VERSION,
|
|
212
|
+
timestamp: new Date().toISOString(),
|
|
213
|
+
uptime: uptimeSeconds,
|
|
214
|
+
mode: config.mode || 'standalone',
|
|
215
|
+
runtime: {
|
|
216
|
+
nodeVersion: process.version,
|
|
217
|
+
spectralCore: SPECTRAL_CORE_VERSION,
|
|
218
|
+
spectralRulesets: SPECTRAL_RULESETS_VERSION,
|
|
219
|
+
spectralCli: SPECTRAL_CLI_VERSION,
|
|
220
|
+
resolver: config.resolver || null,
|
|
221
|
+
},
|
|
222
|
+
server: {
|
|
223
|
+
port: config.server?.port || 3003,
|
|
224
|
+
host: config.server?.host || '0.0.0.0',
|
|
225
|
+
startedAt: serverStartTime.toISOString(),
|
|
226
|
+
},
|
|
227
|
+
documentStore: {
|
|
228
|
+
type: config.documentStore?.type || 'local',
|
|
229
|
+
baseDir: baseDir,
|
|
230
|
+
fullPath: path.resolve(baseDir),
|
|
231
|
+
},
|
|
232
|
+
stats
|
|
233
|
+
};
|
|
234
|
+
// Add Report Service status if configured
|
|
235
|
+
if (orchestrator.hasReportClient()) {
|
|
236
|
+
const reportStatus = await orchestrator.getReportClientStatus();
|
|
237
|
+
health.reportService = reportStatus;
|
|
238
|
+
}
|
|
239
|
+
return health;
|
|
240
|
+
});
|
|
241
|
+
// Upload OpenAPI document (standalone/embedded mode)
|
|
242
|
+
fastify.post('/documents', {
|
|
243
|
+
schema: {
|
|
244
|
+
description: 'Upload a document for linting. Returns a document ID that can be used to submit lint jobs.',
|
|
245
|
+
tags: ['Documents'],
|
|
246
|
+
summary: 'Upload document',
|
|
247
|
+
operationId: 'uploadDocument',
|
|
248
|
+
body: { $ref: 'DocumentUploadRequest#' },
|
|
249
|
+
response: {
|
|
250
|
+
201: { $ref: 'DocumentUploadResponse#', description: 'Document uploaded successfully' },
|
|
251
|
+
400: { $ref: 'ErrorResponse#', description: 'Bad request' },
|
|
252
|
+
413: { $ref: 'ErrorResponse#', description: 'Document exceeds maximum upload size limit' },
|
|
253
|
+
500: { $ref: 'ErrorResponse#', description: 'Internal server error' },
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
}, async (request, reply) => {
|
|
257
|
+
const { content, format = 'json', metadata } = request.body;
|
|
258
|
+
if (!content || typeof content !== 'string') {
|
|
259
|
+
return reply.code(400).send(createErrorResponse('Bad Request', 'Document content is required'));
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
const result = await documentStore.storeDocument(content, format, metadata);
|
|
263
|
+
return reply.code(201).send({
|
|
264
|
+
documentId: result.documentId,
|
|
265
|
+
version: result.version,
|
|
266
|
+
message: 'Document uploaded successfully'
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
console.error('Document upload failed:', error);
|
|
271
|
+
return reply.code(500).send(createErrorResponse('Internal Server Error', 'Failed to store document'));
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
// Get document by ID
|
|
275
|
+
fastify.get('/documents/:documentId', {
|
|
276
|
+
schema: {
|
|
277
|
+
description: 'Retrieve an uploaded document by its identifier.',
|
|
278
|
+
tags: ['Documents'],
|
|
279
|
+
summary: 'Get document by ID',
|
|
280
|
+
operationId: 'getDocument',
|
|
281
|
+
params: documentIdParamSchema,
|
|
282
|
+
response: {
|
|
283
|
+
200: { $ref: 'DocumentResponse#', description: 'Document found and returned' },
|
|
284
|
+
404: { $ref: 'ErrorResponse#', description: 'Resource not found' },
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
}, async (request, reply) => {
|
|
288
|
+
const { documentId } = request.params;
|
|
289
|
+
const document = await documentStore.getDocument(sanitizeDocumentId(documentId));
|
|
290
|
+
if (!document) {
|
|
291
|
+
return reply.code(404).send(createErrorResponse('Not Found', `Document not found: ${documentId}`));
|
|
292
|
+
}
|
|
293
|
+
return document;
|
|
294
|
+
});
|
|
295
|
+
// List/search documents
|
|
296
|
+
fastify.get('/documents', {
|
|
297
|
+
schema: {
|
|
298
|
+
description: 'List uploaded documents with optional filtering, sorting, and search. When `search` is provided, performs a text search; otherwise returns a paginated list.',
|
|
299
|
+
tags: ['Documents'],
|
|
300
|
+
summary: 'List or search documents',
|
|
301
|
+
operationId: 'listDocuments',
|
|
302
|
+
querystring: DocumentListQuerySchema,
|
|
303
|
+
response: {
|
|
304
|
+
200: { $ref: 'DocumentListResponse#', description: 'List of documents matching the query' },
|
|
305
|
+
500: { $ref: 'ErrorResponse#', description: 'Internal server error' },
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
}, async (request) => {
|
|
309
|
+
const { limit, offset, sortBy, sortOrder, search, organization, tags, format } = request.query;
|
|
310
|
+
// If search query present, use searchDocuments; otherwise use listDocuments
|
|
311
|
+
const options = {
|
|
312
|
+
limit: limit ? parseInt(limit, 10) : 50,
|
|
313
|
+
offset: offset ? parseInt(offset, 10) : 0,
|
|
314
|
+
sortBy: sortBy || 'uploadedAt',
|
|
315
|
+
sortOrder: sortOrder || 'desc'
|
|
316
|
+
};
|
|
317
|
+
if (search) {
|
|
318
|
+
// Search mode - combine search with optional filters
|
|
319
|
+
const searchOptions = {
|
|
320
|
+
...options,
|
|
321
|
+
...(organization && { organization }),
|
|
322
|
+
...(tags && { tags: tags.split(',') }),
|
|
323
|
+
...(format && { format })
|
|
324
|
+
};
|
|
325
|
+
const documents = await documentStore.searchDocuments(search, searchOptions);
|
|
326
|
+
return documents;
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
// List mode - optional filters only
|
|
330
|
+
const listOptions = {
|
|
331
|
+
...options,
|
|
332
|
+
...(organization && { organization }),
|
|
333
|
+
...(tags && { tags: tags.split(',') }),
|
|
334
|
+
...(format && { format })
|
|
335
|
+
};
|
|
336
|
+
const documents = await documentStore.listDocuments(listOptions);
|
|
337
|
+
return documents;
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
// Submit lint job
|
|
341
|
+
fastify.post('/lint', {
|
|
342
|
+
schema: {
|
|
343
|
+
description: 'Submit a document for linting against a specified ruleset. Returns a job ID for tracking progress. The job runs asynchronously.',
|
|
344
|
+
tags: ['Linting'],
|
|
345
|
+
summary: 'Submit lint job',
|
|
346
|
+
operationId: 'submitLintJob',
|
|
347
|
+
body: lintJobRequestSchema,
|
|
348
|
+
response: {
|
|
349
|
+
202: { $ref: 'LintJobSubmitResponse#', description: 'Job submitted and queued for processing' },
|
|
350
|
+
404: { $ref: 'ErrorResponse#', description: 'Resource not found' },
|
|
351
|
+
429: { $ref: 'CapacityExceededResponse#', description: 'Too many requests — server at capacity' },
|
|
352
|
+
500: { $ref: 'ErrorResponse#', description: 'Internal server error' },
|
|
353
|
+
503: { $ref: 'ErrorResponse#', description: 'Service unavailable (server starting up)' },
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
}, async (request, reply) => {
|
|
357
|
+
const jobRequest = request.body;
|
|
358
|
+
// Sanitize inputs
|
|
359
|
+
const sanitizedRequest = {
|
|
360
|
+
...jobRequest,
|
|
361
|
+
documentId: sanitizeDocumentId(jobRequest.documentId),
|
|
362
|
+
rulesetName: sanitizeRulesetName(jobRequest.rulesetName),
|
|
363
|
+
ruleOverrides: jobRequest.ruleOverrides
|
|
364
|
+
};
|
|
365
|
+
try {
|
|
366
|
+
const jobId = await orchestrator.submitJob(sanitizedRequest);
|
|
367
|
+
return reply.code(202).send({
|
|
368
|
+
jobId,
|
|
369
|
+
status: 'queued',
|
|
370
|
+
message: 'Job submitted successfully'
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
// Backpressure: reject with 429 when at capacity
|
|
375
|
+
if (error instanceof CapacityExceededError) {
|
|
376
|
+
const retryAfter = Math.ceil(error.activeJobs / 10); // ~1s per 10 active jobs
|
|
377
|
+
reply.header('Retry-After', String(retryAfter));
|
|
378
|
+
return reply.code(429).send({
|
|
379
|
+
error: 'Too Many Requests',
|
|
380
|
+
message: error.message,
|
|
381
|
+
activeJobs: error.activeJobs,
|
|
382
|
+
maxJobs: error.maxJobs,
|
|
383
|
+
retryAfter
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
// Document not found
|
|
387
|
+
if (error instanceof Error && error.message.startsWith('Document not found')) {
|
|
388
|
+
return reply.code(404).send(createErrorResponse('Not Found', error.message));
|
|
389
|
+
}
|
|
390
|
+
// Orchestrator not initialized
|
|
391
|
+
if (error instanceof Error && error.message === 'Orchestrator not initialized') {
|
|
392
|
+
return reply.code(503).send(createErrorResponse('Service Unavailable', 'Server is starting up, please retry shortly'));
|
|
393
|
+
}
|
|
394
|
+
// Unknown error
|
|
395
|
+
console.error('Job submission failed:', error);
|
|
396
|
+
return reply.code(500).send(createErrorResponse('Internal Server Error', 'Failed to submit lint job'));
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
// Get job status
|
|
400
|
+
fastify.get('/lint/:jobId', {
|
|
401
|
+
schema: {
|
|
402
|
+
description: 'Retrieve the current status and progress of a lint job.',
|
|
403
|
+
tags: ['Linting'],
|
|
404
|
+
summary: 'Get job status',
|
|
405
|
+
operationId: 'getJobStatus',
|
|
406
|
+
params: jobIdParamSchema,
|
|
407
|
+
response: {
|
|
408
|
+
200: { $ref: 'LintJobStatus#', description: 'Current job status and progress' },
|
|
409
|
+
404: { $ref: 'ErrorResponse#', description: 'Resource not found' },
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
}, async (request, reply) => {
|
|
413
|
+
const { jobId } = request.params;
|
|
414
|
+
const job = await orchestrator.getJobStatus(jobId);
|
|
415
|
+
if (!job) {
|
|
416
|
+
return reply.code(404).send(createErrorResponse('Not Found', `Job not found: ${jobId}`));
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
jobId: job.jobId,
|
|
420
|
+
documentId: job.documentId,
|
|
421
|
+
rulesetName: job.rulesetName,
|
|
422
|
+
rulesetVersion: job.rulesetVersion,
|
|
423
|
+
status: job.status,
|
|
424
|
+
progress: job.progress,
|
|
425
|
+
startTime: job.startTime,
|
|
426
|
+
endTime: job.endTime
|
|
427
|
+
};
|
|
428
|
+
});
|
|
429
|
+
// Get job results (with optional pagination and filtering)
|
|
430
|
+
fastify.get('/lint/:jobId/results', {
|
|
431
|
+
schema: {
|
|
432
|
+
description: 'Retrieve the results of a completed lint job. Supports optional pagination and filtering via query parameters. When no query params are provided, returns the full result (backward compatible).',
|
|
433
|
+
tags: ['Linting'],
|
|
434
|
+
summary: 'Get job results',
|
|
435
|
+
operationId: 'getJobResults',
|
|
436
|
+
params: jobIdParamSchema,
|
|
437
|
+
querystring: {
|
|
438
|
+
type: 'object',
|
|
439
|
+
properties: {
|
|
440
|
+
offset: { type: 'string', description: 'Skip first N issues (default: 0)' },
|
|
441
|
+
limit: { type: 'string', description: 'Max issues to return (default: all)' },
|
|
442
|
+
severity: { type: 'string', description: 'Filter by severity: 0=error, 1=warn, 2=info, 3=hint' },
|
|
443
|
+
rule: { type: 'string', description: 'Filter by rule ID (exact match)' },
|
|
444
|
+
pathPrefix: { type: 'string', description: 'Filter issues whose path starts with this prefix (dot-separated)' },
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
response: {
|
|
448
|
+
200: { description: 'Job results (full or paginated)' },
|
|
449
|
+
404: { $ref: 'ErrorResponse#', description: 'Resource not found' },
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
}, async (request, reply) => {
|
|
453
|
+
const { jobId } = request.params;
|
|
454
|
+
const { offset, limit, severity, rule, pathPrefix } = request.query;
|
|
455
|
+
const hasPaginationOrFilter = offset !== undefined || limit !== undefined ||
|
|
456
|
+
severity !== undefined || rule !== undefined || pathPrefix !== undefined;
|
|
457
|
+
if (hasPaginationOrFilter && storage.queryJobResults) {
|
|
458
|
+
// Use paginated query
|
|
459
|
+
const options = {};
|
|
460
|
+
if (offset !== undefined)
|
|
461
|
+
options.offset = parseInt(offset, 10);
|
|
462
|
+
if (limit !== undefined)
|
|
463
|
+
options.limit = parseInt(limit, 10);
|
|
464
|
+
if (severity !== undefined)
|
|
465
|
+
options.severity = parseInt(severity, 10);
|
|
466
|
+
if (rule !== undefined)
|
|
467
|
+
options.rule = rule;
|
|
468
|
+
if (pathPrefix !== undefined)
|
|
469
|
+
options.pathPrefix = pathPrefix;
|
|
470
|
+
const result = await storage.queryJobResults(jobId, options);
|
|
471
|
+
if (!result) {
|
|
472
|
+
return reply.code(404).send(createErrorResponse('Not Found', `Job not found or not yet completed: ${jobId}`));
|
|
473
|
+
}
|
|
474
|
+
return result;
|
|
475
|
+
}
|
|
476
|
+
// Backward compatible: return full result
|
|
477
|
+
const result = await orchestrator.getJobResult(jobId);
|
|
478
|
+
if (!result) {
|
|
479
|
+
return reply.code(404).send(createErrorResponse('Not Found', `Job not found or not yet completed: ${jobId}`));
|
|
480
|
+
}
|
|
481
|
+
return result;
|
|
482
|
+
});
|
|
483
|
+
// Get aggregated statistics for a job's lint results
|
|
484
|
+
fastify.get('/lint/:jobId/stats', {
|
|
485
|
+
schema: {
|
|
486
|
+
description: 'Get aggregated statistics for a completed lint job, including rule breakdown and top paths. Does not return individual issues — use GET /lint/:jobId/results for that.',
|
|
487
|
+
tags: ['Linting'],
|
|
488
|
+
summary: 'Get job result statistics',
|
|
489
|
+
operationId: 'getJobStats',
|
|
490
|
+
params: jobIdParamSchema,
|
|
491
|
+
response: {
|
|
492
|
+
200: { description: 'Aggregated job statistics' },
|
|
493
|
+
404: { $ref: 'ErrorResponse#', description: 'Resource not found' },
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
}, async (request, reply) => {
|
|
497
|
+
const { jobId } = request.params;
|
|
498
|
+
if (storage.getJobStats) {
|
|
499
|
+
const stats = await storage.getJobStats(jobId);
|
|
500
|
+
if (!stats) {
|
|
501
|
+
return reply.code(404).send(createErrorResponse('Not Found', `Job not found or not yet completed: ${jobId}`));
|
|
502
|
+
}
|
|
503
|
+
return stats;
|
|
504
|
+
}
|
|
505
|
+
// Fallback: basic stats from full result
|
|
506
|
+
const result = await orchestrator.getJobResult(jobId);
|
|
507
|
+
if (!result) {
|
|
508
|
+
return reply.code(404).send(createErrorResponse('Not Found', `Job not found or not yet completed: ${jobId}`));
|
|
509
|
+
}
|
|
510
|
+
return {
|
|
511
|
+
jobId: result.jobId,
|
|
512
|
+
documentId: result.documentId,
|
|
513
|
+
rulesetName: result.rulesetName,
|
|
514
|
+
rulesetVersion: result.rulesetVersion,
|
|
515
|
+
status: result.status,
|
|
516
|
+
summary: result.summary,
|
|
517
|
+
ruleBreakdown: [],
|
|
518
|
+
topPaths: [],
|
|
519
|
+
};
|
|
520
|
+
});
|
|
521
|
+
// Generate SARIF report for completed job
|
|
522
|
+
fastify.post('/lint/:jobId/reports/generate', {
|
|
523
|
+
schema: {
|
|
524
|
+
description: 'Generate a structured report from a completed lint job. Currently supports SARIF (Static Analysis Results Interchange Format) output.',
|
|
525
|
+
tags: ['Reports'],
|
|
526
|
+
summary: 'Generate report for completed job',
|
|
527
|
+
operationId: 'generateReport',
|
|
528
|
+
params: jobIdParamSchema,
|
|
529
|
+
body: { $ref: 'ReportGenerationRequest#' },
|
|
530
|
+
response: {
|
|
531
|
+
200: { $ref: 'SarifReport#', description: 'Generated SARIF report' },
|
|
532
|
+
400: { $ref: 'ErrorResponse#', description: 'Bad request' },
|
|
533
|
+
404: { $ref: 'ErrorResponse#', description: 'Resource not found' },
|
|
534
|
+
500: { $ref: 'ErrorResponse#', description: 'Internal server error' },
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
}, async (request, reply) => {
|
|
538
|
+
const { jobId } = request.params;
|
|
539
|
+
const { format, options } = request.body;
|
|
540
|
+
// Validate format (only SARIF supported in Phase 1)
|
|
541
|
+
if (format !== 'sarif') {
|
|
542
|
+
return reply.code(400).send(createErrorResponse('Bad Request', `Unsupported format: ${format}. Only 'sarif' is supported.`));
|
|
543
|
+
}
|
|
544
|
+
// Get completed job result
|
|
545
|
+
const result = await orchestrator.getJobResult(jobId);
|
|
546
|
+
if (!result) {
|
|
547
|
+
return reply.code(404).send(createErrorResponse('Not Found', `Job not found or not yet completed: ${jobId}`));
|
|
548
|
+
}
|
|
549
|
+
if (result.status !== 'completed') {
|
|
550
|
+
return reply.code(400).send(createErrorResponse('Bad Request', `Job is not completed. Status: ${result.status}`));
|
|
551
|
+
}
|
|
552
|
+
// Generate SARIF report
|
|
553
|
+
try {
|
|
554
|
+
const sarif = sarifBuilder.buildSarif(result, options);
|
|
555
|
+
return reply.send(sarif);
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
console.error('Error generating SARIF report:', error);
|
|
559
|
+
return reply.code(500).send(createErrorResponse('Internal Server Error', 'Failed to generate SARIF report'));
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
// Generate Spectral CLI reproduction instructions for a completed job
|
|
563
|
+
fastify.get('/lint/:jobId/reproduce', {
|
|
564
|
+
schema: {
|
|
565
|
+
description: 'Generate Markdown instructions for reproducing a lint job using the native Spectral CLI. Includes git clone, install, and spectral lint commands. When rule overrides were applied, includes Spectral override configuration.',
|
|
566
|
+
tags: ['Reports'],
|
|
567
|
+
summary: 'Get Spectral reproduction instructions',
|
|
568
|
+
operationId: 'getReproductionInstructions',
|
|
569
|
+
params: jobIdParamSchema,
|
|
570
|
+
response: {
|
|
571
|
+
200: { type: 'string', description: 'Markdown reproduction instructions' },
|
|
572
|
+
400: { $ref: 'ErrorResponse#', description: 'Job not yet completed' },
|
|
573
|
+
404: { $ref: 'ErrorResponse#', description: 'Job or ruleset not found' },
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
}, async (request, reply) => {
|
|
577
|
+
const { jobId } = request.params;
|
|
578
|
+
// Get completed job result
|
|
579
|
+
const result = await orchestrator.getJobResult(jobId);
|
|
580
|
+
if (!result) {
|
|
581
|
+
return reply.code(404).send(createErrorResponse('Not Found', `Job not found or not yet completed: ${jobId}`));
|
|
582
|
+
}
|
|
583
|
+
// Must be in a terminal state
|
|
584
|
+
const terminalStatuses = ['completed', 'completed_with_errors', 'failed', 'timeout'];
|
|
585
|
+
if (!terminalStatuses.includes(result.status)) {
|
|
586
|
+
return reply.code(400).send(createErrorResponse('Bad Request', `Job not yet completed. Status: ${result.status}`));
|
|
587
|
+
}
|
|
588
|
+
// Get ruleset source metadata
|
|
589
|
+
const sourceMetadata = await rulesetLoader.getSourceMetadata(result.rulesetName, result.rulesetVersion);
|
|
590
|
+
if (!sourceMetadata) {
|
|
591
|
+
return reply.code(404).send(createErrorResponse('Not Found', `Source metadata not found for ruleset '${result.rulesetName}' version '${result.rulesetVersion}'`));
|
|
592
|
+
}
|
|
593
|
+
const markdown = generateReproductionMarkdown(result, sourceMetadata);
|
|
594
|
+
return reply.type('text/markdown; charset=utf-8').send(markdown);
|
|
595
|
+
});
|
|
596
|
+
// Get storage and failure statistics
|
|
597
|
+
fastify.get('/stats', {
|
|
598
|
+
schema: {
|
|
599
|
+
description: 'Returns storage statistics and orchestrator metrics.',
|
|
600
|
+
tags: ['Statistics'],
|
|
601
|
+
summary: 'Storage and failure statistics',
|
|
602
|
+
operationId: 'getStats',
|
|
603
|
+
response: {
|
|
604
|
+
200: { $ref: 'StatsResponse#', description: 'Server statistics' },
|
|
605
|
+
500: { $ref: 'ErrorResponse#', description: 'Internal server error' },
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
}, async () => {
|
|
609
|
+
const storageStats = await storage.getStats();
|
|
610
|
+
const orchestratorStats = orchestrator.getStats();
|
|
611
|
+
return {
|
|
612
|
+
storage: storageStats,
|
|
613
|
+
orchestrator: orchestratorStats,
|
|
614
|
+
timestamp: new Date().toISOString()
|
|
615
|
+
};
|
|
616
|
+
});
|
|
617
|
+
// List jobs (lightweight - documentId only)
|
|
618
|
+
fastify.get('/lint/jobs', {
|
|
619
|
+
schema: {
|
|
620
|
+
description: 'List lint jobs with optional filtering. Returns lightweight job summaries (document ID only, no metadata).',
|
|
621
|
+
tags: ['Jobs'],
|
|
622
|
+
summary: 'List jobs (lightweight)',
|
|
623
|
+
operationId: 'listJobs',
|
|
624
|
+
querystring: JobListQuerySchema,
|
|
625
|
+
response: {
|
|
626
|
+
200: { $ref: 'JobListResponse#', description: 'Paginated list of jobs' },
|
|
627
|
+
500: { $ref: 'ErrorResponse#', description: 'Internal server error' },
|
|
628
|
+
},
|
|
629
|
+
},
|
|
630
|
+
}, async (request) => {
|
|
631
|
+
const { status, documentId, rulesetName, startDate, endDate, limit, offset, sortBy, sortOrder } = request.query;
|
|
632
|
+
const options = {
|
|
633
|
+
...(status && { status: status.includes(',') ? status.split(',') : status }),
|
|
634
|
+
...(documentId && { documentId }),
|
|
635
|
+
...(rulesetName && { rulesetName }),
|
|
636
|
+
...(startDate && { startDate: new Date(startDate) }),
|
|
637
|
+
...(endDate && { endDate: new Date(endDate) }),
|
|
638
|
+
limit: limit ? parseInt(limit, 10) : 50,
|
|
639
|
+
offset: offset ? parseInt(offset, 10) : 0,
|
|
640
|
+
sortBy: sortBy || 'timestamp',
|
|
641
|
+
sortOrder: sortOrder || 'desc'
|
|
642
|
+
};
|
|
643
|
+
return orchestrator.listJobs(options, runtimeSessionId);
|
|
644
|
+
});
|
|
645
|
+
// List jobs with document metadata (detailed)
|
|
646
|
+
fastify.get('/lint/jobs/details', {
|
|
647
|
+
schema: {
|
|
648
|
+
description: 'List lint jobs with full document metadata attached. Heavier than `/lint/jobs` but provides complete context for each job.',
|
|
649
|
+
tags: ['Jobs'],
|
|
650
|
+
summary: 'List jobs with document metadata',
|
|
651
|
+
operationId: 'listJobsDetailed',
|
|
652
|
+
querystring: JobListQuerySchema,
|
|
653
|
+
response: {
|
|
654
|
+
200: { $ref: 'JobListDetailedResponse#', description: 'Paginated list of jobs with document metadata' },
|
|
655
|
+
500: { $ref: 'ErrorResponse#', description: 'Internal server error' },
|
|
656
|
+
},
|
|
657
|
+
},
|
|
658
|
+
}, async (request) => {
|
|
659
|
+
const { status, documentId, rulesetName, startDate, endDate, limit, offset, sortBy, sortOrder } = request.query;
|
|
660
|
+
const options = {
|
|
661
|
+
...(status && { status: status.includes(',') ? status.split(',') : status }),
|
|
662
|
+
...(documentId && { documentId }),
|
|
663
|
+
...(rulesetName && { rulesetName }),
|
|
664
|
+
...(startDate && { startDate: new Date(startDate) }),
|
|
665
|
+
...(endDate && { endDate: new Date(endDate) }),
|
|
666
|
+
limit: limit ? parseInt(limit, 10) : 50,
|
|
667
|
+
offset: offset ? parseInt(offset, 10) : 0,
|
|
668
|
+
sortBy: sortBy || 'timestamp',
|
|
669
|
+
sortOrder: sortOrder || 'desc'
|
|
670
|
+
};
|
|
671
|
+
return orchestrator.listJobsDetailed(options, runtimeSessionId);
|
|
672
|
+
});
|
|
673
|
+
// Invalidate cache for a document
|
|
674
|
+
fastify.delete('/cache/:documentId', {
|
|
675
|
+
schema: {
|
|
676
|
+
description: 'Remove all cached lint results for a specific document.',
|
|
677
|
+
tags: ['Cache'],
|
|
678
|
+
summary: 'Invalidate cache for a document',
|
|
679
|
+
operationId: 'invalidateCache',
|
|
680
|
+
params: documentIdParamSchema,
|
|
681
|
+
response: {
|
|
682
|
+
200: { $ref: 'CacheInvalidationResponse#', description: 'Cache invalidated successfully' },
|
|
683
|
+
500: { $ref: 'ErrorResponse#', description: 'Internal server error' },
|
|
684
|
+
},
|
|
685
|
+
},
|
|
686
|
+
}, async (request) => {
|
|
687
|
+
const { documentId } = request.params;
|
|
688
|
+
await orchestrator.invalidateCache(sanitizeDocumentId(documentId));
|
|
689
|
+
return {
|
|
690
|
+
message: `Cache invalidated for document: ${documentId}`
|
|
691
|
+
};
|
|
692
|
+
});
|
|
693
|
+
// List available rulesets
|
|
694
|
+
fastify.get('/rulesets', {
|
|
695
|
+
schema: {
|
|
696
|
+
description: 'Returns all configured rulesets with metadata and rule counts.',
|
|
697
|
+
tags: ['Rulesets'],
|
|
698
|
+
summary: 'List available rulesets',
|
|
699
|
+
operationId: 'listRulesets',
|
|
700
|
+
response: {
|
|
701
|
+
200: {
|
|
702
|
+
description: 'List of available rulesets',
|
|
703
|
+
type: 'array',
|
|
704
|
+
items: { $ref: 'RulesetSummary#' },
|
|
705
|
+
},
|
|
706
|
+
500: { $ref: 'ErrorResponse#', description: 'Internal server error' },
|
|
707
|
+
},
|
|
708
|
+
},
|
|
709
|
+
}, async () => {
|
|
710
|
+
try {
|
|
711
|
+
// Use the new method that loads rulesets to get accurate rule counts
|
|
712
|
+
const rulesets = await rulesetLoader.listRulesetsWithCounts();
|
|
713
|
+
// Return array directly for easier client consumption
|
|
714
|
+
return rulesets.map(metadata => ({
|
|
715
|
+
name: metadata.name,
|
|
716
|
+
version: metadata.defaultVersion,
|
|
717
|
+
defaultVersion: metadata.defaultVersion,
|
|
718
|
+
description: metadata.description,
|
|
719
|
+
availableVersions: metadata.versions,
|
|
720
|
+
displayName: metadata.displayName,
|
|
721
|
+
category: metadata.category,
|
|
722
|
+
ruleCount: metadata.ruleCount,
|
|
723
|
+
tags: metadata.tags
|
|
724
|
+
}));
|
|
725
|
+
}
|
|
726
|
+
catch (error) {
|
|
727
|
+
console.error('Error listing rulesets:', error);
|
|
728
|
+
throw error;
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
// Get ruleset details including rules
|
|
732
|
+
fastify.get('/rulesets/:name', {
|
|
733
|
+
schema: {
|
|
734
|
+
description: 'Retrieve detailed information about a specific ruleset, including all individual rules with their severity and descriptions.',
|
|
735
|
+
tags: ['Rulesets'],
|
|
736
|
+
summary: 'Get ruleset details including rules',
|
|
737
|
+
operationId: 'getRulesetDetails',
|
|
738
|
+
params: RulesetNameParamSchema,
|
|
739
|
+
querystring: RulesetVersionQuerySchema,
|
|
740
|
+
response: {
|
|
741
|
+
200: { $ref: 'RulesetDetails#', description: 'Ruleset details with rules' },
|
|
742
|
+
404: { $ref: 'ErrorResponse#', description: 'Resource not found' },
|
|
743
|
+
},
|
|
744
|
+
},
|
|
745
|
+
}, async (request, reply) => {
|
|
746
|
+
const { name } = request.params;
|
|
747
|
+
const { version } = request.query;
|
|
748
|
+
try {
|
|
749
|
+
const rulesetVersion = await rulesetLoader.loadVersion(name, version);
|
|
750
|
+
return {
|
|
751
|
+
name: rulesetVersion.metadata.name,
|
|
752
|
+
displayName: rulesetVersion.metadata.displayName,
|
|
753
|
+
version: rulesetVersion.version,
|
|
754
|
+
description: rulesetVersion.metadata.description,
|
|
755
|
+
category: rulesetVersion.metadata.category,
|
|
756
|
+
ruleCount: rulesetVersion.rules.length,
|
|
757
|
+
releaseDate: rulesetVersion.releaseDate,
|
|
758
|
+
deprecated: rulesetVersion.deprecated,
|
|
759
|
+
tags: rulesetVersion.metadata.tags,
|
|
760
|
+
rules: rulesetVersion.rules.map(rule => ({
|
|
761
|
+
name: rule.name,
|
|
762
|
+
severity: rule.severity,
|
|
763
|
+
message: rule.message,
|
|
764
|
+
description: rule.description,
|
|
765
|
+
recommended: rule.recommended,
|
|
766
|
+
}))
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
catch (error) {
|
|
770
|
+
return reply.code(404).send(createErrorResponse('Not Found', `Ruleset '${name}' not found`));
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
// Expose the auto-generated OpenAPI specification
|
|
774
|
+
fastify.get('/docs/openapi.json', {
|
|
775
|
+
schema: { hide: true },
|
|
776
|
+
}, async (_request, reply) => {
|
|
777
|
+
return reply.send(fastify.swagger());
|
|
778
|
+
});
|
|
779
|
+
// Start HTTP listener
|
|
780
|
+
const port = config.server?.port || 3003;
|
|
781
|
+
const host = config.server?.host || '0.0.0.0';
|
|
782
|
+
try {
|
|
783
|
+
await fastify.listen({ port, host });
|
|
784
|
+
console.log(`\n✅ Orchestrator service v${API_VERSION}`);
|
|
785
|
+
console.log(` HTTP API listening on http://${host}:${port}`);
|
|
786
|
+
console.log(`\nAvailable endpoints:`);
|
|
787
|
+
console.log(` POST /documents - Upload OpenAPI document`);
|
|
788
|
+
console.log(` GET /documents - List/search documents`);
|
|
789
|
+
console.log(` GET /documents/:documentId - Get document by ID`);
|
|
790
|
+
console.log(` POST /lint - Submit lint job`);
|
|
791
|
+
console.log(` GET /lint/:jobId - Get job status`);
|
|
792
|
+
console.log(` GET /lint/:jobId/results - Get job results`);
|
|
793
|
+
console.log(` GET /lint/jobs - List jobs (lightweight)`);
|
|
794
|
+
console.log(` GET /lint/jobs/details - List jobs (with document metadata)`);
|
|
795
|
+
console.log(` POST /lint/:jobId/reports/generate - Generate SARIF report`);
|
|
796
|
+
console.log(` GET /lint/:jobId/reproduce - Spectral CLI reproduction instructions`);
|
|
797
|
+
console.log(` DELETE /cache/:documentId - Invalidate cache`);
|
|
798
|
+
console.log(` GET /rulesets - List available rulesets`);
|
|
799
|
+
console.log(` GET /rulesets/:name - Get ruleset details`);
|
|
800
|
+
console.log(` GET /stats - Storage & failure statistics`);
|
|
801
|
+
console.log(` GET /stats/orchestrator - Orchestrator statistics`);
|
|
802
|
+
console.log(` GET /health - Health check`);
|
|
803
|
+
console.log(` GET /docs/openapi.json - OpenAPI specification (auto-generated)`);
|
|
804
|
+
console.log(`\n🎯 Ready to accept lint requests!\n`);
|
|
805
|
+
}
|
|
806
|
+
catch (error) {
|
|
807
|
+
console.error('Failed to start HTTP server:', error);
|
|
808
|
+
throw error;
|
|
809
|
+
}
|
|
810
|
+
// Create shutdown handler
|
|
811
|
+
const shutdown = async () => {
|
|
812
|
+
console.log('\n🛑 Shutting down the Orchestrator service...');
|
|
813
|
+
try {
|
|
814
|
+
await orchestrator.shutdown();
|
|
815
|
+
await workerPool.shutdown();
|
|
816
|
+
await fastify.close();
|
|
817
|
+
console.log('✅ Shutdown complete');
|
|
818
|
+
}
|
|
819
|
+
catch (error) {
|
|
820
|
+
console.error('Error during shutdown:', error);
|
|
821
|
+
throw error;
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
// Return server instance with control interface
|
|
825
|
+
return {
|
|
826
|
+
fastify,
|
|
827
|
+
orchestrator,
|
|
828
|
+
workerPool,
|
|
829
|
+
documentStore,
|
|
830
|
+
config,
|
|
831
|
+
shutdown
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
//# sourceMappingURL=server.js.map
|