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!
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');
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!”.
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.
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.
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.
AxiosError
to Handle Bad InputsSince 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!
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;
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
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.