Feature/Use fastify instead of express (#1)

- Replaced Apollo Server with Mercurius for GraphQL API
- Updated resolvers to use Mercurius-compatible GraphQL implementation
- Migrated from Express to Fastify for server framework
- Improved error handling with GraphQL error extensions
- Added Zod for environment variable validation
- Updated Prisma schema and migrations
- Configured CORS and WebSocket subscriptions
- Simplified GraphQL schema and resolver structure
- Enhanced type safety and code organization
- Replaced Apollo Server with Mercurius for GraphQL API
- Updated resolvers to use Mercurius-compatible GraphQL implementation
- Migrated from Express to Fastify for server framework
- Improved error handling with GraphQL error extensions
- Added Zod for environment variable validation
- Updated Prisma schema and migrations
- Configured CORS and WebSocket subscriptions
- Simplified GraphQL schema and resolver structure
- Enhanced type safety and code organization

Reviewed-on: #1
Co-authored-by: Jusemon <juansmm@outlook.com>
Co-committed-by: Jusemon <juansmm@outlook.com>
This commit is contained in:
Juan Sebastián Montoya 2025-03-06 19:15:56 -05:00 committed by Juan Sebastián Montoya
parent b4e5a04126
commit 6214b503bc
47 changed files with 4968 additions and 5424 deletions

View file

@ -0,0 +1,562 @@
import type {
GraphQLResolveInfo,
GraphQLScalarType,
GraphQLScalarTypeConfig,
} from "graphql";
import type { MercuriusContext } from "mercurius";
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = {
[K in keyof T]: T[K];
};
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & {
[SubKey in K]?: Maybe<T[SubKey]>;
};
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & {
[SubKey in K]: Maybe<T[SubKey]>;
};
export type ResolverFn<TResult, TParent, TContext, TArgs> = (
parent: TParent,
args: TArgs,
context: TContext,
info: GraphQLResolveInfo,
) =>
| Promise<import("mercurius-codegen").DeepPartial<TResult>>
| import("mercurius-codegen").DeepPartial<TResult>;
export type RequireFields<T, K extends keyof T> = Omit<T, K> & {
[P in K]-?: NonNullable<T[P]>;
};
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
ID: string;
String: string;
Boolean: boolean;
Int: number;
Float: number;
DateTime: Date;
_FieldSet: any;
};
export type User = {
__typename?: "User";
id: Scalars["ID"];
email: Scalars["String"];
username: Scalars["String"];
createdAt: Scalars["DateTime"];
updatedAt?: Maybe<Scalars["DateTime"]>;
messages?: Maybe<Array<Message>>;
rooms?: Maybe<Array<Room>>;
ownedRooms?: Maybe<Array<Room>>;
};
export type Room = {
__typename?: "Room";
id: Scalars["ID"];
name: Scalars["String"];
description?: Maybe<Scalars["String"]>;
isPrivate: Scalars["Boolean"];
createdAt: Scalars["DateTime"];
updatedAt?: Maybe<Scalars["DateTime"]>;
messages?: Maybe<Array<Message>>;
members?: Maybe<Array<User>>;
owner?: Maybe<User>;
};
export type Message = {
__typename?: "Message";
id: Scalars["ID"];
content: Scalars["String"];
createdAt: Scalars["DateTime"];
updatedAt?: Maybe<Scalars["DateTime"]>;
userId: Scalars["ID"];
user?: Maybe<User>;
roomId: Scalars["ID"];
room?: Maybe<Room>;
};
export type AuthPayload = {
__typename?: "AuthPayload";
token: Scalars["String"];
user: User;
};
export type Query = {
__typename?: "Query";
me?: Maybe<User>;
users: Array<User>;
user?: Maybe<User>;
rooms: Array<Room>;
room?: Maybe<Room>;
messages: Array<Message>;
};
export type QueryuserArgs = {
id: Scalars["ID"];
};
export type QueryroomArgs = {
id: Scalars["ID"];
};
export type QuerymessagesArgs = {
roomId: Scalars["ID"];
};
export type Mutation = {
__typename?: "Mutation";
register: AuthPayload;
login: AuthPayload;
createRoom: Room;
joinRoom: Room;
leaveRoom: Scalars["Boolean"];
sendMessage: Message;
};
export type MutationregisterArgs = {
email: Scalars["String"];
username: Scalars["String"];
password: Scalars["String"];
};
export type MutationloginArgs = {
email: Scalars["String"];
password: Scalars["String"];
};
export type MutationcreateRoomArgs = {
name: Scalars["String"];
description?: InputMaybe<Scalars["String"]>;
isPrivate?: InputMaybe<Scalars["Boolean"]>;
};
export type MutationjoinRoomArgs = {
roomId: Scalars["ID"];
};
export type MutationleaveRoomArgs = {
roomId: Scalars["ID"];
};
export type MutationsendMessageArgs = {
content: Scalars["String"];
roomId: Scalars["ID"];
};
export type Subscription = {
__typename?: "Subscription";
messageAdded: Message;
roomAdded: Room;
roomUpdated: Room;
};
export type SubscriptionmessageAddedArgs = {
roomId: Scalars["ID"];
};
export type ResolverTypeWrapper<T> = Promise<T> | T;
export type ResolverWithResolve<TResult, TParent, TContext, TArgs> = {
resolve: ResolverFn<TResult, TParent, TContext, TArgs>;
};
export type Resolver<TResult, TParent = {}, TContext = {}, TArgs = {}> =
| ResolverFn<TResult, TParent, TContext, TArgs>
| ResolverWithResolve<TResult, TParent, TContext, TArgs>;
export type SubscriptionSubscribeFn<TResult, TParent, TContext, TArgs> = (
parent: TParent,
args: TArgs,
context: TContext,
info: GraphQLResolveInfo,
) => AsyncIterable<TResult> | Promise<AsyncIterable<TResult>>;
export type SubscriptionResolveFn<TResult, TParent, TContext, TArgs> = (
parent: TParent,
args: TArgs,
context: TContext,
info: GraphQLResolveInfo,
) => TResult | Promise<TResult>;
export interface SubscriptionSubscriberObject<
TResult,
TKey extends string,
TParent,
TContext,
TArgs,
> {
subscribe: SubscriptionSubscribeFn<
{ [key in TKey]: TResult },
TParent,
TContext,
TArgs
>;
resolve?: SubscriptionResolveFn<
TResult,
{ [key in TKey]: TResult },
TContext,
TArgs
>;
}
export interface SubscriptionResolverObject<TResult, TParent, TContext, TArgs> {
subscribe: SubscriptionSubscribeFn<any, TParent, TContext, TArgs>;
resolve: SubscriptionResolveFn<TResult, any, TContext, TArgs>;
}
export type SubscriptionObject<
TResult,
TKey extends string,
TParent,
TContext,
TArgs,
> =
| SubscriptionSubscriberObject<TResult, TKey, TParent, TContext, TArgs>
| SubscriptionResolverObject<TResult, TParent, TContext, TArgs>;
export type SubscriptionResolver<
TResult,
TKey extends string,
TParent = {},
TContext = {},
TArgs = {},
> =
| ((
...args: any[]
) => SubscriptionObject<TResult, TKey, TParent, TContext, TArgs>)
| SubscriptionObject<TResult, TKey, TParent, TContext, TArgs>;
export type TypeResolveFn<TTypes, TParent = {}, TContext = {}> = (
parent: TParent,
context: TContext,
info: GraphQLResolveInfo,
) => Maybe<TTypes> | Promise<Maybe<TTypes>>;
export type IsTypeOfResolverFn<T = {}, TContext = {}> = (
obj: T,
context: TContext,
info: GraphQLResolveInfo,
) => boolean | Promise<boolean>;
export type NextResolverFn<T> = () => Promise<T>;
export type DirectiveResolverFn<
TResult = {},
TParent = {},
TContext = {},
TArgs = {},
> = (
next: NextResolverFn<TResult>,
parent: TParent,
args: TArgs,
context: TContext,
info: GraphQLResolveInfo,
) => TResult | Promise<TResult>;
/** Mapping between all available schema types and the resolvers types */
export type ResolversTypes = {
DateTime: ResolverTypeWrapper<Scalars["DateTime"]>;
User: ResolverTypeWrapper<User>;
ID: ResolverTypeWrapper<Scalars["ID"]>;
String: ResolverTypeWrapper<Scalars["String"]>;
Room: ResolverTypeWrapper<Room>;
Boolean: ResolverTypeWrapper<Scalars["Boolean"]>;
Message: ResolverTypeWrapper<Message>;
AuthPayload: ResolverTypeWrapper<AuthPayload>;
Query: ResolverTypeWrapper<{}>;
Mutation: ResolverTypeWrapper<{}>;
Subscription: ResolverTypeWrapper<{}>;
};
/** Mapping between all available schema types and the resolvers parents */
export type ResolversParentTypes = {
DateTime: Scalars["DateTime"];
User: User;
ID: Scalars["ID"];
String: Scalars["String"];
Room: Room;
Boolean: Scalars["Boolean"];
Message: Message;
AuthPayload: AuthPayload;
Query: {};
Mutation: {};
Subscription: {};
};
export interface DateTimeScalarConfig
extends GraphQLScalarTypeConfig<ResolversTypes["DateTime"], any> {
name: "DateTime";
}
export type UserResolvers<
ContextType = MercuriusContext,
ParentType extends
ResolversParentTypes["User"] = ResolversParentTypes["User"],
> = {
id?: Resolver<ResolversTypes["ID"], ParentType, ContextType>;
email?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
username?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
createdAt?: Resolver<ResolversTypes["DateTime"], ParentType, ContextType>;
updatedAt?: Resolver<
Maybe<ResolversTypes["DateTime"]>,
ParentType,
ContextType
>;
messages?: Resolver<
Maybe<Array<ResolversTypes["Message"]>>,
ParentType,
ContextType
>;
rooms?: Resolver<
Maybe<Array<ResolversTypes["Room"]>>,
ParentType,
ContextType
>;
ownedRooms?: Resolver<
Maybe<Array<ResolversTypes["Room"]>>,
ParentType,
ContextType
>;
isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type RoomResolvers<
ContextType = MercuriusContext,
ParentType extends
ResolversParentTypes["Room"] = ResolversParentTypes["Room"],
> = {
id?: Resolver<ResolversTypes["ID"], ParentType, ContextType>;
name?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
description?: Resolver<
Maybe<ResolversTypes["String"]>,
ParentType,
ContextType
>;
isPrivate?: Resolver<ResolversTypes["Boolean"], ParentType, ContextType>;
createdAt?: Resolver<ResolversTypes["DateTime"], ParentType, ContextType>;
updatedAt?: Resolver<
Maybe<ResolversTypes["DateTime"]>,
ParentType,
ContextType
>;
messages?: Resolver<
Maybe<Array<ResolversTypes["Message"]>>,
ParentType,
ContextType
>;
members?: Resolver<
Maybe<Array<ResolversTypes["User"]>>,
ParentType,
ContextType
>;
owner?: Resolver<Maybe<ResolversTypes["User"]>, ParentType, ContextType>;
isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type MessageResolvers<
ContextType = MercuriusContext,
ParentType extends
ResolversParentTypes["Message"] = ResolversParentTypes["Message"],
> = {
id?: Resolver<ResolversTypes["ID"], ParentType, ContextType>;
content?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
createdAt?: Resolver<ResolversTypes["DateTime"], ParentType, ContextType>;
updatedAt?: Resolver<
Maybe<ResolversTypes["DateTime"]>,
ParentType,
ContextType
>;
userId?: Resolver<ResolversTypes["ID"], ParentType, ContextType>;
user?: Resolver<Maybe<ResolversTypes["User"]>, ParentType, ContextType>;
roomId?: Resolver<ResolversTypes["ID"], ParentType, ContextType>;
room?: Resolver<Maybe<ResolversTypes["Room"]>, ParentType, ContextType>;
isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type AuthPayloadResolvers<
ContextType = MercuriusContext,
ParentType extends
ResolversParentTypes["AuthPayload"] = ResolversParentTypes["AuthPayload"],
> = {
token?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
user?: Resolver<ResolversTypes["User"], ParentType, ContextType>;
isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type QueryResolvers<
ContextType = MercuriusContext,
ParentType extends
ResolversParentTypes["Query"] = ResolversParentTypes["Query"],
> = {
me?: Resolver<Maybe<ResolversTypes["User"]>, ParentType, ContextType>;
users?: Resolver<Array<ResolversTypes["User"]>, ParentType, ContextType>;
user?: Resolver<
Maybe<ResolversTypes["User"]>,
ParentType,
ContextType,
RequireFields<QueryuserArgs, "id">
>;
rooms?: Resolver<Array<ResolversTypes["Room"]>, ParentType, ContextType>;
room?: Resolver<
Maybe<ResolversTypes["Room"]>,
ParentType,
ContextType,
RequireFields<QueryroomArgs, "id">
>;
messages?: Resolver<
Array<ResolversTypes["Message"]>,
ParentType,
ContextType,
RequireFields<QuerymessagesArgs, "roomId">
>;
};
export type MutationResolvers<
ContextType = MercuriusContext,
ParentType extends
ResolversParentTypes["Mutation"] = ResolversParentTypes["Mutation"],
> = {
register?: Resolver<
ResolversTypes["AuthPayload"],
ParentType,
ContextType,
RequireFields<MutationregisterArgs, "email" | "username" | "password">
>;
login?: Resolver<
ResolversTypes["AuthPayload"],
ParentType,
ContextType,
RequireFields<MutationloginArgs, "email" | "password">
>;
createRoom?: Resolver<
ResolversTypes["Room"],
ParentType,
ContextType,
RequireFields<MutationcreateRoomArgs, "name">
>;
joinRoom?: Resolver<
ResolversTypes["Room"],
ParentType,
ContextType,
RequireFields<MutationjoinRoomArgs, "roomId">
>;
leaveRoom?: Resolver<
ResolversTypes["Boolean"],
ParentType,
ContextType,
RequireFields<MutationleaveRoomArgs, "roomId">
>;
sendMessage?: Resolver<
ResolversTypes["Message"],
ParentType,
ContextType,
RequireFields<MutationsendMessageArgs, "content" | "roomId">
>;
};
export type SubscriptionResolvers<
ContextType = MercuriusContext,
ParentType extends
ResolversParentTypes["Subscription"] = ResolversParentTypes["Subscription"],
> = {
messageAdded?: SubscriptionResolver<
ResolversTypes["Message"],
"messageAdded",
ParentType,
ContextType,
RequireFields<SubscriptionmessageAddedArgs, "roomId">
>;
roomAdded?: SubscriptionResolver<
ResolversTypes["Room"],
"roomAdded",
ParentType,
ContextType
>;
roomUpdated?: SubscriptionResolver<
ResolversTypes["Room"],
"roomUpdated",
ParentType,
ContextType
>;
};
export type Resolvers<ContextType = MercuriusContext> = {
DateTime?: GraphQLScalarType;
User?: UserResolvers<ContextType>;
Room?: RoomResolvers<ContextType>;
Message?: MessageResolvers<ContextType>;
AuthPayload?: AuthPayloadResolvers<ContextType>;
Query?: QueryResolvers<ContextType>;
Mutation?: MutationResolvers<ContextType>;
Subscription?: SubscriptionResolvers<ContextType>;
};
export type Loader<TReturn, TObj, TParams, TContext> = (
queries: Array<{
obj: TObj;
params: TParams;
}>,
context: TContext & {
reply: import("fastify").FastifyReply;
},
) => Promise<Array<import("mercurius-codegen").DeepPartial<TReturn>>>;
export type LoaderResolver<TReturn, TObj, TParams, TContext> =
| Loader<TReturn, TObj, TParams, TContext>
| {
loader: Loader<TReturn, TObj, TParams, TContext>;
opts?: {
cache?: boolean;
};
};
export interface Loaders<
TContext = import("mercurius").MercuriusContext & {
reply: import("fastify").FastifyReply;
},
> {
User?: {
id?: LoaderResolver<Scalars["ID"], User, {}, TContext>;
email?: LoaderResolver<Scalars["String"], User, {}, TContext>;
username?: LoaderResolver<Scalars["String"], User, {}, TContext>;
createdAt?: LoaderResolver<Scalars["DateTime"], User, {}, TContext>;
updatedAt?: LoaderResolver<Maybe<Scalars["DateTime"]>, User, {}, TContext>;
messages?: LoaderResolver<Maybe<Array<Message>>, User, {}, TContext>;
rooms?: LoaderResolver<Maybe<Array<Room>>, User, {}, TContext>;
ownedRooms?: LoaderResolver<Maybe<Array<Room>>, User, {}, TContext>;
};
Room?: {
id?: LoaderResolver<Scalars["ID"], Room, {}, TContext>;
name?: LoaderResolver<Scalars["String"], Room, {}, TContext>;
description?: LoaderResolver<Maybe<Scalars["String"]>, Room, {}, TContext>;
isPrivate?: LoaderResolver<Scalars["Boolean"], Room, {}, TContext>;
createdAt?: LoaderResolver<Scalars["DateTime"], Room, {}, TContext>;
updatedAt?: LoaderResolver<Maybe<Scalars["DateTime"]>, Room, {}, TContext>;
messages?: LoaderResolver<Maybe<Array<Message>>, Room, {}, TContext>;
members?: LoaderResolver<Maybe<Array<User>>, Room, {}, TContext>;
owner?: LoaderResolver<Maybe<User>, Room, {}, TContext>;
};
Message?: {
id?: LoaderResolver<Scalars["ID"], Message, {}, TContext>;
content?: LoaderResolver<Scalars["String"], Message, {}, TContext>;
createdAt?: LoaderResolver<Scalars["DateTime"], Message, {}, TContext>;
updatedAt?: LoaderResolver<
Maybe<Scalars["DateTime"]>,
Message,
{},
TContext
>;
userId?: LoaderResolver<Scalars["ID"], Message, {}, TContext>;
user?: LoaderResolver<Maybe<User>, Message, {}, TContext>;
roomId?: LoaderResolver<Scalars["ID"], Message, {}, TContext>;
room?: LoaderResolver<Maybe<Room>, Message, {}, TContext>;
};
AuthPayload?: {
token?: LoaderResolver<Scalars["String"], AuthPayload, {}, TContext>;
user?: LoaderResolver<User, AuthPayload, {}, TContext>;
};
}
declare module "mercurius" {
interface IResolvers
extends Resolvers<import("mercurius").MercuriusContext> {}
interface MercuriusLoaders extends Loaders {}
}

View file

@ -1,93 +1,84 @@
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { createServer } from 'http';
import express from 'express';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import cors from 'cors';
import { PrismaClient } from '@prisma/client';
import { typeDefs } from './schema/typeDefs';
import { resolvers } from './resolvers';
import dotenv from 'dotenv';
import './types';
import fastify, { FastifyRequest } from 'fastify';
import mercurius from 'mercurius';
import mercuriusCodegen from 'mercurius-codegen';
import schema from './schema';
import { resolvers } from './resolvers';
import { PrismaClient } from '@prisma/client';
import fastifyCors from '@fastify/cors';
import { z } from 'zod';
// Load environment variables
dotenv.config();
dotenv.config({ path: '../../.env' });
const envs = z
.object({
ALLOWED_ORIGINS: z.string().default('http://localhost:5173'),
})
.transform((env) => {
return {
allowedOrigins: env.ALLOWED_ORIGINS.split(','),
};
})
.parse(process.env);
console.log(envs);
const app = fastify({
logger: true,
exposeHeadRoutes: true,
});
// Create Prisma client
const prisma = new PrismaClient();
async function startServer() {
// Create Express app and HTTP server
const app = express();
const httpServer = createServer(app);
const context = async (req: FastifyRequest) => {
const userId = (req.headers['user-id'] as string) || null;
return {
prisma,
userId,
};
};
// Create WebSocket server
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
// Create schema
const schema = makeExecutableSchema({ typeDefs, resolvers });
// Set up WebSocket server
const serverCleanup = useServer({ schema }, wsServer);
// Create Apollo Server
const server = new ApolloServer({
schema,
plugins: [
// Proper shutdown for the HTTP server
ApolloServerPluginDrainHttpServer({ httpServer }),
// Proper shutdown for the WebSocket server
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
},
};
},
},
],
});
// Start Apollo Server
await server.start();
// Apply middleware
app.use(
'/graphql',
cors<cors.CorsRequest>(),
express.json(),
expressMiddleware(server, {
context: async ({ req }) => {
// In a real application, you would extract the user ID from a JWT token
// const token = req.headers.authorization || '';
// const userId = getUserIdFromToken(token);
// For demo purposes, we'll use a dummy user ID
const userId = (req.headers['user-id'] as string) || null;
return {
prisma,
userId,
};
},
})
);
// Start the server
const PORT = process.env.PORT || 4000;
httpServer.listen(PORT, () => {
console.log(`🚀 Server ready at http://localhost:${PORT}/graphql`);
console.log(`🚀 Subscriptions ready at ws://localhost:${PORT}/graphql`);
});
}
// Handle errors
startServer().catch((err) => {
console.error('Error starting server:', err);
app.register(fastifyCors, {
origin: (origin, callback) => {
if (envs.allowedOrigins.includes(origin || '*'))
return callback(null, true);
return callback(new Error('Not allowed'), false);
},
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'user-id'],
});
app.register(mercurius, {
schema,
subscription: true,
graphiql: true,
context,
});
app.register(async ({ graphql }) => {
resolvers.forEach((resolver) => {
graphql.defineResolvers(resolver);
});
});
app.get('/ping', async () => {
return 'pong\n';
});
mercuriusCodegen(app, {
targetPath: './src/generated/graphql.ts',
codegenConfig: {
scalars: {
DateTime: 'Date',
},
},
});
app.listen({ port: 8080 }, (err, address) => {
if (err) {
console.error(err);
process.exit(1);
}
console.log(`Server listening at ${address}`);
});

View file

@ -1,23 +1,10 @@
import { userResolvers } from './user';
import { roomResolvers } from './room';
import { messageResolvers } from './message';
import { IResolvers } from 'mercurius';
export const resolvers = {
Query: {
...userResolvers.Query,
...roomResolvers.Query,
...messageResolvers.Query,
},
Mutation: {
...userResolvers.Mutation,
...roomResolvers.Mutation,
...messageResolvers.Mutation,
},
Subscription: {
...messageResolvers.Subscription,
...roomResolvers.Subscription,
},
User: userResolvers.User,
Room: roomResolvers.Room,
Message: messageResolvers.Message,
};
export const resolvers: IResolvers[] = [
userResolvers,
roomResolvers,
messageResolvers,
];

View file

@ -1,17 +1,17 @@
import { PrismaClient } from '@prisma/client';
import { AuthenticationError, ForbiddenError } from 'apollo-server-express';
import { PubSub, withFilter } from 'graphql-subscriptions';
const prisma = new PrismaClient();
const pubsub = new PubSub();
import { GraphQLError } from 'graphql';
import { IResolvers, withFilter } from 'mercurius';
export const MESSAGE_ADDED = 'MESSAGE_ADDED';
export const messageResolvers = {
export const messageResolvers: IResolvers = {
Query: {
messages: async (_: any, { roomId }: { roomId: string }, context: any) => {
if (!context.userId) {
throw new AuthenticationError('You must be logged in to view messages');
messages: async (_, { roomId }, { prisma, userId }) => {
if (!userId) {
throw new GraphQLError('You must be logged in to view messages', {
extensions: {
code: 'UNAUTHENTICATED',
},
});
}
// Check if user is a member of the room
@ -21,14 +21,22 @@ export const messageResolvers = {
});
if (!room) {
throw new ForbiddenError('Room not found');
throw new GraphQLError('Room not found', {
extensions: {
code: 'NOT_FOUND',
},
});
}
const isMember = room.members.some(
(member: { id: string }) => member.id === context.userId
(member: { id: string }) => member.id === userId
);
if (!isMember) {
throw new ForbiddenError('You are not a member of this room');
throw new GraphQLError('You are not a member of this room', {
extensions: {
code: 'FORBIDDEN',
},
});
}
return prisma.message.findMany({
@ -41,12 +49,14 @@ export const messageResolvers = {
sendMessage: async (
_: any,
{ content, roomId }: { content: string; roomId: string },
context: any
{ prisma, userId, pubsub }
) => {
if (!context.userId) {
throw new AuthenticationError(
'You must be logged in to send a message'
);
if (!userId) {
throw new GraphQLError('You must be logged in to send a message', {
extensions: {
code: 'UNAUTHENTICATED',
},
});
}
// Check if user is a member of the room
@ -56,21 +66,29 @@ export const messageResolvers = {
});
if (!room) {
throw new ForbiddenError('Room not found');
throw new GraphQLError('Room not found', {
extensions: {
code: 'NOT_FOUND',
},
});
}
const isMember = room.members.some(
(member: { id: string }) => member.id === context.userId
(member: { id: string }) => member.id === userId
);
if (!isMember) {
throw new ForbiddenError('You are not a member of this room');
throw new GraphQLError('You are not a member of this room', {
extensions: {
code: 'FORBIDDEN',
},
});
}
const message = await prisma.message.create({
data: {
content,
user: {
connect: { id: context.userId },
connect: { id: userId },
},
room: {
connect: { id: roomId },
@ -82,7 +100,10 @@ export const messageResolvers = {
},
});
pubsub.publish(MESSAGE_ADDED, { messageAdded: message, roomId });
pubsub.publish({
topic: MESSAGE_ADDED,
payload: { messageAdded: message, roomId },
});
return message;
},
@ -90,7 +111,7 @@ export const messageResolvers = {
Subscription: {
messageAdded: {
subscribe: withFilter(
() => pubsub.asyncIterator([MESSAGE_ADDED]),
(_, __, { pubsub }) => pubsub.subscribe([MESSAGE_ADDED]),
(payload, variables) => {
return payload.roomId === variables.roomId;
}
@ -98,12 +119,18 @@ export const messageResolvers = {
},
},
Message: {
user: async (parent: any) => {
user: async (parent, _, { prisma }) => {
if (parent.user) {
return parent.user;
}
return prisma.user.findUnique({
where: { id: parent.userId },
});
},
room: async (parent: any) => {
room: async (parent, _, { prisma }) => {
if (parent.room) {
return parent.room;
}
return prisma.room.findUnique({
where: { id: parent.roomId },
});

View file

@ -1,21 +1,17 @@
import { PrismaClient } from '@prisma/client';
import { AuthenticationError, ForbiddenError } from 'apollo-server-express';
import { PubSub } from 'graphql-subscriptions';
const prisma = new PrismaClient();
const pubsub = new PubSub();
import { GraphQLError } from 'graphql';
import { IResolvers } from 'mercurius';
export const ROOM_ADDED = 'ROOM_ADDED';
export const ROOM_UPDATED = 'ROOM_UPDATED';
export const roomResolvers = {
export const roomResolvers: IResolvers = {
Query: {
rooms: async () => {
rooms: async (_, __, { prisma }) => {
return prisma.room.findMany({
where: { isPrivate: false },
});
},
room: async (_: any, { id }: { id: string }) => {
room: async (_: any, { id }: { id: string }, { prisma }) => {
return prisma.room.findUnique({
where: { id },
});
@ -23,39 +19,50 @@ export const roomResolvers = {
},
Mutation: {
createRoom: async (
_: any,
{
name,
description,
isPrivate = false,
}: { name: string; description?: string; isPrivate?: boolean },
context: any
_,
{ name, description, isPrivate },
{ prisma, userId, pubsub }
) => {
if (!context.userId) {
throw new AuthenticationError('You must be logged in to create a room');
if (!userId) {
throw new GraphQLError('You must be logged in to create a room', {
extensions: {
code: 'UNAUTHENTICATED',
},
});
}
const room = await prisma.room.create({
data: {
name,
description,
isPrivate,
isPrivate: isPrivate ?? false,
owner: {
connect: { id: context.userId },
connect: { id: userId },
},
members: {
connect: { id: context.userId },
connect: { id: userId },
},
},
});
pubsub.publish(ROOM_ADDED, { roomAdded: room });
pubsub.publish({
topic: ROOM_ADDED,
payload: { roomAdded: room },
});
return room;
},
joinRoom: async (_: any, { roomId }: { roomId: string }, context: any) => {
if (!context.userId) {
throw new AuthenticationError('You must be logged in to join a room');
joinRoom: async (
_,
{ roomId }: { roomId: string },
{ prisma, userId, pubsub }
) => {
if (!userId) {
throw new GraphQLError('You must be logged in to join a room', {
extensions: {
code: 'UNAUTHENTICATED',
},
});
}
const room = await prisma.room.findUnique({
@ -64,19 +71,28 @@ export const roomResolvers = {
});
if (!room) {
throw new ForbiddenError('Room not found');
throw new GraphQLError('Room not found', {
extensions: {
code: 'NOT_FOUND',
},
});
}
if (room.isPrivate) {
// In a real application, you would check if the user has been invited
throw new ForbiddenError(
'You cannot join a private room without an invitation'
throw new GraphQLError(
'You cannot join a private room without an invitation',
{
extensions: {
code: 'FORBIDDEN',
},
}
);
}
// Check if user is already a member
const isMember = room.members.some(
(member: { id: string }) => member.id === context.userId
(member: { id: string }) => member.id === userId
);
if (isMember) {
return room;
@ -86,20 +102,31 @@ export const roomResolvers = {
where: { id: roomId },
data: {
members: {
connect: { id: context.userId },
connect: { id: userId },
},
},
include: { members: true },
});
// Publish room updated event
pubsub.publish(ROOM_UPDATED, { roomUpdated: updatedRoom });
pubsub.publish({
topic: ROOM_UPDATED,
payload: { roomUpdated: updatedRoom },
});
return updatedRoom;
},
leaveRoom: async (_: any, { roomId }: { roomId: string }, context: any) => {
if (!context.userId) {
throw new AuthenticationError('You must be logged in to leave a room');
leaveRoom: async (
_,
{ roomId }: { roomId: string },
{ prisma, userId, pubsub }
) => {
if (!userId) {
throw new GraphQLError('You must be logged in to leave a room', {
extensions: {
code: 'UNAUTHENTICATED',
},
});
}
const room = await prisma.room.findUnique({
@ -108,68 +135,87 @@ export const roomResolvers = {
});
if (!room) {
throw new ForbiddenError('Room not found');
throw new GraphQLError('Room not found', {
extensions: {
code: 'NOT_FOUND',
},
});
}
// Check if user is a member
const isMember = room.members.some(
(member: { id: string }) => member.id === context.userId
(member: { id: string }) => member.id === userId
);
if (!isMember) {
throw new ForbiddenError('You are not a member of this room');
throw new GraphQLError('You are not a member of this room', {
extensions: {
code: 'FORBIDDEN',
},
});
}
// If user is the owner, they cannot leave
if (room.ownerId === context.userId) {
throw new ForbiddenError('You cannot leave a room you own');
if (room.ownerId === userId) {
throw new GraphQLError('You cannot leave a room you own', {
extensions: {
code: 'FORBIDDEN',
},
});
}
const updatedRoom = await prisma.room.update({
where: { id: roomId },
data: {
members: {
disconnect: { id: context.userId },
disconnect: { id: userId },
},
},
include: { members: true },
});
// Publish room updated event
pubsub.publish(ROOM_UPDATED, { roomUpdated: updatedRoom });
pubsub.publish({
topic: ROOM_UPDATED,
payload: { roomUpdated: updatedRoom },
});
return true;
},
},
Subscription: {
roomAdded: {
subscribe: () => pubsub.asyncIterator([ROOM_ADDED]),
subscribe: (_, __, { pubsub }) => pubsub.subscribe(ROOM_ADDED),
},
roomUpdated: {
subscribe: () => pubsub.asyncIterator([ROOM_UPDATED]),
subscribe: (_, __, { pubsub }) => pubsub.subscribe(ROOM_UPDATED),
},
},
Room: {
messages: async (parent: any) => {
return prisma.message.findMany({
where: { roomId: parent.id },
orderBy: { createdAt: 'asc' },
});
},
members: async (parent: any) => {
return prisma.user.findMany({
where: {
rooms: {
some: {
id: parent.id,
},
},
},
});
},
owner: async (parent: any) => {
return prisma.user.findUnique({
where: { id: parent.ownerId },
});
},
messages: async (parent: any, _, { prisma }) =>
parent.messages
? parent.messages
: prisma.room
.findUnique({
where: { id: parent.id },
})
.messages({
orderBy: { createdAt: 'asc' },
}),
members: async (parent, _, { prisma }) =>
parent.members
? parent.members
: prisma.room
.findUnique({
where: { id: parent.id },
})
.members(),
owner: async (parent, _, { prisma }) =>
parent.owner
? parent.owner
: prisma.room
.findUnique({
where: { id: parent.id },
})
.owner(),
},
};

View file

@ -1,27 +1,26 @@
import { PrismaClient } from '@prisma/client';
import { AuthenticationError, UserInputError } from 'apollo-server-express';
import { GraphQLError } from 'graphql';
import { IResolvers } from 'mercurius';
// In a real application, you would use bcrypt for password hashing
// import bcrypt from 'bcryptjs';
// import jwt from 'jsonwebtoken';
const prisma = new PrismaClient();
export const userResolvers = {
export const userResolvers: IResolvers = {
Query: {
me: async (_: any, __: any, context: any) => {
me: async (_, __, { prisma, userId }) => {
// In a real application, you would get the user from the context
// which would be set by an authentication middleware
if (!context.userId) {
if (!userId) {
return null;
}
return prisma.user.findUnique({
where: { id: context.userId },
where: { id: userId },
});
},
users: async () => {
users: async (_, __, { prisma }) => {
return prisma.user.findMany();
},
user: async (_: any, { id }: { id: string }) => {
user: async (_, { id }: { id: string }, { prisma }) => {
return prisma.user.findUnique({
where: { id },
});
@ -34,7 +33,8 @@ export const userResolvers = {
email,
username,
password,
}: { email: string; username: string; password: string }
}: { email: string; username: string; password: string },
{ prisma }
) => {
// Check if user already exists
const existingUser = await prisma.user.findFirst({
@ -44,7 +44,11 @@ export const userResolvers = {
});
if (existingUser) {
throw new UserInputError('User already exists');
throw new GraphQLError('User already exists', {
extensions: {
code: 'USER_ALREADY_EXISTS',
},
});
}
// In a real application, you would hash the password
@ -68,14 +72,19 @@ export const userResolvers = {
},
login: async (
_: any,
{ email, password }: { email: string; password: string }
{ email, password }: { email: string; password: string },
{ prisma }
) => {
const user = await prisma.user.findUnique({
where: { email },
});
if (!user) {
throw new AuthenticationError('Invalid credentials');
throw new GraphQLError('Invalid credentials', {
extensions: {
code: 'INVALID_CREDENTIALS',
},
});
}
// In a real application, you would verify the password
@ -83,7 +92,11 @@ export const userResolvers = {
const valid = password === user.password; // This is just for demo purposes
if (!valid) {
throw new AuthenticationError('Invalid credentials');
throw new GraphQLError('Invalid credentials', {
extensions: {
code: 'INVALID_CREDENTIALS',
},
});
}
// In a real application, you would generate a JWT token
@ -96,26 +109,23 @@ export const userResolvers = {
},
},
User: {
messages: async (parent: any) => {
return prisma.message.findMany({
where: { userId: parent.id },
});
},
rooms: async (parent: any) => {
return prisma.room.findMany({
messages: async (user, _, { prisma }) =>
prisma.user
.findUnique({
where: { id: user.id },
})
.messages(),
rooms: async (user, _, { prisma }) =>
prisma.room.findMany({
where: {
members: {
some: {
id: parent.id,
},
},
OR: [{ ownerId: user.id }, { members: { some: { id: user.id } } }],
},
});
},
ownedRooms: async (parent: any) => {
return prisma.room.findMany({
where: { ownerId: parent.id },
});
},
}),
ownedRooms: async (user, _, { prisma }) =>
prisma.user
.findUnique({
where: { id: user.id },
})
.rooms(),
},
};

View file

@ -1,12 +1,14 @@
import { gql } from 'apollo-server-express';
import { gql } from 'mercurius-codegen';
export default gql`
scalar DateTime
export const typeDefs = gql`
type User {
id: ID!
email: String!
username: String!
createdAt: String!
updatedAt: String!
createdAt: DateTime!
updatedAt: DateTime
messages: [Message!]
rooms: [Room!]
ownedRooms: [Room!]
@ -17,20 +19,22 @@ export const typeDefs = gql`
name: String!
description: String
isPrivate: Boolean!
createdAt: String!
updatedAt: String!
createdAt: DateTime!
updatedAt: DateTime
messages: [Message!]
members: [User!]
owner: User!
owner: User
}
type Message {
id: ID!
content: String!
createdAt: String!
updatedAt: String!
user: User!
room: Room!
createdAt: DateTime!
updatedAt: DateTime
userId: ID!
user: User
roomId: ID!
room: Room
}
type AuthPayload {

8
apps/api/src/types.ts Normal file
View file

@ -0,0 +1,8 @@
import type { PrismaClient } from '@prisma/client';
declare module 'mercurius' {
interface MercuriusContext {
prisma: PrismaClient;
userId: string | null;
}
}