feat: Upgrade to Apollo v4

* feat: update and make build work

BREAKING CHANGE: Update from 'apollo-server-koa' to '@apollo/server' and '@as-integrations/koa'

* chore: fix comments

* chore: upgrade graphql-upload package

* chore: fix for body type unknown

* chore: remove old comment

* chore: clean up error handling

* chore: fix comment

* fix: http status codes for input validation errors

* fix: remove unused import

* fix: remove accidental bodyparser

* fix: add new required header to tests

* chore: standardize directive key names to be kebab-case

* test: add some extra message validation

* chore: remove devdep for koa-cors typings

* fix: add unknown error name

* fix: yarn.lock

* fix: add typings

* fix: typings

* fix: typings again

* fix: remove unused imports

* chore: remove unused import

* chore: move playground check to a service

* fix: package imports and versions

* chore: fix yarn.lock

* chore: fix types

* chore: clean up koa typings

* chore: koa typing cleanup

* chore: cleanup koa typings

* chore: more koa type cleanup

* chore: revert missing imports

* chore: cleanup koa typings

* chore: update yarn.lock
This commit is contained in:
Ben Irvin 2024-01-15 14:54:58 +01:00 committed by GitHub
parent cc1043c512
commit 13a2f8b246
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 666 additions and 443 deletions

View File

@ -15,7 +15,12 @@ let uploadFolder;
describe('Uploads folder (GraphQL)', () => {
beforeAll(async () => {
strapi = await createStrapiInstance();
rq = await createAuthRequest({ strapi });
rq = await createAuthRequest({
strapi,
// header required for multipart requests
state: { headers: { 'x-apollo-operation-name': 'graphql-upload' } },
});
rqAdmin = await createAuthRequest({ strapi });
});

View File

@ -14,7 +14,11 @@ const data = {};
describe('Upload plugin end to end tests', () => {
beforeAll(async () => {
strapi = await createStrapiInstance();
rq = await createAuthRequest({ strapi });
rq = await createAuthRequest({
strapi,
// header required for multipart requests
state: { headers: { 'x-apollo-operation-name': 'graphql-upload' } },
});
});
afterAll(async () => {
@ -425,4 +429,46 @@ describe('Upload plugin end to end tests', () => {
},
});
});
test('Returns an error when required headers for csrf protection are missing', async () => {
const formData = {
operations: JSON.stringify({
query: /* GraphQL */ `
mutation uploadFile($file: Upload!) {
upload(file: $file) {
data {
id
attributes {
name
mime
url
}
}
}
}
`,
variables: {
file: null,
},
}),
map: JSON.stringify({
nFile1: ['variables.file'],
}),
nFile1: fs.createReadStream(path.join(__dirname, '../utils/rec.jpg')),
};
const res = await rq({ method: 'POST', url: '/graphql', formData, headers: {} });
expect(res.statusCode).toBe(400);
expect(res.body.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: expect.stringContaining('x-apollo-operation-name'),
extensions: expect.objectContaining({
code: 'BAD_REQUEST',
}),
}),
])
);
});
});

View File

@ -1,7 +1,7 @@
import type { Context, Next } from 'koa';
import path from 'path';
import utils from '@strapi/utils';
import { isString, has, toLower } from 'lodash/fp';
import { isString, has, toLower, get } from 'lodash/fp';
import type { Strapi } from '@strapi/types';
const { RateLimitError } = utils.errors;
@ -25,7 +25,9 @@ export default (config: any, { strapi }: { strapi: Strapi }) =>
// eslint-disable-next-line @typescript-eslint/no-var-requires
const rateLimit = require('koa2-ratelimit').RateLimit;
const userEmail = toLower(ctx.request.body.email) || 'unknownEmail';
const requestEmail = get('request.body.email')(ctx);
const userEmail = isString(requestEmail) ? requestEmail.toLowerCase() : 'unknownEmail';
const requestPath = isString(ctx.request.path)
? toLower(path.normalize(ctx.request.path)).replace(/\/$/, '')
: 'invalidPath';

View File

@ -80,6 +80,7 @@
"@strapi/types": "4.17.0",
"@testing-library/react": "14.0.0",
"@testing-library/user-event": "14.4.3",
"@types/koa-bodyparser": "4.3.12",
"@types/pluralize": "0.0.30",
"koa": "2.13.4",
"react": "^18.2.0",

View File

@ -4,7 +4,7 @@ import validateComponentCategory from './validation/component-category';
export default {
async editCategory(ctx: Context) {
const { body } = ctx.request;
const body = ctx.request.body as any;
try {
await validateComponentCategory(body);

View File

@ -49,7 +49,7 @@ export default {
* @param {Object} ctx - koa context
*/
async createComponent(ctx: Context) {
const { body } = ctx.request;
const body = ctx.request.body as any;
try {
await validateComponentInput(body);
@ -83,7 +83,7 @@ export default {
*/
async updateComponent(ctx: Context) {
const { uid } = ctx.params;
const { body } = ctx.request;
const body = ctx.request.body as any;
if (!_.has(strapi.components, uid)) {
return ctx.send({ error: 'component.notFound' }, 404);

View File

@ -51,7 +51,7 @@ export default {
},
async createContentType(ctx: Context) {
const { body } = ctx.request;
const body = ctx.request.body as any;
try {
await validateContentTypeInput(body);
@ -95,7 +95,7 @@ export default {
async updateContentType(ctx: Context) {
const { uid } = ctx.params;
const { body } = ctx.request;
const body = ctx.request.body as any;
if (!_.has(strapi.contentTypes, uid)) {
return ctx.send({ error: 'contentType.notFound' }, 404);

View File

@ -36,18 +36,37 @@ export const security: Common.MiddlewareFactory<Config> =
const specialPaths = ['/documentation'];
if (strapi.plugin('graphql')) {
const directives: {
'script-src': string[];
'img-src': string[];
'manifest-src': string[];
'frame-src': string[];
} = {
'script-src': ["'self'", "'unsafe-inline'", 'cdn.jsdelivr.net'],
'img-src': ["'self'", 'data:', 'cdn.jsdelivr.net', 'strapi.io'],
'manifest-src': [],
'frame-src': [],
};
// if apollo graphql playground is enabled, add exceptions for it
if (strapi.plugin('graphql')?.service('utils').playground.isEnabled()) {
const { config: gqlConfig } = strapi.plugin('graphql');
specialPaths.push(gqlConfig('endpoint'));
directives['script-src'].push(`https: 'unsafe-inline'`);
directives['img-src'].push(`'apollo-server-landing-page.cdn.apollographql.com'`);
directives['manifest-src'].push(`'self'`);
directives['manifest-src'].push('apollo-server-landing-page.cdn.apollographql.com');
directives['frame-src'].push(`'self'`);
directives['frame-src'].push('sandbox.embed.apollographql.com');
}
// TODO: we shouldn't combine playground exceptions with documentation for all routes, we should first check the path and then return exceptions specific to that
if (ctx.method === 'GET' && specialPaths.some((str) => ctx.path.startsWith(str))) {
helmetConfig = merge(helmetConfig, {
crossOriginEmbedderPolicy: false, // TODO: only use this for graphql playground
contentSecurityPolicy: {
directives: {
'script-src': ["'self'", "'unsafe-inline'", 'cdn.jsdelivr.net'],
'img-src': ["'self'", 'data:', 'cdn.jsdelivr.net', 'strapi.io'],
},
directives,
},
});
}

View File

@ -48,19 +48,21 @@
"watch": "strapi plugin:watch"
},
"dependencies": {
"@apollo/server": "4.10.0",
"@as-integrations/koa": "1.1.1",
"@graphql-tools/schema": "8.5.1",
"@graphql-tools/utils": "^8.13.1",
"@koa/cors": "3.4.3",
"@strapi/design-system": "1.14.1",
"@strapi/helper-plugin": "4.17.0",
"@strapi/icons": "1.14.1",
"@strapi/utils": "4.17.0",
"apollo-server-core": "3.12.1",
"apollo-server-koa": "3.10.0",
"graphql": "^15.5.1",
"graphql": "^16.8.1",
"graphql-depth-limit": "^1.1.0",
"graphql-playground-middleware-koa": "^1.6.21",
"graphql-scalars": "1.22.2",
"graphql-upload": "^13.0.0",
"graphql-upload": "^15.0.0",
"koa-bodyparser": "4.4.1",
"koa-compose": "^4.1.0",
"lodash": "4.17.21",
"nexus": "1.3.0",
@ -70,7 +72,8 @@
"@strapi/strapi": "4.17.0",
"@strapi/types": "4.17.0",
"@types/graphql-depth-limit": "1.1.5",
"@types/graphql-upload": "8.0.12",
"@types/graphql-upload": "15.0.2",
"@types/koa__cors": "5.0.0",
"cross-env": "^7.0.3",
"eslint-config-custom": "4.17.0",
"koa": "2.13.4",

View File

@ -1,13 +1,18 @@
import { isEmpty, mergeWith, isArray } from 'lodash/fp';
import { ApolloServer } from 'apollo-server-koa';
import { isEmpty, mergeWith, isArray, isObject } from 'lodash/fp';
import { ApolloServer, type ApolloServerOptions } from '@apollo/server';
import {
ApolloServerPluginLandingPageDisabled,
ApolloServerPluginLandingPageGraphQLPlayground,
} from 'apollo-server-core';
ApolloServerPluginLandingPageLocalDefault,
ApolloServerPluginLandingPageProductionDefault,
} from '@apollo/server/plugin/landingPage/default';
import { koaMiddleware } from '@as-integrations/koa';
import depthLimit from 'graphql-depth-limit';
import { graphqlUploadKoa } from 'graphql-upload';
import type { Config } from 'apollo-server-core';
import type { Strapi } from '@strapi/types';
// eslint-disable-next-line import/extensions
import graphqlUploadKoa from 'graphql-upload/graphqlUploadKoa.js';
import bodyParser from 'koa-bodyparser';
import cors from '@koa/cors';
import type { Strapi, Common } from '@strapi/types';
import type { BaseContext, DefaultContextExtends, DefaultStateExtends } from 'koa';
import { formatGraphqlError } from './format-graphql-error';
@ -48,20 +53,28 @@ export async function bootstrap({ strapi }: { strapi: Strapi }) {
const path: string = config('endpoint');
const defaultServerConfig: Config & {
// TODO: rename playgroundAlways since it's not playground anymore
const playgroundEnabled = !(process.env.NODE_ENV === 'production' && !config('playgroundAlways'));
let landingPage;
if (playgroundEnabled) {
landingPage = ApolloServerPluginLandingPageLocalDefault();
strapi.log.debug('Using Apollo sandbox landing page');
} else {
landingPage = ApolloServerPluginLandingPageProductionDefault();
strapi.log.debug('Using Apollo production landing page');
}
type CustomOptions = {
cors: boolean;
uploads: boolean;
bodyParserConfig: boolean;
} = {
};
const defaultServerConfig: ApolloServerOptions<BaseContext> & CustomOptions = {
// Schema
schema,
// Initialize loaders for this request.
context: ({ ctx }) => ({
state: ctx.state,
koaContext: ctx,
}),
// Validation
validationRules: [depthLimit(config('depthLimit') as number) as any],
@ -72,17 +85,17 @@ export async function bootstrap({ strapi }: { strapi: Strapi }) {
cors: false,
uploads: false,
bodyParserConfig: true,
plugins: [
process.env.NODE_ENV === 'production' && !config('playgroundAlways')
? ApolloServerPluginLandingPageDisabled()
: ApolloServerPluginLandingPageGraphQLPlayground(),
],
// send 400 http status instead of 200 for input validation errors
status400ForVariableCoercionErrors: true,
plugins: [landingPage],
cache: 'bounded' as const,
};
const serverConfig = merge(defaultServerConfig, config('apolloServer'));
const serverConfig = merge(
defaultServerConfig,
config('apolloServer')
) as ApolloServerOptions<BaseContext> & CustomOptions;
// Create a new Apollo server
const server = new ApolloServer(serverConfig);
@ -91,7 +104,7 @@ export async function bootstrap({ strapi }: { strapi: Strapi }) {
useUploadMiddleware(strapi, path);
try {
// Since Apollo-Server v3, server.start() must be called before using server.applyMiddleware()
// server.start() must be called before using server.applyMiddleware()
await server.start();
} catch (error) {
if (error instanceof Error) {
@ -101,33 +114,59 @@ export async function bootstrap({ strapi }: { strapi: Strapi }) {
throw error;
}
// Link the Apollo server & the Strapi app
// Create the route handlers for Strapi
const handler: Common.MiddlewareHandler[] = [];
// add cors middleware
if (cors) {
handler.push(cors());
}
// add koa bodyparser middleware
if (isObject(serverConfig.bodyParserConfig)) {
handler.push(bodyParser(serverConfig.bodyParserConfig));
} else if (serverConfig.bodyParserConfig) {
handler.push(bodyParser());
} else {
strapi.log.debug('Body parser has been disabled for Apollo server');
}
// add the Strapi auth middleware
handler.push((ctx, next) => {
ctx.state.route = {
info: {
// Indicate it's a content API route
type: 'content-api',
},
};
// allow graphql playground to load without authentication
// WARNING: this means graphql should not accept GET requests generally
// TODO: find a better way and remove this, it is causing issues such as https://github.com/strapi/strapi/issues/19073
if (ctx.request.method === 'GET') {
return next();
}
return strapi.auth.authenticate(ctx, next);
});
// add the graphql server for koa
handler.push(
koaMiddleware<DefaultStateExtends, DefaultContextExtends>(server, {
// Initialize loaders for this request.
context: async ({ ctx }) => ({
state: ctx.state,
koaContext: ctx,
}),
})
);
// now that handlers are set up, add the graphql route to our apollo server
strapi.server.routes([
{
method: 'ALL',
path,
handler: [
(ctx, next) => {
ctx.state.route = {
info: {
// Indicate it's a content API route
type: 'content-api',
},
};
// allow graphql playground to load without authentication
if (ctx.request.method === 'GET') return next();
return strapi.auth.authenticate(ctx, next);
},
// Apollo Server
server.getMiddleware({
path,
cors: serverConfig.cors,
bodyParserConfig: serverConfig.bodyParserConfig,
}),
],
handler,
config: {
auth: false,
},

View File

@ -1,11 +1,6 @@
import { toUpper, snakeCase, pick, isEmpty } from 'lodash/fp';
import { errors } from '@strapi/utils';
import {
ApolloError,
UserInputError as ApolloUserInputError,
ForbiddenError as ApolloForbiddenError,
} from 'apollo-server-koa';
import { GraphQLError } from 'graphql';
import { GraphQLError, type GraphQLFormattedError } from 'graphql';
const { HttpError, ForbiddenError, UnauthorizedError, ApplicationError, ValidationError } = errors;
@ -14,31 +9,64 @@ const formatErrorToExtension = (error: any) => ({
error: pick(['name', 'message', 'details'])(error),
});
export function formatGraphqlError(error: GraphQLError) {
const { originalError } = error;
function createFormattedError(
formattedError: GraphQLFormattedError,
message: string,
code: string,
originalError: unknown
) {
const options = {
...formattedError,
extensions: {
...formattedError.extensions,
...formatErrorToExtension(originalError),
code,
},
};
return new GraphQLError(message, options);
}
/**
* The handler for Apollo Server v4's formatError config option
*
* Intercepts specific Strapi error types to send custom error response codes in the GraphQL response
*/
export function formatGraphqlError(formattedError: GraphQLFormattedError, originalError: unknown) {
// If this error doesn't have an associated originalError, it
if (isEmpty(originalError)) {
return error;
return formattedError;
}
const { message = '', name = 'UNKNOWN' } = originalError as Error;
if (originalError instanceof ForbiddenError || originalError instanceof UnauthorizedError) {
return new ApolloForbiddenError(originalError.message, formatErrorToExtension(originalError));
return createFormattedError(formattedError, message, 'FORBIDDEN', originalError);
}
if (originalError instanceof ValidationError) {
return new ApolloUserInputError(originalError.message, formatErrorToExtension(originalError));
return createFormattedError(formattedError, message, 'BAD_USER_INPUT', originalError);
}
if (originalError instanceof ApplicationError || originalError instanceof HttpError) {
const name = formatToCode(originalError.name);
return new ApolloError(originalError.message, name, formatErrorToExtension(originalError));
const errorName = formatToCode(name);
return createFormattedError(formattedError, message, errorName, originalError);
}
if (originalError instanceof ApolloError || originalError instanceof GraphQLError) {
return error;
if (originalError instanceof GraphQLError) {
return formattedError;
}
// Internal server error
// else if originalError doesn't appear to be from Strapi or GraphQL..
// Log the error
strapi.log.error(originalError);
return new ApolloError('Internal Server Error', 'INTERNAL_SERVER_ERROR');
// Create a generic 500 to send so we don't risk leaking any data
return createFormattedError(
new GraphQLError('Internal Server Error'),
'Internal Server Error',
'INTERNAL_SERVER_ERROR',
originalError
);
}

View File

@ -1,5 +1,6 @@
import { GraphQLDateTime, GraphQLLong, GraphQLJSON } from 'graphql-scalars';
import { GraphQLUpload } from 'graphql-upload';
// eslint-disable-next-line import/extensions
import GraphQLUpload from 'graphql-upload/GraphQLUpload.js';
import { asNexusMethod } from 'nexus';
import TimeScalar from './time';

View File

@ -1,10 +1,12 @@
import mappers from './mappers';
import attributes from './attributes';
import naming from './naming';
import playground from './playground';
import type { Context } from '../types';
export default (context: Context) => ({
playground: playground(context),
naming: naming(context),
attributes: attributes(context),
mappers: mappers(context),

View File

@ -0,0 +1,12 @@
import { Context } from '../types';
export default (ctx: Context) => {
return {
isEnabled() {
return !(
process.env.NODE_ENV === 'production' &&
!ctx.strapi.plugin('graphql').config('playgroundAlways')
);
},
};
};

View File

@ -76,6 +76,8 @@
"@strapi/types": "4.17.0",
"@testing-library/react": "14.0.0",
"@testing-library/user-event": "14.4.3",
"@types/koa-bodyparser": "4.3.12",
"koa": "2.13.4",
"msw": "1.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",

View File

@ -16,7 +16,8 @@ const getFirstLevelPath = map((path: string) => path.split('.')[0]);
const controller = {
async getNonLocalizedAttributes(ctx) {
const { user } = ctx.state;
const { model, id, locale } = ctx.request.body;
const body = ctx.request.body as any;
const { model, id, locale } = body;
await validateGetNonLocalizedAttributesInput({ model, id, locale });

View File

@ -26,7 +26,7 @@ const controller: Common.Controller = {
async createLocale(ctx) {
const { user } = ctx.state;
const { body } = ctx.request;
const body = ctx.request.body as any;
const { isDefault, ...localeToCreate } = body;
await validateCreateLocaleInput(body);
@ -54,7 +54,7 @@ const controller: Common.Controller = {
async updateLocale(ctx) {
const { user } = ctx.state;
const { id } = ctx.params;
const { body } = ctx.request;
const body = ctx.request.body as any;
const { isDefault, ...updates } = body;
await validateUpdateLocaleInput(body);

View File

@ -7,7 +7,8 @@ const { ApplicationError } = errors;
const validateLocaleCreation: Common.MiddlewareHandler = async (ctx, next) => {
const { model } = ctx.params;
const { query, body } = ctx.request;
const { query } = ctx.request;
const body = ctx.request.body as any;
const {
getValidLocale,
@ -40,7 +41,7 @@ const validateLocaleCreation: Common.MiddlewareHandler = async (ctx, next) => {
if (modelDef.kind === 'singleType') {
const entity = await strapi.entityService.findMany(modelDef.uid, {
locale: entityLocale,
} as any);
} as any); // TODO: add this type to entityService
ctx.request.query.locale = body.locale;

View File

@ -10,12 +10,17 @@ const createAgent = (strapi, initialState = {}) => {
const utils = createUtils(strapi);
const agent = (options) => {
const { method, url, body, formData, qs: queryString } = options;
const { method, url, body, formData, qs: queryString, headers } = options;
const supertestAgent = request.agent(strapi.server.httpServer);
if (has('token', state)) {
supertestAgent.auth(state.token, { type: 'bearer' });
}
if (headers) {
supertestAgent.set(headers);
} else if (has('headers', state)) {
supertestAgent.set(state.headers);
}
const fullUrl = concat(state.urlPrefix, url).join('');
@ -65,6 +70,10 @@ const createAgent = (strapi, initialState = {}) => {
return this.assignState({ loggedUser });
},
setHeaders(headers) {
return this.assignState({ headers });
},
getLoggedUser() {
return state.loggedUser;
},

View File

@ -18,8 +18,8 @@ const createContentAPIRequest = ({ strapi, auth = {} } = {}) => {
return createAgent(strapi, { urlPrefix: CONTENT_API_URL_PREFIX, token: 'test-token' });
};
const createAuthRequest = ({ strapi, userInfo = superAdmin.credentials }) => {
return createAgent(strapi).login(userInfo);
const createAuthRequest = ({ strapi, userInfo = superAdmin.credentials, state }) => {
return createAgent(strapi, state).login(userInfo);
};
const transformToRESTResource = (input) => {

754
yarn.lock

File diff suppressed because it is too large Load Diff