Skip to content

Commit b71dd1f

Browse files
authored
Merge pull request #34 from flutter-news-app-full-source-code/migrate-from-generig-data-route-into-a-restful-one
Migrate from generig data route into a restful one
2 parents a575011 + 43a4978 commit b71dd1f

40 files changed

+1855
-1553
lines changed

.env.example

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,13 @@
4747
# OPTIONAL: Window for the /auth/request-code endpoint, in hours.
4848
# RATE_LIMIT_REQUEST_CODE_WINDOW_HOURS=24
4949

50-
# OPTIONAL: Limit for the generic /data API endpoints (requests per window).
51-
# RATE_LIMIT_DATA_API_LIMIT=1000
52-
53-
# OPTIONAL: Window for the /data API endpoints, in minutes.
54-
# RATE_LIMIT_DATA_API_WINDOW_MINUTES=60
50+
# OPTIONAL: Rate limit for general READ operations (e.g., GET /headlines).
51+
# RATE_LIMIT_READ_LIMIT=500
52+
# OPTIONAL: Window for READ operations, in minutes.
53+
# RATE_LIMIT_READ_WINDOW_MINUTES=60
54+
55+
# OPTIONAL: Rate limit for general WRITE operations (e.g., POST /headlines).
56+
# This is typically stricter than the read limit.
57+
# RATE_LIMIT_WRITE_LIMIT=50
58+
# OPTIONAL: Window for WRITE operations, in minutes.
59+
# RATE_LIMIT_WRITE_WINDOW_MINUTES=60

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ This API server comes packed with all the features you need to launch a professi
4040
> **Your Advantage:** Deliver a seamless, personalized experience that keeps users' settings in sync, boosting engagement and retention. ❤️
4141
4242
#### 💾 **Robust Data Management API**
43-
* Securely manage all your core news data, including headlines, topics, sources, and countries.
43+
* Leverages a clean, RESTful architecture with dedicated endpoints for each resource (headlines, topics, sources, etc.), following industry best practices.
4444
* The API supports flexible querying, filtering, and sorting, allowing your app to display dynamic content feeds.
4545
> **Your Advantage:** A powerful and secure data backend that's ready to scale with your content needs. 📈
4646

lib/src/config/environment_config.dart

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -157,19 +157,35 @@ abstract final class EnvironmentConfig {
157157
return Duration(hours: hours);
158158
}
159159

160-
/// Retrieves the request limit for the data API endpoints.
160+
/// Retrieves the request limit for READ operations.
161161
///
162-
/// Defaults to 1000 if not set or if parsing fails.
163-
static int get rateLimitDataApiLimit {
164-
return int.tryParse(_env['RATE_LIMIT_DATA_API_LIMIT'] ?? '1000') ?? 1000;
162+
/// Defaults to 5000 if not set or if parsing fails.
163+
static int get rateLimitReadLimit {
164+
return int.tryParse(_env['RATE_LIMIT_READ_LIMIT'] ?? '500') ?? 500;
165165
}
166166

167-
/// Retrieves the time window for the data API rate limit.
167+
/// Retrieves the time window for the READ operations rate limit.
168168
///
169169
/// Defaults to 60 minutes if not set or if parsing fails.
170-
static Duration get rateLimitDataApiWindow {
170+
static Duration get rateLimitReadWindow {
171171
final minutes =
172-
int.tryParse(_env['RATE_LIMIT_DATA_API_WINDOW_MINUTES'] ?? '60') ?? 60;
172+
int.tryParse(_env['RATE_LIMIT_READ_WINDOW_MINUTES'] ?? '60') ?? 60;
173+
return Duration(minutes: minutes);
174+
}
175+
176+
/// Retrieves the request limit for WRITE operations.
177+
///
178+
/// Defaults to 500 if not set or if parsing fails.
179+
static int get rateLimitWriteLimit {
180+
return int.tryParse(_env['RATE_LIMIT_WRITE_LIMIT'] ?? '50') ?? 50;
181+
}
182+
183+
/// Retrieves the time window for the WRITE operations rate limit.
184+
///
185+
/// Defaults to 60 minutes if not set or if parsing fails.
186+
static Duration get rateLimitWriteWindow {
187+
final minutes =
188+
int.tryParse(_env['RATE_LIMIT_WRITE_WINDOW_MINUTES'] ?? '60') ?? 60;
173189
return Duration(minutes: minutes);
174190
}
175191
}

lib/src/middlewares/authorization_middleware.dart

Lines changed: 20 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,24 @@
11
import 'package:core/core.dart';
22
import 'package:dart_frog/dart_frog.dart';
33
import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission_service.dart';
4-
import 'package:flutter_news_app_api_server_full_source_code/src/registry/model_registry.dart';
54
import 'package:logging/logging.dart';
65

76
final _log = Logger('AuthorizationMiddleware');
87

98
/// {@template authorization_middleware}
10-
/// Middleware to enforce role-based permissions and model-specific access rules.
9+
/// Middleware to enforce role-based permissions.
1110
///
12-
/// This middleware reads the authenticated [User], the requested `modelName`,
13-
/// the `HttpMethod`, and the `ModelConfig` from the request context. It then
14-
/// determines the required permission based on the `ModelConfig` metadata for
15-
/// the specific HTTP method and checks if the authenticated user has that
11+
/// This middleware reads the authenticated [User] and a required `permission`
12+
/// string from the request context. It then checks if the user has that
1613
/// permission using the [PermissionService].
1714
///
15+
/// The required permission string must be provided into the context by an
16+
/// earlier middleware, typically one specific to the route group.
17+
///
1818
/// If the user does not have the required permission, it throws a
19-
/// [ForbiddenException], which should be caught by the 'errorHandler' middleware.
19+
/// [ForbiddenException], which should be caught by the `errorHandler` middleware.
2020
///
21-
/// This middleware runs *after* authentication and model validation.
22-
/// It does NOT perform instance-level ownership checks; those are handled
23-
/// by the route handlers (`index.dart`, `[id].dart`) if required by the
24-
/// `ModelActionPermission.requiresOwnershipCheck` flag.
21+
/// This middleware runs *after* authentication.
2522
/// {@endtemplate}
2623
Middleware authorizationMiddleware() {
2724
return (handler) {
@@ -30,90 +27,22 @@ Middleware authorizationMiddleware() {
3027
// User is guaranteed non-null by requireAuthentication() middleware.
3128
final user = context.read<User>();
3229
final permissionService = context.read<PermissionService>();
33-
final modelName = context.read<String>(); // Provided by data/_middleware
34-
final modelConfig = context
35-
.read<ModelConfig<dynamic>>(); // Provided by data/_middleware
36-
final method = context.request.method;
37-
38-
// Determine if the request is for the collection or an item
39-
// The collection path is /api/v1/data
40-
// Item paths are /api/v1/data/[id]
41-
final isCollectionRequest = context.request.uri.path == '/api/v1/data';
30+
final permission = context.read<String>();
4231

43-
// Determine the required permission configuration based on the HTTP method
44-
ModelActionPermission requiredPermissionConfig;
45-
switch (method) {
46-
case HttpMethod.get:
47-
// Differentiate GET based on whether it's a collection or item request
48-
if (isCollectionRequest) {
49-
requiredPermissionConfig = modelConfig.getCollectionPermission;
50-
} else {
51-
requiredPermissionConfig = modelConfig.getItemPermission;
52-
}
53-
case HttpMethod.post:
54-
requiredPermissionConfig = modelConfig.postPermission;
55-
case HttpMethod.put:
56-
requiredPermissionConfig = modelConfig.putPermission;
57-
case HttpMethod.delete:
58-
requiredPermissionConfig = modelConfig.deletePermission;
59-
default:
60-
// Should ideally be caught earlier by Dart Frog's routing,
61-
// but as a safeguard, deny unsupported methods.
62-
throw const ForbiddenException(
63-
'Method not supported for this resource.',
64-
);
32+
if (!permissionService.hasPermission(user, permission)) {
33+
_log.warning(
34+
'User ${user.id} denied access to permission "$permission".',
35+
);
36+
throw const ForbiddenException(
37+
'You do not have permission to perform this action.',
38+
);
6539
}
6640

67-
// Perform the permission check based on the configuration type
68-
switch (requiredPermissionConfig.type) {
69-
case RequiredPermissionType.none:
70-
// No specific permission required (beyond authentication if applicable)
71-
// This case is primarily for documentation/completeness if a route
72-
// group didn't require authentication, but the /data route does.
73-
// For the /data route, 'none' effectively means 'authenticated users allowed'.
74-
break;
75-
case RequiredPermissionType.adminOnly:
76-
// Requires the user to be an admin
77-
if (!permissionService.isAdmin(user)) {
78-
throw const ForbiddenException(
79-
'Only administrators can perform this action.',
80-
);
81-
}
82-
case RequiredPermissionType.specificPermission:
83-
// Requires a specific permission string
84-
final permission = requiredPermissionConfig.permission;
85-
if (permission == null) {
86-
// This indicates a configuration error in ModelRegistry
87-
_log.severe(
88-
'Configuration Error: specificPermission type requires a '
89-
'permission string for model "$modelName", method "$method".',
90-
);
91-
throw const OperationFailedException(
92-
'Internal Server Error: Authorization configuration error.',
93-
);
94-
}
95-
if (!permissionService.hasPermission(user, permission)) {
96-
throw const ForbiddenException(
97-
'You do not have permission to perform this action.',
98-
);
99-
}
100-
case RequiredPermissionType.unsupported:
101-
// This action is explicitly marked as not supported via this generic route.
102-
// Return Method Not Allowed.
103-
_log.warning(
104-
'Action for model "$modelName", method "$method" is marked as '
105-
'unsupported via generic route.',
106-
);
107-
// Throw ForbiddenException to be caught by the errorHandler
108-
throw ForbiddenException(
109-
'Method "$method" is not supported for model "$modelName" '
110-
'via this generic data endpoint.',
111-
);
112-
}
41+
_log.finer(
42+
'User ${user.id} granted access to permission "$permission".',
43+
);
11344

114-
// If all checks pass, proceed to the next handler in the chain.
115-
// Instance-level ownership checks (if requiredPermissionConfig.requiresOwnershipCheck is true)
116-
// are handled by the route handlers themselves.
45+
// If the check passes, proceed to the next handler.
11746
return handler(context);
11847
};
11948
};
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import 'package:core/core.dart';
2+
import 'package:dart_frog/dart_frog.dart';
3+
import 'package:flutter_news_app_api_server_full_source_code/src/config/environment_config.dart';
4+
import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/rate_limiter_middleware.dart';
5+
import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission_service.dart';
6+
import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permissions.dart';
7+
8+
/// A key extractor that uses the authenticated user's ID.
9+
///
10+
/// This should be used for routes that are protected by authentication,
11+
/// ensuring that the rate limit is applied on a per-user basis.
12+
Future<String?> _userKeyExtractor(RequestContext context) async {
13+
return context.read<User>().id;
14+
}
15+
16+
/// A role-aware middleware factory that applies a rate limit only if the
17+
/// authenticated user does not have the `rateLimiting.bypass` permission.
18+
Middleware _createRoleAwareRateLimiter({
19+
required int limit,
20+
required Duration window,
21+
required Future<String?> Function(RequestContext) keyExtractor,
22+
}) {
23+
return (handler) {
24+
return (context) {
25+
// Read dependencies from the context.
26+
final permissionService = context.read<PermissionService>();
27+
final user = context.read<User>(); // Assumes user is authenticated
28+
29+
// Check for the bypass permission.
30+
if (permissionService.hasPermission(user, Permissions.rateLimitingBypass)) {
31+
// If the user has the bypass permission, skip the rate limiter.
32+
return handler(context);
33+
}
34+
35+
// If the user does not have the bypass permission, apply the rate limiter.
36+
return rateLimiter(
37+
limit: limit,
38+
window: window,
39+
keyExtractor: keyExtractor,
40+
)(handler)(context);
41+
};
42+
};
43+
}
44+
45+
/// Creates a pre-configured, role-aware rate limiter for READ operations.
46+
///
47+
/// This middleware will:
48+
/// 1. Check if the authenticated user has the `rateLimiting.bypass` permission.
49+
/// If so, the check is skipped.
50+
/// 2. If not, it applies the rate limit defined by `RATE_LIMIT_READ_LIMIT`
51+
/// and `RATE_LIMIT_READ_WINDOW_MINUTES` from the environment.
52+
/// 3. It uses the authenticated user's ID as the key for the rate limit.
53+
Middleware createReadRateLimiter() {
54+
return _createRoleAwareRateLimiter(
55+
limit: EnvironmentConfig.rateLimitReadLimit,
56+
window: EnvironmentConfig.rateLimitReadWindow,
57+
keyExtractor: _userKeyExtractor,
58+
);
59+
}
60+
61+
/// Creates a pre-configured, role-aware rate limiter for WRITE operations.
62+
///
63+
/// This middleware will:
64+
/// 1. Check if the authenticated user has the `rateLimiting.bypass` permission.
65+
/// If so, the check is skipped.
66+
/// 2. If not, it applies the stricter rate limit defined by
67+
/// `RATE_LIMIT_WRITE_LIMIT` and `RATE_LIMIT_WRITE_WINDOW_MINUTES` from
68+
/// the environment.
69+
/// 3. It uses the authenticated user's ID as the key for the rate limit.
70+
Middleware createWriteRateLimiter() {
71+
return _createRoleAwareRateLimiter(
72+
limit: EnvironmentConfig.rateLimitWriteLimit,
73+
window: EnvironmentConfig.rateLimitWriteWindow,
74+
keyExtractor: _userKeyExtractor,
75+
);
76+
}

0 commit comments

Comments
 (0)