diff --git a/.gitignore b/.gitignore index e69de29..52fe2f7 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,62 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output diff --git a/Meow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..7c5d580 --- /dev/null +++ b/Meow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "b5a4d1a917ee731eae9d288a993a1e905ecfa9e87cb169c76452216c83b5b4f2", + "pins" : [ + { + "identity" : "mocker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/WeTransfer/Mocker", + "state" : { + "revision" : "95fa785c751f6bc40c49e112d433c3acf8417a97", + "version" : "3.0.2" + } + } + ], + "version" : 3 +} diff --git a/Meow.xcodeproj/project.xcworkspace/xcuserdata/bkreeger.xcuserdatad/UserInterfaceState.xcuserstate b/Meow.xcodeproj/project.xcworkspace/xcuserdata/bkreeger.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index aeb479c..0000000 Binary files a/Meow.xcodeproj/project.xcworkspace/xcuserdata/bkreeger.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/Meow.xcodeproj/xcuserdata/bkreeger.xcuserdatad/xcschemes/xcschememanagement.plist b/Meow.xcodeproj/xcuserdata/bkreeger.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index d3f60a2..0000000 --- a/Meow.xcodeproj/xcuserdata/bkreeger.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,32 +0,0 @@ - - - - - SchemeUserState - - App.xcscheme_^#shared#^_ - - orderHint - 0 - - - SuppressBuildableAutocreation - - C3BB763A2ED0C38800D56534 - - primary - - - C3BB76472ED0C38900D56534 - - primary - - - C3BB76512ED0C38900D56534 - - primary - - - - - diff --git a/Meow/.gitignore b/Meow/.gitignore deleted file mode 100644 index 0023a53..0000000 --- a/Meow/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.DS_Store -/.build -/Packages -xcuserdata/ -DerivedData/ -.swiftpm/configuration/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/Meow/Package.swift b/Meow/Package.swift index f410d81..3c48479 100644 --- a/Meow/Package.swift +++ b/Meow/Package.swift @@ -3,6 +3,14 @@ import PackageDescription +let commonSwiftSettings: [SwiftSetting] = [ + .enableExperimentalFeature("StrictConcurrency"), + .enableUpcomingFeature("InferIsolatedConformances"), + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .defaultIsolation(nil), + .swiftLanguageMode(.v6) +] + let package = Package( name: "Meow", platforms: [.iOS(.v18), .macOS(.v15)], @@ -13,15 +21,26 @@ let package = Package( targets: ["Meow"] ), ], + dependencies: [ + .package(url: "https://github.com/WeTransfer/Mocker", from: "3.0.2"), + ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( - name: "Meow" + name: "Meow", + swiftSettings: commonSwiftSettings ), .testTarget( name: "MeowTests", - dependencies: ["Meow"] + dependencies: [ + "Meow", + "Mocker" + ], + resources: [ + .process("Fixtures") + ], + swiftSettings: commonSwiftSettings ), ] ) diff --git a/Meow/Sources/Meow/Meow.swift b/Meow/Sources/Meow/Meow.swift index 0a53ca7..f9a08dd 100644 --- a/Meow/Sources/Meow/Meow.swift +++ b/Meow/Sources/Meow/Meow.swift @@ -7,30 +7,37 @@ import Foundation public struct Meow { let baseURL: URL - let session: URLSession + let urlSession: URLSession - public init(baseURL: URL, session: URLSession = .shared) { + public init(baseURL: URL, urlSession: URLSession = .shared) { self.baseURL = baseURL - self.session = session + self.urlSession = urlSession } - public func getFacts() async throws -> [String] { - let request = try generateRequest(path: "/") + public func getFacts(count: Int? = nil) async throws -> [String] { + let params = count.map { ["count": $0] } ?? [:] + let request = try generateRequest(path: "/", params: params) let response: FactsResponse = try await decodeRequest(request: request) return response.data } // MARK: - Private functionality - private func generateRequest(path: String) throws -> URLRequest { + private func generateRequest(path: String, params: [String: CustomStringConvertible] = [:]) throws -> URLRequest { guard let url = URL(string: path, relativeTo: baseURL) else { throw MeowError.requestError("Couldn't generate URL from path: \(path), baseURL: \(baseURL)") } - return URLRequest(url: url) + + let queryItems = params + .compactMap { URLQueryItem(name: $0, value: $1.description) } + .sorted { a, b in a.name < b.name } + return queryItems.count > 0 + ? URLRequest(url: url.appending(queryItems: queryItems)) + : URLRequest(url: url) } private func decodeRequest(request: URLRequest) async throws -> T { - let (data, response) = try await session.data(for: request) + let (data, response) = try await urlSession.data(for: request) guard let response = response as? HTTPURLResponse else { throw MeowError.connectionError("Couldn't get HTTP response from request \(request)") } diff --git a/Meow/Tests/MeowTests/Fixtures.swift b/Meow/Tests/MeowTests/Fixtures.swift new file mode 100644 index 0000000..dbd192c --- /dev/null +++ b/Meow/Tests/MeowTests/Fixtures.swift @@ -0,0 +1,20 @@ +// +// Fixtures.swift +// MeowTests +// + +import Foundation + +final class Fixtures { + static let facts: Data = loadFixture("facts") + + private static func loadFixture(_ name: String) -> Data { + guard let url = Bundle.module.url(forResource: name, withExtension: "json") else { + fatalError("Failed to load fixture: \(name).json") + } + guard let data = try? Data(contentsOf: url) else { + fatalError("Unable to get Data from contents of \(url)") + } + return data + } +} diff --git a/Meow/Tests/MeowTests/Fixtures/facts.json b/Meow/Tests/MeowTests/Fixtures/facts.json new file mode 100644 index 0000000..4b26196 --- /dev/null +++ b/Meow/Tests/MeowTests/Fixtures/facts.json @@ -0,0 +1,9 @@ +{ + "data": [ + "Abraham Lincoln loved cats. He had four of them while he lived in the White House.", + "Many cats cannot properly digest cows milk. Milk and milk products give them diarrhea.", + "Tylenol and chocolate are both poisonous to cats.", + "The average cat food meal is the equivalent to about five mice.", + "The way you treat kittens in the early stages of it's life will render it's personality traits later in life." + ] +} diff --git a/Meow/Tests/MeowTests/MeowTests.swift b/Meow/Tests/MeowTests/MeowTests.swift index 1456482..c442d0e 100644 --- a/Meow/Tests/MeowTests/MeowTests.swift +++ b/Meow/Tests/MeowTests/MeowTests.swift @@ -5,15 +5,45 @@ import Foundation import Testing +import Mocker @testable import Meow @Suite("Meow Tests") struct MeowTests { private let baseURL = URL(string: "https://meow.meow")! + let urlSession: URLSession = { + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockingURLProtocol.self] + return URLSession(configuration: configuration) + }() @Test func constructor() async throws { - let instance = Meow(baseURL: baseURL) + let instance = vendInstance() #expect(instance.baseURL == baseURL) } + + @Test + func getFacts() async throws { + let instance = vendInstance() + let count = 5 + mockPath("/", queryItems: ["count": count], data: [.get: Fixtures.facts]) + let facts = try await instance.getFacts(count: count) + #expect(facts.count == count) + } + + // MARK: - Private functionality + + private func vendInstance() -> Meow { + return Meow(baseURL: baseURL, urlSession: urlSession) + } + + private func mockPath(_ path: String, headers: [String: String] = [:], queryItems: [String: CustomStringConvertible] = [:], data: [Mock.HTTPMethod: Data] = [:]) { + let urlQueryItems = queryItems + .compactMap({ URLQueryItem(name: $0, value: $1.description) }) + .sorted { a, b in a.name < b.name } + let base = baseURL.appendingPathComponent(path) + let url = urlQueryItems.isEmpty ? base : base.appending(queryItems: urlQueryItems) + Mock(url: url, ignoreQuery: false, contentType: .json, statusCode: 200, data: data, additionalHeaders: headers).register() + } }