diff --git a/backend/controllers/book.api.ts b/backend/controllers/book.api.ts index 9158e08..12036a1 100644 --- a/backend/controllers/book.api.ts +++ b/backend/controllers/book.api.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from 'express'; import mongoose from 'mongoose'; -import { getOptions } from './../helpers/config'; +import { getOptions } from '../helpers/config'; import { BookModel } from '../models/book.model'; import { ChapterModel } from '../models/chapter.model'; @@ -18,7 +18,6 @@ export const bookController = { getBook: async (req: Request, res: Response, next: NextFunction) => { const options = await getOptions(req); - try { const id = req.params.id; const book = await BookModel.paginate({ _id: id }, options); diff --git a/backend/controllers/chapter.api.ts b/backend/controllers/chapter.api.ts index e2a6930..4f500e8 100644 --- a/backend/controllers/chapter.api.ts +++ b/backend/controllers/chapter.api.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from 'express'; -import { getOptions } from './../helpers/config'; +import { getOptions } from '../helpers/config'; import { ChapterModel } from '../models/chapter.model'; export const chapterController = { diff --git a/backend/graphql/resolvers.ts b/backend/graphql/resolvers.ts new file mode 100644 index 0000000..090ce95 --- /dev/null +++ b/backend/graphql/resolvers.ts @@ -0,0 +1,292 @@ +import { bookController } from '../controllers/book.api'; +import { chapterController } from '../controllers/chapter.api'; +import { movieController } from '../controllers/movie.api'; +import { characterController } from '../controllers/character.api'; +import { quoteController } from '../controllers/quote.api'; +import { + IGraphQLContext, + GraphQLBody, + GraphQLResponse, + Book, + Chapter, + Character, + Movie, + Quote, + Pagination, + DataNames, + ChapterWithBookId +} from './schema'; +import { createRESTArgumentsFromGraphqlRequest } from '../helpers/config'; +import { passportHelpers } from '../helpers/passport'; + +export async function getBook(body: GraphQLBody<{ id: string; pagination?: Pagination }>, context: IGraphQLContext) { + const { req, res, next } = createRESTArgumentsFromGraphqlRequest(context, body, 'book'); + const data = await bookController.getBook(req, res, next); + return data; +} + +export async function getBooks(body: GraphQLBody<{ pagination?: Pagination }>, context: IGraphQLContext) { + const { req, res, next } = createRESTArgumentsFromGraphqlRequest(context, body, 'books'); + await passportHelpers.authenticate(req, res, next); + const data = await bookController.getBooks(req, res, next); + return data; +} + +export async function getChaptersByBook( + body: GraphQLBody<{ id: string; pagination?: Pagination }>, + context: IGraphQLContext +) { + const { + req: ChaptersReq, + res: ChaptersRes, + next: ChaptersNext + } = createRESTArgumentsFromGraphqlRequest(context, body, 'chapters'); + const chapters = (await bookController.getChaptersByBook(ChaptersReq, ChaptersRes, ChaptersNext)) as GraphQLResponse<{ + chapters: Chapter[]; + }> | void; + if (!chapters) { + return chapters; + } else { + const chaptersWithBookId = chapters.chapters.map((chapter) => { + return { + ...chapter, + book: body.id + }; + }); + const response = await addBooksToChapters(chaptersWithBookId, context); + return { + ...chapters, + chapters: response + }; + } +} + +export async function getChapters(body: GraphQLBody<{ pagination?: Pagination }>, context: IGraphQLContext) { + const { + req: ChaptersReq, + res: ChaptersRes, + next: ChaptersNext + } = createRESTArgumentsFromGraphqlRequest(context, body, 'chapters'); + const chapters = (await chapterController.getChapters(ChaptersReq, ChaptersRes, ChaptersNext)) as GraphQLResponse<{ + chapters: ChapterWithBookId[]; + }> | void; + if (!chapters) { + return chapters; + } else { + const response = await addBooksToChapters(chapters.chapters, context); + return { + ...chapters, + chapters: response + }; + } +} + +export async function getChapter( + body: GraphQLBody<{ id: string; pagination?: Pagination } & Pagination>, + context: IGraphQLContext +) { + const { req, res, next } = createRESTArgumentsFromGraphqlRequest(context, body, 'chapter'); + const chapter = (await chapterController.getChapter(req, res, next)) as GraphQLResponse<{ + chapter: ChapterWithBookId; + }> | void; + if (!chapter) { + return chapter; + } else { + const response = await addBooksToChapters(chapter.chapter, context); + return { + ...chapter, + chapter: response + }; + } +} + +export async function getMovie(body: GraphQLBody<{ id: string; pagination?: Pagination }>, context: IGraphQLContext) { + const { req, res, next } = createRESTArgumentsFromGraphqlRequest(context, body, 'movie'); + const data = await movieController.getMovie(req, res, next); + return data; +} + +export async function getMovies(body: GraphQLBody<{ pagination?: Pagination }>, context: IGraphQLContext) { + const { req, res, next } = createRESTArgumentsFromGraphqlRequest(context, body, 'movies'); + const data = await movieController.getMovies(req, res, next); + return data; +} + +export async function getQuoteByMovie( + body: GraphQLBody<{ movieId: string; pagination?: Pagination }>, + context: IGraphQLContext +) { + const { + req: QuotesReq, + res: QuotesRes, + next: QuotesNext + } = createRESTArgumentsFromGraphqlRequest(context, body, 'quotes'); + const quotes = (await movieController.getQuoteByMovie(QuotesReq, QuotesRes, QuotesNext)) as GraphQLResponse<{ + quotes: Quote[]; + }> | void; + if (!quotes) { + return quotes; + } else { + let response = await addMoviesToQuotes(quotes.quotes, context); + response = await addCharacterToQuotes(response, context); + return { + ...quotes, + quotes: response + }; + } +} + +export async function getCharacter(body: GraphQLBody<{ id: string; pagination?: Pagination }>, context: IGraphQLContext) { + const { req, res, next } = createRESTArgumentsFromGraphqlRequest(context, body, 'character'); + const data = await characterController.getCharacter(req, res, next); + return data; +} + +export async function getCharacters(body: GraphQLBody<{ pagination?: Pagination }>, context: IGraphQLContext) { + const { req, res, next } = createRESTArgumentsFromGraphqlRequest(context, body, 'characters'); + const data = await characterController.getCharacters(req, res, next); + return data; +} + +export async function getQuoteByCharacter( + body: GraphQLBody<{ characterId: string; pagination?: Pagination }>, + context: IGraphQLContext +) { + const { + req: QuotesReq, + res: QuotesRes, + next: QuotesNext + } = createRESTArgumentsFromGraphqlRequest(context, body, 'quotes'); + const quotes = (await characterController.getQuoteByCharacter(QuotesReq, QuotesRes, QuotesNext)) as GraphQLResponse<{ + quotes: Quote[]; + }> | void; + if (!quotes) { + return quotes; + } else { + let response = await addMoviesToQuotes(quotes.quotes, context); + response = await addCharacterToQuotes(response, context); + return { + ...quotes, + quotes: response + }; + } +} + +export async function getQuote(body: GraphQLBody<{ id: string; pagination?: Pagination }>, context: IGraphQLContext) { + const { req, res, next } = createRESTArgumentsFromGraphqlRequest(context, body, 'quote'); + let quote = (await quoteController.getQuote(req, res, next)) as GraphQLResponse<{ + quote: Quote; + }> | void; + if (!quote) { + return quote; + } + let response = await addMoviesToQuotes(quote.quote, context); + response = await addCharacterToQuotes(response, context); + return { + ...quote, + quote: response + }; +} + +export async function getQuotes(body: GraphQLBody<{ pagination?: Pagination }>, context: IGraphQLContext) { + const { req, res, next } = createRESTArgumentsFromGraphqlRequest(context, body, 'quotes'); + let quotes = (await quoteController.getQuotes(req, res, next)) as GraphQLResponse<{ + quotes: Quote[]; + }> | void; + if (!quotes) { + return quotes; + } + let response = await addMoviesToQuotes(quotes.quotes, context); + response = await addCharacterToQuotes(response, context); + return { + ...quotes, + quotes: response + }; +} + +async function addBooksToChapters(chapters: ChapterWithBookId[] | ChapterWithBookId, context: IGraphQLContext) { + if (!Array.isArray(chapters)) { + if (chapters.book) { + const { req, res, next } = createRESTArgumentsFromGraphqlRequest(context, { id: chapters.book }, 'book', false); + const book = (await bookController.getBook(req, res, next)) as { book: Book } | void; + if (book) { + return { ...chapters, book: book.book }; + } + } + return chapters; + } else { + const chapterPromises: Promise[] = chapters.map(async (chapter) => { + if (chapter.book) { + const { req, res, next } = createRESTArgumentsFromGraphqlRequest(context, { id: chapter.book }, 'book', false); + const book = (await bookController.getBook(req, res, next)) as { book: Book } | void; + if (book) { + return { ...chapter, book: book.book }; + } + } + return chapter; + }); + return await Promise.all(chapterPromises).then((chapters) => chapters); + } +} + +async function addMoviesToQuotes(quotes: Quote[] | Quote, context: IGraphQLContext) { + if (!Array.isArray(quotes)) { + if (quotes.movie) { + const { req, res, next } = createRESTArgumentsFromGraphqlRequest(context, { id: quotes.movie._id }, 'movie', false); + const movie = (await movieController.getMovie(req, res, next)) as { movie: Movie } | void; + if (movie) { + return { ...quotes, movie: movie.movie }; + } + } + return quotes; + } else { + const quotePromises: Promise[] = quotes.map(async (quote) => { + if (quote.movie) { + const { req, res, next } = createRESTArgumentsFromGraphqlRequest(context, { id: quote.movie._id }, 'movie', false); + const movie = (await movieController.getMovie(req, res, next)) as { movie: Movie } | void; + if (movie) { + return { ...quote, movie: movie.movie }; + } + } + return quote; + }); + return await Promise.all(quotePromises).then((quotes) => quotes); + } +} + +async function addCharacterToQuotes(quote: Quote[] | Quote, context: IGraphQLContext) { + if (!Array.isArray(quote)) { + if (quote.character) { + const { req, res, next } = createRESTArgumentsFromGraphqlRequest( + context, + { id: quote.character._id }, + 'character', + false + ); + const character = (await characterController.getCharacter(req, res, next)) as { character: Character } | void; + if (character) { + return { ...quote, character: character.character }; + } + } + return quote; + } else { + const quotePromises: Promise[] = quote.map(async (quote) => { + if (quote.character) { + const { req, res, next } = createRESTArgumentsFromGraphqlRequest( + context, + { id: quote.character._id }, + 'character', + false + ); + const character = (await characterController.getCharacter(req, res, next)) as { + character: Character; + } | void; + if (character) { + return { ...quote, character: character.character }; + } + } + return quote; + }); + return await Promise.all(quotePromises).then((quotes) => quotes); + } +} diff --git a/backend/graphql/root.ts b/backend/graphql/root.ts new file mode 100644 index 0000000..35c1c7c --- /dev/null +++ b/backend/graphql/root.ts @@ -0,0 +1,33 @@ +import { + getChapter, + getChapters, + getChaptersByBook, + getBooks, + getBook, + getCharacters, + getCharacter, + getQuoteByCharacter, + getQuote, + getQuotes, + getQuoteByMovie, + getMovies, + getMovie +} from './resolvers'; + +const root = { + book: getBook, + books: getBooks, + chaptersByBook: getChaptersByBook, + chapters: getChapters, + chapter: getChapter, + movies: getMovies, + movie: getMovie, + quoteByMovie: getQuoteByMovie, + characters: getCharacters, + character: getCharacter, + quoteByCharacter: getQuoteByCharacter, + quotes: getQuotes, + quote: getQuote +}; + +export default root; diff --git a/backend/graphql/schema.ts b/backend/graphql/schema.ts new file mode 100644 index 0000000..a4154d4 --- /dev/null +++ b/backend/graphql/schema.ts @@ -0,0 +1,233 @@ +import { buildSchema } from 'graphql/utilities'; + +const schema = buildSchema(` + type Query { + books(pagination:PaginationInput): BooksResponse + book(id: ID!, pagination:PaginationInput): BookResponse + chaptersByBook(id: ID!, pagination:PaginationInput): ChaptersResponse + chapters(pagination:PaginationInput): ChaptersResponse + chapter(id: ID!, pagination:PaginationInput): ChapterResponse + movies(pagination:PaginationInput): MoviesResponse + movie(id: ID!, pagination:PaginationInput): MovieResponse + quoteByMovie(id: ID!, pagination:PaginationInput): QuotesResponse + characters(pagination:PaginationInput): CharactersResponse + character(id: ID!, pagination:PaginationInput): CharacterResponse + quoteByCharacter(id: ID!, pagination:PaginationInput): QuotesResponse + quotes(pagination:PaginationInput): QuotesResponse + quote(id: ID!, pagination:PaginationInput): QuoteResponse + } + type BooksResponse { + books: [Book] + total: Int + limit: Int + page: Int + pages: Int + } + type BookResponse { + book: Book + total: Int + limit: Int + page: Int + pages: Int + } + type ChapterResponse { + chapter: Chapter + total: Int + limit: Int + page: Int + pages: Int + } + type ChaptersResponse { + chapters: [Chapter] + total: Int + limit: Int + page: Int + pages: Int + } + type MovieResponse { + movie: Movie + limit: Int + page: Int + pages: Int + total: Int + } + + type MoviesResponse { + movies: [Movie] + total: Int + limit: Int + page: Int + pages: Int + } + type CharacterResponse { + character: Character + total: Int + limit: Int + page: Int + pages: Int + } + type CharactersResponse { + characters: [Character] + total: Int + limit: Int + page: Int + pages: Int + } + type QuoteResponse { + quote: Quote + total: Int + limit: Int + page: Int + pages: Int + } + + + type QuotesResponse { + quotes: [Quote] + total: Int + limit: Int + page: Int + pages: Int + } + + type Book { + _id: ID + name: String + } + + type Chapter { + _id: ID + chapterName: String + book: Book + } + + type Movie { + _id: ID + name: String + runtimeInMinutes: Float + budgetInMillions: Float + boxOfficeRevenueInMillions: Float + academyAwardNominations: Int + academyAwardWins: Int + rottenTomatoesScore: Float + } + + type Character { + _id: ID + height: String + race: String + gender: String + birth: String + spouse: String + death: String + realm: String + hair: String + name: String + wikiUrl: String + } + + type Quote { + _id: ID + dialog: String + movie: Movie + character: Character + } + type Pagination { + total: Int + limit: Int + page: Int + pages: Int + } + input PaginationInput { + sort: String + limit: Int + page: Int + offset: Int + } +`); + +export default schema; + +export interface IGraphQLContext { + requestInfo: { + req: Express.Request; + context: { + res: Express.Response; + }; + }; +} + +export type GraphQLBody = T & { + sort?: string; + limit?: string; + page?: string; + offset?: string; +}; +export type GraphQLResponse = T & { + total: number; + limit: number; + page: number; + pages: number; +}; +export type Book = { + _id: string; + name: string; +}; +export type Chapter = { + _id: string; + chapterName: string; +}; +export type ChapterWithBookId = Chapter & { + book: string; +}; +export type Movie = { + _id: string; + name: string; + runtimeInMinutes: number; + budgetInMillions: number; + boxOfficeRevenueInMillions: number; + academyAwardNominations: number; + academyAwardWins: number; + rottenTomatoesScore: number; +}; +export type Character = { + _id: string; + height: string; + race: string; + gender: string; + birth: string; + spouse: string; + death: string; + realm: string; + hair: string; + name: string; + wikiUrl: string; +}; + +export type Quote = { + _id: string; + dialog: string; + movie?: Movie; + character?: Character; +}; + +export type Pagination = { + total?: number; + limit?: number; + page?: number; + pages?: number; +}; + +export type DataNames = + | 'books' + | 'book' + | 'chapters' + | 'chapter' + | 'movies' + | 'movie' + | 'quoteByMovie' + | 'characters' + | 'character' + | 'quoteByCharacter' + | 'quotes' + | 'quote'; diff --git a/backend/helpers/config.ts b/backend/helpers/config.ts index 6678654..4c913d6 100644 --- a/backend/helpers/config.ts +++ b/backend/helpers/config.ts @@ -1,8 +1,8 @@ import { Request } from 'express'; import { MongooseQueryParser } from 'mongoose-query-parser'; - import { PaginateOptions } from './interfaces'; - +import { IGraphQLContext, DataNames } from '../graphql/schema'; +import { ParsedQs } from 'qs'; const ascending = 'asc'; const maxLimit = 1000; @@ -48,3 +48,61 @@ export const getOptions = async (req: Request): Promise => { return options; }; + +export const createRESTArgumentsFromGraphqlRequest = ( + context: IGraphQLContext, + bodyPayload: any, + dataName: DataNames, + addPaginationData: boolean = true +) => { + const { pagination, ...body } = bodyPayload; + const req = { + ...context.requestInfo.req, + query: { + ...pagination + }, + params: { + ...body + } + } as Request<{ params: typeof bodyPayload }, any, any, ParsedQs, Record>; + function isPlural(dataName: DataNames): boolean { + return dataName.endsWith('s'); + } + const res = { + ...context.requestInfo.context.res, + json: ( + data: any + ): { + [dataName: string]: any; + pages?: number; + page?: number; + offset?: number; + limit?: number; + total?: number; + } => { + const targetData = isPlural(dataName) + ? data.docs.map((el: any) => el.toObject()) + : data.docs[0] + ? data.docs[0].toObject() + : []; + let returnData = { + [dataName]: targetData + }; + if (addPaginationData) { + returnData = { + ...returnData, + pages: data.pages, + page: data.page, + offset: data.offset, + limit: data.limit, + total: data.total + }; + } + return returnData; + } + } as any; + const next = (err: any) => { + throw new Error(err); + }; + return { req, res, next }; +}; diff --git a/backend/helpers/passport.ts b/backend/helpers/passport.ts index ce62cfa..28dd04e 100644 --- a/backend/helpers/passport.ts +++ b/backend/helpers/passport.ts @@ -6,8 +6,18 @@ import { HttpCode } from './constants'; export const passportHelpers = { authenticate: async (req: Request, res: Response, next: NextFunction) => { passport.authenticate('bearer', { session: false }, async function (err: Error, token: string) { + if (err || !token) { + return new Error('Unauthorized'); + } else { + next(); + } + })(req, res, next); + }, + graphqlAuthentication: (req: Request, res: Response, next: NextFunction) => { + const allowedOperations = ['getBooks', 'book', 'chaptersByBook', 'IntrospectionQuery']; + passport.authenticate('bearer', { session: false }, function (err: Error, token: string) { try { - if (err || !token) { + if (err || !token || !allowedOperations.includes(req.body.operationName)) { return res.status(HttpCode.UNAUTHORIZED).send({ success: false, message: 'Unauthorized.' @@ -17,9 +27,8 @@ export const passportHelpers = { } catch (err) { console.log(err); } - })(req, res, next); + })(req, res); }, - login: async (req: Request, res: Response, next: NextFunction) => { passport.authenticate('login', async function (err: Error, user: any, info: any) { try { diff --git a/backend/package-lock.json b/backend/package-lock.json index e22f2d9..69f4dc7 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,6 +15,8 @@ "dotenv": "^8.6.0", "express": "^4.21.1", "express-rate-limit": "^5.5.1", + "graphql": "^16.9.0", + "graphql-http": "^1.22.1", "helmet": "^4.6.0", "jsonwebtoken": "^9.0.2", "mongoose": "^5.13.21", @@ -4633,6 +4635,30 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/graphql": { + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", + "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-http": { + "version": "1.22.1", + "resolved": "https://registry.npmjs.org/graphql-http/-/graphql-http-1.22.1.tgz", + "integrity": "sha512-4Jor+LRbA7SfSaw7dfDUs2UBzvWg3cKrykfHRgKsOIvQaLuf+QOcG2t3Mx5N9GzSNJcuqMqJWz0ta5+BryEmXg==", + "license": "MIT", + "workspaces": [ + "implementations/**/*" + ], + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "graphql": ">=0.11 <=16" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 8db5486..c22f15b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,6 +24,8 @@ "dotenv": "^8.6.0", "express": "^4.21.1", "express-rate-limit": "^5.5.1", + "graphql": "^16.9.0", + "graphql-http": "^1.22.1", "helmet": "^4.6.0", "jsonwebtoken": "^9.0.2", "mongoose": "^5.13.21", diff --git a/backend/routes/auth.ts b/backend/routes/auth.ts index 270f92c..bc64f9f 100644 --- a/backend/routes/auth.ts +++ b/backend/routes/auth.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { authController } from '../controllers/auth'; -import { passportHelpers } from './../helpers/passport'; +import { passportHelpers } from '../helpers/passport'; import authLimiter from '../middleware/auth.limiter'; import { HttpCode, notFoundResponse } from '../helpers/constants'; diff --git a/backend/server.ts b/backend/server.ts index 7af6d72..b8a37ee 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -1,5 +1,6 @@ -require('dotenv').config(); +import { passportHelpers } from './helpers/passport'; +require('dotenv').config(); import mongoose from 'mongoose'; import express from 'express'; import bcrypt from 'bcrypt'; @@ -13,15 +14,16 @@ import { apiLimiter } from './middleware/api.limiter'; import { UserModel } from './models/user.model'; import { Strategy as LocalStrategy } from 'passport-local'; import { Strategy as BearerStrategy } from 'passport-http-bearer'; - import { HttpCode } from './helpers/constants'; import apiRoutes from './routes/api'; import authRoutes from './routes/auth'; import { User } from './helpers/interfaces'; +import { createHandler } from 'graphql-http/lib/use/express'; +import schema from './graphql/schema'; +import root from './graphql/root'; const app = express(); const dpwcToken = process.env.DPWC_TOKEN || ''; - app.use(helmet()); app.use(express.json()); app.use(express.urlencoded({ extended: false })); @@ -85,8 +87,8 @@ passport.use( app.use((req, res, next) => { const path = req.path; - if (path.startsWith('/v2') || path.startsWith('/auth')) { - const connected = mongoose.connection.readyState === 1 ? true : false; + if (path.startsWith('/v2') || path.startsWith('/auth') || path.startsWith('/graphql')) { + const connected = mongoose.connection.readyState === 1; if (connected) { next(); } else { @@ -99,7 +101,15 @@ app.use((req, res, next) => { next(); } }); - +app.all( + '/graphql', + passportHelpers.graphqlAuthentication, + createHandler({ + schema: schema, + rootValue: root, + context: (req) => ({ requestInfo: req }) + }) +); app.use('/v2/', apiLimiter); app.use('/v2', apiRoutes); app.use('/auth', authRoutes);