diff --git a/Sources/MapboxDirections/Directions.swift b/Sources/MapboxDirections/Directions.swift index 6f0af0fb..771505ab 100644 --- a/Sources/MapboxDirections/Directions.swift +++ b/Sources/MapboxDirections/Directions.swift @@ -454,7 +454,11 @@ open class Directions: NSObject { } guard (disposition.code == nil && disposition.message == nil) || disposition.code == "Ok" else { - let apiError = DirectionsError(code: disposition.code, message: disposition.message, response: response, underlyingError: possibleError) + let apiError = DirectionsError(code: disposition.code, + message: disposition.message, + response: response, + underlyingError: possibleError, + refreshTTL: disposition.refreshTTL) DispatchQueue.main.async { completionHandler(self.credentials, .failure(apiError)) } diff --git a/Sources/MapboxDirections/DirectionsError.swift b/Sources/MapboxDirections/DirectionsError.swift index a4de44e3..136e0f5d 100644 --- a/Sources/MapboxDirections/DirectionsError.swift +++ b/Sources/MapboxDirections/DirectionsError.swift @@ -8,7 +8,11 @@ import FoundationNetworking */ public enum DirectionsError: LocalizedError { - public init(code: String?, message: String?, response: URLResponse?, underlyingError error: Error?) { + public init(code: String?, message: String?, response: URLResponse?, underlyingError error: Error?, refreshTTL: Int? = nil) { + guard refreshTTL == nil || refreshTTL != 0 else { + self = .refreshExpired + return + } if let response = response as? HTTPURLResponse { switch (response.statusCode, code ?? "") { case (200, "NoRoute"): @@ -105,6 +109,13 @@ public enum DirectionsError: LocalizedError { */ case rateLimited(rateLimitInterval: TimeInterval?, rateLimit: UInt?, resetTime: Date?) + /** + The current route may no longer be refreshed. + + Route's refresh TTL has expired and it is not possible to refresh it anymore. Try requesting a new one instead. + */ + case refreshExpired + /** Unknown error case. Look at associated values for more details. */ @@ -146,6 +157,8 @@ public enum DirectionsError: LocalizedError { #endif let formattedCount = NumberFormatter.localizedString(from: NSNumber(value: limit), number: .decimal) return "More than \(formattedCount) requests have been made with this access token within a period of \(formattedInterval)." + case .refreshExpired: + return "Current routes can no longer be refreshed due to their TTL expiration." case let .unknown(_, underlying: error, _, message): return message ?? (error as NSError?)?.userInfo[NSLocalizedFailureReasonErrorKey] as? String @@ -175,6 +188,8 @@ public enum DirectionsError: LocalizedError { } let formattedDate: String = DateFormatter.localizedString(from: rolloverTime, dateStyle: .long, timeStyle: .long) return "Wait until \(formattedDate) before retrying." + case .refreshExpired: + return "Try making a new route request." case let .unknown(_, underlying: error, _, _): return (error as NSError?)?.userInfo[NSLocalizedRecoverySuggestionErrorKey] as? String } @@ -190,7 +205,8 @@ extension DirectionsError: Equatable { (.tooManyCoordinates, .tooManyCoordinates), (.unableToLocate, .unableToLocate), (.profileNotFound, .profileNotFound), - (.requestTooLarge, .requestTooLarge): + (.requestTooLarge, .requestTooLarge), + (.refreshExpired, .refreshExpired): return true case let (.network(lhsError), .network(rhsError)): return lhsError == rhsError diff --git a/Sources/MapboxDirections/ResponseDisposition.swift b/Sources/MapboxDirections/ResponseDisposition.swift index 409b3c80..6df424a0 100644 --- a/Sources/MapboxDirections/ResponseDisposition.swift +++ b/Sources/MapboxDirections/ResponseDisposition.swift @@ -5,8 +5,9 @@ struct ResponseDisposition: Decodable { var code: String? var message: String? var error: String? + var refreshTTL: Int? - private enum CodingKeys: CodingKey { - case code, message, error + private enum CodingKeys: String, CodingKey { + case code, message, error, refreshTTL = "refresh_ttl" } } diff --git a/Sources/MapboxDirections/RouteResponse.swift b/Sources/MapboxDirections/RouteResponse.swift index a2f3341b..8a95e2ba 100644 --- a/Sources/MapboxDirections/RouteResponse.swift +++ b/Sources/MapboxDirections/RouteResponse.swift @@ -61,6 +61,22 @@ public struct RouteResponse: ForeignMemberContainer { */ public var created: Date = Date() + /* + A time period during which the routes from this ``RouteResponse`` are eligable for refreshing. + + `nil` value indicates that route refreshing is not available for related routes. + */ + public let refreshTTL: TimeInterval? + + /* + A deadline after which the routes from this ``RouteResponse`` are eligable for refreshing. + + `nil` value indicates that route refreshing is not available for related routes. + */ + public var refreshInvalidationDate: Date? { + refreshTTL.map { created.addingTimeInterval($0) } + } + /** Managed array of `RoadClasses` restrictions specified to `RouteOptions.roadClassesToAvoid` which were violated during route calculation. @@ -78,15 +94,17 @@ extension RouteResponse: Codable { case identifier = "uuid" case routes case waypoints + case refreshTTL = "refresh_ttl" } - public init(httpResponse: HTTPURLResponse?, identifier: String? = nil, routes: [Route]? = nil, waypoints: [Waypoint]? = nil, options: ResponseOptions, credentials: Credentials) { + public init(httpResponse: HTTPURLResponse?, identifier: String? = nil, routes: [Route]? = nil, waypoints: [Waypoint]? = nil, options: ResponseOptions, credentials: Credentials, refreshTTL: TimeInterval? = nil) { self.httpResponse = httpResponse self.identifier = identifier self.options = options self.routes = routes self.waypoints = waypoints self.credentials = credentials + self.refreshTTL = refreshTTL updateRoadClassExclusionViolations() } @@ -187,6 +205,8 @@ extension RouteResponse: Codable { routes = nil } + self.refreshTTL = try container.decodeIfPresent(TimeInterval.self, forKey: .refreshTTL) + updateRoadClassExclusionViolations() try decodeForeignMembers(notKeyedBy: CodingKeys.self, with: decoder) @@ -197,6 +217,7 @@ extension RouteResponse: Codable { try container.encodeIfPresent(identifier, forKey: .identifier) try container.encodeIfPresent(routes, forKey: .routes) try container.encodeIfPresent(waypoints, forKey: .waypoints) + try container.encodeIfPresent(refreshTTL, forKey: .refreshTTL) try encodeForeignMembers(notKeyedBy: CodingKeys.self, to: encoder) } diff --git a/Tests/MapboxDirectionsTests/DirectionsErrorTests.swift b/Tests/MapboxDirectionsTests/DirectionsErrorTests.swift index 9c764f0b..08d9444a 100644 --- a/Tests/MapboxDirectionsTests/DirectionsErrorTests.swift +++ b/Tests/MapboxDirectionsTests/DirectionsErrorTests.swift @@ -18,6 +18,7 @@ class DirectionsErrorTests: XCTestCase { XCTAssertEqual(DirectionsError.invalidInput(message: nil).failureReason, nil) XCTAssertEqual(DirectionsError.invalidInput(message: "").failureReason, "") XCTAssertNotNil(DirectionsError.rateLimited(rateLimitInterval: nil, rateLimit: nil, resetTime: nil).failureReason) + XCTAssertNotNil(DirectionsError.refreshExpired.failureReason) XCTAssertNotNil(DirectionsError.unknown(response: nil, underlying: nil, code: nil, message: nil).failureReason) } @@ -33,6 +34,7 @@ class DirectionsErrorTests: XCTestCase { XCTAssertNil(DirectionsError.invalidInput(message: nil).recoverySuggestion) XCTAssertNil(DirectionsError.rateLimited(rateLimitInterval: nil, rateLimit: nil, resetTime: nil).recoverySuggestion) XCTAssertNotNil(DirectionsError.rateLimited(rateLimitInterval: nil, rateLimit: nil, resetTime: .distantFuture).recoverySuggestion) + XCTAssertNotNil(DirectionsError.refreshExpired.recoverySuggestion) XCTAssertNil(DirectionsError.unknown(response: nil, underlying: nil, code: nil, message: nil).recoverySuggestion) let underlyingError = NSError(domain: "com.example", code: 02134, userInfo: [NSLocalizedRecoverySuggestionErrorKey: "Try harder"]) @@ -63,6 +65,7 @@ class DirectionsErrorTests: XCTestCase { XCTAssertNotEqual(DirectionsError.rateLimited(rateLimitInterval: nil, rateLimit: nil, resetTime: nil), .rateLimited(rateLimitInterval: nil, rateLimit: nil, resetTime: .distantPast)) + XCTAssertEqual(DirectionsError.refreshExpired, .refreshExpired) enum BogusError: Error { case bug } @@ -85,6 +88,16 @@ class DirectionsErrorTests: XCTestCase { XCTAssertNotEqual(DirectionsError.noData, .requestTooLarge) XCTAssertNotEqual(DirectionsError.noData, .invalidInput(message: nil)) XCTAssertNotEqual(DirectionsError.noData, .rateLimited(rateLimitInterval: nil, rateLimit: nil, resetTime: nil)) + XCTAssertNotEqual(DirectionsError.noData, .refreshExpired) XCTAssertNotEqual(DirectionsError.noData, .unknown(response: nil, underlying: nil, code: nil, message: "")) } + + func testRefreshExpiredError() { + XCTAssertNotEqual(DirectionsError(code: nil, message: nil, response: nil, underlyingError: nil, refreshTTL: 5), + .refreshExpired) + XCTAssertNotEqual(DirectionsError(code: nil, message: nil, response: nil, underlyingError: nil, refreshTTL: nil), + .refreshExpired) + XCTAssertEqual(DirectionsError(code: nil, message: nil, response: nil, underlyingError: nil, refreshTTL: 0), + .refreshExpired) + } } diff --git a/Tests/MapboxDirectionsTests/RouteResponseTests.swift b/Tests/MapboxDirectionsTests/RouteResponseTests.swift index a2d509d5..51384316 100644 --- a/Tests/MapboxDirectionsTests/RouteResponseTests.swift +++ b/Tests/MapboxDirectionsTests/RouteResponseTests.swift @@ -24,7 +24,8 @@ class RouteResponseTests: XCTestCase { let routeResponse = RouteResponse(httpResponse: nil, waypoints: waypoints, options: responseOptions, - credentials: BogusCredentials) + credentials: BogusCredentials, + refreshTTL: 12.34) do { let encodedRouteResponse = try JSONEncoder().encode(routeResponse) @@ -58,6 +59,7 @@ class RouteResponseTests: XCTestCase { XCTAssertEqual(originWaypoint.separatesLegs, false, "originWaypoint should have separatesLegs set to false.") XCTAssertEqual(decodedSeparatesLegs, true, "First and last decoded waypoints should have separatesLegs value set to true.") XCTAssertEqual(originWaypoint.allowsArrivingOnOppositeSide, decodedAllowsArrivingOnOppositeSide, "Original and decoded allowsArrivingOnOppositeSides should be equal.") + XCTAssertEqual(routeResponse.refreshTTL, decodedRouteResponse.refreshTTL, "Original and decoded refreshTTL should be equal.") } catch { XCTFail("Failed with error: \(error)") } @@ -96,4 +98,18 @@ class RouteResponseTests: XCTestCase { XCTAssertEqual(unwrappedResponse.exclusionViolations(routeIndex: 0, legIndex: 0, stepIndex: 9, intersectionIndex: 0).first!.roadClasses, .ferry) XCTAssertEqual(unwrappedResponse.exclusionViolations(routeIndex: 0, legIndex: 0, stepIndex: 24, intersectionIndex: 7).first!.roadClasses, .toll) } + + func testRefreshTTL() { + let options = RouteOptions(coordinates: []) + let refreshTTL: TimeInterval = 60 + let response = RouteResponse(httpResponse: nil, + options: .route(options), + credentials: BogusCredentials, + refreshTTL: refreshTTL) + + XCTAssertNotNil(response.refreshTTL) + XCTAssertNotNil(response.refreshInvalidationDate) + + XCTAssertEqual(response.refreshInvalidationDate, response.created.addingTimeInterval(refreshTTL)) + } } diff --git a/swift-package-baseline/breakage-allowlist-path.txt b/swift-package-baseline/breakage-allowlist-path.txt index 8b137891..8e118669 100644 --- a/swift-package-baseline/breakage-allowlist-path.txt +++ b/swift-package-baseline/breakage-allowlist-path.txt @@ -1 +1,3 @@ - +API breakage: enumelement DirectionsError.refreshExpired has been added as a new enum case +API breakage: constructor DirectionsError.init(code:message:response:underlyingError:) has been removed +API breakage: constructor RouteResponse.init(httpResponse:identifier:routes:waypoints:options:credentials:) has been removed