react-native-audio-browser 0.2.0-next.32 → 0.2.0-next.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/ios/AVPlayerEngine.swift +60 -24
  2. package/ios/AVPlayerEngineCallbacks.swift +0 -1
  3. package/ios/Browser/BrowserConfig.swift +0 -5
  4. package/ios/Browser/BrowserError.swift +16 -0
  5. package/ios/Browser/BrowserManager.swift +36 -141
  6. package/ios/Browser/BrowserPathHelper.swift +25 -12
  7. package/ios/Browser/FavoriteSet.swift +46 -0
  8. package/ios/Browser/JsonModels.swift +79 -199
  9. package/ios/Browser/PageResolver.swift +16 -96
  10. package/ios/Browser/RequestLayer.swift +97 -2
  11. package/ios/Browser/RequestPipeline.swift +78 -227
  12. package/ios/Browser/SectionScope.swift +25 -0
  13. package/ios/Browser/SimpleRouter.swift +73 -144
  14. package/ios/CarPlay/CarPlayNowPlayingManager.swift +4 -4
  15. package/ios/CarPlay/CarPlaySession.swift +3 -3
  16. package/ios/CarPlay/MediaIntentPlayback.swift +1 -1
  17. package/ios/Extension/Capability+RemoteCommand.swift +22 -69
  18. package/ios/Extension/SearchParams+Query.swift +28 -0
  19. package/ios/Extension/Track+AVPlayer.swift +0 -16
  20. package/ios/Extension/Track+Copying.swift +3 -1
  21. package/ios/Extension/Track+Identity.swift +7 -0
  22. package/ios/Http/HttpClient.swift +12 -30
  23. package/ios/HybridAudioBrowser+Browser.swift +21 -38
  24. package/ios/HybridAudioBrowser+Options.swift +27 -66
  25. package/ios/HybridAudioBrowser+Player.swift +4 -60
  26. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +6 -38
  27. package/ios/HybridAudioBrowser.swift +36 -99
  28. package/ios/Model/NitroTypeStubs.swift +80 -0
  29. package/ios/Model/PlayerUpdateOptions.swift +2 -0
  30. package/ios/Model/RemoteCommand.swift +6 -78
  31. package/ios/Player/NowPlayingUpdater.swift +13 -7
  32. package/ios/Player/Player.swift +26 -10
  33. package/ios/Player/PlayerCallbacks.swift +1 -4
  34. package/ios/Player/PlayerEngine.swift +17 -0
  35. package/ios/Player/RetryLoop.swift +5 -1
  36. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +0 -3
  37. package/ios/RemoteCommand/RemoteCommandController.swift +23 -12
  38. package/ios/Tests/BrowserPathHelperTests.swift +33 -0
  39. package/ios/Tests/CapabilityRemoteCommandTests.swift +35 -0
  40. package/ios/Tests/EmitterTests.swift +3 -48
  41. package/ios/Tests/FavoriteSetTests.swift +60 -0
  42. package/ios/Tests/PlayerPolicyTests.swift +27 -3
  43. package/ios/Tests/PlayerTests.swift +95 -17
  44. package/ios/Tests/RemoteCommandControllerTests.swift +44 -15
  45. package/ios/Tests/RequestLayerTests.swift +116 -0
  46. package/ios/Tests/SearchParamsQueryTests.swift +48 -0
  47. package/ios/Tests/SectionScopeTests.swift +53 -0
  48. package/ios/Tests/SimpleRouterTests.swift +38 -3
  49. package/ios/Tests/Support/TestTypes.swift +44 -3
  50. package/ios/TrackSelector.swift +34 -52
  51. package/ios/Util/Emitter.swift +0 -14
  52. package/ios/Util/NetworkMonitor.swift +8 -25
  53. package/ios/Util/OutputPicker.swift +48 -0
  54. package/package.json +1 -1
  55. package/ios/Model/SourceType.swift +0 -6
  56. package/ios/Tests/RemoteCommandTests.swift +0 -65
@@ -8,11 +8,6 @@ struct RouteMatch {
8
8
 
9
9
  /// Specificity score (higher = more specific match)
10
10
  let specificity: Int
11
-
12
- init(params: [String: String] = [:], specificity: Int = 0) {
13
- self.params = params
14
- self.specificity = specificity
15
- }
16
11
  }
17
12
 
18
13
  /// Protocol for route entries that can be matched.
@@ -20,165 +15,99 @@ protocol RouteEntry {
20
15
  var path: String { get }
21
16
  }
22
17
 
23
- /// Simple route matcher for path patterns with parameter extraction.
18
+ /// Route pattern matching with parameter extraction.
24
19
  ///
25
20
  /// Supports patterns like:
26
21
  /// - /artists - exact match
27
22
  /// - /artists/{id} - parameter extraction
28
- /// - /artists/{id}/albums - multiple segments with parameters
29
23
  /// - /artists/* - wildcard match (matches any single segment)
30
- /// - /files/** - tail wildcard (matches remaining path segments)
24
+ /// - /files/** - tail wildcard (matches remaining path segments, captured as `tail`)
31
25
  ///
32
- /// Route specificity rules:
33
- /// - More path segments = higher specificity
34
- /// - Constant segments > parameter segments > wildcards > tail wildcards
35
- /// - Most specific route wins
36
- final class SimpleRouter {
37
- /// Finds the best matching route for the given path.
38
- ///
39
- /// - Parameters:
40
- /// - path: The incoming path to match (e.g., "/artists/123")
41
- /// - routes: Array of route entries to match against
42
- /// - Returns: Tuple of (route, match result) or nil if no match
43
- func findBestMatch<T: RouteEntry>(path: String, routes: [T]) -> (route: T, match: RouteMatch)? {
44
- var bestMatch: (route: T, match: RouteMatch)?
26
+ /// The most specific route wins, the first on a tie: constant segments over parameters over
27
+ /// wildcards over the tail wildcard, then more segments.
28
+ enum SimpleRouter {
29
+ /// The route the top-level `browse:` source is flattened into.
30
+ static let defaultRoutePath = "__default__"
31
+ static let tabsRoutePath = "__tabs__"
32
+ static let searchRoutePath = "__search__"
33
+
34
+ /// The route that browses `path`: the best match among the routes that are not tabs, search or
35
+ /// the default, else the default route with `path` as its `path` param.
36
+ static func browseRoute<T: RouteEntry>(for path: String, in routes: [T]) -> (route: T, match: RouteMatch)? {
37
+ let browseRoutes = routes.filter {
38
+ $0.path != tabsRoutePath && $0.path != searchRoutePath && $0.path != defaultRoutePath
39
+ }
40
+ if let match = findBestMatch(path: path, routes: browseRoutes) {
41
+ return match
42
+ }
43
+ guard let defaultRoute = routes.first(where: { $0.path == defaultRoutePath }) else { return nil }
44
+ return (defaultRoute, RouteMatch(params: ["path": path], specificity: 0))
45
+ }
45
46
 
47
+ /// The most specific of `routes` matching `path`, or nil when none does.
48
+ static func findBestMatch<T: RouteEntry>(path: String, routes: [T]) -> (route: T, match: RouteMatch)? {
49
+ var best: (route: T, match: RouteMatch)?
46
50
  for route in routes {
47
- if let match = matchPattern(pattern: route.path, path: path) {
48
- if bestMatch == nil || match.specificity > bestMatch!.match.specificity {
49
- bestMatch = (route, match)
50
- }
51
+ guard let match = match(pattern: route.path, path: path) else { continue }
52
+ if best.map({ match.specificity > $0.match.specificity }) ?? true {
53
+ best = (route, match)
51
54
  }
52
55
  }
53
-
54
- return bestMatch
56
+ return best
55
57
  }
56
58
 
57
- /// Attempts to match a single pattern against a path.
58
- ///
59
- /// - Parameters:
60
- /// - pattern: Route pattern (e.g., "/artists/{id}", "/files/*", "/api/**")
61
- /// - path: Incoming path (e.g., "/artists/123")
62
- /// - Returns: RouteMatch if successful, nil otherwise
63
- private func matchPattern(pattern: String, path: String) -> RouteMatch? {
64
- let patternSegments = pattern.split(separator: "/").map(String.init)
65
- let pathSegments = path.split(separator: "/").map(String.init)
66
-
67
- var params: [String: String] = [:]
68
- var constantSegments = 0
69
- var parameterSegments = 0
70
- var wildcardSegments = 0
71
- var hasTailWildcard = false
72
-
73
- // Check for tail wildcard at end
74
- if let lastSegment = patternSegments.last, lastSegment == "**" {
75
- hasTailWildcard = true
76
-
77
- // For tail wildcard, pattern must have <= path segments
78
- if patternSegments.count - 1 > pathSegments.count {
79
- return nil
80
- }
81
-
82
- // Match all segments except the tail wildcard
83
- for i in 0 ..< (patternSegments.count - 1) {
84
- guard
85
- matchSingleSegment(
86
- patternSegment: patternSegments[i],
87
- pathSegment: pathSegments[i],
88
- params: &params,
89
- )
90
- else {
91
- return nil
92
- }
93
-
94
- updateSegmentCounts(
95
- patternSegment: patternSegments[i],
96
- constantSegments: &constantSegments,
97
- parameterSegments: &parameterSegments,
98
- wildcardSegments: &wildcardSegments,
99
- )
100
- }
101
-
102
- // Capture remaining path segments as "tail" parameter (matches Kotlin behavior)
103
- if pathSegments.count > patternSegments.count - 1 {
104
- let remainingSegments = pathSegments.dropFirst(patternSegments.count - 1)
105
- params["tail"] = remainingSegments.joined(separator: "/")
106
- }
107
- } else {
108
- // Normal matching - must have same number of segments
109
- if patternSegments.count != pathSegments.count {
110
- return nil
59
+ private enum Segment {
60
+ case constant(Substring)
61
+ case param(String)
62
+ case wildcard
63
+
64
+ init(_ segment: Substring) {
65
+ if segment.hasPrefix("{"), segment.hasSuffix("}") {
66
+ self = .param(String(segment.dropFirst().dropLast()))
67
+ } else if segment == "*" {
68
+ self = .wildcard
69
+ } else {
70
+ self = .constant(segment)
111
71
  }
72
+ }
112
73
 
113
- for i in patternSegments.indices {
114
- guard
115
- matchSingleSegment(
116
- patternSegment: patternSegments[i],
117
- pathSegment: pathSegments[i],
118
- params: &params,
119
- )
120
- else {
121
- return nil
122
- }
123
-
124
- updateSegmentCounts(
125
- patternSegment: patternSegments[i],
126
- constantSegments: &constantSegments,
127
- parameterSegments: &parameterSegments,
128
- wildcardSegments: &wildcardSegments,
129
- )
74
+ var specificity: Int {
75
+ switch self {
76
+ case .constant: 1000
77
+ case .param: 100
78
+ case .wildcard: 10
130
79
  }
131
80
  }
132
-
133
- // Specificity calculation (higher = more specific):
134
- // - Constants: 1000 points each
135
- // - Parameters: 100 points each
136
- // - Single wildcards: 10 points each
137
- // - Tail wildcard: 1 point
138
- // - Base path length: segments count
139
- let specificity =
140
- (constantSegments * 1000)
141
- + (parameterSegments * 100)
142
- + (wildcardSegments * 10)
143
- + (hasTailWildcard ? 1 : 0)
144
- + patternSegments.count
145
-
146
- return RouteMatch(params: params, specificity: specificity)
147
81
  }
148
82
 
149
- private func matchSingleSegment(
150
- patternSegment: String,
151
- pathSegment: String,
152
- params: inout [String: String],
153
- ) -> Bool {
154
- // Parameter segment: {paramName}
155
- if patternSegment.hasPrefix("{"), patternSegment.hasSuffix("}") {
156
- let paramName = String(patternSegment.dropFirst().dropLast())
157
- params[paramName] = pathSegment
158
- return true
159
- }
83
+ private static func match(pattern: String, path: String) -> RouteMatch? {
84
+ var patternSegments = pattern.split(separator: "/")
85
+ let hasTailWildcard = patternSegments.last == "**"
86
+ if hasTailWildcard { patternSegments.removeLast() }
87
+ let pathSegments = path.split(separator: "/")
88
+ guard hasTailWildcard
89
+ ? pathSegments.count >= patternSegments.count
90
+ : pathSegments.count == patternSegments.count
91
+ else { return nil }
160
92
 
161
- // Single wildcard: *
162
- if patternSegment == "*" {
163
- return true
93
+ var params: [String: String] = [:]
94
+ // Each segment counts once more, the tail wildcard twice (its segment and its own point).
95
+ var specificity = patternSegments.count + (hasTailWildcard ? 2 : 0)
96
+ for (patternSegment, pathSegment) in zip(patternSegments, pathSegments) {
97
+ let segment = Segment(patternSegment)
98
+ switch segment {
99
+ case let .constant(constant):
100
+ guard constant == pathSegment else { return nil }
101
+ case let .param(name):
102
+ params[name] = String(pathSegment)
103
+ case .wildcard:
104
+ break
105
+ }
106
+ specificity += segment.specificity
164
107
  }
165
-
166
- // Constant segment: must match exactly
167
- return patternSegment == pathSegment
168
- }
169
-
170
- private func updateSegmentCounts(
171
- patternSegment: String,
172
- constantSegments: inout Int,
173
- parameterSegments: inout Int,
174
- wildcardSegments: inout Int,
175
- ) {
176
- if patternSegment.hasPrefix("{"), patternSegment.hasSuffix("}") {
177
- parameterSegments += 1
178
- } else if patternSegment == "*" {
179
- wildcardSegments += 1
180
- } else {
181
- constantSegments += 1
108
+ if hasTailWildcard, pathSegments.count > patternSegments.count {
109
+ params["tail"] = pathSegments.dropFirst(patternSegments.count).joined(separator: "/")
182
110
  }
111
+ return RouteMatch(params: params, specificity: specificity)
183
112
  }
184
113
  }
@@ -11,7 +11,7 @@ final class CarPlayNowPlayingManager {
11
11
  private let interfaceController: CPInterfaceController
12
12
  private let audioBrowser: HybridAudioBrowser
13
13
  /// Read on each use: a re-setup replaces the bridge's player.
14
- private var player: Player? { audioBrowser.getPlayer() }
14
+ private var player: Player? { audioBrowser.player }
15
15
  private let listItem: (Track, ((CPSelectableListItem, @escaping () -> Void) -> Void)?) -> CPListItem
16
16
  /// Pushes a browse destination: the album line's.
17
17
  private let navigate: (_ path: String, _ title: String) -> Void
@@ -131,7 +131,7 @@ final class CarPlayNowPlayingManager {
131
131
  private func refreshButtons() {
132
132
  // A gate hides the custom buttons; the transport controls stay, as a gate never blocks
133
133
  // playback.
134
- let configured = audioBrowser.browseGate.isActive ? [] : audioBrowser.carPlayNowPlayingButtons
134
+ let configured = audioBrowser.browseGate.isActive ? [] : audioBrowser.playerOptions.carPlayNowPlayingButtons
135
135
  // The template caps custom buttons at 5 (CPNowPlayingTemplate.h) and says
136
136
  // nothing about overflow — unlike lists (trim) or the tab bar (throw). The
137
137
  // config comes straight from JS, where duplicates and excess are
@@ -319,7 +319,7 @@ final class CarPlayNowPlayingManager {
319
319
  }
320
320
 
321
321
  private func handlePlaybackRateButtonTapped() {
322
- let rates = audioBrowser.playbackRates
322
+ let rates = audioBrowser.playerOptions.playbackRates
323
323
  guard !rates.isEmpty, let player else { return }
324
324
 
325
325
  let currentRate = Double(player.rate)
@@ -349,7 +349,7 @@ final class CarPlayNowPlayingManager {
349
349
  }
350
350
 
351
351
  private func updateUpNextButton() {
352
- CPNowPlayingTemplate.shared.isUpNextButtonEnabled = audioBrowser.carPlayUpNextButton && (player?.tracks.count ?? 0) > 1
352
+ CPNowPlayingTemplate.shared.isUpNextButtonEnabled = audioBrowser.playerOptions.carPlayUpNextButton && (player?.tracks.count ?? 0) > 1
353
353
  }
354
354
 
355
355
  fileprivate func handleUpNextButtonTapped() {
@@ -12,7 +12,7 @@ final class CarPlaySession {
12
12
  let interfaceController: CPInterfaceController
13
13
  let audioBrowser: HybridAudioBrowser
14
14
  /// Read on each use: a re-setup replaces the bridge's player.
15
- var player: Player? { audioBrowser.getPlayer() }
15
+ var player: Player? { audioBrowser.player }
16
16
  let trackSelector: TrackSelector
17
17
  let imageLoader: CarPlayImageLoader
18
18
  let listItemFactory: CarPlayListItemFactory
@@ -58,7 +58,7 @@ final class CarPlaySession {
58
58
  )
59
59
  listItemFactory = CarPlayListItemFactory(
60
60
  isActiveTrack: { [weak audioBrowser] identity in
61
- identity != nil && audioBrowser?.getPlayer()?.currentTrack?.identity == identity
61
+ identity != nil && audioBrowser?.player?.currentTrack?.identity == identity
62
62
  },
63
63
  )
64
64
  listItemFactory.imageLoader = imageLoader
@@ -156,7 +156,7 @@ final class CarPlaySession {
156
156
  // MARK: - Playing Indicators
157
157
 
158
158
  var playingIndicatorLocation: CPListItemPlayingIndicatorLocation {
159
- switch audioBrowser.carPlayPlayingIndicatorLocation {
159
+ switch audioBrowser.playerOptions.carPlayPlayingIndicatorLocation {
160
160
  case .trailing: .trailing
161
161
  case .leading: .leading
162
162
  }
@@ -69,6 +69,6 @@ enum MediaIntentPlayback {
69
69
  /// destroyed player.
70
70
  @MainActor
71
71
  private static func livePlayer(of browser: HybridAudioBrowser) -> Player? {
72
- HybridAudioBrowser.shared === browser ? browser.getPlayer() : nil
72
+ HybridAudioBrowser.shared === browser ? browser.player : nil
73
73
  }
74
74
  }
@@ -1,74 +1,27 @@
1
1
  import Foundation
2
- import NitroModules
3
2
 
4
- /// Extension to build RemoteCommands from PlayerCapabilities
5
- extension PlayerCapabilities {
6
- /// Build array of enabled RemoteCommands based on capabilities
7
- /// All capabilities are enabled by default - only false values disable them
8
- func buildRemoteCommands(
9
- forwardJumpInterval: NSNumber?,
10
- backwardJumpInterval: NSNumber?,
11
- playbackRates: [Double],
12
- ) -> [RemoteCommand] {
13
- var commands: [RemoteCommand] = []
14
-
15
- // Play/Pause/Stop
16
- if play != false {
17
- commands.append(.play)
18
- }
19
- if pause != false {
20
- commands.append(.pause)
21
- }
22
- if stop != false {
23
- commands.append(.stop)
24
- }
25
-
26
- // Navigation
27
- if skipToNext != false {
28
- commands.append(.next)
29
- }
30
- if skipToPrevious != false {
31
- commands.append(.previous)
32
- }
33
-
34
- // Seeking
35
- if seekTo != false {
36
- commands.append(.changePlaybackPosition)
37
- }
3
+ #if canImport(NitroModules)
4
+ import NitroModules
5
+ #endif
38
6
 
39
- // Jump forward/backward
40
- if jumpForward != false {
41
- commands.append(.skipForward(
42
- preferredIntervals: [(forwardJumpInterval ?? backwardJumpInterval) ?? 15],
43
- ))
44
- }
45
- if jumpBackward != false {
46
- commands.append(.skipBackward(
47
- preferredIntervals: [(backwardJumpInterval ?? forwardJumpInterval) ?? 15],
48
- ))
49
- }
50
-
51
- // NOTE: We deliberately do NOT register MPRemoteCommandCenter.likeCommand for the
52
- // `favorite` capability. That MPFeedbackCommand had a single system surface — a
53
- // pre-iOS-15 lock-screen menu shown in place of the previous-track button — which
54
- // Apple silently removed. On the iOS 16+ target it can never be invoked: CarPlay
55
- // uses its own CPNowPlayingButton heart, and Siri "I like this" routes through
56
- // INUpdateMediaAffinityIntent. The `favorite` capability still drives the
57
- // favorite row indicators via setFavoriteEnabled. See issues #67 / #71.
58
-
59
- // Mode controls
60
- if shuffleMode != false {
61
- commands.append(.changeShuffleMode)
62
- }
63
- if repeatMode != false {
64
- commands.append(.changeRepeatMode)
65
- }
66
- if playbackRate != false {
67
- commands.append(.changePlaybackRate(
68
- supportedPlaybackRates: playbackRates.map { NSNumber(value: $0) },
69
- ))
70
- }
71
-
72
- return commands
7
+ extension PlayerCapabilities {
8
+ /// The remote commands the capabilities enable: each one unless set to false. `favorite`
9
+ /// enables none: no iOS 16+ surface invokes `likeCommand`; CarPlay has its own heart button and
10
+ /// Siri its affinity intent.
11
+ var remoteCommands: Set<RemoteCommand> {
12
+ let enabled: [(Bool?, RemoteCommand)] = [
13
+ (play, .play),
14
+ (pause, .pause),
15
+ (stop, .stop),
16
+ (skipToNext, .next),
17
+ (skipToPrevious, .previous),
18
+ (seekTo, .changePlaybackPosition),
19
+ (jumpForward, .skipForward),
20
+ (jumpBackward, .skipBackward),
21
+ (shuffleMode, .changeShuffleMode),
22
+ (repeatMode, .changeRepeatMode),
23
+ (playbackRate, .changePlaybackRate),
24
+ ]
25
+ return Set(enabled.compactMap { $0.0 == false ? nil : $0.1 })
73
26
  }
74
27
  }
@@ -0,0 +1,28 @@
1
+ #if canImport(NitroModules)
2
+ import NitroModules
3
+ #endif
4
+
5
+ extension SearchParams {
6
+ /// The query params this search sends: `q` plus whichever filters are set, as on Android and
7
+ /// web. Also the search's cache identity, so a field added here distinguishes both.
8
+ var queryParams: [String: String] {
9
+ var query: [String: String] = ["q": query]
10
+ if let mode { query["mode"] = mode.stringValue }
11
+ if reference == .my { query["reference"] = "my" }
12
+ if let genre { query["genre"] = genre }
13
+ if let artist { query["artist"] = artist }
14
+ if let album { query["album"] = album }
15
+ if let title { query["title"] = title }
16
+ if let playlist { query["playlist"] = playlist }
17
+ return query
18
+ }
19
+
20
+ /// The cache identity: `queryParams`, sorted so key order is stable and `\u{1}`-delimited so
21
+ /// two param sets cannot serialize alike.
22
+ var cacheKey: String {
23
+ queryParams
24
+ .sorted { $0.key < $1.key }
25
+ .map { "\($0.key)=\($0.value)" }
26
+ .joined(separator: "\u{1}")
27
+ }
28
+ }
@@ -4,22 +4,6 @@ import NitroModules
4
4
 
5
5
  /// Extension to make Nitro Track work with AVPlayer
6
6
  extension Track {
7
- /// The URL for AVPlayer playback (uses src for playback URL)
8
- var playbackUrl: URL? {
9
- guard let src else { return nil }
10
- return URL(string: src)
11
- }
12
-
13
- /// Whether this is a local file URL
14
- var isLocalFile: Bool {
15
- playbackUrl?.isFileURL ?? false
16
- }
17
-
18
- /// The source type (file or stream) based on the URL scheme
19
- var sourceType: SourceType {
20
- isLocalFile ? .file : .stream
21
- }
22
-
23
7
  /// Builds an `ImageSource` from the track's artwork fields, skipping SF Symbol strings.
24
8
  var artworkImageSource: ImageSource? {
25
9
  if let source = artworkSource {
@@ -1,4 +1,6 @@
1
- import NitroModules
1
+ #if canImport(NitroModules)
2
+ import NitroModules
3
+ #endif
2
4
 
3
5
  extension Track {
4
6
  /// Returns a copy of this Track with only the specified fields changed.
@@ -16,3 +16,10 @@ extension Track {
16
16
  return src
17
17
  }
18
18
  }
19
+
20
+ extension Track {
21
+ /// Playable and not Disabled: a track the library may queue and play.
22
+ var isPlayable: Bool {
23
+ src != nil && disabled != true
24
+ }
25
+ }
@@ -13,27 +13,11 @@ final class HttpClient: @unchecked Sendable {
13
13
  /// HTTP request configuration (matches Kotlin HttpClient.HttpRequest).
14
14
  struct HttpRequest {
15
15
  let url: String
16
- let method: String
16
+ let method: HttpMethod
17
17
  let headers: [String: String]?
18
18
  let body: String?
19
19
  let contentType: String
20
20
  let userAgent: String
21
-
22
- init(
23
- url: String,
24
- method: String = "GET",
25
- headers: [String: String]? = nil,
26
- body: String? = nil,
27
- contentType: String = HttpClient.defaultContentType,
28
- userAgent: String = HttpClient.defaultUserAgent,
29
- ) {
30
- self.url = url
31
- self.method = method
32
- self.headers = headers
33
- self.body = body
34
- self.contentType = contentType
35
- self.userAgent = userAgent
36
- }
37
21
  }
38
22
 
39
23
  /// A non-2xx response (matches Kotlin HttpClient.HttpException).
@@ -55,14 +39,14 @@ final class HttpClient: @unchecked Sendable {
55
39
 
56
40
  enum HttpClientError: LocalizedError {
57
41
  case invalidUrl(String)
58
- case unsupportedMethod(String)
42
+ case unsupportedMethod(HttpMethod)
59
43
  case notHttp
60
44
  case undecodable(String)
61
45
 
62
46
  var errorDescription: String? {
63
47
  switch self {
64
48
  case let .invalidUrl(url): "Invalid URL: \(url)"
65
- case let .unsupportedMethod(method): "Unsupported HTTP method: \(method)"
49
+ case let .unsupportedMethod(method): "Unsupported HTTP method: \(method.stringValue)"
66
50
  case .notHttp: "Invalid response type"
67
51
  case let .undecodable(reason): reason
68
52
  }
@@ -74,7 +58,7 @@ final class HttpClient: @unchecked Sendable {
74
58
  }
75
59
 
76
60
  /// Sends `httpRequest` and returns the status code and body, whatever the status.
77
- func request(_ httpRequest: HttpRequest) async throws -> (code: Int, data: Data) {
61
+ private func request(_ httpRequest: HttpRequest) async throws -> (code: Int, data: Data) {
78
62
  guard let url = URL(string: httpRequest.url) else { throw HttpClientError.invalidUrl(httpRequest.url) }
79
63
 
80
64
  var urlRequest = URLRequest(url: url)
@@ -87,21 +71,19 @@ final class HttpClient: @unchecked Sendable {
87
71
  urlRequest.setValue(httpRequest.userAgent, forHTTPHeaderField: "User-Agent")
88
72
  }
89
73
 
90
- let method = httpRequest.method.uppercased()
91
- switch method {
92
- case "GET":
93
- urlRequest.httpMethod = method
94
- case "POST", "PUT", "PATCH":
95
- urlRequest.httpMethod = method
74
+ urlRequest.httpMethod = httpRequest.method.stringValue
75
+ switch httpRequest.method {
76
+ case .get:
77
+ break
78
+ case .post, .put, .patch:
96
79
  urlRequest.setValue(httpRequest.contentType, forHTTPHeaderField: "Content-Type")
97
80
  urlRequest.httpBody = httpRequest.body?.data(using: .utf8)
98
- case "DELETE":
99
- urlRequest.httpMethod = method
81
+ case .delete:
100
82
  if let body = httpRequest.body {
101
83
  urlRequest.setValue(httpRequest.contentType, forHTTPHeaderField: "Content-Type")
102
84
  urlRequest.httpBody = body.data(using: .utf8)
103
85
  }
104
- default:
86
+ case .head, .options:
105
87
  throw HttpClientError.unsupportedMethod(httpRequest.method)
106
88
  }
107
89
 
@@ -143,7 +125,7 @@ final class HttpClient: @unchecked Sendable {
143
125
 
144
126
  extension HttpClient {
145
127
  private func logRequest(_ request: HttpRequest) {
146
- logger.debug("→ \(request.method) \(request.url)")
128
+ logger.debug("→ \(request.method.stringValue) \(request.url)")
147
129
  for (key, value) in request.headers ?? [:] {
148
130
  logger.debug(" \(key): \(value)")
149
131
  }