Home > Blockchain >  Dependency Injection in Protocol/Extension
Dependency Injection in Protocol/Extension

Time:02-05

I am following along with this tutorial in order to create an async generic network layer. I got the network manager working correctly.

https://betterprogramming.pub/async-await-generic-network-layer-with-swift-5-5-2bdd51224ea9

As I try to implement more APIs, that I can use with the networking layer, some of the APIs require different tokens, different content in the body, or header etc, that I have to get at runtime.

In the snippet of code below from the tutorial, I get that we are building up the Movie endpoint based on .self, and then return the specific values we need. But the issue is, some of the data in this, for example, the access token, has to be hard coded here. I am looking for a way, that I can 'inject' the accessToken, and then it will be created with this new token. Again, the reason for this, is that in other APIs, the access token might not always be known.

protocol Endpoint {
    var scheme: String { get }
    var host: String { get }
    var version: String? { get }
    var path: String { get }
    var method: RequestMethod { get }
    var queryItems: [String: String]? { get }
    var header: [String: String]? { get }
    var body: [String: String]? { get }
}

extension MoviesEndpoint: Endpoint {
    var path: String {
        switch self {
        case .topRated:
            return "/3/movie/top_rated"
        case .movieDetail(let id):
            return "/3/movie/\(id)"
        }
    }

    var method: RequestMethod {
        switch self {
        case .topRated, .movieDetail:
            return .get
        }
    }

    var header: [String: String]? {
        // Access Token to use in Bearer header
        let accessToken = "insert your access token here -> https://www.themoviedb.org/settings/api"
        switch self {
        case .topRated, .movieDetail:
            return [
                "Authorization": "Bearer \(accessToken)",
                "Content-Type": "application/json;charset=utf-8"
            ]
        }
    }
    
    var body: [String: String]? {
        switch self {
        case .topRated, .movieDetail:
            return nil
        }
    }

For an example, I tried converting the var body to a function, so I could do

func body(_ bodyDict: [String, String]?) -> [String:String]? {
    switch self{
        case .test:
            return bodyDict
    }

The idea of above, was that I changed it to a function, so I could pass in a dict, and then return that dict in the api call, but that did not work. The MoviesEnpoint adheres to the extension Endpoint, which then gives the compiler error 'Protocol Methods must not have bodies'.

Is there a way to dependency inject runtime parameters into this Extension/Protocol method?

CodePudding user response:

Change the declaration of MoviesEndpoint so that it stores the access token:

struct MoviesEndpoint {
    var accessToken: String

    var detail: Detail

    enum Detail {
        case topRated
        case movieDetail(id: Int)
    }
}

You'll need to change all the switch self statements to switch detail.

However, I think the solution in the article (four protocols) is overwrought.

Instead of a pile of protocols, make one struct with a single function property:

struct MovieDatabaseClient {
    var getRaw: (MovieEndpoint) async throws -> (Data, URLResponse)
}

Extend it with a generic method to handle the response parsing and decoding:

extension MovieDatabaseClient {
    func get<T: Decodable>(
        endpoint: MovieEndpoint,
        as responseType: T.Type = T.self
    ) async throws -> T {
        let (data, response) = try await getRaw(endpoint)

        guard let response = response as? HTTPURLResponse else {
            throw URLError(.badServerResponse)
        }

        switch response.statusCode {
        case 200...299:
            break
        case 401:
            throw URLError(.userAuthenticationRequired)
        default:
            throw URLError(.badServerResponse)
        }

        return try JSONDecoder().decode(responseType, from: data)
    }
}

Provide a “live“ implementation that actually sends network requests:

extension MovieDatabaseClient {
    static func live(host: String, accessToken: String) -> Self {
        return .init { endpoint in
            let request = try liveURLRequest(
                host: host,
                accessToken: accessToken,
                endpoint: endpoint
            )
            return try await URLSession.shared.data(for: request)
        }
    }

    // Factored out in case you want to write unit tests for it:
    static func liveURLRequest(
        host: String,
        accessToken: String,
        endpoint: MovieEndpoint
    ) throws -> URLRequest {
        var components = URLComponents()
        components.scheme = "https"
        components.host = host
        components.path = endpoint.urlPath
        guard let url = components.url else { throw URLError(.badURL) }

        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        request.allHTTPHeaderFields = [
            "Authorization": "Bearer \(accessToken)",
            "Content-Type": "application/json;charset=utf-8",
        ]

        return request
    }
}

extension MovieEndpoint {
    var urlPath: String {
        switch self {
        case .topRated: return "/3/movie/top_rated"
        case .movieDetail(id: let id): return "/3/movie/\(id)"
        }
    }
}

To use it in your app:

// At app startup...

let myAccessToken = "loaded from UserDefaults or something"

let client = MovieDatabaseClient.live(
    host: "api.themoviedb.org",
    accessToken: myAccessToken
)

// Using it:

let topRated: TopRated = try await client.get(endpoint: .topRated)

let movieDetail: MovieDetail = try await client.get(endpoint: .movieDetail(id: 123))

For testing, you can create a mock client by providing a single closure that fakes the network request/response. Simple examples:

extension MovieDatabaseClient {
    static func mockSuccess<T: Encodable>(_ body: T) -> Self {
        return .init { _ in
            let data = try JSONEncoder().encode(body)
            let response = HTTPURLResponse(
                url: URL(string: "test")!,
                statusCode: 200,
                httpVersion: "HTTP/1.1",
                headerFields: nil
            )!
            return (data, response)
        }
    }

    static func mockFailure(_ error: Error) -> Self {
        return .init { _ in
            throw error
        }
    }
}

So a test can create a mock client that always responds with a TopRated response like this:

let mockTopRatedClient = MovieDatabaseClient.mockSuccess(TopRated(...))

If you want to learn more about this style of dependency management and mocking, Point-Free has a good (but subscription required) series of episodes: Designing Dependencies.

  • Related