Rework definition import to support multiple files and imports, nested messages, add configuration export/import

This commit is contained in:
2024-07-13 22:01:41 +09:30
parent 72c24a70ae
commit cf0a96591c
7 changed files with 330 additions and 82 deletions

View File

@@ -10,8 +10,10 @@
} } } }
</div> </div>
@if(showRaw()) {
<h2>Preview</h2> <h2>Preview</h2>
<div class="actions"> <div class="actions">
<button mat-flat-button (click)="copyToClipboard()">Copy</button> <button mat-flat-button (click)="copyToClipboard()">Copy</button>
</div> </div>
<pre *ngIf="showRaw()"><code #code></code></pre> <pre><code #code></code></pre>
}

View File

@@ -11,7 +11,7 @@ div {
} }
div { div {
padding: 10px; padding: 16px;
} }
.actions { .actions {

View File

@@ -93,9 +93,12 @@ export interface ProtoMessageField<T extends MessageConfiguration> {
export interface ProtoBase { export interface ProtoBase {
name: string; name: string;
fullName?: string;
packageName?: string;
} }
export interface ProtoMessage extends ProtoBase { export interface ProtoMessage extends ProtoBase {
fileName?: string;
values: ProtoMessageField<any>[]; values: ProtoMessageField<any>[];
} }
@@ -103,7 +106,11 @@ export interface ProtoEnum extends ProtoBase {
options: string[]; options: string[];
} }
export const UnknownProto = (name: string): ProtoMessage => ({ export const UnknownProto = (
name: string,
fullName?: string
): ProtoMessage => ({
name, name,
fullName,
values: [{ name: 'Raw JSON', configuration: RawMessage() }], values: [{ name: 'Raw JSON', configuration: RawMessage() }],
}); });

View File

@@ -14,3 +14,6 @@
input { input {
display: none; display: none;
} }
a {
display: none;
}

View File

@@ -5,6 +5,7 @@ import {
ElementRef, ElementRef,
HostBinding, HostBinding,
HostListener, HostListener,
computed,
output, output,
signal, signal,
viewChild, viewChild,
@@ -13,25 +14,37 @@ import { MatButtonModule } from '@angular/material/button';
import { MatListModule } from '@angular/material/list'; import { MatListModule } from '@angular/material/list';
import { ProtoMessage } from '../model/proto-message.model'; import { ProtoMessage } from '../model/proto-message.model';
import { ProtoDefinitionService } from './proto-definition.service'; import { ProtoDefinitionService } from './proto-definition.service';
import { MatTreeModule } from '@angular/material/tree';
import { MatSnackBar } from '@angular/material/snack-bar';
@Component({ @Component({
selector: 'app-proto-definition-selector', selector: 'app-proto-definition-selector',
standalone: true, standalone: true,
imports: [CommonModule, MatButtonModule, MatListModule], imports: [CommonModule, MatButtonModule, MatListModule, MatTreeModule],
template: ` template: `
<h2>Protobuf Definitions</h2> <h2>Protobuf Definitions</h2>
<button mat-button (click)="protoSelector.click()"> <button mat-button (click)="protoSelector.click()">
Select definitions Select definitions
</button> </button>
@if(currentFiles().length > 0) {
<button mat-button (click)="exporter.click()">Export Configuration</button>
}
<button mat-button (click)="configurationSelector.click()">
Import Configuration
</button>
<!-- TODO: Make this a selection list to indicate which file is selected, and allow deselecting a file -->
<mat-action-list> <mat-action-list>
@for (item of definitionFiles(); track $index) { @for (item of currentFiles(); track $index) {
<button mat-list-item (click)="selectProtoDefinition(item)"> <button mat-list-item (click)="selectProtoDefinition(item)">
{{ item.name }} {{ item }}
</button> </button>
} }
</mat-action-list> </mat-action-list>
@if(selectedProtoFile()) { @if(selectedDefinition().length > 0) {
<h3>Messages in {{ selectedProtoFile()?.name }}</h3> <h3>
@if(selectedProtoFile()) { Messages in {{ selectedProtoFile() }} } @else
{Showing all messages}
</h3>
<mat-action-list> <mat-action-list>
@for (item of selectedDefinition(); track $index) { @for (item of selectedDefinition(); track $index) {
<button mat-list-item (click)="messageSelected.emit(item)"> <button mat-list-item (click)="messageSelected.emit(item)">
@@ -47,49 +60,77 @@ import { ProtoDefinitionService } from './proto-definition.service';
(change)="addDefinitionFiles()" (change)="addDefinitionFiles()"
accept=".proto" accept=".proto"
/> />
<input
#configurationSelector
type="file"
(change)="importConfiguration()"
accept=".json"
/>
<a #exporter [href]="exportHref()" download="bufpiv.json"></a>
`, `,
styleUrl: './proto-definition-selector.component.scss', styleUrl: './proto-definition-selector.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ProtoDefinitionSelectorComponent { export class ProtoDefinitionSelectorComponent {
protoSelector = viewChild<ElementRef<HTMLInputElement>>('protoSelector');
messageSelected = output<ProtoMessage>(); messageSelected = output<ProtoMessage>();
protected definitionFiles = signal<File[]>([]); protected protoSelector =
protected selectedDefinition = signal<ProtoMessage[]>([]); viewChild<ElementRef<HTMLInputElement>>('protoSelector');
protected selectedProtoFile = signal<File | null>(null); protected configurationSelector = viewChild<ElementRef<HTMLInputElement>>(
protected isDragging = signal(false); 'configurationSelector'
);
private currentFiles: string[] = []; protected selectedDefinition = signal<ProtoMessage[]>([]);
protected selectedProtoFile = signal<string | null>(null);
protected isDragging = signal(false);
protected currentFiles = signal<string[]>([]);
private allDefinitionFiles: File[] = [];
private allProtoFiles = signal<ProtoMessage[]>([]);
protected exportHref = computed(() => {
const blob = new Blob([JSON.stringify(this.allProtoFiles())], {
type: 'application/json',
});
return URL.createObjectURL(blob);
});
@HostBinding('class.droppable') @HostBinding('class.droppable')
get droppable() { get droppable() {
return this.isDragging(); return this.isDragging();
} }
constructor(private protoDefinitionService: ProtoDefinitionService) {} constructor(
private protoDefinitionService: ProtoDefinitionService,
private snackBar: MatSnackBar
) {}
protected async addDefinitionFiles() { protected async addDefinitionFiles() {
const files = this.protoSelector()?.nativeElement.files; const files = this.protoSelector()?.nativeElement.files;
if (files) { if (files) {
const definitionFiles = this.definitionFiles(); const definitionFiles = this.allDefinitionFiles;
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
if (!this.currentFiles.includes(files[i].name)) { if (
!this.allDefinitionFiles.find((file) => file.name === files[i].name)
) {
definitionFiles.push(files[i]); definitionFiles.push(files[i]);
this.currentFiles.push(files[i].name); if (!this.currentFiles().includes(files[i].name)) {
this.currentFiles.update((currentFiles) => [
...currentFiles,
files[i].name,
]);
} }
} }
this.definitionFiles.set(definitionFiles);
} }
} this.allDefinitionFiles = definitionFiles;
protected async selectProtoDefinition(file: File) {
try { try {
const protoContents = await file.text();
const messageObjects = const messageObjects =
await this.protoDefinitionService.parseProtoDefinition(protoContents); await this.protoDefinitionService.parseAllDefinitions(
this.allDefinitionFiles
);
this.allProtoFiles.set(messageObjects);
this.selectedDefinition.set(messageObjects); this.selectedDefinition.set(messageObjects);
this.selectedProtoFile.set(file); this.selectedProtoFile.set(null);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert( alert(
@@ -97,6 +138,51 @@ export class ProtoDefinitionSelectorComponent {
); );
} }
} }
}
protected async selectProtoDefinition(file: string) {
this.selectedProtoFile.set(file);
this.selectedDefinition.set(
this.allProtoFiles().filter(
(protoMessage) => protoMessage.fileName === file
)
);
}
protected async importConfiguration() {
const configurationFile = this.configurationSelector()?.nativeElement.files;
try {
const fileContents = await configurationFile?.item(0)?.text();
if (!fileContents) {
this.snackBar.open('Failed to read file, please try again', 'Dismiss', {
duration: 5000,
});
return;
}
const parsed = JSON.parse(fileContents) as ProtoMessage[];
this.allProtoFiles.set(parsed);
this.selectedDefinition.set(parsed);
this.currentFiles.set([]);
for (const message of parsed) {
if (
message.fileName &&
!this.currentFiles().includes(message.fileName)
) {
this.currentFiles.update((currentFiles) => [
...currentFiles,
message.fileName!,
]);
}
}
} catch (err) {
console.error(err);
this.snackBar.open(
"Could not parse configuration file, please ensure it's valid, otherwise consider recreating the configuration",
'Dismiss',
{ duration: 5000 }
);
}
}
@HostListener('dragover', ['$event']) @HostListener('dragover', ['$event'])
onDrag(event: DragEvent) { onDrag(event: DragEvent) {

View File

@@ -14,6 +14,10 @@ import { ProtoDefinitionService } from './proto-definition.service';
let testProto = ` let testProto = `
syntax="proto3"; syntax="proto3";
package mytestpackage;
import "./myimport.proto";
message Test { message Test {
message NestedMessage { message NestedMessage {
string nested = 1; string nested = 1;
@@ -29,10 +33,12 @@ message Test {
ReferenceMessage hello9 = 9; ReferenceMessage hello9 = 9;
NestedMessage hello10 = 10; NestedMessage hello10 = 10;
EnumTest enum_test = 11; EnumTest enum_test = 11;
myimportedpackage.ImportedMessage imported_message = 12;
} }
message ReferenceMessage { message ReferenceMessage {
string test = 1; string test = 1;
Test.NestedMessage nested_message = 2;
} }
enum EnumTest { enum EnumTest {
@@ -51,13 +57,18 @@ describe('TestService', () => {
service = TestBed.inject(ProtoDefinitionService); service = TestBed.inject(ProtoDefinitionService);
}); });
it('should convert parse protobuf correctly', async () => { it('should convert parsed protobuf correctly', async () => {
const testMessages = await service.parseProtoDefinition(testProto); const testMessages = await service.parseAllDefinitions([
new File(
[new Blob([testProto], { type: 'text/plain' })],
'TestFile.proto'
),
]);
const converted = testMessages[1]; const converted = testMessages[1];
expect(converted.name).toBe('Test'); expect(converted.name).toBe('Test');
expect(converted.values.length).toBe(11); expect(converted.values.length).toBe(12);
checkNameAndType(converted, 'hello', MessageTypeEnum.String); checkNameAndType(converted, 'hello', MessageTypeEnum.String);
checkNameAndType(converted, 'hello2', MessageTypeEnum.Numeric); checkNameAndType(converted, 'hello2', MessageTypeEnum.Numeric);
checkNameAndType(converted, 'hello3', MessageTypeEnum.Numeric); checkNameAndType(converted, 'hello3', MessageTypeEnum.Numeric);
@@ -69,6 +80,7 @@ describe('TestService', () => {
checkNameAndType(converted, 'hello9', MessageTypeEnum.Object); checkNameAndType(converted, 'hello9', MessageTypeEnum.Object);
checkNameAndType(converted, 'hello10', MessageTypeEnum.Object); checkNameAndType(converted, 'hello10', MessageTypeEnum.Object);
checkNameAndType(converted, 'enumTest', MessageTypeEnum.Enum); checkNameAndType(converted, 'enumTest', MessageTypeEnum.Enum);
checkNameAndType(converted, 'importedMessage', MessageTypeEnum.Object);
const listMessage = converted.values[6].configuration as ListMessage; const listMessage = converted.values[6].configuration as ListMessage;
expect(listMessage.subConfiguration.type).toBe(MessageTypeEnum.String); expect(listMessage.subConfiguration.type).toBe(MessageTypeEnum.String);
@@ -78,13 +90,31 @@ describe('TestService', () => {
expect(mapMessage.valueConfiguration.type).toBe(MessageTypeEnum.String); expect(mapMessage.valueConfiguration.type).toBe(MessageTypeEnum.String);
const referenceMessage = converted.values[8].configuration as ObjectMessage; const referenceMessage = converted.values[8].configuration as ObjectMessage;
expect(referenceMessage.messageDefinition.values.length).toBe(1); expect(referenceMessage.messageDefinition.values.length).toBe(2);
expect(referenceMessage.messageDefinition.name).toBe('ReferenceMessage'); expect(referenceMessage.messageDefinition.name).toBe('ReferenceMessage');
const nestedReferenceMessage = referenceMessage.messageDefinition.values[0]; const nestedReferenceMessage = referenceMessage.messageDefinition.values[0];
expect(nestedReferenceMessage.configuration.type).toBe( expect(nestedReferenceMessage.configuration.type).toBe(
MessageTypeEnum.String MessageTypeEnum.String
); );
expect(nestedReferenceMessage.name).toBe('test'); expect(nestedReferenceMessage.name).toBe('test');
const parentNestedMessage = referenceMessage.messageDefinition.values[1];
expect(parentNestedMessage.configuration.type).toBe(MessageTypeEnum.Object);
expect(parentNestedMessage.name).toBe('nestedMessage');
const parentNestedMessageConfiguration =
parentNestedMessage.configuration as ObjectMessage;
expect(parentNestedMessageConfiguration.messageDefinition.name).toBe(
'Test.NestedMessage'
);
expect(
parentNestedMessageConfiguration.messageDefinition.values.length
).toBe(1);
expect(
parentNestedMessageConfiguration.messageDefinition.values[0].name
).toBe('nested');
expect(
parentNestedMessageConfiguration.messageDefinition.values[0].configuration
.type
).toBe(MessageTypeEnum.String);
const nestedMessage = converted.values[9].configuration as ObjectMessage; const nestedMessage = converted.values[9].configuration as ObjectMessage;
expect(nestedMessage.messageDefinition.values.length).toBe(1); expect(nestedMessage.messageDefinition.values.length).toBe(1);

View File

@@ -15,19 +15,69 @@ import {
StringMessage, StringMessage,
UnknownProto, UnknownProto,
} from '../model/proto-message.model'; } from '../model/proto-message.model';
import { readTextFile } from '@tauri-apps/api/fs';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class ProtoDefinitionService { export class ProtoDefinitionService {
constructor() {} async parseAllDefinitions(allProtoFiles: File[]): Promise<ProtoMessage[]> {
const messages: (ProtoBase | undefined)[] = [];
for (const protoFile of allProtoFiles) {
const definition = await parse(await protoFile.text());
// Try resolve imports, if they fail (due to invalid path or running app in browser)
// they'll be skipped
if (definition.imports) {
for (const importPath of definition.imports) {
try {
const fileName = importPath.substring(
importPath.lastIndexOf('/') + 1
);
allProtoFiles.push(
new File(
[
new Blob([await readTextFile(importPath)], {
type: 'text/plain',
}),
],
fileName
)
);
} catch (err) {
console.error(
`Failed to import file ${importPath}. Skipping...`,
err
);
}
}
}
const messageDefinitions = definition.root;
const parsedMessages = this.parseAllNestedMessages(
messageDefinitions,
protoFile.name,
definition.package
);
messages.push(...parsedMessages);
}
async parseProtoDefinition(protoContents: string): Promise<ProtoMessage[]> { if (messages.length === 0) {
const definition = await parse(protoContents); return [];
const messages = definition.root; }
if (messages) {
const messageObjects: ProtoBase[] = this.parseAllNestedMessages(messages); // Now check for duplicates in case we auto-imported files
const standardMessages = messageObjects for (let i = 0; i < messages.length - 1; i++) {
for (let j = i + 1; j < messages.length; j++) {
if (messages[i]?.fullName === messages[j]?.fullName) {
messages[j] = undefined;
}
}
}
const distinctMessages = messages.filter(
(message) => message
) as ProtoBase[];
// Then populate all object/enum messages for the final passes
const standardMessages = distinctMessages
.filter((messageObject) => 'values' in messageObject) .filter((messageObject) => 'values' in messageObject)
.map((messageObject) => messageObject as ProtoMessage); .map((messageObject) => messageObject as ProtoMessage);
for (const messageObject of standardMessages) { for (const messageObject of standardMessages) {
@@ -35,12 +85,13 @@ export class ProtoDefinitionService {
if (value.configuration.type === MessageTypeEnum.Object) { if (value.configuration.type === MessageTypeEnum.Object) {
this.populateNestedObject( this.populateNestedObject(
value.configuration as ObjectMessage, value.configuration as ObjectMessage,
messageObject.packageName!,
standardMessages standardMessages
); );
} }
} }
} }
const enumMessages = messageObjects const enumMessages = distinctMessages
.filter((messageObject) => 'options' in messageObject) .filter((messageObject) => 'options' in messageObject)
.map((messageObject) => messageObject as ProtoEnum); .map((messageObject) => messageObject as ProtoEnum);
for (const messageObject of standardMessages) { for (const messageObject of standardMessages) {
@@ -56,15 +107,36 @@ export class ProtoDefinitionService {
return standardMessages; return standardMessages;
} }
return [];
}
private populateEnumMessages( private populateEnumMessages(
objectMessage: ObjectMessage, objectMessage: ObjectMessage,
enumMessages: ProtoEnum[] enumMessages: ProtoEnum[]
): MessageConfiguration { ): MessageConfiguration {
let useFullName = false;
let objectMessageName = objectMessage.messageDefinition.name;
if (objectMessageName.includes('.')) {
// First do a check for current package.
for (const enumMessage of enumMessages) { for (const enumMessage of enumMessages) {
if (enumMessage.name === objectMessage.messageDefinition.name) { if (
enumMessage.packageName ===
objectMessage.messageDefinition.packageName &&
enumMessage.fullName ===
`${objectMessage.messageDefinition.packageName}.${objectMessageName}`
) {
return EnumMessage(enumMessage.options);
}
}
// If there's no match, then we'll be matching on full name, assuming a separate package
useFullName = true;
}
// None matching messages in this package found, check external package
for (const enumMessage of enumMessages) {
if (
(useFullName && enumMessage.fullName === objectMessageName) ||
(!useFullName && enumMessage.name === objectMessageName)
) {
return EnumMessage(enumMessage.options); return EnumMessage(enumMessage.options);
} }
} }
@@ -83,23 +155,34 @@ export class ProtoDefinitionService {
return objectMessage; return objectMessage;
} }
private parseAllNestedMessages(obj: ReflectionObject) { private parseAllNestedMessages(
obj: ReflectionObject,
fileName: string,
packageName?: string
) {
const messageObjects: ProtoBase[] = []; const messageObjects: ProtoBase[] = [];
// Nested messages/enums // Nested messages/enums
if ('nestedArray' in obj && obj.nestedArray) { if ('nestedArray' in obj && obj.nestedArray) {
const nestedArray = obj.nestedArray as ReflectionObject[]; const nestedArray = obj.nestedArray as ReflectionObject[];
for (const nestedObj of nestedArray) { for (const nestedObj of nestedArray) {
messageObjects.push(...this.parseAllNestedMessages(nestedObj)); messageObjects.push(
...this.parseAllNestedMessages(nestedObj, fileName, packageName)
);
} }
} }
// Message // Message
if ('fieldsArray' in obj) { if ('fieldsArray' in obj) {
messageObjects.push(this.convertProtoDefinitionToModel(obj)); messageObjects.push(
this.convertProtoDefinitionToModel(obj, fileName, packageName)
);
} }
// Enum // Enum
if ('values' in obj) { if ('values' in obj) {
messageObjects.push({ messageObjects.push({
name: obj.name, name: obj.name,
fullName: obj.fullName,
packageName,
fileName,
options: Object.entries(obj.values as Record<string, string>) options: Object.entries(obj.values as Record<string, string>)
.sort((a, b) => Number(a[1]) - Number(b[1])) .sort((a, b) => Number(a[1]) - Number(b[1]))
.map(([key, _]) => key), .map(([key, _]) => key),
@@ -110,36 +193,73 @@ export class ProtoDefinitionService {
private populateNestedObject( private populateNestedObject(
objectMessage: ObjectMessage, objectMessage: ObjectMessage,
packageName: string,
availableMessages: ProtoMessage[] availableMessages: ProtoMessage[]
) { ) {
let useFullName = false;
let objectMessageName = objectMessage.messageDefinition.name;
if (objectMessageName.includes('.')) {
// First do a check for current package.
for (const message of availableMessages) { for (const message of availableMessages) {
if (message.name === objectMessage.messageDefinition.name) { if (
message.packageName === packageName &&
message.fullName === `${packageName}.${objectMessageName}`
) {
this.updateObjectMessage(objectMessage, message, availableMessages);
return;
}
}
// If there's no match, then we'll be matching on full name, assuming a separate package
useFullName = true;
}
for (const message of availableMessages) {
if (
(useFullName && message.fullName === objectMessageName) ||
(!useFullName && message.name === objectMessageName)
) {
this.updateObjectMessage(objectMessage, message, availableMessages);
return;
}
}
objectMessage.messageDefinition = UnknownProto(
objectMessage.messageDefinition.name,
objectMessage.messageDefinition.fullName
);
}
private updateObjectMessage(
objectMessage: ObjectMessage,
message: ProtoMessage,
availableMessages: ProtoMessage[]
) {
objectMessage.messageDefinition = { objectMessage.messageDefinition = {
name: message.name, name: objectMessage.messageDefinition.name,
values: message.values.map((value) => { values: message.values.map((value) => {
if (value.configuration.type === MessageTypeEnum.Object) { if (value.configuration.type === MessageTypeEnum.Object) {
this.populateNestedObject( this.populateNestedObject(
value.configuration as ObjectMessage, value.configuration as ObjectMessage,
message.packageName!,
availableMessages availableMessages
); );
} }
return structuredClone(value); return structuredClone(value);
}), }),
}; };
return;
}
}
objectMessage.messageDefinition = UnknownProto(
objectMessage.messageDefinition.name
);
} }
private convertProtoDefinitionToModel( private convertProtoDefinitionToModel(
messageDefinition?: ReflectionObject messageDefinition: ReflectionObject,
fileName: string,
packageName?: string
): ProtoBase { ): ProtoBase {
if (messageDefinition && 'fieldsArray' in messageDefinition) { if (messageDefinition && 'fieldsArray' in messageDefinition) {
const convertedFields: ProtoMessage = { const convertedFields: ProtoMessage = {
name: messageDefinition.name, name: messageDefinition.name,
fullName: messageDefinition.fullName.substring(1), // trim leading .
packageName,
fileName,
values: [], values: [],
}; };
const fields = messageDefinition.fieldsArray as FieldBase[]; const fields = messageDefinition.fieldsArray as FieldBase[];