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.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
}
If you use pnpm, alongside the package.json
, please create the a new file pnpm-workspace.yaml
.
pnpm-workspace.yaml
packages:
- api
- console
If 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 @httpc
mkdir api
cd api
pnpm create @httpc
mkdir api
cd api
yarn 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
pnpm install
yarn 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:
api/src/index.ts
The entry point of the API server where the configuration and bootstrap happen
api/src/calls/index.ts
The 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 dev
pnpm dev
yarn 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
pnpm httpc call info
yarn run httpc call info
You 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-dev
pnpm add typescript ts-node @types/node --save-dev
yarn add typescript ts-node @types/node --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
pnpm dev
yarn 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
pnpm generate:client
yarn 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
Edit workspace file and include the api client.
pnpm-workspace.yaml
packages:
- api
- api/client
- console
And because we added a new package to the workspace, let’s refresh the dependencies. Please run:
pnpm install
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:
yarn 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
pnpm add movie-library-api-client
yarn add ../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
pnpm add cross-fetch
yarn add 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
pnpm dev
yarn 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
pnpm add kleur
yarn add kleur
And now some refactoring:
- Create a
printWelcome
function 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 dev
pnpm dev
yarn run dev
You 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 stats
pnpm httpc call stats
yarn run httpc call stats
The 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:client
pnpm generate:client
yarn 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
pnpm dev
yarn run dev
You 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 harry
pnpm httpc call search harry
yarn run httpc call search harry
You 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:client
pnpm generate:client
yarn 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
pnpm add prompts
yarn add 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:
- Ask the user the search term
- Use the term to perform the API call
search
and 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 dev
pnpm dev
yarn run dev
Chapter 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
lowercase
term 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:client
pnpm generate:client
yarn 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:
- Ask the user for the search term, used to search a person first
- Use the term to perform the API call
searchPerson
and 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
searchMovie
to 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 dev
pnpm dev
yarn run dev
Conclusions
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