From 6a51ce48ec16c9bad87f4f83526a932c06eb4497 Mon Sep 17 00:00:00 2001 From: Eduardo Toledo Date: Fri, 22 Mar 2024 09:50:13 +0100 Subject: [PATCH 01/10] Creating AvatarURL --- Sources/Gravatar/AvatarURL.swift | 98 +++++++++++++++ Tests/GravatarTests/AvatarURLTests.swift | 146 +++++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 Sources/Gravatar/AvatarURL.swift create mode 100644 Tests/GravatarTests/AvatarURLTests.swift diff --git a/Sources/Gravatar/AvatarURL.swift b/Sources/Gravatar/AvatarURL.swift new file mode 100644 index 00000000..8e677067 --- /dev/null +++ b/Sources/Gravatar/AvatarURL.swift @@ -0,0 +1,98 @@ +import Foundation + +public struct AvatarURL { + public let canonicalUrl: URL + public let hash: String + + let options: ImageQueryOptions + var components: URLComponents + + public var url: URL { + // When `AavatarURL` is initialized successfully, the `canonicalUrl` field is a valid URL. + // Adding query items from the options, which is controlled by the SDK, should never + // result in an invalid URL. If it does, something terrible has happened. + guard let url = canonicalUrl.addQueryItems(from: options) else { + fatalError("Internal error: invalid url with query items") + } + + return url + } + + public init?(url: URL, options: ImageQueryOptions = ImageQueryOptions()) { + guard + Self.isAvatarUrl(url), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false)?.sanitizedComponents(), + let sanitizedURL = components.url + else { + return nil + } + + self.canonicalUrl = sanitizedURL + self.components = components + self.hash = sanitizedURL.lastPathComponent + self.options = options + } + + public init?(email: String, options: ImageQueryOptions = ImageQueryOptions()) { + self.init(hash: email.sanitized.sha256(), options: options) + } + + public init?(hash: String, options: ImageQueryOptions = ImageQueryOptions()){ + guard let url = URL(string: .baseURL + hash) else { return nil } + self.init(url: url, options: options) + } + + public static func isAvatarUrl(_ url: URL) -> Bool { + guard + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let host = components.host + else { + return false + } + + return (host.hasSuffix(".gravatar.com") || host == "gravatar.com") + && components.path.hasPrefix("/avatar/") + } + + public func updating(options: ImageQueryOptions) -> AvatarURL { + guard let avatarUrl = AvatarURL(hash: hash, options: options) else { + // When `AavatarURL` is initialized successfully, is guaranteed to be a valid URL. + // Adding query items from the options, which is controlled by the SDK, should never + // result in an invalid URL. If it does, something terrible has happened. + fatalError("Internal error: invalid url with query items") + } + return avatarUrl + } +} + +extension AvatarURL: Equatable { + public static func == (lhs: AvatarURL, rhs: AvatarURL) -> Bool { + lhs.url.absoluteString == rhs.url.absoluteString + } +} + +private extension String { + static let baseURL = "https://gravatar.com/avatar/" +} + +extension String { + var sanitized: String { + self.lowercased() + .trimmingCharacters(in: .whitespaces) + } +} + +extension URL { + fileprivate func addQueryItems(from options: ImageQueryOptions) -> URL? { + guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { + return nil + } + components.queryItems = options.queryItems + + if components.queryItems?.isEmpty == true { + components.queryItems = nil + } + + return components.url + } +} diff --git a/Tests/GravatarTests/AvatarURLTests.swift b/Tests/GravatarTests/AvatarURLTests.swift new file mode 100644 index 00000000..536121fa --- /dev/null +++ b/Tests/GravatarTests/AvatarURLTests.swift @@ -0,0 +1,146 @@ +import Gravatar +import XCTest + +final class AvatarURLTests: XCTestCase { + let verifiedAvatarURL = URL(string: "https://0.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50")! + let verifiedAvatarURL2 = URL(string: "https://gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50")! + + let exampleEmail = "some@email.com" + let exampleEmailSHA = "676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674" + + func testisAvatarUrl() throws { + XCTAssertTrue(AvatarURL.isAvatarUrl(verifiedAvatarURL)) + XCTAssertTrue(AvatarURL.isAvatarUrl(verifiedAvatarURL2)) + XCTAssertFalse(AvatarURL.isAvatarUrl(URL(string: "https://wordpress.com/")!)) + } + + func testAvatarURLWithDifferentPixelSizes() throws { + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(preferredSize: .pixels(24)))?.url.query, "s=24") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(preferredSize: .pixels(128)))?.url.query, "s=128") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(preferredSize: .pixels(256)))?.url.query, "s=256") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(preferredSize: .pixels(0)))?.url.query, "s=0") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(preferredSize: .pixels(-10)))?.url.query, "s=-10") + } + + func testAvatarURLWithPointSize() throws { + let pointSize = CGFloat(200) + let expectedPixelSize = pointSize * UIScreen.main.scale + + let url = AvatarURL(url: verifiedAvatarURL, options: ImageQueryOptions(preferredSize: .points(pointSize)))?.url + + XCTAssertNotNil(url) + XCTAssertEqual(url?.query, "s=\(Int(expectedPixelSize))") + } + + func testUrlWithDefaultImage() throws { + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .fileNotFound))?.url.query, "d=404") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .misteryPerson))?.url.query, "d=mp") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .monsterId))?.url.query, "d=monsterid") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .retro))?.url.query, "d=retro") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .roboHash))?.url.query, "d=robohash") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .transparentPNG))?.url.query, "d=blank") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .wavatar))?.url.query, "d=wavatar") + } + + func testUrlWithForcedImageDefault() throws { + let avatarUrl = verifiedAvatarURL(options: ImageQueryOptions()) + XCTAssertNotNil(avatarUrl) + XCTAssertEqual(avatarUrl?.url.query, nil) + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(forceDefaultImage: true))?.url.query, "f=y") + } + + func testUrlWithForceImageDefaultFalse() { + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(forceDefaultImage: false))?.url.query, "f=n") + } + + func testCreateAvatarURLWithEmail() throws { + let avatarUrl = AvatarURL(email: exampleEmail, options: ImageQueryOptions()) + XCTAssertEqual( + avatarUrl?.url.absoluteString, + "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674" + ) + + let urlAddingDefaultImage = AvatarURL(email: exampleEmail, options: ImageQueryOptions(defaultImageOption: .identicon)) + XCTAssertEqual( + urlAddingDefaultImage?.url.absoluteString, + "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674?d=identicon" + ) + + let urlAddingSize = AvatarURL(email: exampleEmail, options: ImageQueryOptions(preferredSize: .pixels(24))) + XCTAssertEqual( + urlAddingSize?.url.absoluteString, + "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674?s=24" + ) + + let urlAddingRating = AvatarURL(email: exampleEmail, options: ImageQueryOptions(rating: .parentalGuidance)) + XCTAssertEqual( + urlAddingRating?.url.absoluteString, + "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674?r=pg" + ) + + let urlAddingForceDefault = AvatarURL(email: exampleEmail, options: ImageQueryOptions(forceDefaultImage: true)) + XCTAssertEqual( + urlAddingForceDefault?.url.absoluteString, + "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674?f=y" + ) + + let allOptions = ImageQueryOptions( + preferredSize: .pixels(200), + rating: .general, + defaultImageOption: .monsterId, + forceDefaultImage: true + ) + let urlAddingAllOptions = AvatarURL(email: exampleEmail, options: allOptions) + XCTAssertEqual( + urlAddingAllOptions?.url.absoluteString, + "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674?d=monsterid&s=200&r=g&f=y" + ) + } + + func testCreateAvatarWithHash() { + let avatarUrl = AvatarURL(hash: "HASH") + XCTAssertEqual(avatarUrl?.url.absoluteString, "https://gravatar.com/avatar/HASH") + } + + func testCreateAvatarByUpdatingOptions() { + let avatarUrl = AvatarURL(hash: "HASH", options: ImageQueryOptions(defaultImageOption: .fileNotFound)) + XCTAssertEqual(avatarUrl?.url.absoluteString, "https://gravatar.com/avatar/HASH?d=404") + let updatedAvatarUrl = avatarUrl?.updating(options: ImageQueryOptions(rating: .parentalGuidance)) + XCTAssertEqual(updatedAvatarUrl?.url.absoluteString, "https://gravatar.com/avatar/HASH?r=pg") + } + + func testCreateAvatarWithHashWithInvalidCharacters() { + let avatarUrl = AvatarURL(hash: "πŸ˜‰β‡Άβ–β‚§β„Έβ„βŽœβ™˜Β§@…./+_ =-\\][|}{~`23πŸ₯‘") + XCTAssertEqual( + avatarUrl?.url.absoluteString, + "https://gravatar.com/avatar/%F0%9F%98%89%E2%87%B6%E2%9D%96%E2%82%A7%E2%84%B8%E2%84%8F%E2%8E%9C%E2%99%98%C2%A7@%E2%80%A6./+_%20=-%5C%5D%5B%7C%7D%7B~%6023%F0%9F%A5%A1" + ) + } + + func testIsValidURL() { + XCTAssertTrue(AvatarURL.isAvatarUrl(verifiedAvatarURL)) + XCTAssertFalse(AvatarURL.isAvatarUrl(URL(string: "http://gravatar.com/"))) + XCTAssertEqual( + avatarUrl?.url.absoluteString, + "https://gravatar.com/avatar/%F0%9F%98%89%E2%87%B6%E2%9D%96%E2%82%A7%E2%84%B8%E2%84%8F%E2%8E%9C%E2%99%98%C2%A7@%E2%80%A6./+_%20=-%5C%5D%5B%7C%7D%7B~%6023%F0%9F%A5%A1" + ) + } + + func testAvatarURLIsEquatable() throws { + let lhs = AvatarURL(url: verifiedAvatarURL) + let rhs = AvatarURL(url: verifiedAvatarURL) + + XCTAssertEqual(lhs, rhs) + } + + func testAvatarURLIsEquatableFails() throws { + let lhs = AvatarURL(url: URL(string: "https://www.gravatar.com/avatar/000")!) + let rhs = AvatarURL(url: verifiedAvatarURL) + + XCTAssertNotEqual(lhs, rhs) + } + + func verifiedAvatarURL(options: ImageQueryOptions) -> AvatarURL? { + AvatarURL(url: verifiedAvatarURL, options: options) + } +} From bb7d85304feb9a2ef7c06010444563f292cd7de4 Mon Sep 17 00:00:00 2001 From: Eduardo Toledo Date: Fri, 22 Mar 2024 09:50:26 +0100 Subject: [PATCH 02/10] Creating ProfileURL --- Sources/Gravatar/ProfileURL.swift | 51 +++++++++++++++++++++++ Tests/GravatarTests/ProfileURLTests.swift | 41 ++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 Sources/Gravatar/ProfileURL.swift create mode 100644 Tests/GravatarTests/ProfileURLTests.swift diff --git a/Sources/Gravatar/ProfileURL.swift b/Sources/Gravatar/ProfileURL.swift new file mode 100644 index 00000000..83e93394 --- /dev/null +++ b/Sources/Gravatar/ProfileURL.swift @@ -0,0 +1,51 @@ +import Foundation + +public struct ProfileURL { + public let url: URL + public let hash: String + public var avatarURL: AvatarURL? { + AvatarURL(hash: hash) + } + + public init(email: String) { + self.init(hash: email.sanitized.sha256()) + } + + public init(hash: String) { + guard + let baseUrl = URL(string: .baseURL), + let components = URLComponents(url: baseUrl, resolvingAgainstBaseURL: false)?.sanitizedComponents(), + let sanizitedUrl = components.url + else { + fatalError("A url created from a correct literal string should never fail") + } + + self.url = sanizitedUrl.appending(pathComponent: hash) + self.hash = hash + } +} + +extension URLComponents { + func sanitizedComponents() -> URLComponents { + var copy = self + copy.scheme = .scheme + copy.query = nil + return copy + } +} + +private extension String { + static let scheme = "https" + static let baseURL = "https://gravatar.com/" +} + +extension URL { + @available(swift, deprecated: 16.0, message: "Use URL.appending(path:) instead") + func appending(pathComponent path: String) -> URL { + if #available(iOS 16.0, *) { + return self.appending(path: path) + } else { + return self.appendingPathComponent(path) + } + } +} diff --git a/Tests/GravatarTests/ProfileURLTests.swift b/Tests/GravatarTests/ProfileURLTests.swift new file mode 100644 index 00000000..e07d6765 --- /dev/null +++ b/Tests/GravatarTests/ProfileURLTests.swift @@ -0,0 +1,41 @@ +import Gravatar +import XCTest + +final class ProfileURLTests: XCTestCase { + let urlFromEmail = URL(string: "https://gravatar.com/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674")! + let hashFromEmail = "676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674" + let email = "some@email.com" + + func testProfileUrlWithEmail() { + let profileUrl = ProfileURL(email: email) + XCTAssertEqual(profileUrl.url.absoluteString, urlFromEmail.absoluteString) + } + + func testProfileUrlHashWithEmail() { + let profileUrl = ProfileURL(email: email) + XCTAssertEqual(profileUrl.hash, hashFromEmail) + } + + func testProfileUrlWithHash() { + let profileUrl = ProfileURL(hash: hashFromEmail) + XCTAssertEqual(profileUrl.url.absoluteString, urlFromEmail.absoluteString) + } + + func testAvatarURLFromProfileUrl() { + let profileUrl = ProfileURL(email: email) + XCTAssertEqual(profileUrl.avatarURL, AvatarURL(email: email)) + } + + func testProfileUrlWithEmailWithInvalidCharactersWontCrash() { + let profileUrl = ProfileURL(email: "πŸ˜‰β‡Άβ–β‚§β„Έβ„βŽœβ™˜Β§@…./+_ =-\\][|}{~`23πŸ₯‘") + XCTAssertEqual(profileUrl.hash, "d8bf26df33ebe638f5ad553aedc6df15e67e7e64f3f21e21c03223877a9290c9") + } + + func testProfileUrlWithHashWithInvalidCharactersWontCrash() { + let profileUrl = ProfileURL(hash: "πŸ˜‰β‡Άβ–β‚§β„Έβ„βŽœβ™˜Β§@…./+_ =-\\][|}{~`23πŸ₯‘") + XCTAssertEqual( + profileUrl.url.absoluteString, + "https://gravatar.com/%F0%9F%98%89%E2%87%B6%E2%9D%96%E2%82%A7%E2%84%B8%E2%84%8F%E2%8E%9C%E2%99%98%C2%A7@%E2%80%A6./+_%20=-%5C%5D%5B%7C%7D%7B~%6023%F0%9F%A5%A1" + ) + } +} From 171e9b1593769df327b240076bc6f362be22f6f1 Mon Sep 17 00:00:00 2001 From: Eduardo Toledo Date: Fri, 22 Mar 2024 11:51:03 +0100 Subject: [PATCH 03/10] Removing old GravatarURL --- Sources/Gravatar/AvatarURL.swift | 27 +--- .../{String+SHA256.swift => String.swift} | 7 + Sources/Gravatar/Extensions/URL.swift | 12 ++ .../UIImageView+Gravatar.swift | 2 +- Sources/Gravatar/GravatarURL.swift | 119 ----------------- .../Network/Services/AvatarService.swift | 2 +- Sources/Gravatar/ProfileURL.swift | 39 +++--- Tests/GravatarTests/AvatarURLTests.swift | 72 +++++------ Tests/GravatarTests/GravatarURLTests.swift | 121 ------------------ 9 files changed, 75 insertions(+), 326 deletions(-) rename Sources/Gravatar/Extensions/{String+SHA256.swift => String.swift} (66%) create mode 100644 Sources/Gravatar/Extensions/URL.swift delete mode 100644 Sources/Gravatar/GravatarURL.swift delete mode 100644 Tests/GravatarTests/GravatarURLTests.swift diff --git a/Sources/Gravatar/AvatarURL.swift b/Sources/Gravatar/AvatarURL.swift index 8e677067..0270cca1 100644 --- a/Sources/Gravatar/AvatarURL.swift +++ b/Sources/Gravatar/AvatarURL.swift @@ -21,7 +21,7 @@ public struct AvatarURL { public init?(url: URL, options: ImageQueryOptions = ImageQueryOptions()) { guard Self.isAvatarUrl(url), - let components = URLComponents(url: url, resolvingAgainstBaseURL: false)?.sanitizedComponents(), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false)?.sanitizingComponents(), let sanitizedURL = components.url else { return nil @@ -34,10 +34,10 @@ public struct AvatarURL { } public init?(email: String, options: ImageQueryOptions = ImageQueryOptions()) { - self.init(hash: email.sanitized.sha256(), options: options) + self.init(hash: email.sanitized.sha256(), options: options) } - public init?(hash: String, options: ImageQueryOptions = ImageQueryOptions()){ + public init?(hash: String, options: ImageQueryOptions = ImageQueryOptions()) { guard let url = URL(string: .baseURL + hash) else { return nil } self.init(url: url, options: options) } @@ -51,17 +51,11 @@ public struct AvatarURL { } return (host.hasSuffix(".gravatar.com") || host == "gravatar.com") - && components.path.hasPrefix("/avatar/") + && components.path.hasPrefix("/avatar/") } - public func updating(options: ImageQueryOptions) -> AvatarURL { - guard let avatarUrl = AvatarURL(hash: hash, options: options) else { - // When `AavatarURL` is initialized successfully, is guaranteed to be a valid URL. - // Adding query items from the options, which is controlled by the SDK, should never - // result in an invalid URL. If it does, something terrible has happened. - fatalError("Internal error: invalid url with query items") - } - return avatarUrl + public func replacing(options: ImageQueryOptions) -> AvatarURL? { + AvatarURL(hash: hash, options: options) } } @@ -71,15 +65,8 @@ extension AvatarURL: Equatable { } } -private extension String { - static let baseURL = "https://gravatar.com/avatar/" -} - extension String { - var sanitized: String { - self.lowercased() - .trimmingCharacters(in: .whitespaces) - } + fileprivate static let baseURL = "https://gravatar.com/avatar/" } extension URL { diff --git a/Sources/Gravatar/Extensions/String+SHA256.swift b/Sources/Gravatar/Extensions/String.swift similarity index 66% rename from Sources/Gravatar/Extensions/String+SHA256.swift rename to Sources/Gravatar/Extensions/String.swift index 36e47482..65db2434 100644 --- a/Sources/Gravatar/Extensions/String+SHA256.swift +++ b/Sources/Gravatar/Extensions/String.swift @@ -8,3 +8,10 @@ extension String { return hashString } } + +extension String { + var sanitized: String { + self.lowercased() + .trimmingCharacters(in: .whitespaces) + } +} diff --git a/Sources/Gravatar/Extensions/URL.swift b/Sources/Gravatar/Extensions/URL.swift new file mode 100644 index 00000000..db27f433 --- /dev/null +++ b/Sources/Gravatar/Extensions/URL.swift @@ -0,0 +1,12 @@ +import Foundation + +extension URL { + @available(swift, deprecated: 16.0, message: "Use URL.appending(path:) instead") + func appending(pathComponent path: String) -> URL { + if #available(iOS 16.0, *) { + self.appending(path: path) + } else { + self.appendingPathComponent(path) + } + } +} diff --git a/Sources/Gravatar/GravatarCompatibleUI/UIImageView+Gravatar.swift b/Sources/Gravatar/GravatarCompatibleUI/UIImageView+Gravatar.swift index 5b5d6d93..34e291c3 100644 --- a/Sources/Gravatar/GravatarCompatibleUI/UIImageView+Gravatar.swift +++ b/Sources/Gravatar/GravatarCompatibleUI/UIImageView+Gravatar.swift @@ -158,7 +158,7 @@ extension GravatarWrapper where Component: UIImageView { defaultImageOption: defaultImageOption ) - let gravatarURL = GravatarURL.gravatarUrl(with: email, options: downloadOptions.imageQueryOptions) + let gravatarURL = AvatarURL(email: email, options: downloadOptions.imageQueryOptions)?.url return setImage(with: gravatarURL, placeholder: placeholder, options: options, completionHandler: completionHandler) } diff --git a/Sources/Gravatar/GravatarURL.swift b/Sources/Gravatar/GravatarURL.swift deleted file mode 100644 index 0993f7f0..00000000 --- a/Sources/Gravatar/GravatarURL.swift +++ /dev/null @@ -1,119 +0,0 @@ -import Foundation - -public struct GravatarURL { - private enum Defaults { - static let scheme = "https" - static let host = "secure.gravatar.com" - static let unknownHash = "ad516503a11cd5ca435acc9bb6523536" - static let baseURL = "https://gravatar.com/avatar/" - static let imageSize = 80 - } - - public let canonicalURL: URL - - public func url(with options: ImageQueryOptions) -> URL { - // When `GravatarURL` is initialized successfully, the `canonicalURL` is a valid URL. - // Adding query items from the options, which is controlled by the SDK, should never - // result in an invalid URL. If it does, something terrible has happened. - guard let url = canonicalURL.addQueryItems(from: options) else { - fatalError("Internal error: invalid url with query items") - } - - return url - } - - public static func isGravatarURL(_ url: URL) -> Bool { - guard - let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let host = components.host - else { - return false - } - - return (host.hasSuffix(".gravatar.com") || host == "gravatar.com") - && components.path.hasPrefix("/avatar/") - } - - /// Returns the Gravatar URL, for a given email, with the specified size + rating. - /// - /// - Parameters: - /// - email: the user's email - /// - size: required download size - /// - rating: image rating filtering - /// - /// - Returns: Gravatar's URL - /// - public static func gravatarUrl( - with email: String, - options: ImageQueryOptions = .init() - ) -> URL? { - let hash = gravatarHash(of: email) - guard let baseURL = URL(string: Defaults.baseURL + hash) else { - return nil - } - - return baseURL.addQueryItems(from: options) - } - - /// Returns the gravatar hash of an email - /// - /// - Parameter email: the email associated with the gravatar - /// - Returns: hashed email - /// - /// This really ought to be in a different place, like Gravatar.swift, but there's - /// lots of duplication around gravatars -nh - private static func gravatarHash(of email: String) -> String { - email - .lowercased() - .trimmingCharacters(in: .whitespaces) - .sha256() - } -} - -extension GravatarURL: Equatable {} - -public func == (lhs: GravatarURL, rhs: GravatarURL) -> Bool { - lhs.canonicalURL == rhs.canonicalURL -} - -extension GravatarURL { - public init?(_ url: URL) { - guard GravatarURL.isGravatarURL(url) else { - return nil - } - - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return nil - } - - components.scheme = Defaults.scheme - components.host = Defaults.host - components.query = nil - - // Treat unknown@gravatar.com as a nil url - guard url.lastPathComponent != Defaults.unknownHash else { - return nil - } - - guard let sanitizedURL = components.url else { - return nil - } - - self.canonicalURL = sanitizedURL - } -} - -extension URL { - fileprivate func addQueryItems(from options: ImageQueryOptions) -> URL? { - guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { - return nil - } - components.queryItems = options.queryItems - - if components.queryItems?.isEmpty == true { - components.queryItems = nil - } - - return components.url - } -} diff --git a/Sources/Gravatar/Network/Services/AvatarService.swift b/Sources/Gravatar/Network/Services/AvatarService.swift index d2f0059f..b51e7497 100644 --- a/Sources/Gravatar/Network/Services/AvatarService.swift +++ b/Sources/Gravatar/Network/Services/AvatarService.swift @@ -30,7 +30,7 @@ public struct AvatarService { with email: String, options: ImageDownloadOptions = ImageDownloadOptions() ) async throws -> ImageDownloadResult { - guard let gravatarURL = GravatarURL.gravatarUrl(with: email, options: options.imageQueryOptions) else { + guard let gravatarURL = AvatarURL(email: email, options: options.imageQueryOptions)?.url else { throw ImageFetchingError.requestError(reason: .urlInitializationFailed) } diff --git a/Sources/Gravatar/ProfileURL.swift b/Sources/Gravatar/ProfileURL.swift index 83e93394..847b3bf9 100644 --- a/Sources/Gravatar/ProfileURL.swift +++ b/Sources/Gravatar/ProfileURL.swift @@ -7,26 +7,30 @@ public struct ProfileURL { AvatarURL(hash: hash) } - public init(email: String) { - self.init(hash: email.sanitized.sha256()) - } - - public init(hash: String) { + static let baseUrl: URL = { guard let baseUrl = URL(string: .baseURL), - let components = URLComponents(url: baseUrl, resolvingAgainstBaseURL: false)?.sanitizedComponents(), - let sanizitedUrl = components.url + let components = URLComponents(url: baseUrl, resolvingAgainstBaseURL: false)?.sanitizingComponents(), + let url = components.url else { fatalError("A url created from a correct literal string should never fail") } + return url + }() + + public init(email: String) { + let hash = email.sanitized.sha256() + self.init(hash: hash) + } - self.url = sanizitedUrl.appending(pathComponent: hash) + public init(hash: String) { + self.url = Self.baseUrl.appending(pathComponent: hash) self.hash = hash } } extension URLComponents { - func sanitizedComponents() -> URLComponents { + func sanitizingComponents() -> URLComponents { var copy = self copy.scheme = .scheme copy.query = nil @@ -34,18 +38,7 @@ extension URLComponents { } } -private extension String { - static let scheme = "https" - static let baseURL = "https://gravatar.com/" -} - -extension URL { - @available(swift, deprecated: 16.0, message: "Use URL.appending(path:) instead") - func appending(pathComponent path: String) -> URL { - if #available(iOS 16.0, *) { - return self.appending(path: path) - } else { - return self.appendingPathComponent(path) - } - } +extension String { + fileprivate static let scheme = "https" + fileprivate static let baseURL = "https://gravatar.com/" } diff --git a/Tests/GravatarTests/AvatarURLTests.swift b/Tests/GravatarTests/AvatarURLTests.swift index 536121fa..81d07e53 100644 --- a/Tests/GravatarTests/AvatarURLTests.swift +++ b/Tests/GravatarTests/AvatarURLTests.swift @@ -11,15 +11,15 @@ final class AvatarURLTests: XCTestCase { func testisAvatarUrl() throws { XCTAssertTrue(AvatarURL.isAvatarUrl(verifiedAvatarURL)) XCTAssertTrue(AvatarURL.isAvatarUrl(verifiedAvatarURL2)) - XCTAssertFalse(AvatarURL.isAvatarUrl(URL(string: "https://wordpress.com/")!)) + XCTAssertFalse(AvatarURL.isAvatarUrl(URL(string: "https://gravatar.com/")!)) } func testAvatarURLWithDifferentPixelSizes() throws { - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(preferredSize: .pixels(24)))?.url.query, "s=24") - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(preferredSize: .pixels(128)))?.url.query, "s=128") - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(preferredSize: .pixels(256)))?.url.query, "s=256") - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(preferredSize: .pixels(0)))?.url.query, "s=0") - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(preferredSize: .pixels(-10)))?.url.query, "s=-10") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(preferredSize: .pixels(24))).url.query, "s=24") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(preferredSize: .pixels(128))).url.query, "s=128") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(preferredSize: .pixels(256))).url.query, "s=256") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(preferredSize: .pixels(0))).url.query, "s=0") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(preferredSize: .pixels(-10))).url.query, "s=-10") } func testAvatarURLWithPointSize() throws { @@ -33,54 +33,53 @@ final class AvatarURLTests: XCTestCase { } func testUrlWithDefaultImage() throws { - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .fileNotFound))?.url.query, "d=404") - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .misteryPerson))?.url.query, "d=mp") - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .monsterId))?.url.query, "d=monsterid") - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .retro))?.url.query, "d=retro") - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .roboHash))?.url.query, "d=robohash") - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .transparentPNG))?.url.query, "d=blank") - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .wavatar))?.url.query, "d=wavatar") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .fileNotFound)).url.query, "d=404") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .misteryPerson)).url.query, "d=mp") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .monsterId)).url.query, "d=monsterid") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .retro)).url.query, "d=retro") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .roboHash)).url.query, "d=robohash") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .transparentPNG)).url.query, "d=blank") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .wavatar)).url.query, "d=wavatar") } func testUrlWithForcedImageDefault() throws { let avatarUrl = verifiedAvatarURL(options: ImageQueryOptions()) - XCTAssertNotNil(avatarUrl) - XCTAssertEqual(avatarUrl?.url.query, nil) - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(forceDefaultImage: true))?.url.query, "f=y") + XCTAssertEqual(avatarUrl.url.query, nil) + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(forceDefaultImage: true)).url.query, "f=y") } func testUrlWithForceImageDefaultFalse() { - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(forceDefaultImage: false))?.url.query, "f=n") + XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(forceDefaultImage: false)).url.query, "f=n") } func testCreateAvatarURLWithEmail() throws { - let avatarUrl = AvatarURL(email: exampleEmail, options: ImageQueryOptions()) + let avatarUrl = AvatarURL(email: exampleEmail)! XCTAssertEqual( - avatarUrl?.url.absoluteString, + avatarUrl.url.absoluteString, "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674" ) - let urlAddingDefaultImage = AvatarURL(email: exampleEmail, options: ImageQueryOptions(defaultImageOption: .identicon)) + let urlReplacingDefaultImage = avatarUrl.replacing(options: ImageQueryOptions(defaultImageOption: .identicon)) XCTAssertEqual( - urlAddingDefaultImage?.url.absoluteString, + urlReplacingDefaultImage?.url.absoluteString, "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674?d=identicon" ) - let urlAddingSize = AvatarURL(email: exampleEmail, options: ImageQueryOptions(preferredSize: .pixels(24))) + let urlReplacingSize = avatarUrl.replacing(options: ImageQueryOptions(preferredSize: .pixels(24))) XCTAssertEqual( - urlAddingSize?.url.absoluteString, + urlReplacingSize?.url.absoluteString, "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674?s=24" ) - let urlAddingRating = AvatarURL(email: exampleEmail, options: ImageQueryOptions(rating: .parentalGuidance)) + let urlReplacingRating = avatarUrl.replacing(options: ImageQueryOptions(rating: .parentalGuidance)) XCTAssertEqual( - urlAddingRating?.url.absoluteString, + urlReplacingRating?.url.absoluteString, "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674?r=pg" ) - let urlAddingForceDefault = AvatarURL(email: exampleEmail, options: ImageQueryOptions(forceDefaultImage: true)) + let urlReplacingForceDefault = avatarUrl.replacing(options: ImageQueryOptions(forceDefaultImage: true)) XCTAssertEqual( - urlAddingForceDefault?.url.absoluteString, + urlReplacingForceDefault?.url.absoluteString, "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674?f=y" ) @@ -90,9 +89,9 @@ final class AvatarURLTests: XCTestCase { defaultImageOption: .monsterId, forceDefaultImage: true ) - let urlAddingAllOptions = AvatarURL(email: exampleEmail, options: allOptions) + let urlReplacingAllOptions = avatarUrl.replacing(options: allOptions) XCTAssertEqual( - urlAddingAllOptions?.url.absoluteString, + urlReplacingAllOptions?.url.absoluteString, "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674?d=monsterid&s=200&r=g&f=y" ) } @@ -105,7 +104,7 @@ final class AvatarURLTests: XCTestCase { func testCreateAvatarByUpdatingOptions() { let avatarUrl = AvatarURL(hash: "HASH", options: ImageQueryOptions(defaultImageOption: .fileNotFound)) XCTAssertEqual(avatarUrl?.url.absoluteString, "https://gravatar.com/avatar/HASH?d=404") - let updatedAvatarUrl = avatarUrl?.updating(options: ImageQueryOptions(rating: .parentalGuidance)) + let updatedAvatarUrl = avatarUrl?.replacing(options: ImageQueryOptions(rating: .parentalGuidance)) XCTAssertEqual(updatedAvatarUrl?.url.absoluteString, "https://gravatar.com/avatar/HASH?r=pg") } @@ -117,15 +116,6 @@ final class AvatarURLTests: XCTestCase { ) } - func testIsValidURL() { - XCTAssertTrue(AvatarURL.isAvatarUrl(verifiedAvatarURL)) - XCTAssertFalse(AvatarURL.isAvatarUrl(URL(string: "http://gravatar.com/"))) - XCTAssertEqual( - avatarUrl?.url.absoluteString, - "https://gravatar.com/avatar/%F0%9F%98%89%E2%87%B6%E2%9D%96%E2%82%A7%E2%84%B8%E2%84%8F%E2%8E%9C%E2%99%98%C2%A7@%E2%80%A6./+_%20=-%5C%5D%5B%7C%7D%7B~%6023%F0%9F%A5%A1" - ) - } - func testAvatarURLIsEquatable() throws { let lhs = AvatarURL(url: verifiedAvatarURL) let rhs = AvatarURL(url: verifiedAvatarURL) @@ -140,7 +130,7 @@ final class AvatarURLTests: XCTestCase { XCTAssertNotEqual(lhs, rhs) } - func verifiedAvatarURL(options: ImageQueryOptions) -> AvatarURL? { - AvatarURL(url: verifiedAvatarURL, options: options) + func verifiedAvatarURL(options: ImageQueryOptions = ImageQueryOptions()) -> AvatarURL { + AvatarURL(url: verifiedAvatarURL, options: options)! } } diff --git a/Tests/GravatarTests/GravatarURLTests.swift b/Tests/GravatarTests/GravatarURLTests.swift deleted file mode 100644 index ab126ee0..00000000 --- a/Tests/GravatarTests/GravatarURLTests.swift +++ /dev/null @@ -1,121 +0,0 @@ -@testable import Gravatar -import XCTest - -final class GravatarURLTests: XCTestCase { - let verifiedGravatarURL = URL(string: "https://0.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50")! - let verifiedGravatarURL2 = URL(string: "https://gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50")! - - let exampleEmail = "some@email.com" - let exampleEmailSHA = "676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674" - - func testIsGravatarUrl() throws { - XCTAssertTrue(GravatarURL.isGravatarURL(verifiedGravatarURL)) - XCTAssertTrue(GravatarURL.isGravatarURL(verifiedGravatarURL2)) - XCTAssertFalse(GravatarURL.isGravatarURL(URL(string: "https://wordpress.com/")!)) - } - - func testGravatarURLWithDifferentPixelSizes() throws { - let url = GravatarURL(verifiedGravatarURL) - XCTAssertNotNil(url) - XCTAssertEqual(url?.url(with: ImageQueryOptions(preferredSize: .pixels(24))).query, "s=24") - XCTAssertEqual(url?.url(with: ImageQueryOptions(preferredSize: .pixels(128))).query, "s=128") - XCTAssertEqual(url?.url(with: ImageQueryOptions(preferredSize: .pixels(256))).query, "s=256") - XCTAssertEqual(url?.url(with: ImageQueryOptions(preferredSize: .pixels(0))).query, "s=0") - XCTAssertEqual(url?.url(with: ImageQueryOptions(preferredSize: .pixels(-10))).query, "s=-10") - } - - func testGravatarUrlWithPointSize() throws { - let gavatarUrl = GravatarURL(verifiedGravatarURL) - let pointSize = CGFloat(200) - let expectedPixelSize = pointSize * UIScreen.main.scale - - let url = gavatarUrl?.url(with: ImageQueryOptions(preferredSize: .points(pointSize))) - - XCTAssertNotNil(url) - XCTAssertEqual(url?.query, "s=\(Int(expectedPixelSize))") - } - - func testUrlWithDefaultImage() throws { - let url = GravatarURL(verifiedGravatarURL) - XCTAssertNotNil(url) - XCTAssertEqual(url?.url(with: ImageQueryOptions(defaultImageOption: .fileNotFound)).query, "d=404") - XCTAssertEqual(url?.url(with: ImageQueryOptions(defaultImageOption: .misteryPerson)).query, "d=mp") - XCTAssertEqual(url?.url(with: ImageQueryOptions(defaultImageOption: .monsterId)).query, "d=monsterid") - XCTAssertEqual(url?.url(with: ImageQueryOptions(defaultImageOption: .retro)).query, "d=retro") - XCTAssertEqual(url?.url(with: ImageQueryOptions(defaultImageOption: .roboHash)).query, "d=robohash") - XCTAssertEqual(url?.url(with: ImageQueryOptions(defaultImageOption: .transparentPNG)).query, "d=blank") - XCTAssertEqual(url?.url(with: ImageQueryOptions(defaultImageOption: .wavatar)).query, "d=wavatar") - } - - func testUrlWithForcedImageDefault() throws { - let url = GravatarURL(verifiedGravatarURL) - XCTAssertNotNil(url) - XCTAssertEqual(url?.url(with: ImageQueryOptions()).query, nil) - XCTAssertEqual(url?.url(with: ImageQueryOptions(forceDefaultImage: true)).query, "f=y") - } - - func testUrlWithForceImageDefaultFalse() { - let url = GravatarURL(verifiedGravatarURL) - XCTAssertNotNil(url) - XCTAssertEqual(url?.url(with: ImageQueryOptions()).query, nil) - XCTAssertEqual(url?.url(with: ImageQueryOptions(forceDefaultImage: false)).query, "f=n") - } - - func testCreateGravatarUrlWithEmail() throws { - let url = GravatarURL.gravatarUrl(with: exampleEmail, options: ImageQueryOptions()) - XCTAssertEqual( - url?.absoluteString, - "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674" - ) - - let urlAddingDefaultImage = GravatarURL.gravatarUrl(with: exampleEmail, options: ImageQueryOptions(defaultImageOption: .identicon)) - XCTAssertEqual( - urlAddingDefaultImage?.absoluteString, - "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674?d=identicon" - ) - - let urlAddingSize = GravatarURL.gravatarUrl(with: exampleEmail, options: ImageQueryOptions(preferredSize: .pixels(24))) - XCTAssertEqual( - urlAddingSize?.absoluteString, - "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674?s=24" - ) - - let urlAddingRating = GravatarURL.gravatarUrl(with: exampleEmail, options: ImageQueryOptions(rating: .parentalGuidance)) - XCTAssertEqual( - urlAddingRating?.absoluteString, - "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674?r=pg" - ) - - let urlAddingForceDefault = GravatarURL.gravatarUrl(with: exampleEmail, options: ImageQueryOptions(forceDefaultImage: true)) - XCTAssertEqual( - urlAddingForceDefault?.absoluteString, - "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674?f=y" - ) - - let allOptions = ImageQueryOptions( - preferredSize: .pixels(200), - rating: .general, - defaultImageOption: .monsterId, - forceDefaultImage: true - ) - let urlAddingAllOptions = GravatarURL.gravatarUrl(with: exampleEmail, options: allOptions) - XCTAssertEqual( - urlAddingAllOptions?.absoluteString, - "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674?d=monsterid&s=200&r=g&f=y" - ) - } - - func testGravatarURLIsEquatable() throws { - let lhs = GravatarURL(verifiedGravatarURL) - let rhs = GravatarURL(verifiedGravatarURL) - - XCTAssertEqual(lhs, rhs) - } - - func testGravatarURLIsEquatableFails() throws { - let lhs = GravatarURL(URL(string: "https://www.gravatar.com/avatar/000")!) - let rhs = GravatarURL(verifiedGravatarURL) - - XCTAssertNotEqual(lhs, rhs) - } -} From de84e7c8eb65f01a20ba7fb9d9648270594cb0e2 Mon Sep 17 00:00:00 2001 From: Eduardo Toledo Date: Fri, 22 Mar 2024 12:12:35 +0100 Subject: [PATCH 04/10] Assing test case for isAvatarUrl with malformed URL --- Tests/GravatarTests/AvatarURLTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/GravatarTests/AvatarURLTests.swift b/Tests/GravatarTests/AvatarURLTests.swift index 81d07e53..b3f07154 100644 --- a/Tests/GravatarTests/AvatarURLTests.swift +++ b/Tests/GravatarTests/AvatarURLTests.swift @@ -12,6 +12,7 @@ final class AvatarURLTests: XCTestCase { XCTAssertTrue(AvatarURL.isAvatarUrl(verifiedAvatarURL)) XCTAssertTrue(AvatarURL.isAvatarUrl(verifiedAvatarURL2)) XCTAssertFalse(AvatarURL.isAvatarUrl(URL(string: "https://gravatar.com/")!)) + XCTAssertFalse(AvatarURL.isAvatarUrl(URL(string: "https:/")!)) } func testAvatarURLWithDifferentPixelSizes() throws { From 677120d27a3ef6c538d386dd273bec215a66cf39 Mon Sep 17 00:00:00 2001 From: Eduardo Toledo Date: Mon, 25 Mar 2024 16:49:29 +0100 Subject: [PATCH 05/10] Remove fatalError from `AvatarURL` --- Sources/Gravatar/AvatarURL.swift | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/Sources/Gravatar/AvatarURL.swift b/Sources/Gravatar/AvatarURL.swift index 0270cca1..4e0b19ee 100644 --- a/Sources/Gravatar/AvatarURL.swift +++ b/Sources/Gravatar/AvatarURL.swift @@ -3,26 +3,17 @@ import Foundation public struct AvatarURL { public let canonicalUrl: URL public let hash: String + public let url: URL let options: ImageQueryOptions - var components: URLComponents - - public var url: URL { - // When `AavatarURL` is initialized successfully, the `canonicalUrl` field is a valid URL. - // Adding query items from the options, which is controlled by the SDK, should never - // result in an invalid URL. If it does, something terrible has happened. - guard let url = canonicalUrl.addQueryItems(from: options) else { - fatalError("Internal error: invalid url with query items") - } - - return url - } + let components: URLComponents public init?(url: URL, options: ImageQueryOptions = ImageQueryOptions()) { guard Self.isAvatarUrl(url), let components = URLComponents(url: url, resolvingAgainstBaseURL: false)?.sanitizingComponents(), - let sanitizedURL = components.url + let sanitizedURL = components.url, + let url = sanitizedURL.addQueryItems(from: options) else { return nil } @@ -31,6 +22,7 @@ public struct AvatarURL { self.components = components self.hash = sanitizedURL.lastPathComponent self.options = options + self.url = url } public init?(email: String, options: ImageQueryOptions = ImageQueryOptions()) { From 9d68a31e2adc75c27758a7193c5e1a1eecdbdfa8 Mon Sep 17 00:00:00 2001 From: Eduardo Toledo Date: Mon, 25 Mar 2024 16:49:44 +0100 Subject: [PATCH 06/10] Rename baseUrl -> baseURL --- Sources/Gravatar/ProfileURL.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Gravatar/ProfileURL.swift b/Sources/Gravatar/ProfileURL.swift index 847b3bf9..0dd32417 100644 --- a/Sources/Gravatar/ProfileURL.swift +++ b/Sources/Gravatar/ProfileURL.swift @@ -7,10 +7,10 @@ public struct ProfileURL { AvatarURL(hash: hash) } - static let baseUrl: URL = { + static let baseURL: URL = { guard - let baseUrl = URL(string: .baseURL), - let components = URLComponents(url: baseUrl, resolvingAgainstBaseURL: false)?.sanitizingComponents(), + let baseURL = URL(string: .baseURL), + let components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false), let url = components.url else { fatalError("A url created from a correct literal string should never fail") @@ -24,7 +24,7 @@ public struct ProfileURL { } public init(hash: String) { - self.url = Self.baseUrl.appending(pathComponent: hash) + self.url = Self.baseURL.appending(pathComponent: hash) self.hash = hash } } From cf274c5d039fcafa55611972091ca97f9fc996d5 Mon Sep 17 00:00:00 2001 From: Eduardo Toledo Date: Mon, 25 Mar 2024 17:21:33 +0100 Subject: [PATCH 07/10] Fix merge conflicts --- Sources/Gravatar/AvatarURL.swift | 12 +++--- Tests/GravatarTests/AvatarURLTests.swift | 52 ++++++++++++------------ 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/Sources/Gravatar/AvatarURL.swift b/Sources/Gravatar/AvatarURL.swift index 4e0b19ee..fcc782b8 100644 --- a/Sources/Gravatar/AvatarURL.swift +++ b/Sources/Gravatar/AvatarURL.swift @@ -5,10 +5,10 @@ public struct AvatarURL { public let hash: String public let url: URL - let options: ImageQueryOptions + let options: AvatarQueryOptions let components: URLComponents - public init?(url: URL, options: ImageQueryOptions = ImageQueryOptions()) { + public init?(url: URL, options: AvatarQueryOptions = AvatarQueryOptions()) { guard Self.isAvatarUrl(url), let components = URLComponents(url: url, resolvingAgainstBaseURL: false)?.sanitizingComponents(), @@ -25,11 +25,11 @@ public struct AvatarURL { self.url = url } - public init?(email: String, options: ImageQueryOptions = ImageQueryOptions()) { + public init?(email: String, options: AvatarQueryOptions = AvatarQueryOptions()) { self.init(hash: email.sanitized.sha256(), options: options) } - public init?(hash: String, options: ImageQueryOptions = ImageQueryOptions()) { + public init?(hash: String, options: AvatarQueryOptions = AvatarQueryOptions()) { guard let url = URL(string: .baseURL + hash) else { return nil } self.init(url: url, options: options) } @@ -46,7 +46,7 @@ public struct AvatarURL { && components.path.hasPrefix("/avatar/") } - public func replacing(options: ImageQueryOptions) -> AvatarURL? { + public func replacing(options: AvatarQueryOptions) -> AvatarURL? { AvatarURL(hash: hash, options: options) } } @@ -62,7 +62,7 @@ extension String { } extension URL { - fileprivate func addQueryItems(from options: ImageQueryOptions) -> URL? { + fileprivate func addQueryItems(from options: AvatarQueryOptions) -> URL? { guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { return nil } diff --git a/Tests/GravatarTests/AvatarURLTests.swift b/Tests/GravatarTests/AvatarURLTests.swift index b3f07154..ffc23219 100644 --- a/Tests/GravatarTests/AvatarURLTests.swift +++ b/Tests/GravatarTests/AvatarURLTests.swift @@ -16,41 +16,41 @@ final class AvatarURLTests: XCTestCase { } func testAvatarURLWithDifferentPixelSizes() throws { - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(preferredSize: .pixels(24))).url.query, "s=24") - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(preferredSize: .pixels(128))).url.query, "s=128") - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(preferredSize: .pixels(256))).url.query, "s=256") - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(preferredSize: .pixels(0))).url.query, "s=0") - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(preferredSize: .pixels(-10))).url.query, "s=-10") + XCTAssertEqual(verifiedAvatarURL(options: AvatarQueryOptions(preferredSize: .pixels(24))).url.query, "s=24") + XCTAssertEqual(verifiedAvatarURL(options: AvatarQueryOptions(preferredSize: .pixels(128))).url.query, "s=128") + XCTAssertEqual(verifiedAvatarURL(options: AvatarQueryOptions(preferredSize: .pixels(256))).url.query, "s=256") + XCTAssertEqual(verifiedAvatarURL(options: AvatarQueryOptions(preferredSize: .pixels(0))).url.query, "s=0") + XCTAssertEqual(verifiedAvatarURL(options: AvatarQueryOptions(preferredSize: .pixels(-10))).url.query, "s=-10") } func testAvatarURLWithPointSize() throws { let pointSize = CGFloat(200) let expectedPixelSize = pointSize * UIScreen.main.scale - let url = AvatarURL(url: verifiedAvatarURL, options: ImageQueryOptions(preferredSize: .points(pointSize)))?.url + let url = AvatarURL(url: verifiedAvatarURL, options: AvatarQueryOptions(preferredSize: .points(pointSize)))?.url XCTAssertNotNil(url) XCTAssertEqual(url?.query, "s=\(Int(expectedPixelSize))") } func testUrlWithDefaultImage() throws { - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .fileNotFound)).url.query, "d=404") - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .misteryPerson)).url.query, "d=mp") - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .monsterId)).url.query, "d=monsterid") - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .retro)).url.query, "d=retro") - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .roboHash)).url.query, "d=robohash") - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .transparentPNG)).url.query, "d=blank") - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(defaultImageOption: .wavatar)).url.query, "d=wavatar") + XCTAssertEqual(verifiedAvatarURL(options: AvatarQueryOptions(defaultAvatarOption: .status404)).url.query, "d=404") + XCTAssertEqual(verifiedAvatarURL(options: AvatarQueryOptions(defaultAvatarOption: .mysteryPerson)).url.query, "d=mp") + XCTAssertEqual(verifiedAvatarURL(options: AvatarQueryOptions(defaultAvatarOption: .monsterId)).url.query, "d=monsterid") + XCTAssertEqual(verifiedAvatarURL(options: AvatarQueryOptions(defaultAvatarOption: .retro)).url.query, "d=retro") + XCTAssertEqual(verifiedAvatarURL(options: AvatarQueryOptions(defaultAvatarOption: .roboHash)).url.query, "d=robohash") + XCTAssertEqual(verifiedAvatarURL(options: AvatarQueryOptions(defaultAvatarOption: .transparentPNG)).url.query, "d=blank") + XCTAssertEqual(verifiedAvatarURL(options: AvatarQueryOptions(defaultAvatarOption: .wavatar)).url.query, "d=wavatar") } func testUrlWithForcedImageDefault() throws { - let avatarUrl = verifiedAvatarURL(options: ImageQueryOptions()) + let avatarUrl = verifiedAvatarURL(options: AvatarQueryOptions()) XCTAssertEqual(avatarUrl.url.query, nil) - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(forceDefaultImage: true)).url.query, "f=y") + XCTAssertEqual(verifiedAvatarURL(options: AvatarQueryOptions(forceDefaultAvatar: true)).url.query, "f=y") } func testUrlWithForceImageDefaultFalse() { - XCTAssertEqual(verifiedAvatarURL(options: ImageQueryOptions(forceDefaultImage: false)).url.query, "f=n") + XCTAssertEqual(verifiedAvatarURL(options: AvatarQueryOptions(forceDefaultAvatar: false)).url.query, "f=n") } func testCreateAvatarURLWithEmail() throws { @@ -60,35 +60,35 @@ final class AvatarURLTests: XCTestCase { "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674" ) - let urlReplacingDefaultImage = avatarUrl.replacing(options: ImageQueryOptions(defaultImageOption: .identicon)) + let urlReplacingDefaultImage = avatarUrl.replacing(options: AvatarQueryOptions(defaultAvatarOption: .identicon)) XCTAssertEqual( urlReplacingDefaultImage?.url.absoluteString, "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674?d=identicon" ) - let urlReplacingSize = avatarUrl.replacing(options: ImageQueryOptions(preferredSize: .pixels(24))) + let urlReplacingSize = avatarUrl.replacing(options: AvatarQueryOptions(preferredSize: .pixels(24))) XCTAssertEqual( urlReplacingSize?.url.absoluteString, "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674?s=24" ) - let urlReplacingRating = avatarUrl.replacing(options: ImageQueryOptions(rating: .parentalGuidance)) + let urlReplacingRating = avatarUrl.replacing(options: AvatarQueryOptions(rating: .parentalGuidance)) XCTAssertEqual( urlReplacingRating?.url.absoluteString, "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674?r=pg" ) - let urlReplacingForceDefault = avatarUrl.replacing(options: ImageQueryOptions(forceDefaultImage: true)) + let urlReplacingForceDefault = avatarUrl.replacing(options: AvatarQueryOptions(forceDefaultAvatar: true)) XCTAssertEqual( urlReplacingForceDefault?.url.absoluteString, "https://gravatar.com/avatar/676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674?f=y" ) - let allOptions = ImageQueryOptions( + let allOptions = AvatarQueryOptions( preferredSize: .pixels(200), rating: .general, - defaultImageOption: .monsterId, - forceDefaultImage: true + defaultAvatarOption: .monsterId, + forceDefaultAvatar: true ) let urlReplacingAllOptions = avatarUrl.replacing(options: allOptions) XCTAssertEqual( @@ -103,9 +103,9 @@ final class AvatarURLTests: XCTestCase { } func testCreateAvatarByUpdatingOptions() { - let avatarUrl = AvatarURL(hash: "HASH", options: ImageQueryOptions(defaultImageOption: .fileNotFound)) + let avatarUrl = AvatarURL(hash: "HASH", options: AvatarQueryOptions(defaultAvatarOption: .status404)) XCTAssertEqual(avatarUrl?.url.absoluteString, "https://gravatar.com/avatar/HASH?d=404") - let updatedAvatarUrl = avatarUrl?.replacing(options: ImageQueryOptions(rating: .parentalGuidance)) + let updatedAvatarUrl = avatarUrl?.replacing(options: AvatarQueryOptions(rating: .parentalGuidance)) XCTAssertEqual(updatedAvatarUrl?.url.absoluteString, "https://gravatar.com/avatar/HASH?r=pg") } @@ -131,7 +131,7 @@ final class AvatarURLTests: XCTestCase { XCTAssertNotEqual(lhs, rhs) } - func verifiedAvatarURL(options: ImageQueryOptions = ImageQueryOptions()) -> AvatarURL { + func verifiedAvatarURL(options: AvatarQueryOptions = AvatarQueryOptions()) -> AvatarURL { AvatarURL(url: verifiedAvatarURL, options: options)! } } From 11f8f2df58a8e5e81bdd5d9a6c13f67ce9ab96bd Mon Sep 17 00:00:00 2001 From: Eduardo Toledo Date: Tue, 26 Mar 2024 09:26:16 +0100 Subject: [PATCH 08/10] Making `ProfileURL` init failable to remove fatalError on URL creation. --- Sources/Gravatar/ProfileURL.swift | 18 ++++++++++-------- Tests/GravatarTests/ProfileURLTests.swift | 12 ++++++------ 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/Sources/Gravatar/ProfileURL.swift b/Sources/Gravatar/ProfileURL.swift index 0dd32417..e15dff9a 100644 --- a/Sources/Gravatar/ProfileURL.swift +++ b/Sources/Gravatar/ProfileURL.swift @@ -7,24 +7,26 @@ public struct ProfileURL { AvatarURL(hash: hash) } - static let baseURL: URL = { + static let baseURL: URL? = { guard let baseURL = URL(string: .baseURL), - let components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false), - let url = components.url + let components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { - fatalError("A url created from a correct literal string should never fail") + return nil } - return url + return components.url }() - public init(email: String) { + public init?(email: String) { let hash = email.sanitized.sha256() self.init(hash: hash) } - public init(hash: String) { - self.url = Self.baseURL.appending(pathComponent: hash) + public init?(hash: String) { + guard let url = Self.baseURL?.appending(pathComponent: hash) else { + return nil + } + self.url = url self.hash = hash } } diff --git a/Tests/GravatarTests/ProfileURLTests.swift b/Tests/GravatarTests/ProfileURLTests.swift index e07d6765..cfc2e14c 100644 --- a/Tests/GravatarTests/ProfileURLTests.swift +++ b/Tests/GravatarTests/ProfileURLTests.swift @@ -8,33 +8,33 @@ final class ProfileURLTests: XCTestCase { func testProfileUrlWithEmail() { let profileUrl = ProfileURL(email: email) - XCTAssertEqual(profileUrl.url.absoluteString, urlFromEmail.absoluteString) + XCTAssertEqual(profileUrl?.url.absoluteString, urlFromEmail.absoluteString) } func testProfileUrlHashWithEmail() { let profileUrl = ProfileURL(email: email) - XCTAssertEqual(profileUrl.hash, hashFromEmail) + XCTAssertEqual(profileUrl?.hash, hashFromEmail) } func testProfileUrlWithHash() { let profileUrl = ProfileURL(hash: hashFromEmail) - XCTAssertEqual(profileUrl.url.absoluteString, urlFromEmail.absoluteString) + XCTAssertEqual(profileUrl?.url.absoluteString, urlFromEmail.absoluteString) } func testAvatarURLFromProfileUrl() { let profileUrl = ProfileURL(email: email) - XCTAssertEqual(profileUrl.avatarURL, AvatarURL(email: email)) + XCTAssertEqual(profileUrl?.avatarURL, AvatarURL(email: email)) } func testProfileUrlWithEmailWithInvalidCharactersWontCrash() { let profileUrl = ProfileURL(email: "πŸ˜‰β‡Άβ–β‚§β„Έβ„βŽœβ™˜Β§@…./+_ =-\\][|}{~`23πŸ₯‘") - XCTAssertEqual(profileUrl.hash, "d8bf26df33ebe638f5ad553aedc6df15e67e7e64f3f21e21c03223877a9290c9") + XCTAssertEqual(profileUrl?.hash, "d8bf26df33ebe638f5ad553aedc6df15e67e7e64f3f21e21c03223877a9290c9") } func testProfileUrlWithHashWithInvalidCharactersWontCrash() { let profileUrl = ProfileURL(hash: "πŸ˜‰β‡Άβ–β‚§β„Έβ„βŽœβ™˜Β§@…./+_ =-\\][|}{~`23πŸ₯‘") XCTAssertEqual( - profileUrl.url.absoluteString, + profileUrl?.url.absoluteString, "https://gravatar.com/%F0%9F%98%89%E2%87%B6%E2%9D%96%E2%82%A7%E2%84%B8%E2%84%8F%E2%8E%9C%E2%99%98%C2%A7@%E2%80%A6./+_%20=-%5C%5D%5B%7C%7D%7B~%6023%F0%9F%A5%A1" ) } From 50d9b4c31067e1d7a65cde39fccc265541b7cec2 Mon Sep 17 00:00:00 2001 From: Eduardo Toledo Date: Tue, 26 Mar 2024 09:31:01 +0100 Subject: [PATCH 09/10] Moving `URLComponents.sanitizingComponents` to the correct file --- Sources/Gravatar/AvatarURL.swift | 10 ++++++++++ Sources/Gravatar/ProfileURL.swift | 10 ---------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Sources/Gravatar/AvatarURL.swift b/Sources/Gravatar/AvatarURL.swift index fcc782b8..edd06361 100644 --- a/Sources/Gravatar/AvatarURL.swift +++ b/Sources/Gravatar/AvatarURL.swift @@ -58,6 +58,7 @@ extension AvatarURL: Equatable { } extension String { + fileprivate static let scheme = "https" fileprivate static let baseURL = "https://gravatar.com/avatar/" } @@ -75,3 +76,12 @@ extension URL { return components.url } } + +extension URLComponents { + fileprivate func sanitizingComponents() -> URLComponents { + var copy = self + copy.scheme = .scheme + copy.query = nil + return copy + } +} diff --git a/Sources/Gravatar/ProfileURL.swift b/Sources/Gravatar/ProfileURL.swift index e15dff9a..c127126f 100644 --- a/Sources/Gravatar/ProfileURL.swift +++ b/Sources/Gravatar/ProfileURL.swift @@ -31,16 +31,6 @@ public struct ProfileURL { } } -extension URLComponents { - func sanitizingComponents() -> URLComponents { - var copy = self - copy.scheme = .scheme - copy.query = nil - return copy - } -} - extension String { - fileprivate static let scheme = "https" fileprivate static let baseURL = "https://gravatar.com/" } From 5ccbef862cc4850da96c17788bd66bf6e5ac9b17 Mon Sep 17 00:00:00 2001 From: etoledom Date: Tue, 26 Mar 2024 09:31:36 +0100 Subject: [PATCH 10/10] Update Tests/GravatarTests/AvatarURLTests.swift Co-authored-by: Andrew Montgomery --- Tests/GravatarTests/AvatarURLTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/GravatarTests/AvatarURLTests.swift b/Tests/GravatarTests/AvatarURLTests.swift index ffc23219..06f9a4be 100644 --- a/Tests/GravatarTests/AvatarURLTests.swift +++ b/Tests/GravatarTests/AvatarURLTests.swift @@ -8,7 +8,7 @@ final class AvatarURLTests: XCTestCase { let exampleEmail = "some@email.com" let exampleEmailSHA = "676212ff796c79a3c06261eb10e3f455aa93998ee6e45263da13679c74b1e674" - func testisAvatarUrl() throws { + func testIsAvatarUrl() throws { XCTAssertTrue(AvatarURL.isAvatarUrl(verifiedAvatarURL)) XCTAssertTrue(AvatarURL.isAvatarUrl(verifiedAvatarURL2)) XCTAssertFalse(AvatarURL.isAvatarUrl(URL(string: "https://gravatar.com/")!))