Build a Personal Movie Library

About this tutorial

This is article is a step-by-step tutorial about building a simple function-based API server and a client to consume it.


The Personal Movie Library is a movie catalogue service that allows the user to browse movies, get movie details and search movies by actor and director.


The project will feature:

This project is build upon @httpc/server.


This project will use typescript.

What you’ll learn

Phase 1

  1. Set up a monorepo with 2 packages, the API server and the Console client
  2. Build a simple httpc API with minimal code
  3. Call the API functions from the Console with ease thanks to autocompletion and typing definitions

Phase 2

  1. Evolving the API with new functionalities and modifications
  2. Experience the immediate changes available on the Console, getting new functions to call and breaking at compile time when the API functions have changed

Chapters

Chapter 1: Setup the monorepo

This project will use the following monorepo structure:

project/
├─ api/
├─ console/
└─ package.json

Let’s begin!


Please, create a new folder and inside it create a package.json.

{
    "name": "movie-library-monorepo",
    "version": "1.0.0",
    "private": true
}

You can name it as you want. You can also use package initializer like npm init that will create the package.json in an automatic fashion.

If you use npm, to create a workspace, please edit the package.json with:

package.json

{
    "name": "movie-library-monorepo",
    "workspaces": [
        "api",
        "console"
    ],
    "version": "1.0.0",
    "private": true
}

This folder will be the root of your project.

Chapter 2: Create the API server

Setup the API server package

Now, we can create the API server package. Let’s start from a template to have something ready quickly.


Open a new terminal and inside the project root, please run:

mkdir api
cd api
npm create @httpc

In the @httpc setup wizard, please pick the Server Blank template. Then, you can name it movie-library-api.


Now let’s install all the dependencies.

npm install

The template will create something like:

project/
├─ api/
│  ├─ src/
│  │  ├─ calls/
│  │  │  └─ index.ts
│  │  └─ index.ts
│  ├─ package.json
│  └─ tsconfig.json
├─ console/
└─ package.json

The main files are:

Writing the first function

Let’s start coding! Let’s build the first function.


As the first function, we can create a little utility that returns info about the API server: it’s name and version.

api/src/calls/index.ts

import fs from "fs/promises";
import { httpCall } from "@httpc/server";


const info = httpCall(async () => {
    const packageJson = JSON.parse(await fs.readFile("package.json", "utf8"));

    return {
        name: "Movie Library",
        version: packageJson.version as string
    };
});

export default {
    info
}

The Call file contains the info function. It returns the API name and version read from the package.json. Finally, the info function is exported.


Exported functions will be exposed and callable by clients.

Startup the API

Now let’s run the server and check if everything is ok.


In the api folder, please run:

npm run dev

You should see something like:

Server started: http//localhost:3000

Everything works! The API is up and running!

You can change the listening port by setting a PORT env variable.

Test the API

Let’s test the info function. While the server is running, open another terminal, navigate to the api folder and run

npm run httpc call info

You should see something like:

{ "name": "Movie Library", "version": "1.0.0" }

Chapter 3: Create the Console

Setup the Console package

In the project root, please create a console folder and inside create a package.json.

console/package.json

{
    "name": "movie-library-console",
    "version": "1.0.0"
}

Now let’s install some dependency needed to code the Console. On the terminal, in the console folder please run:

npm install typescript ts-node @types/node --save-dev

Let’s configure typescript. In the console folder, please create a tsconfig.json file.

console/tsconfig.json

{
    "compilerOptions": {
        "outDir": "dist",
        "module": "commonjs",
        "skipLibCheck": true,
        "strict": true,
        "target": "es2022",
        "moduleResolution": "node",
        "esModuleInterop": true,
        "resolveJsonModule": true
    },
    "include": [
        "src"
    ]
}

Now we need to create the entry point of the Console, i.e. the code that will run.


In the console folder, add an index.ts file inside the src folder.

console/src/index.ts

console.log("Movie library console client");

Now we can set the start script. Open the console package.json and write.

console/package.json

{
    "name": "movie-library-console",
    "version": "1.0.0",
    "scripts": {
        "dev": "ts-node src/index.ts"
    }
}

With the dev command, you can run the Console during the development.


You should have the following files:

project/
│  ├─ api/ ...
│  └─ console/
│     ├─ src/
│     │  └─ index.ts
│     ├─ package.json
│     └─ tsconfig.json
└─ ...

Test run

Let’s run it! On the terminal, in the console folder please run:

npm run dev

You should see something like:

Movie library console client

Generate the API client

To call the API, the Console needs to import it. Import, means including the API httpc client.


An httpc client is a small chunk of code auto generated from an httpc server. The client allows to call the function on the server with a smooth experience thanks to autocompletion and typing awareness.


Since we never generated the client for our Movie Library API, first of all we need to generate it.


To generate the client, on the terminal, navigate into the api folder and run:

npm run generate:client

With the default settings, the client will be generated into the api/client folder.


Because the generated client is just a standard package, we need to update the workspace to let the package manager know its existence.

Edit the root package.json and include the api client.

package.json

{
    "name": "movie-library-monorepo",
    "workspaces": [
        "api",
        "api/client",
        "console"
    ],
    "version": "1.0.0",
    "private": true
}

And because we added a new package to the workspace, let’s refresh the dependencies. Please run:

npm install

Import the API client

With the client now generated, we can finally import it into the Console.

On the terminal, navigate to the console folder and run:

npm install ../api/client

The client depends on the fetch to be globally available. If you using node 18+, you’re ready to go. Otherwise you need to install a polyfill.

Install a polyfill for node 16.

In the console folder, please run:

npm install cross-fetch

And import it into the Console index.ts.

console/src/index.ts

import "cross-fetch/polyfill";

console.log("Movie library console client");

Now we can import the api client into the main Console file.

console/src/index.ts

import "cross-fetch/polyfill"; // only for node16
import createClient from "movie-library-api-client";
 
const client = createClient({
    endpoint: process.env.API_ENDPOINT || "http://localhost:3000"
});

console.log("Movie library console client");

The client will connect to the API_ENDPOINT environment variable if found, otherwise will default to localhost.

First function call

Now we’re ready to make our first call to the server.


Let’s call the info function and print the results the to the screen.

console/src/index.ts

import createClient from "movie-library-api-client";

const client = createClient({
    endpoint: process.env.API_ENDPOINT || "http://localhost:3000"
});


async function main(){
    const info = await client.info();
    console.log(`Connected to the API: ${api.name} v${api.version}`);
}

main();

In the above code, the httpc call is highlighted.

All is ready to try it. In the console folder, run.

npm run dev

You should see:

Connected to the API: Movie Library v0.0.1

It works! The function call executed smoothly.

Make it more colorful

Let’s add some spark. We’ll use the kleur package to bring colors to the terminal.


In the console folder, please run:

npm install kleur

And now some refactoring:

console/src/index.ts

import createClient from "movie-library-api-client";
import kleur from "kleur";
 
async function printWelcome() {
    const info = await client.info();
    console.log(kleur.bold(
        kleur.bgGreen(` ${info.name} `) +
        kleur.bgWhite(` v${info.version}`) +
        "\n"
    ));
}

async function main(){
    const info = await client.info();
    console.log(`Connected to the API: ${api.name} v${api.version}`);
    await printWelcome();
}

main();

Try it! In the console folder, run:

npm run dev

You should see a more colorful output!

Movie Library v0.0.1

Chapter 4: Create the Movie Library

Both the API and the Console are ready and talk each other nicely.

Now we can build the core stuff: the Movie library.

Import the movie catalogue

For our library, let’s use a pre-made movie catalogue. We’ll use a simple json file.

Download the movie-db.json and place it in the data folder inside your API.

project/
│  ├─ api/
│  │  ├─ data/
│  │  │  └─ movie-db.json  <-- put it here
│  │  ├─ src/
│  │  │  └─ index.ts
│  │  ├─ package.json
│  │  └─ tsconfig.json
│  └─ console/ ...
└─ ...

The file has a flat structure of movie information:

[
  { ... },
  {
      "id": 671,
    "original_language": "English",
    "overview": "Harry Potter has lived under the stairs at his aunt and uncle's house his whole life. But on his 11th birthday, he learns he's a powerful wizard -- with a place waiting for him at the Hogwarts School of Witchcraft and Wizardry. As he learns to harness his newfound powers with the help of the school's kindly headmaster, Harry uncovers the truth about his parents' deaths -- and about the villain who's to blame.",
    "genres": [
      "Adventure",
      "Fantasy"
    ],
    "release_date": "2001-11-16",
    "runtime": 152,
    "tagline": "Let the Magic Begin.",
    "title": "Harry Potter and the Philosopher's Stone",
    "directors": [
      {
        "id": 10965,
        "name": "Chris Columbus"
      }
    ],
    "cast": [
      {
        "id": 10980,
        "name": "Daniel Radcliffe"
      },
      {
        "id": 10989,
        "name": "Rupert Grint"
      },
      {
        "id": 10990,
        "name": "Emma Watson"
      }
    ]
  },
  { ... },
]

Now, let’s create a module to handle basic operations against the db. Create a db.ts in the api folder.

api/src/db.ts

import fs from "fs/promises";


async function load() {
    return JSON.parse(await fs.readFile("data/movie-db.json", "utf8"));
}

export default {
    load,
}

For now, the db.ts module will just load all the movies.

Let’s add some type information so we can code safely in our the API. In addition, the type info will be available to clients to benefit from it as well.

api/src/db.ts

import fs from "fs/promises";


export type Movie = Readonly<{
    id: number
    original_language: string
    overview: string
    release_date: string
    runtime: number
    tagline: string
    title: string
    genres: readonly string[]
    directors: readonly Person[]
    cast: readonly Person[]
}>
 
export type Person = Readonly<{
    id: number
    name: string
}>

async function load() {
async function load(): Promise<readonly Movie[]> {
    return JSON.parse(await fs.readFile("data/movie-db.json", "utf8"));
}


export default {
    load,
}

Build the stats function

Let’s start with a function that returns some overall information about our db. We’ll create a stats function that returns some aggregate statistics like how many movies our catalogue is composed of, which genres and how many actors and directors are listed.


Add a new stats function inside the call file.

api/src/calls/index.ts

import fs from "fs/promises";
import { httpCall } from "@httpc/server";
import db from "../db";


const info = httpCall(/** omitted  */);

const stats = httpCall(async () => {
    const movies = await db.load();
    const genres = [...new Set(movies.flatMap(x => x.genres))];
    const actors = [...new Set(movies.flatMap(x => x.cast.map(x => x.id)))];
    const directors = [...new Set(movies.flatMap(x => x.directors.map(x => x.id)))];
 
    return {
        count: movies.length,
        genres,
        actorCount: actors.length,
        directorCount: directors.length,
    };
});

export default {
    info,
    stats,
}

The new stats function is ready to be called.


No extra code is needed.


You just write the function and export it. The httpc server will do the hard work of figuring out how to execute it when a new request arrives.


We can test the new stats function and see if anything works. On the terminal, in the api folder, please run:

npm run httpc call stats

The terminal will show something like:

{ "count": 2000, "genres": [ "Action", "Science Fiction", ... ], "actorCount": 3698, "directorCount": 1222 }

Bring the stats to Console

Now let’s display the summary info within our Console.


Because the API changed, we need to update the typing information, so the code editor can provide updated autocompletion and the typescript compiler can ensure type safety with the new state.


Into the api folder, please run:

npm run generate:client

Since the api client is already imported into our Console package, no further action is needed.


Now, switch to the Console and let’s show the stats.


Add a new function to print the db stats on screen.

console/src/index.ts

async function printStats() {
    const stats = await client.stats();
    console.log(`${kleur.bold("Movies:")}\t\t${stats.count}`);
    console.log(`${kleur.bold("Genres:")}\t\t${stats.genres.length}`);
    console.log(`${kleur.bold("Actors:")}\t\t${stats.actorCount}`);
    console.log(`${kleur.bold("Directors:")}\t${stats.directorCount}`);
    console.log("\n");
}

The highlighted line shows the new function call to the API server.


The Console will call the new printStats function form the main.

console/src/index.ts

// previous code here

async function printStats() { /** see above */ }

async function main(){
    await printWelcome();
    await printStats();
}

main();

Now, let’s run the Console end see the result. From the console folder, run:

npm run dev

You should see something like:

Movie Library v0.0.1

Movies: 2000 Genres: 18 Actors: 3698 Directors: 1222

Create a search function

Let’s build a simple search movie functionality. The new search function will allow the user to look for movies with a search term. If the movie title contains the term, then that movie will be added to the search results.


For the term-title comparison we’ll use the lowercase text, so capital letters match as well.


For this case, we’ll add a simple validation, requiring at least 3 characters to perform the search.


In the Call file, add a new search function:

api/src/calls/index.ts

import { httpCall, BadRequestError } from "@httpc/server";

// previous code here

const search = httpCall(async (text: string) => {
    // validation
    if(typeof text !== "string" || text.length < 3) {
        throw new BadRequestError();
    }
    
    // use lowercase to search
    text = text && text.toLowerCase();

    const movies = await db.load();

    const filtered = movies.filter(x => {
        if (text && x.title.toLowerCase().includes(text)) {
            return true;
        }

        return false;
    });

    return filtered.slice(0, 10);
});

export default {
    info,
    stats,
    search,
}

Remember to export the search function.


Let’s test it. Let’s look for Harry Potter movies, with the search term harry.


On the terminal, in the api folder, please run:

npm run httpc call search harry

You should see something like:

[ { "id": 671, "title": "Harry Potter and the Philosopher's Stone", ... }, { "id": 674, "title": "Harry Potter and the Goblet of Fire", ... }, ... ]

Search form the Console

Because the API changed with the new search function, we need to regenerate the api client.


Into the api folder, please run:

npm run generate:client

Because the Console is getting bigger with many functionalities, let’s add a simple menu to help the user pick what operation he want to do.


To aid with user interactions, like asking questions, we’ll use the prompts package.

In the console folder, install it.

npm install prompts

Now, let’s create a quick menu to handle witch action the user want to execute.

console/src/index.ts

import prompts from "prompts";

// previous code here

async function main() {
    await printWelcome();

    do {
        const action = await prompts({
            name: "menu",
            type: "select",
            message: "What would you like to do?",
            choices: [
                { title: "View library stats", value: "stats" },
                { title: "View a movie info", value: "view" },
                { title: "Exit", value: "exit" },
            ]
        });

        switch (action.menu) {
            case "stats": await printStats(); break;
            case "view": await viewMovieInfo(); break;
            case "exit": return;
        }
    } while (true);
}

Let’s implement the viewMovieInfo function to allow the user to look up for a movie and see its details.


The search feature involves 4 steps:

console/src/index.ts

async function viewMovieInfo() {
    let term: string;
    do {
        ({ term } = await prompts({
            name: "term",
            type: "text",
            message: "Search a movie by title (min 3 chars):"
        }));
    } while (!term || term.length <= 2);

    const results = await client.search(term);
    if (results.length === 0) {
        console.log("No movie found for: %s", term);
        return;
    }


    const { movieId } = await prompts({
        name: "movieId",
        type: "select",
        message: "Please select",
        choices: results.map(x => ({
            title: x.title,
            value: x.id
        }))
    });

    const movie = results.find(x => x.id === movieId)!;
    console.log(
        "\n" +
        kleur.bold(movie.title) + "\n" +
        movie.tagline + "\n\n" +
        kleur.gray("Genres: ") + movie.genres.join(", ") + "\n" +
        kleur.gray("Directors: ") + movie.directors.map(x => x.name).join(", ") + "\n" +
        kleur.gray("Cast: ") + movie.cast.map(x => x.name).join(", ") + "\n"
    );
}

Let’s test the new search feature.

In the console folder run the following command and pick "view" from the menu.

npm run dev

Our Movie Library will allow the user to see movies with a specific actor or a director. To do so, we’ll implement a search movie by person functionality.


A user-friendly application cannot pretend the user to write correctly the full actor name. Therefore, we need to build a search person functionality first.

Create the search person function

A new searchPerson function will look for both actors and directors.


Like the search movie function, we’ll


Edit the Call files and add the searchPerson function:

api/src/calls/index.ts

// previous code here

const searchPerson = httpCall(async (text: string) => {
    // validation
    if (typeof text !== "string" || text.length < 3) {
        throw new BadRequestError();
    }

    // use lowercase to search    
    text = text.toLowerCase();

    const movies = await db.load();

    const persons = movies.reduce((persons, movie) => {
        movie.cast.forEach(x => {
            if (x.name.toLowerCase().includes(text)) {
                persons.set(x.id, x);
            }
        });

        movie.directors.forEach(x => {
            if (x.name.toLowerCase().includes(text)) {
                persons.set(x.id, x);
            }
        });

        return persons;
    }, new Map<number, Person>())

    return [...persons.values()].slice(0, 10);
});

export default {
    version,
    stats,
    search,    
    searchPerson,
}

Evolve search with the person filter

Now we need to modify the search function to include the person as a filter.


In addition, let’s rename the search to searchMovie to avoid ambiguity and keep consistent naming with searchPerson.

api/src/calls/index.ts

type SearchMovieOptions = {
    text?: string
    person?: number
}

const search = httpCall(async (text: string) => {
const searchMovie = httpCall(async ({ text, person }: SearchMovieOptions = {}) => {
    // validation
    if (text !== undefined && (typeof text !== "string" || text.length < 3)) {
        throw new BadRequestError();
    }
    if (person !== undefined && typeof person !== "number") {
        throw new BadRequestError();
    }


    text = text && text.toLowerCase();

    const movies = await db.load();

    const filtered = movies.filter(x => {
        if (text && x.title.toLowerCase().includes(text)) {
            return true;
        }
        if (person && (
            x.cast.some(x => x.id === person) ||
            x.directors.some(x => x.id === person)
        )) {
            return true;
        }

        return false;
    });

    return filtered.slice(0, 10);
})

export default {
    version,
    stats,
    search,
    searchMovie,    
    searchPerson,
}

Now we can regenerate the client, with the new changes.


Into the api folder please run:

npm run generate:client

Update the Console

Switch to the Console. We need to update the search functionality to address the API changes.


Because the search API function changed naming and parameter type, your code editor should complain and show an error on the following line.

console/src/index.ts

async function viewMovieInfo() {
    // other code 

    const results = await client.search(term);

    // other code 
}

Let’s update the Console code to address the new API function.

console/src/index.ts

async function viewMovieInfo() {
    // other code 

    const results = await client.search(term);
    const results = await client.searchMovie({
        text: term
    });

    // other code 
}

Implement show movie by person

Now we can allow the user to search movies by an actor or a director.

The search movie by person feature involves 5 steps:

console/src/index.ts

async function viewMovieByPerson() {
    let term: string;
    do {
        ({ term } = await prompts({
            name: "term",
            type: "text",
            message: "Search for a person (min 3 chars):"
        }));
    } while (!term || term.length <= 2);

    const results = await client.searchPerson(term);
    if (results.length === 0) {
        console.log("No person found for: %s", term);
        return;
    }

    const { personId } = await prompts({
        name: "personId",
        type: "select",
        message: "Please select",
        choices: results.map(x => ({
            title: x.name,
            value: x.id
        }))
    });
    console.log("\n");

    const person = results.find(x => x.id === personId)!;


    const movies = await client.searchMovie({
        person: personId
    });
    if (movies.length === 0) {
        console.log("No movie found with: %s", person.name);
        return;
    }

    console.log(kleur.gray("Movies with: ") + kleur.bold(person.name) + "\n");
    // sort by release_date descending
    movies.sort((x, y) => new Date(y.release_date).getTime() - new Date(x.release_date).getTime())
        .forEach(x => console.log(`(${x.release_date.substring(0, 4)}) ${x.title}`));

    console.log("\n");
}

And finally, let’s add the new viewMovieByPerson into the main menu:

console/src/index.ts

async function main() {
    // other code

        const action = await prompts({
            name: "menu",
            type: "select",
            message: "What would you like to do?",
            choices: [
                { title: "View library stats", value: "stats" },
                { title: "View a movie info", value: "view" },
                { title: "View all movies of someone", value: "by-person" },
                { title: "Exit", value: "exit" },
            ]
        });

        switch (action.menu) {
            case "stats": await printStats(); break;
            case "view": await viewMovieInfo(); break;
            case "by-person": await viewMovieByPerson(); break;
            case "exit": return;
        }

    // other code
}

To test it, run the Console. In the console folder:

npm run dev

Conclusions

Here this tutorial comes to an end. Hopefully everything was smooth and clear.


Now, you should have familiarity with:

Next Steps