@criterionx/server 0.3.0 → 0.3.2
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/dist/index.d.ts +252 -26
- package/dist/index.js +606 -26
- package/package.json +4 -4
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,100 @@
|
|
|
1
|
-
import { Decision } from '@criterionx/core';
|
|
2
|
-
|
|
1
|
+
import { Decision, Result, JsonSchema } from '@criterionx/core';
|
|
2
|
+
export { DecisionSchema, JsonSchema, extractDecisionSchema, toJsonSchema } from '@criterionx/core';
|
|
3
3
|
import { Hono } from 'hono';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Context passed to hooks
|
|
7
|
+
*/
|
|
8
|
+
interface HookContext {
|
|
9
|
+
/** ID of the decision being evaluated */
|
|
10
|
+
decisionId: string;
|
|
11
|
+
/** Input data for the decision */
|
|
12
|
+
input: unknown;
|
|
13
|
+
/** Profile being used */
|
|
14
|
+
profile: unknown;
|
|
15
|
+
/** Request ID for tracing (auto-generated) */
|
|
16
|
+
requestId: string;
|
|
17
|
+
/** Timestamp when evaluation started */
|
|
18
|
+
timestamp: Date;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Hook called before decision evaluation
|
|
22
|
+
*
|
|
23
|
+
* Can modify context (input, profile) by returning a modified context.
|
|
24
|
+
* Return undefined to keep original context.
|
|
25
|
+
* Throw to abort evaluation with error.
|
|
26
|
+
*/
|
|
27
|
+
type BeforeEvaluateHook = (ctx: HookContext) => Promise<Partial<HookContext> | void> | Partial<HookContext> | void;
|
|
28
|
+
/**
|
|
29
|
+
* Hook called after decision evaluation
|
|
30
|
+
*
|
|
31
|
+
* Receives the evaluation result. Cannot modify the result.
|
|
32
|
+
* Use for logging, metrics, side effects.
|
|
33
|
+
*/
|
|
34
|
+
type AfterEvaluateHook = (ctx: HookContext, result: Result<unknown>) => Promise<void> | void;
|
|
35
|
+
/**
|
|
36
|
+
* Hook called when an error occurs during evaluation
|
|
37
|
+
*/
|
|
38
|
+
type OnErrorHook = (ctx: HookContext, error: Error) => Promise<void> | void;
|
|
39
|
+
/**
|
|
40
|
+
* Middleware hooks configuration
|
|
41
|
+
*/
|
|
42
|
+
interface Hooks {
|
|
43
|
+
/** Called before decision evaluation */
|
|
44
|
+
beforeEvaluate?: BeforeEvaluateHook;
|
|
45
|
+
/** Called after successful evaluation */
|
|
46
|
+
afterEvaluate?: AfterEvaluateHook;
|
|
47
|
+
/** Called when an error occurs */
|
|
48
|
+
onError?: OnErrorHook;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Metrics configuration options
|
|
52
|
+
*/
|
|
53
|
+
interface MetricsOptions$1 {
|
|
54
|
+
/** Enable metrics collection (default: false) */
|
|
55
|
+
enabled?: boolean;
|
|
56
|
+
/** Endpoint path for metrics (default: /metrics) */
|
|
57
|
+
endpoint?: string;
|
|
58
|
+
/** Histogram buckets for latency in seconds */
|
|
59
|
+
buckets?: number[];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* OpenAPI info object
|
|
63
|
+
*/
|
|
64
|
+
interface OpenAPIInfo {
|
|
65
|
+
/** API title */
|
|
66
|
+
title: string;
|
|
67
|
+
/** API version */
|
|
68
|
+
version: string;
|
|
69
|
+
/** API description */
|
|
70
|
+
description?: string;
|
|
71
|
+
/** Contact information */
|
|
72
|
+
contact?: {
|
|
73
|
+
name?: string;
|
|
74
|
+
url?: string;
|
|
75
|
+
email?: string;
|
|
76
|
+
};
|
|
77
|
+
/** License information */
|
|
78
|
+
license?: {
|
|
79
|
+
name: string;
|
|
80
|
+
url?: string;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* OpenAPI configuration options
|
|
85
|
+
*/
|
|
86
|
+
interface OpenAPIOptions {
|
|
87
|
+
/** Enable OpenAPI spec generation (default: false) */
|
|
88
|
+
enabled?: boolean;
|
|
89
|
+
/** Endpoint path for OpenAPI spec (default: /openapi.json) */
|
|
90
|
+
endpoint?: string;
|
|
91
|
+
/** API info for OpenAPI spec */
|
|
92
|
+
info?: Partial<OpenAPIInfo>;
|
|
93
|
+
/** Enable Swagger UI (default: true when openapi is enabled) */
|
|
94
|
+
swaggerUI?: boolean;
|
|
95
|
+
/** Swagger UI endpoint (default: /swagger) */
|
|
96
|
+
swaggerEndpoint?: string;
|
|
97
|
+
}
|
|
5
98
|
/**
|
|
6
99
|
* Server configuration options
|
|
7
100
|
*/
|
|
@@ -12,6 +105,12 @@ interface ServerOptions {
|
|
|
12
105
|
profiles?: Record<string, unknown>;
|
|
13
106
|
/** Enable CORS (default: true) */
|
|
14
107
|
cors?: boolean;
|
|
108
|
+
/** Middleware hooks for evaluation lifecycle */
|
|
109
|
+
hooks?: Hooks;
|
|
110
|
+
/** Prometheus metrics configuration */
|
|
111
|
+
metrics?: MetricsOptions$1;
|
|
112
|
+
/** OpenAPI spec generation configuration */
|
|
113
|
+
openapi?: OpenAPIOptions;
|
|
15
114
|
}
|
|
16
115
|
/**
|
|
17
116
|
* Request body for decision evaluation
|
|
@@ -32,37 +131,39 @@ interface DecisionInfo {
|
|
|
32
131
|
meta?: Record<string, unknown>;
|
|
33
132
|
}
|
|
34
133
|
/**
|
|
35
|
-
*
|
|
134
|
+
* Error codes for structured error responses
|
|
36
135
|
*/
|
|
37
|
-
|
|
38
|
-
$schema?: string;
|
|
39
|
-
type?: string;
|
|
40
|
-
properties?: Record<string, JsonSchema>;
|
|
41
|
-
required?: string[];
|
|
42
|
-
additionalProperties?: boolean;
|
|
43
|
-
items?: JsonSchema;
|
|
44
|
-
enum?: unknown[];
|
|
45
|
-
[key: string]: unknown;
|
|
46
|
-
}
|
|
136
|
+
type ErrorCode = "DECISION_NOT_FOUND" | "INVALID_JSON" | "MISSING_INPUT" | "MISSING_PROFILE" | "VALIDATION_ERROR" | "EVALUATION_ERROR" | "INTERNAL_ERROR";
|
|
47
137
|
/**
|
|
48
|
-
*
|
|
138
|
+
* Structured error response
|
|
49
139
|
*/
|
|
50
|
-
interface
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
140
|
+
interface ErrorResponse {
|
|
141
|
+
error: {
|
|
142
|
+
code: ErrorCode;
|
|
143
|
+
message: string;
|
|
144
|
+
details?: Record<string, unknown>;
|
|
145
|
+
};
|
|
146
|
+
requestId?: string;
|
|
147
|
+
timestamp: string;
|
|
56
148
|
}
|
|
57
|
-
|
|
58
149
|
/**
|
|
59
|
-
*
|
|
150
|
+
* Health check status
|
|
60
151
|
*/
|
|
61
|
-
|
|
152
|
+
type HealthStatus = "healthy" | "degraded" | "unhealthy";
|
|
62
153
|
/**
|
|
63
|
-
*
|
|
154
|
+
* Health check response
|
|
64
155
|
*/
|
|
65
|
-
|
|
156
|
+
interface HealthResponse {
|
|
157
|
+
status: HealthStatus;
|
|
158
|
+
version: string;
|
|
159
|
+
uptime: number;
|
|
160
|
+
timestamp: string;
|
|
161
|
+
checks?: Record<string, {
|
|
162
|
+
status: HealthStatus;
|
|
163
|
+
message?: string;
|
|
164
|
+
}>;
|
|
165
|
+
}
|
|
166
|
+
|
|
66
167
|
/**
|
|
67
168
|
* Generate OpenAPI-compatible schema for a decision endpoint
|
|
68
169
|
*/
|
|
@@ -71,6 +172,121 @@ declare function generateEndpointSchema(decision: Decision<unknown, unknown, unk
|
|
|
71
172
|
response: JsonSchema;
|
|
72
173
|
};
|
|
73
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Lightweight metrics collector for Criterion Server
|
|
177
|
+
*
|
|
178
|
+
* Exposes metrics in Prometheus format without external dependencies.
|
|
179
|
+
*/
|
|
180
|
+
interface MetricsOptions {
|
|
181
|
+
/** Enable metrics collection (default: false) */
|
|
182
|
+
enabled?: boolean;
|
|
183
|
+
/** Endpoint path for metrics (default: /metrics) */
|
|
184
|
+
endpoint?: string;
|
|
185
|
+
/** Histogram buckets for latency in seconds */
|
|
186
|
+
buckets?: number[];
|
|
187
|
+
}
|
|
188
|
+
interface MetricLabels {
|
|
189
|
+
[key: string]: string;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Simple metrics collector that outputs Prometheus format
|
|
193
|
+
*/
|
|
194
|
+
declare class MetricsCollector {
|
|
195
|
+
private counters;
|
|
196
|
+
private histograms;
|
|
197
|
+
private buckets;
|
|
198
|
+
constructor(options?: MetricsOptions);
|
|
199
|
+
/**
|
|
200
|
+
* Increment a counter metric
|
|
201
|
+
*/
|
|
202
|
+
increment(name: string, labels?: MetricLabels, value?: number): void;
|
|
203
|
+
/**
|
|
204
|
+
* Record a value in a histogram metric
|
|
205
|
+
*/
|
|
206
|
+
observe(name: string, labels: MetricLabels, value: number): void;
|
|
207
|
+
/**
|
|
208
|
+
* Get a counter value
|
|
209
|
+
*/
|
|
210
|
+
getCounter(name: string, labels?: MetricLabels): number;
|
|
211
|
+
/**
|
|
212
|
+
* Get histogram stats
|
|
213
|
+
*/
|
|
214
|
+
getHistogram(name: string, labels: MetricLabels): {
|
|
215
|
+
sum: number;
|
|
216
|
+
count: number;
|
|
217
|
+
} | undefined;
|
|
218
|
+
/**
|
|
219
|
+
* Export all metrics in Prometheus format
|
|
220
|
+
*/
|
|
221
|
+
toPrometheus(): string;
|
|
222
|
+
/**
|
|
223
|
+
* Reset all metrics
|
|
224
|
+
*/
|
|
225
|
+
reset(): void;
|
|
226
|
+
private findCounter;
|
|
227
|
+
private findHistogram;
|
|
228
|
+
private labelsMatch;
|
|
229
|
+
private formatLabels;
|
|
230
|
+
}
|
|
231
|
+
declare const METRIC_EVALUATIONS_TOTAL = "criterion_evaluations_total";
|
|
232
|
+
declare const METRIC_EVALUATION_DURATION_SECONDS = "criterion_evaluation_duration_seconds";
|
|
233
|
+
declare const METRIC_RULE_MATCHES_TOTAL = "criterion_rule_matches_total";
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* OpenAPI 3.0 spec generator for Criterion decisions
|
|
237
|
+
*/
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* OpenAPI 3.0 specification
|
|
241
|
+
*/
|
|
242
|
+
interface OpenAPISpec {
|
|
243
|
+
openapi: "3.0.0";
|
|
244
|
+
info: OpenAPIInfo;
|
|
245
|
+
paths: Record<string, PathItem>;
|
|
246
|
+
components: {
|
|
247
|
+
schemas: Record<string, JsonSchema>;
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
interface PathItem {
|
|
251
|
+
post?: Operation;
|
|
252
|
+
get?: Operation;
|
|
253
|
+
}
|
|
254
|
+
interface Operation {
|
|
255
|
+
operationId: string;
|
|
256
|
+
summary: string;
|
|
257
|
+
description?: string;
|
|
258
|
+
tags?: string[];
|
|
259
|
+
requestBody?: {
|
|
260
|
+
required: boolean;
|
|
261
|
+
content: {
|
|
262
|
+
"application/json": {
|
|
263
|
+
schema: {
|
|
264
|
+
$ref: string;
|
|
265
|
+
} | JsonSchema;
|
|
266
|
+
};
|
|
267
|
+
};
|
|
268
|
+
};
|
|
269
|
+
responses: Record<string, Response>;
|
|
270
|
+
}
|
|
271
|
+
interface Response {
|
|
272
|
+
description: string;
|
|
273
|
+
content?: {
|
|
274
|
+
"application/json": {
|
|
275
|
+
schema: {
|
|
276
|
+
$ref: string;
|
|
277
|
+
} | JsonSchema;
|
|
278
|
+
};
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Generate OpenAPI spec from decisions
|
|
283
|
+
*/
|
|
284
|
+
declare function generateOpenAPISpec(decisions: Decision<any, any, any>[], info?: Partial<OpenAPIInfo>): OpenAPISpec;
|
|
285
|
+
/**
|
|
286
|
+
* Generate Swagger UI HTML
|
|
287
|
+
*/
|
|
288
|
+
declare function generateSwaggerUIHtml(specUrl: string): string;
|
|
289
|
+
|
|
74
290
|
/**
|
|
75
291
|
* Criterion Server
|
|
76
292
|
*
|
|
@@ -81,6 +297,12 @@ declare class CriterionServer {
|
|
|
81
297
|
private engine;
|
|
82
298
|
private decisions;
|
|
83
299
|
private profiles;
|
|
300
|
+
private hooks;
|
|
301
|
+
private metricsCollector;
|
|
302
|
+
private metricsOptions;
|
|
303
|
+
private openApiOptions;
|
|
304
|
+
private openApiSpec;
|
|
305
|
+
private startTime;
|
|
84
306
|
constructor(options: ServerOptions);
|
|
85
307
|
private setupRoutes;
|
|
86
308
|
private generateDocsHtml;
|
|
@@ -88,6 +310,10 @@ declare class CriterionServer {
|
|
|
88
310
|
* Get the Hono app instance (for custom middleware)
|
|
89
311
|
*/
|
|
90
312
|
get handler(): Hono;
|
|
313
|
+
/**
|
|
314
|
+
* Get the metrics collector (if enabled)
|
|
315
|
+
*/
|
|
316
|
+
get metrics(): MetricsCollector | null;
|
|
91
317
|
/**
|
|
92
318
|
* Start the server
|
|
93
319
|
*/
|
|
@@ -98,4 +324,4 @@ declare class CriterionServer {
|
|
|
98
324
|
*/
|
|
99
325
|
declare function createServer(options: ServerOptions): CriterionServer;
|
|
100
326
|
|
|
101
|
-
export { CriterionServer, type DecisionInfo, type
|
|
327
|
+
export { type AfterEvaluateHook, type BeforeEvaluateHook, CriterionServer, type DecisionInfo, type ErrorCode, type ErrorResponse, type EvaluateRequest, type HealthResponse, type HealthStatus, type HookContext, type Hooks, METRIC_EVALUATIONS_TOTAL, METRIC_EVALUATION_DURATION_SECONDS, METRIC_RULE_MATCHES_TOTAL, MetricsCollector, type MetricsOptions$1 as MetricsOptions, type OnErrorHook, type OpenAPIInfo, type OpenAPIOptions, type OpenAPISpec, type ServerOptions, createServer, generateEndpointSchema, generateOpenAPISpec, generateSwaggerUIHtml };
|
package/dist/index.js
CHANGED
|
@@ -1,17 +1,8 @@
|
|
|
1
1
|
// src/schema.ts
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
}
|
|
6
|
-
function extractDecisionSchema(decision) {
|
|
7
|
-
return {
|
|
8
|
-
id: decision.id,
|
|
9
|
-
version: decision.version,
|
|
10
|
-
inputSchema: toJsonSchema(decision.inputSchema),
|
|
11
|
-
outputSchema: toJsonSchema(decision.outputSchema),
|
|
12
|
-
profileSchema: toJsonSchema(decision.profileSchema)
|
|
13
|
-
};
|
|
14
|
-
}
|
|
2
|
+
import {
|
|
3
|
+
toJsonSchema,
|
|
4
|
+
extractDecisionSchema
|
|
5
|
+
} from "@criterionx/core";
|
|
15
6
|
function generateEndpointSchema(decision) {
|
|
16
7
|
const inputSchema = toJsonSchema(decision.inputSchema);
|
|
17
8
|
const profileSchema = toJsonSchema(decision.profileSchema);
|
|
@@ -49,22 +40,462 @@ function generateEndpointSchema(decision) {
|
|
|
49
40
|
};
|
|
50
41
|
}
|
|
51
42
|
|
|
43
|
+
// src/metrics.ts
|
|
44
|
+
var MetricsCollector = class {
|
|
45
|
+
counters = /* @__PURE__ */ new Map();
|
|
46
|
+
histograms = /* @__PURE__ */ new Map();
|
|
47
|
+
buckets;
|
|
48
|
+
constructor(options = {}) {
|
|
49
|
+
this.buckets = options.buckets ?? [
|
|
50
|
+
1e-3,
|
|
51
|
+
5e-3,
|
|
52
|
+
0.01,
|
|
53
|
+
0.025,
|
|
54
|
+
0.05,
|
|
55
|
+
0.1,
|
|
56
|
+
0.25,
|
|
57
|
+
0.5,
|
|
58
|
+
1,
|
|
59
|
+
2.5,
|
|
60
|
+
5,
|
|
61
|
+
10
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Increment a counter metric
|
|
66
|
+
*/
|
|
67
|
+
increment(name, labels = {}, value = 1) {
|
|
68
|
+
const existing = this.findCounter(name, labels);
|
|
69
|
+
if (existing) {
|
|
70
|
+
existing.value += value;
|
|
71
|
+
} else {
|
|
72
|
+
if (!this.counters.has(name)) {
|
|
73
|
+
this.counters.set(name, []);
|
|
74
|
+
}
|
|
75
|
+
this.counters.get(name).push({ labels, value });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Record a value in a histogram metric
|
|
80
|
+
*/
|
|
81
|
+
observe(name, labels, value) {
|
|
82
|
+
const existing = this.findHistogram(name, labels);
|
|
83
|
+
if (existing) {
|
|
84
|
+
existing.sum += value;
|
|
85
|
+
existing.count += 1;
|
|
86
|
+
for (const bucket of this.buckets) {
|
|
87
|
+
if (value <= bucket) {
|
|
88
|
+
existing.buckets.set(bucket, (existing.buckets.get(bucket) ?? 0) + 1);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
if (!this.histograms.has(name)) {
|
|
93
|
+
this.histograms.set(name, []);
|
|
94
|
+
}
|
|
95
|
+
const bucketMap = /* @__PURE__ */ new Map();
|
|
96
|
+
for (const bucket of this.buckets) {
|
|
97
|
+
bucketMap.set(bucket, value <= bucket ? 1 : 0);
|
|
98
|
+
}
|
|
99
|
+
this.histograms.get(name).push({
|
|
100
|
+
labels,
|
|
101
|
+
sum: value,
|
|
102
|
+
count: 1,
|
|
103
|
+
buckets: bucketMap
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Get a counter value
|
|
109
|
+
*/
|
|
110
|
+
getCounter(name, labels = {}) {
|
|
111
|
+
return this.findCounter(name, labels)?.value ?? 0;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Get histogram stats
|
|
115
|
+
*/
|
|
116
|
+
getHistogram(name, labels) {
|
|
117
|
+
const h = this.findHistogram(name, labels);
|
|
118
|
+
if (!h) return void 0;
|
|
119
|
+
return { sum: h.sum, count: h.count };
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Export all metrics in Prometheus format
|
|
123
|
+
*/
|
|
124
|
+
toPrometheus() {
|
|
125
|
+
const lines = [];
|
|
126
|
+
for (const [name, values] of this.counters) {
|
|
127
|
+
lines.push(`# HELP ${name} Counter metric`);
|
|
128
|
+
lines.push(`# TYPE ${name} counter`);
|
|
129
|
+
for (const v of values) {
|
|
130
|
+
const labelStr = this.formatLabels(v.labels);
|
|
131
|
+
lines.push(`${name}${labelStr} ${v.value}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
for (const [name, values] of this.histograms) {
|
|
135
|
+
lines.push(`# HELP ${name} Histogram metric`);
|
|
136
|
+
lines.push(`# TYPE ${name} histogram`);
|
|
137
|
+
for (const v of values) {
|
|
138
|
+
const baseLabels = this.formatLabels(v.labels);
|
|
139
|
+
let cumulative = 0;
|
|
140
|
+
for (const bucket of this.buckets) {
|
|
141
|
+
cumulative += v.buckets.get(bucket) ?? 0;
|
|
142
|
+
const bucketLabels = this.formatLabels({ ...v.labels, le: String(bucket) });
|
|
143
|
+
lines.push(`${name}_bucket${bucketLabels} ${cumulative}`);
|
|
144
|
+
}
|
|
145
|
+
const infLabels = this.formatLabels({ ...v.labels, le: "+Inf" });
|
|
146
|
+
lines.push(`${name}_bucket${infLabels} ${v.count}`);
|
|
147
|
+
lines.push(`${name}_sum${baseLabels} ${v.sum}`);
|
|
148
|
+
lines.push(`${name}_count${baseLabels} ${v.count}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Reset all metrics
|
|
155
|
+
*/
|
|
156
|
+
reset() {
|
|
157
|
+
this.counters.clear();
|
|
158
|
+
this.histograms.clear();
|
|
159
|
+
}
|
|
160
|
+
findCounter(name, labels) {
|
|
161
|
+
const values = this.counters.get(name);
|
|
162
|
+
if (!values) return void 0;
|
|
163
|
+
return values.find((v) => this.labelsMatch(v.labels, labels));
|
|
164
|
+
}
|
|
165
|
+
findHistogram(name, labels) {
|
|
166
|
+
const values = this.histograms.get(name);
|
|
167
|
+
if (!values) return void 0;
|
|
168
|
+
return values.find((v) => this.labelsMatch(v.labels, labels));
|
|
169
|
+
}
|
|
170
|
+
labelsMatch(a, b) {
|
|
171
|
+
const keysA = Object.keys(a);
|
|
172
|
+
const keysB = Object.keys(b);
|
|
173
|
+
if (keysA.length !== keysB.length) return false;
|
|
174
|
+
return keysA.every((key) => a[key] === b[key]);
|
|
175
|
+
}
|
|
176
|
+
formatLabels(labels) {
|
|
177
|
+
const entries = Object.entries(labels);
|
|
178
|
+
if (entries.length === 0) return "";
|
|
179
|
+
const parts = entries.map(([k, v]) => `${k}="${v}"`);
|
|
180
|
+
return `{${parts.join(",")}}`;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
var METRIC_EVALUATIONS_TOTAL = "criterion_evaluations_total";
|
|
184
|
+
var METRIC_EVALUATION_DURATION_SECONDS = "criterion_evaluation_duration_seconds";
|
|
185
|
+
var METRIC_RULE_MATCHES_TOTAL = "criterion_rule_matches_total";
|
|
186
|
+
|
|
187
|
+
// src/openapi.ts
|
|
188
|
+
function toSchemaName(id, suffix) {
|
|
189
|
+
const pascal = id.split(/[-_]/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
190
|
+
return `${pascal}${suffix}`;
|
|
191
|
+
}
|
|
192
|
+
function generateOpenAPISpec(decisions, info = {}) {
|
|
193
|
+
const paths = {};
|
|
194
|
+
const schemas = {};
|
|
195
|
+
schemas["EvaluateRequest"] = {
|
|
196
|
+
type: "object",
|
|
197
|
+
properties: {
|
|
198
|
+
input: { description: "Input data for the decision" },
|
|
199
|
+
profile: { description: "Profile to use (overrides default)" }
|
|
200
|
+
},
|
|
201
|
+
required: ["input"]
|
|
202
|
+
};
|
|
203
|
+
schemas["EvaluationResult"] = {
|
|
204
|
+
type: "object",
|
|
205
|
+
properties: {
|
|
206
|
+
status: {
|
|
207
|
+
type: "string",
|
|
208
|
+
enum: ["OK", "INVALID_INPUT", "INVALID_OUTPUT", "NO_MATCH"],
|
|
209
|
+
description: "Evaluation status"
|
|
210
|
+
},
|
|
211
|
+
data: {
|
|
212
|
+
description: "Output data (null if status is not OK)"
|
|
213
|
+
},
|
|
214
|
+
meta: {
|
|
215
|
+
$ref: "#/components/schemas/ResultMeta"
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
required: ["status", "data", "meta"]
|
|
219
|
+
};
|
|
220
|
+
schemas["ResultMeta"] = {
|
|
221
|
+
type: "object",
|
|
222
|
+
properties: {
|
|
223
|
+
decisionId: { type: "string" },
|
|
224
|
+
decisionVersion: { type: "string" },
|
|
225
|
+
profileId: { type: "string" },
|
|
226
|
+
matchedRule: { type: "string" },
|
|
227
|
+
explanation: { type: "string" },
|
|
228
|
+
evaluatedAt: { type: "string", format: "date-time" },
|
|
229
|
+
evaluatedRules: {
|
|
230
|
+
type: "array",
|
|
231
|
+
items: {
|
|
232
|
+
type: "object",
|
|
233
|
+
properties: {
|
|
234
|
+
ruleId: { type: "string" },
|
|
235
|
+
matched: { type: "boolean" },
|
|
236
|
+
explanation: { type: "string" }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
schemas["ErrorResponse"] = {
|
|
243
|
+
type: "object",
|
|
244
|
+
properties: {
|
|
245
|
+
error: { type: "string", description: "Error message" }
|
|
246
|
+
},
|
|
247
|
+
required: ["error"]
|
|
248
|
+
};
|
|
249
|
+
for (const decision of decisions) {
|
|
250
|
+
const decisionSchema = extractDecisionSchema(decision);
|
|
251
|
+
const inputSchemaName = toSchemaName(decision.id, "Input");
|
|
252
|
+
const outputSchemaName = toSchemaName(decision.id, "Output");
|
|
253
|
+
const profileSchemaName = toSchemaName(decision.id, "Profile");
|
|
254
|
+
const requestSchemaName = toSchemaName(decision.id, "Request");
|
|
255
|
+
schemas[inputSchemaName] = decisionSchema.inputSchema;
|
|
256
|
+
schemas[outputSchemaName] = decisionSchema.outputSchema;
|
|
257
|
+
schemas[profileSchemaName] = decisionSchema.profileSchema;
|
|
258
|
+
schemas[requestSchemaName] = {
|
|
259
|
+
type: "object",
|
|
260
|
+
properties: {
|
|
261
|
+
input: { $ref: `#/components/schemas/${inputSchemaName}` },
|
|
262
|
+
profile: { $ref: `#/components/schemas/${profileSchemaName}` }
|
|
263
|
+
},
|
|
264
|
+
required: ["input"]
|
|
265
|
+
};
|
|
266
|
+
const path = `/decisions/${decision.id}`;
|
|
267
|
+
const description = decision.meta?.description;
|
|
268
|
+
paths[path] = {
|
|
269
|
+
post: {
|
|
270
|
+
operationId: `evaluate_${decision.id.replace(/-/g, "_")}`,
|
|
271
|
+
summary: `Evaluate ${decision.id}`,
|
|
272
|
+
description: description ?? `Evaluate the ${decision.id} decision`,
|
|
273
|
+
tags: ["decisions"],
|
|
274
|
+
requestBody: {
|
|
275
|
+
required: true,
|
|
276
|
+
content: {
|
|
277
|
+
"application/json": {
|
|
278
|
+
schema: { $ref: `#/components/schemas/${requestSchemaName}` }
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
responses: {
|
|
283
|
+
"200": {
|
|
284
|
+
description: "Decision evaluated successfully",
|
|
285
|
+
content: {
|
|
286
|
+
"application/json": {
|
|
287
|
+
schema: {
|
|
288
|
+
allOf: [
|
|
289
|
+
{ $ref: "#/components/schemas/EvaluationResult" },
|
|
290
|
+
{
|
|
291
|
+
type: "object",
|
|
292
|
+
properties: {
|
|
293
|
+
data: { $ref: `#/components/schemas/${outputSchemaName}` }
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
]
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
"400": {
|
|
302
|
+
description: "Invalid input or no matching rule",
|
|
303
|
+
content: {
|
|
304
|
+
"application/json": {
|
|
305
|
+
schema: { $ref: "#/components/schemas/EvaluationResult" }
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
"404": {
|
|
310
|
+
description: "Decision not found",
|
|
311
|
+
content: {
|
|
312
|
+
"application/json": {
|
|
313
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" }
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
paths[`${path}/schema`] = {
|
|
321
|
+
get: {
|
|
322
|
+
operationId: `get_${decision.id.replace(/-/g, "_")}_schema`,
|
|
323
|
+
summary: `Get ${decision.id} schema`,
|
|
324
|
+
description: `Get JSON Schema for the ${decision.id} decision`,
|
|
325
|
+
tags: ["schemas"],
|
|
326
|
+
responses: {
|
|
327
|
+
"200": {
|
|
328
|
+
description: "Decision schema",
|
|
329
|
+
content: {
|
|
330
|
+
"application/json": {
|
|
331
|
+
schema: {
|
|
332
|
+
type: "object",
|
|
333
|
+
properties: {
|
|
334
|
+
id: { type: "string" },
|
|
335
|
+
version: { type: "string" },
|
|
336
|
+
inputSchema: { type: "object" },
|
|
337
|
+
outputSchema: { type: "object" },
|
|
338
|
+
profileSchema: { type: "object" }
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
paths["/"] = {
|
|
349
|
+
get: {
|
|
350
|
+
operationId: "health_check",
|
|
351
|
+
summary: "Health check",
|
|
352
|
+
tags: ["system"],
|
|
353
|
+
responses: {
|
|
354
|
+
"200": {
|
|
355
|
+
description: "Server status",
|
|
356
|
+
content: {
|
|
357
|
+
"application/json": {
|
|
358
|
+
schema: {
|
|
359
|
+
type: "object",
|
|
360
|
+
properties: {
|
|
361
|
+
name: { type: "string" },
|
|
362
|
+
version: { type: "string" },
|
|
363
|
+
decisions: { type: "integer" },
|
|
364
|
+
metrics: { type: "boolean" }
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
paths["/decisions"] = {
|
|
374
|
+
get: {
|
|
375
|
+
operationId: "list_decisions",
|
|
376
|
+
summary: "List all decisions",
|
|
377
|
+
tags: ["decisions"],
|
|
378
|
+
responses: {
|
|
379
|
+
"200": {
|
|
380
|
+
description: "List of registered decisions",
|
|
381
|
+
content: {
|
|
382
|
+
"application/json": {
|
|
383
|
+
schema: {
|
|
384
|
+
type: "object",
|
|
385
|
+
properties: {
|
|
386
|
+
decisions: {
|
|
387
|
+
type: "array",
|
|
388
|
+
items: {
|
|
389
|
+
type: "object",
|
|
390
|
+
properties: {
|
|
391
|
+
id: { type: "string" },
|
|
392
|
+
version: { type: "string" },
|
|
393
|
+
description: { type: "string" },
|
|
394
|
+
meta: { type: "object" }
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
return {
|
|
407
|
+
openapi: "3.0.0",
|
|
408
|
+
info: {
|
|
409
|
+
title: info.title ?? "Criterion Decision API",
|
|
410
|
+
version: info.version ?? "1.0.0",
|
|
411
|
+
description: info.description ?? "Auto-generated API for Criterion decisions",
|
|
412
|
+
...info.contact && { contact: info.contact },
|
|
413
|
+
...info.license && { license: info.license }
|
|
414
|
+
},
|
|
415
|
+
paths,
|
|
416
|
+
components: {
|
|
417
|
+
schemas
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
function generateSwaggerUIHtml(specUrl) {
|
|
422
|
+
return `<!DOCTYPE html>
|
|
423
|
+
<html lang="en">
|
|
424
|
+
<head>
|
|
425
|
+
<meta charset="UTF-8">
|
|
426
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
427
|
+
<title>Criterion API - Swagger UI</title>
|
|
428
|
+
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
|
429
|
+
<style>
|
|
430
|
+
body { margin: 0; padding: 0; }
|
|
431
|
+
.swagger-ui .topbar { display: none; }
|
|
432
|
+
</style>
|
|
433
|
+
</head>
|
|
434
|
+
<body>
|
|
435
|
+
<div id="swagger-ui"></div>
|
|
436
|
+
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
437
|
+
<script>
|
|
438
|
+
window.onload = function() {
|
|
439
|
+
SwaggerUIBundle({
|
|
440
|
+
url: "${specUrl}",
|
|
441
|
+
dom_id: '#swagger-ui',
|
|
442
|
+
deepLinking: true,
|
|
443
|
+
presets: [
|
|
444
|
+
SwaggerUIBundle.presets.apis,
|
|
445
|
+
SwaggerUIBundle.SwaggerUIStandalonePreset
|
|
446
|
+
],
|
|
447
|
+
layout: "BaseLayout"
|
|
448
|
+
});
|
|
449
|
+
};
|
|
450
|
+
</script>
|
|
451
|
+
</body>
|
|
452
|
+
</html>`;
|
|
453
|
+
}
|
|
454
|
+
|
|
52
455
|
// src/server.ts
|
|
53
456
|
import { Hono } from "hono";
|
|
54
457
|
import { cors } from "hono/cors";
|
|
55
458
|
import { serve } from "@hono/node-server";
|
|
56
459
|
import { Engine } from "@criterionx/core";
|
|
460
|
+
var SERVER_VERSION = "0.3.2";
|
|
461
|
+
function generateRequestId() {
|
|
462
|
+
return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
|
|
463
|
+
}
|
|
464
|
+
function createErrorResponse(code, message, requestId, details) {
|
|
465
|
+
return {
|
|
466
|
+
error: {
|
|
467
|
+
code,
|
|
468
|
+
message,
|
|
469
|
+
...details && { details }
|
|
470
|
+
},
|
|
471
|
+
...requestId && { requestId },
|
|
472
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
473
|
+
};
|
|
474
|
+
}
|
|
57
475
|
var CriterionServer = class {
|
|
58
476
|
app;
|
|
59
477
|
engine;
|
|
60
478
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
61
479
|
decisions;
|
|
62
480
|
profiles;
|
|
481
|
+
hooks;
|
|
482
|
+
metricsCollector = null;
|
|
483
|
+
metricsOptions;
|
|
484
|
+
openApiOptions;
|
|
485
|
+
openApiSpec = null;
|
|
486
|
+
startTime;
|
|
63
487
|
constructor(options) {
|
|
64
488
|
this.app = new Hono();
|
|
65
489
|
this.engine = new Engine();
|
|
66
490
|
this.decisions = /* @__PURE__ */ new Map();
|
|
67
491
|
this.profiles = /* @__PURE__ */ new Map();
|
|
492
|
+
this.hooks = options.hooks ?? {};
|
|
493
|
+
this.metricsOptions = options.metrics ?? {};
|
|
494
|
+
this.openApiOptions = options.openapi ?? {};
|
|
495
|
+
this.startTime = /* @__PURE__ */ new Date();
|
|
496
|
+
if (this.metricsOptions.enabled) {
|
|
497
|
+
this.metricsCollector = new MetricsCollector(this.metricsOptions);
|
|
498
|
+
}
|
|
68
499
|
for (const decision of options.decisions) {
|
|
69
500
|
this.decisions.set(decision.id, decision);
|
|
70
501
|
}
|
|
@@ -73,6 +504,12 @@ var CriterionServer = class {
|
|
|
73
504
|
this.profiles.set(id, profile);
|
|
74
505
|
}
|
|
75
506
|
}
|
|
507
|
+
if (this.openApiOptions.enabled) {
|
|
508
|
+
this.openApiSpec = generateOpenAPISpec(
|
|
509
|
+
options.decisions,
|
|
510
|
+
this.openApiOptions.info
|
|
511
|
+
);
|
|
512
|
+
}
|
|
76
513
|
if (options.cors !== false) {
|
|
77
514
|
this.app.use("*", cors());
|
|
78
515
|
}
|
|
@@ -82,10 +519,52 @@ var CriterionServer = class {
|
|
|
82
519
|
this.app.get("/", (c) => {
|
|
83
520
|
return c.json({
|
|
84
521
|
name: "Criterion Server",
|
|
85
|
-
version:
|
|
86
|
-
decisions: this.decisions.size
|
|
522
|
+
version: SERVER_VERSION,
|
|
523
|
+
decisions: this.decisions.size,
|
|
524
|
+
docs: "/docs",
|
|
525
|
+
health: "/health"
|
|
87
526
|
});
|
|
88
527
|
});
|
|
528
|
+
this.app.get("/health", (c) => {
|
|
529
|
+
const uptime = Math.floor((Date.now() - this.startTime.getTime()) / 1e3);
|
|
530
|
+
const response = {
|
|
531
|
+
status: "healthy",
|
|
532
|
+
version: SERVER_VERSION,
|
|
533
|
+
uptime,
|
|
534
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
535
|
+
checks: {
|
|
536
|
+
decisions: {
|
|
537
|
+
status: this.decisions.size > 0 ? "healthy" : "degraded",
|
|
538
|
+
message: `${this.decisions.size} decision(s) registered`
|
|
539
|
+
},
|
|
540
|
+
engine: {
|
|
541
|
+
status: "healthy",
|
|
542
|
+
message: "Engine operational"
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
return c.json(response);
|
|
547
|
+
});
|
|
548
|
+
if (this.metricsCollector) {
|
|
549
|
+
const endpoint = this.metricsOptions.endpoint ?? "/metrics";
|
|
550
|
+
this.app.get(endpoint, (c) => {
|
|
551
|
+
c.header("Content-Type", "text/plain; version=0.0.4; charset=utf-8");
|
|
552
|
+
return c.text(this.metricsCollector.toPrometheus());
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
if (this.openApiSpec) {
|
|
556
|
+
const specEndpoint = this.openApiOptions.endpoint ?? "/openapi.json";
|
|
557
|
+
this.app.get(specEndpoint, (c) => {
|
|
558
|
+
return c.json(this.openApiSpec);
|
|
559
|
+
});
|
|
560
|
+
if (this.openApiOptions.swaggerUI !== false) {
|
|
561
|
+
const swaggerEndpoint = this.openApiOptions.swaggerEndpoint ?? "/swagger";
|
|
562
|
+
this.app.get(swaggerEndpoint, (c) => {
|
|
563
|
+
const html = generateSwaggerUIHtml(specEndpoint);
|
|
564
|
+
return c.html(html);
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
89
568
|
this.app.get("/decisions", (c) => {
|
|
90
569
|
const decisions = [];
|
|
91
570
|
for (const decision of this.decisions.values()) {
|
|
@@ -102,7 +581,10 @@ var CriterionServer = class {
|
|
|
102
581
|
const id = c.req.param("id");
|
|
103
582
|
const decision = this.decisions.get(id);
|
|
104
583
|
if (!decision) {
|
|
105
|
-
return c.json(
|
|
584
|
+
return c.json(
|
|
585
|
+
createErrorResponse("DECISION_NOT_FOUND", `Decision not found: ${id}`),
|
|
586
|
+
404
|
|
587
|
+
);
|
|
106
588
|
}
|
|
107
589
|
const schema = extractDecisionSchema(decision);
|
|
108
590
|
return c.json(schema);
|
|
@@ -111,41 +593,115 @@ var CriterionServer = class {
|
|
|
111
593
|
const id = c.req.param("id");
|
|
112
594
|
const decision = this.decisions.get(id);
|
|
113
595
|
if (!decision) {
|
|
114
|
-
return c.json(
|
|
596
|
+
return c.json(
|
|
597
|
+
createErrorResponse("DECISION_NOT_FOUND", `Decision not found: ${id}`),
|
|
598
|
+
404
|
|
599
|
+
);
|
|
115
600
|
}
|
|
116
601
|
const schema = generateEndpointSchema(decision);
|
|
117
602
|
return c.json(schema);
|
|
118
603
|
});
|
|
119
604
|
this.app.post("/decisions/:id", async (c) => {
|
|
120
605
|
const id = c.req.param("id");
|
|
606
|
+
const requestId = generateRequestId();
|
|
121
607
|
const decision = this.decisions.get(id);
|
|
122
608
|
if (!decision) {
|
|
123
|
-
return c.json(
|
|
609
|
+
return c.json(
|
|
610
|
+
createErrorResponse("DECISION_NOT_FOUND", `Decision not found: ${id}`, requestId),
|
|
611
|
+
404
|
|
612
|
+
);
|
|
124
613
|
}
|
|
125
614
|
let body;
|
|
126
615
|
try {
|
|
127
616
|
body = await c.req.json();
|
|
128
617
|
} catch {
|
|
129
|
-
return c.json(
|
|
618
|
+
return c.json(
|
|
619
|
+
createErrorResponse("INVALID_JSON", "Invalid JSON body", requestId),
|
|
620
|
+
400
|
|
621
|
+
);
|
|
130
622
|
}
|
|
131
623
|
if (body.input === void 0) {
|
|
132
|
-
return c.json(
|
|
624
|
+
return c.json(
|
|
625
|
+
createErrorResponse("MISSING_INPUT", "Missing 'input' in request body", requestId),
|
|
626
|
+
400
|
|
627
|
+
);
|
|
133
628
|
}
|
|
134
629
|
let profile = body.profile;
|
|
135
630
|
if (!profile) {
|
|
136
631
|
profile = this.profiles.get(id);
|
|
137
632
|
if (!profile) {
|
|
138
633
|
return c.json(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
634
|
+
createErrorResponse(
|
|
635
|
+
"MISSING_PROFILE",
|
|
636
|
+
`No profile provided and no default profile for decision: ${id}`,
|
|
637
|
+
requestId
|
|
638
|
+
),
|
|
142
639
|
400
|
|
143
640
|
);
|
|
144
641
|
}
|
|
145
642
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
643
|
+
let ctx = {
|
|
644
|
+
decisionId: id,
|
|
645
|
+
input: body.input,
|
|
646
|
+
profile,
|
|
647
|
+
requestId,
|
|
648
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
649
|
+
};
|
|
650
|
+
const startTime = performance.now();
|
|
651
|
+
try {
|
|
652
|
+
if (this.hooks.beforeEvaluate) {
|
|
653
|
+
const modified = await this.hooks.beforeEvaluate(ctx);
|
|
654
|
+
if (modified) {
|
|
655
|
+
ctx = { ...ctx, ...modified };
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const result = this.engine.run(decision, ctx.input, {
|
|
659
|
+
profile: ctx.profile
|
|
660
|
+
});
|
|
661
|
+
if (this.metricsCollector) {
|
|
662
|
+
const durationSeconds = (performance.now() - startTime) / 1e3;
|
|
663
|
+
this.metricsCollector.increment(METRIC_EVALUATIONS_TOTAL, {
|
|
664
|
+
decision_id: id,
|
|
665
|
+
status: result.status
|
|
666
|
+
});
|
|
667
|
+
this.metricsCollector.observe(
|
|
668
|
+
METRIC_EVALUATION_DURATION_SECONDS,
|
|
669
|
+
{ decision_id: id },
|
|
670
|
+
durationSeconds
|
|
671
|
+
);
|
|
672
|
+
if (result.meta.matchedRule) {
|
|
673
|
+
this.metricsCollector.increment(METRIC_RULE_MATCHES_TOTAL, {
|
|
674
|
+
decision_id: id,
|
|
675
|
+
rule_id: result.meta.matchedRule
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (this.hooks.afterEvaluate) {
|
|
680
|
+
await this.hooks.afterEvaluate(ctx, result);
|
|
681
|
+
}
|
|
682
|
+
const statusCode = result.status === "OK" ? 200 : 400;
|
|
683
|
+
return c.json(result, statusCode);
|
|
684
|
+
} catch (error) {
|
|
685
|
+
if (this.metricsCollector) {
|
|
686
|
+
this.metricsCollector.increment(METRIC_EVALUATIONS_TOTAL, {
|
|
687
|
+
decision_id: id,
|
|
688
|
+
status: "ERROR"
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
692
|
+
if (this.hooks.onError) {
|
|
693
|
+
await this.hooks.onError(ctx, err);
|
|
694
|
+
}
|
|
695
|
+
return c.json(
|
|
696
|
+
createErrorResponse(
|
|
697
|
+
"EVALUATION_ERROR",
|
|
698
|
+
err.message,
|
|
699
|
+
requestId,
|
|
700
|
+
{ decisionId: id }
|
|
701
|
+
),
|
|
702
|
+
500
|
|
703
|
+
);
|
|
704
|
+
}
|
|
149
705
|
});
|
|
150
706
|
this.app.get("/docs", (c) => {
|
|
151
707
|
const decisions = Array.from(this.decisions.values()).map((d) => ({
|
|
@@ -363,6 +919,12 @@ var CriterionServer = class {
|
|
|
363
919
|
get handler() {
|
|
364
920
|
return this.app;
|
|
365
921
|
}
|
|
922
|
+
/**
|
|
923
|
+
* Get the metrics collector (if enabled)
|
|
924
|
+
*/
|
|
925
|
+
get metrics() {
|
|
926
|
+
return this.metricsCollector;
|
|
927
|
+
}
|
|
366
928
|
/**
|
|
367
929
|
* Start the server
|
|
368
930
|
*/
|
|
@@ -371,6 +933,18 @@ var CriterionServer = class {
|
|
|
371
933
|
console.log(` Decisions: ${this.decisions.size}`);
|
|
372
934
|
console.log(` Docs: http://localhost:${port}/docs`);
|
|
373
935
|
console.log(` API: http://localhost:${port}/decisions`);
|
|
936
|
+
if (this.metricsCollector) {
|
|
937
|
+
const endpoint = this.metricsOptions.endpoint ?? "/metrics";
|
|
938
|
+
console.log(` Metrics: http://localhost:${port}${endpoint}`);
|
|
939
|
+
}
|
|
940
|
+
if (this.openApiSpec) {
|
|
941
|
+
const specEndpoint = this.openApiOptions.endpoint ?? "/openapi.json";
|
|
942
|
+
console.log(` OpenAPI: http://localhost:${port}${specEndpoint}`);
|
|
943
|
+
if (this.openApiOptions.swaggerUI !== false) {
|
|
944
|
+
const swaggerEndpoint = this.openApiOptions.swaggerEndpoint ?? "/swagger";
|
|
945
|
+
console.log(` Swagger: http://localhost:${port}${swaggerEndpoint}`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
374
948
|
serve({
|
|
375
949
|
fetch: this.app.fetch,
|
|
376
950
|
port
|
|
@@ -382,8 +956,14 @@ function createServer(options) {
|
|
|
382
956
|
}
|
|
383
957
|
export {
|
|
384
958
|
CriterionServer,
|
|
959
|
+
METRIC_EVALUATIONS_TOTAL,
|
|
960
|
+
METRIC_EVALUATION_DURATION_SECONDS,
|
|
961
|
+
METRIC_RULE_MATCHES_TOTAL,
|
|
962
|
+
MetricsCollector,
|
|
385
963
|
createServer,
|
|
386
964
|
extractDecisionSchema,
|
|
387
965
|
generateEndpointSchema,
|
|
966
|
+
generateOpenAPISpec,
|
|
967
|
+
generateSwaggerUIHtml,
|
|
388
968
|
toJsonSchema
|
|
389
969
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@criterionx/server",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "HTTP server for Criterion decisions with auto-generated docs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -42,17 +42,17 @@
|
|
|
42
42
|
"@hono/node-server": "^1.13.0",
|
|
43
43
|
"hono": "^4.6.0",
|
|
44
44
|
"zod-to-json-schema": "^3.24.0",
|
|
45
|
-
"@criterionx/core": "0.3.
|
|
45
|
+
"@criterionx/core": "0.3.2"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"zod": "^3.22.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@types/node": "^
|
|
51
|
+
"@types/node": "^25.0.3",
|
|
52
52
|
"tsup": "^8.0.0",
|
|
53
53
|
"tsx": "^4.7.0",
|
|
54
54
|
"typescript": "^5.3.0",
|
|
55
|
-
"vitest": "^
|
|
55
|
+
"vitest": "^4.0.16",
|
|
56
56
|
"zod": "^3.22.0"
|
|
57
57
|
},
|
|
58
58
|
"engines": {
|