diff --git a/.env.example b/.env.example index 1f4422a..86ad6eb 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,11 @@ # Copy this file to .env and fill in your actual configuration values. # The .env file is ignored by Git and should NOT be committed. -DATABASE_URL="mongodb://user:password@localhost:27017/ht_api_db" \ No newline at end of file +# REQUIRED: The full connection string for your MongoDB instance. +# The application cannot start without a database connection. +# DATABASE_URL="mongodb://user:password@localhost:27017/ht_api_db" + +# REQUIRED FOR PRODUCTION: The specific origin URL of your web client. +# This allows the client (e.g., the HT Dashboard) to make requests to the API. +# For local development, this can be left unset as 'localhost' is allowed by default. +# CORS_ALLOWED_ORIGIN="https://your-dashboard.com" \ No newline at end of file diff --git a/README.md b/README.md index 93e9aae..c51f7be 100644 --- a/README.md +++ b/README.md @@ -75,14 +75,15 @@ for more details. * Dart Frog CLI (`dart pub global activate dart_frog_cli`) 2. **Configuration:** - Before running the server, you must configure the database connection by - setting the `DATABASE_URL` environment variable. + Before running the server, you must configure the necessary environment + variables. - Create a `.env` file in the root of the project or export the variable in - your shell: - ``` - DATABASE_URL="mongodb://user:password@localhost:27017/ht_api_db" + Copy the `.env.example` file to a new file named `.env`: + ```bash + cp .env.example .env ``` + Then, open the new `.env` file and update the variables with your actual + configuration values, such as the `DATABASE_URL`. 3. **Clone the repository:** ```bash diff --git a/lib/src/config/environment_config.dart b/lib/src/config/environment_config.dart index fd8e18a..0b0dc25 100644 --- a/lib/src/config/environment_config.dart +++ b/lib/src/config/environment_config.dart @@ -79,4 +79,11 @@ abstract final class EnvironmentConfig { /// The value is read from the `ENV` environment variable. /// Defaults to 'production' if the variable is not set. static String get environment => _env['ENV'] ?? 'production'; + + /// Retrieves the allowed CORS origin from the environment. + /// + /// The value is read from the `CORS_ALLOWED_ORIGIN` environment variable. + /// This is used to configure CORS for production environments. + /// Returns `null` if the variable is not set. + static String? get corsAllowedOrigin => _env['CORS_ALLOWED_ORIGIN']; } diff --git a/lib/src/middlewares/error_handler.dart b/lib/src/middlewares/error_handler.dart index a51fcee..e51d0ad 100644 --- a/lib/src/middlewares/error_handler.dart +++ b/lib/src/middlewares/error_handler.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:dart_frog/dart_frog.dart'; +import 'package:ht_api/src/config/environment_config.dart'; import 'package:ht_shared/ht_shared.dart'; import 'package:json_annotation/json_annotation.dart'; @@ -19,13 +20,11 @@ Middleware errorHandler() { } on HtHttpException catch (e, stackTrace) { // Handle specific HtHttpExceptions from the client/repository layers final statusCode = _mapExceptionToStatusCode(e); - final errorCode = _mapExceptionToCodeString(e); print('HtHttpException Caught: $e\n$stackTrace'); // Log for debugging - return Response.json( + return _jsonErrorResponse( statusCode: statusCode, - body: { - 'error': {'code': errorCode, 'message': e.message}, - }, + exception: e, + context: context, ); } on CheckedFromJsonException catch (e, stackTrace) { // Handle json_serializable validation errors. These are client errors. @@ -33,40 +32,26 @@ Middleware errorHandler() { final message = 'Invalid request body: Field "$field" has an ' 'invalid value or is missing. ${e.message}'; print('CheckedFromJsonException Caught: $e\n$stackTrace'); - return Response.json( + return _jsonErrorResponse( statusCode: HttpStatus.badRequest, // 400 - body: { - 'error': { - 'code': 'invalidField', - 'message': message, - }, - }, + exception: InvalidInputException(message), + context: context, ); } on FormatException catch (e, stackTrace) { // Handle data format/parsing errors (often indicates bad client input) print('FormatException Caught: $e\n$stackTrace'); // Log for debugging - return Response.json( + return _jsonErrorResponse( statusCode: HttpStatus.badRequest, // 400 - body: { - 'error': { - 'code': 'invalidFormat', - 'message': 'Invalid data format: ${e.message}', - }, - }, + exception: InvalidInputException('Invalid data format: ${e.message}'), + context: context, ); } catch (e, stackTrace) { // Handle any other unexpected errors print('Unhandled Exception Caught: $e\n$stackTrace'); - return Response.json( + return _jsonErrorResponse( statusCode: HttpStatus.internalServerError, // 500 - body: { - 'error': { - 'code': 'internalServerError', - 'message': 'An unexpected internal server error occurred.', - // Avoid leaking sensitive details in production responses - // 'details': e.toString(), // Maybe include in dev mode only - }, - }, + exception: const UnknownException('An unexpected internal server error occurred.'), + context: context, ); } }; @@ -108,3 +93,51 @@ String _mapExceptionToCodeString(HtHttpException exception) { _ => 'unknownError', // Default }; } + +/// Creates a standardized JSON error response with appropriate CORS headers. +/// +/// This helper ensures that error responses sent to the client include the +/// necessary `Access-Control-Allow-Origin` header, allowing the client-side +/// application to read the error message body. +Response _jsonErrorResponse({ + required int statusCode, + required HtHttpException exception, + required RequestContext context, +}) { + final errorCode = _mapExceptionToCodeString(exception); + final headers = { + HttpHeaders.contentTypeHeader: 'application/json', + }; + + // Add CORS headers to error responses. This logic is environment-aware. + // In production, it uses a specific origin from `CORS_ALLOWED_ORIGIN`. + // In development (if the variable is not set), it allows any localhost. + final requestOrigin = context.request.headers['Origin']; + if (requestOrigin != null) { + final allowedOrigin = EnvironmentConfig.corsAllowedOrigin; + + var isOriginAllowed = false; + if (allowedOrigin != null) { + // Production: Check against the specific allowed origin. + isOriginAllowed = (requestOrigin == allowedOrigin); + } else { + // Development: Allow any localhost origin. + isOriginAllowed = (Uri.tryParse(requestOrigin)?.host == 'localhost'); + } + + if (isOriginAllowed) { + headers[HttpHeaders.accessControlAllowOriginHeader] = requestOrigin; + headers[HttpHeaders.accessControlAllowCredentialsHeader] = 'true'; + headers[HttpHeaders.accessControlAllowMethodsHeader] = + 'GET, POST, PUT, DELETE, OPTIONS'; + headers[HttpHeaders.accessControlAllowHeadersHeader] = + 'Origin, Content-Type, Authorization'; + } + } + + return Response.json( + statusCode: statusCode, + body: {'error': {'code': errorCode, 'message': exception.message}}, + headers: headers, + ); +}