Rework proto parsing to use javascript objects, add nested and object message support

Objects are preferred to classes as they're easier to clone and serialise/deserialise
This commit is contained in:
2024-06-26 21:51:43 +09:30
parent d484f75540
commit da8861cde2
3 changed files with 191 additions and 62 deletions

View File

@@ -1,27 +1,80 @@
export class StringMessage implements MessageConfiguration { export enum MessageTypeEnum {
constructor(public maxLength?: number) {} String = 'string',
Boolean = 'boolean',
Numeric = 'numeric',
List = 'list',
Map = 'map',
Object = 'object',
Raw = 'raw',
} }
export class BooleanMessage implements MessageConfiguration {} export interface StringMessage extends MessageConfiguration {
maxLength?: number;
export class NumericMessage implements MessageConfiguration {
constructor(public min?: number, public max?: number) {}
} }
export class ListMessage implements MessageConfiguration { export interface BooleanMessage extends MessageConfiguration {}
constructor(readonly subConfiguration: MessageConfiguration) {}
export interface NumericMessage extends MessageConfiguration {
min?: number;
max?: number;
} }
export class MapMessage implements MessageConfiguration { export interface ListMessage extends MessageConfiguration {
constructor( subConfiguration: MessageConfiguration;
readonly keyConfiguration: MessageConfiguration,
readonly valueConfiguration: MessageConfiguration
) {}
} }
export class ObjectMessage implements MessageConfiguration {} export interface MapMessage extends MessageConfiguration {
keyConfiguration: MessageConfiguration;
valueConfiguration: MessageConfiguration;
}
export class MessageConfiguration {} export interface ObjectMessage extends MessageConfiguration {
messageDefinition: ProtoMessage;
}
export interface RawMessage extends MessageConfiguration {}
export interface MessageConfiguration {
type: MessageTypeEnum;
}
export const StringMessage = (maxLength?: number): StringMessage => ({
type: MessageTypeEnum.String,
maxLength,
});
export const BooleanMessage = (): BooleanMessage => ({
type: MessageTypeEnum.Boolean,
});
export const NumericMessage = (min?: number, max?: number): NumericMessage => ({
type: MessageTypeEnum.Numeric,
min,
max,
});
export const ListMessage = (
subConfiguration: MessageConfiguration
): ListMessage => ({
type: MessageTypeEnum.List,
subConfiguration,
});
export const MapMessage = (
keyConfiguration: MessageConfiguration,
valueConfiguration: MessageConfiguration
): MapMessage => ({
type: MessageTypeEnum.Map,
keyConfiguration,
valueConfiguration,
});
export const ObjectMessage = (messageDefinition: ProtoMessage) => ({
type: MessageTypeEnum.Object,
messageDefinition,
});
export const RawMessage = (): RawMessage => ({ type: MessageTypeEnum.Raw });
export interface ProtoMessageField { export interface ProtoMessageField {
name: string; name: string;
@@ -33,3 +86,8 @@ export interface ProtoMessage {
name: string; name: string;
values: ProtoMessageField[]; values: ProtoMessageField[];
} }
export const UnknownProto = (name: string): ProtoMessage => ({
name,
values: [{ name: 'Raw JSON', configuration: RawMessage(), value: '' }],
});

View File

@@ -1,17 +1,12 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
import { import {
Type,
provideExperimentalZonelessChangeDetection,
} from '@angular/core';
import {
BooleanMessage,
ListMessage, ListMessage,
MapMessage, MapMessage,
MessageConfiguration, MessageTypeEnum,
NumericMessage, ObjectMessage,
ProtoMessage, ProtoMessage,
StringMessage,
} from './model/proto-message.model'; } from './model/proto-message.model';
import { ProtoDefinitionService } from './proto-definition.service'; import { ProtoDefinitionService } from './proto-definition.service';
@@ -19,15 +14,25 @@ let testProto = `
syntax="proto3"; syntax="proto3";
message Test { message Test {
string hello = 1; message NestedMessage {
int32 hello2 = 2; string nested = 1;
int64 hello3 = 3; }
float hello4 = 4; string hello = 1;
double hello5 = 5; int32 hello2 = 2;
bool hello6 = 6; int64 hello3 = 3;
repeated string hello7 = 7; float hello4 = 4;
map<string, string> hello8 = 8; double hello5 = 5;
bool hello6 = 6;
repeated string hello7 = 7;
map<string, string> hello8 = 8;
ReferenceMessage hello9 = 9;
NestedMessage hello10 = 10;
} }
message ReferenceMessage {
string test = 1;
}
`; `;
describe('TestService', () => { describe('TestService', () => {
@@ -42,34 +47,54 @@ describe('TestService', () => {
it('should convert parse protobuf correctly', async () => { it('should convert parse protobuf correctly', async () => {
const testMessages = await service.parseProtoDefinition(testProto); const testMessages = await service.parseProtoDefinition(testProto);
const converted = testMessages[0]; const converted = testMessages[1];
expect(converted.name).toBe('Test'); expect(converted.name).toBe('Test');
expect(converted.values.length).toBe(8); expect(converted.values.length).toBe(10);
checkNameAndType(converted, 'hello', StringMessage); checkNameAndType(converted, 'hello', MessageTypeEnum.String);
checkNameAndType(converted, 'hello2', NumericMessage); checkNameAndType(converted, 'hello2', MessageTypeEnum.Numeric);
checkNameAndType(converted, 'hello3', NumericMessage); checkNameAndType(converted, 'hello3', MessageTypeEnum.Numeric);
checkNameAndType(converted, 'hello4', NumericMessage); checkNameAndType(converted, 'hello4', MessageTypeEnum.Numeric);
checkNameAndType(converted, 'hello5', NumericMessage); checkNameAndType(converted, 'hello5', MessageTypeEnum.Numeric);
checkNameAndType(converted, 'hello6', BooleanMessage); checkNameAndType(converted, 'hello6', MessageTypeEnum.Boolean);
checkNameAndType(converted, 'hello7', ListMessage); checkNameAndType(converted, 'hello7', MessageTypeEnum.List);
checkNameAndType(converted, 'hello8', MapMessage); checkNameAndType(converted, 'hello8', MessageTypeEnum.Map);
checkNameAndType(converted, 'hello9', MessageTypeEnum.Object);
checkNameAndType(converted, 'hello10', MessageTypeEnum.Object);
const listMessage = converted.values[6].configuration as ListMessage; const listMessage = converted.values[6].configuration as ListMessage;
expect(listMessage.subConfiguration).toBeInstanceOf(StringMessage); expect(listMessage.subConfiguration.type).toBe(MessageTypeEnum.String);
const mapMessage = converted.values[7].configuration as MapMessage; const mapMessage = converted.values[7].configuration as MapMessage;
expect(mapMessage.keyConfiguration).toBeInstanceOf(StringMessage); expect(mapMessage.keyConfiguration.type).toBe(MessageTypeEnum.String);
expect(mapMessage.valueConfiguration).toBeInstanceOf(StringMessage); expect(mapMessage.valueConfiguration.type).toBe(MessageTypeEnum.String);
const referenceMessage = converted.values[8].configuration as ObjectMessage;
expect(referenceMessage.messageDefinition.values.length).toBe(1);
expect(referenceMessage.messageDefinition.name).toBe('ReferenceMessage');
const nestedReferenceMessage = referenceMessage.messageDefinition.values[0];
expect(nestedReferenceMessage.configuration.type).toBe(
MessageTypeEnum.String
);
expect(nestedReferenceMessage.name).toBe('test');
const nestedMessage = converted.values[9].configuration as ObjectMessage;
expect(nestedMessage.messageDefinition.values.length).toBe(1);
expect(nestedMessage.messageDefinition.name).toBe('NestedMessage');
const nestedNestedMessage = nestedMessage.messageDefinition.values[0];
expect(nestedNestedMessage.configuration.type).toBe(MessageTypeEnum.String);
expect(nestedNestedMessage.name).toBe('nested');
// TODO: Enum type
}); });
}); });
const checkNameAndType = ( const checkNameAndType = (
converted: ProtoMessage, converted: ProtoMessage,
name: string, name: string,
type: Type<MessageConfiguration> type: MessageTypeEnum
) => { ) => {
const field = converted.values.find((value) => value.name === name); const field = converted.values.find((value) => value.name === name);
expect(field?.configuration).toBeInstanceOf(type); expect(field?.configuration.type).toBe(type);
}; };

View File

@@ -5,9 +5,13 @@ import {
ListMessage, ListMessage,
MapMessage, MapMessage,
MessageConfiguration, MessageConfiguration,
MessageTypeEnum,
NumericMessage, NumericMessage,
ObjectMessage,
ProtoMessage, ProtoMessage,
RawMessage,
StringMessage, StringMessage,
UnknownProto,
} from './model/proto-message.model'; } from './model/proto-message.model';
@Injectable({ @Injectable({
@@ -18,16 +22,19 @@ export class ProtoDefinitionService {
async parseProtoDefinition(protoContents: string): Promise<ProtoMessage[]> { async parseProtoDefinition(protoContents: string): Promise<ProtoMessage[]> {
const definition = await parse(protoContents); const definition = await parse(protoContents);
const messages = definition.root.nested; const messages = definition.root;
if (messages) { if (messages) {
const objectDefinitions = Object.values(messages); const messageObjects: ProtoMessage[] =
const messageObjects = []; this.parseAllNestedMessages(messages);
for (const objectDefinition of objectDefinitions) { // Now do another pass, where we populate each of the object fields based on their definitions... need a way to detect recursion? Maybe only go up to 5 layers deep?
// TODO: Better way to check for message type (i.e. not enum)? for (const messageObject of messageObjects) {
if ('fieldsArray' in objectDefinition) { for (const value of messageObject.values) {
messageObjects.push( if (value.configuration.type === MessageTypeEnum.Object) {
this.convertProtoDefinitionToModel(objectDefinition) this.populateNestedObject(
); value.configuration as ObjectMessage,
messageObjects
);
}
} }
} }
return messageObjects; return messageObjects;
@@ -35,6 +42,46 @@ export class ProtoDefinitionService {
return []; return [];
} }
private parseAllNestedMessages(obj: ReflectionObject) {
const messageObjects: ProtoMessage[] = [];
if ('nestedArray' in obj && obj.nestedArray) {
const nestedArray = obj.nestedArray as ReflectionObject[];
for (const nestedObj of nestedArray) {
messageObjects.push(...this.parseAllNestedMessages(nestedObj));
}
}
if ('fieldsArray' in obj) {
messageObjects.push(this.convertProtoDefinitionToModel(obj));
}
return messageObjects;
}
private populateNestedObject(
objectMessage: ObjectMessage,
availableMessages: ProtoMessage[]
) {
for (const message of availableMessages) {
if (message.name === objectMessage.messageDefinition.name) {
objectMessage.messageDefinition = {
name: message.name,
values: message.values.map((value) => {
if (value.configuration.type === MessageTypeEnum.Object) {
this.populateNestedObject(
value.configuration as ObjectMessage,
availableMessages
);
}
return structuredClone(value);
}),
};
return;
}
}
objectMessage.messageDefinition = UnknownProto(
objectMessage.messageDefinition.name
);
}
private convertProtoDefinitionToModel( private convertProtoDefinitionToModel(
messageDefinition?: ReflectionObject messageDefinition?: ReflectionObject
): ProtoMessage { ): ProtoMessage {
@@ -44,20 +91,19 @@ export class ProtoDefinitionService {
values: [], values: [],
}; };
const fields = messageDefinition.fieldsArray as FieldBase[]; const fields = messageDefinition.fieldsArray as FieldBase[];
// TODO: Needs to be a visitor/tree search/recursive since messages/enums may be nested
for (const field of fields) { for (const field of fields) {
let type: MessageConfiguration | undefined; let type: MessageConfiguration | undefined;
if ('rule' in field && field.rule === 'repeated') { if ('rule' in field && field.rule === 'repeated') {
const subType = this.getTypeFromField(field.type); const subType = this.getTypeFromField(field.type);
if (subType) { if (subType) {
type = new ListMessage(subType); type = ListMessage(subType);
} }
} else if (field instanceof MapField) { } else if (field instanceof MapField) {
// Map // Map
const keyConfiguration = this.getTypeFromField(field.keyType); const keyConfiguration = this.getTypeFromField(field.keyType);
const valueConfiguration = this.getTypeFromField(field.type); const valueConfiguration = this.getTypeFromField(field.type);
if (keyConfiguration && valueConfiguration) { if (keyConfiguration && valueConfiguration) {
type = new MapMessage(keyConfiguration, valueConfiguration); type = MapMessage(keyConfiguration, valueConfiguration);
} }
} else { } else {
type = this.getTypeFromField(field.type); type = this.getTypeFromField(field.type);
@@ -82,19 +128,19 @@ export class ProtoDefinitionService {
): MessageConfiguration | undefined { ): MessageConfiguration | undefined {
switch (fieldType) { switch (fieldType) {
case 'string': case 'string':
return new StringMessage(); return StringMessage();
break; break;
case 'int32': case 'int32':
case 'int64': case 'int64':
case 'float': case 'float':
case 'double': case 'double':
return new NumericMessage(); return NumericMessage();
break; break;
case 'bool': case 'bool':
return new BooleanMessage(); return BooleanMessage();
break; break;
// TODO: bytes as well, though that's pretty useless (can't really represent/edit it?) // TODO: bytes as well, though that's pretty useless (can't really represent/edit it?)
} }
return undefined; return ObjectMessage({ name: fieldType, values: [] });
} }
} }