Skip to content

Commit 0b44b5a

Browse files
authored
docs(examples): add Edge Functions example (#1632)
## What Adds an `edge_functions` example under `examples/`, following the same structure as `database_crud` and `storage_transforms`. It demonstrates invoking Supabase Edge Functions with `supabase.functions.invoke(...)`: - **JSON body over POST**, reading the JSON response back (`invoke('greet', body: {...})`). - **GET with query parameters** instead of a body (`method: HttpMethod.get, queryParameters: {...}`). - **Custom headers** sent to the function and echoed back (`headers: {...}`). - **Plain text in and out**: a `String` body is sent as `text/plain` and the `text/plain` response arrives as a `String` (`invoke('shout', body: text)`). - **Error handling**: a non-2xx response throws a `FunctionException` whose `details` carry the response body (`invoke('word-count', ...)`). ## How it's structured - All Edge Function access lives in `lib/functions_repository.dart` (`FunctionsRepository`), kept separate from the UI so the calls are easy to read and to drive from tests. Models with `factory fromJson` live in `lib/models.dart`. The Material 3 UI in `lib/main.dart` stays thin. - Three demo functions ship under `examples/supabase/functions/`: `greet`, `shout` and `word-count`. - The Edge Runtime is enabled in `examples/supabase/config.toml`; `supabase start` serves the functions automatically. The demo functions set `verify_jwt = false` so the example can call them with just the publishable key, matching the other examples which run unauthenticated. - The new package is registered in the workspace `pubspec.yaml` and listed in `examples/README.md`. ## Testing - `dart format .` and `flutter analyze` are clean. - `integration_test/functions_test.dart` was run against the local stack (`supabase start`) on web (Chrome 150) via `flutter drive`: **all tests passed**. It covers the repository flow (JSON greeting over POST and GET, plain-text transform, and a 400 validation error surfacing as a `FunctionException`) plus a UI smoke test. A stacked PR (#1633) adds a full end-to-end suite covering the entire `functions.invoke` surface. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added a Flutter example demonstrating Supabase Edge Functions. - Added greeting, plain-text transformation, and word-count function examples. - Added support for POST requests, query parameters, custom headers, and error handling. - Enabled the example to run in the browser and against the local stack. - **Documentation** - Added setup, usage, project structure, and integration testing instructions. - **Tests** - Added end-to-end coverage for function responses, validation errors, and UI interactions. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 64298be commit 0b44b5a

15 files changed

Lines changed: 780 additions & 2 deletions

File tree

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ all sharing a single local Supabase instance.
66
| Example | What it shows |
77
| ------------------------------------------- | ------------------------------------------------ |
88
| [`database_crud`](database_crud) | Database CRUD with PostgREST (`from(...)`) |
9+
| [`edge_functions`](edge_functions) | Invoking Edge Functions (`functions.invoke(...)`)|
910
| [`passkeys`](passkeys) | Passkey (WebAuthn) sign in and management |
1011
| [`storage_transforms`](storage_transforms) | Storage uploads, downloads and image transforms |
1112

examples/edge_functions/.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.dart_tool/
2+
.flutter-plugins
3+
.flutter-plugins-dependencies
4+
build/
5+
pubspec.lock
6+
pubspec_overrides.yaml

examples/edge_functions/README.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Edge Functions
2+
3+
A small app that shows how to call Supabase Edge Functions with
4+
`supabase.functions.invoke(...)`:
5+
6+
- **Invoke with a JSON body** over POST and read the JSON response back
7+
(`invoke('greet', body: {...})`).
8+
- **Invoke over GET** with query parameters instead of a body
9+
(`invoke('greet', method: HttpMethod.get, queryParameters: {...})`).
10+
- **Send custom headers** to the function (`headers: {...}`), echoed back in the
11+
response.
12+
- **Send and receive plain text**: a `String` body is sent as `text/plain` and a
13+
`text/plain` response comes back as a `String` (`invoke('shout', body: text)`).
14+
- **Handle errors**: a non-2xx response throws a `FunctionException` whose
15+
`details` carry the response body (`invoke('word-count', ...)`).
16+
17+
All Edge Function access is in
18+
[`lib/functions_repository.dart`](lib/functions_repository.dart), kept separate
19+
from the UI so the calls are easy to read and to drive from an integration test.
20+
21+
To keep the focus on the Supabase calls, the screen uses plain `setState` rather
22+
than pulling in a state management package. A larger app would typically reach
23+
for a state management solution (for example Riverpod, Bloc or Provider) instead.
24+
25+
The functions live in the shared Supabase config in
26+
[`../supabase`](../supabase): their code is under `functions/` (`greet`, `shout`
27+
and `word-count`), and the Edge Runtime is enabled in `config.toml`
28+
(`[edge_runtime]`). `supabase start` serves every function while the stack is
29+
running. The functions need no database tables or seed data.
30+
31+
The demo functions set `verify_jwt = false` in `config.toml` so the example can
32+
call them with just the publishable (anon) key, matching the other examples,
33+
which run unauthenticated. A real app would leave JWT verification on and read
34+
the caller's user from the request.
35+
36+
## Running
37+
38+
From the `examples` directory, run the launcher and pick `edge_functions`:
39+
40+
```bash
41+
./run.sh
42+
```
43+
44+
Or run it directly against any project (with the functions deployed there):
45+
46+
```bash
47+
flutter run \
48+
--dart-define=SUPABASE_URL=https://YOUR_PROJECT.supabase.co \
49+
--dart-define=SUPABASE_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY
50+
```
51+
52+
## Integration test
53+
54+
[`integration_test/functions_test.dart`](integration_test/functions_test.dart)
55+
is an end-to-end test that runs against the local stack. It drives the flow
56+
through the repository (a JSON greeting over POST and GET, a plain-text
57+
transform, and a validation error that surfaces as a `FunctionException`) and
58+
drives the app widgets to greet through the UI.
59+
60+
With the local stack running, pass the same defines the app uses and run it on a
61+
device (integration tests need one, so `-d macos`, an emulator or a real
62+
device):
63+
64+
```bash
65+
flutter test integration_test/functions_test.dart -d macos \
66+
--dart-define=SUPABASE_URL=http://localhost:54321 \
67+
--dart-define=SUPABASE_PUBLISHABLE_KEY=YOUR_LOCAL_PUBLISHABLE_KEY
68+
```
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
include: package:supabase_lints/analysis_options_flutter.yaml
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import 'package:edge_functions_example/functions_repository.dart';
2+
import 'package:edge_functions_example/main.dart';
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter_test/flutter_test.dart';
5+
import 'package:integration_test/integration_test.dart';
6+
import 'package:supabase_flutter/supabase_flutter.dart';
7+
8+
const supabaseUrl = String.fromEnvironment('SUPABASE_URL');
9+
const supabasePublishableKey = String.fromEnvironment(
10+
'SUPABASE_PUBLISHABLE_KEY',
11+
);
12+
13+
/// End-to-end tests that drive the Edge Functions example against the local
14+
/// stack.
15+
///
16+
/// The first test exercises the core flow through the repository (a JSON
17+
/// greeting over POST and GET, a plain-text transform, and a validation error),
18+
/// asserting on what each function returns. The second drives the app widgets to
19+
/// confirm the greeting card is wired to the function. Edge Functions are
20+
/// stateless, so there is nothing to clean up between runs.
21+
void main() {
22+
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
23+
24+
late FunctionsRepository repository;
25+
26+
setUpAll(() async {
27+
await Supabase.initialize(
28+
url: supabaseUrl,
29+
publishableKey: supabasePublishableKey,
30+
);
31+
repository = FunctionsRepository(Supabase.instance.client);
32+
});
33+
34+
tearDownAll(() async {
35+
await Supabase.instance.dispose();
36+
});
37+
38+
testWidgets('invokes the example functions through the repository', (
39+
tester,
40+
) async {
41+
// POST with a JSON body: the function greets and echoes how it was called.
42+
final posted = await repository.greet(name: 'Ada', excited: true);
43+
expect(posted.message, 'Hello, Ada!!!');
44+
expect(posted.method, 'POST');
45+
expect(posted.source, 'flutter-app');
46+
47+
// GET with a query parameter reaches the same function a different way.
48+
final fetched = await repository.greetViaQuery('Grace');
49+
expect(fetched.message, 'Hello, Grace.');
50+
expect(fetched.method, 'GET');
51+
52+
// A plain-text response comes back as a String.
53+
final shouted = await repository.shout('edge functions');
54+
expect(shouted, 'EDGE FUNCTIONS');
55+
56+
// A JSON response decodes into the model.
57+
final count = await repository.countWords('one two three');
58+
expect(count.words, 3);
59+
expect(count.characters, 13);
60+
61+
// An empty input makes the function respond with a 400, which surfaces as a
62+
// FunctionException carrying the JSON error body.
63+
await expectLater(
64+
repository.countWords(''),
65+
throwsA(
66+
isA<FunctionException>()
67+
.having((error) => error.status, 'status', 400)
68+
.having(
69+
(error) => (error.details as Map)['error'],
70+
'details.error',
71+
isA<String>(),
72+
),
73+
),
74+
);
75+
});
76+
77+
testWidgets('greets through the UI', (tester) async {
78+
await tester.pumpWidget(const EdgeFunctionsExampleApp());
79+
await tester.pumpAndSettle();
80+
81+
// Tapping "Greet (POST)" invokes the function and shows its message.
82+
await tester.tap(find.widgetWithText(FilledButton, 'Greet (POST)'));
83+
await _pumpUntil(tester, find.textContaining('Hello, Ada'));
84+
expect(find.textContaining('via POST'), findsOneWidget);
85+
});
86+
}
87+
88+
/// Pumps frames until [finder] matches at least one widget or [timeout] elapses.
89+
///
90+
/// Invoking a function goes over the network, so the UI can't be settled with
91+
/// `pumpAndSettle`; this polls the widget tree instead.
92+
Future<void> _pumpUntil(
93+
WidgetTester tester,
94+
Finder finder, {
95+
Duration timeout = const Duration(seconds: 20),
96+
}) async {
97+
final deadline = DateTime.now().add(timeout);
98+
while (DateTime.now().isBefore(deadline)) {
99+
await tester.pump(const Duration(milliseconds: 100));
100+
if (finder.evaluate().isNotEmpty) return;
101+
}
102+
fail('Timed out waiting for: $finder');
103+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import 'package:supabase_flutter/supabase_flutter.dart';
2+
3+
import 'models.dart';
4+
5+
/// All Edge Function access for the example lives here, so the UI stays thin and
6+
/// every `supabase.functions.invoke(...)` call is easy to read and to exercise
7+
/// from an integration test.
8+
class FunctionsRepository {
9+
FunctionsRepository(this._client);
10+
11+
final SupabaseClient _client;
12+
13+
FunctionsClient get _functions => _client.functions;
14+
15+
/// Invokes the `greet` function over POST with a JSON body and returns the
16+
/// greeting it builds server-side.
17+
///
18+
/// The custom `x-greeting-source` header is echoed back in the response, so
19+
/// the example can show that headers set here reach the function. A JSON body
20+
/// comes back decoded as a `Map`, which [Greeting.fromJson] turns into a model.
21+
Future<Greeting> greet({required String name, bool excited = false}) async {
22+
final response = await _functions.invoke(
23+
'greet',
24+
body: {'name': name, 'excited': excited},
25+
headers: const {'x-greeting-source': 'flutter-app'},
26+
);
27+
return Greeting.fromJson(response.data as Map<String, dynamic>);
28+
}
29+
30+
/// Invokes the same `greet` function over GET, passing the name as a query
31+
/// parameter instead of a body.
32+
Future<Greeting> greetViaQuery(String name) async {
33+
final response = await _functions.invoke(
34+
'greet',
35+
method: HttpMethod.get,
36+
queryParameters: {'name': name},
37+
);
38+
return Greeting.fromJson(response.data as Map<String, dynamic>);
39+
}
40+
41+
/// Sends [text] to the `shout` function and returns the uppercased text it
42+
/// responds with.
43+
///
44+
/// A `String` body is sent as `text/plain`, and the function replies with
45+
/// `text/plain` too, so `response.data` is a `String` rather than decoded JSON.
46+
Future<String> shout(String text) async {
47+
final response = await _functions.invoke('shout', body: text);
48+
return response.data as String;
49+
}
50+
51+
/// Invokes the `word-count` function, which validates its input.
52+
///
53+
/// When [text] is empty the function replies with a 400 and a JSON error body,
54+
/// which surfaces here as a [FunctionException] whose `details` hold that body.
55+
/// The caller is expected to handle that exception.
56+
Future<WordCount> countWords(String text) async {
57+
final response = await _functions.invoke(
58+
'word-count',
59+
body: {'text': text},
60+
);
61+
return WordCount.fromJson(response.data as Map<String, dynamic>);
62+
}
63+
}

0 commit comments

Comments
 (0)