diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5db18f4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - "*" + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + library: + runs-on: macos-latest + strategy: + matrix: + platform: + - iOS Simulator,name=iPhone 14 Pro Max + - macOS + - tvOS Simulator,name=Apple TV + - watchOS Simulator,name=Apple Watch Series 7 (45mm) + + steps: + - uses: actions/checkout@v3 + - name: Run tests + run: PLATFORM="${{ matrix.platform }}" make test-library diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fac1f75 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +PLATFORM ?= iOS Simulator,name=iPhone 14 Pro Max + +.PHONY: test-library +test-library: + xcodebuild test \ + -scheme functions-swift \ + -destination platform="$(PLATFORM)" + +.PHONY: format +format: + swift format -i -r ./Sources ./Tests ./Package.swift diff --git a/Package.resolved b/Package.resolved index e656aee..49ba33f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { "pins" : [ { - "identity" : "get", + "identity" : "mocker", "kind" : "remoteSourceControl", - "location" : "https://github.com/kean/Get", + "location" : "https://github.com/WeTransfer/Mocker", "state" : { - "revision" : "12830cc64f31789ae6f4352d2d51d03a25fc3741", - "version" : "2.1.6" + "revision" : "4384e015cae4916a6828252467a4437173c7ae17", + "version" : "3.0.1" } } ], diff --git a/Package.swift b/Package.swift index a44a659..4c0b733 100644 --- a/Package.swift +++ b/Package.swift @@ -15,16 +15,19 @@ let package = Package( .library(name: "Functions", targets: ["Functions"]) ], dependencies: [ - .package(url: "https://github.com/kean/Get", from: "2.1.5") + .package(url: "https://github.com/WeTransfer/Mocker", from: "3.0.1") ], targets: [ .target( name: "Functions", - dependencies: ["Get"] + dependencies: [] ), .testTarget( name: "FunctionsTests", - dependencies: ["Functions"] + dependencies: [ + "Functions", + "Mocker", + ] ), ] ) diff --git a/Sources/Functions/FunctionsClient.swift b/Sources/Functions/FunctionsClient.swift index ca692b5..962eb12 100644 --- a/Sources/Functions/FunctionsClient.swift +++ b/Sources/Functions/FunctionsClient.swift @@ -1,94 +1,107 @@ -@preconcurrency import Foundation -@preconcurrency import Get +import Foundation -public final class FunctionsClient { +/// An actor representing a client for invoking functions. +public actor FunctionsClient { + /// Typealias for the fetch handler used to make requests. + public typealias FetchHandler = @Sendable (_ request: URLRequest) async throws -> ( + Data, URLResponse + ) + + /// The base URL for the functions. let url: URL + /// Headers to be included in the requests. var headers: [String: String] + /// The fetch handler used to make requests. + let fetch: FetchHandler - let client: APIClient - + /// Initializes a new instance of `FunctionsClient`. + /// + /// - Parameters: + /// - url: The base URL for the functions. + /// - headers: Headers to be included in the requests. (Default: empty dictionary) + /// - fetch: The fetch handler used to make requests. (Default: URLSession.shared.data(for:)) public init( url: URL, headers: [String: String] = [:], - apiClientDelegate: APIClientDelegate? = nil + fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) } ) { self.url = url self.headers = headers self.headers["X-Client-Info"] = "functions-swift/\(version)" - client = APIClient(baseURL: url) { - $0.delegate = apiClientDelegate - } + self.fetch = fetch } /// Updates the authorization header. - /// - Parameter token: the new JWT token sent in the authorization header + /// + /// - Parameter token: The new JWT token sent in the authorization header. public func setAuth(token: String) { headers["Authorization"] = "Bearer \(token)" } - /// Invokes a function. + /// Invokes a function and decodes the response. + /// /// - Parameters: - /// - functionName: the name of the function to invoke. + /// - functionName: The name of the function to invoke. + /// - invokeOptions: Options for invoking the function. (Default: empty `FunctionInvokeOptions`) + /// - decode: A closure to decode the response data and HTTPURLResponse into a `Response` object. + /// - Returns: The decoded `Response` object. public func invoke( functionName: String, invokeOptions: FunctionInvokeOptions = .init(), decode: (Data, HTTPURLResponse) throws -> Response ) async throws -> Response { let (data, response) = try await rawInvoke( - functionName: functionName, - invokeOptions: invokeOptions - ) + functionName: functionName, invokeOptions: invokeOptions) return try decode(data, response) } - /// Invokes a function. + /// Invokes a function and decodes the response as a specific type. + /// /// - Parameters: - /// - functionName: the name of the function to invoke. + /// - functionName: The name of the function to invoke. + /// - invokeOptions: Options for invoking the function. (Default: empty `FunctionInvokeOptions`) + /// - decoder: The JSON decoder to use for decoding the response. (Default: `JSONDecoder()`) + /// - Returns: The decoded object of type `T`. public func invoke( functionName: String, invokeOptions: FunctionInvokeOptions = .init(), decoder: JSONDecoder = JSONDecoder() ) async throws -> T { - try await invoke( - functionName: functionName, - invokeOptions: invokeOptions, - decode: { data, _ in try decoder.decode(T.self, from: data) } - ) + try await invoke(functionName: functionName, invokeOptions: invokeOptions) { data, _ in + try decoder.decode(T.self, from: data) + } } - /// Invokes a function. + /// Invokes a function without expecting a response. + /// /// - Parameters: - /// - functionName: the name of the function to invoke. + /// - functionName: The name of the function to invoke. + /// - invokeOptions: Options for invoking the function. (Default: empty `FunctionInvokeOptions`) public func invoke( functionName: String, invokeOptions: FunctionInvokeOptions = .init() ) async throws { - try await invoke( - functionName: functionName, - invokeOptions: invokeOptions, - decode: { _, _ in () } - ) + try await invoke(functionName: functionName, invokeOptions: invokeOptions) { _, _ in () } } private func rawInvoke( functionName: String, invokeOptions: FunctionInvokeOptions ) async throws -> (Data, HTTPURLResponse) { - let request = Request( - path: functionName, - method: invokeOptions.method.map({ HTTPMethod(rawValue: $0.rawValue) }) ?? .post, - body: invokeOptions.body, - headers: invokeOptions.headers.merging(headers) { first, _ in first } - ) + let url = self.url.appendingPathComponent(functionName) + var urlRequest = URLRequest(url: url) + urlRequest.allHTTPHeaderFields = invokeOptions.headers.merging(headers) { first, _ in first } + urlRequest.httpMethod = (invokeOptions.method ?? .post).rawValue + urlRequest.httpBody = invokeOptions.body - let response = try await client.data(for: request) + let (data, response) = try await fetch(urlRequest) - guard let httpResponse = response.response as? HTTPURLResponse else { + guard let httpResponse = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } guard 200..<300 ~= httpResponse.statusCode else { - throw FunctionsError.httpError(code: httpResponse.statusCode, data: response.data) + throw FunctionsError.httpError(code: httpResponse.statusCode, data: data) } let isRelayError = httpResponse.value(forHTTPHeaderField: "x-relay-error") == "true" @@ -96,6 +109,6 @@ public final class FunctionsClient { throw FunctionsError.relayError } - return (response.data, httpResponse) + return (data, httpResponse) } } diff --git a/Sources/Functions/Types.swift b/Sources/Functions/Types.swift index 6770a87..6ac72b1 100644 --- a/Sources/Functions/Types.swift +++ b/Sources/Functions/Types.swift @@ -1,22 +1,38 @@ import Foundation +/// An error type representing various errors that can occur while invoking functions. public enum FunctionsError: Error, LocalizedError { + /// Error indicating a relay error while invoking the Edge Function. case relayError + /// Error indicating a non-2xx status code returned by the Edge Function. case httpError(code: Int, data: Data) + /// A localized description of the error. public var errorDescription: String? { switch self { - case .relayError: return "Relay Error invoking the Edge Function" - case let .httpError(code, _): return "Edge Function returned a non-2xx status code: \(code)" + case .relayError: + return "Relay Error invoking the Edge Function" + case let .httpError(code, _): + return "Edge Function returned a non-2xx status code: \(code)" } } } +/// Options for invoking a function. public struct FunctionInvokeOptions { + /// Method to use in the function invocation. let method: Method? + /// Headers to be included in the function invocation. let headers: [String: String] + /// Body data to be sent with the function invocation. let body: Data? + /// Initializes the `FunctionInvokeOptions` structure. + /// + /// - Parameters: + /// - method: Method to use in the function invocation. + /// - headers: Headers to be included in the function invocation. (Default: empty dictionary) + /// - body: The body data to be sent with the function invocation. (Default: nil) public init(method: Method? = nil, headers: [String: String] = [:], body: some Encodable) { var headers = headers @@ -37,6 +53,11 @@ public struct FunctionInvokeOptions { self.headers = headers } + /// Initializes the `FunctionInvokeOptions` structure. + /// + /// - Parameters: + /// - method: Method to use in the function invocation. + /// - headers: Headers to be included in the function invocation. (Default: empty dictionary) public init(method: Method? = nil, headers: [String: String] = [:]) { self.method = method self.headers = headers diff --git a/Tests/FunctionsTests/FunctionsClientTests.swift b/Tests/FunctionsTests/FunctionsClientTests.swift new file mode 100644 index 0000000..fc45b70 --- /dev/null +++ b/Tests/FunctionsTests/FunctionsClientTests.swift @@ -0,0 +1,93 @@ +import Mocker +import XCTest + +@testable import Functions + +final class FunctionsClientTests: XCTestCase { + let url = URL(string: "http://localhost:5432/functions/v1")! + let apiKey = "supabase.anon.key" + + lazy var sut = FunctionsClient(url: url, headers: ["apikey": apiKey]) + + func testInvoke() async throws { + let url = URL(string: "http://localhost:5432/functions/v1/hello_world")! + var _request: URLRequest? + + var mock = Mock(url: url, dataType: .json, statusCode: 200, data: [.post: Data()]) + mock.onRequestHandler = .init { _request = $0 } + mock.register() + + let body = ["name": "Supabase"] + + try await sut.invoke( + functionName: "hello_world", + invokeOptions: .init(headers: ["X-Custom-Key": "value"], body: body) + ) + + let request = try XCTUnwrap(_request) + + XCTAssertEqual(request.url, url) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.value(forHTTPHeaderField: "apikey"), apiKey) + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Custom-Key"), "value") + XCTAssertEqual( + request.value(forHTTPHeaderField: "X-Client-Info"), + "functions-swift/\(Functions.version)" + ) + } + + func testInvoke_shouldThrow_URLError_badServerResponse() async { + let url = URL(string: "http://localhost:5432/functions/v1/hello_world")! + let mock = Mock( + url: url, dataType: .json, statusCode: 200, data: [.post: Data()], + requestError: URLError(.badServerResponse)) + mock.register() + + do { + try await sut.invoke(functionName: "hello_world") + } catch let urlError as URLError { + XCTAssertEqual(urlError.code, .badServerResponse) + } catch { + XCTFail("Unexpected error thrown \(error)") + } + } + + func testInvoke_shouldThrow_FunctionsError_httpError() async { + let url = URL(string: "http://localhost:5432/functions/v1/hello_world")! + let mock = Mock( + url: url, dataType: .json, statusCode: 300, data: [.post: "error".data(using: .utf8)!]) + mock.register() + + do { + try await sut.invoke(functionName: "hello_world") + XCTFail("Invoke should fail.") + } catch let FunctionsError.httpError(code, data) { + XCTAssertEqual(code, 300) + XCTAssertEqual(data, "error".data(using: .utf8)) + } catch { + XCTFail("Unexpected error thrown \(error)") + } + } + + func testInvoke_shouldThrow_FunctionsError_relayError() async { + let url = URL(string: "http://localhost:5432/functions/v1/hello_world")! + let mock = Mock( + url: url, dataType: .json, statusCode: 200, data: [.post: Data()], + additionalHeaders: ["x-relay-error": "true"]) + mock.register() + + do { + try await sut.invoke(functionName: "hello_world") + XCTFail("Invoke should fail.") + } catch FunctionsError.relayError { + } catch { + XCTFail("Unexpected error thrown \(error)") + } + } + + func test_setAuth() async { + await sut.setAuth(token: "access.token") + let headers = await sut.headers + XCTAssertEqual(headers["Authorization"], "Bearer access.token") + } +} diff --git a/scripts/check-for-breaking-api-changes.sh b/scripts/check-for-breaking-api-changes.sh new file mode 100644 index 0000000..d2ce981 --- /dev/null +++ b/scripts/check-for-breaking-api-changes.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +##===----------------------------------------------------------------------===## +## +## This source file is part of the SwiftOpenAPIGenerator open source project +## +## Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +## Licensed under Apache License v2.0 +## +## See LICENSE.txt for license information +## See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +## +## SPDX-License-Identifier: Apache-2.0 +## +##===----------------------------------------------------------------------===## + +set -euo pipefail + +log() { printf -- "** %s\n" "$*" >&2; } +error() { printf -- "** ERROR: %s\n" "$*" >&2; } +fatal() { error "$@"; exit 1; } + +CURRENT_SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +REPO_ROOT="$(git -C "${CURRENT_SCRIPT_DIR}" rev-parse --show-toplevel)" + +log "Checking required environment variables..." +test -n "${BASELINE_REPO_URL:-}" || fatal "BASELINE_REPO_URL unset" +test -n "${BASELINE_TREEISH:-}" || fatal "BASELINE_TREEISH unset" + +log "Fetching baseline: ${BASELINE_REPO_URL}#${BASELINE_TREEISH}..." +git -C "${REPO_ROOT}" fetch "${BASELINE_REPO_URL}" "${BASELINE_TREEISH}" +BASELINE_COMMIT=$(git -C "${REPO_ROOT}" rev-parse FETCH_HEAD) + +log "Checking for API changes since ${BASELINE_REPO_URL}#${BASELINE_TREEISH} (${BASELINE_COMMIT})..." +swift package --package-path "${REPO_ROOT}" diagnose-api-breaking-changes \ + "${BASELINE_COMMIT}" \ + && RC=$? || RC=$? + +if [ "${RC}" -ne 0 ]; then + fatal "❌ Breaking API changes detected." + exit "${RC}" +fi +log "✅ No breaking API changes detected."