Swift - Protocol can only be used as a generic constraint because it has Self or associated type requirements
Asked Answered
C

1

11

I'm working on an app which needs to query multiple APIs. I've come up with classes for each API provider (and in more extreme cases, a class for each specific API Endpoint). This is because each API query is expected to return a very strict type of response, so if an API can, for instance, return both user profiles and profile pictures, I only want a response to be specific to either of those.

I've implemented it roughly in the following manner:

protocol MicroserviceProvider {
    associatedtype Response
}

protocol ProfilePictureMicroserviceProvider: MicroserviceProvider {
    func getPicture(by email: String, _ completion: (Response) -> Void)
}

class SomeProfilePictureAPI: ProfilePictureMicroserviceProvider {
    struct Response {
        let error: Error?
        let picture: UIImage?
    }

    func getPicture(by email: String, _ completion: (Response) -> Void) {
        // some HTTP magic 
        // will eventually call completion(_:) with a Response object 
        // which either holds an error or a UIImage.
    }
}

Because I want to be able to Unit Test classes that will rely on this API, I need to be able to inject that profile picture dependency dynamically. By default it will use SomeProfilePictureAPI but when running tests I will be able to replace that with a MockProfilePictureAPI which will still adhere to ProfilePictureMicroserviceProvider.

And because I'm using associated types, I need to make classes that depend on ProfilePictureMicroserviceProvider generic.

At first, I naively did try to write my view controller like such

class SomeClass {
    var profilePicProvider: ProfilePictureMicroserviceProvider
}

But that just led the frustratingly famous 'Protocol ProfilePictureMicroserviceProvider can only be used as a generic constraint because it has Self or associated type requirements' compile-time error.

Now I've been reading up on the issue over the last couple days, trying to wrap my head around Protocols with Associated Types (PATS), and figured I'd take the route of generic classes like such:

class SomeClass<T: ProfilePictureMicroserviceProvider> {
    var profilePicProfider: T = SomeProfilePictureAPI() 
}

But even then I get the following error:

Cannot convert value of type 'SomeProfilePictureAPI' to specified type 'T'

Even though having T being constrained to the ProfilePictureMicroserviceProvider protocol, and having SomeProfilePictureAPI adhere to it...

Basically the main idea was to reach 2 objectives: enforce Microservice structure with mandatory Response type, and make each Microservice mock-able for unit tests of dependent classes.

I'm now stuck with choosing either one of the two as I can't seem to make it work. Any help telling me what I'm doing wrong would be most welcome.

I've also had a look at type-erasure. But this to me seems very whacky and quite an effort for something that looks wrong on many aspects.

So basically my question is two-fold: how can I enforce my Microservices to define their own Response type ? And how can I easily replace them by mock microservices in classes that depend on them ?

Copenhaver answered 20/6, 2018 at 9:59 Comment(4)
Some question here, have you found a workaround for this?Buchenwald
@TuanDo I did, and eventually wrote a Cocoapod / SPM out of it, check it out: github.com/MrSkwiggs/Netswift. I'll try to write up an answer based on thatCopenhaver
Thanks. Looking forward to your answerBuchenwald
@TuanDo Have posted an answer. Have a look and feel free to let me know if you need anything :)Copenhaver
C
2

You have to turn these requirements around;

Instead of injecting a MicroServiceProvider into each request, you should write a generic MicroService 'Connector' Protocol that should define what it expects from each request, and what each request expects it to return.

You can then write a TestConnector which conforms to this protocol, so that you have complete control over how your requests are handled. The best part is, your requests won't even need to be modified.

Consider the following example:

protocol Request {
    // What type data you expect to decode and return
    associatedtype Response

    // Turn all the data defined by your concrete type 
    // into a URLRequest that we can natively send out.
    func makeURLRequest() -> URLRequest

    // Once the URLRequest returns, decode its content
    // if it succeeds, you have your actual response object 
    func decode(incomingData: Data?) -> Response?
}

protocol Connector {
    // Take in any type conforming to Request, 
    // do whatever is needed to get back some potential data, 
    // and eventually call the handler with the expected response
    func perform<T: Request>(request: T, handler: @escaping (T.Response?) -> Void)
}

These are essentially the bare minimum requirements to setup such a framework. In real life, you'll want more requirements from your Request protocol (such as ways to define the URL, request headers, request body, etc).

The best part is, you can write default implementations for your protocols. That removes a lot of boilerplate code! So for an actual Connector, you could do this:

extension Connector {
    func perform<T: Request>(request: T, handler: @escaping (T.Response?) -> Void) {
        // Use a native URLSession
        let session = URLSession()

        // Get our URLRequest
        let urlRequest = request.makeURLRequest()

        // define how our URLRequest is handled
        let task = session.dataTask(with: urlRequest) { data, response, error in
            // Try to decode our expected response object from the request's data
            let responseObject = request.decode(incomingData: data)

            // send back our potential object to the caller's completion block
            handler(responseObject)
        }

        task.resume()
    }
}

Now, with that, all you need to do is implement your ProfilePictureRequest like this (with extra example class variables):

struct ProfilePictureRequest: Request {
    private let userID: String
    private let useAuthentication: Bool

    /// MARK: Conform to Request
    typealias Response = UIImage

    func makeURLRequest() -> URLRequest {
        // get the url from somewhere
        let url = YourEndpointProvider.profilePictureURL(byUserID: userID)

        // use that URL to instantiate a native URLRequest
        var urlRequest = URLRequest(url: url)

        // example use: Set the http method
        urlRequest.httpMethod = "GET"

        // example use: Modify headers
        if useAuthentication {
            urlRequest.setValue(someAuthenticationToken.rawValue, forHTTPHeaderField: "Authorization")
        }

        // Once the configuration is done, return the urlRequest
        return urlRequest
    }

    func decode(incomingData: Data?) -> Response? {
        // make sure we actually have some data
        guard let data = incomingData else { return nil }

        // use UIImage's native data initializer.
        return UIImage(data: data)
    }
}

If you then want to send a profile picture request out, all you then need to do is (you'll need a concrete type that conforms to Connector, but since the Connector protocol has default implementations, that concrete type is mostly empty in this example: struct GenericConnector: Connector {}):

// Create an instance of your request with the arguments you desire
let request = ProfilePictureRequest(userID: "JohnDoe", useAuthentication: false)

// perform your request with the desired Connector
GenericConnector().perform(request) { image in 
    guard let image = image else { return }

    // You have your image, you can now use that instance whichever way you'd like
    ProfilePictureViewController.current.update(with: image)
}

And finally, to set up your TestConnector, all you need to do is:

struct TestConnector: Connector {

    // define a convenience action for your tests
    enum Behavior {
        // The network call always fails
        case alwaysFail

        // The network call always succeeds with the given response
        case alwaysSucceed(Any)
    }

    // configure this before each request you want to test
    static var behavior: Behavior

    func perform<T: Request>(request: T, handler: @escaping (T.Response?) -> Void) {
        // since this is a test, you don't need to actually perform any network calls.
        // just check what should be done
        switch Self.behavior {
        case alwaysFail:
            handler(nil)

        case alwaysSucceed(let response):
            handler(response as! T)
        }
    }
}

With this, you can easily define Requests, how they should configure their URL actions and how they decode their own Response type, and you can easily write mocks for you connectors.

Of course, keep in mind that the examples given in this answer are quite limited in how they can be used. I would highly suggest you to take a look at this library I wrote. It extends this example in a much more structured way.

Copenhaver answered 9/2, 2020 at 15:16 Comment(1)
Thank you very much for your detailed explanation. It's very easy to understand. Keep up your good work (y).Buchenwald

© 2022 - 2024 — McMap. All rights reserved.