Skip to content

Commit 2e36001

Browse files
committed
chore: #133 - protobuf schema generation
1 parent 534b66e commit 2e36001

6 files changed

Lines changed: 254 additions & 5 deletions

File tree

objectified-ui/src/app/components/Dashboard.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import MenuButton from "@/app/components/common/MenuButton";
3333
import {putUser} from "@/app/services/user";
3434
import {errorDialog} from "@/app/components/common/ConfirmDialog";
3535

36-
const VERSION = '0.1.9';
36+
const VERSION = '0.1.10';
3737

3838
const NavItems = [
3939
{
@@ -101,8 +101,6 @@ const Dashboard = ({ children }: { children?: React.ReactNode }) => {
101101
return (<></>);
102102
}
103103

104-
console.log('Session', session);
105-
106104
const selectedColor = (path: string) => (currentPath.startsWith(path) ? 'bg-blue-300' : '');
107105
const handleTenantChanged = async (tenantId: string) => {
108106
let currentData = (session as any).objectified;
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/**
2+
* Converts a JSON Schema definition to a Protocol Buffers definition.
3+
* @param jsonSchema - The JSON Schema definition to convert
4+
* @returns String containing the Protocol Buffers definition
5+
*/
6+
function jsonSchemaToProtobuf(jsonSchema: any): string {
7+
// Track message definitions to avoid duplicates
8+
const messages: Map<string, string> = new Map();
9+
// Track enums definitions to avoid duplicates
10+
const enums: Map<string, string> = new Map();
11+
// Keep track of the current indentation level
12+
let indent = 0;
13+
14+
/**
15+
* Helper function to indent lines
16+
*/
17+
function getIndent(): string {
18+
return ' '.repeat(indent);
19+
}
20+
21+
/**
22+
* Converts a JSON Schema type to a Protobuf type
23+
*/
24+
function mapType(schema: any): string {
25+
if (!schema || !schema.type) {
26+
return 'google.protobuf.Any';
27+
}
28+
29+
switch (schema.type) {
30+
case 'string':
31+
if (schema.format === 'date-time') {
32+
return 'google.protobuf.Timestamp';
33+
} else if (schema.enum) {
34+
// Enums will be handled separately
35+
return schema.title || 'Enum';
36+
}
37+
return 'string';
38+
case 'integer':
39+
return 'int32';
40+
case 'number':
41+
return 'double';
42+
case 'boolean':
43+
return 'bool';
44+
case 'null':
45+
return 'google.protobuf.NullValue';
46+
case 'array':
47+
if (schema.items) {
48+
const itemType = mapType(schema.items);
49+
return `repeated ${itemType}`;
50+
}
51+
return 'repeated google.protobuf.Any';
52+
case 'object':
53+
if (schema.title) {
54+
// Create a new message type
55+
return schema.title;
56+
}
57+
return 'google.protobuf.Struct';
58+
default:
59+
return 'google.protobuf.Any';
60+
}
61+
}
62+
63+
/**
64+
* Converts a JSON Schema property name to a valid Protobuf field name
65+
*/
66+
function toFieldName(name: string): string {
67+
// Replace invalid characters and convert to snake_case
68+
return name
69+
.replace(/[^\w]/g, '_')
70+
.replace(/([A-Z])/g, '_$1')
71+
.toLowerCase()
72+
.replace(/^_/, '');
73+
}
74+
75+
/**
76+
* Process JSON Schema object and generate a Protobuf message
77+
*/
78+
function processObject(schema: any, name: string = 'Root'): string {
79+
const messageName = name;
80+
81+
// Skip if we've already processed this message
82+
if (messages.has(messageName)) {
83+
return messageName;
84+
}
85+
86+
let fields: string[] = [];
87+
let fieldNumber = 1;
88+
89+
indent++;
90+
91+
// Process properties
92+
if (schema.properties) {
93+
for (const [propName, propSchema] of Object.entries<any>(schema.properties)) {
94+
const fieldName = toFieldName(propName);
95+
let fieldType = '';
96+
97+
// Handle nested objects
98+
if (propSchema.type === 'object' && propSchema.properties) {
99+
const nestedMessageName = processObject(
100+
propSchema,
101+
propSchema.title || `${messageName}${propName}`
102+
);
103+
fieldType = nestedMessageName;
104+
}
105+
// Handle enums
106+
else if (propSchema.type === 'string' && propSchema.enum) {
107+
const enumName = (propSchema.title || `${messageName}${propName}Enum`);
108+
processEnum(propSchema.enum, enumName);
109+
fieldType = enumName;
110+
}
111+
// Handle arrays with object items
112+
else if (propSchema.type === 'array' && propSchema.items && propSchema.items.type === 'object') {
113+
const nestedMessageName = processObject(
114+
propSchema.items,
115+
propSchema.items.title || `${messageName}${propName}Item`
116+
);
117+
fieldType = `repeated ${nestedMessageName}`;
118+
}
119+
// Handle regular types
120+
else {
121+
fieldType = mapType(propSchema);
122+
}
123+
124+
// Check if property is required
125+
const isRequired = schema.required && schema.required.includes(propName);
126+
127+
// Add field definition
128+
fields.push(`${getIndent()}${fieldType} ${fieldName} = ${fieldNumber};${isRequired ? '' : ' // optional'}`);
129+
fieldNumber++;
130+
}
131+
}
132+
133+
indent--;
134+
135+
const messageDefinition = `message ${messageName} {\n${fields.join('\n')}\n${getIndent()}}`;
136+
messages.set(messageName, messageDefinition);
137+
138+
return messageName;
139+
}
140+
141+
/**
142+
* Process JSON Schema enum and generate a Protobuf enum
143+
*/
144+
function processEnum(enumValues: string[], enumName: string): string {
145+
// Skip if we've already processed this enum
146+
if (enums.has(enumName)) {
147+
return enumName;
148+
}
149+
150+
let items: string[] = [];
151+
indent++;
152+
153+
// Add the UNSPECIFIED value as first item (protobuf best practice)
154+
items.push(`${getIndent()}${enumName.toUpperCase()}_UNSPECIFIED = 0;`);
155+
156+
// Process enum values
157+
enumValues.forEach((value, index) => {
158+
const valueName = value
159+
.toString()
160+
.replace(/[^\w]/g, '_')
161+
.toUpperCase();
162+
items.push(`${getIndent()}${valueName} = ${index + 1};`);
163+
});
164+
165+
indent--;
166+
167+
const enumDefinition = `enum ${enumName} {\n${items.join('\n')}\n${getIndent()}}`;
168+
enums.set(enumName, enumDefinition);
169+
170+
return enumName;
171+
}
172+
173+
// Start with the syntax declaration and imports
174+
let result = 'syntax = "proto3";\n\n';
175+
result += 'import "google/protobuf/any.proto";\n';
176+
result += 'import "google/protobuf/struct.proto";\n';
177+
result += 'import "google/protobuf/timestamp.proto";\n\n';
178+
179+
// Add package declaration (optional)
180+
if (jsonSchema.title) {
181+
result += `package ${jsonSchema.title.toLowerCase().replace(/[^\w]/g, '.')};\n\n`;
182+
} else {
183+
result += 'package schema;\n\n';
184+
}
185+
186+
// Generate the main message
187+
processObject(jsonSchema, jsonSchema.title);
188+
189+
// Add all enum definitions
190+
if (enums.size > 0) {
191+
result += '// Enum definitions\n';
192+
for (const enumDef of enums.values()) {
193+
result += enumDef + '\n\n';
194+
}
195+
}
196+
197+
// Add all message definitions
198+
if (messages.size > 0) {
199+
result += '// Message definitions\n';
200+
for (const messageDef of messages.values()) {
201+
result += messageDef + '\n\n';
202+
}
203+
}
204+
205+
return result.trim();
206+
}
207+
208+
export default jsonSchemaToProtobuf;

objectified-ui/src/app/components/class-properties/JsonSchemaTools.tsx renamed to objectified-ui/src/app/components/class-properties/JsonSchemaToSql.tsx

File renamed without changes.

objectified-ui/src/app/components/class-properties/SchemaDialog.tsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ import {errorDialog} from "@/app/components/common/ConfirmDialog";
2525
import DriveFileMoveRtlOutlinedIcon from '@mui/icons-material/DriveFileMoveRtlOutlined';
2626
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
2727
import * as yaml from 'yaml';
28-
import {jsonSchemaToSQL} from "@/app/components/class-properties/JsonSchemaTools";
28+
import {jsonSchemaToSQL} from "@/app/components/class-properties/JsonSchemaToSql";
29+
import jsonSchemaToProtobuf from "@/app/components/class-properties/JsonSchemaToProtobuf";
2930

3031
export interface ISchemaDialog {
3132
schemaOpen: boolean;
@@ -132,6 +133,7 @@ export const SchemaDialog = (props: ISchemaDialog) => {
132133
<ToggleButton value="json">JSON</ToggleButton>
133134
<ToggleButton value="yaml">YAML</ToggleButton>
134135
<ToggleButton value="sql">SQL</ToggleButton>
136+
<ToggleButton value="proto">Protobuf</ToggleButton>
135137
</ToggleButtonGroup>
136138
&nbsp;
137139
</div>
@@ -265,6 +267,46 @@ export const SchemaDialog = (props: ISchemaDialog) => {
265267
</div>
266268
</>
267269
)}
270+
271+
{schema && schemaFormat === 'proto' && (
272+
<>
273+
<div style={{ width: '100%', height: '100%' }}>
274+
<div style={{ width: '100%', textAlign: 'right', paddingBottom: '10px' }}>
275+
<Button style={{
276+
height: '24px', borderRadius: 2,
277+
color: 'black', border: '1px solid #ccc', paddingLeft: '6px', paddingRight: '6px' }}
278+
className={'bg-slate-200'}
279+
variant={'contained'} startIcon={<ContentPasteIcon/>}
280+
onClick={() => navigator.clipboard.writeText(jsonSchemaToProtobuf(JSON.parse(schema)))}>
281+
<Typography className={'font-thin text-xs'} textTransform={'none'}>
282+
Copy to Clipboard
283+
</Typography>
284+
</Button>
285+
&nbsp;
286+
<Button style={{
287+
height: '24px', borderRadius: 2,
288+
color: 'black', border: '1px solid #ccc', paddingLeft: '6px', paddingRight: '6px' }}
289+
className={'bg-slate-200'}
290+
variant={'contained'} startIcon={<DriveFileMoveRtlOutlinedIcon/>}
291+
onClick={() => downloadPayload('.proto', 'text/plain', jsonSchemaToProtobuf(JSON.parse(schema)))}>
292+
<Typography className={'font-thin text-xs'} textTransform={'none'}>
293+
Export
294+
</Typography>
295+
</Button>
296+
</div>
297+
298+
<TextField label={''}
299+
fullWidth
300+
multiline
301+
value={jsonSchemaToProtobuf(JSON.parse(schema))}
302+
name={'yaml-text'}
303+
key={`schema-form-yaml`}
304+
inputProps={{ style: { fontFamily: 'monospace' }, readOnly: true}}
305+
sx={{ width: '100%', height: '100%' }}>
306+
</TextField>
307+
</div>
308+
</>
309+
)}
268310
</DialogContentText>
269311
</DialogContent>
270312
<DialogActions>

objectified-ui/src/app/components/common/AutoForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ export const AutoForm = (props: IAutoForm) => {
394394
<DialogTitle key={'auto-form-dialog-title'}>
395395
<Stack direction={'row'} key={'auto-form-header'}>
396396
<div style={{ width: '70%' }} key={'auto-form-header-1'}>
397-
{props.header} {props.editPayload && props.editPayload.id ? `(id: ${props.editPayload.id})` : ''}
397+
{props.header} {props.editPayload && props.editPayload.id ? `(id: ${props.editPayload.id.substring(0, 8)}...)` : ''}
398398
</div>
399399
<div style={{ width: '30%', textAlign: 'right' }} key={'auto-form-header-2'}>
400400
<IconButton onClick={() => props.onCancel()}>

openapi/openapi.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,6 +1041,7 @@ components:
10411041
description: Date and time of last login
10421042
UserPassword:
10431043
type: object
1044+
x-no-dao: true
10441045
require:
10451046
- currentPassword
10461047
- password1

0 commit comments

Comments
 (0)