[Library] iTunes library (#5)

This PR contains the work done to implement the `iTunesService` service that fetches the reviews from the **Apple App Store**.

Reviewed-on: #5
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
Javier Cicchelli 2024-03-17 22:48:27 +00:00 committed by Javier Cicchelli
parent 4359f53a19
commit 7c016b50d6
18 changed files with 850 additions and 3 deletions

View File

@ -6,7 +6,7 @@
// Copyright © 2024 Röck+Cöde VoF. All rights reserved. // Copyright © 2024 Röck+Cöde VoF. All rights reserved.
// //
public enum EndpointError: Error { public enum EndpointError: Error, Equatable {
case inputParametersEmpty case inputParametersEmpty
case requestFailed(statusCode: Int) case requestFailed(statusCode: Int)
case responseNotFound case responseNotFound

View File

@ -39,3 +39,22 @@ public struct Review {
} }
} }
// MARK: - Equatable
extension Review: Equatable {
// MARK: Functions
public static func == (
lhs: Review,
rhs: Review
) -> Bool {
lhs.author == rhs.author
&& lhs.content == rhs.content
&& lhs.id == rhs.id
&& lhs.rating == rhs.rating
&& lhs.title == rhs.title
&& lhs.version == rhs.version
&& lhs.updated.description == rhs.updated.description
}
}

View File

@ -0,0 +1,90 @@
//
// MockURLProtocol.swift
// ReviewsFoundationKit
//
// Created by Javier Cicchelli on 17/03/2024.
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
//
import Foundation
public class MockURLProtocol: URLProtocol {
// MARK: Properties
public static var response: Response?
// MARK: Functions
public override class func canInit(with task: URLSessionTask) -> Bool {
true
}
public override class func canInit(with request: URLRequest) -> Bool {
true
}
public override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
public override func startLoading() {
guard
let url = request.url,
let response = Self.response
else {
client?.urlProtocolDidFinishLoading(self)
return
}
if let data = try? response.data {
client?.urlProtocol(self, didLoad: data)
}
if let httpResponse = HTTPURLResponse(
url: url,
statusCode: response.statusCode,
httpVersion: nil,
headerFields: nil
) {
client?.urlProtocol(
self,
didReceive: httpResponse,
cacheStoragePolicy: .allowedInMemoryOnly
)
}
client?.urlProtocolDidFinishLoading(self)
}
public override func stopLoading() {}
}
// MARK: - Structs
extension MockURLProtocol {
public struct Response {
// MARK: Constants
public let statusCode: Int
public let object: (any Encodable)?
// MARK: Initialisers
public init(
statusCode: Int,
object: (any Codable)? = nil
) {
self.statusCode = statusCode
self.object = object
}
// MARK: Computed
var data: Data? {
get throws {
guard let object else { return nil }
return try JSONEncoder.default.encode(object)
}
}
}
}

View File

@ -0,0 +1,22 @@
//
// JSONDecoder+Constants.swift
// ReviewsiTunesKit
//
// Created by Javier Cicchelli on 17/03/2024.
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
//
import Foundation
extension JSONDecoder {
// MARK: Constants
public static let `default` = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}()
}

View File

@ -0,0 +1,22 @@
//
// JSONEncoder+Constants.swift
// ReviewsiTunesKit
//
// Created by Javier Cicchelli on 17/03/2024.
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
//
import Foundation
extension JSONEncoder {
// MARK: Constants
public static let `default` = {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return encoder
}()
}

View File

@ -0,0 +1,29 @@
//
// URL+Constants.swift
// ReviewsFoundationKit
//
// Created by Javier Cicchelli on 17/03/2024.
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
//
import Foundation
extension URL {
// MARK: Constants
public static let bitBucket: URL = {
if #available(iOS 16.0, *) {
.init(filePath: .FilePath.bitBucket)
} else {
.init(fileURLWithPath: .FilePath.bitBucket)
}
}()
}
// MARK: - String+Constants
private extension String {
enum FilePath {
static let bitBucket = "/dev/null"
}
}

View File

@ -0,0 +1,22 @@
//
// URLSessionConfiguration+Constants.swift
// ReviewsFoundationKit
//
// Created by Javier Cicchelli on 17/03/2024.
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
//
import Foundation
extension URLSessionConfiguration {
// MARK: Constants
public static let mock = {
let configuration: URLSessionConfiguration = .ephemeral
configuration.protocolClasses = [MockURLProtocol.self]
return configuration
}()
}

View File

@ -6,7 +6,7 @@
// Copyright © 2024 Röck+Cöde VoF. All rights reserved. // Copyright © 2024 Röck+Cöde VoF. All rights reserved.
// //
import ReviewsFoundation import ReviewsFoundationKit
import XCTest import XCTest
final class String_ConstantsTests: XCTestCase { final class String_ConstantsTests: XCTestCase {
@ -15,7 +15,7 @@ final class String_ConstantsTests: XCTestCase {
private var sut: String! private var sut: String!
// MARK: Constants tests // MARK: Constants tests
func testEmpty() throws { func testEmpty() {
// GIVEN // GIVEN
sut = .empty sut = .empty

View File

@ -0,0 +1,27 @@
//
// URL+ConstantsTests.swift
// ReviewsFoundationTest
//
// Created by Javier Cicchelli on 17/03/2024.
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
//
import Foundation
import ReviewsFoundationKit
import XCTest
final class URL_ConstantsTests: XCTestCase {
// MARK: Properties
private var sut: URL!
// MARK: Constants tests
func testBitBucket() {
sut = .bitBucket
// WHEN
// THEN
XCTAssertEqual(sut.absoluteString, "file:///dev/null")
}
}

View File

@ -0,0 +1,70 @@
//
// GetReviewsAPIEndpoint.swift
// ReviewsiTunesKit
//
// Created by Javier Cicchelli on 17/03/2024.
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
//
import Foundation
import ReviewsFoundationKit
import ReviewsFeedKit
struct GetReviewsAPIEndpoint: GetReviewsEndpoint {
// MARK: Constants
let host: URL
let decoder: JSONDecoder
let session: URLSession
// MARK: Functions
@discardableResult func callAsFunction(
_ input: ReviewsFeedKit.GetReviewsInput
) async throws -> ReviewsFeedKit.GetReviewsOutput {
let path = try makePath(with: input)
let url = {
if #available(iOS 16.0, *) {
host.appending(path: path)
} else {
host.appendingPathComponent(path)
}
}()
let (data, response) = try await session.data(from: url)
guard let urlResponse = response as? HTTPURLResponse else {
throw EndpointError.responseNotFound
}
guard urlResponse.statusCode == 200 else {
throw EndpointError.requestFailed(statusCode: urlResponse.statusCode)
}
let feed = try decoder.decode(Feed.self, from: data)
return .init(reviews: feed.entries)
}
func makePath(with input: GetReviewsInput) throws -> String {
guard
!input.appID.isEmpty,
!input.countryCode.isEmpty
else{
throw EndpointError.inputParametersEmpty
}
return .init(
format: .Format.recentReviewsPath,
input.countryCode,
input.appID
)
}
}
// MARK: - String+Formats
private extension String {
enum Format {
static let recentReviewsPath = "/%@/rss/customerreviews/id=%@/sortby=mostrecent/json"
}
}

View File

@ -0,0 +1,24 @@
//
// ServiceConfiguration+Inits.swift
// ReviewsiTunesKit
//
// Created by Javier Cicchelli on 17/03/2024.
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
//
import Foundation
import ReviewsFoundationKit
import ReviewsFeedKit
extension ServiceConfiguration {
// MARK: Initialisers
init(session: URLSessionConfiguration = .ephemeral) {
self.init(
host: .iTunes,
session: session,
decoder: .default
)
}
}

View File

@ -0,0 +1,17 @@
//
// URL+Constants.swift
// ReviewsiTunesKit
//
// Created by Javier Cicchelli on 17/03/2024.
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
//
import Foundation
import ReviewsFoundationKit
extension URL {
// MARK: Constants
static let iTunes: URL = .init(string: "https://itunes.apple.com") ?? .bitBucket
}

View File

@ -0,0 +1,51 @@
//
// Feed.swift
// ReviewsiTunesKit
//
// Created by Javier Cicchelli on 17/03/2024.
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
//
import ReviewsFeedKit
struct Feed {
// MARK: Constants
let entries: [Review]
}
// MARK: - Decodable
extension Feed: Decodable {
// MARK: Enumerations
enum FeedKeys: String, CodingKey {
case feed
}
enum EntryKeys: String, CodingKey {
case entry
}
// MARK: Initialisers
init(from decoder: any Decoder) throws {
let feed = try decoder.container(keyedBy: FeedKeys.self)
let feedEntry = try feed.nestedContainer(keyedBy: EntryKeys.self, forKey: .feed)
self.entries = try feedEntry.decode([Review].self, forKey: .entry)
}
}
// MARK: - Encodable
extension Feed: Encodable {
// MARK: Functions
func encode(to encoder: any Encoder) throws {
var feed = encoder.container(keyedBy: FeedKeys.self)
var feedEntry = feed.nestedContainer(keyedBy: EntryKeys.self, forKey: .feed)
try feedEntry.encode(entries, forKey: .entry)
}
}

View File

@ -0,0 +1,84 @@
//
// Review+Codable.swift
// ReviewsiTunesKit
//
// Created by Javier Cicchelli on 17/03/2024.
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
//
import Foundation
import ReviewsFeedKit
// MARK: - Decodable
extension Review: Decodable {
// MARK: Enumerations
enum ReviewKeys: String, CodingKey {
case author
case content
case id
case rating = "im:rating"
case title
case updated
case version = "im:version"
}
enum NameKeys: String, CodingKey {
case name
}
enum LabelKeys: String, CodingKey {
case label
}
// MARK: Initialisers
public init(from decoder: any Decoder) throws {
let review = try decoder.container(keyedBy: ReviewKeys.self)
let authorName = try review.nestedContainer(keyedBy: NameKeys.self, forKey: .author)
let authorLabel = try authorName.nestedContainer(keyedBy: LabelKeys.self, forKey: .name)
let contentLabel = try review.nestedContainer(keyedBy: LabelKeys.self, forKey: .content)
let idLabel = try review.nestedContainer(keyedBy: LabelKeys.self, forKey: .id)
let ratingLabel = try review.nestedContainer(keyedBy: LabelKeys.self, forKey: .rating)
let titleLabel = try review.nestedContainer(keyedBy: LabelKeys.self, forKey: .title)
let versionLabel = try review.nestedContainer(keyedBy: LabelKeys.self, forKey: .version)
let updatedLabel = try review.nestedContainer(keyedBy: LabelKeys.self, forKey: .updated)
self.init(
id: Int(try idLabel.decode(String.self, forKey: .label)) ?? 0,
author: try authorLabel.decode(String.self, forKey: .label),
title: try titleLabel.decode(String.self, forKey: .label),
content: try contentLabel.decode(String.self, forKey: .label),
rating: Int(try ratingLabel.decode(String.self, forKey: .label)) ?? 0,
version: try versionLabel.decode(String.self, forKey: .label),
updated: try updatedLabel.decode(Date.self, forKey: .label)
)
}
}
// MARK: - Encodable
extension Review: Encodable {
// MARK: Functions
public func encode(to encoder: any Encoder) throws {
var review = encoder.container(keyedBy: ReviewKeys.self)
var authorName = review.nestedContainer(keyedBy: NameKeys.self, forKey: .author)
var authorLabel = authorName.nestedContainer(keyedBy: LabelKeys.self, forKey: .name)
var contentLabel = review.nestedContainer(keyedBy: LabelKeys.self, forKey: .content)
var idLabel = review.nestedContainer(keyedBy: LabelKeys.self, forKey: .id)
var ratingLabel = review.nestedContainer(keyedBy: LabelKeys.self, forKey: .rating)
var titleLabel = review.nestedContainer(keyedBy: LabelKeys.self, forKey: .title)
var versionLabel = review.nestedContainer(keyedBy: LabelKeys.self, forKey: .version)
var updatedLabel = review.nestedContainer(keyedBy: LabelKeys.self, forKey: .updated)
try authorLabel.encode(author, forKey: .label)
try contentLabel.encode(content, forKey: .label)
try idLabel.encode(String(id), forKey: .label)
try ratingLabel.encode(String(rating), forKey: .label)
try titleLabel.encode(title, forKey: .label)
try versionLabel.encode(version, forKey: .label)
try updatedLabel.encode(updated, forKey: .label)
}
}

View File

@ -0,0 +1,43 @@
//
// iTunesService.swift
// ReviewsiTunes
//
// Created by Javier Cicchelli on 17/03/2024.
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
//
import Foundation
import ReviewsFeedKit
public actor iTunesService: Service {
// MARK: Constants
public let configuration: ServiceConfiguration
public let decoder: JSONDecoder
public let session: URLSession
// MARK: Properties
private lazy var getReviewsEndpoint: GetReviewsAPIEndpoint = {
.init(
host: configuration.host,
decoder: decoder,
session: session
)
}()
// MARK: Initialisers
public init(configuration: ServiceConfiguration) {
self.configuration = configuration
self.decoder = configuration.decoder
self.session = .init(configuration: configuration.session)
}
// MARK: Functions
@discardableResult public func getReviews(
_ input: GetReviewsInput
) async throws -> GetReviewsOutput {
try await getReviewsEndpoint(input)
}
}

View File

@ -0,0 +1,58 @@
//
// Array+Reviews.swift
// ReviewsiTunesTest
//
// Created by Javier Cicchelli on 17/03/2024.
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
//
import ReviewsFeedKit
extension Array where Element == Review {
// MARK: Constants
static let empty: [Review] = []
static let many: [Review] = [
.init(
id: 1,
author: "Some author name #1 goes here...",
title: "Some title #1 goes here...",
content: "Some content #1 goes here...",
rating: 1,
version: "Some version #1 goes here...",
updated: .init()
),
.init(
id: 2,
author: "Some author name #2 goes here...",
title: "Some title #2 goes here...",
content: "Some content #2 goes here...",
rating: 5,
version: "Some version #2 goes here...",
updated: .init()
),
.init(
id: 3,
author: "Some author name #3 goes here...",
title: "Some title #3 goes here...",
content: "Some content #3 goes here...",
rating: 3,
version: "Some version #3 goes here...",
updated: .init()
)
]
static let one: [Review] = [
.init(
id: 1,
author: "Some author name goes here...",
title: "Some title goes here...",
content: "Some content goes here...",
rating: 3,
version: "Some version goes here...",
updated: .init()
)
]
}

View File

@ -0,0 +1,161 @@
//
// GetReviewsAPIEndpointTests.swift
// ReviewsiTunesTest
//
// Created by Javier Cicchelli on 17/03/2024.
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
//
import ReviewsFeedKit
import ReviewsFoundationKit
import XCTest
@testable import ReviewsiTunesKit
final class GetReviewsAPIEndpointTests: XCTestCase {
// MARK: Properties
private var input: GetReviewsAPIEndpoint.Input!
private var sut: GetReviewsAPIEndpoint!
// MARK: Setup
override func setUp() async throws {
sut = .init(
host: .iTunes,
decoder: .default,
session: .init(configuration: .mock)
)
}
// MARK: Functions tests
func testCallAsFunction_whenResponseOK_withOneReview() async throws {
// GIVEN
let reviews: [Review] = .one
input = .init(
appID: "1234567890",
countryCode: "abc"
)
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: reviews)
)
// WHEN
let output = try await sut(input)
// THEN
XCTAssertFalse(output.reviews.isEmpty)
XCTAssertEqual(output.reviews, reviews)
}
func testCallAsFunction_whenResponseOK_withManyReviews() async throws {
// GIVEN
let reviews: [Review] = .many
input = .init(
appID: "1234567890",
countryCode: "abc"
)
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: reviews)
)
// WHEN
let output = try await sut(input)
// THEN
XCTAssertFalse(output.reviews.isEmpty)
XCTAssertEqual(output.reviews, reviews)
}
func testCallAsFunction_whenResponseOK_withEmptyReviews() async throws {
// GIVEN
let reviews: [Review] = .empty
input = .init(
appID: "1234567890",
countryCode: "abc"
)
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: reviews)
)
// WHEN
let output = try await sut(input)
// THEN
XCTAssertTrue(output.reviews.isEmpty)
XCTAssertEqual(output.reviews, reviews)
}
func testCallAsFunction_whenResponseNotOK() async throws {
// GIVEN
let statusCode = 404
input = .init(
appID: "1234567890",
countryCode: "abc"
)
MockURLProtocol.response = .init(statusCode: statusCode)
// WHEN
// THEN
do {
try await sut(input)
} catch EndpointError.requestFailed(statusCode: statusCode) {
XCTAssertTrue(true)
} catch {
XCTAssertTrue(false)
}
}
func testMakePath_withFilledInput() throws {
// GIVEN
input = .init(
appID: "1234567890",
countryCode: "abc"
)
// WHEN
let path = try sut.makePath(with: input)
// THEN
XCTAssertEqual(path, "/\(input.countryCode)/rss/customerreviews/id=\(input.appID)/sortby=mostrecent/json")
}
func testMakePath_withPartiallyFilledInput() throws {
// GIVEN
input = .init(
appID: .empty,
countryCode: "abc"
)
// WHEN
// THEN
XCTAssertThrowsError(try sut.makePath(with: input)) { error in
XCTAssertEqual(error as? EndpointError, .inputParametersEmpty)
}
}
func testMakePath_withEmptyInput() throws {
// GIVEN
input = .init(
appID: .empty,
countryCode: .empty
)
// WHEN
// THEN
XCTAssertThrowsError(try sut.makePath(with: input)) { error in
XCTAssertEqual(error as? EndpointError, .inputParametersEmpty)
}
}
}

View File

@ -0,0 +1,108 @@
//
// iTunesServiceTests.swift
// ReviewsiTunesTest
//
// Created by Javier Cicchelli on 17/03/2024.
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
//
import ReviewsFeedKit
import ReviewsFoundationKit
import XCTest
@testable import ReviewsiTunesKit
final class iTunesServiceTests: XCTestCase {
// MARK: Properties
private var sut: iTunesService!
// MARK: Setup
override func setUp() async throws {
sut = .init(configuration: .init(session: .mock))
}
// MARK: Functions
func testGetReviews_whenResponseOK_withOneReview() async throws {
// GIVEN
let reviews: [Review] = .one
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: reviews)
)
// WHEN
let output = try await sut.getReviews(.init(
appID: "1234567890",
countryCode: "abc"
))
// THEN
XCTAssertFalse(output.reviews.isEmpty)
XCTAssertEqual(output.reviews.count, reviews.count)
XCTAssertEqual(output.reviews, reviews)
}
func testGetReviews_whenResponseOK_withManyReview() async throws {
// GIVEN
let reviews: [Review] = .many
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: reviews)
)
// WHEN
let output = try await sut.getReviews(.init(
appID: "1234567890",
countryCode: "abc"
))
// THEN
XCTAssertFalse(output.reviews.isEmpty)
XCTAssertEqual(output.reviews.count, reviews.count)
XCTAssertEqual(output.reviews, reviews)
}
func testGetReviews_whenResponseOK_withEmptyReview() async throws {
// GIVEN
let reviews: [Review] = .empty
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: reviews)
)
// WHEN
let output = try await sut.getReviews(.init(
appID: "1234567890",
countryCode: "abc"
))
// THEN
XCTAssertTrue(output.reviews.isEmpty)
XCTAssertEqual(output.reviews, reviews)
}
func testGetReviews_whenResponseNotOK() async throws {
// GIVEN
let statusCode = 404
MockURLProtocol.response = .init(statusCode: statusCode)
// WHEN
// THEN
do {
try await sut.getReviews(.init(
appID: "1234567890",
countryCode: "abc"
))
} catch EndpointError.requestFailed(statusCode: statusCode) {
XCTAssertTrue(true)
} catch {
XCTAssertTrue(false)
}
}
}