Skip to content

Request Body Stream Can Only Be Read Once, Preventing Logging in Middleware Without Consuming It for Handlers #330

@OmarBakry-eg

Description

@OmarBakry-eg

Describe the bug

Description

I'm encountering an issue when trying to implement a custom logger middleware that reads and logs the request body (e.g., as JSON). The Request object's body stream (accessed via readAsString() or similar) can only be read once, as per the Dart HTTP server constraints. This means that if the middleware reads the body for logging, subsequent handlers (like a login endpoint) cannot read it again, leading to a "Bad state: The 'read' method can only be called once on a Request/Response object" error.

This makes it impossible to log request bodies in middleware without either:

  • Duplicating the read logic in every handler (inefficient).
  • Buffering/cloning the body stream in the middleware and providing a re-readable version (which isn't currently supported out-of-the-box).

Minimal Reproducible Example

Here's a minimal setup to reproduce:
main.dart (app setup):

import 'package:relic/relic.dart';
import 'dart:convert';

// Extension for bodyAsJson (from utils/extensions.dart)
extension RequestExtension on Request {
  Future<Map<String, dynamic>> get bodyAsJson async {
    final body = await this.readAsString();
    return jsonDecode(body) as Map<String, dynamic>;
  }
}

// Main
void main() {
  final app = Router();
  app.use('/', customLogger()); // Apply logger middleware globally
  app.post('/login', login); // Handler that also reads body

  final server = Relic();
  server.use('/', app);
  server.serve();
}


// Example handler (login)
Future<Response> login(Request req) async {
  try {
    final body = await req.bodyAsJson; // Fails here if middleware read it first
    final email = body['email'] as String?;
    final password = body['password'] as String?;
    if (email == null || password == null) {
      return Response.badRequest(
        body: Body.fromString(jsonEncode({
          'success': false,
          'message': 'Email and password are required',
        })),
      );
    }


// Custom Middleware (Issue Occurred In _message Func)
Middleware customLogger({final Logger? logger}) {
  return (final next) {
    final localLogger = logger ?? logMessage;
    return (final req) async {
      final startTime = DateTime.now();
      final watch = Stopwatch()..start();
      try {
        final result = await next(req);
        final msg = switch (result) {
          final Response rc => '${rc.statusCode}',
          final Hijack _ => 'hijacked',
          final WebSocketUpgrade _ => 'connected',
        };
        localLogger(_message(startTime, req, watch.elapsed, msg));
        return result;
      } catch (error, stackTrace) {
        localLogger(
          _errorMessage(startTime, req, watch.elapsed, error),
          type: LoggerType.error,
          stackTrace: stackTrace,
        );
        rethrow;
      }
    };
  };
}

String _formatQuery(final String query) {
  return query == '' ? '' : '?$query';
}


// (Issue Occurred Here)
String _message(
  final DateTime requestTime,
  final Request request,
  final Duration elapsedTime,
  final String message,
) {
  final method = request.method.value;
  final url = request.url;
  final body = request.bodyAsJson; /* Throws a Bad state: The 'read' method can only be called once on a Request/Response object */
  return '${requestTime.toIso8601String()} '
      '${elapsedTime.toString().padLeft(15)} '
      '${method.padRight(7)} [$message] ' // 7 - longest standard HTTP method
      '${url.path}${_formatQuery(url.query)}'
      '${body} ${request.headers}';
}

String _errorMessage(
  final DateTime requestTime,
  final Request request,
  final Duration elapsedTime,
  final Object error,
) {
  return _message(requestTime, request, elapsedTime, 'ERROR: $error');
}
    // Simulate auth logic...
    return Response.ok(
      body: Body.fromString(jsonEncode({'success': true})),
    );
  } catch (e) {
    return Response.internalServerError(
      body: Body.fromString(jsonEncode({
        'success': false,
        'message': 'Login failed: $e',
      })),
    );
  }
}

To reproduce

Steps to Reproduce:

  1. Set up a simple Relic app with a custom logger middleware that reads request.bodyAsJson.
  2. Define a handler (e.g., /login) that also reads request.bodyAsJson.
  3. Send a POST request to /login with a JSON body (e.g., {"email": "test@example.com", "password": "pass"}).
  4. Observe the error in the handler because the middleware already consumed the body stream.

Expected behavior

Body should be readable anywhere if needed.

Library version

1.0.0

Platform information

Relic

Additional context

No response

How experienced are you with this library?

None

Are you interested in working on a PR for this?

  • I want to work on this

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

Status

Done 🚀

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions