diff --git a/.env.example b/.env.example deleted file mode 100644 index 8d87289d7..000000000 --- a/.env.example +++ /dev/null @@ -1 +0,0 @@ -FASTIFY_PORT=8000 \ No newline at end of file diff --git a/src/routes/graphql/dataloaders.ts b/src/routes/graphql/dataloaders.ts new file mode 100644 index 000000000..deb70a102 --- /dev/null +++ b/src/routes/graphql/dataloaders.ts @@ -0,0 +1,65 @@ +import DataLoader from 'dataloader'; +import { PrismaClient } from '@prisma/client'; + +export function createContext(prisma: PrismaClient) { + return { + postLoader: new DataLoader(async (userIds) => { + const posts = await prisma.post.findMany({ + where: { + authorId: { in: userIds as string[] }, + }, + }); + return userIds.map((id) => posts.filter((post) => post.authorId === id)); + }), + + profileLoader: new DataLoader(async (userIds) => { + const profiles = await prisma.profile.findMany({ + where: { + userId: { in: userIds as string[] }, + }, + }); + return userIds.map((id) => profiles.find((profile) => profile.userId === id)); + }), + + memberTypeLoader: new DataLoader(async (memberTypeIds) => { + const memberTypes = await prisma.memberType.findMany({ + where: { + id: { in: memberTypeIds as string[] }, + }, + }); + return memberTypeIds.map((id) => + memberTypes.find((memberType) => memberType.id === id), + ); + }), + + userSubscribedToLoader: new DataLoader(async (userIds) => { + const userSubscribed = await prisma.subscribersOnAuthors.findMany({ + where: { + subscriberId: { in: userIds as string[] }, + }, + include: { + author: true, + }, + }); + return userIds.map((id) => + userSubscribed + .filter((user) => user.subscriberId === id) + .map((user) => user.author), + ); + }), + + subscribedToUserLoader: new DataLoader(async (userIds) => { + const subscribers = await prisma.subscribersOnAuthors.findMany({ + where: { + authorId: { in: userIds as string[] }, + }, + include: { + subscriber: true, + }, + }); + return userIds.map((id) => + subscribers.filter((user) => user.authorId === id).map((user) => user.subscriber), + ); + }), + }; +} diff --git a/src/routes/graphql/index.ts b/src/routes/graphql/index.ts index bb974d9c8..4b7f1ab4d 100644 --- a/src/routes/graphql/index.ts +++ b/src/routes/graphql/index.ts @@ -1,6 +1,8 @@ import { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'; -import { createGqlResponseSchema, gqlResponseSchema } from './schemas.js'; -import { graphql } from 'graphql'; +import { createGqlResponseSchema, gqlResponseSchema, gqlSchema } from './schemas.js'; +import { graphql, parse, validate } from 'graphql'; +import depthLimit from 'graphql-depth-limit'; +import { createContext } from './dataloaders.js'; const plugin: FastifyPluginAsyncTypebox = async (fastify) => { const { prisma } = fastify; @@ -15,7 +17,18 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { }, }, async handler(req) { - // return graphql(); + const errors = validate(gqlSchema, parse(req.body.query), [depthLimit(5)]); + if (errors.length) return { errors }; + + return graphql({ + schema: gqlSchema, + source: req.body.query, + variableValues: req.body.variables, + contextValue: { + prisma, + loaders: createContext(prisma), + }, + }); }, }); }; diff --git a/src/routes/graphql/mutation.ts b/src/routes/graphql/mutation.ts new file mode 100644 index 000000000..fd8ff23d9 --- /dev/null +++ b/src/routes/graphql/mutation.ts @@ -0,0 +1,186 @@ +import { Prisma } from '@prisma/client'; +import { GraphQLNonNull, GraphQLObjectType, GraphQLString } from 'graphql'; +import { postType, createPostInput, changePostInput } from './types/postType.js'; +import { + profileType, + createProfileInput, + changeProfileInput, +} from './types/profileType.js'; +import { userType, createUserInput, changeUserInput } from './types/userType.js'; +import { UUIDType } from './types/uuid.js'; +import { Context } from './types/model.js'; + +export const mutationType = new GraphQLObjectType({ + name: 'Mutations', + fields: { + createUser: { + type: new GraphQLNonNull(userType), + args: { + dto: { type: new GraphQLNonNull(createUserInput) }, + }, + resolve: async (_, { dto }, context: Context) => { + return context.prisma.user.create({ + data: dto as Prisma.UserCreateInput, + }); + }, + }, + createProfile: { + type: new GraphQLNonNull(profileType), + args: { + dto: { type: new GraphQLNonNull(createProfileInput) }, + }, + resolve: async (_, { dto }, context: Context) => { + return context.prisma.profile.create({ + data: dto as Prisma.ProfileCreateInput, + }); + }, + }, + createPost: { + type: new GraphQLNonNull(postType), + args: { + dto: { type: new GraphQLNonNull(createPostInput) }, + }, + resolve: async (_, { dto }, context: Context) => { + return context.prisma.post.create({ + data: dto as Prisma.PostCreateInput, + }); + }, + }, + changePost: { + type: new GraphQLNonNull(postType), + args: { + id: { type: new GraphQLNonNull(UUIDType) }, + dto: { type: new GraphQLNonNull(changePostInput) }, + }, + resolve: ( + _, + { id, dto }: { id: string; dto: Prisma.PostUpdateInput }, + context: Context, + ) => { + return context.prisma.post.update({ + where: { id }, + data: dto, + }); + }, + }, + changeProfile: { + type: new GraphQLNonNull(profileType), + args: { + id: { type: new GraphQLNonNull(UUIDType) }, + dto: { type: new GraphQLNonNull(changeProfileInput) }, + }, + resolve: async ( + _, + { id, dto }: { id: string; dto: Prisma.ProfileUpdateInput }, + context: Context, + ) => { + return context.prisma.profile.update({ + where: { id }, + data: dto, + }); + }, + }, + changeUser: { + type: new GraphQLNonNull(userType), + args: { + id: { type: new GraphQLNonNull(UUIDType) }, + dto: { type: new GraphQLNonNull(changeUserInput) }, + }, + resolve: async ( + _, + { id, dto }: { id: string; dto: Prisma.UserUpdateInput }, + context: Context, + ) => { + return context.prisma.user.update({ + where: { id }, + data: dto, + }); + }, + }, + deleteUser: { + type: new GraphQLNonNull(GraphQLString), + args: { + id: { type: new GraphQLNonNull(UUIDType) }, + }, + resolve: async (_, { id }: { id: string }, context: Context) => { + return ( + await context.prisma.user.delete({ + where: { id }, + }) + ).id; + }, + }, + deletePost: { + type: new GraphQLNonNull(GraphQLString), + args: { + id: { type: new GraphQLNonNull(UUIDType) }, + }, + resolve: async (_, { id }: { id: string }, context: Context) => { + return ( + await context.prisma.post.delete({ + where: { id }, + }) + ).id; + }, + }, + deleteProfile: { + type: new GraphQLNonNull(GraphQLString), + args: { + id: { type: new GraphQLNonNull(UUIDType) }, + }, + resolve: async (_, { id }: { id: string }, context: Context) => { + return ( + await context.prisma.profile.delete({ + where: { id }, + }) + ).id; + }, + }, + subscribeTo: { + type: new GraphQLNonNull(GraphQLString), + args: { + userId: { type: new GraphQLNonNull(UUIDType) }, + authorId: { type: new GraphQLNonNull(UUIDType) }, + }, + resolve: async ( + _, + { userId, authorId }: { userId: string; authorId: string }, + context: Context, + ) => { + return ( + await context.prisma.subscribersOnAuthors.create({ + data: { + subscriberId: userId, + authorId, + }, + }) + ).subscriberId; + }, + }, + unsubscribeFrom: { + type: new GraphQLNonNull(GraphQLString), + args: { + userId: { type: new GraphQLNonNull(UUIDType) }, + authorId: { type: new GraphQLNonNull(UUIDType) }, + }, + resolve: async ( + _, + { userId, authorId }: { userId: string; authorId: string }, + context: Context, + ) => { + return ( + await context.prisma.subscribersOnAuthors.delete({ + where: { + subscriberId: userId, + authorId: authorId, + subscriberId_authorId: { + subscriberId: userId, + authorId, + }, + }, + }) + ).authorId; + }, + }, + }, +}); diff --git a/src/routes/graphql/query.ts b/src/routes/graphql/query.ts new file mode 100644 index 000000000..60984dc73 --- /dev/null +++ b/src/routes/graphql/query.ts @@ -0,0 +1,124 @@ +import { GraphQLList, GraphQLNonNull, GraphQLObjectType } from 'graphql'; +import { Args, Context, User } from './types/model.js'; +import { memberType, memberTypeIdEnum } from './types/memberType.js'; +import { postType } from './types/postType.js'; +import { profileType } from './types/profileType.js'; +import { userType } from './types/userType.js'; +import { UUIDType } from './types/uuid.js'; +import { + parseResolveInfo, + ResolveTree, + simplifyParsedResolveInfoFragmentWithType, +} from 'graphql-parse-resolve-info'; + +export const rootQueryType = new GraphQLObjectType({ + name: 'RootQueryType', + fields: { + users: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(userType))), + resolve: async (parents, args, context: Context, resolveInfo) => { + const parsedResolveInfoFragment = parseResolveInfo(resolveInfo) as ResolveTree; + const { fields } = simplifyParsedResolveInfoFragmentWithType( + parsedResolveInfoFragment, + userType, + ); + const include = { + userSubscribedTo: 'userSubscribedTo' in fields, + subscribedToUser: 'subscribedToUser' in fields, + }; + + const users = await context.prisma.user.findMany({ + include, + }); + + users.forEach((user) => { + context.loaders.subscribedToUserLoader.prime( + user.id, + (user.subscribedToUser?.map((mapping) => + users.find((user) => mapping.subscriberId === user.id), + ) ?? []) as User[], + ); + + context.loaders.userSubscribedToLoader.prime( + user.id, + (user.subscribedToUser?.map((mapping) => + users.find((user) => mapping.authorId === user.id), + ) ?? []) as User[], + ); + }); + + return users; + }, + }, + user: { + type: userType, + args: { + id: { type: new GraphQLNonNull(UUIDType) }, + }, + resolve: async (_, { id }: Args, context: Context) => { + return context.prisma.user.findUnique({ + where: { + id, + }, + }); + }, + }, + posts: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(postType))), + resolve: async (parents, args, context: Context) => { + return context.prisma.post.findMany(); + }, + }, + post: { + type: postType, + args: { + id: { type: new GraphQLNonNull(UUIDType) }, + }, + resolve: async (_, { id }: Args, context: Context) => { + return context.prisma.post.findUnique({ + where: { + id, + }, + }); + }, + }, + profiles: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(profileType))), + resolve: async (parents, args, context: Context) => { + return context.prisma.profile.findMany(); + }, + }, + profile: { + type: profileType, + args: { + id: { type: new GraphQLNonNull(UUIDType) }, + }, + resolve: async (_, { id }: Args, context: Context) => { + return context.prisma.profile.findUnique({ + where: { + id, + }, + }); + }, + }, + memberTypes: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(memberType))), + resolve: async (parents, args, context: Context) => { + return context.prisma.memberType.findMany(); + }, + }, + memberType: { + type: memberType, + args: { + id: { type: new GraphQLNonNull(memberTypeIdEnum) }, + }, + resolve: async (_, { id }: Args, context: Context) => { + return context.prisma.memberType.findUnique({ + where: { + id, + }, + }); + }, + }, + }, +}); diff --git a/src/routes/graphql/schemas.ts b/src/routes/graphql/schemas.ts index 56772d6e7..61303f3e4 100644 --- a/src/routes/graphql/schemas.ts +++ b/src/routes/graphql/schemas.ts @@ -1,4 +1,7 @@ import { Type } from '@fastify/type-provider-typebox'; +import { GraphQLSchema } from 'graphql'; +import { mutationType } from './mutation.js'; +import { rootQueryType } from './query.js'; export const gqlResponseSchema = Type.Partial( Type.Object({ @@ -18,3 +21,8 @@ export const createGqlResponseSchema = { }, ), }; + +export const gqlSchema: GraphQLSchema = new GraphQLSchema({ + mutation: mutationType, + query: rootQueryType, +}); diff --git a/src/routes/graphql/types/memberType.ts b/src/routes/graphql/types/memberType.ts new file mode 100644 index 000000000..9f82c57ef --- /dev/null +++ b/src/routes/graphql/types/memberType.ts @@ -0,0 +1,24 @@ +import { + GraphQLEnumType, + GraphQLObjectType, + GraphQLNonNull, + GraphQLFloat, + GraphQLInt, +} from 'graphql'; + +export const memberTypeIdEnum = new GraphQLEnumType({ + name: 'MemberTypeId', + values: { + BASIC: { value: 'BASIC' }, + BUSINESS: { value: 'BUSINESS' }, + }, +}); + +export const memberType: GraphQLObjectType = new GraphQLObjectType({ + name: 'MemberType', + fields: () => ({ + id: { type: new GraphQLNonNull(memberTypeIdEnum) }, + discount: { type: new GraphQLNonNull(GraphQLFloat) }, + postsLimitPerMonth: { type: new GraphQLNonNull(GraphQLInt) }, + }), +}); diff --git a/src/routes/graphql/types/model.ts b/src/routes/graphql/types/model.ts new file mode 100644 index 000000000..14afcd1ea --- /dev/null +++ b/src/routes/graphql/types/model.ts @@ -0,0 +1,44 @@ +import { PrismaClient } from '@prisma/client'; +import DataLoader from 'dataloader'; + +export interface User { + id: string; + name: string; + balance: number; +} + +export interface Post { + id: string; + title: string; + content: string; + authorId: string; +} + +export interface Profile { + id: string; + isMale: boolean; + yearOfBirth: number; + userId: string; + memberTypeId: string; +} + +export interface MemberType { + id: string; + discount: number; + postsLimitPerMonth: number; +} + +export interface Context { + prisma: PrismaClient; + loaders: { + postLoader: DataLoader; + profileLoader: DataLoader; + memberTypeLoader: DataLoader; + userSubscribedToLoader: DataLoader; + subscribedToUserLoader: DataLoader; + }; +} + +export interface Args { + id: string; +} diff --git a/src/routes/graphql/types/postType.ts b/src/routes/graphql/types/postType.ts new file mode 100644 index 000000000..c70f7ab7b --- /dev/null +++ b/src/routes/graphql/types/postType.ts @@ -0,0 +1,34 @@ +import { + GraphQLInputObjectType, + GraphQLInputType, + GraphQLNonNull, + GraphQLObjectType, + GraphQLString, +} from 'graphql'; +import { UUIDType } from './uuid.js'; + +export const postType: GraphQLObjectType = new GraphQLObjectType({ + name: 'PostType', + fields: () => ({ + id: { type: new GraphQLNonNull(UUIDType) }, + title: { type: new GraphQLNonNull(GraphQLString) }, + content: { type: new GraphQLNonNull(GraphQLString) }, + }), +}); + +export const createPostInput: GraphQLInputType = new GraphQLInputObjectType({ + name: 'CreatePostInput', + fields: () => ({ + title: { type: new GraphQLNonNull(GraphQLString) }, + content: { type: new GraphQLNonNull(GraphQLString) }, + authorId: { type: new GraphQLNonNull(UUIDType) }, + }), +}); + +export const changePostInput: GraphQLInputType = new GraphQLInputObjectType({ + name: 'ChangePostInput', + fields: () => ({ + title: { type: GraphQLString }, + content: { type: GraphQLString }, + }), +}); diff --git a/src/routes/graphql/types/profileType.ts b/src/routes/graphql/types/profileType.ts new file mode 100644 index 000000000..ab4713369 --- /dev/null +++ b/src/routes/graphql/types/profileType.ts @@ -0,0 +1,45 @@ +import { Profile } from '@prisma/client'; +import { + GraphQLObjectType, + GraphQLNonNull, + GraphQLBoolean, + GraphQLInt, + GraphQLInputType, + GraphQLInputObjectType, +} from 'graphql'; +import { memberType, memberTypeIdEnum } from './memberType.js'; +import { UUIDType } from './uuid.js'; +import { Context } from './model.js'; + +export const profileType: GraphQLObjectType = new GraphQLObjectType({ + name: 'Profile', + fields: () => ({ + id: { type: new GraphQLNonNull(UUIDType) }, + isMale: { type: new GraphQLNonNull(GraphQLBoolean) }, + yearOfBirth: { type: new GraphQLNonNull(GraphQLInt) }, + memberType: { + type: new GraphQLNonNull(memberType), + resolve: async (profile: Profile, _, context: Context) => + context.loaders.memberTypeLoader.load(profile.memberTypeId), + }, + }), +}); + +export const createProfileInput: GraphQLInputType = new GraphQLInputObjectType({ + name: 'CreateProfileInput', + fields: () => ({ + isMale: { type: new GraphQLNonNull(GraphQLBoolean) }, + yearOfBirth: { type: new GraphQLNonNull(GraphQLInt) }, + userId: { type: new GraphQLNonNull(UUIDType) }, + memberTypeId: { type: new GraphQLNonNull(memberTypeIdEnum) }, + }), +}); + +export const changeProfileInput: GraphQLInputType = new GraphQLInputObjectType({ + name: 'ChangeProfileInput', + fields: () => ({ + isMale: { type: GraphQLBoolean }, + yearOfBirth: { type: GraphQLInt }, + memberTypeId: { type: memberTypeIdEnum }, + }), +}); diff --git a/src/routes/graphql/types/userType.ts b/src/routes/graphql/types/userType.ts new file mode 100644 index 000000000..5afbf99be --- /dev/null +++ b/src/routes/graphql/types/userType.ts @@ -0,0 +1,60 @@ +import { User } from '@prisma/client'; +import { + GraphQLObjectType, + GraphQLNonNull, + GraphQLString, + GraphQLFloat, + GraphQLList, + GraphQLInputObjectType, + GraphQLInputType, +} from 'graphql'; +import { postType } from './postType.js'; +import { profileType } from './profileType.js'; +import { UUIDType } from './uuid.js'; +import { Context } from './model.js'; + +export const userType: GraphQLObjectType = new GraphQLObjectType({ + name: 'User', + fields: () => ({ + id: { type: new GraphQLNonNull(UUIDType) }, + name: { type: new GraphQLNonNull(GraphQLString) }, + balance: { type: new GraphQLNonNull(GraphQLFloat) }, + profile: { + type: profileType, + resolve: async (user: User, _, context: Context) => { + return context.loaders.profileLoader.load(user.id); + }, + }, + posts: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(postType))), + resolve: async (user: User, _, context: Context) => + context.loaders.postLoader.load(user.id), + }, + userSubscribedTo: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(userType))), + resolve: async (user: User, _, context: Context) => + context.loaders.userSubscribedToLoader.load(user.id), + }, + subscribedToUser: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(userType))), + resolve: async (user: User, _, context: Context) => + context.loaders.subscribedToUserLoader.load(user.id), + }, + }), +}); + +export const createUserInput: GraphQLInputType = new GraphQLInputObjectType({ + name: 'CreateUserInput', + fields: () => ({ + name: { type: new GraphQLNonNull(GraphQLString) }, + balance: { type: new GraphQLNonNull(GraphQLFloat) }, + }), +}); + +export const changeUserInput: GraphQLInputType = new GraphQLInputObjectType({ + name: 'ChangeUserInput', + fields: () => ({ + name: { type: GraphQLString }, + balance: { type: GraphQLFloat }, + }), +});