enums with Associated Values + generics + protocol with associatedtype
Asked Answered
M

1

5

I'm trying to make my API Service as generic as possible:

API Service Class

class ApiService {
  func send<T>(request: RestRequest) -> T {
    return request.parse()
  }
}

So that the compiler can infer the response type from the request categories .auth and .data:

let apiService = ApiService()

// String
let stringResponse = apiService.send(request: .auth(.signupWithFacebook(token: "9999999999999")))
// Int
let intResponse = apiService.send(request: .data(.content(id: "123")))

I tried to come up with a solution using generics and a protocol with associated type to handle the parsing in a clean way. However I'm having trouble associating the request cases with the different response types in a way that it's simple and type-safe:

protocol Parseable {
  associatedtype ResponseType
  func parse() -> ResponseType
}

Endpoints

enum RestRequest {

  case auth(_ request: AuthRequest)
  case data(_ request: DataRequest)

  // COMPILER ERROR HERE: Generic parameter 'T' is not used in function signature
  func parse<T: Parseable>() -> T.ResponseType {
    switch self {
    case .auth(let request): return (request as T).parse()
    case .data(let request): return (request as T).parse()
    }
  }

  enum AuthRequest: Parseable {
    case login(email: String, password: String)
    case signupWithFacebook(token: String)

    typealias ResponseType = String
    func parse() -> ResponseType {
        return "String!!!"
    }
  }
  enum DataRequest: Parseable {
    case content(id: String?)
    case package(id: String?)

    typealias ResponseType = Int
    func parse() -> ResponseType {
        return 16
    }
  }
}

How is T not used in function signature even though I'm using T.ResponseType as function return?

Is there a better still clean way to achieve this?

Mllly answered 14/3, 2019 at 16:26 Comment(0)
B
12

I'm trying to make my API Service as generic as possible:

First, and most importantly, this should never be a goal. Instead, you should start with use cases, and make sure that your API Service meets them. "As generic as possible" doesn't mean anything, and only will get you into type nightmares as you add "generic features" to things, which is not the same thing as being generally useful to many use cases. What callers require this flexibility? Start with the callers, and the protocols will follow.

func send<T>(request: RestRequest) -> T

Next, this is a very bad signature. You don't want type inference on return types. It's a nightmare to manage. Instead, the standard way to do this in Swift is:

func send<ResultType>(request: RestRequest, returning: ResultType.type) -> ResultType

By passing the expected result type as a parameter, you get rid of the type inference headaches. The headache looks like this:

let stringResponse = apiService.send(request: .auth(.signupWithFacebook(token: "9999999999999")))

How is the compiler to know that stringResponse is supposed to be a String? Nothing here says "String." So instead you have to do this:

let stringResponse: String = ...

And that's very ugly Swift. Instead you probably want (but not really):

let stringResponse = apiService.send(request: .auth(.signupWithFacebook(token: "9999999999999")),
                                     returning: String.self)

"But not really" because there's no way to implement this well. How can send know how to translate "whatever response I get" into "an unknown type that happens to be called String?" What would that do?

protocol Parseable {
  associatedtype ResponseType
  func parse() -> ResponseType
}

This PAT (protocol w/ associated type) doesn't really make sense. It says something is parseable if an instance of it can return a ResponseType. But that would be a parser not "something that can be parsed."

For something that can be parsed, you want an init that can take some input and create itself. The best for that is Codable usually, but you could make your own, such as:

protocol Parseable {
    init(parsing data: Data) throws
}

But I'd lean towards Codable, or just passing the parsing function (see below).

enum RestRequest {}

This is probably a bad use of enum, especially if what you're looking for is general usability. Every new RestRequest will require updating parse, which is the wrong place for this kind of code. Enums make it easy to add new "things that all instances implement" but hard to add "new kinds of instances." Structs (+ protocols) are the opposite. They make it easy to add new kinds of the protocol, but hard to add new protocol requirements. Requests, especially in a generic system, are the latter kind. You want to add new requests all the time. Enums make that hard.

Is there a better still clean way to achieve this?

It depends on what "this" is. What does your calling code look like? Where does your current system create code duplication that you want to eliminate? What are your use cases? There is no such thing as "as generic as possible." There are just systems that can adapt to use cases along axes they were prepared to handle. Different configuration axes lead to different kinds of polymorphism, and have different trade-offs.

What do you want your calling code to look like?

Just to provide an example of what this might look like, though, it'd be something like this.

final class ApiService {
    let urlSession: URLSession
    init(urlSession: URLSession = .shared) {
        self.urlSession = urlSession
    }

    func send<Response: Decodable>(request: URLRequest,
                                   returning: Response.Type,
                                   completion: @escaping (Response?) -> Void) {
        urlSession.dataTask(with: request) { (data, response, error) in
            if let error = error {
                // Log your error
                completion(nil)
                return
            }

            if let data = data {
                let result = try? JSONDecoder().decode(Response.self, from: data)
                // Probably check for nil here and log an error
                completion(result)
                return
            }
            // Probably log an error
            completion(nil)
        }
    }
}

This is very generic, and can apply to numerous kinds of use cases (though this particular form is very primitive). You may find it doesn't apply to all your use cases, so you'd begin to expand on it. For example, maybe you don't like using Decodable here. You want a more generic parser. That's fine, make the parser configurable:

func send<Response>(request: URLRequest,
                    returning: Response.Type,
                    parsedBy: @escaping (Data) -> Response?,
                    completion: @escaping (Response?) -> Void) {

    urlSession.dataTask(with: request) { (data, response, error) in
        if let error = error {
            // Log your error
            completion(nil)
            return
        }

        if let data = data {
            let result = parsedBy(data)
            // Probably check for nil here and log an error
            completion(result)
            return
        }
        // Probably log an error
        completion(nil)
    }
}

Maybe you want both approaches. That's fine, build one on top of the other:

func send<Response: Decodable>(request: URLRequest,
                               returning: Response.Type,
                               completion: @escaping (Response?) -> Void) {
    send(request: request,
         returning: returning,
         parsedBy: { try? JSONDecoder().decode(Response.self, from: $0) },
         completion: completion)
}

If you're looking for even more on this topic, you may be interested in "Beyond Crusty" which includes a worked-out example of tying together parsers of the kind you're discussing. It's a bit dated, and Swift protocols are more powerful now, but the basic message is unchanged and the foundation of things like parsedBy in this example.

Boehm answered 14/3, 2019 at 17:48 Comment(2)
I understand your recommendations, there are some restrictions though (bad decisions from the past and a very big legacy codebase) that don't let me re-write the whole API in a better cleaner swiftly way (at least not right now). Trust me, I wouldn't take this "generic as possible" path myself either. Thank you for your detailed answer though! Much appreciated!Mllly
Been a long time since I have come across an answer this thorough and well written, nice job Rob!Lucilucia

© 2022 - 2024 — McMap. All rights reserved.