Skip to content

Commit

Permalink
Support the Result type
Browse files Browse the repository at this point in the history
  • Loading branch information
bilaalrashid committed Jul 9, 2024
1 parent f1cfce0 commit 5b4bdc5
Show file tree
Hide file tree
Showing 4 changed files with 206 additions and 35 deletions.
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,44 @@ let bbcNews = BbcNews()
let bbcNews = BbcNews(modelIdentifier: "iPhone15,2", systemName: "iOS", systemVersion: "17.0")

// Get results from the home page
let results = try await bbcNews.fetchIndexDiscoveryPage(postcode: "W1A")
let results = try await bbcNews.fetchIndexDiscoveryPageThrowing(postcode: "W1A")

// Get results from a topic page
let results = try await bbcNews.fetchTopicDiscoveryPage(for: "c50znx8v8y4t")
let results = try await bbcNews.fetchTopicDiscoveryPageThrowing(for: "c50znx8v8y4t")

// Parse story promo from a set of discovery results and fetch the full contents of that story
for item in results.data.items {
if case .storyPromo(let storyPromo) = item {
let url = storyPromo.link.destinations[0].url

// Get the full contents of the story
let story = try await bbcNews.fetch(url: url)
let story = try await bbcNews.fetchThrowing(url: url)
}
}
```

#### Swift 5 Result type

All methods have equivalents to support both the Swift 5 Result type and traditional try-catch.
You can use the try-catch equivalents by appending `Throwing` to the end of a method name.

try-catch:
```swift
let results = try await bbcNews.fetchIndexDiscoveryPageThrowing(postcode: "W1A")
print(results) // [...]
```

Result type:
```swift
let result = await bbcNews.fetchIndexDiscoveryPage(postcode: "W1A")
switch result {
case .success(let results):
print(results) // [...]
case .failure(let error):
print(error)
}
```

### Utilities

```swift
Expand Down
159 changes: 127 additions & 32 deletions Sources/BbcNews/BbcNews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,26 @@ public struct BbcNews {
self.releaseTrack = nil
}

/// Fetches the main discovery page of the BBC News app i.e. the page shown in the Home tab, throwing an error if one occurs.
///
/// - Parameter postcode: The first part of the user's UK postcode e.g. W1A.
/// - Returns: The index discovery page.
public func fetchIndexDiscoveryPageThrowing(postcode: String? = nil) async throws -> FDResult {
let result = await self.fetchIndexDiscoveryPage(postcode: postcode)

switch result {
case .success(let response):
return response
case .failure(let error):
throw error
}
}

/// Fetches the main discovery page of the BBC News app i.e. the page shown in the Home tab.
///
/// - Parameter postcode: The first part of the user's UK postcode e.g. W1A.
/// - Returns: The index discovery page.
public func fetchIndexDiscoveryPage(postcode: String? = nil) async throws -> FDResult {
public func fetchIndexDiscoveryPage(postcode: String? = nil) async -> Result<FDResult, NetworkError> {
var components = URLComponents()
components.scheme = "https"
components.host = BbcNews.hostname
Expand All @@ -181,32 +196,68 @@ public struct BbcNews {
}

if let url = components.url {
return try await self.fetch(url: url)
return await self.fetch(url: url)
}

throw NetworkError.noUrl
return .failure(NetworkError.noUrl)
}

/// Fetches the pages for multiple topics, throwing an error if one occurs.
///
/// - Parameter topicIds: The topic IDs to fetch.
/// - Returns: The fetched topic pages.
public func fetchTopicDiscoveryPagesThrowing(for topicIds: [String]) async throws -> [FDResult] {
let result = await self.fetchTopicDiscoveryPages(for: topicIds)

switch result {
case .success(let response):
return response
case .failure(let error):
throw error
}
}

/// Fetches the pages for multiple topics.
///
/// - Parameter topicIds: The topic IDs to fetch.
/// - Returns: The fetched topic pages.
public func fetchTopicDiscoveryPages(for topicIds: [String]) async throws -> [FDResult] {
public func fetchTopicDiscoveryPages(for topicIds: [String]) async -> Result<[FDResult], NetworkError> {
var results = [FDResult]()

for topicId in topicIds {
let result = try await self.fetchTopicDiscoveryPage(for: topicId)
results.append(result)
let result = await self.fetchTopicDiscoveryPage(for: topicId)

switch result {
case .success(let result):
results.append(result)
case .failure(let error):
return .failure(error)
}
}

return results
return .success(results)
}

/// Fetches the page for a specified topic, throwing an error if one occurs.
///
/// - Parameter topicId: The topic ID to fetch.
/// - Returns: The fetched topic page.
public func fetchTopicDiscoveryPageThrowing(for topicId: String) async throws -> FDResult {
let result = await self.fetchTopicDiscoveryPage(for: topicId)

switch result {
case .success(let response):
return response
case .failure(let error):
throw error
}
}

/// Fetches the page for a specified topic.
///
/// - Parameter topicId: The topic ID to fetch.
/// - Returns: The fetched topic page.
public func fetchTopicDiscoveryPage(for topicId: String) async throws -> FDResult {
public func fetchTopicDiscoveryPage(for topicId: String) async -> Result<FDResult, NetworkError> {
var components = URLComponents()
components.scheme = "https"
components.host = BbcNews.hostname
Expand All @@ -223,55 +274,99 @@ public struct BbcNews {
}

if let url = components.url {
return try await self.fetch(url: url)
return await self.fetch(url: url)
}

throw NetworkError.noUrl
return .failure(NetworkError.noUrl)
}

/// Fetches a page from the BBC News API, throwing an error if one occurs.
///
/// - Parameter urlString: The absolute URL to fetch.
/// - Returns: The fetched page.
public func fetchThrowing(urlString: String) async throws -> FDResult {
let result = await self.fetch(urlString: urlString)

switch result {
case .success(let response):
return response
case .failure(let error):
throw error
}
}

/// Fetches a page from the BBC News API.
///
/// - Parameter urlString: The absolute URL to fetch.
/// - Returns: The fetched page.
public func fetch(urlString: String) async throws -> FDResult {
public func fetch(urlString: String) async -> Result<FDResult, NetworkError> {
guard let url = URL(string: urlString) else {
throw NetworkError.invalidUrl(url: urlString)
return .failure(NetworkError.invalidUrl(url: urlString))
}

return try await self.fetch(url: url)
return await self.fetch(url: url)
}

/// Fetches a page from the BBC News API, throwing an error if one occurs.
///
/// - Parameter url: The URL to fetch.
/// - Returns: The fetched page.
public func fetchThrowing(url: URL) async throws -> FDResult {
let result = await self.fetch(url: url)

switch result {
case .success(let response):
return response
case .failure(let error):
throw error
}
}

/// Fetches a page from the BBC News API.
///
/// - Parameter url: The URL to fetch.
/// - Returns: The fetched page.
public func fetch(url: URL) async throws -> FDResult {
public func fetch(url: URL) async -> Result<FDResult, NetworkError> {
#if canImport(OSLog)
Logger.network.debug("Requesting: \(url, privacy: .public)")
#endif

let (data, response) = try await self.session.data(from: url)
do {
let (data, response) = try await self.session.data(from: url)

guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse(url: url)
}
guard let httpResponse = response as? HTTPURLResponse else {
return .failure(NetworkError.invalidResponse(url: url))
}

let success = 200..<300
guard success.contains(httpResponse.statusCode) else {
throw NetworkError.unsuccessfulStatusCode(url: url, code: httpResponse.statusCode)
}
let success = 200..<300
guard success.contains(httpResponse.statusCode) else {
return .failure(NetworkError.unsuccessfulStatusCode(url: url, code: httpResponse.statusCode))
}

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .millisecondsSince1970
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .millisecondsSince1970

// First check if the URL resolves to a new destination, otherwise attempt to decode as a normal response.
do {
let newDestination = try decoder.decode(FDResolverResult.self, from: data)
throw NetworkError.newDestination(url: url, link: newDestination.data.resolvedLink)
} catch let error as NetworkError {
throw error
} catch {
return try decoder.decode(FDResult.self, from: data)
// First check if the URL resolves to a new destination, otherwise attempt to decode as a normal response.
let newDestinationResult = decoder.decodeWithoutThrowing(FDResolverResult.self, from: data)

if case .success(let newDestination) = newDestinationResult {
return .failure(NetworkError.newDestination(url: url, link: newDestination.data.resolvedLink))
}

let decodingResult = decoder.decodeWithoutThrowing(FDResult.self, from: data)

switch decodingResult {
case .success(let decoded):
return .success(decoded)
case .failure(let error):
if let error = error as? DecodingError {
return .failure(NetworkError.undecodableResponse(url: url, type: FDResult.self, underlyingError: error))
}

return .failure(NetworkError.generic(underlyingError: error))
}
} catch let error {
return .failure(NetworkError.generic(underlyingError: error))
}
}
}
24 changes: 24 additions & 0 deletions Sources/BbcNews/Utils/JSONDecoder+DecodeWithoutThrowing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// JSONDecoder+DecodeWithoutThrowing.swift
// BbcNews
//
// Created by Bilaal Rashid on 09/07/2024.
//

import Foundation

extension JSONDecoder {
/// Returns a value of the type you specify, decoded from a JSON object.
///
/// - Parameters:
/// - type: The type of the value to decode from the supplied JSON object.
/// - data: The JSON object to decode.
/// - Returns: A value of the specified type, if the decoder can parse the data.
func decodeWithoutThrowing<T>(_ type: T.Type, from data: Data) -> Result<T, Error> where T: Decodable {
do {
return .success(try self.decode(type, from: data))
} catch let error {
return .failure(error)
}
}
}
30 changes: 30 additions & 0 deletions Sources/BbcNews/Utils/NetworkError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ public enum NetworkError: Error, LocalizedError, CustomStringConvertible {
/// The server returned a new destination to resolve the requested response from.
case newDestination(url: URL, link: FDLink)

/// A response was returned that was unable to be decoded into a type.
case undecodableResponse(url: URL, type: Decodable.Type, underlyingError: DecodingError)

/// A generic error encountered when performing a networking operation.
case generic(underlyingError: Error)

/// A human-readable description describing the error.
public var description: String {
switch self {
Expand All @@ -39,6 +45,26 @@ public enum NetworkError: Error, LocalizedError, CustomStringConvertible {
return "\(url) returned an unsuccessful HTTP response code (\(code))"
case .newDestination(let url, let link):
return "\(url) provides a new destination to resolve (\(link))"
case .undecodableResponse(let url, let type, let underlyingError):
// Manually rewrite the error description to be more useful when unwrapped
var description = ""

switch underlyingError {
case .dataCorrupted(let context):
description = "Data corrupted when decoding: \(context)"
case .keyNotFound(let key, let context):
description = "Key \(key) not found for coding path '\(context.codingPath)': \(context.debugDescription)"
case .valueNotFound(let value, let context):
description = "Value \(value) not found for coding path '\(context.codingPath)': \(context.debugDescription)"
case .typeMismatch(let type, let context):
description = "Type \(type) mismatch for coding path '\(context.codingPath)': \(context.debugDescription)"
@unknown default:
description = "Decoding error: \(underlyingError.localizedDescription)"
}

return "\(url) returned a response that was not decodable to \(type): \(description)"
case .generic(let underlyingError):
return underlyingError.localizedDescription.description
}
}

Expand All @@ -55,6 +81,10 @@ public enum NetworkError: Error, LocalizedError, CustomStringConvertible {
return NSLocalizedString(self.description, comment: "Unsuccessful HTTP request")
case .newDestination:
return NSLocalizedString(self.description, comment: "New destination provided")
case .undecodableResponse:
return NSLocalizedString(self.description, comment: "Unable to decode response")
case .generic(let underlyingError):
return underlyingError.localizedDescription
}
}
}

0 comments on commit 5b4bdc5

Please sign in to comment.