Fullstack Entertainment Application

Tools

  • graph-ql
  • react
  • typescript
  • mongodb
  • node-js
  • styled-components
  • cypress
  • react-router

Introduction

Project requirements and design provided by FrontEndMentors.

As my frontend development skills continued to improve, I found myself wondering if I was becoming stagnant. My main goal is to become a frontend developer, but I also wanted to learn more about the full-stack experience as well. I didn't just want to improve my employment opportunities, I wanted to challenge myself to see if I was capable of building a full-stack application. This motivation pushed me to take on the Entertainment Application challenge from FrontEndMentors. The requirement of the project is to create an entertainment app where users can navigate through TV shows, movies, bookmarked, or all content. The challenge was mainly focused on developing the user interface, but I wanted to enhance the the application implementing the following features:

  1. Authentication, user's are able to create an account and can only interact with the application if they are authenticated.
  2. Database modification, user's are able to bookmark content, remove bookmarked content, and have those changes reflect on the user-interface and in the backend.
  3. Front/backend testing, test the entire application to have confidence in client-side and server-side deployments.

Note: when visiting the site, the following credentials can be used to log into the application for those who don't wish to provide an email:

  • Email: testuser@gmail.com
  • Password: Chopper!?990

Techstack Used

This section contains information about the technology used for both the front-end and backend. ESLint is used to lint the application, with a base ESLint file created to apply linting rules that target both the front and backend. The application is set as mono repository, both the client and the server are contained in the same directory with their own configurations that will be explained in this section.

Frontend

The frontend is bootstrapped with Vite using the React/Typescript template. The following tools and libraries were configured to develop the client:

  • React, front-end library for creating user interfaces
  • Cypress, testing library for creating intergration and unit test
  • Apollo Client, state management tool for creating queries/mutations to communicate with the GraphQL server and managing the state of those requests.
  • Formik, front-end library for creating and managing forms in React
  • Styled Components front-end library for writing CSS in React components.
  • React Router front-end library creating page navigation for a React application.

Other packages installed include yup and validator to assist Formik with form validation for the login and sign up pages.

All of the tools were mainly chosen due to familarity. I had the least experience using Formik, but the documentation provided me enough information to get started quickly. The main goal of the project was to learn how to properly configure a full-stack application, so I didn't want to get held up by learning new frontend technologies (though there were cases where it happened anyway).

Backend

No specific template was used to setup the server. Since this was my first time creating my own backend, I wanted to take the time to fully understand what I was doing and configure the server properly. The following tools and libraries were used:

  • Apollo Server, GraphQL server for creating queries and mutations.
  • MongoDB, database for storing user information and bookmarked content.
  • Mongoose, a schema based solution for modeling application data for the database.
  • supertest, testing library for validating HTTP server behavior.

Authentication is handled with JSON Web tokens, with bcrypt used to hash passwords.

I chose GraphQL as the server because it was easier to structure and grab data from a query or mutation. Since I was using TypeScript, the graphql codegen tool was used to scan the GraphQL schema file and generate the type information for queries and mutations. codegen is used on the client as well to generate hook. MongoDB was chosen because it was the database program that I was most familiar with, along with using Mongoose to create the schemas.

Docker

Before starting this project, I had learned about Docker basics and its benefits to frontend/backend development. I didn't want to jump into a full docker development environment just yet, but I at least wanted to streamline the development process when developing with the server. In the root directory, two Docker Compose files were created that initalize a database in a virtual environment that can be accessed by the application:

  • docker-compose.dev.yml, fires up the developer server.
  • docker-compose.test.yml, fires up the test server.

This testing container initializes the database with only a fraction of the actual data, and is configures the sever to executes a reset query (explained in the testing section).

If you aren't familiar with Docker, you can read up on the following:

Development

This section will discuss the methods, thoughts, and challenges faced while developing the application. Specific examples from the client and server will be discussed, we'll also delve into the testing setup of the application as well.

Client

The client is primarily dependent on two libraries, react-router and apollo-client.

When planning out the client, I found that staying on the same URL would make it difficult to organize and change the page structure. Since the application contains home, login, and category pages, relying on some state to change the entire page would be impractical. After conducting some research, I began to learn about routing in react, specfically, the react-router library. After taking the time to read the documentation and complete the provided tutorial, I was able to lay out the structure of my application using the library.

Since I decided to use GraphQL to handle the backend, it was only natural to use a library that plays well with queries and mutations. Like react-router I didn't have much experience using apollo-client, aside from a GraphQL tutorial I found online. Again, after reading through the documentation and multiple articles, I was able to get a basic grasp of the library and plan out exactly what I needed it to do.

The next few sections will dicuss specific tasks or issues faced during development with the client.

React Router

The configuration for the router can be found in the main.tsx component. See the snippet below:

const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    errorElement: <ErrorPage />,
    children: [
      {
        path: '/welcome',
        element: <WelcomePage />,
      },
      {
        path: '/login',
        element: <LoginRoute />,
      },
      { path: '/sign-up', element: <SignUpRoute /> },
      {
        path: '/dashboard',
        element: <Dashboard />,
        children: [
          {
            children: [
              {
                index: true,
                element: <Homepage />,
              },
              {
                path: '/dashboard/movies',
                element: (
                  <Suspense fallback={<SuspenseFallback />}>
                    <Movies />
                  </Suspense>
                ),
              },
              {
                path: '/dashboard/shows',
                element: (
                  <Suspense fallback={<SuspenseFallback />}>
                    <TVShows />
                  </Suspense>
                ),
              },
              {
                path: '/dashboard/my-stuff',
                element: (
                  <Suspense fallback={<SuspenseFallback />}>
                    <Bookmarked />
                  </Suspense>
                ),
              },
            ],
          },
        ],
      },
    ],
  },
]);

If any routing errors occur, users will receive an error page and be navigated back to the login page. React.Suspense was implemented to dynamically load pages. This method only imports the necessary JavaScript needed to run the application, cutting down on the initial bundle size. For example, the Movies.tsx will not be imported on the initial page load, but when a user navigates to the /movies route, a loading component is displayed until the page component is fetched and parsed.

Log in/Sign up

Users can navigate to the /login and /sign-up routes, and once verified, are directed to the homepage. A JSON WebToken is created and saved in local storage, which is inserted into every query to the backend for continous authentication. The token is stored in the applicaiton state, but to avoid repeated logins, the token can be extracted from the local storage (storing tokens in local storage is not a safe practice, this works for my first attempt at this, but I plan to return to improve this functionality).

Formik is used to manage the form state for both forms, see the snippet from the LoginRoute.tsx route:

<Formik
  initialValues={initialValues}
  validationSchema={loginFormValidation}
  onSubmit={(values, actions) => {
    actions.setSubmitting(false);
    void loginUser({
      variables: { email: values.email, password: values.password },
    })
      .then((data) => {
        if (!data.data) return;

        saveToken(data.data.loginUser.token);
        navigate('/dashboard', {
          state: { token: data.data.loginUser.token },
        });
      })
      .catch((e: unknown) => {
        if (e instanceof Error) console.log(e);
      });
  }}
/>

In this case, Formik handles the email and password values. The form is validated with the schema created in the form-validation.ts file. On a successful login, the retrieved token is saved into the local storage and state, and the user is navigated to the /dashboard page with the token passed through as well.

The Dashboard.tsx component contains the following line:

const { error, loading } = useVerifyTokenQuery();

This hook generated by codegen makes a query to the the sever to validate the token. It may seem redundant if we are just logging in, but this also verifies the token saved in the local storage. If the error property is true, then we can assume there is no token or an invalid one and navigate back to the login screen. Else, the <Outlet> component will render a component based on the route config. So by default, users will be on the /dashboard route, and see the Homepage.tsx component.

When a user logs out, they will be navigated back to the login page with a message confirming that they have been logged out. The sign-up process functions virtually the same, with the main difference being in the Formik implementation and validation.

Querying the GraphQL Server

The Apollo Client library is used to handle state management for GraphQL queries. Similar to the backend, the codegen library is used to scan a GraphQL schema file and generate the necesssary types and hooks based on the schema. The generated hooks can be found in the graphql.tsx file.

Let's review one of these hooks, in the Movie.tsx component the following snippet can be found:

const { loading, data: content, error } = useGetAllMoviesQuery();

This hook queries the GraphQL server for all movie information in the database. Like you would expect from libraries such as SWR, the hook returns multiple properites that represent the following:

  • loading, a boolean indicating a loading state.
  • data, the data returned from a successful query. In this case the variable has been renamed to something more appropriate within the context of the application.
  • error, a boolean indicating an error state.

If the query fails for any reason, the PageError.tsx component renders, providing a link to the login page.

On a successfull query, the component will iterate over all movie entires and render them with the SmallContentCard.tsx component.

The other routes follow the same implementation, with a few tweaks for the specific use cases applied.

Mutating the GraphQL Server

Like querying, the Apollo Client library is used to handle state management for mutations. A mutation in GraphQL is a request that modifies data on the server-side. This can include adding users, deleting users, etc. This exampe will focus on bookmarking content on the client. The codegen library also generated hooks to modify the server.

An initial challenge was that if a user visited the bookmarked page, the query would execute to get the bookmarked content. However, since this query already executed, if a user navigates back to the Movie route and bookmarked a movie, the bookmarked page would not contain the updated information. The generated hook was not enough, therefore, a custom hook would need to be created to enhance functionality.

In the /hooks directory in the client, the bookmarkMutation.ts file contains the useBookmarkMutation, which as the name of the hook describes, is used to bookmark content. This new hook uses the generated useBookmarkContentMutation hook, but modifies it by passing in custom cache behavior that executes the following:

  1. If the cache doesn't exist, return.
  2. Grab the cached user data.
  3. Whether the bookmarked content is a movie or TV show, the content is appended to either of the arrays in the cache.
  4. At the end, return the updated user bookmarked data.

We now have a mutation hook that ensures that our page data is properly updated when the bookmark mutation occurs. The hook is utilized in the SmallContent.tsx component and the LargeContent.tsx component. Both of these components create a function that fires the mutation from the hook, which is then placed on the onClick handler of the bookmark button!

Searching For Content

How content is searched depends on the route the user is currently on. For example, on the /movies route, when a user searches by name, the cache is used instead of querying the server.

In the Movie.tsx component, the memoized data of searchedContent is generated when the user changes the value of the search text, see the snippet below:

const { loading, data: content, error } = useGetAllMoviesQuery();
const [search, setSearch] = useState('');

const searchedContent = useMemo(() => {
  return content?.movies.filter((movie) =>
    movie.title.toLowerCase().includes(search.toLowerCase())
  );
}, [search, content]);

A search state keeps track of the user search query, and when it is modified, updates the value of searchedContent. To render the filtered data, the SearchResults.tsx component takes in the query and the filtered content to display it on the page.

The homepage handles searching differently. Due to the schema structure on the GraphQL server, a query hook contacts the server for content information based on the title string passed in.

In the Homepage.tsx, a "lazy" query hook is implemented which only fires if the returned function is called, in this case getSearch. If the state of "search" has a value, and the hook is no longer loading, then we display the results of that hook to the user. On the surface it works the same was as the other pages, but is making queries to the server instead of using the cahce.

To cutdown on the number of network requests made, the following debounce solution was implemented in the Dashboard.tsx component:

const [currentSearch, setCurrentSearch] = useState('');

useEffect(() => {
  const timeoutId = setTimeout(() => {
    setSearch(currentSearch);
  }, 500);

  return () => clearTimeout(timeoutId);
}, [currentSearch, setSearch]);

So as a user updates the currentSearch state (while typing), a timeout is used to hold off on updating the top level state whose change will fire the query. Note that Dashboard.tsx contains its own state for the search input. Since route component search functions fire on the change of search, we're ensuring that the query isn't fired until a user has fully typed out their search.

Server

This was my first time creating my own backend service, thanks to the Fullstack TypeScript course provided by FrontEndMasters, I was able to find a good starting point. I had knowledge of both TypeScript and GraphQL, but didn't have the experience putting it all togther. Much of the time on this application was spent planning how the backend was going to be structured and reading the GraphQL and express documentation.

This section will discuss the configuration of the server, as well as my process to create the query/mutation structure and authentication system.

Server Configuration

For production and development purposes, the file constants.ts contains various constant variables that can be accessed throught the application that provide the following information:

  • Path for client code
  • Path to static folder
  • Path to the GraphQL Schema
  • Port for the GraphQL server
  • The MongoDB URL to use

In the file apollo-server.ts, the GraphQL server is created, see the snippet below:

export async function createApolloServer(
  httpServer: Server,
  app: express.Application
): Promise<ApolloServer<ExpressContext>> {
  const server = new ApolloServer({
    schema: addResolversToSchema({ schema: SCHEMA, resolvers }),
    context: ({ req }): EntertainmentResolverContext => ({
      currentUser: (() => {
        const auth = req ? req.headers.authorization : null;
        if (auth && auth.toLowerCase().startsWith('bearer')) {
          try {
            const decodedToken = jwt.verify(
              auth.substring(7),
              process.env.JWT_SECRET as string
            );
            if (typeof decodedToken === 'string') return null;
            else return decodedToken;
          } catch {
            return null;
          }
        } else return null;
      })(),
    }),
    plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
  });
  await server.start();
  server.applyMiddleware({ app });
  return server;
}

To provide continuous and secure authentication, the context option is passed into the ApolloSever method. For every request made, the authorization header is extracted. From there, the context function verifies the token and either returns the valid token or returns null. How this is used to generate errors will be explained in detail when we discuss resolvers.

The schema.graphql file contains the query and mutation schema infomration which is passed into the schema opton of the server creation. All resolvers created for the server are exported from the resolver.ts file.

The only plugin provided is the ApolloServerPluginDrainHttpServer plugin, which ensures the server shuts down gracefully. At the end of the function, we start the server, apply the express app as a middleware, and return the server.

Intializing the Server

Within the index.ts file, the main function kicks off the server. See the snippet below:

export const app = express();
async function main() {
  app.use('/static', express.static(STATIC_ROOT_FOLDER_PATH));
  const httpServer = createServer(app);
  const apolloServer = await createApolloServer(httpServer, app);
  const mongoURL = MONGO_URL as string;
  const nodeENV = process.env.NODE_ENV as string;

  mongoose
    .connect(mongoURL)
    .then(async () => {
      console.log('connected to mongo db');
      if (nodeENV !== 'test') {
        await seedDB();
        console.log('database seeded');
      }
    })
    .catch((e) => {
      console.log('error connecting to MongoDB');
      console.log(e);
    });

  await new Promise<void>((resolve) => {
    app.listen(PORT, () => {
      console.log(
        `GraphQL API Listening on http://localhost:${PORT}${apolloServer.graphqlPath}`
      );
      resolve();
    });
  });
}

main().catch((err) => console.log(err));

The function executes the following:

  1. create a path to the static folder which will contain images used by the client.
  2. Use the createApolloServer function with an HTTP server passsed in along with the initialized express application.
  3. Grab the Mongo URL and node environment from the constants.ts file.
  4. Connect to the MongoDB database, if it fails we log an error, else we check to see the application is running in the test environment. If in the test environment the database is not seeded, else the database is seeded.
  5. Have the app listen to the PORT provied by constants.ts file.

If you are on a local machine, you can now go to localhost:4000/graphql to view the GraphQL playground and make queries/mutations to the server.

MongoDB

A MongoDB database is used in this application, with content defined as a movie or show, each having its own schema. For example, see the snippet for the from the movie.ts file below:

const movieSchema = new mongoose.Schema<DbMovie>({
  title: String,
  images: {
    small: String,
    medium: String,
    large: String,
  },
  year: Number,
  rating: String,
});

export default mongoose.model<DbMovie>('Movie', movieSchema);

The db.ts file contains the schema types which are applied to the schemas and other parts of the application. In this case, DbMovie is used to help define the structure for movieSchema. A model is exported from each of these schema files, allowing for easier interaction with the database instead of the usual MongoDB queries.

These models are used within service modules, which package all functions related to that model in one file. The file movie-service.ts contains various functions that interface with the database. For instance, see the snippet for getMovieById:

const getMovieById = async (id: string): Promise<DbMovie | null> => {
  const targetMovie = await Movie.findById(id);
  if (targetMovie !== null) return targetMovie;
  else return null;
};

The function uses the modal method find to grab find a movie based on the id passed in. The target movie is returned if found, else null is returned. For every model created, there is a related service module that provides functions to easily interact with it, providing clear intent with our functions and organizing the codebase.

Query Resolvers

All GraphQl resolvers can be found in the /server/resolvers directory. For example, the Query.ts file snippet below displays the movies resolver:

 movies: async (_, __, { currentUser }) => {
    if (!currentUser) throw new AuthenticationError('invalid token');
    const user = await userService.getUser(currentUser.id);
    const bookmarkedMovies = user.bookmarkedMovies.map((id) =>
      id._id.toString()
    );
    const allMovies = await movieService.getAllMovies();
    return allMovies.map((movie) => {
      const bookmarked = bookmarkedMovies.includes(movie.id);
      if (bookmarked) return movieTransform(movie, true);
      else return movieTransform(movie, false);
    });
  },

Refer back to the context implemetation of the server, if there is no valid JWT, then currentUser is set to null. Within this resolver and others, a null value for currentUser indicates either a missing or invalid token. In this case, apollo-sever-core AuthenticationError is thrown, giving the server better context on the error which can provide a clear error message to the client.

If there is a valid user, then the user information is retrieved from the database, movies marked as bookmarked are extracted from the user data, and the getAllMovies method from the movieService is executed to retrieve all movies. Remember that movies can be bookmarked, so before sending the data back to the client, the allMovies array is mapped over. This new array will return an updated version of each movie that indicates if its been bookmarked or not. The movieTransform function found in the transform-resolvers.ts file transforms the information into a data structure that matches the GraphQL schema. This new array is returned by the movies resolver. Since there are not that many entries in the database, and each entry size is miniscule, returning all the data in one go works well in this case.

The resolvers to retrieve tv and bookmarked data work relatively the same.

Mutation Resolvers

Within the Mutation.ts file the bookmarkContent resolver is defined, which can be seen below:

async bookmarkContent(_, args, { currentUser }) {
    if (!currentUser) throw new AuthenticationError('invalid token');
    const user = await userService.getUser(currentUser.id);
    if (args.contentType === 'show') {
      const show = await userService.addFavoriteShow(args.contentId, user);
      return showTransform(show, true);
    } else if (args.contentType === 'movie') {
      const movie = await userService.addFavoriteMovie(args.contentId, user);
      return movieTransform(movie, true);
    } else {
      throw new UserInputError('bad content type provided');
    }
  },

Similar to the query resolvers, if there is no currentUser then an error is thrown. When a valid user is making the request, the following is executed:

  1. If the target content is marked as a content type show, then:
    • Call the user service which inserts the show into the user's bookmarked show property
    • This function will throw an error if a show that is already bookmarked is being inserted again.
  2. If the content being bookmarked is a movie, then the steps for bookmarking a show are repeated, but for the bookmarked movie property under the target user.

To interact with the the database, both the addFavoriteShow and addFavoriteMovie methods from the userService are used. The content that was bookmarked is returned by the resolver, as dicussed earlier in the Frontend section, the content information is used to update the cache on the client.

Testing

Creating test was one of the most challenging process of developing this application, I had some experience testing the client, but had none testing the server. I wanted to create a testing environment that would be give me confidence that the application functions properly when deployed.

Frontend Testing

Cypress was chosen as the end-to-end test runner mainly because it was the tool I had the most experience in with testing the client.

The login.cy.ts file contains the testing suite for the login page. Each test in this suite is set up with the beforeEach function, which visits the intended page and grabs some document elements we expect to already be rendered. The beforeEach function also fires a Mutation to the server to reset the database. Without resetting the database, multiple test would fail because actions made in the previous test would interfere with the current. As discussed before, the test server is running within a docker container, so resetting the database keeps the production database safe and makes running test more efficient. See a test snipppet below:

it('valid login, redirected to home page', () => {
  cy.get('@loginEmail').type('testuser@gmail.com');
  cy.get('@loginPassword').type('Chopper!?990');
  cy.get('@loginButton').click();

  cy.get('@loginForm').should('not.exist');
  cy.url().should('include', '/dashboard');
  cy.get('[data-cy="dashboard"]');
});

The test visits the login page and grabs all the elements needed to interact with the login form. Cypress methods are used to type into the input boxes, click the login button, and verify that the user is navigated to the dashboard. We are basically creating the same process/flow a user would take when using the client.

All Cypress end-to-end test can be found in the directory titled cypress/e2e.

Backend Testing

According to the Apollo Server documentation, it is possible to test the resolvers without sending an HTTP request, but I wanted to verify that our resolvers were responding properly to HTTP requests made.

This led me to use the supertest library to make direct request to the apollo server with custom GraphQL queries and mutations. The most difficult part of this was writing the GraphQL queries and mutations by hand. Since I had already spent an extensive amount of time reading documentation, I just wanted to implement a method that worked. Though in the future I do wish to return and improve the implementation.

The file users.test.ts contains multiple tests that verify a user login and confirms that a token is retrieved. The test file is configured with a baseURL, and before each test, the beforeEach function is used to fire a mutation to reset the database just like in the Cypress test.

const baseURL = request('http://localhost:4000/graphql');

beforeEach(async () => {
  await baseURL.post('').send({
    operationName: 'Mutation',
    query: 'mutation Mutation {resetDb}',
  });
});

See an example of a test below:

describe('User login', () => {
  test('valid user login sent, token returned', async () => {
    await baseURL
      .post('')
      .send({
        operationName: 'Mutation',
        query:
          'mutation Mutation($email: String!, $password: String!) {  loginUser(email: $email, password: $password) {token}}',
        variables: {
          password: 'Chopper!?990',
          email: 'testuser@gmail.com',
        },
      })
      .expect('Content-Type', /application\/json/)
      .expect(200);
  });
  ...
});

Within the User login group, the test sends a mutation to the server to log in. We can't really test the value of the JWT token, but we can confirm that the JSON data typed is returned and a success status code is returned. The same test server that was used for the client test is used here as well.

All backend test can be found in the server directory titled tests.

Conclusion

There is more to discuss about the developemnt of this application, regarding the testing issues, refactoring multiple queries into one, etc. I didn't just create this application to show what I can do, but also get an idea of what knowledge I lacked and where I can improve. For my first attempt at a full-stack application, I think it went pretty well, but I do plan to return and implement improvements from the experience i'll obtain as I progress in my career. As stated earlier, my main goal is to work in the front-end, but I believe having experience with the back-end will make me a more skilled developer.

If you have any suggestions about the application or wish to discuss it, please feel free to email me or visit the GitHub page to open a pull request, thanks for reading!