Skip to content

Commit d51bde3

Browse files
committed
test: add integration tests for functions and fix bug with any function region
1 parent 33a88bf commit d51bde3

File tree

11 files changed

+308
-18
lines changed

11 files changed

+308
-18
lines changed

Sources/Functions/FunctionsClient.swift

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import ConcurrencyExtras
22
import Foundation
33
import HTTPTypes
44
import HTTPTypesFoundation
5+
import Logging
56
import OpenAPIURLSession
67

78
#if canImport(FoundationNetworking)
@@ -45,6 +46,20 @@ struct FetchTransportAdapter: ClientTransport {
4546
}
4647
}
4748

49+
extension URL {
50+
/// Returns a new URL which contains only `{scheme}://{host}:{port}`.
51+
fileprivate var baseURL: URL {
52+
guard let components = URLComponents(string: self.absoluteString) else { return self }
53+
54+
var newComponents = URLComponents()
55+
newComponents.scheme = components.scheme
56+
newComponents.host = components.host
57+
newComponents.port = components.port
58+
59+
return newComponents.url ?? self
60+
}
61+
}
62+
4863
/// An actor representing a client for invoking functions.
4964
public final class FunctionsClient: Sendable {
5065
/// Fetch handler used to make requests.
@@ -83,7 +98,11 @@ public final class FunctionsClient: Sendable {
8398
/// - region: The Region to invoke the functions in.
8499
/// - logger: SupabaseLogger instance to use.
85100
/// - fetch: The fetch handler used to make requests. (Default: URLSession.shared.data(for:))
86-
@available(*, deprecated, message: "Fetch handler is deprecated, use init with `transport` instead.")
101+
@available(
102+
*,
103+
deprecated,
104+
message: "Fetch handler is deprecated, use init with `transport` instead."
105+
)
87106
@_disfavoredOverload
88107
public convenience init(
89108
url: URL,
@@ -114,7 +133,11 @@ public final class FunctionsClient: Sendable {
114133
headers: headers,
115134
region: region,
116135
logger: logger,
117-
client: Client(serverURL: url, transport: transport ?? URLSessionTransport())
136+
client: Client(
137+
serverURL: url.baseURL,
138+
transport: transport ?? URLSessionTransport(),
139+
middlewares: [LoggingMiddleware(logger: Logger(label: "functions"))]
140+
)
118141
)
119142
}
120143

@@ -146,7 +169,11 @@ public final class FunctionsClient: Sendable {
146169
/// - logger: SupabaseLogger instance to use.
147170
/// - fetch: The fetch handler used to make requests. (Default: URLSession.shared.data(for:))
148171

149-
@available(*, deprecated, message: "Fetch handler is deprecated, use init with `transport` instead.")
172+
@available(
173+
*,
174+
deprecated,
175+
message: "Fetch handler is deprecated, use init with `transport` instead."
176+
)
150177
public convenience init(
151178
url: URL,
152179
headers: [String: String] = [:],
@@ -292,18 +319,27 @@ public final class FunctionsClient: Sendable {
292319
functionName: String,
293320
options: FunctionInvokeOptions
294321
) -> (HTTPTypes.HTTPRequest, HTTPBody?) {
295-
var request = HTTPTypes.HTTPRequest(
296-
method: FunctionInvokeOptions.httpMethod(options.method) ?? .post,
297-
url: url.appendingPathComponent(functionName).appendingQueryItems(options.query),
298-
headerFields: mutableState.headers.merging(with: options.headers)
299-
)
322+
var region = options.region
323+
var queryItems = options.query
324+
var headers = options.headers
300325

301326
// TODO: Check how to assign FunctionsClient.requestIdleTimeout
302327

303-
if let region = options.region ?? region {
304-
request.headerFields[.xRegion] = region
328+
if region == nil {
329+
region = self.region
330+
}
331+
332+
if let region, region != "any" {
333+
headers[.xRegion] = region
334+
queryItems.append(URLQueryItem(name: "forceFunctionRegion", value: region))
305335
}
306336

337+
let request = HTTPTypes.HTTPRequest(
338+
method: FunctionInvokeOptions.httpMethod(options.method) ?? .post,
339+
url: url.appendingPathComponent(functionName).appendingQueryItems(queryItems),
340+
headerFields: mutableState.headers.merging(with: headers)
341+
)
342+
307343
let body = options.body.map(HTTPBody.init)
308344

309345
return (request, body)

Sources/Functions/Types.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ public enum FunctionRegion: String, Sendable {
131131
case usEast1 = "us-east-1"
132132
case usWest1 = "us-west-1"
133133
case usWest2 = "us-west-2"
134+
case any = "any"
134135
}
135136

136137
extension FunctionInvokeOptions {

Sources/Helpers/HTTP/Exports.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
@_exported import HTTPTypes
2-
@_exported import protocol OpenAPIRuntime.ClientTransport
3-
@_exported import class OpenAPIRuntime.HTTPBody
2+
3+
import protocol OpenAPIRuntime.ClientTransport
4+
import class OpenAPIRuntime.HTTPBody
5+
6+
public typealias ClientTransport = OpenAPIRuntime.ClientTransport
7+
public typealias HTTPBody = OpenAPIRuntime.HTTPBody

Sources/Helpers/HTTP/LoggingMiddleware.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Logging
22
import OpenAPIRuntime
3+
import HTTPTypesFoundation
34

45
#if canImport(Darwin)
56
import struct Foundation.URL
@@ -9,14 +10,14 @@ import OpenAPIRuntime
910
@preconcurrency import struct Foundation.UUID
1011
#endif
1112

12-
struct LoggingMiddleware: ClientMiddleware {
13+
package struct LoggingMiddleware: ClientMiddleware {
1314
let logger: Logger
1415

15-
init(logger: Logger) {
16+
package init(logger: Logger) {
1617
self.logger = logger
1718
}
1819

19-
func intercept(
20+
package func intercept(
2021
_ request: HTTPTypes.HTTPRequest,
2122
body: HTTPBody?,
2223
baseURL: URL,
@@ -46,7 +47,7 @@ extension HTTPFields {
4647

4748
extension HTTPTypes.HTTPRequest {
4849
fileprivate var prettyDescription: String {
49-
"\(method.rawValue) \(path ?? "<nil>") [\(headerFields.prettyDescription)]"
50+
"\(method.rawValue) \(self.url?.absoluteString ?? "<nil>") [\(headerFields.prettyDescription)]"
5051
}
5152
}
5253

Tests/IntegrationTests/DotEnv.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
enum DotEnv {
2-
static let SUPABASE_URL = "http://localhost:54321"
2+
static let SUPABASE_URL = "http://127.0.0.1:54321"
33
static let SUPABASE_ANON_KEY =
44
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0"
55
static let SUPABASE_SERVICE_ROLE_KEY =
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
//
2+
// FunctionsIntegrationTests.swift
3+
// Supabase
4+
//
5+
// Created by Guilherme Souza on 04/08/25.
6+
//
7+
8+
import Supabase
9+
import XCTest
10+
11+
final class FunctionsIntegrationTests: XCTestCase {
12+
let client = SupabaseClient(
13+
supabaseURL: URL(string: DotEnv.SUPABASE_URL) ?? URL(string: "http://127.0.0.1:54321")!,
14+
supabaseKey: DotEnv.SUPABASE_ANON_KEY
15+
)
16+
17+
func testInvokeMirror() async throws {
18+
let response: MirrorResponse = try await client.functions.invoke("mirror")
19+
XCTAssertTrue(response.url.contains("/mirror"))
20+
XCTAssertEqual(response.method, "POST")
21+
}
22+
23+
func testInvokeMirrorWithClientHeader() async throws {
24+
let client = FunctionsClient(
25+
url: URL(string: "\(DotEnv.SUPABASE_URL)/functions/v1")!,
26+
headers: [
27+
"Authorization": "Bearer \(DotEnv.SUPABASE_ANON_KEY)",
28+
"CustomHeader": "check me",
29+
]
30+
)
31+
let response: MirrorResponse = try await client.invoke("mirror")
32+
XCTAssertEqual(response.headersDictionary["customheader"], "check me")
33+
}
34+
35+
func testInvokeMirrorWithInvokeHeader() async throws {
36+
let response: MirrorResponse = try await client.functions.invoke(
37+
"mirror",
38+
options: FunctionInvokeOptions(headers: ["Custom-Header": "check me"])
39+
)
40+
XCTAssertEqual(response.headersDictionary["custom-header"], "check me")
41+
}
42+
43+
func testInvokeMirrorSetValidRegionOnRequest() async throws {
44+
let response: MirrorResponse = try await client.functions.invoke(
45+
"mirror",
46+
options: FunctionInvokeOptions(region: .apNortheast1)
47+
)
48+
XCTAssertEqual(response.headersDictionary["x-region"], "ap-northeast-1")
49+
XCTAssertTrue(response.url.contains("forceFunctionRegion=ap-northeast-1"))
50+
}
51+
52+
func testInvokeWithRegionOverridesRegionInTheClinet() async throws {
53+
let client = FunctionsClient(
54+
url: URL(string: "\(DotEnv.SUPABASE_URL)/functions/v1")!,
55+
headers: [
56+
"Authorization": "Bearer \(DotEnv.SUPABASE_ANON_KEY)",
57+
"CustomHeader": "check me",
58+
],
59+
region: .apNortheast1
60+
)
61+
let response: MirrorResponse = try await client.invoke(
62+
"mirror",
63+
options: FunctionInvokeOptions(region: .apSoutheast1)
64+
)
65+
XCTAssertEqual(response.headersDictionary["x-region"], "ap-southeast-1")
66+
XCTAssertTrue(response.url.contains("forceFunctionRegion=ap-southeast-1"))
67+
}
68+
69+
func testStartClientWithDefaultRegionInvokeRevertsToAny() async throws {
70+
let client = FunctionsClient(
71+
url: URL(string: "\(DotEnv.SUPABASE_URL)/functions/v1")!,
72+
headers: [
73+
"Authorization": "Bearer \(DotEnv.SUPABASE_ANON_KEY)",
74+
"CustomHeader": "check me",
75+
],
76+
region: .apSoutheast1
77+
)
78+
let response: MirrorResponse = try await client.invoke(
79+
"mirror",
80+
options: FunctionInvokeOptions(region: .any)
81+
)
82+
XCTAssertNil(response.headersDictionary["x-region"])
83+
}
84+
85+
func testInvokeRegionSetOnlyOnTheConstructor() async throws {
86+
let client = FunctionsClient(
87+
url: URL(string: "\(DotEnv.SUPABASE_URL)/functions/v1")!,
88+
headers: [
89+
"Authorization": "Bearer \(DotEnv.SUPABASE_ANON_KEY)",
90+
"CustomHeader": "check me",
91+
],
92+
region: .apSoutheast1
93+
)
94+
let response: MirrorResponse = try await client.invoke("mirror")
95+
XCTAssertEqual(response.headersDictionary["x-region"], "ap-southeast-1")
96+
}
97+
98+
func testInvokeMirrorWithBodyFormData() async throws {
99+
throw XCTSkip("Unsupported body type.")
100+
}
101+
102+
func testInvokeMirrowWithEncodableBody() async throws {
103+
let body = Body(one: "one", two: "two", three: "three", num: 11, flag: false)
104+
let response: MirrorResponse = try await client.functions.invoke(
105+
"mirror",
106+
options: FunctionInvokeOptions(
107+
headers: [
108+
"response-type": "json"
109+
],
110+
body: body
111+
)
112+
)
113+
let responseBody = try response.body.decode(as: Body.self, decoder: JSONDecoder())
114+
XCTAssertEqual(responseBody, body)
115+
116+
XCTAssertEqual(response.headersDictionary["content-type"], "application/json")
117+
XCTAssertEqual(response.headersDictionary["response-type"], "json")
118+
}
119+
120+
func testInvokeMirrowWithDataBody() async throws {
121+
let body = Body(one: "one", two: "two", three: "three", num: 11, flag: false)
122+
123+
let response: MirrorResponse = try await client.functions.invoke(
124+
"mirror",
125+
options: FunctionInvokeOptions(
126+
headers: [
127+
"response-type": "blob"
128+
],
129+
body: try JSONEncoder().encode(body)
130+
)
131+
)
132+
133+
guard let responseBodyData = response.body.stringValue?.data(using: .utf8),
134+
let responseBody = try? JSONDecoder().decode(Body.self, from: responseBodyData)
135+
else {
136+
XCTFail("Expected to receive body response as JSON string.")
137+
return
138+
}
139+
140+
XCTAssertEqual(responseBody, body)
141+
142+
XCTAssertEqual(response.headersDictionary["content-type"], "application/octet-stream")
143+
XCTAssertEqual(response.headersDictionary["response-type"], "blob")
144+
}
145+
}
146+
147+
struct MirrorResponse: Decodable {
148+
let url: String
149+
let method: String
150+
let headers: AnyJSON
151+
let body: AnyJSON
152+
153+
var headersDictionary: [String: String] {
154+
Dictionary(
155+
uniqueKeysWithValues: headers.arrayValue?.compactMap {
156+
$0.arrayValue?.compactMap(\.stringValue) ?? []
157+
}.map { ($0[0], $0[1]) } ?? []
158+
)
159+
}
160+
}
161+
struct Body: Codable, Equatable {
162+
let one, two, three: String
163+
let num: Int
164+
let flag: Bool
165+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v2.22.12
1+
v2.33.9

Tests/IntegrationTests/supabase/config.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,3 +306,14 @@ s3_region = "env(S3_REGION)"
306306
s3_access_key = "env(S3_ACCESS_KEY)"
307307
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
308308
s3_secret_key = "env(S3_SECRET_KEY)"
309+
310+
[functions.mirror]
311+
enabled = true
312+
verify_jwt = true
313+
import_map = "./functions/mirror/deno.json"
314+
# Uncomment to specify a custom file path to the entrypoint.
315+
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
316+
entrypoint = "./functions/mirror/index.ts"
317+
# Specifies static files to be bundled with the function. Supports glob patterns.
318+
# For example, if you want to serve static HTML pages in your function:
319+
# static_files = [ "./functions/mirror/*.html" ]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Configuration for private npm package dependencies
2+
# For more information on using private registries with Edge Functions, see:
3+
# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"imports": {}
3+
}

0 commit comments

Comments
 (0)