If you’re an iOS engineer, you’ve likely heard about Protobuf or gRPC at some point in the past 5 years, only to roll your eyes while your Go counterpart described how great Protobuf was. Why would I use a cumbersome command-line tool to generate code that requires me to add several third-party libraries, replace my networking stack, and bloat my app’s binary by several megabytes, only to use clunky APIs? As iOS engineers, all we want to do is focus on shipping features that customers will actually use.
While this may be painful to hear, your Go counterpart is actually on to something. It turns out there is a reason for their Protobuf madness — the promise of using statically-typed APIs to generate clients and eliminate the need to handwrite API glue just hasn’t been fully realized yet, especially on mobile.
grpc-swift
was supposed to eliminate handwritten networking boilerplate, but the generated code was too awkward for most of us to seriously consider. While companies such as Lyft have seen great success across their iOS apps using Protobuf with proprietary solutions, nobody has made those options available to the industry. Now, we at Buf want to bring this new reality to everyone.
Today, we’re announcing Connect-Swift: A simple, lightweight, idiomatic library that finally unlocks Protobuf’s long-promised productivity wins and will change your mind about Protobuf on iOS.
Codable
conformances. Connect-Swift generates idiomatic APIs that utilize the latest Swift features such as async/await and eliminates the need to worry about serialization.URLSession
. The library provides the option to swap this out, as well as the ability to register custom options, compression algorithms, and interceptors.If you want to go right to a hands-on demo, we created a getting started guide for building a Connect-enabled SwiftUI chat app in ~10 minutes.
As iOS engineers, we’re all familiar with the typical workflow of building a new feature, which looks something like this:
Codable
conformances to mirror the expected JSON response payloadsURLSession
or another wrapper, deserializes the response into the expected Codable
model, and returns the model to the callerAlthough this pattern is repetitive and time-consuming, we have started to accept it as a fact of our craft. Even so, we know that handwriting APIs is prone to human errors and inconsistencies between the client and server. Furthermore, validating this behavior without a real staging environment can only be as good as the mocks we define. Alas, we can do much better.
Our goal with Connect-Swift is to provide a significant productivity boost by eliminating the need to handwrite Swift code for interacting with servers, thus enabling engineers to simply focus on their application logic. This is done using a small, open-source runtime library paired with a code generator that consumes API schemas defined in Protobuf.
To illustrate, consider the following Protobuf schema definition:
package eliza.v1;
message SayRequest {
string sentence = 1;
}
message SayResponse {
string sentence = 1;
}
service ChatService {
rpc Say(SayRequest) returns (SayResponse) {}
}
This simple file defines a ChatService
containing a Say
RPC (Remote Procedure Call, essentially an HTTP endpoint) that accepts a SayRequest
and returns a SayResponse
, each containing a sentence
string field.
When this file is run through Connect-Swift’s Protobuf generator plugin, protoc-gen-connect-swift
, it yields something like this:
public protocol Eliza_V1_ChatServiceClientInterface {
func say(request: Eliza_V1_SayRequest, headers: Headers)
async -> ResponseMessage<Eliza_V1_SayResponse>
}
public final class Eliza_V1_ChatServiceClient: Eliza_V1_ChatServiceClientInterface {
private let client: ProtocolClientInterface
public init(client: ProtocolClientInterface) {
self.client = client
}
public func say(request: Eliza_V1_SayRequest, headers: Headers = [:])
async -> ResponseMessage<Eliza_V1_SayResponse>
{
return await self.client.unary(path: "connectrpc.eliza.v1.ElizaService/Say", request: request, headers: headers)
}
}
The request and response models referenced in the above code are generated alongside the Connect-Swift outputs using SwiftProtobuf (protoc-gen-swift
), Apple’s generator for Protobuf models.
This code can then be integrated into a SwiftUI view model with just a few lines:
final class MessagingViewModel: ObservableObject {
private let elizaClient: Eliza_V1_ChatServiceClientInterface
init(elizaClient: Eliza_V1_ChatServiceClientInterface) {
self.elizaClient = elizaClient
}
@Published private(set) var messages: [Message] {...}
func send(_ userSentence: String) async {
let request = Eliza_V1_SayRequest.with { $0.sentence = userSentence }
let response = await self.elizaClient.say(request: request, headers: [:])
if let elizaSentence = response.message?.sentence {
self.messages.append(Message(sentence: userSentence, author: .user))
self.messages.append(Message(sentence: elizaSentence, author: .eliza))
}
}
}
That’s it! We no longer need to manually define Swift response models, add Codable
conformances, type out URL(string: ...)
initializers, or even create protocol interfaces to wrap service classes - all of this is taken care of by Connect-Swift, and the underlying network transport is handled automatically.
The outputs shown above can also be customized to specify ACLs for the generated types (e.g., internal
versus public
) and whether to use Swift’s async/await APIs or traditional callback closures. A full list of available generator options is available in the documentation.
Writing Swift unit tests for APIs can be very tedious, as it requires manually introducing abstractions and boilerplate code. Testing networking code can be particularly painful since it involves serializing data and is prone to the same mistakes as handwriting response models.
Connect-Swift breaks these existing testing paradigms. With both a production client implementation and a protocol interface for it to conform to, we’re able to generate mock implementations that can be swapped out for testing:
open class Eliza_V1_ChatServiceClientMock: Eliza_V1_ChatServiceClientInterface {
public var mockAsyncSay = { (_: Eliza_V1_SayRequest) -> ResponseMessage<Eliza_V1_Response> in .init(message: .init()) }
open func say(request: Eliza_V1_SayRequest, headers: Headers = [:])
async -> ResponseMessage<Eliza_V1_SayResponse>
{
return self.mockAsyncSay(request)
}
}
Suddenly, testing becomes much easier:
func testMessagingViewModel() async {
let client = Eliza_V1_ChatServiceClientMock()
client.mockAsyncSay = { request in
XCTAssertEqual(request.sentence, "hello!")
return ResponseMessage(message: .with { $0.sentence = "hi, i'm eliza!" })
}
let viewModel = MessagingViewModel(elizaClient: client)
await viewModel.send("hello!")
XCTAssertEqual(viewModel.messages.count, 2)
XCTAssertEqual(viewModel.messages[0].message, "hello!")
XCTAssertEqual(viewModel.messages[0].author, .user)
XCTAssertEqual(viewModel.messages[1].message, "hi, i'm eliza!")
XCTAssertEqual(viewModel.messages[1].author, .eliza)
}
Using generated mocks saves a significant amount of time while also ensuring the mocks conform to the exact server spec. For instructions on how to generate mocks and for additional testing examples (including for streaming), see the testing docs.
Connect-Swift supports two protocols out of the box:
We recently released Connect-Web, which provides many of the same benefits to front-end engineers. For back-end services, Connect-Go is available. We firmly believe that full cross-platform collaboration is critical to the success of using Connect and Protobuf, and we will be launching Connect-Kotlin very soon. If you have an Android engineer counterpart who might be interested, let us know on the Buf Slack!
We’d love for you to try out Connect-Swift! We have several new resources to help you get started:
Connect-Swift is still in beta, so we’re all ears for feedback - you can reach us through the Buf Slack or by filing a GitHub issue and we’d be more than happy to chat!