diff --git a/CHANGELOG.md b/CHANGELOG.md index 115909d..a62e8e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.18.0](https://github.com/amalv/apollo-server-lambda-postgres/compare/v1.17.0...v1.18.0) (2023-12-29) + + +### Features + +* enhance User and Favorite Resolvers and Update Fixtures ([9d378ce](https://github.com/amalv/apollo-server-lambda-postgres/commit/9d378ce0328272bc1ea37796f549eae2de49edbf)) + # [1.17.0](https://github.com/amalv/apollo-server-lambda-postgres/compare/v1.16.1...v1.17.0) (2023-12-28) diff --git a/README.md b/README.md index 2353e4e..0eb8899 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ npm install This project uses two environment files for local and production environments: -.env.local: This file should contain your local Postgres database credentials. It is used when running the project locally. +`.env.local`: This file should contain your local Postgres database credentials. It is used when running the project locally. -.env.production: This file should contain your production Postgres database credentials. It is used when deploying the project to production. +`.env.production`: This file should contain your production Postgres database credentials. It is used when deploying the project to production. Both files should have the following structure: @@ -77,9 +77,11 @@ For TypeScript, `ts-jest` is used to allow Jest to understand TypeScript syntax. To deploy the project to production, use the following command: ```bash -npx serverless deploy --stage production` command. This will deploy the API to AWS Lambda. +npx serverless deploy --stage production` ``` +This will deploy the API to AWS Lambda. + ## Features 🚀 Deploy an Apollo Server GraphQL API to AWS Lambda\ @@ -92,4 +94,4 @@ npx serverless deploy --stage production` command. This will deploy the API to A 📈 Winston logger for comprehensive logging\ 🔧 Jest for unit testing and coverage\ 🔄 Database migrations to manage database changes\ -🔀 TypeScript for static typing\ +🔀 TypeScript for static typing diff --git a/src/entity/Favorite.ts b/src/entity/Favorite.ts index 6bf24b3..ef8e6da 100644 --- a/src/entity/Favorite.ts +++ b/src/entity/Favorite.ts @@ -1,12 +1,25 @@ -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from "typeorm"; +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + Unique, +} from "typeorm"; import { User } from "./User"; import { Book } from "./Book"; @Entity() +@Unique(["userId", "bookId"]) export class Favorite { @PrimaryGeneratedColumn() id: number; + @Column() + userId: number; + + @Column() + bookId: number; + @ManyToOne(() => User, (user) => user.favorites) user: User; diff --git a/src/graphql/resolvers/Favorite.ts b/src/graphql/resolvers/Favorite.ts index 39dac4d..7d2fdce 100644 --- a/src/graphql/resolvers/Favorite.ts +++ b/src/graphql/resolvers/Favorite.ts @@ -5,22 +5,32 @@ import { Book } from "../../entity/Book"; export const FavoriteResolvers = { Mutation: { - favoriteBook: async (_, { userId, bookId }, context) => { + favoriteBook: async (_, { bookId }, context) => { try { - console.log("User ID:", userId); - console.log("Book ID:", bookId); - if (!userId || !bookId) { - throw new Error("User ID or Book ID not provided"); + const userId = context.userId; + + if (!userId) { + throw new Error("User ID not provided"); + } + + if (!bookId) { + throw new Error("Book ID not provided"); } + const dataSourceInstance = await dataSource; const userRepository = dataSourceInstance.getRepository(User); const bookRepository = dataSourceInstance.getRepository(Book); const favoriteRepository = dataSourceInstance.getRepository(Favorite); - const user = await userRepository.findOne({ where: { id: userId } }); + let user = await userRepository.findOne({ where: { id: userId } }); const book = await bookRepository.findOne({ where: { id: bookId } }); - if (!user || !book) { - throw new Error("User or Book not found"); + if (!book) { + throw new Error("Book not found"); + } + + if (!user) { + user = userRepository.create({ auth0Id: userId }); + await userRepository.save(user); } const favorite = new Favorite(); diff --git a/src/graphql/resolvers/User.ts b/src/graphql/resolvers/User.ts index 139d178..ec61e83 100644 --- a/src/graphql/resolvers/User.ts +++ b/src/graphql/resolvers/User.ts @@ -14,5 +14,30 @@ export const UserResolvers = { throw error; } }, + user: async (_, __, context) => { + try { + const userId = context.userId; + + if (!userId) { + throw new Error("User ID not provided"); + } + + const dataSourceInstance = await dataSource; + const userRepository = dataSourceInstance.getRepository(User); + const user = await userRepository.findOne({ + where: { auth0Id: userId }, + relations: ["favorites", "favorites.book"], + }); + + if (!user) { + throw new Error("User not found"); + } + + return user; + } catch (error) { + console.error("Error fetching user:", error); + throw error; + } + }, }, }; diff --git a/src/graphql/resolvers/resolvers.ts b/src/graphql/resolvers/resolvers.ts new file mode 100644 index 0000000..8ba87b3 --- /dev/null +++ b/src/graphql/resolvers/resolvers.ts @@ -0,0 +1,14 @@ +import { UserResolvers } from "./User"; +import { BookResolvers } from "./Book"; +import { FavoriteResolvers } from "./Favorite"; + +export const resolvers = { + Query: { + ...UserResolvers.Query, + ...BookResolvers.Query, + ...FavoriteResolvers.Query, + }, + Mutation: { + ...FavoriteResolvers.Mutation, + }, +}; diff --git a/src/graphql/types/index.ts b/src/graphql/types/index.ts index 0db7a7e..8fac6f4 100644 --- a/src/graphql/types/index.ts +++ b/src/graphql/types/index.ts @@ -1,20 +1 @@ -import { UserType } from "./User"; -import { BookType } from "./Book"; -import { BooksPageType } from "./BooksPage"; -import { FavoriteType } from "./Favorite"; - -export const typeDefs = `#graphql - ${UserType} - ${BookType} - ${BooksPageType} - ${FavoriteType} - type Query { - hello: String! - dbInfo: String! - users: [User!]! - books(author: String, title: String, cursor: String, limit: Int): BooksPage! - favoriteBooks(userId: ID!): [Favorite!]! - } - type Mutation { - favoriteBook(userId: ID!, bookId: ID!): Favorite - }`; +export * from "./typeDefs"; diff --git a/src/graphql/types/typeDefs.ts b/src/graphql/types/typeDefs.ts new file mode 100644 index 0000000..f5f1bf3 --- /dev/null +++ b/src/graphql/types/typeDefs.ts @@ -0,0 +1,21 @@ +import { UserType } from "./User"; +import { BookType } from "./Book"; +import { BooksPageType } from "./BooksPage"; +import { FavoriteType } from "./Favorite"; + +export const typeDefs = `#graphql + ${UserType} + ${BookType} + ${BooksPageType} + ${FavoriteType} + type Query { + hello: String! + dbInfo: String! + user: User + users: [User!]! + books(author: String, title: String, cursor: String, limit: Int): BooksPage! + favoriteBooks(userId: ID!): [Favorite!]! + } + type Mutation { + favoriteBook(bookId: ID!): Favorite + }`; diff --git a/src/scripts/loadBookFixtures.ts b/src/scripts/loadBookFixtures.ts index 8e356a1..3210dfe 100644 --- a/src/scripts/loadBookFixtures.ts +++ b/src/scripts/loadBookFixtures.ts @@ -2,9 +2,16 @@ import { faker } from "@faker-js/faker"; import { AppDataSource } from "../data-source"; import { User } from "../entity/User"; import { Book } from "../entity/Book"; +import { Favorite } from "../entity/Favorite"; import booksData from "../fixtures/raw/books.json"; const initializeBooks = async (bookData) => { + if (isNaN(Date.parse(bookData.publicationDate))) { + console.log( + `Skipping book with invalid publication date: ${bookData.title}` + ); + return; + } const book = new Book(); book.title = bookData.title; book.author = bookData.author; @@ -12,23 +19,38 @@ const initializeBooks = async (bookData) => { book.image = bookData.image || faker.image.url({ width: 150, height: 150 }); book.rating = bookData.rating; book.ratingsCount = bookData.ratingsCount; - await AppDataSource.manager.save(book); - console.log("Saved a new book with id: " + book.id); + return AppDataSource.manager.save(book); }; -const loadBooks = async () => { - const books = await AppDataSource.manager.find(Book); +const initializeUsers = async () => { + const user = new User(); + user.auth0Id = "1234567890"; + return AppDataSource.manager.save(user); +}; + +const initializeFavorites = async (user, book) => { + const favorite = new Favorite(); + favorite.userId = user.id; + favorite.bookId = book.id; + return AppDataSource.manager.save(favorite); }; AppDataSource.initialize() .then(async () => { - console.log("Dropping and recreating the books table..."); + console.log("Dropping and recreating the tables..."); + await AppDataSource.manager.query('TRUNCATE "favorite" CASCADE'); + await AppDataSource.manager.query('TRUNCATE "user", "book" CASCADE'); await AppDataSource.synchronize(true); console.log("Inserting new books into the database..."); - await Promise.all(booksData.map(initializeBooks)); + const books = await Promise.all(booksData.map(initializeBooks)); + + console.log("Inserting new users into the database..."); + const user = await initializeUsers(); - console.log("Loading books from the database..."); - await loadBooks(); + console.log("Marking some books as favorites for the user..."); + await Promise.all( + books.slice(0, 5).map((book) => initializeFavorites(user, book)) + ); }) .catch((error) => console.log(error)); diff --git a/src/server.test.ts b/src/server.test.ts index 7bf5617..65ebbee 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,5 +1,7 @@ import { ApolloServer } from "@apollo/server"; + jest.mock("./graphql/types"); + jest.mock("./graphql/resolvers", () => ({ Query: { users: jest.fn(), @@ -22,10 +24,7 @@ describe("server.ts", () => { const { graphqlHandler } = require("./server"); + expect(startServerAndCreateLambdaHandler).toHaveBeenCalled(); expect(typeof graphqlHandler).toBe("function"); - expect(startServerAndCreateLambdaHandler).toHaveBeenCalledWith( - expect.any(ApolloServer), - expect.any(Function) - ); }); }); diff --git a/src/server.ts b/src/server.ts index e785b05..401c593 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,6 +8,12 @@ import { typeDefs } from "./graphql/types"; import winston from "winston"; import jwt from "jsonwebtoken"; +interface GraphQLContext { + userId?: string; + lambdaEvent?: any; + lambdaContext?: any; +} + const logger = winston.createLogger({ level: "info", format: winston.format.json(), @@ -25,15 +31,21 @@ if (process.env.NODE_ENV !== "production") { ); } -interface MyContext { - userId?: string; - lambdaEvent?: any; - lambdaContext?: any; -} +const decodeToken = (token: string): string | undefined => { + if (token?.startsWith("Bearer ")) { + const jwtToken = token.slice(7, token.length).trimStart(); + try { + const decoded = jwt.verify(jwtToken, process.env.JWT_SECRET); + return decoded.sub; + } catch (err) { + logger.error("Invalid token"); + } + } +}; let server: ApolloServer | undefined; try { - server = new ApolloServer({ + server = new ApolloServer({ typeDefs, resolvers, introspection: true, @@ -49,16 +61,7 @@ const graphqlHandler = startServerAndCreateLambdaHandler( { context: async ({ event, context }) => { const token = event.headers.authorization || ""; - let userId; - if (token && token.startsWith("Bearer ")) { - const jwtToken = token.slice(7, token.length).trimLeft(); - try { - const decoded = jwt.verify(jwtToken, process.env.JWT_SECRET); - userId = decoded.sub; - } catch (err) { - logger.error("Invalid token"); - } - } + const userId = decodeToken(token); return { userId,