· 11 min read

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.swift

Then, 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:30

This code prepares a template for creating a client channel. Let’s break it down:

  1. Create a ClientBootstrap using the NIOSingletons.posixEventLoopGroup as the event loop group. This is a shared event loop group that can be reused across multiple components of our application.

  2. NIO Channels can have options set on them. Here, the SO_REUSEADDR option is set to 1 to allow the reuse of local addresses.

  3. Then, provide an initializer that is used to configure the pipeline of newly created channels.

  4. 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:18

Finally, add an enum to represent the state of processing the response:

enum HTTPPartialResponse {
    case none
    case receiving(HTTPResponse, ByteBuffer)
}
building-swiftnio-clients-01.swift:11

The 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:24

Let’s break it down:

  1. Use the httpClientBootstrap to create a new client channel. This returns an EventLoopFuture containing a regular NIO Channel. By using flatMapThrowing(_:) to transform the result of this future, it’s possible to convert the EventLoopFuture into a NIOAsyncChannel.

  2. In order to use structured concurrency, it’s necessary to wrap the Channel in an NIOAsyncChannel. The inbound and outbound types must be Sendable, and need to be configured to match the pipeline’s input and output. This is based on the handlers added in the bootstrap’s channelInitializer.

  3. The NIOAsyncChannel is configured to receive HTTPClientResponsePart objects. This is the type that the HTTP client will receive from the server.

  4. The NIOAsyncChannel is configured to send SendableHTTPClientRequestPart objects. This is the type that the HTTP client will send to the server.

  5. The get() method is called to await for the result of the EventLoopFuture.

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:75

This is a structured concurrency block that sends the request:

  1. 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.

  2. 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:93

This sets up a state variable to keep track of the response parts received. It then processes the response parts as they come in:

  1. 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 a return statement ends the function.

  2. The part is matched against the HTTPClientResponsePart enum. If the part is a head, it’s stored in the partialResponse variable. If the part is a body, it’s appended to the buffer in the partialResponse variable. If the part is an end, the partialResponse is returned.

  3. 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:137

This 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

· 10 min read

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.

· 5 min read

Working with UDP in SwiftNIO

Create UDP servers and clients using SwiftNIO and structured concurrency

· 13 min read

AsyncHTTPClient by example

This article offers practical examples to introduce the Swift AsyncHTTPClient library.

· 11 min read

Using SwiftNIO - Channels

Create a TCP server using SwiftNIO and structured concurrency

· 5 min read

Using SwiftNIO - Fundamentals

Learn the fundamental concepts of SwiftNIO, such as EventLoops and nonblocking I/O

Server-Side Swift Conference logo

ServerSide.swift

The talk recordings are live!
See you next year!

Watch the Videos