Sources/SPTDataLoaderSwift/Request.swift (239 lines of code) (raw):
// Copyright 2015-2023 Spotify AB
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
/// A wrapper for initiating URL requests and handling responses with optional validation.
///
/// The `Request` type provides a set of functions that make it easy to chain a series of
/// response handlers and validators.
///
/// The request is executed upon attachment of the first response handler. If a handler is
/// attached after the response has been received, it will be immediately invoked with the
/// existing value.
public final class Request {
private let request: SPTDataLoaderRequest
private let executionHandler: (Request) -> SPTDataLoaderCancellationToken?
init(request: SPTDataLoaderRequest, executionHandler: @escaping (Request) -> SPTDataLoaderCancellationToken?) {
self.request = request
self.executionHandler = executionHandler
}
private enum State {
case initialized
case failed(error: Error)
case executed(token: SPTDataLoaderCancellationToken)
case completed(response: SPTDataLoaderResponse)
case completedWithError(response: SPTDataLoaderResponse, error: Error)
case cancelled
}
enum ResponseState {
case failed(error: Error)
case completed(response: SPTDataLoaderResponse)
case completedWithError(response: SPTDataLoaderResponse, error: Error)
var response: SPTDataLoaderResponse? {
switch self {
case .completed(let response), .completedWithError(let response, _):
return response
case .failed:
return nil
}
}
var result: Result<SPTDataLoaderResponse, Error> {
switch self {
case .completed(let response):
return .success(response)
case .completedWithError(_, let error), .failed(let error):
return .failure(error)
}
}
}
private let accessLock = AccessLock()
private var state: State = .initialized
private var responseHandlers: [(ResponseState) -> Void] = []
private var responseValidators: [(SPTDataLoaderResponse) throws -> Void] = []
func addResponseValidator(_ responseValidator: @escaping (SPTDataLoaderResponse) throws -> Void) {
accessLock.sync {
switch state {
case .initialized, .executed:
responseValidators.append(responseValidator)
default:
break
}
}
}
func addResponseHandler(_ responseHandler: @escaping (ResponseState) -> Void) {
var responseState: ResponseState?
accessLock.sync {
switch state {
case .initialized:
if let token = executionHandler(self) {
responseHandlers.append(responseHandler)
state = .executed(token: token)
} else {
responseState = .failed(error: RequestError.executionFailed)
state = .failed(error: RequestError.executionFailed)
}
case .executed:
responseHandlers.append(responseHandler)
case .failed(let error):
responseState = .failed(error: error)
case .completed(let response):
responseState = .completed(response: response)
case .completedWithError(let response, let error):
responseState = .completedWithError(response: response, error: error)
case .cancelled:
break
}
}
responseState.map { responseState in responseHandler(responseState) }
}
func processResponse(_ response: SPTDataLoaderResponse) {
var handlers: [(ResponseState) -> Void] = []
var responseState: ResponseState = .completed(response: response)
accessLock.sync {
guard case .executed = state else {
return
}
// Respect any previous error except the one `SPTDataLoaderResponse` sets
// based on status code, which should instead be enforced using a validator.
if let error = response.error, (error as NSError).domain != SPTDataLoaderResponseErrorDomain {
responseState = .completedWithError(response: response, error: error)
state = .completedWithError(response: response, error: error)
} else {
do {
try responseValidators.forEach { validator in try validator(response) }
state = .completed(response: response)
} catch let validationError {
responseState = .completedWithError(response: response, error: validationError)
state = .completedWithError(response: response, error: validationError)
}
}
handlers = responseHandlers
responseHandlers.removeAll()
responseValidators.removeAll()
}
handlers.forEach { handler in handler(responseState) }
}
}
// MARK: -
public extension Request {
/// Modifies properties of the underlying request.
/// - Parameter requestModifier: The modification closure used to mutate the request.
@discardableResult
func modify(requestModifier: (SPTDataLoaderRequest) throws -> Void) rethrows -> Self {
try accessLock.sync {
guard case .initialized = state else {
return
}
try requestModifier(request)
}
return self
}
}
// MARK: -
public extension Request {
/// Cancels the current request.
func cancel() {
accessLock.sync {
if case .executed(let token) = state {
token.cancel()
}
state = .cancelled
}
}
/// A Boolean value indicating whether the request has been cancelled.
var isCancelled: Bool {
return accessLock.sync {
guard case .cancelled = state else {
return false
}
return true
}
}
}
// MARK: -
public extension Request {
/// Adds a validator used to verify a response.
/// - Parameter responseValidator: The validation closure invoked upon response.
@discardableResult
func validate(responseValidator: @escaping (SPTDataLoaderResponse) throws -> Void) -> Self {
addResponseValidator(responseValidator)
return self
}
/// Adds a validator used to verify a response status code.
/// - Parameter acceptedStatusCodes: The accepted status codes.
@discardableResult
func validateStatusCode<StatusCodes: Sequence>(
in acceptedStatusCodes: StatusCodes
) -> Self where StatusCodes.Iterator.Element == Int {
addResponseValidator { response in
guard acceptedStatusCodes.contains(response.statusCode.rawValue) else {
throw ResponseValidationError.badStatusCode(code: response.statusCode.rawValue)
}
}
return self
}
/// Adds a validator used to verify a response status code.
/// - Parameter acceptedStatusCodes: The accepted status codes.
@discardableResult
func validateStatusCode(in acceptedStatusCodes: Int...) -> Self {
return validateStatusCode(in: acceptedStatusCodes)
}
/// Adds a validator used to verify a response status code is in the successful 2xx range.
/// - Parameter acceptedStatusCodes: The accepted status codes.
@discardableResult
func validateStatusCode() -> Self {
return validateStatusCode(in: 200...299)
}
}
// MARK: -
public extension Request {
/// Adds a handler that receives a `Response`.
/// - Parameter completionHandler: The callback closure invoked upon completion.
@discardableResult
func response(completionHandler: @escaping (Response<Void, Error>) -> Void) -> Self {
addResponseHandler { [request] state in
let response = Response(
request: request,
response: state.response,
result: state.result.map { _ in () }
)
completionHandler(response)
}
return self
}
/// Adds a handler that receives a `Response` containing data.
/// - Parameter completionHandler: The callback closure invoked upon completion.
@discardableResult
func responseData(completionHandler: @escaping (Response<Data, Error>) -> Void) -> Self {
addResponseHandler { [request] state in
let response = Response(
request: request,
response: state.response,
result: state.result.flatMap { response in
Result { try DataResponseSerializer().serialize(response: response) }
}
)
completionHandler(response)
}
return self
}
/// Adds a handler that receives a `Response` containing a decoded value.
/// - Parameter decoder: The `ResponseDecoder` used to decode the value from response data.
/// - Parameter completionHandler: The callback closure invoked upon completion.
@discardableResult
func responseDecodable<Value: Decodable>(
type: Value.Type = Value.self,
decoder: ResponseDecoder = JSONDecoder(),
completionHandler: @escaping (Response<Value, Error>) -> Void
) -> Self {
addResponseHandler { [request] state in
let response = Response(
request: request,
response: state.response,
result: state.result.flatMap { response in
Result { try DecodableResponseSerializer<Value>(decoder: decoder).serialize(response: response) }
}
)
completionHandler(response)
}
return self
}
/// Adds a handler that receives a `Response` containing a JSON value.
/// - Parameter options: The `JSONSerialization.ReadingOptions` to use for reading the JSON response data.
/// - Parameter completionHandler: The callback closure invoked upon completion.
@discardableResult
func responseJSON(
options: JSONSerialization.ReadingOptions = [],
completionHandler: @escaping (Response<Any, Error>) -> Void
) -> Self {
addResponseHandler { [request] state in
let response = Response(
request: request,
response: state.response,
result: state.result.flatMap { response in
Result { try JSONResponseSerializer(options: options).serialize(response: response) }
}
)
completionHandler(response)
}
return self
}
/// Adds a handler that receives a `Response` containing a serialized value.
/// - Parameter serializer: The `ResponseSerializer` to use for serializing the response value.
/// - Parameter completionHandler: The callback closure invoked upon completion.
@discardableResult
func responseSerializable<Serializer: ResponseSerializer>(
serializer: Serializer,
completionHandler: @escaping (Response<Serializer.Output, Error>) -> Void
) -> Self {
addResponseHandler { [request] state in
let response = Response(
request: request,
response: state.response,
result: state.result.flatMap { response in
Result { try serializer.serialize(response: response) }
}
)
completionHandler(response)
}
return self
}
}