Skip to content

Commit 189cceb

Browse files
authored
fix(dart_frog): add default handler for disallowed http methods (#1) (#1727)
1 parent acc6829 commit 189cceb

File tree

4 files changed

+114
-5
lines changed

4 files changed

+114
-5
lines changed

packages/dart_frog/lib/src/request.dart

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
part of '_internal.dart';
22

3+
/// {@template unsupported_http_method_exception}
4+
/// Exception thrown when an unsupported HTTP method is used.
5+
/// {@endtemplate}
6+
class UnsupportedHttpMethodException implements Exception {
7+
/// {@macro unsupported_http_method_exception}
8+
const UnsupportedHttpMethodException(this.method);
9+
10+
/// The unsupported http method.
11+
final String method;
12+
13+
@override
14+
String toString() => '''
15+
Unsupported HTTP method: $method.
16+
The following methods are supported:
17+
${HttpMethod.values.map((m) => m.value.toUpperCase()).join(', ')}.''';
18+
}
19+
320
/// {@template request}
421
/// An HTTP request.
522
/// {@endtemplate}
@@ -112,7 +129,10 @@ class Request {
112129

113130
/// The [HttpMethod] associated with the request.
114131
HttpMethod get method {
115-
return HttpMethod.values.firstWhere((m) => m.value == _request.method);
132+
return HttpMethod.values.firstWhere(
133+
(m) => m.value == _request.method,
134+
orElse: () => throw UnsupportedHttpMethodException(_request.method),
135+
);
116136
}
117137

118138
/// Returns a [Stream] representing the body.

packages/dart_frog/lib/src/router.dart

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,14 @@ class Router {
3636
///
3737
/// The [notFoundHandler] will be invoked for requests where no matching route
3838
/// was found. By default, a simple 404 response will be used.
39-
Router({Handler notFoundHandler = _defaultNotFound})
40-
: _notFoundHandler = notFoundHandler;
39+
///
40+
/// The [methodNotAllowedHandler] will be invoked for requests where the HTTP
41+
/// method is not allowed. By default, a simple 405 response will be used.
42+
Router({
43+
Handler notFoundHandler = _defaultNotFound,
44+
Handler methodNotAllowedHandler = _defaultMethodNotAllowed,
45+
}) : _notFoundHandler = notFoundHandler,
46+
_methodNotAllowedHandler = methodNotAllowedHandler;
4147

4248
/// Name of the parameter used for matching
4349
/// the rest of the path in a mounted route.
@@ -48,6 +54,7 @@ class Router {
4854

4955
final List<RouterEntry> _routes = [];
5056
final Handler _notFoundHandler;
57+
final Handler _methodNotAllowedHandler;
5158

5259
/// Add [handler] for [verb] requests to [route].
5360
///
@@ -167,8 +174,14 @@ class Router {
167174
/// This method allows a Router instance to be a [Handler].
168175
Future<Response> call(RequestContext context) async {
169176
for (final route in _routes) {
170-
if (route.verb != context.request.method.value.toUpperCase() &&
171-
route.verb != 'ALL') {
177+
final HttpMethod method;
178+
try {
179+
method = context.request.method;
180+
} on UnsupportedHttpMethodException {
181+
return _methodNotAllowedHandler(context);
182+
}
183+
184+
if (route.verb != method.value.toUpperCase() && route.verb != 'ALL') {
172185
continue;
173186
}
174187
final params = route.match('/${context.request._request.url.path}');
@@ -211,11 +224,22 @@ class Router {
211224

212225
static Response _defaultNotFound(RequestContext context) => routeNotFound;
213226

227+
static Response _defaultMethodNotAllowed(RequestContext context) {
228+
return methodNotAllowed;
229+
}
230+
214231
/// Sentinel [Response] object indicating that no matching route was found.
215232
///
216233
/// This is the default response value from a [Router] created without a
217234
/// `notFoundHandler`, when no routes matches the incoming request.
218235
static final Response routeNotFound = _RouteNotFoundResponse();
236+
237+
/// Sentinel [Response] object indicating that the http method
238+
/// was not allowed for the requested route.
239+
///
240+
/// This is the default response value from a [Router] created without a
241+
/// `methodNotAllowedHandler`, when an unsupported http method is requested.
242+
static final Response methodNotAllowed = _MethodNotAllowedResponse();
219243
}
220244

221245
/// Extends [Response] to allow it to be used multiple times in the
@@ -241,6 +265,29 @@ class _RouteNotFoundResponse extends Response {
241265
}
242266
}
243267

268+
/// Extends [Response] to allow it to be used multiple times in the
269+
/// actual content being served.
270+
class _MethodNotAllowedResponse extends Response {
271+
_MethodNotAllowedResponse()
272+
: super(statusCode: HttpStatus.methodNotAllowed, body: _message);
273+
static const _message = 'Method not allowed';
274+
static final _messageBytes = utf8.encode(_message);
275+
276+
@override
277+
shelf.Response get _response => super._response.change(body: _messageBytes);
278+
279+
@override
280+
Stream<List<int>> bytes() => Stream<List<int>>.value(_messageBytes);
281+
282+
@override
283+
Future<String> body() async => _message;
284+
285+
@override
286+
Response copyWith({Map<String, Object?>? headers, dynamic body}) {
287+
return super.copyWith(headers: headers, body: body ?? _message);
288+
}
289+
}
290+
244291
/// Check if the [regexp] is non-capturing.
245292
bool _isNoCapture(String regexp) {
246293
// Construct a new regular expression matching anything containing regexp,

packages/dart_frog/test/src/request_test.dart

Lines changed: 15 additions & 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/_internal.dart';
67
import 'package:dart_frog/src/body_parsers/body_parsers.dart';
78
import 'package:test/test.dart';
89

@@ -43,6 +44,20 @@ void main() {
4344
expect(request.bytes(), emits(utf8.encode(body)));
4445
});
4546

47+
test('throw exception when method is unsupported', () {
48+
final request = Request('FOO', localhost);
49+
expect(
50+
() => request.method,
51+
throwsA(
52+
isA<UnsupportedHttpMethodException>()
53+
.having((e) => e.toString(), 'toString', '''
54+
Unsupported HTTP method: FOO.
55+
The following methods are supported:
56+
${HttpMethod.values.map((m) => m.value.toUpperCase()).join(', ')}.'''),
57+
),
58+
);
59+
});
60+
4661
test('throws exception when unable to read body', () async {
4762
final exception = Exception('oops');
4863
final body = Stream<Object>.error(exception);

packages/dart_frog/test/src/router_test.dart

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ void main() {
9191
expect(response.statusCode, equals(HttpStatus.notFound));
9292
expect(response.body, equals('Route not found'));
9393

94+
final streamedResponse = await http.Client().send(
95+
http.Request('FOO', Uri.parse('${server.url}/hello/world')),
96+
);
97+
response = await http.Response.fromStream(streamedResponse);
98+
expect(response.statusCode, equals(HttpStatus.methodNotAllowed));
99+
expect(response.body, equals('Method not allowed'));
100+
94101
response = await http.get(Uri.parse('${server.url}/hello/world'));
95102
expect(response.statusCode, equals(HttpStatus.ok));
96103
expect(response.body, equals('hello'));
@@ -445,6 +452,26 @@ void main() {
445452
expect(response.body(), completion(equals('Route not found')));
446453
});
447454

455+
test('can call Router.methodNotAllowed.body() multiple times', () async {
456+
final b1 = await Router.methodNotAllowed.body();
457+
expect(b1, 'Method not allowed');
458+
final b2 = await Router.methodNotAllowed.body();
459+
expect(b2, b1);
460+
});
461+
462+
test('can call Router.methodNotAllowed.bytes() multiple times', () async {
463+
final b1 = Router.methodNotAllowed.bytes();
464+
expect(b1, emits(utf8.encode('Method not allowed')));
465+
final b2 = Router.methodNotAllowed.bytes();
466+
expect(b2, emits(utf8.encode('Method not allowed')));
467+
});
468+
469+
test('can call Router.methodNotAllowed.copyWith()', () async {
470+
final response = Router.methodNotAllowed.copyWith(headers: {'foo': 'bar'});
471+
expect(response.headers['foo'], equals('bar'));
472+
expect(response.body(), completion(equals('Method not allowed')));
473+
});
474+
448475
test('can mount route without params', () async {
449476
final context = _MockRequestContext();
450477
final app = Router()..mount('/', (RequestContext context) => Response());

0 commit comments

Comments
 (0)