Skip to content

Remove dependencies #2

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
8 changes: 4 additions & 4 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -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"
}
}
],
Expand Down
9 changes: 6 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
),
]
)
91 changes: 52 additions & 39 deletions Sources/Functions/FunctionsClient.swift
Original file line number Diff line number Diff line change
@@ -1,101 +1,114 @@
@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<Response>(
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<T: Decodable>(
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"
if isRelayError {
throw FunctionsError.relayError
}

return (response.data, httpResponse)
return (data, httpResponse)
}
}
25 changes: 23 additions & 2 deletions Sources/Functions/Types.swift
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down
93 changes: 93 additions & 0 deletions Tests/FunctionsTests/FunctionsClientTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading