@eventcatalog/core 3.25.6 → 3.26.0
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/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +1 -1
- package/dist/analytics/log-build.js +3 -3
- package/dist/{chunk-2ILJMBQM.js → chunk-7CRFNX47.js} +1 -1
- package/dist/{chunk-53HXLUNO.js → chunk-ASC3AR2X.js} +1 -1
- package/dist/{chunk-ZEOK723Y.js → chunk-FQNBDDUF.js} +1 -1
- package/dist/{chunk-P23BMUBV.js → chunk-GCNIIIFG.js} +1 -1
- package/dist/{chunk-R7P4GTFQ.js → chunk-XUN32ZVJ.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +562 -19
- package/dist/eventcatalog.js +572 -27
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/eventcatalog/astro.config.mjs +2 -1
- package/eventcatalog/integrations/eventcatalog-features.ts +13 -0
- package/eventcatalog/public/icons/graphql.svg +3 -1
- package/eventcatalog/src/components/FieldsExplorer/FieldFilters.tsx +225 -0
- package/eventcatalog/src/components/FieldsExplorer/FieldNodeGraph.tsx +521 -0
- package/eventcatalog/src/components/FieldsExplorer/FieldsExplorer.tsx +501 -0
- package/eventcatalog/src/components/FieldsExplorer/FieldsTable.tsx +236 -0
- package/eventcatalog/src/enterprise/fields/field-extractor.test.ts +241 -0
- package/eventcatalog/src/enterprise/fields/field-extractor.ts +183 -0
- package/eventcatalog/src/enterprise/fields/field-indexer.ts +131 -0
- package/eventcatalog/src/enterprise/fields/fields-db.test.ts +186 -0
- package/eventcatalog/src/enterprise/fields/fields-db.ts +453 -0
- package/eventcatalog/src/enterprise/fields/pages/api/fields.ts +43 -0
- package/eventcatalog/src/enterprise/fields/pages/fields.astro +19 -0
- package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +23 -3
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/graphql/[filename].astro +14 -16
- package/eventcatalog/src/utils/node-graphs/field-node-graph.ts +192 -0
- package/package.json +4 -2
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { SearchX, Copy, Check, AlertTriangle } from 'lucide-react';
|
|
3
|
+
import { BoltIcon, ChatBubbleLeftIcon, MagnifyingGlassIcon } from '@heroicons/react/24/solid';
|
|
4
|
+
import { buildUrl } from '@utils/url-builder';
|
|
5
|
+
|
|
6
|
+
export interface FieldResult {
|
|
7
|
+
path: string;
|
|
8
|
+
type: string;
|
|
9
|
+
description: string;
|
|
10
|
+
required: boolean;
|
|
11
|
+
schemaFormat: string;
|
|
12
|
+
messageId: string;
|
|
13
|
+
messageVersion: string;
|
|
14
|
+
messageType: string;
|
|
15
|
+
messageName?: string;
|
|
16
|
+
messageSummary?: string;
|
|
17
|
+
messageOwners?: string[];
|
|
18
|
+
producers: { id: string; version: string; name?: string; summary?: string; owners?: string[] }[];
|
|
19
|
+
consumers: { id: string; version: string; name?: string; summary?: string; owners?: string[] }[];
|
|
20
|
+
usedInCount?: number;
|
|
21
|
+
conflicts?: { type: string; count: number }[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface FieldsTableProps {
|
|
25
|
+
fields: FieldResult[];
|
|
26
|
+
onSelectField: (fieldPath: string) => void;
|
|
27
|
+
isLoading: boolean;
|
|
28
|
+
isScaleEnabled?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const colorClasses: Record<string, string> = {
|
|
32
|
+
orange: 'text-orange-500',
|
|
33
|
+
blue: 'text-blue-500',
|
|
34
|
+
green: 'text-green-500',
|
|
35
|
+
gray: 'text-gray-500',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const getColorAndIconForMessageType = (type: string) => {
|
|
39
|
+
switch (type) {
|
|
40
|
+
case 'event':
|
|
41
|
+
return { color: 'orange', Icon: BoltIcon };
|
|
42
|
+
case 'command':
|
|
43
|
+
return { color: 'blue', Icon: ChatBubbleLeftIcon };
|
|
44
|
+
case 'query':
|
|
45
|
+
return { color: 'green', Icon: MagnifyingGlassIcon };
|
|
46
|
+
default:
|
|
47
|
+
return { color: 'gray', Icon: ChatBubbleLeftIcon };
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function MessageBadge({ id, name, version, type }: { id: string; name?: string; version: string; type: string }) {
|
|
52
|
+
const { color, Icon } = getColorAndIconForMessageType(type);
|
|
53
|
+
const collection = type === 'query' ? 'queries' : `${type}s`;
|
|
54
|
+
const messageUrl = buildUrl(`/docs/${collection}/${id}/${version}`);
|
|
55
|
+
return (
|
|
56
|
+
<a
|
|
57
|
+
href={messageUrl}
|
|
58
|
+
onClick={(e) => e.stopPropagation()}
|
|
59
|
+
className="group/msg inline-flex items-center gap-1.5 text-xs hover:text-[rgb(var(--ec-accent))] transition-colors"
|
|
60
|
+
>
|
|
61
|
+
<Icon className={`h-3.5 w-3.5 ${colorClasses[color] || 'text-gray-500'} flex-shrink-0`} />
|
|
62
|
+
<span className="truncate max-w-[160px] text-[rgb(var(--ec-page-text))] group-hover/msg:text-[rgb(var(--ec-accent))]">
|
|
63
|
+
{name || id}
|
|
64
|
+
</span>
|
|
65
|
+
<span className="text-[rgb(var(--ec-icon-color))]">v{version}</span>
|
|
66
|
+
</a>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function FieldPathCell({ path }: { path: string }) {
|
|
71
|
+
const [copied, setCopied] = useState(false);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<span className="inline-flex items-center gap-1 font-mono text-[rgb(var(--ec-page-text))]">
|
|
75
|
+
{path}
|
|
76
|
+
<button
|
|
77
|
+
className={`opacity-0 group-hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-[rgb(var(--ec-content-hover))]`}
|
|
78
|
+
onClick={(e) => {
|
|
79
|
+
e.stopPropagation();
|
|
80
|
+
navigator.clipboard.writeText(path);
|
|
81
|
+
setCopied(true);
|
|
82
|
+
setTimeout(() => setCopied(false), 1500);
|
|
83
|
+
}}
|
|
84
|
+
title="Copy field path"
|
|
85
|
+
>
|
|
86
|
+
{copied ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3 text-[rgb(var(--ec-icon-color))]" />}
|
|
87
|
+
</button>
|
|
88
|
+
</span>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default function FieldsTable({ fields, onSelectField, isLoading, isScaleEnabled = false }: FieldsTableProps) {
|
|
93
|
+
if (isLoading) {
|
|
94
|
+
return (
|
|
95
|
+
<div className="flex-1 flex items-center justify-center">
|
|
96
|
+
<div className="flex flex-col items-center gap-3">
|
|
97
|
+
<div className="w-8 h-8 border-2 border-[rgb(var(--ec-accent))] border-t-transparent rounded-full animate-spin" />
|
|
98
|
+
<span className="text-sm text-[rgb(var(--ec-page-text-muted))]">Loading fields...</span>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (fields.length === 0) {
|
|
105
|
+
return (
|
|
106
|
+
<div className="flex-1 flex items-center justify-center">
|
|
107
|
+
<div className="flex flex-col items-center justify-center text-[rgb(var(--ec-page-text-muted))]">
|
|
108
|
+
<SearchX className="w-10 h-10 text-[rgb(var(--ec-icon-color))] mb-3 opacity-50" />
|
|
109
|
+
<p className="text-sm font-medium text-[rgb(var(--ec-page-text-muted))]">No schema fields found</p>
|
|
110
|
+
<p className="text-xs text-[rgb(var(--ec-icon-color))] mt-1 max-w-xs text-center">
|
|
111
|
+
Add <code className="px-1 py-0.5 rounded bg-[rgb(var(--ec-content-hover))] font-mono text-[11px]">schemaPath</code> to
|
|
112
|
+
your events, commands, or queries to get started.
|
|
113
|
+
</p>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div className="flex-1 overflow-auto px-6">
|
|
121
|
+
<table className="min-w-full divide-y divide-[rgb(var(--ec-page-border))]">
|
|
122
|
+
<thead className="sticky top-0 z-10 bg-[rgb(var(--ec-page-bg))]">
|
|
123
|
+
<tr>
|
|
124
|
+
<th className="px-4 py-2.5 text-left text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] uppercase tracking-wider">
|
|
125
|
+
Field Path
|
|
126
|
+
</th>
|
|
127
|
+
<th className="px-4 py-2.5 text-left text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] uppercase tracking-wider">
|
|
128
|
+
Type
|
|
129
|
+
</th>
|
|
130
|
+
<th className="px-4 py-2.5 text-left text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] uppercase tracking-wider">
|
|
131
|
+
Message
|
|
132
|
+
</th>
|
|
133
|
+
<th className="px-4 py-2.5 text-left text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] uppercase tracking-wider">
|
|
134
|
+
Format
|
|
135
|
+
</th>
|
|
136
|
+
<th className="px-4 py-2.5 text-left text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] uppercase tracking-wider">
|
|
137
|
+
Required
|
|
138
|
+
</th>
|
|
139
|
+
{isScaleEnabled && (
|
|
140
|
+
<th className="px-4 py-2.5 text-left text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] uppercase tracking-wider">
|
|
141
|
+
Consistency
|
|
142
|
+
</th>
|
|
143
|
+
)}
|
|
144
|
+
{isScaleEnabled && (
|
|
145
|
+
<th className="px-4 py-2.5 text-left text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] uppercase tracking-wider">
|
|
146
|
+
Used In
|
|
147
|
+
</th>
|
|
148
|
+
)}
|
|
149
|
+
<th className="px-4 py-2.5 text-left text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] uppercase tracking-wider">
|
|
150
|
+
Owners
|
|
151
|
+
</th>
|
|
152
|
+
</tr>
|
|
153
|
+
</thead>
|
|
154
|
+
<tbody className="divide-y divide-[rgb(var(--ec-page-border)/0.5)]">
|
|
155
|
+
{fields.map((field, index) => {
|
|
156
|
+
const rowKey = `${field.path}-${field.messageId}-${field.messageVersion}-${index}`;
|
|
157
|
+
const owners = field.messageOwners || [];
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<tr
|
|
161
|
+
key={rowKey}
|
|
162
|
+
className="group cursor-pointer transition-colors hover:bg-[rgb(var(--ec-content-hover))]"
|
|
163
|
+
onClick={() => onSelectField(field.path)}
|
|
164
|
+
>
|
|
165
|
+
<td className="px-4 py-3 text-sm">
|
|
166
|
+
<FieldPathCell path={field.path} />
|
|
167
|
+
</td>
|
|
168
|
+
<td className="px-4 py-3 text-sm font-mono text-[rgb(var(--ec-page-text-muted))]">{field.type}</td>
|
|
169
|
+
<td className="px-4 py-3 text-sm">
|
|
170
|
+
<MessageBadge
|
|
171
|
+
id={field.messageId}
|
|
172
|
+
name={field.messageName}
|
|
173
|
+
version={field.messageVersion}
|
|
174
|
+
type={field.messageType}
|
|
175
|
+
/>
|
|
176
|
+
</td>
|
|
177
|
+
<td className="px-4 py-3 text-sm text-[rgb(var(--ec-page-text-muted))]">{field.schemaFormat}</td>
|
|
178
|
+
<td className="px-4 py-3 text-sm">
|
|
179
|
+
{field.required && (
|
|
180
|
+
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-red-500/10 text-red-500 border border-red-500/20">
|
|
181
|
+
Required
|
|
182
|
+
</span>
|
|
183
|
+
)}
|
|
184
|
+
</td>
|
|
185
|
+
{isScaleEnabled && (
|
|
186
|
+
<td className="px-4 py-3 text-sm">
|
|
187
|
+
{field.conflicts && field.conflicts.length > 1 ? (
|
|
188
|
+
<div
|
|
189
|
+
className="flex items-center gap-1.5"
|
|
190
|
+
title={field.conflicts.map((c) => `${c.type} (${c.count})`).join(', ')}
|
|
191
|
+
>
|
|
192
|
+
<AlertTriangle className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
|
|
193
|
+
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-500/10 text-amber-600 border border-amber-500/20">
|
|
194
|
+
{field.conflicts.length} types
|
|
195
|
+
</span>
|
|
196
|
+
</div>
|
|
197
|
+
) : (
|
|
198
|
+
<span className="text-[10px] text-green-500 font-medium">Consistent</span>
|
|
199
|
+
)}
|
|
200
|
+
</td>
|
|
201
|
+
)}
|
|
202
|
+
{isScaleEnabled && (
|
|
203
|
+
<td className="px-4 py-3 text-sm">
|
|
204
|
+
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-[rgb(var(--ec-accent)/0.1)] text-[rgb(var(--ec-accent))]">
|
|
205
|
+
{field.usedInCount || 1} {(field.usedInCount || 1) === 1 ? 'schema' : 'schemas'}
|
|
206
|
+
</span>
|
|
207
|
+
</td>
|
|
208
|
+
)}
|
|
209
|
+
<td className="px-4 py-3 text-sm">
|
|
210
|
+
{owners.length > 0 ? (
|
|
211
|
+
<div className="flex items-center gap-1 flex-wrap">
|
|
212
|
+
{owners.slice(0, 2).map((owner) => (
|
|
213
|
+
<span
|
|
214
|
+
key={owner}
|
|
215
|
+
className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[rgb(var(--ec-accent)/0.1)] text-[rgb(var(--ec-accent))] truncate max-w-[100px]"
|
|
216
|
+
title={owner}
|
|
217
|
+
>
|
|
218
|
+
{owner}
|
|
219
|
+
</span>
|
|
220
|
+
))}
|
|
221
|
+
{owners.length > 2 && (
|
|
222
|
+
<span className="text-[10px] text-[rgb(var(--ec-page-text-muted))]">+{owners.length - 2}</span>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
) : (
|
|
226
|
+
<span className="text-[rgb(var(--ec-page-text-muted))] text-xs">-</span>
|
|
227
|
+
)}
|
|
228
|
+
</td>
|
|
229
|
+
</tr>
|
|
230
|
+
);
|
|
231
|
+
})}
|
|
232
|
+
</tbody>
|
|
233
|
+
</table>
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { extractSchemaFieldsDeep } from './field-extractor';
|
|
3
|
+
|
|
4
|
+
describe('extractSchemaFieldsDeep', () => {
|
|
5
|
+
describe('JSON Schema', () => {
|
|
6
|
+
it('extracts top-level properties with their types', () => {
|
|
7
|
+
const schema = JSON.stringify({
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {
|
|
10
|
+
orderId: { type: 'string', description: 'The order ID' },
|
|
11
|
+
amount: { type: 'number' },
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
const fields = extractSchemaFieldsDeep(schema, 'json-schema');
|
|
15
|
+
expect(fields).toEqual([
|
|
16
|
+
{ path: 'orderId', type: 'string', description: 'The order ID', required: false },
|
|
17
|
+
{ path: 'amount', type: 'number', description: '', required: false },
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('marks fields as required when listed in the JSON Schema required array', () => {
|
|
22
|
+
const schema = JSON.stringify({
|
|
23
|
+
type: 'object',
|
|
24
|
+
required: ['orderId'],
|
|
25
|
+
properties: {
|
|
26
|
+
orderId: { type: 'string' },
|
|
27
|
+
notes: { type: 'string' },
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
const fields = extractSchemaFieldsDeep(schema, 'json-schema');
|
|
31
|
+
expect(fields[0].required).toBe(true);
|
|
32
|
+
expect(fields[1].required).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('extracts nested object fields with dot-notation paths', () => {
|
|
36
|
+
const schema = JSON.stringify({
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
address: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
properties: {
|
|
42
|
+
street: { type: 'string' },
|
|
43
|
+
city: { type: 'string' },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
const fields = extractSchemaFieldsDeep(schema, 'json-schema');
|
|
49
|
+
expect(fields.map((f) => f.path)).toEqual(['address', 'address.street', 'address.city']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('uses bracket notation for array item fields', () => {
|
|
53
|
+
const schema = JSON.stringify({
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
items: {
|
|
57
|
+
type: 'array',
|
|
58
|
+
items: {
|
|
59
|
+
type: 'object',
|
|
60
|
+
properties: {
|
|
61
|
+
productId: { type: 'string' },
|
|
62
|
+
quantity: { type: 'integer' },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
const fields = extractSchemaFieldsDeep(schema, 'json-schema');
|
|
69
|
+
expect(fields.map((f) => f.path)).toEqual(['items', 'items[].productId', 'items[].quantity']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('resolves local $ref references within the same schema', () => {
|
|
73
|
+
const schema = JSON.stringify({
|
|
74
|
+
type: 'object',
|
|
75
|
+
properties: {
|
|
76
|
+
billing: { $ref: '#/definitions/Address' },
|
|
77
|
+
},
|
|
78
|
+
definitions: {
|
|
79
|
+
Address: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: {
|
|
82
|
+
street: { type: 'string' },
|
|
83
|
+
zip: { type: 'string' },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
const fields = extractSchemaFieldsDeep(schema, 'json-schema');
|
|
89
|
+
expect(fields.map((f) => f.path)).toEqual(['billing', 'billing.street', 'billing.zip']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('skips external $ref references and returns only local fields', () => {
|
|
93
|
+
const schema = JSON.stringify({
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
orderId: { type: 'string' },
|
|
97
|
+
customer: { $ref: './common.json#/definitions/Customer' },
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
const fields = extractSchemaFieldsDeep(schema, 'json-schema');
|
|
101
|
+
expect(fields).toHaveLength(2);
|
|
102
|
+
expect(fields[0].path).toBe('orderId');
|
|
103
|
+
expect(fields[1].path).toBe('customer');
|
|
104
|
+
expect(fields[1].type).toBe('$ref');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('merges allOf schemas and extracts combined properties', () => {
|
|
108
|
+
const schema = JSON.stringify({
|
|
109
|
+
allOf: [
|
|
110
|
+
{ type: 'object', properties: { id: { type: 'string' } } },
|
|
111
|
+
{ type: 'object', properties: { name: { type: 'string' } } },
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
const fields = extractSchemaFieldsDeep(schema, 'json-schema');
|
|
115
|
+
expect(fields.map((f) => f.path)).toEqual(['id', 'name']);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('returns empty array when schema content is malformed JSON', () => {
|
|
119
|
+
const fields = extractSchemaFieldsDeep('not valid json{{{', 'json-schema');
|
|
120
|
+
expect(fields).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('returns empty array when schema content is empty', () => {
|
|
124
|
+
const fields = extractSchemaFieldsDeep('', 'json-schema');
|
|
125
|
+
expect(fields).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('Avro', () => {
|
|
130
|
+
it('extracts fields from a simple Avro record', () => {
|
|
131
|
+
const schema = JSON.stringify({
|
|
132
|
+
type: 'record',
|
|
133
|
+
name: 'Order',
|
|
134
|
+
fields: [
|
|
135
|
+
{ name: 'orderId', type: 'string', doc: 'The order identifier' },
|
|
136
|
+
{ name: 'amount', type: 'double' },
|
|
137
|
+
],
|
|
138
|
+
});
|
|
139
|
+
const fields = extractSchemaFieldsDeep(schema, 'avro');
|
|
140
|
+
expect(fields).toEqual([
|
|
141
|
+
{ path: 'orderId', type: 'string', description: 'The order identifier', required: true },
|
|
142
|
+
{ path: 'amount', type: 'double', description: '', required: true },
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('handles Avro union types containing null as optional fields', () => {
|
|
147
|
+
const schema = JSON.stringify({
|
|
148
|
+
type: 'record',
|
|
149
|
+
name: 'Customer',
|
|
150
|
+
fields: [
|
|
151
|
+
{ name: 'id', type: 'string' },
|
|
152
|
+
{ name: 'nickname', type: ['null', 'string'] },
|
|
153
|
+
],
|
|
154
|
+
});
|
|
155
|
+
const fields = extractSchemaFieldsDeep(schema, 'avro');
|
|
156
|
+
expect(fields[0].required).toBe(true);
|
|
157
|
+
expect(fields[1].required).toBe(false);
|
|
158
|
+
expect(fields[1].type).toBe('null | string');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('extracts nested record fields with dot-notation paths', () => {
|
|
162
|
+
const schema = JSON.stringify({
|
|
163
|
+
type: 'record',
|
|
164
|
+
name: 'Order',
|
|
165
|
+
fields: [
|
|
166
|
+
{
|
|
167
|
+
name: 'customer',
|
|
168
|
+
type: {
|
|
169
|
+
type: 'record',
|
|
170
|
+
name: 'Customer',
|
|
171
|
+
fields: [
|
|
172
|
+
{ name: 'name', type: 'string' },
|
|
173
|
+
{ name: 'email', type: 'string' },
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
const fields = extractSchemaFieldsDeep(schema, 'avro');
|
|
180
|
+
expect(fields.map((f) => f.path)).toEqual(['customer', 'customer.name', 'customer.email']);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('uses bracket notation for Avro array items', () => {
|
|
184
|
+
const schema = JSON.stringify({
|
|
185
|
+
type: 'record',
|
|
186
|
+
name: 'Order',
|
|
187
|
+
fields: [
|
|
188
|
+
{
|
|
189
|
+
name: 'items',
|
|
190
|
+
type: {
|
|
191
|
+
type: 'array',
|
|
192
|
+
items: {
|
|
193
|
+
type: 'record',
|
|
194
|
+
name: 'Item',
|
|
195
|
+
fields: [
|
|
196
|
+
{ name: 'productId', type: 'string' },
|
|
197
|
+
{ name: 'qty', type: 'int' },
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
});
|
|
204
|
+
const fields = extractSchemaFieldsDeep(schema, 'avro');
|
|
205
|
+
expect(fields.map((f) => f.path)).toEqual(['items', 'items[].productId', 'items[].qty']);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('Protobuf', () => {
|
|
210
|
+
it('extracts fields from a simple proto3 message', () => {
|
|
211
|
+
const proto = `
|
|
212
|
+
syntax = "proto3";
|
|
213
|
+
message Order {
|
|
214
|
+
string order_id = 1;
|
|
215
|
+
double amount = 2; // total amount
|
|
216
|
+
}
|
|
217
|
+
`;
|
|
218
|
+
const fields = extractSchemaFieldsDeep(proto, 'proto');
|
|
219
|
+
expect(fields).toEqual([
|
|
220
|
+
{ path: 'order_id', type: 'string', description: '', required: false },
|
|
221
|
+
{ path: 'amount', type: 'double', description: 'total amount', required: false },
|
|
222
|
+
]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('marks repeated fields with their modifier in the type', () => {
|
|
226
|
+
const proto = `
|
|
227
|
+
syntax = "proto3";
|
|
228
|
+
message Order {
|
|
229
|
+
repeated string tags = 1;
|
|
230
|
+
}
|
|
231
|
+
`;
|
|
232
|
+
const fields = extractSchemaFieldsDeep(proto, 'proto');
|
|
233
|
+
expect(fields[0].type).toBe('repeated string');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('returns empty array for empty proto content', () => {
|
|
237
|
+
const fields = extractSchemaFieldsDeep('', 'proto');
|
|
238
|
+
expect(fields).toEqual([]);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
export interface DeepSchemaField {
|
|
2
|
+
path: string;
|
|
3
|
+
type: string;
|
|
4
|
+
description: string;
|
|
5
|
+
required: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function extractSchemaFieldsDeep(content: string, format: string): DeepSchemaField[] {
|
|
9
|
+
if (!content) return [];
|
|
10
|
+
|
|
11
|
+
if (format === 'json-schema') {
|
|
12
|
+
return extractJsonSchemaFields(content);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (format === 'avro') {
|
|
16
|
+
return extractAvroFields(content);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (format === 'proto') {
|
|
20
|
+
return extractProtoFields(content);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function extractProtoFields(content: string): DeepSchemaField[] {
|
|
27
|
+
if (!content) return [];
|
|
28
|
+
const fields: DeepSchemaField[] = [];
|
|
29
|
+
const fieldRegex = /^\s*(repeated\s+|optional\s+|required\s+)?(\w+)\s+(\w+)\s*=\s*\d+\s*;(?:\s*\/\/\s*(.*))?/gm;
|
|
30
|
+
let match;
|
|
31
|
+
while ((match = fieldRegex.exec(content)) !== null) {
|
|
32
|
+
const modifier = (match[1] || '').trim();
|
|
33
|
+
const type = modifier ? `${modifier} ${match[2]}` : match[2];
|
|
34
|
+
fields.push({
|
|
35
|
+
path: match[3],
|
|
36
|
+
type,
|
|
37
|
+
description: match[4]?.trim() || '',
|
|
38
|
+
required: modifier === 'required',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return fields;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function extractAvroFields(content: string): DeepSchemaField[] {
|
|
45
|
+
try {
|
|
46
|
+
const schema = JSON.parse(content);
|
|
47
|
+
const fields: DeepSchemaField[] = [];
|
|
48
|
+
walkAvroRecord(schema, '', fields);
|
|
49
|
+
return fields;
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getAvroTypeName(type: any): string {
|
|
56
|
+
if (typeof type === 'string') return type;
|
|
57
|
+
if (Array.isArray(type)) {
|
|
58
|
+
return type.map((t) => (typeof t === 'string' ? t : t.type || 'complex')).join(' | ');
|
|
59
|
+
}
|
|
60
|
+
if (typeof type === 'object' && type !== null) {
|
|
61
|
+
if (type.type === 'array' && type.items) return `array<${getAvroTypeName(type.items)}>`;
|
|
62
|
+
if (type.type === 'record') return type.name || 'record';
|
|
63
|
+
return type.type || 'complex';
|
|
64
|
+
}
|
|
65
|
+
return 'unknown';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function walkAvroRecord(schema: any, prefix: string, fields: DeepSchemaField[]): void {
|
|
69
|
+
if (!schema.fields || !Array.isArray(schema.fields)) return;
|
|
70
|
+
|
|
71
|
+
for (const field of schema.fields) {
|
|
72
|
+
const path = prefix ? `${prefix}.${field.name}` : field.name;
|
|
73
|
+
const isOptional = Array.isArray(field.type) && field.type.includes('null');
|
|
74
|
+
const typeName = getAvroTypeName(field.type);
|
|
75
|
+
|
|
76
|
+
fields.push({
|
|
77
|
+
path,
|
|
78
|
+
type: typeName,
|
|
79
|
+
description: field.doc || '',
|
|
80
|
+
required: !isOptional,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Recurse into nested records
|
|
84
|
+
const innerType = Array.isArray(field.type)
|
|
85
|
+
? field.type.find((t: any) => typeof t === 'object' && t.type === 'record')
|
|
86
|
+
: typeof field.type === 'object' && field.type.type === 'record'
|
|
87
|
+
? field.type
|
|
88
|
+
: null;
|
|
89
|
+
|
|
90
|
+
if (innerType) {
|
|
91
|
+
walkAvroRecord(innerType, path, fields);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Recurse into array items that are records
|
|
95
|
+
const arrayType = Array.isArray(field.type)
|
|
96
|
+
? field.type.find((t: any) => typeof t === 'object' && t.type === 'array')
|
|
97
|
+
: typeof field.type === 'object' && field.type.type === 'array'
|
|
98
|
+
? field.type
|
|
99
|
+
: null;
|
|
100
|
+
|
|
101
|
+
if (arrayType && typeof arrayType.items === 'object' && arrayType.items.type === 'record') {
|
|
102
|
+
walkAvroRecord(arrayType.items, `${path}[]`, fields);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function extractJsonSchemaFields(content: string): DeepSchemaField[] {
|
|
108
|
+
try {
|
|
109
|
+
const schema = JSON.parse(content);
|
|
110
|
+
const fields: DeepSchemaField[] = [];
|
|
111
|
+
walkJsonSchema(schema, '', schema.required || [], schema, fields);
|
|
112
|
+
return fields;
|
|
113
|
+
} catch {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function walkJsonSchema(node: any, prefix: string, requiredList: string[], rootSchema: any, fields: DeepSchemaField[]): void {
|
|
119
|
+
// Handle allOf by merging (resolve $ref entries first)
|
|
120
|
+
if (node.allOf && Array.isArray(node.allOf)) {
|
|
121
|
+
const merged: any = { type: 'object', properties: {}, required: [] };
|
|
122
|
+
for (let sub of node.allOf) {
|
|
123
|
+
if (sub.$ref) {
|
|
124
|
+
const resolved = resolveLocalRef(sub.$ref, rootSchema);
|
|
125
|
+
if (resolved) sub = resolved;
|
|
126
|
+
else continue;
|
|
127
|
+
}
|
|
128
|
+
Object.assign(merged.properties, sub.properties || {});
|
|
129
|
+
merged.required.push(...(sub.required || []));
|
|
130
|
+
}
|
|
131
|
+
walkJsonSchema(merged, prefix, merged.required, rootSchema, fields);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!node.properties) return;
|
|
136
|
+
|
|
137
|
+
for (const [name, prop] of Object.entries(node.properties) as [string, any][]) {
|
|
138
|
+
const path = prefix ? `${prefix}.${name}` : name;
|
|
139
|
+
const isRequired = requiredList.includes(name);
|
|
140
|
+
|
|
141
|
+
// Resolve $ref
|
|
142
|
+
if (prop.$ref) {
|
|
143
|
+
const resolved = resolveLocalRef(prop.$ref, rootSchema);
|
|
144
|
+
if (resolved) {
|
|
145
|
+
const type = resolved.type || 'object';
|
|
146
|
+
fields.push({ path, type, description: resolved.description || '', required: isRequired });
|
|
147
|
+
if (resolved.properties) {
|
|
148
|
+
walkJsonSchema(resolved, path, resolved.required || [], rootSchema, fields);
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
// External ref — add as-is
|
|
152
|
+
fields.push({ path, type: '$ref', description: '', required: isRequired });
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const type = prop.type || (prop.enum ? 'enum' : prop.$ref ? '$ref' : 'object');
|
|
158
|
+
fields.push({ path, type, description: prop.description || '', required: isRequired });
|
|
159
|
+
|
|
160
|
+
// Recurse into nested objects
|
|
161
|
+
if (prop.type === 'object' && prop.properties) {
|
|
162
|
+
walkJsonSchema(prop, path, prop.required || [], rootSchema, fields);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Recurse into array items
|
|
166
|
+
if (prop.type === 'array' && prop.items) {
|
|
167
|
+
if (prop.items.type === 'object' && prop.items.properties) {
|
|
168
|
+
walkJsonSchema(prop.items, `${path}[]`, prop.items.required || [], rootSchema, fields);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function resolveLocalRef(ref: string, rootSchema: any): any {
|
|
175
|
+
if (!ref.startsWith('#/')) return null;
|
|
176
|
+
const parts = ref.replace('#/', '').split('/');
|
|
177
|
+
let current = rootSchema;
|
|
178
|
+
for (const part of parts) {
|
|
179
|
+
current = current?.[part];
|
|
180
|
+
if (!current) return null;
|
|
181
|
+
}
|
|
182
|
+
return current;
|
|
183
|
+
}
|