graphql-gene
v1.3.4
Published
Generates automatically an executable schema out of your ORM models
Maintainers
Readme
GraphQL Gene
Use graphql-gene to generate automatically an executable schema out of your ORM models. Everything is fully typed and define once for both GraphQL and Typescript types. See Highlights section for more.
❤️ Provided by Accès Impôt's engineering team
| | | :---: | | 🇨🇦 Online tax declaration service 🇨🇦 |
Table of contents
Highlights
- ⚡️ Performant - Automatically avoid querying nested database relationships if they are not requested.
- 🔒 Secure - Easily create and share directives at the type or field level (i.e.
@userAuth). - ⏰ Time-to-delivery - No time wasted writing similar resolvers.
- 🧩 Resolver template - Generates the resolver for you with deep
whereargument and more. - Type safe - Resolver arguments and return value are deeply typed.
- 🎯 One source of truth - Types are defined once and shared between GraphQL and Typescript.
- 💥 Works with anything - New or existing projects. Works with any GraphQL servers, ORM, or external sources.
- 🔌 Plugins - Simple plugin system to potentially support any Node.js ORM. See Writing a Plugin.
Quick Setup
Install graphql-gene with the plugin you need for your ORM:
# pnpm
pnpm add graphql-gene @graphql-gene/plugin-sequelize
# yarn
yarn add graphql-gene @graphql-gene/plugin-sequelize
# npm
npm i graphql-gene @graphql-gene/plugin-sequelizeExport all models from one file
Create a file where you export all your GraphQL types including your database models, but also basic GraphQL types, inputs, enums.
src/models/graphqlTypes.ts
import { defineEnum, defineType } from 'graphql-gene'
// All your ORM models
export * from './models'
// i.e. some basic GraphQL types
export const MessageOutput = defineType({
type: 'MessageTypeEnum!',
text: 'String!',
})
export const MessageTypeEnum = defineEnum(['info', 'success', 'warning', 'error'])
// i.e. assuming AuthenticatedUser is defined as alias in User.geneConfig
export { User as AuthenticatedUser, MutationLoginOutput } from '../models/User/User.model'Typing
You can now create a declaration file to define the GeneContext and GeneSchema types used by graphql-gene. You need to use the GeneTypesToTypescript utility to type every GraphQL types in GeneSchema.
You can also extend the context based on the GraphQL server you're using (optional).
src/types/graphql-gene.d.ts
import type { GeneTypesToTypescript } from 'graphql-gene'
import type { YogaInitialContext } from 'graphql-yoga'
import * as graphqlTypes from '../models/graphqlTypes'
declare module 'graphql-gene/schema' {
export interface GeneSchema extends GeneTypesToTypescript<typeof graphqlTypes> {
Query: object
Mutation: object
}
}
declare module 'graphql-gene/context' {
export interface GeneContext extends YogaInitialContext {}
}Generate the schema
The last step is to call generateSchema and pass the returned schema to your GraphQL server. You simply have to pass all types imported from graphqlTypes.ts as shown in the example below.
Please note that graphql-gene is using Date, DateTime, or JSON for certain data types. If you don't provide scalars for them, they will fallback to the String type.
You can use the resolvers option to provide the scalars as it accepts both resolvers and scalar objects (resolvers?: { [field: string]: GraphQLFieldResolver } | GraphQLScalarType).
If you follow the example below, you will also need to install graphql-scalars.
src/server/schema.ts
import { DateResolver, DateTimeResolver, JSONResolver } from 'graphql-scalars'
import { generateSchema } from 'graphql-gene'
import { pluginSequelize } from '@graphql-gene/plugin-sequelize'
import * as graphqlTypes from '../models/graphqlTypes'
const {
typeDefs,
resolvers,
schema,
schemaString,
schemaHtml,
} = generateSchema({
resolvers: {
Date: DateResolver,
DateTime: DateTimeResolver,
JSON: JSONResolver,
},
plugins: [pluginSequelize()],
types: graphqlTypes,
})
export { typeDefs, resolvers, schema, schemaString, schemaHtml }The schema returned is an executable schema so you can simply pass it to your GraphQL server:
src/server/index.ts
import { createServer } from 'node:http'
import { createYoga } from 'graphql-yoga'
import { schema } from './schema'
const yoga = createYoga({ schema })
const server = createServer(yoga)
server.listen(4000, () => {
console.info('Server is running on http://localhost:4000/graphql')
})You can also pass typeDefs and resolvers to a function provided by your GraphQL server to create the schema:
import { createServer } from 'node:http'
import { createSchema, createYoga } from 'graphql-yoga'
import { typeDefs, resolvers } from './schema'
const schema = createSchema({ typeDefs, resolvers })
const yoga = createYoga({ schema })
const server = createServer(yoga)
server.listen(4000, () => {
console.info('Server is running on http://localhost:4000/graphql')
})Allow inspecting the generated schema
You can look at the schema in graphql language using schemaString and schemaHtml returned by generateSchema.
schemaString: you can generate a file like schema.gql that you add to.gitignorethen use it to inspect the schema in your code editor.schemaHtml: you can add a HTML endpoint like/schemaand respond withschemaHtml. The HTML comes with syntax highlighting provided by unpkg.com and highlight.js.
Here's an example using Fastify:
src/server/index.ts
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import fastify from 'fastify'
import { schema, schemaString, schemaHtml } from './schema'
//
// Your GraphQL server code
//
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const app = fastify({ logger: true })
if (process.env.NODE_ENV !== 'production') {
// Expose schema as HTML page with graphql syntax highlighting
app.get('/schema', (_, reply) => reply.type('text/html').send(schemaHtml))
// Generate a .gql file locally (no need to await)
fs.promises.writeFile(
path.resolve(__dirname, '../../schema.gql'),
schemaString
)
}Query filtering
No need to write Query resolvers anymore! You can simply use the filter arguments that are automatically defined when using the default resolver at the Query level. The same filter arguments are also defined on all association fields so you can also query with nested filters.
Default resolver
import { Model } from 'sequelize'
import { extendTypes } from 'graphql-gene'
export class Product extends Model {
// ...
}
extendTypes({
Query: {
products: {
resolver: 'default',
returnType: '[Product!]',
},
},
})query productsByColor($color: String) {
products(where: { color: { eq: $color } }, order: [name_ASC]) {
id
name
color
# Association fields also have filters
variants(where: { size: { in: ["US 10", "US 11"] } }) {
id
size
}
}
}Filter arguments
| Argument | Description |
| :--- | :---------- |
| id | String - Entry id (only available for fields returning a single entry). |
| page | Int - Page number for query pagination. Default: 1. |
| perPage | Int - Amount of results per page. Default: 10. |
| where | Record<Attribute, Record<Operator, T>> - Where options generated based on the fields of the return type (i.e. where: { name: { eq: "Foo" } }). |
| order | [foo_ASC] - Array of enum values representing the order in which the results should be sorted. The enum values are defined based on the attribute name + _ASC or _DESC (i.e. order: [name_ASC, foo_DESC]). |
Operators
Generic operators
| Operator | Description |
| :--- | :---------- |
| eq | T - The value equals to... |
| ne | T - The value does not equals to... |
| in | [T] - The value is in... |
| notIn | [T] - The value is not in... |
| null | Boolean - The value is null if true. The value is not null if false. |
| and | [CurrentWhereOptionsInput!] - Array of object including the same operators. It represents a set of and conditions. |
| or | [CurrentWhereOptionsInput!] - Array of object including the same operators. It represents a set of or conditions. |
String operators
| Operator | Description |
| :--- | :---------- |
| like | String - The value is like... (i.e. { like: "%foo%" }) |
| notLike | String - The value is not like... (i.e. { notLike: "%foo%" }) |
Date and number operators
| Operator | Description |
| :--- | :---------- |
| lt | T - The value is less than... |
| lte | T - The value is less than or equal to... |
| gt | T - The value is greater than... |
| gte | T - The value is greater than or equal to... |
Gene config
By default, if a model is part of the types provided to generateSchema, it will be added to your schema.
Nevertheless, you might need to exclude some fields like password, define queries or mutations. You can set GraphQL-specific configuration by adding a static readonly geneConfig object to your model (more examples below) or use extendTypes to add fields to Query/Mutation.
import { Model } from 'sequelize'
import { defineGraphqlGeneConfig, extendTypes } from 'graphql-gene'
export class User extends Model {
// ...
static readonly geneConfig = defineGraphqlGeneConfig(User, {
// Your config
})
}
extendTypes({
Query: {
foo: {
// ...
},
},
})Options
| Name | Description |
| :--- | :---------- |
| include❔ | (InferFields<M> \| RegExp)[] - Array of fields to include in the GraphQL type. Default: all included. |
| exclude❔ | (InferFields<M> \| RegExp)[] - Array of fields to exclude in the GraphQL type. Default: ['createdAt', updatedAt']. |
| includeTimestamps❔ | boolean \| ('createdAt' \| 'updatedAt')[] - Include the timestamp attributes or not. Default: false. |
| varType❔ | GraphQLVarType - The GraphQL variable type to use. Default: 'type'. |
| directives❔ | GeneDirectiveConfig[] - Directives to apply at the type level (also possible at the field level). |
| aliases❔ | Record<GraphqlTypeName], GeneConfig> - The values of "aliases" would be nested GeneConfig properties that overwrites the ones set at a higher level. This is useful for instances with a specific scope include more fields that the parent model (i.e. AuthenticatedUser being an alias of User). Note that the alias needs to be exported from graphqlTypes.ts as well (i.e. export { User as AuthenticatedUser } from '../models/User/User.model'). |
Define queries/mutations inside your model
src/models/Prospect/Prospect.model.ts
import type { InferAttributes, InferCreationAttributes } from 'sequelize'
import { Model, Table, Column, Unique, AllowNull, DataType } from 'sequelize-typescript'
import { defineEnum, defineType, extendTypes } from 'graphql-gene'
import { isEmail } from '../someUtils.ts'
export
@Table
class Prospect extends Model<InferAttributes<Prospect>, InferCreationAttributes<Prospect>> {
declare id: CreationOptional<number>
@Unique
@AllowNull(false)
@Column(DataType.STRING)
declare email: string
@Column(DataType.STRING)
declare language: string | null
}
extendTypes({
Mutation: {
registerProspect: {
args: { email: 'String!', locale: 'String' },
returnType: 'MessageOutput!',
resolver: async ({ args }) => {
// `args` type is inferred from the GraphQL definition above
// { email: string; locale: string | null | undefined }
const { email, locale } = args
if (!isEmail(email)) {
// The return type is deeply inferred from the `MessageOutput`
// definition. For instance, the `type` value must be:
// 'info' | 'success' | 'warning' | 'error'
return { type: 'error' as const, text: 'Invalid email' }
}
// No need to await
Prospect.create({ email, language: locale })
return { type: 'success' as const }
},
},
},
})
export const MessageOutput = defineType({
type: 'MessageTypeEnum!',
text: 'String',
})
export const MessageTypeEnum = defineEnum(['info', 'success', 'warning', 'error'])Define directives
geneConfig.directives accepts an array of GeneDirectiveConfig which will add the directive at the type level (current model). It is recommended to create directives as factory function using defineDirective for better typing (see example below).
Directives are simply wrappers around resolvers following a middleware pattern. At the type level, it looks across your whole schema and wrap the resolver of fields returning the given type. This way, the field itself returns null on error instead of returning an object with all its fields being null.
type GeneDirectiveConfig<
TDirectiveArgs =
| Record<string, string | number | boolean | string[] | number[] | boolean[] | null>
| undefined,
TSource = Record<string, unknown> | undefined,
TContext = GeneContext,
TArgs = Record<string, unknown> | undefined,
> = {
name: string
args?: TDirectiveArgs
handler: GeneDirectiveHandler<TSource, TContext, TArgs>
}
type GeneDirectiveHandler<TSource, TContext, TArgs, TResult = unknown> = (options: {
source: Parameters<GraphQLFieldResolver<TSource, TContext, TArgs, TResult>>[0]
args: Parameters<GraphQLFieldResolver<TSource, TContext, TArgs, TResult>>[1]
context: Parameters<GraphQLFieldResolver<TSource, TContext, TArgs, TResult>>[2]
info: Parameters<GraphQLFieldResolver<TSource, TContext, TArgs, TResult>>[3]
resolve: () => Promise<TResult> | TResult
}) => Promise<void> | voidExample: User authentication directive
src/models/User/userAuthDirective.ts
import { GraphQLError } from 'graphql'
import { defineDirective } from 'graphql-gene'
import { getQueryIncludeOf } from '@graphql-gene/plugin-sequelize'
import { User } from './User.model'
import { getJwtTokenPayload } from './someUtils'
export enum ADMIN_ROLES {
developer = 'developer',
manager = 'manager',
superAdmin = 'superAdmin',
}
declare module 'graphql-gene/context' {
export interface GeneContext {
authenticatedUser?: User | null
}
}
function throwUnauthorized(): never {
throw new GraphQLError('Unauthorized')
}
/**
* Factory function returning the directive object
*/
export const userAuthDirective = defineDirective<{
// Convert ADMIN_ROLES enum to a union type
roles: `${ADMIN_ROLES}`[]
}>(args => ({
name: 'userAuth', // only used to add `@userAuth` to the schema in graphql language
args,
async handler({ context, info }) {
// If it was previously set to `null`
if (context.authenticatedUser === null) return throwUnauthorized()
const isAuthorized = (user: User | null) =>
!args.roles.length || args.roles.some(role => user?.adminRole === role)
if (context.authenticatedUser) {
if (!isAuthorized(context.authenticatedUser)) throwUnauthorized()
return // Proceed if user is fetched and authorized
}
// i.e. `context.request` coming from Fastify
const authHeader = context.request.headers.get('authorization')
const [, token] =
authHeader?.match(/^Bearer\s+(\S+)$/) || ([] as (string | undefined)[])
if (!token) return throwUnauthorized()
// For performance: avoid querying nested associations if they are not requested.
// `getQueryIncludeOf` look deeply inside the operation (query or mutation) for
// the `AuthenticatedUser` type in order to know which associations are requested.
const includeOptions = getQueryIncludeOf(info, 'AuthenticatedUser', {
// Set to true if the directive is added to a field that is not of type "AuthenticatedUser"
lookFromOperationRoot: true,
})
const { id, email } = getJwtTokenPayload(token) || {}
if (!id && !email) return throwUnauthorized()
const user = await User.findOne({ where: { id, email }, ...includeOptions })
context.authenticatedUser = user
if (!user || !isAuthorized(user)) throwUnauthorized()
},
}))The args option allow you to use it in different contexts:
src/models/User/User.model.ts
import { defineGraphqlGeneConfig, extendTypes } from 'graphql-gene'
import { userAuthDirective } from '.userAuthDirective.ts'
export
@Table
class User extends Model<InferAttributes<User>, InferCreationAttributes<User>> {
declare id: CreationOptional<number>
// ...
static readonly geneConfig = defineGraphqlGeneConfig(User, {
include: ['id', 'username'],
aliases: {
// Use an alias since `AuthenticatedUser` as a quite
// different scope than a public `User`.
AuthenticatedUser: {
include: ['id', 'email', 'username', 'role', 'address', 'orders'],
// `roles: []` means no specific admin `role` needed
// The user just needs to be authenticated.
directives: [userAuthDirective({ roles: [] })],
},
},
})
}
extendTypes({
Query: {
me: {
returnType: 'AuthenticatedUser',
// `context.authenticatedUser` is defined in `userAuthDirective`
resolver: ({ context }) => context.authenticatedUser,
},
},
})The alias needs to be exported as well from graphqlTypes.ts:
src/models/graphqlTypes.ts
export * from './models'
// Export the alias for typing
export { User as AuthenticatedUser } from '../models/User/User.model'Another example for superAdmin role:
src/models/AdminAccount/AdminAccount.model.ts
static readonly geneConfig = defineGraphqlGeneConfig(AdminAccount, {
// i.e. Only allow super admin users to access the `AdminAccount` data
directives: [userAuthDirective({ roles: ['superAdmin'] })],
})Sending the request
This is how the response would look like for Query.me if the token is missing or invalid:
type Query {
me: AuthenticatedUser
}Available plugins
Contribution
# Install dependencies
pnpm install
#
# or if you're having issues on Apple M Chips:
# arch -arm64 pnpm install -f
# Develop
pnpm dev
# Run ESLint
pnpm lint
# Run Vitest
pnpm test
# Run Vitest in watch mode
pnpm test:watch