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:
the Movie Library API server (the service provider)
The server application that exposes the library functionalities like movie details and searching
the Movie Library Console (the service consumer)
The client application that talks to the server to get the data, shows movie info on screen and interact with the user
This project is build upon @httpc/server.
This project will use typescript.
What you’ll learn
Phase 1
- Set up a monorepo with 2 packages, the API server and the Console client
- Build a simple httpc API with minimal code
- Call the API functions from the Console with ease thanks to autocompletion and typing definitions
Phase 2
- Evolving the API with new functionalities and modifications
- 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.jsonLet’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
}If you use pnpm, alongside the package.json, please create the a new file pnpm-workspace.yaml.
pnpm-workspace.yaml
packages:
- api
- consoleIf you use yarn, 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 @httpcmkdir api
cd api
pnpm create @httpcmkdir api
cd api
yarn create @httpcIn 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 installpnpm installyarn installThe template will create something like:
project/
├─ api/
│ ├─ src/
│ │ ├─ calls/
│ │ │ └─ index.ts
│ │ └─ index.ts
│ ├─ package.json
│ └─ tsconfig.json
├─ console/
└─ package.jsonThe main files are:
api/src/index.tsThe entry point of the API server where the configuration and bootstrap happen
api/src/calls/index.tsThe Call file which includes all the functions that will be exposed.
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 devpnpm devyarn run devYou 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 infopnpm httpc call infoyarn run httpc call infoYou should see something like:
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-devpnpm add typescript ts-node @types/node --save-devyarn add typescript ts-node @types/node --devLet’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 devpnpm devyarn run devYou 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:clientpnpm generate:clientyarn run generate:clientWith 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 installEdit workspace file and include the api client.
pnpm-workspace.yaml
packages:
- api
- api/client
- consoleAnd because we added a new package to the workspace, let’s refresh the dependencies. Please run:
pnpm installEdit 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:
yarn installImport 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/clientpnpm add movie-library-api-clientyarn add ../api/clientThe 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-fetchpnpm add cross-fetchyarn add cross-fetchAnd 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 devpnpm devyarn run devYou 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 kleurpnpm add kleuryarn add kleurAnd now some refactoring:
- Create a
printWelcomefunction to display the welcome message - Decorate with some color to make it stand out
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 devpnpm devyarn run devYou should see a more colorful output!
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 statspnpm httpc call statsyarn run httpc call statsThe terminal will show something like:
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:clientpnpm generate:clientyarn run generate:clientSince 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 devpnpm devyarn run devYou should see something like:
Movies: 2000 Genres: 18 Actors: 3698 Directors: 1222
Chapter 5: Build the movie search
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 harrypnpm httpc call search harryyarn run httpc call search harryYou should see something like:
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:clientpnpm generate:clientyarn run generate:clientBecause 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 promptspnpm add promptsyarn add promptsNow, 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:
- Ask the user the search term
- Use the term to perform the API call
searchand get the search results - Ask the user to pick a movie from the search results we printed on screen
- Display the movie details for the one selected by the user
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 devpnpm devyarn run devChapter 6: Build the person search
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
- use the
lowercaseterm to match also with capital letters - add a simple validation requiring the term to be at least 3 characters
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:clientpnpm generate:clientyarn run generate:clientUpdate 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:
- Ask the user for the search term, used to search a person first
- Use the term to perform the API call
searchPersonand get the search results - Ask the user to pick a person from the search results we printed on screen
- Use the person selected to perform the API call
searchMovieto get movies with that person - Display the movies returned to the user
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 devpnpm devyarn run devConclusions
Here this tutorial comes to an end. Hopefully everything was smooth and clear.
Now, you should have familiarity with:
- Creating a function-based API based on @httpc/server just with plain natural functions
- Generating the httpc client for your API
- Importing the client into a consumer application and call the API function with a natural syntax
Next Steps
- Checkout the Getting started if you’re ready to create something with httpc
- Join the community on Discord or participate in our discussions
- Read the Architecture if you want a deep dive on how the httpc server works and how to customize it