Skip to content

Migrate from generig data route into a restful one #34

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 44 commits into from
Aug 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
68160ec
feat(api): implement headlines collection endpoint
fulleni Aug 5, 2025
75bdb02
feat(api): add middleware for headlines route
fulleni Aug 5, 2025
415abf3
feat(api): implement headlines item endpoint
fulleni Aug 5, 2025
699be85
feat(middlewares): add middlewares for headline entity
fulleni Aug 5, 2025
91353e0
feat(api): implement countries endpoint
fulleni Aug 5, 2025
c322fed
feat(api): implement dashboard summary endpoint
fulleni Aug 5, 2025
9384abe
refactor(api): improve permission handling for headline endpoints
fulleni Aug 5, 2025
7510a30
feat(api): implement languages endpoint
fulleni Aug 5, 2025
6d6e420
feat(api): implement remote-configs endpoints
fulleni Aug 5, 2025
d0ad33f
feat(api): implement sources CRUD endpoints
fulleni Aug 5, 2025
83c0be0
feat(api): implement topics CRUD endpoints
fulleni Aug 5, 2025
7aa22a9
feat(api): implement users endpoint
fulleni Aug 5, 2025
1231089
feat(api): implement user preferences and settings endpoints
fulleni Aug 5, 2025
b3f7472
refactor(middlewares): simplify authorization middleware
fulleni Aug 5, 2025
37b03b1
refactor(middlewares): simplify user ownership check middleware
fulleni Aug 5, 2025
eb31c14
refactor(users): apply ownership check middleware to user endpoints
fulleni Aug 5, 2025
860ce8f
refactor: remove unused middleware file
fulleni Aug 5, 2025
4312c91
refactor(middleware): add comments to countries middleware
fulleni Aug 5, 2025
454bdd6
refactor(headlines): simplify permission logic in middleware
fulleni Aug 5, 2025
8426d2e
refactor(api): add comments to explain languages endpoint restrictions
fulleni Aug 5, 2025
1431b8c
docs(middleware): add documentation for sources middleware
fulleni Aug 5, 2025
cb7c212
docs(middleware): add RBAC topics permissions description
fulleni Aug 5, 2025
f634ab8
refactor(users): improve middleware for route group
fulleni Aug 5, 2025
57114ea
feat(middleware): enhance user preferences endpoint security
fulleni Aug 5, 2025
f94dbde
feat(middleware): enhance user settings endpoint security
fulleni Aug 5, 2025
d8bccd1
refactor(remote-config): implement singleton pattern for remote confi…
fulleni Aug 5, 2025
7ab8eca
chore: misc
fulleni Aug 5, 2025
c0573a1
lint: misc
fulleni Aug 5, 2025
3fec2c0
refactor(middlewares): remove unused ModelRegistry import and usage
fulleni Aug 5, 2025
d19dedd
refactor(env): update rate limit configuration examples
fulleni Aug 5, 2025
1b60d05
chore(env): decrease default rate limit values
fulleni Aug 5, 2025
5749d7f
feat(config): implement separate rate limits for read and write opera…
fulleni Aug 5, 2025
a66acba
feat(middlewares): implement configurable rate limiter with RBAC support
fulleni Aug 5, 2025
cf3a406
feat(headlines): apply rate limiting to headline endpoints
fulleni Aug 5, 2025
c94480e
feat(countries): implement rate limiting for country routes
fulleni Aug 5, 2025
4f33218
style: remove unnecessary break statements in middleware
fulleni Aug 5, 2025
e4dbb40
feat(languages): apply rate limiting to middleware
fulleni Aug 5, 2025
8289d10
feat(remote-config): implement rate limiting for API routes
fulleni Aug 5, 2025
9782b62
feat(sources): implement rate limiting for sources endpoints
fulleni Aug 5, 2025
6228ce2
feat(topics): implement rate limiting for API endpoints
fulleni Aug 5, 2025
d470576
feat(users): implement rate limiting for users endpoint
fulleni Aug 5, 2025
0c7578a
feat(middlewares): enhance user preferences endpoint with rate limiting
fulleni Aug 5, 2025
f44bf42
feat(middleware): enhance user settings endpoint with rate limiting
fulleni Aug 5, 2025
43a4978
docs(README): update data management API description
fulleni Aug 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,13 @@
# OPTIONAL: Window for the /auth/request-code endpoint, in hours.
# RATE_LIMIT_REQUEST_CODE_WINDOW_HOURS=24

# OPTIONAL: Limit for the generic /data API endpoints (requests per window).
# RATE_LIMIT_DATA_API_LIMIT=1000

# OPTIONAL: Window for the /data API endpoints, in minutes.
# RATE_LIMIT_DATA_API_WINDOW_MINUTES=60
# OPTIONAL: Rate limit for general READ operations (e.g., GET /headlines).
# RATE_LIMIT_READ_LIMIT=500
# OPTIONAL: Window for READ operations, in minutes.
# RATE_LIMIT_READ_WINDOW_MINUTES=60

# OPTIONAL: Rate limit for general WRITE operations (e.g., POST /headlines).
# This is typically stricter than the read limit.
# RATE_LIMIT_WRITE_LIMIT=50
# OPTIONAL: Window for WRITE operations, in minutes.
# RATE_LIMIT_WRITE_WINDOW_MINUTES=60
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 📈

Expand Down
30 changes: 23 additions & 7 deletions lib/src/config/environment_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
111 changes: 20 additions & 91 deletions lib/src/middlewares/authorization_middleware.dart
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -30,90 +27,22 @@ Middleware authorizationMiddleware() {
// User is guaranteed non-null by requireAuthentication() middleware.
final user = context.read<User>();
final permissionService = context.read<PermissionService>();
final modelName = context.read<String>(); // Provided by data/_middleware
final modelConfig = context
.read<ModelConfig<dynamic>>(); // 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<String>();

// 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);
};
};
Expand Down
76 changes: 76 additions & 0 deletions lib/src/middlewares/configured_rate_limiter.dart
Original file line number Diff line number Diff line change
@@ -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<String?> _userKeyExtractor(RequestContext context) async {
return context.read<User>().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<String?> Function(RequestContext) keyExtractor,
}) {
return (handler) {
return (context) {
// Read dependencies from the context.
final permissionService = context.read<PermissionService>();
final user = context.read<User>(); // 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,
);
}
Loading
Loading