Building a SwiftNIO HTTP client
HTTP clients are a common first networking application to build. HTTP is a well known and simple to understand protocol, making it an excellent start.
In the previous tutorial, SwiftNIO Channels, you learned how to use SwiftNIO to build a simple TCP echo server. In this tutorial, you’ll build a simple HTTP client using SwiftNIO.
We’ll use the NIOHTTP1
package for parsing and serializing HTTP messages. In addition, SwiftNIO’s structured concurrency is used to manage the lifecycle of our client.
By the end of this tutorial, you’ll know how to configure a SwiftNIO Channel’s pipeline, and are able to send HTTP requests to a server.
Download the Samples to get started. It has a dev container for a quick start.
Creating a Client Channel
In SwiftNIO, Channels are created through a bootstrap. For TCP clients, you’d generally use a ClientBootstrap
. There are alternative clients as well, such as Apple’s Transport Services for Apple platforms. In addition, the NIOHTTP1
module is used to simplify the process of creating a client channel.
Add these dependencies to your executable target in your Package.swift
file:
.executableTarget(
name: "swift-nio-part-3",
dependencies: [
.product(name: "NIO", package: "swift-nio"),
.product(name: "NIOHTTP1", package: "swift-nio"),
]
),
Now, let’s create a ClientBootstrap
and configure it to use the NIOHTTP1
module’s handlers. First, import the necessary modules:
import NIOCore
import NIOHTTP1
import HTTPTypes
import NIOHTTPTypes
import NIOHTTPTypesHTTP1
import NIOPosix
building-swiftnio-clients-01.swiftThen, create a ClientBootstrap
:
// 1
let httpClientBootstrap = ClientBootstrap(
group: NIOSingletons.posixEventLoopGroup
)
// 2
.channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
// 3
.channelInitializer { channel in
// 4
channel.eventLoop.makeCompletedFuture {
try channel.pipeline.syncOperations.addHTTPClientHandlers()
try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPClientCodec())
}
}
building-swiftnio-clients-01.swift:30This code prepares a template for creating a client channel. Let’s break it down:
Create a
ClientBootstrap
using theNIOSingletons.posixEventLoopGroup
as the event loop group. This is a shared event loop group that can be reused across multiple components of our application.NIO Channels can have options set on them. Here, the
SO_REUSEADDR
option is set to1
to allow the reuse of local addresses.Then, provide an initializer that is used to configure the pipeline of newly created channels.
Finally, the
channelInitializer
adds the necessary HTTP client handlers to the channel’s pipeline. This uses a helper function provided by NIOHTTP1.
Creating Types
Before creating the HTTP client, it’s necessary to add a few types that are needed for processing HTTP requests and responses.
When a connect
fails, NIO already throws an error. There is no need to catch or represent those. However, the HTTP Client might encounter errors when processing the response. Create an enum to represent these errors:
enum HTTPClientError: Error {
case malformedResponse, unexpectedEndOfStream
}
building-swiftnio-clients-01.swift:18Finally, add an enum to represent the state of processing the response:
enum HTTPPartialResponse {
case none
case receiving(HTTPResponse, ByteBuffer)
}
building-swiftnio-clients-01.swift:11The enum if pretty simple, and is not representative of a mature HTTP client implementation such as AsyncHTTPClient. However, it’s enough to get started with building a (TCP) client.
Implementing the HTTP Client
Now that the necessary types have been created, create the HTTPClient
type with a simple function that sends a request and returns the response.
struct HTTPClient {
let host: String
let httpClientBootstrap: ClientBootstrap
init(host: String) {
building-swiftnio-clients-01.swift:24Let’s break it down:
Use the
httpClientBootstrap
to create a new client channel. This returns anEventLoopFuture
containing a regular NIOChannel
. By usingflatMapThrowing(_:)
to transform the result of this future, it’s possible to convert theEventLoopFuture
into aNIOAsyncChannel
.In order to use structured concurrency, it’s necessary to wrap the
Channel
in anNIOAsyncChannel
. The inbound and outbound types must beSendable
, and need to be configured to match the pipeline’s input and output. This is based on the handlers added in the bootstrap’schannelInitializer
.The
NIOAsyncChannel
is configured to receiveHTTPClientResponsePart
objects. This is the type that the HTTP client will receive from the server.The
NIOAsyncChannel
is configured to sendSendableHTTPClientRequestPart
objects. This is the type that the HTTP client will send to the server.The
get()
method is called to await for the result of theEventLoopFuture
.
Sending a Request
In place of the TODO comment, add the code to send a request and process the response. First, create a HTTPRequestHead
. Note that this function does not currently support sending a body with the request. Do so by adding the following code:
// 1
return try await clientChannel.executeThenClose { inbound, outbound in
// 2
try await outbound.write(
.head(
HTTPRequest(
method: method,
scheme: "http",
authority: host,
path: path,
headerFields: headers
)
)
)
try await outbound.write(.end(nil))
building-swiftnio-clients-01.swift:75This is a structured concurrency block that sends the request:
The
executeThenClose
method is used to obtain a read and write half of the channel. This function returns the result of it’s trailing closure.The writer called
outbound
is used to send the request’s part - the head and ‘end’. This is also where the request’s body would be sent.
Below that, receive and process the response parts as such:
var partialResponse = HTTPPartialResponse.none
// 1
for try await part in inbound {
// 2
switch part {
case .head(let head):
guard case .none = partialResponse else {
throw HTTPClientError.malformedResponse
}
let buffer = clientChannel.channel.allocator.buffer(
capacity: 0
)
partialResponse = .receiving(head, buffer)
case .body(let buffer):
guard
case .receiving(let head, var existingBuffer) =
partialResponse
else {
throw HTTPClientError.malformedResponse
}
existingBuffer.writeImmutableBuffer(buffer)
partialResponse = .receiving(head, existingBuffer)
case .end:
guard
case .receiving(let head, let buffer) = partialResponse
else {
throw HTTPClientError.malformedResponse
}
return (head, buffer)
}
}
// 3
throw HTTPClientError.unexpectedEndOfStream
building-swiftnio-clients-01.swift:93This sets up a state variable to keep track of the response parts received. It then processes the response parts as they come in:
A
for
loop is used to iterate over the response parts. This is a structured concurrency block that will continue to run until the channel is closed by the remote, an error is thrown, or areturn
statement ends the function.The
part
is matched against theHTTPClientResponsePart
enum. If the part is a head, it’s stored in thepartialResponse
variable. If the part is a body, it’s appended to the buffer in thepartialResponse
variable. If the part is an end, thepartialResponse
is returned.If the loop ends without a return, an error is thrown, since the code was unable to receive a complete response.
Using the Client
Now that the HTTP client is complete, it’s time to use it. Add the following code to the main.swift
file:
let client = HTTPClient(host: "example.com")
let (response, body) = try await client.request("/")
print(response)
print(body.getString(at: 0, length: body.readableBytes)!)
building-swiftnio-clients-01.swift:137This creates a client and sends a GET request to example.com
. The response is then printed to the console.
If everything is set up correctly, you should see roughly the following output:
HTTPResponseHead { version: HTTP/1.1, status: 200 OK, headers: [("Accept-Ranges", "bytes"), ("Age", "464157"), ("Cache-Control", "max-age=604800"), ("Content-Type", "text/html; charset=UTF-8"), ("Date", "Wed, 07 Feb 2024 21:22:33 GMT"), ("Etag", "\"3147526947\""), ("Expires", "Wed, 14 Feb 2024 21:22:33 GMT"), ("Last-Modified", "Thu, 17 Oct 2019 07:18:26 GMT"), ("Server", "ECS (dce/26CD)"), ("Vary", "Accept-Encoding"), ("X-Cache", "HIT"), ("Content-Length", "1256")] }
<!doctype html>
<html>
<head>
<title>Example Domain</title>
<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
....
And that’s it! You’ve built a simple HTTP client using SwiftNIO. You can now use this client to send requests to any server that supports HTTP/1.1.
Related posts
Beginner's Guide to Protocol Buffers and gRPC with Swift
Learn Protocol Buffers and gRPC with Swift in this easy, step-by-step beginner's guide.
Working with UDP in SwiftNIO
Create UDP servers and clients using SwiftNIO and structured concurrency
AsyncHTTPClient by example
This article offers practical examples to introduce the Swift AsyncHTTPClient library.
Using SwiftNIO - Channels
Create a TCP server using SwiftNIO and structured concurrency
Using SwiftNIO - Fundamentals
Learn the fundamental concepts of SwiftNIO, such as EventLoops and nonblocking I/O