In this blog post, you will learn about integrating Naurt’s geocoder with Python to enhance last mile delivery. The guide includes steps to sign up for a free Naurt key, set up the environment, make geocoding requests, structure API responses, and plot geocoder responses on a map using Flask and Kepler. You’ll also explore advanced geocoding options and how to improve the usability of a demo app.
Naurt’s geocoder is a simple solution to many of the challenges faced by last mile delivery companies. Naurt provides you with more than the building location - it also gives the parking spots and building entrances. Delivery drivers can find exactly where to hand off goods. This leads to faster deliveries and better customer satisfaction!
In this blog, we’ll look at how to get the best from Naurt’s geocoder, in Python. For even more information on the API, see the docs.
If you want to follow along with the complete code you can find it here.
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.
I’ll use a virtual environment, which you can create and activate with
python -m venv .venv
source .venv/bin/activate
If you’re on Windows then usually you can activate using .venv/Scripts/activate.bat
in cmd and .venv/Scripts/Activate.ps1
in PowerShell. Here’s a guide that talks more about virtual environment management.
I’ll also make a requirements.txt
file, and for now we’ll add just one entry - requests
. The requests
library helps us make HTTP requests. You can then install requests
into the virtual environment with (make sure the virtual environment is active or this will install into your global Python install - which is NOT recommended)
pip install -r requirements.txt
To template this code, I’ll start with the following in a main.py
file next to api.key
1import requests
2
3ENDPOINT = "https://api.naurt.net/final-destination/v1"
4
5with open("api.key") as file:
6 API_KEY = file.read()
7
8def main():
9 pass
10
11if __name__ == "__main__":
12 main()
Now we have some basic structure, the endpoint, as well as our Naurt API key stored in API_KEY,
we’re ready to begin.
Let’s start with a simple request. Naurt’s geocoder couldn’t be easier to use - all we need is a simple JSON body like this
{
"address_string": String
}
And some headers - Authorization
for our API key and content-type: application/json
Let’s program that! I’ll place the following in my main()
function
1headers = {"Authorization": API_KEY, "content-type": "application/json"}
2
3body = {"address_string": "Grand Hotel, Brighton"}
4
5response = requests.post(ENDPOINT, headers=headers, json=body)
6
7print(response.json())
If we run this, we get the output we’re looking for!
1{
2 "best_match": {
3 "address": "Grand Hotel, 97-99, Kings Road, Brighton, East Sussex, England, United Kingdom, BN1 2FW",
4 "geojson": {},
5 "id": "a739c2bc-a45c-374c-d477-8f8dd8081c1c"
6 },
7 "contributors": [
8 "© OpenStreetMap contributors"
9 ],
10 "version": "1.1.0"
11}
(I’ve just collapsed the geojson
field for readability)
When making requests to APIs, it’s helpful to have a class which you can serialise to avoid mistakes. For this purpose, I’ll be using the pydantic
library - remember to add it to your requirements.txt
and run the installation again with pip install -r requirements.txt
. We need to import BaseModel
from pydantic
as well as Optional
from typing
from pydantic import BaseModel
from typing import Optional
Then write the NaurtRequest
class
class NaurtRequest(BaseModel):
address_string: Optional[str]
Now, we can make the body in the following way
body = NaurtRequest(address_string="Grand Hotel, Brighton").dict()
And that’s it - we now have a structured request. We’ll come back later and add some more fields to this. If you run the file again you should get the same output.
Like the structured request, we can structure the response too. Let’s make our classes like so
1class DestinationRresponse(BaseModel):
2 id: str
3 address: str
4 geojson: Dict
5 distance: Optional[float] = None
6
7
8class NaurtResponse(BaseModel):
9 best_match: Optional[DestinationRresponse] = None
10 additional_matches: Optional[List[DestinationRresponse]] = None
11 version: Optional[str] = None
Make sure to also import Dict
and List
from typing
!
Here, we set the defaults to None
- this just means if those fields aren’t present then pydantic
will handle them gracefully for us. So, instead of using the json()
method on the reply, we can actually do this instead.
1response = requests.post(ENDPOINT, headers=headers, json=body)
2
3naurt_response = NaurtResponse.parse_raw(response.text)
4
5print(naurt_response)
naurt_response
will now be of type NaurtResponse
, which is much more useful, and we can see an example of that in the next section.
Let’s explore some of the alternative search options we can try. We can update the NaurtRequest
to look like this
1class NaurtRequest(BaseModel):
2 address_string: Optional[str] = None
3 country: Optional[str] = None
4 latitude: Optional[float] = None
5 longitude: Optional[float] = None
6 distance_filter: Optional[float] = None
7 additional_matches: Optional[bool] = None
Since we’ll have a bunch of fields which will be None
most of the time, we should use the exclude_none=True
option of the dict()
method from pydantic
1body = NaurtRequest(
2 address_string="5 Foundry St, Brighton and Hove, Brighton BN1 4AT"
3 ).dict(exclude_none=True)
This way, the dictionary we get only has an address_string
field.
So, what do these additional fields do?
With just a latitude
and longitude
we can do a reverse geocode. This finds nearby addresses. This works best if we set additional_matches
to true
so we get a total of 5 addresses back
1body = NaurtRequest(latitude=50.83, longitude=-0.13, additional_matches=True).dict(
2 exclude_none=True
3 )
Any extra matches beyond the best match will go into the additional_matches
field of the NaurtResponse
.
We can also search by address and location at the same time - this finds the best match for the address string provided limited to the distance from the point specific by distance_filter
. If no distance_filter
is provided, the default is 5000 metres. You can set a maximum distance of 25000 metres. This mode is helpful for finding more generic things in an area - for example, you can do this to find churches in and around Brighton, England
body = NaurtRequest(
address_string="Church",
latitude=50.83,
longitude=-0.13,
additional_matches=True,
distance_filter=25000,
).dict(exclude_none=True)
Up until now, we haven’t really done too much with the response other than print it out. The most intuitive way to understand geographical information is to plot it on a map. So let’s do that!
The general gist here will be to use Kepler to plot the geojson and use Flask to serve it - so we need to add
keplergl
setuptools
flask
to the requirements.txt
and then run pip install -r requirements.txt
again to install those.
We need to convert the main function now into a flask function
1from flask import Flask
2from keplergl import KeplerGl
3
4app = Flask(__name__)
5
6@app.get("/")
7def main():
8 headers = {"Authorization": API_KEY, "content-type": "application/json"}
9
10 body = NaurtRequest(
11 address_string="Grand Hotel",
12 latitude=50.83,
13 longitude=-0.13,
14 additional_matches=True,
15 distance_filter=25000,
16 ).dict(exclude_none=True)
17 response = requests.post(ENDPOINT, headers=headers, json=body)
18
19 naurt_response = NaurtResponse.parse_raw(response.text)
20
21 my_map = KeplerGl()
22
23 for feature in naurt_response.best_match.geojson["features"]:
24 my_map.add_data(data=feature, name=feature["properties"]["naurt_type"])
25
26 html = my_map._repr_html_()
27
28 return html, 200
We also don’t want to call the main
function directly anymore, so we want to do this instead
1if __name__ == "__main__":
2 app.run(debug=True)
So, if we run this now, we can go to localhost:5000
in our browser and see the plot
By default, the map will centre over San Francisco in the United States, but if you scroll over to Brighton, England you’ll see this (or you can change it to any address you like). We’ll fix that in the next section and make the map jump to the right place to start.
We can see the building outline (if available), the door and the parking zone!
There’s three things we can do now to improve the usability of our little demo app
To add search parameters, we need to import request
from Flask and then add this into the main function
1address = request.args.get("address", type=str)
2latitude = request.args.get("latitude", type=float)
3longitude = request.args.get("longitude", type=float)
4distance = request.args.get("distance", type=int)
5
6body = NaurtRequest(
7 address_string=address,
8 latitude=latitude,
9 longitude=longitude,
10 additional_matches=True,
11 distance_filter=distance,
12).dict(exclude_none=True)
We can go to this URL http://localhost:5000/?address=Grand%20Hotel,%20Brighton&latitude=50.12&longitude=-0.13&distance=25000
to see the map load. We don’t have to restart the app to get a new location now!
Let’s sort out the map centre and zoom. First, we’ll make this function
1def make_config(latitude: float, longitude: float) -> Dict:
2 config = {
3 "version": "v1",
4 "config": {
5 "mapState": {
6 "latitude": latitude,
7 "longitude": longitude,
8 "zoom": 15
9 }
10 }
11 }
12
13 return config
Then we’ll replace the map code with this
1for feature in naurt_response.best_match.geojson["features"]:
2 my_map.add_data(data=feature, name="{} - {}".format(naurt_response.best_match.address, feature["properties"]["naurt_type"]))
3
4 if feature["properties"]["naurt_type"] == "naurt_door":
5 config = make_config(feature["geometry"]["coordinates"][0][1], feature["geometry"]["coordinates"][0][0])
6 my_map.config = config
7
8
9for additional_match in naurt_response.additional_matches:
10 for feature in additional_match.geojson["features"]:
11 my_map.add_data(data=feature, name="{} - {}".format(additional_match.address, feature["properties"]["naurt_type"]))
This will plot all of the matches, as well as centre the map on the door of the best match!
You can see the complete code on our GitHub.