Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 173 additions & 23 deletions protographic/src/sdl-to-proto-visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export interface GraphQLToProtoTextVisitorOptions {
lockData?: ProtoLock;
/** Whether to include descriptions/comments from GraphQL schema */
includeComments?: boolean;
/** Whether to include required annotations */
includeRequiredAnnotations?: boolean;
}

/**
Expand All @@ -96,6 +98,24 @@ interface ProtoType {
isRepeated: boolean;
}

enum OptionType {
STRING = 'string',
NUMBER = 'number',
BOOLEAN = 'bool',
}

interface CustomFieldOption {
name: string;
type: OptionType;
}

interface FieldOption {
name: string;
value: string;
valueType: OptionType;
isCustom: boolean;
}

/**
* Visitor that converts GraphQL SDL to Protocol Buffer text definition
*
Expand Down Expand Up @@ -154,6 +174,15 @@ export class GraphQLToProtoTextVisitor {
/** Track whether wrapper types are used (for conditional import) */
private usesWrapperTypes = false;

/** Whether to include required annotations */
private includeRequiredAnnotations: boolean;

/** Tracks whether required annotations have been added to the proto file */
private hasRequiredAnnotations: boolean = false;

/** Track custom message options */
private customMessageOptions: CustomFieldOption[] = [];

/**
* Map of message names to their field numbers for tracking deleted fields
* This maintains field numbers even when fields are removed from the schema
Expand All @@ -173,13 +202,15 @@ export class GraphQLToProtoTextVisitor {
goPackage,
lockData,
includeComments = true,
includeRequiredAnnotations = true,
} = options;

this.schema = schema;
this.serviceName = serviceName;
this.packageName = packageName;
this.lockManager = new ProtoLockManager(lockData);
this.includeComments = includeComments;
this.includeRequiredAnnotations = includeRequiredAnnotations;

// If we have lock data, initialize the field numbers map
if (lockData) {
Expand Down Expand Up @@ -462,11 +493,20 @@ export class GraphQLToProtoTextVisitor {
this.addImport('google/protobuf/wrappers.proto');
}

if (this.includeRequiredAnnotations && this.hasRequiredAnnotations) {
this.addImport('google/protobuf/descriptor.proto');
this.addCustomFieldOption({
name: 'is_required',
type: OptionType.BOOLEAN,
});
}

// Build the complete proto file
let protoContent: string[] = [];

// Add the header (syntax, package, imports, options)
protoContent.push(...this.buildProtoHeader());
protoContent.push(...this.formatCustomFieldOptions());

// Add a service description comment
if (this.includeComments) {
Expand Down Expand Up @@ -533,6 +573,39 @@ export class GraphQLToProtoTextVisitor {
return protoContent.join('\n');
}

/**
* Add a custom message option to the proto file
*/
private addCustomFieldOption(option: CustomFieldOption): void {
this.customMessageOptions.push(option);
}

/**
* Format the custom message options into a string array
*/
private formatCustomFieldOptions(): string[] {
if (this.customMessageOptions.length === 0 || !this.hasRequiredAnnotations) {
return [];
}

const options: string[] = [];

options.push('extend google.protobuf.FieldOptions {');

// The protobuf spec defines that the number range of 50000 to 99999 is reserved for
// internal use within individual organizations.
// See https://protobuf.dev/programming-guides/proto2/#customoptions
let fieldNumber = 50000;
for (const option of this.customMessageOptions) {
options.push(this.formatIndent(1, `optional ${option.type} ${option.name} = ${fieldNumber};`));
fieldNumber++;
}

options.push('}', '');

return options;
}

Comment thread
Noroth marked this conversation as resolved.
/**
* Trim empty lines from the beginning and end of the array
*/
Expand Down Expand Up @@ -1179,7 +1252,33 @@ Example:
const field = fields[fieldName];
const fieldType = this.getProtoTypeFromGraphQL(field.type);
const protoFieldName = graphqlFieldToProtoField(fieldName);

const fieldOptions: FieldOption[] = [];
const deprecationInfo = this.fieldIsDeprecated(field, [...type.getInterfaces()]);
// Add deprecated option if the field is deprecated
if (deprecationInfo.deprecated) {
fieldOptions.push({
name: 'deprecated',
value: 'true',
valueType: OptionType.BOOLEAN,
isCustom: false,
});
}

if (this.includeRequiredAnnotations) {
// Add required option if the field is non-nullable
const required = isNonNullType(field.type);
if (required) {
fieldOptions.push({
name: 'is_required',
value: 'true',
valueType: OptionType.BOOLEAN,
isCustom: true,
});
}

this.hasRequiredAnnotations = true;
}

// Get the appropriate field number, respecting the lock
const fieldNumber = this.getFieldNumber(type.name, protoFieldName, this.getNextAvailableFieldNumber(type.name));
Expand All @@ -1193,17 +1292,16 @@ Example:
this.protoText.push(...this.formatComment(`Deprecation notice: ${deprecationInfo.reason}`, 1));
}

const fieldOptions = [];
if (deprecationInfo.deprecated) {
fieldOptions.push(` [deprecated = true]`);
}

if (fieldType.isRepeated) {
this.protoText.push(
` repeated ${fieldType.typeName} ${protoFieldName} = ${fieldNumber}${fieldOptions.join(' ')};`,
` repeated ${fieldType.typeName} ${protoFieldName} = ${fieldNumber}${this.formatFieldOptions(
fieldOptions,
)};`,
);
} else {
this.protoText.push(` ${fieldType.typeName} ${protoFieldName} = ${fieldNumber}${fieldOptions.join(' ')};`);
this.protoText.push(
` ${fieldType.typeName} ${protoFieldName} = ${fieldNumber}${this.formatFieldOptions(fieldOptions)};`,
);
}

// Queue complex field types for processing
Expand All @@ -1217,6 +1315,32 @@ Example:
this.protoText.push('}');
}

private formatFieldOptions(fieldOptions: FieldOption[]): string {
if (fieldOptions.length === 0) {
return '';
}

const elements: string[] = [' ['];

for (let index = 0; index < fieldOptions.length; index++) {
const option = fieldOptions[index];
const value = option.valueType === OptionType.STRING ? `"${option.value}"` : option.value;
if (option.isCustom) {
elements.push('(', option.name, ')', ' = ', value);
} else {
elements.push(`${option.name} = ${value}`);
}

if (index < fieldOptions.length - 1) {
elements.push(', ');
}
}

elements.push(']');

return elements.join('');
}

/**
* Resolve deprecation for a field (optionally considering interface fields)
* Field-level reason takes precedence; otherwise the first interface with a non-empty reason wins.
Expand Down Expand Up @@ -1319,7 +1443,31 @@ Example:
const field = fields[fieldName];
const fieldType = this.getProtoTypeFromGraphQL(field.type);
const protoFieldName = graphqlFieldToProtoField(fieldName);

const fieldOptions: FieldOption[] = [];

if (this.includeRequiredAnnotations) {
const required = isNonNullType(field.type);
if (required) {
fieldOptions.push({
name: 'is_required',
value: 'true',
valueType: OptionType.BOOLEAN,
isCustom: true,
});
}
this.hasRequiredAnnotations = true;
}

const deprecationInfo = this.fieldIsDeprecated(field, []);
if (deprecationInfo.deprecated) {
fieldOptions.push({
name: 'deprecated',
value: 'true',
valueType: OptionType.BOOLEAN,
isCustom: false,
});
}

// Get the appropriate field number, respecting the lock
const fieldNumber = this.getFieldNumber(type.name, protoFieldName, this.getNextAvailableFieldNumber(type.name));
Expand All @@ -1333,17 +1481,16 @@ Example:
this.protoText.push(...this.formatComment(`Deprecation notice: ${deprecationInfo.reason}`, 1));
}

const fieldOptions = [];
if (deprecationInfo.deprecated) {
fieldOptions.push(` [deprecated = true]`);
}

if (fieldType.isRepeated) {
this.protoText.push(
` repeated ${fieldType.typeName} ${protoFieldName} = ${fieldNumber}${fieldOptions.join(' ')};`,
` repeated ${fieldType.typeName} ${protoFieldName} = ${fieldNumber}${this.formatFieldOptions(
fieldOptions,
)};`,
);
} else {
this.protoText.push(` ${fieldType.typeName} ${protoFieldName} = ${fieldNumber}${fieldOptions.join(' ')};`);
this.protoText.push(
` ${fieldType.typeName} ${protoFieldName} = ${fieldNumber}${this.formatFieldOptions(fieldOptions)};`,
);
}

// Queue complex field types for processing
Expand Down Expand Up @@ -1746,10 +1893,6 @@ Example:
lines.push(...this.formatComment(`Wrapper message for a list of ${baseType.name}.`, 0));
}

const formatIndent = (indent: number, content: string) => {
return ' '.repeat(indent) + content;
};

lines.push(`message ${wrapperName} {`);
let innerWrapperName = '';
if (level > 1) {
Expand All @@ -1759,16 +1902,23 @@ Example:
}

lines.push(
formatIndent(1, `message List {`),
formatIndent(2, `repeated ${innerWrapperName} items = 1;`),
formatIndent(1, `}`),
formatIndent(1, `List list = 1;`),
formatIndent(0, `}`),
this.formatIndent(1, `message List {`),
this.formatIndent(2, `repeated ${innerWrapperName} items = 1;`),
this.formatIndent(1, `}`),
this.formatIndent(1, `List list = 1;`),
this.formatIndent(0, `}`),
);

return lines;
}

/**
* Format a string with the given indent
*/
private formatIndent(indent: number, content: string): string {
return ' '.repeat(indent) + content;
}

/**
* Get indentation based on the current level
*
Expand Down
Loading