How to Fetch Data from a Third-party API with Deno
In this article, we’ll explore Deno, a relatively new tool built as a competitor/replacement for Node.js that offers a more secure environment and comes with TypeScript support out the box.
We’ll use Deno to build a command-line tool to make requests to a third-party API — the Star Wars API — and see what features Deno provides, how it differs from Node, and what it’s like to work with.
Deno is a more opinionated runtime that’s written in TypeScript, includes its own code formatter (deno fmt
), and uses ES Modules — with no CommonJS require
statements in sight. It’s also extremely secure by default: you have to explicitly give your code permission to make network requests, or read files from disks, which is something Node allows programs to do by default. In this article, we’ll cover installing Deno, setting up our environment, and building a simple command-line application to make API requests.
As ever, you can find the code to accompany this article on GitHub.
Installing Deno
You can check the Deno website for the full instructions. If you’re on macOS or Linux, you can copy this command into your terminal:
curl -fsSL https://deno.land/x/install/install.sh | sh
You’ll also need to add the install directory to your $PATH
.
Don’t worry if you’re on Windows, as you can install Deno via package managers such as Chocolatey:
choco install deno
If Chocolately isn’t for you, deno_install lists a variety of installation methods, so pick the one that suits you best.
You can check Deno is installed by running the following command:
deno -V
This should output the Deno version. At the time of writing, the latest version is 1.7.5, which is what I’m using.
If you’re using VS Code, I highly recommend installing the Deno VS Code plugin. If you use another editor, check the Deno documentation to find the right plugin.
Note that, if you’re using VS Code, by default the Deno plugin isn’t enabled when you load up a project. You should create a .vscode/settings.json
file in your repository and add the following to enable the plugin:
{
"deno.enable": true
}
Again, if you’re not a VS Code user, check the manual above to find the right setup for your editor of choice.
Writing Our First Script
Let’s make sure we have Deno up and running. Create index.ts
and put the following inside:
console.log("hello world!");
We can run this with deno run index.ts
:
$ deno run index.ts
Check file:///home/jack/git/deno-star-wars-api/index.ts
hello world
Note that we might see a TypeScript error in our editor:
'index.ts' cannot be compiled under '--isolatedModules'
because it is considered a global script file. Add an import,
export, or an empty 'export {}' statement
to make it a module.ts(1208)
This error happens because TypeScript doesn’t know that this file is going to use ES Module imports. It will soon, because we’re going to add imports, but in the mean time if we want to remove the error, we can add an empty export
statement to the bottom of the script:
export {}
This will convince the TypeScript compiler that we’re using ES Modules and get rid of the error. I won’t include this in any code samples in the blog post, but it won’t change anything if we add it other than to remove the TypeScript noise.
Fetching in Deno
Deno implements support for the same Fetch API that we’re used to using in the browser. It comes built into Deno — which means there’s no package to install or configure. Let’s see how it works by making our first request to the API we’re going to use here, the Star Wars API (or SWAPI).
Making a request to https://swapi.dev/api/people/1/
will give us back all the data we need for Luke Skywalker. Let’s update our index.ts
file to make that request. Update index.ts
to look like so:
const json = fetch("https://swapi.dev/api/people/1");
json.then((response) => {
return response.json();
}).then((data) => {
console.log(data);
});
Try and run this in your terminal with deno run
:
$ deno run index.ts
Check file:///home/jack/git/deno-star-wars-api/index.ts
error: Uncaught (in promise) PermissionDenied: network access to "swapi.dev", run again with the --allow-net flag
throw new ErrorClass(res.err.message);
Deno is secure by default, which means scripts need permission to do anything that could be considered dangerous — such as reading/writing to the filesystem and making network requests. We have to give Deno scripts permissions when they run to allow them to perform such actions. We can enable ours with the --allow-net
flag:
$ deno run --allow-net index.ts
Check file:///home/jack/git/deno-star-wars-api/index.ts
{
name: "Luke Skywalker",
...(data snipped to save space)...
}
But this flag has given the script permission to access any URL. We can be a bit more explicit and allow our script only to access URLs that we add to an allowlist:
$ deno run --allow-net=swapi.dev index.ts
If we’re running scripts that we’re authoring ourselves, we can trust that they won’t do anything they shouldn’t. But it’s good to know that, by default, any Deno script we execute can’t do anything too damaging without us first allowing it permission. From now on, whenever I talk about running our script in this article, this is the command I’m running:
$ deno run --allow-net=swapi.dev index.ts
We can also write this script slightly differently using top level await, which lets us use the await
keyword rather than deal with promises:
const response = await fetch("https://swapi.dev/api/people/1/");
const data = await response.json();
console.log(data);
This is the style I prefer and will use for this article, but if you’d rather stick to promises, feel free.
Installing Third-party Dependencies
Now that we can make requests to the Star Wars API, let’s start thinking about how we want to allow our users to use this API. We’ll provide command-line flags to let them specify what resource to query (such as people, films, or planets) and a query to filter them by. So a call to our command-line tool might look like so:
$ deno run --allow-net=swapi.dev index.ts --resource=people --query=luke
We could parse those extra command-line arguments manually, or we could use a third-party library. In Node.js, the best solution for this is Yargs, and Yargs also supports Deno, so we can use Yargs to parse and deal with the command-line flags we want to support.
However, there’s no package manager for Deno. We don’t create a package.json
and install a dependency. Instead, we import from URLs. The best source of Deno packages is the Deno package repository, where you can search for a package you’re after. Most popular npm packages now also support Deno, so there’s usually a good amount of choice on there and a high likelihood that you’ll find what you’re after.
At the time of writing, searching for yargs
on the Deno repository gives me yargs 16.2.0. To use it locally, we have to import it from its URL:
import yargs from "https://deno.land/x/yargs/deno.ts";
When we now run our script, we’ll first see a lot of output:
$ deno run --allow-net=swapi.dev index.ts
Download https://deno.land/x/yargs/deno.ts
Warning Implicitly using latest version (v16.2.0-deno) for https://deno.land/x/yargs/deno.ts
Download https://deno.land/x/yargs@v16.2.0-deno/deno.ts
Download https://deno.land/x/yargs@v16.2.0-deno/build/lib/yargs-factory.js
Download https://deno.land/x/yargs@v16.2.0-deno/lib/platform-shims/deno.ts
Download https://deno.land/std/path/mod.ts
Download https://deno.land/x/yargs_parser@v20.2.4-deno/deno.ts
...(more output removed to save space)
The first time Deno sees that we’re using a new module, it will download and cache it locally so that we don’t have to download it every time we use that module and run our script.
Notice this line from the above output:
Warning Implicitly using latest version (v16.2.0-deno)
for https://deno.land/x/yargs/deno.ts
This is Deno telling us that we didn’t specify a particular version when we imported Yargs, so it just downloaded the latest one. That’s probably fine for quick side projects, but generally it’s good practice to pin our import to the version we’d like to use. We can do this by updating the URL:
import yargs from "https://deno.land/x/yargs@v16.2.0-deno/deno.ts";
It took me a moment to figure out that URL. I found it by recognizing that the URL I’m taken to when I search for “yargs” on the Deno repository is https://deno.land/x/yargs@v16.2.0-deno
. I then looked back at the console output and realized that Deno had actually given me the exact path:
Warning Implicitly using latest version (v16.2.0-deno)
for https://deno.land/x/yargs/deno.ts
Download https://deno.land/x/yargs@v16.2.0-deno/deno.ts
I highly recommend pinning your version numbers like this. It will avoid one day a surprising issue because you happen to run after a new release of a dependency.
deno fmt
A quick aside before we continue building our command-line tool. Deno comes with a built in formatter, deno fmt
, which automatically formats code to a consistent style. Think of it like Prettier, but specifically for Deno, and built in. This is another reason I’m drawn to Deno; I love tools that provide all this out of the box for you without needing to configure anything.
We can run the formatter locally with this:
$ deno fmt
This will format all JS and TS files in the current directory, or we can give it a filename to format:
$ deno fmt index.ts
Or, if we’ve got the VS Code extension, we can instead go into .vscode/settings.json
, where we enabled the Deno plugin earlier, and add these two lines:
{
"deno.enable": true,
"editor.formatOnSave": true,
"editor.defaultFormatter": "denoland.vscode-deno"
}
This configures VS Code to run deno fmt
automatically when we save a file. Perfect!
Using Yargs
I won’t be going into the full details of Yargs (you can read the docs if you’d like to get familiar with all it can do), but here’s how we declare that we’d like to take two command-line arguments that are required: --resource
and --query
:
import yargs from "https://deno.land/x/yargs@v16.2.0-deno/deno.ts";
const userArguments: {
query: string;
resource: "films" | "people" | "planets";
} = yargs(Deno.args)
.describe("resource", "the type of resource from SWAPI to query for")
.choices("resource", ["people", "films", "planets"])
.describe("query", "the search term to query the SWAPI for")
.demandOption(["resource", "query"])
.argv;
console.log(userArguments);
Note: now that we have an import
statement, we no longer need the export {}
to silence that TypeScript error.
Unfortunately, at the time of writing TypeScript doesn’t seem to pick up all the type definitions: the return type of yargs(Deno.args)
is set to {}
, so let’s tidy that up a bit. We can define our own TypeScript interface that covers all the parts of the Yargs API we’re relying on:
interface Yargs<ArgvReturnType> {
describe: (param: string, description: string) => Yargs<ArgvReturnType>;
choices: (param: string, options: string[]) => Yargs<ArgvReturnType>;
demandOption: (required: string[]) => Yargs<ArgvReturnType>;
argv: ArgvReturnType;
}
Here I declare the functions we’re using, and that they return the same Yargs interface (this’s what lets us chain calls). I also take a generic type, ArgvReturnType
, which denotes the structure of the arguments that we get back after Yargs has processed them. That means I can declare a UserArguments
type and cast the result of yargs(Deno.argv)
to it:
interface Yargs<ArgvReturnType> {
describe: (param: string, description: string) => Yargs<ArgvReturnType>;
choices: (param: string, options: string[]) => Yargs<ArgvReturnType>;
demandOption: (required: string[]) => Yargs<ArgvReturnType>;
argv: ArgvReturnType;
}
interface UserArguments {
query: string;
resource: "films" | "people" | "planets";
}
const userArguments = (yargs(Deno.args) as Yargs<UserArguments>)
.describe("resource", "the type of resource from SWAPI to query for")
.choices("resource", ["people", "films", "planets"])
.describe("query", "the search term to query the SWAPI for")
.demandOption(["resource", "query"])
.argv;
I’m sure in the future Yargs may provide these types out of the box, so it’s worth checking if you’re on a newer version of Yargs than 16.2.0.
Querying the Star Wars API
Now that we have a method of accepting the user’s input, let’s write a function that takes what was entered and queries the Star Wars API correctly:
async function queryStarWarsAPI(
resource: "films" | "people" | "planets",
query: string,
): Promise<{
count: number;
results: object[];
}> {
const url = `https://swapi.dev/api/${resource}/?search=${query}`;
const response = await fetch(url);
const data = await response.json();
return data;
}
We’ll take two arguments: the resource to search for and then the search term itself. The result that the Star Wars API gives back will return an object including a count
(number of results) and a results
array, which is an array of all the matching resources from our API query. We’ll look at improving the type safety of this later in the article, but for now I’ve gone for object
to get us started. It’s not a great type to use, as it’s very liberal, but sometimes I prefer to get something working and then improve the types later on.
Now we have this function, we can take the arguments parsed by Yargs and fetch some data!
const result = await queryStarWarsAPI(
userArguments.resource,
userArguments.query,
);
console.log(`${result.count} results`);
Now let’s run this:
$ deno run --allow-net=swapi.dev index.ts --resource films --query phantom
Check file:///home/jack/git/deno-star-wars-api/index.ts
1 results
We see that we get one result (we’ll work on the incorrect plural there shortly!). Let’s do some work to get nicer output depending on the resource the user searched for. Firstly, I’m going to do some TypeScript work to improve that return type so we get better support from TypeScript in our editor.
The first thing to do is create a new type representing the resources we let the user query for:
type StarWarsResource = "films" | "people" | "planets";
We can then use this type rather than duplicate it, first when we pass it into Yargs, and the second time when we define the queryStarWarsAPI
function:
interface UserArguments {
query: string;
resource: StarWarsResource;
}
// ...
async function queryStarWarsAPI(
resource: StarWarsResource,
query: string,
): Promise<{
count: number;
results: object[];
}> { ... }
Next up, let’s take a look at the Star Wars API and create interfaces representing what we’ll get back for different resources. These types aren’t exhaustive (the API returns more). I’ve just picked a few items for each resource:
interface Person {
name: string;
films: string[];
height: string;
mass: string;
homeworld: string;
}
interface Film {
title: string;
episode_id: number;
director: string;
release_date: string;
}
interface Planet {
name: string;
terrain: string;
population: string;
}
Once we have these types, we can create a function to process the results for each type, and then call it. We can use a typecast to tell TypeScript that result.results
(which it thinks is object[]
) is actually one of our interface types:
console.log(`${result.count} results`);
switch (userArguments.resource) {
case "films": {
logFilms(result.results as Film[]);
break;
}
case "people": {
logPeople(result.results as Person[]);
break;
}
case "planets": {
logPlanets(result.results as Planet[]);
break;
}
}
function logFilms(films: Film[]): void { ... }
function logPeople(people: Person[]): void { ... }
function logPlanets(planets: Planet[]): void { ... }
Once we fill these functions out with a bit of logging, our CLI tool is complete!
function logFilms(films: Film[]): void {
films.forEach((film) => {
console.log(film.title);
console.log(`=> Directed by ${film.director}`);
console.log(`=> Released on ${film.release_date}`);
});
}
function logPeople(people: Person[]): void {
people.forEach((person) => {
console.log(person.name);
console.log(`=> Height: ${person.height}`);
console.log(`=> Mass: ${person.mass}`);
});
}
function logPlanets(planets: Planet[]): void {
planets.forEach((planet) => {
console.log(planet.name);
console.log(`=> Terrain: ${planet.terrain}`);
console.log(`=> Population: ${planet.population}`);
});
}
Let’s finally fix up the fact that it outputs 1 results
rather than 1 result
:
function pluralise(singular: string, plural: string, count: number): string {
return `${count} ${count === 1 ? singular : plural}`;
}
console.log(pluralise("result", "results", result.count));
And now our CLI’s output is looking good!
$ deno run --allow-net=swapi.dev index.ts --resource planets --query tat
Check file:///home/jack/git/deno-star-wars-api/index.ts
1 result
Tatooine
=> Terrain: desert
=> Population: 200000
Tidying Up
Right now, all our code is one large index.ts
file. Let’s create an api.ts
file and move most of the API logic into it.
Don’t forget to add export
to the front of all the types, interfaces and functions in this file, as we’ll need to import them in index.ts
:
// api.ts
export type StarWarsResource = "films" | "people" | "planets";
export interface Person {
name: string;
films: string[];
height: string;
mass: string;
homeworld: string;
}
export interface Film {
title: string;
episode_id: number;
director: string;
release_date: string;
}
export interface Planet {
name: string;
terrain: string;
population: string;
}
export async function queryStarWarsAPI(
resource: StarWarsResource,
query: string,
): Promise<{
count: number;
results: object[];
}> {
const url = `https://swapi.dev/api/${resource}/?search=${query}`;
const response = await fetch(url);
const data = await response.json();
return data;
}
And then we can import them from index.ts
:
import {
Film,
Person,
Planet,
queryStarWarsAPI,
StarWarsResource,
} from "./api.ts"
Now our index.ts
is looking much cleaner, and we’ve moved all the details of the API to a separate module.
Distributing
Let’s say we now want to distribute this script to a friend. We could share the entire repository with them, but that’s overkill if all they want to do is run the script.
We can use deno bundle
to bundle all our code into one JavaScript file, with all the dependencies installed. That way, sharing the script is a case of sharing one file:
$ deno bundle index.ts out.js
And we can pass this script to deno.run
, just as before. The difference now is that Deno doesn’t have to do any type checking, or install any dependencies, because it’s all been put into out.js
for us. This means running a bundled script like this will likely be quicker than running from the TypeScript source code:
$ deno run --allow-net=swapi.dev out.js --resource films --query phantom
1 result
The Phantom Menace
=> Directed by George Lucas
=> Released on 1999-05-19
Another option we have is to generate a single executable file using deno compile
. Note that, at the time of writing, this is considered experimental, so tread carefully, but I want to include this as I expect it will become stable and more common in the future.
We can run deno compile --unstable --allow-net=swapi.dev index.ts
to ask Deno to build a self-contained executable for us. The --unstable
flag is required because this feature is experimental, though in the future it shouldn’t be. What’s great about this is that we pass in the security flags at compile time — in our case allowing access to the Star Wars API. This means that, if we give this executable to a user, they won’t have to know about configuring the flags:
$ deno compile --unstable --allow-net=swapi.dev index.ts
Check file:///home/jack/git/deno-star-wars-api/index.ts
Bundle file:///home/jack/git/deno-star-wars-api/index.ts
Compile file:///home/jack/git/deno-star-wars-api/index.ts
Emit deno-star-wars-api
And we can now run this executable directly:
$ ./deno-star-wars-api --resource people --query jar jar
1 result
Jar Jar Binks
=> Height: 196
=> Mass: 66
I suspect in the future that this will become the main way to distribute command-line tools written in Deno, and hopefully it’s not too long before it loses its experimental status.
Conclusion
In this article, through building a CLI tool, we’ve learned how to use Deno to fetch data from a third-party API and display the results. We saw how Deno implements support for the same Fetch API that we’re accustomed to using in the browser, how fetch
is built into the Deno standard library, and how we can use await
at the top level of our program without having to wrap everything in an IFFE.
I hope you’ll agree with me that there’s a lot to love about Deno. It provides a very productive environment out the box, complete with TypeScript and a formatter. It’s great to not have the overhead of a package manager, particularly when writing small helper tools, and the ability to compile into one executable means sharing those tools with your colleagues and friends is really easy.