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/HeaderConfigurationDecodingTests.swift b/Tests/KumoTests/Fixtures/XML/HeaderConfigurationDecodingTests.swift new file mode 100644 index 0000000..84d5715 --- /dev/null +++ b/Tests/KumoTests/Fixtures/XML/HeaderConfigurationDecodingTests.swift @@ -0,0 +1,277 @@ +import Foundation +import XCTest +@testable import Kumo +@testable import KumoCoding + +// All XML fixtures in this file are sanitized synthetic data. +// Structure mirrors production Atom-feed layout; identifiers are fictional. + +class HeaderConfigurationDecodingTests: XCTestCase { + + // MARK: - Single Configuration (content only) + + func testDecodeSingleConfiguration() { + let decoder = XMLDecoder() + // Keys in XML are PascalCase and match CodingKeys exactly → useDefaultKeys + let data = """ + + Orders Display + + + + PatientId + Text + False + False + 20 + + + PatientFullName + Text + False + False + 350 + + + + + + Patient + PatientVisit + + Patient + + + + Text + PatientLastName + + + + + Text + PatientFirstName + + + + + + Order + + Order + + + Text + PatientId + + + + + Text + PatientVisitAccountNumber + + + + + """.data(using: .utf8)! + + do { + let config = try decoder.decode(Configuration.self, from: data) + XCTAssertEqual(config.Name, "Orders Display") + XCTAssertEqual(config.Demographics.Controls.count, 2) + XCTAssertEqual(config.Demographics.Controls[0].Field.Source, "Patient") + XCTAssertEqual(config.Demographics.Controls[0].Field.Attribute, "Id") + XCTAssertEqual(config.Demographics.Controls[0].DataType, "Text") + XCTAssertEqual(config.Demographics.Controls[0].MaximumLength, 20) + XCTAssertNil(config.Demographics.Controls[0].Label) + XCTAssertNil(config.Demographics.Controls[0].Choices) + + XCTAssertEqual(config.PatientSearching.Sources, ["Patient", "PatientVisit"]) + XCTAssertEqual(config.PatientSearching.Target, "Patient") + XCTAssertEqual(config.PatientSearching.Criteria.count, 1) + XCTAssertEqual(config.PatientSearching.Criteria[0].Label, "Last Name") + + XCTAssertEqual(config.ArtifactSearching.Sources, ["Order"]) + XCTAssertEqual(config.ArtifactSearching.Target, "Order") + XCTAssertEqual(config.ArtifactSearching.ReadOnlyCriteria.count, 1) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Empty ReadOnlyCriteria + + func testDecodeConfigurationWithEmptyReadOnlyCriteria() { + let decoder = XMLDecoder() + let data = """ + + Test Display + + + + PatientId + Text + False + False + 20 + + + + + Patient + Patient + + + + Text + PatientLastName + + + + + Text + PatientFirstName + + + + + PatientVisit + PatientVisit + + + + Date + PatientVisitAppointmentDate + + + + + """.data(using: .utf8)! + + do { + let config = try decoder.decode(Configuration.self, from: data) + XCTAssertEqual(config.Name, "Test Display") + XCTAssertEqual(config.ArtifactSearching.ReadOnlyCriteria, []) + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Empty Choices on Control + + func testDecodeControlWithEmptyChoices() { + let decoder = XMLDecoder() + let data = """ + + Choices Test + + + + PatientVisitUserField1 + Text + False + False + 100 + + + + + + Patient + Patient + + TextPatientId + + + TextPatientId + + + + Order + Order + + + TextOrderOrderNumber + + + + """.data(using: .utf8)! + + do { + let config = try decoder.decode(Configuration.self, from: data) + XCTAssertEqual(config.Demographics.Controls.count, 1) + // Empty should decode as nil + XCTAssertNil(config.Demographics.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, "Orders Display") + XCTAssertEqual(first.Demographics.Controls.count, 23) + + // Verify first control + XCTAssertEqual(first.Demographics.Controls[0].Field.Source, "Patient") + XCTAssertEqual(first.Demographics.Controls[0].Field.Attribute, "Id") + XCTAssertEqual(first.Demographics.Controls[0].DataType, "Text") + XCTAssertEqual(first.Demographics.Controls[0].MaximumLength, 20) + + // Verify PatientSearching + XCTAssertEqual(first.PatientSearching.Sources, ["Patient", "PatientVisit"]) + XCTAssertEqual(first.PatientSearching.Target, "Patient") + XCTAssertEqual(first.PatientSearching.Criteria.count, 5) + XCTAssertEqual(first.PatientSearching.Criteria[0].Label, "Last Name") + XCTAssertEqual(first.PatientSearching.Columns.count, 8) + + // Verify ArtifactSearching — has ReadOnlyCriteria + XCTAssertEqual(first.ArtifactSearching.Sources, ["Order"]) + XCTAssertEqual(first.ArtifactSearching.Target, "Order") + XCTAssertEqual(first.ArtifactSearching.ReadOnlyCriteria.count, 6) + XCTAssertEqual(first.ArtifactSearching.Columns.count, 8) + + let second = feed.entry[1].content.configuration + XCTAssertEqual(second.Name, "Test Display") + XCTAssertEqual(second.Demographics.Controls.count, 23) + + // Second entry has empty ReadOnlyCriteria + XCTAssertEqual(second.ArtifactSearching.ReadOnlyCriteria, []) + XCTAssertEqual(second.ArtifactSearching.Columns.count, 15) + + // Verify a criterion without Label + let criterion = first.PatientSearching.Criteria[2] // Birthdate — no Label + XCTAssertNil(criterion.Label) + XCTAssertEqual(criterion.Type, "Date") + XCTAssertEqual(criterion.Field.Attribute, "Birthdate") + + // Verify a column with Label + let labeledColumn = first.PatientSearching.Columns[6] // Age + XCTAssertEqual(labeledColumn.Label, "Age") + + // Verify last control is read-only + let lastControl = first.Demographics.Controls[22] + XCTAssertEqual(lastControl.ReadOnly, "True") + XCTAssertEqual(lastControl.Field.Attribute, "AdmittingPhysician") + } catch { + XCTFail("Decode failed: \(error)") + } + } + + // MARK: - Full XML Fixture (sanitized) + + // Synthetic Atom feed that mirrors production structure. + // All URLs, client IDs, author names, and identifiers are fictional. + static let fullAtomFeedXML = """ + http://atom.example.com/Client/00000/HeaderConfigurationsHeader Configurations2025-01-01T00:00:00-00:00TestUserhttp://atom.example.com/Client/00000/HeaderConfiguration/1001Header Configuration2025-01-01T00:00:00-00:00TestUserOrders DisplayPatientIdTextFalseFalse20PatientFullNameTextFalseFalse350PatientGenderTextFalseFalse1PatientBirthdateDateFalseFalsePatientVisitOrderNumberTextFalseFalse50PatientVisitAccountNumberTextFalseFalse50PatientVisitAppointmentDateDateFalseFalsePatientVisitAppointmentTimeTimeFalseFalseHeaderDateDictatedMomentFalseFalsePatientVisitAdmissionDateMomentFalseFalsePatientVisitDischargeDateMomentFalseFalseHeaderAuthorUriFalseFalseHeaderDocumentTypeUriFalseFalseHeaderLocationUriFalseFalseHeaderPatientLetterUriFalseFalsePatientVisitUserField1TextFalseFalse100PatientVisitUserField2TextFalseFalse100PatientVisitUserField3TextFalseFalse100PatientVisitUserField4TextFalseFalse100PatientVisitUserField5TextFalseFalse100PatientVisitBedTextFalseFalse20PatientVisitFloorTextFalseFalse20PatientVisitAdmittingPhysicianTextFalseTruePatientPatientVisitPatientTextPatientLastNameTextPatientFirstNameDatePatientBirthdateTextPatientIdTextPatientVisitPatientAgeTextPatientFirstNameTextPatientLastNameTextPatientGenderDatePatientBirthdateDatePatientAppointmentDateTextPatientIdTextPatientVisitPatientAgeTextPatientVisitClientLocationNameOrderOrderTextPatientIdTextPatientFullNameDatePatientBirthdateTextPatientGenderTextPatientFirstNameTextPatientLastNameTextPatientVisitAccountNumberMomentPatientVisitAdmissionDateTextOrderOrderNumberTextOrderAccessionNumberTextOrderOrderStatusDateOrderDateObservedTextOrderDescriptionTextOrderOrderingPhysicianhttp://atom.example.com/Client/00000/HeaderConfiguration/1002Header Configuration2025-01-01T00:00:00-00:00TestUserTest DisplayPatientIdTextFalseFalse20PatientFullNameTextFalseFalse350PatientGenderTextFalseFalse1PatientBirthdateDateFalseFalsePatientVisitAppointmentDateDateFalseFalsePatientVisitAppointmentTimeTimeFalseFalseHeaderDateDictatedMomentFalseFalsePatientVisitAdmissionDateMomentFalseFalsePatientVisitDischargeDateMomentFalseFalsePatientVisitOrderNumberTextFalseFalse50HeaderAuthorUriFalseFalseHeaderDocumentTypeUriFalseFalseHeaderLocationUriFalseFalseHeaderPatientLetterUriFalseFalsePatientVisitUserField1TextFalseFalse100PatientVisitUserField2TextFalseFalse100PatientVisitUserField3TextFalseFalse100PatientVisitUserField4TextFalseFalse100PatientVisitUserField5TextFalseFalse100PatientVisitBedTextFalseFalse20PatientVisitAccountNumberTextFalseFalse50PatientVisitFloorTextFalseFalse20PatientVisitAdmittingPhysicianTextFalseTruePatientPatientVisitPatientTextPatientLastNameTextPatientFirstNameDatePatientBirthdateTextPatientIdTextPatientVisitPatientAgeTextPatientFirstNameTextPatientLastNameTextPatientGenderDatePatientBirthdateDatePatientAppointmentDateTextPatientIdTextPatientVisitPatientAgeTextPatientVisitClientLocationNamePatientVisitPatientVisitDatePatientVisitAppointmentDateTimePatientVisitAppointmentTimeTextPatientVisitDescriptionTextPatientIdTextPatientFullNameTextPatientGenderDatePatientBirthdateTextPatientVisitClientLocationIdTextPatientVisitClientLocationNameTextPatientVisitOrderNumberTextPatientVisitUserField1TextPatientVisitUserField2TextPatientVisitUserField3TextPatientVisitUserField4TextPatientVisitUserField5 + """ +} 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/HeaderConfiguration.swift b/Tests/KumoTests/Mocks/XML/HeaderConfiguration.swift new file mode 100644 index 0000000..92535a4 --- /dev/null +++ b/Tests/KumoTests/Mocks/XML/HeaderConfiguration.swift @@ -0,0 +1,187 @@ +// HeaderConfiguration models for decoding eSOne header_configurations.xml +// Adjusted from genspec output for KumoCoding XMLDecoder compatibility. +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 Demographics: Demographics + var PatientSearching: PatientSearching + var ArtifactSearching: ArtifactSearching + + private enum CodingKeys: String, CodingKey { + case Name = "Name" + case Demographics = "Demographics" + case PatientSearching = "PatientSearching" + case ArtifactSearching = "ArtifactSearching" + } +} + +struct Demographics: Codable, Equatable { + var Controls: [Control] + + private enum CodingKeys: String, CodingKey { + case Controls = "Controls" + } +} + +struct Control: Codable, Equatable { + var Label: String? // optional — not present in XML + 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 PatientSearching: 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 ArtifactSearching: 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" + } +}