Skip to content

feat: add functions support #13

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

Merged
merged 5 commits into from
Mar 18, 2025
Merged
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
134 changes: 134 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,140 @@ void main() {
}
```

### Mocking Edge Functions

You can easily mock edge functions using the `registerEdgeFunction` method of `MockSupabaseHttpClient`. This method allows you to specify a handler function, giving you fine-grained control over the response based on the request body, HTTP method, and query parameters. You even have access to the mock database.

```dart
group('Edge Functions Client', () {
test('invoke registered edge function with POST', () async {
mockHttpClient.registerEdgeFunction('greet',
(body, queryParams, method, tables) {
return FunctionResponse(
data: {'message': 'Hello, ${body['name']}!'},
status: 200,
);
});

final response = await mockSupabase.functions.invoke(
'greet',
body: {'name': 'Alice'},
);

expect(response.status, 200);
expect(response.data, {'message': 'Hello, Alice!'});
});

test('invoke edge function with different HTTP methods', () async {
mockHttpClient.registerEdgeFunction('say-hello',
(body, queryParams, method, tables) {
final name = switch (method) {
HttpMethod.patch => 'Linda',
HttpMethod.post => 'Bob',
_ => 'Unknown'
};
return FunctionResponse(
data: {'hello': name},
status: 200,
);
});

var patchResponse = await mockSupabase.functions
.invoke('say-hello', method: HttpMethod.patch);
expect(patchResponse.data, {'hello': 'Linda'});

var postResponse = await mockSupabase.functions
.invoke('say-hello', method: HttpMethod.post);
expect(postResponse.data, {'hello': 'Bob'});
});

test('edge function receives query params and body', () async {
mockHttpClient.registerEdgeFunction('params-test',
(body, queryParams, method, tables) {
final city = queryParams['city'];
final street = body['street'] as String;
return FunctionResponse(
data: {'address': '$street, $city'},
status: 200,
);
});

final response = await mockSupabase.functions.invoke(
'params-test',
body: {'street': '123 Main St'},
queryParameters: {'city': 'Springfield'},
);

expect(response.data, {'address': '123 Main St, Springfield'});
});

test('edge function returns different content types', () async {
mockHttpClient.registerEdgeFunction('binary',
(body, queryParams, method, tables) {
return FunctionResponse(
data: Uint8List.fromList([1, 2, 3]),
status: 200,
);
});

var response = await mockSupabase.functions.invoke('binary');
expect(response.data is Uint8List, true);
expect((response.data as Uint8List).length, 3);

mockHttpClient.registerEdgeFunction('text',
(body, queryParams, method, tables) {
return FunctionResponse(
data: 'Hello, world!',
status: 200,
);
});

response = await mockSupabase.functions.invoke('text');
expect(response.data, 'Hello, world!');

mockHttpClient.registerEdgeFunction('json',
(body, queryParams, method, tables) {
return FunctionResponse(
data: {'key': 'value'},
status: 200,
);
});

response = await mockSupabase.functions.invoke('json');
expect(response.data, {'key': 'value'});
});

test('edge function modifies mock database', () async {
mockHttpClient.registerEdgeFunction('add-user',
(body, queryParams, method, tables) {
final users = tables['public.users'] ?? [];
final newUser = {
'id': users.length + 1,
'name': body['name'],
};
users.add(newUser);
tables['public.users'] = users;
return FunctionResponse(data: newUser, status: 201);
});

var users = await mockSupabase.from('users').select();
expect(users, isEmpty);

final response = await mockSupabase.functions.invoke(
'add-user',
body: {'name': 'Alice'},
);
expect(response.status, 201);
expect(response.data, {'id': 1, 'name': 'Alice'});

users = await mockSupabase.from('users').select();
expect(users, [
{'id': 1, 'name': 'Alice'}
]);
});
});
```

### Mocking Errors

You can simulate error scenarios by configuring an error trigger callback. This is useful for testing how your application handles various error conditions:
Expand Down
151 changes: 150 additions & 1 deletion lib/src/mock_supabase_http_client.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:typed_data';

import 'package:http/http.dart';
import 'package:supabase/supabase.dart';
Expand Down Expand Up @@ -50,6 +51,20 @@ import 'utils/filter_parser.dart';
/// }
/// ```
///
/// You can mock edge functions using the [registerEdgeFunction] callback:
/// ```dart
/// final client = MockSupabaseHttpClient();
/// client.registerEdgeFunction(
/// 'get_user_status',
/// (body, queryParameters, method, tables) {
/// return FunctionResponse(
/// data: {'status': 'active'},
/// status: 200,
/// );
///
/// final response = await supabaseClient.functions.invoke('get_user_status');
/// ```
///
/// You can simulate errors using the [postgrestExceptionTrigger] callback:
/// ```dart
/// final client = MockSupabaseHttpClient(
Expand All @@ -76,6 +91,14 @@ import 'utils/filter_parser.dart';
/// {@endtemplate}
class MockSupabaseHttpClient extends BaseClient {
final Map<String, List<Map<String, dynamic>>> _database = {};
final Map<
String,
FunctionResponse Function(
Map<String, dynamic> body,
Map<String, String> queryParameters,
HttpMethod method,
Map<String, List<Map<String, dynamic>>> tables,
)> _edgeFunctions = {};
final Map<
String,
dynamic Function(Map<String, dynamic>? params,
Expand Down Expand Up @@ -122,6 +145,7 @@ class MockSupabaseHttpClient extends BaseClient {
// Clear the mock database and RPC functions
_database.clear();
_rpcHandler.reset();
_edgeFunctions.clear();
}

/// Registers a RPC function that can be called using the `rpc` method on a `Postgrest` client.
Expand Down Expand Up @@ -206,6 +230,19 @@ class MockSupabaseHttpClient extends BaseClient {

@override
Future<StreamedResponse> send(BaseRequest request) async {
final functionName = _extractFunctionName(request.url);
if (functionName != null) {
if (_edgeFunctions.containsKey(functionName)) {
return _handleFunctionInvocation(functionName, request);
} else {
return _createResponse(
{'error': 'Function $functionName not found'},
statusCode: 404,
request: request,
);
}
}

// Decode the request body if it's not a GET, DELETE, or HEAD request
dynamic body;
if (request.method != 'GET' &&
Expand Down Expand Up @@ -780,13 +817,125 @@ class MockSupabaseHttpClient extends BaseClient {
'content-type': 'application/json; charset=utf-8',
...?headers,
};
Stream<List<int>> stream;
if (data is Uint8List) {
stream = Stream.value(data);
responseHeaders['content-type'] = _getContentType(data);
} else if (data is String) {
stream = Stream.value(utf8.encode(data));
responseHeaders['content-type'] = _getContentType(data);
} else {
final jsonData = jsonEncode(data);
stream = Stream.value(utf8.encode(jsonData));
}

return StreamedResponse(
Stream.value(utf8.encode(data is String ? data : jsonEncode(data))),
stream,
statusCode,
headers: responseHeaders,
request: request,
);
}

/// Registers an edge function with the given name and handler.
///
/// The [name] parameter specifies the name of the edge function.
///
/// The [handler] parameter is a function that takes the following parameters:
/// - [body]: A map containing the body of the request.
/// - [queryParameters]: A map containing the query parameters of the request.
/// - [method]: The HTTP method of the request.
/// - [tables]: A map containing lists of maps representing the tables involved in the request.
///
/// The [handler] function should return a [FunctionResponse].
void registerEdgeFunction(
String name,
FunctionResponse Function(
Map<String, dynamic> body,
Map<String, String> queryParameters,
HttpMethod method,
Map<String, List<Map<String, dynamic>>> tables,
) handler,
) {
_edgeFunctions[name] = handler;
}

String? _extractFunctionName(Uri url) {
final pathSegments = url.pathSegments;
// Handle functions endpoint: /functions/v1/{function_name}
if (pathSegments.length >= 3 &&
pathSegments[0] == 'functions' &&
pathSegments[1] == 'v1') {
return pathSegments[2];
}
return null;
}

Future<StreamedResponse> _handleFunctionInvocation(
String functionName,
BaseRequest request,
) async {
if (!_edgeFunctions.containsKey(functionName)) {
return _createResponse(
{'error': 'Edge function $functionName not found'},
statusCode: 404,
request: request,
);
}

// Parse request data
final tables = _database;
final body = await _parseRequestBody(request);
final queryParams = request.url.queryParameters;
final method = _parseMethod(request.method);

// Call handler
final response = _edgeFunctions[functionName]!(
body ?? {},
queryParams,
method,
tables,
);

return _createResponse(
response.data,
statusCode: response.status,
request: request,
headers: {
'content-type': _getContentType(response.data),
},
);
}

Future<dynamic> _parseRequestBody(BaseRequest request) async {
if (request is! Request) return null;
final content = await request.finalize().transform(utf8.decoder).join();
return content.isEmpty ? null : jsonDecode(content);
}

HttpMethod _parseMethod(String method) {
switch (method.toUpperCase()) {
case 'GET':
return HttpMethod.get;
case 'POST':
return HttpMethod.post;
case 'PATCH':
return HttpMethod.patch;
case 'DELETE':
return HttpMethod.delete;
case 'PUT':
return HttpMethod.put;
default:
return HttpMethod.get;
}
}

String _getContentType(dynamic data) {
if (data is Uint8List) return 'application/octet-stream';
if (data is String) return 'text/plain';
if (data is Stream<List<int>>) return 'text/event-stream';
return 'application/json';
}
}

/// Represents the different types of HTTP requests that can be made to the Supabase API
Expand Down
Loading