diff --git a/.env b/.env new file mode 100644 index 0000000..86770c0 --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +# This is an example environment file. +# Copy this file to .env and fill in your actual configuration values. +# The .env file is ignored by Git and should NOT be committed. + +# DATABASE_URL="mongodb://user:password@localhost:27017/ht_api_db" +DATABASE_URL="mongodb://localhost:27017/ht_api_db" diff --git a/.env.example b/.env.example index e7382eb..1f4422a 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,4 @@ # Copy this file to .env and fill in your actual configuration values. # The .env file is ignored by Git and should NOT be committed. -DATABASE_URL="postgres://user:password@localhost:5432/db_name" \ No newline at end of file +DATABASE_URL="mongodb://user:password@localhost:27017/ht_api_db" \ No newline at end of file diff --git a/README.md b/README.md index 36d50e4..93e9aae 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,6 @@ management dashboard](https://github.com/headlines-toolkit/ht-dashboard). behavior—including ad frequency, feature flags, and maintenance status—without requiring a client-side update. -* 💾 **Robust Data Management:** Securely manage core news data (headlines, - topics, sources) through a well-structured API that supports flexible - querying and sorting for dynamic content presentation. - * 📊 **Dynamic Dashboard Summary:** Access real-time, aggregated metrics on key data points like total headlines, topics, and sources, providing an at-a-glance overview for administrative dashboards. @@ -75,7 +71,7 @@ for more details. 1. **Prerequisites:** * Dart SDK (`>=3.0.0`) - * PostgreSQL (`>=14.0` recommended) + * MongoDB (`>=5.0` recommended) * Dart Frog CLI (`dart pub global activate dart_frog_cli`) 2. **Configuration:** @@ -85,7 +81,7 @@ for more details. Create a `.env` file in the root of the project or export the variable in your shell: ``` - DATABASE_URL="postgres://user:password@localhost:5432/ht_api_db" + DATABASE_URL="mongodb://user:password@localhost:27017/ht_api_db" ``` 3. **Clone the repository:** @@ -101,11 +97,10 @@ for more details. ```bash dart_frog dev ``` - The API will typically be available at `http://localhost:8080`. On the - first startup, the server will connect to your PostgreSQL database, create the - necessary tables, and seed them with initial fixture data. This process is - non-destructive; it uses `CREATE TABLE IF NOT EXISTS` and `INSERT ... ON - CONFLICT DO NOTHING` to avoid overwriting existing tables or data. + The API will typically be available at `http://localhost:8080`. On startup, + the server will connect to your MongoDB database and seed it with initial + fixture data. This seeding process is idempotent (using `upsert` + operations), so it can be run multiple times without creating duplicates. **Note on Web Client Integration (CORS):** To allow web applications (like diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 7054ff0..2ae1b7e 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -1,7 +1,4 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:ht_api/src/config/database_connection.dart'; +import 'package:ht_api/src/config/environment_config.dart'; import 'package:ht_api/src/rbac/permission_service.dart'; import 'package:ht_api/src/services/auth_service.dart'; import 'package:ht_api/src/services/auth_token_service.dart'; @@ -12,279 +9,212 @@ import 'package:ht_api/src/services/jwt_auth_token_service.dart'; import 'package:ht_api/src/services/token_blacklist_service.dart'; import 'package:ht_api/src/services/user_preference_limit_service.dart'; import 'package:ht_api/src/services/verification_code_storage_service.dart'; -import 'package:ht_data_client/ht_data_client.dart'; -import 'package:ht_data_postgres/ht_data_postgres.dart'; +import 'package:ht_data_mongodb/ht_data_mongodb.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_email_inmemory/ht_email_inmemory.dart'; import 'package:ht_email_repository/ht_email_repository.dart'; import 'package:ht_shared/ht_shared.dart'; import 'package:logging/logging.dart'; -import 'package:postgres/postgres.dart'; import 'package:uuid/uuid.dart'; -/// A singleton class to manage all application dependencies. -/// -/// This class follows a lazy initialization pattern. Dependencies are created -/// only when the `init()` method is first called, typically triggered by the -/// first incoming request. A `Completer` ensures that subsequent requests -/// await the completion of the initial setup. +/// {@template app_dependencies} +/// A singleton class responsible for initializing and providing all application +/// dependencies, such as database connections, repositories, and services. +/// {@endtemplate} class AppDependencies { + /// Private constructor for the singleton pattern. AppDependencies._(); - /// The single, global instance of the [AppDependencies]. - static final instance = AppDependencies._(); + /// The single, static instance of this class. + static final AppDependencies _instance = AppDependencies._(); + + /// Provides access to the singleton instance. + static AppDependencies get instance => _instance; + bool _isInitialized = false; + Object? _initializationError; + StackTrace? _initializationStackTrace; final _log = Logger('AppDependencies'); - final _completer = Completer(); - // --- Repositories --- - /// A repository for managing [Headline] data. - late final HtDataRepository headlineRepository; + // --- Late-initialized fields for all dependencies --- - /// A repository for managing [Topic] data. - late final HtDataRepository topicRepository; + // Database + late final MongoDbConnectionManager _mongoDbConnectionManager; - /// A repository for managing [Source] data. + // Repositories + late final HtDataRepository headlineRepository; + late final HtDataRepository topicRepository; late final HtDataRepository sourceRepository; - - /// A repository for managing [Country] data. late final HtDataRepository countryRepository; - - /// A repository for managing [User] data. late final HtDataRepository userRepository; - - /// A repository for managing [UserAppSettings] data. late final HtDataRepository userAppSettingsRepository; - - /// A repository for managing [UserContentPreferences] data. late final HtDataRepository - userContentPreferencesRepository; - - /// A repository for managing the global [RemoteConfig] data. + userContentPreferencesRepository; late final HtDataRepository remoteConfigRepository; - - // --- Services --- - /// A service for sending emails. late final HtEmailRepository emailRepository; - /// A service for managing a blacklist of invalidated authentication tokens. + // Services late final TokenBlacklistService tokenBlacklistService; - - /// A service for generating and validating authentication tokens. late final AuthTokenService authTokenService; - - /// A service for storing and validating one-time verification codes. late final VerificationCodeStorageService verificationCodeStorageService; - - /// A service that orchestrates authentication logic. late final AuthService authService; - - /// A service for calculating and providing a summary for the dashboard. late final DashboardSummaryService dashboardSummaryService; - - /// A service for checking user permissions. late final PermissionService permissionService; - - /// A service for enforcing limits on user content preferences. late final UserPreferenceLimitService userPreferenceLimitService; /// Initializes all application dependencies. /// - /// This method is idempotent. It performs the full initialization only on - /// the first call. Subsequent calls will await the result of the first one. - Future init() { - if (_completer.isCompleted) { - _log.fine('Dependencies already initializing/initialized.'); - return _completer.future; + /// This method is idempotent; it will only run the initialization logic once. + Future init() async { + // If initialization previously failed, re-throw the original error. + if (_initializationError != null) { + return Future.error(_initializationError!, _initializationStackTrace); } - _log.info('Initializing application dependencies...'); - _init() - .then((_) { - _log.info('Application dependencies initialized successfully.'); - _completer.complete(); - }) - .catchError((Object e, StackTrace s) { - _log.severe('Failed to initialize application dependencies.', e, s); - _completer.completeError(e, s); - }); - - return _completer.future; - } - - Future _init() async { - // 1. Establish Database Connection. - await DatabaseConnectionManager.instance.init(); - final connection = await DatabaseConnectionManager.instance.connection; + if (_isInitialized) return; - // 2. Run Database Seeding. - final seedingService = DatabaseSeedingService( - connection: connection, - log: _log, - ); - await seedingService.createTables(); - await seedingService.seedGlobalFixtureData(); - await seedingService.seedInitialAdminAndConfig(); - - // 3. Initialize Repositories. - headlineRepository = _createRepository( - connection, - 'headlines', - // The HtDataPostgresClient returns DateTime objects from TIMESTAMPTZ - // columns. The Headline.fromJson factory expects ISO 8601 strings. - // This handler converts them before deserialization. - (json) => Headline.fromJson(_convertTimestampsToString(json)), - (headline) => headline.toJson() - ..['source_id'] = headline.source.id - ..['topic_id'] = headline.topic.id - ..['event_country_id'] = headline.eventCountry.id - ..remove('source') - ..remove('topic') - ..remove('eventCountry'), - ); - topicRepository = _createRepository( - connection, - 'topics', - (json) => Topic.fromJson(_convertTimestampsToString(json)), - (topic) => topic.toJson(), - ); - sourceRepository = _createRepository( - connection, - 'sources', - (json) => Source.fromJson(_convertTimestampsToString(json)), - (source) => source.toJson() - ..['headquarters_country_id'] = source.headquarters.id - ..remove('headquarters'), - ); - countryRepository = _createRepository( - connection, - 'countries', - (json) => Country.fromJson(_convertTimestampsToString(json)), - (country) => country.toJson(), - ); - userRepository = _createRepository( - connection, - 'users', - (json) => User.fromJson(_convertTimestampsToString(json)), - (user) { - final json = user.toJson(); - // Convert enums to their string names for the database. - json['app_role'] = user.appRole.name; - json['dashboard_role'] = user.dashboardRole.name; - // The `feed_action_status` map must be JSON encoded for the JSONB column. - json['feed_action_status'] = jsonEncode(json['feed_action_status']); - return json; - }, - ); - userAppSettingsRepository = _createRepository( - connection, - 'user_app_settings', - UserAppSettings.fromJson, - (settings) { - final json = settings.toJson(); - // These fields are complex objects and must be JSON encoded for the DB. - json['display_settings'] = jsonEncode(json['display_settings']); - json['feed_preferences'] = jsonEncode(json['feed_preferences']); - return json; - }, - ); - userContentPreferencesRepository = _createRepository( - connection, - 'user_content_preferences', - UserContentPreferences.fromJson, - (preferences) { - final json = preferences.toJson(); - // These fields are lists of complex objects and must be JSON encoded. - json['followed_topics'] = jsonEncode(json['followed_topics']); - json['followed_sources'] = jsonEncode(json['followed_sources']); - json['followed_countries'] = jsonEncode(json['followed_countries']); - json['saved_headlines'] = jsonEncode(json['saved_headlines']); - return json; - }, - ); - remoteConfigRepository = _createRepository( - connection, - 'remote_config', - (json) => RemoteConfig.fromJson(_convertTimestampsToString(json)), - (config) { - final json = config.toJson(); - // All nested config objects must be JSON encoded for JSONB columns. - json['user_preference_limits'] = jsonEncode( - json['user_preference_limits'], - ); - json['ad_config'] = jsonEncode(json['ad_config']); - json['account_action_config'] = jsonEncode( - json['account_action_config'], - ); - json['app_status'] = jsonEncode(json['app_status']); - return json; - }, - ); - - // 4. Initialize Services. - emailRepository = const HtEmailRepository( - emailClient: HtEmailInMemoryClient(), - ); - tokenBlacklistService = InMemoryTokenBlacklistService(log: _log); - authTokenService = JwtAuthTokenService( - userRepository: userRepository, - blacklistService: tokenBlacklistService, - uuidGenerator: const Uuid(), - log: _log, - ); - verificationCodeStorageService = InMemoryVerificationCodeStorageService(); - authService = AuthService( - userRepository: userRepository, - authTokenService: authTokenService, - verificationCodeStorageService: verificationCodeStorageService, - emailRepository: emailRepository, - userAppSettingsRepository: userAppSettingsRepository, - userContentPreferencesRepository: userContentPreferencesRepository, - uuidGenerator: const Uuid(), - log: _log, - ); - dashboardSummaryService = DashboardSummaryService( - headlineRepository: headlineRepository, - topicRepository: topicRepository, - sourceRepository: sourceRepository, - ); - permissionService = const PermissionService(); - userPreferenceLimitService = DefaultUserPreferenceLimitService( - remoteConfigRepository: remoteConfigRepository, - log: _log, - ); - } + _log.info('Initializing application dependencies...'); - HtDataRepository _createRepository( - Connection connection, - String tableName, - FromJson fromJson, - ToJson toJson, - ) { - return HtDataRepository( - dataClient: HtDataPostgresClient( - connection: connection, - tableName: tableName, - fromJson: fromJson, - toJson: toJson, - log: _log, - ), - ); + try { + // 1. Initialize Database Connection + _mongoDbConnectionManager = MongoDbConnectionManager(); + await _mongoDbConnectionManager.init(EnvironmentConfig.databaseUrl); + _log.info('MongoDB connection established.'); + + // 2. Seed Database + final seedingService = DatabaseSeedingService( + db: _mongoDbConnectionManager.db, + log: Logger('DatabaseSeedingService'), + ); + await seedingService.seedInitialData(); + _log.info('Database seeding complete.'); + + // 3. Initialize Data Clients (MongoDB implementation) + final headlineClient = HtDataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'headlines', + fromJson: Headline.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('HtDataMongodb'), + ); + final topicClient = HtDataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'topics', + fromJson: Topic.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('HtDataMongodb'), + ); + final sourceClient = HtDataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'sources', + fromJson: Source.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('HtDataMongodb'), + ); + final countryClient = HtDataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'countries', + fromJson: Country.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('HtDataMongodb'), + ); + final userClient = HtDataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'users', + fromJson: User.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('HtDataMongodb'), + ); + final userAppSettingsClient = HtDataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'user_app_settings', + fromJson: UserAppSettings.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('HtDataMongodb'), + ); + final userContentPreferencesClient = + HtDataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'user_content_preferences', + fromJson: UserContentPreferences.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('HtDataMongodb'), + ); + final remoteConfigClient = HtDataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'remote_configs', + fromJson: RemoteConfig.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('HtDataMongodb'), + ); + + // 4. Initialize Repositories + headlineRepository = HtDataRepository(dataClient: headlineClient); + topicRepository = HtDataRepository(dataClient: topicClient); + sourceRepository = HtDataRepository(dataClient: sourceClient); + countryRepository = HtDataRepository(dataClient: countryClient); + userRepository = HtDataRepository(dataClient: userClient); + userAppSettingsRepository = + HtDataRepository(dataClient: userAppSettingsClient); + userContentPreferencesRepository = + HtDataRepository(dataClient: userContentPreferencesClient); + remoteConfigRepository = + HtDataRepository(dataClient: remoteConfigClient); + + final emailClient = HtEmailInMemoryClient( + logger: Logger('HtEmailInMemoryClient'), + ); + emailRepository = HtEmailRepository(emailClient: emailClient); + + // 5. Initialize Services + tokenBlacklistService = InMemoryTokenBlacklistService( + log: Logger('InMemoryTokenBlacklistService'), + ); + authTokenService = JwtAuthTokenService( + userRepository: userRepository, + blacklistService: tokenBlacklistService, + uuidGenerator: const Uuid(), + log: Logger('JwtAuthTokenService'), + ); + verificationCodeStorageService = + InMemoryVerificationCodeStorageService(); + authService = AuthService( + userRepository: userRepository, + authTokenService: authTokenService, + verificationCodeStorageService: verificationCodeStorageService, + emailRepository: emailRepository, + userAppSettingsRepository: userAppSettingsRepository, + userContentPreferencesRepository: userContentPreferencesRepository, + uuidGenerator: const Uuid(), + log: Logger('AuthService'), + ); + dashboardSummaryService = DashboardSummaryService( + headlineRepository: headlineRepository, + topicRepository: topicRepository, + sourceRepository: sourceRepository, + ); + permissionService = const PermissionService(); + userPreferenceLimitService = DefaultUserPreferenceLimitService( + remoteConfigRepository: remoteConfigRepository, + log: Logger('DefaultUserPreferenceLimitService'), + ); + + _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; + } } - /// Converts DateTime values in a JSON map to ISO 8601 strings. - /// - /// The postgres driver returns DateTime objects for TIMESTAMPTZ columns, - /// but our models' `fromJson` factories expect ISO 8601 strings. This - /// utility function performs the conversion for known timestamp fields. - Map _convertTimestampsToString(Map json) { - const timestampKeys = {'created_at', 'updated_at'}; - final newJson = Map.from(json); - for (final key in timestampKeys) { - if (newJson[key] is DateTime) { - newJson[key] = (newJson[key] as DateTime).toIso8601String(); - } - } - return newJson; + /// Disposes of resources, such as closing the database connection. + Future dispose() async { + if (!_isInitialized) return; + await _mongoDbConnectionManager.close(); + tokenBlacklistService.dispose(); + _isInitialized = false; + _log.info('Application dependencies disposed.'); } -} +} \ No newline at end of file diff --git a/lib/src/config/database_connection.dart b/lib/src/config/database_connection.dart deleted file mode 100644 index 36e85c8..0000000 --- a/lib/src/config/database_connection.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'dart:async'; - -import 'package:ht_api/src/config/environment_config.dart'; -import 'package:logging/logging.dart'; -import 'package:postgres/postgres.dart'; - -/// A singleton class to manage a single, shared PostgreSQL database connection. -/// -/// This pattern ensures that the application establishes a connection to the -/// database only once and reuses it for all subsequent operations, which is -/// crucial for performance and resource management. -class DatabaseConnectionManager { - // Private constructor for the singleton pattern. - DatabaseConnectionManager._(); - - /// The single, global instance of the [DatabaseConnectionManager]. - static final instance = DatabaseConnectionManager._(); - - final _log = Logger('DatabaseConnectionManager'); - - // A completer to signal when the database connection is established. - final _completer = Completer(); - - /// Returns a future that completes with the established database connection. - /// - /// If the connection has not been initialized yet, it calls `init()` to - /// establish it. Subsequent calls will return the same connection future. - Future get connection => _completer.future; - - /// Initializes the database connection. - /// - /// This method is idempotent. It parses the database URL from the - /// environment, opens a connection to the PostgreSQL server, and completes - /// the `_completer` with the connection. It only performs the connection - /// logic on the very first call. - Future init() async { - if (_completer.isCompleted) { - _log.fine('Database connection already initializing/initialized.'); - return; - } - - _log.info('Initializing database connection...'); - final dbUri = Uri.parse(EnvironmentConfig.databaseUrl); - String? username; - String? password; - if (dbUri.userInfo.isNotEmpty) { - final parts = dbUri.userInfo.split(':'); - username = Uri.decodeComponent(parts.first); - if (parts.length > 1) { - password = Uri.decodeComponent(parts.last); - } - } - - final connection = await Connection.open( - Endpoint( - host: dbUri.host, - port: dbUri.port, - database: dbUri.path.substring(1), - username: username, - password: password, - ), - settings: const ConnectionSettings(sslMode: SslMode.require), - ); - _log.info('Database connection established successfully.'); - _completer.complete(connection); - } -} diff --git a/lib/src/config/environment_config.dart b/lib/src/config/environment_config.dart index 0c6eeee..fd8e18a 100644 --- a/lib/src/config/environment_config.dart +++ b/lib/src/config/environment_config.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:dotenv/dotenv.dart'; import 'package:logging/logging.dart'; @@ -11,11 +13,50 @@ import 'package:logging/logging.dart'; abstract final class EnvironmentConfig { static final _log = Logger('EnvironmentConfig'); - // The DotEnv instance that loads the .env file and platform variables. - // It's initialized once and reused. - static final _env = DotEnv(includePlatformEnvironment: true)..load(); + // The DotEnv instance is now loaded via a helper method to make it more + // resilient to current working directory issues. + static final _env = _loadEnv(); + + /// Helper method to load the .env file more robustly. + /// + /// It searches for the .env file starting from the current directory + /// and moving up to parent directories. This makes it resilient to + /// issues where the execution context's working directory is not the + /// project root. + static DotEnv _loadEnv() { + final env = DotEnv(includePlatformEnvironment: true); // Start with default + var currentDir = Directory.current; + _log.fine('Starting .env search from: ${currentDir.path}'); + // Traverse up the directory tree to find pubspec.yaml + while (currentDir.parent.path != currentDir.path) { + final pubspecPath = + '${currentDir.path}${Platform.pathSeparator}pubspec.yaml'; + _log.finer('Checking for pubspec.yaml at: $pubspecPath'); + if (File(pubspecPath).existsSync()) { + // Found pubspec.yaml, now load .env from the same directory + final envPath = '${currentDir.path}${Platform.pathSeparator}.env'; + _log.info( + 'Found pubspec.yaml, now looking for .env at: ${currentDir.path}', + ); + if (File(envPath).existsSync()) { + _log.info('Found .env file at: $envPath'); + env.load([envPath]); // Load variables from the found .env file + return env; // Return immediately upon finding + } else { + _log.warning('pubspec.yaml found, but no .env in the same directory.'); + break; // Stop searching since pubspec.yaml should contain .env + } + } + currentDir = currentDir.parent; // Move to the parent directory + _log.finer('Moving up to parent directory: ${currentDir.path}'); + } + // If loop completes without returning, .env was not found + _log.warning('.env not found by searching. Falling back to default load().'); + env.load(); // Fallback to default load + return env; // Return even if fallback + } - /// Retrieves the PostgreSQL database connection URI from the environment. + /// Retrieves the database connection URI from the environment. /// /// The value is read from the `DATABASE_URL` environment variable. /// @@ -33,7 +74,7 @@ abstract final class EnvironmentConfig { return dbUrl; } - /// Retrieves the current environment mode (e.g., 'development', 'production'). + /// Retrieves the current environment mode (e.g., 'development'). /// /// The value is read from the `ENV` environment variable. /// Defaults to 'production' if the variable is not set. diff --git a/lib/src/middlewares/error_handler.dart b/lib/src/middlewares/error_handler.dart index 2778590..a51fcee 100644 --- a/lib/src/middlewares/error_handler.dart +++ b/lib/src/middlewares/error_handler.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:dart_frog/dart_frog.dart'; import 'package:ht_shared/ht_shared.dart'; +import 'package:json_annotation/json_annotation.dart'; /// Middleware that catches errors and converts them into /// standardized JSON responses. @@ -26,6 +27,21 @@ Middleware errorHandler() { 'error': {'code': errorCode, 'message': e.message}, }, ); + } on CheckedFromJsonException catch (e, stackTrace) { + // Handle json_serializable validation errors. These are client errors. + final field = e.key ?? 'unknown'; + final message = 'Invalid request body: Field "$field" has an ' + 'invalid value or is missing. ${e.message}'; + print('CheckedFromJsonException Caught: $e\n$stackTrace'); + return Response.json( + statusCode: HttpStatus.badRequest, // 400 + body: { + 'error': { + 'code': 'invalidField', + 'message': message, + }, + }, + ); } on FormatException catch (e, stackTrace) { // Handle data format/parsing errors (often indicates bad client input) print('FormatException Caught: $e\n$stackTrace'); // Log for debugging diff --git a/lib/src/middlewares/ownership_check_middleware.dart b/lib/src/middlewares/ownership_check_middleware.dart new file mode 100644 index 0000000..58410a4 --- /dev/null +++ b/lib/src/middlewares/ownership_check_middleware.dart @@ -0,0 +1,103 @@ +import 'package:dart_frog/dart_frog.dart'; +import 'package:ht_api/src/rbac/permission_service.dart'; +import 'package:ht_api/src/registry/model_registry.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_shared/ht_shared.dart'; + +/// A wrapper class to provide a fetched item into the request context. +/// +/// This ensures type safety and avoids providing a raw `dynamic` object, +/// which could lead to ambiguity if other dynamic objects are in the context. +class FetchedItem { + /// Creates a wrapper for the fetched item. + const FetchedItem(this.data); + + /// The fetched item data. + final T data; +} + +/// Middleware to check if the authenticated user is the owner of the requested +/// item. +/// +/// This middleware is designed to run on item-specific routes (e.g., `/[id]`). +/// It performs the following steps: +/// +/// 1. Determines if an ownership check is required for the current action +/// (GET, PUT, DELETE) based on the `ModelConfig`. +/// 2. If a check is required and the user is not an admin, it fetches the +/// item from the database. +/// 3. It then compares the item's owner ID with the authenticated user's ID. +/// 4. If the check fails, it throws a [ForbiddenException]. +/// 5. If the check passes, it provides the fetched item into the request +/// context via `context.provide>`. This prevents the +/// downstream route handler from needing to fetch the item again. +Middleware ownershipCheckMiddleware() { + return (handler) { + return (context) async { + final modelName = context.read(); + final modelConfig = context.read>(); + final user = context.read(); + final permissionService = context.read(); + final method = context.request.method; + final id = context.request.uri.pathSegments.last; + + ModelActionPermission permission; + switch (method) { + case HttpMethod.get: + permission = modelConfig.getItemPermission; + case HttpMethod.put: + permission = modelConfig.putPermission; + case HttpMethod.delete: + permission = modelConfig.deletePermission; + default: + // For other methods, no ownership check is performed here. + return handler(context); + } + + // If no ownership check is required or if the user is an admin, + // proceed to the next handler without fetching the item. + if (!permission.requiresOwnershipCheck || + permissionService.isAdmin(user)) { + return handler(context); + } + + if (modelConfig.getOwnerId == null) { + throw const OperationFailedException( + 'Internal Server Error: Model configuration error for ownership check.', + ); + } + + final userIdForRepoCall = user.id; + dynamic item; + + switch (modelName) { + case 'user': + final repo = context.read>(); + item = await repo.read(id: id, userId: userIdForRepoCall); + case 'user_app_settings': + final repo = context.read>(); + item = await repo.read(id: id, userId: userIdForRepoCall); + case 'user_content_preferences': + final repo = context.read>(); + item = await repo.read(id: id, userId: userIdForRepoCall); + default: + throw OperationFailedException( + 'Ownership check not implemented for model "$modelName".', + ); + } + + final itemOwnerId = modelConfig.getOwnerId!(item); + if (itemOwnerId != user.id) { + throw const ForbiddenException( + 'You do not have permission to access this item.', + ); + } + + final updatedContext = context.provide>( + () => FetchedItem(item), + ); + + return handler(updatedContext); + }; + }; +} diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index 05f53bd..dc38237 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -411,8 +411,9 @@ class AuthService { try { // 1. Check if emailToLink is already used by another permanent user. - final query = {'email': emailToLink}; - final existingUsersResponse = await _userRepository.readAllByQuery(query); + final existingUsersResponse = await _userRepository.readAll( + filter: {'email': emailToLink}, + ); // Filter for permanent users (not guests) that are not the current user. final conflictingPermanentUsers = existingUsersResponse.items.where( @@ -548,25 +549,29 @@ class AuthService { /// Throws [NotFoundException] if the user does not exist. /// Throws [OperationFailedException] for other errors during deletion or cleanup. Future deleteAccount({required String userId}) async { - // Note: The user record itself is deleted via a CASCADE constraint - // when the corresponding entry in the `users` table is deleted. - // This is because `user_app_settings.user_id` and - // `user_content_preferences.user_id` have `ON DELETE CASCADE`. - // Therefore, we only need to delete the main user record. try { // Fetch the user first to get their email if needed for cleanup final userToDelete = await _userRepository.read(id: userId); _log.info('Found user ${userToDelete.id} for deletion.'); - // 1. Delete the main user record from the `users` table. - // The `ON DELETE CASCADE` constraint on the `user_app_settings` and - // `user_content_preferences` tables will automatically delete the - // associated records in those tables. This also implicitly invalidates + // 1. Explicitly delete associated user data. Unlike relational databases + // with CASCADE constraints, MongoDB requires manual deletion of related + // documents in different collections. + await _userAppSettingsRepository.delete(id: userId, userId: userId); + _log.info('Deleted UserAppSettings for user ${userToDelete.id}.'); + + await _userContentPreferencesRepository.delete( + id: userId, + userId: userId, + ); + _log.info('Deleted UserContentPreferences for user ${userToDelete.id}.'); + + // 2. Delete the main user record. This also implicitly invalidates // tokens that rely on user lookup, as the user will no longer exist. await _userRepository.delete(id: userId); _log.info('User ${userToDelete.id} deleted from repository.'); - // 2. Clear any pending verification codes for this user ID (linking). + // 3. Clear any pending verification codes for this user ID (linking). try { await _verificationCodeStorageService.clearLinkCode(userId); _log.info('Cleared link code for user ${userToDelete.id}.'); @@ -577,7 +582,7 @@ class AuthService { ); } - // 3. Clear any pending sign-in codes for the user's email (if they had one). + // 4. Clear any pending sign-in codes for the user's email (if they had one). // The email for anonymous users is a placeholder and not used for sign-in. if (userToDelete.appRole != AppUserRole.guestUser) { try { @@ -612,8 +617,9 @@ class AuthService { /// Re-throws any [HtHttpException] from the repository. Future _findUserByEmail(String email) async { try { - final query = {'email': email}; - final response = await _userRepository.readAllByQuery(query); + final response = await _userRepository.readAll( + filter: {'email': email}, + ); if (response.items.isNotEmpty) { return response.items.first; } diff --git a/lib/src/services/dashboard_summary_service.dart b/lib/src/services/dashboard_summary_service.dart index ba9bfdc..478a281 100644 --- a/lib/src/services/dashboard_summary_service.dart +++ b/lib/src/services/dashboard_summary_service.dart @@ -23,26 +23,26 @@ class DashboardSummaryService { /// Calculates and returns the current dashboard summary. /// - /// This method fetches all items from the required repositories to count them - /// and constructs a [DashboardSummary] object. + /// This method fetches the counts of all items from the required + /// repositories and constructs a [DashboardSummary] object. Future getSummary() async { // Use Future.wait to fetch all counts in parallel for efficiency. final results = await Future.wait([ - _headlineRepository.readAll(), - _topicRepository.readAll(), - _sourceRepository.readAll(), + _headlineRepository.count(), + _topicRepository.count(), + _sourceRepository.count(), ]); - // The results are PaginatedResponse objects. - final headlineResponse = results[0] as PaginatedResponse; - final topicResponse = results[1] as PaginatedResponse; - final sourceResponse = results[2] as PaginatedResponse; + // The results are integers. + final headlineCount = results[0]; + final topicCount = results[1]; + final sourceCount = results[2]; return DashboardSummary( id: 'dashboard_summary', // Fixed ID for the singleton summary - headlineCount: headlineResponse.items.length, - topicCount: topicResponse.items.length, - sourceCount: sourceResponse.items.length, + headlineCount: headlineCount, + topicCount: topicCount, + sourceCount: sourceCount, ); } } diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index cf85361..86df5bf 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -1,406 +1,111 @@ -import 'dart:convert'; import 'package:ht_shared/ht_shared.dart'; import 'package:logging/logging.dart'; -import 'package:postgres/postgres.dart'; +import 'package:mongo_dart/mongo_dart.dart'; /// {@template database_seeding_service} -/// A service responsible for initializing the database schema and seeding it -/// with initial data. +/// A service responsible for seeding the MongoDB database with initial data. /// -/// This service is intended to be run at application startup, particularly -/// in development environments or during the first run of a production instance -/// to set up the initial admin user and default configuration. +/// This service reads data from predefined fixture lists in `ht_shared` and +/// uses `upsert` operations to ensure that the seeding process is idempotent. +/// It can be run multiple times without creating duplicate documents. /// {@endtemplate} class DatabaseSeedingService { /// {@macro database_seeding_service} - const DatabaseSeedingService({ - required Connection connection, - required Logger log, - }) : _connection = connection, - _log = log; + const DatabaseSeedingService({required Db db, required Logger log}) + : _db = db, + _log = log; - final Connection _connection; + final Db _db; final Logger _log; - /// Creates all necessary tables in the database if they do not already exist. - /// - /// This method executes a series of `CREATE TABLE IF NOT EXISTS` statements - /// within a single transaction to ensure atomicity. - Future createTables() async { - _log.info('Starting database schema creation...'); - try { - // Manually handle the transaction with BEGIN/COMMIT/ROLLBACK. - await _connection.execute('BEGIN'); - - try { - _log.fine('Creating "users" table...'); - // All statements are executed on the main connection within the - // manual transaction. - await _connection.execute(''' - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - email TEXT NOT NULL UNIQUE, - app_role TEXT NOT NULL, - dashboard_role TEXT NOT NULL, - feed_action_status JSONB NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - '''); - - _log.fine('Creating "remote_config" table...'); - await _connection.execute(''' - CREATE TABLE IF NOT EXISTS remote_config ( - id TEXT PRIMARY KEY, - user_preference_config JSONB NOT NULL, - ad_config JSONB NOT NULL, - account_action_config JSONB NOT NULL, - app_status JSONB NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ - ); - '''); - - _log.fine('Creating "topics" table...'); - await _connection.execute(''' - CREATE TABLE IF NOT EXISTS topics ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - icon_url TEXT, - status TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ - ); - '''); - - _log.fine('Creating "sources" table...'); - await _connection.execute(''' - CREATE TABLE IF NOT EXISTS sources ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - url TEXT, - language TEXT, - status TEXT, - source_type TEXT, - headquarters_country_id TEXT REFERENCES countries(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ - ); - '''); - - _log.fine('Creating "countries" table...'); - await _connection.execute(''' - CREATE TABLE IF NOT EXISTS countries ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - iso_code TEXT NOT NULL UNIQUE, - flag_url TEXT NOT NULL, - status TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ - ); - '''); - - _log.fine('Creating "headlines" table...'); - await _connection.execute(''' - CREATE TABLE IF NOT EXISTS headlines ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - excerpt TEXT, - url TEXT, - image_url TEXT, - source_id TEXT REFERENCES sources(id), - topic_id TEXT REFERENCES topics(id), - event_country_id TEXT REFERENCES countries(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ, - status TEXT, - ); - '''); - - _log.fine('Creating "user_app_settings" table...'); - await _connection.execute(''' - CREATE TABLE IF NOT EXISTS user_app_settings ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - display_settings JSONB NOT NULL, -- Nested object, stored as JSON - language TEXT NOT NULL, -- Simple string, stored as TEXT - feed_preferences JSONB NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ - ); - '''); - - _log.fine('Creating "user_content_preferences" table...'); - await _connection.execute(''' - CREATE TABLE IF NOT EXISTS user_content_preferences ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - followed_topics JSONB NOT NULL, - followed_sources JSONB NOT NULL, - followed_countries JSONB NOT NULL, - saved_headlines JSONB NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ - ); - '''); - - await _connection.execute('COMMIT'); - } catch (e) { - // If any query inside the transaction fails, roll back. - await _connection.execute('ROLLBACK'); - rethrow; // Re-throw the original error - } - _log.info('Database schema creation completed successfully.'); - } on Object catch (e, st) { - _log.severe('An error occurred during database schema creation.', e, st); - // Propagate as a standard exception for the server to handle. - throw OperationFailedException( - 'Failed to initialize database schema: $e', - ); - } + /// The main entry point for seeding all necessary data. + Future seedInitialData() async { + _log.info('Starting database seeding process...'); + + await _seedCollection( + collectionName: 'countries', + fixtureData: countriesFixturesData, + getId: (item) => item.id, + toJson: (item) => item.toJson(), + ); + await _seedCollection( + collectionName: 'sources', + fixtureData: sourcesFixturesData, + getId: (item) => item.id, + toJson: (item) => item.toJson(), + ); + await _seedCollection( + collectionName: 'topics', + fixtureData: topicsFixturesData, + getId: (item) => item.id, + toJson: (item) => item.toJson(), + ); + await _seedCollection( + collectionName: 'headlines', + fixtureData: headlinesFixturesData, + getId: (item) => item.id, + toJson: (item) => item.toJson(), + ); + await _seedCollection( + collectionName: 'users', + fixtureData: usersFixturesData, + getId: (item) => item.id, + toJson: (item) => item.toJson(), + ); + await _seedCollection( + collectionName: 'remote_configs', + fixtureData: remoteConfigsFixturesData, + getId: (item) => item.id, + toJson: (item) => item.toJson(), + ); + + _log.info('Database seeding process completed.'); } - /// Seeds the database with global fixture data (categories, sources, etc.). - /// - /// This method is idempotent, using `ON CONFLICT DO NOTHING` to prevent - /// errors if the data already exists. It runs within a single transaction. - Future seedGlobalFixtureData() async { - _log.info('Seeding global fixture data...'); + /// Seeds a specific collection from a given list of fixture data. + Future _seedCollection({ + required String collectionName, + required List fixtureData, + required String Function(T) getId, + required Map Function(T) toJson, + }) async { + _log.info('Seeding collection: "$collectionName"...'); try { - await _connection.execute('BEGIN'); - try { - // Seed Topics - _log.fine('Seeding topics...'); - for (final topic in topicsFixturesData) { - final params = topic.toJson() - ..putIfAbsent('description', () => null) - ..putIfAbsent('icon_url', () => null) - ..putIfAbsent('updated_at', () => null); - - await _connection.execute( - Sql.named( - 'INSERT INTO topics (id, name, description, icon_url, ' - 'status, created_at, updated_at) VALUES (@id, @name, ' - '@description, @icon_url, @status, @created_at, @updated_at) ' - 'ON CONFLICT (id) DO NOTHING', - ), - parameters: params, - ); - } - - // Seed Countries (must be done before sources and headlines) - _log.fine('Seeding countries...'); - for (final country in countriesFixturesData) { - final params = country.toJson() - ..putIfAbsent('updated_at', () => null); - - await _connection.execute( - Sql.named( - 'INSERT INTO countries (id, name, iso_code, flag_url, ' - 'status, created_at, updated_at) VALUES (@id, @name, ' - '@iso_code, @flag_url, @status, @created_at, @updated_at) ' - 'ON CONFLICT (id) DO NOTHING', - ), - parameters: params, - ); - } - - // Seed Sources - _log.fine('Seeding sources...'); - for (final source in sourcesFixturesData) { - final params = source.toJson() - // The `headquarters` field in the model is a nested `Country` - // object. We extract its ID to store in the - // `headquarters_country_id` column and then remove the original - // nested object from the parameters to avoid a "superfluous - // variable" error. - ..['headquarters_country_id'] = source.headquarters.id - ..remove('headquarters') - ..putIfAbsent('description', () => null) - ..putIfAbsent('url', () => null) - ..putIfAbsent('language', () => null) - ..putIfAbsent('source_type', () => null) - ..putIfAbsent('updated_at', () => null); - - await _connection.execute( - Sql.named( - 'INSERT INTO sources (id, name, description, url, language, ' - 'status, source_type, headquarters_country_id, ' - 'created_at, updated_at) VALUES (@id, @name, @description, @url, ' - '@language, @status, @source_type, ' - '@headquarters_country_id, @created_at, @updated_at) ' - 'ON CONFLICT (id) DO NOTHING', - ), - parameters: params, - ); - } - - // Seed Headlines - _log.fine('Seeding headlines...'); - for (final headline in headlinesFixturesData) { - final params = headline.toJson() - ..['source_id'] = headline.source.id - ..['topic_id'] = headline.topic.id - ..['event_country_id'] = headline.eventCountry.id - ..remove('source') - ..remove('topic') - ..remove('eventCountry') - ..putIfAbsent('excerpt', () => null) - ..putIfAbsent('updated_at', () => null) - ..putIfAbsent('image_url', () => null) - ..putIfAbsent('url', () => null); - - await _connection.execute( - Sql.named( - 'INSERT INTO headlines (id, title, excerpt, url, image_url, ' - 'source_id, topic_id, event_country_id, status, created_at, ' - 'updated_at) VALUES (@id, @title, @excerpt, @url, @image_url, ' - '@source_id, @topic_id, @event_country_id, @status, @created_at, @updated_at) ' - 'ON CONFLICT (id) DO NOTHING', - ), - parameters: params, - ); - } - - await _connection.execute('COMMIT'); - _log.info('Global fixture data seeding completed successfully.'); - } catch (e) { - await _connection.execute('ROLLBACK'); - rethrow; + if (fixtureData.isEmpty) { + _log.info('No documents to seed for "$collectionName".'); + return; } - } on Object catch (e, st) { - _log.severe( - 'An error occurred during global fixture data seeding.', - e, - st, - ); - throw OperationFailedException('Failed to seed global fixture data: $e'); - } - } - /// Seeds the database with the initial RemoteConfig and the default admin user. - /// - /// This method is idempotent, using `ON CONFLICT DO NOTHING` to prevent - /// errors if the data already exists. It runs within a single transaction. - Future seedInitialAdminAndConfig() async { - _log.info('Seeding initial RemoteConfig and admin user...'); - try { - await _connection.execute('BEGIN'); - try { - // Seed RemoteConfig - _log.fine('Seeding RemoteConfig...'); - const remoteConfig = remoteConfigFixtureData; - // The `remote_config` table has multiple JSONB columns. We must - // provide an explicit map with JSON-encoded values to avoid a - // "superfluous variables" error from the postgres driver. - await _connection.execute( - Sql.named( - 'INSERT INTO remote_config (id, user_preference_config, ad_config, ' - 'account_action_config, app_status) VALUES (@id, ' - '@user_preference_config, @ad_config, @account_action_config, ' - '@app_status) ' - 'ON CONFLICT (id) DO NOTHING', - ), - parameters: { - 'id': remoteConfig.id, - 'user_preference_config': jsonEncode( - remoteConfig.userPreferenceConfig.toJson(), - ), - 'ad_config': jsonEncode(remoteConfig.adConfig.toJson()), - 'account_action_config': jsonEncode( - remoteConfig.accountActionConfig.toJson(), - ), - 'app_status': jsonEncode(remoteConfig.appStatus.toJson()), + final collection = _db.collection(collectionName); + final operations = >[]; + + for (final item in fixtureData) { + // Use the predefined hex string ID from the fixture to create a + // deterministic ObjectId. This is crucial for maintaining relationships + // between documents (e.g., a headline and its source). + final objectId = ObjectId.fromHexString(getId(item)); + final document = toJson(item)..remove('id'); + + operations.add({ + // Use updateOne with $set to be less destructive than replaceOne. + 'updateOne': { + // Filter by the specific, deterministic _id. + 'filter': {'_id': objectId}, + // Set the fields of the document. + 'update': {'\$set': document}, + 'upsert': true, }, - ); - - // Seed Admin User - _log.fine('Seeding admin user...'); - // Find the admin user in the fixture data. - final adminUser = usersFixturesData.firstWhere( - (user) => user.dashboardRole == DashboardUserRole.admin, - orElse: () => throw StateError('Admin user not found in fixtures.'), - ); - - // The `users` table has specific columns for roles and status. - await _connection.execute( - Sql.named( - 'INSERT INTO users (id, email, app_role, dashboard_role, ' - 'feed_action_status) VALUES (@id, @email, @app_role, ' - '@dashboard_role, @feed_action_status) ' - 'ON CONFLICT (id) DO NOTHING', - ), - parameters: () { - final params = adminUser.toJson(); - params['feed_action_status'] = jsonEncode( - params['feed_action_status'], - ); - return params; - }(), - ); - - // Seed default settings and preferences for the admin user. - final adminSettings = userAppSettingsFixturesData.firstWhere( - (settings) => settings.id == adminUser.id, - ); - final adminPreferences = userContentPreferencesFixturesData.firstWhere( - (prefs) => prefs.id == adminUser.id, - ); - - await _connection.execute( - Sql.named( - 'INSERT INTO user_app_settings (id, user_id, display_settings, ' - 'language, feed_preferences) VALUES (@id, @user_id, ' - '@display_settings, @language, @feed_preferences) ' - 'ON CONFLICT (id) DO NOTHING', - ), - parameters: () { - final params = adminSettings.toJson(); - params['user_id'] = adminUser.id; - params['display_settings'] = jsonEncode(params['display_settings']); - params['feed_preferences'] = jsonEncode(params['feed_preferences']); - return params; - }(), - ); - - await _connection.execute( - Sql.named( - 'INSERT INTO user_content_preferences (id, user_id, ' - 'followed_topics, followed_sources, followed_countries, ' - 'saved_headlines) VALUES (@id, @user_id, @followed_topics, ' - '@followed_sources, @followed_countries, @saved_headlines) ' - 'ON CONFLICT (id) DO NOTHING', - ), - parameters: () { - final params = adminPreferences.toJson(); - params['user_id'] = adminUser.id; - params['followed_topics'] = jsonEncode(params['followed_topics']); - params['followed_sources'] = jsonEncode(params['followed_sources']); - params['followed_countries'] = jsonEncode( - params['followed_countries'], - ); - params['saved_headlines'] = jsonEncode(params['saved_headlines']); - return params; - }(), - ); - - await _connection.execute('COMMIT'); - _log.info('Initial RemoteConfig and admin user seeding completed.'); - } catch (e) { - await _connection.execute('ROLLBACK'); - rethrow; + }); } - } on Object catch (e, st) { - _log.severe( - 'An error occurred during initial admin/config seeding.', - e, - st, - ); - throw OperationFailedException( - 'Failed to seed initial admin/config data: $e', + + final result = await collection.bulkWrite(operations); + _log.info( + 'Seeding for "$collectionName" complete. ' + 'Upserted: ${result.nUpserted}, Modified: ${result.nModified}.', ); + } on Exception catch (e, s) { + _log.severe('Failed to seed collection "$collectionName".', e, s); + rethrow; } } } diff --git a/pubspec.yaml b/pubspec.yaml index 56fade7..96e31e9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,9 +13,9 @@ dependencies: ht_data_client: git: url: https://github.com/headlines-toolkit/ht-data-client.git - ht_data_postgres: + ht_data_mongodb: git: - url: https://github.com/headlines-toolkit/ht-data-postgres.git + url: https://github.com/headlines-toolkit/ht-data-mongodb.git ht_data_repository: git: url: https://github.com/headlines-toolkit/ht-data-repository.git @@ -34,10 +34,10 @@ dependencies: ht_shared: git: url: https://github.com/headlines-toolkit/ht-shared.git - + json_annotation: ^4.9.0 logging: ^1.3.0 meta: ^1.16.0 - postgres: ^3.5.6 + mongo_dart: ^0.10.5 shelf_cors_headers: ^0.1.5 uuid: ^4.5.1 diff --git a/routes/api/v1/data/[id]/_middleware.dart b/routes/api/v1/data/[id]/_middleware.dart new file mode 100644 index 0000000..3f1c5c2 --- /dev/null +++ b/routes/api/v1/data/[id]/_middleware.dart @@ -0,0 +1,19 @@ +import 'package:dart_frog/dart_frog.dart'; +import 'package:ht_api/src/middlewares/ownership_check_middleware.dart'; + +/// Middleware specific to the item-level `/api/v1/data/[id]` route path. +/// +/// This middleware applies the [ownershipCheckMiddleware] to perform an +/// ownership check on the requested item *after* the parent middleware +/// (`/api/v1/data/_middleware.dart`) has already performed authentication and +/// authorization checks. +/// +/// This ensures that only authorized users can proceed, and then this +/// middleware adds the final layer of security by verifying item ownership +/// for non-admin users when required by the model's configuration. +Handler middleware(Handler handler) { + // The `ownershipCheckMiddleware` will run after the middleware from + // `/api/v1/data/_middleware.dart` (authn, authz, model validation). + return handler.use(ownershipCheckMiddleware()); +} + diff --git a/routes/api/v1/data/[id].dart b/routes/api/v1/data/[id]/index.dart similarity index 98% rename from routes/api/v1/data/[id].dart rename to routes/api/v1/data/[id]/index.dart index 19ba614..2190367 100644 --- a/routes/api/v1/data/[id].dart +++ b/routes/api/v1/data/[id]/index.dart @@ -8,7 +8,7 @@ import 'package:ht_api/src/services/user_preference_limit_service.dart'; // Impo import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_shared/ht_shared.dart'; -import '../../../_middleware.dart'; // Assuming RequestId is here +import '../../../../_middleware.dart'; // Assuming RequestId is here /// Handles requests for the /api/v1/data/[id] endpoint. /// Dispatches requests to specific handlers based on the HTTP method. @@ -87,7 +87,8 @@ Future _handleGet( // for filtering. Otherwise, pass null. // Note: This is for data *scoping* by the repository, not the permission check. // We infer user-owned based on the presence of getOwnerId function. - if (modelConfig.getOwnerId != null) { + if (modelConfig.getOwnerId != null && + !permissionService.isAdmin(authenticatedUser)) { userIdForRepoCall = authenticatedUser.id; } else { userIdForRepoCall = null; @@ -276,7 +277,8 @@ Future _handlePut( String? userIdForRepoCall; // If the model is user-owned, pass the authenticated user's ID to the repository // for ownership enforcement. Otherwise, pass null. - if (modelConfig.getOwnerId != null) { + if (modelConfig.getOwnerId != null && + !permissionService.isAdmin(authenticatedUser)) { userIdForRepoCall = authenticatedUser.id; } else { userIdForRepoCall = null; @@ -445,7 +447,8 @@ Future _handleDelete( String? userIdForRepoCall; // If the model is user-owned, pass the authenticated user's ID to the repository // for ownership enforcement. Otherwise, pass null. - if (modelConfig.getOwnerId != null) { + if (modelConfig.getOwnerId != null && + !permissionService.isAdmin(authenticatedUser)) { userIdForRepoCall = authenticatedUser.id; } else { userIdForRepoCall = null; diff --git a/routes/api/v1/data/index.dart b/routes/api/v1/data/index.dart index f5959aa..82ed8d8 100644 --- a/routes/api/v1/data/index.dart +++ b/routes/api/v1/data/index.dart @@ -1,610 +1,240 @@ +import 'dart:convert'; import 'dart:io'; import 'package:dart_frog/dart_frog.dart'; -import 'package:ht_api/src/rbac/permission_service.dart'; // Import PermissionService import 'package:ht_api/src/registry/model_registry.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_api/src/rbac/permission_service.dart'; import 'package:ht_shared/ht_shared.dart'; -import '../../../_middleware.dart'; // Assuming RequestId is here - -/// Converts a camelCase string to snake_case. -String _camelToSnake(String input) { - return input - .replaceAllMapped( - RegExp('(? '_${match.group(0)}', - ) - .toLowerCase(); -} +import '../../../_middleware.dart'; // For RequestId /// Handles requests for the /api/v1/data collection endpoint. /// Dispatches requests to specific handlers based on the HTTP method. Future onRequest(RequestContext context) async { - // Read dependencies provided by middleware - final modelName = context.read(); - final modelConfig = context.read>(); - final requestId = context.read().id; - // User is guaranteed non-null by requireAuthentication() middleware - final authenticatedUser = context.read(); - final permissionService = context - .read(); // Read PermissionService - - // The main try/catch block here is removed to let the errorHandler middleware - // handle all exceptions thrown by the handlers below. switch (context.request.method) { case HttpMethod.get: - return _handleGet( - context, - modelName, - modelConfig, - authenticatedUser, - permissionService, // Pass PermissionService - requestId, - ); + return _handleGet(context); case HttpMethod.post: - return _handlePost( - context, - modelName, - modelConfig, - authenticatedUser, - permissionService, // Pass PermissionService - requestId, - ); - // Add cases for other methods if needed in the future + return _handlePost(context); default: - // Methods not allowed on the collection endpoint return Response(statusCode: HttpStatus.methodNotAllowed); } } -// --- GET Handler --- -/// Handles GET requests: Retrieves all items for the specified model. -/// -/// This handler implements model-specific filtering rules: -/// - **Headlines (`model=headline`):** -/// - Filterable by `q` (text query on title only). If `q` is present, -/// `topics` and `sources` are ignored. -/// Example: `/api/v1/data?model=headline&q=Dart+Frog` -/// - OR by a combination of: -/// - `topics` (comma-separated topic IDs). -/// Example: `/api/v1/data?model=headline&topics=topicId1,topicId2` -/// - `sources` (comma-separated source IDs). -/// Example: `/api/v1/data?model=headline&sources=sourceId1` -/// - Both `topics` and `sources` can be used together (AND logic). -/// Example: `/api/v1/data?model=headline&topics=topicId1&sources=sourceId1` -/// - Other parameters for headlines (e.g., `countries`) will result in a 400 Bad Request. -/// -/// - **Sources (`model=source`):** -/// - Filterable by `q` (text query on name only). If `q` is present, -/// `countries`, `sourceTypes`, `languages` are ignored. -/// Example: `/api/v1/data?model=source&q=Tech+News` -/// - OR by a combination of: -/// - `countries` (comma-separated country ISO codes for `source.headquarters.iso_code`). -/// Example: `/api/v1/data?model=source&countries=US,GB` -/// - `sourceTypes` (comma-separated `SourceType` enum string values for `source.sourceType`). -/// Example: `/api/v1/data?model=source&sourceTypes=blog,news_agency` -/// - `languages` (comma-separated language codes for `source.language`). -/// Example: `/api/v1/data?model=source&languages=en,fr` -/// - These specific filters are ANDed if multiple are provided. -/// - Other parameters for sources will result in a 400 Bad Request. +/// Handles GET requests: Retrieves a collection of items. /// -/// - **Topics (`model=topic`):** -/// - Filterable ONLY by `q` (text query on name only). -/// Example: `/api/v1/data?model=topic&q=Technology` -/// - Other parameters for topics will result in a 400 Bad Request. -/// -/// - **Countries (`model=country`):** -/// - Filterable ONLY by `q` (text query on name and isoCode). -/// Example: `/api/v1/data?model=country&q=United` (searches name and isoCode) -/// Example: `/api/v1/data?model=country&q=US` (searches name and isoCode) -/// - Other parameters for countries will result in a 400 Bad Request. -/// -/// - **Other Models (User, UserAppSettings, UserContentPreferences, RemoteConfig):** -/// - Currently support exact match for top-level query parameters passed directly. -/// - No specific complex filtering logic (like `_in` or `_contains`) is applied -/// by this handler for these models yet. The `HtDataInMemoryClient` can -/// process such queries if the `specificQueryForClient` map is constructed -/// with the appropriate keys by this handler in the future. -/// -/// Includes request metadata in the response. -Future _handleGet( - RequestContext context, - String modelName, - ModelConfig modelConfig, - User authenticatedUser, - PermissionService permissionService, - String requestId, -) async { - final queryParams = context.request.uri.queryParameters; - final startAfterId = queryParams['startAfterId']; - final limitParam = queryParams['limit']; - final sortByParam = queryParams['sortBy']; - final sortOrderRaw = queryParams['sortOrder']?.toLowerCase(); - final limit = limitParam != null ? int.tryParse(limitParam) : null; +/// This handler now accepts a single, JSON-encoded `filter` parameter for +/// MongoDB-style queries, along with `sort` and pagination parameters. +Future _handleGet(RequestContext context) async { + // Read dependencies provided by middleware + final modelName = context.read(); + final modelConfig = context.read>(); + final authenticatedUser = context.read(); + final requestId = context.read().id; - // Convert sortBy from camelCase to snake_case for the database query. - // This prevents errors where the client sends 'createdAt' and the database - // expects 'created_at'. - final sortBy = sortByParam != null ? _camelToSnake(sortByParam) : null; + // --- Parse Query Parameters --- + final params = context.request.uri.queryParameters; - SortOrder? sortOrder; - if (sortOrderRaw != null) { - if (sortOrderRaw == 'asc') { - sortOrder = SortOrder.asc; - } else if (sortOrderRaw == 'desc') { - sortOrder = SortOrder.desc; - } else { - throw const BadRequestException( - 'Invalid "sortOrder" parameter. Must be "asc" or "desc".', + // 1. Parse Filter (MongoDB-style) + Map? filter; + if (params.containsKey('filter')) { + try { + filter = jsonDecode(params['filter']!) as Map; + } on FormatException catch (e) { + throw BadRequestException( + 'Invalid "filter" parameter: Not valid JSON. $e', ); } } - final specificQueryForClient = {}; - final Set allowedKeys; - final receivedKeys = queryParams.keys - .where( - (k) => - k != 'model' && - k != 'startAfterId' && - k != 'limit' && - k != 'sortBy' && - k != 'sortOrder', - ) - .toSet(); - - switch (modelName) { - case 'headline': - allowedKeys = {'topics', 'sources', 'q'}; - final qValue = queryParams['q']; - if (qValue != null && qValue.isNotEmpty) { - specificQueryForClient['title_contains'] = qValue; - // specificQueryForClient['description_contains'] = qValue; // Removed - } else { - if (queryParams.containsKey('topics')) { - specificQueryForClient['topic.id_in'] = queryParams['topics']!; - } - if (queryParams.containsKey('sources')) { - specificQueryForClient['source.id_in'] = queryParams['sources']!; - } - } - case 'source': - allowedKeys = {'countries', 'sourceTypes', 'languages', 'q'}; - final qValue = queryParams['q']; - if (qValue != null && qValue.isNotEmpty) { - specificQueryForClient['name_contains'] = qValue; - // specificQueryForClient['description_contains'] = qValue; // Removed - } else { - if (queryParams.containsKey('countries')) { - specificQueryForClient['headquarters.iso_code_in'] = - queryParams['countries']!; - } - if (queryParams.containsKey('sourceTypes')) { - specificQueryForClient['source_type_in'] = - queryParams['sourceTypes']!; - } - if (queryParams.containsKey('languages')) { - specificQueryForClient['language_in'] = queryParams['languages']!; - } - } - case 'topic': - allowedKeys = {'q'}; - final qValue = queryParams['q']; - if (qValue != null && qValue.isNotEmpty) { - specificQueryForClient['name_contains'] = qValue; - // specificQueryForClient['description_contains'] = qValue; // Removed - } - case 'country': - allowedKeys = {'q'}; - final qValue = queryParams['q']; - if (qValue != null && qValue.isNotEmpty) { - specificQueryForClient['name_contains'] = qValue; - specificQueryForClient['iso_code_contains'] = qValue; // Added back - } - default: - // For other models, pass through all non-standard query params directly. - // No specific validation of allowed keys for these other models here. - // The client will attempt exact matches. - allowedKeys = receivedKeys; // Effectively allows all received keys - queryParams.forEach((key, value) { - if (key != 'model' && key != 'startAfterId' && key != 'limit') { - specificQueryForClient[key] = value; - } - }); + // 2. Parse Sort + List? sort; + if (params.containsKey('sort')) { + try { + sort = params['sort']! + .split(',') + .map((s) { + final parts = s.split(':'); + final field = parts[0]; + final order = (parts.length > 1 && parts[1] == 'desc') + ? SortOrder.desc + : SortOrder.asc; + return SortOption(field, order); + }) + .toList(); + } catch (e) { + throw BadRequestException( + 'Invalid "sort" parameter format. Use "field:order,field2:order".', + ); + } } - // Validate received keys against allowed keys for the specific models - if (modelName == 'headline' || - modelName == 'source' || - modelName == 'topic' || - modelName == 'country') { - for (final key in receivedKeys) { - if (!allowedKeys.contains(key)) { - throw BadRequestException( - 'Invalid query parameter "$key" for model "$modelName". ' - 'Allowed parameters are: ${allowedKeys.join(', ')}.', - ); - } - } + // 3. Parse Pagination + PaginationOptions? pagination; + if (params.containsKey('limit') || params.containsKey('cursor')) { + final limit = int.tryParse(params['limit'] ?? ''); + pagination = PaginationOptions(cursor: params['cursor'], limit: limit); } - PaginatedResponse paginatedResponse; - // ignore: prefer_final_locals - var userIdForRepoCall = modelConfig.getOwnerId != null + // --- Repository Call --- + final userIdForRepoCall = (modelConfig.getOwnerId != null && + !context.read().isAdmin(authenticatedUser)) ? authenticatedUser.id : null; - // Repository calls using specificQueryForClient + dynamic responseData; + + // The switch statement now only dispatches to the correct repository type. + // The query logic is handled by the repository/client. switch (modelName) { case 'headline': final repo = context.read>(); - paginatedResponse = await repo.readAllByQuery( - specificQueryForClient, + responseData = await repo.readAll( userId: userIdForRepoCall, - startAfterId: startAfterId, - limit: limit, - sortBy: sortBy, - sortOrder: sortOrder, + filter: filter, + sort: sort, + pagination: pagination, ); case 'topic': final repo = context.read>(); - paginatedResponse = await repo.readAllByQuery( - specificQueryForClient, + responseData = await repo.readAll( userId: userIdForRepoCall, - startAfterId: startAfterId, - limit: limit, - sortBy: sortBy, - sortOrder: sortOrder, + filter: filter, + sort: sort, + pagination: pagination, ); case 'source': final repo = context.read>(); - paginatedResponse = await repo.readAllByQuery( - specificQueryForClient, + responseData = await repo.readAll( userId: userIdForRepoCall, - startAfterId: startAfterId, - limit: limit, - sortBy: sortBy, - sortOrder: sortOrder, + filter: filter, + sort: sort, + pagination: pagination, ); case 'country': final repo = context.read>(); - paginatedResponse = await repo.readAllByQuery( - specificQueryForClient, + responseData = await repo.readAll( userId: userIdForRepoCall, - startAfterId: startAfterId, - limit: limit, - sortBy: sortBy, - sortOrder: sortOrder, + filter: filter, + sort: sort, + pagination: pagination, ); case 'user': final repo = context.read>(); - paginatedResponse = await repo.readAllByQuery( - specificQueryForClient, // Pass the potentially empty map - userId: userIdForRepoCall, - startAfterId: startAfterId, - limit: limit, - sortBy: sortBy, - sortOrder: sortOrder, - ); - case 'user_app_settings': - final repo = context.read>(); - paginatedResponse = await repo.readAllByQuery( - specificQueryForClient, - userId: userIdForRepoCall, - startAfterId: startAfterId, - limit: limit, - sortBy: sortBy, - sortOrder: sortOrder, - ); - case 'user_content_preferences': - final repo = context.read>(); - paginatedResponse = await repo.readAllByQuery( - specificQueryForClient, - userId: userIdForRepoCall, - startAfterId: startAfterId, - limit: limit, - sortBy: sortBy, - sortOrder: sortOrder, - ); - case 'remote_config': - final repo = context.read>(); - paginatedResponse = await repo.readAllByQuery( - specificQueryForClient, + responseData = await repo.readAll( userId: userIdForRepoCall, - startAfterId: startAfterId, - limit: limit, - sortBy: sortBy, - sortOrder: sortOrder, + filter: filter, + sort: sort, + pagination: pagination, ); default: throw OperationFailedException( - 'Unsupported model type "$modelName" reached data retrieval switch.', + 'Unsupported model type "$modelName" for GET all.', ); } - final finalFeedItems = paginatedResponse.items; + // --- Format and Return Response --- final metadata = ResponseMetadata( requestId: requestId, - timestamp: DateTime.now().toUtc(), // Use UTC for consistency + timestamp: DateTime.now().toUtc(), ); - // Wrap the PaginatedResponse in SuccessApiResponse with metadata - final successResponse = SuccessApiResponse>( - data: PaginatedResponse( - items: finalFeedItems, // Items are already dynamic - cursor: paginatedResponse.cursor, - hasMore: paginatedResponse.hasMore, - ), + final successResponse = SuccessApiResponse( + data: responseData, metadata: metadata, ); - // Need to provide the correct toJsonT for PaginatedResponse final responseJson = successResponse.toJson( - (paginated) => paginated.toJson( - (item) => (item as dynamic).toJson(), // Assuming all models have toJson + (paginated) => (paginated as PaginatedResponse).toJson( + (item) => (item as dynamic).toJson(), ), ); - // Return 200 OK with the wrapped and serialized response return Response.json(body: responseJson); } -// --- POST Handler --- -/// Handles POST requests: Creates a new item for the specified model. -/// Includes request metadata in response. -Future _handlePost( - RequestContext context, - String modelName, - ModelConfig modelConfig, - User authenticatedUser, - PermissionService permissionService, - String requestId, -) async { - // Authorization check is handled by authorizationMiddleware before this. +/// Handles POST requests: Creates a new item in a collection. +Future _handlePost(RequestContext context) async { + // Read dependencies from middleware + final modelName = context.read(); + final modelConfig = context.read>(); + final authenticatedUser = context.read(); + final requestId = context.read().id; + // --- Parse Body --- final requestBody = await context.request.json() as Map?; if (requestBody == null) { - // Throw BadRequestException to be caught by the errorHandler throw const BadRequestException('Missing or invalid request body.'); } - // Deserialize using ModelConfig's fromJson, catching TypeErrors - dynamic newItem; + dynamic itemToCreate; try { - newItem = modelConfig.fromJson(requestBody); + itemToCreate = modelConfig.fromJson(requestBody); } on TypeError catch (e) { - // Catch errors during deserialization (e.g., missing required fields) - // Include requestId in the server log - print('[ReqID: $requestId] Deserialization TypeError in POST /data: $e'); - // Throw BadRequestException to be caught by the errorHandler - throw const BadRequestException( - 'Invalid request body: Missing or invalid required field(s).', + throw BadRequestException( + 'Invalid request body: Missing or invalid required field(s). $e', ); } - // Determine userId for repository call based on ModelConfig (for data scoping/ownership enforcement) - String? userIdForRepoCall; - // If the model is user-owned, pass the authenticated user's ID to the repository - // for associating ownership during creation. Otherwise, pass null. - // We infer user-owned based on the presence of getOwnerId function. - if (modelConfig.getOwnerId != null) { - userIdForRepoCall = authenticatedUser.id; - } else { - userIdForRepoCall = null; - } + // --- Repository Call --- + final userIdForRepoCall = (modelConfig.getOwnerId != null && + !context.read().isAdmin(authenticatedUser)) + ? authenticatedUser.id + : null; - // Process based on model type dynamic createdItem; - - // Repository exceptions (like BadRequestException from create) will propagate - // up to the errorHandler. switch (modelName) { case 'headline': final repo = context.read>(); createdItem = await repo.create( - item: newItem as Headline, + item: itemToCreate as Headline, userId: userIdForRepoCall, ); case 'topic': final repo = context.read>(); createdItem = await repo.create( - item: newItem as Topic, + item: itemToCreate as Topic, userId: userIdForRepoCall, ); case 'source': final repo = context.read>(); createdItem = await repo.create( - item: newItem as Source, + item: itemToCreate as Source, userId: userIdForRepoCall, ); case 'country': final repo = context.read>(); createdItem = await repo.create( - item: newItem as Country, + item: itemToCreate as Country, userId: userIdForRepoCall, ); - case 'user': // Handle User model specifically if needed, or rely on generic - // User creation is typically handled by auth routes, not generic data POST. - // Throw Forbidden or BadRequest if attempted here. - throw const ForbiddenException( - 'User creation is not allowed via the generic data endpoint.', - ); - case 'user_app_settings': // New case for UserAppSettings - // Creation of UserAppSettings is handled by auth service, not generic data POST. - throw const ForbiddenException( - 'UserAppSettings creation is not allowed via the generic data endpoint.', - ); - case 'user_content_preferences': // New case for UserContentPreferences - // Creation of UserContentPreferences is handled by auth service, not generic data POST. - throw const ForbiddenException( - 'UserContentPreferences creation is not allowed via the generic data endpoint.', - ); - case 'remote_config': // New case for RemoteConfig (create by admin) + case 'remote_config': final repo = context.read>(); createdItem = await repo.create( - item: newItem as RemoteConfig, - userId: userIdForRepoCall, // userId should be null for AppConfig + item: itemToCreate as RemoteConfig, + userId: userIdForRepoCall, ); default: - // This case should ideally be caught by middleware, but added for safety - // Throw an exception to be caught by the errorHandler throw OperationFailedException( - 'Unsupported model type "$modelName" reached handler.', + 'Unsupported model type "$modelName" for POST.', ); } - // Create metadata including the request ID and current timestamp + // --- Format and Return Response --- final metadata = ResponseMetadata( requestId: requestId, - timestamp: DateTime.now().toUtc(), // Use UTC for consistency + timestamp: DateTime.now().toUtc(), ); - // Wrap the created item in SuccessApiResponse with metadata final successResponse = SuccessApiResponse( data: createdItem, metadata: metadata, ); - // Provide the correct toJsonT for the specific model type final responseJson = successResponse.toJson( - (item) => (item as dynamic).toJson(), // Assuming all models have toJson + (item) => (item as dynamic).toJson(), ); - // Return 201 Created with the wrapped and serialized response return Response.json(statusCode: HttpStatus.created, body: responseJson); -} - -/* -Simplified Strict Filtering Rules (ALL FILTERS ARE ANDed if present): - -1. Headlines (`model=headline`): - - Filterable by any combination (ANDed) of: - - `categories` (plural, comma-separated IDs, matching `headline.category.id`) - - `sources` (plural, comma-separated IDs, matching `headline.source.id`) - - `q` (free-text query, searching `headline.title` only) - - *No other filters (like `countries`) are allowed for headlines.* - -2. Sources (`model=source`): - - Filterable by any combination (ANDed) of: - - `countries` (plural, comma-separated ISO codes, matching `source.headquarters.iso_code`) - - `sourceTypes` (plural, comma-separated enum strings, matching `source.sourceType`) - - `languages` (plural, comma-separated language codes, matching `source.language`) - - `q` (free-text query, searching `source.name` only) - -3. Categories (`model=category`): - - Filterable __only__ by: - `q` (free-text query, searching `topic.name` - only) - -4. Countries (`model=country`): - - Filterable __only__ by: - `q` (free-text query, searching `country.name` - only) - ------- - -Explicitly Define Allowed Parameters per Model: When processing the request for a given `modelName`, the handler should have a predefined set of *allowed* query parameter keys for that specific model. - -- Example for `modelName == 'headline'`: - - Allowed keys: `categories`, `sources`, `q` (plus standard ones like `limit`, `startAfterId`). -- Example for `modelName == 'source'`: - - Allowed keys: `countries`, `sourceTypes`, `languages`, `q` (plus standard ones). -- And so on for `category` and `country`. - ------------------ TESTED FILTERS --------------- - -Model: `headline` - -1. Filter by single category: - - URL: `/api/v1/data?model=headline&topics=c1a2b3c4-d5e6-f789-0123-456789abcdef` - - Expected: Headlines with topic ID `c1a2b3c4-d5e6-f789-0123-456789abcdef`. - -2. Filter by multiple comma-separated categories (client-side `_in` implies OR for values): - - URL: `/api/v1/data?model=headline&topics=c1a2b3c4-d5e6-f789-0123-456789abcdef,c2b3c4d5-e6f7-a890-1234-567890abcdef` - - Expected: Headlines whose topic ID is *either* of the two provided. - -3. Filter by single source: - - URL: `/api/v1/data?model=headline&sources=s1a2b3c4-d5e6-f789-0123-456789abcdef` - - Expected: Headlines with source ID `s1a2b3c4-d5e6-f789-0123-456789abcdef`. - -4. Filter by multiple comma-separated sources (client-side `_in` implies OR for values): - - URL: `/api/v1/data?model=headline&sources=s1a2b3c4-d5e6-f789-0123-456789abcdef,s2b3c4d5-e6f7-a890-1234-567890abcdef` - - Expected: Headlines whose source ID is *either* of the two provided. - -5. Filter by a category AND a source: - - URL: `/api/v1/data?model=headline&topics=c1a2b3c4-d5e6-f789-0123-456789abcdef&sources=s1a2b3c4-d5e6-f789-0123-456789abcdef` - - Expected: Headlines matching *both* the topic ID AND the source ID. - -6. Filter by text query `q` (title only): - - URL: `/api/v1/data?model=headline&q=Dart` - - Expected: Headlines where "Dart" (case-insensitive) appears in the title. - -7. Filter by `q` AND `categories` (q should take precedence, categories ignored): - - URL: `/api/v1/data?model=headline&q=Flutter&topics=c1a2b3c4-d5e6-f789-0123-456789abcdef` - - Expected: Headlines matching `q=Flutter` (in title), ignoring the topic filter. - -8. Invalid parameter for headlines (e.g., `countries`): - - URL: `/api/v1/data?model=headline&countries=US` - - Expected: `400 Bad Request` with an error message about an invalid query parameter. - -Model: `source` - -9. Filter by single country (ISO code): - - URL: `/api/v1/data?model=source&countries=GB` - - Expected: Sources headquartered in 'GB'. - -10. Filter by multiple comma-separated countries (client-side `_in` implies OR for values): - - URL: `/api/v1/data?model=source&countries=US,GB` - - Expected: Sources headquartered in 'US' OR 'GB'. - -11. Filter by single `sourceType`: - - URL: `/api/v1/data?model=source&sourceTypes=blog` - - Expected: Sources of type 'blog'. - -12. Filter by multiple comma-separated `sourceTypes` (client-side `_in` implies OR for values): - - URL: `/api/v1/data?model=source&sourceTypes=blog,specializedPublisher` - - Expected: Sources of type 'blog' OR 'specializedPublisher'. - -13. Filter by single `language`: - - URL: `/api/v1/data?model=source&languages=en` - - Expected: Sources in 'en' language. - -14. Filter by combination (countries AND sourceTypes AND languages): - - URL: `/api/v1/data?model=source&countries=GB&sourceTypes=nationalNewsOutlet&languages=en` - - Expected: Sources matching all three criteria. - -15. Filter by text query `q` for sources (name only): - - URL: `/api/v1/data?model=source&q=Ventures` - - Expected: Sources where "Ventures" appears in the name. - -16. Filter by `q` AND `countries` for sources (`q` takes precedence): - - URL: `/api/v1/data?model=source&q=Official&countries=US` - - Expected: Sources matching `q=Official` (in name), ignoring the country filter. - -17. Invalid parameter for sources (e.g., `topics`): - - URL: `/api/v1/data?model=source&topics=topicId1` - - Expected: `400 Bad Request`. - -Model: `topic` - -18. Filter by text query `q` for categories (name only): - - URL: `/api/v1/data?model=topic&q=Mobile` - - Expected: Topics where "Mobile" appears in name. - -19. Invalid parameter for categories (e.g., `sources`): - - URL: `/api/v1/data?model=topic&sources=sourceId1` - - Expected: `400 Bad Request`. - -Model: `country` - -20. Filter by text query `q` for countries (name and iso_code): - - URL: `/api/v1/data?model=country&q=United` - - Expected: Countries where "United" appears in the name. - -21. Filter by text query `q` for countries (name and iso_code): - - URL: `/api/v1/data?model=country&q=US` - - Expected: Countries where "US" appears in the name OR the isoCode. - -22. Invalid parameter for countries (e.g., `categories`): - - URL: `/api/v1/data?model=country&topics=topicId1` - - Expected: `400 Bad Request`. -*/ +} \ No newline at end of file