Repository: VeryGoodOpenSource/dart_frog (dart)

Issue:
feat: improve support for x-www-form-urlencoded form data
**Description**

As a developer, I want to be able to easily parse the contents of incoming requests that are using the `x-www-form-urlencoded` content type.

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): add formData to Request/Response (closes #425)

## 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/packages/dart_frog/lib/src/_internal.dart b/packages/dart_frog/lib/src/_internal.dart
index 3e70aac3c..908aba0e6 100644
--- a/packages/dart_frog/lib/src/_internal.dart
+++ b/packages/dart_frog/lib/src/_internal.dart
@@ -3,6 +3,7 @@ import 'dart:convert';
 import 'dart:io';
 
 import 'package:dart_frog/dart_frog.dart';
+import 'package:dart_frog/src/body_parsers/body_parsers.dart';
 import 'package:http_methods/http_methods.dart' show isHttpMethod;
 import 'package:shelf/shelf.dart' as shelf;
 import 'package:shelf/shelf_io.dart' as shelf_io;
diff --git a/packages/dart_frog/lib/src/body_parsers/body_parsers.dart b/packages/dart_frog/lib/src/body_parsers/body_parsers.dart
new file mode 100644
index 000000000..605ca91a6
--- /dev/null
+++ b/packages/dart_frog/lib/src/body_parsers/body_parsers.dart
@@ -0,0 +1,1 @@
+export 'form_data.dart';
diff --git a/packages/dart_frog/lib/src/body_parsers/form_data.dart b/packages/dart_frog/lib/src/body_parsers/form_data.dart
new file mode 100644
index 000000000..bcb7ec583
--- /dev/null
+++ b/packages/dart_frog/lib/src/body_parsers/form_data.dart
@@ -0,0 +1,39 @@
+import 'dart:io';
+
+/// Content-Type: application/x-www-form-urlencoded
+final formUrlEncodedContentType = ContentType(
+  'application',
+  'x-www-form-urlencoded',
+);
+
+/// Parses the body as form data and returns a `Future<Map<String, String>>`.
+/// Throws a [StateError] if the MIME type is not "application/x-www-form-urlencoded"
+/// https://fetch.spec.whatwg.org/#ref-for-dom-body-formdata%E2%91%A0
+Future<Map<String, String>> parseFormData({
+  required Map<String, String> headers,
+  required Future<String> Function() body,
+}) async {
+  final contentType = _extractContentType(headers);
+  if (!_isFormUrlEncoded(contentType)) {
+    throw StateError(
+      '''
+Body could not be parsed as form data due to an invalid MIME type.
+Expected MIME type: "${formUrlEncodedContentType.mimeType}"
+Actual MIME type: "${contentType?.mimeType ?? ''}"
+''',
+    );
+  }
+
+  return Uri.splitQueryString(await body());
+}
+
+ContentType? _extractContentType(Map<String, String> headers) {
+  final contentTypeValue = headers[HttpHeaders.contentTypeHeader];
+  if (contentTypeValue == null) return null;
+  return ContentType.parse(contentTypeValue);
+}
+
+bool _isFormUrlEncoded(ContentType? contentType) {
+  if (contentType == null) return false;
+  return contentType.mimeType == formUrlEncodedContentType.mimeType;
+}
diff --git a/packages/dart_frog/lib/src/request.dart b/packages/dart_frog/lib/src/request.dart
index bcb064d15..3f41debc7 100644
--- a/packages/dart_frog/lib/src/request.dart
+++ b/packages/dart_frog/lib/src/request.dart
@@ -115,13 +115,18 @@ class Request {
     return HttpMethod.values.firstWhere((m) => m.value == _request.method);
   }
 
-  /// The body as a byte array stream.
+  /// Returns a [Stream] representing the body.
   Stream<List<int>> bytes() => _request.read();
 
-  /// The body as a string.
+  /// Returns a [Future] containing the body as a [String].
   Future<String> body() => _request.readAsString();
 
-  /// The body as a json object.
+  /// Returns a [Future] containing the form data as a [Map].
+  Future<Map<String, String>> formData() {
+    return parseFormData(headers: headers, body: body);
+  }
+
+  /// 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());
diff --git a/packages/dart_frog/lib/src/response.dart b/packages/dart_frog/lib/src/response.dart
index f140abbf9..8c61f2b6a 100644
--- a/packages/dart_frog/lib/src/response.dart
+++ b/packages/dart_frog/lib/src/response.dart
@@ -60,10 +60,15 @@ class Response {
   /// Returns a [Stream] representing the body.
   Stream<List<int>> bytes() => _response.read();
 
-  /// Returns a Future containing the body as a string.
+  /// Returns a [Future] containing the body as a [String].
   Future<String> body() => _response.readAsString();
 
-  /// The body as a json object.
+  /// Returns a [Future] containing the form data as a [Map].
+  Future<Map<String, String>> formData() {
+    return parseFormData(headers: headers, body: body);
+  }
+
+  /// 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());


Test patch:
diff --git a/examples/kitchen_sink/e2e/greet_test.dart b/examples/kitchen_sink/e2e/greet_test.dart
new file mode 100644
index 000000000..789fc8a58
--- /dev/null
+++ b/examples/kitchen_sink/e2e/greet_test.dart
@@ -0,0 +1,17 @@
+import 'dart:io';
+
+import 'package:http/http.dart' as http;
+import 'package:test/test.dart';
+
+void main() {
+  group('E2E (/greet)', () {
+    test('GET /greet/<name> responds with the "Hello <name>"', () async {
+      const name = 'Frog';
+      final response = await http.get(
+        Uri.parse('http://localhost:8080/greet/$name'),
+      );
+      expect(response.statusCode, equals(HttpStatus.ok));
+      expect(response.body, equals('Hello $name'));
+    });
+  });
+}
diff --git a/examples/kitchen_sink/e2e/index_test.dart b/examples/kitchen_sink/e2e/index_test.dart
index 6f2ba582c..49f476b86 100644
--- a/examples/kitchen_sink/e2e/index_test.dart
+++ b/examples/kitchen_sink/e2e/index_test.dart
@@ -4,7 +4,7 @@ import 'package:http/http.dart' as http;
 import 'package:test/test.dart';
 
 void main() {
-  group('E2E', () {
+  group('E2E (/)', () {
     const greeting = 'Hello';
     test('GET / responds with "Hello"', () async {
       final response = await http.get(Uri.parse('http://localhost:8080'));
@@ -19,61 +19,5 @@ void main() {
       expect(response.statusCode, equals(HttpStatus.ok));
       expect(response.body, isNotEmpty);
     });
-
-    test('GET /greet/<name> responds with the "Hello <name>"', () async {
-      const name = 'Frog';
-      final response = await http.get(
-        Uri.parse('http://localhost:8080/greet/$name'),
-      );
-      expect(response.statusCode, equals(HttpStatus.ok));
-      expect(response.body, equals('Hello $name'));
-    });
-
-    test('GET /users/<id> responds with the "Hello user <id>"', () async {
-      const id = 'id';
-      final response = await http.get(
-        Uri.parse('http://localhost:8080/users/$id'),
-      );
-      expect(response.statusCode, equals(HttpStatus.ok));
-      expect(response.body, equals('$greeting user $id'));
-    });
-
-    test('GET /users/<id>/<name> responds with the "Hello <name> (user <id>)"',
-        () async {
-      const id = 'id';
-      const name = 'Frog';
-      final response = await http.get(
-        Uri.parse('http://localhost:8080/users/$id/$name'),
-      );
-      expect(response.statusCode, equals(HttpStatus.ok));
-      expect(response.body, equals('$greeting $name (user $id)'));
-    });
-
-    test('GET /api/pets responds with unauthorized when header is missing',
-        () async {
-      final response = await http.get(
-        Uri.parse('http://localhost:8080/api/pets'),
-      );
-      expect(response.statusCode, equals(HttpStatus.unauthorized));
-    });
-
-    test('GET /api/pets responds with "Hello pets"', () async {
-      final response = await http.get(
-        Uri.parse('http://localhost:8080/api/pets'),
-        headers: {HttpHeaders.authorizationHeader: 'token'},
-      );
-      expect(response.statusCode, equals(HttpStatus.ok));
-      expect(response.body, equals('$greeting pets'));
-    });
-
-    test('GET /api/pets/<name> responds with "Hello <name>"', () async {
-      const name = 'Frog';
-      final response = await http.get(
-        Uri.parse('http://localhost:8080/api/pets/$name'),
-        headers: {HttpHeaders.authorizationHeader: 'token'},
-      );
-      expect(response.statusCode, equals(HttpStatus.ok));
-      expect(response.body, equals('$greeting $name'));
-    });
   });
 }
diff --git a/examples/kitchen_sink/e2e/pets_test.dart b/examples/kitchen_sink/e2e/pets_test.dart
new file mode 100644
index 000000000..8d653a527
--- /dev/null
+++ b/examples/kitchen_sink/e2e/pets_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 (/pets)', () {
+    const greeting = 'Hello';
+    test('GET /api/pets responds with unauthorized when header is missing',
+        () async {
+      final response = await http.get(
+        Uri.parse('http://localhost:8080/api/pets'),
+      );
+      expect(response.statusCode, equals(HttpStatus.unauthorized));
+    });
+
+    test('GET /api/pets responds with "Hello pets"', () async {
+      final response = await http.get(
+        Uri.parse('http://localhost:8080/api/pets'),
+        headers: {HttpHeaders.authorizationHeader: 'token'},
+      );
+      expect(response.statusCode, equals(HttpStatus.ok));
+      expect(response.body, equals('$greeting pets'));
+    });
+
+    test('GET /api/pets/<name> responds with "Hello <name>"', () async {
+      const name = 'Frog';
+      final response = await http.get(
+        Uri.parse('http://localhost:8080/api/pets/$name'),
+        headers: {HttpHeaders.authorizationHeader: 'token'},
+      );
+      expect(response.statusCode, equals(HttpStatus.ok));
+      expect(response.body, equals('$greeting $name'));
+    });
+  });
+}
diff --git a/examples/kitchen_sink/e2e/users_test.dart b/examples/kitchen_sink/e2e/users_test.dart
new file mode 100644
index 000000000..87b388d52
--- /dev/null
+++ b/examples/kitchen_sink/e2e/users_test.dart
@@ -0,0 +1,29 @@
+import 'dart:io';
+
+import 'package:http/http.dart' as http;
+import 'package:test/test.dart';
+
+void main() {
+  group('E2E (/users)', () {
+    const greeting = 'Hello';
+    test('GET /users/<id> responds with the "Hello user <id>"', () async {
+      const id = 'id';
+      final response = await http.get(
+        Uri.parse('http://localhost:8080/users/$id'),
+      );
+      expect(response.statusCode, equals(HttpStatus.ok));
+      expect(response.body, equals('$greeting user $id'));
+    });
+
+    test('GET /users/<id>/<name> responds with the "Hello <name> (user <id>)"',
+        () async {
+      const id = 'id';
+      const name = 'Frog';
+      final response = await http.get(
+        Uri.parse('http://localhost:8080/users/$id/$name'),
+      );
+      expect(response.statusCode, equals(HttpStatus.ok));
+      expect(response.body, equals('$greeting $name (user $id)'));
+    });
+  });
+}
diff --git a/packages/dart_frog/test/src/body_parsers/form_data_test.dart b/packages/dart_frog/test/src/body_parsers/form_data_test.dart
new file mode 100644
index 000000000..2fedd0651
--- /dev/null
+++ b/packages/dart_frog/test/src/body_parsers/form_data_test.dart
@@ -0,0 +1,75 @@
+import 'dart:io';
+
+import 'package:dart_frog/src/body_parsers/form_data.dart';
+import 'package:test/test.dart';
+
+void main() {
+  group('parseFormData', () {
+    test('throws StateError when content-type header is missing', () async {
+      const message = '''
+Body could not be parsed as form data due to an invalid MIME type.
+Expected MIME type: "application/x-www-form-urlencoded"
+Actual MIME type: ""
+''';
+      expect(
+        parseFormData(headers: {}, body: () async => ''),
+        throwsA(isA<StateError>().having((e) => e.message, 'message', message)),
+      );
+    });
+
+    test('throws StateError when content-type header is incorrect', () async {
+      const message = '''
+Body could not be parsed as form data due to an invalid MIME type.
+Expected MIME type: "application/x-www-form-urlencoded"
+Actual MIME type: "application/json"
+''';
+      expect(
+        parseFormData(
+          headers: {HttpHeaders.contentTypeHeader: ContentType.json.mimeType},
+          body: () async => '',
+        ),
+        throwsA(isA<StateError>().having((e) => e.message, 'message', message)),
+      );
+    });
+
+    test('returns empty form data when body is empty', () async {
+      expect(
+        parseFormData(
+          headers: {
+            HttpHeaders.contentTypeHeader: formUrlEncodedContentType.mimeType
+          },
+          body: () async => '',
+        ),
+        completion(isEmpty),
+      );
+    });
+
+    test(
+        'returns populated form data '
+        'when body contains single key/value', () async {
+      expect(
+        parseFormData(
+          headers: {
+            HttpHeaders.contentTypeHeader: formUrlEncodedContentType.mimeType
+          },
+          body: () async => 'foo=bar',
+        ),
+        completion(equals({'foo': 'bar'})),
+      );
+    });
+
+    test(
+        'returns populated form data '
+        'when body contains multiple key/values', () async {
+      expect(
+        parseFormData(
+          headers: {
+            HttpHeaders.contentTypeHeader: formUrlEncodedContentType.mimeType
+          },
+          body: () async => 'foo=bar&bar=baz',
+        ),
+        completion(equals({'foo': 'bar', 'bar': 'baz'})),
+      );
+    });
+  });
+}
diff --git a/packages/dart_frog/test/src/request_test.dart b/packages/dart_frog/test/src/request_test.dart
index 3a6f023e0..d8d336e0d 100644
--- a/packages/dart_frog/test/src/request_test.dart
+++ b/packages/dart_frog/test/src/request_test.dart
@@ -1,6 +1,8 @@
 import 'dart:convert';
+import 'dart:io';
 
 import 'package:dart_frog/dart_frog.dart';
+import 'package:dart_frog/src/body_parsers/body_parsers.dart';
 import 'package:test/test.dart';
 
 void main() {
@@ -107,6 +109,55 @@ void main() {
       });
     });
 
+    group('formData', () {
+      final contentTypeFormUrlEncoded = {
+        HttpHeaders.contentTypeHeader: formUrlEncodedContentType.mimeType
+      };
+
+      test('throws StateError on invalid content-type', () async {
+        final request = Request.post(localhost);
+        expect(request.formData(), throwsStateError);
+      });
+
+      test('has correct data (no body)', () async {
+        final request = Request.post(
+          localhost,
+          headers: contentTypeFormUrlEncoded,
+        );
+        expect(request.formData(), completion(equals({})));
+      });
+
+      test('has correct data (empty body)', () async {
+        final request = Request.post(
+          localhost,
+          headers: contentTypeFormUrlEncoded,
+          body: '',
+        );
+        expect(request.formData(), completion(equals({})));
+      });
+
+      test('has correct data (single key/value pair)', () async {
+        final request = Request.post(
+          localhost,
+          headers: contentTypeFormUrlEncoded,
+          body: 'foo=bar',
+        );
+        expect(request.formData(), completion(equals({'foo': 'bar'})));
+      });
+
+      test('has correct data (multiple key/value pairs)', () async {
+        final request = Request.post(
+          localhost,
+          headers: contentTypeFormUrlEncoded,
+          body: 'foo=bar&bar=baz',
+        );
+        expect(
+          request.formData(),
+          completion(equals({'foo': 'bar', 'bar': 'baz'})),
+        );
+      });
+    });
+
     group('json', () {
       test('has correct body (map)', () {
         final body = <String, dynamic>{'foo': 'bar'};
diff --git a/packages/dart_frog/test/src/response_test.dart b/packages/dart_frog/test/src/response_test.dart
index ecc05e668..95b53812c 100644
--- a/packages/dart_frog/test/src/response_test.dart
+++ b/packages/dart_frog/test/src/response_test.dart
@@ -2,6 +2,7 @@ import 'dart:convert';
 import 'dart:io';
 
 import 'package:dart_frog/dart_frog.dart';
+import 'package:dart_frog/src/body_parsers/form_data.dart';
 import 'package:test/test.dart';
 
 void main() {
@@ -66,6 +67,46 @@ void main() {
       });
     });
 
+    group('formData', () {
+      final contentTypeFormUrlEncoded = {
+        HttpHeaders.contentTypeHeader: formUrlEncodedContentType.mimeType
+      };
+
+      test('throws StateError on invalid content-type', () async {
+        final response = Response();
+        expect(response.formData(), throwsStateError);
+      });
+
+      test('has correct data (no body)', () async {
+        final response = Response(headers: contentTypeFormUrlEncoded);
+        expect(response.formData(), completion(equals({})));
+      });
+
+      test('has correct data (empty body)', () async {
+        final response = Response(headers: contentTypeFormUrlEncoded, body: '');
+        expect(response.formData(), completion(equals({})));
+      });
+
+      test('has correct data (single key/value pair)', () async {
+        final response = Response(
+          headers: contentTypeFormUrlEncoded,
+          body: 'foo=bar',
+        );
+        expect(response.formData(), completion(equals({'foo': 'bar'})));
+      });
+
+      test('has correct data (multiple key/value pairs)', () async {
+        final response = Response(
+          headers: contentTypeFormUrlEncoded,
+          body: 'foo=bar&bar=baz',
+        );
+        expect(
+          response.formData(),
+          completion(equals({'foo': 'bar', 'bar': 'baz'})),
+        );
+      });
+    });
+
     group('json', () {
       test('has correct body (map)', () {
         final body = <String, dynamic>{'foo': 'bar'};