Skip to content

Commit 281f912

Browse files
authored
feat(dart_frog): add formData to Request/Response (#428)
1 parent 4cd77a9 commit 281f912

File tree

12 files changed

+306
-62
lines changed

12 files changed

+306
-62
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import 'dart:io';
2+
3+
import 'package:http/http.dart' as http;
4+
import 'package:test/test.dart';
5+
6+
void main() {
7+
group('E2E (/greet)', () {
8+
test('GET /greet/<name> responds with the "Hello <name>"', () async {
9+
const name = 'Frog';
10+
final response = await http.get(
11+
Uri.parse('http://localhost:8080/greet/$name'),
12+
);
13+
expect(response.statusCode, equals(HttpStatus.ok));
14+
expect(response.body, equals('Hello $name'));
15+
});
16+
});
17+
}

examples/kitchen_sink/e2e/index_test.dart

Lines changed: 1 addition & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import 'package:http/http.dart' as http;
44
import 'package:test/test.dart';
55

66
void main() {
7-
group('E2E', () {
7+
group('E2E (/)', () {
88
const greeting = 'Hello';
99
test('GET / responds with "Hello"', () async {
1010
final response = await http.get(Uri.parse('http://localhost:8080'));
@@ -19,61 +19,5 @@ void main() {
1919
expect(response.statusCode, equals(HttpStatus.ok));
2020
expect(response.body, isNotEmpty);
2121
});
22-
23-
test('GET /greet/<name> responds with the "Hello <name>"', () async {
24-
const name = 'Frog';
25-
final response = await http.get(
26-
Uri.parse('http://localhost:8080/greet/$name'),
27-
);
28-
expect(response.statusCode, equals(HttpStatus.ok));
29-
expect(response.body, equals('Hello $name'));
30-
});
31-
32-
test('GET /users/<id> responds with the "Hello user <id>"', () async {
33-
const id = 'id';
34-
final response = await http.get(
35-
Uri.parse('http://localhost:8080/users/$id'),
36-
);
37-
expect(response.statusCode, equals(HttpStatus.ok));
38-
expect(response.body, equals('$greeting user $id'));
39-
});
40-
41-
test('GET /users/<id>/<name> responds with the "Hello <name> (user <id>)"',
42-
() async {
43-
const id = 'id';
44-
const name = 'Frog';
45-
final response = await http.get(
46-
Uri.parse('http://localhost:8080/users/$id/$name'),
47-
);
48-
expect(response.statusCode, equals(HttpStatus.ok));
49-
expect(response.body, equals('$greeting $name (user $id)'));
50-
});
51-
52-
test('GET /api/pets responds with unauthorized when header is missing',
53-
() async {
54-
final response = await http.get(
55-
Uri.parse('http://localhost:8080/api/pets'),
56-
);
57-
expect(response.statusCode, equals(HttpStatus.unauthorized));
58-
});
59-
60-
test('GET /api/pets responds with "Hello pets"', () async {
61-
final response = await http.get(
62-
Uri.parse('http://localhost:8080/api/pets'),
63-
headers: {HttpHeaders.authorizationHeader: 'token'},
64-
);
65-
expect(response.statusCode, equals(HttpStatus.ok));
66-
expect(response.body, equals('$greeting pets'));
67-
});
68-
69-
test('GET /api/pets/<name> responds with "Hello <name>"', () async {
70-
const name = 'Frog';
71-
final response = await http.get(
72-
Uri.parse('http://localhost:8080/api/pets/$name'),
73-
headers: {HttpHeaders.authorizationHeader: 'token'},
74-
);
75-
expect(response.statusCode, equals(HttpStatus.ok));
76-
expect(response.body, equals('$greeting $name'));
77-
});
7822
});
7923
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import 'dart:io';
2+
3+
import 'package:http/http.dart' as http;
4+
import 'package:test/test.dart';
5+
6+
void main() {
7+
group('E2E (/pets)', () {
8+
const greeting = 'Hello';
9+
test('GET /api/pets responds with unauthorized when header is missing',
10+
() async {
11+
final response = await http.get(
12+
Uri.parse('http://localhost:8080/api/pets'),
13+
);
14+
expect(response.statusCode, equals(HttpStatus.unauthorized));
15+
});
16+
17+
test('GET /api/pets responds with "Hello pets"', () async {
18+
final response = await http.get(
19+
Uri.parse('http://localhost:8080/api/pets'),
20+
headers: {HttpHeaders.authorizationHeader: 'token'},
21+
);
22+
expect(response.statusCode, equals(HttpStatus.ok));
23+
expect(response.body, equals('$greeting pets'));
24+
});
25+
26+
test('GET /api/pets/<name> responds with "Hello <name>"', () async {
27+
const name = 'Frog';
28+
final response = await http.get(
29+
Uri.parse('http://localhost:8080/api/pets/$name'),
30+
headers: {HttpHeaders.authorizationHeader: 'token'},
31+
);
32+
expect(response.statusCode, equals(HttpStatus.ok));
33+
expect(response.body, equals('$greeting $name'));
34+
});
35+
});
36+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import 'dart:io';
2+
3+
import 'package:http/http.dart' as http;
4+
import 'package:test/test.dart';
5+
6+
void main() {
7+
group('E2E (/users)', () {
8+
const greeting = 'Hello';
9+
test('GET /users/<id> responds with the "Hello user <id>"', () async {
10+
const id = 'id';
11+
final response = await http.get(
12+
Uri.parse('http://localhost:8080/users/$id'),
13+
);
14+
expect(response.statusCode, equals(HttpStatus.ok));
15+
expect(response.body, equals('$greeting user $id'));
16+
});
17+
18+
test('GET /users/<id>/<name> responds with the "Hello <name> (user <id>)"',
19+
() async {
20+
const id = 'id';
21+
const name = 'Frog';
22+
final response = await http.get(
23+
Uri.parse('http://localhost:8080/users/$id/$name'),
24+
);
25+
expect(response.statusCode, equals(HttpStatus.ok));
26+
expect(response.body, equals('$greeting $name (user $id)'));
27+
});
28+
});
29+
}

packages/dart_frog/lib/src/_internal.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:convert';
33
import 'dart:io';
44

55
import 'package:dart_frog/dart_frog.dart';
6+
import 'package:dart_frog/src/body_parsers/body_parsers.dart';
67
import 'package:http_methods/http_methods.dart' show isHttpMethod;
78
import 'package:shelf/shelf.dart' as shelf;
89
import 'package:shelf/shelf_io.dart' as shelf_io;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export 'form_data.dart';
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import 'dart:io';
2+
3+
/// Content-Type: application/x-www-form-urlencoded
4+
final formUrlEncodedContentType = ContentType(
5+
'application',
6+
'x-www-form-urlencoded',
7+
);
8+
9+
/// Parses the body as form data and returns a `Future<Map<String, String>>`.
10+
/// Throws a [StateError] if the MIME type is not "application/x-www-form-urlencoded"
11+
/// https://fetch.spec.whatwg.org/#ref-for-dom-body-formdata%E2%91%A0
12+
Future<Map<String, String>> parseFormData({
13+
required Map<String, String> headers,
14+
required Future<String> Function() body,
15+
}) async {
16+
final contentType = _extractContentType(headers);
17+
if (!_isFormUrlEncoded(contentType)) {
18+
throw StateError(
19+
'''
20+
Body could not be parsed as form data due to an invalid MIME type.
21+
Expected MIME type: "${formUrlEncodedContentType.mimeType}"
22+
Actual MIME type: "${contentType?.mimeType ?? ''}"
23+
''',
24+
);
25+
}
26+
27+
return Uri.splitQueryString(await body());
28+
}
29+
30+
ContentType? _extractContentType(Map<String, String> headers) {
31+
final contentTypeValue = headers[HttpHeaders.contentTypeHeader];
32+
if (contentTypeValue == null) return null;
33+
return ContentType.parse(contentTypeValue);
34+
}
35+
36+
bool _isFormUrlEncoded(ContentType? contentType) {
37+
if (contentType == null) return false;
38+
return contentType.mimeType == formUrlEncodedContentType.mimeType;
39+
}

packages/dart_frog/lib/src/request.dart

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,18 @@ class Request {
115115
return HttpMethod.values.firstWhere((m) => m.value == _request.method);
116116
}
117117

118-
/// The body as a byte array stream.
118+
/// Returns a [Stream] representing the body.
119119
Stream<List<int>> bytes() => _request.read();
120120

121-
/// The body as a string.
121+
/// Returns a [Future] containing the body as a [String].
122122
Future<String> body() => _request.readAsString();
123123

124-
/// The body as a json object.
124+
/// Returns a [Future] containing the form data as a [Map].
125+
Future<Map<String, String>> formData() {
126+
return parseFormData(headers: headers, body: body);
127+
}
128+
129+
/// Returns a [Future] containing the body text parsed as a json object.
125130
/// This object could be anything that can be represented by json
126131
/// e.g. a map, a list, a string, a number, a bool...
127132
Future<dynamic> json() async => jsonDecode(await _request.readAsString());

packages/dart_frog/lib/src/response.dart

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,15 @@ class Response {
6060
/// Returns a [Stream] representing the body.
6161
Stream<List<int>> bytes() => _response.read();
6262

63-
/// Returns a Future containing the body as a string.
63+
/// Returns a [Future] containing the body as a [String].
6464
Future<String> body() => _response.readAsString();
6565

66-
/// The body as a json object.
66+
/// Returns a [Future] containing the form data as a [Map].
67+
Future<Map<String, String>> formData() {
68+
return parseFormData(headers: headers, body: body);
69+
}
70+
71+
/// Returns a [Future] containing the body text parsed as a json object.
6772
/// This object could be anything that can be represented by json
6873
/// e.g. a map, a list, a string, a number, a bool...
6974
Future<dynamic> json() async => jsonDecode(await _response.readAsString());
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import 'dart:io';
2+
3+
import 'package:dart_frog/src/body_parsers/form_data.dart';
4+
import 'package:test/test.dart';
5+
6+
void main() {
7+
group('parseFormData', () {
8+
test('throws StateError when content-type header is missing', () async {
9+
const message = '''
10+
Body could not be parsed as form data due to an invalid MIME type.
11+
Expected MIME type: "application/x-www-form-urlencoded"
12+
Actual MIME type: ""
13+
''';
14+
expect(
15+
parseFormData(headers: {}, body: () async => ''),
16+
throwsA(isA<StateError>().having((e) => e.message, 'message', message)),
17+
);
18+
});
19+
20+
test('throws StateError when content-type header is incorrect', () async {
21+
const message = '''
22+
Body could not be parsed as form data due to an invalid MIME type.
23+
Expected MIME type: "application/x-www-form-urlencoded"
24+
Actual MIME type: "application/json"
25+
''';
26+
expect(
27+
parseFormData(
28+
headers: {HttpHeaders.contentTypeHeader: ContentType.json.mimeType},
29+
body: () async => '',
30+
),
31+
throwsA(isA<StateError>().having((e) => e.message, 'message', message)),
32+
);
33+
});
34+
35+
test('returns empty form data when body is empty', () async {
36+
expect(
37+
parseFormData(
38+
headers: {
39+
HttpHeaders.contentTypeHeader: formUrlEncodedContentType.mimeType
40+
},
41+
body: () async => '',
42+
),
43+
completion(isEmpty),
44+
);
45+
});
46+
47+
test(
48+
'returns populated form data '
49+
'when body contains single key/value', () async {
50+
expect(
51+
parseFormData(
52+
headers: {
53+
HttpHeaders.contentTypeHeader: formUrlEncodedContentType.mimeType
54+
},
55+
body: () async => 'foo=bar',
56+
),
57+
completion(equals({'foo': 'bar'})),
58+
);
59+
});
60+
61+
test(
62+
'returns populated form data '
63+
'when body contains multiple key/values', () async {
64+
expect(
65+
parseFormData(
66+
headers: {
67+
HttpHeaders.contentTypeHeader: formUrlEncodedContentType.mimeType
68+
},
69+
body: () async => 'foo=bar&bar=baz',
70+
),
71+
completion(equals({'foo': 'bar', 'bar': 'baz'})),
72+
);
73+
});
74+
});
75+
}

0 commit comments

Comments
 (0)