diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e1c8b65..908d643e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## Upcoming Release +- **refactor!**: shift to eager loading for a robust, fail-fast startup. + ## 1.0.1 - 2025-10-17 - **chore**: A new migration ensures that existing user preference documents are updated to include the savedFilters field, initialized as an empty array. diff --git a/README.md b/README.md index 69247054..4b2a4585 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,13 @@ Click on any category to explore. --- +### ✨ Eager Loading & Fail-Fast Startup +- **Robust Initialization:** The server now initializes all critical dependencies (database connection, migrations, seeding) *before* it starts accepting any requests. +- **Fail-Fast by Design:** If any part of the startup process fails (e.g., the database is down), the server will immediately exit with a clear error. +> **Your Advantage:** This eliminates startup race conditions and ensures the server is either fully operational or not running at all. It provides a highly predictable and stable production environment, preventing the server from running in a broken state. + +--- + ### 🔌 Robust Dependency Injection - **Testable & Modular:** A centralized dependency injection system makes the entire application highly modular and easy to test. - **Swappable Implementations:** Easily swap out core components—like the database (MongoDB), email provider (SendGrid), or storage services—without rewriting your business logic. diff --git a/bin/main.dart b/bin/main.dart new file mode 100644 index 00000000..ab59661a --- /dev/null +++ b/bin/main.dart @@ -0,0 +1,92 @@ +// ignore_for_file: avoid_print + +import 'dart:async'; +import 'dart:io'; + +import 'package:dart_frog/dart_frog.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/config/app_dependencies.dart'; +import 'package:logging/logging.dart'; + +// Import the generated server entrypoint to access `buildRootHandler`. +import '../.dart_frog/server.dart' as dart_frog; + +/// The main entrypoint for the application. +/// +/// This custom entrypoint implements an "eager loading" strategy. It ensures +/// that all critical application dependencies are initialized *before* the +/// HTTP server starts listening for requests. +/// +/// If any part of the dependency initialization fails (e.g., database +/// connection, migrations), the process will log a fatal error and exit, +/// preventing the server from running in a broken state. This is a robust, +/// "fail-fast" approach. +Future main(List args) async { + // Use a local logger for startup-specific messages. + // This is also the ideal place to configure the root logger for the entire + // application, as it's guaranteed to run only once at startup. + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + final message = StringBuffer() + ..write('${record.level.name}: ${record.time}: ${record.loggerName}: ') + ..writeln(record.message); + + if (record.error != null) { + message.writeln(' ERROR: ${record.error}'); + } + if (record.stackTrace != null) { + message.writeln(' STACK TRACE: ${record.stackTrace}'); + } + + // Write the log message atomically to stdout. + stdout.write(message.toString()); + }); + + final log = Logger('EagerEntrypoint'); + HttpServer? server; + + Future shutdown([String? signal]) async { + log.info('Received ${signal ?? 'signal'}. Shutting down gracefully...'); + // Stop accepting new connections. + await server?.close(); + // Dispose all application dependencies. + await AppDependencies.instance.dispose(); + log.info('Shutdown complete.'); + exit(0); + } + + // Listen for termination signals. + ProcessSignal.sigint.watch().listen((_) => shutdown('SIGINT')); + // SIGTERM is not supported on Windows. Attempting to listen to it will throw. + if (!Platform.isWindows) { + ProcessSignal.sigterm.watch().listen((_) => shutdown('SIGTERM')); + } + + try { + log.info('EAGER_INIT: Initializing application dependencies...'); + + // Eagerly initialize all dependencies. If this fails, it will throw. + await AppDependencies.instance.init(); + + log.info('EAGER_INIT: Dependencies initialized successfully.'); + log.info('EAGER_INIT: Starting Dart Frog server...'); + + // Start the server directly without the hot reload wrapper. + final address = InternetAddress.anyIPv6; + final port = int.tryParse(Platform.environment['PORT'] ?? '8080') ?? 8080; + + // Explicitly cast the handler to resolve the type ambiguity. + final handler = dart_frog.buildRootHandler() as Handler; + server = await serve(handler, address, port); + log.info( + 'Server listening on http://${server.address.host}:${server.port}', + ); + } catch (e, s) { + log.severe('EAGER_INIT: FATAL: Failed to start server.', e, s); + // Log directly to stderr and flush to ensure the message is captured + // before the process exits, which is crucial for debugging startup errors. + stderr.writeln('EAGER_INIT: FATAL: Failed to start server. Error: $e\nStack Trace: $s'); + await stderr.flush(); + // Exit the process if initialization fails. + exit(1); + } +} diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 3de4fdca..828b9d26 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -40,11 +40,11 @@ class AppDependencies { /// Provides access to the singleton instance. static AppDependencies get instance => _instance; - bool _isInitialized = false; - Object? _initializationError; - StackTrace? _initializationStackTrace; final _log = Logger('AppDependencies'); + // A flag to track if initialization has started, for safe disposal. + bool _initStarted = false; + // --- Late-initialized fields for all dependencies --- // Database @@ -78,217 +78,206 @@ class AppDependencies { /// Initializes all application dependencies. /// - /// This method is idempotent; it will only run the initialization logic once. + /// This method is now designed to be called once at application startup + /// by the eager-loading entrypoint (`bin/main.dart`). It will throw an + /// exception if any part of the initialization fails, which will be caught + /// by the entrypoint to terminate the server process. Future init() async { - // If initialization previously failed, re-throw the original error. - if (_initializationError != null) { - return Future.error(_initializationError!, _initializationStackTrace); - } - - if (_isInitialized) return; - + _initStarted = true; _log.info('Initializing application dependencies...'); - try { - // 1. Initialize Database Connection - _mongoDbConnectionManager = MongoDbConnectionManager(); - await _mongoDbConnectionManager.init(EnvironmentConfig.databaseUrl); - _log.info('MongoDB connection established.'); + // 1. Initialize Database Connection + _mongoDbConnectionManager = MongoDbConnectionManager(); + await _mongoDbConnectionManager.init(EnvironmentConfig.databaseUrl); + _log.info('MongoDB connection established.'); - // 2. Initialize and Run Database Migrations - databaseMigrationService = DatabaseMigrationService( - db: _mongoDbConnectionManager.db, - log: Logger('DatabaseMigrationService'), - migrations: - allMigrations, // From lib/src/database/migrations/all_migrations.dart - ); - await databaseMigrationService.init(); - _log.info('Database migrations applied.'); + // 2. Initialize and Run Database Migrations + databaseMigrationService = DatabaseMigrationService( + db: _mongoDbConnectionManager.db, + log: Logger('DatabaseMigrationService'), + migrations: + allMigrations, // From lib/src/database/migrations/all_migrations.dart + ); + await databaseMigrationService.init(); + _log.info('Database migrations applied.'); - // 3. Seed Database - // This runs AFTER migrations to ensure the schema is up-to-date. - final seedingService = DatabaseSeedingService( - db: _mongoDbConnectionManager.db, - log: Logger('DatabaseSeedingService'), - ); - await seedingService.seedInitialData(); - _log.info('Database seeding complete.'); + // 3. Seed Database + // This runs AFTER migrations to ensure the schema is up-to-date. + final seedingService = DatabaseSeedingService( + db: _mongoDbConnectionManager.db, + log: Logger('DatabaseSeedingService'), + ); + await seedingService.seedInitialData(); + _log.info('Database seeding complete.'); - // 4. Initialize Data Clients (MongoDB implementation) - final headlineClient = DataMongodb( - connectionManager: _mongoDbConnectionManager, - modelName: 'headlines', - fromJson: Headline.fromJson, - toJson: (item) => item.toJson(), - searchableFields: ['title'], - logger: Logger('DataMongodb'), - ); - final topicClient = DataMongodb( - connectionManager: _mongoDbConnectionManager, - modelName: 'topics', - fromJson: Topic.fromJson, - toJson: (item) => item.toJson(), - searchableFields: ['name'], - logger: Logger('DataMongodb'), - ); - final sourceClient = DataMongodb( - connectionManager: _mongoDbConnectionManager, - modelName: 'sources', - fromJson: Source.fromJson, - toJson: (item) => item.toJson(), - searchableFields: ['name'], - logger: Logger('DataMongodb'), - ); - final countryClient = DataMongodb( - connectionManager: _mongoDbConnectionManager, - modelName: 'countries', - fromJson: Country.fromJson, - toJson: (item) => item.toJson(), - searchableFields: ['name'], - logger: Logger('DataMongodb'), - ); - final languageClient = DataMongodb( - connectionManager: _mongoDbConnectionManager, - modelName: 'languages', - fromJson: Language.fromJson, - toJson: (item) => item.toJson(), - logger: Logger('DataMongodb'), - ); - final userClient = DataMongodb( - connectionManager: _mongoDbConnectionManager, - modelName: 'users', - fromJson: User.fromJson, - toJson: (item) => item.toJson(), - logger: Logger('DataMongodb'), - ); - final userAppSettingsClient = DataMongodb( - connectionManager: _mongoDbConnectionManager, - modelName: 'user_app_settings', - fromJson: UserAppSettings.fromJson, - toJson: (item) => item.toJson(), - logger: Logger('DataMongodb'), - ); - final userContentPreferencesClient = DataMongodb( - connectionManager: _mongoDbConnectionManager, - modelName: 'user_content_preferences', - fromJson: UserContentPreferences.fromJson, - toJson: (item) => item.toJson(), - logger: Logger('DataMongodb'), - ); - final remoteConfigClient = DataMongodb( - connectionManager: _mongoDbConnectionManager, - modelName: 'remote_configs', - fromJson: RemoteConfig.fromJson, - toJson: (item) => item.toJson(), - logger: Logger('DataMongodb'), - ); + // 4. Initialize Data Clients (MongoDB implementation) + final headlineClient = DataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'headlines', + fromJson: Headline.fromJson, + toJson: (item) => item.toJson(), + searchableFields: ['title'], + logger: Logger('DataMongodb'), + ); + final topicClient = DataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'topics', + fromJson: Topic.fromJson, + toJson: (item) => item.toJson(), + searchableFields: ['name'], + logger: Logger('DataMongodb'), + ); + final sourceClient = DataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'sources', + fromJson: Source.fromJson, + toJson: (item) => item.toJson(), + searchableFields: ['name'], + logger: Logger('DataMongodb'), + ); + final countryClient = DataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'countries', + fromJson: Country.fromJson, + toJson: (item) => item.toJson(), + searchableFields: ['name'], + logger: Logger('DataMongodb'), + ); + final languageClient = DataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'languages', + fromJson: Language.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('DataMongodb'), + ); + final userClient = DataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'users', + fromJson: User.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('DataMongodb'), + ); + final userAppSettingsClient = DataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'user_app_settings', + fromJson: UserAppSettings.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('DataMongodb'), + ); + final userContentPreferencesClient = DataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'user_content_preferences', + fromJson: UserContentPreferences.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('DataMongodb'), + ); + final remoteConfigClient = DataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'remote_configs', + fromJson: RemoteConfig.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('DataMongodb'), + ); - // 4. Initialize Repositories - headlineRepository = DataRepository(dataClient: headlineClient); - topicRepository = DataRepository(dataClient: topicClient); - sourceRepository = DataRepository(dataClient: sourceClient); - countryRepository = DataRepository(dataClient: countryClient); - languageRepository = DataRepository(dataClient: languageClient); - userRepository = DataRepository(dataClient: userClient); - userAppSettingsRepository = DataRepository( - dataClient: userAppSettingsClient, - ); - userContentPreferencesRepository = DataRepository( - dataClient: userContentPreferencesClient, - ); - remoteConfigRepository = DataRepository(dataClient: remoteConfigClient); - // Configure the HTTP client for SendGrid. - // The HttpClient's AuthInterceptor will use the tokenProvider to add - // the 'Authorization: Bearer ' header. - final sendGridApiBase = - EnvironmentConfig.sendGridApiUrl ?? 'https://api.sendgrid.com'; - final sendGridHttpClient = HttpClient( - baseUrl: '$sendGridApiBase/v3', - tokenProvider: () async => EnvironmentConfig.sendGridApiKey, - logger: Logger('EmailSendgridClient'), - ); + // 4. Initialize Repositories + headlineRepository = DataRepository(dataClient: headlineClient); + topicRepository = DataRepository(dataClient: topicClient); + sourceRepository = DataRepository(dataClient: sourceClient); + countryRepository = DataRepository(dataClient: countryClient); + languageRepository = DataRepository(dataClient: languageClient); + userRepository = DataRepository(dataClient: userClient); + userAppSettingsRepository = DataRepository( + dataClient: userAppSettingsClient, + ); + userContentPreferencesRepository = DataRepository( + dataClient: userContentPreferencesClient, + ); + remoteConfigRepository = DataRepository(dataClient: remoteConfigClient); + // Configure the HTTP client for SendGrid. + // The HttpClient's AuthInterceptor will use the tokenProvider to add + // the 'Authorization: Bearer ' header. + final sendGridApiBase = + EnvironmentConfig.sendGridApiUrl ?? 'https://api.sendgrid.com'; + final sendGridHttpClient = HttpClient( + baseUrl: '$sendGridApiBase/v3', + tokenProvider: () async => EnvironmentConfig.sendGridApiKey, + logger: Logger('EmailSendgridClient'), + ); - // Initialize the SendGrid email client with the dedicated HTTP client. - final emailClient = EmailSendGrid( - httpClient: sendGridHttpClient, - log: Logger('EmailSendgrid'), - ); + // Initialize the SendGrid email client with the dedicated HTTP client. + final emailClient = EmailSendGrid( + httpClient: sendGridHttpClient, + log: Logger('EmailSendgrid'), + ); - emailRepository = EmailRepository(emailClient: emailClient); + emailRepository = EmailRepository(emailClient: emailClient); - final localAdClient = DataMongodb( - connectionManager: _mongoDbConnectionManager, - modelName: 'local_ads', - fromJson: LocalAd.fromJson, - toJson: LocalAd.toJson, - searchableFields: ['title'], - logger: Logger('DataMongodb'), - ); - localAdRepository = DataRepository(dataClient: localAdClient); + final localAdClient = DataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'local_ads', + fromJson: LocalAd.fromJson, + toJson: LocalAd.toJson, + searchableFields: ['title'], + logger: Logger('DataMongodb'), + ); + localAdRepository = DataRepository(dataClient: localAdClient); - // 5. Initialize Services - tokenBlacklistService = MongoDbTokenBlacklistService( - connectionManager: _mongoDbConnectionManager, - log: Logger('MongoDbTokenBlacklistService'), - ); - authTokenService = JwtAuthTokenService( - userRepository: userRepository, - blacklistService: tokenBlacklistService, - log: Logger('JwtAuthTokenService'), - ); - verificationCodeStorageService = MongoDbVerificationCodeStorageService( - connectionManager: _mongoDbConnectionManager, - log: Logger('MongoDbVerificationCodeStorageService'), - ); - permissionService = const PermissionService(); - authService = AuthService( - userRepository: userRepository, - authTokenService: authTokenService, - verificationCodeStorageService: verificationCodeStorageService, - permissionService: permissionService, - emailRepository: emailRepository, - userAppSettingsRepository: userAppSettingsRepository, - userContentPreferencesRepository: userContentPreferencesRepository, - log: Logger('AuthService'), - ); - dashboardSummaryService = DashboardSummaryService( - headlineRepository: headlineRepository, - topicRepository: topicRepository, - sourceRepository: sourceRepository, - ); - userPreferenceLimitService = DefaultUserPreferenceLimitService( - remoteConfigRepository: remoteConfigRepository, - permissionService: permissionService, - log: Logger('DefaultUserPreferenceLimitService'), - ); - rateLimitService = MongoDbRateLimitService( - connectionManager: _mongoDbConnectionManager, - log: Logger('MongoDbRateLimitService'), - ); - countryQueryService = CountryQueryService( - countryRepository: countryRepository, - log: Logger('CountryQueryService'), - cacheDuration: EnvironmentConfig.countryServiceCacheDuration, - ); + // 5. Initialize Services + tokenBlacklistService = MongoDbTokenBlacklistService( + connectionManager: _mongoDbConnectionManager, + log: Logger('MongoDbTokenBlacklistService'), + ); + authTokenService = JwtAuthTokenService( + userRepository: userRepository, + blacklistService: tokenBlacklistService, + log: Logger('JwtAuthTokenService'), + ); + verificationCodeStorageService = MongoDbVerificationCodeStorageService( + connectionManager: _mongoDbConnectionManager, + log: Logger('MongoDbVerificationCodeStorageService'), + ); + permissionService = const PermissionService(); + authService = AuthService( + userRepository: userRepository, + authTokenService: authTokenService, + verificationCodeStorageService: verificationCodeStorageService, + permissionService: permissionService, + emailRepository: emailRepository, + userAppSettingsRepository: userAppSettingsRepository, + userContentPreferencesRepository: userContentPreferencesRepository, + log: Logger('AuthService'), + ); + dashboardSummaryService = DashboardSummaryService( + headlineRepository: headlineRepository, + topicRepository: topicRepository, + sourceRepository: sourceRepository, + ); + userPreferenceLimitService = DefaultUserPreferenceLimitService( + remoteConfigRepository: remoteConfigRepository, + permissionService: permissionService, + log: Logger('DefaultUserPreferenceLimitService'), + ); + rateLimitService = MongoDbRateLimitService( + connectionManager: _mongoDbConnectionManager, + log: Logger('MongoDbRateLimitService'), + ); + countryQueryService = CountryQueryService( + countryRepository: countryRepository, + log: Logger('CountryQueryService'), + cacheDuration: EnvironmentConfig.countryServiceCacheDuration, + ); - _isInitialized = true; - _log.info('Application dependencies initialized successfully.'); - } catch (e, s) { - _log.severe('Failed to initialize application dependencies', e, s); - _initializationError = e; - _initializationStackTrace = s; - rethrow; - } + _log.info('Application dependencies initialized successfully.'); } /// Disposes of resources, such as closing the database connection. Future dispose() async { - if (!_isInitialized) return; - await _mongoDbConnectionManager.close(); + if (_initStarted) { + await _mongoDbConnectionManager.close(); + } tokenBlacklistService.dispose(); rateLimitService.dispose(); countryQueryService.dispose(); // Dispose the new service - _isInitialized = false; _log.info('Application dependencies disposed.'); } } diff --git a/pubspec.yaml b/pubspec.yaml index 0f189132..5058e820 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,3 +54,7 @@ dev_dependencies: mocktail: ^1.0.3 test: ^1.25.5 very_good_analysis: ^9.0.0 + +executables: + # Defines the custom entrypoint for eager loading. + main: main diff --git a/routes/_middleware.dart b/routes/_middleware.dart index e21a84c9..e4ef0d4f 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -22,38 +22,12 @@ import 'package:mongo_dart/mongo_dart.dart'; // --- Middleware Definition --- final _log = Logger('RootMiddleware'); -// A flag to ensure the logger is only configured once for the application's -// entire lifecycle. -bool _loggerConfigured = false; - Handler middleware(Handler handler) { // This is the root middleware for the entire API. It's responsible for // providing all shared dependencies to the request context. // The order of `.use()` calls is important: the last one in the chain // runs first. - // This check ensures that the logger is configured only once. - if (!_loggerConfigured) { - Logger.root.level = Level.ALL; - Logger.root.onRecord.listen((record) { - // A more detailed logger that includes the error and stack trace. - // ignore: avoid_print - print( - '${record.level.name}: ${record.time}: ${record.loggerName}: ' - '${record.message}', - ); - if (record.error != null) { - // ignore: avoid_print - print(' ERROR: ${record.error}'); - } - if (record.stackTrace != null) { - // ignore: avoid_print - print(' STACK TRACE: ${record.stackTrace}'); - } - }); - _loggerConfigured = true; - } - return handler // --- Core Middleware --- // These run after all dependencies have been provided. @@ -74,17 +48,17 @@ Handler middleware(Handler handler) { }) // --- Dependency Provider --- // This is the outermost middleware. It runs once per request, before any - // other middleware. It's responsible for initializing and providing all - // dependencies for the request. + // other middleware. It's responsible for providing all dependencies, + // which are guaranteed to be pre-initialized by the eager-loading + // entrypoint (`bin/main.dart`), to the request context. .use((handler) { return (context) async { - // 1. Ensure all dependencies are initialized (idempotent). - _log.info('Ensuring all application dependencies are initialized...'); - await AppDependencies.instance.init(); - _log.info('Dependencies are ready.'); - - // 2. Provide all dependencies to the inner handler. + // Provide all dependencies to the inner handler. + // The AppDependencies instance is a singleton that has already been + // initialized at application startup. final deps = AppDependencies.instance; + _log.finer('Providing pre-initialized dependencies to context.'); + return handler .use( provider((_) => DataOperationRegistry()),