Perhaps before just mapping to the data, we should ensure that the server sent back status code 200. Let's extend URLResponse
with a method that validates the status code:
extension URLResponse {
func requireHTTPStatusCode(_ code: Int) throws {
guard
let response = self as? HTTPURLResponse,
response.statusCode == 200
else { throw URLError(.badServerResponse) }
}
}
And then let's extend Publisher
to use it:
extension Publisher where Output == URLSession.DataTaskPublisher.Output {
func justData(ifStatusCode code: Int) -> Publishers.TryMap<Self, Data> {
return self
.tryMap { data, response in
try response.requireHTTPStatusCode(code)
return data
}
}
}
Now let's mock the types you want to try decoding:
enum API {
struct ExpectedResponse: Decodable {
var value: Int
}
struct ExpectedError: Error, Decodable {
var reason: String
}
typealias APIResult = Result<API.ExpectedResponse, API.ExpectedError>
}
We could decode the Data
to these types by composing Combine operators, but because of the error handling, it gets complicated. We can instead do it by extending API.APIResult
with an initializer that handles all the decoding:
extension API.APIResult {
init(apiData: Data) throws {
let decoder = JSONDecoder()
do {
let response = try decoder.decode(API.ExpectedResponse.self, from: apiData)
self = .success(response)
} catch {
let error = try decoder.decode(API.ExpectedError.self, from: apiData)
self = .failure(error)
}
}
}
Then we can extend Publisher
with another extension that goes all the way from URLSession.DataTaskPublisher.Output
to API.APIResult
:
extension Publisher where Output == URLSession.DataTaskPublisher.Output {
func decodeAPIResult() -> AnyPublisher<API.APIResult, Error> {
return self
.justData(ifStatusCode: 200)
.tryMap(API.APIResult.init(apiData:))
.eraseToAnyPublisher()
}
}
Because you can apply this operator to any Publisher
of URLSession.DataTaskPublisher.Output
, you can test it without hitting a server. Examples:
Just((
data: #"{ "value": 123 }"#.data(using: .utf8)!,
response: HTTPURLResponse(url: URL(string: "https://localhost/")!, statusCode: 200, httpVersion: nil, headerFields: nil)! as URLResponse
))
.decodeAPIResult()
.sink(
receiveCompletion: { print("completion:", $0) },
receiveValue: { print("value:", $0) })
// value: success(__lldb_expr_45.API.ExpectedResponse(value: 123))
// completion: finished
Just((
data: #"{ "reason": "test failure" }"#.data(using: .utf8)!,
response: HTTPURLResponse(url: URL(string: "https://localhost/")!, statusCode: 200, httpVersion: nil, headerFields: nil)! as URLResponse
))
.decodeAPIResult()
.sink(
receiveCompletion: { print("completion:", $0) },
receiveValue: { print("value:", $0) })
// value: failure(__lldb_expr_45.API.ExpectedError(reason: "test failure"))
// completion: finished
Just((
data: #"file not found"#.data(using: .utf8)!,
response: HTTPURLResponse(url: URL(string: "https://localhost/")!, statusCode: 404, httpVersion: nil, headerFields: nil)! as URLResponse
))
.decodeAPIResult()
.sink(
receiveCompletion: { print("completion:", $0) },
receiveValue: { print("value:", $0) })
// completion: failure(Error Domain=NSURLErrorDomain Code=-1011 "(null)")
Just((
data: #"bogus data"#.data(using: .utf8)!,
response: HTTPURLResponse(url: URL(string: "https://localhost/")!, statusCode: 200, httpVersion: nil, headerFields: nil)! as URLResponse
))
.decodeAPIResult()
.sink(
receiveCompletion: { print("completion:", $0) },
receiveValue: { print("value:", $0) })
// completion: failure(Swift.DecodingError.dataCorrupted(Swift.DecodingError.Context(codingPath: [], debugDescription: "The given data was not valid JSON.", underlyingError: Optional(Error Domain=NSCocoaErrorDomain Code=3840 "Invalid value around character 0." UserInfo={NSDebugDescription=Invalid value around character 0.}))))