Automatically Generated GraphQL Middleware Service
I delved into the feasibility of creating a fully automated GraphQL middleware service. The goal was to develop a universal middleware solution that could be seamlessly adapted to various projects.
Frameworks, Packages, and Tools
• @apollo/server
• @prisma/client
• graphql
• reflect-metadata
• type-graphql
• prisma
• typegraphql-prisma
How It Works - A Quick Introduction to GraphQL
GraphQL operates on the basis of schemas that outline the structure of the data to be returned by resolver functions. Typically, when setting up a GraphQL service, a schema might be defined as follows:
type Book {
title: String
author: Author
}
type Author {
name: String
books: [Book]
}
This schema can be highly detailed, with various field types, including nullable fields, lists, and nested objects. The GraphQL server uses this schema to validate incoming queries and will return an error if the request doesn't adhere to it.
For example, a GraphQL query might look like this:
query GetBooks {
books {
title
author {
name
}
}
}
Based on this query, the GraphQL server identifies the necessary entities and fields to resolve. It then calls the appropriate resolver functions, which are attached to the schema.
Understanding Resolver Functions
Resolver functions are responsible for fetching and returning data in response to queries. They might be structured like this:
const resolvers = {
Query: {
author(parent, args, contextValue, info) {
return authors.find((author) => author.name === args.name);
},
},
};
This function receives several parameters:
• parent
: The return value of the previous resolver in the chain.
• args
: An object containing the arguments provided for this field, such as search parameters.
• contextValue
: An object shared across the entire resolver chain, often including authentication details.
• info
: Provides information about the execution state of the operation, useful for logging or monitoring purposes, though less commonly used in functional logic.
TypeGraphQL
When building a "pure" GraphQL setup, maintaining the codebase can become challenging, particularly because GraphQL isn’t natively strictly typed. This is where tools like TypeGraphQL become invaluable. TypeGraphQL allows developers to define schemas, types, and resolvers in TypeScript using classes and decorators, offering a more structured and maintainable approach.
TypeGraphQL is one of the few packages that integrates seamlessly with Prisma (ORM), to automatically generate GraphQL schemas, types, and resolvers. Another similar tool is Nexus, but for this proof of concept, TypeGraphQL was the primary choice. The decision was based on its robust integration and lack of issues during implementation, so Nexus wasn’t explored further.
The beauty of this setup is that all the necessary TypeGraphQL code is auto-generated. As a result, there's no need to manually interact with or modify the TypeGraphQL code during this process.
Prisma
Prisma is a powerful ORM that, like many others, relies on a schema to describe the data structure. It simplifies database interactions by generating SQL queries from TypeScript functions and types.
One of Prisma's standout features is its ability to generate a schema directly from a connected database using the prisma db pull
command. This means the data structure only needs to be defined once, in the database itself, maintaining a single source of truth.
TypeGraphQL-Prisma Integration
The integration of TypeGraphQL with Prisma is where the real magic happens. The TypeGraphQL-Prisma plugin enables the automatic generation of GraphQL schemas and resolvers based on the Prisma schema. The generated resolvers align closely with the existing Prisma API, providing full CRUD capabilities, including filtering, pagination, aggregation, and mutations, just as you would expect from Prisma.
Despite the automated generation, the plugin doesn't restrict developers to only the generated resolvers. It allows for the creation of custom resolvers, making it easy to extend the schema with additional functionality where more fine-tuned optimization is required.
To enable this feature, it's as simple as installing the necessary package and adding the following configuration to the schema.prisma
file:
generator typegraphql {
provider = "typegraphql-prisma"
useOriginalMapping = true
}
Running the prisma generate
command then triggers the plugin, which generates all the necessary TypeGraphQL classes, types, and resolvers automatically—a truly powerful and convenient feature.
Apollo GraphQL
The final piece of the puzzle is setting up a GraphQL server to serve the generated code as an API. There are various options available for this, but Apollo GraphQL was chosen due to its popularity and my familiarity with its robust features. Apollo is widely recognized in the community, making it a reliable choice for exposing the GraphQL API to a frontend application.
Implementation Middleware Service
For the most basic implementation of this proof of concept (POC), the following code demonstrates how to set up the middleware service:
// reflect-metadata import MUST be the first import.
import "reflect-metadata";
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { resolvers } from "@generated/type-graphql";
import { PrismaClient } from "@prisma/client";
import { buildSchema } from "type-graphql";
import { customAuthChecker } from "./authorization/authchecker";
import { Role } from "./authorization/role";
async function bootstrap() {
const schema = await buildSchema({
resolvers,
validate: true,
authChecker: customAuthChecker,
});
const prisma = new PrismaClient();
const server = new ApolloServer({ schema });
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => {
// Get the user token from the headers.
const token = req.headers.authorization || '';
const userRoles = await getRoles(token);
return {
prisma,
userRoles,
};
},
});
console.log(`🚀 Server ready at: ${url}`);
}
bootstrap().catch(console.error);
Build Schema
The core of this implementation lies in building the GraphQLSchema
object using the buildSchema
method from TypeGraphQL. This schema object adheres to the GraphQL specification and serves as the bridge between TypeGraphQL and the native GraphQL ecosystem. This approach ensures that the generated schema is fully compatible with any GraphQL server, allowing for flexibility in server selection.
In the code snippet:
import { resolvers } from "@generated/type-graphql";
import { buildSchema } from "type-graphql";
import { customAuthChecker } from "./authorization/authchecker";
async function bootstrap() {
const schema = await buildSchema({
resolvers,
validate: true,
authChecker: customAuthChecker,
});
}
The buildSchema
function is where the schema is constructed, incorporating the resolvers and the custom authorization checker.
Instantiate Prisma and Apollo
After the schema is built, the next step involves instantiating key objects: the Prisma client and the Apollo server.
const prisma = new PrismaClient();
const server = new ApolloServer({ schema });
The Prisma client is particularly significant as it is auto-generated based on your database schema, ensuring that all necessary TypeScript types are available and correctly typed.
Start Server
To quickly start a GraphQL API, the startStandaloneServer
function from Apollo is used. This function is ideal for POCs or simple implementations, though you could use other frameworks like NestJS, Express, or even serverless environments like AWS Lambda for production setups.
The context
function is a critical part of this setup, as it provides the context object passed to the resolvers. This object can include various elements such as user roles, the Prisma client, and other relevant data.
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => {
// Get the user token from the headers.
const token = req.headers.authorization || '';
const userRoles = await getRoles(token);
return {
prisma,
userRoles,
};
},
});
This setup allows you to define roles for the current user, which can be used in the authorization logic.
Authorization
To handle authorization effectively, TypeGraphQL-Prisma offers a robust solution that can be applied to resolvers, models, relationships, and aggregation functions. These rules must be defined before the buildSchema
function is executed, ensuring they are incorporated into the schema.
Example of Applying Authorization Rules
Authorization rules can be applied as follows:
import {
ResolversEnhanceMap,
ModelsEnhanceMap,
RelationResolversEnhanceMap,
OutputTypesEnhanceMap,
applyResolversEnhanceMap,
applyModelsEnhanceMap,
applyRelationResolversEnhanceMap,
applyOutputTypesEnhanceMap
} from "@generated/typegraphql"
async function bootstrap() {
const resolversEnhanceMap: ResolversEnhanceMap = {
book: bookResolverAuth,
};
const modelsEnhanceMap: ModelsEnhanceMap = {
book: bookModelAuth,
};
const relationResolversEnhanceMap: RelationResolversEnhanceMap = {
book: bookModelRelationAuth,
};
const outputTypesEnhanceMap: OutputTypesEnhanceMap = {
AggregateBook: bookAggregateAuth,
};
applyResolversEnhanceMap(resolversEnhanceMap);
applyModelsEnhanceMap(modelsEnhanceMap);
applyRelationResolversEnhanceMap(relationResolversEnhanceMap);
applyOutputTypesEnhanceMap(outputTypesEnhanceMap);
const schema = await buildSchema({
resolvers,
validate: true,
authChecker: customAuthChecker,
});
}
Model Authorization
An example of model-level authorization might look like this:
import { ModelConfig } from "@generated/type-graphql";
import { Authorized } from "type-graphql";
import { Role } from "../role";
export const bookModelAuth: ModelConfig<"Book"> = {
fields: {
_all: [Authorized(Role.USER)], // default for all fields
title: () => [Authorized(Role.ADMIN)], // override for specific field
/** Override default
* the function variant overrides the default, but takes the default as a parameter,
* so you can leverage that to combine the _all and selected method decorators in a desired way
*/
summary: (defaultRoles) => [...defaultRoles, Role.Admin]
},
};
Relation Authorization
An example of relation-level authorization might look like this:
import { RelationResolverActionsConfig } from "@generated/type-graphql";
import { Authorized } from "type-graphql";
import { Role } from "../role";
export const bookModelRelationAuth: RelationResolverActionsConfig<"Book"> = {
_all: [Authorized(Role.USER)], // default for all relations
book_author: () => [Authorized(Role.USER)]
};
Aggregation Authorization
An example of aggregation-level authorization (over an entire entity more generally) might look like this:
import { OutputTypeConfig } from "@generated/type-graphql";
import { Authorized } from "type-graphql";
import { Role } from "../role";
export const bookAggregateAuth: OutputTypeConfig<"AggregateBook"> = {
_all: [Authorized(Role.USER)], // default for all aggregations
_count: () => [Authorized(Role.ADMIN)],
//_avg: (defaultRoles) => [],
//_sum: (defaultRoles) => [],
//_min: (defaultRoles) => [],
//_max: (defaultRoles) => [],
};
An example of aggregation-level authorization (on specific entity fields) might look like this:
import { OutputTypeConfig } from "@generated/type-graphql";
import { Authorized } from "type-graphql";
import { Role } from "../role";
export const bookAvgAuth: OutputTypeConfig<"BookAvgAggregate"> = {
_all: [Authorized(Role.USER)], // default for all aggregations
word_count: () => [Authorized(Role.ADMIN)],
};
//Other types of aggregations to possibly write authorization rules for:
//bookAvgAggregate
//bookCount
//bookCountAggregate
//bookGroupBy
//bookMaxAggregate
//bookMinAggregate
//bookSumAggregate
Resolver Authorization
For resolvers, you can define authorization rules like this:
import { ResolverActionsConfig } from "@generated/type-graphql";
import { Authorized } from "type-graphql";
import { Role } from "../role";
export const bookResolverAuth: ResolverActionsConfig<"Book"> = {
_all: [Authorized(Role.USER)], // default for all resolvers
createManyBook: () => [Authorized(Role.ADMIN)], // specific resolver rule
createOneBook: () => [Authorized("CONDITIONAL_BOOK_TYPE")], // conditional authorization
};
Conditional Authorization
Conditional authorization adds another layer of security by basing permissions on the input data for a mutation. For example, you might want to restrict certain actions to specific roles based on the value of a field in the input:
export const conditionalCreateOneBookAuth: Partial<Record<string, (args: { data: BookCreateInput }) => Role[]>> = {
CONDITIONAL_BOOK_TYPE: (args: { data: BookCreateInput }) => {
const input = args?.data;
if (input.type === "protected") {
return [Role.ADMIN];
}
if (input.type === "public") {
return [Role.USER];
}
return [];
},
};
Custom Authorization Checker
The custom authorization checker evaluates whether the current context meets the authorization requirements defined for each resolver, model, field, relation, or aggregation:
cosnt conditionalAuth: Partial<Record<string, (args: any) => Role[]>> = {
...conditionalCreateOneBookAuth
}
export const customAuthChecker: AuthChecker<{ userRoles: Role[] }> = ({ context, args }, authRoles) => {
const conditionalRole = authRoles.find((role) => !!conditionalAuth[role]);
if (conditionalRole) {
authRoles = (conditionalAuth[conditionalRole]?.(args) || []) as string[];
}
if (authRoles.length === 0) {
return false;
}
const authenticated = authRoles.some((authRole) => context.userRoles.includes(authRole as Role));
return authenticated;
};
This customAuthChecker
is used during initialization of the setup:
const schema = await buildSchema({
resolvers,
validate: true,
authChecker: customAuthChecker,
});
This implementation ensures that roles are checked against both static and dynamic conditions, providing robust security tailored to your application's needs.
Pros & Cons of the Approach
Pros:
1. Schema Synchronization: The middleware setup ensures the database and API schema remain in sync automatically, minimizing manual intervention and reducing the potential for discrepancies. Only the authorization rules would have to be maintained.
2. Future proof: The generated resolvers seem to cover pretty much all needs for any standard frontend you might develop. While keeping the option to implement custom resolvers within the same development paradigm.
3. Flexible and Efficient Data Fetching: GraphQL allows clients to request only the data they need, avoiding over-fetching. This not only optimizes payload sizes but also reduces the number of network requests, leading to better performance.
4. Single API Endpoint: By using GraphQL, all data operations are consolidated under a single endpoint, simplifying the API structure and reducing complexity in managing multiple endpoints.
5. Ease of Understanding with GraphiQL: The schema-based nature of GraphQL, along with tools like GraphiQL, makes it easier for developers to explore and understand the API, thus improving developer experience.
6. Incremental Evolution of Schema: Fields can be added or modified in the schema without impacting clients that do not use those fields, allowing for smoother API evolution.
7. Rich Ecosystem: The GraphQL ecosystem is robust, with numerous tools and libraries available across various platforms, aiding in faster development and integration.
Cons:
1. Potential Complexity: Implementing and maintaining GraphQL servers can become complex, especially with custom data resolvers for different scenarios. While TypeGraphQL-Prisma abstracts some of this complexity, there’s a risk of encountering issues in auto-generated resolvers, which might be challenging to fix.
2. Caching Challenges: Unlike REST, where caching is more straightforward due to predictable endpoints, caching in GraphQL can be more complicated. Each query is unique, making traditional caching mechanisms less effective.
3. Performance Concerns: Complex and overly nested queries or inefficient resolvers can impact server performance. It could be that the generated TypeGraphQL resolvers do something smart for this issue. For example to use the dataloader pattern. However, I have not had the opportunity yet to investigate this topic.
4. Adoption and Skill Gap: Despite its growing popularity, GraphQL is still not as widely adopted as REST. This might present a learning curve for teams unfamiliar with the technology.
5. Security Considerations: With GraphQL’s single endpoint, securing the API becomes more complex. Fine-grained control over authorization at the resolver, model, and field levels is necessary, making it more challenging compared to REST.
Todo
While the POC has demonstrated the potential of this stack, several areas require further exploration to fully assess its viability for production use:
-
Automated Deployment Pipeline: Investigate the possibility of setting up an automated CI/CD pipeline that:
- Triggers schema generation (
prisma db pull
) and resolver generation (prisma generate
) on database changes. - Automates the addition or removal of authorization rules, creating a PR for review.
- Deploys the updated middleware upon PR merge.
- Triggers schema generation (
- Explore Nexus as an Alternative: Nexus could be a viable alternative to TypeGraphQL for generating resolvers and schema. Evaluating its performance and ease of use compared to TypeGraphQL is worth considering.
- Performance Testing: Conduct performance tests with large and complex GraphQL queries to identify any potential issues with the N+1 problem. This might involve examining whether the generated resolvers use optimizations like the dataloader pattern.
Top comments (0)