Skip to content

Commit

Permalink
feat(functions): add support for specifying function region (#347)
Browse files Browse the repository at this point in the history
* feat(functions): add function region

* feat: specify region when initializing client

* chore: remove `any` function region and optional
  • Loading branch information
grdsdev committed Apr 19, 2024
1 parent 56c8117 commit f470874
Show file tree
Hide file tree
Showing 6 changed files with 185 additions and 7 deletions.
33 changes: 33 additions & 0 deletions Sources/Functions/FunctionsClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ public actor FunctionsClient {
let url: URL
/// Headers to be included in the requests.
var headers: [String: String]
/// The Region to invoke the functions in.
let region: String?
/// The fetch handler used to make requests.
let fetch: FetchHandler

Expand All @@ -26,17 +28,43 @@ public actor FunctionsClient {
/// - Parameters:
/// - url: The base URL for the functions.
/// - headers: Headers to be included in the requests. (Default: empty dictionary)
/// - region: The Region to invoke the functions in.
/// - fetch: The fetch handler used to make requests. (Default: URLSession.shared.data(for:))
@_disfavoredOverload
public init(
url: URL,
headers: [String: String] = [:],
region: String? = nil,
fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) }
) {
self.url = url
self.headers = headers
if headers["X-Client-Info"] == nil {
self.headers["X-Client-Info"] = "functions-swift/\(version)"
}
self.region = region
self.fetch = fetch
}

/// 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)
/// - region: The Region to invoke the functions in.
/// - fetch: The fetch handler used to make requests. (Default: URLSession.shared.data(for:))
public init(
url: URL,
headers: [String: String] = [:],
region: FunctionRegion? = nil,
fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) }
) {
self.url = url
self.headers = headers
if headers["X-Client-Info"] == nil {
self.headers["X-Client-Info"] = "functions-swift/\(version)"
}
self.region = region?.rawValue
self.fetch = fetch
}

Expand Down Expand Up @@ -109,6 +137,11 @@ public actor FunctionsClient {
urlRequest.httpMethod = (invokeOptions.method ?? .post).rawValue
urlRequest.httpBody = invokeOptions.body

let region = invokeOptions.region ?? region
if let region {
urlRequest.setValue(region, forHTTPHeaderField: "x-region")
}

let (data, response) = try await fetch(urlRequest)

guard let httpResponse = response as? HTTPURLResponse else {
Expand Down
79 changes: 75 additions & 4 deletions Sources/Functions/Types.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,30 @@ public enum FunctionsError: Error, LocalizedError {
}

/// Options for invoking a function.
public struct FunctionInvokeOptions {
public struct FunctionInvokeOptions: Sendable {
/// 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?
/// The Region to invoke the function in.
let region: String?

/// 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)
/// - region: The Region to invoke the function in.
/// - body: The body data to be sent with the function invocation. (Default: nil)
public init(method: Method? = nil, headers: [String: String] = [:], body: some Encodable) {
@_disfavoredOverload
public init(
method: Method? = nil,
headers: [String: String] = [:],
region: String? = nil,
body: some Encodable
) {
var defaultHeaders = headers

switch body {
Expand All @@ -51,24 +60,86 @@ public struct FunctionInvokeOptions {

self.method = method
self.headers = defaultHeaders.merging(headers) { _, new in new }
self.region = region
}

/// 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] = [:]) {
/// - region: The Region to invoke the function in.
@_disfavoredOverload
public init(
method: Method? = nil,
headers: [String: String] = [:],
region: String? = nil
) {
self.method = method
self.headers = headers
self.region = region
body = nil
}

public enum Method: String {
public enum Method: String, Sendable {
case get = "GET"
case post = "POST"
case put = "PUT"
case patch = "PATCH"
case delete = "DELETE"
}
}

public enum FunctionRegion: String, Sendable {
case apNortheast1 = "ap-northeast-1"
case apNortheast2 = "ap-northeast-2"
case apSouth1 = "ap-south-1"
case apSoutheast1 = "ap-southeast-1"
case apSoutheast2 = "ap-southeast-2"
case caCentral1 = "ca-central-1"
case euCentral1 = "eu-central-1"
case euWest1 = "eu-west-1"
case euWest2 = "eu-west-2"
case euWest3 = "eu-west-3"
case saEast1 = "sa-east-1"
case usEast1 = "us-east-1"
case usWest1 = "us-west-1"
case usWest2 = "us-west-2"
}

extension FunctionInvokeOptions {
/// 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)
/// - region: The Region to invoke the function in.
/// - body: The body data to be sent with the function invocation. (Default: nil)
public init(
method: Method? = nil,
headers: [String: String] = [:],
region: FunctionRegion? = nil,
body: some Encodable
) {
self.init(
method: method,
headers: headers,
region: region?.rawValue,
body: body
)
}

/// 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)
/// - region: The Region to invoke the function in.
public init(
method: Method? = nil,
headers: [String: String] = [:],
region: FunctionRegion? = nil
) {
self.init(method: method, headers: headers, region: region?.rawValue)
}
}
1 change: 1 addition & 0 deletions Sources/Supabase/SupabaseClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ public final class SupabaseClient: @unchecked Sendable {
public private(set) lazy var functions = FunctionsClient(
url: functionsURL,
headers: defaultHeaders,
region: options.functions.region,
fetch: fetchWithAuth
)

Expand Down
23 changes: 21 additions & 2 deletions Sources/Supabase/Types.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public struct SupabaseClientOptions: Sendable {
public let db: DatabaseOptions
public let auth: AuthOptions
public let global: GlobalOptions
public let functions: FunctionsOptions

public struct DatabaseOptions: Sendable {
/// The Postgres schema which your tables belong to. Must be on the list of exposed schemas in
Expand Down Expand Up @@ -87,26 +88,44 @@ public struct SupabaseClientOptions: Sendable {
}
}

public struct FunctionsOptions: Sendable {
/// The Region to invoke the functions in.
public let region: String?

@_disfavoredOverload
public init(region: String? = nil) {
self.region = region
}

public init(region: FunctionRegion? = nil) {
self.init(region: region?.rawValue)
}
}

public init(
db: DatabaseOptions = .init(),
auth: AuthOptions,
global: GlobalOptions = .init()
global: GlobalOptions = .init(),
functions: FunctionsOptions = .init()
) {
self.db = db
self.auth = auth
self.global = global
self.functions = functions
}
}

extension SupabaseClientOptions {
#if !os(Linux)
public init(
db: DatabaseOptions = .init(),
global: GlobalOptions = .init()
global: GlobalOptions = .init(),
functions: FunctionsOptions = .init()
) {
self.db = db
auth = .init()
self.global = global
self.functions = functions
}
#endif
}
Expand Down
47 changes: 47 additions & 0 deletions Tests/FunctionsTests/FunctionsClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@ final class FunctionsClientTests: XCTestCase {

lazy var sut = FunctionsClient(url: url, headers: ["Apikey": apiKey])

func testInit() async {
let client = FunctionsClient(
url: url,
headers: ["Apikey": apiKey],
region: .saEast1
)
let region = await client.region
XCTAssertEqual(region, "sa-east-1")

let headers = await client.headers
XCTAssertEqual(headers["Apikey"], apiKey)
XCTAssertNotNil(headers["X-Client-Info"])
}

func testInvoke() async throws {
let url = URL(string: "http://localhost:5432/functions/v1/hello_world")!
let _request = ActorIsolated(URLRequest?.none)
Expand Down Expand Up @@ -43,6 +57,39 @@ final class FunctionsClientTests: XCTestCase {
)
}

func testInvokeWithRegionDefinedInClient() async {
let sut = FunctionsClient(url: url, region: .caCentral1) {
let region = $0.value(forHTTPHeaderField: "x-region")
XCTAssertEqual(region, "ca-central-1")

throw CancellationError()
}

let _ = try? await sut.invoke("hello-world")
}

func testInvokeWithRegion() async {
let sut = FunctionsClient(url: url) {
let region = $0.value(forHTTPHeaderField: "x-region")
XCTAssertEqual(region, "ca-central-1")

throw CancellationError()
}

let _ = try? await sut.invoke("hello-world", options: .init(region: .caCentral1))
}

func testInvokeWithoutRegion() async {
let sut = FunctionsClient(url: url) {
let region = $0.value(forHTTPHeaderField: "x-region")
XCTAssertNil(region)

throw CancellationError()
}

let _ = try? await sut.invoke("hello-world")
}

func testInvoke_shouldThrow_URLError_badServerResponse() async {
let sut = FunctionsClient(url: url, headers: ["Apikey": apiKey]) { _ in
throw URLError(.badServerResponse)
Expand Down
9 changes: 8 additions & 1 deletion Tests/SupabaseTests/SupabaseClientTests.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Auth
import XCTest

@testable import Functions
@testable import Supabase

final class AuthLocalStorageMock: AuthLocalStorage {
Expand All @@ -14,7 +15,7 @@ final class AuthLocalStorageMock: AuthLocalStorage {
}

final class SupabaseClientTests: XCTestCase {
func testClientInitialization() {
func testClientInitialization() async {
let customSchema = "custom_schema"
let localStorage = AuthLocalStorageMock()
let customHeaders = ["header_field": "header_value"]
Expand All @@ -28,6 +29,9 @@ final class SupabaseClientTests: XCTestCase {
global: SupabaseClientOptions.GlobalOptions(
headers: customHeaders,
session: .shared
),
functions: SupabaseClientOptions.FunctionsOptions(
region: .apNortheast1
)
)
)
Expand All @@ -50,6 +54,9 @@ final class SupabaseClientTests: XCTestCase {
"Authorization": "Bearer ANON_KEY",
]
)

let region = await client.functions.region
XCTAssertEqual(region, "ap-northeast-1")
}

#if !os(Linux)
Expand Down

0 comments on commit f470874

Please sign in to comment.