@eventcatalog/core 4.10.13 → 4.10.14

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.
Files changed (160) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-SSSN5FXC.js → chunk-HERAHLM2.js} +1 -1
  6. package/dist/{chunk-IIECZFXN.js → chunk-LITOQH56.js} +1 -1
  7. package/dist/{chunk-34RMFKFB.js → chunk-LUW6RDA7.js} +1 -1
  8. package/dist/{chunk-JHUICVBT.js → chunk-ONDFEA5R.js} +1 -1
  9. package/dist/{chunk-PADMH2RJ.js → chunk-V2SKOQYF.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/docs/api/02-config.md +55 -1
  13. package/dist/docs/development/authentication/providers/03-setting-up-github.md +29 -0
  14. package/dist/docs/development/bring-your-own-documentation/custom-pages/02-adding-custom-docs.md +2 -2
  15. package/dist/docs/development/bring-your-own-documentation/custom-pages/03-components.md +17 -0
  16. package/dist/docs/development/components/07-resource-references.md +7 -1
  17. package/dist/docs/development/components/components/05-architecture-graph.md +65 -0
  18. package/dist/docs/development/components/components/{05-attachments.md → 06-attachments.md} +1 -1
  19. package/dist/docs/development/components/components/{06-channel-information.md → 07-channel-information.md} +1 -1
  20. package/dist/docs/development/components/components/{07-custom-properties.md → 08-custom-properties.md} +1 -1
  21. package/dist/docs/development/components/components/{08-design.md → 09-design.md} +1 -1
  22. package/dist/docs/development/components/components/{09-entitymap.md → 10-entitymap.md} +1 -1
  23. package/dist/docs/development/components/components/{10-flow.md → 11-flow.md} +1 -1
  24. package/dist/docs/development/components/components/{11-link.md → 12-link.md} +1 -1
  25. package/dist/docs/development/components/components/{12-mermaid-file-loader.md → 13-mermaid-file-loader.md} +1 -1
  26. package/dist/docs/development/components/components/{13-message-table.md → 14-message-table.md} +1 -1
  27. package/dist/docs/development/components/components/{14-nodegraph.md → 15-nodegraph.md} +1 -1
  28. package/dist/docs/development/components/components/{15-openapi.md → 16-openapi.md} +1 -1
  29. package/dist/docs/development/components/components/{16-prompt.md → 17-prompt.md} +1 -1
  30. package/dist/docs/development/components/components/{17-remote-schema.md → 18-remote-schema.md} +1 -1
  31. package/dist/docs/development/components/components/{18-resource-group-table.md → 19-resource-group-table.md} +1 -1
  32. package/dist/docs/development/components/components/{19-resource-link.md → 20-resource-link.md} +1 -1
  33. package/dist/docs/development/components/components/{20-schema.md → 21-schema.md} +1 -1
  34. package/dist/docs/development/components/components/{21-schema-viewer.md → 22-schema-viewer.md} +1 -1
  35. package/dist/docs/development/components/components/{22-steps.md → 23-steps.md} +1 -1
  36. package/dist/docs/development/components/components/{23-tabs.md → 24-tabs.md} +1 -1
  37. package/dist/docs/development/components/components/{24-tiles.md → 25-tiles.md} +1 -1
  38. package/dist/docs/development/components/components/{25-visibility.md → 26-visibility.md} +1 -1
  39. package/dist/docs/development/customization/04-documentation-sidebar.md +3 -3
  40. package/dist/docs/development/customization/05-resource-sidebar.md +235 -0
  41. package/dist/docs/development/customization/09-embed-eventcatalog.md +179 -0
  42. package/dist/docs/development/customization/_category_.json +1 -1
  43. package/dist/docs/development/deployment/build-and-deploy.md +4 -0
  44. package/dist/docs/development/deployment/hosting-options.md +1 -1
  45. package/dist/docs/development/deployment/link-validation.md +105 -0
  46. package/dist/docs/development/developer-tools/eventcatalog-linter/00-overview.md +76 -0
  47. package/dist/docs/development/developer-tools/eventcatalog-linter/01-setup.md +144 -0
  48. package/dist/docs/development/developer-tools/eventcatalog-linter/_category_.json +10 -0
  49. package/dist/docs/development/developer-tools/eventcatalog-linter/how-to/01-configure-rules.md +130 -0
  50. package/dist/docs/development/developer-tools/eventcatalog-linter/how-to/02-run-in-ci.md +93 -0
  51. package/dist/docs/development/developer-tools/eventcatalog-linter/how-to/03-use-custom-frontmatter.md +81 -0
  52. package/dist/docs/development/developer-tools/eventcatalog-linter/how-to/04-reference-external-catalogs.md +51 -0
  53. package/dist/docs/development/developer-tools/eventcatalog-linter/how-to/05-fix-common-problems.md +120 -0
  54. package/dist/docs/development/developer-tools/eventcatalog-linter/how-to/_category_.json +12 -0
  55. package/dist/docs/development/developer-tools/eventcatalog-linter/reference/01-cli.md +109 -0
  56. package/dist/docs/development/developer-tools/eventcatalog-linter/reference/02-configuration.md +120 -0
  57. package/dist/docs/development/developer-tools/eventcatalog-linter/reference/03-rules.md +287 -0
  58. package/dist/docs/development/developer-tools/eventcatalog-linter/reference/04-versions.md +77 -0
  59. package/dist/docs/development/developer-tools/eventcatalog-linter/reference/05-supported-resources.md +75 -0
  60. package/dist/docs/development/developer-tools/eventcatalog-linter/reference/_category_.json +12 -0
  61. package/dist/docs/development/guides/97-architecture-graph.md +78 -0
  62. package/dist/docs/development/guides/domains/02-create-domain.md +1 -1
  63. package/dist/docs/development/guides/domains/03-ownership-and-language/02-adding-ubiquitous-language.md +29 -0
  64. package/dist/docs/development/guides/domains/09-reference.md +10 -0
  65. package/dist/docs/development/guides/resources/messages/04-message-channels/02-adding-channels.md +2 -2
  66. package/dist/docs/development/guides/resources/messages/04-message-channels/04-adding-messages-to-services.md +8 -0
  67. package/dist/docs/development/guides/resources/schemas/03-add-specifications-to-services/01-add-openapi-specifications.md +2 -2
  68. package/dist/docs/development/guides/resources/schemas/03-add-specifications-to-services/02-add-asyncapi-specifications.md +3 -3
  69. package/dist/docs/development/guides/systems/05-system-context-maps.md +2 -0
  70. package/dist/docs/development/license-keys/license-validation.md +7 -3
  71. package/dist/docs/development/license-keys/overview.md +7 -3
  72. package/dist/docs/development/upgrading/upgrading.md +3 -3
  73. package/dist/docs/development/upgrading/v2.md +3 -3
  74. package/dist/docs/federation/00-overview.md +158 -0
  75. package/dist/docs/federation/01-first-federation.md +199 -0
  76. package/dist/docs/federation/_category_.json +12 -0
  77. package/dist/docs/federation/explanation/_category_.json +12 -0
  78. package/dist/docs/federation/explanation/how-it-works.md +129 -0
  79. package/dist/docs/federation/explanation/lockfile-and-cache.md +134 -0
  80. package/dist/docs/federation/explanation/mvp-status-and-feedback.md +86 -0
  81. package/dist/docs/federation/explanation/ownership-and-references.md +134 -0
  82. package/dist/docs/federation/how-to/_category_.json +12 -0
  83. package/dist/docs/federation/how-to/configure-github-sources.md +156 -0
  84. package/dist/docs/federation/how-to/configure-validation-rules.md +127 -0
  85. package/dist/docs/federation/how-to/run-in-ci.md +138 -0
  86. package/dist/docs/federation/how-to/share-assets-and-components.md +125 -0
  87. package/dist/docs/federation/how-to/use-local-sources.md +138 -0
  88. package/dist/docs/{plugins/eventcatalog-federation → federation/legacy-federation}/00-introduction.md +11 -2
  89. package/dist/docs/{plugins/eventcatalog-federation → federation/legacy-federation}/01-installation.md +11 -3
  90. package/dist/docs/{plugins/eventcatalog-federation → federation/legacy-federation}/02-plugin-configuration.md +11 -2
  91. package/dist/docs/{plugins/eventcatalog-federation → federation/legacy-federation}/03-examples.md +11 -1
  92. package/dist/docs/{plugins/eventcatalog-federation → federation/legacy-federation}/04-configuration.md +11 -1
  93. package/dist/docs/{plugins/eventcatalog-federation → federation/legacy-federation}/05-setup-team-catalog.md +11 -2
  94. package/dist/docs/federation/legacy-federation/_category_.json +12 -0
  95. package/dist/docs/federation/reference/_category_.json +12 -0
  96. package/dist/docs/federation/reference/cli.md +122 -0
  97. package/dist/docs/federation/reference/configuration.md +192 -0
  98. package/dist/docs/federation/reference/diagnostic-rules.md +173 -0
  99. package/dist/docs/federation/reference/generated-output.md +118 -0
  100. package/dist/docs/federation/reference/supported-content.md +86 -0
  101. package/dist/docs/federation/reference/troubleshooting.md +208 -0
  102. package/dist/docs/plugins/01-intro.md +3 -3
  103. package/dist/docs/plugins/03-all-plugins.md +2 -2
  104. package/dist/docs/plugins/asyncapi/00-intro.md +4 -4
  105. package/dist/docs/plugins/asyncapi/02-plugin-configuration.md +4 -4
  106. package/dist/docs/plugins/backstage/00-overview.md +53 -0
  107. package/dist/docs/plugins/backstage/01-get-started.md +95 -0
  108. package/dist/docs/plugins/backstage/02-embed-entity-tabs.md +109 -0
  109. package/dist/docs/plugins/backstage/03-add-overview-cards.md +79 -0
  110. package/dist/docs/plugins/backstage/04-control-theme-and-size.md +54 -0
  111. package/dist/docs/plugins/backstage/05-migrate-to-annotations.md +49 -0
  112. package/dist/docs/plugins/backstage/06-configuration.md +85 -0
  113. package/dist/docs/plugins/backstage/07-components.md +129 -0
  114. package/dist/docs/plugins/backstage/08-how-embeds-work.md +52 -0
  115. package/dist/docs/plugins/backstage/_category_.json +4 -3
  116. package/dist/docs/plugins/eventbridge/00-intro.md +5 -5
  117. package/dist/docs/plugins/eventbridge/03-api.md +3 -3
  118. package/dist/docs/plugins/graphql/00-intro.md +2 -2
  119. package/dist/docs/plugins/graphql/02-plugin-configuration.md +2 -2
  120. package/dist/docs/plugins/hookdeck/01-intro.md +1 -1
  121. package/dist/docs/plugins/openapi/00-intro.md +1 -1
  122. package/dist/eventcatalog.cjs +1 -1
  123. package/dist/eventcatalog.js +5 -5
  124. package/dist/generate.cjs +1 -1
  125. package/dist/generate.js +3 -3
  126. package/dist/utils/cli-logger.cjs +1 -1
  127. package/dist/utils/cli-logger.js +2 -2
  128. package/eventcatalog/src/components/SchemaExplorer/AvroSchemaViewer.tsx +21 -19
  129. package/eventcatalog/src/components/SchemaExplorer/JSONSchemaViewer.tsx +79 -77
  130. package/eventcatalog/src/components/SchemaExplorer/ProtobufSchemaViewer.test.tsx +91 -0
  131. package/eventcatalog/src/components/SchemaExplorer/ProtobufSchemaViewer.tsx +115 -33
  132. package/eventcatalog/src/components/SchemaExplorer/SchemaViewerHover.test.tsx +33 -0
  133. package/eventcatalog/src/components/SchemaExplorer/protobuf-validation.test.ts +84 -0
  134. package/eventcatalog/src/components/SchemaExplorer/protobuf-validation.ts +149 -0
  135. package/eventcatalog/src/utils/protobuf-schema.ts +211 -16
  136. package/package.json +3 -3
  137. package/dist/docs/cli/channels.md +0 -180
  138. package/dist/docs/cli/commands.md +0 -183
  139. package/dist/docs/cli/custom-docs.md +0 -78
  140. package/dist/docs/cli/data-products.md +0 -177
  141. package/dist/docs/cli/data-stores.md +0 -166
  142. package/dist/docs/cli/diagrams.md +0 -147
  143. package/dist/docs/cli/domains.md +0 -280
  144. package/dist/docs/cli/entities.md +0 -138
  145. package/dist/docs/cli/events.md +0 -186
  146. package/dist/docs/cli/export.md +0 -27
  147. package/dist/docs/cli/governance.md +0 -24
  148. package/dist/docs/cli/messages.md +0 -69
  149. package/dist/docs/cli/queries.md +0 -183
  150. package/dist/docs/cli/services.md +0 -266
  151. package/dist/docs/cli/snapshots.md +0 -44
  152. package/dist/docs/cli/teams.md +0 -75
  153. package/dist/docs/cli/users.md +0 -75
  154. package/dist/docs/cli/utilities.md +0 -43
  155. package/dist/docs/development/developer-tools/eventcatalog-linter.md +0 -732
  156. package/dist/docs/plugins/backstage/00-intro.md +0 -67
  157. package/dist/docs/plugins/backstage/01-installation.md +0 -242
  158. package/dist/docs/plugins/backstage/02-api.md +0 -51
  159. package/dist/docs/plugins/backstage/03-examples.md +0 -12
  160. package/dist/docs/plugins/eventcatalog-federation/_category_.json +0 -11
@@ -1,5 +1,12 @@
1
1
  import { useState, useEffect, useRef, useMemo } from 'react';
2
2
  import type { ProtobufSchema, ProtobufMessage, ProtobufEnum, ProtobufField } from '@utils/protobuf-schema';
3
+ import {
4
+ formatProtobufOptionValue,
5
+ formatProtovalidateRule,
6
+ getCustomFieldAnnotations,
7
+ getProtovalidateRules,
8
+ isProtovalidateRequired,
9
+ } from './protobuf-validation';
3
10
 
4
11
  interface ProtobufSchemaViewerProps {
5
12
  schema: ProtobufSchema;
@@ -63,9 +70,21 @@ function lookupType<T>(map: Map<string, T>, typeName: string, packageName?: stri
63
70
  return undefined;
64
71
  }
65
72
 
66
- function formatProtobufType(field: ProtobufField): string {
67
- if (field.map) return field.type;
68
- return field.label ? `${field.label} ${field.type}` : field.type;
73
+ function compactTypeName(typeName: string): string {
74
+ const normalized = typeName.startsWith('.') ? typeName.slice(1) : typeName;
75
+ return normalized.split('.').pop() ?? normalized;
76
+ }
77
+
78
+ function formatProtobufType(field: ProtobufField, compact = false): string {
79
+ const type = field.map
80
+ ? compact
81
+ ? `map<${field.map.keyType}, ${compactTypeName(field.map.valueType)}>`
82
+ : field.type
83
+ : compact
84
+ ? compactTypeName(field.type)
85
+ : field.type;
86
+
87
+ return field.label ? `${field.label} ${type}` : type;
69
88
  }
70
89
 
71
90
  function countFields(messages: ProtobufMessage[]): number {
@@ -92,7 +111,27 @@ const ProtobufFieldRow = ({ field, level, expand, showRequired, registry, packag
92
111
  const nestedEnum = nestedMessage ? undefined : lookupType(registry.enums, targetTypeName, packageName);
93
112
  const isCyclic = nestedMessage ? ancestors.includes(nestedMessage.name) : false;
94
113
  const hasNested = !!nestedMessage && nestedMessage.fields.length > 0 && !isCyclic;
95
- const isRequired = field.label === 'required';
114
+ const hasRequiredAnnotation = isProtovalidateRequired(field);
115
+ const isRequired = hasRequiredAnnotation || field.label === 'required';
116
+ const validationRules = getProtovalidateRules(field).filter((rule) => rule.path !== 'required');
117
+ const customAnnotations = getCustomFieldAnnotations(field);
118
+ const fullType = formatProtobufType(field);
119
+ const displayType = formatProtobufType(field, true);
120
+ const enumRuleScope = field.map ? 'map.values.enum' : field.label === 'repeated' ? 'repeated.items.enum' : 'enum';
121
+ const enumConstant = validationRules.find((rule) => rule.path === `${enumRuleScope}.const`);
122
+ const enumIncludes = validationRules.find((rule) => rule.path === `${enumRuleScope}.in`);
123
+ const enumExcludes = validationRules.find((rule) => rule.path === `${enumRuleScope}.not_in`);
124
+ const normalizeRuleValues = (value: unknown) => (Array.isArray(value) ? value : value === undefined ? [] : [value]);
125
+ const includedEnumValues = normalizeRuleValues(enumIncludes?.value);
126
+ const excludedEnumValues = normalizeRuleValues(enumExcludes?.value);
127
+ const displayedEnumValues = nestedEnum?.values.filter((value) => {
128
+ const matches = (candidate: unknown) => String(candidate) === String(value.value) || candidate === value.name;
129
+ return (
130
+ (!enumConstant || matches(enumConstant.value)) &&
131
+ (includedEnumValues.length === 0 || includedEnumValues.some(matches)) &&
132
+ !excludedEnumValues.some(matches)
133
+ );
134
+ });
96
135
 
97
136
  useEffect(() => {
98
137
  setIsExpanded(expand);
@@ -116,40 +155,83 @@ const ProtobufFieldRow = ({ field, level, expand, showRequired, registry, packag
116
155
  )}
117
156
 
118
157
  <div className="flex-grow">
119
- <div className="flex justify-between items-baseline">
120
- <div>
121
- <span className="proto-field-name font-semibold text-[rgb(var(--ec-page-text))] text-sm">{field.name}</span>
122
- <span className="ml-1.5 text-[rgb(var(--ec-accent))] font-mono text-xs">{formatProtobufType(field)}</span>
123
- {field.number !== undefined && (
124
- <span className="ml-1.5 text-[rgb(var(--ec-page-text-muted))] font-mono text-xs">= {field.number}</span>
125
- )}
126
- {field.oneof && (
127
- <span className="ml-1.5 text-[rgb(var(--ec-page-text-muted))] text-xs bg-[rgb(var(--ec-content-hover))] px-1 rounded">
128
- oneof {field.oneof}
158
+ <div className="-mx-1 rounded px-1 transition-colors hover:bg-[rgb(var(--ec-content-hover))]">
159
+ <div className="flex justify-between items-baseline">
160
+ <div>
161
+ <span className="proto-field-name font-semibold text-[rgb(var(--ec-page-text))] text-sm">{field.name}</span>
162
+ <span
163
+ className="ml-1.5 text-[rgb(var(--ec-accent))] font-mono text-xs"
164
+ title={displayType === fullType ? undefined : fullType}
165
+ >
166
+ {displayType}
129
167
  </span>
168
+ {field.number !== undefined && (
169
+ <span className="ml-1.5 text-[rgb(var(--ec-page-text-muted))] font-mono text-xs">= {field.number}</span>
170
+ )}
171
+ {field.oneof && (
172
+ <span className="ml-1.5 text-[rgb(var(--ec-page-text-muted))] text-xs bg-[rgb(var(--ec-content-hover))] px-1 rounded">
173
+ oneof {field.oneof}
174
+ </span>
175
+ )}
176
+ </div>
177
+ {(hasRequiredAnnotation || (showRequired && isRequired)) && (
178
+ <span className="text-red-600 dark:text-red-400 text-xs ml-3 flex-shrink-0">required</span>
130
179
  )}
131
180
  </div>
132
- {showRequired && isRequired && (
133
- <span className="text-red-600 dark:text-red-400 text-xs ml-3 flex-shrink-0">required</span>
181
+
182
+ {field.doc && <p className="text-[rgb(var(--ec-page-text-muted))] text-xs mt-0.5">{field.doc}</p>}
183
+
184
+ {validationRules.length > 0 && (
185
+ <div className="text-xs text-[rgb(var(--ec-page-text-muted))] mt-0.5 space-y-0">
186
+ {validationRules.map((rule, index) => {
187
+ const display = formatProtovalidateRule(rule);
188
+ return (
189
+ <div key={`${rule.path}-${index}`}>
190
+ {display.label}
191
+ {display.value !== undefined && (
192
+ <>
193
+ :{' '}
194
+ <code className="bg-[rgb(var(--ec-content-hover))] px-1 rounded text-[rgb(var(--ec-page-text))] font-thin py-0.5">
195
+ {display.value}
196
+ </code>
197
+ </>
198
+ )}
199
+ </div>
200
+ );
201
+ })}
202
+ </div>
134
203
  )}
135
- </div>
136
204
 
137
- {field.doc && <p className="text-[rgb(var(--ec-page-text-muted))] text-xs mt-0.5">{field.doc}</p>}
138
-
139
- {/* Show enum values if the field type resolves to an enum */}
140
- {nestedEnum && nestedEnum.values.length > 0 && (
141
- <div className="text-xs text-[rgb(var(--ec-page-text-muted))] mt-0.5">
142
- <span className="text-xs inline-block">Allowed values:</span>
143
- {nestedEnum.values.map((value) => (
144
- <span key={value.name} className="text-xs">
145
- {' '}
146
- <code className="bg-[rgb(var(--ec-content-hover))] px-1 rounded text-[rgb(var(--ec-page-text))] font-thin py-0.5">
147
- {value.name}
148
- </code>
149
- </span>
150
- ))}
151
- </div>
152
- )}
205
+ {customAnnotations.length > 0 && (
206
+ <div className="mt-0.5 space-y-0 text-xs text-gray-400 dark:text-gray-500">
207
+ {customAnnotations.map((annotation, index) => (
208
+ <div key={`${annotation.name}-${index}`} className="flex max-w-full flex-wrap items-baseline gap-x-1">
209
+ <span>Annotation:</span>
210
+ <code className="break-all font-mono text-gray-500 dark:text-gray-400">{annotation.name}</code>
211
+ <span>=</span>
212
+ <code className="font-medium text-gray-500 dark:text-gray-400">
213
+ {formatProtobufOptionValue(annotation.value)}
214
+ </code>
215
+ </div>
216
+ ))}
217
+ </div>
218
+ )}
219
+
220
+ {/* Show enum values if the field type resolves to an enum */}
221
+ {nestedEnum && displayedEnumValues && displayedEnumValues.length > 0 && (
222
+ <div className="text-xs text-[rgb(var(--ec-page-text-muted))] mt-0.5">
223
+ <span className="text-xs inline-block">Allowed values:</span>
224
+ {displayedEnumValues.map((value) => (
225
+ <span key={value.name} className="text-xs">
226
+ {' '}
227
+ <code className="bg-[rgb(var(--ec-content-hover))] px-1 rounded text-[rgb(var(--ec-page-text))] font-thin py-0.5">
228
+ {value.name}
229
+ </code>
230
+ </span>
231
+ ))}
232
+ </div>
233
+ )}
234
+ </div>
153
235
 
154
236
  {/* Nested fields for message types */}
155
237
  {hasNested && nestedMessage && (
@@ -0,0 +1,33 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { parseProtobufSchema } from '@utils/protobuf-schema';
4
+ import AvroSchemaViewer from './AvroSchemaViewer';
5
+ import JSONSchemaViewer from './JSONSchemaViewer';
6
+ import ProtobufSchemaViewer from './ProtobufSchemaViewer';
7
+
8
+ const PROPERTY_HOVER_CLASS = 'hover:bg-[rgb(var(--ec-content-hover))]';
9
+
10
+ describe('schema viewer property hover states', () => {
11
+ it('adds a hover background to JSON Schema properties', () => {
12
+ const html = renderToStaticMarkup(
13
+ <JSONSchemaViewer schema={{ type: 'object', properties: { id: { type: 'string' } } }} search={false} />
14
+ );
15
+
16
+ expect(html).toContain(PROPERTY_HOVER_CLASS);
17
+ });
18
+
19
+ it('adds a hover background to Avro fields', () => {
20
+ const html = renderToStaticMarkup(
21
+ <AvroSchemaViewer schema={{ type: 'record', name: 'Example', fields: [{ name: 'id', type: 'string' }] }} search={false} />
22
+ );
23
+
24
+ expect(html).toContain(PROPERTY_HOVER_CLASS);
25
+ });
26
+
27
+ it('adds a hover background to Protobuf fields', () => {
28
+ const schema = parseProtobufSchema('syntax = "proto3"; message Example { string id = 1; }');
29
+ const html = renderToStaticMarkup(<ProtobufSchemaViewer schema={schema} search={false} />);
30
+
31
+ expect(html).toContain(PROPERTY_HOVER_CLASS);
32
+ });
33
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { ProtobufField } from '@utils/protobuf-schema';
3
+ import {
4
+ formatProtobufOptionValue,
5
+ formatProtovalidateRule,
6
+ getCustomFieldAnnotations,
7
+ getProtovalidateRules,
8
+ isProtovalidateRequired,
9
+ } from './protobuf-validation';
10
+
11
+ describe('protobuf validation helpers', () => {
12
+ it('flattens direct and aggregate Protovalidate options', () => {
13
+ const field: ProtobufField = {
14
+ name: 'status',
15
+ type: 'Status',
16
+ options: [
17
+ { name: '(buf.validate.field).required', value: true },
18
+ {
19
+ name: '(buf.validate.field).enum',
20
+ value: { defined_only: true, not_in: [0] },
21
+ },
22
+ ],
23
+ };
24
+
25
+ expect(getProtovalidateRules(field)).toEqual([
26
+ { path: 'required', value: true },
27
+ { path: 'enum.defined_only', value: true },
28
+ { path: 'enum.not_in', value: [0] },
29
+ ]);
30
+ expect(isProtovalidateRequired(field)).toBe(true);
31
+ });
32
+
33
+ it('ignores non-Protovalidate custom options', () => {
34
+ const field: ProtobufField = {
35
+ name: 'description',
36
+ type: 'string',
37
+ options: [{ name: '(datahub.v1.gdpr_rule).string', value: true }],
38
+ };
39
+
40
+ expect(getProtovalidateRules(field)).toEqual([]);
41
+ expect(isProtovalidateRequired(field)).toBe(false);
42
+ expect(getCustomFieldAnnotations(field)).toEqual([{ name: '(datahub.v1.gdpr_rule).string', value: true }]);
43
+ });
44
+
45
+ it('only treats extension options as custom annotations and formats aggregate values', () => {
46
+ const field: ProtobufField = {
47
+ name: 'description',
48
+ type: 'string',
49
+ options: [
50
+ { name: 'deprecated', value: true },
51
+ { name: '(buf.validate.field).string.min_len', value: 1 },
52
+ { name: '(datahub.v1.classification)', value: { level: 'personal', encrypted: true } },
53
+ ],
54
+ };
55
+
56
+ expect(getCustomFieldAnnotations(field)).toEqual([
57
+ {
58
+ name: '(datahub.v1.classification)',
59
+ value: { level: 'personal', encrypted: true },
60
+ },
61
+ ]);
62
+ expect(formatProtobufOptionValue(getCustomFieldAnnotations(field)[0].value)).toBe('{"level":"personal","encrypted":true}');
63
+ });
64
+
65
+ it('formats common rules for display', () => {
66
+ expect(formatProtovalidateRule({ path: 'string.email', value: true })).toEqual({ label: 'Format', value: 'Email' });
67
+ expect(formatProtovalidateRule({ path: 'uint32.gte', value: 1 })).toEqual({ label: 'Minimum', value: '1' });
68
+ expect(formatProtovalidateRule({ path: 'enum.defined_only', value: true })).toEqual({
69
+ label: 'Defined enum values only',
70
+ });
71
+ expect(formatProtovalidateRule({ path: 'enum.not_in', value: [0] })).toEqual({
72
+ label: 'Excluded values',
73
+ value: '0',
74
+ });
75
+ expect(formatProtovalidateRule({ path: 'repeated.items.string.min_len', value: 3 })).toEqual({
76
+ label: 'Item minimum length',
77
+ value: '3',
78
+ });
79
+ expect(formatProtovalidateRule({ path: 'map.values.string.max_len', value: 100 })).toEqual({
80
+ label: 'Value maximum length',
81
+ value: '100',
82
+ });
83
+ });
84
+ });
@@ -0,0 +1,149 @@
1
+ import type { ProtobufField, ProtobufOptionValue } from '@utils/protobuf-schema';
2
+
3
+ const PROTOVALIDATE_FIELD_OPTION = '(buf.validate.field)';
4
+
5
+ export interface ProtovalidateRule {
6
+ path: string;
7
+ value: ProtobufOptionValue;
8
+ }
9
+
10
+ export interface ProtovalidateRuleDisplay {
11
+ label: string;
12
+ value?: string;
13
+ }
14
+
15
+ export interface ProtobufCustomAnnotation {
16
+ name: string;
17
+ value: ProtobufOptionValue;
18
+ }
19
+
20
+ const isOptionObject = (value: ProtobufOptionValue): value is { [key: string]: ProtobufOptionValue } =>
21
+ typeof value === 'object' && !Array.isArray(value);
22
+
23
+ const flattenOptionValue = (path: string, value: ProtobufOptionValue): ProtovalidateRule[] => {
24
+ if (!isOptionObject(value)) return [{ path, value }];
25
+
26
+ return Object.entries(value).flatMap(([name, nestedValue]) => flattenOptionValue(path ? `${path}.${name}` : name, nestedValue));
27
+ };
28
+
29
+ export const getProtovalidateRules = (field: ProtobufField): ProtovalidateRule[] =>
30
+ (field.options ?? []).flatMap((option) => {
31
+ if (option.name !== PROTOVALIDATE_FIELD_OPTION && !option.name.startsWith(`${PROTOVALIDATE_FIELD_OPTION}.`)) return [];
32
+
33
+ const path = option.name.slice(PROTOVALIDATE_FIELD_OPTION.length).replace(/^\./, '');
34
+ return flattenOptionValue(path, option.value);
35
+ });
36
+
37
+ export const isProtovalidateRequired = (field: ProtobufField) =>
38
+ getProtovalidateRules(field).some((rule) => rule.path === 'required' && rule.value === true);
39
+
40
+ export const getCustomFieldAnnotations = (field: ProtobufField): ProtobufCustomAnnotation[] =>
41
+ (field.options ?? [])
42
+ .filter(
43
+ (option) =>
44
+ option.name.startsWith('(') &&
45
+ option.name !== PROTOVALIDATE_FIELD_OPTION &&
46
+ !option.name.startsWith(`${PROTOVALIDATE_FIELD_OPTION}.`)
47
+ )
48
+ .map(({ name, value }) => ({ name, value }));
49
+
50
+ export const formatProtobufOptionValue = (value: ProtobufOptionValue): string => {
51
+ if (Array.isArray(value)) return value.map(formatProtobufOptionValue).join(', ');
52
+ if (isOptionObject(value)) return JSON.stringify(value);
53
+ return String(value);
54
+ };
55
+
56
+ const humanize = (value: string) => value.replace(/_/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase());
57
+
58
+ const FORMAT_NAMES: Record<string, string> = {
59
+ address: 'Address',
60
+ email: 'Email',
61
+ hostname: 'Hostname',
62
+ ip: 'IP',
63
+ ipv4: 'IPv4',
64
+ ipv6: 'IPv6',
65
+ protobuf_fqn: 'Protobuf FQN',
66
+ tuuid: 'TUUID',
67
+ uri: 'URI',
68
+ uri_ref: 'URI reference',
69
+ uuid: 'UUID',
70
+ };
71
+
72
+ const getRuleScope = (path: string) => {
73
+ if (path.includes('repeated.items')) return 'Item';
74
+ if (path.includes('map.keys')) return 'Key';
75
+ if (path.includes('map.values')) return 'Value';
76
+ return '';
77
+ };
78
+
79
+ const withScope = (scope: string, label: string) =>
80
+ scope ? `${scope} ${label.charAt(0).toLowerCase()}${label.slice(1)}` : label;
81
+
82
+ export const formatProtovalidateRule = ({ path, value }: ProtovalidateRule): ProtovalidateRuleDisplay => {
83
+ const name = path.split('.').pop() ?? path;
84
+ const scope = getRuleScope(path);
85
+ const formattedValue = formatProtobufOptionValue(value);
86
+
87
+ switch (name) {
88
+ case 'required':
89
+ return value === true ? { label: 'Required' } : { label: 'Required', value: formattedValue };
90
+ case 'const':
91
+ return { label: withScope(scope, 'Constant'), value: formattedValue };
92
+ case 'len':
93
+ return { label: withScope(scope, 'Length'), value: formattedValue };
94
+ case 'min_len':
95
+ return { label: withScope(scope, 'Minimum length'), value: formattedValue };
96
+ case 'max_len':
97
+ return { label: withScope(scope, 'Maximum length'), value: formattedValue };
98
+ case 'len_bytes':
99
+ return { label: withScope(scope, 'Byte length'), value: formattedValue };
100
+ case 'min_bytes':
101
+ return { label: withScope(scope, 'Minimum byte length'), value: formattedValue };
102
+ case 'max_bytes':
103
+ return { label: withScope(scope, 'Maximum byte length'), value: formattedValue };
104
+ case 'pattern':
105
+ return { label: withScope(scope, 'Match pattern'), value: formattedValue };
106
+ case 'gt':
107
+ return { label: withScope(scope, 'Greater than'), value: formattedValue };
108
+ case 'gte':
109
+ return { label: withScope(scope, 'Minimum'), value: formattedValue };
110
+ case 'lt':
111
+ return { label: withScope(scope, 'Less than'), value: formattedValue };
112
+ case 'lte':
113
+ return { label: withScope(scope, 'Maximum'), value: formattedValue };
114
+ case 'in':
115
+ return { label: withScope(scope, 'Allowed values'), value: formattedValue };
116
+ case 'not_in':
117
+ return { label: withScope(scope, 'Excluded values'), value: formattedValue };
118
+ case 'defined_only':
119
+ return value === true
120
+ ? { label: 'Defined enum values only' }
121
+ : { label: 'Defined enum values only', value: formattedValue };
122
+ case 'min_items':
123
+ return { label: 'Minimum items', value: formattedValue };
124
+ case 'max_items':
125
+ return { label: 'Maximum items', value: formattedValue };
126
+ case 'unique':
127
+ return value === true ? { label: 'Unique items' } : { label: 'Unique items', value: formattedValue };
128
+ case 'min_pairs':
129
+ return { label: 'Minimum map entries', value: formattedValue };
130
+ case 'max_pairs':
131
+ return { label: 'Maximum map entries', value: formattedValue };
132
+ case 'email':
133
+ case 'hostname':
134
+ case 'ip':
135
+ case 'ipv4':
136
+ case 'ipv6':
137
+ case 'uri':
138
+ case 'uri_ref':
139
+ case 'address':
140
+ case 'uuid':
141
+ case 'tuuid':
142
+ case 'protobuf_fqn':
143
+ return value === true
144
+ ? { label: withScope(scope, 'Format'), value: FORMAT_NAMES[name] }
145
+ : { label: withScope(scope, humanize(name)), value: formattedValue };
146
+ default:
147
+ return { label: withScope(scope, humanize(name)), value: formattedValue };
148
+ }
149
+ };