@criterionx/server 0.3.0 → 0.3.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/dist/index.d.ts +221 -29
- package/dist/index.js +538 -17
- 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
|
|
@@ -31,45 +130,129 @@ interface DecisionInfo {
|
|
|
31
130
|
description?: string;
|
|
32
131
|
meta?: Record<string, unknown>;
|
|
33
132
|
}
|
|
133
|
+
|
|
34
134
|
/**
|
|
35
|
-
*
|
|
135
|
+
* Generate OpenAPI-compatible schema for a decision endpoint
|
|
36
136
|
*/
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
137
|
+
declare function generateEndpointSchema(decision: Decision<unknown, unknown, unknown>): {
|
|
138
|
+
requestBody: JsonSchema;
|
|
139
|
+
response: JsonSchema;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Lightweight metrics collector for Criterion Server
|
|
144
|
+
*
|
|
145
|
+
* Exposes metrics in Prometheus format without external dependencies.
|
|
146
|
+
*/
|
|
147
|
+
interface MetricsOptions {
|
|
148
|
+
/** Enable metrics collection (default: false) */
|
|
149
|
+
enabled?: boolean;
|
|
150
|
+
/** Endpoint path for metrics (default: /metrics) */
|
|
151
|
+
endpoint?: string;
|
|
152
|
+
/** Histogram buckets for latency in seconds */
|
|
153
|
+
buckets?: number[];
|
|
154
|
+
}
|
|
155
|
+
interface MetricLabels {
|
|
156
|
+
[key: string]: string;
|
|
46
157
|
}
|
|
47
158
|
/**
|
|
48
|
-
*
|
|
159
|
+
* Simple metrics collector that outputs Prometheus format
|
|
49
160
|
*/
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
161
|
+
declare class MetricsCollector {
|
|
162
|
+
private counters;
|
|
163
|
+
private histograms;
|
|
164
|
+
private buckets;
|
|
165
|
+
constructor(options?: MetricsOptions);
|
|
166
|
+
/**
|
|
167
|
+
* Increment a counter metric
|
|
168
|
+
*/
|
|
169
|
+
increment(name: string, labels?: MetricLabels, value?: number): void;
|
|
170
|
+
/**
|
|
171
|
+
* Record a value in a histogram metric
|
|
172
|
+
*/
|
|
173
|
+
observe(name: string, labels: MetricLabels, value: number): void;
|
|
174
|
+
/**
|
|
175
|
+
* Get a counter value
|
|
176
|
+
*/
|
|
177
|
+
getCounter(name: string, labels?: MetricLabels): number;
|
|
178
|
+
/**
|
|
179
|
+
* Get histogram stats
|
|
180
|
+
*/
|
|
181
|
+
getHistogram(name: string, labels: MetricLabels): {
|
|
182
|
+
sum: number;
|
|
183
|
+
count: number;
|
|
184
|
+
} | undefined;
|
|
185
|
+
/**
|
|
186
|
+
* Export all metrics in Prometheus format
|
|
187
|
+
*/
|
|
188
|
+
toPrometheus(): string;
|
|
189
|
+
/**
|
|
190
|
+
* Reset all metrics
|
|
191
|
+
*/
|
|
192
|
+
reset(): void;
|
|
193
|
+
private findCounter;
|
|
194
|
+
private findHistogram;
|
|
195
|
+
private labelsMatch;
|
|
196
|
+
private formatLabels;
|
|
56
197
|
}
|
|
198
|
+
declare const METRIC_EVALUATIONS_TOTAL = "criterion_evaluations_total";
|
|
199
|
+
declare const METRIC_EVALUATION_DURATION_SECONDS = "criterion_evaluation_duration_seconds";
|
|
200
|
+
declare const METRIC_RULE_MATCHES_TOTAL = "criterion_rule_matches_total";
|
|
57
201
|
|
|
58
202
|
/**
|
|
59
|
-
*
|
|
203
|
+
* OpenAPI 3.0 spec generator for Criterion decisions
|
|
60
204
|
*/
|
|
61
|
-
|
|
205
|
+
|
|
62
206
|
/**
|
|
63
|
-
*
|
|
207
|
+
* OpenAPI 3.0 specification
|
|
64
208
|
*/
|
|
65
|
-
|
|
209
|
+
interface OpenAPISpec {
|
|
210
|
+
openapi: "3.0.0";
|
|
211
|
+
info: OpenAPIInfo;
|
|
212
|
+
paths: Record<string, PathItem>;
|
|
213
|
+
components: {
|
|
214
|
+
schemas: Record<string, JsonSchema>;
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
interface PathItem {
|
|
218
|
+
post?: Operation;
|
|
219
|
+
get?: Operation;
|
|
220
|
+
}
|
|
221
|
+
interface Operation {
|
|
222
|
+
operationId: string;
|
|
223
|
+
summary: string;
|
|
224
|
+
description?: string;
|
|
225
|
+
tags?: string[];
|
|
226
|
+
requestBody?: {
|
|
227
|
+
required: boolean;
|
|
228
|
+
content: {
|
|
229
|
+
"application/json": {
|
|
230
|
+
schema: {
|
|
231
|
+
$ref: string;
|
|
232
|
+
} | JsonSchema;
|
|
233
|
+
};
|
|
234
|
+
};
|
|
235
|
+
};
|
|
236
|
+
responses: Record<string, Response>;
|
|
237
|
+
}
|
|
238
|
+
interface Response {
|
|
239
|
+
description: string;
|
|
240
|
+
content?: {
|
|
241
|
+
"application/json": {
|
|
242
|
+
schema: {
|
|
243
|
+
$ref: string;
|
|
244
|
+
} | JsonSchema;
|
|
245
|
+
};
|
|
246
|
+
};
|
|
247
|
+
}
|
|
66
248
|
/**
|
|
67
|
-
* Generate OpenAPI
|
|
249
|
+
* Generate OpenAPI spec from decisions
|
|
68
250
|
*/
|
|
69
|
-
declare function
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
251
|
+
declare function generateOpenAPISpec(decisions: Decision<any, any, any>[], info?: Partial<OpenAPIInfo>): OpenAPISpec;
|
|
252
|
+
/**
|
|
253
|
+
* Generate Swagger UI HTML
|
|
254
|
+
*/
|
|
255
|
+
declare function generateSwaggerUIHtml(specUrl: string): string;
|
|
73
256
|
|
|
74
257
|
/**
|
|
75
258
|
* Criterion Server
|
|
@@ -81,6 +264,11 @@ declare class CriterionServer {
|
|
|
81
264
|
private engine;
|
|
82
265
|
private decisions;
|
|
83
266
|
private profiles;
|
|
267
|
+
private hooks;
|
|
268
|
+
private metricsCollector;
|
|
269
|
+
private metricsOptions;
|
|
270
|
+
private openApiOptions;
|
|
271
|
+
private openApiSpec;
|
|
84
272
|
constructor(options: ServerOptions);
|
|
85
273
|
private setupRoutes;
|
|
86
274
|
private generateDocsHtml;
|
|
@@ -88,6 +276,10 @@ declare class CriterionServer {
|
|
|
88
276
|
* Get the Hono app instance (for custom middleware)
|
|
89
277
|
*/
|
|
90
278
|
get handler(): Hono;
|
|
279
|
+
/**
|
|
280
|
+
* Get the metrics collector (if enabled)
|
|
281
|
+
*/
|
|
282
|
+
get metrics(): MetricsCollector | null;
|
|
91
283
|
/**
|
|
92
284
|
* Start the server
|
|
93
285
|
*/
|
|
@@ -98,4 +290,4 @@ declare class CriterionServer {
|
|
|
98
290
|
*/
|
|
99
291
|
declare function createServer(options: ServerOptions): CriterionServer;
|
|
100
292
|
|
|
101
|
-
export { CriterionServer, type DecisionInfo, type
|
|
293
|
+
export { type AfterEvaluateHook, type BeforeEvaluateHook, CriterionServer, type DecisionInfo, type EvaluateRequest, 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,448 @@ 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
|
+
function generateRequestId() {
|
|
461
|
+
return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
|
|
462
|
+
}
|
|
57
463
|
var CriterionServer = class {
|
|
58
464
|
app;
|
|
59
465
|
engine;
|
|
60
466
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
61
467
|
decisions;
|
|
62
468
|
profiles;
|
|
469
|
+
hooks;
|
|
470
|
+
metricsCollector = null;
|
|
471
|
+
metricsOptions;
|
|
472
|
+
openApiOptions;
|
|
473
|
+
openApiSpec = null;
|
|
63
474
|
constructor(options) {
|
|
64
475
|
this.app = new Hono();
|
|
65
476
|
this.engine = new Engine();
|
|
66
477
|
this.decisions = /* @__PURE__ */ new Map();
|
|
67
478
|
this.profiles = /* @__PURE__ */ new Map();
|
|
479
|
+
this.hooks = options.hooks ?? {};
|
|
480
|
+
this.metricsOptions = options.metrics ?? {};
|
|
481
|
+
this.openApiOptions = options.openapi ?? {};
|
|
482
|
+
if (this.metricsOptions.enabled) {
|
|
483
|
+
this.metricsCollector = new MetricsCollector(this.metricsOptions);
|
|
484
|
+
}
|
|
68
485
|
for (const decision of options.decisions) {
|
|
69
486
|
this.decisions.set(decision.id, decision);
|
|
70
487
|
}
|
|
@@ -73,6 +490,12 @@ var CriterionServer = class {
|
|
|
73
490
|
this.profiles.set(id, profile);
|
|
74
491
|
}
|
|
75
492
|
}
|
|
493
|
+
if (this.openApiOptions.enabled) {
|
|
494
|
+
this.openApiSpec = generateOpenAPISpec(
|
|
495
|
+
options.decisions,
|
|
496
|
+
this.openApiOptions.info
|
|
497
|
+
);
|
|
498
|
+
}
|
|
76
499
|
if (options.cors !== false) {
|
|
77
500
|
this.app.use("*", cors());
|
|
78
501
|
}
|
|
@@ -83,9 +506,30 @@ var CriterionServer = class {
|
|
|
83
506
|
return c.json({
|
|
84
507
|
name: "Criterion Server",
|
|
85
508
|
version: "0.1.0",
|
|
86
|
-
decisions: this.decisions.size
|
|
509
|
+
decisions: this.decisions.size,
|
|
510
|
+
metrics: this.metricsCollector !== null
|
|
87
511
|
});
|
|
88
512
|
});
|
|
513
|
+
if (this.metricsCollector) {
|
|
514
|
+
const endpoint = this.metricsOptions.endpoint ?? "/metrics";
|
|
515
|
+
this.app.get(endpoint, (c) => {
|
|
516
|
+
c.header("Content-Type", "text/plain; version=0.0.4; charset=utf-8");
|
|
517
|
+
return c.text(this.metricsCollector.toPrometheus());
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
if (this.openApiSpec) {
|
|
521
|
+
const specEndpoint = this.openApiOptions.endpoint ?? "/openapi.json";
|
|
522
|
+
this.app.get(specEndpoint, (c) => {
|
|
523
|
+
return c.json(this.openApiSpec);
|
|
524
|
+
});
|
|
525
|
+
if (this.openApiOptions.swaggerUI !== false) {
|
|
526
|
+
const swaggerEndpoint = this.openApiOptions.swaggerEndpoint ?? "/swagger";
|
|
527
|
+
this.app.get(swaggerEndpoint, (c) => {
|
|
528
|
+
const html = generateSwaggerUIHtml(specEndpoint);
|
|
529
|
+
return c.html(html);
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
}
|
|
89
533
|
this.app.get("/decisions", (c) => {
|
|
90
534
|
const decisions = [];
|
|
91
535
|
for (const decision of this.decisions.values()) {
|
|
@@ -143,9 +587,62 @@ var CriterionServer = class {
|
|
|
143
587
|
);
|
|
144
588
|
}
|
|
145
589
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
590
|
+
let ctx = {
|
|
591
|
+
decisionId: id,
|
|
592
|
+
input: body.input,
|
|
593
|
+
profile,
|
|
594
|
+
requestId: generateRequestId(),
|
|
595
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
596
|
+
};
|
|
597
|
+
const startTime = performance.now();
|
|
598
|
+
try {
|
|
599
|
+
if (this.hooks.beforeEvaluate) {
|
|
600
|
+
const modified = await this.hooks.beforeEvaluate(ctx);
|
|
601
|
+
if (modified) {
|
|
602
|
+
ctx = { ...ctx, ...modified };
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
const result = this.engine.run(decision, ctx.input, {
|
|
606
|
+
profile: ctx.profile
|
|
607
|
+
});
|
|
608
|
+
if (this.metricsCollector) {
|
|
609
|
+
const durationSeconds = (performance.now() - startTime) / 1e3;
|
|
610
|
+
this.metricsCollector.increment(METRIC_EVALUATIONS_TOTAL, {
|
|
611
|
+
decision_id: id,
|
|
612
|
+
status: result.status
|
|
613
|
+
});
|
|
614
|
+
this.metricsCollector.observe(
|
|
615
|
+
METRIC_EVALUATION_DURATION_SECONDS,
|
|
616
|
+
{ decision_id: id },
|
|
617
|
+
durationSeconds
|
|
618
|
+
);
|
|
619
|
+
if (result.meta.matchedRule) {
|
|
620
|
+
this.metricsCollector.increment(METRIC_RULE_MATCHES_TOTAL, {
|
|
621
|
+
decision_id: id,
|
|
622
|
+
rule_id: result.meta.matchedRule
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (this.hooks.afterEvaluate) {
|
|
627
|
+
await this.hooks.afterEvaluate(ctx, result);
|
|
628
|
+
}
|
|
629
|
+
const statusCode = result.status === "OK" ? 200 : 400;
|
|
630
|
+
return c.json(result, statusCode);
|
|
631
|
+
} catch (error) {
|
|
632
|
+
if (this.metricsCollector) {
|
|
633
|
+
this.metricsCollector.increment(METRIC_EVALUATIONS_TOTAL, {
|
|
634
|
+
decision_id: id,
|
|
635
|
+
status: "ERROR"
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
if (this.hooks.onError) {
|
|
639
|
+
await this.hooks.onError(
|
|
640
|
+
ctx,
|
|
641
|
+
error instanceof Error ? error : new Error(String(error))
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
throw error;
|
|
645
|
+
}
|
|
149
646
|
});
|
|
150
647
|
this.app.get("/docs", (c) => {
|
|
151
648
|
const decisions = Array.from(this.decisions.values()).map((d) => ({
|
|
@@ -363,6 +860,12 @@ var CriterionServer = class {
|
|
|
363
860
|
get handler() {
|
|
364
861
|
return this.app;
|
|
365
862
|
}
|
|
863
|
+
/**
|
|
864
|
+
* Get the metrics collector (if enabled)
|
|
865
|
+
*/
|
|
866
|
+
get metrics() {
|
|
867
|
+
return this.metricsCollector;
|
|
868
|
+
}
|
|
366
869
|
/**
|
|
367
870
|
* Start the server
|
|
368
871
|
*/
|
|
@@ -371,6 +874,18 @@ var CriterionServer = class {
|
|
|
371
874
|
console.log(` Decisions: ${this.decisions.size}`);
|
|
372
875
|
console.log(` Docs: http://localhost:${port}/docs`);
|
|
373
876
|
console.log(` API: http://localhost:${port}/decisions`);
|
|
877
|
+
if (this.metricsCollector) {
|
|
878
|
+
const endpoint = this.metricsOptions.endpoint ?? "/metrics";
|
|
879
|
+
console.log(` Metrics: http://localhost:${port}${endpoint}`);
|
|
880
|
+
}
|
|
881
|
+
if (this.openApiSpec) {
|
|
882
|
+
const specEndpoint = this.openApiOptions.endpoint ?? "/openapi.json";
|
|
883
|
+
console.log(` OpenAPI: http://localhost:${port}${specEndpoint}`);
|
|
884
|
+
if (this.openApiOptions.swaggerUI !== false) {
|
|
885
|
+
const swaggerEndpoint = this.openApiOptions.swaggerEndpoint ?? "/swagger";
|
|
886
|
+
console.log(` Swagger: http://localhost:${port}${swaggerEndpoint}`);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
374
889
|
serve({
|
|
375
890
|
fetch: this.app.fetch,
|
|
376
891
|
port
|
|
@@ -382,8 +897,14 @@ function createServer(options) {
|
|
|
382
897
|
}
|
|
383
898
|
export {
|
|
384
899
|
CriterionServer,
|
|
900
|
+
METRIC_EVALUATIONS_TOTAL,
|
|
901
|
+
METRIC_EVALUATION_DURATION_SECONDS,
|
|
902
|
+
METRIC_RULE_MATCHES_TOTAL,
|
|
903
|
+
MetricsCollector,
|
|
385
904
|
createServer,
|
|
386
905
|
extractDecisionSchema,
|
|
387
906
|
generateEndpointSchema,
|
|
907
|
+
generateOpenAPISpec,
|
|
908
|
+
generateSwaggerUIHtml,
|
|
388
909
|
toJsonSchema
|
|
389
910
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@criterionx/server",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
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.1"
|
|
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": {
|