From 63e28a7759fa6e2bf5b6ed4a206bd629e28e37bb Mon Sep 17 00:00:00 2001 From: kevmoo Date: Tue, 31 Mar 2026 09:38:08 -0700 Subject: [PATCH 1/2] Refactor logging and environment parsing to use package:google_cloud Integrates `package:google_cloud` for structured logging, trace context, and standard environment variables. Details: - Update `Logger` to extend `CloudLogger` from `package:google_cloud`. - Replace custom trace context parsing and zone keys with `TraceContextData` and standard constants. - Update `fireUp` server startup to use `TraceContextData.tryParse`. - Remove custom `removeCircular` logic and related tests (offloaded to `google_cloud`). - Upgrade `google_cloud_storage` to `^0.6.0` and add `google_cloud` dependency. - Modernize logging calls throughout (`logger.warning` with `stackTrace` etc). - Remove obsolete `server_test.dart` for custom trace parsing. - Use standard constants for environment variables (e.g., `serviceEnvironmentVariable`, `projectIdEnvironmentVariableOptions`). --- lib/firebase_functions.dart | 4 +- lib/logger.dart | 57 ---- lib/src/common/environment.dart | 12 +- lib/src/common/on_init.dart | 4 +- lib/src/common/params.dart | 44 ++-- lib/src/common/utilities.dart | 13 +- lib/src/logger/logger.dart | 284 -------------------- lib/src/server.dart | 74 ++---- lib/src/tasks/tasks_namespace.dart | 6 +- pubspec.yaml | 1 + test/unit/logger_test.dart | 409 ----------------------------- test/unit/server_test.dart | 33 --- 12 files changed, 56 insertions(+), 885 deletions(-) delete mode 100644 lib/logger.dart delete mode 100644 lib/src/logger/logger.dart delete mode 100644 test/unit/logger_test.dart diff --git a/lib/firebase_functions.dart b/lib/firebase_functions.dart index 8c5efb4..f6e9ca1 100644 --- a/lib/firebase_functions.dart +++ b/lib/firebase_functions.dart @@ -82,6 +82,8 @@ library; import 'params.dart' as params; // Package re-exports +export 'package:google_cloud/google_cloud.dart' + show CloudLogger, LogSeverity, currentLogger; export 'package:google_cloud_firestore/google_cloud_firestore.dart' show DocumentData, DocumentSnapshot, QueryDocumentSnapshot; export 'package:shelf/shelf.dart' show Request, Response; @@ -108,8 +110,6 @@ export 'src/firestore/firestore.dart'; export 'src/https/https.dart'; // Experimental: Identity triggers (not yet supported in production or emulator) export 'src/identity/identity.dart'; -// Logger -export 'src/logger/logger.dart' show LogEntry, LogSeverity, Logger, logger; // Experimental: Pub/Sub triggers (not yet supported in production or emulator) export 'src/pubsub/pubsub.dart'; // Experimental: Remote Config triggers (not yet supported in production or emulator) diff --git a/lib/logger.dart b/lib/logger.dart deleted file mode 100644 index d45b82a..0000000 --- a/lib/logger.dart +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/// Structured logger for Cloud Logging, compatible with the Firebase -/// Functions Node.js SDK `logger` namespace. -/// -/// ## Usage -/// -/// ```dart -/// import 'package:firebase_functions/logger.dart'; -/// -/// logger.info('Request received'); -/// logger.warn('Slow query', {'durationMs': 1200, 'query': 'SELECT ...'}); -/// logger.error('Failed to process request'); -/// ``` -/// -/// ## Structured Logging -/// -/// Pass a [Map] as the second argument to include -/// structured data in the Cloud Logging `jsonPayload`: -/// -/// ```dart -/// logger.info('User signed in', { -/// 'userId': user.id, -/// 'provider': 'google', -/// }); -/// ``` -/// -/// Or pass a [Map] as the sole argument for structured-only entries: -/// -/// ```dart -/// logger.info({'message': 'Batch complete', 'processedCount': 42}); -/// ``` -/// -/// ## Severity Routing -/// -/// - **stdout**: DEBUG, INFO, NOTICE -/// - **stderr**: WARNING, ERROR, CRITICAL, ALERT, EMERGENCY -library; - -export 'src/logger/logger.dart' - hide - cloudTraceContextHeader, - createLogger, - projectIdZoneKey, - traceIdZoneKey; diff --git a/lib/src/common/environment.dart b/lib/src/common/environment.dart index 16208eb..c116543 100644 --- a/lib/src/common/environment.dart +++ b/lib/src/common/environment.dart @@ -15,6 +15,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:google_cloud/constants.dart'; import 'package:meta/meta.dart'; /// Provides unified access to environment variables, emulator checks, and @@ -83,17 +84,12 @@ class FirebaseEnv { ); } - /// The port to listen on. - /// - /// Uses the `PORT` environment variable, defaulting to 8080. - int get port => int.tryParse(environment['PORT'] ?? '8080') ?? 8080; - /// The name of the Cloud Run service. /// /// Uses the `K_SERVICE` environment variable. /// /// See https://cloud.google.com/run/docs/container-contract#env-vars - String? get kService => environment['K_SERVICE']; + String? get kService => environment[serviceEnvironmentVariable]; /// The name of the target function. /// @@ -114,9 +110,7 @@ class FirebaseEnv { /// Common project ID environment variables checked in order. const _projectIdEnvKeyOptions = [ 'FIREBASE_PROJECT', - 'GCLOUD_PROJECT', - 'GOOGLE_CLOUD_PROJECT', - 'GCP_PROJECT', + ...projectIdEnvironmentVariableOptions, ]; /// Common emulator host keys used to detect emulator environment. diff --git a/lib/src/common/on_init.dart b/lib/src/common/on_init.dart index 41c4b0e..4aa839d 100644 --- a/lib/src/common/on_init.dart +++ b/lib/src/common/on_init.dart @@ -17,6 +17,8 @@ library; import 'dart:async'; +import 'package:google_cloud/google_cloud.dart'; + /// Callback registered via [onInit]. FutureOr Function()? _initCallback; @@ -73,7 +75,7 @@ bool _didInit = false; /// - [defineJsonSecret] for JSON-encoded secrets void onInit(FutureOr Function() callback) { if (_initCallback != null) { - print( + currentLogger.warning( 'Warning: Setting onInit callback more than once. ' 'Only the most recent callback will be called.', ); diff --git a/lib/src/common/params.dart b/lib/src/common/params.dart index 876d288..b82a269 100644 --- a/lib/src/common/params.dart +++ b/lib/src/common/params.dart @@ -15,6 +15,8 @@ import 'dart:convert'; import 'dart:io'; +import 'package:google_cloud/google_cloud.dart'; + import 'expression.dart'; // ============================================================================ @@ -354,13 +356,11 @@ abstract class Param extends Expression { @override T value() { if (Platform.environment['FUNCTIONS_CONTROL_API'] == 'true') { - print( - 'Warning: ${toString()}.value() invoked during function deployment, ' - 'instead of during runtime.\n' - 'This is usually a mistake. In configs, use Params directly without ' - 'calling .value().\n' - 'Example: HttpsOptions(minInstances: minInstancesParam) ' - 'not HttpsOptions(minInstances: Option(minInstancesParam.value()))', + currentLogger.warning( + ''' +${toString()}.value() invoked during function deployment, instead of during runtime. +This is usually a mistake. In configs, use Params directly without calling .value(). +Example: HttpsOptions(minInstances: minInstancesParam) not HttpsOptions(minInstances: Option(minInstancesParam.value()))''', ); } return runtimeValue(); @@ -415,11 +415,10 @@ class SecretParam extends Param { String runtimeValue() { final val = Platform.environment[name]; if (val == null) { - print( - 'Warning: No value found for secret parameter "$name". ' - 'A function can only access a secret if you include the secret ' - 'in the function\'s secrets array.', - ); + currentLogger.warning(''' +No value found for secret parameter "$name". +A function can only access a secret if you include the secret in the function's secrets array. +'''); return ''; } return val; @@ -699,12 +698,12 @@ class ListParam extends Param> { if (parsed is List && parsed.every((v) => v is String)) { return List.from(parsed); } - } on FormatException { + } on FormatException catch (e, stack) { // Invalid JSON, return default - print( - 'Warning: Failed to parse list parameter "$name" as JSON array. ' - 'Expected format: \'["value1", "value2"]\'. Returning default value.', - ); + currentLogger.warning(''' +Failed to parse list parameter "$name" as JSON array. +Expected format: `["value1", "value2"]`. Returning default value. +''', stackTrace: stack); } return options?.defaultValue ?? []; @@ -753,12 +752,11 @@ class EnumListParam extends Param> { } return result; } - } on FormatException catch (e) { - print( - 'Warning: Failed to parse enum list parameter "$name". ' - 'Expected format: \'["value1", "value2"]\'. Error: $e. ' - 'Returning default value.', - ); + } on FormatException catch (e, stack) { + currentLogger.warning(''' +Failed to parse enum list parameter "$name" as JSON array. +Expected format: `["value1", "value2"]`. Error: $e. Returning default value. +''', stackTrace: stack); } return options?.defaultValue ?? []; diff --git a/lib/src/common/utilities.dart b/lib/src/common/utilities.dart index 2525ee4..d069950 100644 --- a/lib/src/common/utilities.dart +++ b/lib/src/common/utilities.dart @@ -14,11 +14,10 @@ import 'dart:convert'; +import 'package:google_cloud/google_cloud.dart'; import 'package:shelf/shelf.dart' show Request, Response; -import 'package:stack_trace/stack_trace.dart' show Trace; import '../https/error.dart'; -import '../logger/logger.dart'; Future> readAsJsonMap(Request request) async { final decoded = await _converter.bind(request.read()).first; @@ -44,7 +43,7 @@ extension HttpErrorExtension on HttpsError { /// a structured JSON error. The actual error details are only logged /// server-side and never exposed to the client. InternalError logInternalError(Object error, StackTrace stackTrace) { - _logError(error, stackTrace); + currentLogger.error(error, stackTrace: stackTrace); return InternalError(); } @@ -55,12 +54,6 @@ InternalError logInternalError(Object error, StackTrace stackTrace) { /// where the caller is the Cloud Functions infrastructure rather than an /// end-user client. Response logEventHandlerError(Object error, StackTrace stackTrace) { - _logError(error, stackTrace); + currentLogger.error(error, stackTrace: stackTrace); return Response.internalServerError(); } - -/// Formats and logs an error with a terse, readable stack trace. -void _logError(Object error, StackTrace stackTrace) { - final terse = Trace.from(stackTrace).terse; - logger.error('$error\n$terse'); -} diff --git a/lib/src/logger/logger.dart b/lib/src/logger/logger.dart deleted file mode 100644 index aa96100..0000000 --- a/lib/src/logger/logger.dart +++ /dev/null @@ -1,284 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io' as io; - -import 'package:meta/meta.dart'; - -/// Log severity levels for Cloud Logging. -/// -/// See [LogSeverity](https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity). -enum LogSeverity { - debug('DEBUG'), - info('INFO'), - notice('NOTICE'), - warning('WARNING'), - error('ERROR'), - critical('CRITICAL'), - alert('ALERT'), - emergency('EMERGENCY'); - - const LogSeverity(this.value); - - /// The string value used in Cloud Logging JSON entries. - final String value; -} - -/// A structured Cloud Logging log entry. -/// -/// A [Map] where `severity` (required) and `message` (optional) are standard -/// Cloud Logging fields. All other keys are included in the `jsonPayload` -/// of the logged entry. -typedef LogEntry = Map; - -/// Removes circular references from an object graph for safe JSON -/// serialization. -/// -/// Returns a new data structure with circular references replaced by the -/// string `"[Circular]"`. Does not mutate the original object. -/// -/// Handles [DateTime] by converting to ISO 8601 UTC string and respects -/// objects with a `toJson()` method. -Object? removeCircular(Object? obj, [Set? existingRefs]) { - if (obj == null || obj is bool || obj is num || obj is String) { - return obj; - } - - if (obj is DateTime) { - return obj.toUtc().toIso8601String(); - } - - final refs = existingRefs ?? {}; - - // Handle objects with toJson() (custom serializable types). - if (obj is! Map && obj is! List) { - try { - final result = (obj as dynamic).toJson(); - return removeCircular(result, refs); - } catch (_) { - return obj.toString(); - } - } - - if (refs.contains(obj)) { - return '[Circular]'; - } - refs.add(obj); - - late final Object? result; - - if (obj is Map) { - final map = {}; - for (final MapEntry(:key, :value) in obj.entries) { - try { - if (value != null && refs.contains(value)) { - map[key.toString()] = '[Circular]'; - } else { - map[key.toString()] = removeCircular(value, refs); - } - } catch (_) { - map[key.toString()] = '[Error - cannot serialize]'; - } - } - result = map; - } else { - // obj is List - result = List.generate((obj as List).length, (i) { - final value = obj[i]; - try { - if (value != null && refs.contains(value)) { - return '[Circular]'; - } - return removeCircular(value, refs); - } catch (_) { - return '[Error - cannot serialize]'; - } - }); - } - - refs.remove(obj); - return result; -} - -/// Whether a severity level should be written to stderr. -bool _isStderrSeverity(String severity) => switch (severity) { - 'WARNING' || 'ERROR' || 'CRITICAL' || 'ALERT' || 'EMERGENCY' => true, - _ => false, -}; - -/// Creates a new [Logger] instance. -/// -/// [stdoutWriter] and [stderrWriter] can be provided for testing. -@internal -Logger createLogger({ - void Function(String line)? stdoutWriter, - void Function(String line)? stderrWriter, -}) => Logger._(stdoutWriter: stdoutWriter, stderrWriter: stderrWriter); - -/// Structured logger for Cloud Logging, compatible with the Firebase -/// Functions Node.js SDK `logger` namespace. -/// -/// Writes JSON-formatted [LogEntry] objects to stdout or stderr depending -/// on severity. DEBUG, INFO, and NOTICE go to stdout; WARNING, ERROR, -/// CRITICAL, ALERT, and EMERGENCY go to stderr. -/// -/// ## Usage -/// -/// ```dart -/// import 'package:firebase_functions/logger.dart'; -/// -/// // Simple message -/// logger.info('Request received'); -/// -/// // Message with structured data -/// logger.info('Processing', {'userId': '123', 'action': 'update'}); -/// -/// // Structured data only (message inside the map is preserved) -/// logger.info({'message': 'Custom', 'requestId': 'abc'}); -/// -/// // Low-level structured entry -/// logger.write({'severity': 'NOTICE', 'message': 'Custom', 'code': 42}); -/// ``` -final class Logger { - /// Creates a [Logger] instance. - /// - /// Custom [stdoutWriter] and [stderrWriter] can be provided for testing. - Logger._({ - void Function(String line)? stdoutWriter, - void Function(String line)? stderrWriter, - }) : _stdoutWriter = stdoutWriter ?? _defaultStdoutWriter, - _stderrWriter = stderrWriter ?? _defaultStderrWriter; - - final void Function(String line) _stdoutWriter; - final void Function(String line) _stderrWriter; - - static void _defaultStdoutWriter(String line) => io.stdout.writeln(line); - static void _defaultStderrWriter(String line) => io.stderr.writeln(line); - - /// Writes a [LogEntry] to stdout or stderr depending on severity. - /// - /// The entry must contain a `severity` key. If a trace ID is available - /// in the current [Zone] (via [traceIdZoneKey]), it is automatically added - /// to the entry. - void write(LogEntry entry) { - // Add trace context if available. - - final projectId = Zone.current[projectIdZoneKey] as String?; - final traceId = Zone.current[traceIdZoneKey] as String?; - - if (projectId != null && traceId != null) { - assert(projectId.isNotEmpty, 'projectIdZoneKey value must not be empty'); - assert(traceId.isNotEmpty, 'traceIdZoneKey value must not be empty'); - entry['logging.googleapis.com/trace'] = - 'projects/$projectId/traces/$traceId'; - } - - final sanitized = removeCircular(entry); - final json = jsonEncode(sanitized); - - final severity = entry['severity'] as String? ?? 'INFO'; - if (_isStderrSeverity(severity)) { - _stderrWriter(json); - } else { - _stdoutWriter(json); - } - } - - /// Writes a DEBUG severity log. - /// - /// If [messageOrPayload] is a [Map] and [jsonPayload] - /// is null, the map is used directly as the structured log entry - /// (preserving any `message` key within it). - /// - /// Otherwise, [messageOrPayload] is converted to a string for the - /// `message` field, and [jsonPayload] entries are merged into the entry. - void debug(Object? messageOrPayload, [Map? jsonPayload]) { - write(_entryFromArgs('DEBUG', messageOrPayload, jsonPayload)); - } - - /// Writes an INFO severity log. Alias for [info]. - void log(Object? messageOrPayload, [Map? jsonPayload]) { - write(_entryFromArgs('INFO', messageOrPayload, jsonPayload)); - } - - /// Writes an INFO severity log. - void info(Object? messageOrPayload, [Map? jsonPayload]) { - write(_entryFromArgs('INFO', messageOrPayload, jsonPayload)); - } - - /// Writes a WARNING severity log. - void warn(Object? messageOrPayload, [Map? jsonPayload]) { - write(_entryFromArgs('WARNING', messageOrPayload, jsonPayload)); - } - - /// Writes an ERROR severity log. - void error(Object? messageOrPayload, [Map? jsonPayload]) { - write(_entryFromArgs('ERROR', messageOrPayload, jsonPayload)); - } -} - -/// Constructs a [LogEntry] from a severity, message, and optional JSON -/// payload. -/// -/// When [messageOrPayload] is a [Map] and [jsonPayload] -/// is null, the map is used directly as structured data (matching Node.js -/// behavior where a lone plain-object argument is treated as the entry). -/// -/// Otherwise, [messageOrPayload] is stringified and set as the `message` -/// field, overwriting any `message` key from [jsonPayload]. -LogEntry _entryFromArgs( - String severity, - Object? messageOrPayload, - Map? jsonPayload, -) { - // If only a Map was passed, treat it as structured data. - if (messageOrPayload is Map && jsonPayload == null) { - return {...messageOrPayload, 'severity': severity}; - } - - final entry = {...?jsonPayload, 'severity': severity}; - - final messageStr = '$messageOrPayload'; - if (messageStr.isNotEmpty) { - entry['message'] = messageStr; - } - - return entry; -} - -/// Default [Logger] instance. -/// -/// This is the primary way to use the logger: -/// ```dart -/// import 'package:firebase_functions/logger.dart'; -/// -/// logger.info('Hello'); -/// logger.warn('Something is off', {'requestId': 'abc'}); -/// ``` -final logger = Logger._(); - -/// Standard HTTP header used by -/// [Cloud Trace](https://cloud.google.com/trace/docs/setup). -@internal -const cloudTraceContextHeader = 'x-cloud-trace-context'; - -/// Zone key for propagating trace IDs. -@internal -final Object traceIdZoneKey = Object(); - -/// Zone key for propagating project ID. -@internal -final Object projectIdZoneKey = Object(); diff --git a/lib/src/server.dart b/lib/src/server.dart index 2b20404..6e50a2e 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -16,19 +16,14 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:google_cloud/http_serving.dart'; import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; -import 'package:shelf/shelf_io.dart' as shelf_io; -import 'package:stack_trace/stack_trace.dart' show Trace; import 'common/cloud_run_id.dart'; import 'common/environment.dart'; import 'common/on_init.dart'; import 'firebase.dart'; -import 'logger/logger.dart'; - -/// Callback type for the user's function registration code. -typedef FunctionsRunner = FutureOr Function(Firebase firebase); /// Starts the Firebase Functions runtime. /// @@ -45,39 +40,29 @@ typedef FunctionsRunner = FutureOr Function(Firebase firebase); /// }); /// } /// ``` -Future fireUp(List args, FunctionsRunner runner) async { +Future fireUp( + List args, + FutureOr Function(Firebase firebase) runner, +) async { final firebase = Firebase(); - final env = firebase.$env; - final projectId = env.projectId; - await runZoned(zoneValues: {projectIdZoneKey: projectId}, () async { - // Run user's function registration code - await runner(firebase); + // Run user's function registration code + await runner(firebase); - // Build request handler with middleware pipeline - var middleware = const Pipeline().middleware; - - final env = firebase.$env; - if (env.enableCors) { - middleware = middleware.addMiddleware(_corsMiddleware); - } + // Build request handler with middleware pipeline + var middleware = const Pipeline().middleware; - // Build request handler with middleware pipeline - final handler = middleware.addHandler((request) { - final traceId = extractTraceId(request.headers[cloudTraceContextHeader]); - - if (traceId == null) { - return _routeRequest(request, firebase, env); - } + final env = firebase.$env; + if (env.enableCors) { + middleware = middleware.addMiddleware(_corsMiddleware); + } - return runZoned(zoneValues: {traceIdZoneKey: traceId}, () { - return _routeRequest(request, firebase, env); - }); - }); + final handler = middleware + .addMiddleware(createLoggingMiddleware(projectId: env.projectId)) + .addHandler((request) => _routeRequest(request, firebase, env)); - // Start HTTP server - await shelf_io.serve(handler, InternetAddress.anyIPv4, env.port); - }); + // Start HTTP server + await serveHandler(handler); } /// CORS middleware for emulator mode. @@ -506,8 +491,9 @@ Future<(Request, FirebaseFunctionDeclaration?)> _tryMatchCloudEventFunction( return (finalRequest, null); } catch (e, stackTrace) { // CloudEvent parsing failed - not a CloudEvent request - logger.warn( - 'CloudEvent parsing failed: $e\n${Trace.from(stackTrace).terse}', + currentLogger.warning( + 'CloudEvent parsing failed: $e', + stackTrace: stackTrace, ); return (request, null); } @@ -718,21 +704,3 @@ bool _matchesRefPattern(String refPath, String pattern) { return true; } - -final _traceIdRegExp = RegExp(r'^[a-f0-9]{32}$', caseSensitive: false); - -/// Extracts the 32-character hexadecimal trace ID from an [x-cloud-trace-context] header. -/// -/// Expected format: `TRACE_ID/SPAN_ID;o=TRACE_TRUE` -@visibleForTesting -String? extractTraceId(String? header) { - if (header == null || header.isEmpty) return null; - final parts = header.split('/'); - if (parts.isNotEmpty) { - final traceId = parts[0]; - if (_traceIdRegExp.hasMatch(traceId)) { - return traceId; - } - } - return null; -} diff --git a/lib/src/tasks/tasks_namespace.dart b/lib/src/tasks/tasks_namespace.dart index 9dc86cb..4fec5e2 100644 --- a/lib/src/tasks/tasks_namespace.dart +++ b/lib/src/tasks/tasks_namespace.dart @@ -16,13 +16,11 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:google_cloud/google_cloud.dart'; import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; -import 'package:stack_trace/stack_trace.dart' show Trace; - import '../firebase.dart'; -import '../logger/logger.dart'; import 'options.dart'; import 'task_request.dart'; @@ -133,7 +131,7 @@ class TasksNamespace extends FunctionsNamespace { // Return 204 No Content (matching Node.js behavior) return Response(204); } catch (e, stackTrace) { - logger.error('$e\n${Trace.from(stackTrace).terse}'); + currentLogger.error(e, stackTrace: stackTrace); return Response( 500, body: jsonEncode({ diff --git a/pubspec.yaml b/pubspec.yaml index 046b041..311ff3b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,7 @@ dependencies: path: packages/dart_firebase_admin dart_jsonwebtoken: ^3.1.1 glob: ^2.1.3 + google_cloud: ^0.4.0 google_cloud_firestore: git: url: https://github.com/firebase/firebase-admin-dart.git diff --git a/test/unit/logger_test.dart b/test/unit/logger_test.dart deleted file mode 100644 index 3071c32..0000000 --- a/test/unit/logger_test.dart +++ /dev/null @@ -1,409 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// ignore_for_file: avoid_dynamic_calls - -import 'dart:async'; -import 'dart:convert'; - -import 'package:firebase_functions/src/logger/logger.dart'; -import 'package:test/test.dart'; - -void main() { - group('Logger', () { - late String lastStdout; - late String lastStderr; - late Logger testLogger; - - setUp(() { - lastStdout = ''; - lastStderr = ''; - testLogger = createLogger( - stdoutWriter: (line) => lastStdout = line, - stderrWriter: (line) => lastStderr = line, - ); - }); - - Map parseStdout() => - jsonDecode(lastStdout) as Map; - - Map parseStderr() => - jsonDecode(lastStderr) as Map; - - group('logging methods', () { - test('should write message to output', () { - testLogger.log('hello world'); - expect(parseStdout(), {'severity': 'INFO', 'message': 'hello world'}); - }); - - test('should merge structured data from jsonPayload', () { - testLogger.log('hello world', {'additional': 'context'}); - expect(parseStdout(), { - 'severity': 'INFO', - 'message': 'hello world', - 'additional': 'context', - }); - }); - - test('should handle null message', () { - testLogger.log(null); - expect(parseStdout(), {'severity': 'INFO', 'message': 'null'}); - }); - - test('should overwrite message field in structured data when ' - 'message is provided', () { - testLogger.log('this instead', {'test': true, 'message': 'not this'}); - expect(parseStdout(), { - 'severity': 'INFO', - 'message': 'this instead', - 'test': true, - }); - }); - - test('should not overwrite message field when only structured data ' - 'is provided', () { - testLogger.log({'test': true, 'message': 'this'}); - expect(parseStdout(), { - 'severity': 'INFO', - 'message': 'this', - 'test': true, - }); - }); - - test('should handle structured data without message', () { - testLogger.log({'test': true, 'count': 42}); - expect(parseStdout(), {'severity': 'INFO', 'test': true, 'count': 42}); - }); - - test('should handle empty string message', () { - testLogger.log(''); - expect(parseStdout(), {'severity': 'INFO'}); - }); - }); - - group('severity methods', () { - test('debug writes DEBUG severity to stdout', () { - testLogger.debug('test'); - expect(parseStdout(), {'severity': 'DEBUG', 'message': 'test'}); - }); - - test('info writes INFO severity to stdout', () { - testLogger.info('test'); - expect(parseStdout(), {'severity': 'INFO', 'message': 'test'}); - }); - - test('log writes INFO severity to stdout', () { - testLogger.log('test'); - expect(parseStdout(), {'severity': 'INFO', 'message': 'test'}); - }); - - test('warn writes WARNING severity to stderr', () { - testLogger.warn('test'); - expect(parseStderr(), {'severity': 'WARNING', 'message': 'test'}); - }); - - test('error writes ERROR severity to stderr', () { - testLogger.error('test'); - expect(parseStderr(), {'severity': 'ERROR', 'message': 'test'}); - }); - }); - - group('severity methods with structured data', () { - test('debug with jsonPayload', () { - testLogger.debug('msg', {'key': 'value'}); - expect(parseStdout(), { - 'severity': 'DEBUG', - 'message': 'msg', - 'key': 'value', - }); - }); - - test('warn with jsonPayload', () { - testLogger.warn('msg', {'key': 'value'}); - expect(parseStderr(), { - 'severity': 'WARNING', - 'message': 'msg', - 'key': 'value', - }); - }); - - test('error with jsonPayload', () { - testLogger.error('msg', {'key': 'value'}); - expect(parseStderr(), { - 'severity': 'ERROR', - 'message': 'msg', - 'key': 'value', - }); - }); - }); - - group('write', () { - test('should remove circular references', () { - final circ = {'b': 'foo'}; - circ['circ'] = circ; - - testLogger.write({ - 'severity': 'ERROR', - 'message': 'testing circular', - 'circ': circ, - }); - expect(parseStderr(), { - 'severity': 'ERROR', - 'message': 'testing circular', - 'circ': {'b': 'foo', 'circ': '[Circular]'}, - }); - }); - - test('should remove circular references in arrays', () { - final circ = {'b': 'foo'}; - circ['circ'] = [circ]; - - testLogger.write({ - 'severity': 'ERROR', - 'message': 'testing circular', - 'circ': circ, - }); - expect(parseStderr(), { - 'severity': 'ERROR', - 'message': 'testing circular', - 'circ': { - 'b': 'foo', - 'circ': ['[Circular]'], - }, - }); - }); - - test('should not detect duplicate object as circular', () { - final obj = {'a': 'foo'}; - testLogger.write({ - 'severity': 'ERROR', - 'message': 'testing circular', - 'a': obj, - 'b': obj, - }); - expect(parseStderr(), { - 'severity': 'ERROR', - 'message': 'testing circular', - 'a': {'a': 'foo'}, - 'b': {'a': 'foo'}, - }); - }); - - test('should not detect duplicate object in array as circular', () { - final obj = {'a': 'foo'}; - final arr = [ - {'a': obj, 'b': obj}, - {'a': obj, 'b': obj}, - ]; - testLogger.write({ - 'severity': 'ERROR', - 'message': 'testing circular', - 'a': arr, - 'b': arr, - }); - expect(parseStderr(), { - 'severity': 'ERROR', - 'message': 'testing circular', - 'a': [ - { - 'a': {'a': 'foo'}, - 'b': {'a': 'foo'}, - }, - { - 'a': {'a': 'foo'}, - 'b': {'a': 'foo'}, - }, - ], - 'b': [ - { - 'a': {'a': 'foo'}, - 'b': {'a': 'foo'}, - }, - { - 'a': {'a': 'foo'}, - 'b': {'a': 'foo'}, - }, - ], - }); - }); - - test('should handle objects with toJson()', () { - final date = DateTime.utc(1994, 8, 26, 12, 24); - testLogger.write({ - 'severity': 'ERROR', - 'message': 'testing toJSON', - 'obj': {'a': date}, - }); - expect(parseStderr(), { - 'severity': 'ERROR', - 'message': 'testing toJSON', - 'obj': {'a': '1994-08-26T12:24:00.000Z'}, - }); - }); - - test('should not alter parameters that are logged', () { - final circ = {'b': 'foo'}; - circ['array'] = [circ]; - circ['object'] = circ; - - testLogger.write({ - 'severity': 'ERROR', - 'message': 'testing circular', - 'circ': circ, - }); - - // Verify original object is not mutated. - expect(circ['b'], 'foo'); - expect((circ['object'] as Map)['b'], 'foo'); - expect( - ((circ['object'] as Map)['array'] as List)[0]['object'] is Map, - isTrue, - ); - }); - - for (final severity in ['DEBUG', 'INFO', 'NOTICE']) { - test('should output $severity severity to stdout', () { - testLogger.write({'severity': severity, 'message': 'test'}); - expect(parseStdout(), {'severity': severity, 'message': 'test'}); - }); - } - - for (final severity in [ - 'WARNING', - 'ERROR', - 'CRITICAL', - 'ALERT', - 'EMERGENCY', - ]) { - test('should output $severity severity to stderr', () { - testLogger.write({'severity': severity, 'message': 'test'}); - expect(parseStderr(), {'severity': severity, 'message': 'test'}); - }); - } - }); - - group('trace context', () { - test( - 'should add trace header when both projectId and traceId are in Zone', - () { - runZoned( - () { - testLogger.write({'severity': 'INFO', 'message': 'traced'}); - expect(parseStdout(), { - 'severity': 'INFO', - 'message': 'traced', - 'logging.googleapis.com/trace': - 'projects/test-project/traces/abc123', - }); - }, - zoneValues: { - projectIdZoneKey: 'test-project', - traceIdZoneKey: 'abc123', - }, - ); - }, - ); - - test('should not add trace header when projectId is missing in Zone', () { - runZoned(() { - testLogger.write({'severity': 'INFO', 'message': 'traced'}); - expect(parseStdout(), {'severity': 'INFO', 'message': 'traced'}); - }, zoneValues: {traceIdZoneKey: 'abc123'}); - }); - - test('should not add trace header when traceId is missing in Zone', () { - runZoned(() { - testLogger.write({'severity': 'INFO', 'message': 'traced'}); - expect(parseStdout(), {'severity': 'INFO', 'message': 'traced'}); - }, zoneValues: {projectIdZoneKey: 'test-project'}); - }); - }); - }); - - group('removeCircular', () { - test('should return primitives as-is', () { - expect(removeCircular(null), isNull); - expect(removeCircular(true), true); - expect(removeCircular(42), 42); - expect(removeCircular(3.14), 3.14); - expect(removeCircular('hello'), 'hello'); - }); - - test('should handle DateTime', () { - final date = DateTime.utc(2024, 1, 15, 10, 30); - expect(removeCircular(date), '2024-01-15T10:30:00.000Z'); - }); - - test('should handle simple maps', () { - expect(removeCircular({'a': 1, 'b': 'hello'}), {'a': 1, 'b': 'hello'}); - }); - - test('should handle simple lists', () { - expect(removeCircular([1, 2, 3]), [1, 2, 3]); - }); - - test('should handle nested structures', () { - expect( - removeCircular({ - 'a': { - 'b': [1, 2, 3], - }, - }), - { - 'a': { - 'b': [1, 2, 3], - }, - }, - ); - }); - - test('should replace circular map reference', () { - final map = {'key': 'value'}; - map['self'] = map; - - expect(removeCircular(map), {'key': 'value', 'self': '[Circular]'}); - }); - - test('should replace circular list reference', () { - final list = ['value']; - list.add(list); - - expect(removeCircular(list), ['value', '[Circular]']); - }); - - test('should handle deeply nested circular references', () { - final a = {'name': 'a'}; - final b = {'name': 'b', 'parent': a}; - a['child'] = b; - - expect(removeCircular(a), { - 'name': 'a', - 'child': {'name': 'b', 'parent': '[Circular]'}, - }); - }); - }); - - group('LogSeverity', () { - test('should have correct string values', () { - expect(LogSeverity.debug.value, 'DEBUG'); - expect(LogSeverity.info.value, 'INFO'); - expect(LogSeverity.notice.value, 'NOTICE'); - expect(LogSeverity.warning.value, 'WARNING'); - expect(LogSeverity.error.value, 'ERROR'); - expect(LogSeverity.critical.value, 'CRITICAL'); - expect(LogSeverity.alert.value, 'ALERT'); - expect(LogSeverity.emergency.value, 'EMERGENCY'); - }); - }); -} diff --git a/test/unit/server_test.dart b/test/unit/server_test.dart index c881b5b..f6637ad 100644 --- a/test/unit/server_test.dart +++ b/test/unit/server_test.dart @@ -18,39 +18,6 @@ import 'package:test/test.dart'; void main() { group('server', () { - group('extractTraceId', () { - test('extracts valid trace ID with span and option', () { - const header = '4bf92f3577b34da6a3ce929d0e0e4736/12345;o=1'; - expect(extractTraceId(header), '4bf92f3577b34da6a3ce929d0e0e4736'); - }); - - test('extracts valid trace ID with uppercase hex', () { - const header = '4BF92F3577B34DA6A3CE929D0E0E4736/12345;o=1'; - expect(extractTraceId(header), '4BF92F3577B34DA6A3CE929D0E0E4736'); - }); - - test('extracts valid trace ID without span or option', () { - const header = '1234567890abcdef1234567890abcdef'; - expect(extractTraceId(header), '1234567890abcdef1234567890abcdef'); - }); - - test('handles null and empty', () { - expect(extractTraceId(null), isNull); - expect(extractTraceId(''), isNull); - }); - - test('rejects malformed traces', () { - // Too short - expect(extractTraceId('1234/567;o=1'), isNull); - - // Too long - expect(extractTraceId('1234567890abcdef1234567890abcdef0/5'), isNull); - - // Invalid hex - expect(extractTraceId('1234567890xyzdef1234567890abcdef/5'), isNull); - }); - }); - group('corsHeadersFor', () { test('returns asterisk when allowedOrigins contains asterisk', () { final request = Request('GET', Uri.parse('http://localhost/test')); From 40d02dfd89d9223cbf315897f928e1539e53daf2 Mon Sep 17 00:00:00 2001 From: kevmoo Date: Wed, 1 Apr 2026 10:48:49 -0700 Subject: [PATCH 2/2] cleanup logging! --- example/auth/bin/server.dart | 2 +- example/https/bin/server.dart | 4 +- lib/src/alerts/alerts_namespace.dart | 3 - .../alerts/app_distribution_namespace.dart | 3 - lib/src/alerts/billing_namespace.dart | 3 - lib/src/alerts/crashlytics_namespace.dart | 3 - lib/src/alerts/performance_namespace.dart | 3 - lib/src/common/utilities.dart | 34 +- lib/src/database/database_namespace.dart | 139 +++--- lib/src/eventarc/eventarc_namespace.dart | 3 - lib/src/firestore/firestore_namespace.dart | 247 +++++------ lib/src/https/callable.dart | 10 +- lib/src/https/error.dart | 221 ---------- lib/src/https/https.dart | 3 +- lib/src/https/https_namespace.dart | 111 +++-- lib/src/identity/identity_namespace.dart | 95 ++--- lib/src/identity/token_verifier.dart | 49 ++- lib/src/pubsub/pubsub_namespace.dart | 3 - .../remote_config_namespace.dart | 3 - lib/src/scheduler/scheduler_namespace.dart | 20 +- lib/src/storage/storage_namespace.dart | 3 - lib/src/test_lab/test_lab_namespace.dart | 3 - pubspec.yaml | 3 + test/fixtures/dart_reference/bin/server.dart | 6 +- test/unit/error_logging_test.dart | 114 +---- test/unit/https_error_test.dart | 398 ------------------ test/unit/https_namespace_test.dart | 194 ++++++--- test/unit/remote_config_test.dart | 35 +- test/unit/scheduler_namespace_test.dart | 29 +- test/unit/shared_utils.dart | 33 ++ test/unit/storage_test.dart | 50 +-- 31 files changed, 585 insertions(+), 1242 deletions(-) delete mode 100644 lib/src/https/error.dart delete mode 100644 test/unit/https_error_test.dart create mode 100644 test/unit/shared_utils.dart diff --git a/example/auth/bin/server.dart b/example/auth/bin/server.dart index e59806e..d51de8c 100644 --- a/example/auth/bin/server.dart +++ b/example/auth/bin/server.dart @@ -31,7 +31,7 @@ void main(List args) async { // Example: Block users with certain email domains final email = user?.email; if (email != null && email.endsWith('@blocked.com')) { - throw PermissionDeniedError('Email domain not allowed'); + throw HttpResponseException(403, 'Email domain not allowed'); } // Example: Set custom claims based on email domain diff --git a/example/https/bin/server.dart b/example/https/bin/server.dart index 149d72d..167a901 100644 --- a/example/https/bin/server.dart +++ b/example/https/bin/server.dart @@ -67,11 +67,11 @@ void main(List args) async { final b = (data?['b'] as num?)?.toDouble(); if (a == null || b == null) { - throw InvalidArgumentError('Both "a" and "b" are required'); + throw HttpResponseException(400, 'Both "a" and "b" are required'); } if (b == 0) { - throw FailedPreconditionError('Cannot divide by zero'); + throw HttpResponseException(400, 'Cannot divide by zero'); } return CallableResult({'result': a / b}); diff --git a/lib/src/alerts/alerts_namespace.dart b/lib/src/alerts/alerts_namespace.dart index e208a14..351e1e0 100644 --- a/lib/src/alerts/alerts_namespace.dart +++ b/lib/src/alerts/alerts_namespace.dart @@ -18,7 +18,6 @@ import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; import '../common/cloud_event.dart'; -import '../common/utilities.dart'; import '../firebase.dart'; import 'alert_event.dart'; import 'alert_type.dart'; @@ -102,8 +101,6 @@ class AlertsNamespace extends FunctionsNamespace { return Response.ok(''); } on FormatException catch (e) { return Response(400, body: 'Invalid CloudEvent: ${e.message}'); - } catch (e, stackTrace) { - return logEventHandlerError(e, stackTrace); } }); } diff --git a/lib/src/alerts/app_distribution_namespace.dart b/lib/src/alerts/app_distribution_namespace.dart index 069a071..a8ab5aa 100644 --- a/lib/src/alerts/app_distribution_namespace.dart +++ b/lib/src/alerts/app_distribution_namespace.dart @@ -18,7 +18,6 @@ import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; import '../common/cloud_event.dart'; -import '../common/utilities.dart'; import '../firebase.dart'; import 'alert_event.dart'; import 'alert_type.dart'; @@ -84,8 +83,6 @@ class AppDistributionNamespace { return Response.ok(''); } on FormatException catch (e) { return Response(400, body: 'Invalid CloudEvent: ${e.message}'); - } catch (e, stackTrace) { - return logEventHandlerError(e, stackTrace); } }); } diff --git a/lib/src/alerts/billing_namespace.dart b/lib/src/alerts/billing_namespace.dart index f2de5ec..82f82ee 100644 --- a/lib/src/alerts/billing_namespace.dart +++ b/lib/src/alerts/billing_namespace.dart @@ -18,7 +18,6 @@ import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; import '../common/cloud_event.dart'; -import '../common/utilities.dart'; import '../firebase.dart'; import 'alert_event.dart'; import 'alert_type.dart'; @@ -85,8 +84,6 @@ class BillingNamespace { return Response.ok(''); } on FormatException catch (e) { return Response(400, body: 'Invalid CloudEvent: ${e.message}'); - } catch (e, stackTrace) { - return logEventHandlerError(e, stackTrace); } }); } diff --git a/lib/src/alerts/crashlytics_namespace.dart b/lib/src/alerts/crashlytics_namespace.dart index f38ff7b..f6d2d20 100644 --- a/lib/src/alerts/crashlytics_namespace.dart +++ b/lib/src/alerts/crashlytics_namespace.dart @@ -18,7 +18,6 @@ import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; import '../common/cloud_event.dart'; -import '../common/utilities.dart'; import '../firebase.dart'; import 'alert_event.dart'; import 'alert_type.dart'; @@ -141,8 +140,6 @@ class CrashlyticsNamespace { return Response.ok(''); } on FormatException catch (e) { return Response(400, body: 'Invalid CloudEvent: ${e.message}'); - } catch (e, stackTrace) { - return logEventHandlerError(e, stackTrace); } }); } diff --git a/lib/src/alerts/performance_namespace.dart b/lib/src/alerts/performance_namespace.dart index 4088f48..9e6b2b7 100644 --- a/lib/src/alerts/performance_namespace.dart +++ b/lib/src/alerts/performance_namespace.dart @@ -18,7 +18,6 @@ import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; import '../common/cloud_event.dart'; -import '../common/utilities.dart'; import '../firebase.dart'; import 'alert_event.dart'; import 'alert_type.dart'; @@ -70,8 +69,6 @@ class PerformanceNamespace { return Response.ok(''); } on FormatException catch (e) { return Response(400, body: 'Invalid CloudEvent: ${e.message}'); - } catch (e, stackTrace) { - return logEventHandlerError(e, stackTrace); } }); } diff --git a/lib/src/common/utilities.dart b/lib/src/common/utilities.dart index d069950..e7bf3cd 100644 --- a/lib/src/common/utilities.dart +++ b/lib/src/common/utilities.dart @@ -14,10 +14,7 @@ import 'dart:convert'; -import 'package:google_cloud/google_cloud.dart'; -import 'package:shelf/shelf.dart' show Request, Response; - -import '../https/error.dart'; +import 'package:shelf/shelf.dart' show Request; Future> readAsJsonMap(Request request) async { final decoded = await _converter.bind(request.read()).first; @@ -28,32 +25,3 @@ Future> readAsJsonMap(Request request) async { } final _converter = const Utf8Decoder().fuse(const JsonDecoder()); - -extension HttpErrorExtension on HttpsError { - Response toShelfResponse() => Response( - httpStatusCode, - headers: {'Content-Type': 'application/json'}, - body: jsonEncode(toErrorResponse()), - ); -} - -/// Logs an unexpected error with its stack trace and returns an [InternalError]. -/// -/// Use in HTTPS and callable function handlers where the response must be -/// a structured JSON error. The actual error details are only logged -/// server-side and never exposed to the client. -InternalError logInternalError(Object error, StackTrace stackTrace) { - currentLogger.error(error, stackTrace: stackTrace); - return InternalError(); -} - -/// Logs an unexpected error with its stack trace and returns a generic 500 -/// response. -/// -/// Use in event-triggered function handlers (Firestore, PubSub, Storage, etc.) -/// where the caller is the Cloud Functions infrastructure rather than an -/// end-user client. -Response logEventHandlerError(Object error, StackTrace stackTrace) { - currentLogger.error(error, stackTrace: stackTrace); - return Response.internalServerError(); -} diff --git a/lib/src/database/database_namespace.dart b/lib/src/database/database_namespace.dart index c7c95a6..bb94f7b 100644 --- a/lib/src/database/database_namespace.dart +++ b/lib/src/database/database_namespace.dart @@ -19,7 +19,6 @@ import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; import '../common/cloud_event.dart'; -import '../common/utilities.dart'; import '../firebase.dart'; import 'data_snapshot.dart'; import 'event.dart'; @@ -123,26 +122,22 @@ class DatabaseNamespace extends FunctionsNamespace { // Body parsing failed - snapshot remains null } - try { - final event = DatabaseEvent( - data: snapshot, - id: ceId, - source: ceSource, - specversion: '1.0', - subject: ceSubject, - time: DateTime.parse(ceTime), - type: ceType ?? createdEventType, - firebaseDatabaseHost: databaseHost, - instance: instanceName, - ref: refPath, - location: location, - params: params, - ); + final event = DatabaseEvent( + data: snapshot, + id: ceId, + source: ceSource, + specversion: '1.0', + subject: ceSubject, + time: DateTime.parse(ceTime), + type: ceType ?? createdEventType, + firebaseDatabaseHost: databaseHost, + instance: instanceName, + ref: refPath, + location: location, + params: params, + ); - await handler(event); - } catch (e, stackTrace) { - return logEventHandlerError(e, stackTrace); - } + await handler(event); return Response.ok(''); } else { @@ -191,8 +186,6 @@ class DatabaseNamespace extends FunctionsNamespace { } } on FormatException catch (e) { return Response(400, body: 'Invalid CloudEvent: ${e.message}'); - } catch (e, stackTrace) { - return logEventHandlerError(e, stackTrace); } }, refPattern: _normalizeRefPattern(ref)); } @@ -288,26 +281,22 @@ class DatabaseNamespace extends FunctionsNamespace { // Body parsing failed - change remains null } - try { - final event = DatabaseEvent?>( - data: change, - id: ceId, - source: ceSource, - specversion: '1.0', - subject: ceSubject, - time: DateTime.parse(ceTime), - type: ceType ?? updatedEventType, - firebaseDatabaseHost: databaseHost, - instance: instanceName, - ref: refPath, - location: location, - params: params, - ); + final event = DatabaseEvent?>( + data: change, + id: ceId, + source: ceSource, + specversion: '1.0', + subject: ceSubject, + time: DateTime.parse(ceTime), + type: ceType ?? updatedEventType, + firebaseDatabaseHost: databaseHost, + instance: instanceName, + ref: refPath, + location: location, + params: params, + ); - await handler(event); - } catch (e, stackTrace) { - return logEventHandlerError(e, stackTrace); - } + await handler(event); return Response.ok(''); } else { @@ -449,26 +438,22 @@ class DatabaseNamespace extends FunctionsNamespace { // Body parsing failed - snapshot remains null } - try { - final event = DatabaseEvent( - data: snapshot, - id: ceId, - source: ceSource, - specversion: '1.0', - subject: ceSubject, - time: DateTime.parse(ceTime), - type: ceType ?? deletedEventType, - firebaseDatabaseHost: databaseHost, - instance: instanceName, - ref: refPath, - location: location, - params: params, - ); + final event = DatabaseEvent( + data: snapshot, + id: ceId, + source: ceSource, + specversion: '1.0', + subject: ceSubject, + time: DateTime.parse(ceTime), + type: ceType ?? deletedEventType, + firebaseDatabaseHost: databaseHost, + instance: instanceName, + ref: refPath, + location: location, + params: params, + ); - await handler(event); - } catch (e, stackTrace) { - return logEventHandlerError(e, stackTrace); - } + await handler(event); return Response.ok(''); } else { @@ -624,26 +609,22 @@ class DatabaseNamespace extends FunctionsNamespace { // Body parsing failed - change remains null } - try { - final event = DatabaseEvent?>( - data: change, - id: ceId, - source: ceSource, - specversion: '1.0', - subject: ceSubject, - time: DateTime.parse(ceTime), - type: ceType ?? writtenEventType, - firebaseDatabaseHost: databaseHost, - instance: instanceName, - ref: refPath, - location: location, - params: params, - ); + final event = DatabaseEvent?>( + data: change, + id: ceId, + source: ceSource, + specversion: '1.0', + subject: ceSubject, + time: DateTime.parse(ceTime), + type: ceType ?? writtenEventType, + firebaseDatabaseHost: databaseHost, + instance: instanceName, + ref: refPath, + location: location, + params: params, + ); - await handler(event); - } catch (e, stackTrace) { - return logEventHandlerError(e, stackTrace); - } + await handler(event); return Response.ok(''); } else { diff --git a/lib/src/eventarc/eventarc_namespace.dart b/lib/src/eventarc/eventarc_namespace.dart index 6829fec..7e0383b 100644 --- a/lib/src/eventarc/eventarc_namespace.dart +++ b/lib/src/eventarc/eventarc_namespace.dart @@ -18,7 +18,6 @@ import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; import '../common/cloud_event.dart'; -import '../common/utilities.dart'; import '../firebase.dart'; import 'options.dart'; @@ -72,8 +71,6 @@ class EventarcNamespace extends FunctionsNamespace { return Response.ok(''); } on FormatException catch (e) { return Response(400, body: 'Invalid CloudEvent: ${e.message}'); - } catch (e, stackTrace) { - return logEventHandlerError(e, stackTrace); } }); } diff --git a/lib/src/firestore/firestore_namespace.dart b/lib/src/firestore/firestore_namespace.dart index 0f977b2..d725ee5 100644 --- a/lib/src/firestore/firestore_namespace.dart +++ b/lib/src/firestore/firestore_namespace.dart @@ -19,7 +19,6 @@ import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; import '../common/cloud_event.dart'; -import '../common/utilities.dart'; import '../firebase.dart'; import 'document_snapshot.dart'; import 'event.dart'; @@ -474,54 +473,50 @@ class FirestoreNamespace extends FunctionsNamespace { : 'value'; final snapshot = parsed?[snapshotKey]; - try { - if (withAuthContext) { - final event = FirestoreAuthEvent( - data: snapshot, - id: headers.id, - source: headers.source, - specversion: '1.0', - subject: headers.subject, - time: DateTime.parse(headers.time), - type: headers.type, - location: 'us-central1', - project: _extractProject(headers.source), - database: headers.database, - namespace: headers.namespace, - document: headers.documentPath, - params: params, - authType: AuthType.fromString(headers.authType ?? 'unknown'), - authId: headers.authId, - ); + if (withAuthContext) { + final event = FirestoreAuthEvent( + data: snapshot, + id: headers.id, + source: headers.source, + specversion: '1.0', + subject: headers.subject, + time: DateTime.parse(headers.time), + type: headers.type, + location: 'us-central1', + project: _extractProject(headers.source), + database: headers.database, + namespace: headers.namespace, + document: headers.documentPath, + params: params, + authType: AuthType.fromString(headers.authType ?? 'unknown'), + authId: headers.authId, + ); - await (handler - as Future Function( - FirestoreAuthEvent, - ))(event); - } else { - final event = FirestoreEvent( - data: snapshot, - id: headers.id, - source: headers.source, - specversion: '1.0', - subject: headers.subject, - time: DateTime.parse(headers.time), - type: headers.type, - location: 'us-central1', - project: _extractProject(headers.source), - database: headers.database, - namespace: headers.namespace, - document: headers.documentPath, - params: params, - ); + await (handler + as Future Function( + FirestoreAuthEvent, + ))(event); + } else { + final event = FirestoreEvent( + data: snapshot, + id: headers.id, + source: headers.source, + specversion: '1.0', + subject: headers.subject, + time: DateTime.parse(headers.time), + type: headers.type, + location: 'us-central1', + project: _extractProject(headers.source), + database: headers.database, + namespace: headers.namespace, + document: headers.documentPath, + params: params, + ); - await (handler - as Future Function( - FirestoreEvent, - ))(event); - } - } catch (e, stackTrace) { - return logEventHandlerError(e, stackTrace); + await (handler + as Future Function( + FirestoreEvent, + ))(event); } return Response.ok(''); @@ -590,8 +585,6 @@ class FirestoreNamespace extends FunctionsNamespace { } } on FormatException catch (e) { return Response(400, body: 'Invalid CloudEvent: ${e.message}'); - } catch (e, stackTrace) { - return logEventHandlerError(e, stackTrace); } }, documentPattern: document); } @@ -612,98 +605,86 @@ class FirestoreNamespace extends FunctionsNamespace { final functionName = _documentToFunctionName(methodName, document); firebase.registerFunction(functionName, (request) async { - try { - final isBinaryMode = request.headers.containsKey('ce-type'); + final isBinaryMode = request.headers.containsKey('ce-type'); - if (isBinaryMode) { - final ceType = request.headers['ce-type']; + if (isBinaryMode) { + final ceType = request.headers['ce-type']; - if (ceType != null && !validateEventType(ceType)) { - return Response( - 400, - body: 'Invalid event type for Firestore $methodName: $ceType', - ); - } - - final headers = _extractHeaders(request); - if (headers == null) { - return Response(400, body: 'Missing required CloudEvent headers'); - } - - final params = _extractParams(document, headers.documentPath); - - final parsed = await _parseBody(request); - final beforeSnapshot = parsed?['old_value']; - final afterSnapshot = parsed?['value']; - - try { - final change = Change( - before: beforeSnapshot, - after: afterSnapshot, - ); - - if (withAuthContext) { - final event = - FirestoreAuthEvent?>( - data: change, - id: headers.id, - source: headers.source, - specversion: '1.0', - subject: headers.subject, - time: DateTime.parse(headers.time), - type: headers.type, - location: 'us-central1', - project: _extractProject(headers.source), - database: headers.database, - namespace: headers.namespace, - document: headers.documentPath, - params: params, - authType: AuthType.fromString( - headers.authType ?? 'unknown', - ), - authId: headers.authId, - ); + if (ceType != null && !validateEventType(ceType)) { + return Response( + 400, + body: 'Invalid event type for Firestore $methodName: $ceType', + ); + } - await (handler - as Future Function( - FirestoreAuthEvent?>, - ))(event); - } else { - final event = FirestoreEvent?>( - data: change, - id: headers.id, - source: headers.source, - specversion: '1.0', - subject: headers.subject, - time: DateTime.parse(headers.time), - type: headers.type, - location: 'us-central1', - project: _extractProject(headers.source), - database: headers.database, - namespace: headers.namespace, - document: headers.documentPath, - params: params, - ); + final headers = _extractHeaders(request); + if (headers == null) { + return Response(400, body: 'Missing required CloudEvent headers'); + } - await (handler - as Future Function( - FirestoreEvent?>, - ))(event); - } - } catch (e, stackTrace) { - return logEventHandlerError(e, stackTrace); - } + final params = _extractParams(document, headers.documentPath); + + final parsed = await _parseBody(request); + final beforeSnapshot = parsed?['old_value']; + final afterSnapshot = parsed?['value']; + + final change = Change( + before: beforeSnapshot, + after: afterSnapshot, + ); + + if (withAuthContext) { + final event = FirestoreAuthEvent?>( + data: change, + id: headers.id, + source: headers.source, + specversion: '1.0', + subject: headers.subject, + time: DateTime.parse(headers.time), + type: headers.type, + location: 'us-central1', + project: _extractProject(headers.source), + database: headers.database, + namespace: headers.namespace, + document: headers.documentPath, + params: params, + authType: AuthType.fromString(headers.authType ?? 'unknown'), + authId: headers.authId, + ); - return Response.ok(''); + await (handler + as Future Function( + FirestoreAuthEvent?>, + ))(event); } else { - return Response( - 501, - body: - 'Structured CloudEvent mode not yet supported for $methodName', + final event = FirestoreEvent?>( + data: change, + id: headers.id, + source: headers.source, + specversion: '1.0', + subject: headers.subject, + time: DateTime.parse(headers.time), + type: headers.type, + location: 'us-central1', + project: _extractProject(headers.source), + database: headers.database, + namespace: headers.namespace, + document: headers.documentPath, + params: params, ); + + await (handler + as Future Function( + FirestoreEvent?>, + ))(event); } - } catch (e, stackTrace) { - return logEventHandlerError(e, stackTrace); + + return Response.ok(''); + } else { + return Response( + 501, + body: 'Structured CloudEvent mode not yet supported for $methodName', + ); } }, documentPattern: document); } diff --git a/lib/src/https/callable.dart b/lib/src/https/callable.dart index c503728..509fd51 100644 --- a/lib/src/https/callable.dart +++ b/lib/src/https/callable.dart @@ -196,7 +196,7 @@ class CallableResponse { final bool acceptsStreaming; final int? heartbeatSeconds; - StreamController? _streamController; + StreamController>? _streamController; Response? _streamingResponse; Timer? _heartbeatTimer; StreamSubscription>? _streamSubscription; @@ -204,7 +204,7 @@ class CallableResponse { /// Initializes SSE streaming. void initializeStreaming() { - _streamController = StreamController(); + _streamController = StreamController>(); _streamingResponse = Response.ok( _streamController!.stream, headers: { @@ -270,7 +270,7 @@ class CallableResponse { try { final formattedData = _encodeSSE({'message': chunk}); - _streamController!.add(formattedData); + _streamController!.add(utf8.encode(formattedData)); // Reset heartbeat timer after successful write if (heartbeatSeconds != null && heartbeatSeconds! > 0) { @@ -288,7 +288,7 @@ class CallableResponse { if (_streamController == null || _streamController!.isClosed) { return; } - _streamController!.add(_encodeSSE(data)); + _streamController!.add(utf8.encode(_encodeSSE(data))); } /// Closes the streaming response. @@ -333,7 +333,7 @@ class CallableResponse { if (!_aborted && _streamController != null && !_streamController!.isClosed) { - _streamController!.add(': ping\n\n'); + _streamController!.add(utf8.encode(': ping\n\n')); _scheduleHeartbeat(); } }); diff --git a/lib/src/https/error.dart b/lib/src/https/error.dart deleted file mode 100644 index e953619..0000000 --- a/lib/src/https/error.dart +++ /dev/null @@ -1,221 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/// Firebase Functions error codes. -/// -/// These match the gRPC error codes used by the Node.js SDK. -/// See: https://grpc.github.io/grpc/core/md_doc_statuscodes.html -enum FunctionsErrorCode { - // NOTE: These are ordered so that the first error code with a given HTTP - // status code is the one that is used when mapping from HTTP status codes. - ok('ok', 'OK', 200), - invalidArgument('invalid-argument', 'Invalid argument', 400), - failedPrecondition('failed-precondition', 'Failed precondition', 400), - outOfRange('out-of-range', 'Value out of range', 400), - unauthenticated('unauthenticated', 'Unauthenticated', 401), - permissionDenied('permission-denied', 'Permission denied', 403), - notFound('not-found', 'Resource not found', 404), - alreadyExists('already-exists', 'Resource already exists', 409), - aborted('aborted', 'Operation aborted', 409), - resourceExhausted('resource-exhausted', 'Resource exhausted', 429), - cancelled('cancelled', 'Request was cancelled', 499), - internal('internal', 'Internal error', 500), - unknown('unknown', 'Unknown error occurred', 500), - dataLoss('data-loss', 'Data loss', 500), - unimplemented('unimplemented', 'Operation not implemented', 501), - unavailable('unavailable', 'Service unavailable', 503), - deadlineExceeded('deadline-exceeded', 'Deadline exceeded', 504); - - const FunctionsErrorCode(this.value, this.message, this.httpStatusCode); - - /// The string value used in JSON serialization. - final String value; - - /// The default human-readable message for this error code. - final String message; - - /// The corresponding HTTP status code. - final int httpStatusCode; - - /// Maps an error code value string to the corresponding enum. - static FunctionsErrorCode? fromValue(String value) { - for (final code in FunctionsErrorCode.values) { - if (code.value == value) { - return code; - } - } - return null; - } -} - -/// Base error class for HTTPS Callable functions. -/// -/// When thrown from a callable function, this error is automatically -/// serialized and sent to the client with the appropriate status code. -/// -/// You can throw this directly: -/// ```dart -/// throw HttpsError(FunctionsErrorCode.notFound, 'Document not found'); -/// ``` -/// -/// Or use the specific error subclasses for convenience: -/// ```dart -/// throw NotFoundError('Document not found'); -/// throw UnauthenticatedError('User must be authenticated'); -/// ``` -sealed class HttpsError implements Exception { - HttpsError(this.code, [this.message, this.details]); - - /// The error code. - final FunctionsErrorCode code; - - /// Human-readable error message. - final String? message; - - /// Additional error details (must be JSON-serializable). - final dynamic details; - - /// Converts this error to JSON for wire transmission. - Map toJson() => { - 'status': code.value.toUpperCase().replaceAll('-', '_'), - 'message': message ?? code.message, - if (details != null) 'details': details, - }; - - /// Converts this error to a full error response. - Map toErrorResponse() => { - 'error': toJson(), - }; - - /// Gets the HTTP status code for this error. - int get httpStatusCode => code.httpStatusCode; - - @override - String toString() => 'HttpsError(${code.value}): ${message ?? code.message}'; - - /// Maps HTTP status codes to error codes (for parsing). - static FunctionsErrorCode httpStatusToErrorCode(int statusCode) => - FunctionsErrorCode.values.firstWhere( - (v) => v.httpStatusCode == statusCode, - orElse: () => FunctionsErrorCode.unknown, - ); -} - -/// Creates an [HttpsError] with the given code and optional message. -/// -/// This is a factory for creating HttpsError instances. -final class GenericHttpsError extends HttpsError { - GenericHttpsError(super.code, [super.message, super.details]); -} - -/// Error indicating the operation was cancelled. -final class CancelledError extends HttpsError { - CancelledError([String? message, dynamic details]) - : super(FunctionsErrorCode.cancelled, message, details); -} - -/// Error indicating an unknown error occurred. -final class UnknownError extends HttpsError { - UnknownError([String? message, dynamic details]) - : super(FunctionsErrorCode.unknown, message, details); -} - -/// Error indicating an invalid argument was provided. -final class InvalidArgumentError extends HttpsError { - InvalidArgumentError([String? message, dynamic details]) - : super(FunctionsErrorCode.invalidArgument, message, details); -} - -/// Error indicating the deadline was exceeded. -final class DeadlineExceededError extends HttpsError { - DeadlineExceededError([String? message, dynamic details]) - : super(FunctionsErrorCode.deadlineExceeded, message, details); -} - -/// Error indicating the requested resource was not found. -final class NotFoundError extends HttpsError { - NotFoundError([String? message, dynamic details]) - : super(FunctionsErrorCode.notFound, message, details); -} - -/// Error indicating the resource already exists. -final class AlreadyExistsError extends HttpsError { - AlreadyExistsError([String? message, dynamic details]) - : super(FunctionsErrorCode.alreadyExists, message, details); -} - -/// Error indicating permission was denied. -final class PermissionDeniedError extends HttpsError { - PermissionDeniedError([String? message, dynamic details]) - : super(FunctionsErrorCode.permissionDenied, message, details); -} - -/// Error indicating a resource has been exhausted. -final class ResourceExhaustedError extends HttpsError { - ResourceExhaustedError([String? message, dynamic details]) - : super(FunctionsErrorCode.resourceExhausted, message, details); -} - -/// Error indicating a precondition check failed. -final class FailedPreconditionError extends HttpsError { - FailedPreconditionError([String? message, dynamic details]) - : super(FunctionsErrorCode.failedPrecondition, message, details); -} - -/// Error indicating the operation was aborted. -final class AbortedError extends HttpsError { - AbortedError([String? message, dynamic details]) - : super(FunctionsErrorCode.aborted, message, details); -} - -/// Error indicating a value was out of range. -final class OutOfRangeError extends HttpsError { - OutOfRangeError([String? message, dynamic details]) - : super(FunctionsErrorCode.outOfRange, message, details); -} - -/// Error indicating the operation is not implemented. -/// -/// Note: This shadows Dart's core UnimplementedError. If you need the core -/// error, use `throw UnsupportedError('...')` instead. -final class UnimplementedError extends HttpsError { - UnimplementedError([String? message, dynamic details]) - : super(FunctionsErrorCode.unimplemented, message, details); -} - -/// Error indicating an internal error occurred. -final class InternalError extends HttpsError { - InternalError([ - String message = 'An unexpected error occurred.', - dynamic details, - ]) : super(FunctionsErrorCode.internal, message, details); -} - -/// Error indicating the service is unavailable. -final class UnavailableError extends HttpsError { - UnavailableError([String? message, dynamic details]) - : super(FunctionsErrorCode.unavailable, message, details); -} - -/// Error indicating data was lost. -final class DataLossError extends HttpsError { - DataLossError([String? message, dynamic details]) - : super(FunctionsErrorCode.dataLoss, message, details); -} - -/// Error indicating the user is not authenticated. -final class UnauthenticatedError extends HttpsError { - UnauthenticatedError([String message = 'Unauthenticated', dynamic details]) - : super(FunctionsErrorCode.unauthenticated, message, details); -} diff --git a/lib/src/https/https.dart b/lib/src/https/https.dart index a14da41..dd206e7 100644 --- a/lib/src/https/https.dart +++ b/lib/src/https/https.dart @@ -12,7 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +export 'package:google_cloud/http_serving.dart' show HttpResponseException; + export 'callable.dart'; -export 'error.dart'; export 'https_namespace.dart'; export 'options.dart'; diff --git a/lib/src/https/https_namespace.dart b/lib/src/https/https_namespace.dart index f0dd123..9b297dd 100644 --- a/lib/src/https/https_namespace.dart +++ b/lib/src/https/https_namespace.dart @@ -15,14 +15,13 @@ import 'dart:async'; import 'dart:convert'; +import 'package:google_cloud/http_serving.dart'; import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; -import '../common/utilities.dart'; import '../firebase.dart'; import 'auth.dart'; import 'callable.dart'; -import 'error.dart'; import 'options.dart'; /// HTTPS triggers namespace. @@ -54,13 +53,7 @@ class HttpsNamespace extends FunctionsNamespace { firebase.registerFunction( name, (request) async { - try { - return await handler(request); - } on HttpsError catch (e) { - return e.toShelfResponse(); - } catch (e, stackTrace) { - return logInternalError(e, stackTrace).toShelfResponse(); - } + return await handler(request); }, external: true, allowedOrigins: options?.cors?.runtimeValue(), @@ -115,18 +108,22 @@ class HttpsNamespace extends FunctionsNamespace { // Check for invalid auth token if (tokens.result.auth == TokenStatus.invalid) { - return UnauthenticatedError().toShelfResponse(); + throw HttpResponseException.unauthorized(message: 'Invalid auth token'); } // Check for invalid or missing app check token if enforced final enforceAppCheck = options?.enforceAppCheck?.runtimeValue() ?? false; if (tokens.result.app == TokenStatus.invalid) { if (enforceAppCheck) { - return UnauthenticatedError().toShelfResponse(); + throw HttpResponseException.unauthorized( + message: 'Invalid app check token', + ); } } if (tokens.result.app == TokenStatus.missing && enforceAppCheck) { - return UnauthenticatedError().toShelfResponse(); + throw HttpResponseException.unauthorized( + message: 'Missing app check token', + ); } final callableRequest = CallableRequest( @@ -196,18 +193,22 @@ class HttpsNamespace extends FunctionsNamespace { // Check for invalid auth token if (tokens.result.auth == TokenStatus.invalid) { - return UnauthenticatedError().toShelfResponse(); + throw HttpResponseException.unauthorized(message: 'Invalid auth token'); } // Check for invalid or missing app check token if enforced final enforceAppCheck = options?.enforceAppCheck?.runtimeValue() ?? false; if (tokens.result.app == TokenStatus.invalid) { if (enforceAppCheck) { - return UnauthenticatedError().toShelfResponse(); + throw HttpResponseException.unauthorized( + message: 'Invalid app check token', + ); } } if (tokens.result.app == TokenStatus.missing && enforceAppCheck) { - return UnauthenticatedError().toShelfResponse(); + throw HttpResponseException.unauthorized( + message: 'Missing app check token', + ); } final callableRequest = CallableRequest( @@ -256,7 +257,9 @@ class HttpsNamespace extends FunctionsNamespace { ) async { // Validate request - pass empty map if body is null to avoid double-read if (!await request.isValidRequest(body ?? {})) { - return InvalidArgumentError('Invalid callable request').toShelfResponse(); + throw HttpResponseException.badRequest( + message: 'Invalid callable request', + ); } final heartbeatSeconds = options?.heartBeatIntervalSeconds?.runtimeValue(); @@ -278,34 +281,88 @@ class HttpsNamespace extends FunctionsNamespace { if (callableRequest.acceptsStreaming && !callableResponse.aborted) { final finalResult = {'result': extractResultData(result)}; callableResponse.writeSSE(finalResult); - await callableResponse.closeStream(); + unawaited(callableResponse.closeStream()); return callableResponse.streamingResponse!; } // Non-streaming response return createNonStreamingResponse(result); - } on HttpsError catch (e) { + } on HttpResponseException catch (e) { + final errorPayload = e.toJson(); + // Handle HttpsError - use SSE format if streaming if (callableRequest.acceptsStreaming && !callableResponse.aborted) { - callableResponse.writeSSE(e.toErrorResponse()); - await callableResponse.closeStream(); + callableResponse.writeSSE(errorPayload); + unawaited(callableResponse.closeStream()); return callableResponse.streamingResponse!; } - return e.toShelfResponse(); - } catch (e, stackTrace) { - // Unexpected error - don't expose details to client - final error = logInternalError(e, stackTrace); + return Response( + e.statusCode, + body: jsonEncode(errorPayload), + headers: {'content-type': 'application/json'}, + ); + } catch (e) { + final errorPayload = { + 'error': {'status': 'INTERNAL', 'message': 'Internal error'}, + }; if (callableRequest.acceptsStreaming && !callableResponse.aborted) { - callableResponse.writeSSE(error.toErrorResponse()); - await callableResponse.closeStream(); + callableResponse.writeSSE(errorPayload); + unawaited(callableResponse.closeStream()); return callableResponse.streamingResponse!; } - return error.toShelfResponse(); + return Response( + 500, + body: jsonEncode(errorPayload), + headers: {'content-type': 'application/json'}, + ); } finally { callableResponse.clearHeartbeat(); } } } + +enum FunctionsErrorCode { + // NOTE: These are ordered so that the first error code with a given HTTP + // status code is the one that is used when mapping from HTTP status codes. + ok('ok', 'OK', 200), + invalidArgument('invalid-argument', 'Invalid argument', 400), + failedPrecondition('failed-precondition', 'Failed precondition', 400), + outOfRange('out-of-range', 'Value out of range', 400), + unauthenticated('unauthenticated', 'Unauthenticated', 401), + permissionDenied('permission-denied', 'Permission denied', 403), + notFound('not-found', 'Resource not found', 404), + alreadyExists('already-exists', 'Resource already exists', 409), + aborted('aborted', 'Operation aborted', 409), + resourceExhausted('resource-exhausted', 'Resource exhausted', 429), + cancelled('cancelled', 'Request was cancelled', 499), + internal('internal', 'Internal error', 500), + unknown('unknown', 'Unknown error occurred', 500), + dataLoss('data-loss', 'Data loss', 500), + unimplemented('unimplemented', 'Operation not implemented', 501), + unavailable('unavailable', 'Service unavailable', 503), + deadlineExceeded('deadline-exceeded', 'Deadline exceeded', 504); + + const FunctionsErrorCode(this.value, this.message, this.httpStatusCode); + + /// The string value used in JSON serialization. + final String value; + + /// The default human-readable message for this error code. + final String message; + + /// The corresponding HTTP status code. + final int httpStatusCode; + + /// Maps an error code value string to the corresponding enum. + static FunctionsErrorCode? fromValue(String value) { + for (final code in FunctionsErrorCode.values) { + if (code.value == value) { + return code; + } + } + return null; + } +} diff --git a/lib/src/identity/identity_namespace.dart b/lib/src/identity/identity_namespace.dart index 7d23c9c..ab4ae31 100644 --- a/lib/src/identity/identity_namespace.dart +++ b/lib/src/identity/identity_namespace.dart @@ -18,12 +18,12 @@ library; import 'dart:async'; import 'dart:convert'; +import 'package:google_cloud/http_serving.dart'; import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; import '../common/utilities.dart'; import '../firebase.dart'; -import '../https/error.dart'; import 'auth_blocking_event.dart'; import 'options.dart'; import 'responses.dart'; @@ -213,45 +213,41 @@ class IdentityNamespace extends FunctionsNamespace { final functionName = eventType.value; firebase.registerFunction(functionName, (request) async { - try { - // Validate request - if (!_isValidRequest(request)) { - throw InvalidArgumentError('Bad Request'); - } + // Validate request + if (!_isValidRequest(request)) { + throw HttpResponseException.badRequest(); + } - // Parse request body - final body = await readAsJsonMap(request); + // Parse request body + final body = await readAsJsonMap(request); - // Extract JWT from request body - final data = body['data'] as Map?; - final jwt = data?['jwt'] as String?; + // Extract JWT from request body + final data = body['data'] as Map?; + final jwt = data?['jwt'] as String?; - if (jwt == null) { - throw InvalidArgumentError('Missing JWT in request body'); - } + if (jwt == null) { + throw HttpResponseException.badRequest( + message: 'Missing JWT in request body', + ); + } - // Decode and verify JWT payload - final decodedPayload = await _decodeAndVerifyJwt(jwt); + // Decode and verify JWT payload + final decodedPayload = await _decodeAndVerifyJwt(jwt); - // Parse the event - final event = AuthBlockingEvent.fromDecodedPayload(decodedPayload); + // Parse the event + final event = AuthBlockingEvent.fromDecodedPayload(decodedPayload); - // Validate response claims - final response = await handler(event); - _validateAuthResponse(eventType, response); + // Validate response claims + final response = await handler(event); + _validateAuthResponse(eventType, response); - // Generate response payload - final result = generateResponsePayload(response); + // Generate response payload + final result = generateResponsePayload(response); - return Response.ok( - jsonEncode(result.toJson()), - headers: {'Content-Type': 'application/json'}, - ); - } on HttpsError catch (e) { - return e.toShelfResponse(); - } catch (e, stackTrace) { - return logInternalError(e, stackTrace).toShelfResponse(); - } + return Response.ok( + jsonEncode(result.toJson()), + headers: {'Content-Type': 'application/json'}, + ); }); } @@ -328,15 +324,17 @@ class IdentityNamespace extends FunctionsNamespace { .where(customClaims.containsKey) .toList(); if (invalidClaims.isNotEmpty) { - throw InvalidArgumentError( - 'The customClaims claims "${invalidClaims.join(",")}" are reserved ' - 'and cannot be specified.', + throw HttpResponseException.badRequest( + message: + 'The customClaims claims "${invalidClaims.join(",")}" are reserved ' + 'and cannot be specified.', ); } if (jsonEncode(customClaims).length > claimsMaxPayloadSize) { - throw InvalidArgumentError( - 'The customClaims payload should not exceed $claimsMaxPayloadSize ' - 'characters.', + throw HttpResponseException.badRequest( + message: + 'The customClaims payload should not exceed $claimsMaxPayloadSize ' + 'characters.', ); } } @@ -349,15 +347,17 @@ class IdentityNamespace extends FunctionsNamespace { .where(sessionClaims.containsKey) .toList(); if (invalidClaims.isNotEmpty) { - throw InvalidArgumentError( - 'The sessionClaims claims "${invalidClaims.join(",")}" are reserved ' - 'and cannot be specified.', + throw HttpResponseException.badRequest( + message: + 'The sessionClaims claims "${invalidClaims.join(",")}" are reserved ' + 'and cannot be specified.', ); } if (jsonEncode(sessionClaims).length > claimsMaxPayloadSize) { - throw InvalidArgumentError( - 'The sessionClaims payload should not exceed $claimsMaxPayloadSize ' - 'characters.', + throw HttpResponseException.badRequest( + message: + 'The sessionClaims payload should not exceed $claimsMaxPayloadSize ' + 'characters.', ); } @@ -365,9 +365,10 @@ class IdentityNamespace extends FunctionsNamespace { final customClaims = authResponse.customClaims ?? {}; final combinedClaims = {...customClaims, ...sessionClaims}; if (jsonEncode(combinedClaims).length > claimsMaxPayloadSize) { - throw InvalidArgumentError( - 'The customClaims and sessionClaims payloads should not exceed ' - '$claimsMaxPayloadSize characters combined.', + throw HttpResponseException.badRequest( + message: + 'The customClaims and sessionClaims payloads should not exceed ' + '$claimsMaxPayloadSize characters combined.', ); } } diff --git a/lib/src/identity/token_verifier.dart b/lib/src/identity/token_verifier.dart index 325db1a..39fa446 100644 --- a/lib/src/identity/token_verifier.dart +++ b/lib/src/identity/token_verifier.dart @@ -18,10 +18,9 @@ library; import 'dart:convert'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; +import 'package:google_cloud/http_serving.dart'; import 'package:http/http.dart' as http; -import '../https/error.dart'; - /// URL to fetch Google's public keys in JWK format for JWT verification. /// This endpoint returns keys directly as JWKs, no certificate parsing needed. const _googleJwksUrl = 'https://www.googleapis.com/oauth2/v3/certs'; @@ -64,7 +63,7 @@ class AuthBlockingTokenVerifier { /// If [audience] is provided, it's used for audience validation. /// For Cloud Run (GCF v2), pass `"run.app"` as the audience. /// - /// Throws [UnauthenticatedError] if verification fails. + /// Throws [HttpResponseException] if verification fails. Future> verifyToken( String token, { String? audience, @@ -79,13 +78,13 @@ class AuthBlockingTokenVerifier { try { decoded = JWT.decode(token); } catch (e) { - throw UnauthenticatedError('Invalid JWT format: $e'); + throw HttpResponseException.badRequest(message: 'Invalid JWT format: $e'); } final kid = decoded.header?['kid']; if (kid is! String) { - throw UnauthenticatedError( - 'Invalid JWT: missing "kid" header or not String', + throw HttpResponseException.badRequest( + message: 'Invalid JWT: missing "kid" header or not String', ); } @@ -94,16 +93,20 @@ class AuthBlockingTokenVerifier { final key = keys[kid]; if (key == null) { - throw UnauthenticatedError('Invalid JWT: unknown "kid"'); + throw HttpResponseException.badRequest( + message: 'Invalid JWT: unknown "kid"', + ); } // Verify the token using dart_jsonwebtoken try { JWT.verify(token, key); } on JWTException catch (e) { - throw UnauthenticatedError('Invalid JWT: ${e.message}'); + throw HttpResponseException.badRequest( + message: 'Invalid JWT: ${e.message}', + ); } catch (e) { - throw UnauthenticatedError('Invalid JWT: $e'); + throw HttpResponseException.badRequest(message: 'Invalid JWT: $e'); } // Extract the payload as a map @@ -121,7 +124,7 @@ class AuthBlockingTokenVerifier { final decoded = JWT.decode(token); return decoded.payload as Map; } catch (e) { - throw InvalidArgumentError('Invalid JWT format'); + throw HttpResponseException.badRequest(message: 'Invalid JWT format'); } } @@ -189,8 +192,8 @@ class AuthBlockingTokenVerifier { // Validate issuer final iss = payload['iss'] as String?; if (iss != _expectedIssuer) { - throw UnauthenticatedError( - 'Invalid token issuer. Expected $_expectedIssuer, got $iss', + throw HttpResponseException.badRequest( + message: 'Invalid token issuer. Expected $_expectedIssuer, got $iss', ); } @@ -211,20 +214,22 @@ class AuthBlockingTokenVerifier { } if (!audienceValid) { - throw UnauthenticatedError( - 'Invalid token audience. Expected $expectedAudience, got $aud', + throw HttpResponseException.badRequest( + message: 'Invalid token audience. Expected $expectedAudience, got $aud', ); } // Validate expiration final exp = payload['exp'] as int?; if (exp == null) { - throw UnauthenticatedError('Token missing expiration claim'); + throw HttpResponseException.badRequest( + message: 'Token missing expiration claim', + ); } final expiration = DateTime.fromMillisecondsSinceEpoch(exp * 1000); if (DateTime.now().isAfter(expiration)) { - throw UnauthenticatedError('Token has expired'); + throw HttpResponseException.badRequest(message: 'Token has expired'); } // Validate issued-at (not in the future) @@ -233,7 +238,9 @@ class AuthBlockingTokenVerifier { final issuedAt = DateTime.fromMillisecondsSinceEpoch(iat * 1000); // Allow 5 minutes of clock skew if (issuedAt.isAfter(DateTime.now().add(const Duration(minutes: 5)))) { - throw UnauthenticatedError('Token issued in the future'); + throw HttpResponseException.badRequest( + message: 'Token issued in the future', + ); } } @@ -242,10 +249,14 @@ class AuthBlockingTokenVerifier { if (eventType != 'beforeSendEmail' && eventType != 'beforeSendSms') { final sub = payload['sub'] as String?; if (sub == null || sub.isEmpty) { - throw UnauthenticatedError('Token missing subject claim'); + throw HttpResponseException.badRequest( + message: 'Token missing subject claim', + ); } if (sub.length > 128) { - throw UnauthenticatedError('Token subject exceeds 128 characters'); + throw HttpResponseException.badRequest( + message: 'Token subject exceeds 128 characters', + ); } } } diff --git a/lib/src/pubsub/pubsub_namespace.dart b/lib/src/pubsub/pubsub_namespace.dart index 914f280..bdeba36 100644 --- a/lib/src/pubsub/pubsub_namespace.dart +++ b/lib/src/pubsub/pubsub_namespace.dart @@ -18,7 +18,6 @@ import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; import '../common/cloud_event.dart'; -import '../common/utilities.dart'; import '../firebase.dart'; import 'message.dart'; import 'options.dart'; @@ -80,8 +79,6 @@ class PubSubNamespace extends FunctionsNamespace { return Response.ok(''); } on FormatException catch (e) { return Response(400, body: 'Invalid CloudEvent: ${e.message}'); - } catch (e, stackTrace) { - return logEventHandlerError(e, stackTrace); } }); } diff --git a/lib/src/remote_config/remote_config_namespace.dart b/lib/src/remote_config/remote_config_namespace.dart index 8fd2a8d..0e08b5a 100644 --- a/lib/src/remote_config/remote_config_namespace.dart +++ b/lib/src/remote_config/remote_config_namespace.dart @@ -18,7 +18,6 @@ import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; import '../common/cloud_event.dart'; -import '../common/utilities.dart'; import '../firebase.dart'; import 'config_update_data.dart'; import 'options.dart'; @@ -76,8 +75,6 @@ class RemoteConfigNamespace extends FunctionsNamespace { return Response.ok(''); } on FormatException catch (e) { return Response(400, body: 'Invalid CloudEvent: ${e.message}'); - } catch (e, stackTrace) { - return logEventHandlerError(e, stackTrace); } }); } diff --git a/lib/src/scheduler/scheduler_namespace.dart b/lib/src/scheduler/scheduler_namespace.dart index 9341c7b..87a72d1 100644 --- a/lib/src/scheduler/scheduler_namespace.dart +++ b/lib/src/scheduler/scheduler_namespace.dart @@ -17,7 +17,6 @@ import 'dart:async'; import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; -import '../common/utilities.dart'; import '../firebase.dart'; import 'options.dart'; import 'scheduled_event.dart'; @@ -95,20 +94,15 @@ class SchedulerNamespace extends FunctionsNamespace { final functionName = _scheduleToFunctionName(schedule); firebase.registerFunction(functionName, (request) async { - try { - // Extract event data from request headers - final headers = _lowercaseHeaders(request.headers); - final event = ScheduledEvent.fromHeaders(headers); + // Extract event data from request headers + final headers = _lowercaseHeaders(request.headers); + final event = ScheduledEvent.fromHeaders(headers); - // Execute handler - await handler(event); + // Execute handler + await handler(event); - // Return success (Cloud Scheduler expects 2xx response) - return Response.ok(''); - } catch (e, stackTrace) { - // Cloud Scheduler will retry based on retry config - return logEventHandlerError(e, stackTrace); - } + // Return success (Cloud Scheduler expects 2xx response) + return Response.ok(''); }); } diff --git a/lib/src/storage/storage_namespace.dart b/lib/src/storage/storage_namespace.dart index 84f39f4..7966cfe 100644 --- a/lib/src/storage/storage_namespace.dart +++ b/lib/src/storage/storage_namespace.dart @@ -18,7 +18,6 @@ import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; import '../common/cloud_event.dart'; -import '../common/utilities.dart'; import '../firebase.dart'; import 'options.dart'; import 'storage_event.dart'; @@ -163,8 +162,6 @@ class StorageNamespace extends FunctionsNamespace { return Response.ok(''); } on FormatException catch (e) { return Response(400, body: 'Invalid CloudEvent: ${e.message}'); - } catch (e, stackTrace) { - return logEventHandlerError(e, stackTrace); } }); } diff --git a/lib/src/test_lab/test_lab_namespace.dart b/lib/src/test_lab/test_lab_namespace.dart index 077acbe..cc56e7a 100644 --- a/lib/src/test_lab/test_lab_namespace.dart +++ b/lib/src/test_lab/test_lab_namespace.dart @@ -18,7 +18,6 @@ import 'package:meta/meta.dart'; import 'package:shelf/shelf.dart'; import '../common/cloud_event.dart'; -import '../common/utilities.dart'; import '../firebase.dart'; import 'options.dart'; import 'test_matrix_completed_data.dart'; @@ -71,8 +70,6 @@ class TestLabNamespace extends FunctionsNamespace { return Response.ok(''); } on FormatException catch (e) { return Response(400, body: 'Invalid CloudEvent: ${e.message}'); - } catch (e, stackTrace) { - return logEventHandlerError(e, stackTrace); } }); } diff --git a/pubspec.yaml b/pubspec.yaml index 311ff3b..ee18cc9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -69,6 +69,9 @@ dev_dependencies: yaml: ^3.1.3 dependency_overrides: + google_cloud: + path: /Users/kevmoo/github/google-cloud-dart/pkgs/google_cloud + google_cloud_firestore: git: url: https://github.com/firebase/firebase-admin-dart.git diff --git a/test/fixtures/dart_reference/bin/server.dart b/test/fixtures/dart_reference/bin/server.dart index 2e75d8f..5d7344f 100644 --- a/test/fixtures/dart_reference/bin/server.dart +++ b/test/fixtures/dart_reference/bin/server.dart @@ -77,11 +77,11 @@ void main(List args) async { final b = (data?['b'] as num?)?.toDouble(); if (a == null || b == null) { - throw InvalidArgumentError('Both "a" and "b" are required'); + throw HttpResponseException(400, 'Both "a" and "b" are required'); } if (b == 0) { - throw FailedPreconditionError('Cannot divide by zero'); + throw HttpResponseException(400, 'Cannot divide by zero'); } return CallableResult({'result': a / b}); @@ -386,7 +386,7 @@ void main(List args) async { // Example: Block users with certain email domains final email = user?.email; if (email != null && email.endsWith('@blocked.com')) { - throw PermissionDeniedError('Email domain not allowed'); + throw HttpResponseException(403, 'Email domain not allowed'); } // Example: Set custom claims based on email domain diff --git a/test/unit/error_logging_test.dart b/test/unit/error_logging_test.dart index d373c6f..81ae10d 100644 --- a/test/unit/error_logging_test.dart +++ b/test/unit/error_logging_test.dart @@ -16,82 +16,18 @@ import 'dart:convert'; +import 'package:firebase_functions/firebase_functions.dart'; import 'package:firebase_functions/src/common/environment.dart'; -import 'package:firebase_functions/src/common/utilities.dart'; import 'package:firebase_functions/src/firebase.dart'; -import 'package:firebase_functions/src/https/error.dart'; -import 'package:firebase_functions/src/https/https_namespace.dart'; -import 'package:firebase_functions/src/pubsub/pubsub_namespace.dart'; -import 'package:firebase_functions/src/scheduler/scheduler_namespace.dart'; -import 'package:shelf/shelf.dart'; import 'package:test/test.dart'; +import 'shared_utils.dart'; + void main() { setUpAll(() { FirebaseEnv.mockEnvironment = {'FIREBASE_PROJECT': 'demo-test'}; }); - group('Error logging utilities', () { - group('logInternalError', () { - test('returns InternalError', () { - final result = logInternalError( - Exception('secret db password: abc123'), - StackTrace.current, - ); - expect(result, isA()); - expect(result.code, FunctionsErrorCode.internal); - }); - - test('does not include error details in the HTTP response', () { - final error = logInternalError( - Exception('secret db password: abc123'), - StackTrace.current, - ); - final response = error.toShelfResponse(); - // Synchronously read the body from the response - // The response wraps the body as a shelf body - expect(response.statusCode, 500); - - // Verify the JSON body contains generic message, not the secret - return response.readAsString().then((body) { - final json = jsonDecode(body) as Map; - expect(json['error']['status'], 'INTERNAL'); - expect(json['error']['message'], 'An unexpected error occurred.'); - expect(body, isNot(contains('secret db password'))); - expect(body, isNot(contains('abc123'))); - }); - }); - }); - - group('logEventHandlerError', () { - test('returns 500 response', () { - final response = logEventHandlerError( - Exception('secret api key: xyz789'), - StackTrace.current, - ); - expect(response.statusCode, 500); - }); - - test('does not include error details in the response body', () async { - final response = logEventHandlerError( - Exception('secret api key: xyz789'), - StackTrace.current, - ); - final body = await response.readAsString(); - expect(body, isNot(contains('secret api key'))); - expect(body, isNot(contains('xyz789'))); - }); - }); - - group('_logError (via logInternalError)', () { - test('logs error and terse stack trace to stderr', () { - // We can't directly test the global logger, but we can test - // Trace.terse formatting indirectly by verifying our utility logic. - // The actual logging is tested via integration below. - }); - }); - }); - group('HTTPS handler error logging integration', () { late Firebase firebase; late HttpsNamespace https; @@ -115,32 +51,22 @@ void main() { 'GET', Uri.parse('http://localhost/crash-endpoint'), ); - final response = await func.handler(request); - - expect(response.statusCode, 500); - final body = await response.readAsString(); - final json = jsonDecode(body) as Map; - - // Error response is generic - expect(json['error']['status'], 'INTERNAL'); - expect(json['error']['message'], 'An unexpected error occurred.'); - - // Sensitive details are NOT in the response - expect(body, isNot(contains('postgres://'))); - expect(body, isNot(contains('connection string'))); + expect(() => func.handler(request), throwsA(isA())); }, ); test('onRequest: HttpsError is passed through to client', () async { https.onRequest(name: 'knownError', (request) async { - throw NotFoundError('User 42 not found'); + throw HttpResponseException.notFound(message: 'User 42 not found'); }); - final func = firebase.functions.firstWhere( - (f) => f.name == 'known-error', + final handler = findHandler(firebase, 'known-error'); + final request = Request( + 'GET', + Uri.parse('http://localhost/known-error'), + headers: {'accept': 'application/json'}, ); - final request = Request('GET', Uri.parse('http://localhost/known-error')); - final response = await func.handler(request); + final response = await handler(request); expect(response.statusCode, 404); final body = await response.readAsString(); @@ -194,14 +120,7 @@ void main() { body: jsonEncode(cloudEvent), headers: {'content-type': 'application/json'}, ); - final response = await func.handler(request); - - expect(response.statusCode, 500); - final body = await response.readAsString(); - - // Sensitive details are NOT in the response - expect(body, isNot(contains('api key'))); - expect(body, isNot(contains('sk-12345'))); + expect(() => func.handler(request), throwsA(isA())); }); test('Scheduler: unexpected error returns 500 without details', () async { @@ -219,14 +138,7 @@ void main() { Uri.parse('http://localhost/on-schedule-0-0'), headers: {'x-cloudscheduler-scheduletime': '2024-01-01T00:00:00Z'}, ); - final response = await func.handler(request); - - expect(response.statusCode, 500); - final body = await response.readAsString(); - - // Sensitive details are NOT in the response - expect(body, isNot(contains('db password'))); - expect(body, isNot(contains('hunter2'))); + expect(() => func.handler(request), throwsA(isA())); }); }); } diff --git a/test/unit/https_error_test.dart b/test/unit/https_error_test.dart deleted file mode 100644 index 50eb581..0000000 --- a/test/unit/https_error_test.dart +++ /dev/null @@ -1,398 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'package:firebase_functions/src/https/error.dart'; -import 'package:test/test.dart'; - -void main() { - group('FunctionsErrorCode', () { - test('has correct string values', () { - expect(FunctionsErrorCode.ok.value, 'ok'); - expect(FunctionsErrorCode.cancelled.value, 'cancelled'); - expect(FunctionsErrorCode.unknown.value, 'unknown'); - expect(FunctionsErrorCode.invalidArgument.value, 'invalid-argument'); - expect(FunctionsErrorCode.deadlineExceeded.value, 'deadline-exceeded'); - expect(FunctionsErrorCode.notFound.value, 'not-found'); - expect(FunctionsErrorCode.alreadyExists.value, 'already-exists'); - expect(FunctionsErrorCode.permissionDenied.value, 'permission-denied'); - expect(FunctionsErrorCode.resourceExhausted.value, 'resource-exhausted'); - expect( - FunctionsErrorCode.failedPrecondition.value, - 'failed-precondition', - ); - expect(FunctionsErrorCode.aborted.value, 'aborted'); - expect(FunctionsErrorCode.outOfRange.value, 'out-of-range'); - expect(FunctionsErrorCode.unimplemented.value, 'unimplemented'); - expect(FunctionsErrorCode.internal.value, 'internal'); - expect(FunctionsErrorCode.unavailable.value, 'unavailable'); - expect(FunctionsErrorCode.dataLoss.value, 'data-loss'); - expect(FunctionsErrorCode.unauthenticated.value, 'unauthenticated'); - }); - - test('fromValue returns correct enum', () { - expect( - FunctionsErrorCode.fromValue('not-found'), - FunctionsErrorCode.notFound, - ); - expect( - FunctionsErrorCode.fromValue('invalid-argument'), - FunctionsErrorCode.invalidArgument, - ); - expect(FunctionsErrorCode.fromValue('unknown-value'), isNull); - }); - }); - - group('HttpsError', () { - test('toJson returns correct structure', () { - final error = GenericHttpsError( - FunctionsErrorCode.notFound, - 'Document not found', - ); - - expect(error.toJson(), { - 'status': 'NOT_FOUND', - 'message': 'Document not found', - }); - }); - - test('toJson includes details when provided', () { - final error = GenericHttpsError( - FunctionsErrorCode.invalidArgument, - 'Invalid input', - {'field': 'email', 'reason': 'invalid format'}, - ); - - expect(error.toJson(), { - 'status': 'INVALID_ARGUMENT', - 'message': 'Invalid input', - 'details': {'field': 'email', 'reason': 'invalid format'}, - }); - }); - - test('toJson uses default message when message is null', () { - final error = GenericHttpsError(FunctionsErrorCode.notFound); - - expect(error.toJson()['message'], 'Resource not found'); - }); - - test('toErrorResponse wraps toJson in error key', () { - final error = GenericHttpsError( - FunctionsErrorCode.notFound, - 'Document not found', - ); - - expect(error.toErrorResponse(), { - 'error': {'status': 'NOT_FOUND', 'message': 'Document not found'}, - }); - }); - - test('httpStatusCode returns correct HTTP status', () { - expect(GenericHttpsError(FunctionsErrorCode.ok).httpStatusCode, 200); - expect( - GenericHttpsError(FunctionsErrorCode.cancelled).httpStatusCode, - 499, - ); - expect(GenericHttpsError(FunctionsErrorCode.unknown).httpStatusCode, 500); - expect( - GenericHttpsError(FunctionsErrorCode.invalidArgument).httpStatusCode, - 400, - ); - expect( - GenericHttpsError(FunctionsErrorCode.deadlineExceeded).httpStatusCode, - 504, - ); - expect( - GenericHttpsError(FunctionsErrorCode.notFound).httpStatusCode, - 404, - ); - expect( - GenericHttpsError(FunctionsErrorCode.alreadyExists).httpStatusCode, - 409, - ); - expect( - GenericHttpsError(FunctionsErrorCode.permissionDenied).httpStatusCode, - 403, - ); - expect( - GenericHttpsError(FunctionsErrorCode.resourceExhausted).httpStatusCode, - 429, - ); - expect( - GenericHttpsError(FunctionsErrorCode.failedPrecondition).httpStatusCode, - 400, - ); - expect(GenericHttpsError(FunctionsErrorCode.aborted).httpStatusCode, 409); - expect( - GenericHttpsError(FunctionsErrorCode.outOfRange).httpStatusCode, - 400, - ); - expect( - GenericHttpsError(FunctionsErrorCode.unimplemented).httpStatusCode, - 501, - ); - expect( - GenericHttpsError(FunctionsErrorCode.internal).httpStatusCode, - 500, - ); - expect( - GenericHttpsError(FunctionsErrorCode.unavailable).httpStatusCode, - 503, - ); - expect( - GenericHttpsError(FunctionsErrorCode.dataLoss).httpStatusCode, - 500, - ); - expect( - GenericHttpsError(FunctionsErrorCode.unauthenticated).httpStatusCode, - 401, - ); - }); - - test('httpStatusToErrorCode returns correct error code', () { - expect(HttpsError.httpStatusToErrorCode(200), FunctionsErrorCode.ok); - expect( - HttpsError.httpStatusToErrorCode(400), - FunctionsErrorCode.invalidArgument, - ); - expect( - HttpsError.httpStatusToErrorCode(401), - FunctionsErrorCode.unauthenticated, - ); - expect( - HttpsError.httpStatusToErrorCode(403), - FunctionsErrorCode.permissionDenied, - ); - expect( - HttpsError.httpStatusToErrorCode(404), - FunctionsErrorCode.notFound, - ); - expect( - HttpsError.httpStatusToErrorCode(409), - FunctionsErrorCode.alreadyExists, - ); - expect( - HttpsError.httpStatusToErrorCode(429), - FunctionsErrorCode.resourceExhausted, - ); - expect( - HttpsError.httpStatusToErrorCode(499), - FunctionsErrorCode.cancelled, - ); - expect( - HttpsError.httpStatusToErrorCode(500), - FunctionsErrorCode.internal, - ); - expect( - HttpsError.httpStatusToErrorCode(501), - FunctionsErrorCode.unimplemented, - ); - expect( - HttpsError.httpStatusToErrorCode(503), - FunctionsErrorCode.unavailable, - ); - expect( - HttpsError.httpStatusToErrorCode(504), - FunctionsErrorCode.deadlineExceeded, - ); - expect( - HttpsError.httpStatusToErrorCode(418), // I'm a teapot - FunctionsErrorCode.unknown, - ); - }); - - test('toString returns descriptive message', () { - final error = GenericHttpsError( - FunctionsErrorCode.notFound, - 'Document not found', - ); - - expect(error.toString(), 'HttpsError(not-found): Document not found'); - }); - - test('toString uses default message when message is null', () { - final error = GenericHttpsError(FunctionsErrorCode.notFound); - - expect(error.toString(), 'HttpsError(not-found): Resource not found'); - }); - }); - - group('Specific Error Classes', () { - test('CancelledError has correct code', () { - final error = CancelledError('Operation cancelled'); - expect(error.code, FunctionsErrorCode.cancelled); - expect(error.message, 'Operation cancelled'); - expect(error.httpStatusCode, 499); - }); - - test('CancelledError uses default message', () { - final error = CancelledError(); - expect(error.toJson()['message'], 'Request was cancelled'); - }); - - test('UnknownError has correct code', () { - final error = UnknownError('Something went wrong'); - expect(error.code, FunctionsErrorCode.unknown); - expect(error.message, 'Something went wrong'); - expect(error.httpStatusCode, 500); - }); - - test('InvalidArgumentError has correct code', () { - final error = InvalidArgumentError('Invalid email format'); - expect(error.code, FunctionsErrorCode.invalidArgument); - expect(error.message, 'Invalid email format'); - expect(error.httpStatusCode, 400); - }); - - test('DeadlineExceededError has correct code', () { - final error = DeadlineExceededError('Request timed out'); - expect(error.code, FunctionsErrorCode.deadlineExceeded); - expect(error.message, 'Request timed out'); - expect(error.httpStatusCode, 504); - }); - - test('NotFoundError has correct code', () { - final error = NotFoundError('User not found'); - expect(error.code, FunctionsErrorCode.notFound); - expect(error.message, 'User not found'); - expect(error.httpStatusCode, 404); - }); - - test('AlreadyExistsError has correct code', () { - final error = AlreadyExistsError('Email already registered'); - expect(error.code, FunctionsErrorCode.alreadyExists); - expect(error.message, 'Email already registered'); - expect(error.httpStatusCode, 409); - }); - - test('PermissionDeniedError has correct code', () { - final error = PermissionDeniedError('Access denied'); - expect(error.code, FunctionsErrorCode.permissionDenied); - expect(error.message, 'Access denied'); - expect(error.httpStatusCode, 403); - }); - - test('ResourceExhaustedError has correct code', () { - final error = ResourceExhaustedError('Rate limit exceeded'); - expect(error.code, FunctionsErrorCode.resourceExhausted); - expect(error.message, 'Rate limit exceeded'); - expect(error.httpStatusCode, 429); - }); - - test('FailedPreconditionError has correct code', () { - final error = FailedPreconditionError('Account not verified'); - expect(error.code, FunctionsErrorCode.failedPrecondition); - expect(error.message, 'Account not verified'); - expect(error.httpStatusCode, 400); - }); - - test('AbortedError has correct code', () { - final error = AbortedError('Transaction aborted'); - expect(error.code, FunctionsErrorCode.aborted); - expect(error.message, 'Transaction aborted'); - expect(error.httpStatusCode, 409); - }); - - test('OutOfRangeError has correct code', () { - final error = OutOfRangeError('Value must be between 1 and 100'); - expect(error.code, FunctionsErrorCode.outOfRange); - expect(error.message, 'Value must be between 1 and 100'); - expect(error.httpStatusCode, 400); - }); - - test('UnimplementedError has correct code', () { - final error = UnimplementedError('Feature not yet available'); - expect(error.code, FunctionsErrorCode.unimplemented); - expect(error.message, 'Feature not yet available'); - expect(error.httpStatusCode, 501); - }); - - test('InternalError has correct code', () { - final error = InternalError('Database connection failed'); - expect(error.code, FunctionsErrorCode.internal); - expect(error.message, 'Database connection failed'); - expect(error.httpStatusCode, 500); - }); - - test('UnavailableError has correct code', () { - final error = UnavailableError('Service temporarily unavailable'); - expect(error.code, FunctionsErrorCode.unavailable); - expect(error.message, 'Service temporarily unavailable'); - expect(error.httpStatusCode, 503); - }); - - test('DataLossError has correct code', () { - final error = DataLossError('Data corrupted'); - expect(error.code, FunctionsErrorCode.dataLoss); - expect(error.message, 'Data corrupted'); - expect(error.httpStatusCode, 500); - }); - - test('UnauthenticatedError has correct code', () { - final error = UnauthenticatedError('Please sign in'); - expect(error.code, FunctionsErrorCode.unauthenticated); - expect(error.message, 'Please sign in'); - expect(error.httpStatusCode, 401); - }); - - test('Error classes support details parameter', () { - final error = NotFoundError('User not found', {'userId': '12345'}); - - expect(error.details, {'userId': '12345'}); - expect(error.toJson()['details'], {'userId': '12345'}); - }); - }); - - group('HttpsError implements Exception', () { - test('can be thrown and caught', () { - expect( - () => throw NotFoundError('Not found'), - throwsA(isA()), - ); - }); - - test('can be caught as specific type', () { - expect( - () => throw NotFoundError('Not found'), - throwsA(isA()), - ); - }); - - test('is sealed - all subclasses are known', () { - // This test verifies that HttpsError is sealed by checking - // that we can use exhaustive switch. The variable must be typed - // as HttpsError (not a specific subclass) for exhaustive checking. - final HttpsError error = NotFoundError('test'); - final result = switch (error) { - GenericHttpsError() => 'generic', - CancelledError() => 'cancelled', - UnknownError() => 'unknown', - InvalidArgumentError() => 'invalid', - DeadlineExceededError() => 'deadline', - NotFoundError() => 'not-found', - AlreadyExistsError() => 'exists', - PermissionDeniedError() => 'permission', - ResourceExhaustedError() => 'exhausted', - FailedPreconditionError() => 'precondition', - AbortedError() => 'aborted', - OutOfRangeError() => 'range', - UnimplementedError() => 'unimplemented', - InternalError() => 'internal', - UnavailableError() => 'unavailable', - DataLossError() => 'data-loss', - UnauthenticatedError() => 'unauthenticated', - }; - expect(result, 'not-found'); - }); - }); -} diff --git a/test/unit/https_namespace_test.dart b/test/unit/https_namespace_test.dart index 2415ca5..2885cab 100644 --- a/test/unit/https_namespace_test.dart +++ b/test/unit/https_namespace_test.dart @@ -19,20 +19,13 @@ import 'dart:convert'; import 'package:firebase_functions/src/common/environment.dart'; import 'package:firebase_functions/src/firebase.dart'; import 'package:firebase_functions/src/https/callable.dart'; -import 'package:firebase_functions/src/https/error.dart'; import 'package:firebase_functions/src/https/https_namespace.dart'; import 'package:firebase_functions/src/https/options.dart'; +import 'package:google_cloud/http_serving.dart'; import 'package:shelf/shelf.dart'; import 'package:test/test.dart'; -// Helper to find function by name (uses kebab-case Cloud Run ID) -FirebaseFunctionDeclaration? _findFunction(Firebase firebase, String name) { - try { - return firebase.functions.firstWhere((f) => f.name == name); - } catch (e) { - return null; - } -} +import 'shared_utils.dart'; void main() { setUpAll(() { @@ -55,7 +48,7 @@ void main() { (request) async => Response.ok('Hello'), ); - expect(_findFunction(firebase, 'test-function'), isNotNull); + expect(findHandler(firebase, 'test-function'), isNotNull); }); test('handler receives request and returns response', () async { @@ -64,12 +57,12 @@ void main() { (request) async => Response.ok('Hello, World!'), ); - final func = _findFunction(firebase, 'test-function')!; + final func = findHandler(firebase, 'test-function'); final request = Request( 'GET', Uri.parse('http://localhost/test-function'), ); - final response = await func.handler(request); + final response = await func(request); expect(response.statusCode, 200); expect(await response.readAsString(), 'Hello, World!'); @@ -77,22 +70,23 @@ void main() { test('catches HttpsError and returns proper error response', () async { https.onRequest(name: 'errorFunction', (request) async { - throw NotFoundError('Resource not found'); + throw HttpResponseException.notFound(message: 'Resource not found'); }); - final func = _findFunction(firebase, 'error-function')!; + final func = findHandler(firebase, 'error-function'); final request = Request( 'GET', Uri.parse('http://localhost/error-function'), + headers: {'content-type': 'application/json'}, ); - final response = await func.handler(request); + final response = await func(request); expect(response.statusCode, 404); expect(response.headers['Content-Type'], 'application/json'); final body = jsonDecode(await response.readAsString()); - expect(body['error']['status'], 'NOT_FOUND'); - expect(body['error']['message'], 'Resource not found'); + expect(body['error'], containsPair('status', 'NOT_FOUND')); + expect(body['error'], containsPair('message', 'Resource not found')); }); test('catches unexpected errors and returns internal error', () async { @@ -100,18 +94,19 @@ void main() { throw Exception('Unexpected crash'); }); - final func = _findFunction(firebase, 'crash-function')!; + final func = findHandler(firebase, 'crash-function'); final request = Request( 'GET', Uri.parse('http://localhost/crash-function'), + headers: {'content-type': 'application/json'}, ); - final response = await func.handler(request); + final response = await func(request); expect(response.statusCode, 500); expect(response.headers['Content-Type'], 'application/json'); final body = jsonDecode(await response.readAsString()); - expect(body['error']['status'], 'INTERNAL'); + expect(body['error'], containsPair('status', 'INTERNAL')); }); test('marks function as external', () { @@ -120,18 +115,18 @@ void main() { (request) async => Response.ok('OK'), ); - final func = _findFunction(firebase, 'external-function')!; + final func = findFunction(firebase, 'external-function'); expect(func.external, isTrue); }); test('uses correct HTTP status codes for different errors', () async { https.onRequest(name: 'unauthorizedFunction', (request) async { - throw UnauthenticatedError('Not logged in'); + throw HttpResponseException.unauthorized(message: 'Not logged in'); }); - final func = _findFunction(firebase, 'unauthorized-function')!; + final func = findHandler(firebase, 'unauthorized-function'); final request = Request('GET', Uri.parse('http://localhost/test')); - final response = await func.handler(request); + final response = await func(request); expect(response.statusCode, 401); }); @@ -157,7 +152,7 @@ void main() { (request, response) async => CallableResult('OK'), ); - expect(_findFunction(firebase, 'callable-function'), isNotNull); + expect(findHandler(firebase, 'callable-function'), isNotNull); }); test('handler returns wrapped result', () async { @@ -167,9 +162,9 @@ void main() { return CallableResult({'message': 'Hello, $name!'}); }); - final func = _findFunction(firebase, 'greet-function')!; + final func = findHandler(firebase, 'greet-function'); final request = createCallableRequest(data: {'name': 'World'}); - final response = await func.handler(request); + final response = await func(request); expect(response.statusCode, 200); expect(response.headers['content-type'], 'application/json'); @@ -184,17 +179,17 @@ void main() { (request, response) async => CallableResult('OK'), ); - final func = _findFunction(firebase, 'post-only-function')!; + final func = findHandler(firebase, 'post-only-function'); final request = Request( 'GET', Uri.parse('http://localhost/post-only-function'), headers: {'content-type': 'application/json'}, ); - final response = await func.handler(request); + final response = await func(request); expect(response.statusCode, 400); final body = jsonDecode(await response.readAsString()); - expect(body['error']['status'], 'INVALID_ARGUMENT'); + expect(body['error'], containsPair('status', 'INVALID_ARGUMENT')); }); test('validates content-type is application/json', () async { @@ -203,31 +198,31 @@ void main() { (request, response) async => CallableResult('OK'), ); - final func = _findFunction(firebase, 'json-only-function')!; + final func = findHandler(firebase, 'json-only-function'); final request = Request( 'POST', Uri.parse('http://localhost/json-only-function'), headers: {'content-type': 'text/plain'}, body: '{"data": "test"}', ); - final response = await func.handler(request); + final response = await func(request); expect(response.statusCode, 400); }); test('catches HttpsError and returns proper status', () async { https.onCall(name: 'errorFunction', (request, response) async { - throw PermissionDeniedError('Not authorized'); + throw HttpResponseException.forbidden(message: 'Not authorized'); }); - final func = _findFunction(firebase, 'error-function')!; + final func = findHandler(firebase, 'error-function'); final request = createCallableRequest(); - final response = await func.handler(request); + final response = await func(request); expect(response.statusCode, 403); final body = jsonDecode(await response.readAsString()); - expect(body['error']['status'], 'PERMISSION_DENIED'); - expect(body['error']['message'], 'Not authorized'); + expect(body['error'], containsPair('status', 'PERMISSION_DENIED')); + expect(body['error'], containsPair('message', 'Not authorized')); }); test('catches unexpected errors as internal errors', () async { @@ -235,13 +230,13 @@ void main() { throw StateError('Something broke'); }); - final func = _findFunction(firebase, 'crash-function')!; + final func = findHandler(firebase, 'crash-function'); final request = createCallableRequest(); - final response = await func.handler(request); + final response = await func(request); expect(response.statusCode, 500); final body = jsonDecode(await response.readAsString()); - expect(body['error']['status'], 'INTERNAL'); + expect(body['error'], containsPair('status', 'INTERNAL')); }); test('returns JSON when client does not accept SSE', () async { @@ -249,9 +244,9 @@ void main() { return CallableResult('result'); }); - final func = _findFunction(firebase, 'non-stream-function')!; + final func = findHandler(firebase, 'non-stream-function'); final request = createCallableRequest(); - final response = await func.handler(request); + final response = await func(request); expect(response.statusCode, 200); expect(response.headers['content-type'], 'application/json'); @@ -265,13 +260,90 @@ void main() { return JsonResult({'status': 'ok', 'count': 42}); }); - final func = _findFunction(firebase, 'json-result-function')!; + final func = findHandler(firebase, 'json-result-function'); final request = createCallableRequest(); - final response = await func.handler(request); + final response = await func(request); final body = jsonDecode(await response.readAsString()); expect(body['result'], {'status': 'ok', 'count': 42}); }); + group('streaming handling', () { + test('catches BadRequestException and returns SSE error', () async { + https.onCall(name: 'streamErrorFunction', (request, response) async { + throw HttpResponseException.badRequest(message: 'Invalid data'); + }); + + final func = findHandler(firebase, 'stream-error-function'); + final request = createCallableRequest( + headers: {'accept': 'text/event-stream'}, + ); + final response = await func(request); + + expect(response.statusCode, 200); + expect(response.headers['content-type'], 'text/event-stream'); + + final body = await response.readAsString(); + expect(body, startsWith('data: {')); + + final jsonString = body.trim().substring('data: '.length); + final payload = jsonDecode(jsonString); + expect(payload['error'], containsPair('status', 'INVALID_ARGUMENT')); + expect(payload['error'], containsPair('message', 'Invalid data')); + }); + + test( + 'catches unexpected exceptions and returns SSE internal error', + () async { + https.onCall(name: 'streamCrashFunction', ( + request, + response, + ) async { + throw Exception('Unexpected crash'); + }); + + final func = findHandler(firebase, 'stream-crash-function'); + final request = createCallableRequest( + headers: {'accept': 'text/event-stream'}, + ); + final response = await func(request); + + expect(response.statusCode, 200); + expect(response.headers['content-type'], 'text/event-stream'); + + final body = await response.readAsString(); + expect(body, startsWith('data: {')); + + final jsonString = body.trim().substring('data: '.length); + final payload = jsonDecode(jsonString); + expect(payload['error'], containsPair('status', 'INTERNAL')); + expect(payload['error'], containsPair('message', 'Internal error')); + }, + ); + test('success streaming returns SSE data', () async { + https.onCall(name: 'streamSuccessFunction', ( + request, + response, + ) async { + return CallableResult('success'); + }); + + final func = findHandler(firebase, 'stream-success-function'); + final request = createCallableRequest( + headers: {'accept': 'text/event-stream'}, + ); + final response = await func(request); + + expect(response.statusCode, 200); + expect(response.headers['content-type'], 'text/event-stream'); + + final body = await response.readAsString(); + expect(body, startsWith('data: {')); + + final jsonString = body.trim().substring('data: '.length); + final payload = jsonDecode(jsonString); + expect(payload, containsPair('result', 'success')); + }); + }); }); group('onCallWithData', () { @@ -297,7 +369,7 @@ void main() { }, ); - expect(_findFunction(firebase, 'typed-function'), isNotNull); + expect(findHandler(firebase, 'typed-function'), isNotNull); }); test('deserializes input using fromJson', () async { @@ -311,9 +383,9 @@ void main() { }, ); - final func = _findFunction(firebase, 'typed-function')!; + final func = findHandler(firebase, 'typed-function'); final request = createCallableRequest(data: {'name': 'World'}); - final response = await func.handler(request); + final response = await func(request); expect(response.statusCode, 200); final body = jsonDecode(await response.readAsString()); @@ -329,9 +401,9 @@ void main() { }, ); - final func = _findFunction(firebase, 'typed-output-function')!; + final func = findHandler(firebase, 'typed-output-function'); final request = createCallableRequest(data: {'name': 'World'}); - final response = await func.handler(request); + final response = await func(request); expect(response.statusCode, 200); final body = jsonDecode(await response.readAsString()); @@ -346,13 +418,13 @@ void main() { (request, response) async => 'OK', ); - final func = _findFunction(firebase, 'post-only-typed-function')!; + final func = findHandler(firebase, 'post-only-typed-function'); final request = Request( 'GET', Uri.parse('http://localhost/post-only-typed-function'), headers: {'content-type': 'application/json'}, ); - final response = await func.handler(request); + final response = await func(request); expect(response.statusCode, 400); }); @@ -362,18 +434,20 @@ void main() { name: 'errorTypedFunction', fromJson: _GreetRequest.fromJson, (request, response) async { - throw InvalidArgumentError('Name cannot be empty'); + throw HttpResponseException.badRequest( + message: 'Name cannot be empty', + ); }, ); - final func = _findFunction(firebase, 'error-typed-function')!; + final func = findHandler(firebase, 'error-typed-function'); final request = createCallableRequest(data: {'name': ''}); - final response = await func.handler(request); + final response = await func(request); expect(response.statusCode, 400); final body = jsonDecode(await response.readAsString()); - expect(body['error']['status'], 'INVALID_ARGUMENT'); - expect(body['error']['message'], 'Name cannot be empty'); + expect(body['error'], containsPair('status', 'INVALID_ARGUMENT')); + expect(body['error'], containsPair('message', 'Name cannot be empty')); }); }); @@ -385,7 +459,7 @@ void main() { (request) async => Response.ok('OK'), ); - final func = _findFunction(firebase, 'options-function')!; + final func = findFunction(firebase, 'options-function'); expect(func.allowedOrigins, ['https://example.com']); }); @@ -398,7 +472,7 @@ void main() { (request, response) async => CallableResult('OK'), ); - expect(_findFunction(firebase, 'callable-options-function'), isNotNull); + expect(findHandler(firebase, 'callable-options-function'), isNotNull); }); test('CallableOptions can be provided passing allowedOrigins', () { @@ -408,10 +482,10 @@ void main() { (request, response) async => CallableResult('OK'), ); - final func = _findFunction( + final func = findFunction( firebase, 'callable-options-function-with-origins', - )!; + ); expect(func.allowedOrigins, ['https://example.com']); }); }); diff --git a/test/unit/remote_config_test.dart b/test/unit/remote_config_test.dart index 231c478..6042ed4 100644 --- a/test/unit/remote_config_test.dart +++ b/test/unit/remote_config_test.dart @@ -22,14 +22,7 @@ import 'package:firebase_functions/src/remote_config/remote_config_namespace.dar import 'package:shelf/shelf.dart'; import 'package:test/test.dart'; -// Helper to find function by name -FirebaseFunctionDeclaration? _findFunction(Firebase firebase, String name) { - try { - return firebase.functions.firstWhere((f) => f.name == name.toLowerCase()); - } catch (e) { - return null; - } -} +import 'shared_utils.dart'; /// Creates a mock CloudEvent POST request for Remote Config. Request _createRemoteConfigRequest({ @@ -89,13 +82,13 @@ void main() { test('registers function with firebase', () { remoteConfig.onConfigUpdated((event) async {}); - expect(_findFunction(firebase, 'on-config-updated'), isNotNull); + expect(findFunction(firebase, 'on-config-updated'), isNotNull); }); test('registered function is not external', () { remoteConfig.onConfigUpdated((event) async {}); - final func = _findFunction(firebase, 'on-config-updated')!; + final func = findFunction(firebase, 'on-config-updated'); expect(func.external, isFalse); }); @@ -106,7 +99,7 @@ void main() { receivedEvent = event; }); - final func = _findFunction(firebase, 'on-config-updated')!; + final func = findFunction(firebase, 'on-config-updated'); final request = _createRemoteConfigRequest( versionNumber: 42, description: 'My config update', @@ -133,7 +126,7 @@ void main() { receivedEvent = event; }); - final func = _findFunction(firebase, 'on-config-updated')!; + final func = findFunction(firebase, 'on-config-updated'); final response = await func.handler(_createRemoteConfigRequest()); expect(response.statusCode, 200); @@ -157,7 +150,7 @@ void main() { receivedEvent = event; }); - final func = _findFunction(firebase, 'on-config-updated')!; + final func = findFunction(firebase, 'on-config-updated'); final response = await func.handler( _createRemoteConfigRequest(updateOrigin: 'REST_API'), ); @@ -173,7 +166,7 @@ void main() { receivedEvent = event; }); - final func = _findFunction(firebase, 'on-config-updated')!; + final func = findFunction(firebase, 'on-config-updated'); final response = await func.handler( _createRemoteConfigRequest(updateType: 'FORCED_UPDATE'), ); @@ -189,7 +182,7 @@ void main() { receivedEvent = event; }); - final func = _findFunction(firebase, 'on-config-updated')!; + final func = findFunction(firebase, 'on-config-updated'); final response = await func.handler( _createRemoteConfigRequest(updateType: 'ROLLBACK', rollbackSource: 5), ); @@ -206,7 +199,7 @@ void main() { receivedEvent = event; }); - final func = _findFunction(firebase, 'on-config-updated')!; + final func = findFunction(firebase, 'on-config-updated'); final response = await func.handler(_createRemoteConfigRequest()); expect(response.statusCode, 200); @@ -216,7 +209,7 @@ void main() { test('returns 200 on success', () async { remoteConfig.onConfigUpdated((event) async {}); - final func = _findFunction(firebase, 'on-config-updated')!; + final func = findFunction(firebase, 'on-config-updated'); final response = await func.handler(_createRemoteConfigRequest()); expect(response.statusCode, 200); @@ -227,8 +220,8 @@ void main() { throw Exception('Handler error'); }); - final func = _findFunction(firebase, 'on-config-updated')!; - final response = await func.handler(_createRemoteConfigRequest()); + final handler = findHandler(firebase, 'on-config-updated'); + final response = await handler(_createRemoteConfigRequest()); expect(response.statusCode, 500); final body = await response.readAsString(); @@ -238,7 +231,7 @@ void main() { test('returns 400 for invalid CloudEvent', () async { remoteConfig.onConfigUpdated((event) async {}); - final func = _findFunction(firebase, 'on-config-updated')!; + final func = findFunction(firebase, 'on-config-updated'); final request = Request( 'POST', Uri.parse('http://localhost/on-config-updated'), @@ -253,7 +246,7 @@ void main() { test('returns 400 for wrong event type', () async { remoteConfig.onConfigUpdated((event) async {}); - final func = _findFunction(firebase, 'on-config-updated')!; + final func = findFunction(firebase, 'on-config-updated'); final request = Request( 'POST', Uri.parse('http://localhost/on-config-updated'), diff --git a/test/unit/scheduler_namespace_test.dart b/test/unit/scheduler_namespace_test.dart index 6ae4bd7..320b90f 100644 --- a/test/unit/scheduler_namespace_test.dart +++ b/test/unit/scheduler_namespace_test.dart @@ -21,14 +21,7 @@ import 'package:firebase_functions/src/scheduler/scheduler_namespace.dart'; import 'package:shelf/shelf.dart'; import 'package:test/test.dart'; -// Helper to find function by name -FirebaseFunctionDeclaration? _findFunction(Firebase firebase, String name) { - try { - return firebase.functions.firstWhere((f) => f.name == name.toLowerCase()); - } catch (e) { - return null; - } -} +import 'shared_utils.dart'; void main() { setUpAll(() { @@ -49,14 +42,14 @@ void main() { scheduler.onSchedule(schedule: '0 0 * * *', (event) async {}); // Function name: schedule '0 0 * * *' becomes 'on-schedule-0-0' (kebab-case Cloud Run ID) - expect(_findFunction(firebase, 'on-schedule-0-0'), isNotNull); + expect(findFunction(firebase, 'on-schedule-0-0'), isNotNull); }); test('generates correct function name from schedule', () { scheduler.onSchedule(schedule: '*/5 * * * *', (event) async {}); // '*/5 * * * *' -> 'on-schedule-5' (removes *, /, converts to kebab-case) - expect(_findFunction(firebase, 'on-schedule-5'), isNotNull); + expect(findFunction(firebase, 'on-schedule-5'), isNotNull); }); test('handler receives scheduled event', () async { @@ -66,7 +59,7 @@ void main() { receivedEvent = event; }); - final func = _findFunction(firebase, 'on-schedule-0-0')!; + final func = findFunction(firebase, 'on-schedule-0-0'); final request = Request( 'POST', Uri.parse('http://localhost/on-schedule-0-0'), @@ -94,7 +87,7 @@ void main() { receivedEvent = event; }); - final func = _findFunction(firebase, 'on-schedule-0-0')!; + final func = findFunction(firebase, 'on-schedule-0-0'); final request = Request( 'POST', Uri.parse('http://localhost/on-schedule-0-0'), @@ -115,7 +108,7 @@ void main() { receivedEvent = event; }); - final func = _findFunction(firebase, 'on-schedule-0-0')!; + final func = findFunction(firebase, 'on-schedule-0-0'); final request = Request( 'POST', Uri.parse('http://localhost/on-schedule-0-0'), @@ -138,7 +131,7 @@ void main() { // Success - do nothing }); - final func = _findFunction(firebase, 'on-schedule-0-0')!; + final func = findFunction(firebase, 'on-schedule-0-0'); final request = Request( 'POST', Uri.parse('http://localhost/on-schedule-0-0'), @@ -154,13 +147,13 @@ void main() { throw Exception('Handler error'); }); - final func = _findFunction(firebase, 'on-schedule-0-0')!; + final handler = findHandler(firebase, 'on-schedule-0-0'); final request = Request( 'POST', Uri.parse('http://localhost/on-schedule-0-0'), headers: {'x-cloudscheduler-scheduletime': '2024-01-01T00:00:00Z'}, ); - final response = await func.handler(request); + final response = await handler(request); expect(response.statusCode, 500); final body = await response.readAsString(); @@ -180,7 +173,7 @@ void main() { (event) async {}, ); - expect(_findFunction(firebase, 'on-schedule-0-0'), isNotNull); + expect(findFunction(firebase, 'on-schedule-0-0'), isNotNull); }); test('handles case-insensitive headers', () async { @@ -190,7 +183,7 @@ void main() { receivedEvent = event; }); - final func = _findFunction(firebase, 'on-schedule-0-0')!; + final func = findFunction(firebase, 'on-schedule-0-0'); final request = Request( 'POST', Uri.parse('http://localhost/on-schedule-0-0'), diff --git a/test/unit/shared_utils.dart b/test/unit/shared_utils.dart new file mode 100644 index 0000000..ef3526b --- /dev/null +++ b/test/unit/shared_utils.dart @@ -0,0 +1,33 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:firebase_functions/src/firebase.dart'; +import 'package:google_cloud/http_serving.dart'; +import 'package:shelf/shelf.dart'; + +/// Helper to find function by name (uses kebab-case Cloud Run ID) +FirebaseFunctionDeclaration findFunction(Firebase firebase, String name) => + firebase.functions.firstWhere( + (f) => f.name == name.toLowerCase(), + orElse: () => throw Exception('Function $name not found'), + ); + +/// Helper to find handler by name (uses kebab-case Cloud Run ID and adds logging middleware) +Handler findHandler(Firebase firebase, String name) { + final handler = findFunction(firebase, name).handler; + + return const Pipeline() + .addMiddleware(createLoggingMiddleware(projectId: 'demo-test')) + .addHandler(handler); +} diff --git a/test/unit/storage_test.dart b/test/unit/storage_test.dart index 9c96f16..e7086ae 100644 --- a/test/unit/storage_test.dart +++ b/test/unit/storage_test.dart @@ -22,14 +22,7 @@ import 'package:firebase_functions/src/storage/storage_object_data.dart'; import 'package:shelf/shelf.dart'; import 'package:test/test.dart'; -// Helper to find function by name -FirebaseFunctionDeclaration? _findFunction(Firebase firebase, String name) { - try { - return firebase.functions.firstWhere((f) => f.name == name.toLowerCase()); - } catch (e) { - return null; - } -} +import 'shared_utils.dart'; /// Creates a mock CloudEvent POST request for Storage. Request _createStorageRequest({ @@ -91,7 +84,7 @@ void main() { storage.onObjectFinalized(bucket: 'my-bucket', (event) async {}); expect( - _findFunction(firebase, 'on-object-finalized-mybucket'), + findFunction(firebase, 'on-object-finalized-mybucket'), isNotNull, ); }); @@ -99,7 +92,7 @@ void main() { test('registered function is not external', () { storage.onObjectFinalized(bucket: 'my-bucket', (event) async {}); - final func = _findFunction(firebase, 'on-object-finalized-mybucket')!; + final func = findFunction(firebase, 'on-object-finalized-mybucket'); expect(func.external, isFalse); }); @@ -110,7 +103,7 @@ void main() { receivedEvent = event; }); - final func = _findFunction(firebase, 'on-object-finalized-mybucket')!; + final func = findFunction(firebase, 'on-object-finalized-mybucket'); final request = _createStorageRequest( objectName: 'uploads/image.png', contentType: 'image/png', @@ -134,7 +127,7 @@ void main() { receivedEvent = event; }); - final func = _findFunction(firebase, 'on-object-finalized-mybucket')!; + final func = findFunction(firebase, 'on-object-finalized-mybucket'); final response = await func.handler(_createStorageRequest()); expect(response.statusCode, 200); @@ -156,7 +149,7 @@ void main() { receivedEvent = event; }); - final func = _findFunction(firebase, 'on-object-finalized-mybucket')!; + final func = findFunction(firebase, 'on-object-finalized-mybucket'); await func.handler(_createStorageRequest()); expect(receivedEvent!.bucket, 'my-bucket'); @@ -168,7 +161,7 @@ void main() { storage.onObjectArchived(bucket: 'my-bucket', (event) async {}); expect( - _findFunction(firebase, 'on-object-archived-mybucket'), + findFunction(firebase, 'on-object-archived-mybucket'), isNotNull, ); }); @@ -180,7 +173,7 @@ void main() { receivedEvent = event; }); - final func = _findFunction(firebase, 'on-object-archived-mybucket')!; + final func = findFunction(firebase, 'on-object-archived-mybucket'); final request = _createStorageRequest( eventType: 'google.cloud.storage.object.v1.archived', ); @@ -196,10 +189,7 @@ void main() { test('registers function with firebase', () { storage.onObjectDeleted(bucket: 'my-bucket', (event) async {}); - expect( - _findFunction(firebase, 'on-object-deleted-mybucket'), - isNotNull, - ); + expect(findFunction(firebase, 'on-object-deleted-mybucket'), isNotNull); }); test('handler receives StorageEvent', () async { @@ -209,7 +199,7 @@ void main() { receivedEvent = event; }); - final func = _findFunction(firebase, 'on-object-deleted-mybucket')!; + final func = findFunction(firebase, 'on-object-deleted-mybucket'); final request = _createStorageRequest( eventType: 'google.cloud.storage.object.v1.deleted', ); @@ -226,7 +216,7 @@ void main() { storage.onObjectMetadataUpdated(bucket: 'my-bucket', (event) async {}); expect( - _findFunction(firebase, 'on-object-metadata-updated-mybucket'), + findFunction(firebase, 'on-object-metadata-updated-mybucket'), isNotNull, ); }); @@ -238,10 +228,10 @@ void main() { receivedEvent = event; }); - final func = _findFunction( + final func = findFunction( firebase, 'on-object-metadata-updated-mybucket', - )!; + ); final request = _createStorageRequest( eventType: 'google.cloud.storage.object.v1.metadataUpdated', metadata: {'key1': 'value1', 'key2': 'value2'}, @@ -262,7 +252,7 @@ void main() { storage.onObjectFinalized(bucket: 'my-test-bucket', (event) async {}); expect( - _findFunction(firebase, 'on-object-finalized-mytestbucket'), + findFunction(firebase, 'on-object-finalized-mytestbucket'), isNotNull, ); }); @@ -274,7 +264,7 @@ void main() { ); expect( - _findFunction( + findFunction( firebase, 'on-object-finalized-demotestfirebasestorageapp', ), @@ -287,7 +277,7 @@ void main() { test('returns 200 on success', () async { storage.onObjectFinalized(bucket: 'my-bucket', (event) async {}); - final func = _findFunction(firebase, 'on-object-finalized-mybucket')!; + final func = findFunction(firebase, 'on-object-finalized-mybucket'); final response = await func.handler(_createStorageRequest()); expect(response.statusCode, 200); @@ -298,8 +288,8 @@ void main() { throw Exception('Handler error'); }); - final func = _findFunction(firebase, 'on-object-finalized-mybucket')!; - final response = await func.handler(_createStorageRequest()); + final handler = findHandler(firebase, 'on-object-finalized-mybucket'); + final response = await handler(_createStorageRequest()); expect(response.statusCode, 500); final body = await response.readAsString(); @@ -309,7 +299,7 @@ void main() { test('returns 400 for invalid CloudEvent', () async { storage.onObjectFinalized(bucket: 'my-bucket', (event) async {}); - final func = _findFunction(firebase, 'on-object-finalized-mybucket')!; + final func = findFunction(firebase, 'on-object-finalized-mybucket'); final request = Request( 'POST', Uri.parse('http://localhost/on-object-finalized-mybucket'), @@ -324,7 +314,7 @@ void main() { test('returns 400 for wrong event type', () async { storage.onObjectFinalized(bucket: 'my-bucket', (event) async {}); - final func = _findFunction(firebase, 'on-object-finalized-mybucket')!; + final func = findFunction(firebase, 'on-object-finalized-mybucket'); final request = Request( 'POST', Uri.parse('http://localhost/on-object-finalized-mybucket'),