A detailed guide to Naurt geocoding in swift
NOTE: As of right now, this will only work on MacOS and Linux machines. The server dependency, Vapor, is currently not supported on Microsoft Windows. Please see https://github.com/vapor/vapor/issues/2646 and https://github.com/apple/swift-nio/issues/2065. We’ll update this blog once Windows support comes, hopefully soon!
The set up will vary slightly depending on what platform you use. I will be setting this up on an ArchLinux machine for use with the command line. If you’re using a different platform, see the instructions here.
On ArchLinux, you can use yay
to install Swift
yay -S swift-bin
Then initialise the Swift project with
swift package init --type executable
We’ll use Vapor as the web, server, so install it by making this the contents of your Package.swift file
1// swift-tools-version: 5.10
2// The swift-tools-version declares the minimum version of Swift required to build this package.
3
4import PackageDescription
5
6let package = Package(
7 name: "NaurtExample",
8 dependencies: [
9 .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0")
10 ],
11 targets: [
12 // Targets are the basic building blocks of a package, defining a module or a test suite.
13 // Targets can depend on other targets in this package and products from dependencies.
14 .executableTarget(
15 name: "NaurtExample",
16 dependencies: [
17 .product(name: "Vapor", package: "vapor")
18 ]
19 ),
20 ]
21)
And then fetch the dependencies with
swift package update
We’ll create a basic web server with Vapor. I deviate from the Vapor introductory docs slightly in the implementation - I don’t think the example there is as illuminating. I think the most scalable way to structure this is with three elements
main
function where top-level configuration can take placeSo, the implementation we’ll use is the following
1import Vapor
2
3func main() throws {
4 var env = try Environment.detect()
5 let app = Application(env)
6 defer { app.shutdown() }
7
8 try configureRoutes(app)
9
10 try app.run()
11}
12
13func configureRoutes(_ app: Application) throws {
14 app.get(use: helloWorldHandler)
15}
16
17func helloWorldHandler(req: Request) -> Response {
18 return Response(status: .ok, body: .init(string: "Thanks for calling me!"))
19}
20
21try main()
At this point you can run this with
swift build
swift run
And then go to http://localhost:8080 - if everything went well you should see a simple reply from the server.
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
. Make sure the file is next to the Package.swift
file. In most backend applications, this will probably be injected via an environment variable or a secret.
For us, just having this as a global variable will suffice, so we can place this at the top of the main.swift
file
1struct Config {
2 static let apiKey: String = {
3 let filePath = "api.key"
4 do {
5 let fileContents = try String(contentsOfFile: filePath, encoding: .utf8)
6 return fileContents
7 } catch {
8 fatalError("API Key file not found")
9 }
10 }()
11}
Just before the main function executes the API key will be stored in this struct
.
Obviously our web server will be far more useful once we actually make requests to Naurt. Let’s replace our handler
function with the following code
1func handler(req: Request) async throws -> Response {
2 let response = try await req.client.post("https://api.naurt.net/final-destination/v1") { req in
3
4 try req.content.encode(["address_string": "The Grand Hotel, Brighton"])
5
6 req.headers.add(name: "Authorization", value: Config.apiKey)
7}
8
9 guard response.status == .ok else {
10 return Response(status: .internalServerError, body: .init(string: "Failed to fetch data from the external API"))
11 }
12
13 var headers = HTTPHeaders()
14 headers.add(name: .contentType, value: "application/json")
15 let jsonString = String(buffer: response.body!)
16
17 return Response(status: .ok, headers: headers, body: .init(string: jsonString))
18
19}
Just a few things to note here - we’ve hard coded in a request, but we’ll come back to that later. We use let jsonString = String(buffer: response.body!)
to convert the bytes of the response into a utf8
string which we can actually send back to the user.
Hard coding in a request isn’t so useful, since we’d have to recompile the program every time we wanted a new request. Instead, we’ll use query parameters in the URL. Before we do that, we need to create a helper struct
to handle the encoding into a JSON. However, there’s two things we need to take note of
Therefore, make the following struct. We make it conform to Content
so it can fit into the Vapor ecosystem.
1struct NaurtRequest: Content {
2 let addressString: String?
3 let additionalMatches: Bool?
4 let latitude: Float?
5 let longitude: Float?
6
7 func encode(to encoder: any Encoder) throws {
8 var container = encoder.container(keyedBy: CustomCodingKeys.self)
9
10 if let addressString = self.addressString {
11 try container.encode(addressString, forKey: .addressString)
12 }
13
14 if let additionalMatches = self.additionalMatches {
15 try container.encode(additionalMatches, forKey: .additionalMatches)
16 }
17
18 if let latitude = self.latitude {
19 try container.encode(latitude, forKey: .latitude)
20 }
21
22 if let longitude = self.longitude {
23 try container.encode(longitude, forKey: .longitude)
24 }
25 }
26
27 enum CustomCodingKeys: String, CodingKey {
28 case addressString = "address_string"
29 case additionalMatches = "additional_matches"
30 case latitude = "latitude"
31 case longitude = "longitude"
32 }
33}
Once we have that, actually parsing the query parameters is really easy in Vapor, we just need to update the handler function like so
1func handler(req: Request) async throws -> Response {
2
3 let addressString = req.query[String.self, at: "address_string"]
4 let additionalMatches = req.query[Bool.self, at: "additional_matches"]
5 let latitude = req.query[Float.self, at: "latitude"]
6 let longitude = req.query[Float.self, at: "longitude"]
7
8 let response = try await req.client.post("https://api.naurt.net/final-destination/v1") { req in
9
10 let naurtRequest = NaurtRequest(addressString: addressString, additionalMatches: additionalMatches, latitude: latitude, longitude: longitude)
11 try req.content.encode(naurtRequest)
12
13 req.headers.add(name: "Authorization", value: Config.apiKey)
14}
15// Rest of the function...
Now you can try going to http://localhost:8080/?address_string=The Grand Hotel, Brighton to see this working!
The majority of the data returned by Naurt is inside a GeoJSON. While this is a great format, it isn’t really designed for humans to read. The best way to visualise the GeoJSON is of course on a map. We’ll use a simple Leaflet script to do that, and we’ll insert the Naurt response using Lead - Vapor’s templating library. To install Leaf, update the Package.swift
like so
1// swift-tools-version: 5.10
2// The swift-tools-version declares the minimum version of Swift required to build this package.
3
4import PackageDescription
5
6let package = Package(
7 name: "NaurtExample",
8 dependencies: [
9 .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"),
10 .package(url: "https://github.com/vapor/leaf.git", from: "4.0.0")
11 ],
12 targets: [
13 // Targets are the basic building blocks of a package, defining a module or a test suite.
14 // Targets can depend on other targets in this package and products from dependencies.
15 .executableTarget(
16 name: "NaurtExample",
17 dependencies: [
18 .product(name: "Vapor", package: "vapor"),
19 .product(name: "Leaf", package: "leaf")
20 ]
21 ),
22 ]
23)
24
Don’t forget to update the packages with
swift package update
Create a folder next to Sources called Resources and then a Views folder inside that. Create a file called index.leaf . This will be our template, and the contents are
1<!DOCTYPE html>
2<html>
3
4<head>
5 <title>Naurt Geocoder</title>
6 <meta charset="utf-8" />
7 <meta name="viewport" content="width=device-width, initial-scale=1.0">
8 <link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css" />
9 <style>
10 #map {
11 height: 100vh;
12 }
13 </style>
14</head>
15
16<body>
17 <div id="map"></div>
18 <script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"></script>
19 <script>
20
21 function getColor(type) {
22 switch (type) {
23 case 'naurt_door': return '#FF0000';
24 case 'naurt_building': return '#9d79f2';
25 case 'naurt_parking': return '#00FF00';
26 default: return '#000000';
27 }
28 }
29
30 function style(feature) {
31 return {
32 fillColor: getColor(feature.properties.naurt_type), weight: 2, opacity: 1, color: 'white', dashArray: '5', fillOpacity: 0.4
33 };
34 }
35
36 function onEachFeatureCurry(address) {
37 return function onEachFeature(feature, layer) {
38 if (feature.properties) {
39 var popupContent = 'Type: ' + feature.properties.naurt_type;
40 if (feature.properties.naurt_type == 'naurt_building') {
41 popupContent += '<br>Address: ' + address;
42 }
43 layer.bindPopup(popupContent).openPopup();
44 }
45 }
46 }
47
48
49
50 var map = L.map('map').setView([0, 0], 2);
51 L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' }).addTo(map);
52 var response = #unsafeHTML(NaurtResponse);
53
54
55 L.geoJSON(response["best_match"]["geojson"], { style: style, onEachFeature: onEachFeatureCurry(response["best_match"]["address"]) }).addTo(map);
56
57 if ("additional_matches" in response) {
58 for (const additionalMatch of response["additional_matches"]) {
59 L.geoJSON(additionalMatch["geojson"], { style: style, onEachFeature: onEachFeatureCurry(additionalMatch["address"]) }).addTo(map);
60 }
61 }
62
63 var bounds = L.geoJSON(response["best_match"]["geojson"]).getBounds();
64 map.fitBounds(bounds);
65 </script>
66</body>
67
68
69</html>
I won’t walk through every detail of the template, as it’s pretty self-explanatory. I’ll just note that the basic idea here is to plot each aspect of the GeoJSON - naurt_door
, naurt_parking
, naurt_building
- as its own layer. We also check to see if there are additional_matches
, and if there are, loop over them and plot them in the same way as the best_match
.
To actually use the template we just need to do a few things.
First, import Leaf at the top of main.swift
import Leaf
Then add Leaf to the main function
1func main() throws {
2
3 var env = try Environment.detect()
4 let app = Application(env)
5 defer { app.shutdown() }
6
7 app.views.use(.leaf)
8
9 try configureRoutes(app)
10
11 try app.run()
12}
And then just update the end of handler
1 // Rest of function...
2 guard response.status == .ok else {
3 return Response(status: .internalServerError, body: .init(string: "Failed to fetch data from the external API"))
4 }
5
6 let jsonString = String(buffer: response.body!)
7
8 let context = ["NaurtResponse": jsonString]
9 return try await req.view.render("index", context).encodeResponse(status: .ok, for: req)
10}
This will render the template, and also encode it to a Response
. Normally, with Leaf templates you return a EventLoopFuture<View>
however, we sometimes need to return different kinds of responses (when there’s an error), so that doesn’t work here.
And that’s it!
If you run the server again and go to http://localhost:8080/?address_string=The Grand Hotel, Brighton&additional_matches=true you should see the following