@webda/grpc 4.0.0-beta.3

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.
@@ -0,0 +1,338 @@
1
+ import type { OperationDefinition } from "@webda/core";
2
+ import type { JSONSchema7 } from "json-schema";
3
+
4
+ /**
5
+ * Generate a .proto file from registered operations and their schemas.
6
+ *
7
+ * Groups operations by prefix (e.g., "Post.Create" → service PostService)
8
+ * and converts JSON Schema input/output types to protobuf messages.
9
+ * @param operations - map of operation IDs to their definitions
10
+ * @param schemas - all known JSON schemas keyed by schema reference name
11
+ * @param packageName - protobuf package name to use in the generated file
12
+ * @returns the complete .proto file content as a string
13
+ */
14
+ export function generateProto(
15
+ operations: Record<string, Omit<OperationDefinition, "service" | "method">>,
16
+ schemas: Record<string, JSONSchema7>,
17
+ packageName: string = "webda"
18
+ ): string {
19
+ const lines: string[] = [];
20
+ const messages = new Map<string, string>(); // messageName → proto definition
21
+ const fieldCounter = 0;
22
+
23
+ lines.push('syntax = "proto3";');
24
+ lines.push("");
25
+ lines.push(`package ${packageName};`);
26
+ lines.push("");
27
+ lines.push('import "google/protobuf/empty.proto";');
28
+ lines.push('import "google/protobuf/struct.proto";');
29
+ lines.push("");
30
+
31
+ // Group operations by service prefix
32
+ const serviceGroups = new Map<string, { opId: string; op: any; rpcName: string }[]>();
33
+ for (const [opId, op] of Object.entries(operations)) {
34
+ if (op.hidden) continue;
35
+ const dotIdx = opId.indexOf(".");
36
+ const prefix = dotIdx > 0 ? opId.substring(0, dotIdx) : "Default";
37
+ // proto3 RPC method names must match `[A-Za-z_][A-Za-z0-9_]*` — dots are
38
+ // namespace separators and rejected by the parser. Behavior operations
39
+ // are namespaced as `<Model>.<Attribute>.<Action>` (e.g.
40
+ // `Post.MainImage.Delete`), so the trailing portion still carries a
41
+ // dot. Flatten to underscore so the parser accepts the rpc name.
42
+ const rpcName = (dotIdx > 0 ? opId.substring(dotIdx + 1) : opId).replace(/\./g, "_");
43
+ if (!serviceGroups.has(prefix)) {
44
+ serviceGroups.set(prefix, []);
45
+ }
46
+ serviceGroups.get(prefix)!.push({ opId, op, rpcName });
47
+ }
48
+
49
+ // Webda operation inputs ending in `?` are the "ignoreRequired" variant of
50
+ // the base schema; strip the suffix when looking the schema up since only
51
+ // the base key exists in the registry.
52
+ const resolveSchemaRef = (ref?: string): string | undefined => {
53
+ if (!ref || ref === "void") return undefined;
54
+ if (schemas[ref]) return ref;
55
+ if (ref.endsWith("?") && schemas[ref.slice(0, -1)]) return ref.slice(0, -1);
56
+ return undefined;
57
+ };
58
+
59
+ // Hoist every operation schema's local `definitions` block into the flat
60
+ // schema registry so $refs like `#/definitions/BinaryMap<T>` (which the
61
+ // schema generator emits next to its hoisted definitions block) resolve
62
+ // to a real shape instead of falling back to `google.protobuf.Struct`.
63
+ // The compiler's `normalizeSchemaDefinitions` already lifted these to the
64
+ // schema root; we just need to fold them into the flat registry the proto
65
+ // walker consults.
66
+ const enrichedSchemas: Record<string, JSONSchema7> = { ...schemas };
67
+ for (const op of Object.values(operations)) {
68
+ if (op.hidden) continue;
69
+ for (const ref of [op.input, op.output]) {
70
+ if (!ref || ref === "void") continue;
71
+ const baseRef = ref.endsWith("?") ? ref.slice(0, -1) : ref;
72
+ const target = enrichedSchemas[baseRef];
73
+ const localDefs = (target as any)?.definitions;
74
+ if (!localDefs) continue;
75
+ for (const [k, v] of Object.entries(localDefs)) {
76
+ if (!(k in enrichedSchemas)) {
77
+ enrichedSchemas[k] = v as JSONSchema7;
78
+ }
79
+ }
80
+ }
81
+ }
82
+
83
+ // Generate messages for all referenced schemas
84
+ for (const [opId, op] of Object.entries(operations)) {
85
+ if (op.hidden) continue;
86
+ const inputRef = resolveSchemaRef(op.input);
87
+ if (inputRef) {
88
+ const msgName = schemaToMessageName(inputRef);
89
+ if (!messages.has(msgName)) {
90
+ messages.set(msgName, jsonSchemaToMessage(msgName, enrichedSchemas[inputRef], enrichedSchemas, messages));
91
+ }
92
+ }
93
+ const outputRef = resolveSchemaRef(op.output);
94
+ if (outputRef) {
95
+ const msgName = schemaToMessageName(outputRef);
96
+ if (!messages.has(msgName)) {
97
+ messages.set(msgName, jsonSchemaToMessage(msgName, enrichedSchemas[outputRef], enrichedSchemas, messages));
98
+ }
99
+ }
100
+ }
101
+
102
+ // Write all messages
103
+ for (const [, msgDef] of messages) {
104
+ lines.push(msgDef);
105
+ lines.push("");
106
+ }
107
+
108
+ // Write services
109
+ for (const [prefix, ops] of serviceGroups) {
110
+ lines.push(`service ${prefix}Service {`);
111
+ for (const { opId, op, rpcName } of ops) {
112
+ const inputType = getInputType(op, schemas, messages);
113
+ const outputType = getOutputType(op, schemas, messages);
114
+ const streaming = op.grpc?.streaming || "none";
115
+
116
+ const inputMod = streaming === "client" || streaming === "bidi" ? "stream " : "";
117
+ const outputMod = streaming === "server" || streaming === "bidi" ? "stream " : "";
118
+
119
+ if (op.summary) {
120
+ lines.push(` // ${op.summary}`);
121
+ }
122
+ lines.push(` rpc ${rpcName}(${inputMod}${inputType}) returns (${outputMod}${outputType});`);
123
+ }
124
+ lines.push("}");
125
+ lines.push("");
126
+ }
127
+
128
+ return lines.join("\n");
129
+ }
130
+
131
+ /**
132
+ * Convert a schema reference name to a protobuf message name.
133
+ * "WebdaSample/Post" → "Post", "uuidRequest" → "UuidRequest"
134
+ * @param schemaRef - the schema reference string, e.g. "MyApp/Post" or "uuidRequest"
135
+ * @returns the PascalCase protobuf message name derived from the reference
136
+ */
137
+ function schemaToMessageName(schemaRef: string): string {
138
+ // JSON-Pointer escapes (`%3C%7B%7D%3E`) survive into `$ref` strings when the
139
+ // schema generator names a definition like `BinaryFileInfo<{}>`. Decode
140
+ // them first so we can strip the generic-bracket noise rather than emitting
141
+ // `BinaryFileInfo%3C%7B%7D%3E` as a proto identifier (which the parser
142
+ // rejects as an illegal token).
143
+ let decoded: string;
144
+ try {
145
+ decoded = decodeURIComponent(schemaRef);
146
+ } catch {
147
+ decoded = schemaRef;
148
+ }
149
+ const tail = (decoded.includes("/") ? decoded.split("/").pop() : decoded) ?? decoded;
150
+ // Drop generic args (`<...>`) entirely — proto3 has no generic concept and
151
+ // the unresolved `T` made the generated schema collapse them to `<{}>`.
152
+ const stripped = tail.replace(/<[^>]*>/g, "");
153
+ // Sanitize anything left that proto3 doesn't accept in an identifier.
154
+ // proto3 idents match `[A-Za-z_][A-Za-z0-9_]*`; splitting on every other
155
+ // char and PascalCasing keeps the original word boundaries readable.
156
+ const cleaned = stripped
157
+ .split(/[^A-Za-z0-9]+/)
158
+ .filter(Boolean)
159
+ .map(s => s.charAt(0).toUpperCase() + s.slice(1))
160
+ .join("") || "Empty";
161
+ // proto3 identifiers must start with a letter or underscore; underscore
162
+ // prefix the cleaned name when it begins with a digit (e.g. `1Metadata`
163
+ // from a `&1$metadata` schema-generator artefact for unbound generics).
164
+ return /^[A-Za-z_]/.test(cleaned) ? cleaned : `_${cleaned}`;
165
+ }
166
+
167
+ /**
168
+ * Get the protobuf input type for an operation.
169
+ * Merges parameters + input into a single request message if both exist.
170
+ * @param op - the operation definition containing optional input and parameters schema references
171
+ * @param schemas - all known JSON schemas keyed by schema reference name
172
+ * @param messages - accumulator map of already-generated protobuf message definitions
173
+ * @returns the protobuf type name to use as the RPC input type
174
+ */
175
+ function getInputType(
176
+ op: any,
177
+ schemas: Record<string, JSONSchema7>,
178
+ messages: Map<string, string>
179
+ ): string {
180
+ const ref = op.input && op.input !== "void"
181
+ ? (schemas[op.input]
182
+ ? op.input
183
+ : op.input.endsWith("?") && schemas[op.input.slice(0, -1)]
184
+ ? op.input.slice(0, -1)
185
+ : undefined)
186
+ : undefined;
187
+ if (ref) {
188
+ return schemaToMessageName(ref);
189
+ }
190
+ return "google.protobuf.Empty";
191
+ }
192
+
193
+ /**
194
+ * Get the protobuf output type for an operation.
195
+ * @param op - the operation definition containing an optional output schema reference
196
+ * @param schemas - all known JSON schemas keyed by schema reference name
197
+ * @param messages - accumulator map of already-generated protobuf message definitions
198
+ * @returns the protobuf type name to use as the RPC output type
199
+ */
200
+ function getOutputType(
201
+ op: any,
202
+ schemas: Record<string, JSONSchema7>,
203
+ messages: Map<string, string>
204
+ ): string {
205
+ if (op.output && op.output !== "void" && schemas[op.output]) {
206
+ return schemaToMessageName(op.output);
207
+ }
208
+ return "google.protobuf.Empty";
209
+ }
210
+
211
+ /**
212
+ * Convert a JSON Schema object to a protobuf message definition.
213
+ * @param name - the protobuf message name to assign to this definition
214
+ * @param schema - the JSON Schema object describing the message fields
215
+ * @param allSchemas - all known JSON schemas, used to resolve nested references
216
+ * @param messages - accumulator map updated with any nested message definitions generated
217
+ * @returns the protobuf message block as a string
218
+ */
219
+ function jsonSchemaToMessage(
220
+ name: string,
221
+ schema: JSONSchema7,
222
+ allSchemas: Record<string, JSONSchema7>,
223
+ messages: Map<string, string>
224
+ ): string {
225
+ const lines: string[] = [];
226
+ lines.push(`message ${name} {`);
227
+
228
+ if (schema.properties) {
229
+ // JSON-Schema `required` list drives proto3 `optional` marker so that
230
+ // fields the caller didn't set aren't sent as empty-string defaults and
231
+ // trip pattern/format/minLength validations server-side.
232
+ const required = new Set((schema.required as string[]) || []);
233
+ let fieldNum = 1;
234
+ for (const [propName, propSchema] of Object.entries(schema.properties)) {
235
+ const prop = propSchema as JSONSchema7;
236
+ const protoType = jsonTypeToProto(propName, prop, name, allSchemas, messages);
237
+ const fieldType = prop.type === "array" ? jsonTypeToProto(propName, (prop.items as JSONSchema7) || {}, name, allSchemas, messages) : protoType;
238
+ if (prop.type === "array") {
239
+ lines.push(` repeated ${fieldType} ${propName} = ${fieldNum};`);
240
+ } else if (required.has(propName)) {
241
+ lines.push(` ${protoType} ${propName} = ${fieldNum};`);
242
+ } else {
243
+ lines.push(` optional ${protoType} ${propName} = ${fieldNum};`);
244
+ }
245
+ fieldNum++;
246
+ }
247
+ }
248
+
249
+ lines.push("}");
250
+ return lines.join("\n");
251
+ }
252
+
253
+ /**
254
+ * Map a JSON Schema type to a protobuf type.
255
+ * @param fieldName - the property name in the parent schema, used to derive nested message names
256
+ * @param schema - the JSON Schema describing this field's type
257
+ * @param parentName - the name of the enclosing protobuf message, used for nested message naming
258
+ * @param allSchemas - all known JSON schemas, used to resolve nested references
259
+ * @param messages - accumulator map updated with any nested message definitions generated
260
+ * @returns the protobuf scalar or message type name for this field
261
+ */
262
+ function jsonTypeToProto(
263
+ fieldName: string,
264
+ schema: JSONSchema7,
265
+ parentName: string,
266
+ allSchemas: Record<string, JSONSchema7>,
267
+ messages: Map<string, string>
268
+ ): string {
269
+ if (schema.$ref) {
270
+ // Distinguish two ref shapes:
271
+ // - `#/components/schemas/<name>` points at a top-level schema in the
272
+ // OpenAPI components registry. The original behaviour was to emit
273
+ // the bare message name and trust that some other schema source
274
+ // defines it; preserve that to keep `componentsSchemaRef` passing.
275
+ // - `#/definitions/<name>` is the typescript-json-schema shape: refs
276
+ // are local to the same schema's `definitions` block. The compiler
277
+ // already hoists those into `allSchemas` for us. If the lookup
278
+ // misses, we have a genuinely-unresolvable inline ref (e.g. a
279
+ // dropped placeholder from a generic-class method) — fall back to
280
+ // `google.protobuf.Struct` so proto-loader doesn't reject the
281
+ // output.
282
+ const isComponentsRef = schema.$ref.startsWith("#/components/schemas/");
283
+ const rawRefName = schema.$ref.replace("#/definitions/", "").replace("#/components/schemas/", "");
284
+ let decodedRefName: string;
285
+ try {
286
+ decodedRefName = decodeURIComponent(rawRefName);
287
+ } catch {
288
+ decodedRefName = rawRefName;
289
+ }
290
+ const msgName = schemaToMessageName(rawRefName);
291
+ const targetSchema = allSchemas[rawRefName] || allSchemas[decodedRefName];
292
+ if (!messages.has(msgName) && targetSchema) {
293
+ messages.set(msgName, ""); // placeholder to break recursion cycles
294
+ messages.set(msgName, jsonSchemaToMessage(msgName, targetSchema, allSchemas, messages));
295
+ }
296
+ if (!isComponentsRef && !targetSchema && !messages.has(msgName)) {
297
+ return "google.protobuf.Struct";
298
+ }
299
+ return msgName;
300
+ }
301
+
302
+ switch (schema.type) {
303
+ case "string":
304
+ if (schema.format === "date-time" || schema.format === "date") return "string"; // Timestamps as ISO strings
305
+ if (schema.format === "int64") return "int64";
306
+ return "string";
307
+ case "number":
308
+ if (schema.format === "float") return "float";
309
+ if (schema.format === "int32" || schema.format === "integer") return "int32";
310
+ if (schema.format === "int64") return "int64";
311
+ return "double";
312
+ case "integer":
313
+ if (schema.format === "int64") return "int64";
314
+ return "int32";
315
+ case "boolean":
316
+ return "bool";
317
+ case "object":
318
+ if (schema.properties) {
319
+ // Nested message
320
+ const nestedName = `${parentName}${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}`;
321
+ if (!messages.has(nestedName)) {
322
+ messages.set(nestedName, jsonSchemaToMessage(nestedName, schema, allSchemas, messages));
323
+ }
324
+ return nestedName;
325
+ }
326
+ return "google.protobuf.Struct";
327
+ case "array":
328
+ // Handled by caller
329
+ return jsonTypeToProto(fieldName, (schema.items as JSONSchema7) || {}, parentName, allSchemas, messages);
330
+ default:
331
+ return "google.protobuf.Struct";
332
+ }
333
+
334
+ // Enum support
335
+ if (schema.enum) {
336
+ return "string"; // Map enums to strings for simplicity
337
+ }
338
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "nodenext",
5
+ "outDir": "./lib",
6
+ "rootDir": "./src",
7
+ "strict": false,
8
+ "declaration": true,
9
+ "sourceMap": true,
10
+ "isolatedModules": true,
11
+ "experimentalDecorators": false,
12
+ "esModuleInterop": true,
13
+ "moduleResolution": "nodenext",
14
+ "skipLibCheck": true,
15
+ "declarationMap": true,
16
+ "useDefineForClassFields": true,
17
+ "strictPropertyInitialization": false,
18
+ "lib": [
19
+ "ES2022",
20
+ "ESNext.Decorators"
21
+ ],
22
+ "types": [
23
+ "node"
24
+ ]
25
+ },
26
+ "include": [
27
+ "src/**/*"
28
+ ],
29
+ "exclude": [
30
+ "**/node_modules",
31
+ "src/**/*.spec.ts"
32
+ ]
33
+ }
@@ -0,0 +1,20 @@
1
+ /// <reference types="vitest" />
2
+
3
+ import { defineConfig } from "vite";
4
+
5
+ export default defineConfig({
6
+ clearScreen: false,
7
+ test: {
8
+ allowOnly: true,
9
+ testTimeout: 20000,
10
+ coverage: {
11
+ enabled: true,
12
+ provider: "v8",
13
+ include: ["src/**/*.ts"],
14
+ exclude: ["src/**/*.spec.ts", "src/index.ts"],
15
+ reporter: ["lcov", "html", "text"]
16
+ },
17
+ reporters: "verbose",
18
+ include: ["src/**/*.spec.ts"]
19
+ }
20
+ });
@@ -0,0 +1,111 @@
1
+ {
2
+ "$schema": "https://webda.io/schemas/webda.module.v4.json",
3
+ "beans": {},
4
+ "deployers": {},
5
+ "moddas": {
6
+ "Webda/GrpcService": {
7
+ "Import": "lib/grpcservice.service:GrpcService",
8
+ "Schema": {
9
+ "$schema": "http://json-schema.org/draft-07/schema#",
10
+ "additionalProperties": false,
11
+ "description": "Parameters for the gRPC service",
12
+ "properties": {
13
+ "operations": {
14
+ "default": [
15
+ "*"
16
+ ],
17
+ "description": "List of operations to include/exclude\nSupports wildcards and negation: [\"*\", \"!User.Delete\"]",
18
+ "items": {
19
+ "type": "string"
20
+ },
21
+ "type": "array"
22
+ },
23
+ "packageName": {
24
+ "default": "webda",
25
+ "description": "Protobuf package name",
26
+ "type": "string"
27
+ },
28
+ "protoFile": {
29
+ "default": ".webda/app.proto",
30
+ "description": "Path to the generated .proto file",
31
+ "type": "string"
32
+ },
33
+ "type": {
34
+ "description": "Type of the service",
35
+ "type": "string"
36
+ },
37
+ "openapi": {
38
+ "type": "object",
39
+ "additionalProperties": true
40
+ }
41
+ },
42
+ "required": [
43
+ "type"
44
+ ],
45
+ "type": "object",
46
+ "title": "GrpcService"
47
+ },
48
+ "Configuration": "lib/grpcservice.service:GrpcServiceParameters",
49
+ "commands": {
50
+ "build": {
51
+ "description": "Generate protobuf definition from operations",
52
+ "method": "build",
53
+ "args": {},
54
+ "requires": [
55
+ "rest-domain"
56
+ ],
57
+ "phase": "resolved"
58
+ }
59
+ }
60
+ }
61
+ },
62
+ "models": {},
63
+ "schemas": {
64
+ "Webda/BinaryFile": {
65
+ "$schema": "http://json-schema.org/draft-07/schema#",
66
+ "WebdaSchema": true,
67
+ "additionalProperties": false,
68
+ "description": "Represent a file to store",
69
+ "properties": {
70
+ "challenge": {
71
+ "description": "Will be computed by the service\n\nhash of the content prefixed by 'WEBDA'",
72
+ "type": "string"
73
+ },
74
+ "hash": {
75
+ "description": "Will be computed by the service\n\nhash of the content",
76
+ "type": "string"
77
+ },
78
+ "metadata": {
79
+ "description": "Metadatas stored along with the binary"
80
+ },
81
+ "mimetype": {
82
+ "description": "Mimetype of the binary",
83
+ "type": "string"
84
+ },
85
+ "name": {
86
+ "description": "Current name",
87
+ "type": "string"
88
+ },
89
+ "originalname": {
90
+ "description": "Original name",
91
+ "type": "string"
92
+ },
93
+ "size": {
94
+ "description": "Size of the binary",
95
+ "type": "number"
96
+ }
97
+ },
98
+ "required": [
99
+ "mimetype",
100
+ "name",
101
+ "size"
102
+ ],
103
+ "type": "object",
104
+ "title": "BinaryFile"
105
+ }
106
+ },
107
+ "behaviors": {},
108
+ "capabilities": {
109
+ "grpc": "Webda/GrpcService"
110
+ }
111
+ }