Dart Shelf REST API with auth, recipes, AI (Claude), search, and community modules. PostgreSQL, Redis, Meilisearch. Docker Compose for local dev.
148 lines
5.5 KiB
Dart
148 lines
5.5 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:logging/logging.dart';
|
|
import 'package:shelf/shelf.dart';
|
|
import 'package:shelf/shelf_io.dart' as shelf_io;
|
|
import 'package:shelf_router/shelf_router.dart';
|
|
|
|
import 'package:cleanplate_api/src/config/env.dart';
|
|
import 'package:cleanplate_api/src/config/database.dart';
|
|
import 'package:cleanplate_api/src/config/redis_config.dart';
|
|
|
|
import 'package:cleanplate_api/src/middleware/auth_middleware.dart';
|
|
import 'package:cleanplate_api/src/middleware/cors_middleware.dart';
|
|
import 'package:cleanplate_api/src/middleware/logging_middleware.dart';
|
|
import 'package:cleanplate_api/src/middleware/error_handler.dart';
|
|
import 'package:cleanplate_api/src/middleware/rate_limit_middleware.dart';
|
|
|
|
import 'package:cleanplate_api/src/modules/auth/auth_routes.dart';
|
|
import 'package:cleanplate_api/src/modules/recipes/recipe_routes.dart';
|
|
import 'package:cleanplate_api/src/modules/ai/ai_routes.dart';
|
|
import 'package:cleanplate_api/src/modules/search/search_routes.dart';
|
|
import 'package:cleanplate_api/src/modules/media/media_routes.dart';
|
|
import 'package:cleanplate_api/src/modules/users/user_routes.dart';
|
|
import 'package:cleanplate_api/src/modules/community/community_routes.dart';
|
|
|
|
final _log = Logger('Server');
|
|
|
|
void main(List<String> args) async {
|
|
// ---------------------------------------------------------------
|
|
// Logging
|
|
// ---------------------------------------------------------------
|
|
Logger.root.level = Env.isProduction ? Level.INFO : Level.ALL;
|
|
Logger.root.onRecord.listen((record) {
|
|
stderr.writeln(
|
|
'${record.time} [${record.level.name}] ${record.loggerName}: ${record.message}');
|
|
if (record.error != null) stderr.writeln(' Error: ${record.error}');
|
|
if (record.stackTrace != null) {
|
|
stderr.writeln(' Stack: ${record.stackTrace}');
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------
|
|
// Infrastructure connections
|
|
// ---------------------------------------------------------------
|
|
try {
|
|
await Database.initialize();
|
|
_log.info('Database connected');
|
|
} catch (e) {
|
|
_log.warning('Could not connect to database: $e');
|
|
_log.warning('Server will start but database-dependent routes will fail');
|
|
}
|
|
|
|
try {
|
|
await RedisConfig.initialize();
|
|
_log.info('Redis connected');
|
|
} catch (e) {
|
|
_log.warning('Could not connect to Redis: $e');
|
|
_log.warning('Server will start but rate-limiting may use in-memory fallback');
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Routers
|
|
// ---------------------------------------------------------------
|
|
final authRoutes = AuthRoutes();
|
|
final recipeRoutes = RecipeRoutes();
|
|
final aiRoutes = AiRoutes();
|
|
final searchRoutes = SearchRoutes();
|
|
final mediaRoutes = MediaRoutes();
|
|
final userRoutes = UserRoutes();
|
|
final communityRoutes = CommunityRoutes();
|
|
|
|
// Top-level router.
|
|
final app = Router();
|
|
|
|
// Health check.
|
|
app.get('/health', (Request request) {
|
|
return Response.ok(
|
|
jsonEncode({'status': 'ok'}),
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
});
|
|
|
|
// Mount module routers under /api/v1.
|
|
app.mount('/api/v1/auth/', authRoutes.router.call);
|
|
app.mount('/api/v1/recipes/', recipeRoutes.router.call);
|
|
app.mount('/api/v1/ai/', aiRoutes.router.call);
|
|
app.mount('/api/v1/search/', searchRoutes.router.call);
|
|
app.mount('/api/v1/media/', mediaRoutes.router.call);
|
|
app.mount('/api/v1/users/', userRoutes.router.call);
|
|
|
|
// Community reviews are nested under a recipe.
|
|
// We use a small adapter to inject the recipeId into the request context.
|
|
app.mount('/api/v1/recipes/<recipeId>/reviews/', (Request request) {
|
|
final recipeId = request.params['recipeId'];
|
|
final updated = request.change(context: {'recipeId': recipeId});
|
|
return communityRoutes.router.call(updated);
|
|
});
|
|
|
|
// ---------------------------------------------------------------
|
|
// Middleware pipeline
|
|
// ---------------------------------------------------------------
|
|
final handler = Pipeline()
|
|
.addMiddleware(corsMiddleware())
|
|
.addMiddleware(loggingMiddleware())
|
|
.addMiddleware(errorHandler())
|
|
.addMiddleware(rateLimitMiddleware())
|
|
.addMiddleware(authMiddleware())
|
|
.addHandler(app.call);
|
|
|
|
// ---------------------------------------------------------------
|
|
// Start server
|
|
// ---------------------------------------------------------------
|
|
final ip = InternetAddress.anyIPv4;
|
|
final port = int.parse(Env.port);
|
|
final server = await shelf_io.serve(handler, ip, port);
|
|
|
|
_log.info('CleanPlate API listening on http://${server.address.host}:${server.port}');
|
|
_log.info('Environment: ${Env.environment}');
|
|
|
|
// ---------------------------------------------------------------
|
|
// Graceful shutdown
|
|
// ---------------------------------------------------------------
|
|
ProcessSignal.sigint.watch().listen((_) async {
|
|
_log.info('SIGINT received — shutting down...');
|
|
await _shutdown(server);
|
|
});
|
|
|
|
// SIGTERM is not available on Windows but fine on Linux/macOS.
|
|
if (!Platform.isWindows) {
|
|
ProcessSignal.sigterm.watch().listen((_) async {
|
|
_log.info('SIGTERM received — shutting down...');
|
|
await _shutdown(server);
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _shutdown(HttpServer server) async {
|
|
_log.info('Closing HTTP server...');
|
|
await server.close(force: false);
|
|
_log.info('Closing database pool...');
|
|
await Database.close();
|
|
_log.info('Closing Redis connection...');
|
|
await RedisConfig.close();
|
|
_log.info('Shutdown complete');
|
|
exit(0);
|
|
}
|