From 3514a62f8f9fdedda09440e9c2a7e8e795b92e0c Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Fri, 5 Dec 2025 11:14:16 +0100 Subject: [PATCH 1/7] HTTPStatusCode enum --- Sources/PasskeyAuth/PasskeyAuth.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Sources/PasskeyAuth/PasskeyAuth.swift b/Sources/PasskeyAuth/PasskeyAuth.swift index e37ba7e..8df7386 100644 --- a/Sources/PasskeyAuth/PasskeyAuth.swift +++ b/Sources/PasskeyAuth/PasskeyAuth.swift @@ -116,7 +116,7 @@ public actor PasskeyAuth { throw PasskeyError.networkError(NSError(domain: "", code: -1)) } - if httpResponse.statusCode == 429 { + if httpResponse.statusCode == HTTPStatusCode.tooManyRequests.rawValue { let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After") .flatMap { TimeInterval($0) } throw PasskeyError.rateLimit(retryAfter: retryAfter) @@ -146,7 +146,7 @@ public actor PasskeyAuth { let request = provider.createCredentialRegistrationRequest( challenge: challengeData, name: displayName, - userID: UUID().uuidString.data(using: .utf8)! + userID: Data(UUID().uuidString.utf8) ) request.userVerificationPreference = self.configuration.userVerificationPreference @@ -205,7 +205,7 @@ public actor PasskeyAuth { throw PasskeyError.networkError(NSError(domain: "", code: -1)) } - if httpResponse.statusCode == 429 { + if httpResponse.statusCode == HTTPStatusCode.tooManyRequests.rawValue { let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After") .flatMap { TimeInterval($0) } throw PasskeyError.rateLimit(retryAfter: retryAfter) @@ -317,7 +317,7 @@ public actor PasskeyAuth { throw PasskeyError.networkError(NSError(domain: "", code: -1)) } - if httpResponse.statusCode == 429 { + if httpResponse.statusCode == HTTPStatusCode.tooManyRequests.rawValue { let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After") .flatMap { TimeInterval($0) } throw PasskeyError.rateLimit(retryAfter: retryAfter) @@ -371,7 +371,7 @@ public actor PasskeyAuth { throw PasskeyError.networkError(NSError(domain: "", code: -1)) } - if httpResponse.statusCode == 429 { + if httpResponse.statusCode == HTTPStatusCode.tooManyRequests.rawValue { let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After") .flatMap { TimeInterval($0) } throw PasskeyError.rateLimit(retryAfter: retryAfter) @@ -387,3 +387,7 @@ public actor PasskeyAuth { return try JSONDecoder().decode(PasskeyResponse.self, from: data) } } + +enum HTTPStatusCode: Int { + case tooManyRequests = 429 +} From 91a0f7308a4eab52b057b7dd1ca0bc08f79ac176 Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Fri, 5 Dec 2025 12:09:48 +0100 Subject: [PATCH 2/7] DRY: Add prepareAuthentication --- Sources/PasskeyAuth/PasskeyAuth.swift | 37 ++++++++++++++------------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/Sources/PasskeyAuth/PasskeyAuth.swift b/Sources/PasskeyAuth/PasskeyAuth.swift index 8df7386..001aaca 100644 --- a/Sources/PasskeyAuth/PasskeyAuth.swift +++ b/Sources/PasskeyAuth/PasskeyAuth.swift @@ -84,15 +84,7 @@ public actor PasskeyAuth { /// - Throws: Various PasskeyError cases if registration fails public func registerPasskey(displayName: String) async throws -> ( ASAuthorizationPlatformPublicKeyCredentialRegistration, PasskeyResponse) { - try checkRateLimit() - - guard let presentationContextProvider = presentationContextProvider else { - throw PasskeyError.configurationError("Presentation context provider not set") - } - - guard !isAuthenticating else { - throw PasskeyError.authenticationInProgress - } + let presentationContextProvider = try prepareAuthentication() defer { self.setAuthenticating(false) } @@ -181,15 +173,7 @@ public actor PasskeyAuth { /// - Throws: Various PasskeyError cases if login fails public func loginWithPasskey() async throws -> ( ASAuthorizationPlatformPublicKeyCredentialAssertion, PasskeyResponse) { - try checkRateLimit() - - guard let presentationContextProvider = presentationContextProvider else { - throw PasskeyError.configurationError("Presentation context provider not set") - } - - guard !isAuthenticating else { - throw PasskeyError.authenticationInProgress - } + let presentationContextProvider = try prepareAuthentication() defer { self.setAuthenticating(false) } @@ -388,6 +372,23 @@ public actor PasskeyAuth { } } +// MARK: Private Methods +extension PasskeyAuth { + /// Performs common preflight checks, such as rate limit and wether we are already authenticating. + /// Returns the presentationContextProvider and a cleanup closure you should call in a `defer`. + private func prepareAuthentication() throws -> PasskeyPresentationContextProvider { + try checkRateLimit() + guard let presentationContextProvider = presentationContextProvider else { + throw PasskeyError.configurationError("Presentation context provider not set") + } + guard !isAuthenticating else { + throw PasskeyError.authenticationInProgress + } + + return presentationContextProvider + } +} + enum HTTPStatusCode: Int { case tooManyRequests = 429 } From 4d1ce1f2767779724e9361a4de987de2aec463c7 Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Fri, 5 Dec 2025 12:14:03 +0100 Subject: [PATCH 3/7] Add `performRequest`, `get` and `getChallenge` methods --- Sources/PasskeyAuth/PasskeyAuth.swift | 139 +++++++++----------------- 1 file changed, 47 insertions(+), 92 deletions(-) diff --git a/Sources/PasskeyAuth/PasskeyAuth.swift b/Sources/PasskeyAuth/PasskeyAuth.swift index 001aaca..707f896 100644 --- a/Sources/PasskeyAuth/PasskeyAuth.swift +++ b/Sources/PasskeyAuth/PasskeyAuth.swift @@ -102,34 +102,7 @@ public actor PasskeyAuth { throw PasskeyError.invalidURL("Failed to create URL from components") } - let (data, response) = try await session.data(from: url) - - guard let httpResponse = response as? HTTPURLResponse else { - throw PasskeyError.networkError(NSError(domain: "", code: -1)) - } - - if httpResponse.statusCode == HTTPStatusCode.tooManyRequests.rawValue { - let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After") - .flatMap { TimeInterval($0) } - throw PasskeyError.rateLimit(retryAfter: retryAfter) - } - - if !(200...299).contains(httpResponse.statusCode) { - throw PasskeyError.serverError( - statusCode: httpResponse.statusCode, - message: String(data: data, encoding: .utf8) - ) - } - - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] - - guard let challengeB64 = json?["challenge"] as? String else { - throw PasskeyError.invalidChallenge("Challenge not found in response") - } - - guard let challengeData = challengeB64.base64URLDecoded() else { - throw PasskeyError.invalidChallenge("Failed to decode challenge") - } + let challengeData = try await getChallengeData(from: url) let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( relyingPartyIdentifier: self.configuration.rpID @@ -183,34 +156,7 @@ public actor PasskeyAuth { throw PasskeyError.invalidURL("Failed to create URL for login challenge") } - let (data, response) = try await session.data(from: url) - - guard let httpResponse = response as? HTTPURLResponse else { - throw PasskeyError.networkError(NSError(domain: "", code: -1)) - } - - if httpResponse.statusCode == HTTPStatusCode.tooManyRequests.rawValue { - let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After") - .flatMap { TimeInterval($0) } - throw PasskeyError.rateLimit(retryAfter: retryAfter) - } - - if !(200...299).contains(httpResponse.statusCode) { - throw PasskeyError.serverError( - statusCode: httpResponse.statusCode, - message: String(data: data, encoding: .utf8) - ) - } - - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] - - guard let challengeB64 = json?["challenge"] as? String else { - throw PasskeyError.invalidChallenge("Challenge not found in response") - } - - guard let challengeData = challengeB64.base64URLDecoded() else { - throw PasskeyError.invalidChallenge("Failed to decode challenge") - } + let challengeData = try await getChallengeData(from: url) let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( relyingPartyIdentifier: configuration.rpID @@ -295,24 +241,7 @@ public actor PasskeyAuth { request.httpBody = try JSONSerialization.data(withJSONObject: body) - let (data, response) = try await session.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw PasskeyError.networkError(NSError(domain: "", code: -1)) - } - - if httpResponse.statusCode == HTTPStatusCode.tooManyRequests.rawValue { - let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After") - .flatMap { TimeInterval($0) } - throw PasskeyError.rateLimit(retryAfter: retryAfter) - } - - if !(200...299).contains(httpResponse.statusCode) { - throw PasskeyError.serverError( - statusCode: httpResponse.statusCode, - message: String(data: data, encoding: .utf8) - ) - } + let (data, _) = try await performRequest(request) return try JSONDecoder().decode(PasskeyResponse.self, from: data) } @@ -349,24 +278,7 @@ public actor PasskeyAuth { request.httpBody = try JSONSerialization.data(withJSONObject: body) - let (data, response) = try await session.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw PasskeyError.networkError(NSError(domain: "", code: -1)) - } - - if httpResponse.statusCode == HTTPStatusCode.tooManyRequests.rawValue { - let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After") - .flatMap { TimeInterval($0) } - throw PasskeyError.rateLimit(retryAfter: retryAfter) - } - - if !(200...299).contains(httpResponse.statusCode) { - throw PasskeyError.serverError( - statusCode: httpResponse.statusCode, - message: String(data: data, encoding: .utf8) - ) - } + let (data, _) = try await performRequest(request) return try JSONDecoder().decode(PasskeyResponse.self, from: data) } @@ -387,6 +299,49 @@ extension PasskeyAuth { return presentationContextProvider } + + /// Performs a URLRequest and enforces shared HTTP handling (status codes, rate limiting, decoding safety) + private func performRequest(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw PasskeyError.networkError(NSError(domain: "", code: -1)) + } + if httpResponse.statusCode == HTTPStatusCode.tooManyRequests.rawValue { + let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After") + .flatMap { TimeInterval($0) } + throw PasskeyError.rateLimit(retryAfter: retryAfter) + } + guard (200...299).contains(httpResponse.statusCode) else { + throw PasskeyError.serverError( + statusCode: httpResponse.statusCode, + message: String(data: data, encoding: .utf8) + ) + } + return (data, httpResponse) + } + + /// Convenience for simple GET requests + private func get(from url: URL) async throws -> (Data, HTTPURLResponse) { + var request = URLRequest(url: url) + request.httpMethod = "GET" + return try await performRequest(request) + } + + private func getChallengeData(from url: URL) async throws -> Data { + let (data, _) = try await get(from: url) + + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + guard let challengeB64 = json?["challenge"] as? String else { + throw PasskeyError.invalidChallenge("Challenge not found in response") + } + + guard let challengeData = challengeB64.base64URLDecoded() else { + throw PasskeyError.invalidChallenge("Failed to decode challenge") + } + + return challengeData + } } enum HTTPStatusCode: Int { From a76dfbf534745971f4a3b48a7bc808f76109c661 Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Fri, 5 Dec 2025 12:21:14 +0100 Subject: [PATCH 4/7] Add `makeUrl` --- Sources/PasskeyAuth/PasskeyAuth.swift | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/Sources/PasskeyAuth/PasskeyAuth.swift b/Sources/PasskeyAuth/PasskeyAuth.swift index 707f896..e5243ce 100644 --- a/Sources/PasskeyAuth/PasskeyAuth.swift +++ b/Sources/PasskeyAuth/PasskeyAuth.swift @@ -83,14 +83,16 @@ public actor PasskeyAuth { /// - Returns: A PasskeyResponse containing the registration result /// - Throws: Various PasskeyError cases if registration fails public func registerPasskey(displayName: String) async throws -> ( - ASAuthorizationPlatformPublicKeyCredentialRegistration, PasskeyResponse) { + ASAuthorizationPlatformPublicKeyCredentialRegistration, PasskeyResponse + ) { let presentationContextProvider = try prepareAuthentication() defer { self.setAuthenticating(false) } setAuthenticating(true) - guard var urlComponents = URLComponents(string: "\(configuration.baseURL)\(configuration.endpoints.registerChallenge)") else { + let urlBase = try makeUrl(endpoint: \.registerChallenge) + guard var urlComponents = URLComponents(url: urlBase, resolvingAgainstBaseURL: false) else { throw PasskeyError.invalidURL("Failed to create URL components for registration challenge") } @@ -152,9 +154,7 @@ public actor PasskeyAuth { setAuthenticating(true) - guard let url = URL(string: "\(configuration.baseURL)\(configuration.endpoints.loginChallenge)") else { - throw PasskeyError.invalidURL("Failed to create URL for login challenge") - } + let url = try makeUrl(endpoint: \.loginChallenge) let challengeData = try await getChallengeData(from: url) @@ -219,9 +219,7 @@ public actor PasskeyAuth { ) async throws -> PasskeyResponse { defer { self.setAuthenticating(false) } - guard let url = URL(string: "\(configuration.baseURL)\(configuration.endpoints.registerPasskey)") else { - throw PasskeyError.invalidURL("Failed to create URL for passkey registration") - } + let url = try makeUrl(endpoint: \.registerPasskey) var request = URLRequest(url: url) request.httpMethod = "POST" @@ -254,9 +252,7 @@ public actor PasskeyAuth { ) async throws -> PasskeyResponse { defer { self.setAuthenticating(false) } - guard let url = URL(string: "\(configuration.baseURL)\(configuration.endpoints.loginPasskey)") else { - throw PasskeyError.invalidURL("Failed to create URL for passkey login") - } + let url = try makeUrl(endpoint: \.loginPasskey) var request = URLRequest(url: url) request.httpMethod = "POST" @@ -342,6 +338,14 @@ extension PasskeyAuth { return challengeData } + + private func makeUrl(endpoint keyPath: KeyPath) throws -> URL { + let endpoint = configuration.endpoints[keyPath: keyPath] + guard let url = URL(string: "\(configuration.baseURL)\(endpoint)") else { + throw PasskeyError.invalidURL("Failed to create URL for \(endpoint)") + } + return url + } } enum HTTPStatusCode: Int { From 85d11e66d1c05b6d978e7b83cf07fd4c90a19758 Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Fri, 5 Dec 2025 12:28:22 +0100 Subject: [PATCH 5/7] Add `post` --- Sources/PasskeyAuth/PasskeyAuth.swift | 42 ++++++++++++--------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/Sources/PasskeyAuth/PasskeyAuth.swift b/Sources/PasskeyAuth/PasskeyAuth.swift index e5243ce..b013ad2 100644 --- a/Sources/PasskeyAuth/PasskeyAuth.swift +++ b/Sources/PasskeyAuth/PasskeyAuth.swift @@ -212,6 +212,22 @@ public actor PasskeyAuth { isAuthenticating = value } + private func post( + decode: Response.Type = Response.self, + _ endpointKeyPath: KeyPath, + body: [String: Any] + ) async throws -> Response where Response: Decodable { + let url = try makeUrl(endpoint: endpointKeyPath) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, _) = try await performRequest(request) + return try JSONDecoder().decode(Response.self, from: data) + } + func postRegisterData( credentialID: Data, attestationObject: Data, @@ -219,12 +235,6 @@ public actor PasskeyAuth { ) async throws -> PasskeyResponse { defer { self.setAuthenticating(false) } - let url = try makeUrl(endpoint: \.registerPasskey) - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - let body: [String: Any] = [ "attestationResponse": [ "id": credentialID.base64URLEncodedString(), @@ -237,11 +247,7 @@ public actor PasskeyAuth { ] ] - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, _) = try await performRequest(request) - - return try JSONDecoder().decode(PasskeyResponse.self, from: data) + return try await post(\.registerPasskey, body: body) } func postLoginData( @@ -251,13 +257,7 @@ public actor PasskeyAuth { signature: Data ) async throws -> PasskeyResponse { defer { self.setAuthenticating(false) } - - let url = try makeUrl(endpoint: \.loginPasskey) - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - + let body: [String: Any] = [ "authenticationResponse": [ "id": credentialID.base64URLEncodedString(), @@ -272,11 +272,7 @@ public actor PasskeyAuth { ] ] - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, _) = try await performRequest(request) - - return try JSONDecoder().decode(PasskeyResponse.self, from: data) + return try await post(\.loginPasskey, body: body) } } From da17245e2a1005b405a974c624729bed79967252 Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Fri, 5 Dec 2025 12:35:04 +0100 Subject: [PATCH 6/7] Improve `get` and `getChallengeData` to use Keypaths and URLQueryItem --- Sources/PasskeyAuth/PasskeyAuth.swift | 64 +++++++++++++++++---------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/Sources/PasskeyAuth/PasskeyAuth.swift b/Sources/PasskeyAuth/PasskeyAuth.swift index b013ad2..781fbd0 100644 --- a/Sources/PasskeyAuth/PasskeyAuth.swift +++ b/Sources/PasskeyAuth/PasskeyAuth.swift @@ -91,20 +91,11 @@ public actor PasskeyAuth { setAuthenticating(true) - let urlBase = try makeUrl(endpoint: \.registerChallenge) - guard var urlComponents = URLComponents(url: urlBase, resolvingAgainstBaseURL: false) else { - throw PasskeyError.invalidURL("Failed to create URL components for registration challenge") - } - - urlComponents.queryItems = [ - URLQueryItem(name: "displayName", value: displayName) - ] - - guard let url = urlComponents.url else { - throw PasskeyError.invalidURL("Failed to create URL from components") - } - - let challengeData = try await getChallengeData(from: url) + let challengeData = try await getChallengeData( + endpoint: \.registerChallenge, + queryItemName: "displayName", + queryItemValue: displayName + ) let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( relyingPartyIdentifier: self.configuration.rpID @@ -154,9 +145,7 @@ public actor PasskeyAuth { setAuthenticating(true) - let url = try makeUrl(endpoint: \.loginChallenge) - - let challengeData = try await getChallengeData(from: url) + let challengeData = try await getChallengeData(endpoint: \.loginChallenge) let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( relyingPartyIdentifier: configuration.rpID @@ -313,14 +302,29 @@ extension PasskeyAuth { } /// Convenience for simple GET requests - private func get(from url: URL) async throws -> (Data, HTTPURLResponse) { + private func get( + endpoint endpointKeyPath: KeyPath, + queryItems: [URLQueryItem] = [] + ) async throws -> (Data, HTTPURLResponse) { + let url = try makeUrl(endpoint: endpointKeyPath, queryItems: queryItems) var request = URLRequest(url: url) request.httpMethod = "GET" return try await performRequest(request) } - private func getChallengeData(from url: URL) async throws -> Data { - let (data, _) = try await get(from: url) + private func getChallengeData( + endpoint endpointKeyPath: KeyPath, + queryItemName name: String, + queryItemValue value: String + ) async throws -> Data { + try await getChallengeData(endpoint: endpointKeyPath, queryItems: [.init(name: name, value: value)]) + } + + private func getChallengeData( + endpoint endpointKeyPath: KeyPath, + queryItems: [URLQueryItem] = [] + ) async throws -> Data { + let (data, _) = try await get(endpoint: endpointKeyPath, queryItems: queryItems) let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] @@ -335,11 +339,25 @@ extension PasskeyAuth { return challengeData } - private func makeUrl(endpoint keyPath: KeyPath) throws -> URL { - let endpoint = configuration.endpoints[keyPath: keyPath] - guard let url = URL(string: "\(configuration.baseURL)\(endpoint)") else { + private func makeUrl( + endpoint endpointKeyPath: KeyPath, + queryItems: [URLQueryItem] = [] + ) throws -> URL { + let endpoint = configuration.endpoints[keyPath: endpointKeyPath] + guard let urlBase = URL(string: "\(configuration.baseURL)\(endpoint)") else { throw PasskeyError.invalidURL("Failed to create URL for \(endpoint)") } + + guard var urlComponents = URLComponents(url: urlBase, resolvingAgainstBaseURL: false) else { + throw PasskeyError.invalidURL("Failed to create URL components for \(endpoint)") + } + + urlComponents.queryItems = queryItems + + guard let url = urlComponents.url else { + throw PasskeyError.invalidURL("Failed to create URL from components for \(endpoint)") + } + return url } } From 154e89bbc506e9d75c5955864376a75566f56784 Mon Sep 17 00:00:00 2001 From: Alexander Cyon Date: Fri, 5 Dec 2025 12:38:34 +0100 Subject: [PATCH 7/7] Move post into private method section --- Sources/PasskeyAuth/PasskeyAuth.swift | 32 +++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Sources/PasskeyAuth/PasskeyAuth.swift b/Sources/PasskeyAuth/PasskeyAuth.swift index 781fbd0..c85c876 100644 --- a/Sources/PasskeyAuth/PasskeyAuth.swift +++ b/Sources/PasskeyAuth/PasskeyAuth.swift @@ -200,22 +200,6 @@ public actor PasskeyAuth { fileprivate func setAuthenticating(_ value: Bool) { isAuthenticating = value } - - private func post( - decode: Response.Type = Response.self, - _ endpointKeyPath: KeyPath, - body: [String: Any] - ) async throws -> Response where Response: Decodable { - let url = try makeUrl(endpoint: endpointKeyPath) - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, _) = try await performRequest(request) - return try JSONDecoder().decode(Response.self, from: data) - } func postRegisterData( credentialID: Data, @@ -339,6 +323,22 @@ extension PasskeyAuth { return challengeData } + private func post( + decode: Response.Type = Response.self, + _ endpointKeyPath: KeyPath, + body: [String: Any] + ) async throws -> Response where Response: Decodable { + let url = try makeUrl(endpoint: endpointKeyPath) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, _) = try await performRequest(request) + return try JSONDecoder().decode(Response.self, from: data) + } + private func makeUrl( endpoint endpointKeyPath: KeyPath, queryItems: [URLQueryItem] = []