Skip to main content

GraphQL Framework Type Generators

Type Generators are the fundamental building blocks for creating GraphQL schemas in the Brightspot GraphQL Framework. They provide a type-safe, object-oriented approach to defining GraphQL types and their relationships. Each kind of GraphQL type (type, interface, union, input, enum, scalar, and directive) has its own specialized generator that handles the unique requirements and constraints of that type.

Type Generators abstract away the complexities of schema construction while enforcing compile-time type safety through Java generics. They manage everything from type naming and field definitions to interface implementations and union type compositions. The framework provides base classes for each generator type, allowing you to focus on your schema's business logic rather than the intricacies of GraphQL schema definition.

In the following sections, we'll explore how Type Generators handle naming conflicts, examine the built-in reserved types, and dive into the specific capabilities of each generator type. We'll also cover field generation, type coercion, and advanced data fetching patterns that make Type Generators a powerful tool for building robust GraphQL APIs.

Type Names

The framework provides a sophisticated type naming system through the TypeName interface that handles the complexities of naming in dynamic GraphQL schemas. This system ensures unique and valid GraphQL names while providing flexible conflict resolution strategies. It supports both exact names that must be unique and auto-incrementing names that can handle conflicts gracefully. The system also includes utilities for deriving names from Java class names and managing namespaces through prefixes and suffixes.

A key design principle of the framework is the separation of concerns between type generation and type naming. A Type Generator is responsible for defining the structure and behavior of one or more types in the schema, while the actual names of those types are determined by the consumer of the Type Generator. This decoupling allows Type Generators to be reusable across different schemas, where the same generator might produce types with different names.

For example, consider a FooBarTypeGenerator that defines a type with certain fields. This generator could be used in one schema to create a type named "Foo", while in another schema it could create a type named "Bar". The Type Generator focuses on the type's structure and behavior, while the naming is handled separately through the TypeName interface.

When you instantiate a Type Generator, you're creating a builder object that can be converted into a concrete type for use in the schema. This conversion is necessary when defining fields (to specify return types) or unions (to specify member types). The conversion process requires calling one of two methods:

  1. #named(TypeName) - This method allows you to explicitly specify the name for the type. It's the most flexible approach as it lets you choose any name you want:
1
// Create a type generator
2
OutputObjectTypeGenerator<SchemaContext, Site> generator = new SiteTypeGenerator();
3
4
// Convert it to a type with a specific name
5
OutputObjectType<SchemaContext, Site> type = generator.named(TypeName.ofExact("Site"));
  1. #withDefaultName(SchemaContext) - This method is used when the Type Generator implementation includes its own naming logic. It's particularly useful for common or expected type names:
1
// A Type Generator that provides its own naming logic
2
public class SiteTypeGenerator extends OutputObjectTypeGenerator<SchemaContext, Site> {
3
@Override
4
protected TypeName getDefaultTypeName(SchemaContext context) {
5
return TypeName.ofExact("Site");
6
}
7
8
// ... rest of implementation
9
}
10
11
// Using the generator with its default name
12
OutputObjectType<SchemaContext, Site> type = generator.withDefaultName(context);

The framework provides several ways to create TypeName instances:

  • TypeName.ofExact(String name) - Creates a name that must be unique
  • TypeName.ofExact(String id, String name) - Creates a name with a custom ID that must be unique
  • TypeName.ofAuto(String name) - Creates a name that can auto-increment if conflicts occur
  • TypeName.ofAuto(String id, String name) - Creates a name with a custom ID that can auto-increment

Since types are referenced frequently throughout a schema, it's common to create concrete implementations of the TypeName interface to make it easier to reference specific types. This is particularly useful when you need to reference the same type name in multiple places, such as when defining fields or implementing interfaces.

Here's an example of a reusable TypeName implementation for a Site type:

1
public class SiteTypeName implements TypeName {
2
private static final String ID = "com.psddev.cms.db.Site";
3
4
@Override
5
public String getSchemaNameId() {
6
return ID;
7
}
8
9
@Override
10
public List<String> getSchemaNames() {
11
return List.of("Site");
12
}
13
14
@Override
15
public String getAutoIncrementSchemaName() {
16
return null; // We want this to be exact, not auto-incrementing
17
}
18
}

This implementation can then be used consistently throughout your schema, avoiding hardcoded Strings.

1
// Using the reusable TypeName
2
OutputObjectType<SchemaContext, Site> type = generator.named(new SiteTypeName());

The separation of type generation and naming, combined with the ability to create reusable TypeName implementations, provides a powerful and flexible system for managing type names in your GraphQL schema. This approach ensures consistency while maintaining the ability to adapt names to different contexts when needed.

This flexibility in type naming is particularly important when working with reserved types, which are a special category of type generators that enforce stricter naming rules. Reserved types are designed to create exactly one type in the schema, ensuring type uniqueness and improving traceability by linking schema types directly to their Java implementations.

Reserved Types

Reserved types are a special category of type generators that enforce a strict one-to-one mapping between a Type Generator and its corresponding Schema Type. While they maintain the same flexibility in type naming as their non-reserved counterparts (allowing the same Type Generator to produce different named types in different schemas), they are easier to use within your schema definition because you can reference the Type Generator class directly without worrying about TypeName objects at all.

To mark a Type Generator as reserved, it must implement the ReservedTypeGenerator marker interface:

1
public class MyReservedTypeGenerator extends OutputObjectTypeGenerator<SchemaContext, MyType>
2
implements ReservedTypeGenerator {
3
// ... implementation
4
}

The association between a Reserved Type Generator and its Type Name is established in a ReservedTypeRegistry. You define your reserved type registry by implementing the createReservedTypeRegistry method on your schema loader:

1
public class MySchemaLoader extends GraphQLJavaSchemaLoader<SchemaContext> {
2
@Override
3
protected ReservedTypeRegistry createReservedTypeRegistry() {
4
return new MyReservedTypeRegistry();
5
}
6
}

The ReservedTypeRegistry interface defines a mapping between Type Generator classes and their corresponding Type Names:

1
public interface ReservedTypeRegistry {
2
Map<Class<? extends TypeGenerator<?, ?>>, TypeName> getAllTypeGeneratorNames();
3
Map<Class<? extends Enum<?>>, TypeName> getAllEnumNames();
4
}

The framework also provides native support for mapping Java enums to reserved GraphQL types, making it easy to expose enum values in your schema.

ℹ️ NOTE: While the registry defines the superset of all reserved types allowed in your schema, it doesn't automatically include all of them. Type inclusion is determined by the schema loading process, which traverses the type hierarchy starting from the root query and mutation types. Only types that are referenced during this traversal will be included in the final schema.

For larger schemas, defining every reserved type generator and its associated type name in a class can become cumbersome to develop and maintain. To simplify this process, the framework provides an AbstractReservedTypeRegistry with two key features:

  1. Registry Composition: You can combine multiple registries using either the dependencies method or the static combine method:
1
public class MyReservedTypeRegistry extends AbstractReservedTypeRegistry {
2
@Override
3
protected Set<ReservedTypeRegistry> dependencies() {
4
return Set.of(new GCASchemaRegistry());
5
}
6
}
7
8
// Or using the static combine method
9
ReservedTypeRegistry combined = ReservedTypeRegistry.combine(
10
new MyReservedTypeRegistry(),
11
new GCASchemaRegistry()
12
);
  1. Annotation-Based Type Names: You can define Type Names directly on Type Generator classes using the @ReservedTypeName annotation:
1
@ReservedTypeName(context = "myContext", value = "MyType")
2
public class MyReservedTypeGenerator extends OutputObjectTypeGenerator<SchemaContext, MyType>
3
implements ReservedTypeGenerator {
4
// ... implementation
5
}

Then, in your registry implementation, specify which contexts should be included:

1
public class MyReservedTypeRegistry extends AbstractReservedTypeRegistry {
2
@Override
3
protected Set<String> reservedTypeNameContexts() {
4
return Set.of("myContext");
5
}
6
}

A real-world example of this can be seen in the GCA (GraphQL Content API), where many reserved types are defined using this pattern.

1
public class GCAReservedTypeRegistry extends AbstractReservedTypeRegistry {
2
3
private static final GCAReservedTypeRegistry INSTANCE = new GCAReservedTypeRegistry();
4
5
private GCAReservedTypeRegistry() {
6
}
7
8
@Override
9
protected Set<String> reservedTypeNameContexts() {
10
return Collections.singleton(GCA.CONTEXT);
11
}
12
13
@Override
14
protected Set<ReservedTypeRegistry> dependencies() {
15
return new LinkedHashSet<>(Arrays.asList(
16
MarkedTextReservedTypeRegistry.getInstance(),
17
NumberReservedTypeRegistry.getInstance(),
18
TextReservedTypeRegistry.getInstance(),
19
UuidReservedTypeRegistry.getInstance(),
20
TimeReservedTypeRegistry.getInstance(),
21
JsonReservedTypeRegistry.getInstance(),
22
StorageReservedTypeRegistry.getInstance(),
23
GeoReservedTypeRegistry.getInstance()
24
));
25
}
26
27
public static GCAReservedTypeRegistry getInstance() {
28
return INSTANCE;
29
}
30
}

Finally, to use a Reserved Type Generator in your schema, you can use the static #of API which will take care of type generator instantiation as well as TypeName association. There are multiple #of APIs, one in each kind of schema type. For example if your type generator is for a GraphQL type then your call might look like:

1
OutputObjectType<SchemaContext, MyType> type = OutputObjectType.of(MyReservedTypeGenerator.class);

The combination of these features makes it easy to manage reserved types in both small and large schemas while maintaining type safety and clear relationships between Java implementations and GraphQL types.

type Type Generator

The type type generator is the foundation for creating GraphQL object types in the schema. It provides a type-safe way to define object types with their fields, interfaces, and relationships. This generator handles all the complexities of field definition, including argument handling, return type specification, and data fetching logic, while ensuring type safety through Java generics.

To create a type generator, extend OutputObjectTypeGenerator with two generic type parameters:

  • C extends SchemaContext: The schema context type that provides access to shared functionality
  • S: The Java source type that will be used to fetch data for the type's fields

The only method that must be implemented is:

  • getFields(TypeLoadContext<C> context) - Returns the list of fields defined on this type.

    This is the only required method. The fields returned should be unique to this type - fields inherited from interfaces can be omitted if getInterfaces() is properly implemented.

It is common for a type to also implement one or more interface types. For that you can override:

  • getInterfaces(TypeLoadContext<C> context) - Returns the list of interfaces that this type implements.

    The default implementation returns an empty list. When implemented, the type will inherit all fields from the interfaces recursively.

There are a couple more optional methods for more advanced use cases.

  1. getFieldOrderComparator(TypeLoadContext<C> context) - Provides a sorter for the field list.

    Optional method to provide custom field ordering. By default, fields are ordered based on the list returned from getFields(), so you have control the fields that way by returning the field list in your desired sort order. However, when your type implements interfaces, those inherited fields may disrupt the expected final ordering of fields. This method allows you to return a Comparator<ResolvedField> which will be applied to all fields, giving it the final say on the field ordering. The ResolvedField object gives you access to all the information about a field including its RegisteredSchemaName as well as its original target position in the field list prior to the sort getting applied.

    The simple use case of sorting the fields alphabetically might look like the following:

    1
    @Override
    2
    protected Comparator<ResolvedField<OutputField<? super SchemaContext, ? super Object, ?>>> getFieldOrderComparator(
    3
    TypeLoadContext<SchemaContext> context
    4
    ) {
    5
    return Comparator.comparing(
    6
    ResolvedField::getRegisteredFieldName, Comparator.comparing(
    7
    String::toLowerCase));
    8
    }
  2. getZeroFieldsBehavior(TypeLoadContext<C> context) - Defines behavior when the type has no fields.

    The GraphQL spec clearly prohibits empty type definitions, however there's been plenty of discussion online about the feasibility of allowing such behavior. For schemas like the GCA which aims to map 1:1 to the Dari Type System we find that there are some cases where empty types are desired. The GCA is also highly dynamic, where the inclusion of certain types and fields is conditional. This method allows us to fully express those needs. The possible return values are:

    1. ALLOW - Allow zero fields on a type by adding a dummy deprecated field that always returns null.
    2. OMIT - Omit the type if it has zero fields, as well any fields that return the type.
    3. ERROR - Throw an error if the type has zero fields.

    Default behavior is defined by GraphQLJavaSchemaLoader#getDefaultZeroFieldsBehavior().

Below is an example type generator:

1
public class ArticleType
2
extends OutputObjectTypeGenerator<SchemaContext, Article>
3
implements ReservedTypeGenerator {
4
5
@Override
6
protected List<? extends OutputField<? super SchemaContext, Article, ?>> getFields(
7
TypeLoadContext<SchemaContext> context
8
) {
9
return List.of(
10
ScalarType.ofString()
11
.toOutputFieldType()
12
.named("title")
13
.fetching(this, Article::getTitle),
14
15
ScalarType.ofString()
16
.toOutputFieldType()
17
.named("body")
18
.fetching(this, Article::getBody)
19
);
20
}
21
22
@Override
23
protected List<InterfaceType<? super SchemaContext, ? super Article>> getInterfaces(
24
TypeLoadContext<SchemaContext> context
25
) {
26
return List.of(
27
InterfaceType.of(ContentInterface.class)
28
);
29
}
30
}

interface Type Generator

Interface type generators enable the creation of GraphQL interfaces that define a contract for implementing types. They provide a mechanism for sharing common fields and behavior across multiple types while maintaining type safety. The framework ensures that all types implementing an interface provide the required fields with compatible types.

To create an interface generator, extend InterfaceTypeGenerator with two generic type parameters:

  • C extends SchemaContext: The schema context type
  • S: The Java source type for this interface

InterfaceTypeGenerator has all the same methods as OutputObjectTypeGenerator from the previous section, with two key additions:

  1. getImplementingTypes(TypeLoadContext<C> context) - Returns the list of types that implement this interface.

    The default implementation returns an empty list. Subclasses may optionally override this method as a way to conveniently trigger the registration of all dependent types in the schema. For example, suppose your root Query type has a single field that returns an interface. Without this API, somewhere during the construction of that type and field the implementer would need to manually trigger the creation and registration of all the types that implement this interface. It may be the case that the implementing types are created organically via other field return types but this serves as a safeguard and convenient location to ensure nothing is left out. If a type in the returned list has already been generated by the time this method executes, it just gets skipped so there's no harm in implementing this.

    Below is an example SDL showcasing where it would be important for the InterfaceTypeGenerator to override the getImplementingTypes method.

    1
    schema {
    2
    query: RootQuery
    3
    }
    4
    5
    type RootQuery {
    6
    contents: [Content!]!
    7
    articles: [Article!]!
    8
    }
    9
    10
    interface Content {
    11
    id: ID
    12
    }
    13
    14
    type Article implements Content {
    15
    id: ID
    16
    headline: String
    17
    }
    18
    19
    type Gallery implements Content {
    20
    id: ID
    21
    title: String
    22
    }

    If you are trying to replicate this schema, unless you override getImplementingTypes on your ContentTypeGenerator the Gallery type will not get included in the schema, but Article will. The system knows that Article is part of the schema because it's the direct return type of the RootQuery#articles field, where as Gallery is not explicitly referenced anywhere.

  2. resolveTypeNameId(TypeResolveContext<C> context, S source) - Required method that resolves the TypeName ID of the concrete type backed by the given source object.

    This is used by the GraphQL runtime to determine which concrete type to use when an interface field is queried. By only requiring the TypeName ID, and not the resolved GraphQL type name, we can continue to support dynamic type names, and makes it easier for implementers to return the correct type.

    For example, in the GCA, the TypeName ID for types backed by Brightspot Content Types is simply the ObjectType ID which is guaranteed to be unique and predictable. As such the implementation of such a method may look like the following:

    1
    protected abstract String resolveTypeNameId(TypeResolveContext<C> context, S source) {
    2
    return State.getInstance(source).getTypeId().toString();
    3
    }

    For reserved types, you can consult the ReservedTypeRegistry to fetch the schema name ID:

    1
    protected abstract String resolveTypeNameId(TypeResolveContext<C> context, S source) {
    2
    return context.getReservedTypeRegistry()
    3
    .getTypeGeneratorName(MyReservedTypeGenerator.class)
    4
    .getSchemaNameId();
    5
    }
    6

Below is an example interface generator:

1
public class ContentInterface extends InterfaceTypeGenerator<SchemaContext, Content> {
2
3
@Override
4
protected List<? extends OutputField<? super SchemaContext, Content, ?>> getFields(
5
TypeLoadContext<SchemaContext> context
6
) {
7
return List.of(
8
ScalarType.ofString()
9
.toOutputFieldType()
10
.named("id")
11
.fetching(this, Content::getId)
12
);
13
}
14
15
@Override
16
protected List<? extends OutputObjectType<? super SchemaContext, ? extends Content>> getImplementingTypes(
17
TypeLoadContext<SchemaContext> context
18
) {
19
return List.of(
20
OutputObjectType.of(ArticleType.class),
21
OutputObjectType.of(GalleryType.class)
22
);
23
}
24
25
@Override
26
protected String resolveTypeNameId(TypeResolveContext<SchemaContext> context, Content source) {
27
if (source instanceof Article) {
28
return context.getReservedTypeRegistry()
29
.getTypeGeneratorName(ArticleType.class)
30
.getSchemaNameId();
31
} else if (source instanceof Gallery) {
32
return context.getReservedTypeRegistry()
33
.getTypeGeneratorName(GalleryType.class)
34
.getSchemaNameId();
35
}
36
throw new IllegalStateException("Unknown source type: " + source.getClass());
37
}
38
}

When used with the ReservedTypeGenerator interface, you can use the static InterfaceType.of(Class<G> type) method to create instances of the type.

union Type Generator

The union type generator allows you to define GraphQL union types that can represent multiple possible types. It provides a type-safe way to specify which concrete types can be returned by fields of the union type. This generator handles the complexities of type resolution and ensures proper handling of polymorphic data.

To create a union generator, extend UnionTypeGenerator with two generic type parameters:

  • C extends SchemaContext: The schema context type that provides access to shared functionality
  • S: The Java source type that will be used as the common supertype for all possible types

The key methods that must be implemented are:

  1. getPossibleTypes(TypeLoadContext<C> context) - Returns the list of possible types that this union can return.

    This is the primary method that defines which concrete types can be members of the union. Each type in the returned list must be an OutputObjectType.

  2. resolveTypeNameId(TypeResolveContext<C> context, S source) - Resolves the concrete type name ID for a given source object.

    This method is called during query execution to determine which concrete type should be used when a union field is queried. It should return the schema name ID of the appropriate type based on the source object.

There is one optional method that can be overridden:

  • getZeroMembersBehavior(TypeLoadContext<C> context) - Defines behavior when the union has no possible types.

    The default implementation returns the value from GraphQLJavaSchemaLoader#getDefaultZeroMembersBehavior(). The possible behaviors are:

    • OMIT - Omit the union type if it has no members, as well any fields that return the type
    • ERROR - Throw an error if the union has no members (default behavior)

Here's an example of a union type generator:

1
public class MarkDataUnion extends UnionTypeGenerator<SchemaContext, Object>
2
implements ReservedTypeGenerator {
3
4
@Override
5
protected List<? extends OutputObjectType<? super SchemaContext, ? extends Object>> getPossibleTypes(
6
TypeLoadContext<SchemaContext> context
7
) {
8
return List.of(
9
OutputObjectType.of(HtmlElementType.class),
10
OutputObjectType.of(TextElementType.class),
11
OutputObjectType.of(ImageElementType.class)
12
);
13
}
14
15
@Override
16
protected String resolveTypeNameId(TypeResolveContext<SchemaContext> context, Object source) {
17
if (source instanceof HtmlElement) {
18
return context.getReservedTypeRegistry()
19
.getTypeGeneratorName(HtmlElementType.class)
20
.getSchemaNameId();
21
} else if (source instanceof TextElement) {
22
return context.getReservedTypeRegistry()
23
.getTypeGeneratorName(TextElementType.class)
24
.getSchemaNameId();
25
} else if (source instanceof ImageElement) {
26
return context.getReservedTypeRegistry()
27
.getTypeGeneratorName(ImageElementType.class)
28
.getSchemaNameId();
29
}
30
throw new IllegalStateException("Unknown source type: " + source.getClass());
31
}
32
}

When used with the ReservedTypeGenerator interface, you can use the static UnionType.of(Class<G> type) method to create instances of the type.

input Type Generator

Input type generators are specialized for creating GraphQL input types used in mutations and queries. They handle the unique requirements of input types, such as validation and coercion of input values. The framework provides type-safe ways to define input fields and their constraints while ensuring proper handling of complex input structures.

To create an input generator, extend InputTypeGenerator with two generic type parameters:

  • C extends SchemaContext: The schema context type that provides access to shared functionality
  • A: The Java type that will be produced after processing all input fields

The key methods that must be implemented are:

  1. getFields(TypeLoadContext<C> context) - Returns the list of input fields defined on this type.

    This method defines the structure of your input type by specifying what fields it accepts. Each field must be an instance of InputField.

  2. transformInput(InputProcessingContext<C, F> context) - Transforms the input field values into the final output type.

    This method is called after all input fields have been processed and validated. It receives an InputProcessingContext that provides access to the field values and should return an instance of the type specified by the generic parameter A.

There are two optional methods that can be overridden:

  1. getZeroFieldsBehavior(TypeLoadContext<C> context) - Defines behavior when the input type has no fields.

    The default implementation returns the value from GraphQLJavaSchemaLoader#getDefaultZeroMembersBehavior(). The possible behaviors are:

    • ALLOW - Add a dummy deprecated field that always returns null
    • OMIT - Omit the input type if it has no fields
    • ERROR - Throw an error if the input type has no fields (default behavior)
  2. getFieldOrderComparator(TypeLoadContext<C> context) - Provides a sorter for the field list.

    Optional method to provide custom field ordering. By default, fields are ordered based on the list returned from getFields().

Here's an example of an input type generator:

1
public class ArticleInput extends InputTypeGenerator<SchemaContext, Article> {
2
3
@Override
4
protected List<? extends InputField<? super SchemaContext, ?>> getFields(
5
TypeLoadContext<SchemaContext> context
6
) {
7
return List.of(
8
ScalarType.ofString()
9
.toInputFieldType()
10
.named("title")
11
.toInput(),
12
13
ScalarType.ofString()
14
.toInputFieldType()
15
.named("body")
16
.toInput()
17
);
18
}
19
20
@Override
21
protected Article transformInput(InputProcessingContext<SchemaContext, Object> context) {
22
Article article = new Article();
23
24
context.getField("title").ifPresent(title ->
25
article.setTitle((String) title));
26
27
context.getField("body").ifPresent(body ->
28
article.setBody((String) body));
29
30
return article;
31
}
32
}

The framework also provides specialized input type generators for common patterns:

  1. OneOfInputTypeGenerator - For input types where exactly one field must be set
  2. ManyOfInputTypeGenerator - For input types that collect multiple field values into a single result
  3. SDLInputTypeGenerator - For input types defined in SDL that need to be converted to framework types

When used with the ReservedTypeGenerator interface, you can use the static InputType.of(Class<G> type) method to create instances of the type.

enum Type Generator

Enum type generators enable the creation of GraphQL enumeration types that represent a fixed set of possible values. They provide a type-safe way to define enums in your schema, with support for both Java enums and custom enumeration values. The framework ensures proper validation and serialization of enum values while maintaining type safety.

To create an enum generator, extend EnumTypeGenerator with two generic type parameters:

  • C extends SchemaContext: The schema context type that provides access to shared functionality
  • A: The internal value type for this enum (values of this type will be returned when fetching arguments and will be the required return type during data fetching)

The key method that must be implemented is:

  • getValues(TypeLoadContext<C> context) - Returns the list of enum values defined for this type.

    This method defines the possible values for the enum. Each value is represented by an EnumValue<A> object that contains the name, description, and internal value for that enum option.

For Java enums, the framework provides a specialized generator called EnumClassTypeGenerator that automatically converts a Java enum into a GraphQL enum type:

1
public class ImageOrientationEnum
2
extends EnumClassTypeGenerator<SchemaContext, ImageOrientation>
3
implements ReservedTypeGenerator {
4
5
public ImageOrientationEnum() {
6
super(ImageOrientation.class);
7
}
8
}

For custom enum values, you can implement EnumTypeGenerator directly:

1
public class RevisionTypeEnum
2
extends EnumTypeGenerator<SchemaContext, Class<? extends Revision>>
3
implements ReservedTypeGenerator {
4
5
@Override
6
protected List<EnumValue<Class<? extends Revision>>> getValues(
7
TypeLoadContext<SchemaContext> context
8
) {
9
List<EnumValue<Class<? extends Revision>>> values = new ArrayList<>();
10
11
for (Class<? extends Revision> revisionClass : getRevisionClasses()) {
12
values.add(EnumValue.ofType(
13
context.getLoader(),
14
TypeName.ofExact(revisionClass.getSimpleName()),
15
revisionClass,
16
getDescription(revisionClass)));
17
}
18
19
return values;
20
}
21
}

When used with the ReservedTypeGenerator interface, you can use the static EnumType.of(Class<E> enumClass) method to create instances of the type for Java enums, or EnumType.ofTypeGenerator(Class<G> type) for custom enum generators.

The framework also provides special support for enums through the ReservedEnum interface. When a Java enum implements this interface, it gains additional capabilities:

  1. Automatic schema registration through the ReservedTypeRegistry
  2. The ability to provide descriptions for enum values through the getDescription() method
  3. Singleton instance management via the ReservedEnum.ofType(Class<E>) method

Example of a reserved enum:

1
public enum ImageOrientation implements ReservedEnum {
2
LANDSCAPE("Image is wider than it is tall"),
3
PORTRAIT("Image is taller than it is wide"),
4
SQUARE("Image has equal width and height");
5
6
private final String description;
7
8
ImageOrientation(String description) {
9
this.description = description;
10
}
11
12
@Override
13
public String getDescription() {
14
return description;
15
}
16
}

Enum fields can be used in various contexts:

  • As output fields using toOutput()
  • As input fields using toInput()
  • As arguments using toArgument()

Example usage in a type generator:

1
@Override
2
protected List<OutputField<SchemaContext, Image, ?>> getFields(
3
TypeLoadContext<SchemaContext> context
4
) {
5
return List.of(
6
EnumType.of(ImageOrientation.class)
7
.toOutputFieldType()
8
.named("orientation")
9
.fetching(this, Image::getOrientation)
10
);
11
}

scalar Type Generator

Scalar type generators enable the creation of custom GraphQL scalar types that represent primitive values. They provide a type-safe way to handle data serialization and deserialization between Java types and GraphQL scalar values. The framework includes built-in scalar types for common use cases and provides a robust foundation for creating custom scalars.

To create a scalar generator, extend ScalarTypeGenerator with two generic type parameters:

  • C extends SchemaContext: The schema context type that provides access to shared functionality
  • A: The Java type that this scalar represents (e.g., UUID, LocalDateTime, URI)

The key methods that must be implemented are:

  1. serialize(ScalarSerializationContext<C> serializationContext) - Converts a Java object to a valid runtime value for the scalar type.

    This method is called during query execution to convert the result of a data fetcher into a format that can be included in the GraphQL response.

  2. parseValue(ScalarValueParsingContext<C> valueParsingContext) - Converts a query variable into a Java object.

    This method is called when processing variables provided with a GraphQL query to convert them into the appropriate Java type.

There are two optional methods that can be overridden:

  1. parseLiteral(ScalarLiteralParsingContext<C> literalParsingContext) - Converts a query input AST node into a Java object.

    This method is called during query execution to convert inline query arguments (as opposed to variables) into the appropriate Java type.

  2. valueToLiteral(ScalarValueToLiteralConversionContext<C> valueToLiteralConversionContext) - Converts an external input value to a literal (AST Value).

    This method is used when the GraphQL runtime needs to convert a value back into a query-compatible format.

For common scalar types that are already supported by the graphql-java library, you can extend GraphQLJavaScalarTypeGenerator instead, which simplifies implementation by delegating to the library's built-in coercion logic:

1
public class UuidScalar
2
extends GraphQLJavaScalarTypeGenerator<SchemaContext, UUID>
3
implements ReservedTypeGenerator {
4
5
public UuidScalar() {
6
super(ExtendedScalars.UUID, UUID.class);
7
}
8
}

For custom scalar types, you'll need to implement the coercion logic yourself:

1
public class InstantScalar
2
extends ScalarTypeGenerator<SchemaContext, Instant>
3
implements ReservedTypeGenerator {
4
5
@Override
6
protected Object serialize(ScalarSerializationContext<C> context) {
7
Object input = context.getInput();
8
if (input instanceof Instant) {
9
return ((Instant) input).toString();
10
}
11
throw new CoercingSerializeException(
12
"Expected type 'Instant' but was '" + input.getClass().getName() + "'");
13
}
14
15
@Override
16
protected Instant parseValue(ScalarValueParsingContext<C> context) {
17
Object input = context.getInput();
18
if (input instanceof String) {
19
try {
20
return Instant.parse((String) input);
21
} catch (DateTimeParseException e) {
22
throw new CoercingParseValueException(
23
"Invalid ISO-8601 value : '" + input + "'");
24
}
25
}
26
throw new CoercingParseValueException(
27
"Expected type 'String' but was '" + input.getClass().getName() + "'");
28
}
29
}

When used with the ReservedTypeGenerator interface, you can use the static ScalarType.of(Class<G> type) method to create instances of the type:

1
ScalarType<SchemaContext, UUID> uuidType = ScalarType.of(UuidScalar.class);

Scalar fields can be used in various contexts:

  • As output fields using toOutputType()
  • As input fields using toInputType()
  • As arguments using toArgumentType()

Example usage in a type generator:

1
@Override
2
protected List<OutputField<SchemaContext, Article, ?>> getFields(
3
TypeLoadContext<SchemaContext> context
4
) {
5
return List.of(
6
ScalarType.of(UuidScalar.class)
7
.toOutputFieldType()
8
.named("id")
9
.fetching(this, Article::getId)
10
);
11
}

The framework provides several built-in scalar types for common use cases:

  • Basic Types: String, Int, Float, Boolean, ID
  • Numbers: Byte, Short, Long, BigInteger, BigDecimal
  • Time: Date, DateTime, Time, Duration, Instant
  • Text: URI, URL, Locale
  • Others: UUID, Void, JSON

Each built-in scalar includes appropriate validation and coercion logic, ensuring type safety and proper data handling throughout the GraphQL execution process.

directive Type Generator

Directive type generators enable the creation of custom GraphQL directives that can modify the behavior of types and fields in your schema. The framework provides two specialized directive generators for different use cases: query directives that are applied at runtime and SDL directives that are encoded into the schema definition.

To create a directive generator, extend either QueryDirectiveTypeGenerator or SDLDirectiveTypeGenerator with appropriate generic type parameters:

For Query Directives:

1
public abstract class QueryDirectiveTypeGenerator<C extends SchemaContext, F, A> {
2
// C: The schema context type
3
// F: The Java type for directive's fields
4
// A: The Java type for the directive
5
}

For SDL Directives:

1
public abstract class SDLDirectiveTypeGenerator<C extends SchemaContext, S> {
2
// C: The schema context type
3
// S: The Java type for parsed SDL directive
4
}

The key methods that must be implemented depend on which type of directive you're creating:

For Query Directives:

  1. getArguments(TypeLoadContext<C> context) - Returns the list of arguments accepted by this directive
  2. transformInput(InputProcessingContext<C, F> context) - Transforms the directive's arguments into the final output type
  3. getLocations(TypeLoadContext<C> context) - Returns the list of valid locations where this directive can be applied

For SDL Directives:

  1. getArguments(TypeLoadContext<C> context) - Returns the list of SDL-specific arguments for this directive
  2. getLocations(TypeLoadContext<C> context) - Returns the list of valid locations where this directive can be applied

Example of a query directive that enables debugging:

1
public class DebugDirective extends QueryDirectiveTypeGenerator<SchemaContext, Boolean, Boolean> {
2
3
@Override
4
protected List<ArgumentField<SchemaContext, Boolean>> getArguments(
5
TypeLoadContext<SchemaContext> context
6
) {
7
return List.of(
8
ScalarType.ofBoolean()
9
.toArgumentType()
10
.named("if")
11
.toArgument()
12
);
13
}
14
15
@Override
16
protected Boolean transformInput(InputProcessingContext<SchemaContext, Boolean> context) {
17
return context.getField("if").orElse(true);
18
}
19
20
@Override
21
protected List<QueryDirectiveLocation> getLocations(TypeLoadContext<SchemaContext> context) {
22
return List.of(
23
QueryDirectiveLocation.QUERY,
24
QueryDirectiveLocation.FIELD
25
);
26
}
27
}

Example of an SDL directive that adds complexity information to fields:

1
public class ComplexityDirective extends SDLDirectiveTypeGenerator<SchemaContext, Integer> {
2
3
@Override
4
protected List<SDLArgumentField<SchemaContext, Integer, ?>> getArguments(
5
TypeLoadContext<SchemaContext> context
6
) {
7
return List.of(
8
SDLArgumentField.of(
9
ScalarType.ofInt()
10
.toArgumentType()
11
.named("value")
12
.toArgument(),
13
(context, value) -> (Integer) value
14
)
15
);
16
}
17
18
@Override
19
protected List<SDLDirectiveLocation> getLocations(TypeLoadContext<SchemaContext> context) {
20
return List.of(SDLDirectiveLocation.FIELD_DEFINITION);
21
}
22
}

When used with the ReservedTypeGenerator interface, you can use the static DirectiveType.of(Class<G> type) method to create instances of the directive type.

Directive fields can be used in various contexts:

  • As query directives using QueryDirectiveTypeGenerator
  • As SDL directives using SDLDirectiveTypeGenerator
  • With arguments using toArgument()

The framework provides several built-in directives:

  • @complexity - Specifies the complexity cost of a field
  • @debug - Enables debugging information for fields
  • @sdl - Controls SDL inclusion in responses
  • @field_generator - Specifies field generator implementation details
  • @type_generator - Specifies type generator implementation details
  • @type_origin - Describes type origins in the schema
  • @type_stats - Provides schema statistics

Each built-in directive includes appropriate validation and processing logic, ensuring proper directive application throughout the GraphQL execution process.

Field Generators

Field generators are specialized components in the framework that handle the creation and management of individual fields within GraphQL types. Unlike Type Generators which define entire types, Field Generators focus on the granular definition of fields, their properties, and their behavior. They provide a type-safe way to define both output fields (for queries) and input fields (for mutations and arguments).

The base FieldGenerator<C extends SchemaContext> class defines common functionality shared by all field generators:

  • Field generation control through shouldGenerate(FieldLoadContext<C>)
  • Documentation via getDescription(FieldLoadContext<C>)
  • Directive application using getAppliedDirectives(FieldLoadContext<C>)
  • Deprecation management with getDeprecationReason(FieldLoadContext<C>)

Field generators are particularly useful when you have fields that:

  1. Require complex data fetching or transformation logic
  2. Need to be reused across multiple types
  3. Have dynamic properties based on context
  4. Require specialized validation or processing
Output Field Generator

The OutputFieldGenerator<C extends SchemaContext, S, T> class specializes in creating fields that return data in GraphQL queries and mutations. It uses three type parameters:

  • C: The schema context type
  • S: The source type (the Java type of the parent object)
  • T: The target type (the Java type that will be returned)

Key methods that must be implemented:

  1. getReturnType(FieldLoadContext<C>): Defines the GraphQL type returned by this field
  2. fetchField(DataFetchContext<C>, S): Implements the data fetching logic, converting the source object into the target type

Additional features include:

  • Argument definition through getArguments(FieldLoadContext<C>)
  • Field complexity specification via getComplexity(C)
  • Pre/post fetch hooks using prePostFieldFetch(DataFetchContext<C>, S)
  • Source type adaptation with fetching(FieldsContainerTypeGenerator, DataFetcher)

Example implementation:

1
public class ExampleOutputField extends OutputFieldGenerator<SchemaContext, Article, String> {
2
3
@Override
4
protected OutputFieldType<SchemaContext, String> getReturnType(
5
FieldLoadContext<SchemaContext> context
6
) {
7
return ScalarType.ofString().toOutputFieldType();
8
}
9
10
@Override
11
protected String fetchField(DataFetchContext<SchemaContext> context, Article source) {
12
return source.getTitle();
13
}
14
15
@Override
16
protected List<ArgumentField<SchemaContext, ?>> getArguments(
17
FieldLoadContext<SchemaContext> context
18
) {
19
return List.of(
20
ScalarType.ofBoolean()
21
.toArgumentType()
22
.named("uppercase")
23
.toArgument()
24
);
25
}
26
}
Input Field Generator

The InputArgumentFieldGenerator<C extends SchemaContext, T> class specializes in creating fields for input types and arguments. It uses two type parameters:

  • C: The schema context type
  • T: The Java type that this field will produce

Key method that must be implemented:

  • getReturnType(FieldLoadContext<C>): Defines the GraphQL type accepted by this field

The class provides two main ways to create fields:

  1. inputNamed(C, String): Creates an input field for use in input types
  2. argumentNamed(C, String): Creates an argument field for use in output fields

A powerful feature is the ability to coerce input values using:

  • coercing(Class<S>, ArgumentConverter<C, T, S>): Converts the input to a different type
  • coercing(TypeReference<S>, ArgumentConverter<C, T, S>): Same as above but supports generic types

Example implementation:

1
public class ExampleInputField extends InputArgumentFieldGenerator<SchemaContext, UUID> {
2
3
@Override
4
protected InputFieldType<SchemaContext, UUID> getReturnType(
5
FieldLoadContext<SchemaContext> context
6
) {
7
return ScalarType.of(UuidScalar.class)
8
.toInputFieldType()
9
.asNonNull();
10
}
11
}

Type Coercion

Type coercion in the GraphQL Framework provides mechanisms to safely transform between different Java types while maintaining type safety throughout the system. This is particularly important when working with Type and Field Generators, which use Java generics to ensure type safety between GraphQL types and their backing Java implementations.

The framework provides two distinct coercion patterns:

Output Field Coercion

Output field coercion allows you to register transformation functions that convert unexpected source types into the expected target type during data fetching. This is useful when you want to reuse an existing field generator but need to adapt its input type.

The API for output coercion is:

1
public <S> OutputFieldType<C, T> coercing(
2
Class<S> sourceClass,
3
Function<S, T> coercionFunction
4
)

For example, suppose you have a field generator that expects to work with Person objects, but in some contexts, you receive Employee objects that you want to adapt:

1
public class PersonFieldGenerator extends OutputFieldGenerator<SchemaContext, Person, String> {
2
@Override
3
protected OutputFieldType<SchemaContext, String> getReturnType(
4
FieldLoadContext<SchemaContext> context
5
) {
6
return ScalarType.ofString()
7
.toOutputFieldType()
8
.coercing(Employee.class, Employee::getFullName);
9
}
10
11
@Override
12
protected String fetchField(DataFetchContext<SchemaContext> context, Person source) {
13
return source.getName();
14
}
15
}

You can chain multiple coercions to handle different source types:

1
ScalarType.ofString()
2
.toOutputFieldType()
3
.coercing(Employee.class, Employee::getFullName)
4
.coercing(Customer.class, Customer::getDisplayName)
5
.named("name")
6
.fetching(this, source -> source.getName());

Input Field Coercion

Input field coercion transforms the type signature of an input field, allowing you to adapt incompatible input types. This is particularly useful when you want to reuse existing input types but need to transform their output to match your requirements.

The framework provides four input coercion methods:

1
// For non-null inputs with Class
2
<S> InputFieldType<C, S> coercing(
3
Class<S> destinationClass,
4
ArgumentConverter<C, T, S> coercingConverter
5
);
6
7
// For non-null inputs with TypeReference (supports generics)
8
<S> InputFieldType<C, S> coercing(
9
TypeReference<S> destinationType,
10
ArgumentConverter<C, T, S> coercingConverter
11
);
12
13
// For nullable inputs with Class
14
<S> InputFieldType<C, S> coercingNullable(
15
Class<S> destinationClass,
16
ArgumentConverter<C, T, S> coercingConverter
17
);
18
19
// For nullable inputs with TypeReference (supports generics)
20
<S> InputFieldType<C, S> coercingNullable(
21
TypeReference<S> destinationType,
22
ArgumentConverter<C, T, S> coercingConverter
23
);

Here's an example where we adapt a string-based ID input to return a UUID:

1
public class IdInputField extends InputArgumentFieldGenerator<SchemaContext, String> {
2
@Override
3
protected InputFieldType<SchemaContext, String> getReturnType(
4
FieldLoadContext<SchemaContext> context
5
) {
6
return ScalarType.ofString()
7
.toInputFieldType()
8
.coercing(UUID.class, (ctx, value) -> UUID.fromString(value));
9
}
10
}

You can also use coercion with generic types using TypeReference:

1
public class ListInputField extends InputArgumentFieldGenerator<SchemaContext, String> {
2
@Override
3
protected InputFieldType<SchemaContext, String> getReturnType(
4
FieldLoadContext<SchemaContext> context
5
) {
6
return ScalarType.ofString()
7
.toInputFieldType()
8
.coercing(
9
new TypeReference<List<UUID>>() {},
10
(ctx, value) -> Arrays.stream(value.split(","))
11
.map(UUID::fromString)
12
.collect(Collectors.toList())
13
);
14
}
15
}

The key difference between output and input coercion is:

  • Output coercion registers multiple transformation functions while maintaining the original return type
  • Input coercion changes the type signature of the field to a new type, with a single transformation function

Both patterns ensure type safety is maintained throughout the system while providing flexibility to adapt and reuse existing generators in different contexts.

Advanced Data Fetching

The Brightspot GraphQL Framework provides several advanced data fetching capabilities that give developers fine-grained control over how data is fetched and processed during query execution.

DataFetchContext and Field Metadata

The DataFetchContext class provides rich metadata about the current field being fetched. It extends FieldExecutionContext and implements ArgumentFieldMapSupplier, giving you access to both field execution state and processed arguments.

Key APIs for accessing field metadata:

1
public class DataFetchContext<C extends SchemaContext> {
2
// Get the name of the current field
3
public String getFieldName();
4
5
// Get the full path of the field in the query
6
public String getFieldPath();
7
8
// Get processed arguments with proper Java types
9
public ArgumentFieldMap<?> getArgumentFieldMap();
10
11
// Get raw arguments as Map<String, Object>
12
public Map<String, Object> getRawArguments();
13
}

Example usage:

1
public class ExampleField extends OutputFieldGenerator<SchemaContext, Article, String> {
2
@Override
3
protected String fetchField(DataFetchContext<SchemaContext> context, Article source) {
4
// Get field name and path
5
String fieldName = context.getFieldName(); // e.g. "title"
6
String fieldPath = context.getFieldPath(); // e.g. "article/title"
7
8
// Get a typed argument
9
UUID id = context.getArgumentFieldMap().get("id", UUID.class);
10
11
// Get raw argument value
12
Object rawId = context.getRawArguments().get("id");
13
14
return source.getTitle();
15
}
16
}

Local Context Management

The framework provides a hierarchical context system through FieldExecutionContext that allows you to store and retrieve metadata that is scoped to the current field and its sub-fields. This is particularly useful for passing data down the field resolution chain without polluting the global context.

Key APIs for managing local context:

1
public class FieldExecutionContext<C extends SchemaContext> {
2
// Store a value in local context
3
public void putLocalContext(String key, Object value);
4
5
// Retrieve a value from local context
6
public <T> T getLocalContext(String key);
7
8
// Retrieve with default value
9
public <T> T getLocalContextOrDefault(String key, T defaultValue);
10
11
// Remove a value from local context
12
public void removeLocalContext(String key);
13
}

Example usage showing how local context is propagated to sub-fields:

1
public class ParentField extends OutputFieldGenerator<SchemaContext, Parent, Child> {
2
@Override
3
protected Child fetchField(DataFetchContext<SchemaContext> context, Parent source) {
4
// Store data in local context
5
context.putLocalContext("parentId", source.getId());
6
return source.getChild();
7
}
8
}
9
10
public class ChildField extends OutputFieldGenerator<SchemaContext, Child, String> {
11
@Override
12
protected String fetchField(DataFetchContext<SchemaContext> context, Child source) {
13
// Access data from parent field
14
UUID parentId = context.getLocalContext("parentId");
15
return source.getName() + " (Parent: " + parentId + ")";
16
}
17
}

The local context is automatically propagated down to sub-fields but is not accessible to sibling or parent fields, maintaining proper data isolation.

Pre/Post Field Fetch Operations

The prePostFieldFetch API in OutputFieldGenerator provides a powerful mechanism to execute operations before a field is fetched and after all its sub-fields have completed. This is particularly useful for setting up and cleaning up resources that need to be available during the entire field resolution process.

1
protected Consumer<FieldResolutionContext<C, T>> prePostFieldFetch(
2
DataFetchContext<C> context,
3
S source
4
)

The method:

  1. Executes before the field's fetchField operation
  2. Returns a callback that executes after all sub-fields have been resolved
  3. Guarantees cleanup even if errors occur during field resolution

Example showing database override management:

1
public class DatabaseOverrideField extends OutputFieldGenerator<SchemaContext, Record, Record> {
2
@Override
3
protected Consumer<FieldResolutionContext<SchemaContext, Record>> prePostFieldFetch(
4
DataFetchContext<SchemaContext> context,
5
Record source
6
) {
7
// Pre-fetch: Set up database override
8
Database originalDb = Database.Current.get();
9
Database overrideDb = getOverrideDatabase();
10
Database.Current.set(overrideDb);
11
12
// Return post-fetch callback
13
return resolution -> {
14
try {
15
// Access field result if needed
16
Record result = resolution.getObject();
17
18
// Check for errors
19
if (resolution.getFetchError() != null) {
20
handleError(resolution.getFetchError());
21
}
22
23
if (resolution.getExecutionError() != null) {
24
handleError(resolution.getExecutionError());
25
}
26
} finally {
27
// Always restore original database
28
Database.Current.set(originalDb);
29
}
30
};
31
}
32
33
@Override
34
protected Record fetchField(DataFetchContext<SchemaContext> context, Record source) {
35
// This executes with overrideDb active
36
return source;
37
}
38
}

This pattern is particularly useful for:

  • Managing database connections or transactions
  • Setting up and tearing down thread-local state
  • Applying and removing context-specific configurations
  • Measuring performance of field resolution including sub-fields
  • Ensuring proper cleanup of resources regardless of success or failure

The framework guarantees that the post-fetch callback will be executed even if errors occur during field resolution or in sub-fields, making it safe for critical cleanup operations.

Extensions

The GraphQL framework provides a powerful mechanism to populate the extensions map in the GraphQL response. This feature is accessible to any method that has access to an ExecutionContext or one of its sub-classes. The extensions map is stored in the global context and can be accessed through the #getExtensions() method, which returns a mutable map that will be included in the extensions part of the GraphQL response.

1
public class CustomField extends OutputFieldGenerator<SchemaContext, Source, Target> {
2
@Override
3
protected Target fetchField(DataFetchContext<SchemaContext> context, Source source) {
4
// Access the extensions map
5
Map<String, Object> extensions = context.getExtensions();
6
7
// Add custom data to extensions
8
extensions.put("customKey", "customValue");
9
10
return fetchTarget(source);
11
}
12
}

The framework includes several built-in uses of extensions:

  1. Error Extensions: When exceptions occur, they can include additional data in the extensions map of the error response:
1
public class CustomException extends GraphQLRequestException {
2
@Override
3
public void addDefaultExtensions() {
4
super.addDefaultExtensions();
5
addExtension("customErrorData", "error details");
6
}
7
}
  1. Query Complexity: The framework tracks query complexity and includes this information in extensions:
1
{
2
"data": { ... },
3
"extensions": {
4
"complexity": {
5
"static": [{
6
"consumed": 5,
7
"remaining": 95
8
}]
9
}
10
}
11
}
  1. Debug Information: When debugging is enabled via the @debug directive, debug information is included in extensions:
1
query @debug {
2
field
3
}
  1. Schema Definition: The raw SDL can be included in extensions using the @sdl directive:
1
query @sdl {
2
__typename
3
}

Extensions provide a flexible way to include metadata, debugging information, or any additional context about the query execution that doesn't fit into the standard response format.