Alamofire + Combine

While prototype Alamofire Combine support has existed on a branch since WWDC '19, with the release of Alamofire 5 we're discussing what the real, shipped API should look like. Unlike NotificationCenter.Publisher and URLSession.DataTaskPublisher, things aren't quite as simple for Alamofire, due to the nature of our chained request APIs. For example, you can customize a Request's behavior quite extensively just using the chained APIs:

AF.request(...)
    .authenticate(with: ...)
    .downloadProgress(...)
    .uploadProgress(...)
    .validate()
    .cacheResponse(using: ...)
    .redirect(using: ...)
    .cURLDescription(...)
    .response(...)
    .responseString(...)
    .responseDecodable(...)

While extreme, this example illustrates the amount of API that can be called on a Request, so it's important to find a good experience with Combine to handle these calls. Of course, it could be as simple as RequestPublisher -> Map -> ResponsePublisher, but that's not a great experience, and using map to add the chained APIs doesn't prevent adding response handlers, which creates opportunities for misuse and bugs. We could also expose the chainable APIs we want on our Publisher types, but as you can see that would create a rather large API surfaces that just piped to the produced Request. If Swift could do something like this automatically it might be feasible, but I wonder if there's a better option.

In the end, it seems like we'll need a corresponding Publisher for each Request type we have, which each expose the chainable API of that request type as instance methods which connect to the underlying request, and then a corresponding Response Publisher which knows how to add the response methods to the Request, and would be responsible for actually starting the request.

Any additional ideas?

5 Likes

Given the complexity of Alamofire's inline and often closure-based APIs, I've decided my first exploration into official Combine support will exist as a Publisher exposed through a publishedResponse (or similar) API on the various Request types. So something like this:

AF.request(...).publishedResponse(of: SomeType.self)

This seems the only reasonable way to handle the flexibility of Alamofire's API without duplicating the API surface on a Publisher. Changes in Alamofire 5 should allow this usage to be completely lazy (barring the previous addition of a response handler outside the stream) and integrate with the rest of Combine's APIs.

Other ideas are welcome, as development is only getting started.

3 Likes

If you're interested in following along, my work will be up on the feature/combine branch. I've gotten responsePublisher working for DataRequest. It seems like this approach will be pretty simple, though it would be nice if I could get the publishers to be generic to the request type, otherwise I'll have to create one for each request type.

1 Like

I've found an approach that will let us publish a DataRequest on demand using any existing ResponseSerializer. It works with existing Alamofire usage, just replacing the response* method with responsePublisher.

AF.request(...).responsePublisher(of: DecodableType.self)

This produces a DataResponsePublisher<DecodableResponseSerializer<DecodableType>> that will produce a single DataResponse<DecodableType, AFError value down the stream.

There is also convenience API for sending the Result value, or a traditional stream of `<Success, Failure>.

AF.request(...).responsePublisher(of: DecodableType.self).value()

This produces an AnyPublisher<DecodableType, AFError> stream. It would be great if I didn't have to erase to AnyPublisher, but I can't find a way to expose these additional publishers directly with a simple type.

A possible downside of this approach is that it lets users keep using response* handlers, so there may be confusion around where parsing is occurring. It's not that big of an issue since users can already have multiple response handlers, and this method should work just fine in that case.

Next, I'll be adding convenience methods for the other serializers and then move on to download handling.

2 Likes

Hello and thank you @Jon_Shier. I like what I see. The convenience publisher value(), the one that hides the HTTP machinery but exposes errors as regular publishers do, has a very straightforward implementation. To me this is a good sign: the root DataResponsePublisher is quite versatile, and does not hide anything. :+1:


The naming of DataResponsePublisher<DecodableResponseSerializer<DecodableType>>

Combine creates interesting design opportunities for library authors, because we're able to expose specific operators on our publishers. The Alamofire DataResponsePublisher.value() is one of such specific operators. It's not a general Publisher operator. It's an operator for DataResponsePublisher only.

This practice was quite uncommon with RxSwift, for example, where api boundaries almost always expose a type-erased Observable. The proper way to implement specific operators with RxSwift, available for only one observable, is obscure, when not frankly discouraged.

But with Combine, designing specific operators is easy. No wonder we use this opportunity.

Specific operators are pretty cool, because they allow late configuration of a publisher, close to the subscription site, when the application really knows which "flavor" of the publisher it needs.

For this nice scenario to work well, though, we need that the user does not erase publishers on the way with eraseToAnyPublisher(). For example, an emergent Alamofire + Combine good practice could say that one should not erase DataResponsePublisher until as late as possible, in order to make it possible to use a specific operator when needed (close to the subscription site).

I think we api designers must take great care of keeping the names of our "configurable publishers", the ones that expose specific operators, as clean, nice and tidy as possible, in order to prevent the erasing-reflex users may have in front of an ugly-looking publisher type.

On this front, and if you agree with my reasoning, I may suggest to look for a way to improve DataResponsePublisher<DecodableResponseSerializer<DecodableType>>:

extension MyNetworkLayer {
    // The desire to type-erase may be strong, here
    func myResourcePublisher() -> DataResponsePublisher<DecodableResponseSerializer<Foo>> {
        ...
    }
}

I'll try to alias or something, but we have to be generic to to the response serializer given how the protocols work (it's a PAT). Any suggestions to simplify the types?

If the DecodableResponseSerializer is of no use for the DataResponsePublisher client, then DataResponsePublisher<Value> ought be enough and could be the goal, do you agree?

This require a slightly more complex inner implementation, in order to erase DecodableResponseSerializer, but not too much.

The goal is to get rid of the Serializer type, and of the serializer property, which is only used in this line.

This can be done with a closure, created in DataResponsePublisher.init(), and passed to DataResponsePublisher.Inner which can use it when it needs. Gross sample code:

struct DataResponsePublisher<Value>: Publisher {
    public typealias Output = DataResponse<Value, AFError>
    public typealias Failure = Never
    
    private let response: (_ completion: @escaping (DataResponse<Value, AFError>) -> ()) -> Request

    init<Serializer>(
        _ request: DataRequest, 
        queue: DispatchQueue, 
        serializer: Serializer) 
    where
        Serializer: ResponseSerializer,
        Serializer.SerializedObject = Value 
    {
        // Fully erase Serializer
        response = { request.response(queue: queue, responseSerializer: serializer, completion: $0) }
    }
}

This is the technique I use in GRDBCombine: GRDBCombine/ValueObservation+Combine.swift at 800bbbd89b50b77a77b2dd2a9400972c40da93c9 · groue/GRDBCombine · GitHub

Interesting approach, I'll give it a try.

1 Like

@gwendal.roue That's worked great, thanks a lot! It's now DataResponsePublisher<T>. I'll flesh out convenience methods for the rest of the serializers next.

I'm very happy to give a hand :-) Alamofire is my go-to networking library, you know, if only for the validation and the per-request dispatching of the URLSession delegate callbacks :+1:

I've put up a draft PR that includes all of the work on DataResponsePublisher but doesn't include publishers for DownloadRequest or DataStreamRequest. Of note, since the last update, the publisher methods on DataRequest have been renamed to publish, publishData, publishString, and publishDecodable(type:). An overloaded API just wasn't viable, but other suggestions for names are welcome. publishUnserialized was also added in case anyone wants the raw DataResponse<Data?, AFError>.

Documentation has also been added, so feel free to give it a read to see if it's useful enough. It's not intended to be a exhaustive look at Combine, just a summary of how Alamofire's Combine integration can be used.

DownloadResponsePublisher and DataStreamPublisher have both been added with preliminary API and test. I'll be fleshing them out more soon but wanted to let people know so that testing can begin. They're also very similar, so I'll be investigating how I could unify them into a single Publisher, if it isn't too complex. Any suggestions there would be welcome as well.

I tried combining the DataResponsePublisher and DownloadResponsePublisher using generics but couldn't find a way to make it work due to generics issues I've described previous. If anyone wants to give it try, feel free.

The branch has been updated with hopefully final definitions of the publishers. Just have a bit more convenience functionality, tests, and documentation to go.

One final question: how interested would people be in Progress publishers for upload and download? The API would be a bit awkward since there's not really a common pattern for returning multiple publishers, but we could make it work. Depending on interest I could add it now or add it later, since it seems easy to add at any time.

The PR is now in its final state if anyone wants to give it a try. We should be able to release it as part of Alamofire 5.2 soon.

2 Likes

Can Alamofire run on Linux?

No; Linux Foundation still lacks important functionality. There is an open PR which isn’t up to date but which is usual even on Linux. The conditional checks are just to extensive to ship.

1 Like