diff --git a/.env.example b/.env.example index 171a774..80e246b 100644 --- a/.env.example +++ b/.env.example @@ -47,8 +47,13 @@ # OPTIONAL: Window for the /auth/request-code endpoint, in hours. # RATE_LIMIT_REQUEST_CODE_WINDOW_HOURS=24 -# OPTIONAL: Limit for the generic /data API endpoints (requests per window). -# RATE_LIMIT_DATA_API_LIMIT=1000 - -# OPTIONAL: Window for the /data API endpoints, in minutes. -# RATE_LIMIT_DATA_API_WINDOW_MINUTES=60 +# OPTIONAL: Rate limit for general READ operations (e.g., GET /headlines). +# RATE_LIMIT_READ_LIMIT=500 +# OPTIONAL: Window for READ operations, in minutes. +# RATE_LIMIT_READ_WINDOW_MINUTES=60 + +# OPTIONAL: Rate limit for general WRITE operations (e.g., POST /headlines). +# This is typically stricter than the read limit. +# RATE_LIMIT_WRITE_LIMIT=50 +# OPTIONAL: Window for WRITE operations, in minutes. +# RATE_LIMIT_WRITE_WINDOW_MINUTES=60 diff --git a/README.md b/README.md index 3a4ebe4..0318212 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ This API server comes packed with all the features you need to launch a professi > **Your Advantage:** Deliver a seamless, personalized experience that keeps users' settings in sync, boosting engagement and retention. ❤️ #### 💾 **Robust Data Management API** -* Securely manage all your core news data, including headlines, topics, sources, and countries. +* Leverages a clean, RESTful architecture with dedicated endpoints for each resource (headlines, topics, sources, etc.), following industry best practices. * The API supports flexible querying, filtering, and sorting, allowing your app to display dynamic content feeds. > **Your Advantage:** A powerful and secure data backend that's ready to scale with your content needs. 📈 diff --git a/lib/src/config/environment_config.dart b/lib/src/config/environment_config.dart index 81147df..281d106 100644 --- a/lib/src/config/environment_config.dart +++ b/lib/src/config/environment_config.dart @@ -157,19 +157,35 @@ abstract final class EnvironmentConfig { return Duration(hours: hours); } - /// Retrieves the request limit for the data API endpoints. + /// Retrieves the request limit for READ operations. /// - /// Defaults to 1000 if not set or if parsing fails. - static int get rateLimitDataApiLimit { - return int.tryParse(_env['RATE_LIMIT_DATA_API_LIMIT'] ?? '1000') ?? 1000; + /// Defaults to 5000 if not set or if parsing fails. + static int get rateLimitReadLimit { + return int.tryParse(_env['RATE_LIMIT_READ_LIMIT'] ?? '500') ?? 500; } - /// Retrieves the time window for the data API rate limit. + /// Retrieves the time window for the READ operations rate limit. /// /// Defaults to 60 minutes if not set or if parsing fails. - static Duration get rateLimitDataApiWindow { + static Duration get rateLimitReadWindow { final minutes = - int.tryParse(_env['RATE_LIMIT_DATA_API_WINDOW_MINUTES'] ?? '60') ?? 60; + int.tryParse(_env['RATE_LIMIT_READ_WINDOW_MINUTES'] ?? '60') ?? 60; + return Duration(minutes: minutes); + } + + /// Retrieves the request limit for WRITE operations. + /// + /// Defaults to 500 if not set or if parsing fails. + static int get rateLimitWriteLimit { + return int.tryParse(_env['RATE_LIMIT_WRITE_LIMIT'] ?? '50') ?? 50; + } + + /// Retrieves the time window for the WRITE operations rate limit. + /// + /// Defaults to 60 minutes if not set or if parsing fails. + static Duration get rateLimitWriteWindow { + final minutes = + int.tryParse(_env['RATE_LIMIT_WRITE_WINDOW_MINUTES'] ?? '60') ?? 60; return Duration(minutes: minutes); } } diff --git a/lib/src/middlewares/authorization_middleware.dart b/lib/src/middlewares/authorization_middleware.dart index c89fb24..caa4af6 100644 --- a/lib/src/middlewares/authorization_middleware.dart +++ b/lib/src/middlewares/authorization_middleware.dart @@ -1,27 +1,24 @@ import 'package:core/core.dart'; import 'package:dart_frog/dart_frog.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission_service.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/registry/model_registry.dart'; import 'package:logging/logging.dart'; final _log = Logger('AuthorizationMiddleware'); /// {@template authorization_middleware} -/// Middleware to enforce role-based permissions and model-specific access rules. +/// Middleware to enforce role-based permissions. /// -/// This middleware reads the authenticated [User], the requested `modelName`, -/// the `HttpMethod`, and the `ModelConfig` from the request context. It then -/// determines the required permission based on the `ModelConfig` metadata for -/// the specific HTTP method and checks if the authenticated user has that +/// This middleware reads the authenticated [User] and a required `permission` +/// string from the request context. It then checks if the user has that /// permission using the [PermissionService]. /// +/// The required permission string must be provided into the context by an +/// earlier middleware, typically one specific to the route group. +/// /// If the user does not have the required permission, it throws a -/// [ForbiddenException], which should be caught by the 'errorHandler' middleware. +/// [ForbiddenException], which should be caught by the `errorHandler` middleware. /// -/// This middleware runs *after* authentication and model validation. -/// It does NOT perform instance-level ownership checks; those are handled -/// by the route handlers (`index.dart`, `[id].dart`) if required by the -/// `ModelActionPermission.requiresOwnershipCheck` flag. +/// This middleware runs *after* authentication. /// {@endtemplate} Middleware authorizationMiddleware() { return (handler) { @@ -30,90 +27,22 @@ Middleware authorizationMiddleware() { // User is guaranteed non-null by requireAuthentication() middleware. final user = context.read(); final permissionService = context.read(); - final modelName = context.read(); // Provided by data/_middleware - final modelConfig = context - .read>(); // Provided by data/_middleware - final method = context.request.method; - - // Determine if the request is for the collection or an item - // The collection path is /api/v1/data - // Item paths are /api/v1/data/[id] - final isCollectionRequest = context.request.uri.path == '/api/v1/data'; + final permission = context.read(); - // Determine the required permission configuration based on the HTTP method - ModelActionPermission requiredPermissionConfig; - switch (method) { - case HttpMethod.get: - // Differentiate GET based on whether it's a collection or item request - if (isCollectionRequest) { - requiredPermissionConfig = modelConfig.getCollectionPermission; - } else { - requiredPermissionConfig = modelConfig.getItemPermission; - } - case HttpMethod.post: - requiredPermissionConfig = modelConfig.postPermission; - case HttpMethod.put: - requiredPermissionConfig = modelConfig.putPermission; - case HttpMethod.delete: - requiredPermissionConfig = modelConfig.deletePermission; - default: - // Should ideally be caught earlier by Dart Frog's routing, - // but as a safeguard, deny unsupported methods. - throw const ForbiddenException( - 'Method not supported for this resource.', - ); + if (!permissionService.hasPermission(user, permission)) { + _log.warning( + 'User ${user.id} denied access to permission "$permission".', + ); + throw const ForbiddenException( + 'You do not have permission to perform this action.', + ); } - // Perform the permission check based on the configuration type - switch (requiredPermissionConfig.type) { - case RequiredPermissionType.none: - // No specific permission required (beyond authentication if applicable) - // This case is primarily for documentation/completeness if a route - // group didn't require authentication, but the /data route does. - // For the /data route, 'none' effectively means 'authenticated users allowed'. - break; - case RequiredPermissionType.adminOnly: - // Requires the user to be an admin - if (!permissionService.isAdmin(user)) { - throw const ForbiddenException( - 'Only administrators can perform this action.', - ); - } - case RequiredPermissionType.specificPermission: - // Requires a specific permission string - final permission = requiredPermissionConfig.permission; - if (permission == null) { - // This indicates a configuration error in ModelRegistry - _log.severe( - 'Configuration Error: specificPermission type requires a ' - 'permission string for model "$modelName", method "$method".', - ); - throw const OperationFailedException( - 'Internal Server Error: Authorization configuration error.', - ); - } - if (!permissionService.hasPermission(user, permission)) { - throw const ForbiddenException( - 'You do not have permission to perform this action.', - ); - } - case RequiredPermissionType.unsupported: - // This action is explicitly marked as not supported via this generic route. - // Return Method Not Allowed. - _log.warning( - 'Action for model "$modelName", method "$method" is marked as ' - 'unsupported via generic route.', - ); - // Throw ForbiddenException to be caught by the errorHandler - throw ForbiddenException( - 'Method "$method" is not supported for model "$modelName" ' - 'via this generic data endpoint.', - ); - } + _log.finer( + 'User ${user.id} granted access to permission "$permission".', + ); - // If all checks pass, proceed to the next handler in the chain. - // Instance-level ownership checks (if requiredPermissionConfig.requiresOwnershipCheck is true) - // are handled by the route handlers themselves. + // If the check passes, proceed to the next handler. return handler(context); }; }; diff --git a/lib/src/middlewares/configured_rate_limiter.dart b/lib/src/middlewares/configured_rate_limiter.dart new file mode 100644 index 0000000..b543541 --- /dev/null +++ b/lib/src/middlewares/configured_rate_limiter.dart @@ -0,0 +1,76 @@ +import 'package:core/core.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/config/environment_config.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/rate_limiter_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permissions.dart'; + +/// A key extractor that uses the authenticated user's ID. +/// +/// This should be used for routes that are protected by authentication, +/// ensuring that the rate limit is applied on a per-user basis. +Future _userKeyExtractor(RequestContext context) async { + return context.read().id; +} + +/// A role-aware middleware factory that applies a rate limit only if the +/// authenticated user does not have the `rateLimiting.bypass` permission. +Middleware _createRoleAwareRateLimiter({ + required int limit, + required Duration window, + required Future Function(RequestContext) keyExtractor, +}) { + return (handler) { + return (context) { + // Read dependencies from the context. + final permissionService = context.read(); + final user = context.read(); // Assumes user is authenticated + + // Check for the bypass permission. + if (permissionService.hasPermission(user, Permissions.rateLimitingBypass)) { + // If the user has the bypass permission, skip the rate limiter. + return handler(context); + } + + // If the user does not have the bypass permission, apply the rate limiter. + return rateLimiter( + limit: limit, + window: window, + keyExtractor: keyExtractor, + )(handler)(context); + }; + }; +} + +/// Creates a pre-configured, role-aware rate limiter for READ operations. +/// +/// This middleware will: +/// 1. Check if the authenticated user has the `rateLimiting.bypass` permission. +/// If so, the check is skipped. +/// 2. If not, it applies the rate limit defined by `RATE_LIMIT_READ_LIMIT` +/// and `RATE_LIMIT_READ_WINDOW_MINUTES` from the environment. +/// 3. It uses the authenticated user's ID as the key for the rate limit. +Middleware createReadRateLimiter() { + return _createRoleAwareRateLimiter( + limit: EnvironmentConfig.rateLimitReadLimit, + window: EnvironmentConfig.rateLimitReadWindow, + keyExtractor: _userKeyExtractor, + ); +} + +/// Creates a pre-configured, role-aware rate limiter for WRITE operations. +/// +/// This middleware will: +/// 1. Check if the authenticated user has the `rateLimiting.bypass` permission. +/// If so, the check is skipped. +/// 2. If not, it applies the stricter rate limit defined by +/// `RATE_LIMIT_WRITE_LIMIT` and `RATE_LIMIT_WRITE_WINDOW_MINUTES` from +/// the environment. +/// 3. It uses the authenticated user's ID as the key for the rate limit. +Middleware createWriteRateLimiter() { + return _createRoleAwareRateLimiter( + limit: EnvironmentConfig.rateLimitWriteLimit, + window: EnvironmentConfig.rateLimitWriteWindow, + keyExtractor: _userKeyExtractor, + ); +} diff --git a/lib/src/middlewares/ownership_check_middleware.dart b/lib/src/middlewares/ownership_check_middleware.dart index 5e0cfda..6db5777 100644 --- a/lib/src/middlewares/ownership_check_middleware.dart +++ b/lib/src/middlewares/ownership_check_middleware.dart @@ -1,103 +1,41 @@ import 'package:core/core.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:data_repository/data_repository.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission_service.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/registry/model_registry.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. +/// resource. /// -/// This middleware is designed to run on item-specific routes (e.g., `/[id]`). -/// It performs the following steps: +/// This middleware is designed to run on item-specific routes where the last +/// path segment is the resource ID (e.g., `/users/[id]`). /// -/// 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() { +/// It performs the following steps: +/// 1. Checks if the authenticated user is an admin. If so, access is granted +/// immediately. +/// 2. If the user is not an admin, it compares the authenticated user's ID +/// with the resource ID from the URL path. +/// 3. If the IDs do not match, it throws a [ForbiddenException]. +/// 4. If the check passes, it proceeds to the next handler. +Middleware userOwnershipMiddleware() { return (handler) { - return (context) async { - final modelName = context.read(); - final modelConfig = context.read>(); + return (context) { 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); - } + final resourceId = context.request.uri.pathSegments.last; - // 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)) { + // Admins can access any user's resources. + if (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) { + // For non-admins, the user's ID must match the resource ID in the path. + if (user.id != resourceId) { throw const ForbiddenException( - 'You do not have permission to access this item.', + 'You do not have permission to access this resource.', ); } - final updatedContext = context.provide>( - () => FetchedItem(item), - ); - - return handler(updatedContext); + // If the check passes, proceed to the next handler. + return handler(context); }; }; } diff --git a/lib/src/providers/countries_client_provider.dart b/lib/src/providers/countries_client_provider.dart deleted file mode 100644 index 36747ed..0000000 --- a/lib/src/providers/countries_client_provider.dart +++ /dev/null @@ -1,39 +0,0 @@ -// Dart Frog Dependency Injection Pattern: Individual Providers -// -// This directory (`lib/src/providers`) and files like this one demonstrate -// a common pattern in Dart Frog for providing dependencies using dedicated -// middleware for each specific dependency (e.g., a client or repository). -// -// Example (Conceptual - Code Removed): -// ```dart -// // Middleware countriesClientProvider() { -// // final HtCountriesClient client = HtCountriesInMemoryClient(); -// // return provider((_) => client); -// // } -// ``` -// This middleware would then be `.use()`d in a relevant `_middleware.dart` file. -// -// --- Why This Pattern Isn't Used for Core Data Models in THIS Project --- -// -// While the individual provider pattern is valid, this specific project uses a -// slightly different approach for its main data models (Headline, Category, etc.) -// to support the generic `/api/v1/data` endpoint. -// -// Instead of individual provider middleware files here: -// 1. Instances of the core data repositories (`DataRepository`, -// `DataRepository`, etc.) are created and provided directly -// within the top-level `routes/_middleware.dart` file. -// 2. A `modelRegistry` (`lib/src/registry/model_registry.dart`) is used in -// conjunction with middleware at `routes/api/v1/data/_middleware.dart` to -// dynamically determine which model and repository to use based on the -// `?model=` query parameter in requests to `/api/v1/data`. -// -// This centralized approach in `routes/_middleware.dart` and the use of the -// registry were chosen to facilitate the generic nature of the `/api/v1/data` -// endpoint. -// -// This `providers` directory is kept primarily as a reference to the standard -// individual provider pattern or for potential future use with dependencies -// ignore_for_file: lines_longer_than_80_chars - -// that don't fit the generic data model structure. diff --git a/lib/src/registry/model_registry.dart b/lib/src/registry/model_registry.dart deleted file mode 100644 index 0c7ad7a..0000000 --- a/lib/src/registry/model_registry.dart +++ /dev/null @@ -1,369 +0,0 @@ -// ignore_for_file: comment_references - -import 'package:core/core.dart'; -import 'package:dart_frog/dart_frog.dart'; -import 'package:data_client/data_client.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permissions.dart'; - -/// Defines the type of permission check required for a specific action. -enum RequiredPermissionType { - /// No specific permission check is required (e.g., public access). - /// Note: This assumes the parent route group middleware allows unauthenticated - /// access if needed. The /data route requires authentication by default. - none, - - /// Requires the user to have the [UserRole.admin] role. - adminOnly, - - /// Requires the user to have a specific permission string. - specificPermission, - - /// This action is not supported via this generic route. - /// It is typically handled by a dedicated service or route. - unsupported, -} - -/// Configuration for the authorization requirements of a single HTTP method -/// on a data model. -class ModelActionPermission { - /// {@macro model_action_permission} - const ModelActionPermission({ - required this.type, - this.permission, - this.requiresOwnershipCheck = false, - }) : assert( - type != RequiredPermissionType.specificPermission || - permission != null, - 'Permission string must be provided for specificPermission type', - ); - - /// The type of permission check required. - final RequiredPermissionType type; - - /// The specific permission string required if [type] is - /// [RequiredPermissionType.specificPermission]. - final String? permission; - - /// Whether an additional check is required to verify the authenticated user - /// is the owner of the specific data item being accessed (for item-specific - /// methods like GET, PUT, DELETE on `/[id]`). - final bool requiresOwnershipCheck; -} - -/// {@template model_config} -/// Configuration holder for a specific data model type [T]. -/// -/// This class encapsulates the type-specific operations (like deserialization -/// from JSON, ID extraction, and owner ID extraction) and authorization -/// requirements needed by the generic `/api/v1/data` endpoint handlers and -/// middleware. It allows those handlers to work with different data models -/// without needing explicit type checks for these common operations. -/// -/// An instance of this config is looked up via the [modelRegistry] based on the -/// `?model=` query parameter provided in the request. -/// {@endtemplate} -class ModelConfig { - /// {@macro model_config} - const ModelConfig({ - required this.fromJson, - required this.getId, - required this.getCollectionPermission, - required this.getItemPermission, - required this.postPermission, - required this.putPermission, - required this.deletePermission, - this.getOwnerId, // Optional: Function to get owner ID for user-owned models - }); - - /// Function to deserialize JSON into an object of type [T]. - final FromJson fromJson; - - /// Function to extract the unique string ID from an item of type [T]. - final String Function(T item) getId; - - /// Optional function to extract the unique string ID of the owner from an - /// item of type [T]. Required for models where `requiresOwnershipCheck` - /// is true for any action. - final String? Function(T item)? getOwnerId; - - /// Authorization configuration for GET requests to the collection endpoint. - final ModelActionPermission getCollectionPermission; - - /// Authorization configuration for GET requests to a specific item endpoint. - final ModelActionPermission getItemPermission; - - /// Authorization configuration for POST requests. - final ModelActionPermission postPermission; - - /// Authorization configuration for PUT requests. - final ModelActionPermission putPermission; - - /// Authorization configuration for DELETE requests. - final ModelActionPermission deletePermission; -} - -/// {@template model_registry} -/// Central registry mapping model name strings (used in the `?model=` query parameter) -/// to their corresponding [ModelConfig] instances. -/// -/// This registry is the core component enabling the generic `/api/v1/data` endpoint. -/// The middleware (`routes/api/v1/data/_middleware.dart`) uses this map to: -/// 1. Validate the `model` query parameter provided by the client. -/// 2. Retrieve the correct [ModelConfig] containing type-specific functions -/// (like `fromJson`, `getOwnerId`) and authorization metadata needed by the -/// generic route handlers (`index.dart`, `[id].dart`) and authorization middleware. -/// -/// While individual repositories (`DataRepository`, etc.) are provided -/// directly in the main `routes/_middleware.dart`, this registry provides the -/// *metadata* needed to work with those repositories generically based on the -/// request's `model` parameter. -/// {@endtemplate} -final modelRegistry = >{ - 'headline': ModelConfig( - fromJson: Headline.fromJson, - getId: (h) => h.id, - // Headlines: Admin-owned, read allowed by standard/guest users - getCollectionPermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.headlineRead, - ), - getItemPermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.headlineRead, - ), - postPermission: const ModelActionPermission( - type: RequiredPermissionType.adminOnly, - ), - putPermission: const ModelActionPermission( - type: RequiredPermissionType.adminOnly, - ), - deletePermission: const ModelActionPermission( - type: RequiredPermissionType.adminOnly, - ), - ), - 'topic': ModelConfig( - fromJson: Topic.fromJson, - getId: (t) => t.id, - // Topics: Admin-owned, read allowed by standard/guest users - getCollectionPermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.topicRead, - ), - getItemPermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.topicRead, - ), - postPermission: const ModelActionPermission( - type: RequiredPermissionType.adminOnly, - ), - putPermission: const ModelActionPermission( - type: RequiredPermissionType.adminOnly, - ), - deletePermission: const ModelActionPermission( - type: RequiredPermissionType.adminOnly, - ), - ), - 'source': ModelConfig( - fromJson: Source.fromJson, - getId: (s) => s.id, - // Sources: Admin-owned, read allowed by standard/guest users - getCollectionPermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.sourceRead, - ), - getItemPermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.sourceRead, - ), - postPermission: const ModelActionPermission( - type: RequiredPermissionType.adminOnly, - ), - putPermission: const ModelActionPermission( - type: RequiredPermissionType.adminOnly, - ), - deletePermission: const ModelActionPermission( - type: RequiredPermissionType.adminOnly, - ), - ), - 'country': ModelConfig( - fromJson: Country.fromJson, - getId: (c) => c.id, - // Countries: Static data, read-only for all authenticated users. - // Modification is not allowed via the API as this is real-world data - // managed by database seeding. - getCollectionPermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.countryRead, - ), - getItemPermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.countryRead, - ), - postPermission: const ModelActionPermission(type: RequiredPermissionType.unsupported), - putPermission: const ModelActionPermission(type: RequiredPermissionType.unsupported), - deletePermission: const ModelActionPermission(type: RequiredPermissionType.unsupported), - ), - 'language': ModelConfig( - fromJson: Language.fromJson, - getId: (l) => l.id, - // Languages: Static data, read-only for all authenticated users. - // Modification is not allowed via the API as this is real-world data - // managed by database seeding. - getCollectionPermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.languageRead, - ), - getItemPermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.languageRead, - ), - postPermission: const ModelActionPermission( - type: RequiredPermissionType.unsupported, - ), - putPermission: const ModelActionPermission( - type: RequiredPermissionType.unsupported, - ), - deletePermission: const ModelActionPermission( - type: RequiredPermissionType.unsupported, - ), - ), - 'user': ModelConfig( - fromJson: User.fromJson, - getId: (u) => u.id, - getOwnerId: (dynamic item) => - (item as User).id as String?, // User is the owner of their profile - getCollectionPermission: const ModelActionPermission( - type: RequiredPermissionType.adminOnly, // Only admin can list all users - ), - getItemPermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.userReadOwned, // User can read their own - requiresOwnershipCheck: true, // Must be the owner - ), - postPermission: const ModelActionPermission( - type: RequiredPermissionType - .unsupported, // User creation handled by auth routes - ), - putPermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.userUpdateOwned, // User can update their own - requiresOwnershipCheck: true, // Must be the owner - ), - deletePermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.userDeleteOwned, // User can delete their own - requiresOwnershipCheck: true, // Must be the owner - ), - ), - 'user_app_settings': ModelConfig( - fromJson: UserAppSettings.fromJson, - getId: (s) => s.id, - getOwnerId: (dynamic item) => - (item as UserAppSettings).id as String?, // User ID is the owner ID - getCollectionPermission: const ModelActionPermission( - type: RequiredPermissionType.unsupported, // Not accessible via collection - ), - getItemPermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.userAppSettingsReadOwned, - requiresOwnershipCheck: true, - ), - postPermission: const ModelActionPermission( - type: RequiredPermissionType.unsupported, - // Creation of UserAppSettings is handled by the authentication service - // during user creation, not via a direct POST to /api/v1/data. - ), - putPermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.userAppSettingsUpdateOwned, - requiresOwnershipCheck: true, - ), - deletePermission: const ModelActionPermission( - type: RequiredPermissionType.unsupported, - // Deletion of UserAppSettings is handled by the authentication service - // during account deletion, not via a direct DELETE to /api/v1/data. - ), - ), - 'user_content_preferences': ModelConfig( - fromJson: UserContentPreferences.fromJson, - getId: (p) => p.id, - getOwnerId: (dynamic item) => - (item as UserContentPreferences).id - as String?, // User ID is the owner ID - getCollectionPermission: const ModelActionPermission( - type: RequiredPermissionType.unsupported, // Not accessible via collection - ), - getItemPermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.userContentPreferencesReadOwned, - requiresOwnershipCheck: true, - ), - postPermission: const ModelActionPermission( - type: RequiredPermissionType.unsupported, - // Creation of UserContentPreferences is handled by the authentication - // service during user creation, not via a direct POST to /api/v1/data. - ), - putPermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.userContentPreferencesUpdateOwned, - requiresOwnershipCheck: true, - ), - deletePermission: const ModelActionPermission( - type: RequiredPermissionType.unsupported, - // Deletion of UserContentPreferences is handled by the authentication - // service during account deletion, not via a direct DELETE to /api/v1/data. - ), - ), - 'remote_config': ModelConfig( - fromJson: RemoteConfig.fromJson, - getId: (config) => config.id, - getOwnerId: null, // RemoteConfig is a global resource, not user-owned - getCollectionPermission: const ModelActionPermission( - type: RequiredPermissionType.unsupported, // Not accessible via collection - ), - getItemPermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.remoteConfigRead, - ), - postPermission: const ModelActionPermission( - type: RequiredPermissionType.adminOnly, // Only administrators can create - ), - putPermission: const ModelActionPermission( - type: RequiredPermissionType.adminOnly, // Only administrators can update - ), - deletePermission: const ModelActionPermission( - type: RequiredPermissionType.adminOnly, // Only administrators can delete - ), - ), - 'dashboard_summary': ModelConfig( - fromJson: DashboardSummary.fromJson, - getId: (summary) => summary.id, - getOwnerId: null, // Not a user-owned resource - // Permissions: Read-only for admins, all other actions unsupported. - getCollectionPermission: const ModelActionPermission( - type: RequiredPermissionType.unsupported, - ), - getItemPermission: const ModelActionPermission( - type: RequiredPermissionType.adminOnly, - ), - postPermission: const ModelActionPermission( - type: RequiredPermissionType.unsupported, - ), - putPermission: const ModelActionPermission( - type: RequiredPermissionType.unsupported, - ), - deletePermission: const ModelActionPermission( - type: RequiredPermissionType.unsupported, - ), - ), -}; - -/// Type alias for the ModelRegistry map for easier provider usage. -typedef ModelRegistryMap = Map>; - -/// Dart Frog provider function factory for the entire [modelRegistry]. -/// -/// This makes the `modelRegistry` map available for injection into the -/// request context via `context.read()`. It's primarily -/// used by the middleware in `routes/api/v1/data/_middleware.dart`. -final modelRegistryProvider = provider((_) => modelRegistry); diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 609462d..2889558 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -6,7 +6,6 @@ import 'package:flutter_news_app_api_server_full_source_code/src/config/app_depe import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/error_handler.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/models/request_id.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission_service.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/registry/model_registry.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_token_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; @@ -75,7 +74,6 @@ Handler middleware(Handler handler) { // 2. Provide all dependencies to the inner handler. final deps = AppDependencies.instance; return handler - .use(provider((_) => modelRegistry)) .use( provider>( (_) => deps.headlineRepository, diff --git a/routes/api/v1/countries/[id]/index.dart b/routes/api/v1/countries/[id]/index.dart new file mode 100644 index 0000000..5b2543d --- /dev/null +++ b/routes/api/v1/countries/[id]/index.dart @@ -0,0 +1,30 @@ +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/helpers/response_helper.dart'; + +/// Handles requests for the /api/v1/countries/[id] endpoint. +/// +/// This endpoint supports GET for retrieving a single country. +Future onRequest(RequestContext context, String id) async { + switch (context.request.method) { + case HttpMethod.get: + return _handleGet(context, id); + default: + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +/// Handles GET requests: Retrieves a single country by its ID. +Future _handleGet(RequestContext context, String id) async { + final repo = context.read>(); + final item = await repo.read(id: id); + + return ResponseHelper.success( + context: context, + data: item, + toJsonT: (data) => (data as dynamic).toJson() as Map, + ); +} diff --git a/routes/api/v1/countries/_middleware.dart b/routes/api/v1/countries/_middleware.dart new file mode 100644 index 0000000..d31f332 --- /dev/null +++ b/routes/api/v1/countries/_middleware.dart @@ -0,0 +1,46 @@ +import 'package:dart_frog/dart_frog.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/authentication_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/authorization_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/configured_rate_limiter.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permissions.dart'; + +/// Countries are static data, read-only for all authenticated users. +/// Modification is not allowed via the API as this is real-world data +/// managed by database seeding. This middleware also applies rate limiting. +/// +/// Middleware for the `/api/v1/countries` route. +/// +/// This middleware chain performs the following actions: +/// 1. `requireAuthentication()`: Ensures the user is authenticated. +/// 2. `authorizationMiddleware()`: Checks if the authenticated user has the +/// necessary permission to perform the requested action. +/// 3. The inner middleware provides the specific permission required for the +/// current request to the `authorizationMiddleware`. +Handler middleware(Handler handler) { + return handler + .use( + (handler) => (context) { + final request = context.request; + final String permission; + final Middleware rateLimiter; + + switch (request.method) { + case HttpMethod.get: + permission = Permissions.countryRead; + rateLimiter = createReadRateLimiter(); + default: + // Return 405 Method Not Allowed for unsupported methods. + return Response(statusCode: 405); + } + + // Apply the selected rate limiter and then provide the permission. + return rateLimiter( + (context) => handler( + context.provide(() => permission), + ), + )(context); + }, + ) + .use(authorizationMiddleware()) + .use(requireAuthentication()); +} diff --git a/routes/api/v1/countries/index.dart b/routes/api/v1/countries/index.dart new file mode 100644 index 0000000..4795d71 --- /dev/null +++ b/routes/api/v1/countries/index.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/helpers/response_helper.dart'; + +/// Handles requests for the /api/v1/countries collection endpoint. +/// +/// This endpoint supports GET for retrieving a list of countries. +Future onRequest(RequestContext context) async { + switch (context.request.method) { + case HttpMethod.get: + return _handleGet(context); + default: + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +/// Handles GET requests: Retrieves a collection of countries. +/// +/// Supports filtering, sorting, and pagination. +Future _handleGet(RequestContext context) async { + final params = context.request.uri.queryParameters; + + 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', + ); + } + } + + 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 const BadRequestException( + 'Invalid "sort" parameter format. Use "field:order,field2:order".', + ); + } + } + + PaginationOptions? pagination; + if (params.containsKey('limit') || params.containsKey('cursor')) { + final limit = int.tryParse(params['limit'] ?? ''); + pagination = PaginationOptions(cursor: params['cursor'], limit: limit); + } + + final repo = context.read>(); + final responseData = await repo.readAll( + filter: filter, + sort: sort, + pagination: pagination, + ); + + return ResponseHelper.success( + context: context, + data: responseData, + toJsonT: (paginated) => (paginated as PaginatedResponse).toJson( + (item) => (item as dynamic).toJson() as Map, + ), + ); +} diff --git a/routes/api/v1/dashboard/summary/_middleware.dart b/routes/api/v1/dashboard/summary/_middleware.dart new file mode 100644 index 0000000..77d4b58 --- /dev/null +++ b/routes/api/v1/dashboard/summary/_middleware.dart @@ -0,0 +1,26 @@ +import 'package:dart_frog/dart_frog.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/authentication_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/authorization_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permissions.dart'; + +/// Middleware for the `/api/v1/dashboard/summary` route. +/// +/// This middleware chain ensures that only authenticated administrators +/// can access this route. +Handler middleware(Handler handler) { + return handler + .use( + (handler) => (context) { + // This endpoint only supports GET. + if (context.request.method != HttpMethod.get) { + return Response(statusCode: 405); + } + // Provide the required permission to the authorization middleware. + return handler( + context.provide(() => Permissions.dashboardLogin), + ); + }, + ) + .use(authorizationMiddleware()) + .use(requireAuthentication()); +} diff --git a/routes/api/v1/dashboard/summary/index.dart b/routes/api/v1/dashboard/summary/index.dart new file mode 100644 index 0000000..5476210 --- /dev/null +++ b/routes/api/v1/dashboard/summary/index.dart @@ -0,0 +1,25 @@ +import 'dart:io'; + +import 'package:dart_frog/dart_frog.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/helpers/response_helper.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; + +/// Handles requests for the /api/v1/dashboard/summary endpoint. +Future onRequest(RequestContext context) async { + if (context.request.method == HttpMethod.get) { + return _handleGet(context); + } + return Response(statusCode: HttpStatus.methodNotAllowed); +} + +/// Handles GET requests: Retrieves the dashboard summary. +Future _handleGet(RequestContext context) async { + final summaryService = context.read(); + final summary = await summaryService.getSummary(); + + return ResponseHelper.success( + context: context, + data: summary, + toJsonT: (data) => data.toJson(), + ); +} diff --git a/routes/api/v1/data/[id]/_middleware.dart b/routes/api/v1/data/[id]/_middleware.dart deleted file mode 100644 index 9f5b218..0000000 --- a/routes/api/v1/data/[id]/_middleware.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:dart_frog/dart_frog.dart'; -import 'package:flutter_news_app_api_server_full_source_code/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]/index.dart b/routes/api/v1/data/[id]/index.dart deleted file mode 100644 index 96b9ed0..0000000 --- a/routes/api/v1/data/[id]/index.dart +++ /dev/null @@ -1,574 +0,0 @@ -import 'dart:io'; - -import 'package:core/core.dart'; -import 'package:dart_frog/dart_frog.dart'; -import 'package:data_repository/data_repository.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/helpers/response_helper.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission_service.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/registry/model_registry.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/services/user_preference_limit_service.dart'; // Import UserPreferenceLimitService -import 'package:logging/logging.dart'; - -// Create a logger for this file. -final _logger = Logger('data_item_handler'); - -/// Handles requests for the /api/v1/data/[id] endpoint. -/// Dispatches requests to specific handlers based on the HTTP method. -Future onRequest(RequestContext context, String id) async { - // Read dependencies provided by middleware - final modelName = context.read(); - final modelConfig = context.read>(); - // User is guaranteed non-null by requireAuthentication() middleware - final authenticatedUser = context.read(); - final permissionService = context - .read(); // Read PermissionService - // Read the UserPreferenceLimitService (only needed for UserContentPreferences PUT) - final userPreferenceLimitService = context.read(); - - // 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, - id, - modelName, - modelConfig, - authenticatedUser, - permissionService, // Pass PermissionService - ); - case HttpMethod.put: - return _handlePut( - context, - id, - modelName, - modelConfig, - authenticatedUser, - permissionService, // Pass PermissionService - userPreferenceLimitService, // Pass the limit service - ); - case HttpMethod.delete: - return _handleDelete( - context, - id, - modelName, - modelConfig, - authenticatedUser, - permissionService, // Pass PermissionService - ); - default: - // Methods not allowed on the item endpoint - return Response(statusCode: HttpStatus.methodNotAllowed); - } -} - -// --- GET Handler --- -/// Handles GET requests: Retrieves a single item by its ID. -/// Includes request metadata in response. -Future _handleGet( - RequestContext context, - String id, - String modelName, - ModelConfig modelConfig, - User authenticatedUser, - PermissionService permissionService, -) async { - // Authorization check is handled by authorizationMiddleware before this. - // This handler only needs to perform the ownership check if required. - - dynamic item; - - // Determine userId for repository call based on ModelConfig (for data scoping) - String? userIdForRepoCall; - // If the model is user-owned, pass the authenticated user's ID to the repository - // 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 && - !permissionService.isAdmin(authenticatedUser)) { - userIdForRepoCall = authenticatedUser.id; - } else { - userIdForRepoCall = null; - } - - // Repository exceptions (like NotFoundException) will propagate up to the - // main onRequest try/catch (which is now removed, so they go to errorHandler). - switch (modelName) { - case 'headline': - final repo = context.read>(); - item = await repo.read(id: id, userId: userIdForRepoCall); - case 'topic': - final repo = context.read>(); - item = await repo.read(id: id, userId: userIdForRepoCall); - case 'source': - final repo = context.read>(); - item = await repo.read(id: id, userId: userIdForRepoCall); - case 'country': - final repo = context.read>(); - item = await repo.read(id: id, userId: userIdForRepoCall); - case 'language': - final repo = context.read>(); - item = await repo.read(id: id, userId: userIdForRepoCall); - case 'user': // Handle User model specifically if needed, or rely on generic - final repo = context.read>(); - item = await repo.read(id: id, userId: userIdForRepoCall); - case 'user_app_settings': // New case for UserAppSettings - final repo = context.read>(); - item = await repo.read(id: id, userId: userIdForRepoCall); - case 'user_content_preferences': // New case for UserContentPreferences - final repo = context.read>(); - item = await repo.read(id: id, userId: userIdForRepoCall); - case 'remote_config': // New case for RemoteConfig (read by admin) - final repo = context.read>(); - item = await repo.read( - id: id, - userId: userIdForRepoCall, - ); // userId should be null for AppConfig - case 'dashboard_summary': - final service = context.read(); - item = await service.getSummary(); - 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.', - ); - } - - // --- Handler-Level Ownership Check (for GET item) --- - // This check is needed if the ModelConfig for GET item requires ownership - // AND the user is NOT an admin (admins can bypass ownership checks). - if (modelConfig.getItemPermission.requiresOwnershipCheck && - !permissionService.isAdmin(authenticatedUser)) { - // Ensure getOwnerId is provided for models requiring ownership check - if (modelConfig.getOwnerId == null) { - _logger.severe( - 'Configuration Error: Model "$modelName" requires ' - 'ownership check for GET item but getOwnerId is not provided.', - ); - // Throw an exception to be caught by the errorHandler - throw const OperationFailedException( - 'Internal Server Error: Model configuration error.', - ); - } - - final itemOwnerId = modelConfig.getOwnerId!(item); - if (itemOwnerId != authenticatedUser.id) { - // If the authenticated user is not the owner, deny access. - // Throw ForbiddenException to be caught by the errorHandler - throw const ForbiddenException( - 'You do not have permission to access this specific item.', - ); - } - } - - return ResponseHelper.success( - context: context, - data: item, - toJsonT: (data) => (data as dynamic).toJson() as Map, - ); -} - -// --- PUT Handler --- -/// Handles PUT requests: Updates an existing item by its ID. -/// Includes request metadata in response. -Future _handlePut( - RequestContext context, - String id, - String modelName, - ModelConfig modelConfig, - User authenticatedUser, - PermissionService permissionService, // Receive PermissionService - UserPreferenceLimitService - userPreferenceLimitService, // Receive Limit Service -) async { - // Authorization check is handled by authorizationMiddleware before this. - // This handler only needs to perform the ownership check if required. - - 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.'); - } - - // Standardize timestamp before model creation - requestBody['updatedAt'] = DateTime.now().toUtc().toIso8601String(); - - // Deserialize using ModelConfig's fromJson, catching TypeErrors locally - dynamic itemToUpdate; - try { - itemToUpdate = modelConfig.fromJson(requestBody); - } on TypeError catch (e, s) { - // Catch errors during deserialization (e.g., missing required fields) - _logger.warning('Deserialization TypeError in PUT /data/[id]', e, s); - // Throw BadRequestException to be caught by the errorHandler - throw const BadRequestException( - 'Invalid request body: Missing or invalid required field(s).', - ); - } - - // Ensure the ID in the path matches the ID in the request body (if present) - // This is a data integrity check, not an authorization check. - try { - final bodyItemId = modelConfig.getId(itemToUpdate); - if (bodyItemId != id) { - // Throw BadRequestException to be caught by the errorHandler - throw BadRequestException( - 'Bad Request: ID in request body ("$bodyItemId") does not match ID in path ("$id").', - ); - } - } catch (e) { - // Ignore if getId throws, means ID might not be in the body, - // which is acceptable depending on the model/client. - _logger.info('Could not get ID from PUT body: $e'); - } - - // --- Handler-Level Limit Check (for UserContentPreferences PUT) --- - // If the model is UserContentPreferences, check if the proposed update - // exceeds the user's limits before attempting the repository update. - if (modelName == 'user_content_preferences') { - try { - // Ensure the itemToUpdate is the correct type for the limit service - if (itemToUpdate is! UserContentPreferences) { - _logger.severe( - 'Type Error: Expected UserContentPreferences ' - 'for limit check, but got ${itemToUpdate.runtimeType}.', - ); - throw const OperationFailedException( - 'Internal Server Error: Model type mismatch for limit check.', - ); - } - await userPreferenceLimitService.checkUpdatePreferences( - authenticatedUser, - itemToUpdate, - ); - } on HttpException { - // Propagate known exceptions from the limit service (e.g., ForbiddenException) - rethrow; - } catch (e, s) { - // Catch unexpected errors from the limit service - _logger.severe( - 'Unexpected error during limit check for ' - 'UserContentPreferences PUT', - e, - s, - ); - throw const OperationFailedException( - 'An unexpected error occurred during limit check.', - ); - } - } - - // 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 ownership enforcement. Otherwise, pass null. - if (modelConfig.getOwnerId != null && - !permissionService.isAdmin(authenticatedUser)) { - userIdForRepoCall = authenticatedUser.id; - } else { - userIdForRepoCall = null; - } - - dynamic updatedItem; - - // Repository exceptions (like NotFoundException, BadRequestException) - // will propagate up to the errorHandler. - switch (modelName) { - case 'headline': - { - final repo = context.read>(); - updatedItem = await repo.update( - id: id, - item: itemToUpdate as Headline, - userId: userIdForRepoCall, - ); - } - case 'topic': - { - final repo = context.read>(); - updatedItem = await repo.update( - id: id, - item: itemToUpdate as Topic, - userId: userIdForRepoCall, - ); - } - case 'source': - { - final repo = context.read>(); - updatedItem = await repo.update( - id: id, - item: itemToUpdate as Source, - userId: userIdForRepoCall, - ); - } - case 'country': - { - final repo = context.read>(); - updatedItem = await repo.update( - id: id, - item: itemToUpdate as Country, - userId: userIdForRepoCall, - ); - } - case 'language': - { - final repo = context.read>(); - updatedItem = await repo.update( - id: id, - item: itemToUpdate as Language, - userId: userIdForRepoCall, - ); - } - case 'user': - { - final repo = context.read>(); - updatedItem = await repo.update( - id: id, - item: itemToUpdate as User, - userId: userIdForRepoCall, - ); - } - case 'user_app_settings': // New case for UserAppSettings - { - final repo = context.read>(); - updatedItem = await repo.update( - id: id, - item: itemToUpdate as UserAppSettings, - userId: userIdForRepoCall, - ); - } - case 'user_content_preferences': // New case for UserContentPreferences - { - final repo = context.read>(); - updatedItem = await repo.update( - id: id, - item: itemToUpdate as UserContentPreferences, - userId: userIdForRepoCall, - ); - } - case 'remote_config': // New case for RemoteConfig (update by admin) - { - final repo = context.read>(); - updatedItem = await repo.update( - id: id, - item: itemToUpdate as RemoteConfig, - userId: userIdForRepoCall, // userId should be null for AppConfig - ); - } - 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.', - ); - } - - // --- Handler-Level Ownership Check (for PUT) --- - // This check is needed if the ModelConfig for PUT requires ownership - // AND the user is NOT an admin (admins can bypass ownership checks). - // Note: The repository *might* have already enforced ownership if userId was passed. - // This handler-level check provides a second layer of defense and is necessary - // if the repository doesn't fully enforce ownership based on userId alone - // (e.g., if the repo update method allows admins to update any item even if userId is passed). - if (modelConfig.putPermission.requiresOwnershipCheck && - !permissionService.isAdmin(authenticatedUser)) { - // Ensure getOwnerId is provided for models requiring ownership check - if (modelConfig.getOwnerId == null) { - _logger.severe( - 'Configuration Error: Model "$modelName" requires ' - 'ownership check for PUT but getOwnerId is not provided.', - ); - // Throw an exception to be caught by the errorHandler - throw const OperationFailedException( - 'Internal Server Error: Model configuration error.', - ); - } - // Re-fetch the item to ensure we have the owner ID from the source of truth - // after the update, or ideally, the update method returns the item with owner ID. - // Assuming the updatedItem returned by the repo has the owner ID: - final itemOwnerId = modelConfig.getOwnerId!(updatedItem); - if (itemOwnerId != authenticatedUser.id) { - // This scenario should ideally not happen if the repository correctly - // enforced ownership during the update call when userId was passed. - _logger.warning( - 'Ownership check failed AFTER PUT for item $id. ' - 'Item owner: $itemOwnerId, User: ${authenticatedUser.id}', - ); - // Throw ForbiddenException to be caught by the errorHandler - throw const ForbiddenException( - 'You do not have permission to update this specific item.', - ); - } - } - - return ResponseHelper.success( - context: context, - data: updatedItem, - toJsonT: (data) => (data as dynamic).toJson() as Map, - ); -} - -// --- DELETE Handler --- -/// Handles DELETE requests: Deletes an item by its ID. -Future _handleDelete( - RequestContext context, - String id, - String modelName, - ModelConfig modelConfig, - User authenticatedUser, - PermissionService permissionService, -) async { - // Authorization check is handled by authorizationMiddleware before this. - // This handler only needs to perform the ownership check if required. - - // 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 ownership enforcement. Otherwise, pass null. - if (modelConfig.getOwnerId != null && - !permissionService.isAdmin(authenticatedUser)) { - userIdForRepoCall = authenticatedUser.id; - } else { - userIdForRepoCall = null; - } - - // --- Handler-Level Ownership Check (for DELETE) --- - // For DELETE, we need to fetch the item *before* attempting deletion - // to perform the ownership check if required. - dynamic itemToDelete; - if (modelConfig.deletePermission.requiresOwnershipCheck && - !permissionService.isAdmin(authenticatedUser)) { - // Ensure getOwnerId is provided for models requiring ownership check - if (modelConfig.getOwnerId == null) { - _logger.severe( - 'Configuration Error: Model "$modelName" requires ' - 'ownership check for DELETE but getOwnerId is not provided.', - ); - // Throw an exception to be caught by the errorHandler - throw const OperationFailedException( - 'Internal Server Error: Model configuration error.', - ); - } - // Fetch the item to check ownership. Use userIdForRepoCall for scoping. - // Repository exceptions (like NotFoundException) will propagate up to the errorHandler. - switch (modelName) { - case 'headline': - final repo = context.read>(); - itemToDelete = await repo.read(id: id, userId: userIdForRepoCall); - case 'topic': - final repo = context.read>(); - itemToDelete = await repo.read(id: id, userId: userIdForRepoCall); - case 'source': - final repo = context.read>(); - itemToDelete = await repo.read(id: id, userId: userIdForRepoCall); - case 'country': - final repo = context.read>(); - itemToDelete = await repo.read(id: id, userId: userIdForRepoCall); - case 'language': - final repo = context.read>(); - itemToDelete = await repo.read(id: id, userId: userIdForRepoCall); - case 'user': - final repo = context.read>(); - itemToDelete = await repo.read(id: id, userId: userIdForRepoCall); - case 'user_app_settings': // New case for UserAppSettings - final repo = context.read>(); - itemToDelete = await repo.read(id: id, userId: userIdForRepoCall); - case 'user_content_preferences': // New case for UserContentPreferences - final repo = context.read>(); - itemToDelete = await repo.read(id: id, userId: userIdForRepoCall); - case 'remote_config': // New case for RemoteConfig (delete by admin) - final repo = context.read>(); - itemToDelete = await repo.read( - id: id, - userId: userIdForRepoCall, - ); // userId should be null for AppConfig - default: - _logger.severe( - 'Unsupported model type "$modelName" reached _handleDelete ownership check.', - ); - // Throw an exception to be caught by the errorHandler - throw OperationFailedException( - 'Unsupported model type "$modelName" reached handler.', - ); - } - - // Perform the ownership check if the item was found - if (itemToDelete != null) { - final itemOwnerId = modelConfig.getOwnerId!(itemToDelete); - if (itemOwnerId != authenticatedUser.id) { - // If the authenticated user is not the owner, deny access. - // Throw ForbiddenException to be caught by the errorHandler - throw const ForbiddenException( - 'You do not have permission to delete this specific item.', - ); - } - } - // If itemToDelete is null here, it means the item wasn't found during the read. - // The subsequent delete call will likely throw NotFoundException, which is correct. - } - - // Allow repository exceptions (e.g., NotFoundException) to propagate - // upwards to be handled by the standard error handling mechanism. - switch (modelName) { - case 'headline': - await context.read>().delete( - id: id, - userId: userIdForRepoCall, - ); - case 'topic': - await context.read>().delete( - id: id, - userId: userIdForRepoCall, - ); - case 'source': - await context.read>().delete( - id: id, - userId: userIdForRepoCall, - ); - case 'country': - await context.read>().delete( - id: id, - userId: userIdForRepoCall, - ); - case 'language': - await context.read>().delete( - id: id, - userId: userIdForRepoCall, - ); - case 'user': - await context.read>().delete( - id: id, - userId: userIdForRepoCall, - ); - case 'user_app_settings': // New case for UserAppSettings - await context.read>().delete( - id: id, - userId: userIdForRepoCall, - ); - case 'user_content_preferences': // New case for UserContentPreferences - await context.read>().delete( - id: id, - userId: userIdForRepoCall, - ); - case 'remote_config': // New case for RemoteConfig (delete by admin) - await context.read>().delete( - id: id, - userId: userIdForRepoCall, - ); // userId should be null for AppConfig - default: - // This case should ideally be caught by the data/_middleware.dart, - // but added for safety. - _logger.severe( - 'Unsupported model type "$modelName" reached _handleDelete.', - ); - // Throw an exception to be caught by the errorHandler - throw OperationFailedException( - 'Unsupported model type "$modelName" reached handler.', - ); - } - - // Return 204 No Content for successful deletion (no body, no metadata) - return Response(statusCode: HttpStatus.noContent); -} diff --git a/routes/api/v1/data/_middleware.dart b/routes/api/v1/data/_middleware.dart deleted file mode 100644 index c5b67f9..0000000 --- a/routes/api/v1/data/_middleware.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'package:core/core.dart'; -import 'package:dart_frog/dart_frog.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/config/environment_config.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/authentication_middleware.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/authorization_middleware.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/rate_limiter_middleware.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission_service.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permissions.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/registry/model_registry.dart'; - -// Helper middleware for applying rate limiting to the data routes. -Middleware _dataRateLimiterMiddleware() { - return (handler) { - return (context) { - final user = context.read(); - final permissionService = context.read(); - - // Users with the bypass permission are not rate-limited. - if (permissionService.hasPermission( - user, - Permissions.rateLimitingBypass, - )) { - return handler(context); - } - - // For all other users, apply the configured rate limit. - // The key is the user's ID, ensuring the limit is per-user. - final rateLimitHandler = rateLimiter( - limit: EnvironmentConfig.rateLimitDataApiLimit, - window: EnvironmentConfig.rateLimitDataApiWindow, - keyExtractor: (context) async => context.read().id, - )(handler); - - return rateLimitHandler(context); - }; - }; -} - -// Helper middleware for model validation and context provision. -Middleware _modelValidationAndProviderMiddleware() { - return (handler) { - // This 'handler' is the next handler in the chain, - // which, in this setup, is the authorizationMiddleware. - return (context) async { - // --- 1. Read and Validate `model` Parameter --- - final modelName = context.request.uri.queryParameters['model']; - if (modelName == null || modelName.isEmpty) { - // Throw BadRequestException to be caught by the errorHandler - throw const BadRequestException( - 'Missing or empty "model" query parameter.', - ); - } - - // --- 2. Look Up Model Configuration --- - // Read the globally provided registry (from routes/_middleware.dart) - final registry = context.read(); - final modelConfig = registry[modelName]; - - // Further validation: Ensure model exists in the registry - if (modelConfig == null) { - // Throw BadRequestException to be caught by the errorHandler - throw BadRequestException( - 'Invalid model type "$modelName". ' - 'Supported models are: ${registry.keys.join(', ')}.', - ); - } - - // --- 3. Provide Context Downstream --- - final updatedContext = context - .provide>(() => modelConfig) - .provide(() => modelName); - - // Call the next handler in the chain (authorizationMiddleware) - return handler(updatedContext); - }; - }; -} - -// Main middleware exported for the /api/v1/data route group. -Handler middleware(Handler handler) { - // This 'handler' is the actual route handler from index.dart or [id].dart. - // - // The .use() method applies middleware in an "onion-skin" fashion, where - // the last .use() call in the chain represents the outermost middleware layer. - // Therefore, the execution order for an incoming request is: - // - // 1. `requireAuthentication()`: - // - This runs first. It relies on `authenticationProvider()` (from the - // parent `/api/v1/_middleware.dart`) having already attempted to - // authenticate the user and provide `User?` into the context. - // - If `User` is null (no valid authentication), `requireAuthentication()` - // throws an `UnauthorizedException`, and the request is aborted (usually - // resulting in a 401 response via the global `errorHandler`). - // - If `User` is present, the request proceeds to the next middleware. - // - // 2. `_dataRateLimiterMiddleware()`: - // - This runs if `requireAuthentication()` passes. - // - It checks if the user has a bypass permission. If not, it applies - // the configured rate limit based on the user's ID. - // - If the limit is exceeded, it throws a `ForbiddenException`. - // - // 3. `_modelValidationAndProviderMiddleware()`: - // - This runs if rate limiting passes. - // - It validates the `?model=` query parameter and provides the - // `ModelConfig` and `modelName` into the context. - // - If model validation fails, it throws a `BadRequestException`. - // - // 4. `authorizationMiddleware()`: - // - This runs if `_modelValidationAndProviderMiddleware()` passes. - // - It reads the `User`, `modelName`, and `ModelConfig` from the context. - // - It checks if the user has permission to perform the requested HTTP - // method on the specified model based on the `ModelConfig` metadata. - // - If authorization fails, it throws a ForbiddenException, caught by - // the global errorHandler. - // - If successful, it calls the next handler in the chain (the actual - // route handler). - // - // 5. Actual Route Handler (from `index.dart` or `[id].dart`): - // - This runs last, only if all preceding middlewares pass. It will have - // access to a non-null `User`, `ModelConfig`, and `modelName` from the context. - // - It performs the data operation and any necessary handler-level - // ownership checks (if flagged by `ModelActionPermission.requiresOwnershipCheck`). - // - return handler - .use(authorizationMiddleware()) // Applied fourth (inner-most) - .use(_modelValidationAndProviderMiddleware()) // Applied third - .use(_dataRateLimiterMiddleware()) // Applied second - .use(requireAuthentication()); // Applied first (outermost) -} diff --git a/routes/api/v1/data/index.dart b/routes/api/v1/data/index.dart deleted file mode 100644 index 47bba1c..0000000 --- a/routes/api/v1/data/index.dart +++ /dev/null @@ -1,236 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:core/core.dart'; -import 'package:dart_frog/dart_frog.dart'; -import 'package:data_repository/data_repository.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/helpers/response_helper.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission_service.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/registry/model_registry.dart'; -import 'package:mongo_dart/mongo_dart.dart'; - -/// Handles requests for the /api/v1/data collection endpoint. -/// Dispatches requests to specific handlers based on the HTTP method. -Future onRequest(RequestContext context) async { - switch (context.request.method) { - case HttpMethod.get: - return _handleGet(context); - case HttpMethod.post: - return _handlePost(context); - default: - return Response(statusCode: HttpStatus.methodNotAllowed); - } -} - -/// Handles GET requests: Retrieves a collection of items. -/// -/// 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(); - - // --- Parse Query Parameters --- - final params = context.request.uri.queryParameters; - - // 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', - ); - } - } - - // 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 const BadRequestException( - 'Invalid "sort" parameter format. Use "field:order,field2:order".', - ); - } - } - - // 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); - } - - // --- Repository Call --- - final userIdForRepoCall = - (modelConfig.getOwnerId != null && - !context.read().isAdmin(authenticatedUser)) - ? authenticatedUser.id - : null; - - 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>(); - responseData = await repo.readAll( - userId: userIdForRepoCall, - filter: filter, - sort: sort, - pagination: pagination, - ); - case 'topic': - final repo = context.read>(); - responseData = await repo.readAll( - userId: userIdForRepoCall, - filter: filter, - sort: sort, - pagination: pagination, - ); - case 'source': - final repo = context.read>(); - responseData = await repo.readAll( - userId: userIdForRepoCall, - filter: filter, - sort: sort, - pagination: pagination, - ); - case 'country': - final repo = context.read>(); - responseData = await repo.readAll( - userId: userIdForRepoCall, - filter: filter, - sort: sort, - pagination: pagination, - ); - case 'language': - final repo = context.read>(); - responseData = await repo.readAll( - userId: userIdForRepoCall, - filter: filter, - sort: sort, - pagination: pagination, - ); - case 'user': - final repo = context.read>(); - responseData = await repo.readAll( - userId: userIdForRepoCall, - filter: filter, - sort: sort, - pagination: pagination, - ); - default: - throw OperationFailedException( - 'Unsupported model type "$modelName" for GET all.', - ); - } - - return ResponseHelper.success( - context: context, - data: responseData, - toJsonT: (paginated) => (paginated as PaginatedResponse).toJson( - (item) => (item as dynamic).toJson() as Map, - ), - ); -} - -/// 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(); - - // --- Parse Body --- - final requestBody = await context.request.json() as Map?; - if (requestBody == null) { - throw const BadRequestException('Missing or invalid request body.'); - } - - // Standardize ID and timestamps before model creation - final now = DateTime.now().toUtc().toIso8601String(); - requestBody['id'] = ObjectId().oid; - requestBody['createdAt'] = now; - requestBody['updatedAt'] = now; - - dynamic itemToCreate; - try { - itemToCreate = modelConfig.fromJson(requestBody); - } on TypeError catch (e) { - throw BadRequestException( - 'Invalid request body: Missing or invalid required field(s). $e', - ); - } - - // --- Repository Call --- - final userIdForRepoCall = - (modelConfig.getOwnerId != null && - !context.read().isAdmin(authenticatedUser)) - ? authenticatedUser.id - : null; - - dynamic createdItem; - switch (modelName) { - case 'headline': - final repo = context.read>(); - createdItem = await repo.create( - item: itemToCreate as Headline, - userId: userIdForRepoCall, - ); - case 'topic': - final repo = context.read>(); - createdItem = await repo.create( - item: itemToCreate as Topic, - userId: userIdForRepoCall, - ); - case 'source': - final repo = context.read>(); - createdItem = await repo.create( - item: itemToCreate as Source, - userId: userIdForRepoCall, - ); - case 'country': - final repo = context.read>(); - createdItem = await repo.create( - item: itemToCreate as Country, - userId: userIdForRepoCall, - ); - case 'language': - final repo = context.read>(); - createdItem = await repo.create( - item: itemToCreate as Language, - userId: userIdForRepoCall, - ); - case 'remote_config': - final repo = context.read>(); - createdItem = await repo.create( - item: itemToCreate as RemoteConfig, - userId: userIdForRepoCall, - ); - default: - throw OperationFailedException( - 'Unsupported model type "$modelName" for POST.', - ); - } - - return ResponseHelper.success( - context: context, - data: createdItem, - toJsonT: (item) => (item as dynamic).toJson() as Map, - statusCode: HttpStatus.created, - ); -} diff --git a/routes/api/v1/headlines/[id]/index.dart b/routes/api/v1/headlines/[id]/index.dart new file mode 100644 index 0000000..05c7157 --- /dev/null +++ b/routes/api/v1/headlines/[id]/index.dart @@ -0,0 +1,77 @@ +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/helpers/response_helper.dart'; +import 'package:logging/logging.dart'; + +final _logger = Logger('headlines_item_handler'); + +Future onRequest(RequestContext context, String id) async { + switch (context.request.method) { + case HttpMethod.get: + return _handleGet(context, id); + case HttpMethod.put: + return _handlePut(context, id); + case HttpMethod.delete: + return _handleDelete(context, id); + default: + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +Future _handleGet(RequestContext context, String id) async { + final repo = context.read>(); + final item = await repo.read(id: id); + + return ResponseHelper.success( + context: context, + data: item, + toJsonT: (data) => (data as dynamic).toJson() as Map, + ); +} + +Future _handlePut(RequestContext context, String id) async { + final requestBody = await context.request.json() as Map?; + if (requestBody == null) { + throw const BadRequestException('Missing or invalid request body.'); + } + + requestBody['updatedAt'] = DateTime.now().toUtc().toIso8601String(); + + Headline itemToUpdate; + try { + itemToUpdate = Headline.fromJson(requestBody); + } on TypeError catch (e, s) { + _logger.warning('Deserialization TypeError in PUT /headlines/[id]', e, s); + throw const BadRequestException( + 'Invalid request body: Missing or invalid required field(s).', + ); + } + + if (itemToUpdate.id != id) { + throw BadRequestException( + 'Bad Request: ID in request body ("${itemToUpdate.id}") does not match ID in path ("$id").', + ); + } + + final repo = context.read>(); + final updatedItem = await repo.update( + id: id, + item: itemToUpdate, + ); + + return ResponseHelper.success( + context: context, + data: updatedItem, + toJsonT: (data) => (data as dynamic).toJson() as Map, + ); +} + +Future _handleDelete(RequestContext context, String id) async { + final repo = context.read>(); + await repo.delete(id: id); + + return Response(statusCode: HttpStatus.noContent); +} diff --git a/routes/api/v1/headlines/_middleware.dart b/routes/api/v1/headlines/_middleware.dart new file mode 100644 index 0000000..a630c08 --- /dev/null +++ b/routes/api/v1/headlines/_middleware.dart @@ -0,0 +1,45 @@ +import 'package:dart_frog/dart_frog.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/authentication_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/authorization_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/configured_rate_limiter.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permissions.dart'; + +/// Headlines are managed by admins and publishers, but are readable by all +/// authenticated users. This middleware also applies rate limiting. +Handler middleware(Handler handler) { + return handler + .use( + (handler) => (context) { + final request = context.request; + final String permission; + final Middleware rateLimiter; + + switch (request.method) { + case HttpMethod.get: + permission = Permissions.headlineRead; + rateLimiter = createReadRateLimiter(); + case HttpMethod.post: + permission = Permissions.headlineCreate; + rateLimiter = createWriteRateLimiter(); + case HttpMethod.put: + permission = Permissions.headlineUpdate; + rateLimiter = createWriteRateLimiter(); + case HttpMethod.delete: + permission = Permissions.headlineDelete; + rateLimiter = createWriteRateLimiter(); + default: + // Return 405 Method Not Allowed for unsupported methods. + return Response(statusCode: 405); + } + + // Apply the selected rate limiter and then provide the permission. + return rateLimiter( + (context) => handler( + context.provide(() => permission), + ), + )(context); + }, + ) + .use(authorizationMiddleware()) + .use(requireAuthentication()); +} diff --git a/routes/api/v1/headlines/index.dart b/routes/api/v1/headlines/index.dart new file mode 100644 index 0000000..d6f73bb --- /dev/null +++ b/routes/api/v1/headlines/index.dart @@ -0,0 +1,107 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/helpers/response_helper.dart'; +import 'package:mongo_dart/mongo_dart.dart'; + +/// Handles requests for the /api/v1/headlines collection endpoint. +Future onRequest(RequestContext context) async { + switch (context.request.method) { + case HttpMethod.get: + return _handleGet(context); + case HttpMethod.post: + return _handlePost(context); + default: + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +/// Handles GET requests: Retrieves a collection of headlines. +Future _handleGet(RequestContext context) async { + final params = context.request.uri.queryParameters; + + 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', + ); + } + } + + 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 const BadRequestException( + 'Invalid "sort" parameter format. Use "field:order,field2:order".', + ); + } + } + + PaginationOptions? pagination; + if (params.containsKey('limit') || params.containsKey('cursor')) { + final limit = int.tryParse(params['limit'] ?? ''); + pagination = PaginationOptions(cursor: params['cursor'], limit: limit); + } + + final repo = context.read>(); + final responseData = await repo.readAll( + filter: filter, + sort: sort, + pagination: pagination, + ); + + return ResponseHelper.success( + context: context, + data: responseData, + toJsonT: (paginated) => (paginated as PaginatedResponse).toJson( + (item) => (item as dynamic).toJson() as Map, + ), + ); +} + +/// Handles POST requests: Creates a new headline. +Future _handlePost(RequestContext context) async { + final requestBody = await context.request.json() as Map?; + if (requestBody == null) { + throw const BadRequestException('Missing or invalid request body.'); + } + + final now = DateTime.now().toUtc().toIso8601String(); + requestBody['id'] = ObjectId().oid; + requestBody['createdAt'] = now; + requestBody['updatedAt'] = now; + + Headline itemToCreate; + try { + itemToCreate = Headline.fromJson(requestBody); + } on TypeError catch (e) { + throw BadRequestException( + 'Invalid request body: Missing or invalid required field(s). $e', + ); + } + + final repo = context.read>(); + final createdItem = await repo.create(item: itemToCreate); + + return ResponseHelper.success( + context: context, + data: createdItem, + toJsonT: (item) => (item as dynamic).toJson() as Map, + statusCode: HttpStatus.created, + ); +} diff --git a/routes/api/v1/languages/[id]/index.dart b/routes/api/v1/languages/[id]/index.dart new file mode 100644 index 0000000..f4a6be0 --- /dev/null +++ b/routes/api/v1/languages/[id]/index.dart @@ -0,0 +1,30 @@ +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/helpers/response_helper.dart'; + +/// Handles requests for the /api/v1/languages/[id] endpoint. +/// +/// This endpoint supports GET for retrieving a single language. +Future onRequest(RequestContext context, String id) async { + switch (context.request.method) { + case HttpMethod.get: + return _handleGet(context, id); + default: + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +/// Handles GET requests: Retrieves a single language by its ID. +Future _handleGet(RequestContext context, String id) async { + final repo = context.read>(); + final item = await repo.read(id: id); + + return ResponseHelper.success( + context: context, + data: item, + toJsonT: (data) => (data as dynamic).toJson() as Map, + ); +} diff --git a/routes/api/v1/languages/_middleware.dart b/routes/api/v1/languages/_middleware.dart new file mode 100644 index 0000000..405320e --- /dev/null +++ b/routes/api/v1/languages/_middleware.dart @@ -0,0 +1,46 @@ +import 'package:dart_frog/dart_frog.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/authentication_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/authorization_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/configured_rate_limiter.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permissions.dart'; + +/// Languages are static data, read-only for all authenticated users. +/// Modification is not allowed via the API as this is real-world data +/// managed by database seeding. This middleware also applies rate limiting. +/// +/// Middleware for the `/api/v1/languages` route. +/// +/// This middleware chain performs the following actions: +/// 1. `requireAuthentication()`: Ensures the user is authenticated. +/// 2. `authorizationMiddleware()`: Checks if the authenticated user has the +/// necessary permission to perform the requested action. +/// 3. The inner middleware provides the specific permission required for the +/// current request to the `authorizationMiddleware`. +Handler middleware(Handler handler) { + return handler + .use( + (handler) => (context) { + final request = context.request; + final String permission; + final Middleware rateLimiter; + + switch (request.method) { + case HttpMethod.get: + permission = Permissions.languageRead; + rateLimiter = createReadRateLimiter(); + default: + // Return 405 Method Not Allowed for unsupported methods. + return Response(statusCode: 405); + } + + // Apply the selected rate limiter and then provide the permission. + return rateLimiter( + (context) => handler( + context.provide(() => permission), + ), + )(context); + }, + ) + .use(authorizationMiddleware()) + .use(requireAuthentication()); +} diff --git a/routes/api/v1/languages/index.dart b/routes/api/v1/languages/index.dart new file mode 100644 index 0000000..6867e88 --- /dev/null +++ b/routes/api/v1/languages/index.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/helpers/response_helper.dart'; + +/// Handles requests for the /api/v1/languages collection endpoint. +/// +/// This endpoint supports GET for retrieving a list of languages. +Future onRequest(RequestContext context) async { + switch (context.request.method) { + case HttpMethod.get: + return _handleGet(context); + default: + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +/// Handles GET requests: Retrieves a collection of languages. +/// +/// Supports filtering, sorting, and pagination. +Future _handleGet(RequestContext context) async { + final params = context.request.uri.queryParameters; + + 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', + ); + } + } + + 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 const BadRequestException( + 'Invalid "sort" parameter format. Use "field:order,field2:order".', + ); + } + } + + PaginationOptions? pagination; + if (params.containsKey('limit') || params.containsKey('cursor')) { + final limit = int.tryParse(params['limit'] ?? ''); + pagination = PaginationOptions(cursor: params['cursor'], limit: limit); + } + + final repo = context.read>(); + final responseData = await repo.readAll( + filter: filter, + sort: sort, + pagination: pagination, + ); + + return ResponseHelper.success( + context: context, + data: responseData, + toJsonT: (paginated) => (paginated as PaginatedResponse).toJson( + (item) => (item as dynamic).toJson() as Map, + ), + ); +} diff --git a/routes/api/v1/remote-config/_middleware.dart b/routes/api/v1/remote-config/_middleware.dart new file mode 100644 index 0000000..821597d --- /dev/null +++ b/routes/api/v1/remote-config/_middleware.dart @@ -0,0 +1,42 @@ +import 'package:dart_frog/dart_frog.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/authentication_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/authorization_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/configured_rate_limiter.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permissions.dart'; + +/// Middleware for the singleton `/api/v1/remote-config` route. +/// +/// This middleware chain enforces the following access rules and applies rate limiting: +/// - GET: Requires `remoteConfig.read` permission. +/// - PUT: Requires `remoteConfig.update` permission (admin-only). +/// - Other methods (POST, DELETE, etc.) are disallowed. +Handler middleware(Handler handler) { + return handler + .use( + (handler) => (context) { + final request = context.request; + final String permission; + final Middleware rateLimiter; + + switch (request.method) { + case HttpMethod.get: + permission = Permissions.remoteConfigRead; + rateLimiter = createReadRateLimiter(); + case HttpMethod.put: + permission = Permissions.remoteConfigUpdate; + rateLimiter = createWriteRateLimiter(); + default: + // Return 405 Method Not Allowed for unsupported methods. + return Response(statusCode: 405); + } + // Apply the selected rate limiter and then provide the permission. + return rateLimiter( + (context) => handler( + context.provide(() => permission), + ), + )(context); + }, + ) + .use(authorizationMiddleware()) + .use(requireAuthentication()); +} diff --git a/routes/api/v1/remote-config/index.dart b/routes/api/v1/remote-config/index.dart new file mode 100644 index 0000000..99dae14 --- /dev/null +++ b/routes/api/v1/remote-config/index.dart @@ -0,0 +1,76 @@ +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/helpers/response_helper.dart'; +import 'package:logging/logging.dart'; + +// Logger for this handler. +final _logger = Logger('remote_config_handler'); + +// The well-known, constant ID for the singleton remote config document. +const _singletonId = 'default_config'; + +/// Handles requests for the singleton /api/v1/remote-config endpoint. +Future onRequest(RequestContext context) async { + switch (context.request.method) { + case HttpMethod.get: + return _handleGet(context); + case HttpMethod.put: + return _handlePut(context); + default: + // Other methods like POST, DELETE are not allowed on this singleton resource. + // This is also enforced by the middleware. + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +/// Handles GET requests: Retrieves the singleton remote config. +Future _handleGet(RequestContext context) async { + final repo = context.read>(); + // Fetch the single configuration document using the well-known ID. + final item = await repo.read(id: _singletonId); + + return ResponseHelper.success( + context: context, + data: item, + toJsonT: (data) => data.toJson(), + ); +} + +/// Handles PUT requests: Updates/replaces the singleton remote config. +Future _handlePut(RequestContext context) async { + final requestBody = await context.request.json() as Map?; + if (requestBody == null) { + throw const BadRequestException('Missing or invalid request body.'); + } + + // Ensure the updatedAt timestamp is set for the update. + requestBody['updatedAt'] = DateTime.now().toUtc().toIso8601String(); + + RemoteConfig itemToUpdate; + try { + // The ID is always the singleton ID, so we inject it into the body + // before deserialization to ensure the model is valid. + requestBody['id'] = _singletonId; + itemToUpdate = RemoteConfig.fromJson(requestBody); + } on TypeError catch (e, s) { + _logger.warning('Deserialization TypeError in PUT /remote-config', e, s); + throw const BadRequestException( + 'Invalid request body: Missing or invalid required field(s).', + ); + } + + final repo = context.read>(); + final updatedItem = await repo.update( + id: _singletonId, + item: itemToUpdate, + ); + + return ResponseHelper.success( + context: context, + data: updatedItem, + toJsonT: (data) => data.toJson(), + ); +} diff --git a/routes/api/v1/sources/[id]/index.dart b/routes/api/v1/sources/[id]/index.dart new file mode 100644 index 0000000..7572da8 --- /dev/null +++ b/routes/api/v1/sources/[id]/index.dart @@ -0,0 +1,86 @@ +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/helpers/response_helper.dart'; +import 'package:logging/logging.dart'; + +final _logger = Logger('sources_item_handler'); + +/// Handles requests for the /api/v1/sources/[id] endpoint. +/// +/// This endpoint supports GET for retrieving a single source, PUT for updating +/// a source, and DELETE for removing a source. +Future onRequest(RequestContext context, String id) async { + switch (context.request.method) { + case HttpMethod.get: + return _handleGet(context, id); + case HttpMethod.put: + return _handlePut(context, id); + case HttpMethod.delete: + return _handleDelete(context, id); + default: + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +/// Handles GET requests: Retrieves a single source by its ID. +Future _handleGet(RequestContext context, String id) async { + final repo = context.read>(); + final item = await repo.read(id: id); + + return ResponseHelper.success( + context: context, + data: item, + toJsonT: (data) => (data as dynamic).toJson() as Map, + ); +} + +/// Handles PUT requests: Updates an existing source by its ID. +/// +/// The request body must be a valid JSON representation of a source. +Future _handlePut(RequestContext context, String id) async { + final requestBody = await context.request.json() as Map?; + if (requestBody == null) { + throw const BadRequestException('Missing or invalid request body.'); + } + + requestBody['updatedAt'] = DateTime.now().toUtc().toIso8601String(); + + Source itemToUpdate; + try { + itemToUpdate = Source.fromJson(requestBody); + } on TypeError catch (e, s) { + _logger.warning('Deserialization TypeError in PUT /sources/[id]', e, s); + throw const BadRequestException( + 'Invalid request body: Missing or invalid required field(s).', + ); + } + + if (itemToUpdate.id != id) { + throw BadRequestException( + 'Bad Request: ID in request body ("${itemToUpdate.id}") does not match ID in path ("$id").', + ); + } + + final repo = context.read>(); + final updatedItem = await repo.update( + id: id, + item: itemToUpdate, + ); + + return ResponseHelper.success( + context: context, + data: updatedItem, + toJsonT: (data) => (data as dynamic).toJson() as Map, + ); +} + +/// Handles DELETE requests: Deletes a source by its ID. +Future _handleDelete(RequestContext context, String id) async { + final repo = context.read>(); + await repo.delete(id: id); + + return Response(statusCode: HttpStatus.noContent); +} diff --git a/routes/api/v1/sources/_middleware.dart b/routes/api/v1/sources/_middleware.dart new file mode 100644 index 0000000..a00202d --- /dev/null +++ b/routes/api/v1/sources/_middleware.dart @@ -0,0 +1,54 @@ +import 'package:dart_frog/dart_frog.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/authentication_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/authorization_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/configured_rate_limiter.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permissions.dart'; + +/// Sources are managed by admins, but are readable by all authenticated users. +/// This middleware also applies rate limiting. +/// +/// Middleware for the `/api/v1/sources` route. +/// +/// This middleware chain performs the following actions: +/// 1. `requireAuthentication()`: Ensures the user is authenticated. +/// 2. `authorizationMiddleware()`: Checks if the authenticated user has the +/// necessary permission to perform the requested action. +/// 3. The inner middleware provides the specific permission required for the +/// current request to the `authorizationMiddleware`. +Handler middleware(Handler handler) { + return handler + .use( + (handler) => (context) { + final request = context.request; + final String permission; + final Middleware rateLimiter; + + switch (request.method) { + case HttpMethod.get: + permission = Permissions.sourceRead; + rateLimiter = createReadRateLimiter(); + case HttpMethod.post: + permission = Permissions.sourceCreate; + rateLimiter = createWriteRateLimiter(); + case HttpMethod.put: + permission = Permissions.sourceUpdate; + rateLimiter = createWriteRateLimiter(); + case HttpMethod.delete: + permission = Permissions.sourceDelete; + rateLimiter = createWriteRateLimiter(); + default: + // Return 405 Method Not Allowed for unsupported methods. + return Response(statusCode: 405); + } + + // Apply the selected rate limiter and then provide the permission. + return rateLimiter( + (context) => handler( + context.provide(() => permission), + ), + )(context); + }, + ) + .use(authorizationMiddleware()) + .use(requireAuthentication()); +} diff --git a/routes/api/v1/sources/index.dart b/routes/api/v1/sources/index.dart new file mode 100644 index 0000000..f65b1df --- /dev/null +++ b/routes/api/v1/sources/index.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/helpers/response_helper.dart'; +import 'package:mongo_dart/mongo_dart.dart'; + +/// Handles requests for the /api/v1/sources collection endpoint. +/// +/// This endpoint supports GET for retrieving a list of sources and POST for +/// creating a new source. +Future onRequest(RequestContext context) async { + switch (context.request.method) { + case HttpMethod.get: + return _handleGet(context); + case HttpMethod.post: + return _handlePost(context); + default: + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +/// Handles GET requests: Retrieves a collection of sources. +/// +/// Supports filtering, sorting, and pagination. +Future _handleGet(RequestContext context) async { + final params = context.request.uri.queryParameters; + + 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', + ); + } + } + + 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 const BadRequestException( + 'Invalid "sort" parameter format. Use "field:order,field2:order".', + ); + } + } + + PaginationOptions? pagination; + if (params.containsKey('limit') || params.containsKey('cursor')) { + final limit = int.tryParse(params['limit'] ?? ''); + pagination = PaginationOptions(cursor: params['cursor'], limit: limit); + } + + final repo = context.read>(); + final responseData = await repo.readAll( + filter: filter, + sort: sort, + pagination: pagination, + ); + + return ResponseHelper.success( + context: context, + data: responseData, + toJsonT: (paginated) => (paginated as PaginatedResponse).toJson( + (item) => (item as dynamic).toJson() as Map, + ), + ); +} + +/// Handles POST requests: Creates a new source. +/// +/// The request body must be a valid JSON representation of a source. +Future _handlePost(RequestContext context) async { + final requestBody = await context.request.json() as Map?; + if (requestBody == null) { + throw const BadRequestException('Missing or invalid request body.'); + } + + final now = DateTime.now().toUtc().toIso8601String(); + requestBody['id'] = ObjectId().oid; + requestBody['createdAt'] = now; + requestBody['updatedAt'] = now; + + Source itemToCreate; + try { + itemToCreate = Source.fromJson(requestBody); + } on TypeError catch (e) { + throw BadRequestException( + 'Invalid request body: Missing or invalid required field(s). $e', + ); + } + + final repo = context.read>(); + final createdItem = await repo.create(item: itemToCreate); + + return ResponseHelper.success( + context: context, + data: createdItem, + toJsonT: (item) => (item as dynamic).toJson() as Map, + statusCode: HttpStatus.created, + ); +} diff --git a/routes/api/v1/topics/[id]/index.dart b/routes/api/v1/topics/[id]/index.dart new file mode 100644 index 0000000..a2c48d1 --- /dev/null +++ b/routes/api/v1/topics/[id]/index.dart @@ -0,0 +1,86 @@ +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/helpers/response_helper.dart'; +import 'package:logging/logging.dart'; + +final _logger = Logger('topics_item_handler'); + +/// Handles requests for the /api/v1/topics/[id] endpoint. +/// +/// This endpoint supports GET for retrieving a single topic, PUT for updating +/// a topic, and DELETE for removing a topic. +Future onRequest(RequestContext context, String id) async { + switch (context.request.method) { + case HttpMethod.get: + return _handleGet(context, id); + case HttpMethod.put: + return _handlePut(context, id); + case HttpMethod.delete: + return _handleDelete(context, id); + default: + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +/// Handles GET requests: Retrieves a single topic by its ID. +Future _handleGet(RequestContext context, String id) async { + final repo = context.read>(); + final item = await repo.read(id: id); + + return ResponseHelper.success( + context: context, + data: item, + toJsonT: (data) => (data as dynamic).toJson() as Map, + ); +} + +/// Handles PUT requests: Updates an existing topic by its ID. +/// +/// The request body must be a valid JSON representation of a topic. +Future _handlePut(RequestContext context, String id) async { + final requestBody = await context.request.json() as Map?; + if (requestBody == null) { + throw const BadRequestException('Missing or invalid request body.'); + } + + requestBody['updatedAt'] = DateTime.now().toUtc().toIso8601String(); + + Topic itemToUpdate; + try { + itemToUpdate = Topic.fromJson(requestBody); + } on TypeError catch (e, s) { + _logger.warning('Deserialization TypeError in PUT /topics/[id]', e, s); + throw const BadRequestException( + 'Invalid request body: Missing or invalid required field(s).', + ); + } + + if (itemToUpdate.id != id) { + throw BadRequestException( + 'Bad Request: ID in request body ("${itemToUpdate.id}") does not match ID in path ("$id").', + ); + } + + final repo = context.read>(); + final updatedItem = await repo.update( + id: id, + item: itemToUpdate, + ); + + return ResponseHelper.success( + context: context, + data: updatedItem, + toJsonT: (data) => (data as dynamic).toJson() as Map, + ); +} + +/// Handles DELETE requests: Deletes a topic by its ID. +Future _handleDelete(RequestContext context, String id) async { + final repo = context.read>(); + await repo.delete(id: id); + + return Response(statusCode: HttpStatus.noContent); +} diff --git a/routes/api/v1/topics/_middleware.dart b/routes/api/v1/topics/_middleware.dart new file mode 100644 index 0000000..48c9c58 --- /dev/null +++ b/routes/api/v1/topics/_middleware.dart @@ -0,0 +1,54 @@ +import 'package:dart_frog/dart_frog.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/authentication_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/authorization_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/configured_rate_limiter.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permissions.dart'; + +/// Topics are managed by admins, but are readable by all authenticated users. +/// This middleware also applies rate limiting. +/// +/// Middleware for the `/api/v1/topics` route. +/// +/// This middleware chain performs the following actions: +/// 1. `requireAuthentication()`: Ensures the user is authenticated. +/// 2. `authorizationMiddleware()`: Checks if the authenticated user has the +/// necessary permission to perform the requested action. +/// 3. The inner middleware provides the specific permission required for the +/// current request to the `authorizationMiddleware`. +Handler middleware(Handler handler) { + return handler + .use( + (handler) => (context) { + final request = context.request; + final String permission; + final Middleware rateLimiter; + + switch (request.method) { + case HttpMethod.get: + permission = Permissions.topicRead; + rateLimiter = createReadRateLimiter(); + case HttpMethod.post: + permission = Permissions.topicCreate; + rateLimiter = createWriteRateLimiter(); + case HttpMethod.put: + permission = Permissions.topicUpdate; + rateLimiter = createWriteRateLimiter(); + case HttpMethod.delete: + permission = Permissions.topicDelete; + rateLimiter = createWriteRateLimiter(); + default: + // Return 405 Method Not Allowed for unsupported methods. + return Response(statusCode: 405); + } + + // Apply the selected rate limiter and then provide the permission. + return rateLimiter( + (context) => handler( + context.provide(() => permission), + ), + )(context); + }, + ) + .use(authorizationMiddleware()) + .use(requireAuthentication()); +} diff --git a/routes/api/v1/topics/index.dart b/routes/api/v1/topics/index.dart new file mode 100644 index 0000000..fa77a43 --- /dev/null +++ b/routes/api/v1/topics/index.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/helpers/response_helper.dart'; +import 'package:mongo_dart/mongo_dart.dart'; + +/// Handles requests for the /api/v1/topics collection endpoint. +/// +/// This endpoint supports GET for retrieving a list of topics and POST for +/// creating a new topic. +Future onRequest(RequestContext context) async { + switch (context.request.method) { + case HttpMethod.get: + return _handleGet(context); + case HttpMethod.post: + return _handlePost(context); + default: + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +/// Handles GET requests: Retrieves a collection of topics. +/// +/// Supports filtering, sorting, and pagination. +Future _handleGet(RequestContext context) async { + final params = context.request.uri.queryParameters; + + 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', + ); + } + } + + 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 const BadRequestException( + 'Invalid "sort" parameter format. Use "field:order,field2:order".', + ); + } + } + + PaginationOptions? pagination; + if (params.containsKey('limit') || params.containsKey('cursor')) { + final limit = int.tryParse(params['limit'] ?? ''); + pagination = PaginationOptions(cursor: params['cursor'], limit: limit); + } + + final repo = context.read>(); + final responseData = await repo.readAll( + filter: filter, + sort: sort, + pagination: pagination, + ); + + return ResponseHelper.success( + context: context, + data: responseData, + toJsonT: (paginated) => (paginated as PaginatedResponse).toJson( + (item) => (item as dynamic).toJson() as Map, + ), + ); +} + +/// Handles POST requests: Creates a new topic. +/// +/// The request body must be a valid JSON representation of a topic. +Future _handlePost(RequestContext context) async { + final requestBody = await context.request.json() as Map?; + if (requestBody == null) { + throw const BadRequestException('Missing or invalid request body.'); + } + + final now = DateTime.now().toUtc().toIso8601String(); + requestBody['id'] = ObjectId().oid; + requestBody['createdAt'] = now; + requestBody['updatedAt'] = now; + + Topic itemToCreate; + try { + itemToCreate = Topic.fromJson(requestBody); + } on TypeError catch (e) { + throw BadRequestException( + 'Invalid request body: Missing or invalid required field(s). $e', + ); + } + + final repo = context.read>(); + final createdItem = await repo.create(item: itemToCreate); + + return ResponseHelper.success( + context: context, + data: createdItem, + toJsonT: (item) => (item as dynamic).toJson() as Map, + statusCode: HttpStatus.created, + ); +} diff --git a/routes/api/v1/users/[id]/_middleware.dart b/routes/api/v1/users/[id]/_middleware.dart new file mode 100644 index 0000000..73ab3f4 --- /dev/null +++ b/routes/api/v1/users/[id]/_middleware.dart @@ -0,0 +1,11 @@ +import 'package:dart_frog/dart_frog.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/ownership_check_middleware.dart'; + +/// Applies the ownership check to the user item endpoint. +/// +/// This runs after the parent `users/_middleware.dart`, which handles +/// authentication and permission checks. This middleware adds the final +/// security layer, ensuring a user can only access their own resource. +Handler middleware(Handler handler) { + return handler.use(userOwnershipMiddleware()); +} diff --git a/routes/api/v1/users/[id]/index.dart b/routes/api/v1/users/[id]/index.dart new file mode 100644 index 0000000..43c44f4 --- /dev/null +++ b/routes/api/v1/users/[id]/index.dart @@ -0,0 +1,86 @@ +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/helpers/response_helper.dart'; +import 'package:logging/logging.dart'; + +final _logger = Logger('users_item_handler'); + +/// Handles requests for the /api/v1/users/[id] endpoint. +/// +/// This endpoint supports GET for retrieving a single user, PUT for updating +/// a user, and DELETE for removing a user. +Future onRequest(RequestContext context, String id) async { + switch (context.request.method) { + case HttpMethod.get: + return _handleGet(context, id); + case HttpMethod.put: + return _handlePut(context, id); + case HttpMethod.delete: + return _handleDelete(context, id); + default: + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +/// Handles GET requests: Retrieves a single user by its ID. +Future _handleGet(RequestContext context, String id) async { + final repo = context.read>(); + final item = await repo.read(id: id); + + return ResponseHelper.success( + context: context, + data: item, + toJsonT: (data) => (data as dynamic).toJson() as Map, + ); +} + +/// Handles PUT requests: Updates an existing user by its ID. +/// +/// The request body must be a valid JSON representation of a user. +Future _handlePut(RequestContext context, String id) async { + final requestBody = await context.request.json() as Map?; + if (requestBody == null) { + throw const BadRequestException('Missing or invalid request body.'); + } + + requestBody['updatedAt'] = DateTime.now().toUtc().toIso8601String(); + + User itemToUpdate; + try { + itemToUpdate = User.fromJson(requestBody); + } on TypeError catch (e, s) { + _logger.warning('Deserialization TypeError in PUT /users/[id]', e, s); + throw const BadRequestException( + 'Invalid request body: Missing or invalid required field(s).', + ); + } + + if (itemToUpdate.id != id) { + throw BadRequestException( + 'Bad Request: ID in request body ("${itemToUpdate.id}") does not match ID in path ("$id").', + ); + } + + final repo = context.read>(); + final updatedItem = await repo.update( + id: id, + item: itemToUpdate, + ); + + return ResponseHelper.success( + context: context, + data: updatedItem, + toJsonT: (data) => (data as dynamic).toJson() as Map, + ); +} + +/// Handles DELETE requests: Deletes a user by its ID. +Future _handleDelete(RequestContext context, String id) async { + final repo = context.read>(); + await repo.delete(id: id); + + return Response(statusCode: HttpStatus.noContent); +} diff --git a/routes/api/v1/users/[id]/preferences/_middleware.dart b/routes/api/v1/users/[id]/preferences/_middleware.dart new file mode 100644 index 0000000..a5dd3bd --- /dev/null +++ b/routes/api/v1/users/[id]/preferences/_middleware.dart @@ -0,0 +1,49 @@ +import 'package:dart_frog/dart_frog.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/authorization_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/configured_rate_limiter.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/ownership_check_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permissions.dart'; + +/// Middleware for the user preferences endpoint. +/// +/// This chain ensures that: +/// 1. The user is authenticated (handled by the parent `users` middleware). +/// 2. Rate limiting is applied. +/// 3. The correct permission (`userContentPreferences...`) is required. +/// 4. The user has that permission. +/// 5. The user is the owner of the preferences resource. +Handler middleware(Handler handler) { + return handler + // Final check: ensure the authenticated user owns this resource. + .use(userOwnershipMiddleware()) + // Check if the user has the required permission. + .use(authorizationMiddleware()) + // Apply rate limiting and provide the specific permission for this route. + .use(_rateAndPermissionSetter()); +} + +Middleware _rateAndPermissionSetter() { + return (handler) { + return (context) { + final String permission; + final Middleware rateLimiter; + + switch (context.request.method) { + case HttpMethod.get: + permission = Permissions.userContentPreferencesReadOwned; + rateLimiter = createReadRateLimiter(); + case HttpMethod.put: + permission = Permissions.userContentPreferencesUpdateOwned; + rateLimiter = createWriteRateLimiter(); + default: + return Response(statusCode: 405); + } + + return rateLimiter( + (context) => handler( + context.provide(() => permission), + ), + )(context); + }; + }; +} diff --git a/routes/api/v1/users/[id]/preferences/index.dart b/routes/api/v1/users/[id]/preferences/index.dart new file mode 100644 index 0000000..b11fc55 --- /dev/null +++ b/routes/api/v1/users/[id]/preferences/index.dart @@ -0,0 +1,76 @@ +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/helpers/response_helper.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/user_preference_limit_service.dart'; +import 'package:logging/logging.dart'; + +final _logger = Logger('user_preferences_handler'); + +/// Handles requests for the /api/v1/users/[id]/preferences endpoint. +/// +/// This endpoint supports GET for retrieving a user's content preferences and +/// PUT for updating them. +Future onRequest(RequestContext context, String id) async { + switch (context.request.method) { + case HttpMethod.get: + return _handleGet(context, id); + case HttpMethod.put: + return _handlePut(context, id); + default: + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +/// Handles GET requests: Retrieves UserContentPreferences by user ID. +Future _handleGet(RequestContext context, String id) async { + final repo = context.read>(); + final item = await repo.read(id: id); + + return ResponseHelper.success( + context: context, + data: item, + toJsonT: (data) => data.toJson(), + ); +} + +/// Handles PUT requests: Updates an existing UserContentPreferences by user ID. +Future _handlePut(RequestContext context, String id) async { + final requestBody = await context.request.json() as Map?; + if (requestBody == null) { + throw const BadRequestException('Missing or invalid request body.'); + } + + UserContentPreferences itemToUpdate; + try { + // Ensure the ID from the path is used, as it's the source of truth. + requestBody['id'] = id; + itemToUpdate = UserContentPreferences.fromJson(requestBody); + } on TypeError catch (e, s) { + _logger.warning('Deserialization TypeError in PUT /preferences', e, s); + throw const BadRequestException( + 'Invalid request body: Missing or invalid required field(s).', + ); + } + + // --- Business Logic: Enforce Preference Limits --- + // Before updating, check if the new preferences exceed the user's limits. + final user = context.read(); // User is guaranteed by middleware + final limitService = context.read(); + await limitService.checkUpdatePreferences(user, itemToUpdate); + + // --- Data Persistence --- + final repo = context.read>(); + final updatedItem = await repo.update( + id: id, + item: itemToUpdate, + ); + + return ResponseHelper.success( + context: context, + data: updatedItem, + toJsonT: (data) => data.toJson(), + ); +} diff --git a/routes/api/v1/users/[id]/settings/_middleware.dart b/routes/api/v1/users/[id]/settings/_middleware.dart new file mode 100644 index 0000000..392647e --- /dev/null +++ b/routes/api/v1/users/[id]/settings/_middleware.dart @@ -0,0 +1,49 @@ +import 'package:dart_frog/dart_frog.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/authorization_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/configured_rate_limiter.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/ownership_check_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permissions.dart'; + +/// Middleware for the user settings endpoint. +/// +/// This chain ensures that: +/// 1. The user is authenticated (handled by the parent `users` middleware). +/// 2. Rate limiting is applied. +/// 3. The correct permission (`userAppSettings...`) is required. +/// 4. The user has that permission. +/// 5. The user is the owner of the settings resource. +Handler middleware(Handler handler) { + return handler + // Final check: ensure the authenticated user owns this resource. + .use(userOwnershipMiddleware()) + // Check if the user has the required permission. + .use(authorizationMiddleware()) + // Apply rate limiting and provide the specific permission for this route. + .use(_rateAndPermissionSetter()); +} + +Middleware _rateAndPermissionSetter() { + return (handler) { + return (context) { + final String permission; + final Middleware rateLimiter; + + switch (context.request.method) { + case HttpMethod.get: + permission = Permissions.userAppSettingsReadOwned; + rateLimiter = createReadRateLimiter(); + case HttpMethod.put: + permission = Permissions.userAppSettingsUpdateOwned; + rateLimiter = createWriteRateLimiter(); + default: + return Response(statusCode: 405); + } + + return rateLimiter( + (context) => handler( + context.provide(() => permission), + ), + )(context); + }; + }; +} diff --git a/routes/api/v1/users/[id]/settings/index.dart b/routes/api/v1/users/[id]/settings/index.dart new file mode 100644 index 0000000..5281971 --- /dev/null +++ b/routes/api/v1/users/[id]/settings/index.dart @@ -0,0 +1,73 @@ +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/helpers/response_helper.dart'; +import 'package:logging/logging.dart'; + +final _logger = Logger('user_settings_handler'); + +/// Handles requests for the /api/v1/users/[id]/settings endpoint. +/// +/// This endpoint supports GET for retrieving a user's app settings and +/// PUT for updating them. +Future onRequest(RequestContext context, String id) async { + switch (context.request.method) { + case HttpMethod.get: + return _handleGet(context, id); + case HttpMethod.put: + return _handlePut(context, id); + default: + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +/// Handles GET requests: Retrieves UserAppSettings by user ID. +Future _handleGet(RequestContext context, String id) async { + final repo = context.read>(); + final item = await repo.read(id: id); + + return ResponseHelper.success( + context: context, + data: item, + toJsonT: (data) => data.toJson(), + ); +} + +/// Handles PUT requests: Updates an existing UserAppSettings by user ID. +Future _handlePut(RequestContext context, String id) async { + final requestBody = await context.request.json() as Map?; + if (requestBody == null) { + throw const BadRequestException('Missing or invalid request body.'); + } + + // Note: Timestamps for settings are not typically updated on every change. + // If they were, you would add `updatedAt` here. + + UserAppSettings itemToUpdate; + try { + // Ensure the ID from the path is used, as it's the source of truth. + requestBody['id'] = id; + itemToUpdate = UserAppSettings.fromJson(requestBody); + } on TypeError catch (e, s) { + _logger.warning('Deserialization TypeError in PUT /settings', e, s); + throw const BadRequestException( + 'Invalid request body: Missing or invalid required field(s).', + ); + } + + // The ID check is implicitly handled by setting it from the path parameter. + + final repo = context.read>(); + final updatedItem = await repo.update( + id: id, + item: itemToUpdate, + ); + + return ResponseHelper.success( + context: context, + data: updatedItem, + toJsonT: (data) => data.toJson(), + ); +} diff --git a/routes/api/v1/users/_middleware.dart b/routes/api/v1/users/_middleware.dart new file mode 100644 index 0000000..dcb3526 --- /dev/null +++ b/routes/api/v1/users/_middleware.dart @@ -0,0 +1,75 @@ +import 'package:dart_frog/dart_frog.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/authentication_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/authorization_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/configured_rate_limiter.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permissions.dart'; + +/// Middleware for the `/api/v1/users` route group. +/// +/// This middleware performs the following actions: +/// 1. `requireAuthentication()`: Ensures a user is authenticated for all +/// /users/* routes. +/// 2. `rateAndPermissionSetter`: A middleware that applies rate limiting and +/// provides the correct permission string into the context *only* for the +/// `/users` and `/users/{id}` endpoints. It ignores sub-routes like +/// `/users/{id}/settings`, leaving them to be handled by their own more +/// specific middleware. +/// 3. `authorizationMiddleware()`: Checks if the authenticated user has the +/// permission provided by the `rateAndPermissionSetter`. +Handler middleware(Handler handler) { + // This middleware applies rate limiting and provides the required permission. + // It is scoped to only handle `/users` and `/users/{id}`. + // ignore: prefer_function_declarations_over_variables + final rateAndPermissionSetter = (Handler handler) { + return (RequestContext context) { + final request = context.request; + final pathSegments = request.uri.pathSegments; + + // This logic only applies to /users (length 3) and /users/{id} (length 4). + // It intentionally ignores longer paths like /users/{id}/settings (length 5), + // allowing sub-route middleware to handle them. + if (pathSegments.length > 4) { + return handler(context); + } + + final String permission; + final Middleware rateLimiter; + final isItemRequest = pathSegments.length == 4; + + switch (request.method) { + case HttpMethod.get: + // Admins can list all users; users can read their own profile. + permission = + isItemRequest ? Permissions.userReadOwned : Permissions.userRead; + rateLimiter = createReadRateLimiter(); + case HttpMethod.put: + // Users can update their own profile. + permission = Permissions.userUpdateOwned; + rateLimiter = createWriteRateLimiter(); + case HttpMethod.delete: + // Users can delete their own profile. + permission = Permissions.userDeleteOwned; + rateLimiter = createWriteRateLimiter(); + default: + // Disallow any other methods (e.g., POST) on this route group. + // User creation is handled by the /auth routes. + return Response(statusCode: 405); + } + + // Apply the selected rate limiter and then provide the permission. + return rateLimiter( + (context) => handler( + context.provide(() => permission), + ), + )(context); + }; + }; + + return handler + // The authorization middleware runs after the permission has been set. + .use(authorizationMiddleware()) + // The rate limiter and permission setter runs after authentication. + .use(rateAndPermissionSetter) + // Authentication is the first check for all /users/* routes. + .use(requireAuthentication()); +} diff --git a/routes/api/v1/users/index.dart b/routes/api/v1/users/index.dart new file mode 100644 index 0000000..f4310e4 --- /dev/null +++ b/routes/api/v1/users/index.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/helpers/response_helper.dart'; + +/// Handles requests for the /api/v1/users collection endpoint. +/// +/// This endpoint supports GET for retrieving a list of users. +Future onRequest(RequestContext context) async { + switch (context.request.method) { + case HttpMethod.get: + return _handleGet(context); + default: + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +/// Handles GET requests: Retrieves a collection of users. +/// +/// Supports filtering, sorting, and pagination. +Future _handleGet(RequestContext context) async { + final params = context.request.uri.queryParameters; + + 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', + ); + } + } + + 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 const BadRequestException( + 'Invalid "sort" parameter format. Use "field:order,field2:order".', + ); + } + } + + PaginationOptions? pagination; + if (params.containsKey('limit') || params.containsKey('cursor')) { + final limit = int.tryParse(params['limit'] ?? ''); + pagination = PaginationOptions(cursor: params['cursor'], limit: limit); + } + + final repo = context.read>(); + final responseData = await repo.readAll( + filter: filter, + sort: sort, + pagination: pagination, + ); + + return ResponseHelper.success( + context: context, + data: responseData, + toJsonT: (paginated) => (paginated as PaginatedResponse).toJson( + (item) => (item as dynamic).toJson() as Map, + ), + ); +}