Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"framer-motion": "^11.1.7",
"graphql": "^16.2.0",
"graphql": "^16.13.1",
"jsonpointer": "^5.0.1",
"latlon-geohash": "^2.0.0",
"lodash": "^4.17.21",
Expand Down
97 changes: 46 additions & 51 deletions server/api.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/* eslint-disable max-lines */
// In this case, we want to rely on apollo-server-express bundling a
// corresponding version of apollo-server-core, rather than picking an
// apollo-server-core version in package.json
// eslint-disable-next-line import/no-extraneous-dependencies
import os from 'node:os';
import path from 'path';
import { ApolloServer } from '@apollo/server';
import { unwrapResolverError } from '@apollo/server/errors';
import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled';
import { expressMiddleware } from '@as-integrations/express4';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { MapperKind, mapSchema } from '@graphql-tools/utils';
import { SpanStatusCode } from '@opentelemetry/api';
Expand All @@ -13,12 +13,7 @@ import {
SEMATTRS_EXCEPTION_STACKTRACE,
SEMATTRS_EXCEPTION_TYPE,
} from '@opentelemetry/semantic-conventions';
import {
ApolloError,
ApolloServerPluginLandingPageDisabled,
ApolloServerPluginLandingPageGraphQLPlayground,
} from 'apollo-server-core';
import { ApolloServer } from 'apollo-server-express';
import { GraphQLError, type GraphQLFormattedError } from 'graphql';
import bodyParser from 'body-parser';
import connectPgSimple from 'connect-pg-simple';
import cors from 'cors';
Expand All @@ -34,7 +29,7 @@ import {
makeLoginSsoRequiredError,
makeLoginUserDoesNotExistError,
} from './graphql/datasources/UserApi.js';
import resolvers from './graphql/resolvers.js';
import resolvers, { type Context } from './graphql/resolvers.js';
import typeDefs from './graphql/schema.js';
import { authSchemaWrapper } from './graphql/utils/authorization.js';
import { type Dependencies } from './iocContainer/index.js';
Expand Down Expand Up @@ -323,7 +318,7 @@ export default async function makeApiServer(deps: Dependencies) {
/**
* Apollo Server - uses /api/graphql path
*/
const apolloServer = new ApolloServer({
const apolloServer = new ApolloServer<Context>({
schema: mapSchema(makeExecutableSchema({ typeDefs, resolvers }), {
[MapperKind.QUERY_ROOT_FIELD](
fieldConfig,
Expand All @@ -342,52 +337,41 @@ export default async function makeApiServer(deps: Dependencies) {
return authSchemaWrapper(fieldConfig, schema);
},
}),
dataSources: () => deps.DataSources,
context: ({ req, res }) => {
return {
...buildContext({ req, res }),
services: makeGqlServices(deps),
};
},
plugins: [
{
...(process.env.NODE_ENV === 'production'
? ApolloServerPluginLandingPageDisabled()
: ApolloServerPluginLandingPageGraphQLPlayground()),
},
...(process.env.NODE_ENV === 'production'
? [ApolloServerPluginLandingPageDisabled()]
: []),
],
introspection: process.env.NODE_ENV !== 'production',
formatError(e) {
// `e` can be an ApolloError instance, but will only be one if such an
// instance (or an ApolloError subclass) was explicitly thrown from a
// resolver. In that case, we assume the thrower knows they're dealing
// with apollo, and we can just pass the error through as-is.
if (e instanceof ApolloError) {
return e;
formatError(formattedError, error) {
// unwrapResolverError removes the GraphQLError wrapper added by graphql-js
// when a non-GraphQL error is thrown from a resolver.
const rawError = unwrapResolverError(error);

// If the raw error is a GraphQLError (explicitly thrown by our code or
// generated by graphql-js for parse/validation errors), the formattedError
// is already correctly shaped -- pass it through.
if (rawError instanceof GraphQLError) {
return formattedError;
}

// In almost all other cases, the error will be an instance of the
// `GraphQLError` class, which apollo instantiates automatically, and uses
// to wrap any non-ApolloError error thrown from a resolver. However,
// ocassionally -- e.g., if an error occurs during context creation rather
// than in the resolver -- the error doesn't get wrapped (or it's wrapped
// but with no originalError), so we handle both cases. Once we have the
// underlying error that was actually thrown, we sanitize it to remove
// sensitive details, and then try to format it in the most informative
// way possible.
const sanitizedError = sanitizeError(e.originalError ?? e);
// For all other errors (CoopError, unexpected errors, context errors),
// sanitize to remove sensitive details and reformat for the client.
const sanitizedError = sanitizeError(
rawError instanceof Error ? rawError : (error as Error),
);
const { title: sanitizedErrorTitle, ...extensions } = sanitizedError;

return {
// When apollo-server wraps the resolver-thrown error in a GraphQLError,
// When graphql-js wraps the resolver-thrown error in a GraphQLError,
// it automatically tracks some metadata about where the error was thrown
// from. That can be useful to clients, in a way that's a bit different
// from our CoopError.pointer field; it tells them whether a null
// value was return in the response because a given resolver failed, or
// because the field's value is actually null. So, we pass this
// apollo-annotated metdata through as-is.
locations: e.locations,
path: e.path,
// metadata through as-is.
locations: formattedError.locations,
path: formattedError.path,
// Apollo server also defines some predefined error codes that it could
// be helpful for us to mimic on our custom errors (in case Apollo
// clients handle them out of the box). The true, Coop-assigned code
Expand All @@ -401,16 +385,28 @@ export default async function makeApiServer(deps: Dependencies) {
: extensions.type.includes(ErrorType.InvalidUserInput)
? 'BAD_USER_INPUT'
: 'INTERNAL_SERVER_ERROR',
// Then, this is info from the sanitized verion of the actual thrown error.
// Then, this is info from the sanitized version of the actual thrown error.
message: sanitizedErrorTitle,
extensions,
};
} as unknown as GraphQLFormattedError;
},
});

await apolloServer.start().then(() => {
apolloServer.applyMiddleware({ app });
Object.entries(controllers).forEach(([_k, controller]) => {
await apolloServer.start();

app.use(
'/graphql',
express.json(),
expressMiddleware(apolloServer, {
context: async ({ req, res }) => ({
...buildContext({ req, res }),
services: makeGqlServices(deps),
dataSources: deps.DataSources,
} as unknown as Context),
}),
);

Object.entries(controllers).forEach(([_k, controller]) => {
controller.routes.forEach((it) => {
const handler = it.handler(deps);
app[it.method](
Expand Down Expand Up @@ -482,7 +478,6 @@ export default async function makeApiServer(deps: Dependencies) {
},
);
} as ErrorRequestHandler);
});

return {
app,
Expand Down
11 changes: 0 additions & 11 deletions server/decs.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,6 @@ declare interface String {
toLowerCase<T extends string>(this: T): Lowercase<T>;
}

// Workaround for https://github.com/apollographql/apollo-server/issues/6868
// At some point, we should just upgrade to Apollo Server 4, but that's a big lift.
//
// We also have to fork + override retry-axios, which we don't depend on
// directly, but it's a dependency of the google maps sdk. However, the version
// of retry-axios used by the SDK isn't compatible with moduleResolution=nodeNext,
// so we were getting a type error. Forking the SDK to update retry-axios isn't
// feasible, because the new version of retry-axios uses a newer axios version,
// which contains some breaking changes (to param serialization; see
// https://github.com/axios/axios/pull/4734), which would be hard to update the
// SDK to account for.
declare module '@graphql-tools/schema' {
import { GraphQLSchema } from 'graphql';

Expand Down
9 changes: 4 additions & 5 deletions server/graphql/customScalars/CoopInputOrString.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { UserInputError } from 'apollo-server-express';
import { GraphQLScalarType, Kind } from 'graphql';
import { GraphQLError, GraphQLScalarType, Kind } from 'graphql';

import { CoopInput } from '../../services/moderationConfigService/index.js';

Expand All @@ -20,23 +19,23 @@ export default new GraphQLScalarType<CoopInput | string, string>({
'Either an arbitrary string or a CoopInput enum key name (not the TS runtime value).',
serialize(value) {
if (typeof value !== 'string') {
throw new UserInputError('Expected a string.');
throw new GraphQLError('Expected a string.', { extensions: { code: 'BAD_USER_INPUT' } });
}

return CoopInputEnumInverted[value] ?? value;
},
parseValue: parseCoopInputOrStringValue,
parseLiteral(ast) {
if (ast.kind !== Kind.STRING) {
throw new UserInputError('CoopInputOrString must be a string.');
throw new GraphQLError('CoopInputOrString must be a string.', { extensions: { code: 'BAD_USER_INPUT' } });
}
return parseCoopInputOrStringValue(ast.value);
},
});

function parseCoopInputOrStringValue(value: unknown) {
if (typeof value !== 'string') {
throw new UserInputError('CoopInputOrString must be a CoopInput.');
throw new GraphQLError('CoopInputOrString must be a CoopInput.', { extensions: { code: 'BAD_USER_INPUT' } });
}

// @ts-ignore
Expand Down
9 changes: 4 additions & 5 deletions server/graphql/customScalars/Cursor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { UserInputError } from 'apollo-server-express';
import { GraphQLScalarType, Kind } from 'graphql';
import { GraphQLError, GraphQLScalarType, Kind } from 'graphql';

import {
b64Decode,
Expand Down Expand Up @@ -33,22 +32,22 @@ export default new GraphQLScalarType<JSON, B64Of<JsonOf<JSON>>>({
parseValue: parseCursorValue,
parseLiteral(ast) {
if (ast.kind !== Kind.STRING) {
throw new UserInputError('Cursor values must be strings.');
throw new GraphQLError('Cursor values must be strings.', { extensions: { code: 'BAD_USER_INPUT' } });
}
return parseCursorValue(ast.value);
},
});

function parseCursorValue(value: unknown) {
if (typeof value !== 'string') {
throw new UserInputError('Cursor values must be strings.');
throw new GraphQLError('Cursor values must be strings.', { extensions: { code: 'BAD_USER_INPUT' } });
}

try {
// Cast isn't necessarily true, but it's ok cuz we're in a try-catch.
const jsonString = b64Decode(value as B64Of<JsonOf<JSON>>);
return jsonParse(jsonString);
} catch {
throw new UserInputError('Invalid cursor value');
throw new GraphQLError('Invalid cursor value', { extensions: { code: 'BAD_USER_INPUT' } });
}
}
7 changes: 3 additions & 4 deletions server/graphql/customScalars/NonEmptyString.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { UserInputError } from 'apollo-server-express';
import { GraphQLScalarType, Kind } from 'graphql';
import { GraphQLError, GraphQLScalarType, Kind } from 'graphql';

import {
tryParseNonEmptyString,
Expand All @@ -16,14 +15,14 @@ export default new GraphQLScalarType<NonEmptyString, NonEmptyString>({
description: 'A string that must be non-empty.',
serialize(value) {
if (typeof value !== 'string') {
throw new UserInputError('Expected a string.');
throw new GraphQLError('Expected a string.', { extensions: { code: 'BAD_USER_INPUT' } });
}
return tryParseNonEmptyString(value);
},
parseValue: tryParseNonEmptyString,
parseLiteral(ast) {
if (ast.kind !== Kind.STRING) {
throw new UserInputError('NonEmptyString must be a string.');
throw new GraphQLError('NonEmptyString must be a string.', { extensions: { code: 'BAD_USER_INPUT' } });
}
return tryParseNonEmptyString(ast.value);
},
Expand Down
7 changes: 3 additions & 4 deletions server/graphql/customScalars/OpaqueScalarMixin.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { UserInputError } from 'apollo-server-express';
import { Kind, type GraphQLScalarType } from 'graphql';
import { GraphQLError, Kind, type GraphQLScalarType } from 'graphql';
import jwt from 'jsonwebtoken';

const parseOpaqueScalarValue =
<T>(jwtSigningKey: string) =>
(inputValue: unknown) => {
if (typeof inputValue !== 'string') {
throw new UserInputError('OpaqueScalar values must be strings.');
throw new GraphQLError('OpaqueScalar values must be strings.', { extensions: { code: 'BAD_USER_INPUT' } });
}

return jwt.verify(inputValue, jwtSigningKey) as T;
Expand Down Expand Up @@ -50,7 +49,7 @@ export default <T extends object>(
parseValue: parseOpaqueScalarValue<T>(jwtSigningKey),
parseLiteral(ast) {
if (ast.kind !== Kind.STRING) {
throw new UserInputError('OpaqueScalar values must be strings.');
throw new GraphQLError('OpaqueScalar values must be strings.', { extensions: { code: 'BAD_USER_INPUT' } });
}
return parseOpaqueScalarValue<T>(jwtSigningKey)(ast.value);
},
Expand Down
10 changes: 5 additions & 5 deletions server/graphql/customScalars/StringOrFloat.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { UserInputError } from 'apollo-server-express';
import { GraphQLScalarType, Kind } from 'graphql';
import { GraphQLError, GraphQLScalarType, Kind } from 'graphql';

/**
* This scalar is needed for values that can be represented either
Expand All @@ -10,7 +9,7 @@ export default new GraphQLScalarType<string | number, string | number>({
description: 'Either an arbitrary string or a float.',
serialize(value) {
if (typeof value !== 'string' && typeof value !== 'number') {
throw new UserInputError('Expected a string or float.');
throw new GraphQLError('Expected a string or float.', { extensions: { code: 'BAD_USER_INPUT' } });
}
return value;
},
Expand All @@ -21,16 +20,17 @@ export default new GraphQLScalarType<string | number, string | number>({
ast.kind !== Kind.FLOAT &&
ast.kind !== Kind.INT
) {
throw new UserInputError('StringOrFloat must be a string or number.');
throw new GraphQLError('StringOrFloat must be a string or number.', { extensions: { code: 'BAD_USER_INPUT' } });
}
return parseStringOrFloatValue(ast.value);
},
});

function parseStringOrFloatValue(value: unknown) {
if (typeof value !== 'string' && typeof value !== 'number') {
throw new UserInputError(
throw new GraphQLError(
'StringOrFloat must be a string or number when passed to the server.',
{ extensions: { code: 'BAD_USER_INPUT' } },
);
}

Expand Down
4 changes: 1 addition & 3 deletions server/graphql/datasources/ActionApi.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { type Exception } from '@opentelemetry/api';
import { DataSource } from 'apollo-datasource';
import pLimit from 'p-limit';
import { uid } from 'uid';
import { v1 as uuidv1 } from 'uuid';
Expand Down Expand Up @@ -30,15 +29,14 @@ import {
/**
* GraphQL Object for an Action
*/
class ActionAPI extends DataSource {
class ActionAPI {
constructor(
private readonly actionPublisher: Dependencies['ActionPublisher'],
private readonly sequelize: Dependencies['Sequelize'],
private readonly tracer: Dependencies['Tracer'],
private readonly itemInvestigationService: Dependencies['ItemInvestigationService'],
private readonly getItemTypeEventuallyConsistent: Dependencies['getItemTypeEventuallyConsistent'],
) {
super();
}

async getGraphQLActionFromId(opts: { id: string; orgId: string }) {
Expand Down
Loading
Loading