From 68160ecc60f4df9b47f1b3365554cc0592956e57 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 17:08:40 +0100 Subject: [PATCH 01/44] feat(api): implement headlines collection endpoint - Add support for GET and POST requests - Implement filtering, sorting, and pagination for GET requests - Handle creation of new headlines via POST request - Utilize DataRepository for data operations - Return appropriate responses using ResponseHelper --- routes/api/v1/headlines/index.dart | 107 +++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 routes/api/v1/headlines/index.dart 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, + ); +} From 75bdb02d7939f5d99c3164775515634c7bfec80c Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 17:10:54 +0100 Subject: [PATCH 02/44] feat(api): add middleware for headlines route - Implement middleware for headlines route in Dart Frog API - Include authentication and authorization checks - Provide model configuration and resource name for context --- routes/api/v1/headlines/_middleware.dart | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 routes/api/v1/headlines/_middleware.dart diff --git a/routes/api/v1/headlines/_middleware.dart b/routes/api/v1/headlines/_middleware.dart new file mode 100644 index 0000000..f46ceeb --- /dev/null +++ b/routes/api/v1/headlines/_middleware.dart @@ -0,0 +1,20 @@ +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/registry/model_registry.dart'; + +Handler middleware(Handler handler) { + return handler + .use( + (handler) => (context) { + final modelConfig = modelRegistry['headline']!; + return handler( + context + .provide>(() => modelConfig) + .provide(() => 'headline'), + ); + }, + ) + .use(authorizationMiddleware()) + .use(requireAuthentication()); +} From 415abf3dd72fd53d2a4d16c29491c47646b0a3ba Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 17:11:29 +0100 Subject: [PATCH 03/44] feat(api): implement headlines item endpoint - Add GET, PUT, and DELETE handlers for individual headlines - Implement request validation and error handling - Use DataRepository for data operations - Return appropriate HTTP responses for each operation --- routes/api/v1/headlines/[id]/index.dart | 78 +++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 routes/api/v1/headlines/[id]/index.dart diff --git a/routes/api/v1/headlines/[id]/index.dart b/routes/api/v1/headlines/[id]/index.dart new file mode 100644 index 0000000..d796034 --- /dev/null +++ b/routes/api/v1/headlines/[id]/index.dart @@ -0,0 +1,78 @@ +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: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); +} From 699be85cc44bcd74f9df5a9a60326b6691be7b92 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 17:15:21 +0100 Subject: [PATCH 04/44] feat(middlewares): add middlewares for headline entity - Add model configuration provider for headline - Implement authorization middleware - Require authentication for headline routes --- routes/api/v1/headlines/[id]/_middleware.dart | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 routes/api/v1/headlines/[id]/_middleware.dart diff --git a/routes/api/v1/headlines/[id]/_middleware.dart b/routes/api/v1/headlines/[id]/_middleware.dart new file mode 100644 index 0000000..71da057 --- /dev/null +++ b/routes/api/v1/headlines/[id]/_middleware.dart @@ -0,0 +1,21 @@ +import 'package:core/core.dart'; +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/registry/model_registry.dart'; + +Handler middleware(Handler handler) { + return handler + .use( + (handler) => (context) { + final modelConfig = modelRegistry['headline']!; + return handler( + context + .provide>(() => modelConfig) + .provide(() => 'headline'), + ); + }, + ) + .use(authorizationMiddleware()) + .use(requireAuthentication()); +} From 91353e0607f96934b17cbeed2300d82fa65c6452 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 18:29:33 +0100 Subject: [PATCH 05/44] feat(api): implement countries endpoint - Add GET endpoint for retrieving a list of countries (/api/v1/countries) - Add GET endpoint for retrieving a single country by ID (/api/v1/countries/[id]) - Implement middleware for authentication and authorization - Add support for filtering, sorting, and pagination in the countries list endpoint --- routes/api/v1/countries/[id]/index.dart | 30 ++++++++++ routes/api/v1/countries/_middleware.dart | 37 ++++++++++++ routes/api/v1/countries/index.dart | 76 ++++++++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 routes/api/v1/countries/[id]/index.dart create mode 100644 routes/api/v1/countries/_middleware.dart create mode 100644 routes/api/v1/countries/index.dart 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..d581d64 --- /dev/null +++ b/routes/api/v1/countries/_middleware.dart @@ -0,0 +1,37 @@ +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/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; + + switch (request.method) { + case HttpMethod.get: + // Both collection and item GET requests use the same permission. + permission = Permissions.countryRead; + default: + // Return 405 Method Not Allowed for unsupported methods. + return Response(statusCode: 405); + } + // Provide the required permission to the authorization middleware. + return handler( + context.provide(() => permission), + ); + }, + ) + .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, + ), + ); +} From c322fed23aadcaf8e33035efd7a5c33a728c6b13 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 18:29:44 +0100 Subject: [PATCH 06/44] feat(api): implement dashboard summary endpoint - Add middleware for /api/v1/dashboard/summary route - Implement GET request handler for dashboard summary - Introduce DashboardSummaryService for business logic --- .../api/v1/dashboard/summary/_middleware.dart | 26 +++++++++++++++++++ routes/api/v1/dashboard/summary/index.dart | 25 ++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 routes/api/v1/dashboard/summary/_middleware.dart create mode 100644 routes/api/v1/dashboard/summary/index.dart 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(), + ); +} From 9384abeb2091ef6e2dc9c1f09bbb4e8752b2c788 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 18:29:59 +0100 Subject: [PATCH 07/44] refactor(api): improve permission handling for headline endpoints - Remove unused imports in middleware files - Simplify permission checking in headlines endpoint - Implement more granular permission checks based on HTTP method - Remove unused PermissionService import --- routes/api/v1/headlines/[id]/_middleware.dart | 1 - routes/api/v1/headlines/[id]/index.dart | 1 - routes/api/v1/headlines/_middleware.dart | 27 +++++++++++++++---- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/routes/api/v1/headlines/[id]/_middleware.dart b/routes/api/v1/headlines/[id]/_middleware.dart index 71da057..f46ceeb 100644 --- a/routes/api/v1/headlines/[id]/_middleware.dart +++ b/routes/api/v1/headlines/[id]/_middleware.dart @@ -1,4 +1,3 @@ -import 'package:core/core.dart'; 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'; diff --git a/routes/api/v1/headlines/[id]/index.dart b/routes/api/v1/headlines/[id]/index.dart index d796034..05c7157 100644 --- a/routes/api/v1/headlines/[id]/index.dart +++ b/routes/api/v1/headlines/[id]/index.dart @@ -4,7 +4,6 @@ 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:logging/logging.dart'; final _logger = Logger('headlines_item_handler'); diff --git a/routes/api/v1/headlines/_middleware.dart b/routes/api/v1/headlines/_middleware.dart index f46ceeb..590a17d 100644 --- a/routes/api/v1/headlines/_middleware.dart +++ b/routes/api/v1/headlines/_middleware.dart @@ -1,17 +1,34 @@ 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/registry/model_registry.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permissions.dart'; Handler middleware(Handler handler) { return handler .use( (handler) => (context) { - final modelConfig = modelRegistry['headline']!; + final request = context.request; + final String permission; + // Check if the request is for a specific item by looking at the path. + final isItemRequest = request.uri.pathSegments.length > 3; + + switch (request.method) { + case HttpMethod.get: + permission = isItemRequest + ? Permissions.headlineRead + : Permissions.headlineRead; + case HttpMethod.post: + permission = Permissions.headlineCreate; + case HttpMethod.put: + permission = Permissions.headlineUpdate; + case HttpMethod.delete: + permission = Permissions.headlineDelete; + default: + // This will be caught by the error handler. + return Response(statusCode: 405); + } return handler( - context - .provide>(() => modelConfig) - .provide(() => 'headline'), + context.provide(() => permission), ); }, ) From 7510a308a96b5f55dbcb7f4eb6fe02940425e48a Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 18:30:11 +0100 Subject: [PATCH 08/44] feat(api): implement languages endpoint - Add middleware for authentication and authorization - Implement GET endpoint for languages collection with filtering, sorting, and pagination - Implement GET endpoint for single language by ID --- routes/api/v1/languages/[id]/index.dart | 30 ++++++++++ routes/api/v1/languages/_middleware.dart | 37 ++++++++++++ routes/api/v1/languages/index.dart | 76 ++++++++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 routes/api/v1/languages/[id]/index.dart create mode 100644 routes/api/v1/languages/_middleware.dart create mode 100644 routes/api/v1/languages/index.dart 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..d0c2161 --- /dev/null +++ b/routes/api/v1/languages/_middleware.dart @@ -0,0 +1,37 @@ +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/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; + + switch (request.method) { + case HttpMethod.get: + // Both collection and item GET requests use the same permission. + permission = Permissions.languageRead; + default: + // Return 405 Method Not Allowed for unsupported methods. + return Response(statusCode: 405); + } + // Provide the required permission to the authorization middleware. + return handler( + context.provide(() => permission), + ); + }, + ) + .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, + ), + ); +} From 6d6e4204abfa5bc142e9f9df470bf288a4833772 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 18:30:23 +0100 Subject: [PATCH 09/44] feat(api): implement remote-configs endpoints - Add GET, POST, and PUT handlers for remote-configs - Implement collection and singleton resource endpoints - Add middleware for authentication and authorization - Update DataRepository to support RemoteConfig type --- routes/api/v1/remote-configs/[id]/index.dart | 68 ++++++++++++ routes/api/v1/remote-configs/_middleware.dart | 36 +++++++ routes/api/v1/remote-configs/index.dart | 101 ++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 routes/api/v1/remote-configs/[id]/index.dart create mode 100644 routes/api/v1/remote-configs/_middleware.dart create mode 100644 routes/api/v1/remote-configs/index.dart diff --git a/routes/api/v1/remote-configs/[id]/index.dart b/routes/api/v1/remote-configs/[id]/index.dart new file mode 100644 index 0000000..f082a0a --- /dev/null +++ b/routes/api/v1/remote-configs/[id]/index.dart @@ -0,0 +1,68 @@ +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('remote_configs_item_handler'); + +/// Handles requests for the /api/v1/remote-configs/[id] endpoint. +/// This is treated as a singleton resource endpoint. +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: + // This should be caught by middleware, but as a safeguard: + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +/// Handles GET requests: Retrieves a single remote config 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.toJson(), + ); +} + +/// Handles PUT requests: Updates an existing remote config by its 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.'); + } + + requestBody['updatedAt'] = DateTime.now().toUtc().toIso8601String(); + + RemoteConfig itemToUpdate; + try { + requestBody['id'] = id; + itemToUpdate = RemoteConfig.fromJson(requestBody); + } on TypeError catch (e, s) { + _logger.warning('Deserialization TypeError in PUT /remote-configs/[id]', e, s); + throw const BadRequestException( + 'Invalid request body: Missing or invalid required field(s).', + ); + } + + 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/remote-configs/_middleware.dart b/routes/api/v1/remote-configs/_middleware.dart new file mode 100644 index 0000000..18fe884 --- /dev/null +++ b/routes/api/v1/remote-configs/_middleware.dart @@ -0,0 +1,36 @@ +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/remote-configs` route. +/// +/// This middleware chain enforces the following access rules: +/// - GET: Requires `remoteConfig.read` permission (all authenticated users). +/// - PUT: Requires `remoteConfig.update` permission (admin-only). +/// - Other methods (POST, DELETE) are disallowed. +Handler middleware(Handler handler) { + return handler + .use( + (handler) => (context) { + final request = context.request; + final String permission; + + switch (request.method) { + case HttpMethod.get: + permission = Permissions.remoteConfigRead; + case HttpMethod.put: + permission = Permissions.remoteConfigUpdate; + default: + // Return 405 Method Not Allowed for unsupported methods like POST/DELETE. + return Response(statusCode: 405); + } + // Provide the required permission to the authorization middleware. + return handler( + context.provide(() => permission), + ); + }, + ) + .use(authorizationMiddleware()) + .use(requireAuthentication()); +} diff --git a/routes/api/v1/remote-configs/index.dart b/routes/api/v1/remote-configs/index.dart new file mode 100644 index 0000000..1312c36 --- /dev/null +++ b/routes/api/v1/remote-configs/index.dart @@ -0,0 +1,101 @@ +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/remote-configs 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 remote configs. +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.toJson( + (item) => item.toJson(), + ), + ); +} + +/// Handles POST requests: Creates a new remote config. +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(); + requestBody['id'] = ObjectId().oid; + requestBody['createdAt'] = now.toIso8601String(); + requestBody['updatedAt'] = now.toIso8601String(); + + final itemToCreate = RemoteConfig.fromJson(requestBody); + + final repo = context.read>(); + final createdItem = await repo.create(item: itemToCreate); + + return ResponseHelper.success( + context: context, + data: createdItem, + toJsonT: (item) => item.toJson(), + statusCode: HttpStatus.created, + ); +} From d0ad33f15d0e569d8a0f73f9b225eac17323231a Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 18:30:35 +0100 Subject: [PATCH 10/44] feat(api): implement sources CRUD endpoints - Add GET, POST, PUT, and DELETE handlers for /api/v1/sources - Implement filtering, sorting, and pagination for GET requests - Add middleware for authentication and authorization - Create Source model and DataRepository --- routes/api/v1/sources/[id]/index.dart | 86 +++++++++++++++++++ routes/api/v1/sources/_middleware.dart | 43 ++++++++++ routes/api/v1/sources/index.dart | 114 +++++++++++++++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 routes/api/v1/sources/[id]/index.dart create mode 100644 routes/api/v1/sources/_middleware.dart create mode 100644 routes/api/v1/sources/index.dart 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..6cbdc48 --- /dev/null +++ b/routes/api/v1/sources/_middleware.dart @@ -0,0 +1,43 @@ +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/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; + + switch (request.method) { + case HttpMethod.get: + // Both collection and item GET requests use the same permission. + permission = Permissions.sourceRead; + case HttpMethod.post: + permission = Permissions.sourceCreate; + case HttpMethod.put: + permission = Permissions.sourceUpdate; + case HttpMethod.delete: + permission = Permissions.sourceDelete; + default: + // Return 405 Method Not Allowed for unsupported methods. + return Response(statusCode: 405); + } + // Provide the required permission to the authorization middleware. + return handler( + context.provide(() => permission), + ); + }, + ) + .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, + ); +} From 83c0be0a03114c940419a7a26445e01903a16ce5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 18:30:50 +0100 Subject: [PATCH 11/44] feat(api): implement topics CRUD endpoints - Add GET, POST, PUT, and DELETE handlers for /api/v1/topics - Implement filtering, sorting, and pagination for GET requests - Add middleware for authentication and authorization - Use DataRepository for data access - Return appropriate HTTP statuses and JSON responses --- routes/api/v1/topics/[id]/index.dart | 86 +++++++++++++++++++ routes/api/v1/topics/_middleware.dart | 43 ++++++++++ routes/api/v1/topics/index.dart | 114 ++++++++++++++++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 routes/api/v1/topics/[id]/index.dart create mode 100644 routes/api/v1/topics/_middleware.dart create mode 100644 routes/api/v1/topics/index.dart 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..d2035e8 --- /dev/null +++ b/routes/api/v1/topics/_middleware.dart @@ -0,0 +1,43 @@ +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/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; + + switch (request.method) { + case HttpMethod.get: + // Both collection and item GET requests use the same permission. + permission = Permissions.topicRead; + case HttpMethod.post: + permission = Permissions.topicCreate; + case HttpMethod.put: + permission = Permissions.topicUpdate; + case HttpMethod.delete: + permission = Permissions.topicDelete; + default: + // Return 405 Method Not Allowed for unsupported methods. + return Response(statusCode: 405); + } + // Provide the required permission to the authorization middleware. + return handler( + context.provide(() => permission), + ); + }, + ) + .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, + ); +} From 7aa22a986404cc4319512bbb2363a24d5e3fa807 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 18:31:01 +0100 Subject: [PATCH 12/44] feat(api): implement users endpoint - Add GET, PUT, and DELETE handlers for individual user items - Implement GET handler for user collection with filtering, sorting, and pagination - Create middleware for authentication and authorization - Use DataRepository for user data access - Implement response helper for consistent API responses --- routes/api/v1/users/[id]/index.dart | 86 ++++++++++++++++++++++++++++ routes/api/v1/users/_middleware.dart | 45 +++++++++++++++ routes/api/v1/users/index.dart | 76 ++++++++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 routes/api/v1/users/[id]/index.dart create mode 100644 routes/api/v1/users/_middleware.dart create mode 100644 routes/api/v1/users/index.dart 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/_middleware.dart b/routes/api/v1/users/_middleware.dart new file mode 100644 index 0000000..da637e5 --- /dev/null +++ b/routes/api/v1/users/_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/rbac/permissions.dart'; + +/// Middleware for the `/api/v1/users` 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; + // A request is for a specific item if it has more than 3 path segments: + // e.g., /api/v1/users/{id} + final isItemRequest = request.uri.pathSegments.length > 3; + + switch (request.method) { + case HttpMethod.get: + permission = isItemRequest + ? Permissions.userReadOwned + : Permissions.userRead; + case HttpMethod.put: + permission = Permissions.userUpdateOwned; + case HttpMethod.delete: + permission = Permissions.userDeleteOwned; + default: + // Return 405 Method Not Allowed for unsupported methods. + return Response(statusCode: 405); + } + // Provide the required permission to the authorization middleware. + return handler( + context.provide(() => permission), + ); + }, + ) + .use(authorizationMiddleware()) + .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, + ), + ); +} From 1231089a1864eeddfc4dfd59127bdff5215da220 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 18:31:15 +0100 Subject: [PATCH 13/44] feat(api): implement user preferences and settings endpoints - Add GET and PUT endpoints for user content preferences - Add GET and PUT endpoints for user app settings - Implement middleware for authentication and authorization - Include business logic for validating preference limits --- .../users/[id]/preferences/_middleware.dart | 38 ++++++++++ .../api/v1/users/[id]/preferences/index.dart | 76 +++++++++++++++++++ .../v1/users/[id]/settings/_middleware.dart | 38 ++++++++++ routes/api/v1/users/[id]/settings/index.dart | 73 ++++++++++++++++++ 4 files changed, 225 insertions(+) create mode 100644 routes/api/v1/users/[id]/preferences/_middleware.dart create mode 100644 routes/api/v1/users/[id]/preferences/index.dart create mode 100644 routes/api/v1/users/[id]/settings/_middleware.dart create mode 100644 routes/api/v1/users/[id]/settings/index.dart 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..5817be1 --- /dev/null +++ b/routes/api/v1/users/[id]/preferences/_middleware.dart @@ -0,0 +1,38 @@ +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/users/[id]/preferences` 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; + + switch (request.method) { + case HttpMethod.get: + permission = Permissions.userContentPreferencesReadOwned; + case HttpMethod.put: + permission = Permissions.userContentPreferencesUpdateOwned; + default: + // Return 405 Method Not Allowed for unsupported methods. + return Response(statusCode: 405); + } + // Provide the required permission to the authorization middleware. + return handler( + context.provide(() => permission), + ); + }, + ) + .use(authorizationMiddleware()) + .use(requireAuthentication()); +} 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..8dbd454 --- /dev/null +++ b/routes/api/v1/users/[id]/settings/_middleware.dart @@ -0,0 +1,38 @@ +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/users/[id]/settings` 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; + + switch (request.method) { + case HttpMethod.get: + permission = Permissions.userAppSettingsReadOwned; + case HttpMethod.put: + permission = Permissions.userAppSettingsUpdateOwned; + default: + // Return 405 Method Not Allowed for unsupported methods. + return Response(statusCode: 405); + } + // Provide the required permission to the authorization middleware. + return handler( + context.provide(() => permission), + ); + }, + ) + .use(authorizationMiddleware()) + .use(requireAuthentication()); +} 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(), + ); +} From b3f7472c6010c9186d4eb90be671f3a8788134cf Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 19:02:07 +0100 Subject: [PATCH 14/44] refactor(middlewares): simplify authorization middleware - Remove model-specific access rules and route-group specific logic - Focus on role-based permissions only - Reduce middleware responsibilities and increase flexibility --- .../middlewares/authorization_middleware.dart | 111 ++++-------------- 1 file changed, 20 insertions(+), 91 deletions(-) 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); }; }; From 37b03b19283b3abee002625c11b19f121fe8aae9 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 19:02:17 +0100 Subject: [PATCH 15/44] refactor(middlewares): simplify user ownership check middleware - Renamed `ownershipCheckMiddleware` to `userOwnershipMiddleware` for clarity - Removed unnecessary dependencies and simplified middleware logic - Removed model-specific checks and focused on user ID comparison - Updated documentation to reflect simplified functionality --- .../ownership_check_middleware.dart | 102 ++++-------------- 1 file changed, 20 insertions(+), 82 deletions(-) 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); }; }; } From eb31c14d965e5cc1775357cf46125ef879aac28c Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 19:02:38 +0100 Subject: [PATCH 16/44] refactor(users): apply ownership check middleware to user endpoints - Add ownership check middleware to user, preferences, and settings endpoints - Remove duplicate authentication and authorization logic - Simplify middleware implementation using a shared ownership check function --- routes/api/v1/users/[id]/_middleware.dart | 11 ++++++ .../users/[id]/preferences/_middleware.dart | 39 +++---------------- .../v1/users/[id]/settings/_middleware.dart | 39 +++---------------- 3 files changed, 23 insertions(+), 66 deletions(-) create mode 100644 routes/api/v1/users/[id]/_middleware.dart 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]/preferences/_middleware.dart b/routes/api/v1/users/[id]/preferences/_middleware.dart index 5817be1..0065d60 100644 --- a/routes/api/v1/users/[id]/preferences/_middleware.dart +++ b/routes/api/v1/users/[id]/preferences/_middleware.dart @@ -1,38 +1,11 @@ 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'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/ownership_check_middleware.dart'; -/// Middleware for the `/api/v1/users/[id]/preferences` route. +/// Applies the ownership check to the user preferences endpoint. /// -/// 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`. +/// 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 preferences. Handler middleware(Handler handler) { - return handler - .use( - (handler) => (context) { - final request = context.request; - final String permission; - - switch (request.method) { - case HttpMethod.get: - permission = Permissions.userContentPreferencesReadOwned; - case HttpMethod.put: - permission = Permissions.userContentPreferencesUpdateOwned; - default: - // Return 405 Method Not Allowed for unsupported methods. - return Response(statusCode: 405); - } - // Provide the required permission to the authorization middleware. - return handler( - context.provide(() => permission), - ); - }, - ) - .use(authorizationMiddleware()) - .use(requireAuthentication()); + return handler.use(userOwnershipMiddleware()); } diff --git a/routes/api/v1/users/[id]/settings/_middleware.dart b/routes/api/v1/users/[id]/settings/_middleware.dart index 8dbd454..5c574e1 100644 --- a/routes/api/v1/users/[id]/settings/_middleware.dart +++ b/routes/api/v1/users/[id]/settings/_middleware.dart @@ -1,38 +1,11 @@ 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'; +import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/ownership_check_middleware.dart'; -/// Middleware for the `/api/v1/users/[id]/settings` route. +/// Applies the ownership check to the user settings endpoint. /// -/// 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`. +/// 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 settings. Handler middleware(Handler handler) { - return handler - .use( - (handler) => (context) { - final request = context.request; - final String permission; - - switch (request.method) { - case HttpMethod.get: - permission = Permissions.userAppSettingsReadOwned; - case HttpMethod.put: - permission = Permissions.userAppSettingsUpdateOwned; - default: - // Return 405 Method Not Allowed for unsupported methods. - return Response(statusCode: 405); - } - // Provide the required permission to the authorization middleware. - return handler( - context.provide(() => permission), - ); - }, - ) - .use(authorizationMiddleware()) - .use(requireAuthentication()); + return handler.use(userOwnershipMiddleware()); } From 860ce8f1dc2eca9a172647cc2ab2e255a7f62a25 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 19:02:47 +0100 Subject: [PATCH 17/44] refactor: remove unused middleware file - Deleted the entire _middleware.dart file in the headlines route - This file was likely no longer needed, as its functionality might have been replaced or moved elsewhere --- routes/api/v1/headlines/[id]/_middleware.dart | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 routes/api/v1/headlines/[id]/_middleware.dart diff --git a/routes/api/v1/headlines/[id]/_middleware.dart b/routes/api/v1/headlines/[id]/_middleware.dart deleted file mode 100644 index f46ceeb..0000000 --- a/routes/api/v1/headlines/[id]/_middleware.dart +++ /dev/null @@ -1,20 +0,0 @@ -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/registry/model_registry.dart'; - -Handler middleware(Handler handler) { - return handler - .use( - (handler) => (context) { - final modelConfig = modelRegistry['headline']!; - return handler( - context - .provide>(() => modelConfig) - .provide(() => 'headline'), - ); - }, - ) - .use(authorizationMiddleware()) - .use(requireAuthentication()); -} From 4312c9186169f32a00fa87ad4bdfecae6c630772 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 19:22:54 +0100 Subject: [PATCH 18/44] refactor(middleware): add comments to countries middleware - Clarify that countries data is static and read-only for authenticated users - Explain that modifications are not allowed via the API - Mention that the data is managed by database seeding --- routes/api/v1/countries/_middleware.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/routes/api/v1/countries/_middleware.dart b/routes/api/v1/countries/_middleware.dart index d581d64..b7118a3 100644 --- a/routes/api/v1/countries/_middleware.dart +++ b/routes/api/v1/countries/_middleware.dart @@ -3,6 +3,10 @@ import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/aut 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'; +/// 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. +/// /// Middleware for the `/api/v1/countries` route. /// /// This middleware chain performs the following actions: From 454bdd6b038f1e8f530997aab2f52f3401c17720 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 19:23:10 +0100 Subject: [PATCH 19/44] refactor(headlines): simplify permission logic in middleware - Remove unnecessary item request check for GET method - Use Permissions.headlineRead for all GET requests - Add comment for default case - Improve code readability and maintainability --- routes/api/v1/headlines/_middleware.dart | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/routes/api/v1/headlines/_middleware.dart b/routes/api/v1/headlines/_middleware.dart index 590a17d..c4b40eb 100644 --- a/routes/api/v1/headlines/_middleware.dart +++ b/routes/api/v1/headlines/_middleware.dart @@ -3,20 +3,18 @@ import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/aut 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'; +/// Headlines are managed by admins and publishers, but are readable by all +/// authenticated users. Handler middleware(Handler handler) { return handler .use( (handler) => (context) { final request = context.request; final String permission; - // Check if the request is for a specific item by looking at the path. - final isItemRequest = request.uri.pathSegments.length > 3; switch (request.method) { case HttpMethod.get: - permission = isItemRequest - ? Permissions.headlineRead - : Permissions.headlineRead; + permission = Permissions.headlineRead; case HttpMethod.post: permission = Permissions.headlineCreate; case HttpMethod.put: @@ -24,9 +22,10 @@ Handler middleware(Handler handler) { case HttpMethod.delete: permission = Permissions.headlineDelete; default: - // This will be caught by the error handler. + // Return 405 Method Not Allowed for unsupported methods. return Response(statusCode: 405); } + // Provide the required permission to the authorization middleware. return handler( context.provide(() => permission), ); From 8426d2ebc7545fd158c61e1d58c24ff8b29fa99d Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 19:30:22 +0100 Subject: [PATCH 20/44] refactor(api): add comments to explain languages endpoint restrictions - Clarify that languages data is static and read-only for all authenticated users - Emphasize that modification via API is not allowed - Mention that language data is managed through database seeding in real-world scenarios --- routes/api/v1/languages/_middleware.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/routes/api/v1/languages/_middleware.dart b/routes/api/v1/languages/_middleware.dart index d0c2161..794b007 100644 --- a/routes/api/v1/languages/_middleware.dart +++ b/routes/api/v1/languages/_middleware.dart @@ -3,6 +3,10 @@ import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/aut 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'; +/// 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. +/// /// Middleware for the `/api/v1/languages` route. /// /// This middleware chain performs the following actions: From 1431b8c42044b04de14743cc09a3fe52c653ab59 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 19:30:35 +0100 Subject: [PATCH 21/44] docs(middleware): add documentation for sources middleware - Add a comment explaining that sources are managed by admins but readable by all authenticated users - Improve code readability and maintainability --- routes/api/v1/sources/_middleware.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/routes/api/v1/sources/_middleware.dart b/routes/api/v1/sources/_middleware.dart index 6cbdc48..2669fb9 100644 --- a/routes/api/v1/sources/_middleware.dart +++ b/routes/api/v1/sources/_middleware.dart @@ -3,6 +3,8 @@ import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/aut 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'; +/// Sources are managed by admins, but are readable by all authenticated users. +/// /// Middleware for the `/api/v1/sources` route. /// /// This middleware chain performs the following actions: From cb7c21218eaf38d5db8c8c796ae91c13ac9e18a7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 20:19:00 +0100 Subject: [PATCH 22/44] docs(middleware): add RBAC topics permissions description Add a comment to explain that topics are managed by admins but readable by all authenticated users. --- routes/api/v1/topics/_middleware.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/routes/api/v1/topics/_middleware.dart b/routes/api/v1/topics/_middleware.dart index d2035e8..616d993 100644 --- a/routes/api/v1/topics/_middleware.dart +++ b/routes/api/v1/topics/_middleware.dart @@ -3,6 +3,8 @@ import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/aut 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'; +/// Topics are managed by admins, but are readable by all authenticated users. +/// /// Middleware for the `/api/v1/topics` route. /// /// This middleware chain performs the following actions: From f634ab82ebbb387584da90ed08f7c37b5dc672b6 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 20:19:13 +0100 Subject: [PATCH 23/44] refactor(users): improve middleware for route group - Separate middleware responsibilities for better clarity and maintainability - Enhance permission checking logic for /users and /users/{id} routes - Disallow user creation through /users route, redirecting to /auth routes - Optimize middleware order for improved security and performance --- routes/api/v1/users/_middleware.dart | 89 +++++++++++++++++----------- 1 file changed, 55 insertions(+), 34 deletions(-) diff --git a/routes/api/v1/users/_middleware.dart b/routes/api/v1/users/_middleware.dart index da637e5..54f456f 100644 --- a/routes/api/v1/users/_middleware.dart +++ b/routes/api/v1/users/_middleware.dart @@ -3,43 +3,64 @@ import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/aut 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/users` route. +/// Middleware for the `/api/v1/users` route group. /// -/// 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`. +/// This middleware performs the following actions: +/// 1. `requireAuthentication()`: Ensures a user is authenticated for all +/// /users/* routes. +/// 2. `permissionSetter`: A middleware that 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 `permissionSetter`. Handler middleware(Handler handler) { - return handler - .use( - (handler) => (context) { - final request = context.request; - final String permission; - // A request is for a specific item if it has more than 3 path segments: - // e.g., /api/v1/users/{id} - final isItemRequest = request.uri.pathSegments.length > 3; + // This middleware provides the required permission string into the context. + // It is scoped to only handle `/users` and `/users/{id}`. + // ignore: prefer_function_declarations_over_variables + final permissionSetter = (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 isItemRequest = pathSegments.length == 4; - switch (request.method) { - case HttpMethod.get: - permission = isItemRequest - ? Permissions.userReadOwned - : Permissions.userRead; - case HttpMethod.put: - permission = Permissions.userUpdateOwned; - case HttpMethod.delete: - permission = Permissions.userDeleteOwned; - default: - // Return 405 Method Not Allowed for unsupported methods. - return Response(statusCode: 405); - } - // Provide the required permission to the authorization middleware. - return handler( - context.provide(() => permission), - ); - }, - ) + switch (request.method) { + case HttpMethod.get: + // Admins can list all users; users can read their own profile. + permission = + isItemRequest ? Permissions.userReadOwned : Permissions.userRead; + case HttpMethod.put: + // Users can update their own profile. + permission = Permissions.userUpdateOwned; + case HttpMethod.delete: + // Users can delete their own profile. + permission = Permissions.userDeleteOwned; + default: + // Disallow any other methods (e.g., POST) on this route group. + // User creation is handled by the /auth routes. + return Response(statusCode: 405); + } + // Provide the required permission to the authorization middleware. + return handler( + context.provide(() => permission), + ); + }; + }; + + return handler + // The authorization middleware runs after the permission has been set. .use(authorizationMiddleware()) + // The permission setter runs after authentication is confirmed. + .use(permissionSetter) + // Authentication is the first check for all /users/* routes. .use(requireAuthentication()); } From 57114eaa11c11ae68152685d7e3ddecfdb431e42 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 20:19:25 +0100 Subject: [PATCH 24/44] feat(middleware): enhance user preferences endpoint security - Add authentication and authorization checks - Implement permission-based access control - Support for GET and PUT requests with specific permissions - Return 405 Method Not Allowed for unsupported request methods --- .../users/[id]/preferences/_middleware.dart | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/routes/api/v1/users/[id]/preferences/_middleware.dart b/routes/api/v1/users/[id]/preferences/_middleware.dart index 0065d60..4853856 100644 --- a/routes/api/v1/users/[id]/preferences/_middleware.dart +++ b/routes/api/v1/users/[id]/preferences/_middleware.dart @@ -1,11 +1,40 @@ 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/ownership_check_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permissions.dart'; -/// Applies the ownership check to the user preferences endpoint. +/// Middleware for the user preferences 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 preferences. +/// This chain ensures that: +/// 1. The user is authenticated (handled by the parent `users` middleware). +/// 2. The correct permission (`userContentPreferences...`) is required. +/// 3. The user has that permission. +/// 4. The user is the owner of the preferences resource. Handler middleware(Handler handler) { - return handler.use(userOwnershipMiddleware()); + + return handler + // Final check: ensure the authenticated user owns this resource. + .use(userOwnershipMiddleware()) + // Check if the user has the required permission. + .use(authorizationMiddleware()) + // Provide the specific permission required for this route. + .use(_permissionSetter()); } + +Middleware _permissionSetter() { + return (handler) { + return (context) { + final String permission; + switch (context.request.method) { + case HttpMethod.get: + permission = Permissions.userContentPreferencesReadOwned; + case HttpMethod.put: + permission = Permissions.userContentPreferencesUpdateOwned; + default: + return Response(statusCode: 405); + } + return handler(context.provide(() => permission)); + }; + }; +} + From f94dbde30c11052af47300745e55c40d1fba42e1 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 20:19:39 +0100 Subject: [PATCH 25/44] feat(middleware): enhance user settings endpoint security - Add authentication and authorization checks - Implement permission-based access control --- .../v1/users/[id]/settings/_middleware.dart | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/routes/api/v1/users/[id]/settings/_middleware.dart b/routes/api/v1/users/[id]/settings/_middleware.dart index 5c574e1..6e4e6ef 100644 --- a/routes/api/v1/users/[id]/settings/_middleware.dart +++ b/routes/api/v1/users/[id]/settings/_middleware.dart @@ -1,11 +1,38 @@ 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/ownership_check_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permissions.dart'; -/// Applies the ownership check to the user settings endpoint. +/// Middleware for the user settings 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 settings. +/// This chain ensures that: +/// 1. The user is authenticated (handled by the parent `users` middleware). +/// 2. The correct permission (`userAppSettings...`) is required. +/// 3. The user has that permission. +/// 4. The user is the owner of the settings resource. Handler middleware(Handler handler) { - return handler.use(userOwnershipMiddleware()); + return handler + // Final check: ensure the authenticated user owns this resource. + .use(userOwnershipMiddleware()) + // Check if the user has the required permission. + .use(authorizationMiddleware()) + // Provide the specific permission required for this route. + .use(_permissionSetter()); +} + +Middleware _permissionSetter() { + return (handler) { + return (context) { + final String permission; + switch (context.request.method) { + case HttpMethod.get: + permission = Permissions.userAppSettingsReadOwned; + case HttpMethod.put: + permission = Permissions.userAppSettingsUpdateOwned; + default: + return Response(statusCode: 405); + } + return handler(context.provide(() => permission)); + }; + }; } From d8bccd14c08d3c65cffc1209827fa6282c694e30 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 20:47:45 +0100 Subject: [PATCH 26/44] refactor(remote-config): implement singleton pattern for remote config endpoint - Merge remote-configs and remote-configs/[id] endpoints into a single remote-config endpoint - Update middleware to reflect singleton resource constraints - Modify handler to use a well-known ID for the singleton remote config document - Remove collection operations (GET all, POST) as they are no longer applicable - Update API documentation to reflect changes --- .../_middleware.dart | 10 +- .../[id] => remote-config}/index.dart | 38 ++++--- routes/api/v1/remote-configs/index.dart | 101 ------------------ 3 files changed, 29 insertions(+), 120 deletions(-) rename routes/api/v1/{remote-configs => remote-config}/_middleware.dart (85%) rename routes/api/v1/{remote-configs/[id] => remote-config}/index.dart (55%) delete mode 100644 routes/api/v1/remote-configs/index.dart diff --git a/routes/api/v1/remote-configs/_middleware.dart b/routes/api/v1/remote-config/_middleware.dart similarity index 85% rename from routes/api/v1/remote-configs/_middleware.dart rename to routes/api/v1/remote-config/_middleware.dart index 18fe884..e105f86 100644 --- a/routes/api/v1/remote-configs/_middleware.dart +++ b/routes/api/v1/remote-config/_middleware.dart @@ -3,12 +3,12 @@ import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/aut 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/remote-configs` route. +/// Middleware for the singleton `/api/v1/remote-config` route. /// /// This middleware chain enforces the following access rules: -/// - GET: Requires `remoteConfig.read` permission (all authenticated users). +/// - GET: Requires `remoteConfig.read` permission. /// - PUT: Requires `remoteConfig.update` permission (admin-only). -/// - Other methods (POST, DELETE) are disallowed. +/// - Other methods (POST, DELETE, etc.) are disallowed. Handler middleware(Handler handler) { return handler .use( @@ -19,10 +19,12 @@ Handler middleware(Handler handler) { switch (request.method) { case HttpMethod.get: permission = Permissions.remoteConfigRead; + break; case HttpMethod.put: permission = Permissions.remoteConfigUpdate; + break; default: - // Return 405 Method Not Allowed for unsupported methods like POST/DELETE. + // Return 405 Method Not Allowed for unsupported methods. return Response(statusCode: 405); } // Provide the required permission to the authorization middleware. diff --git a/routes/api/v1/remote-configs/[id]/index.dart b/routes/api/v1/remote-config/index.dart similarity index 55% rename from routes/api/v1/remote-configs/[id]/index.dart rename to routes/api/v1/remote-config/index.dart index f082a0a..99dae14 100644 --- a/routes/api/v1/remote-configs/[id]/index.dart +++ b/routes/api/v1/remote-config/index.dart @@ -6,26 +6,31 @@ 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('remote_configs_item_handler'); +// Logger for this handler. +final _logger = Logger('remote_config_handler'); -/// Handles requests for the /api/v1/remote-configs/[id] endpoint. -/// This is treated as a singleton resource endpoint. -Future onRequest(RequestContext context, String id) async { +// 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, id); + return _handleGet(context); case HttpMethod.put: - return _handlePut(context, id); + return _handlePut(context); default: - // This should be caught by middleware, but as a safeguard: + // 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 a single remote config by its ID. -Future _handleGet(RequestContext context, String id) async { +/// Handles GET requests: Retrieves the singleton remote config. +Future _handleGet(RequestContext context) async { final repo = context.read>(); - final item = await repo.read(id: id); + // Fetch the single configuration document using the well-known ID. + final item = await repo.read(id: _singletonId); return ResponseHelper.success( context: context, @@ -34,21 +39,24 @@ Future _handleGet(RequestContext context, String id) async { ); } -/// Handles PUT requests: Updates an existing remote config by its ID. -Future _handlePut(RequestContext context, String id) async { +/// 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 { - requestBody['id'] = id; + // 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-configs/[id]', e, s); + _logger.warning('Deserialization TypeError in PUT /remote-config', e, s); throw const BadRequestException( 'Invalid request body: Missing or invalid required field(s).', ); @@ -56,7 +64,7 @@ Future _handlePut(RequestContext context, String id) async { final repo = context.read>(); final updatedItem = await repo.update( - id: id, + id: _singletonId, item: itemToUpdate, ); diff --git a/routes/api/v1/remote-configs/index.dart b/routes/api/v1/remote-configs/index.dart deleted file mode 100644 index 1312c36..0000000 --- a/routes/api/v1/remote-configs/index.dart +++ /dev/null @@ -1,101 +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:mongo_dart/mongo_dart.dart'; - -/// Handles requests for the /api/v1/remote-configs 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 remote configs. -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.toJson( - (item) => item.toJson(), - ), - ); -} - -/// Handles POST requests: Creates a new remote config. -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(); - requestBody['id'] = ObjectId().oid; - requestBody['createdAt'] = now.toIso8601String(); - requestBody['updatedAt'] = now.toIso8601String(); - - final itemToCreate = RemoteConfig.fromJson(requestBody); - - final repo = context.read>(); - final createdItem = await repo.create(item: itemToCreate); - - return ResponseHelper.success( - context: context, - data: createdItem, - toJsonT: (item) => item.toJson(), - statusCode: HttpStatus.created, - ); -} From 7ab8eca0eb25caab2e0848512b7725322d70f658 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 20:52:57 +0100 Subject: [PATCH 27/44] chore: misc --- .../providers/countries_client_provider.dart | 39 -- lib/src/registry/model_registry.dart | 369 ----------- routes/api/v1/data/[id]/_middleware.dart | 18 - routes/api/v1/data/[id]/index.dart | 574 ------------------ routes/api/v1/data/_middleware.dart | 129 ---- routes/api/v1/data/index.dart | 236 ------- 6 files changed, 1365 deletions(-) delete mode 100644 lib/src/providers/countries_client_provider.dart delete mode 100644 lib/src/registry/model_registry.dart delete mode 100644 routes/api/v1/data/[id]/_middleware.dart delete mode 100644 routes/api/v1/data/[id]/index.dart delete mode 100644 routes/api/v1/data/_middleware.dart delete mode 100644 routes/api/v1/data/index.dart 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/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, - ); -} From c0573a1aba019c68b96de8361b429167e20a97d5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 20:54:07 +0100 Subject: [PATCH 28/44] lint: misc --- routes/api/v1/remote-config/_middleware.dart | 2 -- routes/api/v1/users/[id]/preferences/_middleware.dart | 2 -- 2 files changed, 4 deletions(-) diff --git a/routes/api/v1/remote-config/_middleware.dart b/routes/api/v1/remote-config/_middleware.dart index e105f86..d8871cc 100644 --- a/routes/api/v1/remote-config/_middleware.dart +++ b/routes/api/v1/remote-config/_middleware.dart @@ -19,10 +19,8 @@ Handler middleware(Handler handler) { switch (request.method) { case HttpMethod.get: permission = Permissions.remoteConfigRead; - break; case HttpMethod.put: permission = Permissions.remoteConfigUpdate; - break; default: // Return 405 Method Not Allowed for unsupported methods. return Response(statusCode: 405); diff --git a/routes/api/v1/users/[id]/preferences/_middleware.dart b/routes/api/v1/users/[id]/preferences/_middleware.dart index 4853856..af953a8 100644 --- a/routes/api/v1/users/[id]/preferences/_middleware.dart +++ b/routes/api/v1/users/[id]/preferences/_middleware.dart @@ -11,7 +11,6 @@ import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission /// 3. The user has that permission. /// 4. 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()) @@ -37,4 +36,3 @@ Middleware _permissionSetter() { }; }; } - From 3fec2c0a8a1b22643c9f9d2c52e3b33f87e3d79c Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 20:56:03 +0100 Subject: [PATCH 29/44] refactor(middlewares): remove unused ModelRegistry import and usage - Remove unused import of ModelRegistryMap - Remove unused provider of ModelRegistryMap in middleware function --- routes/_middleware.dart | 2 -- 1 file changed, 2 deletions(-) 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, From d19dedd6b93e23ae9cb361509cbfdb9ad7aba9a5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 21:17:02 +0100 Subject: [PATCH 30/44] refactor(env): update rate limit configuration examples - Rename generic DATA API rate limit settings to more specific READ and WRITE limits - Increase default READ limit from 1000 to 5000 requests per hour - Set default WRITE limit to 500 requests per hour - Update comments to clarify the purpose of each rate limit setting --- .env.example | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 171a774..14911d3 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=5000 +# 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=500 +# OPTIONAL: Window for WRITE operations, in minutes. +# RATE_LIMIT_WRITE_WINDOW_MINUTES=60 From 1b60d057c3931db23403255c215d1d6ad8bbaf01 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 21:17:35 +0100 Subject: [PATCH 31/44] chore(env): decrease default rate limit values - Lower default READ rate limit from 5000 to 500 requests per hour - Reduce default WRITE rate limit from 500 to 50 requests per hour - These changes provide a more restrictive starting point for rate limiting, which can help protect against abuse while still allowing for reasonable usage. --- .env.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 14911d3..80e246b 100644 --- a/.env.example +++ b/.env.example @@ -48,12 +48,12 @@ # RATE_LIMIT_REQUEST_CODE_WINDOW_HOURS=24 # OPTIONAL: Rate limit for general READ operations (e.g., GET /headlines). -# RATE_LIMIT_READ_LIMIT=5000 +# 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=500 +# RATE_LIMIT_WRITE_LIMIT=50 # OPTIONAL: Window for WRITE operations, in minutes. # RATE_LIMIT_WRITE_WINDOW_MINUTES=60 From 5749d7f1a1c47b9029866c4c344db9dd7d2b5dbf Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 21:17:59 +0100 Subject: [PATCH 32/44] feat(config): implement separate rate limits for read and write operations - Rename `rateLimitDataApiLimit` to `rateLimitReadLimit` and adjust default value - Rename `rateLimitDataApiWindow` to `rateLimitReadWindow` - Add new `rateLimitWriteLimit` and `rateLimitWriteWindow` configurations - Update parameter names and comments for clarity --- lib/src/config/environment_config.dart | 30 ++++++++++++++++++++------ 1 file changed, 23 insertions(+), 7 deletions(-) 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); } } From a66acba218cf329e904a4e317630992aa0214d0b Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 21:18:16 +0100 Subject: [PATCH 33/44] feat(middlewares): implement configurable rate limiter with RBAC support - Add configured_rate_limiter.dart with implementations for rate limiting - Introduce role-aware rate limiter that respects bypass permissions - Provide pre-configured rate limiters for READ and WRITE operations - Use authenticated user's ID as the key for rate limiting --- .../middlewares/configured_rate_limiter.dart | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 lib/src/middlewares/configured_rate_limiter.dart 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, + ); +} From cf3a406d83dbccd6d3d5217a53e3b739ba8a5cbf Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 21:18:28 +0100 Subject: [PATCH 34/44] feat(headlines): apply rate limiting to headline endpoints - Add rate limiting middleware to all headline endpoints - Implement separate rate limiters for read and write operations - Update middleware documentation to reflect new rate limiting functionality --- routes/api/v1/headlines/_middleware.dart | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/routes/api/v1/headlines/_middleware.dart b/routes/api/v1/headlines/_middleware.dart index c4b40eb..e7b5c29 100644 --- a/routes/api/v1/headlines/_middleware.dart +++ b/routes/api/v1/headlines/_middleware.dart @@ -1,34 +1,47 @@ 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. +/// 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(); + break; case HttpMethod.post: permission = Permissions.headlineCreate; + rateLimiter = createWriteRateLimiter(); + break; case HttpMethod.put: permission = Permissions.headlineUpdate; + rateLimiter = createWriteRateLimiter(); + break; case HttpMethod.delete: permission = Permissions.headlineDelete; + rateLimiter = createWriteRateLimiter(); + break; default: // Return 405 Method Not Allowed for unsupported methods. return Response(statusCode: 405); } - // Provide the required permission to the authorization middleware. - return handler( - context.provide(() => permission), - ); + + // Apply the selected rate limiter and then provide the permission. + return rateLimiter( + (context) => handler( + context.provide(() => permission), + ), + )(context); }, ) .use(authorizationMiddleware()) From c94480e4feb2967a4fe065400f8563811a953665 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 21:42:28 +0100 Subject: [PATCH 35/44] feat(countries): implement rate limiting for country routes - Add rate limiting to the countries API routes - Implement read rate limiting for GET requests - Update middleware to include rate limiting functionality - Import configured_rate_limiter for rate limiting capabilities --- routes/api/v1/countries/_middleware.dart | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/routes/api/v1/countries/_middleware.dart b/routes/api/v1/countries/_middleware.dart index b7118a3..d31f332 100644 --- a/routes/api/v1/countries/_middleware.dart +++ b/routes/api/v1/countries/_middleware.dart @@ -1,11 +1,12 @@ 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. +/// managed by database seeding. This middleware also applies rate limiting. /// /// Middleware for the `/api/v1/countries` route. /// @@ -21,19 +22,23 @@ Handler middleware(Handler handler) { (handler) => (context) { final request = context.request; final String permission; - + final Middleware rateLimiter; + switch (request.method) { case HttpMethod.get: - // Both collection and item GET requests use the same permission. permission = Permissions.countryRead; + rateLimiter = createReadRateLimiter(); default: // Return 405 Method Not Allowed for unsupported methods. return Response(statusCode: 405); } - // Provide the required permission to the authorization middleware. - return handler( - context.provide(() => permission), - ); + + // Apply the selected rate limiter and then provide the permission. + return rateLimiter( + (context) => handler( + context.provide(() => permission), + ), + )(context); }, ) .use(authorizationMiddleware()) From 4f33218defff20cdec56eadf8621f35e511c0694 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 21:43:20 +0100 Subject: [PATCH 36/44] style: remove unnecessary break statements in middleware - Removed break statements after setting rateLimiter in switch case - This change simplifies the code structure and improves readability --- routes/api/v1/headlines/_middleware.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/routes/api/v1/headlines/_middleware.dart b/routes/api/v1/headlines/_middleware.dart index e7b5c29..a630c08 100644 --- a/routes/api/v1/headlines/_middleware.dart +++ b/routes/api/v1/headlines/_middleware.dart @@ -18,19 +18,15 @@ Handler middleware(Handler handler) { case HttpMethod.get: permission = Permissions.headlineRead; rateLimiter = createReadRateLimiter(); - break; case HttpMethod.post: permission = Permissions.headlineCreate; rateLimiter = createWriteRateLimiter(); - break; case HttpMethod.put: permission = Permissions.headlineUpdate; rateLimiter = createWriteRateLimiter(); - break; case HttpMethod.delete: permission = Permissions.headlineDelete; rateLimiter = createWriteRateLimiter(); - break; default: // Return 405 Method Not Allowed for unsupported methods. return Response(statusCode: 405); From e4dbb401c816a290f1d17ec66f93163e65755dcf Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 21:44:04 +0100 Subject: [PATCH 37/44] feat(languages): apply rate limiting to middleware - Add rate limiting to GET requests for languages endpoint - Import configured_rate_limiter package - Update middleware documentation to reflect new rate limiting feature - Implement rate limiter in the request handling pipeline --- routes/api/v1/languages/_middleware.dart | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/routes/api/v1/languages/_middleware.dart b/routes/api/v1/languages/_middleware.dart index 794b007..405320e 100644 --- a/routes/api/v1/languages/_middleware.dart +++ b/routes/api/v1/languages/_middleware.dart @@ -1,11 +1,12 @@ 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. +/// managed by database seeding. This middleware also applies rate limiting. /// /// Middleware for the `/api/v1/languages` route. /// @@ -21,19 +22,23 @@ Handler middleware(Handler handler) { (handler) => (context) { final request = context.request; final String permission; - + final Middleware rateLimiter; + switch (request.method) { case HttpMethod.get: - // Both collection and item GET requests use the same permission. permission = Permissions.languageRead; + rateLimiter = createReadRateLimiter(); default: // Return 405 Method Not Allowed for unsupported methods. return Response(statusCode: 405); } - // Provide the required permission to the authorization middleware. - return handler( - context.provide(() => permission), - ); + + // Apply the selected rate limiter and then provide the permission. + return rateLimiter( + (context) => handler( + context.provide(() => permission), + ), + )(context); }, ) .use(authorizationMiddleware()) From 8289d1031b038eaa64f6639349043b0ed02f2f02 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 21:44:27 +0100 Subject: [PATCH 38/44] feat(remote-config): implement rate limiting for API routes - Add rate limiting to GET and PUT requests - Introduce configured_rate_limiter middleware - Update middleware chain to apply rate limiting before authorization --- routes/api/v1/remote-config/_middleware.dart | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/routes/api/v1/remote-config/_middleware.dart b/routes/api/v1/remote-config/_middleware.dart index d8871cc..821597d 100644 --- a/routes/api/v1/remote-config/_middleware.dart +++ b/routes/api/v1/remote-config/_middleware.dart @@ -1,11 +1,12 @@ 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: +/// 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. @@ -15,20 +16,25 @@ Handler middleware(Handler handler) { (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); } - // Provide the required permission to the authorization middleware. - return handler( - context.provide(() => permission), - ); + // Apply the selected rate limiter and then provide the permission. + return rateLimiter( + (context) => handler( + context.provide(() => permission), + ), + )(context); }, ) .use(authorizationMiddleware()) From 9782b62aeab9f8e3ead98723c78519f7ad7f6412 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 21:45:13 +0100 Subject: [PATCH 39/44] feat(sources): implement rate limiting for sources endpoints - Add rate limiting middleware for all sources endpoints - Implement separate rate limiters for read and write operations - Update middleware documentation to reflect new rate limiting feature --- routes/api/v1/sources/_middleware.dart | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/routes/api/v1/sources/_middleware.dart b/routes/api/v1/sources/_middleware.dart index 2669fb9..a00202d 100644 --- a/routes/api/v1/sources/_middleware.dart +++ b/routes/api/v1/sources/_middleware.dart @@ -1,9 +1,11 @@ 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. /// @@ -19,25 +21,32 @@ Handler middleware(Handler handler) { (handler) => (context) { final request = context.request; final String permission; - + final Middleware rateLimiter; + switch (request.method) { case HttpMethod.get: - // Both collection and item GET requests use the same permission. 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); } - // Provide the required permission to the authorization middleware. - return handler( - context.provide(() => permission), - ); + + // Apply the selected rate limiter and then provide the permission. + return rateLimiter( + (context) => handler( + context.provide(() => permission), + ), + )(context); }, ) .use(authorizationMiddleware()) From 6228ce2175ea8f66486f59bdf2962d5ef37e14c7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 21:45:25 +0100 Subject: [PATCH 40/44] feat(topics): implement rate limiting for API endpoints - Add rate limiting middleware to topics route - Implement separate rate limiters for read and write operations - Update middleware to use rate limiter for non-GET requests --- routes/api/v1/topics/_middleware.dart | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/routes/api/v1/topics/_middleware.dart b/routes/api/v1/topics/_middleware.dart index 616d993..48c9c58 100644 --- a/routes/api/v1/topics/_middleware.dart +++ b/routes/api/v1/topics/_middleware.dart @@ -1,9 +1,11 @@ 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. /// @@ -19,25 +21,32 @@ Handler middleware(Handler handler) { (handler) => (context) { final request = context.request; final String permission; - + final Middleware rateLimiter; + switch (request.method) { case HttpMethod.get: - // Both collection and item GET requests use the same permission. 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); } - // Provide the required permission to the authorization middleware. - return handler( - context.provide(() => permission), - ); + + // Apply the selected rate limiter and then provide the permission. + return rateLimiter( + (context) => handler( + context.provide(() => permission), + ), + )(context); }, ) .use(authorizationMiddleware()) From d4705769ca51746fc9db210aa88f822144158ce7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 21:45:37 +0100 Subject: [PATCH 41/44] feat(users): implement rate limiting for users endpoint - Add `configured_rate_limiter` import to middleware dependencies - Refactor `permissionSetter` to `rateAndPermissionSetter` to include rate limiting - Apply rate limiting to GET, PUT, and DELETE requests on users endpoints - Update middleware documentation to reflect new rate limiting functionality --- routes/api/v1/users/_middleware.dart | 35 +++++++++++++++++----------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/routes/api/v1/users/_middleware.dart b/routes/api/v1/users/_middleware.dart index 54f456f..dcb3526 100644 --- a/routes/api/v1/users/_middleware.dart +++ b/routes/api/v1/users/_middleware.dart @@ -1,6 +1,7 @@ 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. @@ -8,17 +9,18 @@ import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission /// This middleware performs the following actions: /// 1. `requireAuthentication()`: Ensures a user is authenticated for all /// /users/* routes. -/// 2. `permissionSetter`: A middleware that 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. +/// 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 `permissionSetter`. +/// permission provided by the `rateAndPermissionSetter`. Handler middleware(Handler handler) { - // This middleware provides the required permission string into the context. + // 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 permissionSetter = (Handler handler) { + final rateAndPermissionSetter = (Handler handler) { return (RequestContext context) { final request = context.request; final pathSegments = request.uri.pathSegments; @@ -31,6 +33,7 @@ Handler middleware(Handler handler) { } final String permission; + final Middleware rateLimiter; final isItemRequest = pathSegments.length == 4; switch (request.method) { @@ -38,29 +41,35 @@ Handler middleware(Handler handler) { // 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); } - // Provide the required permission to the authorization middleware. - return handler( - context.provide(() => permission), - ); + + // 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 permission setter runs after authentication is confirmed. - .use(permissionSetter) + // The rate limiter and permission setter runs after authentication. + .use(rateAndPermissionSetter) // Authentication is the first check for all /users/* routes. .use(requireAuthentication()); } From 0c7578aab7b8709a0f72d666c2d50b0eabd84124 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 21:47:11 +0100 Subject: [PATCH 42/44] feat(middlewares): enhance user preferences endpoint with rate limiting - Add configured_rate_limiter import to apply rate limiting - Update middleware chain to include rate limiting for different HTTP methods - Refactor _permissionSetter to _rateAndPermissionSetter to incorporate rate limiting - Apply read and write rate limiters based on the HTTP method --- .../users/[id]/preferences/_middleware.dart | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/routes/api/v1/users/[id]/preferences/_middleware.dart b/routes/api/v1/users/[id]/preferences/_middleware.dart index af953a8..a5dd3bd 100644 --- a/routes/api/v1/users/[id]/preferences/_middleware.dart +++ b/routes/api/v1/users/[id]/preferences/_middleware.dart @@ -1,5 +1,6 @@ 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'; @@ -7,32 +8,42 @@ import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission /// /// This chain ensures that: /// 1. The user is authenticated (handled by the parent `users` middleware). -/// 2. The correct permission (`userContentPreferences...`) is required. -/// 3. The user has that permission. -/// 4. The user is the owner of the preferences resource. +/// 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()) - // Provide the specific permission required for this route. - .use(_permissionSetter()); + // Apply rate limiting and provide the specific permission for this route. + .use(_rateAndPermissionSetter()); } -Middleware _permissionSetter() { +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 handler(context.provide(() => permission)); + + return rateLimiter( + (context) => handler( + context.provide(() => permission), + ), + )(context); }; }; } From f44bf425baab1ed6063408654cb4bd7b53a4b574 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 21:47:18 +0100 Subject: [PATCH 43/44] feat(middleware): enhance user settings endpoint with rate limiting - Add configured_rate_limiter import for rate limiting functionality - Implement rate limiting in the user settings middleware chain - Update middleware documentation to reflect new rate limiting step - Modify _rateAndPermissionSetter function to handle rate limiting and permission setting --- .../v1/users/[id]/settings/_middleware.dart | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/routes/api/v1/users/[id]/settings/_middleware.dart b/routes/api/v1/users/[id]/settings/_middleware.dart index 6e4e6ef..392647e 100644 --- a/routes/api/v1/users/[id]/settings/_middleware.dart +++ b/routes/api/v1/users/[id]/settings/_middleware.dart @@ -1,5 +1,6 @@ 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'; @@ -7,32 +8,42 @@ import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission /// /// This chain ensures that: /// 1. The user is authenticated (handled by the parent `users` middleware). -/// 2. The correct permission (`userAppSettings...`) is required. -/// 3. The user has that permission. -/// 4. The user is the owner of the settings resource. +/// 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()) - // Provide the specific permission required for this route. - .use(_permissionSetter()); + // Apply rate limiting and provide the specific permission for this route. + .use(_rateAndPermissionSetter()); } -Middleware _permissionSetter() { +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 handler(context.provide(() => permission)); + + return rateLimiter( + (context) => handler( + context.provide(() => permission), + ), + )(context); }; }; } From 43a49783236c6a8bbe263f61ccd02cbf8167114f Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 5 Aug 2025 21:54:19 +0100 Subject: [PATCH 44/44] docs(README): update data management API description - Remove outdated information about managing core news data --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. 📈