diff --git a/CHANGELOG.md b/CHANGELOG.md index f016903f6..25f6c8c55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,17 @@ All notable changes to this project will be documented in this file. Take a look #### Shared * The default `ZIPArchiveOpener` is now using ZIPFoundation instead of Minizip, with improved performances when reading ranges of `stored` ZIP entries. +* Improvements in the HTTP client: + * The `consume` closure of `HTTPClient.stream()` can now return an error to abort the HTTP request. + * `HTTPError` has been refactored for improved type safety and a clearer separation of connection errors versus HTTP errors. + * `DefaultHTTPClient` no longer automatically restarts a failed `HEAD` request as a `GET` to retrieve the response body. If you relied on this behavior, you can implement it using a custom `DefaultHTTPClientDelegate.httpClient(_:recoverRequest:fromError:)`. ### Fixed +#### Shared + +* Fixed a crash using `HTTPClient.download()` when the device storage is full. + #### OPDS * Fixed a data race in the OPDS 1 parser. diff --git a/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift b/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift index 1960a0e7d..7f65c15f2 100644 --- a/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift +++ b/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift @@ -175,7 +175,14 @@ public class GCDHTTPServer: HTTPServer, Loggable { log(.warning, "Resource not found for request \(request)") completion( HTTPServerRequest(url: url, href: nil), - HTTPServerResponse(error: .notFound), + HTTPServerResponse(error: .errorResponse(HTTPResponse( + request: HTTPRequest(url: url), + url: url, + status: .notFound, + headers: [:], + mediaType: nil, + body: nil + ))), nil ) } diff --git a/Sources/LCP/LCPError.swift b/Sources/LCP/LCPError.swift index 7a44bcacb..2d8e464ae 100644 --- a/Sources/LCP/LCPError.swift +++ b/Sources/LCP/LCPError.swift @@ -86,7 +86,7 @@ public enum RenewError: Error { // Incorrect renewal period, your publication could not be renewed. case invalidRenewalPeriod(maxRenewDate: Date?) // An unexpected error has occurred on the licensing server. - case unexpectedServerError + case unexpectedServerError(HTTPError) } /// Errors while returning a loan. @@ -96,7 +96,7 @@ public enum ReturnError: Error { // Your publication has already been returned before or is expired. case alreadyReturnedOrExpired // An unexpected error has occurred on the licensing server. - case unexpectedServerError + case unexpectedServerError(HTTPError) } /// Errors while parsing the License or Status JSON Documents. diff --git a/Sources/LCP/License/License.swift b/Sources/LCP/License/License.swift index cd5db587f..17478e21d 100644 --- a/Sources/LCP/License/License.swift +++ b/Sources/LCP/License/License.swift @@ -221,13 +221,18 @@ extension License: LCPLicense { return try await httpClient.fetch(HTTPRequest(url: url, method: .put)) .map { $0.body ?? Data() } .mapError { error -> RenewError in - switch error.kind { - case .badRequest: - return .renewFailed - case .forbidden: - return .invalidRenewalPeriod(maxRenewDate: self.maxRenewDate) + switch error { + case let .errorResponse(response): + switch response.status { + case .badRequest: + return .renewFailed + case .forbidden: + return .invalidRenewalPeriod(maxRenewDate: self.maxRenewDate) + default: + return .unexpectedServerError(error) + } default: - return .unexpectedServerError + return .unexpectedServerError(error) } } .get() @@ -260,13 +265,18 @@ extension License: LCPLicense { do { let data = try await httpClient.fetch(HTTPRequest(url: url, method: .put)) .mapError { error -> ReturnError in - switch error.kind { - case .badRequest: - return .returnFailed - case .forbidden: - return .alreadyReturnedOrExpired + switch error { + case let .errorResponse(response): + switch response.status { + case .badRequest: + return .returnFailed + case .forbidden: + return .alreadyReturnedOrExpired + default: + return .unexpectedServerError(error) + } default: - return .unexpectedServerError + return .unexpectedServerError(error) } } .map { $0.body ?? Data() } diff --git a/Sources/LCP/Services/DeviceService.swift b/Sources/LCP/Services/DeviceService.swift index b0eb5ddc1..94ce9df08 100644 --- a/Sources/LCP/Services/DeviceService.swift +++ b/Sources/LCP/Services/DeviceService.swift @@ -56,12 +56,7 @@ final class DeviceService { } let data = await httpClient.fetch(HTTPRequest(url: url, method: .post)) - .map { response -> Data? in - guard 100 ..< 400 ~= response.statusCode else { - return nil - } - return response.body - } + .map(\.body) try await repository.registerDevice(for: license.id) diff --git a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift index ded204b1b..7d3db33d4 100644 --- a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift @@ -182,7 +182,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { public func stream( request: any HTTPRequestConvertible, - consume: @escaping (Data, Double?) -> Void + consume: @escaping (Data, Double?) -> HTTPResult ) async -> HTTPResult { await request.httpRequest() .asyncFlatMap(willStartRequest) @@ -321,11 +321,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { typealias Continuation = CheckedContinuation, Never> typealias ReceiveResponse = (HTTPResponse) -> Void typealias ReceiveChallenge = (URLAuthenticationChallenge) async -> URLAuthenticationChallengeResponse - typealias Consume = (Data, Double?) -> Void - - enum TaskError: Error { - case byteRangesNotSupported(url: HTTPURL) - } + typealias Consume = (Data, Double?) -> HTTPResult private let request: HTTPRequest fileprivate let task: URLSessionTask @@ -339,13 +335,20 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { private enum State { /// Waiting to start the task. case initializing + /// Waiting for the HTTP response. case start(continuation: Continuation) - /// We received a success response, the data will be sent to `consume` progressively. + + /// We received a success response, the data will be sent to + /// `consume` progressively. case stream(continuation: Continuation, response: HTTPResponse, readBytes: Int64) - /// We received an error response, the data will be accumulated in `response.body` to make the final - /// `HTTPError`. The body is needed for example when the response is an OPDS Authentication Document. - case failure(continuation: Continuation, kind: HTTPError.Kind, cause: Error?, response: HTTPResponse?) + + /// We received an error response, the data will be accumulated in + /// `response.body` if the error is an `HTTPError.errorResponse`, as + /// it could be needed for example when the response is an OPDS + /// Authentication Document. + case failure(continuation: Continuation, error: HTTPError) + /// The request is terminated. case finished @@ -357,7 +360,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { return continuation case let .stream(continuation, _, _): return continuation - case let .failure(continuation, _, _, _): + case let .failure(continuation, _): return continuation } } @@ -394,14 +397,15 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { private func finish() { switch state { case let .start(continuation): - continuation.resume(returning: .failure(HTTPError(kind: .cancelled))) + continuation.resume(returning: .failure(.cancelled)) case let .stream(continuation, response, _): continuation.resume(returning: .success(response)) - case let .failure(continuation, kind, cause, response): - let error = HTTPError(kind: kind, cause: cause, response: response) - log(.error, "\(request.method) \(request.url) failed with: \(error.localizedDescription)") + case let .failure(continuation, error): + var errorDescription = "" + dump(error, to: &errorDescription) + log(.error, "\(request.method) \(request.url) failed with:\n\(errorDescription)") continuation.resume(returning: .failure(error)) case .initializing, .finished: @@ -427,34 +431,22 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { var response = HTTPResponse(request: request, response: urlResponse, url: url) - if let kind = HTTPError.Kind(statusCode: response.statusCode) { - state = .failure(continuation: continuation, kind: kind, cause: nil, response: response) - - // It was a HEAD request? We need to query the resource again to get the error body. The body is needed - // for example when the response is an OPDS Authentication Document. - if request.method == .head { - var modifiedRequest = request - modifiedRequest.method = .get - session.dataTask(with: modifiedRequest.urlRequest) { data, _, error in - response.body = data - self.state = .failure(continuation: continuation, kind: kind, cause: error, response: response) - completionHandler(.cancel) - }.resume() - return - } - - } else { - guard !request.hasHeader("Range") || response.acceptsByteRanges else { - log(.error, "Streaming ranges requires the remote HTTP server to support byte range requests: \(url)") - state = .failure(continuation: continuation, kind: .other, cause: TaskError.byteRangesNotSupported(url: url), response: response) - completionHandler(.cancel) - return - } + guard response.status.isSuccess else { + state = .failure(continuation: continuation, error: .errorResponse(response)) + completionHandler(.allow) + return + } - state = .stream(continuation: continuation, response: response, readBytes: 0) - receiveResponse(response) + guard !request.hasHeader("Range") || response.acceptsByteRanges else { + log(.error, "Streaming ranges requires the remote HTTP server to support byte range requests: \(url)") + state = .failure(continuation: continuation, error: .rangeNotSupported) + completionHandler(.cancel) + return } + state = .stream(continuation: continuation, response: response, readBytes: 0) + receiveResponse(response) + completionHandler(.allow) } @@ -469,14 +461,23 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { if let expectedBytes = response.contentLength { progress = Double(min(readBytes, expectedBytes)) / Double(expectedBytes) } - consume(data, progress) - state = .stream(continuation: continuation, response: response, readBytes: readBytes) - - case .failure(let continuation, let kind, let cause, var response): - var body = response?.body ?? Data() - body.append(data) - response?.body = body - state = .failure(continuation: continuation, kind: kind, cause: cause, response: response) + + switch consume(data, progress) { + case .success: + state = .stream(continuation: continuation, response: response, readBytes: readBytes) + case let .failure(error): + state = .failure(continuation: continuation, error: error) + } + + case .failure(let continuation, var error): + if case var .errorResponse(response) = error { + var body = response.body ?? Data() + body.append(data) + response.body = body + error = .errorResponse(response) + } + + state = .failure(continuation: continuation, error: error) } } @@ -485,7 +486,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { if case .failure = state { // No-op, we don't want to overwrite the failure state in this case. } else if let continuation = state.continuation { - state = .failure(continuation: continuation, kind: HTTPError.Kind(error: error), cause: error, response: nil) + state = .failure(continuation: continuation, error: HTTPError(error: error)) } else { state = .finished } @@ -511,6 +512,35 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { } } +private extension HTTPError { + /// Maps a native `URLError` to `HTTPError`. + init(error: Error) { + switch error { + case let error as URLError: + switch error.code { + case .httpTooManyRedirects, .redirectToNonExistentLocation: + self = .redirection(error) + case .secureConnectionFailed, .clientCertificateRejected, .clientCertificateRequired, .appTransportSecurityRequiresSecureConnection, .userAuthenticationRequired: + self = .security(error) + case .badServerResponse, .zeroByteResource, .cannotDecodeContentData, .cannotDecodeRawData, .dataLengthExceedsMaximum: + self = .malformedResponse(error) + case .notConnectedToInternet, .networkConnectionLost: + self = .offline(error) + case .cannotConnectToHost, .cannotFindHost: + self = .unreachable(error) + case .timedOut: + self = .timeout(error) + case .cancelled, .userCancelledAuthentication: + self = .cancelled + default: + self = .other(error) + } + default: + self = .other(error) + } + } +} + private extension HTTPRequest { var urlRequest: URLRequest { var request = URLRequest(url: url.url) @@ -545,7 +575,7 @@ private extension HTTPResponse { self.init( request: request, url: url, - statusCode: response.statusCode, + status: HTTPStatus(rawValue: response.statusCode), headers: headers, mediaType: response.mimeType.flatMap { MediaType($0) }, body: body diff --git a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift index aac78a944..031cb2025 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift @@ -17,10 +17,11 @@ public protocol HTTPClient: Loggable { /// - request: Request to the streamed resource. /// also access it in the completion block after consuming the data. /// - consume: Callback called for each chunk of data received. Callers - /// are responsible to accumulate the data if needed. + /// are responsible to accumulate the data if needed. Return an error + /// to abort the request. func stream( request: HTTPRequestConvertible, - consume: @escaping (_ chunk: Data, _ progress: Double?) -> Void + consume: @escaping (_ chunk: Data, _ progress: Double?) -> HTTPResult ) async -> HTTPResult } @@ -30,7 +31,10 @@ public extension HTTPClient { var data = Data() let response = await stream( request: request, - consume: { chunk, _ in data.append(chunk) } + consume: { chunk, _ in + data.append(chunk) + return .success(()) + } ) return response @@ -55,12 +59,12 @@ public extension HTTPClient { let body = response.body, let result = try decoder(response, body) else { - return .failure(HTTPError(kind: .malformedResponse)) + return .failure(.malformedResponse(nil)) } return .success(result) } catch { - return .failure(HTTPError(kind: .malformedResponse, cause: error)) + return .failure(.malformedResponse(error)) } } } @@ -106,18 +110,24 @@ public extension HTTPClient { try "".write(to: location.url, atomically: true, encoding: .utf8) fileHandle = try FileHandle(forWritingTo: location.url) } catch { - return .failure(HTTPError(kind: .fileSystem(.io(error)), cause: error)) + return .failure(.fileSystem(.io(error))) } let result = await stream( request: request, consume: { data, progression in - fileHandle.seekToEndOfFile() - fileHandle.write(data) + do { + try fileHandle.seekToEnd() + try fileHandle.write(contentsOf: data) + } catch { + return .failure(.fileSystem(.io(error))) + } if let progression = progression { onProgress(progression) } + + return .success(()) } ) @@ -199,6 +209,52 @@ public extension HTTPClient { } } +/// Status code of an HTTP response. +public struct HTTPStatus: Equatable, RawRepresentable, ExpressibleByIntegerLiteral { + public let rawValue: Int + + public init(rawValue: RawValue) { + self.rawValue = rawValue + } + + public init(integerLiteral value: IntegerLiteralType) { + rawValue = value + } + + /// Returns whether this represents a successful HTTP status. + public var isSuccess: Bool { + (200 ..< 400).contains(rawValue) + } + + /// (200) OK. + public static let ok = HTTPStatus(rawValue: 200) + + /// (206) This response code is used in response to a range request when the + /// client has requested a part or parts of a resource. + public static let partialContent = HTTPStatus(rawValue: 206) + + /// (400) The server cannot or will not process the request due to an + /// apparent client error. + public static let badRequest = HTTPStatus(rawValue: 400) + + /// (401) Authentication is required and has failed or has not yet been + /// provided. + public static let unauthorized = HTTPStatus(rawValue: 401) + + /// (403) The server refuses the action, probably because we don't have the + /// necessary permissions. + public static let forbidden = HTTPStatus(rawValue: 403) + + /// (404) The requested resource could not be found. + public static let notFound = HTTPStatus(rawValue: 404) + + /// (405) Method not allowed. + public static let methodNotAllowed = HTTPStatus(rawValue: 405) + + /// (500) Internal server error. + public static let internalServerError = HTTPStatus(rawValue: 500) +} + /// Represents a successful HTTP response received from a server. public struct HTTPResponse: Equatable { /// Request associated with the response. @@ -208,7 +264,10 @@ public struct HTTPResponse: Equatable { public let url: HTTPURL /// HTTP status code returned by the server. - public let statusCode: Int + public let status: HTTPStatus + + @available(*, unavailable, renamed: "status.rawValue") + public var statusCode: HTTPStatus { fatalError() } /// HTTP response headers, indexed by their name. public let headers: [String: String] @@ -219,10 +278,17 @@ public struct HTTPResponse: Equatable { /// Response body content, when available. public var body: Data? - public init(request: HTTPRequest, url: HTTPURL, statusCode: Int, headers: [String: String], mediaType: MediaType?, body: Data?) { + public init( + request: HTTPRequest, + url: HTTPURL, + status: HTTPStatus, + headers: [String: String], + mediaType: MediaType?, + body: Data? + ) { self.request = request self.url = url - self.statusCode = statusCode + self.status = status self.headers = headers self.mediaType = mediaType self.body = body diff --git a/Sources/Shared/Toolkit/HTTP/HTTPError.swift b/Sources/Shared/Toolkit/HTTP/HTTPError.swift index 72c6a0983..27a21e306 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPError.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPError.swift @@ -9,138 +9,69 @@ import Foundation public typealias HTTPResult = Result /// Represents an error occurring during an `HTTPClient` activity. -public struct HTTPError: Error, Loggable { - public enum Kind: Sendable { - /// The provided request was not valid. - case malformedRequest(url: String?) - /// The received response couldn't be decoded. - case malformedResponse - /// The client, server or gateways timed out. - case timeout - /// (400) The server cannot or will not process the request due to an apparent client error. - case badRequest - /// (401) Authentication is required and has failed or has not yet been provided. - case unauthorized - /// (403) The server refuses the action, probably because we don't have the necessary - /// permissions. - case forbidden - /// (404) The requested resource could not be found. - case notFound - /// (4xx) Other client errors - case clientError - /// (5xx) Server errors - case serverError - /// Cannot connect to the server, or the host cannot be resolved. - case serverUnreachable - /// The device is offline. - case offline - /// IO error while accessing the disk. - case fileSystem(FileSystemError) - /// The request was cancelled. - case cancelled - /// An error whose kind is not recognized. - case other - - public init?(statusCode: Int) { - switch statusCode { - case 200 ..< 400: - return nil - case 400: - self = .badRequest - case 401: - self = .unauthorized - case 403: - self = .forbidden - case 404: - self = .notFound - case 405 ... 498: - self = .clientError - case 499: - self = .cancelled - case 500 ... 599: - self = .serverError - default: - self = .malformedResponse - } - } +public enum HTTPError: Error, Loggable { + /// The provided request was not valid. + case malformedRequest(url: String?) - /// Creates a `Kind` from a native `URLError` or another error. - public init(error: Error) { - switch error { - case let error as HTTPError: - self = error.kind - case let error as URLError: - switch error.code { - case .badURL, .unsupportedURL: - self = .badRequest - case .httpTooManyRedirects, .redirectToNonExistentLocation, .badServerResponse, .secureConnectionFailed: - self = .serverError - case .zeroByteResource, .cannotDecodeContentData, .cannotDecodeRawData, .dataLengthExceedsMaximum: - self = .malformedResponse - case .notConnectedToInternet, .networkConnectionLost: - self = .offline - case .cannotConnectToHost, .cannotFindHost: - self = .serverUnreachable - case .timedOut: - self = .timeout - case .userAuthenticationRequired, .appTransportSecurityRequiresSecureConnection, .noPermissionsToReadFile: - self = .forbidden - case .fileDoesNotExist: - self = .notFound - case .cancelled, .userCancelledAuthentication: - self = .cancelled - default: - self = .other - } - default: - self = .other - } - } - } + /// The received response couldn't be decoded. + case malformedResponse(Error?) + + /// The server returned a response with an HTTP status error. + case errorResponse(HTTPResponse) + + /// The client, server or gateways timed out. + case timeout(Error?) + + /// Cannot connect to the server, or the host cannot be resolved. + case unreachable(Error?) + + /// Redirection failed. + case redirection(Error?) + + /// Cannot open a secure connection to the server, for example because of + /// a failed SSL handshake. + case security(Error?) + + /// A Range header was used in the request, but the server does not support + /// byte range requests. The request was cancelled. + case rangeNotSupported + + /// The device appears offline. + case offline(Error?) - /// Category of HTTP error. - public let kind: Kind + /// IO error while accessing the disk. + case fileSystem(FileSystemError) + + /// The request was cancelled. + case cancelled + + /// An other unknown error occurred. + case other(Error) + + @available(*, unavailable, message: "Use the HTTPError enum instead. HTTP status codes are available with HTTPError.errorResponse.") + public enum Kind: Sendable {} + + @available(*, unavailable, message: "Use the HTTPError enum instead. HTTP status codes are available with HTTPError.errorResponse.") + public var kind: Kind { fatalError() } /// Underlying error, if any. - public let cause: Error? + @available(*, unavailable, message: "Use the HTTPError enum instead. HTTP status codes are available with HTTPError.errorResponse.") + public var cause: Error? { fatalError() } /// Received HTTP response, if any. - public let response: HTTPResponse? + @available(*, unavailable, message: "Use the HTTPError.errorResponse enum case instead.") + public var response: HTTPResponse? { fatalError() } /// Response body parsed as a JSON problem details. - public let problemDetails: HTTPProblemDetails? - - public init(kind: Kind, cause: Error? = nil, response: HTTPResponse? = nil) { - self.kind = kind - self.cause = cause - self.response = response - - problemDetails = { - if let body = response?.body, response?.mediaType?.matches(.problemDetails) == true { - do { - return try HTTPProblemDetails(data: body) - } catch { - HTTPError.log(.error, "Failed to parse the JSON problem details: \(error)") - } - } - return nil - }() - } - - public init?(response: HTTPResponse) { - guard let kind = Kind(statusCode: response.statusCode) else { + public func problemDetails() throws -> HTTPProblemDetails? { + guard + case let .errorResponse(response) = self, + response.mediaType?.matches(.problemDetails) == true, + let body = response.body + else { return nil } - self.init(kind: kind, response: response) - } - - /// Creates an `HTTPError` from a native `URLError` or another error. - public init(error: Error) { - if let error = error as? HTTPError { - self = error - return - } - self.init(kind: Kind(error: error), cause: error) + return try HTTPProblemDetails(data: body) } } diff --git a/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift b/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift index 522befef9..0b78c1375 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift @@ -154,7 +154,7 @@ extension HTTPURL: HTTPRequestConvertible { extension URL: HTTPRequestConvertible { public func httpRequest() -> HTTPResult { guard let url = HTTPURL(url: self) else { - return .failure(HTTPError(kind: .malformedRequest(url: absoluteString))) + return .failure(.malformedRequest(url: absoluteString)) } return url.httpRequest() } @@ -163,7 +163,7 @@ extension URL: HTTPRequestConvertible { extension URLComponents: HTTPRequestConvertible { public func httpRequest() -> HTTPResult { guard let url = url else { - return .failure(HTTPError(kind: .malformedRequest(url: description))) + return .failure(.malformedRequest(url: description)) } return url.httpRequest() } @@ -172,7 +172,7 @@ extension URLComponents: HTTPRequestConvertible { extension String: HTTPRequestConvertible { public func httpRequest() -> HTTPResult { guard let url = HTTPURL(string: self) else { - return .failure(HTTPError(kind: .malformedRequest(url: self))) + return .failure(.malformedRequest(url: self)) } return url.httpRequest() } @@ -181,7 +181,7 @@ extension String: HTTPRequestConvertible { extension Link: HTTPRequestConvertible { public func httpRequest() -> HTTPResult { guard let url = url().httpURL else { - return .failure(HTTPError(kind: .malformedRequest(url: href))) + return .failure(.malformedRequest(url: href)) } return url.httpRequest() } diff --git a/Sources/Shared/Toolkit/HTTP/HTTPResource.swift b/Sources/Shared/Toolkit/HTTP/HTTPResource.swift index 931cbc3f6..31f6ad2ce 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPResource.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPResource.swift @@ -62,7 +62,10 @@ public actor HTTPResource: Resource { return await client.stream( request: request, - consume: { data, _ in consume(data) } + consume: { data, _ in + consume(data) + return .success(()) + } ) .map { _ in () } .mapError { .access(.http($0)) } diff --git a/Sources/Shared/Toolkit/HTTP/HTTPServer.swift b/Sources/Shared/Toolkit/HTTP/HTTPServer.swift index a296cc06c..50ec137b2 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPServer.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPServer.swift @@ -79,14 +79,23 @@ public extension HTTPServer { onFailure: HTTPRequestHandler.OnFailure? = nil ) throws -> HTTPURL { func onRequest(request: HTTPServerRequest) -> HTTPServerResponse { + lazy var notFound = HTTPError.errorResponse(HTTPResponse( + request: HTTPRequest(url: request.url), + url: request.url, + status: .notFound, + headers: [:], + mediaType: nil, + body: nil + )) + guard let href = request.href, let link = publication.linkWithHREF(href), let resource = publication.get(href) else { - onFailure?(request, .access(.http(HTTPError(kind: .notFound)))) + onFailure?(request, .access(.http(notFound))) - return HTTPServerResponse(error: .notFound) + return HTTPServerResponse(error: notFound) } return HTTPServerResponse( @@ -132,9 +141,9 @@ public struct HTTPServerResponse { self.mediaType = mediaType } - public init(error: HTTPError.Kind) { + public init(error: HTTPError) { self.init( - resource: FailureResource(error: .access(.http(HTTPError(kind: error)))), + resource: FailureResource(error: .access(.http(error))), mediaType: nil ) } diff --git a/TestApp/Sources/App/Readium.swift b/TestApp/Sources/App/Readium.swift index 2565a76a3..4d4c5df86 100644 --- a/TestApp/Sources/App/Readium.swift +++ b/TestApp/Sources/App/Readium.swift @@ -101,17 +101,22 @@ extension ReadiumShared.AccessError: UserErrorConvertible { extension ReadiumShared.HTTPError: UserErrorConvertible { func userError() -> UserError { UserError(cause: self) { - switch kind { - case .malformedRequest, .malformedResponse, .timeout, .badRequest, .clientError, .serverError, .serverUnreachable, .offline, .other: - return "error_network".localized - case .unauthorized, .forbidden: - return "error_forbidden".localized - case .notFound: - return "error_not_found".localized + switch self { + case let .errorResponse(response): + switch response.status { + case .notFound: + return "error_not_found".localized + case .unauthorized, .forbidden: + return "error_forbidden".localized + default: + return "error_network".localized + } case let .fileSystem(error): return error.userError().message case .cancelled: return "error_cancelled".localized + case .malformedRequest, .malformedResponse, .timeout, .unreachable, .redirection, .security, .rangeNotSupported, .offline, .other: + return "error_network".localized } } }