Integrating Naurt’s Geocoder with TypeScript

Indigo Curnick
July 1, 2024
Resources

Setting up the Environment with Bun

Let’s begin by setting up the environment. For this project I’ll be using bun, which is a new alternative to Node. If you use ArchLinux like me you can install Bun using yay

$ yay -S bun-bin

For other systems, you can find install instructions here. If you’d prefer, you can also just use Node.

Create a new folder for this project and then in that folder run

$ bun init

Depending on how bun executes, you might end up with just an index.ts file in the root directory. Personally, I prefer having a src folder. So, I created a src folder and moved the index.ts file into it

$ mkdir src
$ mv index.ts src/index.ts

We then need to update the tsconfig.json like so

1{
2  "compilerOptions": {
3    // Enable latest features
4    "lib": ["ESNext", "DOM"],
5    "target": "ESNext",
6    "module": "ESNext",
7    "moduleDetection": "force",
8    "jsx": "react-jsx",
9    "allowJs": true,
10
11    // Bundler mode
12    "moduleResolution": "bundler",
13    // "allowImportingTsExtensions": true,
14    "verbatimModuleSyntax": true,
15    // "noEmit": true,
16
17    // Best practices
18    "strict": true,
19    "skipLibCheck": true,
20    "noFallthroughCasesInSwitch": true,
21
22    // Some stricter flags (disabled by default)
23    "noUnusedLocals": false,
24    "noUnusedParameters": false,
25    "noPropertyAccessFromIndexSignature": false,
26
27    // Input/Output
28    "outDir": "./dist",
29    "rootDir": "./src"
30  },
31  "include": ["src"],
32  "exclude": ["node_modules", "dist"]
33}

Then modify the package.json like so

1{
2  "name": "naurt-demo",
3  "module": "src/index.ts",
4  "type": "module",
5  "devDependencies": {
6    "@types/bun": "latest"
7  },
8  "peerDependencies": {
9    "typescript": "^5.4.5"
10  },
11  "scripts": {
12    "start": "bun run ./src/index.ts"
13  }
14}

Now we can use bun start to run our TypeScript code directly, at this point you should see it output a hello world!

Sign up for your FREE Naurt Key

If you haven’t already, sign up at Naurt’s dashboard for a free key. The free key comes loaded with thousands of API requests per month, so there’s no cost to try it out. We don’t require a credit card for sign up, either!

For this post, I’ll be placing my API key in a file called api.key. In most backend applications, this will probably be injected via an environment variable or a secret. We can then read this in as a constant with

1import fs from 'fs';
2
3const API_KEY: string = fs.readFileSync('api.key', 'utf-8');

Creating the Express Server

Let’s install the dependencies for this project with

$ bun add express 
$ bun add - d @types/express

And then make a very simple server with

1import express from 'express';
2
3const app = express();
4const port = 3000;
5
6app.get("/", (req, res) => {
7    res.send("Hello world!");
8})
9
10app.listen(port, () => {
11    console.log(`Server is running on http://localhost:${port}`);
12})
13

Now if you run this with bun start and go to localhost:3000 in your browser you should see “Hello world!”.

Using Axios to Make a Request To Naurt

Let’s make our first Naurt request. We’ll use axios to handle the requests to Naurt, install it with

$ bun add axios
$ bun add -d @types/axios

We’ll replace our get with this

1import axios from 'axios';
2
3app.get("/", async (req, res) => {
4
5    const body = {"address_string": "The Grand Hotel, Brighton"};
6    const response = await axios.post("https://api.naurt.net/final-destination/v1", body, {
7        headers: {
8            "Content-Type": "application/json",
9            "Authorization": API_KEY
10        }
11    });
12
13    res.status(200).json(response.data);
14})

Right now this is super simple, and has no error handling! You shouldn’t ever do this in production, but for now it works. In FireFox, it will automatically render this as a JSON for you, too.

Typing the Naurt Request with Interfaces

Obviously, having a static request isn’t very useful. Let’s start to expand it a little. First, we’ll make an object to help give some typing to our Naurt request.

1interface NaurtRequest {
2    address_string?: string | null;
3    latitude?: number | null;
4    longitude?: number | null;
5    additional_matches?: boolean | null;
6    distance_filter?: number | null;
7}
8
9function toJSON(obj: Object): string {
10    const filteredObj = Object.fromEntries(
11        Object.entries(obj).filter(([_, value]) => value != null)
12    );
13
14    return JSON.stringify(filteredObj);
15}
16
17app.get("/", async (req, res) => {
18
19    const request: NaurtRequest = {address_string: "The Grand Hotel, Brighton", latitude: null};
20    const body: string = toJSON(request);
21
22    const response = await axios.post("https://api.naurt.net/final-destination/v1", body, {
23        headers: {
24            "Content-Type": "application/json",
25            "Authorization": API_KEY
26        }
27    });
28
29    res.status(200).json(response.data);
30})

Using this method, we now have a system of making Naurt requests with a defined interface. The purpose of toJSON function is to filter out anything which is undefined or not present from the NaurtRequest interface. This code achieves the same end goals but it is more extensible.

Using URL Arguments to Customize the Request

The next step is to take some arguments from the URL. This will allow the user to make many different sorts of requests to Naurt without having the restart the server each time.

1function getStringOrNull(value: unknown): string | null {
2    return typeof value === 'string' ? value : null;
3}
4
5function getNumberOrNull(value: unknown): number | null {
6    if (typeof value === 'string' && !isNaN(Number(value))) {
7      return Number(value);
8    }
9    return null;
10  }
11
12app.get("/", async (req, res) => {
13
14    const address = getStringOrNull(req.query.address);
15    const lat = getNumberOrNull(req.query.lat);
16    const lon = getNumberOrNull(req.query.lon);
17
18    const request: NaurtRequest = {
19        address_string: address, 
20        latitude: lat,
21        longitude: lon,
22        additional_matches: true,
23        distance_filter: null
24    };
25    const body: string = toJSON(request);
26
27    const response = await axios.post("https://api.naurt.net/final-destination/v1", body, {
28        headers: {
29            "Content-Type": "application/json",
30            "Authorization": API_KEY
31        }
32    });
33
34    res.status(200).json(response.data);
35})

Now, we can make different requests with the same server! Try going to localhost:3000/?address=grand hotel, brighton or localhost:3000/?latitude=50.82&longitude=-0.13.

Using AxiosError to Handle Bad Inputs

Since we’re taking arguments from the URL, we should add a little error handling, as a user might not use it properly.

We can use the AxiosError to get the error response from the server and pass that through to the user

1try {
2    const body: string = toJSON(request);
3
4    const response = await axios.post("https://api.naurt.net/final-destination/v1", body, {
5        headers: {
6            "Content-Type": "application/json",
7            "Authorization": API_KEY
8        }
9    });
10
11    res.status(200).json(response.data);
12} catch (error) {
13
14    if (axios.isAxiosError(error)) {
15        res.status(500).json(error.response?.data);
16    } else {
17        res.status(500).json({
18            message: (error as Error).message
19        });
20    }
21}

For example, if the user provides a latitude but not a longitude then the Naurt server will respond explaining the issue!

Strongly Typing the Naurt Response with Interfaces

Right now we are essentially just displaying the JSON to the user. It would be more useful if we could do something with this data like plot it on a map. To start working towards that we’ll make a NaurtResponse interface, much like the NaurtRequest interface.

1interface NaurtGeojson {
2    features: Feature[],
3    type: string
4}
5
6interface Feature {
7    geometry: Coordinates,
8    type: string,
9    properties: Properties
10}
11
12interface Properties {
13    naurt_type: string
14}
15
16interface Coordinates {
17    coordinates: any[],
18    type: string
19}
20
21interface DestinationResponse {
22    id: string;
23    address: string;
24    geojson: NaurtGeojson;
25    distance?: number;
26}
27
28interface NaurtResponse {
29    best_match?: DestinationResponse;
30    additional_matches?: DestinationResponse[];
31    version?: string;
32}
33
34function parseJson<T>(jsonString: string): T | null {
35    try {
36        return JSON.parse(jsonString) as T;
37    } catch (_) {
38        return null;
39    }
40}

That parseJSON function is just a small utility that will help us convert the server response into an object we can work with more easily.

The structure of the interfaces is a little complex, but we’ll want everything to be properly typed quite soon so we can more easily plot it all on a map.

In the try block we can make convert the response into a NaurtResponse very simply by using

const naurtResponse: NaurtResponse = response.data;

Plotting the Naurt Response on a Map

To keep this demo really simple we’ll just use Plotly to plot the response, and we won’t introduce a framework - we’ll just generate HTML on the server which will contain everything needed for Plotly to actually render the map on the client side.

Let’s scaffhold a function first, and then fill in the details later

1function generateMapHTML(naurtData: NaurtResponse): string {
2
3    var data = [];
4
5    var layout = {};
6
7    const htmlContent = `
8<!DOCTYPE html>
9<html>
10<head>
11  <meta charset="UTF-8">
12  <title>Plotly Map</title>
13  <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
14</head>
15<style>
16.container {
17    width: 100%;
18    height: 100vh;
19}
20</style>
21<body>
22  <div class="container" id="mapDiv"></div>
23  <script>
24    var data = JSON.parse('${JSON.stringify(data)}');
25
26    var layout = JSON.parse('${JSON.stringify(layout)}');
27
28    Plotly.newPlot("mapDiv", data, layout);
29  </script>
30</body>
31</html>
32`;
33
34    return htmlContent;
35}

This will essentially generate a blank map. In the try block of the get closure we can then do

1const naurtResponse: NaurtResponse = response.data;
2const mapHtml = generateMapHTML(naurtResponse);
3
4res.status(200).send(mapHtml);

This will send back the rendered HTML.

We want to do the following things

  • Centre the map on the best match door
  • If the data is a door, plot a point
  • If the data is a parking zone or building outline, plot a shape

Unfortunately, breaking up the response into something Plotly can handle is a little fiddly, so I think the best way to handle it is with a class

Let’s begin with a class stub

1class NaurtExtractor {
2    private _data: Object[];
3    private _layout: Object;
4
5    constructor() {
6        this._data = [];
7        this._layout = {}
8    }
9
10    public get data(): Object[] {
11        return this._data;
12    }
13
14    public get layout(): Object {
15        return this._layout;
16    }
17
18    private set layout(newLayout: Object) {
19        this._layout = newLayout;
20    }
21
22    private appendToData(newData: Object) {
23        this._data.push(newData);
24    }
25}

The first method we’ll write for this class will be to generate the layout - this is where we will be centering the map. So, add this function to the NaurtExtractor class

1private generateLayout(points: number[][]) {
2    const newLayout = {
3        mapbox: {
4            style: "carto-positron", 
5            center: {
6                lat: points[0][1],
7                lon: points[0][0]
8            },
9            zoom: 15
10        },
11        showlegend: false,
12        autosize: true
13    };
14
15    this.layout = newLayout;
16}

You can do a decent amount of customisation in here. If you have a MapBox API key you could use one of their custom maps, but here I’ll use the carto-positron map as it requires no key and is free to use.

Now we need to process a single geojson. There’s three possible types of items that can be inside the geojson Naurt sends - a naurt_door (which we’ll want to make a single point), or a naurt_parking or  naurt_building (which we want to plot as a shape). Also, when we come across the naurt_door from the best match then we want to generate the layout as we can use that to centre the map. Again, add this function as a method of the NaurtExtractor class

1private extractFromNaurtInner(naurtData: DestinationResponse, best_match: boolean) {
2    for (var feat of naurtData.geojson.features) {
3        if (feat.properties.naurt_type === "naurt_door") {
4            if (feat.geometry === null) {
5                continue;
6            }
7            const points: number[][] = feat.geometry.coordinates;
8
9            if (best_match) {
10                this.generateLayout(points);
11            }
12
13
14            this.appendToData({
15                type: "scattermapbox",
16                lat: points.map(coords => coords[1]),
17                lon: points.map(coords => coords[0]),
18                text: `${naurtData.address} - ${feat.properties.naurt_type}`,
19                marker: { size: 9 }
20            })
21        } else {
22            if (feat.geometry === null) {
23                continue;
24            }
25
26            const points: number[][][] = feat.geometry.coordinates;
27
28            for (var shape of points) {
29                this.appendToData({
30                    type: "scattermapbox",
31                    fill: "toself",
32                    lat: shape.map(coords => coords[1]),
33                    lon: shape.map(coords => coords[0]),
34                    text: `${naurtData.address} - ${feat.properties.naurt_type}`,
35                    mode: "lines"
36                })
37            }
38
39        }
40    }
41}

Note how we check if the geometry is null - sometimes Naurt might lack the geometry of some places (this is most common with naurt_building where there is no building outline).

Now finally, we can create the public method to handle this (again, place this inside the NaurtExtractor class)

1public extractFromNaurt(naurtResponse: NaurtResponse) {
2    if (naurtResponse.best_match !== undefined) {
3        this.extractFromNaurtInner(naurtResponse.best_match, true);
4    }
5
6    if (naurtResponse.additional_matches !== undefined) {
7        for (var naurtData of naurtResponse.additional_matches) {
8            this.extractFromNaurtInner(naurtData, false);
9        }
10    }
11}

So, putting this together we can create the new generateMapHTML function like so

1function generateMapHTML(naurtData: NaurtResponse): string {
2
3    var naurtExtractor = new NaurtExtractor();
4    naurtExtractor.extractFromNaurt(naurtData);
5
6    const htmlContent = `
7<!DOCTYPE html>
8<html>
9<head>
10  <meta charset="UTF-8">
11  <title>Plotly Map</title>
12  <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
13</head>
14<style>
15.container {
16    width: 100%;
17    height: 100vh;
18}
19</style>
20<body>
21  <div class="container" id="mapDiv"></div>
22  <script>
23    var data = JSON.parse('${JSON.stringify(naurtExtractor.data)}');
24
25    var layout = JSON.parse('${JSON.stringify(naurtExtractor.layout)}');
26
27    Plotly.newPlot("mapDiv", data, layout);
28  </script>
29</body>
30</html>
31`;
32
33    return htmlContent;
34}

When we run this again with bun start we get a web server that will send us these maps. For example, here’s the map I get when I search with http://localhost:3000/?address=grand%20hotel,%20brighton

You can view the whole code at our GitHub.

Subscribe To Our Newsletter - Sleek X Webflow Template

Subscribe to our newsletter

Sign up at Naurt for product updates, and stay in the loop!

Thanks for subscribing to our newsletter
Oops! Something went wrong while submitting the form.