Migrating Apollo Server (express) to v3
Apollo Server v3 has been available for a moment now and keeping the version 2 is an open security issue with our server backends.
What's new ?
Quite a few breaking changes occurred with the upgrade to v3. Mainly, Apollo sever get rid of heavy load that is used by 2% of the projects. Apollo removes naive the schemaDirectives option.
- Apollo Server 3 supports Node.js 12 and later. Apollo Server 2 supports back to Node.js 6. The new package is lighter and faster
- Apollo Server 3 supports graphql v15.3.0 and later. Apollo Server 2 supported graphql v0.12 through v15.
- Removing support for
graphql-upload
andthe subscriptions-transport-ws
- Discouraging developers to upload using GraphQL
Read more on this link
You can reenable all of these integrations as they exist in Apollo Server 2.
Migrating SchemaDirectives out of Apollo
The native support of schemaDirectives is completely removed.
We'll be using @graphql-tools/schema is to pass the generated schema to your implementation of these custom directives.
Update and install the new packages
yarn upgrade graphql@latest apollo-server-express@latest
yarn add @graphql-tools/schema @graphql-tools/utils
Update the directive files
The highlighted line are the most important, the rest is boiler plate. A big difference is now the typeDef is moved from the main schema file to directive file.
- isAuthenticated.ts
- hasAccess.ts
- ip.ts
- ua.ts
import { ApolloError, gql } from "apollo-server-express";
import { GraphQLSchema, defaultFieldResolver } from "graphql";
import { decode } from "@exiry/utils/jwt.utils";
import { User } from "@main/plugins/users/models";
import type { UserContext } from "@exiry/core/@types/context";
import { getDirective, MapperKind, mapSchema } from "@graphql-tools/utils";
export const directiveName = "isAuthenticated";
export function typeDef() {
return gql`
directive @isAuthenticated on OBJECT | FIELD_DEFINITION | INPUT_FIELD_DEFINITION
`;
}
export function transformer(schema: GraphQLSchema) {
const typeDirectiveArgumentMaps: Record<string, any> = {};
return mapSchema(schema, {
[MapperKind.TYPE]: (type) => {
const authDirective = getDirective(schema, type, directiveName)?.[0];
if (authDirective) {
typeDirectiveArgumentMaps[type.name] = authDirective;
}
return undefined;
},
[MapperKind.OBJECT_FIELD]: (fieldConfig, _fieldName, typeName) => {
const authDirective =
getDirective(schema, fieldConfig, directiveName)?.[0] ??
typeDirectiveArgumentMaps[typeName];
if (authDirective) {
const { resolve = defaultFieldResolver } = fieldConfig;
fieldConfig.resolve = async function (source, args, context, info) {
const ctx = await renderCtx(context);
return resolve(source, args, ctx, info);
};
return fieldConfig;
}
},
});
}
type Context = { [k: string]: any };
interface ContextWithUser extends Context {
user: UserContext;
}
export async function renderCtx<T extends Context>(
context: T
): Promise<ContextWithUser> {
const _user = await decode(context.req.headers.authorization);
if (!_user) {
const error = "You are not authenticated.";
throw new ApolloError(error, "UNAUTHENTICATED");
}
const user = await User.findOne({
attributes: ["id", "isActive", "email", "firstName", "lastName"],
where: { id: _user.id },
});
if (!user) {
const error = "User is not found in our Database.";
throw new ApolloError(error, "UNAUTHENTICATED");
}
if (!user.isActive) {
const error = "User is disabled.";
throw new ApolloError(error, "USER_DISABLED");
}
return {
...context,
user: {
id: user.id,
email: user.email || null,
fullName: user.firstName + " " + user.lastName,
},
};
}
import { ApolloError, gql } from "apollo-server-express";
import { defaultFieldResolver, GraphQLSchema } from "graphql";
import { renderCtx as checkAndGetUser } from "./isAuthenticated";
import { hasAccess } from "@main/plugins/accesses/helpers";
import { getDirective, MapperKind, mapSchema } from "@graphql-tools/utils";
interface HasAccessArgs {
slug: string;
scope?: string;
own?: boolean;
}
export const directiveName = "hasAccess";
export function typeDef() {
return gql`
directive @hasAccess(
slug: String
scope: String
own: Boolean
) on OBJECT | FIELD_DEFINITION | INPUT_FIELD_DEFINITION
`;
}
export function transformer(schema: GraphQLSchema) {
const typeDirectiveArgumentMaps: Record<string, any> = {};
return mapSchema(schema, {
[MapperKind.TYPE]: (type) => {
const directive = getDirective(schema, type, directiveName)?.[0];
if (directive) {
typeDirectiveArgumentMaps[type.name] = directive;
}
return undefined;
},
[MapperKind.OBJECT_FIELD]: (fieldConfig, _fieldName, typeName) => {
const directive =
getDirective(schema, fieldConfig, directiveName)?.[0] ??
typeDirectiveArgumentMaps[typeName];
if (directive) {
const { slug, scope, own } = directive;
const { resolve = defaultFieldResolver } = fieldConfig;
fieldConfig.resolve = async function (source, args, context, info) {
const ctx = await renderCtx(context, { slug, scope, own });
return resolve(source, args, ctx, info);
};
return fieldConfig;
}
},
});
}
async function renderCtx(context: any, args: HasAccessArgs) {
// Check user and access
const { user } = await checkAndGetUser(context);
const privilege = await hasAccess({ userId: user.id, ...args });
if (privilege) {
return { ...context, user, privilege };
}
const error = "You are not authorized for this resource.";
throw new ApolloError(error, "NOT_AUTHORIZED");
}
import { defaultFieldResolver, GraphQLSchema } from "graphql";
import { getDirective, MapperKind, mapSchema } from "@graphql-tools/utils";
import { getIP } from "@exiry/utils/misc.utils";
import { gql } from "apollo-server-express";
export const directiveName = "ip";
export function typeDef() {
return gql`
"""
Injects the user IP into the Context
"""
directive @ip on OBJECT | FIELD_DEFINITION
`;
}
export function transformer(schema: GraphQLSchema) {
const typeDirectiveArgumentMaps: Record<string, any> = {};
return mapSchema(schema, {
[MapperKind.TYPE]: (type) => {
const directive = getDirective(schema, type, directiveName)?.[0];
if (directive) {
typeDirectiveArgumentMaps[type.name] = directive;
}
return undefined;
},
[MapperKind.OBJECT_FIELD]: (fieldConfig, _fieldName, typeName) => {
const directive =
getDirective(schema, fieldConfig, directiveName)?.[0] ??
typeDirectiveArgumentMaps[typeName];
if (directive) {
const { resolve = defaultFieldResolver } = fieldConfig;
fieldConfig.resolve = async function (source, args, context, info) {
const ctx = renderCtx(context);
return resolve(source, args, ctx, info);
};
return fieldConfig;
}
},
});
}
async function renderCtx(context: any) {
const { req } = context;
return { ...context, ip: getIP(req) };
}
import { gql } from "apollo-server-express";
import { GraphQLSchema, defaultFieldResolver } from "graphql";
import { getDirective, MapperKind, mapSchema } from "@graphql-tools/utils";
import useragent from "express-useragent";
export const directiveName = "ua";
export function typeDef() {
return gql`
"""
Injects the User Agent into the Context
see [express-useragent](https://www.npmjs.com/package/express-useragent) for details
"""
directive @ua on OBJECT | FIELD_DEFINITION
`;
}
export function transformer(schema: GraphQLSchema) {
const typeDirectiveArgumentMaps: Record<string, any> = {};
return mapSchema(schema, {
[MapperKind.TYPE]: (type) => {
const authDirective = getDirective(schema, type, directiveName)?.[0];
if (authDirective) {
typeDirectiveArgumentMaps[type.name] = authDirective;
}
return undefined;
},
[MapperKind.OBJECT_FIELD]: (fieldConfig, _fieldName, typeName) => {
const authDirective =
getDirective(schema, fieldConfig, directiveName)?.[0] ??
typeDirectiveArgumentMaps[typeName];
if (authDirective) {
const { resolve = defaultFieldResolver } = fieldConfig;
fieldConfig.resolve = async function (source, args, context, info) {
const ctx = renderCtx(context);
return resolve(source, args, ctx, info);
};
return fieldConfig;
}
},
});
}
async function renderCtx(context: any) {
const { req } = context;
const source = req.headers["user-agent"];
const ua = useragent.parse(source) || null;
return { ...context, ua };
}
Update the directive index files
The index.ts
file of the Exiry's /src/core/directives
directory needs to be updated too:
import * as isAuthenticated from "./isAuthenticated";
import * as hasAccess from "./hasAccess";
import * as ip from "./ip";
import * as ua from "./ua";
export default {
typeDef: [isAuthenticated.typeDef, hasAccess.typeDef, ip.typeDef, ua.typeDef],
transformer: [
isAuthenticated.transformer,
hasAccess.transformer,
ip.transformer,
ua.transformer,
],
};
Update Exiry main server
Updating the Exiry's server Factory
The index.ts
of Exiry's main folder needs to be updated according to Apollo Server v3 requirements.
...
import mainSchema from "./main-schema.gql";
- import schemaDirectives from "./directives";
+ import directives from "./directives";
import scalarsResolvers from "./scalars";
+ import { makeExecutableSchema } from "@graphql-tools/schema";
+ import { ApolloServerPluginLandingPageDisabled } from "apollo-server-core";
...
export default async function server(port: number | string) {
const app = express();
+ // Create the schema
+ let schema = makeExecutableSchema({
+ typeDefs: [
+ mainSchema,
+ ...directives.typeDef,
+ ...plugins.schema,
+ ...modules.schema,
+ ],
+ resolvers: [scalarsResolvers, ...plugins.resolvers, ...modules.resolvers],
+ });
+
+ // Apply the directive to the schema
+ for (const transformer of directives.transformer) {
+ if (typeof transformer !== "function") continue;
+ schema = transformer(schema);
+ }
const server = new ApolloServer({
+ schema,
+ csrfPrevention: true,
+ plugins: [ApolloServerPluginLandingPageDisabled()],
- typeDefs: [mainSchema, ...plugins.schema, ...modules.schema],
- resolvers: [scalarsResolvers, ...plugins.resolvers, ...modules.resolvers],
- schemaDirectives,
- introspection: process.env.NODE_ENV !== "production",
- playground: process.env.NODE_ENV !== "production",
- uploads: {
- maxFileSize: 10000000, // 10 MB
- maxFiles: 20,
- },
context: (context) => {
// Initiate the context to null
return {
...context,
user: null,
privilege: null,
ip: null,
agent: null,
};
},
});
+ // Start the Apollo Server before going further
+ await server.start();
...
// Apply Middleware to the graphQl server
- server.applyMiddleware({ app, cors: true });
+ server.applyMiddleware({
+ app,
+ cors: true,
+ bodyParserConfig: true,
+ path: "/graphql",
+ });
...
return httpServer.listen(port, () => {
- const { graphqlPath, subscriptionsPath } = server;
+ const { graphqlPath } = server;
logger.info(`Server ready at */:${port}${graphqlPath}`);
- logger.info(`Subscriptions ready at ws://*/:${port}${subscriptionsPath}`);
});
}
Updating the Exiry's main schema
The directive definition is moved to their own respective files. We remove them from the main schema file:
import { gql } from "apollo-server-express";
// Handel different Apollo Schemas and Resolvers
const schema = gql`
- directive @isAuthenticated on OBJECT | FIELD_DEFINITION | INPUT_FIELD_DEFINITION
- directive @hasAccess(
- slug: String
- scope: String
- own: Boolean
- ) on OBJECT | FIELD_DEFINITION | INPUT_FIELD_DEFINITION
- """
- Injects the user IP into the Context
- """
- directive @ip on OBJECT | FIELD_DEFINITION
- """
- Injects the User Agent into the Context
- see [express-useragent](https://www.npmjs.com/package/express-useragent) for details
- """
- directive @ua on OBJECT | FIELD_DEFINITION
-
"""
Date - Tekru custom scalar
Convert outgoing Date to integer for JSON