diff --git a/packages/dart_firebase_admin/.firebaserc b/packages/dart_firebase_admin/.firebaserc new file mode 100644 index 00000000..23fc90b9 --- /dev/null +++ b/packages/dart_firebase_admin/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "dart-firebase-admin" + } +} diff --git a/packages/dart_firebase_admin/.gitignore b/packages/dart_firebase_admin/.gitignore index a34fcceb..d524d600 100644 --- a/packages/dart_firebase_admin/.gitignore +++ b/packages/dart_firebase_admin/.gitignore @@ -1 +1,6 @@ -service-account-key.json \ No newline at end of file +service-account-key.json + +# Test functions artifacts +test/functions/node_modules/ +test/functions/lib/ +test/functions/package-lock.json diff --git a/packages/dart_firebase_admin/example/example_functions_ts/.eslintrc.js b/packages/dart_firebase_admin/example/example_functions_ts/.eslintrc.js new file mode 100644 index 00000000..0f8e2a9b --- /dev/null +++ b/packages/dart_firebase_admin/example/example_functions_ts/.eslintrc.js @@ -0,0 +1,33 @@ +module.exports = { + root: true, + env: { + es6: true, + node: true, + }, + extends: [ + "eslint:recommended", + "plugin:import/errors", + "plugin:import/warnings", + "plugin:import/typescript", + "google", + "plugin:@typescript-eslint/recommended", + ], + parser: "@typescript-eslint/parser", + parserOptions: { + project: ["tsconfig.json", "tsconfig.dev.json"], + sourceType: "module", + }, + ignorePatterns: [ + "/lib/**/*", // Ignore built files. + "/generated/**/*", // Ignore generated files. + ], + plugins: [ + "@typescript-eslint", + "import", + ], + rules: { + "quotes": ["error", "double"], + "import/no-unresolved": 0, + "indent": ["error", 2], + }, +}; diff --git a/packages/dart_firebase_admin/example/example_functions_ts/.gitignore b/packages/dart_firebase_admin/example/example_functions_ts/.gitignore new file mode 100644 index 00000000..961917a2 --- /dev/null +++ b/packages/dart_firebase_admin/example/example_functions_ts/.gitignore @@ -0,0 +1,11 @@ +# Compiled JavaScript files +lib/**/*.js +lib/**/*.js.map + +# TypeScript v1 declaration files +typings/ + +# Node.js dependency directory +node_modules/ +*.local +package-lock.json diff --git a/packages/dart_firebase_admin/example/example_functions_ts/package.json b/packages/dart_firebase_admin/example/example_functions_ts/package.json new file mode 100644 index 00000000..d7788081 --- /dev/null +++ b/packages/dart_firebase_admin/example/example_functions_ts/package.json @@ -0,0 +1,31 @@ +{ + "name": "functions", + "scripts": { + "lint": "eslint --ext .js,.ts .", + "build": "tsc", + "build:watch": "tsc --watch", + "serve": "npm run build && firebase emulators:start --only functions", + "shell": "npm run build && firebase functions:shell", + "start": "npm run shell", + "deploy": "firebase deploy --only functions", + "logs": "firebase functions:log" + }, + "engines": { + "node": "22" + }, + "main": "lib/index.js", + "dependencies": { + "firebase-admin": "^12.6.0", + "firebase-functions": "^6.0.1" + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^5.12.0", + "@typescript-eslint/parser": "^5.12.0", + "eslint": "^8.9.0", + "eslint-config-google": "^0.14.0", + "eslint-plugin-import": "^2.25.4", + "firebase-functions-test": "^3.1.0", + "typescript": "^4.9.0" + }, + "private": true +} diff --git a/packages/dart_firebase_admin/example/example_functions_ts/src/index.ts b/packages/dart_firebase_admin/example/example_functions_ts/src/index.ts new file mode 100644 index 00000000..46c66104 --- /dev/null +++ b/packages/dart_firebase_admin/example/example_functions_ts/src/index.ts @@ -0,0 +1,28 @@ +/** + * Import function triggers from their respective submodules: + * + * import {onCall} from "firebase-functions/v2/https"; + * import {onDocumentWritten} from "firebase-functions/v2/firestore"; + * + * See a full list of supported triggers at https://firebase.google.com/docs/functions + */ + +import {onTaskDispatched} from "firebase-functions/v2/tasks"; + +// Start writing functions +// https://firebase.google.com/docs/functions/typescript + +export const helloWorld = onTaskDispatched( + { + retryConfig: { + maxAttempts: 5, + minBackoffSeconds: 60, + }, + rateLimits: { + maxConcurrentDispatches: 6, + }, + }, + async (req) => { + console.log("Task received:", req.data); + } +); diff --git a/packages/dart_firebase_admin/example/example_functions_ts/tsconfig.dev.json b/packages/dart_firebase_admin/example/example_functions_ts/tsconfig.dev.json new file mode 100644 index 00000000..7560eed4 --- /dev/null +++ b/packages/dart_firebase_admin/example/example_functions_ts/tsconfig.dev.json @@ -0,0 +1,5 @@ +{ + "include": [ + ".eslintrc.js" + ] +} diff --git a/packages/dart_firebase_admin/example/example_functions_ts/tsconfig.json b/packages/dart_firebase_admin/example/example_functions_ts/tsconfig.json new file mode 100644 index 00000000..57b915f3 --- /dev/null +++ b/packages/dart_firebase_admin/example/example_functions_ts/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "esModuleInterop": true, + "moduleResolution": "nodenext", + "noImplicitReturns": true, + "noUnusedLocals": true, + "outDir": "lib", + "sourceMap": true, + "strict": true, + "target": "es2017" + }, + "compileOnSave": true, + "include": [ + "src" + ] +} diff --git a/packages/dart_firebase_admin/example/lib/main.dart b/packages/dart_firebase_admin/example/lib/main.dart index 1aa1aa41..8ddfc29c 100644 --- a/packages/dart_firebase_admin/example/lib/main.dart +++ b/packages/dart_firebase_admin/example/lib/main.dart @@ -1,13 +1,20 @@ import 'package:dart_firebase_admin/auth.dart'; import 'package:dart_firebase_admin/dart_firebase_admin.dart'; import 'package:dart_firebase_admin/firestore.dart'; +import 'package:dart_firebase_admin/functions.dart'; import 'package:dart_firebase_admin/messaging.dart'; Future main() async { final admin = FirebaseApp.initializeApp(); - await authExample(admin); - await firestoreExample(admin); - await projectConfigExample(admin); + + // Uncomment to run auth example + // await authExample(admin); + + // Uncomment to run firestore example + // await firestoreExample(admin); + + // Uncomment to run project config example + // await projectConfigExample(admin); // Uncomment to run tenant example (requires Identity Platform upgrade) // await tenantExample(admin); @@ -15,9 +22,13 @@ Future main() async { // Uncomment to run messaging example (requires valid fcm token) // await messagingExample(admin); + // Uncomment to run functions example + // await functionsExample(admin); + await admin.close(); } +// ignore: unreachable_from_main Future authExample(FirebaseApp admin) async { print('\n### Auth Example ###\n'); @@ -47,6 +58,7 @@ Future authExample(FirebaseApp admin) async { } } +// ignore: unreachable_from_main Future firestoreExample(FirebaseApp admin) async { print('\n### Firestore Example ###\n'); @@ -64,6 +76,7 @@ Future firestoreExample(FirebaseApp admin) async { } } +// ignore: unreachable_from_main Future projectConfigExample(FirebaseApp admin) async { print('\n### Project Config Example ###\n'); @@ -379,3 +392,83 @@ Future messagingExample(FirebaseApp admin) async { print('> Error sending platform-specific message: $e'); } } + +/// Functions example prerequisites: +/// 1) Run `npm run build` in `example_functions_ts` to generate `index.js`. +/// 2) From the example directory root (with `firebase.json` and `.firebaserc`), +/// start emulators with `firebase emulators:start`. +/// 3) Run `dart_firebase_admin/packages/dart_firebase_admin/example/run_with_emulator.sh`. +// ignore: unreachable_from_main +Future functionsExample(FirebaseApp admin) async { + print('\n### Functions Example ###\n'); + + final functions = Functions(admin); + + // Get a task queue reference + // The function name should match an existing Cloud Function or queue name + final taskQueue = functions.taskQueue('helloWorld'); + + // Example 1: Enqueue a simple task + try { + print('> Enqueuing a simple task...\n'); + await taskQueue.enqueue({ + 'userId': 'user-123', + 'action': 'sendWelcomeEmail', + 'timestamp': DateTime.now().toIso8601String(), + }); + print('Task enqueued successfully!\n'); + } on FirebaseFunctionsAdminException catch (e) { + print('> Functions error: ${e.code} - ${e.message}\n'); + } catch (e) { + print('> Error enqueuing task: $e\n'); + } + + // Example 2: Enqueue with delay (1 hour from now) + try { + print('> Enqueuing a delayed task...\n'); + await taskQueue.enqueue( + {'action': 'cleanupTempFiles'}, + TaskOptions(schedule: DelayDelivery(3600)), // 1 hour delay + ); + print('Delayed task enqueued successfully!\n'); + } on FirebaseFunctionsAdminException catch (e) { + print('> Functions error: ${e.code} - ${e.message}\n'); + } + + // Example 3: Enqueue at specific time + try { + print('> Enqueuing a scheduled task...\n'); + final scheduledTime = DateTime.now().add(const Duration(minutes: 30)); + await taskQueue.enqueue({ + 'action': 'sendReport', + }, TaskOptions(schedule: AbsoluteDelivery(scheduledTime))); + print('Scheduled task enqueued for: $scheduledTime\n'); + } on FirebaseFunctionsAdminException catch (e) { + print('> Functions error: ${e.code} - ${e.message}\n'); + } + + // Example 4: Enqueue with custom task ID (for deduplication) + try { + print('> Enqueuing a task with custom ID...\n'); + await taskQueue.enqueue({ + 'orderId': 'order-456', + 'action': 'processPayment', + }, TaskOptions(id: 'payment-order-456')); + print('Task with custom ID enqueued!\n'); + } on FirebaseFunctionsAdminException catch (e) { + if (e.errorCode == FunctionsClientErrorCode.taskAlreadyExists) { + print('> Task with this ID already exists (deduplication)\n'); + } else { + print('> Functions error: ${e.code} - ${e.message}\n'); + } + } + + // Example 5: Delete a task + try { + print('> Deleting task...\n'); + await taskQueue.delete('payment-order-456'); + print('Task deleted successfully!\n'); + } on FirebaseFunctionsAdminException catch (e) { + print('> Functions error: ${e.code} - ${e.message}\n'); + } +} diff --git a/packages/dart_firebase_admin/example/run_with_emulator.sh b/packages/dart_firebase_admin/example/run_with_emulator.sh index ebce7453..d9f0fbca 100755 --- a/packages/dart_firebase_admin/example/run_with_emulator.sh +++ b/packages/dart_firebase_admin/example/run_with_emulator.sh @@ -3,6 +3,7 @@ # Set environment variables for emulator export FIRESTORE_EMULATOR_HOST=localhost:8080 export FIREBASE_AUTH_EMULATOR_HOST=localhost:9099 +export CLOUD_TASKS_EMULATOR_HOST=localhost:9499 export GOOGLE_CLOUD_PROJECT=dart-firebase-admin # Run the example diff --git a/packages/dart_firebase_admin/firebase.json b/packages/dart_firebase_admin/firebase.json new file mode 100644 index 00000000..8d50eaf1 --- /dev/null +++ b/packages/dart_firebase_admin/firebase.json @@ -0,0 +1,32 @@ +{ + "emulators": { + "auth": { + "port": 9099 + }, + "firestore": { + "port": 8080 + }, + "functions": { + "port": 5001 + }, + "tasks": { + "port": 9499 + }, + "ui": { + "enabled": true + }, + "singleProjectMode": true + }, + "functions": [ + { + "source": "test/functions", + "codebase": "default", + "ignore": [ + "node_modules", + ".git", + "firebase-debug.log", + "*.local" + ] + } + ] +} diff --git a/packages/dart_firebase_admin/lib/dart_firebase_admin.dart b/packages/dart_firebase_admin/lib/dart_firebase_admin.dart index 0fb8e2e0..56e3a5cb 100644 --- a/packages/dart_firebase_admin/lib/dart_firebase_admin.dart +++ b/packages/dart_firebase_admin/lib/dart_firebase_admin.dart @@ -7,4 +7,5 @@ export 'src/app.dart' EmulatorClient, Environment, FirebaseServiceType, - FirebaseService; + FirebaseService, + CloudTasksEmulatorClient; diff --git a/packages/dart_firebase_admin/lib/functions.dart b/packages/dart_firebase_admin/lib/functions.dart new file mode 100644 index 00000000..ff072ec6 --- /dev/null +++ b/packages/dart_firebase_admin/lib/functions.dart @@ -0,0 +1 @@ +export 'src/functions/functions.dart'; diff --git a/packages/dart_firebase_admin/lib/src/app.dart b/packages/dart_firebase_admin/lib/src/app.dart index 330db369..9fef313a 100644 --- a/packages/dart_firebase_admin/lib/src/app.dart +++ b/packages/dart_firebase_admin/lib/src/app.dart @@ -6,6 +6,7 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:equatable/equatable.dart'; +// import 'package:googleapis/cloudfunctions/v2.dart' as auth4; import 'package:googleapis/identitytoolkit/v3.dart' as auth3; import 'package:googleapis_auth/auth_io.dart' as googleapis_auth; import 'package:googleapis_auth_utils/googleapis_auth_utils.dart' @@ -16,6 +17,7 @@ import 'package:meta/meta.dart'; import '../app_check.dart'; import '../auth.dart'; import '../firestore.dart'; +import '../functions.dart'; import '../messaging.dart'; import '../security_rules.dart'; diff --git a/packages/dart_firebase_admin/lib/src/app/emulator_client.dart b/packages/dart_firebase_admin/lib/src/app/emulator_client.dart index 13ea4bd2..fe0bb94f 100644 --- a/packages/dart_firebase_admin/lib/src/app/emulator_client.dart +++ b/packages/dart_firebase_admin/lib/src/app/emulator_client.dart @@ -101,3 +101,126 @@ class EmulatorClient implements googleapis_auth.AuthClient { Future readBytes(Uri url, {Map? headers}) => client.readBytes(url, headers: headers); } + +/// HTTP client for Cloud Tasks emulator that rewrites URLs. +/// +/// The googleapis CloudTasksApi uses `/v2/` prefix in its API paths, but the +/// Firebase Cloud Tasks emulator expects paths without this prefix: +/// - googleapis sends: `http://host:port/v2/projects/{projectId}/...` +/// - emulator expects: `http://host:port/projects/{projectId}/...` +/// +/// This client intercepts requests and removes the `/v2/` prefix from the path. +@internal +class CloudTasksEmulatorClient implements googleapis_auth.AuthClient { + CloudTasksEmulatorClient(this._emulatorHost) + : _innerClient = EmulatorClient(Client()); + + final String _emulatorHost; + final EmulatorClient _innerClient; + + @override + googleapis_auth.AccessCredentials get credentials => + throw UnimplementedError(); + + /// Rewrites the URL to remove `/v2/` prefix and route to emulator host. + Uri _rewriteUrl(Uri url) { + // Replace the path: remove /v2/ prefix if present + var path = url.path; + if (path.startsWith('/v2/')) { + path = path.substring(3); // Remove '/v2' (keep the trailing /) + } + + // Route to emulator host + return Uri.parse( + 'http://$_emulatorHost$path${url.hasQuery ? '?${url.query}' : ''}', + ); + } + + @override + Future send(BaseRequest request) async { + final rewrittenUrl = _rewriteUrl(request.url); + + final modifiedRequest = _RequestImpl( + request.method, + rewrittenUrl, + request.finalize(), + ); + modifiedRequest.headers.addAll(request.headers); + modifiedRequest.headers['Authorization'] = 'Bearer owner'; + + return _innerClient.client.send(modifiedRequest); + } + + @override + void close() { + _innerClient.close(); + } + + @override + Future head(Uri url, {Map? headers}) => + _innerClient.head(_rewriteUrl(url), headers: headers); + + @override + Future get(Uri url, {Map? headers}) => + _innerClient.get(_rewriteUrl(url), headers: headers); + + @override + Future post( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, + }) => _innerClient.post( + _rewriteUrl(url), + headers: headers, + body: body, + encoding: encoding, + ); + + @override + Future put( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, + }) => _innerClient.put( + _rewriteUrl(url), + headers: headers, + body: body, + encoding: encoding, + ); + + @override + Future patch( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, + }) => _innerClient.patch( + _rewriteUrl(url), + headers: headers, + body: body, + encoding: encoding, + ); + + @override + Future delete( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, + }) => _innerClient.delete( + _rewriteUrl(url), + headers: headers, + body: body, + encoding: encoding, + ); + + @override + Future read(Uri url, {Map? headers}) => + _innerClient.read(_rewriteUrl(url), headers: headers); + + @override + Future readBytes(Uri url, {Map? headers}) => + _innerClient.readBytes(_rewriteUrl(url), headers: headers); +} diff --git a/packages/dart_firebase_admin/lib/src/app/environment.dart b/packages/dart_firebase_admin/lib/src/app/environment.dart index 13046f28..c5a32f84 100644 --- a/packages/dart_firebase_admin/lib/src/app/environment.dart +++ b/packages/dart_firebase_admin/lib/src/app/environment.dart @@ -34,6 +34,12 @@ abstract class Environment { /// Format: `host:port` (e.g., `localhost:8080`) static const firestoreEmulatorHost = 'FIRESTORE_EMULATOR_HOST'; + /// Cloud Tasks Emulator host address. + /// + /// When set, Functions (Cloud Tasks) service automatically connects to the emulator instead of production. + /// Format: `host:port` (e.g., `127.0.0.1:9499`) + static const cloudTasksEmulatorHost = 'CLOUD_TASKS_EMULATOR_HOST'; + /// Checks if the Firestore emulator is enabled via environment variable. /// /// Returns `true` if [firestoreEmulatorHost] is set in the environment. @@ -65,4 +71,20 @@ abstract class Environment { Zone.current[envSymbol] as Map? ?? Platform.environment; return env[firebaseAuthEmulatorHost] != null; } + + /// Checks if the Cloud Tasks emulator is enabled via environment variable. + /// + /// Returns `true` if [cloudTasksEmulatorHost] is set in the environment. + /// + /// Example: + /// ```dart + /// if (Environment.isCloudTasksEmulatorEnabled()) { + /// print('Using Cloud Tasks emulator'); + /// } + /// ``` + static bool isCloudTasksEmulatorEnabled() { + final env = + Zone.current[envSymbol] as Map? ?? Platform.environment; + return env[cloudTasksEmulatorHost] != null; + } } diff --git a/packages/dart_firebase_admin/lib/src/app/firebase_app.dart b/packages/dart_firebase_admin/lib/src/app/firebase_app.dart index cd134402..3daf2c2b 100644 --- a/packages/dart_firebase_admin/lib/src/app/firebase_app.dart +++ b/packages/dart_firebase_admin/lib/src/app/firebase_app.dart @@ -77,7 +77,11 @@ class FirebaseApp { @override String toString() => - 'FirebaseApp(name: $name, projectId: $projectId, wasInitializedFromEnv: $wasInitializedFromEnv, isDeleted: $_isDeleted)'; + 'FirebaseApp(' + 'name: $name, ' + 'projectId: $projectId, ' + 'wasInitializedFromEnv: $wasInitializedFromEnv, ' + 'isDeleted: $_isDeleted)'; /// Map of service name to service instance for caching. final Map _services = {}; @@ -178,6 +182,12 @@ class FirebaseApp { SecurityRules.new, ); + /// Gets the Functions service instance for this app. + /// + /// Returns a cached instance if one exists, otherwise creates a new one. + Functions get functions => + getOrInitService(FirebaseServiceType.functions.name, Functions.new); + /// Closes this app and cleans up all associated resources. /// /// This method: diff --git a/packages/dart_firebase_admin/lib/src/app/firebase_service.dart b/packages/dart_firebase_admin/lib/src/app/firebase_service.dart index 40e181e0..af467d78 100644 --- a/packages/dart_firebase_admin/lib/src/app/firebase_service.dart +++ b/packages/dart_firebase_admin/lib/src/app/firebase_service.dart @@ -6,7 +6,8 @@ enum FirebaseServiceType { auth(name: 'auth'), firestore(name: 'firestore'), messaging(name: 'messaging'), - securityRules(name: 'security-rules'); + securityRules(name: 'security-rules'), + functions(name: 'functions'); const FirebaseServiceType({required this.name}); diff --git a/packages/dart_firebase_admin/lib/src/app_check/app_check_http_client.dart b/packages/dart_firebase_admin/lib/src/app_check/app_check_http_client.dart index 6421a80f..ec0f7b65 100644 --- a/packages/dart_firebase_admin/lib/src/app_check/app_check_http_client.dart +++ b/packages/dart_firebase_admin/lib/src/app_check/app_check_http_client.dart @@ -34,18 +34,14 @@ class AppCheckHttpClient { /// Executes an App Check v1 API operation with automatic projectId injection. Future v1( - Future Function(appcheck1.FirebaseappcheckApi client, String projectId) - fn, + Future Function(appcheck1.FirebaseappcheckApi api, String projectId) fn, ) => _run( (client, projectId) => fn(appcheck1.FirebaseappcheckApi(client), projectId), ); /// Executes an App Check v1Beta API operation with automatic projectId injection. Future v1Beta( - Future Function( - appcheck1_beta.FirebaseappcheckApi client, - String projectId, - ) + Future Function(appcheck1_beta.FirebaseappcheckApi api, String projectId) fn, ) => _run( (client, projectId) => @@ -59,8 +55,8 @@ class AppCheckHttpClient { String customToken, String appId, ) { - return v1((client, projectId) async { - return client.projects.apps.exchangeCustomToken( + return v1((api, projectId) async { + return api.projects.apps.exchangeCustomToken( appcheck1.GoogleFirebaseAppcheckV1ExchangeCustomTokenRequest( customToken: customToken, ), @@ -74,8 +70,8 @@ class AppCheckHttpClient { /// Returns the raw googleapis response without transformation. Future verifyAppCheckToken(String token) { - return v1Beta((client, projectId) async { - return client.projects.verifyAppCheckToken( + return v1Beta((api, projectId) async { + return api.projects.verifyAppCheckToken( appcheck1_beta.GoogleFirebaseAppcheckV1betaVerifyAppCheckTokenRequest( appCheckToken: token, ), diff --git a/packages/dart_firebase_admin/lib/src/auth/auth_http_client.dart b/packages/dart_firebase_admin/lib/src/auth/auth_http_client.dart index 2ebed546..ea36cd69 100644 --- a/packages/dart_firebase_admin/lib/src/auth/auth_http_client.dart +++ b/packages/dart_firebase_admin/lib/src/auth/auth_http_client.dart @@ -71,7 +71,7 @@ class AuthHttpClient { Future getOobCode( auth1.GoogleCloudIdentitytoolkitV1GetOobCodeRequest request, ) { - return v1((client, projectId) async { + return v1((api, projectId) async { final email = request.email; if (email == null || !isEmail(email)) { throw FirebaseAuthAdminException(AuthClientErrorCode.invalidEmail); @@ -89,7 +89,7 @@ class AuthHttpClient { ); } - final response = await client.accounts.sendOobCode(request); + final response = await api.accounts.sendOobCode(request); if (response.oobLink == null) { throw FirebaseAuthAdminException( @@ -104,7 +104,7 @@ class AuthHttpClient { Future listInboundSamlConfigs({required int pageSize, String? pageToken}) { - return v2((client, projectId) async { + return v2((api, projectId) async { if (pageToken != null && pageToken.isEmpty) { throw FirebaseAuthAdminException(AuthClientErrorCode.invalidPageToken); } @@ -117,7 +117,7 @@ class AuthHttpClient { ); } - return client.projects.inboundSamlConfigs.list( + return api.projects.inboundSamlConfigs.list( buildParent(projectId), pageSize: pageSize, pageToken: pageToken, @@ -127,7 +127,7 @@ class AuthHttpClient { Future listOAuthIdpConfigs({required int pageSize, String? pageToken}) { - return v2((client, projectId) async { + return v2((api, projectId) async { if (pageToken != null && pageToken.isEmpty) { throw FirebaseAuthAdminException(AuthClientErrorCode.invalidPageToken); } @@ -140,7 +140,7 @@ class AuthHttpClient { ); } - return client.projects.oauthIdpConfigs.list( + return api.projects.oauthIdpConfigs.list( buildParent(projectId), pageSize: pageSize, pageToken: pageToken, @@ -153,8 +153,8 @@ class AuthHttpClient { auth2.GoogleCloudIdentitytoolkitAdminV2OAuthIdpConfig request, String providerId, ) { - return v2((client, projectId) async { - final response = await client.projects.oauthIdpConfigs.create( + return v2((api, projectId) async { + final response = await api.projects.oauthIdpConfigs.create( request, buildParent(projectId), oauthIdpConfigId: providerId, @@ -177,8 +177,8 @@ class AuthHttpClient { auth2.GoogleCloudIdentitytoolkitAdminV2InboundSamlConfig request, String providerId, ) { - return v2((client, projectId) async { - final response = await client.projects.inboundSamlConfigs.create( + return v2((api, projectId) async { + final response = await api.projects.inboundSamlConfigs.create( request, buildParent(projectId), inboundSamlConfigId: providerId, @@ -197,16 +197,16 @@ class AuthHttpClient { } Future deleteOauthIdpConfig(String providerId) { - return v2((client, projectId) async { - await client.projects.oauthIdpConfigs.delete( + return v2((api, projectId) async { + await api.projects.oauthIdpConfigs.delete( buildOAuthIdpParent(projectId, providerId), ); }); } Future deleteInboundSamlConfig(String providerId) { - return v2((client, projectId) async { - await client.projects.inboundSamlConfigs.delete( + return v2((api, projectId) async { + await api.projects.inboundSamlConfigs.delete( buildSamlParent(projectId, providerId), ); }); @@ -218,8 +218,8 @@ class AuthHttpClient { String providerId, { required String? updateMask, }) { - return v2((client, projectId) async { - final response = await client.projects.inboundSamlConfigs.patch( + return v2((api, projectId) async { + final response = await api.projects.inboundSamlConfigs.patch( request, buildSamlParent(projectId, providerId), updateMask: updateMask, @@ -242,8 +242,8 @@ class AuthHttpClient { String providerId, { required String? updateMask, }) { - return v2((client, projectId) async { - final response = await client.projects.oauthIdpConfigs.patch( + return v2((api, projectId) async { + final response = await api.projects.oauthIdpConfigs.patch( request, buildOAuthIdpParent(projectId, providerId), updateMask: updateMask, @@ -264,11 +264,11 @@ class AuthHttpClient { setAccountInfo( auth1.GoogleCloudIdentitytoolkitV1SetAccountInfoRequest request, ) { - return v1((client, projectId) async { + return v1((api, projectId) async { // TODO should this use account/project/update or account/update? // Or maybe both? // ^ Depending on it, use tenantId... Or do we? The request seems to reject tenantID args - final response = await client.accounts.update(request); + final response = await api.accounts.update(request); final localId = response.localId; if (localId == null) { @@ -280,8 +280,8 @@ class AuthHttpClient { Future getOauthIdpConfig(String providerId) { - return v2((client, projectId) async { - final response = await client.projects.oauthIdpConfigs.get( + return v2((api, projectId) async { + final response = await api.projects.oauthIdpConfigs.get( buildOAuthIdpParent(projectId, providerId), ); @@ -299,8 +299,8 @@ class AuthHttpClient { Future getInboundSamlConfig(String providerId) { - return v2((client, projectId) async { - final response = await client.projects.inboundSamlConfigs.get( + return v2((api, projectId) async { + final response = await api.projects.inboundSamlConfigs.get( buildSamlParent(projectId, providerId), ); @@ -320,7 +320,7 @@ class AuthHttpClient { Future getTenant( String tenantId, ) { - return v2((client, projectId) async { + return v2((api, projectId) async { if (tenantId.isEmpty) { throw FirebaseAuthAdminException( AuthClientErrorCode.invalidTenantId, @@ -328,7 +328,7 @@ class AuthHttpClient { ); } - final response = await client.projects.tenants.get( + final response = await api.projects.tenants.get( buildTenantParent(projectId, tenantId), ); @@ -345,8 +345,9 @@ class AuthHttpClient { Future listTenants({required int maxResults, String? pageToken}) { - return v2((client, projectId) async { - final response = await client.projects.tenants.list( + // TODO(demalaf): rename client below to identityApi or api + return v2((api, projectId) async { + final response = await api.projects.tenants.list( buildParent(projectId), pageSize: maxResults, pageToken: pageToken, @@ -357,7 +358,7 @@ class AuthHttpClient { } Future deleteTenant(String tenantId) { - return v2((client, projectId) async { + return v2((api, projectId) async { if (tenantId.isEmpty) { throw FirebaseAuthAdminException( AuthClientErrorCode.invalidTenantId, @@ -365,7 +366,7 @@ class AuthHttpClient { ); } - return client.projects.tenants.delete( + return api.projects.tenants.delete( buildTenantParent(projectId, tenantId), ); }); @@ -374,8 +375,8 @@ class AuthHttpClient { Future createTenant( auth2.GoogleCloudIdentitytoolkitAdminV2Tenant request, ) { - return v2((client, projectId) async { - final response = await client.projects.tenants.create( + return v2((api, projectId) async { + final response = await api.projects.tenants.create( request, buildParent(projectId), ); @@ -395,7 +396,7 @@ class AuthHttpClient { String tenantId, auth2.GoogleCloudIdentitytoolkitAdminV2Tenant request, ) { - return v2((client, projectId) async { + return v2((api, projectId) async { if (tenantId.isEmpty) { throw FirebaseAuthAdminException( AuthClientErrorCode.invalidTenantId, @@ -406,7 +407,7 @@ class AuthHttpClient { final name = buildTenantParent(projectId, tenantId); final updateMask = request.toJson().keys.join(','); - final response = await client.projects.tenants.patch( + final response = await api.projects.tenants.patch( request, name, updateMask: updateMask, @@ -425,9 +426,9 @@ class AuthHttpClient { // Project Config management methods Future getConfig() { - return v2((client, projectId) async { + return v2((api, projectId) async { final name = buildProjectConfigParent(projectId); - final response = await client.projects.getConfig(name); + final response = await api.projects.getConfig(name); return response; }); } @@ -436,9 +437,9 @@ class AuthHttpClient { auth2.GoogleCloudIdentitytoolkitAdminV2Config request, String updateMask, ) { - return v2((client, projectId) async { + return v2((api, projectId) async { final name = buildProjectConfigParent(projectId); - final response = await client.projects.updateConfig( + final response = await api.projects.updateConfig( request, name, updateMask: updateMask, @@ -462,7 +463,7 @@ class AuthHttpClient { } Future v1( - Future Function(auth1.IdentityToolkitApi client, String projectId) fn, + Future Function(auth1.IdentityToolkitApi api, String projectId) fn, ) => _run( (client, projectId) => fn( auth1.IdentityToolkitApi(client, rootUrl: _authApiHost.toString()), @@ -471,7 +472,7 @@ class AuthHttpClient { ); Future v2( - Future Function(auth2.IdentityToolkitApi client, String projectId) fn, + Future Function(auth2.IdentityToolkitApi api, String projectId) fn, ) => _run( (client, projectId) => fn( auth2.IdentityToolkitApi(client, rootUrl: _authApiHost.toString()), @@ -480,7 +481,7 @@ class AuthHttpClient { ); Future v3( - Future Function(auth3.IdentityToolkitApi client, String projectId) fn, + Future Function(auth3.IdentityToolkitApi api, String projectId) fn, ) => _run( (client, projectId) => fn( auth3.IdentityToolkitApi(client, rootUrl: _authApiHost.toString()), diff --git a/packages/dart_firebase_admin/lib/src/auth/auth_request_handler.dart b/packages/dart_firebase_admin/lib/src/auth/auth_request_handler.dart index 094a9c11..951b2aac 100644 --- a/packages/dart_firebase_admin/lib/src/auth/auth_request_handler.dart +++ b/packages/dart_firebase_admin/lib/src/auth/auth_request_handler.dart @@ -319,7 +319,7 @@ abstract class _AbstractAuthRequestHandler { validDuration: validDuration.toString(), ); - return _httpClient.v1((client, projectId) async { + return _httpClient.v1((api, projectId) async { // Validate the ID token is a non-empty string. if (idToken.isEmpty) { throw FirebaseAuthAdminException(AuthClientErrorCode.invalidIdToken); @@ -333,7 +333,7 @@ abstract class _AbstractAuthRequestHandler { ); } - final response = await client.projects.createSessionCookie( + final response = await api.projects.createSessionCookie( request, projectId, ); @@ -392,8 +392,8 @@ abstract class _AbstractAuthRequestHandler { return userImportBuilder.buildResponse([]); } - return _httpClient.v1((client, projectId) async { - final response = await client.projects.accounts_1.batchCreate( + return _httpClient.v1((api, projectId) async { + final response = await api.projects.accounts_1.batchCreate( request, projectId, ); @@ -430,8 +430,8 @@ abstract class _AbstractAuthRequestHandler { ); } - return _httpClient.v1((client, projectId) async { - return client.projects.accounts_1.batchGet( + return _httpClient.v1((api, projectId) async { + return api.projects.accounts_1.batchGet( projectId, maxResults: maxResults, nextPageToken: pageToken, @@ -445,8 +445,8 @@ abstract class _AbstractAuthRequestHandler { ) async { assertIsUid(uid); - return _httpClient.v1((client, projectId) async { - return client.projects.accounts_1.delete( + return _httpClient.v1((api, projectId) async { + return api.projects.accounts_1.delete( auth1.GoogleCloudIdentitytoolkitV1DeleteAccountRequest(localId: uid), projectId, ); @@ -464,8 +464,8 @@ abstract class _AbstractAuthRequestHandler { ); } - return _httpClient.v1((client, projectId) async { - return client.projects.accounts_1.batchDelete( + return _httpClient.v1((api, projectId) async { + return api.projects.accounts_1.batchDelete( auth1.GoogleCloudIdentitytoolkitV1BatchDeleteAccountsRequest( localIds: uids, force: force, @@ -480,13 +480,13 @@ abstract class _AbstractAuthRequestHandler { /// A [Future] that resolves when the operation completes /// with the user id that was created. Future createNewAccount(CreateRequest properties) async { - return _httpClient.v1((client, projectId) async { + return _httpClient.v1((api, projectId) async { var mfaInfo = properties.multiFactor?.enrolledFactors .map((info) => info.toGoogleCloudIdentitytoolkitV1MfaFactor()) .toList(); if (mfaInfo != null && mfaInfo.isEmpty) mfaInfo = null; - final response = await client.projects.accounts( + final response = await api.projects.accounts( auth1.GoogleCloudIdentitytoolkitV1SignUpRequest( disabled: properties.disabled, displayName: properties.displayName?.value, @@ -517,8 +517,8 @@ abstract class _AbstractAuthRequestHandler { _accountsLookup( auth1.GoogleCloudIdentitytoolkitV1GetAccountInfoRequest request, ) async { - return _httpClient.v1((client, projectId) async { - final response = await client.accounts.lookup(request); + return _httpClient.v1((api, projectId) async { + final response = await api.accounts.lookup(request); final users = response.users; if (users == null || users.isEmpty) { throw FirebaseAuthAdminException(AuthClientErrorCode.userNotFound); @@ -652,9 +652,7 @@ abstract class _AbstractAuthRequestHandler { } } - return _httpClient.v1( - (client, projectId) => client.accounts.lookup(request), - ); + return _httpClient.v1((api, projectId) => api.accounts.lookup(request)); } /// Edits an existing user. diff --git a/packages/dart_firebase_admin/lib/src/functions/functions.dart b/packages/dart_firebase_admin/lib/src/functions/functions.dart new file mode 100644 index 00000000..46094755 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/functions/functions.dart @@ -0,0 +1,99 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:googleapis/cloudtasks/v2.dart' as tasks2; +import 'package:googleapis_auth/auth_io.dart' as googleapis_auth; +import 'package:googleapis_auth_utils/googleapis_auth_utils.dart'; +import 'package:meta/meta.dart'; + +import '../app.dart'; +import '../utils/validator.dart'; + +part 'functions_api.dart'; +part 'functions_exception.dart'; +part 'functions_http_client.dart'; +part 'functions_request_handler.dart'; +part 'task_queue.dart'; + +const _defaultLocation = 'us-central1'; + +/// Default service account email used when running with the Cloud Tasks emulator. +const _emulatedServiceAccountDefault = 'emulated-service-acct@email.com'; + +/// An interface for interacting with Cloud Functions Task Queues. +/// +/// This service allows you to enqueue tasks for Cloud Functions and manage +/// those tasks before they execute. +class Functions implements FirebaseService { + /// Creates or returns the cached Functions instance for the given app. + factory Functions(FirebaseApp app) { + return app.getOrInitService( + FirebaseServiceType.functions.name, + Functions._, + ); + } + + /// An interface for interacting with Cloud Functions Task Queues. + Functions._(this.app) : _requestHandler = FunctionsRequestHandler(app); + + @internal + factory Functions.internal( + FirebaseApp app, { + FunctionsRequestHandler? requestHandler, + }) { + return app.getOrInitService( + FirebaseServiceType.functions.name, + (app) => Functions._internal(app, requestHandler: requestHandler), + ); + } + + Functions._internal(this.app, {FunctionsRequestHandler? requestHandler}) + : _requestHandler = requestHandler ?? FunctionsRequestHandler(app); + + /// The app associated with this Functions instance. + @override + final FirebaseApp app; + + final FunctionsRequestHandler _requestHandler; + + /// Creates a reference to a task queue for the given function. + /// + /// The [functionName] can be: + /// 1. A fully qualified function resource name: + /// `projects/{project}/locations/{location}/functions/{functionName}` + /// 2. A partial resource name with location and function name: + /// `locations/{location}/functions/{functionName}` + /// 3. Just the function name (uses default location `us-central1`): + /// `{functionName}` + /// + /// The optional [extensionId] is used for Firebase Extension functions. + /// + /// Example: + /// ```dart + /// final functions = FirebaseApp.instance.functions; + /// final queue = functions.taskQueue('myFunction'); + /// await queue.enqueue({'data': 'value'}); + /// ``` + TaskQueue taskQueue(String functionName, {String? extensionId}) { + return TaskQueue._( + functionName: functionName, + requestHandler: _requestHandler, + extensionId: extensionId, + ); + } + + @override + Future delete() async { + // Close HTTP client if we created it (emulator mode) + // In production mode, we use app.client which is closed by the app + if (Environment.isCloudTasksEmulatorEnabled()) { + try { + final client = await _requestHandler.httpClient.client; + client.close(); + } catch (_) { + // Ignore errors if client wasn't initialized + } + } + } +} diff --git a/packages/dart_firebase_admin/lib/src/functions/functions_api.dart b/packages/dart_firebase_admin/lib/src/functions/functions_api.dart new file mode 100644 index 00000000..c3257cfb --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/functions/functions_api.dart @@ -0,0 +1,162 @@ +part of 'functions.dart'; + +/// Represents delivery scheduling options for a task. +/// +/// Use [AbsoluteDelivery] to schedule a task at a specific time, or +/// [DelayDelivery] to schedule a task after a delay from the current time. +/// +/// This is a sealed class, ensuring compile-time exhaustiveness checking +/// when pattern matching. +sealed class DeliverySchedule { + const DeliverySchedule(); +} + +/// Schedules task delivery at an absolute time. +/// +/// The task will be attempted or retried at the specified [scheduleTime]. +class AbsoluteDelivery extends DeliverySchedule { + /// Creates an absolute delivery schedule. + /// + /// The [scheduleTime] specifies when the task should be attempted. + const AbsoluteDelivery(this.scheduleTime); + + /// The time when the task is scheduled to be attempted or retried. + final DateTime scheduleTime; +} + +/// Schedules task delivery after a delay from the current time. +/// +/// The task will be attempted after [scheduleDelaySeconds] seconds from now. +class DelayDelivery extends DeliverySchedule { + /// Creates a delayed delivery schedule. + /// + /// The [scheduleDelaySeconds] specifies how many seconds from now + /// the task should be attempted. Must be non-negative. + /// + /// Throws [FirebaseFunctionsAdminException] if [scheduleDelaySeconds] is negative. + DelayDelivery(this.scheduleDelaySeconds) { + if (scheduleDelaySeconds < 0) { + throw FirebaseFunctionsAdminException( + FunctionsClientErrorCode.invalidArgument, + 'scheduleDelaySeconds must be a non-negative duration in seconds.', + ); + } + } + + /// The duration of delay (in seconds) before the task is scheduled + /// to be attempted. + /// + /// This delay is added to the current time. + final int scheduleDelaySeconds; +} + +/// Experimental (beta) task options. +/// +/// These options may change in future releases. +class TaskOptionsExperimental { + /// Creates experimental task options. + TaskOptionsExperimental({this.uri}) { + if (uri != null && !isURL(uri)) { + throw FirebaseFunctionsAdminException( + FunctionsClientErrorCode.invalidArgument, + 'uri must be a valid URL string.', + ); + } + } + + /// The full URL path that the request will be sent to. + /// + /// Must be a valid URL. + /// + /// **Beta feature** - May change in future releases. + final String? uri; +} + +/// Options for enqueuing a task. +/// +/// Specifies scheduling, delivery, and identification options for a task. +class TaskOptions { + /// Creates task options with the specified configuration. + TaskOptions({ + this.schedule, + this.dispatchDeadlineSeconds, + this.id, + this.headers, + this.experimental, + }) { + // Validate dispatchDeadlineSeconds range + if (dispatchDeadlineSeconds != null && + (dispatchDeadlineSeconds! < 15 || dispatchDeadlineSeconds! > 1800)) { + throw FirebaseFunctionsAdminException( + FunctionsClientErrorCode.invalidArgument, + 'dispatchDeadlineSeconds must be between 15 and 1800 seconds.', + ); + } + + // Validate task ID format + if (id != null && !isValidTaskId(id)) { + throw FirebaseFunctionsAdminException( + FunctionsClientErrorCode.invalidArgument, + 'id can contain only letters ([A-Za-z]), numbers ([0-9]), ' + 'hyphens (-), or underscores (_). The maximum length is 500 characters.', + ); + } + } + + /// Optional delivery schedule for the task. + /// + /// Use [AbsoluteDelivery] to schedule at a specific time, or + /// [DelayDelivery] to schedule after a delay. + /// + /// If not specified, the task will be enqueued immediately. + final DeliverySchedule? schedule; + + /// The deadline for requests sent to the worker. + /// + /// If the worker does not respond by this deadline then the request is + /// cancelled and the attempt is marked as a DEADLINE_EXCEEDED failure. + /// Cloud Tasks will retry the task according to the RetryConfig. + /// + /// The default is 10 minutes (600 seconds). + /// The deadline must be in the range of 15 seconds to 30 minutes (1800 seconds). + final int? dispatchDeadlineSeconds; + + /// The ID to use for the enqueued task. + /// + /// If not provided, one will be automatically generated. + /// + /// If provided, an explicitly specified task ID enables task de-duplication. + /// If a task's ID is identical to that of an existing task or a task that + /// was deleted or executed recently then the call will throw an error with + /// code "task-already-exists". Another task with the same ID can't be + /// created for ~1 hour after the original task was deleted or executed. + /// + /// Because there is an extra lookup cost to identify duplicate task IDs, + /// setting ID significantly increases latency. Using hashed strings for + /// the task ID or for the prefix of the task ID is recommended. + /// + /// Choosing task IDs that are sequential or have sequential prefixes, + /// for example using a timestamp, causes an increase in latency and error + /// rates in all task commands. The infrastructure relies on an approximately + /// uniform distribution of task IDs to store and serve tasks efficiently. + /// + /// The ID can contain only letters ([A-Za-z]), numbers ([0-9]), hyphens (-), + /// or underscores (_). The maximum length is 500 characters. + final String? id; + + /// HTTP request headers to include in the request to the task queue function. + /// + /// These headers represent a subset of the headers that will accompany the + /// task's HTTP request. Some HTTP request headers will be ignored or replaced, + /// e.g. Authorization, Host, Content-Length, User-Agent etc. cannot be overridden. + /// + /// By default, Content-Type is set to 'application/json'. + /// + /// The size of the headers must be less than 80KB. + final Map? headers; + + /// Experimental (beta) task options. + /// + /// Contains experimental features that may change in future releases. + final TaskOptionsExperimental? experimental; +} diff --git a/packages/dart_firebase_admin/lib/src/functions/functions_exception.dart b/packages/dart_firebase_admin/lib/src/functions/functions_exception.dart new file mode 100644 index 00000000..28aacc68 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/functions/functions_exception.dart @@ -0,0 +1,150 @@ +part of 'functions.dart'; + +/// Functions server to client enum error codes. +@internal +const functionsServerToClientCode = { + // Cloud Tasks error codes + 'ABORTED': FunctionsClientErrorCode.aborted, + 'INVALID_ARGUMENT': FunctionsClientErrorCode.invalidArgument, + 'INVALID_CREDENTIAL': FunctionsClientErrorCode.invalidCredential, + 'INTERNAL': FunctionsClientErrorCode.internalError, + 'FAILED_PRECONDITION': FunctionsClientErrorCode.failedPrecondition, + 'PERMISSION_DENIED': FunctionsClientErrorCode.permissionDenied, + 'UNAUTHENTICATED': FunctionsClientErrorCode.unauthenticated, + 'NOT_FOUND': FunctionsClientErrorCode.notFound, + 'UNKNOWN': FunctionsClientErrorCode.unknownError, + 'ALREADY_EXISTS': FunctionsClientErrorCode.taskAlreadyExists, +}; + +/// Exception thrown by Firebase Functions operations. +class FirebaseFunctionsAdminException extends FirebaseAdminException + implements Exception { + /// Creates a Functions exception with the given error code and message. + FirebaseFunctionsAdminException(this.errorCode, [String? message]) + : super( + FirebaseServiceType.functions.name, + errorCode.code, + message ?? errorCode.message, + ); + + /// Creates a Functions exception from a server error response. + @internal + factory FirebaseFunctionsAdminException.fromServerError({ + required String serverErrorCode, + String? message, + Object? rawServerResponse, + }) { + // If not found, default to unknown error. + final error = + functionsServerToClientCode[serverErrorCode] ?? + FunctionsClientErrorCode.unknownError; + var effectiveMessage = message ?? error.message; + + if (error == FunctionsClientErrorCode.unknownError && + rawServerResponse != null) { + try { + effectiveMessage += + ' Raw server response: "${jsonEncode(rawServerResponse)}"'; + } catch (e) { + // Ignore JSON parsing error. + } + } + + return FirebaseFunctionsAdminException(error, effectiveMessage); + } + + /// The error code for this exception. + final FunctionsClientErrorCode errorCode; + + @override + String toString() => 'FirebaseFunctionsAdminException: $code: $message'; +} + +/// Functions client error codes and their default messages. +enum FunctionsClientErrorCode { + /// Invalid argument provided. + invalidArgument( + code: 'invalid-argument', + message: 'Invalid argument provided.', + ), + + /// Invalid credential. + invalidCredential(code: 'invalid-credential', message: 'Invalid credential.'), + + /// Internal server error. + internalError(code: 'internal-error', message: 'Internal server error.'), + + /// Failed precondition. + failedPrecondition( + code: 'failed-precondition', + message: 'Failed precondition.', + ), + + /// Permission denied. + permissionDenied(code: 'permission-denied', message: 'Permission denied.'), + + /// Unauthenticated. + unauthenticated(code: 'unauthenticated', message: 'Unauthenticated.'), + + /// Resource not found. + notFound(code: 'not-found', message: 'Resource not found.'), + + /// Unknown error. + unknownError(code: 'unknown-error', message: 'Unknown error.'), + + /// Task with the given ID already exists. + taskAlreadyExists( + code: 'task-already-exists', + message: 'Task already exists.', + ), + + /// Request aborted. + aborted(code: 'aborted', message: 'Request aborted.'); + + const FunctionsClientErrorCode({required this.code, required this.message}); + + /// The error code string. + final String code; + + /// The default error message. + final String message; +} + +/// Helper function to create a Firebase error from an HTTP response. +FirebaseFunctionsAdminException _createFirebaseError({ + required int statusCode, + required String body, + required bool isJson, +}) { + if (!isJson) { + return FirebaseFunctionsAdminException( + FunctionsClientErrorCode.unknownError, + 'Unexpected response with status: $statusCode and body: $body', + ); + } + + try { + final json = jsonDecode(body) as Map; + final error = json['error'] as Map?; + + if (error != null) { + final status = error['status'] as String?; + final message = error['message'] as String?; + + if (status != null) { + return FirebaseFunctionsAdminException.fromServerError( + serverErrorCode: status, + message: message, + rawServerResponse: json, + ); + } + } + } catch (e) { + // Fall through to default error + } + + return FirebaseFunctionsAdminException( + FunctionsClientErrorCode.unknownError, + 'Unknown server error: $body', + ); +} diff --git a/packages/dart_firebase_admin/lib/src/functions/functions_http_client.dart b/packages/dart_firebase_admin/lib/src/functions/functions_http_client.dart new file mode 100644 index 00000000..21a325b9 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/functions/functions_http_client.dart @@ -0,0 +1,122 @@ +part of 'functions.dart'; + +/// HTTP client for Cloud Functions Task Queue operations. +/// +/// Handles HTTP client management, googleapis API client creation, +/// path builders, and emulator support. +class FunctionsHttpClient { + FunctionsHttpClient(this.app); + + final FirebaseApp app; + + /// Gets the Cloud Tasks emulator host if enabled. + /// + /// Returns the host:port string (e.g., "localhost:9499") if the + /// CLOUD_TASKS_EMULATOR_HOST environment variable is set. + String? get _cloudTasksEmulatorHost { + final env = + Zone.current[envSymbol] as Map? ?? Platform.environment; + final host = env[Environment.cloudTasksEmulatorHost]; + return (host != null && host.isNotEmpty) ? host : null; + } + + /// Lazy-initialized HTTP client that's cached for reuse. + /// Uses CloudTasksEmulatorClient for emulator, authenticated client for production. + late final Future _client = _createClient(); + + Future get client => _client; + + /// Creates the appropriate HTTP client based on emulator configuration. + Future _createClient() async { + // If app has custom httpClient (e.g., mock for testing), always use it + if (app.options.httpClient != null) { + return app.client; + } + + // Check if Cloud Tasks emulator is enabled + final emulatorHost = _cloudTasksEmulatorHost; + if (emulatorHost != null) { + // Emulator: Use CloudTasksEmulatorClient which: + // 1. Adds "Authorization: Bearer owner" header + // 2. Rewrites URLs to remove /v2/ prefix (Firebase emulator doesn't use it) + return CloudTasksEmulatorClient(emulatorHost); + } + + // Production: Use authenticated client from app + return app.client; + } + + /// Builds the parent resource path for Cloud Tasks operations. + /// + /// Format: `projects/{projectId}/locations/{locationId}/queues/{queueId}` + String buildTasksParent({ + required String projectId, + required String locationId, + required String queueId, + }) { + return 'projects/$projectId/locations/$locationId/queues/$queueId'; + } + + /// Builds the full task resource name. + /// + /// Format: `projects/{projectId}/locations/{locationId}/queues/{queueId}/tasks/{taskId}` + String buildTaskName({ + required String projectId, + required String locationId, + required String queueId, + required String taskId, + }) { + return 'projects/$projectId/locations/$locationId/queues/$queueId/tasks/$taskId'; + } + + /// Builds the function URL. + /// + /// Format: `https://{locationId}-{projectId}.cloudfunctions.net/{functionName}` + String buildFunctionUrl({ + required String projectId, + required String locationId, + required String functionName, + }) { + return 'https://$locationId-$projectId.cloudfunctions.net/$functionName'; + } + + Future _run( + Future Function(googleapis_auth.AuthClient client, String projectId) fn, + ) async { + final authClient = await client; + final projectId = await authClient.getProjectId( + projectIdOverride: app.options.projectId, + environment: Zone.current[envSymbol] as Map?, + ); + return _functionsGuard(() => fn(authClient, projectId)); + } + + /// Executes a Cloud Tasks API operation with automatic projectId injection. + /// + /// Works for both production and emulator: + /// - Production: Uses the googleapis CloudTasksApi client directly + /// - Emulator: CloudTasksEmulatorClient intercepts requests and removes /v2/ prefix + /// + /// The callback receives the CloudTasksApi, and the projectId + /// (for authentication setup like OIDC tokens). + Future cloudTasks( + Future Function(tasks2.CloudTasksApi api, String projectId) fn, + ) => _run((client, projectId) => fn(tasks2.CloudTasksApi(client), projectId)); +} + +/// Guards a Functions operation and converts errors to FirebaseFunctionsAdminException. +Future _functionsGuard(Future Function() operation) async { + try { + return await operation(); + } on tasks2.DetailedApiRequestError catch (error) { + // Convert googleapis error to Functions exception + throw _createFirebaseError( + statusCode: error.status ?? 500, + body: switch (error.jsonResponse) { + null => error.message ?? '', + final json => jsonEncode(json), + }, + isJson: error.jsonResponse != null, + ); + } +} diff --git a/packages/dart_firebase_admin/lib/src/functions/functions_request_handler.dart b/packages/dart_firebase_admin/lib/src/functions/functions_request_handler.dart new file mode 100644 index 00000000..8d5a068a --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/functions/functions_request_handler.dart @@ -0,0 +1,300 @@ +part of 'functions.dart'; + +/// Parsed resource name components. +class _ParsedResource { + _ParsedResource({this.projectId, this.locationId, required this.resourceId}); + + String? projectId; + String? locationId; + final String resourceId; +} + +/// Request handler for Cloud Functions Task Queue operations. +/// +/// Handles complex business logic, request/response transformations, +/// and validation. Delegates API calls to [FunctionsHttpClient]. +class FunctionsRequestHandler { + FunctionsRequestHandler(FirebaseApp app, {FunctionsHttpClient? httpClient}) + : _httpClient = httpClient ?? FunctionsHttpClient(app); + + final FunctionsHttpClient _httpClient; + + FunctionsHttpClient get httpClient => _httpClient; + + /// Enqueues a task to the specified function's queue. + Future enqueue( + Map data, + String functionName, + String? extensionId, + TaskOptions? options, + ) async { + validateNonEmptyString(functionName, 'functionName'); + + // Parse the function name to extract project, location, and function ID + final resources = _parseResourceName(functionName, 'functions'); + + return _httpClient.cloudTasks((api, projectId) async { + // Fill in missing resource components + resources.projectId ??= projectId; + resources.locationId ??= _defaultLocation; + + validateNonEmptyString(resources.resourceId, 'resourceId'); + + // Apply extension ID prefix if provided + var queueId = resources.resourceId; + if (extensionId != null && extensionId.isNotEmpty) { + queueId = 'ext-$extensionId-$queueId'; + } + + // Build the task + final task = _buildTask(data, resources, queueId, options); + + // Update task with proper authentication (OIDC token or Authorization header) + await _updateTaskAuth(task, await _httpClient.client, extensionId); + + final parent = _httpClient.buildTasksParent( + projectId: resources.projectId!, + locationId: resources.locationId!, + queueId: queueId, + ); + + try { + await api.projects.locations.queues.tasks.create( + tasks2.CreateTaskRequest(task: task), + parent, + ); + } on tasks2.DetailedApiRequestError catch (error) { + // Handle 409 Conflict (task already exists) + if (error.status == 409) { + throw FirebaseFunctionsAdminException( + FunctionsClientErrorCode.taskAlreadyExists, + 'A task with ID ${options?.id} already exists', + ); + } + rethrow; // Will be caught by _functionsGuard + } + }); + } + + /// Deletes a task from the specified function's queue. + Future delete( + String id, + String functionName, + String? extensionId, + ) async { + validateNonEmptyString(functionName, 'functionName'); + validateNonEmptyString(id, 'id'); + + if (!isValidTaskId(id)) { + throw FirebaseFunctionsAdminException( + FunctionsClientErrorCode.invalidArgument, + 'id can contain only letters ([A-Za-z]), numbers ([0-9]), ' + 'hyphens (-), or underscores (_). The maximum length is 500 characters.', + ); + } + + // Parse the function name + final resources = _parseResourceName(functionName, 'functions'); + + return _httpClient.cloudTasks((api, projectId) async { + // Fill in missing resource components + resources.projectId ??= projectId; + resources.locationId ??= _defaultLocation; + + validateNonEmptyString(resources.resourceId, 'resourceId'); + + // Apply extension ID prefix if provided + var queueId = resources.resourceId; + if (extensionId != null && extensionId.isNotEmpty) { + queueId = 'ext-$extensionId-$queueId'; + } + + // Build the full task name + final taskName = _httpClient.buildTaskName( + projectId: resources.projectId!, + locationId: resources.locationId!, + queueId: queueId, + taskId: id, + ); + + try { + await api.projects.locations.queues.tasks.delete(taskName); + } on tasks2.DetailedApiRequestError catch (error) { + // If the task doesn't exist (404), ignore the error + if (error.status == 404) { + return; + } + rethrow; // Will be caught by _functionsGuard + } + }); + } + + /// Parses a resource name into its components. + /// + /// Supports: + /// - Full: `projects/{project}/locations/{location}/functions/{functionName}` + /// - Partial: `locations/{location}/functions/{functionName}` + /// - Simple: `{functionName}` + _ParsedResource _parseResourceName( + String resourceName, + String resourceIdKey, + ) { + // Simple case: no slashes means it's just the resource ID + if (!resourceName.contains('/')) { + return _ParsedResource(resourceId: resourceName); + } + + // Parse full or partial resource name + final regex = RegExp( + '^(projects/([^/]+)/)?locations/([^/]+)/$resourceIdKey/([^/]+)\$', + ); + final match = regex.firstMatch(resourceName); + + if (match == null) { + throw FirebaseFunctionsAdminException( + FunctionsClientErrorCode.invalidArgument, + 'Invalid resource name format.', + ); + } + + return _ParsedResource( + projectId: match.group(2), // Optional project ID + locationId: match.group(3), // Required location + resourceId: match.group(4)!, // Required resource ID + ); + } + + /// Builds a Cloud Tasks Task from the given data and options. + tasks2.Task _buildTask( + Map data, + _ParsedResource resources, + String queueId, + TaskOptions? options, + ) { + // Base64 encode the data payload + final bodyBytes = utf8.encode(jsonEncode({'data': data})); + final bodyBase64 = base64Encode(bodyBytes); + + // Build HTTP request + final httpRequest = tasks2.HttpRequest( + body: bodyBase64, + headers: {'Content-Type': 'application/json', ...?options?.headers}, + ); + + // Build the task + final task = tasks2.Task(httpRequest: httpRequest); + + // Set schedule time using pattern matching on DeliverySchedule + switch (options?.schedule) { + case AbsoluteDelivery(:final scheduleTime): + task.scheduleTime = scheduleTime.toUtc().toIso8601String(); + case DelayDelivery(:final scheduleDelaySeconds): + final scheduledTime = DateTime.now().toUtc().add( + Duration(seconds: scheduleDelaySeconds), + ); + task.scheduleTime = scheduledTime.toIso8601String(); + case null: + // No scheduling specified - task will be enqueued immediately + break; + } + + // Set dispatch deadline + if (options?.dispatchDeadlineSeconds != null) { + task.dispatchDeadline = '${options!.dispatchDeadlineSeconds}s'; + } + + // Set task ID (for deduplication) + if (options?.id != null) { + task.name = _httpClient.buildTaskName( + projectId: resources.projectId!, + locationId: resources.locationId!, + queueId: queueId, + taskId: options!.id!, + ); + } + + // Set custom URI if provided (experimental feature) + if (options?.experimental?.uri != null) { + httpRequest.url = options!.experimental!.uri; + } else { + // Use default function URL + httpRequest.url = _httpClient.buildFunctionUrl( + projectId: resources.projectId!, + locationId: resources.locationId!, + functionName: queueId, + ); + } + + // Note: Authentication (OIDC token or Authorization header) is set + // separately via _updateTaskAuth after the task is built. + + return task; + } + + /// Updates the task with proper authentication. + /// + /// This method handles the authentication strategy based on the credential type: + /// - When running with emulator: Uses a default emulated service account email + /// - When running as an extension with ComputeEngine credentials: Uses ID token + /// with Authorization header (Cloud Tasks will not override this) + /// - Otherwise: Uses OIDC token with the service account email + Future _updateTaskAuth( + tasks2.Task task, + googleapis_auth.AuthClient authClient, + String? extensionId, + ) async { + final httpRequest = task.httpRequest!; + + // Check if running with emulator + if (Environment.isCloudTasksEmulatorEnabled()) { + httpRequest.oidcToken = tasks2.OidcToken( + serviceAccountEmail: _emulatedServiceAccountDefault, + ); + return; + } + + // Get the credential associated with the auth client + final credential = authClient.credential; + + // Check if running as an extension with ComputeEngine credentials. + // ComputeEngine credentials are used when running on GCE/Cloud Run without + // a service account JSON file - indicated by credentials without local + // service account credentials (i.e., using metadata server). + final isComputeEngine = + credential != null && credential.serviceAccountCredentials == null; + + if (extensionId != null && extensionId.isNotEmpty && isComputeEngine) { + // Running as extension with ComputeEngine - use ID token with Authorization header. + // This is the same approach as Node.js SDK for Firebase Extensions. + final idToken = authClient.credentials.idToken; + if (idToken != null && idToken.isNotEmpty) { + httpRequest.headers = { + ...?httpRequest.headers, + 'Authorization': 'Bearer $idToken', + }; + // Don't set oidcToken when using Authorization header, + // as Cloud Tasks would overwrite our Authorization header. + httpRequest.oidcToken = null; + return; + } + } + + // Default: Use OIDC token with service account email. + // Try to get service account email from credential first, then from metadata service. + var serviceAccountEmail = credential?.serviceAccountId; + serviceAccountEmail ??= await authClient.getServiceAccountEmail(); + + if (serviceAccountEmail == null || serviceAccountEmail.isEmpty) { + throw FirebaseFunctionsAdminException( + FunctionsClientErrorCode.invalidCredential, + 'Failed to determine service account email. Initialize the SDK with ' + 'service account credentials or ensure you are running on Google Cloud ' + 'infrastructure with a default service account.', + ); + } + + httpRequest.oidcToken = tasks2.OidcToken( + serviceAccountEmail: serviceAccountEmail, + ); + } +} diff --git a/packages/dart_firebase_admin/lib/src/functions/task_queue.dart b/packages/dart_firebase_admin/lib/src/functions/task_queue.dart new file mode 100644 index 00000000..42fc3c1e --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/functions/task_queue.dart @@ -0,0 +1,66 @@ +part of 'functions.dart'; + +/// A reference to a Cloud Functions task queue. +/// +/// Use this to enqueue tasks for a specific Cloud Function or delete +/// pending tasks. +class TaskQueue { + TaskQueue._({ + required String functionName, + required FunctionsRequestHandler requestHandler, + String? extensionId, + }) : _functionName = functionName, + _requestHandler = requestHandler, + _extensionId = extensionId { + validateNonEmptyString(_functionName, 'functionName'); + if (_extensionId != null) { + validateString(_extensionId, 'extensionId'); + } + } + + final String _functionName; + final FunctionsRequestHandler _requestHandler; + final String? _extensionId; + + /// Enqueues a task with the given [data] payload. + /// + /// The [data] will be JSON-encoded and sent to the function. + /// + /// Optional [options] can specify: + /// - Schedule time (absolute or delay) + /// - Dispatch deadline + /// - Task ID (for deduplication) + /// - Custom headers + /// - Custom URI + /// + /// Example: + /// ```dart + /// await queue.enqueue( + /// {'userId': '123', 'action': 'sendEmail'}, + /// TaskOptions( + /// scheduleDelaySeconds: 3600, // Send in 1 hour + /// id: 'unique-task-id', + /// ), + /// ); + /// ``` + /// + /// Throws [FirebaseFunctionsAdminException] if the request fails. + Future enqueue(Map data, [TaskOptions? options]) { + return _requestHandler.enqueue(data, _functionName, _extensionId, options); + } + + /// Deletes a task from the queue by its [id]. + /// + /// A task can only be deleted if it hasn't been executed yet. + /// If the task doesn't exist, this method completes successfully without error. + /// + /// Example: + /// ```dart + /// await queue.delete('unique-task-id'); + /// ``` + /// + /// Throws [FirebaseFunctionsAdminException] if the request fails. + Future delete(String id) { + return _requestHandler.delete(id, _functionName, _extensionId); + } +} diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document_reader.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document_reader.dart index a03583a2..41eeb012 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document_reader.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document_reader.dart @@ -80,8 +80,8 @@ class _DocumentReader { var resultCount = 0; try { final documents = await firestore._client - .v1((client, projectId) async { - return client.projects.databases.documents.batchGet( + .v1((api, projectId) async { + return api.projects.databases.documents.batchGet( request, firestore._formattedDatabaseName, ); diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore_http_client.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore_http_client.dart index e7624c7b..06406915 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore_http_client.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore_http_client.dart @@ -67,7 +67,7 @@ class FirestoreHttpClient { /// Discovers and caches the projectId on first call, then provides it to /// all subsequent operations. This matches the Auth service pattern. Future v1( - Future Function(firestore1.FirestoreApi client, String projectId) fn, + Future Function(firestore1.FirestoreApi api, String projectId) fn, ) => _run( (client, projectId) => fn( firestore1.FirestoreApi(client, rootUrl: _firestoreApiHost.toString()), diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart index 8fb8f0e7..966866f1 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart @@ -83,13 +83,13 @@ final class CollectionReference extends Query { /// document reference (e.g. via [DocumentReference.get]) will return a /// [DocumentSnapshot] whose [DocumentSnapshot.exists] property is `false`. Future>> listDocuments() async { - final response = await firestore._client.v1((client, projectId) { + final response = await firestore._client.v1((api, projectId) { final parentPath = _queryOptions.parentPath._toQualifiedResourcePath( projectId, firestore._databaseId, ); - return client.projects.databases.documents.list( + return api.projects.databases.documents.list( parentPath._formattedName, id, showMissing: true, @@ -1071,8 +1071,8 @@ base class Query { Future> get() => _get(transactionId: null); Future> _get({required String? transactionId}) async { - final response = await firestore._client.v1((client, projectId) async { - return client.projects.databases.documents.runQuery( + final response = await firestore._client.v1((api, projectId) async { + return api.projects.databases.documents.runQuery( _toProto(transactionId: transactionId, readTime: null), _buildProtoParentPath(), ); @@ -1888,8 +1888,8 @@ class AggregateQuery { ), ); - final response = await firestore._client.v1((client, projectId) async { - return client.projects.databases.documents.runAggregationQuery( + final response = await firestore._client.v1((api, projectId) async { + return api.projects.databases.documents.runAggregationQuery( aggregationQuery, query._buildProtoParentPath(), ); diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/transaction.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/transaction.dart index cfcfb54f..7082b6dd 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/transaction.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/transaction.dart @@ -235,8 +235,8 @@ class Transaction { final rollBackRequest = firestore1.RollbackRequest( transaction: transactionId, ); - return _firestore._client.v1((client, projectId) { - return client.projects.databases.documents + return _firestore._client.v1((api, projectId) { + return api.projects.databases.documents .rollback(rollBackRequest, _firestore._formattedDatabaseName) .catchError(_handleException); }); diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/write_batch.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/write_batch.dart index 30a9a57e..32681e5c 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/write_batch.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/write_batch.dart @@ -105,13 +105,13 @@ class WriteBatch { }) async { _commited = true; - return firestore._client.v1((client, projectId) async { + return firestore._client.v1((api, projectId) async { final request = firestore1.CommitRequest( transaction: transactionId, writes: _operations.map((op) => op.op()).toList(), ); - return client.projects.databases.documents.commit( + return api.projects.databases.documents.commit( request, firestore._formattedDatabaseName, ); diff --git a/packages/dart_firebase_admin/lib/src/messaging/messaging_http_client.dart b/packages/dart_firebase_admin/lib/src/messaging/messaging_http_client.dart index d9c21405..c5c4d400 100644 --- a/packages/dart_firebase_admin/lib/src/messaging/messaging_http_client.dart +++ b/packages/dart_firebase_admin/lib/src/messaging/messaging_http_client.dart @@ -40,8 +40,7 @@ class FirebaseMessagingHttpClient { /// Executes a Messaging v1 API operation with automatic projectId injection. Future v1( - Future Function(fmc1.FirebaseCloudMessagingApi client, String projectId) - fn, + Future Function(fmc1.FirebaseCloudMessagingApi api, String projectId) fn, ) => _run( (client, projectId) => fn(fmc1.FirebaseCloudMessagingApi(client), projectId), diff --git a/packages/dart_firebase_admin/lib/src/messaging/messaging_request_handler.dart b/packages/dart_firebase_admin/lib/src/messaging/messaging_request_handler.dart index 0e741d21..e721237e 100644 --- a/packages/dart_firebase_admin/lib/src/messaging/messaging_request_handler.dart +++ b/packages/dart_firebase_admin/lib/src/messaging/messaging_request_handler.dart @@ -21,9 +21,9 @@ class FirebaseMessagingRequestHandler { /// Returns a unique message ID string after the message has been successfully /// handed off to the FCM service for delivery. Future send(Message message, {bool? dryRun}) { - return _httpClient.v1((client, projectId) async { + return _httpClient.v1((api, projectId) async { final parent = _httpClient.buildParent(projectId); - final response = await client.projects.messages.send( + final response = await api.projects.messages.send( fmc1.SendMessageRequest( message: message._toRequest(), validateOnly: dryRun, @@ -58,7 +58,7 @@ class FirebaseMessagingRequestHandler { /// - [dryRun]: Whether to send the messages in the dry-run /// (validation only) mode. Future sendEach(List messages, {bool? dryRun}) { - return _httpClient.v1((client, projectId) async { + return _httpClient.v1((api, projectId) async { if (messages.isEmpty) { throw FirebaseMessagingAdminException( MessagingClientErrorCode.invalidArgument, @@ -75,7 +75,7 @@ class FirebaseMessagingRequestHandler { final parent = _httpClient.buildParent(projectId); final responses = await Future.wait( messages.map((message) async { - final response = client.projects.messages.send( + final response = api.projects.messages.send( fmc1.SendMessageRequest( message: message._toRequest(), validateOnly: dryRun, diff --git a/packages/dart_firebase_admin/lib/src/security_rules/security_rules_http_client.dart b/packages/dart_firebase_admin/lib/src/security_rules/security_rules_http_client.dart index 4f5d7409..86bb903d 100644 --- a/packages/dart_firebase_admin/lib/src/security_rules/security_rules_http_client.dart +++ b/packages/dart_firebase_admin/lib/src/security_rules/security_rules_http_client.dart @@ -69,10 +69,7 @@ class SecurityRulesHttpClient { /// Executes a Security Rules v1 API operation with automatic projectId injection. Future v1( - Future Function( - firebase_rules_v1.FirebaseRulesApi client, - String projectId, - ) + Future Function(firebase_rules_v1.FirebaseRulesApi api, String projectId) fn, ) => _run( (client, projectId) => diff --git a/packages/dart_firebase_admin/lib/src/utils/validator.dart b/packages/dart_firebase_admin/lib/src/utils/validator.dart index 85e8c579..8a1f7e89 100644 --- a/packages/dart_firebase_admin/lib/src/utils/validator.dart +++ b/packages/dart_firebase_admin/lib/src/utils/validator.dart @@ -59,3 +59,66 @@ bool isTopic(Object? topic) { ); return validTopicRegExp.hasMatch(topic); } + +/// Validates that a value is a string. +@internal +bool isString(Object? value) => value is String; + +/// Validates that a value is a non-empty string. +@internal +bool isNonEmptyString(Object? value) => value is String && value.isNotEmpty; + +/// Validates that a string is a non-empty string. Throws otherwise. +@internal +void validateNonEmptyString(Object? value, String name) { + if (!isNonEmptyString(value)) { + throw ArgumentError('$name must be a non-empty string'); + } +} + +/// Validates that a value is a string. Throws otherwise. +@internal +void validateString(Object? value, String name) { + if (!isString(value)) { + throw ArgumentError('$name must be a string'); + } +} + +/// Validates that a string is a valid URL. +@internal +bool isURL(String? urlStr) { + if (urlStr == null || urlStr.isEmpty) return false; + + // Check for illegal characters + final illegalChars = RegExp( + r'[^a-z0-9:/?#[\]@!$&' + "'" + r'()*+,;=.\-_~%]', + caseSensitive: false, + ); + if (illegalChars.hasMatch(urlStr)) { + return false; + } + + try { + final uri = Uri.parse(urlStr); + // Must have a scheme (http, https, etc.) + return uri.hasScheme && uri.host.isNotEmpty; + } catch (e) { + return false; + } +} + +/// Validates that a string is a valid task ID. +/// +/// Task IDs can only contain letters (A-Za-z), numbers (0-9), +/// hyphens (-), or underscores (_). Maximum length is 500 characters. +@internal +bool isValidTaskId(String? taskId) { + if (taskId == null || taskId.isEmpty || taskId.length > 500) { + return false; + } + + final validTaskIdRegex = RegExp(r'^[A-Za-z0-9_-]+$'); + return validTaskIdRegex.hasMatch(taskId); +} diff --git a/packages/dart_firebase_admin/test/functions/functions_integration_test.dart b/packages/dart_firebase_admin/test/functions/functions_integration_test.dart new file mode 100644 index 00000000..4010b3c5 --- /dev/null +++ b/packages/dart_firebase_admin/test/functions/functions_integration_test.dart @@ -0,0 +1,137 @@ +import 'package:dart_firebase_admin/functions.dart'; +import 'package:test/test.dart'; + +import 'util/helpers.dart'; + +void main() { + group('Functions Integration Tests', () { + setUpAll(ensureCloudTasksEmulatorConfigured); + + group('TaskQueue', () { + late Functions functions; + + setUp(() { + functions = createFunctionsForTest(); + }); + + group('enqueue', () { + test('enqueues a simple task', () async { + final queue = functions.taskQueue('helloWorld'); + + // Should not throw + await queue.enqueue({'message': 'Hello from integration test'}); + }); + + test('enqueues a task with delay', () async { + final queue = functions.taskQueue('helloWorld'); + + await queue.enqueue({ + 'message': 'Delayed task', + }, TaskOptions(schedule: DelayDelivery(30))); + }); + + test('enqueues a task with absolute schedule time', () async { + final queue = functions.taskQueue('helloWorld'); + + final scheduleTime = DateTime.now().add(const Duration(minutes: 5)); + await queue.enqueue({ + 'message': 'Scheduled task', + }, TaskOptions(schedule: AbsoluteDelivery(scheduleTime))); + }); + + test('enqueues a task with custom ID', () async { + final queue = functions.taskQueue('helloWorld'); + final taskId = 'test-task-${DateTime.now().millisecondsSinceEpoch}'; + + await queue.enqueue({ + 'message': 'Task with custom ID', + }, TaskOptions(id: taskId)); + + // Clean up - delete the task + await queue.delete(taskId); + }); + + test('enqueues a task with custom headers', () async { + final queue = functions.taskQueue('helloWorld'); + + await queue.enqueue({ + 'message': 'Task with headers', + }, TaskOptions(headers: {'X-Custom-Header': 'custom-value'})); + }); + + test('enqueues a task with dispatch deadline', () async { + final queue = functions.taskQueue('helloWorld'); + + await queue.enqueue({ + 'message': 'Task with deadline', + }, TaskOptions(dispatchDeadlineSeconds: 300)); + }); + }); + + group('delete', () { + test('deletes an existing task', () async { + final queue = functions.taskQueue('helloWorld'); + final taskId = 'delete-test-${DateTime.now().millisecondsSinceEpoch}'; + + // First enqueue a task with a known ID + await queue.enqueue({ + 'message': 'Task to delete', + }, TaskOptions(id: taskId)); + + // Then delete it - should not throw + await queue.delete(taskId); + }); + + test('succeeds silently when deleting non-existent task', () async { + final queue = functions.taskQueue('helloWorld'); + + // Should not throw even though task doesn't exist + await queue.delete('non-existent-task-id'); + }); + }); + + group('validation', () { + test('throws on invalid task ID format', () async { + final queue = functions.taskQueue('helloWorld'); + + expect( + () => queue.delete('invalid/task/id'), + throwsA(isA()), + ); + }); + + test('throws on empty task ID', () async { + final queue = functions.taskQueue('helloWorld'); + + expect(() => queue.delete(''), throwsA(isA())); + }); + + test('throws on empty function name', () { + expect(() => functions.taskQueue(''), throwsA(isA())); + }); + + test('throws on invalid dispatch deadline (too low)', () { + final queue = functions.taskQueue('helloWorld'); + + expect( + () => queue.enqueue({ + 'data': 'test', + }, TaskOptions(dispatchDeadlineSeconds: 10)), + throwsA(isA()), + ); + }); + + test('throws on invalid dispatch deadline (too high)', () { + final queue = functions.taskQueue('helloWorld'); + + expect( + () => queue.enqueue({ + 'data': 'test', + }, TaskOptions(dispatchDeadlineSeconds: 2000)), + throwsA(isA()), + ); + }); + }); + }); + }); +} diff --git a/packages/dart_firebase_admin/test/functions/functions_test.dart b/packages/dart_firebase_admin/test/functions/functions_test.dart new file mode 100644 index 00000000..5311999e --- /dev/null +++ b/packages/dart_firebase_admin/test/functions/functions_test.dart @@ -0,0 +1,1217 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dart_firebase_admin/functions.dart'; +import 'package:dart_firebase_admin/src/app.dart'; +import 'package:googleapis_auth/auth_io.dart' as auth; +import 'package:googleapis_auth_utils/googleapis_auth_utils.dart'; +import 'package:http/http.dart'; +import 'package:http/testing.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +import '../google_cloud_firestore/util/helpers.dart'; +import '../mock_service_account.dart'; +import 'util/helpers.dart'; + +// ============================================================================= +// Mocks and Test Utilities +// ============================================================================= + +class MockRequestHandler extends Mock implements FunctionsRequestHandler {} + +class MockAuthClient extends Mock implements auth.AuthClient {} + +class FakeBaseRequest extends Fake implements BaseRequest {} + +/// Creates a mock HTTP client that handles OAuth token requests and +/// optionally Cloud Tasks API requests. +MockClient createMockHttpClient({ + String? idToken, + Response Function(Request)? apiHandler, +}) { + return MockClient((request) async { + // Handle OAuth token endpoint (JWT flow) + if (request.url.toString().contains('oauth2') || + request.url.toString().contains('token')) { + return Response( + jsonEncode({ + 'access_token': 'mock-access-token', + 'expires_in': 3600, + 'token_type': 'Bearer', + if (idToken != null) 'id_token': idToken, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + + // Handle Cloud Tasks API requests + if (request.url.toString().contains('cloudtasks')) { + if (apiHandler != null) { + return apiHandler(request); + } + // Default: successful task creation + return Response( + jsonEncode({ + 'name': 'projects/test/locations/us-central1/queues/q/tasks/123', + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + + // Default response + return Response('{}', 200); + }); +} + +/// Creates an AuthClient with service account credentials for testing. +/// +/// This creates a real AuthClient properly associated with a GoogleCredential, +/// so extension methods like `credential` and `getServiceAccountEmail()` work. +Future createTestAuthClient({ + required String email, + String? idToken, + Response Function(Request)? apiHandler, +}) async { + final baseClient = createMockHttpClient( + idToken: idToken, + apiHandler: apiHandler, + ); + + // Create real credential from service account parameters + final credential = GoogleCredential.fromServiceAccountParams( + privateKey: mockPrivateKey, + email: email, + clientId: 'test-client-id', + projectId: projectId, + ); + + // Create real auth client (properly associated with credential via Expando) + return createAuthClient(credential, [ + 'https://www.googleapis.com/auth/cloud-platform', + ], baseClient: baseClient); +} + +// ============================================================================= +// Tests +// ============================================================================= + +void main() { + setUpAll(() { + registerFallbackValue(FakeBaseRequest()); + }); + + // =========================================================================== + // Functions and TaskQueue Tests (with mocked handler) + // =========================================================================== + group('Functions', () { + late MockRequestHandler mockHandler; + late Functions functions; + + setUp(() { + mockHandler = MockRequestHandler(); + functions = createFunctionsWithMockHandler(mockHandler); + }); + + group('taskQueue', () { + test('creates TaskQueue with function name', () { + final queue = functions.taskQueue('helloWorld'); + expect(queue, isNotNull); + }); + + test('creates TaskQueue with full resource name', () { + final queue = functions.taskQueue( + 'projects/my-project/locations/us-central1/functions/helloWorld', + ); + expect(queue, isNotNull); + }); + + test('creates TaskQueue with partial resource name', () { + final queue = functions.taskQueue( + 'locations/us-east1/functions/helloWorld', + ); + expect(queue, isNotNull); + }); + + test('creates TaskQueue with extension ID', () { + final queue = functions.taskQueue( + 'helloWorld', + extensionId: 'my-extension', + ); + expect(queue, isNotNull); + }); + + test('throws on empty function name', () { + expect(() => functions.taskQueue(''), throwsA(isA())); + }); + }); + + group('TaskQueue.enqueue', () { + test('enqueues task with data', () async { + when( + () => mockHandler.enqueue(any(), any(), any(), any()), + ).thenAnswer((_) async {}); + + final queue = functions.taskQueue('helloWorld'); + await queue.enqueue({'message': 'Hello, World!'}); + + verify( + () => mockHandler.enqueue( + {'message': 'Hello, World!'}, + 'helloWorld', + null, + null, + ), + ).called(1); + }); + + test('enqueues task with schedule delay', () async { + when( + () => mockHandler.enqueue(any(), any(), any(), any()), + ).thenAnswer((_) async {}); + + final queue = functions.taskQueue('helloWorld'); + final options = TaskOptions(schedule: DelayDelivery(60)); + + await queue.enqueue({'message': 'Delayed task'}, options); + + verify( + () => mockHandler.enqueue( + {'message': 'Delayed task'}, + 'helloWorld', + null, + options, + ), + ).called(1); + }); + + test('enqueues task with absolute schedule time', () async { + when( + () => mockHandler.enqueue(any(), any(), any(), any()), + ).thenAnswer((_) async {}); + + final queue = functions.taskQueue('helloWorld'); + final scheduleTime = DateTime.now().add(const Duration(hours: 1)); + final options = TaskOptions(schedule: AbsoluteDelivery(scheduleTime)); + + await queue.enqueue({'message': 'Scheduled task'}, options); + + verify( + () => mockHandler.enqueue( + {'message': 'Scheduled task'}, + 'helloWorld', + null, + options, + ), + ).called(1); + }); + + test('enqueues task with custom ID', () async { + when( + () => mockHandler.enqueue(any(), any(), any(), any()), + ).thenAnswer((_) async {}); + + final queue = functions.taskQueue('helloWorld'); + final options = TaskOptions(id: 'my-custom-id'); + + await queue.enqueue({'message': 'Task with ID'}, options); + + verify( + () => mockHandler.enqueue( + {'message': 'Task with ID'}, + 'helloWorld', + null, + options, + ), + ).called(1); + }); + + test('enqueues task with extension ID', () async { + when( + () => mockHandler.enqueue(any(), any(), any(), any()), + ).thenAnswer((_) async {}); + + final queue = functions.taskQueue( + 'helloWorld', + extensionId: 'my-extension', + ); + await queue.enqueue({'data': 'test'}); + + verify( + () => mockHandler.enqueue( + {'data': 'test'}, + 'helloWorld', + 'my-extension', + null, + ), + ).called(1); + }); + + test('throws on duplicate task ID (409 conflict)', () async { + when(() => mockHandler.enqueue(any(), any(), any(), any())).thenThrow( + FirebaseFunctionsAdminException( + FunctionsClientErrorCode.taskAlreadyExists, + 'Task already exists', + ), + ); + + final queue = functions.taskQueue('helloWorld'); + + expect( + () => + queue.enqueue({'data': 'test'}, TaskOptions(id: 'duplicate-id')), + throwsA(isA()), + ); + }); + }); + + group('TaskQueue.delete', () { + test('deletes task by ID', () async { + when( + () => mockHandler.delete(any(), any(), any()), + ).thenAnswer((_) async {}); + + final queue = functions.taskQueue('helloWorld'); + await queue.delete('task-to-delete'); + + verify( + () => mockHandler.delete('task-to-delete', 'helloWorld', null), + ).called(1); + }); + + test('deletes task with extension ID', () async { + when( + () => mockHandler.delete(any(), any(), any()), + ).thenAnswer((_) async {}); + + final queue = functions.taskQueue( + 'helloWorld', + extensionId: 'my-extension', + ); + await queue.delete('task-id'); + + verify( + () => mockHandler.delete('task-id', 'helloWorld', 'my-extension'), + ).called(1); + }); + + test('succeeds silently when task not found (404)', () async { + when( + () => mockHandler.delete(any(), any(), any()), + ).thenAnswer((_) async {}); + + final queue = functions.taskQueue('helloWorld'); + await queue.delete('non-existent-task'); + + verify( + () => mockHandler.delete('non-existent-task', 'helloWorld', null), + ).called(1); + }); + + test('throws on empty task ID', () async { + when( + () => mockHandler.delete(any(), any(), any()), + ).thenThrow(ArgumentError('id must be a non-empty string')); + + final queue = functions.taskQueue('helloWorld'); + + expect(() => queue.delete(''), throwsA(isA())); + }); + + test('throws on invalid task ID format', () async { + when(() => mockHandler.delete(any(), any(), any())).thenThrow( + FirebaseFunctionsAdminException( + FunctionsClientErrorCode.invalidArgument, + 'Invalid task ID format', + ), + ); + + final queue = functions.taskQueue('helloWorld'); + + expect( + () => queue.delete('invalid/task/id'), + throwsA(isA()), + ); + }); + }); + }); + + // =========================================================================== + // FunctionsRequestHandler Validation Tests + // =========================================================================== + group('FunctionsRequestHandler', () { + late MockAuthClient mockClient; + late FunctionsRequestHandler handler; + late FunctionsHttpClient httpClient; + + setUp(() { + mockClient = MockAuthClient(); + + final app = FirebaseApp.initializeApp( + name: 'handler-test-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId, httpClient: mockClient), + ); + + httpClient = FunctionsHttpClient(app); + handler = FunctionsRequestHandler(app, httpClient: httpClient); + + addTearDown(() async { + await app.close(); + }); + }); + + group('enqueue validation', () { + test('throws on empty function name', () { + expect( + () => handler.enqueue({}, '', null, null), + throwsA(isA()), + ); + }); + + test('throws on invalid function name format', () { + expect( + () => handler.enqueue( + {}, + 'project/abc/locations/east/fname', + null, + null, + ), + throwsA(isA()), + ); + }); + + test('throws on invalid function name with double slashes', () { + expect( + () => handler.enqueue({}, '//', null, null), + throwsA(isA()), + ); + }); + + test('throws on function name with trailing slash', () { + expect( + () => handler.enqueue({}, 'location/west/', null, null), + throwsA(isA()), + ); + }); + }); + + group('delete validation', () { + test('throws on empty task ID', () { + expect( + () => handler.delete('', 'helloWorld', null), + throwsA(isA()), + ); + }); + + test('throws on empty function name', () { + expect( + () => handler.delete('task-id', '', null), + throwsA(isA()), + ); + }); + + test('throws on invalid task ID with special characters', () { + expect( + () => handler.delete('task!', 'helloWorld', null), + throwsA(isA()), + ); + }); + + test('throws on invalid task ID with colons', () { + expect( + () => handler.delete('id:0', 'helloWorld', null), + throwsA(isA()), + ); + }); + + test('throws on invalid task ID with brackets', () { + expect( + () => handler.delete('[1234]', 'helloWorld', null), + throwsA(isA()), + ); + }); + + test('throws on invalid task ID with parentheses', () { + expect( + () => handler.delete('(1234)', 'helloWorld', null), + throwsA(isA()), + ); + }); + + test('throws on invalid task ID with slashes', () { + expect( + () => handler.delete('invalid/task/id', 'helloWorld', null), + throwsA(isA()), + ); + }); + }); + }); + + // =========================================================================== + // TaskOptions Validation Tests + // =========================================================================== + group('TaskOptions validation', () { + group('dispatchDeadlineSeconds', () { + test('throws on dispatch deadline too low (14)', () { + expect( + () => TaskOptions(dispatchDeadlineSeconds: 14), + throwsA(isA()), + ); + }); + + test('throws on dispatch deadline too high (1801)', () { + expect( + () => TaskOptions(dispatchDeadlineSeconds: 1801), + throwsA(isA()), + ); + }); + + test('throws on dispatch deadline exactly at boundary (10)', () { + expect( + () => TaskOptions(dispatchDeadlineSeconds: 10), + throwsA(isA()), + ); + }); + + test('throws on dispatch deadline exactly at boundary (2000)', () { + expect( + () => TaskOptions(dispatchDeadlineSeconds: 2000), + throwsA(isA()), + ); + }); + + test('throws on negative dispatch deadline', () { + expect( + () => TaskOptions(dispatchDeadlineSeconds: -1), + throwsA(isA()), + ); + }); + + test('accepts dispatch deadline at minimum (15)', () { + expect(() => TaskOptions(dispatchDeadlineSeconds: 15), returnsNormally); + }); + + test('accepts dispatch deadline at maximum (1800)', () { + expect( + () => TaskOptions(dispatchDeadlineSeconds: 1800), + returnsNormally, + ); + }); + + test('accepts valid dispatch deadline (300)', () { + expect( + () => TaskOptions(dispatchDeadlineSeconds: 300), + returnsNormally, + ); + }); + }); + + group('id', () { + test('throws on invalid task ID format', () { + expect( + () => TaskOptions(id: 'task!invalid'), + throwsA(isA()), + ); + }); + + test('throws on empty task ID', () { + expect( + () => TaskOptions(id: ''), + throwsA(isA()), + ); + }); + + test('throws on task ID with colons', () { + expect( + () => TaskOptions(id: 'id:0'), + throwsA(isA()), + ); + }); + + test('throws on task ID with brackets', () { + expect( + () => TaskOptions(id: '[1234]'), + throwsA(isA()), + ); + }); + + test('throws on task ID with parentheses', () { + expect( + () => TaskOptions(id: '(1234)'), + throwsA(isA()), + ); + }); + + test('throws on task ID exceeding 500 characters', () { + final longId = 'a' * 501; + expect( + () => TaskOptions(id: longId), + throwsA(isA()), + ); + }); + + test( + 'accepts valid task ID with letters, numbers, hyphens, underscores', + () { + expect(() => TaskOptions(id: 'valid-task-id_123'), returnsNormally); + }, + ); + + test('accepts task ID at maximum length (500)', () { + final maxId = 'a' * 500; + expect(() => TaskOptions(id: maxId), returnsNormally); + }); + }); + + group('scheduleDelaySeconds', () { + test('throws on negative scheduleDelaySeconds', () { + expect( + () => TaskOptions(schedule: DelayDelivery(-1)), + throwsA(isA()), + ); + }); + + test('accepts scheduleDelaySeconds of 0', () { + expect(() => TaskOptions(schedule: DelayDelivery(0)), returnsNormally); + }); + + test('accepts positive scheduleDelaySeconds', () { + expect( + () => TaskOptions(schedule: DelayDelivery(3600)), + returnsNormally, + ); + }); + }); + }); + + // =========================================================================== + // Task Authentication Tests (_updateTaskAuth) + // =========================================================================== + group('Task Authentication', () { + group('emulator mode', () { + test('uses emulated service account when emulator is enabled', () async { + Map? capturedTaskBody; + + // Create an auth client that captures requests + final authClient = await createTestAuthClient( + email: mockClientEmail, + apiHandler: (request) { + capturedTaskBody = jsonDecode(request.body) as Map; + return Response( + jsonEncode({'name': 'task/123'}), + 200, + headers: {'content-type': 'application/json'}, + ); + }, + ); + + await runZoned( + () async { + final app = FirebaseApp.initializeApp( + name: 'emulator-test-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId, httpClient: authClient), + ); + + try { + final functions = Functions(app); + final queue = functions.taskQueue('helloWorld'); + await queue.enqueue({'data': 'test'}); + + expect(capturedTaskBody, isNotNull); + final task = capturedTaskBody!['task'] as Map; + final httpRequest = task['httpRequest'] as Map; + final oidcToken = + httpRequest['oidcToken'] as Map?; + + expect(oidcToken, isNotNull); + // When emulator is enabled, uses the default emulated service account + expect( + oidcToken!['serviceAccountEmail'], + equals('emulated-service-acct@email.com'), + ); + } finally { + await app.close(); + } + }, + zoneValues: { + envSymbol: {'CLOUD_TASKS_EMULATOR_HOST': 'localhost:9499'}, + }, + ); + }); + }); + + group('production mode with service account credentials', () { + test('uses service account email from credential for OIDC token', () async { + Map? capturedTaskBody; + + final authClient = await createTestAuthClient( + email: mockClientEmail, + apiHandler: (request) { + capturedTaskBody = jsonDecode(request.body) as Map; + return Response( + jsonEncode({'name': 'task/123'}), + 200, + headers: {'content-type': 'application/json'}, + ); + }, + ); + + // Use runZoned to disable emulator env var (set by firebase emulators:exec) + await runZoned(() async { + final app = FirebaseApp.initializeApp( + name: 'sa-test-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId, httpClient: authClient), + ); + + try { + final functions = Functions(app); + final queue = functions.taskQueue('helloWorld'); + await queue.enqueue({'data': 'test'}); + + expect(capturedTaskBody, isNotNull); + final task = capturedTaskBody!['task'] as Map; + final httpRequest = task['httpRequest'] as Map; + final oidcToken = httpRequest['oidcToken'] as Map?; + + expect(oidcToken, isNotNull); + expect(oidcToken!['serviceAccountEmail'], equals(mockClientEmail)); + + // Should NOT have Authorization header (that's for extensions) + expect( + (httpRequest['headers'] + as Map?)?['Authorization'], + isNull, + ); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: {}}); + }); + + test('sets correct function URL in task', () async { + Map? capturedTaskBody; + + final authClient = await createTestAuthClient( + email: mockClientEmail, + apiHandler: (request) { + capturedTaskBody = jsonDecode(request.body) as Map; + return Response( + jsonEncode({'name': 'task/123'}), + 200, + headers: {'content-type': 'application/json'}, + ); + }, + ); + + final app = FirebaseApp.initializeApp( + name: 'url-test-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId, httpClient: authClient), + ); + + try { + final functions = Functions(app); + final queue = functions.taskQueue('helloWorld'); + await queue.enqueue({'data': 'test'}); + + expect(capturedTaskBody, isNotNull); + final task = capturedTaskBody!['task'] as Map; + final httpRequest = task['httpRequest'] as Map; + + expect( + httpRequest['url'], + equals( + 'https://us-central1-$projectId.cloudfunctions.net/helloWorld', + ), + ); + } finally { + await app.close(); + } + }); + + test('uses custom location from partial resource name', () async { + Map? capturedTaskBody; + String? capturedUrl; + + final authClient = await createTestAuthClient( + email: mockClientEmail, + apiHandler: (request) { + capturedUrl = request.url.toString(); + capturedTaskBody = jsonDecode(request.body) as Map; + return Response( + jsonEncode({'name': 'task/123'}), + 200, + headers: {'content-type': 'application/json'}, + ); + }, + ); + + final app = FirebaseApp.initializeApp( + name: 'partial-test-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId, httpClient: authClient), + ); + + try { + final functions = Functions(app); + final queue = functions.taskQueue( + 'locations/us-west1/functions/myFunc', + ); + await queue.enqueue({'data': 'test'}); + + expect(capturedUrl, contains('us-west1')); + expect(capturedUrl, contains('myFunc')); + + final task = capturedTaskBody!['task'] as Map; + final httpRequest = task['httpRequest'] as Map; + expect( + httpRequest['url'], + equals('https://us-west1-$projectId.cloudfunctions.net/myFunc'), + ); + } finally { + await app.close(); + } + }); + + test('uses project and location from full resource name', () async { + Map? capturedTaskBody; + String? capturedUrl; + + final authClient = await createTestAuthClient( + email: mockClientEmail, + apiHandler: (request) { + capturedUrl = request.url.toString(); + capturedTaskBody = jsonDecode(request.body) as Map; + return Response( + jsonEncode({'name': 'task/123'}), + 200, + headers: {'content-type': 'application/json'}, + ); + }, + ); + + final app = FirebaseApp.initializeApp( + name: 'full-test-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId, httpClient: authClient), + ); + + try { + final functions = Functions(app); + final queue = functions.taskQueue( + 'projects/custom-project/locations/europe-west1/functions/euroFunc', + ); + await queue.enqueue({'data': 'test'}); + + expect(capturedUrl, contains('custom-project')); + expect(capturedUrl, contains('europe-west1')); + expect(capturedUrl, contains('euroFunc')); + + final task = capturedTaskBody!['task'] as Map; + final httpRequest = task['httpRequest'] as Map; + expect( + httpRequest['url'], + equals( + 'https://europe-west1-custom-project.cloudfunctions.net/euroFunc', + ), + ); + } finally { + await app.close(); + } + }); + }); + + group('extension support', () { + test('prefixes queue name with extension ID', () async { + String? capturedUrl; + + final authClient = await createTestAuthClient( + email: mockClientEmail, + idToken: 'mock-id-token', + apiHandler: (request) { + capturedUrl = request.url.toString(); + return Response( + jsonEncode({'name': 'task/123'}), + 200, + headers: {'content-type': 'application/json'}, + ); + }, + ); + + final app = FirebaseApp.initializeApp( + name: 'ext-test-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId, httpClient: authClient), + ); + + try { + final functions = Functions(app); + final queue = functions.taskQueue( + 'helloWorld', + extensionId: 'my-extension', + ); + await queue.enqueue({'data': 'test'}); + + expect(capturedUrl, contains('ext-my-extension-helloWorld')); + } finally { + await app.close(); + } + }); + + test('prefixes function URL with extension ID', () async { + Map? capturedTaskBody; + + final authClient = await createTestAuthClient( + email: mockClientEmail, + apiHandler: (request) { + capturedTaskBody = jsonDecode(request.body) as Map; + return Response( + jsonEncode({'name': 'task/123'}), + 200, + headers: {'content-type': 'application/json'}, + ); + }, + ); + + final app = FirebaseApp.initializeApp( + name: 'ext-url-test-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId, httpClient: authClient), + ); + + try { + final functions = Functions(app); + final queue = functions.taskQueue( + 'helloWorld', + extensionId: 'image-resize', + ); + await queue.enqueue({'data': 'test'}); + + final task = capturedTaskBody!['task'] as Map; + final httpRequest = task['httpRequest'] as Map; + + expect( + httpRequest['url'], + equals( + 'https://us-central1-$projectId.cloudfunctions.net/ext-image-resize-helloWorld', + ), + ); + } finally { + await app.close(); + } + }); + }); + }); + + // =========================================================================== + // Task Options Serialization Tests + // =========================================================================== + group('Task Options Serialization', () { + test('converts scheduleTime to ISO string', () async { + Map? capturedTaskBody; + final scheduleTime = DateTime.now().add(const Duration(hours: 1)); + + final authClient = await createTestAuthClient( + email: mockClientEmail, + apiHandler: (request) { + capturedTaskBody = jsonDecode(request.body) as Map; + return Response( + jsonEncode({'name': 'task/123'}), + 200, + headers: {'content-type': 'application/json'}, + ); + }, + ); + + final app = FirebaseApp.initializeApp( + name: 'schedule-test-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId, httpClient: authClient), + ); + + try { + final functions = Functions(app); + final queue = functions.taskQueue('helloWorld'); + final options = TaskOptions(schedule: AbsoluteDelivery(scheduleTime)); + await queue.enqueue({'data': 'test'}, options); + + final task = capturedTaskBody!['task'] as Map; + expect( + task['scheduleTime'], + equals(scheduleTime.toUtc().toIso8601String()), + ); + } finally { + await app.close(); + } + }); + + test('sets scheduleTime based on scheduleDelaySeconds', () async { + Map? capturedTaskBody; + const delaySeconds = 1800; + + final authClient = await createTestAuthClient( + email: mockClientEmail, + apiHandler: (request) { + capturedTaskBody = jsonDecode(request.body) as Map; + return Response( + jsonEncode({'name': 'task/123'}), + 200, + headers: {'content-type': 'application/json'}, + ); + }, + ); + + final app = FirebaseApp.initializeApp( + name: 'delay-test-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId, httpClient: authClient), + ); + + try { + final now = DateTime.now().toUtc(); + final functions = Functions(app); + final queue = functions.taskQueue('helloWorld'); + final options = TaskOptions(schedule: DelayDelivery(delaySeconds)); + await queue.enqueue({'data': 'test'}, options); + + final task = capturedTaskBody!['task'] as Map; + final scheduleTimeStr = task['scheduleTime'] as String; + final scheduleTime = DateTime.parse(scheduleTimeStr); + + // Should be approximately now + delaySeconds (allow 5 second tolerance) + final expectedTime = now.add(const Duration(seconds: delaySeconds)); + expect( + scheduleTime.difference(expectedTime).inSeconds.abs(), + lessThan(5), + ); + } finally { + await app.close(); + } + }); + + test('converts dispatchDeadline to duration with s suffix', () async { + Map? capturedTaskBody; + const dispatchDeadlineSeconds = 300; + + final authClient = await createTestAuthClient( + email: mockClientEmail, + apiHandler: (request) { + capturedTaskBody = jsonDecode(request.body) as Map; + return Response( + jsonEncode({'name': 'task/123'}), + 200, + headers: {'content-type': 'application/json'}, + ); + }, + ); + + final app = FirebaseApp.initializeApp( + name: 'deadline-test-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId, httpClient: authClient), + ); + + try { + final functions = Functions(app); + final queue = functions.taskQueue('helloWorld'); + final options = TaskOptions( + dispatchDeadlineSeconds: dispatchDeadlineSeconds, + ); + await queue.enqueue({'data': 'test'}, options); + + final task = capturedTaskBody!['task'] as Map; + expect(task['dispatchDeadline'], equals('${dispatchDeadlineSeconds}s')); + } finally { + await app.close(); + } + }); + + test('encodes data in base64 payload', () async { + Map? capturedTaskBody; + final testData = {'privateKey': '~/.ssh/id_rsa.pub', 'count': 42}; + + final authClient = await createTestAuthClient( + email: mockClientEmail, + apiHandler: (request) { + capturedTaskBody = jsonDecode(request.body) as Map; + return Response( + jsonEncode({'name': 'task/123'}), + 200, + headers: {'content-type': 'application/json'}, + ); + }, + ); + + final app = FirebaseApp.initializeApp( + name: 'encode-test-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId, httpClient: authClient), + ); + + try { + final functions = Functions(app); + final queue = functions.taskQueue('helloWorld'); + await queue.enqueue(testData); + + final task = capturedTaskBody!['task'] as Map; + final httpRequest = task['httpRequest'] as Map; + final bodyBase64 = httpRequest['body'] as String; + + final decodedBytes = base64Decode(bodyBase64); + final decodedJson = jsonDecode(utf8.decode(decodedBytes)); + expect((decodedJson as Map)['data'], equals(testData)); + } finally { + await app.close(); + } + }); + + test('sets task name when ID is provided', () async { + Map? capturedTaskBody; + const taskId = 'my-custom-task-id'; + + final authClient = await createTestAuthClient( + email: mockClientEmail, + apiHandler: (request) { + capturedTaskBody = jsonDecode(request.body) as Map; + return Response( + jsonEncode({'name': 'task/123'}), + 200, + headers: {'content-type': 'application/json'}, + ); + }, + ); + + final app = FirebaseApp.initializeApp( + name: 'id-test-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId, httpClient: authClient), + ); + + try { + final functions = Functions(app); + final queue = functions.taskQueue('helloWorld'); + final options = TaskOptions(id: taskId); + await queue.enqueue({'data': 'test'}, options); + + final task = capturedTaskBody!['task'] as Map; + expect(task['name'], contains(taskId)); + expect(task['name'], contains('helloWorld')); + } finally { + await app.close(); + } + }); + }); + + // =========================================================================== + // Error Handling Tests + // =========================================================================== + group('Error Handling', () { + test('throws task-already-exists on 409 conflict', () async { + final authClient = await createTestAuthClient( + email: mockClientEmail, + apiHandler: (request) { + return Response( + jsonEncode({ + 'error': { + 'code': 409, + 'message': 'Task already exists', + 'status': 'ALREADY_EXISTS', + }, + }), + 409, + headers: {'content-type': 'application/json'}, + ); + }, + ); + + final app = FirebaseApp.initializeApp( + name: 'conflict-test-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId, httpClient: authClient), + ); + + try { + final functions = Functions(app); + final queue = functions.taskQueue('helloWorld'); + + expect( + () => + queue.enqueue({'data': 'test'}, TaskOptions(id: 'duplicate-id')), + throwsA( + isA().having( + (e) => e.errorCode, + 'errorCode', + FunctionsClientErrorCode.taskAlreadyExists, + ), + ), + ); + } finally { + await app.close(); + } + }); + + test('throws not-found on 404 error for enqueue', () async { + final authClient = await createTestAuthClient( + email: mockClientEmail, + apiHandler: (request) { + return Response( + jsonEncode({ + 'error': { + 'code': 404, + 'message': 'Queue not found', + 'status': 'NOT_FOUND', + }, + }), + 404, + headers: {'content-type': 'application/json'}, + ); + }, + ); + + final app = FirebaseApp.initializeApp( + name: 'notfound-test-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId, httpClient: authClient), + ); + + try { + final functions = Functions(app); + final queue = functions.taskQueue('nonExistentQueue'); + + expect( + () => queue.enqueue({'data': 'test'}), + throwsA(isA()), + ); + } finally { + await app.close(); + } + }); + + test('silently succeeds on 404 for delete (task not found)', () async { + final authClient = await createTestAuthClient( + email: mockClientEmail, + apiHandler: (request) { + if (request.method == 'DELETE') { + return Response( + jsonEncode({ + 'error': { + 'code': 404, + 'message': 'Task not found', + 'status': 'NOT_FOUND', + }, + }), + 404, + headers: {'content-type': 'application/json'}, + ); + } + return Response('{}', 200); + }, + ); + + final app = FirebaseApp.initializeApp( + name: 'delete-notfound-test-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId, httpClient: authClient), + ); + + try { + final functions = Functions(app); + final queue = functions.taskQueue('helloWorld'); + + // Should NOT throw - 404 on delete is expected for non-existent tasks + await queue.delete('non-existent-task'); + } finally { + await app.close(); + } + }); + }); +} diff --git a/packages/dart_firebase_admin/test/functions/package.json b/packages/dart_firebase_admin/test/functions/package.json new file mode 100644 index 00000000..9cbbd7aa --- /dev/null +++ b/packages/dart_firebase_admin/test/functions/package.json @@ -0,0 +1,23 @@ +{ + "name": "functions", + "scripts": { + "build": "tsc", + "serve": "npm run build && firebase emulators:start --only functions", + "shell": "npm run build && firebase functions:shell", + "start": "npm run shell", + "deploy": "firebase deploy --only functions", + "logs": "firebase functions:log" + }, + "engines": { + "node": "18" + }, + "main": "lib/index.js", + "dependencies": { + "firebase-admin": "^13.0.0", + "firebase-functions": "^6.1.2" + }, + "devDependencies": { + "typescript": "^5.7.2" + }, + "private": true +} diff --git a/packages/dart_firebase_admin/test/functions/src/index.ts b/packages/dart_firebase_admin/test/functions/src/index.ts new file mode 100644 index 00000000..b2e16a54 --- /dev/null +++ b/packages/dart_firebase_admin/test/functions/src/index.ts @@ -0,0 +1,16 @@ +import { onTaskDispatched } from "firebase-functions/v2/tasks"; + +export const helloWorld = onTaskDispatched( + { + retryConfig: { + maxAttempts: 5, + minBackoffSeconds: 60, + }, + rateLimits: { + maxConcurrentDispatches: 6, + }, + }, + async (req) => { + console.log("Task received:", req.data); + } +); diff --git a/packages/dart_firebase_admin/test/functions/tsconfig.json b/packages/dart_firebase_admin/test/functions/tsconfig.json new file mode 100644 index 00000000..7ce05d03 --- /dev/null +++ b/packages/dart_firebase_admin/test/functions/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "noImplicitReturns": true, + "noUnusedLocals": true, + "outDir": "lib", + "sourceMap": true, + "strict": true, + "target": "es2017" + }, + "compileOnSave": true, + "include": [ + "src" + ] +} diff --git a/packages/dart_firebase_admin/test/functions/util/helpers.dart b/packages/dart_firebase_admin/test/functions/util/helpers.dart new file mode 100644 index 00000000..baf9201c --- /dev/null +++ b/packages/dart_firebase_admin/test/functions/util/helpers.dart @@ -0,0 +1,87 @@ +import 'package:dart_firebase_admin/functions.dart'; +import 'package:dart_firebase_admin/src/app.dart'; +import 'package:googleapis_auth/auth_io.dart'; +import 'package:test/test.dart'; + +import '../../google_cloud_firestore/util/helpers.dart'; + +/// Validates that Cloud Tasks emulator environment variable is set. +/// +/// Call this in setUpAll() of integration test files to fail fast if +/// the emulator isn't configured. +void ensureCloudTasksEmulatorConfigured() { + if (!Environment.isCloudTasksEmulatorEnabled()) { + throw StateError( + 'Missing emulator configuration: ${Environment.cloudTasksEmulatorHost}\n\n' + 'Integration tests must run against the Cloud Tasks emulator.\n' + 'Set the following environment variable:\n' + ' ${Environment.cloudTasksEmulatorHost}=localhost:9499\n\n' + 'Or run tests with: firebase emulators:exec "dart test"', + ); + } +} + +/// Creates a Functions instance for integration testing with the emulator. +/// +/// No cleanup is needed since tasks are ephemeral and queue state is +/// managed by the emulator. +/// +/// Note: Tests should be run with CLOUD_TASKS_EMULATOR_HOST=localhost:9499 +/// environment variable set. The emulator will be auto-detected. +Functions createFunctionsForTest() { + // CRITICAL: Ensure emulator is running to prevent hitting production + if (!Environment.isCloudTasksEmulatorEnabled()) { + throw StateError( + '${Environment.cloudTasksEmulatorHost} environment variable must be set to run tests. ' + 'This prevents accidentally writing test data to production. ' + 'Set it to "localhost:9499" or your emulator host.', + ); + } + + // Use unique app name for each test to avoid interference + final appName = 'functions-test-${DateTime.now().microsecondsSinceEpoch}'; + + final app = createApp(name: appName); + + return Functions(app); +} + +/// Creates a Functions instance for unit testing with a mock HTTP client. +/// +/// This uses the internal constructor to inject a custom HTTP client, +/// allowing tests to run without the emulator. +Functions createFunctionsWithMockClient(AuthClient mockClient) { + final appName = + 'functions-unit-test-${DateTime.now().microsecondsSinceEpoch}'; + + final app = FirebaseApp.initializeApp( + name: appName, + options: AppOptions(projectId: projectId, httpClient: mockClient), + ); + + addTearDown(() async { + await app.close(); + }); + + return Functions(app); +} + +/// Creates a Functions instance for unit testing with a mock request handler. +/// +/// This uses the internal constructor to inject a mock FunctionsRequestHandler, +/// allowing complete control over the request/response cycle. +Functions createFunctionsWithMockHandler(FunctionsRequestHandler mockHandler) { + final appName = + 'functions-unit-test-${DateTime.now().microsecondsSinceEpoch}'; + + final app = FirebaseApp.initializeApp( + name: appName, + options: const AppOptions(projectId: projectId), + ); + + addTearDown(() async { + await app.close(); + }); + + return Functions.internal(app, requestHandler: mockHandler); +} diff --git a/packages/googleapis_auth_utils/lib/src/credential_aware_client.dart b/packages/googleapis_auth_utils/lib/src/credential_aware_client.dart index 5fbecaba..e16986a7 100644 --- a/packages/googleapis_auth_utils/lib/src/credential_aware_client.dart +++ b/packages/googleapis_auth_utils/lib/src/credential_aware_client.dart @@ -1,5 +1,6 @@ import 'package:googleapis_auth/auth_io.dart'; import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; import 'credential.dart'; @@ -11,6 +12,7 @@ import 'credential.dart'; /// /// The association is maintained via [Expando], which doesn't prevent garbage /// collection of the auth client. +@internal final authClientCredentials = Expando( 'AuthClient credentials', ); diff --git a/scripts/coverage.sh b/scripts/coverage.sh index b48a4710..b07d1064 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -16,10 +16,16 @@ PACKAGE_DIR="$SCRIPT_DIR/../packages/dart_firebase_admin" # Change to package directory cd "$PACKAGE_DIR" +# Build test functions for Cloud Tasks emulator +cd test/functions +npm install +npm run build +cd ../.. + dart pub global activate coverage # Use test_with_coverage which supports workspaces (dart test --coverage doesn't work with resolution: workspace) -firebase emulators:exec --project dart-firebase-admin --only firestore,auth "dart run coverage:test_with_coverage -- --concurrency=1" +firebase emulators:exec --project dart-firebase-admin --only firestore,auth,functions,tasks "dart run coverage:test_with_coverage -- --concurrency=1" # test_with_coverage already generates lcov.info, just move it mv coverage/lcov.info coverage.lcov \ No newline at end of file