Skip to content

Fix data route #40

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 8 commits into from
Aug 8, 2025
143 changes: 143 additions & 0 deletions lib/src/middlewares/data_fetch_middleware.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
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/middlewares/ownership_check_middleware.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart';
import 'package:logging/logging.dart';

final _log = Logger('DataFetchMiddleware');

/// Middleware to fetch a data item by its ID and provide it to the context.
///
/// This middleware is responsible for:
/// 1. Reading the `modelName` and item `id` from the context.
/// 2. Calling the appropriate data repository to fetch the item.
/// 3. If the item is found, providing it to the downstream context wrapped in a
/// [FetchedItem] for type safety.
/// 4. If the item is not found, throwing a [NotFoundException] to halt the
/// request pipeline early.
///
/// This centralizes the item fetching logic for all item-specific routes,
/// ensuring that subsequent middleware (like ownership checks) and the final
/// route handler can safely assume the item exists in the context.
Middleware dataFetchMiddleware() {
return (handler) {
return (context) async {
final modelName = context.read<String>();
final id = context.request.uri.pathSegments.last;

_log.info('Fetching item for model "$modelName", id "$id".');

final item = await _fetchItem(context, modelName, id);

if (item == null) {
_log.warning(
'Item not found for model "$modelName", id "$id".',
);
throw NotFoundException(
'The requested item of type "$modelName" with id "$id" was not found.',
);
}

_log.finer('Item found. Providing to context.');
final updatedContext = context.provide<FetchedItem<dynamic>>(
() => FetchedItem(item),
);

return handler(updatedContext);
};
};
}

/// Helper function to fetch an item from the correct repository based on the
/// model name.
///
/// This function contains the switch statement that maps a `modelName` string
/// to a specific `DataRepository` call.
///
/// Throws [OperationFailedException] for unsupported model types.
Future<dynamic> _fetchItem(
RequestContext context,
String modelName,
String id,
) async {
// The `userId` is not needed here because this middleware's purpose is to
// fetch the item regardless of ownership. Ownership is checked in a
// subsequent middleware. We pass `null` for `userId` to ensure we are
// performing a global lookup for the item.
const String? userId = null;

try {
switch (modelName) {
case 'headline':
return await context.read<DataRepository<Headline>>().read(
id: id,
userId: userId,
);
case 'topic':
return await context
.read<DataRepository<Topic>>()
.read(id: id, userId: userId);
case 'source':
return await context.read<DataRepository<Source>>().read(
id: id,
userId: userId,
);
case 'country':
return await context.read<DataRepository<Country>>().read(
id: id,
userId: userId,
);
case 'language':
return await context.read<DataRepository<Language>>().read(
id: id,
userId: userId,
);
case 'user':
return await context
.read<DataRepository<User>>()
.read(id: id, userId: userId);
case 'user_app_settings':
return await context.read<DataRepository<UserAppSettings>>().read(
id: id,
userId: userId,
);
case 'user_content_preferences':
return await context
.read<DataRepository<UserContentPreferences>>()
.read(
id: id,
userId: userId,
);
case 'remote_config':
return await context.read<DataRepository<RemoteConfig>>().read(
id: id,
userId: userId,
);
case 'dashboard_summary':
// This is a special case that doesn't use a standard repository.
return await context.read<DashboardSummaryService>().getSummary();
default:
_log.warning('Unsupported model type "$modelName" for fetch operation.');
throw OperationFailedException(
'Unsupported model type "$modelName" for fetch operation.',
);
}
} on NotFoundException {
// The repository will throw this if the item doesn't exist.
// We return null to let the main middleware handler throw a more
// detailed exception.
return null;
} catch (e, s) {
_log.severe(
'Unhandled exception in _fetchItem for model "$modelName", id "$id".',
e,
s,
);
// Re-throw as a standard exception type that the main error handler
// can process into a 500 error, while preserving the original cause.
throw OperationFailedException(
'An internal error occurred while fetching the item: $e',
);
}
}
65 changes: 25 additions & 40 deletions lib/src/middlewares/ownership_check_middleware.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
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';

Expand All @@ -19,28 +18,27 @@ class FetchedItem<T> {
/// Middleware to check if the authenticated user is the owner of the requested
/// item.
///
/// This middleware is designed to run on item-specific routes (e.g., `/[id]`).
/// It performs the following steps:
/// This middleware runs *after* the `dataFetchMiddleware`, which means it can
/// safely assume that the requested item has already been fetched and is
/// available in the context.
///
/// 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<FetchedItem<dynamic>>`. This prevents the
/// downstream route handler from needing to fetch the item again.
/// It performs the following steps:
/// 1. Determines if an ownership check is required for the current action
/// based on the `ModelConfig`.
/// 2. If a check is required and the user is not an admin, it reads the
/// pre-fetched item from the context.
/// 3. It then compares the item's owner ID with the authenticated user's ID.
/// 4. If the IDs do not match, it throws a [ForbiddenException].
/// 5. If the check is not required or passes, it calls the next handler.
Middleware ownershipCheckMiddleware() {
return (handler) {
return (context) async {
final modelName = context.read<String>();
final modelConfig = context.read<ModelConfig<dynamic>>();
final user = context.read<User>();
final permissionService = context.read<PermissionService>();
final method = context.request.method;
final id = context.request.uri.pathSegments.last;

// Determine the required permission configuration for the current method.
ModelActionPermission permission;
switch (method) {
case HttpMethod.get:
Expand All @@ -50,54 +48,41 @@ Middleware ownershipCheckMiddleware() {
case HttpMethod.delete:
permission = modelConfig.deletePermission;
default:
// For other methods, no ownership check is performed here.
// For any other methods, no ownership check is performed.
return handler(context);
}

// If no ownership check is required or if the user is an admin,
// proceed to the next handler without fetching the item.
// If no ownership check is required for this action, or if the user is
// an admin (who bypasses ownership checks), proceed immediately.
if (!permission.requiresOwnershipCheck ||
permissionService.isAdmin(user)) {
return handler(context);
}

// At this point, an ownership check is required for a non-admin user.

// Ensure the model is configured to support ownership checks.
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<DataRepository<User>>();
item = await repo.read(id: id, userId: userIdForRepoCall);
case 'user_app_settings':
final repo = context.read<DataRepository<UserAppSettings>>();
item = await repo.read(id: id, userId: userIdForRepoCall);
case 'user_content_preferences':
final repo = context.read<DataRepository<UserContentPreferences>>();
item = await repo.read(id: id, userId: userIdForRepoCall);
default:
throw OperationFailedException(
'Ownership check not implemented for model "$modelName".',
);
}
// Read the item that was pre-fetched by the dataFetchMiddleware.
// This is guaranteed to exist because dataFetchMiddleware would have
// thrown a NotFoundException if the item did not exist.
final item = context.read<FetchedItem<dynamic>>().data;

// Compare the item's owner ID with the authenticated user's ID.
final itemOwnerId = modelConfig.getOwnerId!(item);
if (itemOwnerId != user.id) {
throw const ForbiddenException(
'You do not have permission to access this item.',
);
}

final updatedContext = context.provide<FetchedItem<dynamic>>(
() => FetchedItem(item),
);

return handler(updatedContext);
// If the ownership check passes, proceed to the final route handler.
return handler(context);
};
};
}
9 changes: 9 additions & 0 deletions routes/_middleware.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,20 @@ Handler middleware(Handler handler) {
if (!_loggerConfigured) {
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) {
// A more detailed logger that includes the error and stack trace.
// ignore: avoid_print
print(
'${record.level.name}: ${record.time}: ${record.loggerName}: '
'${record.message}',
);
if (record.error != null) {
// ignore: avoid_print
print(' ERROR: ${record.error}');
}
if (record.stackTrace != null) {
// ignore: avoid_print
print(' STACK TRACE: ${record.stackTrace}');
}
});
_loggerConfigured = true;
}
Expand Down
29 changes: 19 additions & 10 deletions routes/api/v1/data/[id]/_middleware.dart
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import 'package:dart_frog/dart_frog.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/data_fetch_middleware.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 middleware chain is responsible for fetching the requested data item
/// and then performing an ownership check on it.
///
/// 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.
/// The execution order is as follows:
/// 1. `dataFetchMiddleware`: This runs first. It fetches the item by its ID
/// from the database and provides it to the context. If the item is not
/// found, it throws a `NotFoundException`, aborting the request.
/// 2. `ownershipCheckMiddleware`: This runs second. It reads the fetched item
/// from the context and verifies that the authenticated user is the owner,
/// if the model's configuration requires such a check.
///
/// This ensures that the final route handler only executes for valid,
/// authorized requests and can safely assume the requested item exists.
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());
// The middleware is applied in reverse order of execution.
// `ownershipCheckMiddleware` is the inner middleware, running after
// `dataFetchMiddleware`.
return handler
.use(ownershipCheckMiddleware()) // Runs second
.use(dataFetchMiddleware()); // Runs first
}
Loading
Loading