Skip to content

Commit

Permalink
Support strongly typed URLs
Browse files Browse the repository at this point in the history
  • Loading branch information
bilaalrashid committed Jun 2, 2024
1 parent 078f05e commit 21fa1f7
Show file tree
Hide file tree
Showing 11 changed files with 122 additions and 49 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ for item in results.data.items {
import BbcNews

// Check if a URL is part of the BBC News API
BbcNews.isApiUrl(url: "https://bbc.co.uk") // false
BbcNews.isApiUrl(url: URL(string: "https://bbc.co.uk")!) // false

// Convert a webpage URL to a URL for the API
BbcNews.convertWebUrlToApi(url: "https://www.bbc.com/news/articles/c289n8m4j19o") // https://news-app.api.bbc.co.uk/fd/app-article-api?clientName=Chrysalis&clientVersion=pre-7&page=https://www.bbc.com/news/articles/c289n8m4j19o
BbcNews.convertWebUrlToApi(url: URL(string: "https://www.bbc.com/news/articles/c289n8m4j19o")!) // https://news-app.api.bbc.co.uk/fd/app-article-api?clientName=Chrysalis&clientVersion=pre-7&page=https://www.bbc.com/news/articles/c289n8m4j19o
```

## Development
Expand Down
51 changes: 36 additions & 15 deletions Sources/BbcNews/BbcNews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,24 +33,41 @@ public struct BbcNews {

// MARK: - Static methods

/// Checks if a URL is hosted on the BBC News API.
///
/// - Parameter urlString: The URL to check.
/// - Returns: If the URL is hosted on the BBC News API.
public static func isApiUrl(urlString: String) -> Bool {
guard let url = URL(string: urlString) else { return false }
return BbcNews.isApiUrl(url: url)
}

/// Checks if a URL is hosted on the BBC News API.
///
/// - Parameter url: The URL to check.
/// - Returns: If the URL is hosted on the BBC News API.
public static func isApiUrl(url: String) -> Bool {
guard let hostname = URL(string: url)?.host else { return false }
return hostname == self.hostname
public static func isApiUrl(url: URL) -> Bool {
return url.host == self.hostname
}

/// Converts a BBC News webpage URL to a native API URL that returns a JSON representation, if one exists.
///
/// - Parameter urlString: The web URL to convert.
/// - Returns: The native API URL, if successful.
public static func convertWebUrlToApi(urlString: String) -> String? {
guard let url = URL(string: urlString) else { return nil }
return BbcNews.convertWebUrlToApi(url: url)?.absoluteString
}

/// Converts a BBC News webpage URL to a native API URL that returns a JSON representation, if one exists.
///
/// - Parameter url: The web URL to convert.
/// - Returns: The native API URL, if successful.
public static func convertWebUrlToApi(url: String) -> String? {
public static func convertWebUrlToApi(url: URL) -> URL? {
let regex = #/https?:\/\/(www\.)?bbc\.co(m|\.uk)\/news\/(\w|\-|\/)+(\.app)?$/#

// swiftlint:disable:next unused_optional_binding
guard let _ = try? regex.firstMatch(in: url) else {
guard let _ = try? regex.firstMatch(in: url.absoluteString) else {
return nil
}

Expand All @@ -61,10 +78,10 @@ public struct BbcNews {
components.queryItems = [
URLQueryItem(name: "clientName", value: self.clientName),
URLQueryItem(name: "clientVersion", value: self.clientVersion),
URLQueryItem(name: "page", value: url)
URLQueryItem(name: "page", value: url.absoluteString)
]

return components.url?.absoluteString
return components.url
}

// MARK: - Instance properties
Expand Down Expand Up @@ -128,7 +145,7 @@ public struct BbcNews {
URLQueryItem(name: "type", value: "index")
]

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

Expand Down Expand Up @@ -165,7 +182,7 @@ public struct BbcNews {
URLQueryItem(name: "type", value: "topic")
]

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

Expand All @@ -176,17 +193,21 @@ public struct BbcNews {
///
/// - Parameter urlString: The absolute URL to fetch.
/// - Returns: The fetched page.
public func fetch(url urlString: String) async throws -> FDResult {
public func fetch(urlString: String) async throws -> FDResult {
guard let url = URL(string: urlString) else {
throw NetworkError.invalidUrl(url: urlString)
}

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

public func fetch(url: URL) async throws -> FDResult {
// swiftlint:disable indentation_width
#if canImport(OSLog)
Logger.network.debug("Requesting: \(urlString, privacy: .public)")
Logger.network.debug("Requesting: \(url, privacy: .public)")
#endif
// swiftlint:enable indentation_width

guard let url = URL(string: urlString) else {
throw NetworkError.invalidUrl(url: urlString)
}

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

guard let httpResponse = response as? HTTPURLResponse else {
Expand Down
6 changes: 3 additions & 3 deletions Sources/BbcNews/Models/Discovery/Link/FDLinkDestination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public struct FDLinkDestination: Codable, Equatable, Hashable {
public var sourceFormat: FDSourceFormat

/// The URL being linked to.
public var url: String
public var url: URL

/// The id of the destination being linked to.
///
Expand All @@ -30,7 +30,7 @@ public struct FDLinkDestination: Codable, Equatable, Hashable {
/// - url: The URL being linked to.
/// - id: The id of the destination being linked to.
/// - presentation: A description of how the destination should be presented.
public init(sourceFormat: FDSourceFormat, url: String, id: String, presentation: FDPresentation) {
public init(sourceFormat: FDSourceFormat, url: URL, id: String, presentation: FDPresentation) {
self.sourceFormat = sourceFormat
self.url = url
self.id = id
Expand All @@ -50,7 +50,7 @@ public struct FDLinkDestination: Codable, Equatable, Hashable {
// The ID will contain a leading slash, so we shouldn't include one ourself in the concatenation.
return URL(string: "https://bbc.co.uk" + self.id)
case .html:
return URL(string: self.url)
return self.url
default:
return nil
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/BbcNews/Models/Result/FDDataMetadata.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public struct FDDataMetadata: Codable, Equatable, Hashable {
public var lastUpdated: Date

/// The URL of a webpage displaying the same page returned by the API.
public var shareUrl: String?
public var shareUrl: URL?

/// Creates new page results metadata.
///
Expand All @@ -30,7 +30,7 @@ public struct FDDataMetadata: Codable, Equatable, Hashable {
/// - allowAdvertising: If the page allows advertising to be displayed.
/// - lastUpdated: The timestamp of the last time the page was updated.
/// - shareUrl: The URL of a webpage displaying the same returned by the API.
public init(name: String, allowAdvertising: Bool, lastUpdated: Date, shareUrl: String? = nil) {
public init(name: String, allowAdvertising: Bool, lastUpdated: Date, shareUrl: URL? = nil) {
self.name = name
self.allowAdvertising = allowAdvertising
self.lastUpdated = lastUpdated
Expand Down
4 changes: 2 additions & 2 deletions Sources/BbcNews/Models/Story/FDVideoPortraitStory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public struct FDVideoPortraitStory: Codable, Equatable, Hashable {
public var id: String

/// The URL of the poster image for the video story.
public var url: String
public var url: URL

/// The title of the video story.
public var text: String
Expand All @@ -35,7 +35,7 @@ public struct FDVideoPortraitStory: Codable, Equatable, Hashable {
/// - text: The title of the video story.
/// - subtext: The short description of the video story.
/// - media: The video media to display in the story.
public init(id: String, url: String, text: String, subtext: String, media: FDMedia) {
public init(id: String, url: URL, text: String, subtext: String, media: FDMedia) {
self.type = "VideoPortraitStory"
self.id = id
self.url = url
Expand Down
9 changes: 5 additions & 4 deletions Sources/BbcNews/Models/Story/Image/FDImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,26 +30,27 @@ public struct FDImage: Codable, Equatable, Hashable {
}

/// Returns the URL that provides the image in the largest possible width, or `nil` if no image URLs can be found.
public var largestImageUrl: String? {
public var largestImageUrl: URL? {
return self.largestImageUrl(upTo: .infinity)
}

/// Returns a URL that provides the image in the largest possible width within the specified maximum.
///
/// - Parameter maxWidth: The maximum width which the URL should return an image for.
/// - Returns: A URL that returns a version of the image, or `nil` if no image URLs can be found.
public func largestImageUrl(upTo maxWidth: Double) -> String? {
public func largestImageUrl(upTo maxWidth: Double) -> URL? {
if self.source.sizingMethod.type == .specificWidths {
let allowedWidths = self.source.sizingMethod.widths.filter { Double($0) <= maxWidth }

if let maxSize = allowedWidths.last {
return self.source.url.replacingOccurrences(of: self.source.sizingMethod.widthToken, with: String(maxSize))
let url = self.source.url.replacingOccurrences(of: self.source.sizingMethod.widthToken, with: String(maxSize))
return URL(string: url)
}

return nil
}

// If we don't recognise the sizing method, assume the URL is not templated.
return self.source.url
return URL(string: self.source.url)
}
}
2 changes: 2 additions & 0 deletions Sources/BbcNews/Models/Story/Image/FDImageSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import Foundation
/// The remote source for an image.
public struct FDImageSource: Codable, Equatable, Hashable {
/// The URL that the image is located.
///
/// Depending on the `sizingMethod` this could be a template string.
public var url: String

/// The definition of how to fetch the correct size of the image.
Expand Down
4 changes: 2 additions & 2 deletions Sources/BbcNews/Models/Story/Media/FDMediaMetaData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public struct FDMediaMetadata: Codable, Equatable, Hashable {
public var timestamp: Date

/// A URL that displays content associated to the media item.
public var associatedContentUrl: String?
public var associatedContentUrl: URL?

/// Is advertising allowed with the media item.
public var allowAdvertising: Bool
Expand All @@ -46,7 +46,7 @@ public struct FDMediaMetadata: Codable, Equatable, Hashable {
caption: String,
captionWithStyle: FDAttributedText? = nil,
timestamp: Date,
associatedContentUrl: String? = nil,
associatedContentUrl: URL? = nil,
allowAdvertising: Bool
) {
self.title = title
Expand Down
68 changes: 57 additions & 11 deletions Tests/BbcNewsTests/BbcNewsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,90 @@ import XCTest
@testable import BbcNews

final class BbcNewsTests: XCTestCase {
func testIsApiUrl() throws {
let invalidUrlResult = BbcNews.isApiUrl(url: "invalid")
func testIsApiUrlString() throws {
let invalidUrlResult = BbcNews.isApiUrl(urlString: "invalid")
XCTAssertFalse(invalidUrlResult, "An invalid URL does not match the API")

let differentHostResult = BbcNews.isApiUrl(url: "https://bilaal.co.uk")
let differentHostResult = BbcNews.isApiUrl(urlString: "https://bilaal.co.uk")
XCTAssertFalse(differentHostResult, "A URL with on a different hostname does not match the API")

let tlsResult = BbcNews.isApiUrl(url: "https://news-app.api.bbc.co.uk/fd/abl?clientName=Chrysalis&page=cwgdx0ppwnzt&type=topic")
let tlsResult = BbcNews.isApiUrl(urlString: "https://news-app.api.bbc.co.uk/fd/abl?clientName=Chrysalis&page=cwgdx0ppwnzt&type=topic")
XCTAssertTrue(tlsResult, "A URL on the correct hostname using HTTPS, correctly matches the API")

// swiftlint:disable:next force_https
let insecureResult = BbcNews.isApiUrl(url: "http://news-app.api.bbc.co.uk/fd/abl?clientName=Chrysalis&page=cwgdx0ppwnzt&type=topic")
let insecureResult = BbcNews.isApiUrl(urlString: "http://news-app.api.bbc.co.uk/fd/abl?clientName=Chrysalis&page=cwgdx0ppwnzt&type=topic")
XCTAssertTrue(insecureResult, "A URL on the correct hostname using HTTP, correctly matches the API")
}

func testConvertWebUrlToApi() throws {
func testIsApiUrl() throws {
// swiftlint:disable:next force_unwrapping
let differentHostResult = BbcNews.isApiUrl(url: URL(string: "https://bilaal.co.uk")!)
XCTAssertFalse(differentHostResult, "A URL with on a different hostname does not match the API")

// swiftlint:disable:next force_unwrapping
let tlsResult = BbcNews.isApiUrl(url: URL(string: "https://news-app.api.bbc.co.uk/fd/abl?clientName=Chrysalis&page=cwgdx0ppwnzt&type=topic")!)
XCTAssertTrue(tlsResult, "A URL on the correct hostname using HTTPS, correctly matches the API")

// swiftlint:disable:next force_https force_unwrapping
let insecureResult = BbcNews.isApiUrl(url: URL(string: "http://news-app.api.bbc.co.uk/fd/abl?clientName=Chrysalis&page=cwgdx0ppwnzt&type=topic")!)
XCTAssertTrue(insecureResult, "A URL on the correct hostname using HTTP, correctly matches the API")
}

func testConvertWebUrlStringToApi() throws {
XCTAssertEqual(
BbcNews.convertWebUrlToApi(url: "https://www.bbc.co.uk/news/uk-politics-68983472.app"),
BbcNews.convertWebUrlToApi(urlString: "https://www.bbc.co.uk/news/uk-politics-68983472.app"),
"https://news-app.api.bbc.co.uk/fd/app-article-api?clientName=Chrysalis&clientVersion=pre-7&page=https://www.bbc.co.uk/news/uk-politics-68983472.app"
)

XCTAssertEqual(
BbcNews.convertWebUrlToApi(url: "https://www.bbc.co.uk/news/world-europe-18023383.app"),
BbcNews.convertWebUrlToApi(urlString: "https://www.bbc.co.uk/news/world-europe-18023383.app"),
"https://news-app.api.bbc.co.uk/fd/app-article-api?clientName=Chrysalis&clientVersion=pre-7&page=https://www.bbc.co.uk/news/world-europe-18023383.app"
)

XCTAssertEqual(
BbcNews.convertWebUrlToApi(url: "https://www.bbc.com/news/articles/c289n8m4j19o.app"),
BbcNews.convertWebUrlToApi(urlString: "https://www.bbc.com/news/articles/c289n8m4j19o.app"),
"https://news-app.api.bbc.co.uk/fd/app-article-api?clientName=Chrysalis&clientVersion=pre-7&page=https://www.bbc.com/news/articles/c289n8m4j19o.app"
)

XCTAssertEqual(
BbcNews.convertWebUrlToApi(url: "https://www.bbc.co.uk/news/uk-politics-68983472"),
BbcNews.convertWebUrlToApi(urlString: "https://www.bbc.co.uk/news/uk-politics-68983472"),
"https://news-app.api.bbc.co.uk/fd/app-article-api?clientName=Chrysalis&clientVersion=pre-7&page=https://www.bbc.co.uk/news/uk-politics-68983472"
)

XCTAssertEqual(
BbcNews.convertWebUrlToApi(url: "https://bilaal.co.uk"),
BbcNews.convertWebUrlToApi(urlString: "https://bilaal.co.uk"),
nil
)
}

func testConvertWebUrlToApi() throws {
XCTAssertEqual(
// swiftlint:disable:next force_https force_unwrapping
BbcNews.convertWebUrlToApi(url: URL(string: "https://www.bbc.co.uk/news/uk-politics-68983472.app")!),
URL(string: "https://news-app.api.bbc.co.uk/fd/app-article-api?clientName=Chrysalis&clientVersion=pre-7&page=https://www.bbc.co.uk/news/uk-politics-68983472.app")
)

XCTAssertEqual(
// swiftlint:disable:next force_https force_unwrapping
BbcNews.convertWebUrlToApi(url: URL(string: "https://www.bbc.co.uk/news/world-europe-18023383.app")!),
URL(string: "https://news-app.api.bbc.co.uk/fd/app-article-api?clientName=Chrysalis&clientVersion=pre-7&page=https://www.bbc.co.uk/news/world-europe-18023383.app")
)

XCTAssertEqual(
// swiftlint:disable:next force_https force_unwrapping
BbcNews.convertWebUrlToApi(url: URL(string: "https://www.bbc.com/news/articles/c289n8m4j19o.app")!),
URL(string: "https://news-app.api.bbc.co.uk/fd/app-article-api?clientName=Chrysalis&clientVersion=pre-7&page=https://www.bbc.com/news/articles/c289n8m4j19o.app")
)

XCTAssertEqual(
// swiftlint:disable:next force_https force_unwrapping
BbcNews.convertWebUrlToApi(url: URL(string: "https://www.bbc.co.uk/news/uk-politics-68983472")!),
URL(string: "https://news-app.api.bbc.co.uk/fd/app-article-api?clientName=Chrysalis&clientVersion=pre-7&page=https://www.bbc.co.uk/news/uk-politics-68983472")
)

XCTAssertEqual(
// swiftlint:disable:next force_https force_unwrapping
BbcNews.convertWebUrlToApi(url: URL(string: "https://bilaal.co.uk")!),
nil
)
}
Expand Down
10 changes: 5 additions & 5 deletions Tests/BbcNewsTests/FDImageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,17 @@ final class FDImageTests: XCTestCase {

XCTAssertEqual(
image.largestImageUrl,
"https://example.invalid/img/300/example.jpg",
URL(string: "https://example.invalid/img/300/example.jpg"),
"Fails to return URL for the largest width"
)
XCTAssertEqual(
image.largestImageUrl(upTo: 250),
"https://example.invalid/img/200/example.jpg",
URL(string: "https://example.invalid/img/200/example.jpg"),
"Fails to return the largest width up to the specified maximum"
)
XCTAssertEqual(
image.largestImageUrl(upTo: 200),
"https://example.invalid/img/200/example.jpg",
URL(string: "https://example.invalid/img/200/example.jpg"),
"Fails to return the largest width when equal to the specified maximum"
)
}
Expand All @@ -34,12 +34,12 @@ final class FDImageTests: XCTestCase {

XCTAssertEqual(
image.largestImageUrl,
"https://example.invalid/img/{width}/example.jpg",
URL(string: "https://example.invalid/img/{width}/example.jpg"),
"Fails to return an unmodified URL for an unrecognised sizing method"
)
XCTAssertEqual(
image.largestImageUrl(upTo: 250),
"https://example.invalid/img/{width}/example.jpg",
URL(string: "https://example.invalid/img/{width}/example.jpg"),
"Fails to return an unmodified URL for an unrecognised sizing method"
)
}
Expand Down
Loading

0 comments on commit 21fa1f7

Please sign in to comment.