From f32dc6700898b97df2b12cdae19c85cc70eb34b3 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 5 Dec 2025 05:15:38 -0800 Subject: [PATCH 1/4] Use appending(path:) instead of string interpolation for URL path segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use appending(component:) for revision values that may contain slashes (e.g., pr/1 → pr%2F1) --- .../HuggingFace/Hub/HubClient+Datasets.swift | 35 +++++++++---- Sources/HuggingFace/Hub/HubClient+Files.swift | 51 ++++++++++++++----- .../HuggingFace/Hub/HubClient+Models.swift | 35 +++++++++---- .../HuggingFace/Hub/HubClient+Spaces.swift | 35 +++++++++---- Sources/HuggingFace/Shared/HTTPClient.swift | 38 +++++++++++++- 5 files changed, 154 insertions(+), 40 deletions(-) diff --git a/Sources/HuggingFace/Hub/HubClient+Datasets.swift b/Sources/HuggingFace/Hub/HubClient+Datasets.swift index 8f427df..ac0cf1c 100644 --- a/Sources/HuggingFace/Hub/HubClient+Datasets.swift +++ b/Sources/HuggingFace/Hub/HubClient+Datasets.swift @@ -53,17 +53,22 @@ extension HubClient { revision: String? = nil, full: Bool? = nil ) async throws -> Dataset { - let path: String + var url = httpClient.host + .appending(path: "api") + .appending(path: "datasets") + .appending(path: id.namespace) + .appending(path: id.name) if let revision { - path = "/api/datasets/\(id.namespace)/\(id.name)/revision/\(revision)" - } else { - path = "/api/datasets/\(id.namespace)/\(id.name)" + url = + url + .appending(path: "revision") + .appending(component: revision) } var params: [String: Value] = [:] if let full { params["full"] = .bool(full) } - return try await httpClient.fetch(.get, path, params: params) + return try await httpClient.fetch(.get, url: url, params: params) } /// Gets all available dataset tags hosted in the Hub. @@ -228,14 +233,20 @@ extension HubClient { tag: String, message: String? = nil ) async throws -> Bool { - let path = "/api/datasets/\(id.namespace)/\(id.name)/tag/\(revision)" + let url = httpClient.host + .appending(path: "api") + .appending(path: "datasets") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "tag") + .appending(component: revision) let params: [String: Value] = [ "tag": .string(tag), "message": message.map { .string($0) } ?? .null, ] - let result: Bool = try await httpClient.fetch(.post, path, params: params) + let result: Bool = try await httpClient.fetch(.post, url: url, params: params) return result } @@ -252,14 +263,20 @@ extension HubClient { revision: String, message: String ) async throws -> String { - let path = "/api/datasets/\(id.namespace)/\(id.name)/super-squash/\(revision)" + let url = httpClient.host + .appending(path: "api") + .appending(path: "datasets") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "super-squash") + .appending(component: revision) let params: [String: Value] = [ "message": .string(message) ] struct Response: Decodable { let commitID: String } - let resp: Response = try await httpClient.fetch(.post, path, params: params) + let resp: Response = try await httpClient.fetch(.post, url: url, params: params) return resp.commitID } } diff --git a/Sources/HuggingFace/Hub/HubClient+Files.swift b/Sources/HuggingFace/Hub/HubClient+Files.swift index e88ec14..c510e8d 100644 --- a/Sources/HuggingFace/Hub/HubClient+Files.swift +++ b/Sources/HuggingFace/Hub/HubClient+Files.swift @@ -46,8 +46,13 @@ public extension HubClient { branch: String = "main", message: String? = nil ) async throws -> (path: String, commit: String?) { - let urlPath = "/api/\(kind.pluralized)/\(repo)/upload/\(branch)" - var request = try await httpClient.createRequest(.post, urlPath) + let url = httpClient.host + .appending(path: "api") + .appending(path: kind.pluralized) + .appending(path: repo.description) + .appending(path: "upload") + .appending(component: branch) + var request = try await httpClient.createRequest(.post, url: url) let boundary = "----hf-\(UUID().uuidString)" request.setValue( @@ -195,8 +200,12 @@ public extension HubClient { } let endpoint = useRaw ? "raw" : "resolve" - let urlPath = "/\(repo)/\(endpoint)/\(revision)/\(repoPath)" - var request = try await httpClient.createRequest(.get, urlPath) + let url = httpClient.host + .appending(path: repo.description) + .appending(path: endpoint) + .appending(component: revision) + .appending(path: repoPath) + var request = try await httpClient.createRequest(.get, url: url) request.cachePolicy = cachePolicy let (data, response) = try await session.data(for: request) @@ -265,8 +274,12 @@ public extension HubClient { } let endpoint = useRaw ? "raw" : "resolve" - let urlPath = "/\(repo)/\(endpoint)/\(revision)/\(repoPath)" - var request = try await httpClient.createRequest(.get, urlPath) + let url = httpClient.host + .appending(path: repo.description) + .appending(path: endpoint) + .appending(component: revision) + .appending(path: repoPath) + var request = try await httpClient.createRequest(.get, url: url) request.cachePolicy = cachePolicy let (tempURL, response) = try await session.download( @@ -424,7 +437,12 @@ public extension HubClient { branch: String = "main", message: String ) async throws { - let urlPath = "/api/\(kind.pluralized)/\(repo)/commit/\(branch)" + let url = httpClient.host + .appending(path: "api") + .appending(path: kind.pluralized) + .appending(path: repo.description) + .appending(path: "commit") + .appending(component: branch) let operations = repoPaths.map { path in Value.object(["op": .string("delete"), "path": .string(path)]) } @@ -433,7 +451,7 @@ public extension HubClient { "operations": .array(operations), ] - let _: Bool = try await httpClient.fetch(.post, urlPath, params: params) + let _: Bool = try await httpClient.fetch(.post, url: url, params: params) } } @@ -474,10 +492,15 @@ public extension HubClient { revision: String = "main", recursive: Bool = true ) async throws -> [Git.TreeEntry] { - let urlPath = "/api/\(kind.pluralized)/\(repo)/tree/\(revision)" + let url = httpClient.host + .appending(path: "api") + .appending(path: kind.pluralized) + .appending(path: repo.description) + .appending(path: "tree") + .appending(component: revision) let params: [String: Value]? = recursive ? ["recursive": .bool(true)] : nil - return try await httpClient.fetch(.get, urlPath, params: params) + return try await httpClient.fetch(.get, url: url, params: params) } /// Get file information @@ -493,8 +516,12 @@ public extension HubClient { kind _: Repo.Kind = .model, revision: String = "main" ) async throws -> File { - let urlPath = "/\(repo)/resolve/\(revision)/\(repoPath)" - var request = try await httpClient.createRequest(.head, urlPath) + let url = httpClient.host + .appending(path: repo.description) + .appending(path: "resolve") + .appending(component: revision) + .appending(path: repoPath) + var request = try await httpClient.createRequest(.head, url: url) request.setValue("bytes=0-0", forHTTPHeaderField: "Range") do { diff --git a/Sources/HuggingFace/Hub/HubClient+Models.swift b/Sources/HuggingFace/Hub/HubClient+Models.swift index 494abe1..32a955b 100644 --- a/Sources/HuggingFace/Hub/HubClient+Models.swift +++ b/Sources/HuggingFace/Hub/HubClient+Models.swift @@ -53,17 +53,22 @@ extension HubClient { revision: String? = nil, full: Bool? = nil ) async throws -> Model { - let path: String + var url = httpClient.host + .appending(path: "api") + .appending(path: "models") + .appending(path: id.namespace) + .appending(path: id.name) if let revision { - path = "/api/models/\(id.namespace)/\(id.name)/revision/\(revision)" - } else { - path = "/api/models/\(id.namespace)/\(id.name)" + url = + url + .appending(path: "revision") + .appending(component: revision) } var params: [String: Value] = [:] if let full { params["full"] = .bool(full) } - return try await httpClient.fetch(.get, path, params: params) + return try await httpClient.fetch(.get, url: url, params: params) } /// Gets all available model tags hosted in the Hub. @@ -199,14 +204,20 @@ extension HubClient { tag: String, message: String? = nil ) async throws -> Bool { - let path = "/api/models/\(id.namespace)/\(id.name)/tag/\(revision)" + let url = httpClient.host + .appending(path: "api") + .appending(path: "models") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "tag") + .appending(component: revision) let params: [String: Value] = [ "tag": .string(tag), "message": message.map { .string($0) } ?? .null, ] - let result: Bool = try await httpClient.fetch(.post, path, params: params) + let result: Bool = try await httpClient.fetch(.post, url: url, params: params) return result } @@ -223,14 +234,20 @@ extension HubClient { revision: String, message: String ) async throws -> String { - let path = "/api/models/\(id.namespace)/\(id.name)/super-squash/\(revision)" + let url = httpClient.host + .appending(path: "api") + .appending(path: "models") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "super-squash") + .appending(component: revision) let params: [String: Value] = [ "message": .string(message) ] struct Response: Decodable { let commitID: String } - let resp: Response = try await httpClient.fetch(.post, path, params: params) + let resp: Response = try await httpClient.fetch(.post, url: url, params: params) return resp.commitID } } diff --git a/Sources/HuggingFace/Hub/HubClient+Spaces.swift b/Sources/HuggingFace/Hub/HubClient+Spaces.swift index 8669e38..0ee033f 100644 --- a/Sources/HuggingFace/Hub/HubClient+Spaces.swift +++ b/Sources/HuggingFace/Hub/HubClient+Spaces.swift @@ -50,17 +50,22 @@ extension HubClient { revision: String? = nil, full: Bool? = nil ) async throws -> Space { - let path: String + var url = httpClient.host + .appending(path: "api") + .appending(path: "spaces") + .appending(path: id.namespace) + .appending(path: id.name) if let revision { - path = "/api/spaces/\(id.namespace)/\(id.name)/revision/\(revision)" - } else { - path = "/api/spaces/\(id.namespace)/\(id.name)" + url = + url + .appending(path: "revision") + .appending(component: revision) } var params: [String: Value] = [:] if let full { params["full"] = .bool(full) } - return try await httpClient.fetch(.get, path, params: params) + return try await httpClient.fetch(.get, url: url, params: params) } /// Gets runtime information for a Space. @@ -290,14 +295,20 @@ extension HubClient { tag: String, message: String? = nil ) async throws -> Bool { - let path = "/api/spaces/\(id.namespace)/\(id.name)/tag/\(revision)" + let url = httpClient.host + .appending(path: "api") + .appending(path: "spaces") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "tag") + .appending(component: revision) let params: [String: Value] = [ "tag": .string(tag), "message": message.map { .string($0) } ?? .null, ] - let result: Bool = try await httpClient.fetch(.post, path, params: params) + let result: Bool = try await httpClient.fetch(.post, url: url, params: params) return result } @@ -314,14 +325,20 @@ extension HubClient { revision: String, message: String ) async throws -> String { - let path = "/api/spaces/\(id.namespace)/\(id.name)/super-squash/\(revision)" + let url = httpClient.host + .appending(path: "api") + .appending(path: "spaces") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "super-squash") + .appending(component: revision) let params: [String: Value] = [ "message": .string(message) ] struct Response: Decodable { let commitID: String } - let resp: Response = try await httpClient.fetch(.post, path, params: params) + let resp: Response = try await httpClient.fetch(.post, url: url, params: params) return resp.commitID } } diff --git a/Sources/HuggingFace/Shared/HTTPClient.swift b/Sources/HuggingFace/Shared/HTTPClient.swift index 66801f2..9ecc1ea 100644 --- a/Sources/HuggingFace/Shared/HTTPClient.swift +++ b/Sources/HuggingFace/Shared/HTTPClient.swift @@ -72,6 +72,20 @@ final class HTTPClient: @unchecked Sendable { headers: [String: String]? = nil ) async throws -> T { let request = try await createRequest(method, path, params: params, headers: headers) + return try await performFetch(request: request) + } + + func fetch( + _ method: HTTPMethod, + url: URL, + params: [String: Value]? = nil, + headers: [String: String]? = nil + ) async throws -> T { + let request = try await createRequest(method, url: url, params: params, headers: headers) + return try await performFetch(request: request) + } + + private func performFetch(request: URLRequest) async throws -> T { let (data, response) = try await session.data(for: request) let httpResponse = try validateResponse(response, data: data) @@ -192,6 +206,28 @@ final class HTTPClient: @unchecked Sendable { var urlComponents = URLComponents(url: host, resolvingAgainstBaseURL: true) urlComponents?.path = path + return try await createRequest(method, urlComponents: urlComponents, params: params, headers: headers) + } + + func createRequest( + _ method: HTTPMethod, + url: URL, + params: [String: Value]? = nil, + headers: [String: String]? = nil + ) async throws -> URLRequest { + let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) + + return try await createRequest(method, urlComponents: urlComponents, params: params, headers: headers) + } + + private func createRequest( + _ method: HTTPMethod, + urlComponents: URLComponents?, + params: [String: Value]? = nil, + headers: [String: String]? = nil + ) async throws -> URLRequest { + var urlComponents = urlComponents + var httpBody: Data? = nil switch method { case .get, .head: @@ -218,7 +254,7 @@ final class HTTPClient: @unchecked Sendable { guard let url = urlComponents?.url else { throw HTTPClientError.requestError( - #"Unable to construct URL with host "\#(host)" and path "\#(path)""# + #"Unable to construct URL from components \#(String(describing: urlComponents))"# ) } var request: URLRequest = URLRequest(url: url) From 14b10f5651c5280e34ddd319fe15ee67edbfe502 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 5 Dec 2025 05:22:38 -0800 Subject: [PATCH 2/4] Use appending(path:) and appending(component:) for all URL path templates --- .../HuggingFace/Hub/HubClient+Datasets.swift | 66 ++++++++--- Sources/HuggingFace/Hub/HubClient+Files.swift | 5 +- .../HuggingFace/Hub/HubClient+Models.swift | 65 ++++++++--- .../Hub/HubClient+Organizations.swift | 8 +- Sources/HuggingFace/Hub/HubClient+Repos.swift | 9 +- .../HuggingFace/Hub/HubClient+Spaces.swift | 109 ++++++++++++++---- Sources/HuggingFace/Shared/HTTPClient.swift | 42 ++++++- 7 files changed, 245 insertions(+), 59 deletions(-) diff --git a/Sources/HuggingFace/Hub/HubClient+Datasets.swift b/Sources/HuggingFace/Hub/HubClient+Datasets.swift index ac0cf1c..a3730c9 100644 --- a/Sources/HuggingFace/Hub/HubClient+Datasets.swift +++ b/Sources/HuggingFace/Hub/HubClient+Datasets.swift @@ -133,8 +133,14 @@ extension HubClient { /// - Returns: `true` if the request was cancelled successfully. /// - Throws: An error if the request fails. public func cancelDatasetAccessRequest(_ id: Repo.ID) async throws -> Bool { - let path = "/api/datasets/\(id.namespace)/\(id.name)/user-access-request/cancel" - let result: Bool = try await httpClient.fetch(.post, path) + let url = httpClient.host + .appending(path: "api") + .appending(path: "datasets") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "user-access-request") + .appending(path: "cancel") + let result: Bool = try await httpClient.fetch(.post, url: url) return result } @@ -144,8 +150,14 @@ extension HubClient { /// - Returns: `true` if access was granted successfully. /// - Throws: An error if the request fails. public func grantDatasetAccess(_ id: Repo.ID) async throws -> Bool { - let path = "/api/datasets/\(id.namespace)/\(id.name)/user-access-request/grant" - let result: Bool = try await httpClient.fetch(.post, path) + let url = httpClient.host + .appending(path: "api") + .appending(path: "datasets") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "user-access-request") + .appending(path: "grant") + let result: Bool = try await httpClient.fetch(.post, url: url) return result } @@ -155,8 +167,14 @@ extension HubClient { /// - Returns: `true` if the request was handled successfully. /// - Throws: An error if the request fails. public func handleDatasetAccessRequest(_ id: Repo.ID) async throws -> Bool { - let path = "/api/datasets/\(id.namespace)/\(id.name)/user-access-request/handle" - let result: Bool = try await httpClient.fetch(.post, path) + let url = httpClient.host + .appending(path: "api") + .appending(path: "datasets") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "user-access-request") + .appending(path: "handle") + let result: Bool = try await httpClient.fetch(.post, url: url) return result } @@ -171,8 +189,14 @@ extension HubClient { _ id: Repo.ID, status: AccessRequest.Status ) async throws -> [AccessRequest] { - let path = "/api/datasets/\(id.namespace)/\(id.name)/user-access-request/\(status.rawValue)" - return try await httpClient.fetch(.get, path) + let url = httpClient.host + .appending(path: "api") + .appending(path: "datasets") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "user-access-request") + .appending(path: status.rawValue) + return try await httpClient.fetch(.get, url: url) } /// Gets user access report for a dataset repository. @@ -181,8 +205,12 @@ extension HubClient { /// - Returns: User access report data. /// - Throws: An error if the request fails. public func getDatasetUserAccessReport(_ id: Repo.ID) async throws -> Data { - let path = "/datasets/\(id.namespace)/\(id.name)/user-access-report" - return try await httpClient.fetchData(.get, path) + let url = httpClient.host + .appending(path: "datasets") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "user-access-report") + return try await httpClient.fetchData(.get, url: url) } // MARK: - Dataset Advanced Features @@ -198,13 +226,18 @@ extension HubClient { _ id: Repo.ID, resourceGroupId: String? ) async throws -> ResourceGroup { - let path = "/api/datasets/\(id.namespace)/\(id.name)/resource-group" + let url = httpClient.host + .appending(path: "api") + .appending(path: "datasets") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "resource-group") let params: [String: Value] = [ "resourceGroupId": resourceGroupId.map { .string($0) } ?? .null ] - return try await httpClient.fetch(.post, path, params: params) + return try await httpClient.fetch(.post, url: url, params: params) } /// Scans a dataset repository. @@ -213,8 +246,13 @@ extension HubClient { /// - Returns: `true` if the scan was initiated successfully. /// - Throws: An error if the request fails. public func scanDataset(_ id: Repo.ID) async throws -> Bool { - let path = "/api/datasets/\(id.namespace)/\(id.name)/scan" - let result: Bool = try await httpClient.fetch(.post, path) + let url = httpClient.host + .appending(path: "api") + .appending(path: "datasets") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "scan") + let result: Bool = try await httpClient.fetch(.post, url: url) return result } diff --git a/Sources/HuggingFace/Hub/HubClient+Files.swift b/Sources/HuggingFace/Hub/HubClient+Files.swift index c510e8d..9002f7f 100644 --- a/Sources/HuggingFace/Hub/HubClient+Files.swift +++ b/Sources/HuggingFace/Hub/HubClient+Files.swift @@ -266,9 +266,10 @@ public extension HubClient { at: destination.deletingLastPathComponent(), withIntermediateDirectories: true ) - // Copy from cache to destination + // Copy from cache to destination (resolve symlinks first) + let resolvedPath = cachedPath.resolvingSymlinksInPath() try? FileManager.default.removeItem(at: destination) - try FileManager.default.copyItem(at: cachedPath, to: destination) + try FileManager.default.copyItem(at: resolvedPath, to: destination) progress?.completedUnitCount = progress?.totalUnitCount ?? 100 return destination } diff --git a/Sources/HuggingFace/Hub/HubClient+Models.swift b/Sources/HuggingFace/Hub/HubClient+Models.swift index 32a955b..7825399 100644 --- a/Sources/HuggingFace/Hub/HubClient+Models.swift +++ b/Sources/HuggingFace/Hub/HubClient+Models.swift @@ -104,8 +104,14 @@ extension HubClient { /// - Returns: `true` if the request was cancelled successfully. /// - Throws: An error if the request fails. public func cancelModelAccessRequest(_ id: Repo.ID) async throws -> Bool { - let path = "/api/models/\(id.namespace)/\(id.name)/user-access-request/cancel" - let result: Bool = try await httpClient.fetch(.post, path) + let url = httpClient.host + .appending(path: "api") + .appending(path: "models") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "user-access-request") + .appending(path: "cancel") + let result: Bool = try await httpClient.fetch(.post, url: url) return result } @@ -115,8 +121,14 @@ extension HubClient { /// - Returns: `true` if access was granted successfully. /// - Throws: An error if the request fails. public func grantModelAccess(_ id: Repo.ID) async throws -> Bool { - let path = "/api/models/\(id.namespace)/\(id.name)/user-access-request/grant" - let result: Bool = try await httpClient.fetch(.post, path) + let url = httpClient.host + .appending(path: "api") + .appending(path: "models") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "user-access-request") + .appending(path: "grant") + let result: Bool = try await httpClient.fetch(.post, url: url) return result } @@ -126,8 +138,14 @@ extension HubClient { /// - Returns: `true` if the request was handled successfully. /// - Throws: An error if the request fails. public func handleModelAccessRequest(_ id: Repo.ID) async throws -> Bool { - let path = "/api/models/\(id.namespace)/\(id.name)/user-access-request/handle" - let result: Bool = try await httpClient.fetch(.post, path) + let url = httpClient.host + .appending(path: "api") + .appending(path: "models") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "user-access-request") + .appending(path: "handle") + let result: Bool = try await httpClient.fetch(.post, url: url) return result } @@ -142,8 +160,14 @@ extension HubClient { _ id: Repo.ID, status: AccessRequest.Status ) async throws -> [AccessRequest] { - let path = "/api/models/\(id.namespace)/\(id.name)/user-access-request/\(status.rawValue)" - return try await httpClient.fetch(.get, path) + let url = httpClient.host + .appending(path: "api") + .appending(path: "models") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "user-access-request") + .appending(path: status.rawValue) + return try await httpClient.fetch(.get, url: url) } /// Gets user access report for a model repository. @@ -152,8 +176,11 @@ extension HubClient { /// - Returns: User access report data. /// - Throws: An error if the request fails. public func getModelUserAccessReport(_ id: Repo.ID) async throws -> Data { - let path = "/\(id.namespace)/\(id.name)/user-access-report" - return try await httpClient.fetchData(.get, path) + let url = httpClient.host + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "user-access-report") + return try await httpClient.fetchData(.get, url: url) } // MARK: - Model Advanced Features @@ -169,13 +196,18 @@ extension HubClient { _ id: Repo.ID, resourceGroupId: String? ) async throws -> ResourceGroup { - let path = "/api/models/\(id.namespace)/\(id.name)/resource-group" + let url = httpClient.host + .appending(path: "api") + .appending(path: "models") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "resource-group") let params: [String: Value] = [ "resourceGroupId": resourceGroupId.map { .string($0) } ?? .null ] - return try await httpClient.fetch(.post, path, params: params) + return try await httpClient.fetch(.post, url: url, params: params) } /// Scans a model repository. @@ -184,8 +216,13 @@ extension HubClient { /// - Returns: `true` if the scan was initiated successfully. /// - Throws: An error if the request fails. public func scanModel(_ id: Repo.ID) async throws -> Bool { - let path = "/api/models/\(id.namespace)/\(id.name)/scan" - let result: Bool = try await httpClient.fetch(.post, path) + let url = httpClient.host + .appending(path: "api") + .appending(path: "models") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "scan") + let result: Bool = try await httpClient.fetch(.post, url: url) return result } diff --git a/Sources/HuggingFace/Hub/HubClient+Organizations.swift b/Sources/HuggingFace/Hub/HubClient+Organizations.swift index 0a358f2..0acd8b8 100644 --- a/Sources/HuggingFace/Hub/HubClient+Organizations.swift +++ b/Sources/HuggingFace/Hub/HubClient+Organizations.swift @@ -100,7 +100,11 @@ extension HubClient { repos: [String: String]? = nil, autoJoin: ResourceGroup.AutoJoin? = nil ) async throws -> Bool { - let path = "/api/organizations/\(name)/resource-groups" + let url = httpClient.host + .appending(path: "api") + .appending(path: "organizations") + .appending(path: name) + .appending(path: "resource-groups") var params: [String: Value] = ["name": .string(resourceGroupName)] if let description { params["description"] = .string(description) } if let users { @@ -128,7 +132,7 @@ extension HubClient { if let role = autoJoin.role { auto["role"] = .string(role.rawValue) } params["autoJoin"] = .object(auto) } - let result: Bool = try await httpClient.fetch(.post, path, params: params) + let result: Bool = try await httpClient.fetch(.post, url: url, params: params) return result } } diff --git a/Sources/HuggingFace/Hub/HubClient+Repos.swift b/Sources/HuggingFace/Hub/HubClient+Repos.swift index 0088217..7376147 100644 --- a/Sources/HuggingFace/Hub/HubClient+Repos.swift +++ b/Sources/HuggingFace/Hub/HubClient+Repos.swift @@ -46,13 +46,18 @@ extension HubClient { _ id: Repo.ID, settings: Repo.Settings ) async throws -> Bool { - let path = "/api/\(kind.rawValue)s/\(id.namespace)/\(id.name)/settings" + let url = httpClient.host + .appending(path: "api") + .appending(path: kind.pluralized) + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "settings") let encoder = JSONEncoder() let data = try encoder.encode(settings) let params = try JSONDecoder().decode([String: Value].self, from: data) - return try await httpClient.fetch(.put, path, params: params) + return try await httpClient.fetch(.put, url: url, params: params) } /// Moves a repository to a new location. diff --git a/Sources/HuggingFace/Hub/HubClient+Spaces.swift b/Sources/HuggingFace/Hub/HubClient+Spaces.swift index 0ee033f..aae0713 100644 --- a/Sources/HuggingFace/Hub/HubClient+Spaces.swift +++ b/Sources/HuggingFace/Hub/HubClient+Spaces.swift @@ -74,8 +74,13 @@ extension HubClient { /// - Returns: Runtime information for the space. /// - Throws: An error if the request fails or the response cannot be decoded. public func spaceRuntime(_ id: Repo.ID) async throws -> Space.Runtime { - let path = "/api/spaces/\(id.namespace)/\(id.name)/runtime" - return try await httpClient.fetch(.get, path) + let url = httpClient.host + .appending(path: "api") + .appending(path: "spaces") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "runtime") + return try await httpClient.fetch(.get, url: url) } /// Puts a Space to sleep. @@ -84,8 +89,13 @@ extension HubClient { /// - Returns: `true` if the operation was successful. /// - Throws: An error if the request fails. public func sleepSpace(_ id: Repo.ID) async throws -> Bool { - let path = "/api/spaces/\(id.namespace)/\(id.name)/sleeptime" - return try await httpClient.fetch(.post, path) + let url = httpClient.host + .appending(path: "api") + .appending(path: "spaces") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "sleeptime") + return try await httpClient.fetch(.post, url: url) } /// Restarts a Space. @@ -96,12 +106,17 @@ extension HubClient { /// - Returns: `true` if the operation was successful. /// - Throws: An error if the request fails. public func restartSpace(_ id: Repo.ID, factory: Bool = false) async throws -> Bool { - let path = "/api/spaces/\(id.namespace)/\(id.name)/restart" + let url = httpClient.host + .appending(path: "api") + .appending(path: "spaces") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "restart") var params: [String: Value] = [:] if factory { params["factory"] = .bool(true) } - return try await httpClient.fetch(.post, path, params: params) + return try await httpClient.fetch(.post, url: url, params: params) } // MARK: - Space Streaming @@ -116,8 +131,14 @@ extension HubClient { _ id: Repo.ID, logType: String ) -> AsyncThrowingStream { - let path = "/api/spaces/\(id.namespace)/\(id.name)/logs/\(logType)" - return httpClient.fetchStream(.get, path) + let url = httpClient.host + .appending(path: "api") + .appending(path: "spaces") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "logs") + .appending(path: logType) + return httpClient.fetchStream(.get, url: url) } /// Streams live metrics for a Space using Server-Sent Events. @@ -125,8 +146,13 @@ extension HubClient { /// - Parameter id: The repository identifier. /// - Returns: An async stream of metrics. public func streamSpaceMetrics(_ id: Repo.ID) -> AsyncThrowingStream { - let path = "/api/spaces/\(id.namespace)/\(id.name)/metrics" - return httpClient.fetchStream(.get, path) + let url = httpClient.host + .appending(path: "api") + .appending(path: "spaces") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "metrics") + return httpClient.fetchStream(.get, url: url) } /// Streams events for a Space using Server-Sent Events. @@ -139,12 +165,17 @@ extension HubClient { _ id: Repo.ID, sessionUUID: String? = nil ) -> AsyncThrowingStream { - let path = "/api/spaces/\(id.namespace)/\(id.name)/events" + let url = httpClient.host + .appending(path: "api") + .appending(path: "spaces") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "events") var params: [String: Value] = [:] if let sessionUUID { params["session_uuid"] = .string(sessionUUID) } - return httpClient.fetchStream(.get, path, params: params) + return httpClient.fetchStream(.get, url: url, params: params) } // MARK: - Space Secrets Management @@ -164,7 +195,12 @@ extension HubClient { description: String? = nil, value: String? = nil ) async throws -> Bool { - let path = "/api/spaces/\(id.namespace)/\(id.name)/secrets" + let url = httpClient.host + .appending(path: "api") + .appending(path: "spaces") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "secrets") let params: [String: Value] = [ "key": .string(key), @@ -172,7 +208,7 @@ extension HubClient { "value": value.map { .string($0) } ?? .string(""), ] - let result: Bool = try await httpClient.fetch(.post, path, params: params) + let result: Bool = try await httpClient.fetch(.post, url: url, params: params) return result } @@ -187,13 +223,18 @@ extension HubClient { _ id: Repo.ID, key: String ) async throws -> Bool { - let path = "/api/spaces/\(id.namespace)/\(id.name)/secrets" + let url = httpClient.host + .appending(path: "api") + .appending(path: "spaces") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "secrets") let params: [String: Value] = [ "key": .string(key) ] - let result: Bool = try await httpClient.fetch(.delete, path, params: params) + let result: Bool = try await httpClient.fetch(.delete, url: url, params: params) return result } @@ -214,7 +255,12 @@ extension HubClient { description: String? = nil, value: String? = nil ) async throws -> Bool { - let path = "/api/spaces/\(id.namespace)/\(id.name)/variables" + let url = httpClient.host + .appending(path: "api") + .appending(path: "spaces") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "variables") let params: [String: Value] = [ "key": .string(key), @@ -222,7 +268,7 @@ extension HubClient { "value": value.map { .string($0) } ?? .string(""), ] - let result: Bool = try await httpClient.fetch(.post, path, params: params) + let result: Bool = try await httpClient.fetch(.post, url: url, params: params) return result } @@ -237,13 +283,18 @@ extension HubClient { _ id: Repo.ID, key: String ) async throws -> Bool { - let path = "/api/spaces/\(id.namespace)/\(id.name)/variables" + let url = httpClient.host + .appending(path: "api") + .appending(path: "spaces") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "variables") let params: [String: Value] = [ "key": .string(key) ] - let result: Bool = try await httpClient.fetch(.delete, path, params: params) + let result: Bool = try await httpClient.fetch(.delete, url: url, params: params) return result } @@ -260,13 +311,18 @@ extension HubClient { _ id: Repo.ID, resourceGroupId: String? ) async throws -> ResourceGroup { - let path = "/api/spaces/\(id.namespace)/\(id.name)/resource-group" + let url = httpClient.host + .appending(path: "api") + .appending(path: "spaces") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "resource-group") let params: [String: Value] = [ "resourceGroupId": resourceGroupId.map { .string($0) } ?? .null ] - return try await httpClient.fetch(.post, path, params: params) + return try await httpClient.fetch(.post, url: url, params: params) } /// Scans a space repository. @@ -275,8 +331,13 @@ extension HubClient { /// - Returns: `true` if the scan was initiated successfully. /// - Throws: An error if the request fails. public func scanSpace(_ id: Repo.ID) async throws -> Bool { - let path = "/api/spaces/\(id.namespace)/\(id.name)/scan" - let result: Bool = try await httpClient.fetch(.post, path) + let url = httpClient.host + .appending(path: "api") + .appending(path: "spaces") + .appending(path: id.namespace) + .appending(path: id.name) + .appending(path: "scan") + let result: Bool = try await httpClient.fetch(.post, url: url) return result } diff --git a/Sources/HuggingFace/Shared/HTTPClient.swift b/Sources/HuggingFace/Shared/HTTPClient.swift index 9ecc1ea..43754b2 100644 --- a/Sources/HuggingFace/Shared/HTTPClient.swift +++ b/Sources/HuggingFace/Shared/HTTPClient.swift @@ -133,11 +133,37 @@ final class HTTPClient: @unchecked Sendable { _ path: String, params: [String: Value]? = nil, headers: [String: String]? = nil + ) -> AsyncThrowingStream { + performFetchStream( + method, + requestBuilder: { [self] in + try await self.createRequest(method, path, params: params, headers: headers) + } + ) + } + + func fetchStream( + _ method: HTTPMethod, + url: URL, + params: [String: Value]? = nil, + headers: [String: String]? = nil + ) -> AsyncThrowingStream { + performFetchStream( + method, + requestBuilder: { [self] in + try await self.createRequest(method, url: url, params: params, headers: headers) + } + ) + } + + private func performFetchStream( + _ method: HTTPMethod, + requestBuilder: @escaping @Sendable () async throws -> URLRequest ) -> AsyncThrowingStream { AsyncThrowingStream { @Sendable continuation in let task = Task { do { - let request = try await createRequest(method, path, params: params, headers: headers) + let request = try await requestBuilder() let (bytes, response) = try await session.bytes(for: request) let httpResponse = try validateResponse(response) @@ -191,6 +217,20 @@ final class HTTPClient: @unchecked Sendable { headers: [String: String]? = nil ) async throws -> Data { let request = try await createRequest(method, path, params: params, headers: headers) + return try await performFetchData(request: request) + } + + func fetchData( + _ method: HTTPMethod, + url: URL, + params: [String: Value]? = nil, + headers: [String: String]? = nil + ) async throws -> Data { + let request = try await createRequest(method, url: url, params: params, headers: headers) + return try await performFetchData(request: request) + } + + private func performFetchData(request: URLRequest) async throws -> Data { let (data, response) = try await session.data(for: request) let _ = try validateResponse(response, data: data) From 15ebf14bd2beb0902803df269e0613f013d545e0 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 5 Dec 2025 05:26:08 -0800 Subject: [PATCH 3/4] Revert unintentionally staged changes for symlink resolution --- Sources/HuggingFace/Hub/HubClient+Files.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/HuggingFace/Hub/HubClient+Files.swift b/Sources/HuggingFace/Hub/HubClient+Files.swift index 9002f7f..c510e8d 100644 --- a/Sources/HuggingFace/Hub/HubClient+Files.swift +++ b/Sources/HuggingFace/Hub/HubClient+Files.swift @@ -266,10 +266,9 @@ public extension HubClient { at: destination.deletingLastPathComponent(), withIntermediateDirectories: true ) - // Copy from cache to destination (resolve symlinks first) - let resolvedPath = cachedPath.resolvingSymlinksInPath() + // Copy from cache to destination try? FileManager.default.removeItem(at: destination) - try FileManager.default.copyItem(at: resolvedPath, to: destination) + try FileManager.default.copyItem(at: cachedPath, to: destination) progress?.completedUnitCount = progress?.totalUnitCount ?? 100 return destination } From e478c89a3da84f13c5359ce45ff11871a2a779c0 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 5 Dec 2025 07:17:01 -0800 Subject: [PATCH 4/4] Consistently append namespace and name path components instead of repo description --- Sources/HuggingFace/Hub/HubClient+Files.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/Sources/HuggingFace/Hub/HubClient+Files.swift b/Sources/HuggingFace/Hub/HubClient+Files.swift index c510e8d..0e26a51 100644 --- a/Sources/HuggingFace/Hub/HubClient+Files.swift +++ b/Sources/HuggingFace/Hub/HubClient+Files.swift @@ -49,7 +49,8 @@ public extension HubClient { let url = httpClient.host .appending(path: "api") .appending(path: kind.pluralized) - .appending(path: repo.description) + .appending(path: repo.namespace) + .appending(path: repo.name) .appending(path: "upload") .appending(component: branch) var request = try await httpClient.createRequest(.post, url: url) @@ -201,7 +202,8 @@ public extension HubClient { let endpoint = useRaw ? "raw" : "resolve" let url = httpClient.host - .appending(path: repo.description) + .appending(path: repo.namespace) + .appending(path: repo.name) .appending(path: endpoint) .appending(component: revision) .appending(path: repoPath) @@ -275,7 +277,8 @@ public extension HubClient { let endpoint = useRaw ? "raw" : "resolve" let url = httpClient.host - .appending(path: repo.description) + .appending(path: repo.namespace) + .appending(path: repo.name) .appending(path: endpoint) .appending(component: revision) .appending(path: repoPath) @@ -440,7 +443,8 @@ public extension HubClient { let url = httpClient.host .appending(path: "api") .appending(path: kind.pluralized) - .appending(path: repo.description) + .appending(path: repo.namespace) + .appending(path: repo.name) .appending(path: "commit") .appending(component: branch) let operations = repoPaths.map { path in @@ -495,7 +499,8 @@ public extension HubClient { let url = httpClient.host .appending(path: "api") .appending(path: kind.pluralized) - .appending(path: repo.description) + .appending(path: repo.namespace) + .appending(path: repo.name) .appending(path: "tree") .appending(component: revision) let params: [String: Value]? = recursive ? ["recursive": .bool(true)] : nil @@ -517,7 +522,8 @@ public extension HubClient { revision: String = "main" ) async throws -> File { let url = httpClient.host - .appending(path: repo.description) + .appending(path: repo.namespace) + .appending(path: repo.name) .appending(path: "resolve") .appending(component: revision) .appending(path: repoPath)