Repository: VeryGoodOpenSource/dart_frog (dart)

Issue:
feat: be able to read body more than once on Request and Response
**Description**

I'm trying to add body validation on middleware level for both Request and Response, but look like those can be read only once which make the server crash the second time you want to read.

**Requirements**

- [ ] being able to read request body multiple time
- [ ] being able to read response body multiple time

For the request one workaround would be to read it and then provide it frog dependancy injection but didn't manage to make it work for now.

For response I have no workaround as it will be read by shelf to send back the response anyway, is there a way to duplicate the response so I can read the body in a safe way?

Pull request description:
<!--
  Thanks for contributing!

  Provide a description of your changes below and a general summary in the title

  Please look at the following checklist to ensure that your PR can be accepted quickly:
-->

## Status

**READY**

## Description

<!--- Describe your changes in detail -->
- feat(dart_frog): cache body for `Request` and `Response` (closes #468)

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [X] ✨ New feature (non-breaking change which adds functionality)
- [ ] 🛠️ Bug fix (non-breaking change which fixes an issue)
- [ ] ❌ Breaking change (fix or feature that would cause existing functionality to change)
- [ ] 🧹 Code refactor
- [ ] ✅ Build configuration change
- [ ] 📝 Documentation
- [ ] 🗑️ Chore

Patch:
diff --git a/examples/kitchen_sink/routes/messages/_middleware.dart b/examples/kitchen_sink/routes/messages/_middleware.dart
new file mode 100644
index 000000000..b96788bb1
--- /dev/null
+++ b/examples/kitchen_sink/routes/messages/_middleware.dart
@@ -0,0 +1,25 @@
+import 'dart:io';
+
+import 'package:dart_frog/dart_frog.dart';
+
+Handler middleware(Handler handler) {
+  return handler.use(_requestValidator());
+}
+
+Middleware _requestValidator() {
+  return (handler) {
+    return (context) async {
+      final request = context.request;
+
+      if (request.method != HttpMethod.post) {
+        return Response(statusCode: HttpStatus.methodNotAllowed);
+      }
+
+      final body = await request.body();
+
+      if (body.isEmpty) return Response(statusCode: HttpStatus.badRequest);
+
+      return handler(context);
+    };
+  };
+}
diff --git a/examples/kitchen_sink/routes/messages/index.dart b/examples/kitchen_sink/routes/messages/index.dart
new file mode 100644
index 000000000..9acd3e9a5
--- /dev/null
+++ b/examples/kitchen_sink/routes/messages/index.dart
@@ -0,0 +1,6 @@
+import 'package:dart_frog/dart_frog.dart';
+
+Future<Response> onRequest(RequestContext context) async {
+  final body = await context.request.body();
+  return Response(body: 'message: $body');
+}
diff --git a/packages/dart_frog/lib/src/_internal.dart b/packages/dart_frog/lib/src/_internal.dart
index 908aba0e6..bea82913f 100644
--- a/packages/dart_frog/lib/src/_internal.dart
+++ b/packages/dart_frog/lib/src/_internal.dart
@@ -1,3 +1,4 @@
+import 'dart:async';
 import 'dart:collection';
 import 'dart:convert';
 import 'dart:io';
diff --git a/packages/dart_frog/lib/src/request.dart b/packages/dart_frog/lib/src/request.dart
index 3f41debc7..12df87a9b 100644
--- a/packages/dart_frog/lib/src/request.dart
+++ b/packages/dart_frog/lib/src/request.dart
@@ -93,7 +93,7 @@ class Request {
 
   Request._(this._request);
 
-  final shelf.Request _request;
+  shelf.Request _request;
 
   /// Connection information for the associated HTTP request.
   HttpConnectionInfo get connectionInfo {
@@ -119,7 +119,24 @@ class Request {
   Stream<List<int>> bytes() => _request.read();
 
   /// Returns a [Future] containing the body as a [String].
-  Future<String> body() => _request.readAsString();
+  Future<String> body() async {
+    const requestBodyKey = 'dart_frog.request.body';
+    final bodyFromContext =
+        _request.context[requestBodyKey] as Completer<String>?;
+    if (bodyFromContext != null) return bodyFromContext.future;
+
+    final completer = Completer<String>();
+    try {
+      _request = _request.change(
+        context: {..._request.context, requestBodyKey: completer},
+      );
+      completer.complete(await _request.readAsString());
+    } catch (error, stackTrace) {
+      completer.completeError(error, stackTrace);
+    }
+
+    return completer.future;
+  }
 
   /// Returns a [Future] containing the form data as a [Map].
   Future<Map<String, String>> formData() {
@@ -129,7 +146,7 @@ class Request {
   /// Returns a [Future] containing the body text parsed as a json object.
   /// This object could be anything that can be represented by json
   /// e.g. a map, a list, a string, a number, a bool...
-  Future<dynamic> json() async => jsonDecode(await _request.readAsString());
+  Future<dynamic> json() async => jsonDecode(await body());
 
   /// Creates a new [Request] by copying existing values and applying specified
   /// changes.
diff --git a/packages/dart_frog/lib/src/response.dart b/packages/dart_frog/lib/src/response.dart
index 8c61f2b6a..a9265a5e8 100644
--- a/packages/dart_frog/lib/src/response.dart
+++ b/packages/dart_frog/lib/src/response.dart
@@ -48,7 +48,7 @@ class Response {
 
   Response._(this._response);
 
-  final shelf.Response _response;
+  shelf.Response _response;
 
   /// The HTTP status code of the response.
   int get statusCode => _response.statusCode;
@@ -61,7 +61,24 @@ class Response {
   Stream<List<int>> bytes() => _response.read();
 
   /// Returns a [Future] containing the body as a [String].
-  Future<String> body() => _response.readAsString();
+  Future<String> body() async {
+    const responseBodyKey = 'dart_frog.response.body';
+    final bodyFromContext =
+        _response.context[responseBodyKey] as Completer<String>?;
+    if (bodyFromContext != null) return bodyFromContext.future;
+
+    final completer = Completer<String>();
+    try {
+      _response = _response.change(
+        context: {..._response.context, responseBodyKey: completer},
+      );
+      completer.complete(await _response.readAsString());
+    } catch (error, stackTrace) {
+      completer.completeError(error, stackTrace);
+    }
+
+    return completer.future;
+  }
 
   /// Returns a [Future] containing the form data as a [Map].
   Future<Map<String, String>> formData() {
@@ -71,7 +88,7 @@ class Response {
   /// Returns a [Future] containing the body text parsed as a json object.
   /// This object could be anything that can be represented by json
   /// e.g. a map, a list, a string, a number, a bool...
-  Future<dynamic> json() async => jsonDecode(await _response.readAsString());
+  Future<dynamic> json() async => jsonDecode(await body());
 
   /// Creates a new [Response] by copying existing values and applying specified
   /// changes.


Test patch:
diff --git a/examples/kitchen_sink/e2e/message_test.dart b/examples/kitchen_sink/e2e/message_test.dart
new file mode 100644
index 000000000..b4add1424
--- /dev/null
+++ b/examples/kitchen_sink/e2e/message_test.dart
@@ -0,0 +1,36 @@
+import 'dart:io';
+
+import 'package:http/http.dart' as http;
+import 'package:test/test.dart';
+
+void main() {
+  group('E2E (/messages)', () {
+    test('GET /messages responds with 405', () async {
+      final response = await http.get(
+        Uri.parse('http://localhost:8080/messages'),
+      );
+      expect(response.statusCode, equals(HttpStatus.methodNotAllowed));
+    });
+
+    test('POST /messages responds with 400 when body is empty', () async {
+      final response = await http.post(
+        Uri.parse('http://localhost:8080/messages'),
+      );
+      expect(response.statusCode, equals(HttpStatus.badRequest));
+    });
+
+    test(
+      'POST /messages responds with 200 when body is not empty',
+      () async {
+        const message = 'hello world';
+        final response = await http.post(
+          Uri.parse('http://localhost:8080/messages'),
+          body: message,
+        );
+        expect(response.statusCode, equals(HttpStatus.ok));
+        expect(response.body, equals('message: $message'));
+      },
+      skip: true,
+    );
+  });
+}
diff --git a/examples/kitchen_sink/test/routes/messages/_middleware_test.dart b/examples/kitchen_sink/test/routes/messages/_middleware_test.dart
new file mode 100644
index 000000000..1ca760cf9
--- /dev/null
+++ b/examples/kitchen_sink/test/routes/messages/_middleware_test.dart
@@ -0,0 +1,49 @@
+import 'dart:io';
+
+import 'package:dart_frog/dart_frog.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:test/test.dart';
+
+import '../../../routes/messages/_middleware.dart';
+
+class _MockRequestContext extends Mock implements RequestContext {}
+
+void main() {
+  group('middleware', () {
+    final handler = middleware(
+      (context) async {
+        final body = await context.request.body();
+        return Response(body: 'body: $body');
+      },
+    );
+
+    test('returns 405 when method is not POST', () async {
+      final request = Request.get(Uri.parse('http://localhost/'));
+      final context = _MockRequestContext();
+      when(() => context.request).thenReturn(request);
+
+      final response = await handler(context);
+      expect(response.statusCode, equals(HttpStatus.methodNotAllowed));
+    });
+
+    test('returns 400 when POST body is empty', () async {
+      final request = Request.post(Uri.parse('http://localhost/'));
+      final context = _MockRequestContext();
+      when(() => context.request).thenReturn(request);
+
+      final response = await handler(context);
+      expect(response.statusCode, equals(HttpStatus.badRequest));
+    });
+
+    test('returns 200 when POST body is not empty', () async {
+      const body = '__test_body__';
+      final request = Request.post(Uri.parse('http://localhost/'), body: body);
+      final context = _MockRequestContext();
+      when(() => context.request).thenReturn(request);
+
+      final response = await handler(context);
+      expect(response.statusCode, equals(HttpStatus.ok));
+      expect(response.body(), completion(equals('body: $body')));
+    });
+  });
+}
diff --git a/examples/kitchen_sink/test/routes/messages/index_test.dart b/examples/kitchen_sink/test/routes/messages/index_test.dart
new file mode 100644
index 000000000..3c1a37c4c
--- /dev/null
+++ b/examples/kitchen_sink/test/routes/messages/index_test.dart
@@ -0,0 +1,26 @@
+import 'dart:io';
+
+import 'package:dart_frog/dart_frog.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:test/test.dart';
+
+import '../../../routes/messages/index.dart' as route;
+
+class _MockRequestContext extends Mock implements RequestContext {}
+
+void main() {
+  group('POST /', () {
+    test('responds with a 200 and message in body', () async {
+      const message = 'Hello World';
+      final request = Request.post(
+        Uri.parse('http://localhost/'),
+        body: message,
+      );
+      final context = _MockRequestContext();
+      when(() => context.request).thenReturn(request);
+      final response = await route.onRequest(context);
+      expect(response.statusCode, equals(HttpStatus.ok));
+      expect(response.body(), completion(equals('message: $message')));
+    });
+  });
+}
diff --git a/packages/dart_frog/test/src/middleware_test.dart b/packages/dart_frog/test/src/middleware_test.dart
index a910a65e9..9b9dc54d3 100644
--- a/packages/dart_frog/test/src/middleware_test.dart
+++ b/packages/dart_frog/test/src/middleware_test.dart
@@ -42,4 +42,81 @@ void main() {
       equals('$stringValue $intValue'),
     );
   });
+
+  test('middleware can be used to read the request body', () async {
+    Middleware requestValidator() {
+      return (handler) {
+        return (context) async {
+          final body = await context.request.body();
+          if (body.isEmpty) return Response(statusCode: HttpStatus.badRequest);
+          return handler(context);
+        };
+      };
+    }
+
+    Future<Response> onRequest(RequestContext context) async {
+      final body = await context.request.body();
+      return Response(body: 'body: $body');
+    }
+
+    final handler = const Pipeline()
+        .addMiddleware(requestValidator())
+        .addHandler(onRequest);
+
+    var request = Request.get(Uri.parse('http://localhost/'));
+    var context = _MockRequestContext();
+    when(() => context.request).thenReturn(request);
+    var response = await handler(context);
+
+    expect(response.statusCode, equals(HttpStatus.badRequest));
+
+    const body = '__test_body__';
+    request = Request.get(Uri.parse('http://localhost/'), body: body);
+    context = _MockRequestContext();
+    when(() => context.request).thenReturn(request);
+    response = await handler(context);
+
+    expect(response.statusCode, equals(HttpStatus.ok));
+    expect(await response.body(), equals('body: $body'));
+  });
+
+  test('middleware can be used to read the response body', () async {
+    const emptyBody = '(empty)';
+    Middleware responseValidator() {
+      return (handler) {
+        return (context) async {
+          final response = await handler(context);
+          final body = await response.body();
+          if (body.isEmpty) return Response(body: emptyBody);
+          return response;
+        };
+      };
+    }
+
+    Future<Response> onRequest(RequestContext context) async {
+      final body = await context.request.body();
+      return Response(body: body);
+    }
+
+    final handler = const Pipeline()
+        .addMiddleware(responseValidator())
+        .addHandler(onRequest);
+
+    var request = Request.get(Uri.parse('http://localhost/'));
+    var context = _MockRequestContext();
+    when(() => context.request).thenReturn(request);
+    var response = await handler(context);
+
+    expect(response.statusCode, equals(HttpStatus.ok));
+    expect(response.body(), completion(equals(emptyBody)));
+
+    const body = '__test_body__';
+    request = Request.get(Uri.parse('http://localhost/'), body: body);
+    context = _MockRequestContext();
+    when(() => context.request).thenReturn(request);
+    response = await handler(context);
+
+    expect(response.statusCode, equals(HttpStatus.ok));
+    expect(response.body(), completion(equals(body)));
+  });
 }
diff --git a/packages/dart_frog/test/src/request_test.dart b/packages/dart_frog/test/src/request_test.dart
index d8d336e0d..3506312bc 100644
--- a/packages/dart_frog/test/src/request_test.dart
+++ b/packages/dart_frog/test/src/request_test.dart
@@ -1,3 +1,4 @@
+import 'dart:async';
 import 'dart:convert';
 import 'dart:io';
 
@@ -42,12 +43,49 @@ void main() {
       expect(request.bytes(), emits(utf8.encode(body)));
     });
 
+    test('throws exception when unable to read body', () async {
+      final exception = Exception('oops');
+      final body = Stream<Object>.error(exception);
+      final request = Request('GET', localhost, body: body);
+      expect(request.body, throwsA(exception));
+    });
+
+    test('throws exception when unable to read body multiple times', () async {
+      final exception = Exception('oops');
+      final body = Stream<Object>.error(exception);
+      final request = Request('GET', localhost, body: body);
+      expect(request.body, throwsA(exception));
+      expect(request.body, throwsA(exception));
+    });
+
     test('has correct headers', () {
       const headers = <String, String>{'foo': 'bar'};
       final request = Request('GET', localhost, headers: headers);
       expect(request.headers['foo'], equals(headers['foo']));
     });
 
+    test('body can be read multiple times (sync)', () {
+      final body = json.encode({'test': 'body'});
+      final request = Request('GET', localhost, body: body);
+
+      expect(request.body(), completion(equals(body)));
+      expect(request.body(), completion(equals(body)));
+
+      expect(request.json(), completion(equals(json.decode(body))));
+      expect(request.json(), completion(equals(json.decode(body))));
+    });
+
+    test('body can be read multiple times (async)', () async {
+      final body = json.encode({'test': 'body'});
+      final request = Request('GET', localhost, body: body);
+
+      await expectLater(request.body(), completion(equals(body)));
+      await expectLater(request.body(), completion(equals(body)));
+
+      await expectLater(request.json(), completion(equals(json.decode(body))));
+      await expectLater(request.json(), completion(equals(json.decode(body))));
+    });
+
     group('copyWith', () {
       test('returns a copy with overridden properties', () {
         const headers = <String, String>{'foo': 'bar'};
diff --git a/packages/dart_frog/test/src/response_test.dart b/packages/dart_frog/test/src/response_test.dart
index 95b53812c..7b8215160 100644
--- a/packages/dart_frog/test/src/response_test.dart
+++ b/packages/dart_frog/test/src/response_test.dart
@@ -31,6 +31,17 @@ void main() {
       expect(response.body(), completion(isEmpty));
     });
 
+    test('throws exception when unable to read body', () async {
+      final request = Response.bytes(body: base64Decode('1234'));
+      expect(request.body, throwsException);
+    });
+
+    test('throws exception when unable to read body multiple times.', () async {
+      final request = Response.bytes(body: base64Decode('1234'));
+      expect(request.body, throwsException);
+      expect(request.body, throwsException);
+    });
+
     test('has correct headers', () {
       const headers = <String, String>{'foo': 'bar'};
       final response = Response(headers: headers);
@@ -45,6 +56,28 @@ void main() {
       expect(response.headers['foo'], equals('bar,baz'));
     });
 
+    test('body can be read multiple times (sync)', () {
+      final body = json.encode({'test': 'body'});
+      final response = Response(body: body);
+
+      expect(response.body(), completion(equals(body)));
+      expect(response.body(), completion(equals(body)));
+
+      expect(response.json(), completion(equals(json.decode(body))));
+      expect(response.json(), completion(equals(json.decode(body))));
+    });
+
+    test('body can be read multiple times (async)', () async {
+      final body = json.encode({'test': 'body'});
+      final response = Response(body: body);
+
+      await expectLater(response.body(), completion(equals(body)));
+      await expectLater(response.body(), completion(equals(body)));
+
+      await expectLater(response.json(), completion(equals(json.decode(body))));
+      await expectLater(response.json(), completion(equals(json.decode(body))));
+    });
+
     group('copyWith', () {
       test('returns a copy with overridden properties', () {
         const headers = <String, String>{'foo': 'bar'};