Better Programming

Advice for programmers.

Follow publication

Park API — Server-Side Swift With Hummingbird

--

Special thanks to Tibor Bödecs for his patience and guidance while writing this tutorial

Image credit: Szabolcs Tóth

Server-side Swift has been available since the end of 2015. The idea was behind the development that you can use the same language for RESTful APIs, desktop and mobile applications. With the evolution of the Swift language, the different Swift web frameworks got more robust and complex.

I was happy to read Tib’s excellent article about a new HTTP server library written in Swift, Hummingbird. I immediately liked the modularity concept, so I created a tutorial to show its simplicity.

We will build a swift server running on SQLite database to store playgrounds around the city with names and coordinates. A simple JSON response will look like this:

[
{
"latitude": 50.105848999999999,
"longitude": 14.413999,
"name": "Stromovka"
},
{
"latitude": 50.0959721,
"longitude": 14.4202892,
"name": "Letenské sady"
}, {
"latitude": 50.132259369,
"longitude": 14.46098423,
"name": "Žernosecká - Čumpelíkova"
}
]

The project will use FeatherCMS’s own Database Component.

Step 1. — Init the Project

mkdir parkAPI && cd $_
swift package init --type executable

This creates the backbone of our project. One of the most important files and initial points of our project is the Package.swift, the Swift manifest file. Here you can read more about it.

Step 2. — Create the Folder Structure

We need to follow certain guidelines about folder structure. Otherwise, the compiler won’t be able to handle our project. In the picture below, you can find the simplest structure, which follows the Hummingbird template.

.
├── Package.swift
├── README.md
└── Sources
└── parkAPI
├── App.swift
└── Application+configure.swift

We will add the Tests folder later, when we will have something to test.

Step 3. — Run the Server

Before we can run our server, we need to add two packages to the Package.swift file:

  • Hummingbird
  • Swift Argument Parser

Using .executableTarget the @main will be the entry point of our application, and we can rename main.swift to App.swift. Paul Hudson wrote a short article about it.

import PackageDescription

let package = Package(
name: "parkAPI",
platforms: [
.macOS(.v12),
],
dependencies: [
.package(url: "https://github.com/hummingbird-project/hummingbird", from: "1.5.0"),
.package(url: "https://github.com/apple/swift-argument-parser",from: "1.2.0"),
],
targets: [
.executableTarget(
name: "parkAPI",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Hummingbird", package: "hummingbird"),
.product(name: "HummingbirdFoundation", package: "hummingbird"),
],
swiftSettings: [
.unsafeFlags(
["-cross-module-optimization"],
.when(configuration: .release)
)
]
)
]
)

Define the hostname and port in the App.swift.

import ArgumentParser
import Hummingbird

@main
struct App: ParsableCommand {

@Option(name: .shortAndLong)
var hostname: String = "127.0.0.1"

@Option(name: .shortAndLong)
var port: Int = 8080

func run() throws {
let app = HBApplication(
configuration: .init(
address: .hostname(hostname, port: port),
serverName: "parkAPI"
)
)

try app.configure()
try app.start()
app.wait()
}
}

One last thing that remained before we could run our application was to define the route in the Application+configuration.swift.

import Hummingbird
import HummingbirdFoundation

public extension HBApplication {

func configure() throws {
router.get("/") { _ in
"The server is running...🚀"
}
}
}

Run the first Hummingbird server by typing:

swift run parkAPI

Step 4. Create API Response

Our server will be accessible on the following routes by using different HTTP methods.

  • GET - http://hostname/api/v1/parks: Lists all the parks in the database
  • GET - http://hostname/api/v1/parks/:id: Shows a single park with given id
  • POST - http://hostname/api/v1/parks: Creates a new park
  • PATCH - http://hostname/api/v1/parks/:id: Updates the park with the given id
  • DELETE - http://hostname/api/v1/parks/:id: Removes the park with id from database

Step 4.1 Add database dependency

Our server will use the SQLite database to store all data, so we must add the database dependency to our manifest file. This will allow the server to communicate with the database.

The updated Package.swift file will look like this:

import PackageDescription

let package = Package(
name: "parkAPI",
platforms: [
.macOS(.v12)
],
dependencies: [
.package(url: "https://github.com/hummingbird-project/hummingbird", from: "1.5.0"),
.package(url: "https://github.com/apple/swift-argument-parser",from: "1.2.0"),
// Database dependency
.package(url: "https://github.com/feathercms/hummingbird-db", branch: "main")
],
targets: [
.executableTarget(
name: "parkAPI",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Hummingbird", package: "hummingbird"),
.product(name: "HummingbirdFoundation", package: "hummingbird"),
// Database dependencies
.product(name: "HummingbirdDatabase", package: "hummingbird-db"),
.product(name: "HummingbirdSQLiteDatabase", package: "hummingbird-db"),
],
swiftSettings: [
.unsafeFlags(
["-cross-module-optimization"],
.when(configuration: .release)
)
]
)
]
)

Step 4.2 Use concurrency in App.swift

Add async to function run() and await to app.configure.

import ArgumentParser
import Hummingbird

@main
struct App: AsyncParsableCommand, AppArguments {

@Option(name: .shortAndLong)
var hostname: String = "127.0.0.1"

@Option(name: .shortAndLong)
var port: Int = 8080

func run() async throws {
let app = HBApplication(
configuration: .init(
address: .hostname(hostname, port: port),
serverName: "Hummingbird"
)
)

try await app.configure()
try app.start()
app.wait()
}
}

We need to use AsyncParsableCommand and AppArguments protocols.

Step 4.3 Create a database configuration file

Add a new file, DatabaseSetup.swift, under a new Database folder under Source/parkAPI/.

import Hummingbird
import HummingbirdSQLiteDatabase

extension HBApplication {
func setupDatabase() async throws {
services.setUpSQLiteDatabase(
storage: .file(path: "./hb-parks.sqlite"),
threadPool: threadPool,
eventLoopGroup: eventLoopGroup,
logger: logger
)

// Create the database table
try await db.execute(
.init(unsafeSQL:
"""
CREATE TABLE IF NOT EXISTS parks (
"id" uuid PRIMARY KEY,
"latitude" double NOT NULL,
"longitude" double NOT NULL,
"name" text NOT NULL
);
"""
)
)
}
}

Step 4.4 Call the setupDatabase function

Add try await setupDatabase() to Application+configure.swift.

import Hummingbird
import HummingbirdFoundation


public protocol AppArguments {}

public extension HBApplication {

func configure() async throws {

// Setup the database
try await setupDatabase()

// Set encoder and decoder
encoder = JSONEncoder()
decoder = JSONDecoder()

// Logger
logger.logLevel = .debug

// Middleware
middleware.add(HBLogRequestsMiddleware(.debug))
middleware.add(HBCORSMiddleware(
allowOrigin: .originBased,
allowHeaders: ["Content-Type"],
allowMethods: [.GET, .OPTIONS, .POST, .DELETE, .PATCH]
))

router.get("/") { _ in
"The server is running...🚀"
}

// Additional routes are defined in the controller
// We want our server to respond on "api/v1/parks"
ParkController().addRoutes(to: router.group("api/v1/parks"))
}
}

Step 4.5 Create the park model

Add the Park.swift under /Source/parkAPI/Models.

import Foundation
import Hummingbird

struct Park: Codable {
let id: UUID
let latitude: Double
let longitude: Double
let name: String

init(id: UUID, latitude: Double, longitude: Double, name: String) {
self.id = id
self.latitude = latitude
self.longitude = longitude
self.name = name
}
}

extension Park: HBResponseCodable {}

Step 4.6 Create the park controller

The Controller receives input from the users, then processes the user’s data with the help of Model and passes the results back. Add ParkController.swift to a new Controllers folder under Source/parkAPI/.

import Foundation
import Hummingbird
import HummingbirdDatabase

extension UUID: LosslessStringConvertible {

public init?(_ description: String) {
self.init(uuidString: description)
}
}

struct ParkController {
// Define the table in the databse
let tableName = "parks"

// The routes for CRUD operations
func addRoutes(to group: HBRouterGroup) {
group
.get(use: list)
}

// Return all parks
func list(req: HBRequest) async throws -> [Park] {
let sql = """
SELECT * FROM parks
"""
let query = HBDatabaseQuery(unsafeSQL: sql)

return try await req.db.execute(query, rowType: Park.self)
}
}

In the controller file, define the table of the database you want to use. Ideally, it is the same as you defined in the DatabaseSetup.swift file.

Use HBRouterGroup to collect all routes under a single path.

GET — all parks

Start with listing all elements: .get(use: list) Where get refers to GET method and use to the function where you describe what is supposed to happen if you call that endpoint.

The list() function returns with the array of Park model.

GET — park with {id}

Show park with specified id: .get(":id", use: show).

 func show(req: HBRequest) async throws -> Park? {
let id = try req.parameters.require("id", as: UUID.self)
let sql = """
SELECT * FROM parks WHERE id = :id:
"""
let query = HBDatabaseQuery(
unsafeSQL: sql,
bindings: ["id": id]
)
let rows = try await req.db.execute(query, rowType: Park.self)

return rows.first
}

POST — create park

Create a new park: .post(options: .editResponse, use: create).

    func create(req: HBRequest) async throws -> Park {
struct CreatePark: Decodable {
let latitude: Double
let longitude: Double
let name: String
}

let park = try req.decode(as: CreatePark.self)
let id = UUID()
let row = Park(
id: id,
latitude: park.latitude,
longitude: park.longitude,
name: park.name
)
let sql = """
INSERT INTO
parks (id, latitude, longitude, name)
VALUES
(:id:, :latitude:, :longitude:, :name:)
"""

try await req.db.execute(.init(unsafeSQL: sql, bindings: row))
req.response.status = .created

PATCH — update park with {id}

Update park with specified id: ‌.patch(":id", use: update)

   func update(req: HBRequest) async throws -> HTTPResponseStatus {
struct UpdatePark: Decodable {
var latitude: Double?
var longitude: Double?
var name: String?
}
let id = try req.parameters.require("id", as: UUID.self)
let park = try req.decode(as: UpdatePark.self)
let sql = """
UPDATE
parks
SET
"latitude" = CASE WHEN :1: IS NOT NULL THEN :1: ELSE "latitude" END,
"longitude" = CASE WHEN :2: IS NOT NULL THEN :2: ELSE "longitude" END,
"name" = CASE WHEN :3: IS NOT NULL THEN :3: ELSE "name" END
WHERE
id = :0:
"""
try await req.db.execute(
.init(
unsafeSQL:
sql,
bindings:
id, park.latitude, park.longitude, park.name
)
)
return .ok
}

As in the DatabaseSetup.swift file, we defined that none of the table columns can be NULL, we need to check that the request contains all values of only some of them and update the columns respectively.

DELETE — delete park with {id}

Delete park with specified id: .delete(":id", use: deletePark)

    func deletePark(req: HBRequest) async throws -> HTTPResponseStatus {
let id = try req.parameters.require("id", as: UUID.self)
let sql = """
DELETE FROM parks WHERE id = :0:
"""
try await req.db.execute(
.init(
unsafeSQL: sql,
bindings: id
)
)
return .ok
}
}

Our final folder structure looks like this:

.
├── Package.swift
├── README.md
└── Sources
└── parkAPI
├── App.swift
├── Application+configure.swift
├── Controllers
│ └── ParkController.swift
├── Database
│ └── DatabaseSetup.swift
└── Models
└── Park.swift

Step 5: Run the API Server

swift run parkAPI

You can reach the server on http://127.0.0.1:8080.

Summary

I was impressed by how easily and quickly I could build a working API server using Hummingbird and FeatherCMS’s Database Component. Building and running the project took very minimal time compared to Vapor. I highly recommend trying the Hummingbird project if you want something light and modular on the server-side Swift.

You can find the source code here.

The original article was published here.

You can find out how the PostgreSQL database is used here.

--

--

Responses (1)

Write a response