protographic/src/sdl-to-mapping-visitor.ts (233 lines of code) (raw):
import {
DirectiveNode,
GraphQLEnumType,
GraphQLField,
GraphQLInputObjectType,
GraphQLNamedType,
GraphQLObjectType,
GraphQLSchema,
isEnumType,
isInputObjectType,
isObjectType,
Kind,
} from 'graphql';
import {
createEntityLookupMethodName,
createEntityLookupRequestName,
createEntityLookupResponseName,
createOperationMethodName,
createRequestMessageName,
createResponseMessageName,
graphqlArgumentToProtoField,
graphqlEnumValueToProtoEnumValue,
graphqlFieldToProtoField,
OperationTypeName,
} from './naming-conventions.js';
import {
ArgumentMapping,
EntityMapping,
EnumMapping,
EnumValueMapping,
FieldMapping,
GRPCMapping,
OperationMapping,
OperationType,
TypeFieldMapping,
} from '@wundergraph/cosmo-connect/dist/node/v1/node_pb';
import { Maybe } from 'graphql/jsutils/Maybe.js';
/**
* Visitor that converts a GraphQL schema to gRPC mapping definitions
*
* This visitor traverses a GraphQL schema and generates mappings between:
* - GraphQL operations and gRPC RPC methods
* - GraphQL entity types (with @key directive) and corresponding lookup methods
* - GraphQL types/fields and Protocol Buffer message types/fields
* - GraphQL enums and Protocol Buffer enums
*
* The generated mappings are used to translate between GraphQL and Protocol Buffer
* representations in a consistent manner.
*/
export class GraphQLToProtoVisitor {
private readonly mapping: GRPCMapping;
private readonly schema: GraphQLSchema;
/**
* Creates a new visitor for generating gRPC mappings from a GraphQL schema
*
* @param schema - The GraphQL schema to process
* @param serviceName - Name for the generated service (defaults to "DefaultService")
*/
constructor(schema: GraphQLSchema, serviceName: string = 'DefaultService') {
this.schema = schema;
this.mapping = new GRPCMapping({
version: 1,
service: serviceName,
operationMappings: [],
entityMappings: [],
typeFieldMappings: [],
enumMappings: [],
});
}
/**
* Process the GraphQL schema and generate all necessary mappings
*
* The processing order is important:
* 1. First entity types (with @key directives) are processed to identify federated entities
* 2. Then Query operations are processed to map GraphQL queries to RPC methods
* 3. Finally all remaining types are processed to ensure complete mapping coverage
*
* @returns The completed gRPC mapping definitions
*/
public visit(): GRPCMapping {
// Process entity types first (types with @key directive)
this.processEntityTypes();
// Process query type
this.processQueryType();
// Process mutation type
this.processMutationType();
// Process subscription type
this.processSubscriptionType();
// Process all other types for field mappings
this.processAllTypes();
return this.mapping;
}
/**
* Processes entity types (GraphQL types with @key directive)
*
* Federation entities require special handling to generate appropriate
* lookup RPC methods and entity mappings.
*/
private processEntityTypes(): void {
const typeMap = this.schema.getTypeMap();
for (const typeName in typeMap) {
const type = typeMap[typeName];
// Skip built-in types and query/mutation/subscription types
if (this.shouldSkipRootType(type)) continue;
// Check if this is an entity type (has @key directive)
if (isObjectType(type)) {
const keyDirective = this.getKeyDirective(type);
if (!keyDirective) continue;
const keyFields = this.getKeyFieldsFromDirective(keyDirective);
if (keyFields.length > 0) {
// Create entity mapping using the first key field
this.createEntityMapping(typeName, keyFields[0]);
}
}
}
}
/**
* Extract the key directive from a GraphQL object type
*
* @param type - The GraphQL object type to check for key directive
* @returns The key directive if found, undefined otherwise
*/
private getKeyDirective(type: GraphQLObjectType): DirectiveNode | undefined {
return type.astNode?.directives?.find((d) => d.name.value === 'key');
}
/**
* Creates an entity mapping for a federated entity type
*
* This defines how a GraphQL federated entity maps to a gRPC lookup method
* and its corresponding request/response messages.
*
* @param typeName - The name of the GraphQL entity type
* @param keyField - The field that serves as the entity's key
*/
private createEntityMapping(typeName: string, keyField: string): void {
const entityMapping = new EntityMapping({
typeName,
kind: 'entity',
key: keyField,
rpc: createEntityLookupMethodName(typeName, keyField),
request: createEntityLookupRequestName(typeName, keyField),
response: createEntityLookupResponseName(typeName, keyField),
});
this.mapping.entityMappings.push(entityMapping);
}
/**
* Extract key fields from a @key directive
*
* The @key directive specifies which fields form the entity's primary key
* in Federation. This method extracts those field names.
*
* @param directive - The @key directive from the GraphQL AST
* @returns Array of field names that form the key
*/
private getKeyFieldsFromDirective(directive: DirectiveNode): string[] {
// Extract fields argument from the key directive
const fieldsArg = directive.arguments?.find((arg) => arg.name.value === 'fields');
if (fieldsArg && fieldsArg.value.kind === Kind.STRING) {
return fieldsArg.value.value.split(' ');
}
return [];
}
/**
* Process the GraphQL Query type to generate query operation mappings
*
* Each field on the Query type represents a GraphQL query operation that
* needs to be mapped to a corresponding gRPC RPC method.
*/
private processQueryType(): void {
this.processType('Query', OperationType.QUERY, this.schema.getQueryType());
}
/**
* Process the GraphQL Mutation type to generate mutation operation mappings
*
* Each field on the Mutation type represents a GraphQL mutation operation that
* needs to be mapped to a corresponding gRPC RPC method.
*/
private processMutationType(): void {
this.processType('Mutation', OperationType.MUTATION, this.schema.getMutationType());
}
/**
* Process the GraphQL Subscription type to generate subscription operation mappings
*
* Each field on the Subscription type represents a GraphQL subscription operation that
* needs to be mapped to a corresponding gRPC RPC method.
*/
private processSubscriptionType(): void {
this.processType('Subscription', OperationType.SUBSCRIPTION, this.schema.getSubscriptionType());
}
/**
* Process a GraphQL type to generate operation mappings
*
* This method processes a specific GraphQL type (e.g., Query, Mutation, Subscription)
* and generates mappings for its fields to corresponding gRPC RPC methods.
*
* @param operationTypeName - The name of the GraphQL type (Query, Mutation, Subscription)
* @param operationType - The type of operation (Query, Mutation, Subscription)
* @param graphqlType - The GraphQL type to process
*/
private processType(
operationTypeName: OperationTypeName,
operationType: OperationType,
graphqlType: Maybe<GraphQLObjectType>,
): void {
if (!graphqlType) return;
const typeFieldMapping = new TypeFieldMapping({
type: operationTypeName,
fieldMappings: [],
});
const fields = graphqlType.getFields();
for (const fieldName in fields) {
// Skip special federation fields
if (fieldName === '_entities') continue;
const field = fields[fieldName];
const mappedName = createOperationMethodName(operationTypeName, fieldName);
this.createOperationMapping(operationType, fieldName, mappedName);
const fieldMapping = this.createFieldMapping(operationTypeName, field);
typeFieldMapping.fieldMappings.push(fieldMapping);
}
this.mapping.typeFieldMappings.push(typeFieldMapping);
}
/**
* Create an operation mapping between a GraphQL query and gRPC method
*
* @param operationType - The type of operation (Query, Mutation, Subscription)
* @param fieldName - Original GraphQL field name
* @param mappedName - Transformed name for use in gRPC context
*/
private createOperationMapping(operationType: OperationType, fieldName: string, mappedName: string): void {
const operationMapping = new OperationMapping({
type: operationType,
original: fieldName,
mapped: mappedName,
request: createRequestMessageName(mappedName),
response: createResponseMessageName(mappedName),
});
this.mapping.operationMappings.push(operationMapping);
}
/**
* Process all remaining GraphQL types to generate complete mappings
*
* This ensures that all object types, input types, and enums in the schema
* have appropriate mappings for their fields and values.
*/
private processAllTypes(): void {
const typeMap = this.schema.getTypeMap();
for (const typeName in typeMap) {
const type = typeMap[typeName];
if (this.shouldSkipRootType(type)) continue;
// Process each type according to its kind
if (isObjectType(type)) {
this.processObjectType(type);
} else if (isInputObjectType(type)) {
this.processInputObjectType(type);
} else if (isEnumType(type)) {
this.processEnumType(type);
}
// Note: Union types don't need field mappings in our implementation
}
}
/**
* Determines if a type should be skipped during processing
*
* We skip:
* - Built-in GraphQL types (prefixed with __)
* - Root operation types (Query, Mutation, Subscription)
*
* @param type - The GraphQL type to check
* @returns True if the type should be skipped, false otherwise
*/
private shouldSkipRootType(type: GraphQLNamedType): boolean {
const typeName = type.name;
return (
typeName.startsWith('__') ||
typeName === this.schema.getQueryType()?.name ||
typeName === this.schema.getMutationType()?.name ||
typeName === this.schema.getSubscriptionType()?.name
);
}
/**
* Process a GraphQL object type to generate field mappings
*
* @param type - The GraphQL object type to process
*/
private processObjectType(type: GraphQLObjectType): void {
const typeFieldMapping = new TypeFieldMapping({
type: type.name,
fieldMappings: [],
});
const fields = type.getFields();
for (const fieldName in fields) {
const field = fields[fieldName];
const fieldMapping = this.createFieldMapping(type.name, field);
typeFieldMapping.fieldMappings.push(fieldMapping);
}
// Only add to mappings if there are fields to map
if (typeFieldMapping.fieldMappings.length > 0) {
this.mapping.typeFieldMappings.push(typeFieldMapping);
}
}
/**
* Process a GraphQL input object type to generate field mappings
*
* Input objects are handled separately because they have different
* field structures than regular object types.
*
* @param type - The GraphQL input object type to process
*/
private processInputObjectType(type: GraphQLInputObjectType): void {
const typeFieldMapping = new TypeFieldMapping({
type: type.name,
fieldMappings: [],
});
const fields = type.getFields();
for (const fieldName in fields) {
const field = fields[fieldName];
// Input fields don't have args, so we create a simpler field mapping
const fieldMapping = new FieldMapping({
original: field.name,
mapped: graphqlFieldToProtoField(field.name),
argumentMappings: [],
});
typeFieldMapping.fieldMappings.push(fieldMapping);
}
// Only add to mappings if there are fields to map
if (typeFieldMapping.fieldMappings.length > 0) {
this.mapping.typeFieldMappings.push(typeFieldMapping);
}
}
/**
* Process a GraphQL enum type to generate value mappings
*
* GraphQL enums are mapped to Protocol Buffer enums with appropriate
* naming conventions for the enum values.
*
* @param type - The GraphQL enum type to process
*/
private processEnumType(type: GraphQLEnumType): void {
const enumMapping = new EnumMapping({
type: type.name,
values: [],
});
const enumValues = type.getValues();
// Map each enum value to its Protocol Buffer representation
for (const enumValue of enumValues) {
enumMapping.values.push(
new EnumValueMapping({
original: enumValue.name,
// Convert to UPPER_SNAKE_CASE with type name prefix for Proto enums
mapped: graphqlEnumValueToProtoEnumValue(type.name, enumValue.name),
}),
);
}
this.mapping.enumMappings.push(enumMapping);
}
/**
* Create a field mapping between a GraphQL field and Protocol Buffer field
*
* This includes mapping the field name and any arguments the field may have.
*
* @param type - The name of the containing GraphQL type
* @param field - The GraphQL field to create a mapping for
* @returns The created field mapping
*/
private createFieldMapping(type: string, field: GraphQLField<any, any>): FieldMapping {
const fieldName = field.name;
// Convert field names to snake_case for Protocol Buffers
const mappedFieldName = graphqlFieldToProtoField(fieldName);
const argumentMappings: ArgumentMapping[] = this.createArgumentMappings(field);
return new FieldMapping({
original: fieldName,
mapped: mappedFieldName,
argumentMappings,
});
}
/**
* Create argument mappings for a GraphQL field
*
* Maps each argument to its Protocol Buffer representation with
* appropriate naming conventions.
*
* @param field - The GraphQL field containing arguments
* @returns Array of argument mappings
*/
private createArgumentMappings(field: GraphQLField<any, any>): ArgumentMapping[] {
const argumentMappings: ArgumentMapping[] = [];
if (field.args && field.args.length > 0) {
for (const arg of field.args) {
argumentMappings.push(
new ArgumentMapping({
original: arg.name,
// Convert argument names to snake_case for Protocol Buffers
mapped: graphqlArgumentToProtoField(arg.name),
}),
);
}
}
return argumentMappings;
}
}