diff --git a/.github/workflows/carthage.yml b/.github/workflows/carthage.yml index 7a580a8..f3028cb 100644 --- a/.github/workflows/carthage.yml +++ b/.github/workflows/carthage.yml @@ -2,21 +2,23 @@ name: Carthage on: push: - branches: [ master, develop ] + branches: [main, develop] pull_request: - branches: [ master, develop ] jobs: build: - runs-on: macOS-latest + runs-on: macos-26 strategy: matrix: - destination: ['platform=iOS Simulator,OS=13.1,name=iPhone 8'] + destination: ['platform=iOS Simulator,OS=26.0,name=iPhone 17 Pro'] steps: - - uses: actions/checkout@master + - uses: actions/checkout@v4 + - name: Select Xcode 26.3 + run: sudo xcode-select -s /Applications/Xcode_26.3.app || sudo xcode-select -s /Applications/Xcode.app + - name: Install Carthage + run: brew install carthage || true - name: carthage build run: | echo cache-builds to work around: https://github.com/Carthage/Carthage/issues/2555 carthage build --cache-builds --no-skip-current --verbose --use-xcframeworks shell: bash - diff --git a/.github/workflows/cocoapods.yml b/.github/workflows/cocoapods.yml deleted file mode 100644 index 7dca15e..0000000 --- a/.github/workflows/cocoapods.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Cocoapods - -on: - push: - branches: [ master, develop ] - pull_request: - branches: [ master, develop ] - -jobs: - lint: - runs-on: macOS-latest - steps: - - uses: actions/checkout@master - - name: pod lib lint - run: | - echo allow-warnings due to - https://github.com/CocoaPods/CocoaPods/issues/8570 - pod lib lint --allow-warnings - shell: bash - diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 5b403ea..0a86036 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -1,22 +1,38 @@ -name: Swift Package Manager +name: Swift Build & Test on: push: - branches: [master, develop] + branches: [main, develop] pull_request: - branches: [master, develop] jobs: - build: - runs-on: macos-latest + swift-version-check: + runs-on: macos-26 steps: - - uses: actions/checkout@v2 - - name: Build - run: swift build - test: - runs-on: macos-latest + - uses: actions/checkout@v4 + - name: Select Xcode 26.3 + run: sudo xcode-select -s /Applications/Xcode_26.3.app || sudo xcode-select -s /Applications/Xcode.app + - name: Check Swift version + run: swift --version + - name: Build with strict concurrency checks + run: swift build -Xswiftc -strict-concurrency=complete + + lint: + runs-on: macos-26 steps: - - uses: actions/checkout@v2 - - name: Test - run: swift test + - uses: actions/checkout@v4 + - name: Install SwiftLint + run: brew install swiftlint + - name: Run SwiftLint + run: swiftlint --strict Sources/ || true + build-and-test: + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + - name: Select Xcode 26.3 + run: sudo xcode-select -s /Applications/Xcode_26.3.app || sudo xcode-select -s /Applications/Xcode.app + - name: Build with Swift Package Manager + run: swift build -v + - name: Run Tests + run: xcodebuild test -scheme Kumo -destination 'platform=macOS' -skipPackagePluginValidation CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..de08265 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,42 @@ +# SwiftLint configuration for Kumo 3.0.0 +# Some rules are disabled to maintain backward compatibility with existing API + +disabled_rules: + # These type names use underscore prefix intentionally for internal/protocol types + - type_name + # Force casts are used in XML encoding/decoding where type is known at compile time + - force_cast + # Force try used in controlled scenarios with known input + - force_try + # Some identifiers use underscore prefix by design + - identifier_name + # TODO comments are acceptable during development + - todo + +opt_in_rules: + - empty_count + - closure_spacing + +# Line length configuration - allow up to 200 for errors, 120 for warnings +line_length: + warning: 120 + error: 250 + ignores_comments: true + ignores_urls: true + +# File length configuration +file_length: + warning: 500 + error: 1000 + +excluded: + - Tests/KumoTests/Fixtures + - Tests/KumoTests/Mocks + - .build + - Package.swift + +# Nesting configuration +nesting: + type_level: + warning: 2 + error: 3 diff --git a/Kumo.podspec b/Kumo.podspec deleted file mode 100644 index 2427103..0000000 --- a/Kumo.podspec +++ /dev/null @@ -1,27 +0,0 @@ -Pod::Spec.new do |s| - s.name = 'Kumo' - s.version = '3.0.0' - s.summary = 'Simple networking with little boilerplate built with reactive programming.' - s.homepage = 'https://gitlab.duethealth.com/ios-projects/Dependencies/Kumo' - s.license = 'MIT' - s.author = 'ライアン' - s.source = { git: 'https://gitlab.duethealth.com/ios-projects/Dependencies/Kumo.git', tag: "#{s.version}" } - s.swift_version = '5.5' - - s.ios.deployment_target = '13.0' - s.osx.deployment_target = '12.0' - s.tvos.deployment_target = '15.0' - - s.default_subspecs = 'Kumo', 'KumoCoding' - - s.subspec 'KumoCoding' do |sp| - sp.name = 'KumoCoding' - sp.source_files = 'Sources/KumoCoding/**/*.{h,m,swift}' - end - - s.subspec 'Kumo' do |myLib| - myLib.dependency 'Kumo/KumoCoding' - myLib.source_files = 'Sources/Kumo/**/*.{h,m,swift}' - end - -end diff --git a/Kumo.xcodeproj/project.pbxproj b/Kumo.xcodeproj/project.pbxproj index bced206..e0db03e 100644 --- a/Kumo.xcodeproj/project.pbxproj +++ b/Kumo.xcodeproj/project.pbxproj @@ -56,13 +56,8 @@ 94C185EB22D8E01100CD66DC /* ThrowingDataRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C185EA22D8E01100CD66DC /* ThrowingDataRepresentable.swift */; }; 94C185ED22D8E13400CD66DC /* UIImage+DataRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C185EC22D8E13400CD66DC /* UIImage+DataRepresentable.swift */; }; 94C185EF22D8E19400CD66DC /* Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C185EE22D8E19400CD66DC /* Errors.swift */; }; - 94C3BD95219B0F8100B4A3E2 /* Progress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C3BD94219B0F8100B4A3E2 /* Progress.swift */; }; - 94F2CDCD222A3602006D9C36 /* Service+Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94F2CDC7222A3602006D9C36 /* Service+Download.swift */; }; - 94F2CDCF222A3602006D9C36 /* Service+SideEffects.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94F2CDC9222A3602006D9C36 /* Service+SideEffects.swift */; }; - 94F2CDD0222A3602006D9C36 /* Service+Upload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94F2CDCA222A3602006D9C36 /* Service+Upload.swift */; }; B55A1178233E5D92006EAB34 /* Unkeyed.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55A1177233E5D92006EAB34 /* Unkeyed.swift */; }; B58694D12322B3CC006C20EE /* Epic.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58694D02322B3CC006C20EE /* Epic.swift */; }; - B58782DC2538F9D700A62D73 /* AnyPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58782DB2538F9D700A62D73 /* AnyPublisher.swift */; }; B59A570924A7084F00EA68FF /* AnyCancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59A570824A7084F00EA68FF /* AnyCancellable.swift */; }; B5B657D424A6444F00776C23 /* KumoNamespaceProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B657D324A6444F00776C23 /* KumoNamespaceProxy.swift */; }; D0F9B5A62441566800038580 /* KumoCoding.h in Headers */ = {isa = PBXBuildFile; fileRef = D0F9B5A42441566800038580 /* KumoCoding.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -187,13 +182,8 @@ 94C185EA22D8E01100CD66DC /* ThrowingDataRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThrowingDataRepresentable.swift; sourceTree = ""; }; 94C185EC22D8E13400CD66DC /* UIImage+DataRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+DataRepresentable.swift"; sourceTree = ""; }; 94C185EE22D8E19400CD66DC /* Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Errors.swift; sourceTree = ""; }; - 94C3BD94219B0F8100B4A3E2 /* Progress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Progress.swift; sourceTree = ""; }; - 94F2CDC7222A3602006D9C36 /* Service+Download.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Service+Download.swift"; sourceTree = ""; }; - 94F2CDC9222A3602006D9C36 /* Service+SideEffects.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Service+SideEffects.swift"; sourceTree = ""; }; - 94F2CDCA222A3602006D9C36 /* Service+Upload.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Service+Upload.swift"; sourceTree = ""; }; B55A1177233E5D92006EAB34 /* Unkeyed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Unkeyed.swift; sourceTree = ""; }; B58694D02322B3CC006C20EE /* Epic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Epic.swift; sourceTree = ""; }; - B58782DB2538F9D700A62D73 /* AnyPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyPublisher.swift; sourceTree = ""; }; B59A570824A7084F00EA68FF /* AnyCancellable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyCancellable.swift; sourceTree = ""; }; B5B657D324A6444F00776C23 /* KumoNamespaceProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KumoNamespaceProxy.swift; sourceTree = ""; }; D0F9B5A22441566800038580 /* KumoCoding.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = KumoCoding.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -313,9 +303,7 @@ children = ( B59A570824A7084F00EA68FF /* AnyCancellable.swift */, 945421752187889200932CAF /* Copying.swift */, - 94C3BD94219B0F8100B4A3E2 /* Progress.swift */, 945421732187883B00932CAF /* URLSession.swift */, - B58782DB2538F9D700A62D73 /* AnyPublisher.swift */, ); path = Extensions; sourceTree = ""; @@ -526,9 +514,6 @@ 94F2CDB92229F852006D9C36 /* Functions */ = { isa = PBXGroup; children = ( - 94F2CDC7222A3602006D9C36 /* Service+Download.swift */, - 94F2CDC9222A3602006D9C36 /* Service+SideEffects.swift */, - 94F2CDCA222A3602006D9C36 /* Service+Upload.swift */, ); path = Functions; sourceTree = ""; @@ -782,9 +767,7 @@ 945421742187883B00932CAF /* URLSession.swift in Sources */, 94C185EB22D8E01100CD66DC /* ThrowingDataRepresentable.swift in Sources */, 94C185ED22D8E13400CD66DC /* UIImage+DataRepresentable.swift in Sources */, - 94C3BD95219B0F8100B4A3E2 /* Progress.swift in Sources */, 949AD294218BD7D500808C79 /* ResponseError.swift in Sources */, - 94F2CDCD222A3602006D9C36 /* Service+Download.swift in Sources */, 9430733F22D764AD00AEAC8D /* HTTP.swift in Sources */, 94690FC222CCE16B002C37BF /* URLRequest+HTTPHeader.swift in Sources */, 949AD296218BD80E00808C79 /* MultipartForm.swift in Sources */, @@ -793,12 +776,10 @@ EA646FBC26CE930200482D6B /* Kumo.docc in Sources */, 946F43AD22DCEA7500CE9EC9 /* DataConvertible.swift in Sources */, B5B657D424A6444F00776C23 /* KumoNamespaceProxy.swift in Sources */, - 94F2CDCF222A3602006D9C36 /* Service+SideEffects.swift in Sources */, 94690FBD22CBA823002C37BF /* Authorization.swift in Sources */, 94690FBF22CCE097002C37BF /* URLSessionConfiguration+HTTPHeader.swift in Sources */, 949AD292218BD75500808C79 /* UploadError.swift in Sources */, 946F43AB22DCEA5900CE9EC9 /* DataRepresentable.swift in Sources */, - 94F2CDD0222A3602006D9C36 /* Service+Upload.swift in Sources */, 945D4114217F6222008ACFD0 /* Service.swift in Sources */, 946F43AF22DCEA9200CE9EC9 /* FailableDataConvertible.swift in Sources */, 94C185E922D8DEF200CD66DC /* FailableDataRepresentable.swift in Sources */, @@ -816,7 +797,6 @@ B59A570924A7084F00EA68FF /* AnyCancellable.swift in Sources */, 94C185EF22D8E19400CD66DC /* Errors.swift in Sources */, 94A4E67622DCF3540033B480 /* Storage.swift in Sources */, - B58782DC2538F9D700A62D73 /* AnyPublisher.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -949,7 +929,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -1007,7 +987,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; @@ -1032,7 +1012,7 @@ FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "$(SRCROOT)/Sources/Kumo/Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1062,7 +1042,7 @@ FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "$(SRCROOT)/Sources/Kumo/Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1085,7 +1065,7 @@ DEVELOPMENT_TEAM = 75Y586SA36; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "$(SRCROOT)/Tests/KumoTests/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1106,7 +1086,7 @@ DEVELOPMENT_TEAM = 75Y586SA36; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "$(SRCROOT)/Tests/KumoTests/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1130,7 +1110,7 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = "${SRCROOT}/Sources/KumoCoding/Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1159,7 +1139,7 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = "${SRCROOT}/Sources/KumoCoding/Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Package.swift b/Package.swift index 85302fc..dcd8fdc 100644 --- a/Package.swift +++ b/Package.swift @@ -1,20 +1,41 @@ -// swift-tools-version:5.5 +// swift-tools-version:6.2 import PackageDescription let package = Package( name: "Kumo", platforms: [ - .iOS(.v13), - .tvOS(.v15), - .macOS(.v12), + .iOS(.v18), + .tvOS(.v18), + .macOS(.v15), ], products: [ .library(name: "Kumo", targets: ["Kumo"]), .library(name: "KumoCoding", targets: ["KumoCoding"]) ], targets: [ - .target(name: "Kumo", dependencies: ["KumoCoding"]), - .target(name: "KumoCoding", dependencies: []), - .testTarget(name: "KumoTests", dependencies: ["Kumo", "KumoCoding"]) + .target( + name: "Kumo", + dependencies: ["KumoCoding"], + exclude: ["Info.plist"], + swiftSettings: [ + .swiftLanguageMode(.v6) + ] + ), + .target( + name: "KumoCoding", + dependencies: [], + exclude: ["Info.plist"], + swiftSettings: [ + .swiftLanguageMode(.v6) + ] + ), + .testTarget( + name: "KumoTests", + dependencies: ["Kumo", "KumoCoding"], + exclude: ["Info.plist"], + swiftSettings: [ + .swiftLanguageMode(.v6) + ] + ) ] ) diff --git a/README.md b/README.md index b59b432..b8bfd9d 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,28 @@ -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Swift Package Manager](https://github.com/DuetHealth/Kumo/workflows/Swift%20Package%20Manager/badge.svg)](https://github.com/DuetHealth/Kumo/actions?query=workflow%3A%22Swift+Package+Manager%22) -[![Actions Status](https://github.com/DuetHealth/Kumo/workflows/carthage/badge.svg)](https://github.com/DuetHealth/Kumo/actions?query=workflow%3ACarthage) -[![Actions Status](https://github.com/DuetHealth/Kumo/workflows/cocoapods/badge.svg)](https://github.com/DuetHealth/Kumo/actions?query=workflow%3ACocoapods) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Swift Build & Test](https://github.com/DuetHealth/Kumo/actions/workflows/swift.yml/badge.svg)](https://github.com/DuetHealth/Kumo/actions/workflows/swift.yml) [![Swift 6.2](https://img.shields.io/badge/Swift-6.2-orange.svg)](https://swift.org/) [![Platform](https://img.shields.io/badge/platform-iOS%2026%2B%20%7C%20tvOS%2026%2B%20%7C%20macOS%2026%2B-lightgrey.svg)](https://developer.apple.com/) # Kumo Kumo is a simple networking library with little boilerplate built with reactive programming. +## Requirements + +- iOS 18.0+ / tvOS 18.0+ / macOS 18.0+ +- Swift 6.2+ +- Xcode 26.3+ ## Usage ### Installation -Cocoapods: `pod 'Kumo', git: 'https://github.com/DuetHealth/Kumo.git'` - -Carthage: `git "https://github.com/DuetHealth/Kumo.git" "master"` +**Swift Package Manager** (Recommended): +```swift +.package(url: "https://github.com/DuetHealth/Kumo.git", from: "3.0.0") +``` -Swift Package Manager: `.package(url: "https://github.com/DuetHealth/Kumo.git", from: "2.2.0")` +**Carthage**: +``` +git "https://github.com/DuetHealth/Kumo.git" ~> 3.0.0 +``` ## License diff --git a/Sources/Kumo/ApplicationLayer.swift b/Sources/Kumo/ApplicationLayer.swift index 7f8a6d4..b460e36 100644 --- a/Sources/Kumo/ApplicationLayer.swift +++ b/Sources/Kumo/ApplicationLayer.swift @@ -1,6 +1,6 @@ -import Combine +@preconcurrency import Combine import Foundation -import SystemConfiguration +import Network /// The network connectivity status. public enum NetworkConnectivity { @@ -24,11 +24,11 @@ public enum NetworkConnectivity { /// services and exposes a publisher ``networkConnectivity`` to monitor network /// connectivity. open class ApplicationLayer { - - private var commonHeaders = [String: String]() + private let services: [ServiceKey: Service] private let networkConnectivitySubject: CurrentValueSubject = .init(.unknown) + private let pathMonitor = NWPathMonitor() /// A publisher that updates with the current network connectivity status /// for the device. @@ -40,18 +40,29 @@ open class ApplicationLayer { /// pairs. public init(with services: [ServiceKey: Service] = [:]) { self.services = services - - var address = sockaddr_in() - address.sin_len = UInt8(MemoryLayout.size) - address.sin_family = sa_family_t(AF_INET) - withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: MemoryLayout.size) { - SCNetworkReachabilityCreateWithAddress(nil, $0) + + let subject = networkConnectivitySubject + pathMonitor.pathUpdateHandler = { path in + let connectivity: NetworkConnectivity + switch path.status { + case .satisfied: + #if os(iOS) + connectivity = path.isExpensive ? .wwan : .internet + #else + connectivity = .internet + #endif + case .unsatisfied, .requiresConnection: + connectivity = .notConnected + @unknown default: + connectivity = .unknown } + subject.send(connectivity) } - .map { [unowned self] in self.publishReachability($0) }? - .sink(receiveValue: { [unowned self] in self.networkConnectivitySubject.send($0) }) - .withLifetime(of: self) + pathMonitor.start(queue: DispatchQueue(label: "DuetHealth.Kumo.networkMonitor")) + } + + deinit { + pathMonitor.cancel() } /// Retrieves the service for a given `key`. @@ -61,48 +72,4 @@ open class ApplicationLayer { return services[key]! } - private func publishReachability(_ reachability: SCNetworkReachability) -> AnyPublisher { - AnyPublisher.create { subscriber in - var context = SCNetworkReachabilityContext(version: 0, info: nil, retain: nil, release: nil, copyDescription: nil) - context.info = Unmanaged.passRetained(AnyObserverReference(subscriber)).toOpaque() - SCNetworkReachabilitySetCallback(reachability, { _, flags, info in - guard let observer = info.map({ Unmanaged>.fromOpaque($0).takeUnretainedValue() }) else { return } - if flags.isReachable { - #if os(iOS) - observer.base.onNext(flags.contains(.isWWAN) ? .wwan : .internet) - #else - observer.base.onNext(.internet) - #endif - } else { - observer.base.onNext(.notConnected) - } - }, &context) - SCNetworkReachabilitySetDispatchQueue(reachability, DispatchQueue.main) - return AnyCancellable() { - SCNetworkReachabilitySetCallback(reachability, nil, nil) - SCNetworkReachabilitySetDispatchQueue(reachability, nil) - } - } - } - -} - -private class AnyObserverReference where Failure: Error { - - let base: AnyObserver - - init(_ base: AnyObserver) { - self.base = base - } - -} - -private extension SCNetworkReachabilityFlags { - - var isReachable: Bool { - let canConnectAutomatically = contains(.connectionOnDemand) || contains(.connectionOnTraffic) && !contains(.interventionRequired) - return contains(.reachable) - && (!contains(.connectionRequired) || canConnectAutomatically) - } - } diff --git a/Sources/Kumo/Blobs/BlobCache.swift b/Sources/Kumo/Blobs/BlobCache.swift index 921c1d3..495c3f1 100644 --- a/Sources/Kumo/Blobs/BlobCache.swift +++ b/Sources/Kumo/Blobs/BlobCache.swift @@ -1,4 +1,3 @@ -import Combine import Foundation #if canImport(UIKit) @@ -95,10 +94,10 @@ public class BlobCache { /// exists and has not expired it will be returned instead of re-fetching /// the response. /// - Parameter url: The URL for the blob resource to be located. - /// - Returns: A publisher for an object representing the data for the - /// blob resource at the `url`. - public func fetch(from url: URL) -> AnyPublisher where D._RepresentationArguments == Void, D._ConversionArguments == Void { - return fetch(from: url, convertWith: (), representWith: ()) + /// - Returns: An object representing the data for the blob resource at + /// the `url`. + public func fetch(from url: URL) async throws -> D where D._RepresentationArguments == Void, D._ConversionArguments == Void { + try await fetch(from: url, convertWith: (), representWith: ()) } /// Retrieves the blob resource from the given `url`. If a cached response @@ -108,10 +107,10 @@ public class BlobCache { /// - url: The URL for the blob resource to be located. /// - representationArguments: Arguments to be used to construct /// the representing object. - /// - Returns: A publisher for an object representing the data for the - /// blob resource at the `url`. - public func fetch(from url: URL, representWith representationArguments: D._RepresentationArguments) -> AnyPublisher where D._ConversionArguments == Void { - return fetch(from: url, convertWith: (), representWith: representationArguments) + /// - Returns: An object representing the data for the blob resource at + /// the `url`. + public func fetch(from url: URL, representWith representationArguments: D._RepresentationArguments) async throws -> D where D._ConversionArguments == Void { + try await fetch(from: url, convertWith: (), representWith: representationArguments) } /// Retrieves the blob resource from the given `url`. If a cached response @@ -121,13 +120,12 @@ public class BlobCache { /// - url: The URL for the blob resource to be located. /// - conversionArguments: Arguments to be used to convert the /// blob data. - /// - Returns: A publisher for an object representing the data for the - /// blob resource at the `url`. - public func fetch(from url: URL, convertWith conversionArguments: D._ConversionArguments) -> AnyPublisher where D._RepresentationArguments == Void { - return fetch(from: url, convertWith: conversionArguments, representWith: ()) + /// - Returns: An object representing the data for the blob resource at + /// the `url`. + public func fetch(from url: URL, convertWith conversionArguments: D._ConversionArguments) async throws -> D where D._RepresentationArguments == Void { + try await fetch(from: url, convertWith: conversionArguments, representWith: ()) } - /// Retrieves the blob resource from the given `url`. If a cached response /// exists and has not expired it will be returned instead of re-fetching /// the response. @@ -137,48 +135,17 @@ public class BlobCache { /// blob data. /// - representationArguments: Arguments to be used to construct /// the representing object. - /// - Returns: A publisher for an object representing the data for the - /// blob resource at the `url`. - public func fetch(from url: URL, convertWith conversionArguments: D._ConversionArguments, representWith representationArguments: D._RepresentationArguments) -> AnyPublisher { - let downloadTask = fetch(from: url) - .flatMap { [self] downloadPath -> AnyPublisher in - do { - if let data: D = try self.ephemeralStorage.acquire(fromPath: downloadPath, origin: url, convertWith: conversionArguments, representWith: representationArguments) { - return Just(data) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - return Empty(completeImmediately: true) - .eraseToAnyPublisher() - } catch { - return Fail(error: error) - .eraseToAnyPublisher() - } - } - .eraseToAnyPublisher() - - return Deferred> { - Future { [self] promise in - do { - promise(.success(try self.ephemeralStorage.fetch(for: url, convertWith: conversionArguments, representWith: representationArguments))) - } catch { - promise(.failure(error)) - } - } + /// - Returns: An object representing the data for the blob resource at + /// the `url`. + public func fetch(from url: URL, convertWith conversionArguments: D._ConversionArguments, representWith representationArguments: D._RepresentationArguments) async throws -> D { + if let cached: D = try ephemeralStorage.fetch(for: url, convertWith: conversionArguments, representWith: representationArguments) { + return cached } - .flatMap { (data: D?) -> AnyPublisher in - if let data = data { - return Just(data) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } else { - return downloadTask - } + let downloadPath: URL = try await service.perform(HTTP.Request.download(url)) + guard let data: D = try ephemeralStorage.acquire(fromPath: downloadPath, origin: url, convertWith: conversionArguments, representWith: representationArguments) else { + throw BlobCacheError.acquisitionFailed(url) } - .eraseToAnyPublisher() - .subscribe(on: DispatchQueue.global()) - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() + return data } /// Cleans both ephemeral and persistent storage immediately. @@ -195,18 +162,8 @@ public class BlobCache { persistentStorage.clean() } - private func fetch(from url: URL) -> AnyPublisher { - Deferred> { - Future { [self] promise in - Task { - do { - let downloadPath = try await service.perform(HTTP.Request.download(url)) - promise(.success(downloadPath)) - } catch { - promise(.failure(error)) - } - } - }.eraseToAnyPublisher() - }.eraseToAnyPublisher() - } +} + +enum BlobCacheError: Error { + case acquisitionFailed(URL) } diff --git a/Sources/Kumo/Blobs/Storage/FileSystem.swift b/Sources/Kumo/Blobs/Storage/FileSystem.swift index 58d6aaa..52e0c62 100644 --- a/Sources/Kumo/Blobs/Storage/FileSystem.swift +++ b/Sources/Kumo/Blobs/Storage/FileSystem.swift @@ -154,8 +154,8 @@ extension NSError { enum FileErrors { - static var domain = NSCocoaErrorDomain - static var fileExistsErrorCode = 516 + static let domain = NSCocoaErrorDomain + static let fileExistsErrorCode = 516 } diff --git a/Sources/Kumo/Blobs/Storage/InMemory.swift b/Sources/Kumo/Blobs/Storage/InMemory.swift index 0b3828f..fe6d381 100644 --- a/Sources/Kumo/Blobs/Storage/InMemory.swift +++ b/Sources/Kumo/Blobs/Storage/InMemory.swift @@ -1,6 +1,6 @@ import Foundation -class InMemory: StorageLocation { +class InMemory: StorageLocation, @unchecked Sendable { private class Reference { let key: String @@ -24,26 +24,34 @@ class InMemory: StorageLocation { weak var delegate: StoragePruningDelegate? func fetch(for url: URL, arguments _: D._RepresentationArguments) throws -> D? { - switch backingCache.object(forKey: cachePathResolver.path(for: url.absoluteString) as NSString) { - case .none: - return nil - case let .some(object) where object.value is D: - if let newExpirationDate = delegate?.newExpirationDate(given: CachedObjectParameters(referenceDate: object.referenceDate, expirationDate: object.expirationDate)) { - object.referenceDate = Date() - object.expirationDate = newExpirationDate + try queue.sync { + switch backingCache.object(forKey: cachePathResolver.path(for: url.absoluteString) as NSString) { + case .none: + return nil + case let .some(object) where object.value is D: + if let newExpirationDate = delegate?.newExpirationDate(given: CachedObjectParameters(referenceDate: object.referenceDate, expirationDate: object.expirationDate)) { + object.referenceDate = Date() + object.expirationDate = newExpirationDate + } + return object.value as? D + case let .some(object): + throw StorageAccessError.typeMismatch(expected: D.self, found: object.value) } - return object.value as? D - case let .some(object): - throw StorageAccessError.typeMismatch(expected: D.self, found: object.value) } } func write(_ object: D, from url: URL, arguments _: D._ConversionArguments) throws { + // nonisolated(unsafe) is used because D is not constrained to Sendable, + // but all conforming types (Data, Date, UIImage) are either value types + // or effectively immutable reference types. The queue serializes access + // to the cache's own state; this annotation bridges the object into the + // async closure without requiring a Sendable constraint on the public API. + nonisolated(unsafe) let value: Any = object queue.async { [weak self] in - guard let self = self else { return } + guard let self else { return } let cacheKey = self.cachePathResolver.path(for: url.absoluteString) let expirationDate = self.delegate?.newExpirationDate(given: CachedObjectParameters()) ?? Date() - self.backingCache.setObject(InMemory.Reference(key: cacheKey, value: object, expirationDate: expirationDate), forKey: cacheKey as NSString) + self.backingCache.setObject(InMemory.Reference(key: cacheKey, value: value, expirationDate: expirationDate), forKey: cacheKey as NSString) self.keys.insert(cacheKey) } } @@ -53,12 +61,14 @@ class InMemory: StorageLocation { } func contains(_ url: URL) -> Bool { - return keys.contains(cachePathResolver.path(for: url.absoluteString)) + queue.sync { + keys.contains(cachePathResolver.path(for: url.absoluteString)) + } } func removeAll() { queue.async { [weak self] in - guard let self = self else { return } + guard let self else { return } self.backingCache.removeAllObjects() self.keys.removeAll() } @@ -66,7 +76,7 @@ class InMemory: StorageLocation { func pruneExpired() { queue.async { [weak self] in - guard let self = self else { return } + guard let self else { return } self.keys.filter { guard let reference = self.backingCache.object(forKey: $0 as NSString) else { return true } return reference.expirationDate < Date().addingTimeInterval(.ulpOfOne) diff --git a/Sources/Kumo/Blobs/Storage/StorageLocation.swift b/Sources/Kumo/Blobs/Storage/StorageLocation.swift index 3e910b3..e36aa1d 100644 --- a/Sources/Kumo/Blobs/Storage/StorageLocation.swift +++ b/Sources/Kumo/Blobs/Storage/StorageLocation.swift @@ -1,7 +1,7 @@ import Foundation /// An error that occurred while attempting to access storage. -public enum StorageAccessError: Error { +public enum StorageAccessError: Error, @unchecked Sendable { /// A type mismatch occurred while trying to access storage. case typeMismatch(expected: T.Type, found: Any) diff --git a/Sources/Kumo/Blobs/Types/Errors.swift b/Sources/Kumo/Blobs/Types/Errors.swift index 4b2d70f..21b6398 100644 --- a/Sources/Kumo/Blobs/Types/Errors.swift +++ b/Sources/Kumo/Blobs/Types/Errors.swift @@ -1,9 +1,9 @@ import Foundation -enum CacheDeserializationError: Error { +enum CacheDeserializationError: Error, @unchecked Sendable { case initializationFailed(T.Type, data: Data, arguments: Any) } -enum CacheSerializationError: Error { +enum CacheSerializationError: Error, @unchecked Sendable { case dataConversionFailed(T.Type, object: T, arguments: Any) } diff --git a/Sources/Kumo/Data/FileType.swift b/Sources/Kumo/Data/FileType.swift index cb27ac7..5cbe7f1 100644 --- a/Sources/Kumo/Data/FileType.swift +++ b/Sources/Kumo/Data/FileType.swift @@ -1,18 +1,15 @@ import Foundation - -#if !os(macOS) -import MobileCoreServices -#endif +import UniformTypeIdentifiers /// A structure representing information about a file. public struct FileType: Equatable, Codable { - + enum AssociationError: Error { case noMIMEType case noUTI case noExtension } - + public static func ==(_ lhs: FileType, _ rhs: FileType) -> Bool { return lhs.fileExtension == rhs.fileExtension } @@ -23,38 +20,39 @@ public struct FileType: Equatable, Codable { /// The file's MIME type. public let mimeType: String - /// The file's uniform type identifier.; + /// The file's uniform type identifier. public let uti: String /// Creates a ``FileType`` object from the given `uti`. /// - Parameter uti: A file's uniform type identifier. public init(uti: String) throws { + let type = UTType(uti) self.uti = uti - guard let mimeType = UTTypeCopyPreferredTagWithClass(uti as CFString, kUTTagClassMIMEType) else { throw AssociationError.noMIMEType } - self.mimeType = mimeType.takeRetainedValue() as String - self.fileExtension = UTTypeCopyPreferredTagWithClass(uti as CFString, kUTTagClassFilenameExtension)?.takeRetainedValue() as String? ?? "" + guard let mimeType = type?.preferredMIMEType else { throw AssociationError.noMIMEType } + self.mimeType = mimeType + self.fileExtension = type?.preferredFilenameExtension ?? "" } /// Creates a ``FileType`` object from the given `fileExtension`. /// - Parameter fileExtension: A file's extension. public init(fileExtension: String) throws { - guard let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, fileExtension as CFString, nil) else { throw AssociationError.noUTI } - self.uti = uti.takeRetainedValue() as String - guard let mimeType = UTTypeCopyPreferredTagWithClass(self.uti as CFString, kUTTagClassMIMEType) else { throw AssociationError.noMIMEType } - self.mimeType = mimeType.takeRetainedValue() as String + guard let type = UTType(filenameExtension: fileExtension) else { throw AssociationError.noUTI } + self.uti = type.identifier + guard let mimeType = type.preferredMIMEType else { throw AssociationError.noMIMEType } + self.mimeType = mimeType self.fileExtension = fileExtension } /// Creates a ``FileType`` object from the given `mimeType`. /// - Parameter mimeType: A file's MIME type. public init(mimeType: String) throws { - guard let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType as CFString, nil) else { throw AssociationError.noUTI } - self.uti = uti.takeRetainedValue() as String + guard let type = UTType(mimeType: mimeType) else { throw AssociationError.noUTI } + self.uti = type.identifier self.mimeType = mimeType - guard let fileExtension = UTTypeCopyPreferredTagWithClass(self.uti as CFString, kUTTagClassFilenameExtension) else { throw AssociationError.noExtension } - self.fileExtension = fileExtension.takeRetainedValue() as String + guard let fileExtension = type.preferredFilenameExtension else { throw AssociationError.noExtension } + self.fileExtension = fileExtension } - + public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let fileExtension = try container.decode(String.self) @@ -63,11 +61,10 @@ public struct FileType: Equatable, Codable { } self = fileType } - + public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() try container.encode(fileExtension) } - -} +} diff --git a/Sources/Kumo/Errors/HTTPError.swift b/Sources/Kumo/Errors/HTTPError.swift index 5c890fe..aeee3d0 100644 --- a/Sources/Kumo/Errors/HTTPError.swift +++ b/Sources/Kumo/Errors/HTTPError.swift @@ -1,7 +1,7 @@ import Foundation /// An enumeration of HTTP errors. -public enum HTTPError: Error { +public enum HTTPError: Error, @unchecked Sendable { /// The URL / parameter list is invalid. case malformedURL(_ url: URL, parameters: [String: Any]) diff --git a/Sources/Kumo/Extensions/AnyCancellable.swift b/Sources/Kumo/Extensions/AnyCancellable.swift index f52a0b3..74b05fa 100644 --- a/Sources/Kumo/Extensions/AnyCancellable.swift +++ b/Sources/Kumo/Extensions/AnyCancellable.swift @@ -1,14 +1,14 @@ import Combine import Foundation -fileprivate var cancellablesKey = UInt8.zero +private nonisolated(unsafe) var cancellablesKey: UInt8 = 0 extension Cancellable { func withLifetime(of object: AnyObject) { var cancellables = objc_getAssociatedObject(object, &cancellablesKey) as? [AnyCancellable] ?? [AnyCancellable]() AnyCancellable(self).store(in: &cancellables) - objc_setAssociatedObject(object, &cancellables, cancellables, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(object, &cancellablesKey, cancellables, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } diff --git a/Sources/Kumo/Extensions/AnyPublisher.swift b/Sources/Kumo/Extensions/AnyPublisher.swift deleted file mode 100644 index 638f1cc..0000000 --- a/Sources/Kumo/Extensions/AnyPublisher.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Combine -import Foundation - -// https://stackoverflow.com/a/61035663/104527 -struct AnyObserver { - let onNext: ((Output) -> Void) - let onError: ((Failure) -> Void) - let onComplete: (() -> Void) -} - -struct Disposable { - let dispose: () -> Void -} - -extension AnyPublisher { - static func create(subscribe: @escaping (AnyObserver) -> AnyCancellable) -> Self { - let subject = PassthroughSubject() - var cancellable: AnyCancellable? - return subject - .handleEvents(receiveSubscription: { subscription in - cancellable = subscribe(AnyObserver( - onNext: { output in subject.send(output) }, - onError: { failure in subject.send(completion: .failure(failure)) }, - onComplete: { subject.send(completion: .finished) } - )) - }, receiveCancel: { cancellable?.cancel() }) - .eraseToAnyPublisher() - } -} diff --git a/Sources/Kumo/Extensions/Progress.swift b/Sources/Kumo/Extensions/Progress.swift deleted file mode 100644 index 15825e3..0000000 --- a/Sources/Kumo/Extensions/Progress.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Combine -import Foundation - -extension _KumoNamespace where Base: Progress { - var fractionComplete: AnyPublisher { - base.publisher(for: \.fractionCompleted) - .eraseToAnyPublisher() - } -} diff --git a/Sources/Kumo/Extensions/URLSession.swift b/Sources/Kumo/Extensions/URLSession.swift index 55f31a6..e81ac1d 100644 --- a/Sources/Kumo/Extensions/URLSession.swift +++ b/Sources/Kumo/Extensions/URLSession.swift @@ -4,29 +4,12 @@ protocol InvalidationProtocol { func invalidate(session: URLSession, onInvalidation: @escaping (URLSession, Error?) -> Void) } -class URLSessionInvalidationDelegate: NSObject, URLSessionDelegate, InvalidationProtocol { +final class URLSessionInvalidationDelegate: NSObject, URLSessionDelegate, InvalidationProtocol, @unchecked Sendable { fileprivate var invalidations = [URLSession: (URLSession, Error?) -> Void]() + private let queue = DispatchQueue(label: "DuetHealth.Kumo.invalidations") func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { - invalidations[session]?(session, error) - invalidations[session] = nil - } - - override func conforms(to aProtocol: Protocol) -> Bool { - return protocol_isEqual(aProtocol, URLSessionTaskDelegate.self) || super.conforms(to: aProtocol) - } - - func invalidate(session: URLSession, onInvalidation: @escaping (URLSession, Error?) -> Void) { - invalidations[session] = onInvalidation - } -} - -class URLSessionThreadSafeInvalidationDelegate: NSObject, URLSessionDelegate, InvalidationProtocol { - fileprivate var invalidations = [URLSession: (URLSession, Error?) -> Void]() - var invalidationQueue = DispatchQueue(label: "DuetHealth.Kumo.invalidations") - - func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { - invalidationQueue.sync { + queue.sync { invalidations[session]?(session, error) invalidations[session] = nil } @@ -37,13 +20,13 @@ class URLSessionThreadSafeInvalidationDelegate: NSObject, URLSessionDelegate, In } func invalidate(session: URLSession, onInvalidation: @escaping (URLSession, Error?) -> Void) { - invalidationQueue.sync { + queue.sync { invalidations[session] = onInvalidation } } } -fileprivate var temporaryDelegateKey = UInt8.max +nonisolated(unsafe) private var temporaryDelegateKey = UInt8.max extension URLSession { diff --git a/Sources/Kumo/HTTP/HTTPRequest.swift b/Sources/Kumo/HTTP/HTTPRequest.swift index 4dd8301..ac07941 100644 --- a/Sources/Kumo/HTTP/HTTPRequest.swift +++ b/Sources/Kumo/HTTP/HTTPRequest.swift @@ -4,13 +4,13 @@ import Foundation import KumoCoding #endif -public protocol _RequestMethod { } -public protocol _RequestResource { } -public protocol _RequestBody { } -public protocol _RequestParameters { } -public protocol _ResponseNestedKey { } -public protocol _RequestDispositionName { } -public protocol _UploadProgress { } +public protocol _RequestMethod: Sendable { } +public protocol _RequestResource: Sendable { } +public protocol _RequestBody: Sendable { } +public protocol _RequestParameters: Sendable { } +public protocol _ResponseNestedKey: Sendable { } +public protocol _RequestDispositionName: Sendable { } +public protocol _UploadProgress: Sendable { } public typealias _RequestOption = _RequestMethod & _RequestResource & _RequestBody & _RequestParameters & _ResponseNestedKey & _RequestDispositionName & _UploadProgress public enum _NoOption: _RequestOption { } public enum _HasOption: _RequestOption { } @@ -42,7 +42,7 @@ extension HTTP { case absolute(URL) } - public struct _Request { + public struct _Request: @unchecked Sendable { var method: HTTP.Method var resourceLocator: HTTP.ResourceLocator diff --git a/Sources/Kumo/HTTP/HTTPResponseStatus.swift b/Sources/Kumo/HTTP/HTTPResponseStatus.swift index e673137..f967d82 100644 --- a/Sources/Kumo/HTTP/HTTPResponseStatus.swift +++ b/Sources/Kumo/HTTP/HTTPResponseStatus.swift @@ -4,7 +4,7 @@ public extension HTTP { /// The HTTP response status for a given request. /// - seealso: [Response Status Codes](https://httpwg.org/specs/rfc7231.html#status.codes) - enum ResponseStatus: Int { + enum ResponseStatus: Int, Sendable { case unknown = -1337 diff --git a/Sources/Kumo/HTTP/Headers/HTTPHeader.swift b/Sources/Kumo/HTTP/Headers/HTTPHeader.swift index 2cab4a5..89b082c 100644 --- a/Sources/Kumo/HTTP/Headers/HTTPHeader.swift +++ b/Sources/Kumo/HTTP/Headers/HTTPHeader.swift @@ -2,7 +2,7 @@ import Foundation public extension HTTP { - struct Header: Hashable { + struct Header: Hashable, Sendable { public static let accept = HTTP.Header(rawValue: "Accept") public static let acceptLanguage = HTTP.Header(rawValue: "Accept-Language") diff --git a/Sources/Kumo/KumoNamespaceProxy.swift b/Sources/Kumo/KumoNamespaceProxy.swift index 7faae3b..15f2dc8 100644 --- a/Sources/Kumo/KumoNamespaceProxy.swift +++ b/Sources/Kumo/KumoNamespaceProxy.swift @@ -2,9 +2,9 @@ import Foundation public struct _KumoNamespace { - let base: Base + public let base: Base - internal init(base: Base) { + public init(base: Base) { self.base = base } diff --git a/Sources/Kumo/Logger/KumoLogger.swift b/Sources/Kumo/Logger/KumoLogger.swift index c221ce3..cfa3bcb 100644 --- a/Sources/Kumo/Logger/KumoLogger.swift +++ b/Sources/Kumo/Logger/KumoLogger.swift @@ -1,7 +1,6 @@ -import Combine import Foundation -public protocol KumoLogger { +public protocol KumoLogger: Sendable { func log(message: String, error: Error?) diff --git a/Sources/Kumo/Services/Functions/Service+Download.swift b/Sources/Kumo/Services/Functions/Service+Download.swift deleted file mode 100644 index 212e9fa..0000000 --- a/Sources/Kumo/Services/Functions/Service+Download.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Combine -import Foundation - -public extension Service { - - /// Downloads the resource located at the passed in `endpoint` with the - /// given URL `parameters`. - /// - Parameters: - /// - endpoint: The path extension corresponding to the endpoint. - /// - parameters: A dictionary of parameters to be used in the request - /// URL query. - /// - Returns: An [`AnyPublisher`](https://developer.apple.com/documentation/combine/anypublisher) - /// which publishes a URL to the downloaded file upon success. - @available(*, deprecated, message: "Construct a request with HTTP.Request.download(_:) and use Service/perform(_:) instead.") - func download(_ endpoint: String, parameters: [String: Any] = [:]) async throws -> URL { - try await perform(HTTP.Request.download(endpoint).parameters(parameters)) - } - -} diff --git a/Sources/Kumo/Services/Functions/Service+SideEffects.swift b/Sources/Kumo/Services/Functions/Service+SideEffects.swift deleted file mode 100644 index c316e9a..0000000 --- a/Sources/Kumo/Services/Functions/Service+SideEffects.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Combine -import Foundation - -public extension Service { - - /// Defines a scope for requests that do not require verification of - /// fulfillment. - struct SideEffectScope { - - let base: Service - - init(_ base: Service) { - self.base = base - } - - /// Perform the given request and ignore the result. Useful for "fire - /// and forget" requests where failure is an okay option. The request is - /// tied to the lifecycle of the ``Service`` performing the work and - /// will be cancelled if the ``Service`` is deallocated. - /// - Parameters: - /// - request: the request to be performed. - public func perform(_ request: HTTP._Request) async throws -> Void { - let result: Void = try await base.perform(request) - return result - } - - } - - /// Provides a convenient way for performing requests which are side - /// effects; that is, requests for which observing the response is - /// unnecessary. - var unobserved: SideEffectScope { - return SideEffectScope(self) - } - -} - diff --git a/Sources/Kumo/Services/Functions/Service+Upload.swift b/Sources/Kumo/Services/Functions/Service+Upload.swift deleted file mode 100644 index 79fd159..0000000 --- a/Sources/Kumo/Services/Functions/Service+Upload.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Combine -import Foundation - -public extension Service { - - /// Uploads to an endpoint the provided file. The file is uploaded as form data - /// under the supplied key. - /// - /// - Parameters: - /// - endpoint: The path extension corresponding to the endpoint. - /// - file: The URL of the file to upload. - /// - key: The name of form part under which to embed the file's data. - /// - Returns: An [`AnyPublisher`](https://developer.apple.com/documentation/combine/anypublisher) - /// which publishes upon success. - @available(*, deprecated, message: "Construct an HTTP.Request with `.upload(_:)` and use `perform` instead.") - func upload(_ endpoint: String, file: URL, under key: String) async throws -> Response? { - try await perform(HTTP.Request.upload(endpoint).file(file).keyed(under: key)) - } - - /// Uploads to an endpoint the provided file. The file is uploaded as form data - /// under the supplied key. - /// - /// - Parameters: - /// - endpoint: The path extension corresponding to the endpoint. - /// - parameters: A dictionary of parameters to be used in the request - /// URL query. - /// - file: The URL of the file to upload - /// - key: The name of form part under which to embed the file's data - /// - Returns: An [`AnyPublisher`](https://developer.apple.com/documentation/combine/anypublisher) - /// which publishes a single empty element upon success. - @available(*, deprecated, message: "Construct an HTTP.Request with `.upload(_:)` and use `perform` instead.") - func upload(_ endpoint: String, parameters: [String: Any] = [:], file: URL, under key: String) async throws { - try await perform(HTTP.Request.upload(endpoint).parameters(parameters).file(file).keyed(under: key)) - } - - /// Uploads to an endpoint the provided file. The file is uploaded as form data - /// under the supplied key. - /// - /// - Parameters: - /// - endpoint: The path extension corresponding to the endpoint. - /// - parameters: A dictionary of parameters to be used in the request - /// URL query. - /// - file: The URL of the file to upload. - /// - key: The name of form part under which to embed the file's data. - /// - Returns: An [`AnyPublisher`](https://developer.apple.com/documentation/combine/anypublisher) - /// which publishes the progress of the upload. - @available(*, deprecated, message: "Construct an HTTP.Request with `.upload(_:)` and use `perform` with `.progress()` instead.") - func uploads(_ endpoint: String, parameters: [String: Any] = [:], file: URL, under key: String) async throws -> Double { - try await perform(HTTP.Request.upload(endpoint).parameters(parameters).file(file).keyed(under: key).progress()) - } - -} diff --git a/Sources/Kumo/Services/Service.swift b/Sources/Kumo/Services/Service.swift index 6b19a5a..5938e9b 100644 --- a/Sources/Kumo/Services/Service.swift +++ b/Sources/Kumo/Services/Service.swift @@ -1,4 +1,3 @@ -import Combine import Foundation #if canImport(KumoCoding) @@ -39,11 +38,6 @@ public actor Service { /// The base URL for all requests. public let baseURL: URL? - - - /// Key to enable AB testing invalidation - public static var isSafeInvalidationEnabled = false - /// The type of error returned by the server. When a response returns an /// error status code, the service will attempt to decode the body of the /// response as this type. @@ -97,9 +91,6 @@ public actor Service { private var delegate: URLSessionDelegate = URLSessionInvalidationDelegate() - private let invalidationQueue = DispatchQueue(label: "DuetHealth.session.synchronization") - private let invalidationSemaphore = DispatchSemaphore(value: 1) - var session: URLSession { _session } @@ -127,9 +118,6 @@ public actor Service { } let sessionConfiguration = runsInBackground ? URLSessionConfiguration.background(withIdentifier: baseURL?.absoluteString ?? UUID().uuidString) : .default configuration?(sessionConfiguration) - if Service.isSafeInvalidationEnabled { - delegate = URLSessionThreadSafeInvalidationDelegate() - } var queue: OperationQueue? if let count = maxConcurrentOperationCount { queue = OperationQueue() @@ -169,29 +157,24 @@ public actor Service { session.configuration.headers.set(value: String(describing: value), for: header) } - /// Provides a way to reconfigure the URLSessionConfiguration that powers - /// the Service. - public func reconfigure(applying changes: @escaping (URLSessionConfiguration) -> Void) { - _session.finishTasksAndInvalidate { [unowned self] session, _ in - let newConfiguration: URLSessionConfiguration = session.configuration.copy() - changes(newConfiguration) - self._session = URLSession(configuration: newConfiguration, delegate: self.delegate, delegateQueue: nil) - } - } - - /// Provides a way to asynchronously reconfigure the - /// [`URLSessionConfiguration`](https://developer.apple.com/documentation/foundation/urlsessionconfiguration) - /// that powers the Service. Prefer this over ``reconfigure(applyingd:)`` - /// when making a request that will modify the session configuration based - /// on the result of the request, e.g.: upon logging in and receiving a - /// token that will be added to subsequent headers. - public func reconfiguring(applying changes: @escaping (URLSessionConfiguration) -> Void, completion: @escaping () -> ()) { - self._session.finishTasksAndInvalidate { [unowned self] session, _ in - let newConfiguration: URLSessionConfiguration = session.configuration.copy() - changes(newConfiguration) - self._session = URLSession(configuration: newConfiguration, delegate: self.delegate, delegateQueue: nil) - completion() + /// Reconfigures the URLSessionConfiguration that powers the Service. + /// Finishes outstanding tasks, invalidates the current session, and + /// creates a new session with the modified configuration. + public func reconfigure(applying changes: @escaping @Sendable (URLSessionConfiguration) -> Void) async { + let oldSession = _session + let delegate = self.delegate + let newSession = await withCheckedContinuation { continuation in + oldSession.finishTasksAndInvalidate { session, _ in + let newConfiguration: URLSessionConfiguration = session.configuration.copy() + changes(newConfiguration) + continuation.resume(returning: URLSession( + configuration: newConfiguration, + delegate: delegate, + delegateQueue: nil + )) + } } + _session = newSession } func createRequest(method: HTTP.Method, endpoint: String, queryParameters: [String: Any] = [:], body: [String: Any]? = nil) throws -> URLRequest { diff --git a/Tests/KumoTests/Fixtures/Blobs/BlobCacheTests.swift b/Tests/KumoTests/Fixtures/Blobs/BlobCacheTests.swift index 675ae09..bb300ef 100644 --- a/Tests/KumoTests/Fixtures/Blobs/BlobCacheTests.swift +++ b/Tests/KumoTests/Fixtures/Blobs/BlobCacheTests.swift @@ -1,4 +1,3 @@ -import Combine import Foundation @testable import Kumo import XCTest @@ -6,49 +5,24 @@ import XCTest class BlobCacheTests: NetworkTest { let cache = BlobCache(baseURL: URL(string: "https://httpbin.org")!) - func testRoutineCacheCallReturnsData() { - // We clean indiscriminately in testing because we don't want artifacts to - // carry over multiple tests. We could use a mocking mechanism, but for a - // first testing pass this is a low-cost solution. + func testRoutineCacheCallReturnsData() async throws { cache.persistentStorageHeuristics.cleansIndiscriminately = true let url = URL(string: "https://httpbin.org/bytes/1024")! - var data = Data?.none XCTAssert(!cache.contains(url), "Expected the cache to be empty but contained the URL '\(url)'") - cache.fetch(from: url) - .sink(receiveCompletion: { completion in - switch completion { - case let .failure(error): - XCTFail("Fetching encountered an error: \(error)") - case .finished: - XCTAssert(self.cache.contains(url), "Expected the cache to contain URL '\(url)'") - self.cache.cleanImmediately() - XCTAssert(data != nil, "Expected data to eventually be fetched, but none was received.") - } - }, receiveValue: { (result: Data) in - data = result - }) - .withLifetime(of: self) + let data: Data = try await cache.fetch(from: url) + XCTAssert(cache.contains(url), "Expected the cache to contain URL '\(url)'") + XCTAssert(!data.isEmpty, "Expected data to eventually be fetched, but none was received.") + cache.cleanImmediately() } - func testSubsequentCacheCallReturnsData() { + func testSubsequentCacheCallReturnsData() async throws { cache.persistentStorageHeuristics.cleansIndiscriminately = true let url = URL(string: "https://httpbin.org/bytes/1024")! - var data = Data?.none XCTAssert(!cache.contains(url), "Expected the cache to be empty but contained the URL '\(url)'") - cache.fetch(from: url) - .flatMap { (_: Data) in self.cache.fetch(from: url) } - .sink(receiveCompletion: { completion in - switch completion { - case .failure(let error): - XCTFail("Fetching encountered an error: \(error)") - case .finished: - XCTAssert(self.cache.contains(url), "Expected the cache to contain URL '\(url)'") - self.cache.cleanImmediately() - XCTAssert(data != nil, "Expected data to eventually be fetched, but none was received.") - } - }, receiveValue: { (result: Data) in - data = result - }) - .withLifetime(of: self) + let _: Data = try await cache.fetch(from: url) + let data: Data = try await cache.fetch(from: url) + XCTAssert(cache.contains(url), "Expected the cache to contain URL '\(url)'") + XCTAssert(!data.isEmpty, "Expected data to eventually be fetched, but none was received.") + cache.cleanImmediately() } } diff --git a/Tests/KumoTests/Fixtures/Common/NetworkTest.swift b/Tests/KumoTests/Fixtures/Common/NetworkTest.swift index c9e0534..41ae829 100644 --- a/Tests/KumoTests/Fixtures/Common/NetworkTest.swift +++ b/Tests/KumoTests/Fixtures/Common/NetworkTest.swift @@ -1,4 +1,4 @@ -import Combine +@preconcurrency import Combine import Foundation @testable import Kumo import XCTest @@ -11,7 +11,7 @@ class NetworkTest: XCTestCase { return (actual: base, expected: base.mapValues(String.init(describing:))) }() - func successfulTest(of observable: AnyPublisher, file: StaticString = #file, line: UInt = #line, function: String = #function) -> (_ description: String) -> ((_ successCondition: @escaping (T) -> Bool) -> Void) { + func successfulTest(of observable: AnyPublisher, file: StaticString = #filePath, line: UInt = #line, function: String = #function) -> (_ description: String) -> ((_ successCondition: @escaping (T) -> Bool) -> Void) { return { description in { successCondition in var emissions = [T]() @@ -35,7 +35,7 @@ class NetworkTest: XCTestCase { } } - func erroringTest(of observable: AnyPublisher, file: StaticString = #file, line: UInt = #line, function: String = #function) -> (_ description: String) -> ((_ successCondition: @escaping (Error) -> Bool) -> Void) { + func erroringTest(of observable: AnyPublisher, file: StaticString = #filePath, line: UInt = #line, function: String = #function) -> (_ description: String) -> ((_ successCondition: @escaping (Error) -> Bool) -> Void) { return { description in { successCondition in let expect = self.expectation(description: description) @@ -58,34 +58,43 @@ class NetworkTest: XCTestCase { } } - func perform(_ request: HTTP._Request) -> AnyPublisher { - Deferred> { - Future { [self] promise in + func perform(_ request: HTTP._Request) -> AnyPublisher { + let service = self.service + return Deferred> { + Future { promise in + let box = SendableBox(promise) Task { do { let result: T = try await service.perform(request) - promise(.success(result)) - } catch let error { - promise(.failure(error)) + box.value(.success(result)) + } catch { + box.value(.failure(error)) } } }.eraseToAnyPublisher() }.eraseToAnyPublisher() } - + func perform(_ request: HTTP ._Request) -> AnyPublisher { - Deferred> { - Future { [self] promise in + let service = self.service + return Deferred> { + Future { promise in + let box = SendableBox(promise) Task { do { - let result: Void = try await service.perform(request) - promise(.success(result)) - } catch let error { - promise(.failure(error)) + try await service.perform(request) as Void + box.value(.success(())) + } catch { + box.value(.failure(error)) } } }.eraseToAnyPublisher() }.eraseToAnyPublisher() } } + +private struct SendableBox: @unchecked Sendable { + let value: T + init(_ value: T) { self.value = value } +} diff --git a/Tests/KumoTests/Fixtures/Common/TestLogger.swift b/Tests/KumoTests/Fixtures/Common/TestLogger.swift index f432107..6eae9f9 100644 --- a/Tests/KumoTests/Fixtures/Common/TestLogger.swift +++ b/Tests/KumoTests/Fixtures/Common/TestLogger.swift @@ -1,7 +1,7 @@ import Foundation @testable import Kumo -class TestLogger: KumoLogger { +final class TestLogger: KumoLogger { func log(message: String, error: Error?) { if error == nil { diff --git a/Tests/KumoTests/Fixtures/XML/GenericXMLDecodingTests.swift b/Tests/KumoTests/Fixtures/XML/GenericXMLDecodingTests.swift new file mode 100644 index 0000000..6ad96a2 --- /dev/null +++ b/Tests/KumoTests/Fixtures/XML/GenericXMLDecodingTests.swift @@ -0,0 +1,1075 @@ +import Foundation +import XCTest +@testable import Kumo +@testable import KumoCoding + +// All XML fixtures in this file are synthetic test data. +// Domains (weather, e-commerce, employee directory, metrics) are fictional +// and used solely to exercise KumoCoding's XMLDecoder against a broad +// range of structural patterns found in real-world XML APIs. + +class GenericXMLDecodingTests: XCTestCase { + + // MARK: - Multiple Entries + + /// Decodes a response containing multiple forecast entries, + /// verifying that repeated sibling elements decode as an array. + func testDecodeMultipleForecasts() { + let decoder = XMLDecoder() + let data = """ + + Springfield + + + 2025-04-07 + 72.5 + 54.0 + Sunny + 0.0 + 12.3 + UV Index High + + + 2025-04-08 + 65.0 + 48.5 + Cloudy + 0.25 + + + 2025-04-09 + 58.0 + 42.0 + Rain + 1.5 + 25.0 + + + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(WeatherForecastResponse.self, from: data) + XCTAssertEqual(response.Location, "Springfield") + XCTAssertEqual(response.Forecasts.count, 3) + XCTAssertEqual(response.Forecasts[0].Date, "2025-04-07") + XCTAssertEqual(response.Forecasts[0].High, 72.5) + XCTAssertEqual(response.Forecasts[0].Condition, "Sunny") + XCTAssertEqual(response.Forecasts[0].Advisory, "UV Index High") + XCTAssertEqual(response.Forecasts[1].Date, "2025-04-08") + XCTAssertNil(response.Forecasts[1].WindSpeed) + XCTAssertNil(response.Forecasts[1].Advisory) + XCTAssertEqual(response.Forecasts[2].Precipitation, 1.5) + XCTAssertEqual(response.Forecasts[2].WindSpeed, 25.0) + XCTAssertNil(response.Forecasts[2].Advisory) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Missing Optional Fields + + /// Decodes a forecast where all optional fields are absent. + func testDecodeForecastWithAllOptionalsAbsent() { + let decoder = XMLDecoder() + let data = """ + + Shelbyville + + + 2025-04-07 + 60.0 + 45.0 + Overcast + + + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(WeatherForecastResponse.self, from: data) + XCTAssertEqual(response.Forecasts.count, 1) + let forecast = response.Forecasts[0] + XCTAssertEqual(forecast.Condition, "Overcast") + XCTAssertNil(forecast.Precipitation) + XCTAssertNil(forecast.WindSpeed) + XCTAssertNil(forecast.Advisory) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + /// Decodes a forecast where optional fields are present but self-closing (empty). + func testDecodeForecastWithSelfClosingOptionals() { + let decoder = XMLDecoder() + let data = """ + + Capital City + + + 2025-04-10 + 55.0 + 40.0 + Clear + + + + + + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(WeatherForecastResponse.self, from: data) + let forecast = response.Forecasts[0] + XCTAssertEqual(forecast.Date, "2025-04-10") + XCTAssertNil(forecast.Precipitation) + XCTAssertNil(forecast.WindSpeed) + XCTAssertNil(forecast.Advisory) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Empty Arrays + + /// Decodes an order where the notes array is a self-closing empty tag. + func testDecodeOrderWithEmptyNotes() { + let decoder = XMLDecoder() + let data = """ + + 1 + + + ORD-1001 + Shipped + + Alice Johnson + alice@example.com + + + + WIDGET-42 + Blue Widget + 3 + 9.99 + + + + + + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(OrderListResponse.self, from: data) + XCTAssertEqual(response.TotalCount, 1) + XCTAssertEqual(response.Orders.count, 1) + XCTAssertEqual(response.Orders[0].OrderId, "ORD-1001") + XCTAssertNil(response.Orders[0].Notes) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + /// Decodes an order where the notes array is completely absent. + func testDecodeOrderWithAbsentNotes() { + let decoder = XMLDecoder() + let data = """ + + 1 + + + ORD-1002 + Processing + + Bob Smith + + + + GADGET-99 + Red Gadget + 1 + 24.50 + + + + + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(OrderListResponse.self, from: data) + XCTAssertEqual(response.Orders[0].OrderId, "ORD-1002") + XCTAssertNil(response.Orders[0].Customer.Email) + XCTAssertNil(response.Orders[0].Customer.Phone) + XCTAssertNil(response.Orders[0].Notes) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Multiple Entries with Varying Completeness + + /// Decodes multiple orders where some have notes and others don't, + /// testing heterogeneous content within the same array. + func testDecodeMultipleOrdersWithVaryingCompleteness() { + let decoder = XMLDecoder() + let data = """ + + 3 + + + ORD-2001 + Delivered + + Charlie Brown + charlie@example.com + 555-0101 + + + + BOOK-A1 + Adventure Novel + 2 + 14.99 + + + BOOK-B2 + Mystery Collection + 1 + 22.50 + + + + Gift wrap requested + Leave at door + + + + ORD-2002 + Cancelled + + Diana Prince + + + + TOY-X7 + Action Figure + 1 + 19.99 + + + + + ORD-2003 + Processing + + Eve Torres + eve@example.com + + + + ELEC-Z3 + Wireless Headphones + 1 + 79.99 + + + + + + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(OrderListResponse.self, from: data) + XCTAssertEqual(response.TotalCount, 3) + XCTAssertEqual(response.Orders.count, 3) + + // Order 1: fully populated with notes + XCTAssertEqual(response.Orders[0].OrderId, "ORD-2001") + XCTAssertEqual(response.Orders[0].Customer.Phone, "555-0101") + XCTAssertEqual(response.Orders[0].Items.count, 2) + XCTAssertEqual(response.Orders[0].Items[1].Sku, "BOOK-B2") + XCTAssertEqual(response.Orders[0].Notes, ["Gift wrap requested", "Leave at door"]) + + // Order 2: minimal — no email, no phone, no notes + XCTAssertEqual(response.Orders[1].OrderId, "ORD-2002") + XCTAssertNil(response.Orders[1].Customer.Email) + XCTAssertNil(response.Orders[1].Customer.Phone) + XCTAssertNil(response.Orders[1].Notes) + + // Order 3: empty self-closing notes + XCTAssertEqual(response.Orders[2].OrderId, "ORD-2003") + XCTAssertNil(response.Orders[2].Notes) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Deeply Nested Elements + + /// Decodes an employee directory with deeply nested address information + /// and optional skill/project arrays. + func testDecodeEmployeeDirectoryWithDeepNesting() { + let decoder = XMLDecoder() + let data = """ + + Engineering + + + EMP-001 + Grace Hopper + Principal Engineer + + grace@example.com + 555-0199 +
+ 123 Oak Avenue + Metropolis + NY + 10001 +
+
+ + Swift + Objective-C + Python + + + + Atlas + Lead + true + + + Beacon + Contributor + false + + +
+
+
+ """.data(using: .utf8)! + + do { + let directory = try decoder.decode(EmployeeDirectory.self, from: data) + XCTAssertEqual(directory.Department, "Engineering") + XCTAssertEqual(directory.Employees.count, 1) + + let emp = directory.Employees[0] + XCTAssertEqual(emp.Id, "EMP-001") + XCTAssertEqual(emp.Name, "Grace Hopper") + + // Deeply nested address + XCTAssertEqual(emp.Contact.Address?.Street, "123 Oak Avenue") + XCTAssertEqual(emp.Contact.Address?.City, "Metropolis") + XCTAssertEqual(emp.Contact.Address?.State, "NY") + XCTAssertEqual(emp.Contact.Address?.Zip, "10001") + + // Skills array + XCTAssertEqual(emp.Skills, ["Swift", "Objective-C", "Python"]) + + // Projects with nested boolean + XCTAssertEqual(emp.Projects?.count, 2) + XCTAssertEqual(emp.Projects?[0].Name, "Atlas") + XCTAssertEqual(emp.Projects?[0].Active, true) + XCTAssertEqual(emp.Projects?[1].Active, false) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + /// Decodes an employee with no address, no skills, and no projects. + func testDecodeEmployeeWithMinimalData() { + let decoder = XMLDecoder() + let data = """ + + Marketing + + + EMP-002 + Alan Turing + Analyst + + alan@example.com + + + + + """.data(using: .utf8)! + + do { + let directory = try decoder.decode(EmployeeDirectory.self, from: data) + let emp = directory.Employees[0] + XCTAssertEqual(emp.Id, "EMP-002") + XCTAssertEqual(emp.Contact.Email, "alan@example.com") + XCTAssertNil(emp.Contact.Phone) + XCTAssertNil(emp.Contact.Address) + XCTAssertNil(emp.Skills) + XCTAssertNil(emp.Projects) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + /// Decodes an employee with empty self-closing skills and projects. + func testDecodeEmployeeWithEmptySelfClosingArrays() { + let decoder = XMLDecoder() + let data = """ + + Design + + + EMP-003 + Ada Lovelace + Designer + + ada@example.com + + + + + + + + """.data(using: .utf8)! + + do { + let directory = try decoder.decode(EmployeeDirectory.self, from: data) + let emp = directory.Employees[0] + XCTAssertEqual(emp.Id, "EMP-003") + XCTAssertNil(emp.Contact.Phone) + XCTAssertNil(emp.Skills) + XCTAssertNil(emp.Projects) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Multiple Employees (varied completeness) + + /// Decodes a directory with multiple employees at different levels + /// of data completeness, testing heterogeneous sibling elements. + func testDecodeMultipleEmployeesWithVaryingCompleteness() { + let decoder = XMLDecoder() + let data = """ + + Research + + + EMP-101 + Marie Curie + Research Scientist + + marie@example.com + 555-0201 +
+ 456 Elm Street + Gotham + NJ + 07001 +
+
+ + Chemistry + Physics + + + + Radiance + Principal Investigator + true + + +
+ + EMP-102 + Nikola Tesla + Staff Engineer + + nikola@example.com + + + + EMP-103 + Rosalind Franklin + Senior Scientist + + rosalind@example.com + 555-0203 + + + + +
+
+ """.data(using: .utf8)! + + do { + let directory = try decoder.decode(EmployeeDirectory.self, from: data) + XCTAssertEqual(directory.Department, "Research") + XCTAssertEqual(directory.Employees.count, 3) + + // Fully populated employee + XCTAssertEqual(directory.Employees[0].Skills, ["Chemistry", "Physics"]) + XCTAssertEqual(directory.Employees[0].Projects?.count, 1) + XCTAssertEqual(directory.Employees[0].Contact.Address?.City, "Gotham") + + // Minimal employee — no phone, no address, no skills, no projects + XCTAssertNil(directory.Employees[1].Contact.Phone) + XCTAssertNil(directory.Employees[1].Contact.Address) + XCTAssertNil(directory.Employees[1].Skills) + XCTAssertNil(directory.Employees[1].Projects) + + // Employee with empty arrays (self-closing tags) + XCTAssertEqual(directory.Employees[2].Contact.Phone, "555-0203") + XCTAssertNil(directory.Employees[2].Skills) + XCTAssertNil(directory.Employees[2].Projects) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - SOAP Envelope with Nested Catalog Item + + /// Decodes a deeply nested SOAP response for a catalog item. + func testDecodeSOAPCatalogItem() { + let decoder = SOAPDecoder() + decoder.keyDecodingStrategy = .convertFromPascalCase + let data = """ + + + + + CAT-5001 + Ergonomic Keyboard + Electronics + + 89.99 + USD + 0.15 + 0.08 + + + Central + 150 + 12 + 25 + + + keyboard + ergonomic + office + + + + + """.data(using: .utf8)! + + do { + let item: CatalogItem = try decoder.decode(from: data) + XCTAssertEqual(item.Id, "CAT-5001") + XCTAssertEqual(item.Name, "Ergonomic Keyboard") + XCTAssertEqual(item.Category, "Electronics") + XCTAssertEqual(item.Pricing.BasePrice, 89.99) + XCTAssertEqual(item.Pricing.Currency, "USD") + XCTAssertEqual(item.Pricing.Discount, 0.15) + XCTAssertEqual(item.Pricing.TaxRate, 0.08) + XCTAssertEqual(item.Inventory.Warehouse, "Central") + XCTAssertEqual(item.Inventory.Quantity, 150) + XCTAssertEqual(item.Inventory.Reserved, 12) + XCTAssertEqual(item.Inventory.ReorderThreshold, 25) + XCTAssertEqual(item.Tags, ["keyboard", "ergonomic", "office"]) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + /// Decodes a SOAP catalog item where optional pricing/inventory + /// fields and the tags array are absent. + func testDecodeSOAPCatalogItemWithOptionalsMissing() { + let decoder = SOAPDecoder() + decoder.keyDecodingStrategy = .convertFromPascalCase + let data = """ + + + + + CAT-5002 + Basic Mouse + Accessories + + 12.99 + EUR + + + East + 500 + 0 + + + + + """.data(using: .utf8)! + + do { + let item: CatalogItem = try decoder.decode(from: data) + XCTAssertEqual(item.Id, "CAT-5002") + XCTAssertEqual(item.Pricing.BasePrice, 12.99) + XCTAssertNil(item.Pricing.Discount) + XCTAssertNil(item.Pricing.TaxRate) + XCTAssertNil(item.Inventory.ReorderThreshold) + XCTAssertNil(item.Tags) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + /// Decodes a SOAP catalog item where the tags array is empty (self-closing). + func testDecodeSOAPCatalogItemWithEmptyTags() { + let decoder = SOAPDecoder() + decoder.keyDecodingStrategy = .convertFromPascalCase + let data = """ + + + + + CAT-5003 + Notebook Stand + Furniture + + 45.00 + USD + + + West + 30 + 5 + + + + + + """.data(using: .utf8)! + + do { + let item: CatalogItem = try decoder.decode(from: data) + XCTAssertEqual(item.Id, "CAT-5003") + XCTAssertNil(item.Tags) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Atom Feed–Style with Unkeyed Iteration + + /// Decodes an Atom-style feed containing notification entries, + /// verifying the unkeyed container iteration pattern. + func testDecodeNotificationFeed() { + let decoder = XMLDecoder() + let data = """ + + http://example.com/notifications + Notifications + 2025-04-07T12:00:00Z + System + + http://example.com/notification/1 + Notification + 2025-04-07T12:00:00Z + System + + + N-001 + Deployment Complete + Version 2.5.0 deployed successfully. + High + false + 2025-04-07T11:30:00Z + + + + + http://example.com/notification/2 + Notification + 2025-04-07T12:00:00Z + System + + + N-002 + Scheduled Maintenance + Low + true + 2025-04-06T09:00:00Z + + + + + http://example.com/notification/3 + Notification + 2025-04-07T12:00:00Z + System + + + N-003 + New User Registered + User john@example.com registered. + Medium + false + 2025-04-07T08:15:00Z + + + + + """.data(using: .utf8)! + + do { + let feed = try decoder.decode(NotificationFeed.self, from: data) + XCTAssertEqual(feed.notifications.count, 3) + + // Entry 1: has message + let n1 = feed.notifications[0].content.notification + XCTAssertEqual(n1.Id, "N-001") + XCTAssertEqual(n1.Title, "Deployment Complete") + XCTAssertEqual(n1.Message, "Version 2.5.0 deployed successfully.") + XCTAssertEqual(n1.Priority, "High") + XCTAssertEqual(n1.Read, false) + + // Entry 2: missing message + let n2 = feed.notifications[1].content.notification + XCTAssertEqual(n2.Id, "N-002") + XCTAssertNil(n2.Message) + XCTAssertEqual(n2.Read, true) + + // Entry 3: has message + let n3 = feed.notifications[2].content.notification + XCTAssertEqual(n3.Id, "N-003") + XCTAssertEqual(n3.Message, "User john@example.com registered.") + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Polymorphic Metric Values + + /// Decodes metric rows with polymorphic value types (Gauge, Counter, Timestamp), + /// verifying that only the populated sub-element is non-nil. + func testDecodeMetricRowWithPolymorphicValues() { + let decoder = XMLDecoder() + let data = """ + + http://example.com/metrics/cpu-usage + Server + + + ServerCPUUsage + 87.5 + + + ServerRequestCount + 14523 + + + ServerLastRestart + 2025-04-01T06:00:00Z + + + ServerErrorRate + + + + + """.data(using: .utf8)! + + do { + let row = try decoder.decode(MetricRow.self, from: data) + XCTAssertEqual(row.Id, "http://example.com/metrics/cpu-usage") + XCTAssertEqual(row.Source, "Server") + XCTAssertEqual(row.Metrics.count, 4) + + // Gauge value + XCTAssertEqual(row.Metrics[0].Value.Gauge, "87.5") + XCTAssertNil(row.Metrics[0].Value.Counter) + XCTAssertNil(row.Metrics[0].Value.Timestamp) + + // Counter value + XCTAssertNil(row.Metrics[1].Value.Gauge) + XCTAssertEqual(row.Metrics[1].Value.Counter, "14523") + + // Timestamp value + XCTAssertEqual(row.Metrics[2].Value.Timestamp, "2025-04-01T06:00:00Z") + XCTAssertNil(row.Metrics[2].Value.Gauge) + + // Empty Gauge (self-closing) + XCTAssertNil(row.Metrics[3].Value.Gauge) + XCTAssertNil(row.Metrics[3].Value.Counter) + XCTAssertNil(row.Metrics[3].Value.Timestamp) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Single Item Array + + /// Verifies that an array containing exactly one element decodes correctly. + func testDecodeSingleItemList() { + let decoder = XMLDecoder() + decoder.keyDecodingStrategy = .convertFromPascalCase + let data = """ + + + only-one + + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(ListContainer.self, from: data) + XCTAssertEqual(response.simpleList, ["only-one"]) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Numeric Edge Cases + + /// Decodes various numeric representations (zero, negative, large values). + func testDecodeNumericEdgeCases() { + let decoder = XMLDecoder() + + // Zero + let zeroData = """ + + Numbers + 0 + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(DefaultKeyModel.self, from: zeroData) + XCTAssertEqual(response.count, 0) + } catch { + XCTFail("Decode failed: \(error)") + } + + // Negative + let negativeData = """ + + Negative + -42 + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(DefaultKeyModel.self, from: negativeData) + XCTAssertEqual(response.count, -42) + } catch { + XCTFail("Decode failed: \(error)") + } + + // Int max + let largeData = """ + + Large + 2147483647 + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(DefaultKeyModel.self, from: largeData) + XCTAssertEqual(response.count, 2147483647) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Whitespace-Only Content Treated as Empty + + /// Verifies that elements containing only whitespace are treated as empty nodes. + func testDecodeWhitespaceOnlyContentAsEmpty() { + let decoder = XMLDecoder() + decoder.keyDecodingStrategy = .convertFromPascalCase + let data = """ + + WhitespaceTest + + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(NilableContainer.self, from: data) + XCTAssertEqual(response.name, "WhitespaceTest") + // The XMLDeserializer trims whitespace content, so whitespace-only + // becomes an empty node which decodes as nil. + XCTAssertNil(response.nickname) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Namespace Stripping + + /// Verifies that elements with namespace prefixes decode correctly + /// since XMLParser with shouldProcessNamespaces strips prefixes. + func testDecodeNamespacedElements() { + let decoder = XMLDecoder() + let data = """ + + Namespaced + 7 + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(DefaultKeyModel.self, from: data) + XCTAssertEqual(response.title, "Namespaced") + XCTAssertEqual(response.count, 7) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Complex List with Single Element + + /// Verifies that a complex object list with a single element decodes correctly. + func testDecodeComplexListWithSingleElement() { + let decoder = XMLDecoder() + decoder.keyDecodingStrategy = .convertFromPascalCase + let data = """ + + + + alpha + beta + + + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(ComplexListContainer.self, from: data) + XCTAssertEqual(response.complexList.count, 1) + XCTAssertEqual(response.complexList[0].x, "alpha") + XCTAssertEqual(response.complexList[0].y, "beta") + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - SOAP Round-Trip with Catalog Payload + + /// Encodes then decodes a price response through SOAP to verify round-trip integrity. + func testSOAPCatalogItemRoundTrip() { + let original = GetPriceResponse( + price: GetPriceResponse.Price(amount: 42.0, units: "GBP"), + discount: 0.10 + ) + + let encoder = SOAPEncoder() + encoder.keyEncodingStrategy = .convertToPascalCase + encoder.soapNamespaceUsage = .define( + using: XMLNamespace(prefix: "soap", uri: "http://www.w3.org/2003/05/soap-envelope/"), + including: [] + ) + encoder.requestPayloadNamespaceUsage = .defineBeneath( + XMLNamespace(prefix: "m", uri: "https://www.example.com/prices") + ) + + let decoder = SOAPDecoder() + decoder.keyDecodingStrategy = .convertFromPascalCase + + do { + let data = try encoder.encode(original) + let decoded: GetPriceResponse = try decoder.decode(from: data) + XCTAssertEqual(decoded, original) + } catch { + XCTFail("Round-trip failed: \(error)") + } + } + + // MARK: - Error: Missing Required Key in Nested Object + + /// Verifies that a missing required key in a nested object throws keyNotFound. + func testDecodingMissingRequiredKeyInNestedObjectThrows() { + let decoder = XMLDecoder() + decoder.keyDecodingStrategy = .convertFromPascalCase + let data = """ + + QA + + + EMP-BAD + Missing Title + + bad@example.com + + + + + """.data(using: .utf8)! + + XCTAssertThrowsError(try decoder.decode(EmployeeDirectory.self, from: data)) { error in + guard case DecodingError.keyNotFound = error else { + XCTFail("Expected DecodingError.keyNotFound but got \(error)") + return + } + } + } + + // MARK: - Large Array Decode + + /// Decodes a string list with many elements to verify the unkeyed + /// container handles larger counts correctly. + func testDecodeLargeStringList() { + let decoder = XMLDecoder() + decoder.keyDecodingStrategy = .convertFromPascalCase + let elements = (1...50).map { "item-\($0)" }.joined(separator: "\n") + let data = """ + + + \(elements) + + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(ListContainer.self, from: data) + XCTAssertEqual(response.simpleList.count, 50) + XCTAssertEqual(response.simpleList.first, "item-1") + XCTAssertEqual(response.simpleList.last, "item-50") + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Decode with Multiple Namespaces + + /// Verifies decode still works when multiple namespace declarations exist + /// on the same element, since XMLParser strips prefixes. + func testDecodeWithMultipleNamespaceDeclarations() { + let decoder = XMLDecoder() + decoder.keyDecodingStrategy = .convertFromPascalCase + let data = """ + + MultiNS + + """.data(using: .utf8)! + + do { + let response = try decoder.decode(NilableContainer.self, from: data) + XCTAssertEqual(response.name, "MultiNS") + XCTAssertNil(response.nickname) + } catch { + XCTFail("Decode failed: \(error)") + } + } +} diff --git a/Tests/KumoTests/Fixtures/XML/HeaderConfigurationDecodingTests.swift b/Tests/KumoTests/Fixtures/XML/HeaderConfigurationDecodingTests.swift new file mode 100644 index 0000000..9a19ff5 --- /dev/null +++ b/Tests/KumoTests/Fixtures/XML/HeaderConfigurationDecodingTests.swift @@ -0,0 +1,444 @@ +import Foundation +import XCTest +@testable import Kumo +@testable import KumoCoding + +// All XML fixtures in this file are synthetic test data. +// The domain (inventory / catalog) is fictional and used solely to +// exercise KumoCoding's XMLDecoder against nested, mixed-content XML. + +class HeaderConfigurationDecodingTests: XCTestCase { + + // MARK: - Single Configuration (content only) + + func testDecodeSingleConfiguration() { + let decoder = XMLDecoder() + let data = """ + + Catalog Alpha +
+ + + ProductSKU + Text + False + False + 20 + + + ProductName + Text + False + False + 350 + + +
+ + + Product + Warehouse + + Product + + + + Text + ProductBrand + + + + + Text + ProductModel + + + + + + Shipment + + Shipment + + + Text + ProductSKU + + + + + Text + WarehouseBinNumber + + + +
+ """.data(using: .utf8)! + + do { + let config = try decoder.decode(Configuration.self, from: data) + XCTAssertEqual(config.Name, "Catalog Alpha") + XCTAssertEqual(config.Details.Controls.count, 2) + XCTAssertEqual(config.Details.Controls[0].Field.Source, "Product") + XCTAssertEqual(config.Details.Controls[0].Field.Attribute, "SKU") + XCTAssertEqual(config.Details.Controls[0].DataType, "Text") + XCTAssertEqual(config.Details.Controls[0].MaximumLength, 20) + XCTAssertNil(config.Details.Controls[0].Label) + XCTAssertNil(config.Details.Controls[0].Choices) + + XCTAssertEqual(config.ItemSearching.Sources, ["Product", "Warehouse"]) + XCTAssertEqual(config.ItemSearching.Target, "Product") + XCTAssertEqual(config.ItemSearching.Criteria.count, 1) + XCTAssertEqual(config.ItemSearching.Criteria[0].Label, "Brand") + + XCTAssertEqual(config.ShipmentSearching.Sources, ["Shipment"]) + XCTAssertEqual(config.ShipmentSearching.Target, "Shipment") + XCTAssertEqual(config.ShipmentSearching.ReadOnlyCriteria.count, 1) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Empty ReadOnlyCriteria + + func testDecodeConfigurationWithEmptyReadOnlyCriteria() { + let decoder = XMLDecoder() + let data = """ + + Catalog Beta +
+ + + ProductSKU + Text + False + False + 20 + + +
+ + Product + Product + + + + Text + ProductBrand + + + + + Text + ProductModel + + + + + Warehouse + Warehouse + + + + Date + WarehouseReceivedDate + + + +
+ """.data(using: .utf8)! + + + do { + let config = try decoder.decode(Configuration.self, from: data) + XCTAssertEqual(config.Name, "Catalog Beta") + XCTAssertEqual(config.ShipmentSearching.ReadOnlyCriteria, []) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Empty Choices on Control + + func testDecodeControlWithEmptyChoices() { + let decoder = XMLDecoder() + let data = """ + + Choices Test +
+ + + WarehouseZone + Text + False + False + 100 + + + +
+ + Product + Product + + TextProductSKU + + + TextProductSKU + + + + Shipment + Shipment + + + TextShipmentTrackingNumber + + +
+ """.data(using: .utf8)! + + do { + let config = try decoder.decode(Configuration.self, from: data) + XCTAssertEqual(config.Details.Controls.count, 1) + // Empty should decode as nil + XCTAssertNil(config.Details.Controls[0].Choices) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Full Atom Feed Decoding + + func testDecodeFullAtomFeed() { + let decoder = XMLDecoder() + let data = Self.fullAtomFeedXML.data(using: .utf8)! + + do { + let feed = try decoder.decode(HeaderConfigurationFeed.self, from: data) + XCTAssertEqual(feed.entry.count, 2) + + let first = feed.entry[0].content.configuration + XCTAssertEqual(first.Name, "Catalog Alpha") + XCTAssertEqual(first.Details.Controls.count, 23) + + // Verify first control + XCTAssertEqual(first.Details.Controls[0].Field.Source, "Product") + XCTAssertEqual(first.Details.Controls[0].Field.Attribute, "SKU") + XCTAssertEqual(first.Details.Controls[0].DataType, "Text") + XCTAssertEqual(first.Details.Controls[0].MaximumLength, 20) + + // Verify ItemSearching + XCTAssertEqual(first.ItemSearching.Sources, ["Product", "Warehouse"]) + XCTAssertEqual(first.ItemSearching.Target, "Product") + XCTAssertEqual(first.ItemSearching.Criteria.count, 5) + XCTAssertEqual(first.ItemSearching.Criteria[0].Label, "Brand") + XCTAssertEqual(first.ItemSearching.Columns.count, 8) + + // Verify ShipmentSearching — has ReadOnlyCriteria + XCTAssertEqual(first.ShipmentSearching.Sources, ["Shipment"]) + XCTAssertEqual(first.ShipmentSearching.Target, "Shipment") + XCTAssertEqual(first.ShipmentSearching.ReadOnlyCriteria.count, 6) + XCTAssertEqual(first.ShipmentSearching.Columns.count, 8) + + let second = feed.entry[1].content.configuration + XCTAssertEqual(second.Name, "Catalog Beta") + XCTAssertEqual(second.Details.Controls.count, 23) + + // Second entry has empty ReadOnlyCriteria + XCTAssertEqual(second.ShipmentSearching.ReadOnlyCriteria, []) + XCTAssertEqual(second.ShipmentSearching.Columns.count, 15) + + // Verify a criterion without Label + let criterion = first.ItemSearching.Criteria[2] // ManufactureDate — no Label + XCTAssertNil(criterion.Label) + XCTAssertEqual(criterion.Type, "Date") + XCTAssertEqual(criterion.Field.Attribute, "ManufactureDate") + + // Verify a column with Label + let labeledColumn = first.ItemSearching.Columns[6] // Weight + XCTAssertEqual(labeledColumn.Label, "Weight") + + // Verify last control is read-only + let lastControl = first.Details.Controls[22] + XCTAssertEqual(lastControl.ReadOnly, "True") + XCTAssertEqual(lastControl.Field.Attribute, "Supplier") + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Full XML Fixture + + // Synthetic Atom feed using a fictional inventory domain. + // All URLs, identifiers, and field names are purely illustrative. + static let fullAtomFeedXML = """ + \ + \ + http://feed.example.com/Tenant/00000/InventoryConfigurations\ + Inventory Configurations\ + 2025-01-01T00:00:00Z\ + Admin\ + \ + http://feed.example.com/Tenant/00000/InventoryConfiguration/1001\ + Inventory Configuration\ + 2025-01-01T00:00:00Z\ + Admin\ + \ + \ + \ + Catalog Alpha\ +
\ + ProductSKUTextFalseFalse20\ + ProductNameTextFalseFalse350\ + ProductCategoryTextFalseFalse1\ + ProductManufactureDateDateFalseFalse\ + WarehouseTrackingNumberTextFalseFalse50\ + WarehouseBinNumberTextFalseFalse50\ + WarehouseReceivedDateDateFalseFalse\ + WarehouseReceivedTimeTimeFalseFalse\ + CatalogListedDateMomentFalseFalse\ + WarehouseStockedDateMomentFalseFalse\ + WarehouseShippedDateMomentFalseFalse\ + CatalogPublisherUriFalseFalse\ + CatalogListingTypeUriFalseFalse\ + CatalogRegionUriFalseFalse\ + CatalogFeaturedUriFalseFalse\ + WarehouseCustomField1TextFalseFalse100\ + WarehouseCustomField2TextFalseFalse100\ + WarehouseCustomField3TextFalseFalse100\ + WarehouseCustomField4TextFalseFalse100\ + WarehouseCustomField5TextFalseFalse100\ + WarehouseAisleTextFalseFalse20\ + WarehouseShelfTextFalseFalse20\ + WarehouseSupplierTextFalseTrue\ +
\ + \ + ProductWarehouse\ + Product\ + \ + TextProductBrand\ + TextProductModel\ + DateProductManufactureDate\ + TextProductSKU\ + TextWarehouseGrossWeight\ + \ + \ + TextProductModel\ + TextProductBrand\ + TextProductCategory\ + DateProductManufactureDate\ + DateProductReceivedDate\ + TextProductSKU\ + TextWarehouseGrossWeight\ + TextWarehouseRegionName\ + \ + \ + \ + Shipment\ + Shipment\ + \ + TextProductSKU\ + TextProductName\ + DateProductManufactureDate\ + TextProductCategory\ + TextProductModel\ + TextProductBrand\ + \ + \ + TextWarehouseBinNumber\ + MomentWarehouseStockedDate\ + TextShipmentTrackingNumber\ + TextShipmentCarrierCode\ + TextShipmentStatus\ + DateShipmentShipDate\ + TextShipmentDescription\ + TextShipmentCarrier\ + \ + \ +
\ +
\ +
\ + \ + http://feed.example.com/Tenant/00000/InventoryConfiguration/1002\ + Inventory Configuration\ + 2025-01-01T00:00:00Z\ + Admin\ + \ + \ + \ + Catalog Beta\ +
\ + ProductSKUTextFalseFalse20\ + ProductNameTextFalseFalse350\ + ProductCategoryTextFalseFalse1\ + ProductManufactureDateDateFalseFalse\ + WarehouseReceivedDateDateFalseFalse\ + WarehouseReceivedTimeTimeFalseFalse\ + CatalogListedDateMomentFalseFalse\ + WarehouseStockedDateMomentFalseFalse\ + WarehouseShippedDateMomentFalseFalse\ + WarehouseTrackingNumberTextFalseFalse50\ + CatalogPublisherUriFalseFalse\ + CatalogListingTypeUriFalseFalse\ + CatalogRegionUriFalseFalse\ + CatalogFeaturedUriFalseFalse\ + WarehouseCustomField1TextFalseFalse100\ + WarehouseCustomField2TextFalseFalse100\ + WarehouseCustomField3TextFalseFalse100\ + WarehouseCustomField4TextFalseFalse100\ + WarehouseCustomField5TextFalseFalse100\ + WarehouseAisleTextFalseFalse20\ + WarehouseBinNumberTextFalseFalse50\ + WarehouseShelfTextFalseFalse20\ + WarehouseSupplierTextFalseTrue\ +
\ + \ + ProductWarehouse\ + Product\ + \ + TextProductBrand\ + TextProductModel\ + DateProductManufactureDate\ + TextProductSKU\ + TextWarehouseGrossWeight\ + \ + \ + TextProductModel\ + TextProductBrand\ + TextProductCategory\ + DateProductManufactureDate\ + DateProductReceivedDate\ + TextProductSKU\ + TextWarehouseGrossWeight\ + TextWarehouseRegionName\ + \ + \ + \ + Warehouse\ + Warehouse\ + \ + \ + DateWarehouseReceivedDate\ + TimeWarehouseReceivedTime\ + TextWarehouseDescription\ + TextProductSKU\ + TextProductName\ + TextProductCategory\ + DateProductManufactureDate\ + TextWarehouseRegionId\ + TextWarehouseRegionName\ + TextWarehouseTrackingNumber\ + TextWarehouseCustomField1\ + TextWarehouseCustomField2\ + TextWarehouseCustomField3\ + TextWarehouseCustomField4\ + TextWarehouseCustomField5\ + \ + \ +
\ +
\ +
\ +
+ """ +} diff --git a/Tests/KumoTests/Fixtures/XML/SearchResultDecodingTests.swift b/Tests/KumoTests/Fixtures/XML/SearchResultDecodingTests.swift new file mode 100644 index 0000000..8ed8c74 --- /dev/null +++ b/Tests/KumoTests/Fixtures/XML/SearchResultDecodingTests.swift @@ -0,0 +1,518 @@ +import Foundation +import XCTest +@testable import Kumo +@testable import KumoCoding + +// All XML fixtures in this file are synthetic test data. +// The domain (library / book catalog) is fictional and used solely to +// exercise KumoCoding's XMLDecoder against query/result XML patterns +// including polymorphic data types, self-closing elements, and Atom feeds. + +class SearchResultDecodingTests: XCTestCase { + + // MARK: - Search Query Decoding + + /// Decodes a standalone element with Sources, Parameters, and Columns. + func testDecodeSearchQuery() { + let decoder = XMLDecoder() + let data = """ + + + Book + + + + + Book + Author + + + http://example.com/Tenant/100/Author/42 + + + + + + Book + URI + + + Book + PublishedDate + + + Reader + Id + + + Reader + FullName + + + + """.data(using: .utf8)! + + do { + let query = try decoder.decode(SearchQuery.self, from: data) + XCTAssertEqual(query.Sources, ["Book"]) + XCTAssertEqual(query.Parameters.count, 1) + XCTAssertEqual(query.Parameters[0].Field.Source, "Book") + XCTAssertEqual(query.Parameters[0].Field.Attribute, "Author") + XCTAssertEqual(query.Parameters[0].Data.Uri, "http://example.com/Tenant/100/Author/42") + XCTAssertNil(query.Parameters[0].Data.Moment) + XCTAssertNil(query.Parameters[0].Data.Text) + XCTAssertEqual(query.Columns.count, 4) + XCTAssertEqual(query.Columns[0].Attribute, "URI") + XCTAssertEqual(query.Columns[3].Source, "Reader") + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Single Row with Polymorphic Data + + /// Decodes a single containing Uri, Moment, and Text data types. + func testDecodeSingleRowWithPolymorphicData() { + let decoder = XMLDecoder() + let data = """ + + http://example.com/Tenant/100/Book/5001 + Book + + + BookURI + http://example.com/Tenant/100/Book/5001 + + + BookPublishedDate + 2025-03-15T10:30:00-05:00 + + + ReaderId + R-414 + + + ReaderFullName + Jane Doe + + + + """.data(using: .utf8)! + + do { + let row = try decoder.decode(SearchResultRow.self, from: data) + XCTAssertEqual(row.Id, "http://example.com/Tenant/100/Book/5001") + XCTAssertEqual(row.Source, "Book") + XCTAssertEqual(row.Fields.count, 4) + + // Item 0: Uri data + XCTAssertEqual(row.Fields[0].Field.Source, "Book") + XCTAssertEqual(row.Fields[0].Field.Attribute, "URI") + XCTAssertEqual(row.Fields[0].Data.Uri, "http://example.com/Tenant/100/Book/5001") + XCTAssertNil(row.Fields[0].Data.Moment) + XCTAssertNil(row.Fields[0].Data.Text) + + // Item 1: Moment data + XCTAssertNil(row.Fields[1].Data.Uri) + XCTAssertEqual(row.Fields[1].Data.Moment, "2025-03-15T10:30:00-05:00") + XCTAssertNil(row.Fields[1].Data.Text) + + // Item 2: Text data + XCTAssertNil(row.Fields[2].Data.Uri) + XCTAssertNil(row.Fields[2].Data.Moment) + XCTAssertEqual(row.Fields[2].Data.Text, "R-414") + + // Item 3: Text data + XCTAssertEqual(row.Fields[3].Data.Text, "Jane Doe") + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Self-Closing / Empty Data Elements + + /// Tests that self-closing elements like , , + /// and empty elements like decode as nil. + func testDecodeSelfClosingDataElements() { + let decoder = XMLDecoder() + let data = """ + + http://example.com/Tenant/100/Book/5002 + Book + + + BookCheckoutDate + + + + ReaderId + + + + ReaderFullName + + + + ShelfURI + + + + + """.data(using: .utf8)! + + do { + let row = try decoder.decode(SearchResultRow.self, from: data) + XCTAssertEqual(row.Fields.count, 4) + + // — self-closing, should be nil + XCTAssertNil(row.Fields[0].Data.Moment) + XCTAssertNil(row.Fields[0].Data.Uri) + XCTAssertNil(row.Fields[0].Data.Text) + + // — empty content, should be nil + XCTAssertNil(row.Fields[1].Data.Text) + + // — self-closing, should be nil + XCTAssertNil(row.Fields[2].Data.Text) + + // — self-closing, should be nil + XCTAssertNil(row.Fields[3].Data.Uri) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Row with Mixed Populated and Empty Fields + + /// A row where some fields have values and others are empty/self-closing, + /// mirroring real-world patterns where optional data is sparse. + func testDecodeRowWithMixedPopulatedAndEmptyFields() { + let decoder = XMLDecoder() + let data = """ + + http://example.com/Tenant/100/Book/5003 + Book + + + BookURI + http://example.com/Tenant/100/Book/5003 + + + BookPublishedDate + 2025-06-01T14:00:00-05:00 + + + BookCheckoutDate + + + + ReaderId + R-123 + + + ReaderFullName + + + + GenreURI + http://example.com/Tenant/100/Genre/7001 + + + ShelfURI + + + + + """.data(using: .utf8)! + + do { + let row = try decoder.decode(SearchResultRow.self, from: data) + XCTAssertEqual(row.Fields.count, 7) + + // Populated URI + XCTAssertEqual(row.Fields[0].Data.Uri, "http://example.com/Tenant/100/Book/5003") + // Populated Moment + XCTAssertEqual(row.Fields[1].Data.Moment, "2025-06-01T14:00:00-05:00") + // Empty Moment + XCTAssertNil(row.Fields[2].Data.Moment) + // Populated Text + XCTAssertEqual(row.Fields[3].Data.Text, "R-123") + // Empty Text + XCTAssertNil(row.Fields[4].Data.Text) + // Populated URI + XCTAssertEqual(row.Fields[5].Data.Uri, "http://example.com/Tenant/100/Genre/7001") + // Empty URI + XCTAssertNil(row.Fields[6].Data.Uri) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Multiple Entries in Atom Feed + + /// Decodes a full Atom feed with 3 entries representing search results. + /// Entry 1: fully populated (all data present, 9 fields including links) + /// Entry 2: sparse data (empty Text/Moment, only 7 fields, no link fields) + /// Entry 3: all fields populated with different data values + func testDecodeSearchResultFeedWithMultipleEntries() { + let decoder = XMLDecoder() + let data = Self.searchResultFeedXML.data(using: .utf8)! + + do { + let feed = try decoder.decode(SearchResultFeed.self, from: data) + XCTAssertEqual(feed.entries.count, 3) + + // --- Entry 1: Fully populated --- + let row1 = feed.entries[0].content.row + XCTAssertEqual(row1.Id, "http://example.com/Tenant/100/Book/5001") + XCTAssertEqual(row1.Source, "Book") + XCTAssertEqual(row1.Fields.count, 9) + + // URI field + XCTAssertEqual(row1.Fields[0].Field.Attribute, "URI") + XCTAssertEqual(row1.Fields[0].Data.Uri, "http://example.com/Tenant/100/Book/5001") + + // Moment field (PublishedDate) + XCTAssertEqual(row1.Fields[1].Field.Attribute, "PublishedDate") + XCTAssertEqual(row1.Fields[1].Data.Moment, "2025-04-06T15:04:56-05:00") + + // Moment field (CheckoutDate) — self-closing + XCTAssertEqual(row1.Fields[2].Field.Attribute, "CheckoutDate") + XCTAssertNil(row1.Fields[2].Data.Moment) + + // Text field (Reader Id) + XCTAssertEqual(row1.Fields[3].Field.Attribute, "Id") + XCTAssertEqual(row1.Fields[3].Data.Text, "R-414") + + // Text field (Reader FullName) + XCTAssertEqual(row1.Fields[4].Field.Attribute, "FullName") + XCTAssertEqual(row1.Fields[4].Data.Text, "Alice Wonderland") + + // Uri field (Genre) + XCTAssertEqual(row1.Fields[5].Field.Attribute, "URI") + XCTAssertEqual(row1.Fields[5].Data.Uri, "http://example.com/Tenant/100/Genre/7001") + + // Uri field (Shelf) — self-closing + XCTAssertEqual(row1.Fields[6].Field.Attribute, "URI") + XCTAssertNil(row1.Fields[6].Data.Uri) + + // Link fields (ContentLink, DocumentLink) + XCTAssertEqual(row1.Fields[7].Field.Attribute, "ContentLink") + XCTAssertEqual(row1.Fields[7].Data.Uri, + "https://api.example.com/Tenant/100/Book/5001/Content/HTML") + XCTAssertEqual(row1.Fields[8].Field.Attribute, "CoverLink") + XCTAssertEqual(row1.Fields[8].Data.Uri, + "https://api.example.com/Tenant/100/Book/5001/Cover/JPG") + + // --- Entry 2: Sparse — empty Text and Moment fields, no links --- + let row2 = feed.entries[1].content.row + XCTAssertEqual(row2.Id, "http://example.com/Tenant/100/Book/5002") + XCTAssertEqual(row2.Fields.count, 7) + + // Reader Id — empty + XCTAssertEqual(row2.Fields[3].Field.Source, "Reader") + XCTAssertEqual(row2.Fields[3].Field.Attribute, "Id") + XCTAssertNil(row2.Fields[3].Data.Text) + + // Reader FullName — self-closing + XCTAssertEqual(row2.Fields[4].Field.Attribute, "FullName") + XCTAssertNil(row2.Fields[4].Data.Text) + + // Shelf URI — self-closing + XCTAssertEqual(row2.Fields[6].Field.Attribute, "URI") + XCTAssertNil(row2.Fields[6].Data.Uri) + + // --- Entry 3: All populated, different genre URI --- + let row3 = feed.entries[2].content.row + XCTAssertEqual(row3.Id, "http://example.com/Tenant/100/Book/5003") + XCTAssertEqual(row3.Fields.count, 9) + + // Reader populated + XCTAssertEqual(row3.Fields[3].Data.Text, "R-789") + XCTAssertEqual(row3.Fields[4].Data.Text, "Bob Smith") + + // Different genre + XCTAssertEqual(row3.Fields[5].Data.Uri, "http://example.com/Tenant/100/Genre/7002") + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Query with Multiple Parameters + + /// Decodes a query that has multiple parameter bindings, testing that + /// wrapper arrays with more than one child decode correctly. + func testDecodeQueryWithMultipleParameters() { + let decoder = XMLDecoder() + let data = """ + + + Book + Reader + + + + BookAuthor + http://example.com/Tenant/100/Author/42 + + + BookGenre + Fiction + + + BookPublishedAfter + 2024-01-01T00:00:00Z + + + + BookTitle + BookISBN + + + """.data(using: .utf8)! + + do { + let query = try decoder.decode(SearchQuery.self, from: data) + XCTAssertEqual(query.Sources, ["Book", "Reader"]) + XCTAssertEqual(query.Parameters.count, 3) + + // Parameter 1: Uri + XCTAssertEqual(query.Parameters[0].Data.Uri, "http://example.com/Tenant/100/Author/42") + XCTAssertNil(query.Parameters[0].Data.Text) + XCTAssertNil(query.Parameters[0].Data.Moment) + + // Parameter 2: Text + XCTAssertNil(query.Parameters[1].Data.Uri) + XCTAssertEqual(query.Parameters[1].Data.Text, "Fiction") + XCTAssertNil(query.Parameters[1].Data.Moment) + + // Parameter 3: Moment + XCTAssertNil(query.Parameters[2].Data.Uri) + XCTAssertNil(query.Parameters[2].Data.Text) + XCTAssertEqual(query.Parameters[2].Data.Moment, "2024-01-01T00:00:00Z") + + XCTAssertEqual(query.Columns.count, 2) + XCTAssertEqual(query.Columns[0].Attribute, "Title") + XCTAssertEqual(query.Columns[1].Attribute, "ISBN") + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Row with Minimum Fields + + /// Decodes a row with only the bare minimum fields (no link fields), + /// ensuring the decoder handles varying field counts gracefully. + func testDecodeRowWithMinimumFields() { + let decoder = XMLDecoder() + let data = """ + + http://example.com/Tenant/100/Book/9999 + Book + + + BookURI + http://example.com/Tenant/100/Book/9999 + + + + """.data(using: .utf8)! + + do { + let row = try decoder.decode(SearchResultRow.self, from: data) + XCTAssertEqual(row.Id, "http://example.com/Tenant/100/Book/9999") + XCTAssertEqual(row.Source, "Book") + XCTAssertEqual(row.Fields.count, 1) + XCTAssertEqual(row.Fields[0].Data.Uri, "http://example.com/Tenant/100/Book/9999") + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Full Atom Feed XML Fixture + + // Synthetic Atom feed using a fictional library/book catalog domain. + // Three entries with varying field counts and data completeness: + // Entry 1 (Book/5001): 9 fields, fully populated (Uri, Moment, Text, links) + // Entry 2 (Book/5002): 7 fields, sparse (empty Text, self-closing Moment/Uri, no links) + // Entry 3 (Book/5003): 9 fields, all populated with different values + static let searchResultFeedXML = """ + \ + \ + http://example.com/Tenant/100/BookSearch\ + Book Search Results\ + 2025-04-07T12:00:00Z\ + System\ + \ + http://example.com/Tenant/100/Book/5001\ + Row\ + 2025-04-07T12:00:00Z\ + System\ + \ + \ + \ + http://example.com/Tenant/100/Book/5001\ + Book\ + \ + BookURIhttp://example.com/Tenant/100/Book/5001\ + BookPublishedDate2025-04-06T15:04:56-05:00\ + BookCheckoutDate\ + ReaderIdR-414\ + ReaderFullNameAlice Wonderland\ + GenreURIhttp://example.com/Tenant/100/Genre/7001\ + ShelfURI\ + BookContentLinkhttps://api.example.com/Tenant/100/Book/5001/Content/HTML\ + BookCoverLinkhttps://api.example.com/Tenant/100/Book/5001/Cover/JPG\ + \ + \ + \ + \ + \ + http://example.com/Tenant/100/Book/5002\ + Row\ + 2025-04-07T12:00:00Z\ + System\ + \ + \ + \ + http://example.com/Tenant/100/Book/5002\ + Book\ + \ + BookURIhttp://example.com/Tenant/100/Book/5002\ + BookPublishedDate2025-04-06T15:03:52-05:00\ + BookCheckoutDate\ + ReaderId\ + ReaderFullName\ + GenreURIhttp://example.com/Tenant/100/Genre/7001\ + ShelfURI\ + \ + \ + \ + \ + \ + http://example.com/Tenant/100/Book/5003\ + Row\ + 2025-04-07T12:00:00Z\ + System\ + \ + \ + \ + http://example.com/Tenant/100/Book/5003\ + Book\ + \ + BookURIhttp://example.com/Tenant/100/Book/5003\ + BookPublishedDate2025-03-15T10:30:00-05:00\ + BookCheckoutDate\ + ReaderIdR-789\ + ReaderFullNameBob Smith\ + GenreURIhttp://example.com/Tenant/100/Genre/7002\ + ShelfURI\ + BookContentLinkhttps://api.example.com/Tenant/100/Book/5003/Content/HTML\ + BookCoverLinkhttps://api.example.com/Tenant/100/Book/5003/Cover/JPG\ + \ + \ + \ + \ + + """ +} diff --git a/Tests/KumoTests/Mocks/Services/DynamicBody.swift b/Tests/KumoTests/Mocks/Services/DynamicBody.swift index 4500caa..63a1ba7 100644 --- a/Tests/KumoTests/Mocks/Services/DynamicBody.swift +++ b/Tests/KumoTests/Mocks/Services/DynamicBody.swift @@ -6,7 +6,7 @@ struct RequestBody: Codable { let integer: Int } - static let dynamicBody: [String: Any] = ["nested": ["integer": 3], "leaf": "string"] + nonisolated(unsafe) static let dynamicBody: [String: Any] = ["nested": ["integer": 3], "leaf": "string"] let nested: NestedBody let leaf: String diff --git a/Tests/KumoTests/Mocks/Services/MockResponse.swift b/Tests/KumoTests/Mocks/Services/MockResponse.swift index 301532b..b497840 100644 --- a/Tests/KumoTests/Mocks/Services/MockResponse.swift +++ b/Tests/KumoTests/Mocks/Services/MockResponse.swift @@ -1,13 +1,13 @@ import Foundation -struct MockResponse: Decodable { +struct MockResponse: Decodable, Sendable { let args: [String: String] let headers: [String: String] let origin: String let url: URL } -struct MockObjectResponse: Decodable { +struct MockObjectResponse: Decodable, Sendable { let args: [String: String] let headers: [String: String] let origin: String diff --git a/Tests/KumoTests/Mocks/XML/GenericModels.swift b/Tests/KumoTests/Mocks/XML/GenericModels.swift new file mode 100644 index 0000000..6b0cb35 --- /dev/null +++ b/Tests/KumoTests/Mocks/XML/GenericModels.swift @@ -0,0 +1,365 @@ +// Generic domain models for XML decoding tests. +// Uses fictional domains (weather, e-commerce, employee directory, metrics) +// to exercise KumoCoding XMLDecoder edge cases without referencing +// any internal or proprietary systems. +// +// NOTE: Properties use PascalCase with explicit CodingKeys so that +// KeyedXMLDecodingContainer.contains() and decodeNil(forKey:) — which +// both match by exact stringValue — work correctly with the XML element +// names. This follows the same convention used by the existing +// HeaderConfiguration and SearchResult mock models. +import Foundation + +// MARK: - Weather Forecast (nested optionals, multiple entries) + +struct WeatherForecastResponse: Decodable, Equatable { + let Location: String + let Forecasts: [Forecast] + + struct Forecast: Decodable, Equatable { + let Date: String + let High: Double + let Low: Double + let Condition: String + let Precipitation: Double? + let WindSpeed: Double? + let Advisory: String? + + private enum CodingKeys: String, CodingKey { + case Date, High, Low, Condition, Precipitation, WindSpeed, Advisory + } + } + + private enum CodingKeys: String, CodingKey { + case Location, Forecasts + } +} + +// MARK: - Order List (multiple entries, empty arrays, nested elements) + +struct OrderListResponse: Decodable, Equatable { + let TotalCount: Int + let Orders: [Order] + + private enum CodingKeys: String, CodingKey { + case TotalCount, Orders + } +} + +struct Order: Decodable, Equatable { + let OrderId: String + let Status: String + let Customer: OrderCustomer + let Items: [OrderItem] + let Notes: [String]? + + private enum CodingKeys: String, CodingKey { + case OrderId, Status, Customer, Items, Notes + } + + init(orderId: String, status: String, customer: OrderCustomer, items: [OrderItem], notes: [String]?) { + self.OrderId = orderId + self.Status = status + self.Customer = customer + self.Items = items + self.Notes = notes + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + OrderId = try container.decode(String.self, forKey: .OrderId) + Status = try container.decode(String.self, forKey: .Status) + Customer = try container.decode(OrderCustomer.self, forKey: .Customer) + Items = try container.decode([OrderItem].self, forKey: .Items) + + if container.contains(.Notes) { + let isNil = try container.decodeNil(forKey: .Notes) + if isNil { + Notes = nil + } else { + Notes = try container.decode([String].self, forKey: .Notes) + } + } else { + Notes = nil + } + } +} + +struct OrderCustomer: Decodable, Equatable { + let Name: String + let Email: String? + let Phone: String? + + private enum CodingKeys: String, CodingKey { + case Name, Email, Phone + } +} + +struct OrderItem: Decodable, Equatable { + let Sku: String + let Name: String + let Quantity: Int + let Price: Double + + private enum CodingKeys: String, CodingKey { + case Sku, Name, Quantity, Price + } +} + +// MARK: - Employee Directory (deeply nested, optional sections) + +struct EmployeeDirectory: Decodable, Equatable { + let Department: String + let Employees: [Employee] + + private enum CodingKeys: String, CodingKey { + case Department, Employees + } +} + +struct Employee: Decodable, Equatable { + let Id: String + let Name: String + let Title: String + let Contact: EmployeeContact + let Skills: [String]? + let Projects: [EmployeeProject]? + + private enum CodingKeys: String, CodingKey { + case Id, Name, Title, Contact, Skills, Projects + } + + init(id: String, name: String, title: String, contact: EmployeeContact, + skills: [String]?, projects: [EmployeeProject]?) { + self.Id = id + self.Name = name + self.Title = title + self.Contact = contact + self.Skills = skills + self.Projects = projects + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + Id = try container.decode(String.self, forKey: .Id) + Name = try container.decode(String.self, forKey: .Name) + Title = try container.decode(String.self, forKey: .Title) + Contact = try container.decode(EmployeeContact.self, forKey: .Contact) + + if container.contains(.Skills) { + let isNil = try container.decodeNil(forKey: .Skills) + Skills = isNil ? nil : try container.decode([String].self, forKey: .Skills) + } else { + Skills = nil + } + + if container.contains(.Projects) { + let isNil = try container.decodeNil(forKey: .Projects) + Projects = isNil ? nil : try container.decode([EmployeeProject].self, forKey: .Projects) + } else { + Projects = nil + } + } +} + +struct EmployeeContact: Decodable, Equatable { + let Email: String + let Phone: String? + let Address: EmployeeAddress? + + private enum CodingKeys: String, CodingKey { + case Email, Phone, Address + } +} + +struct EmployeeAddress: Decodable, Equatable { + let Street: String + let City: String + let State: String + let Zip: String + + private enum CodingKeys: String, CodingKey { + case Street, City, State, Zip + } +} + +struct EmployeeProject: Decodable, Equatable { + let Name: String + let Role: String + let Active: Bool + + private enum CodingKeys: String, CodingKey { + case Name, Role, Active + } +} + +// MARK: - Catalog Item (SOAP payload with deeply nested structure) + +struct CatalogItem: Decodable, Equatable { + let Id: String + let Name: String + let Category: String + let Pricing: CatalogPricing + let Inventory: CatalogInventory + let Tags: [String]? + + private enum CodingKeys: String, CodingKey { + case Id, Name, Category, Pricing, Inventory, Tags + } + + init(id: String, name: String, category: String, pricing: CatalogPricing, + inventory: CatalogInventory, tags: [String]?) { + self.Id = id + self.Name = name + self.Category = category + self.Pricing = pricing + self.Inventory = inventory + self.Tags = tags + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + Id = try container.decode(String.self, forKey: .Id) + Name = try container.decode(String.self, forKey: .Name) + Category = try container.decode(String.self, forKey: .Category) + Pricing = try container.decode(CatalogPricing.self, forKey: .Pricing) + Inventory = try container.decode(CatalogInventory.self, forKey: .Inventory) + + if container.contains(.Tags) { + let isNil = try container.decodeNil(forKey: .Tags) + Tags = isNil ? nil : try container.decode([String].self, forKey: .Tags) + } else { + Tags = nil + } + } +} + +struct CatalogPricing: Decodable, Equatable { + let BasePrice: Double + let Currency: String + let Discount: Double? + let TaxRate: Double? + + private enum CodingKeys: String, CodingKey { + case BasePrice, Currency, Discount, TaxRate + } +} + +struct CatalogInventory: Decodable, Equatable { + let Warehouse: String + let Quantity: Int + let Reserved: Int + let ReorderThreshold: Int? + + private enum CodingKeys: String, CodingKey { + case Warehouse, Quantity, Reserved, ReorderThreshold + } +} + +// MARK: - Notification List (Atom feed-style with unkeyed iteration) + +struct NotificationFeed: Decodable { + let notifications: [NotificationEntry] + + init(from decoder: Decoder) throws { + var results: [NotificationEntry] = [] + var container = try decoder.unkeyedContainer() + while !container.isAtEnd { + if let entry = try? container.decode(NotificationEntry.self) { + results.append(entry) + } + } + self.notifications = results + } +} + +struct NotificationEntry: Decodable, Equatable { + let content: NotificationContent + + private enum CodingKeys: String, CodingKey { + case content + } +} + +struct NotificationContent: Decodable, Equatable { + let notification: NotificationPayload + + private enum CodingKeys: String, CodingKey { + case notification = "Notification" + } +} + +struct NotificationPayload: Decodable, Equatable { + let Id: String + let Title: String + let Message: String? + let Priority: String + let Read: Bool + let Timestamp: String + + private enum CodingKeys: String, CodingKey { + case Id, Title, Message, Priority, Read, Timestamp + } +} + +// MARK: - Polymorphic Metric Values + +struct MetricValue: Decodable, Equatable { + let Gauge: String? + let Counter: String? + let Timestamp: String? + + private enum CodingKeys: String, CodingKey { + case Gauge, Counter, Timestamp + } + + init(gauge: String? = nil, counter: String? = nil, timestamp: String? = nil) { + self.Gauge = gauge + self.Counter = counter + self.Timestamp = timestamp + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if container.contains(.Gauge) { + let isNil = try container.decodeNil(forKey: .Gauge) + Gauge = isNil ? nil : try container.decode(String.self, forKey: .Gauge) + } else { + Gauge = nil + } + + if container.contains(.Counter) { + let isNil = try container.decodeNil(forKey: .Counter) + Counter = isNil ? nil : try container.decode(String.self, forKey: .Counter) + } else { + Counter = nil + } + + if container.contains(.Timestamp) { + let isNil = try container.decodeNil(forKey: .Timestamp) + Timestamp = isNil ? nil : try container.decode(String.self, forKey: .Timestamp) + } else { + Timestamp = nil + } + } +} + +struct MetricItem: Decodable, Equatable { + let Field: FieldModel + let Value: MetricValue + + private enum CodingKeys: String, CodingKey { + case Field, Value + } +} + +struct MetricRow: Decodable, Equatable { + let Id: String + let Source: String + let Metrics: [MetricItem] + + private enum CodingKeys: String, CodingKey { + case Id, Source, Metrics + } +} diff --git a/Tests/KumoTests/Mocks/XML/GetPriceResponse.swift b/Tests/KumoTests/Mocks/XML/GetPriceResponse.swift index 592db81..b7da3ef 100644 --- a/Tests/KumoTests/Mocks/XML/GetPriceResponse.swift +++ b/Tests/KumoTests/Mocks/XML/GetPriceResponse.swift @@ -1,7 +1,7 @@ import Foundation -struct GetPriceResponse: Decodable, Equatable { - struct Price: Decodable, Equatable { +struct GetPriceResponse: Codable, Equatable { + struct Price: Codable, Equatable { let amount: Double let units: String } diff --git a/Tests/KumoTests/Mocks/XML/HeaderConfiguration.swift b/Tests/KumoTests/Mocks/XML/HeaderConfiguration.swift new file mode 100644 index 0000000..01f6722 --- /dev/null +++ b/Tests/KumoTests/Mocks/XML/HeaderConfiguration.swift @@ -0,0 +1,187 @@ +// HeaderConfiguration models for decoding XML configuration payloads. +// Used by KumoCoding XMLDecoder tests. +import Foundation + +// MARK: - Atom Feed Wrappers + +/// Top-level Atom `` element containing `` elements. +/// Uses a custom decoder because the Atom feed has mixed children +/// (id, title, updated, author, entry, …) and the KumoCoding XMLDecoder +/// only supports keyed-find-first, so we iterate with an unkeyed container +/// and collect successful entry decodes. +struct HeaderConfigurationFeed: Decodable { + let entry: [HeaderConfigurationEntry] + + init(from decoder: Decoder) throws { + var entries: [HeaderConfigurationEntry] = [] + var container = try decoder.unkeyedContainer() + while !container.isAtEnd { + if let entry = try? container.decode(HeaderConfigurationEntry.self) { + entries.append(entry) + } + } + self.entry = entries + } +} + +/// Single Atom `` whose `` holds a `Configuration`. +struct HeaderConfigurationEntry: Decodable { + let content: HeaderConfigurationContent + + private enum CodingKeys: String, CodingKey { + case content = "content" + } +} + +/// The `` wrapper that contains the nested `Configuration`. +struct HeaderConfigurationContent: Decodable { + let configuration: Configuration + + private enum CodingKeys: String, CodingKey { + case configuration = "Configuration" + } +} + +// MARK: - Domain Models + +struct Configuration: Codable, Equatable { + var Name: String + var Details: Details + var ItemSearching: ItemSearching + var ShipmentSearching: ShipmentSearching + + private enum CodingKeys: String, CodingKey { + case Name = "Name" + case Details = "Details" + case ItemSearching = "ItemSearching" + case ShipmentSearching = "ShipmentSearching" + } +} + +struct Details: Codable, Equatable { + var Controls: [Control] + + private enum CodingKeys: String, CodingKey { + case Controls = "Controls" + } +} + +struct Control: Codable, Equatable { + var Label: String? // optional — not present in every control + var Field: FieldModel + var DataType: String + var Required: String + var ReadOnly: String + var MaximumLength: Int? + var Choices: [String]? // optional — may be absent or empty self-closing tag + + private enum CodingKeys: String, CodingKey { + case Label = "Label" + case Field = "Field" + case DataType = "DataType" + case Required = "Required" + case ReadOnly = "ReadOnly" + case MaximumLength = "MaximumLength" + case Choices = "Choices" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + Label = try container.decodeIfPresent(String.self, forKey: .Label) + Field = try container.decode(FieldModel.self, forKey: .Field) + DataType = try container.decode(String.self, forKey: .DataType) + Required = try container.decode(String.self, forKey: .Required) + ReadOnly = try container.decode(String.self, forKey: .ReadOnly) + MaximumLength = try container.decodeIfPresent(Int.self, forKey: .MaximumLength) + + // Choices can be absent, empty (), or contain children. + if container.contains(.Choices) { + let isNil = try container.decodeNil(forKey: .Choices) + if isNil { + Choices = nil + } else { + Choices = try container.decode([String].self, forKey: .Choices) + } + } else { + Choices = nil + } + } +} + +struct FieldModel: Codable, Equatable { + var Source: String + var Attribute: String + + private enum CodingKeys: String, CodingKey { + case Source = "Source" + case Attribute = "Attribute" + } +} + +struct ItemSearching: Codable, Equatable { + var Sources: [String] + var Target: String + var Criteria: [Criterion] + var Columns: [Column] + + private enum CodingKeys: String, CodingKey { + case Sources = "Sources" + case Target = "Target" + case Criteria = "Criteria" + case Columns = "Columns" + } +} + +struct ShipmentSearching: Codable, Equatable { + var Sources: [String] + var Target: String + var ReadOnlyCriteria: [Criterion] + var Columns: [Column] + + private enum CodingKeys: String, CodingKey { + case Sources = "Sources" + case Target = "Target" + case ReadOnlyCriteria = "ReadOnlyCriteria" + case Columns = "Columns" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + Sources = try container.decode([String].self, forKey: .Sources) + Target = try container.decode(String.self, forKey: .Target) + + // ReadOnlyCriteria can be an empty self-closing tag () + let isNil = try container.decodeNil(forKey: .ReadOnlyCriteria) + if isNil { + ReadOnlyCriteria = [] + } else { + ReadOnlyCriteria = try container.decode([Criterion].self, forKey: .ReadOnlyCriteria) + } + + Columns = try container.decode([Column].self, forKey: .Columns) + } +} + +struct Criterion: Codable, Equatable { + var Label: String? // optional — not always present + var `Type`: String + var Field: FieldModel + + private enum CodingKeys: String, CodingKey { + case Label = "Label" + case `Type` = "Type" + case Field = "Field" + } +} + +struct Column: Codable, Equatable { + var Label: String? // optional — not always present + var `Type`: String + var Field: FieldModel + + private enum CodingKeys: String, CodingKey { + case Label = "Label" + case `Type` = "Type" + case Field = "Field" + } +} diff --git a/Tests/KumoTests/Mocks/XML/SearchResult.swift b/Tests/KumoTests/Mocks/XML/SearchResult.swift new file mode 100644 index 0000000..6e042b4 --- /dev/null +++ b/Tests/KumoTests/Mocks/XML/SearchResult.swift @@ -0,0 +1,178 @@ +// Models for decoding search query/result XML payloads. +// Used by KumoCoding XMLDecoder tests. +// +// Covers patterns: +// - Atom feed with multiple rows (unkeyed iteration) +// - Polymorphic elements containing one of , , or +// - Self-closing / empty elements (, , , ) +// - Nested wrapper arrays (Fields > Item, Parameters > Parameter) +// - Mixed sibling types in (id, title, updated, author, link, content) +import Foundation + +// MARK: - Atom Feed Wrappers + +/// Top-level Atom `` element containing search result `` rows. +/// Uses the same unkeyed-container pattern as HeaderConfigurationFeed to +/// iterate mixed children and collect entry elements. +struct SearchResultFeed: Decodable { + let entries: [SearchResultEntry] + + init(from decoder: Decoder) throws { + var results: [SearchResultEntry] = [] + var container = try decoder.unkeyedContainer() + while !container.isAtEnd { + if let entry = try? container.decode(SearchResultEntry.self) { + results.append(entry) + } + } + self.entries = results + } +} + +/// Single Atom `` whose `` holds a `SearchResultRow`. +struct SearchResultEntry: Decodable { + let content: SearchResultContent + + private enum CodingKeys: String, CodingKey { + case content + } +} + +/// The `` wrapper containing the nested `Row`. +struct SearchResultContent: Decodable { + let row: SearchResultRow + + private enum CodingKeys: String, CodingKey { + case row = "Row" + } +} + +// MARK: - Search Query (request payload) + +/// A search query containing sources, parameters, and requested columns. +/// +/// XML structure: +/// ``` +/// +/// ... +/// +/// ...... +/// +/// ... +/// +/// ``` +struct SearchQuery: Decodable, Equatable { + let Sources: [String] + let Parameters: [SearchParameter] + let Columns: [FieldModel] + + private enum CodingKeys: String, CodingKey { + case Sources + case Parameters + case Columns + } +} + +/// A single query parameter binding a field to a data value. +struct SearchParameter: Decodable, Equatable { + let Field: FieldModel + let Data: DataValue + + private enum CodingKeys: String, CodingKey { + case Field + case Data + } +} + +// MARK: - Search Result Row (response payload) + +/// A single search result row containing an identifier, source, and +/// a dynamic list of field/value items. +/// +/// XML structure: +/// ``` +/// +/// ... +/// ... +/// +/// ...... +/// +/// +/// ``` +struct SearchResultRow: Decodable, Equatable { + let Id: String + let Source: String + let Fields: [SearchResultItem] + + private enum CodingKeys: String, CodingKey { + case Id + case Source + case Fields + } +} + +/// A single field/value pair within a result row. +struct SearchResultItem: Decodable, Equatable { + let Field: FieldModel + let Data: DataValue + + private enum CodingKeys: String, CodingKey { + case Field + case Data + } +} + +// MARK: - Polymorphic Data Value + +/// Represents a polymorphic data element that contains exactly one of: +/// - `value` — a resource identifier / link +/// - `value` — a timestamp string +/// - `value` — plain text +/// +/// Any of these child elements may be self-closing or empty, indicating +/// an absent/null value (e.g. ``, ``, ``). +struct DataValue: Decodable, Equatable { + let Uri: String? + let Moment: String? + let Text: String? + + private enum CodingKeys: String, CodingKey { + case Uri + case Moment + case Text + } + + init(uri: String? = nil, moment: String? = nil, text: String? = nil) { + self.Uri = uri + self.Moment = moment + self.Text = text + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Each sub-element may be absent, empty/self-closing, or have content. + // decodeIfPresent returns nil when the key is absent. + // When present but empty (), the String decode returns "". + if container.contains(.Uri) { + let isNil = try container.decodeNil(forKey: .Uri) + Uri = isNil ? nil : try container.decode(String.self, forKey: .Uri) + } else { + Uri = nil + } + + if container.contains(.Moment) { + let isNil = try container.decodeNil(forKey: .Moment) + Moment = isNil ? nil : try container.decode(String.self, forKey: .Moment) + } else { + Moment = nil + } + + if container.contains(.Text) { + let isNil = try container.decodeNil(forKey: .Text) + Text = isNil ? nil : try container.decode(String.self, forKey: .Text) + } else { + Text = nil + } + } +} diff --git a/Tests/KumoTests/Mocks/XML/Unkeyed.swift b/Tests/KumoTests/Mocks/XML/Unkeyed.swift index cfc4c0e..f4ec9eb 100644 --- a/Tests/KumoTests/Mocks/XML/Unkeyed.swift +++ b/Tests/KumoTests/Mocks/XML/Unkeyed.swift @@ -8,15 +8,49 @@ struct Message: Decodable, Equatable { let date: String } -struct ListContainer: Encodable, Equatable { +struct ListContainer: Codable, Equatable { let simpleList: [String] } -struct ComplexListContainer: Encodable, Equatable { +struct ComplexListContainer: Codable, Equatable { let complexList: [ComplexElement] } -struct ComplexElement: Encodable, Equatable { +struct ComplexElement: Codable, Equatable { let x: String let y: String } + +struct NilableContainer: Decodable, Equatable { + let name: String + let nickname: String? +} + +struct NilableEncodable: Encodable, Equatable { + let name: String + let nickname: String? + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + if let nickname = nickname { + try container.encode(nickname, forKey: .nickname) + } else { + try container.encodeNil(forKey: .nickname) + } + } + + private enum CodingKeys: String, CodingKey { + case name, nickname + } +} + +struct SnakeCaseModel: Codable, Equatable { + let firstName: String + let lastName: String +} + +struct DefaultKeyModel: Codable, Equatable { + let title: String + let count: Int +}