示例
shelf 1.4.2: Handle and test shelf HTTP requests in Dart without binding a port, by building a Request and calling the router, middleware and handler directly
已验证示例 — pub shelf 1.4.2: Handle and test shelf HTTP requests in Dart without binding a port, by building a Request and calling the router, middleware and…
sha256:2651162215727e626baac2f69d7b7e043707968fea0f9218aa9dda0648549e95
本网络只提供一件事:能构建的样本。它在沙箱中运行并保留签名回执。它不评级、不担保——同样的代码能否在你的环境构建,它没有测量过。
提交了通过的契约回执的不同签名密钥数量。为 1 表示只有作者;大于 1 表示还有其他人构建过。密钥是自行生成的,背后没有注册身份,因此计的是密钥而非人。
MIT-0
执行证据
声明的环境与签名的运行分开呈现,你可以看到这个样本究竟运行了什么、在哪里运行。
- 证据依据
- 签名契约通过
- 验证回执
- 2
- 构建过它的签名密钥
- 2
声明的环境
dart 3 linux x64 dart 3 dart pub
验证运行环境
| 环境 | 契约 | 阶段 | 运行日期 |
|---|---|---|---|
| dart 3 · linux alpine/x64 · docker ed25519:a2ec939a4c60e243 | PASS | compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS CONTAINER_RUN · pub@1 |
2026-08-14 |
| dart 3 · linux debian/x64 · docker ed25519:2175b912ea1c23b1 | PASS | compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS CONTAINER_RUN · pub@1 |
2026-08-18 |
案例
HOW- 目标
- Handle and test shelf HTTP requests in Dart without binding a port, by building a Request and calling the router, middleware and handler directly
- 符号
-
- Router
- Router.routeNotFound
- Request.params
- Pipeline.addMiddleware
- Middleware
- Response.ok
- Message.readAsString
- Message.read
- Response.change
- Request.change
- 环境
- dart 3
- 创建时间
- 2026-08-14T14:13:26Z
契约
- assert a constructed Request driven straight through the Router dispatches on method and path with no port bound, that an unregistered method on a matched path is a 404 and not a 405, that a <param> stops at a slash, and that registering GET also registers a HEAD in front of it whose body is stripped but whose content-length still reports the GET body length
- assert a handler taking only a Request receives the captures through the request.params extension getter, backed by a shelf_router/params context key that survives request.change, and that params outside a Router is an empty unmodifiable map rather than null
- assert a handler taking extra arguments is filled positionally in route-pattern order with the closure's parameter names ignored, and that a wrong argument count is accepted at registration and fails as a NoSuchMethodError on the first matching request
- assert the unmatched route returns Router.routeNotFound itself, one shared instance that can be read more than once, that returning it from a handler resumes matching while an identical-looking Response.notFound ends the request, and that notFoundHandler replaces it
- assert Pipeline runs middleware in declaration order inbound and the reverse outbound, proven by a response header each middleware appends to and by one they overwrite where the outermost writer wins, and that middleware answering without calling the inner handler cuts out everything declared below it
- assert a shelf body is single-subscription whether it was passed as a String or as a Stream, that the second read throws StateError synchronously out of readAsString, and that change() shares the drained Body so only change(body: ...) restores it
- assert a logging middleware that reads the response body forwards an unreadable response, that the request-side version breaks the route handler through the router's own request.change, and that both are fixed by putting the read body back
- assert header lookup is case-insensitive on requests and responses while the stored keys keep the case they were written with, and that a request served over loopback arrives with lowercase keys because dart:io lowercases them off the wire
文件
- csx.json
- lib/api.dart
- pubspec.lock
- pubspec.yaml
- test/contract_test.dart
源代码
{"case":{"caseId":"case:sha256:c27c1ac7a36f97e2258b1b133024cba0f673ce0c08d86faee861d788b2d1ae5d","constraints":{"runtime":"dart"},"contract":["assert a constructed Request driven straight through the Router dispatches on method and path with no port bound, that an unregistered method on a matched path is a 404 and not a 405, that a \u003cparam\u003e stops at a slash, and that registering GET also registers a HEAD in front of it whose body is stripped but whose content-length still reports the GET body length","assert a handler taking only a Request receives the captures through the request.params extension getter, backed by a shelf_router/params context key that survives request.change, and that params outside a Router is an empty unmodifiable map rather than null","assert a handler taking extra arguments is filled positionally in route-pattern order with the closure's parameter names ignored, and that a wrong argument count is accepted at registration and fails as a NoSuchMethodError on the first matching request","assert the unmatched route returns Router.routeNotFound itself, one shared instance that can be read more than once, that returning it from a handler resumes matching while an identical-looking Response.notFound ends the request, and that notFoundHandler replaces it","assert Pipeline runs middleware in declaration order inbound and the reverse outbound, proven by a response header each middleware appends to and by one they overwrite where the outermost writer wins, and that middleware answering without calling the inner handler cuts out everything declared below it","assert a shelf body is single-subscription whether it was passed as a String or as a Stream, that the second read throws StateError synchronously out of readAsString, and that change() shares the drained Body so only change(body: ...) restores it","assert a logging middleware that reads the response body forwards an unreadable response, that the request-side version breaks the route handler through the router's own request.change, and that both are fixed by putting the read body back","assert header lookup is case-insensitive on requests and responses while the stored keys keep the case they were written with, and that a request served over loopback arrives with lowercase keys because dart:io lowercases them off the wire"],"goal":"Handle and test shelf HTTP requests in Dart without binding a port, by building a Request and calling the router, middleware and handler directly","kind":"HOW","packages":["pkg:pub/shelf@1.4.2","pkg:pub/shelf_router@1.1.4"],"schemaVersion":1,"symbols":["Router","Router.routeNotFound","Request.params","Pipeline.addMiddleware","Middleware","Response.ok","Message.readAsString","Message.read","Response.change","Request.change"]},"contractCommand":["dart","test","--reporter=expanded"],"environment":{"arch":"x64","ecosystem":"pub","executionContext":"dart","language":"dart","os":"linux","packageManager":"pub","runtime":"dart","runtimeVersion":"3","schemaVersion":1},"license":"MIT-0","packages":["pkg:pub/shelf@1.4.2","pkg:pub/shelf_router@1.1.4"],"schemaVersion":1,"symbols":["Router","Router.routeNotFound","Request.params","Pipeline.addMiddleware","Middleware","Response.ok","Message.readAsString","Message.read","Response.change","Request.change"],"verifierAdapter":"pub@1"}
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
/// Handling shelf requests without binding a port. A `Handler` is nothing but
/// `FutureOr<Response> Function(Request)`, so a test builds a Request and calls
/// it. No socket, no mock, no fixture server: the router, the middleware and
/// the handler under test are all the real ones, and the whole round trip is a
/// function call.
///
/// Six things behave differently than the shape of the API suggests.
///
/// 1. shelf_router decides how to deliver URL parameters from the handler's
/// signature, not from the route. RouterEntry tests `_handler is Handler`
/// first: a handler that takes only a Request is called with only the
/// Request, and the captured values arrive through `request.params`, the
/// extension getter shelf_router adds to Request. A handler that takes extra
/// arguments has them applied positionally, in the order the parameters
/// appear in the route pattern — the closure's own parameter names are never
/// read, so swapping two of them silently swaps two values. Getting the
/// count wrong is not rejected when the route is registered; it is a
/// NoSuchMethodError on the first matching request.
///
/// 2. The default 404 is `Router.routeNotFound`, one shared object rather than
/// a fresh Response per request. shelf_router had to override `read()` on it
/// so the same instance can be served repeatedly, which is the loudest hint
/// in either package that an ordinary Response cannot. That object also
/// means "no match, keep looking" when a handler returns it: the router
/// resumes matching later routes. `Response.notFound('Route not found')` —
/// same status, same bytes — does not, because the router compares by
/// identity.
///
/// 3. `Pipeline().addMiddleware(a).addMiddleware(b).addHandler(h)` composes to
/// `a(b(h))`. Declaration order is the order requests are seen and the
/// reverse of the order responses are seen, so the outermost middleware
/// writes its response header last and wins a collision.
///
/// 4. Every shelf body is a single-subscription stream, whatever you passed in.
/// A String body is not stored as a String; `Body` encodes it to bytes and
/// wraps it in a Stream, and `read()` hands that stream over and nulls its
/// own reference. The second `readAsString()` is a StateError, thrown
/// synchronously out of the call rather than delivered to the Future. Worse
/// for middleware: `change()` carries the same Body object across, so a
/// logging middleware that reads the body and returns `response.change(...)`
/// forwards a response whose body is already gone. Read once, then put what
/// you read back with `change(body: ...)`.
///
/// 5. `Router.get` quietly registers two routes: the GET, and a HEAD in front
/// of it wrapped in a body-stripping middleware. A `router.head` you add
/// afterwards for the same pattern is dead code, because the generated one
/// already matched.
///
/// 6. Header lookup is case-insensitive, but the keys are not lowercased —
/// `CaseInsensitiveMap` canonicalises for lookup and keeps the spelling you
/// stored. dart:io lowercases header names as it parses them off the wire,
/// so a served request really does have lowercase keys, and a Request you
/// constructed has whatever case you typed. Only code that iterates `.keys`
/// or serialises the header map can tell, and that code passes in a unit
/// test and fails in production.
/// The application under test. Routes are registered against the real Router,
/// so what the test exercises is the routing, not a description of it.
Router buildApp() {
final app = Router();
// One argument, so shelf_router treats this as a plain Handler and the
// capture arrives in request.params.
app.get('/users/<id>', (Request request) {
return Response.ok('user ${request.params['id']}');
});
// Extra arguments, so the captures are applied positionally instead. Both
// styles are supported at once; the router chooses per route.
app.get('/orgs/<org>/users/<id>', (Request request, String org, String id) {
return Response.ok('$org/$id');
});
app.post('/users', (Request request) async {
return Response(201, body: 'created ${await request.readAsString()}');
});
// Two handlers on one pattern. The first declines by returning the sentinel,
// which sends the router back to matching rather than ending the request.
app.get('/search', (Request request) {
final q = request.url.queryParameters['q'];
if (q == null) return Router.routeNotFound;
return Response.ok('results for $q');
});
app.get('/search', (Request request) => Response.ok('search form'));
return app;
}
/// Builds the Request an adapter would have built, without the socket. The
/// host is only there because Request requires an absolute requestedUri; the
/// router matches on `request.url`, which is the path relative to handlerPath.
Request buildRequest(
String method,
String path, {
Map<String, Object>? headers,
Object? body,
}) =>
Request(
method,
Uri.parse('http://example.com$path'),
headers: headers,
body: body,
);
/// Records the order it is entered and left, and appends its name to a shared
/// response header on the way out so the ordering can be read off the response
/// as well as off the log.
Middleware stamp(String name, List<String> log) =>
(Handler innerHandler) => (Request request) async {
log.add('$name >');
final response = await innerHandler(request);
log.add('< $name');
final existing = response.headers['x-stamp'];
return response.change(headers: {
'x-stamp': existing == null ? name : '$existing,$name',
});
};
/// Sets a header outright instead of appending to it, so a pipeline of these
/// shows which writer lands last rather than only in which order they ran.
Middleware setOwner(String name) =>
(Handler innerHandler) => (Request request) async =>
(await innerHandler(request)).change(headers: {'x-owner': name});
/// Answers without calling the inner handler, which is what makes middleware
/// order load-bearing rather than cosmetic.
Middleware requireToken() => (Handler innerHandler) => (Request request) async {
if (request.headers['authorization'] != 'Bearer t0ken') {
return Response.forbidden('no token');
}
return innerHandler(request);
};
/// The logging middleware everybody writes first. It reads the body to log it
/// and hands the same Response on, and `change` brings the drained Body with
/// it, so the adapter finds nothing left to write.
Middleware drainingLogger(List<String> log) =>
(Handler innerHandler) => (Request request) async {
final response = await innerHandler(request);
log.add(await response.readAsString());
return response.change(headers: {'x-logged': 'yes'});
};
/// The same middleware with the one fix: put back what you took.
Middleware bodyLogger(List<String> log) =>
(Handler innerHandler) => (Request request) async {
final response = await innerHandler(request);
final body = await response.readAsString();
log.add(body);
return response.change(body: body, headers: {'x-logged': 'yes'});
};
/// The request-side version of the same mistake. The handler downstream gets a
/// Request whose body has already been consumed.
Middleware drainingRequestLogger(List<String> log) =>
(Handler innerHandler) => (Request request) async {
log.add(await request.readAsString());
return innerHandler(request);
};
/// And its fix, for the request side.
Middleware requestLogger(List<String> log) =>
(Handler innerHandler) => (Request request) async {
final body = await request.readAsString();
log.add(body);
return innerHandler(request.change(body: body));
};
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "9a3386eea899815698dd55995277cf7cb8572ee52b399a6edfb7ae2b50e5fc19"
url: "https://pub.dev"
source: hosted
version: "105.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "62993bed6eadbe9596c5c20d5c167e7bc563c5fe266657a04ddeb93bdb84f4c9"
url: "https://pub.dev"
source: hosted
version: "14.1.0"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
cli_config:
dependency: transitive
description:
name: cli_config
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
url: "https://pub.dev"
source: hosted
version: "0.2.0"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
coverage:
dependency: transitive
description:
name: coverage
sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d"
url: "https://pub.dev"
source: hosted
version: "1.15.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
http_methods:
dependency: transitive
description:
name: http_methods
sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.20"
meta:
dependency: transitive
description:
name: meta
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.19.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
node_preamble:
dependency: transitive
description:
name: node_preamble
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
package_config:
dependency: transitive
description:
name: package_config
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
url: "https://pub.dev"
source: hosted
version: "3.0.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
shelf:
dependency: "direct main"
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
shelf_router:
dependency: "direct main"
description:
name: shelf_router
sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864
url: "https://pub.dev"
source: hosted
version: "1.1.4"
shelf_static:
dependency: transitive
description:
name: shelf_static
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
url: "https://pub.dev"
source: hosted
version: "1.1.3"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
url: "https://pub.dev"
source: hosted
version: "2.1.2"
source_maps:
dependency: transitive
description:
name: source_maps
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
url: "https://pub.dev"
source: hosted
version: "0.10.13"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test:
dependency: "direct dev"
description:
name: test
sha256: "0d5ba5602ec3baa28c8ce365e1efc5575969c765f45c554a3e167dc7945b9c30"
url: "https://pub.dev"
source: hosted
version: "1.31.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "475610b2aa23c19687cce2961e44b0cc57cafe220f67c2b80201231b2a07fbe7"
url: "https://pub.dev"
source: hosted
version: "0.7.13"
test_core:
dependency: transitive
description:
name: test_core
sha256: a39c204a4fc7a7ccb04a2b985e359fda3cc37e45e0b8ac61c3fb1a05aa832132
url: "https://pub.dev"
source: hosted
version: "0.6.19"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.11.0 <4.0.0"
name: csx_shelf_requests
description: Handling shelf requests by calling the handler directly, with no port bound.
publish_to: none
environment:
sdk: ^3.5.0
dependencies:
shelf: 1.4.2
shelf_router: 1.1.4
dev_dependencies:
test: 1.31.2
import 'dart:async';
import 'dart:io';
import 'package:csx_shelf_requests/api.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart';
import 'package:test/test.dart';
void main() {
test('a Router dispatches on method and path, with no port bound', () async {
final app = buildApp();
// The whole round trip: build a Request, call the Router, read the
// Response. Router implements call(), so it is itself a Handler.
final user = await app(buildRequest('GET', '/users/u42'));
expect(user.statusCode, equals(200));
expect(await user.readAsString(), equals('user u42'));
final created = await app(buildRequest('POST', '/users', body: 'ada'));
expect(created.statusCode, equals(201));
expect(await created.readAsString(), equals('created ada'));
// Same path, unregistered method. shelf_router does not do 405: a method
// it has no route for is indistinguishable from a path it has no route
// for, so this is the plain 404.
expect((await app(buildRequest('DELETE', '/users/u42'))).statusCode,
equals(404));
// A GET route answers HEAD too, because Router.add registers a second
// entry for it wrapped in a body-stripping middleware. The status matches
// the GET and the body is gone.
final head = await app(buildRequest('HEAD', '/users/u42'));
expect(head.statusCode, equals(200));
expect(await head.readAsString(), isEmpty);
// Measured, against the expectation that stripping the body zeroes the
// length: it is still the length of the GET body. The stripping middleware
// sets content-length to '0' first and Response.change recomputes the
// header from the body it carries over, undoing that; the later
// change(body: []) then leaves the header alone. RFC 9110 wants exactly
// this number on a HEAD, but nothing in the code aimed for it, so assert
// the number rather than the intent.
expect(head.headers['content-length'], equals('8'));
expect((await app(buildRequest('GET', '/users/u42'))).contentLength,
equals(8));
// <id> compiles to [^/]+, so it stops at a slash and does not swallow the
// rest of the path.
expect((await app(buildRequest('GET', '/users/u42/roles'))).statusCode,
equals(404));
// The generated HEAD entry is registered ahead of the GET, so a head route
// added afterwards for the same pattern never matches. Registering it
// first is the documented way to own HEAD, and it works.
final headLast = Router()
..get('/ping', (Request request) => Response.ok('pong'))
..head('/ping',
(Request request) => Response.ok('', headers: {'x-from': 'head'}));
expect((await headLast(buildRequest('HEAD', '/ping'))).headers['x-from'],
isNull);
final headFirst = Router()
..head('/ping',
(Request request) => Response.ok('', headers: {'x-from': 'head'}))
..get('/ping', (Request request) => Response.ok('pong'));
expect((await headFirst(buildRequest('HEAD', '/ping'))).headers['x-from'],
equals('head'));
});
test('captures reach a one-argument handler through request.params',
() async {
final app = buildApp();
expect(await (await app(buildRequest('GET', '/users/u42'))).readAsString(),
equals('user u42'));
// Outside a Router the extension getter is not an error and not null: it
// is an empty unmodifiable map, so a route registered without <id> reads
// params['id'] as null rather than failing.
final bare = buildRequest('GET', '/users/u42');
expect(bare.params, isEmpty);
expect(bare.params['id'], isNull);
expect(() => bare.params['id'] = 'u42', throwsUnsupportedError);
// params is carried in the request context under a namespaced key, which
// is how it survives request.change() through middleware.
late Request seen;
final router = Router()
..get('/users/<id>', (Request request) {
seen = request;
return Response.ok('');
});
await router(buildRequest('GET', '/users/u42'));
expect(seen.context['shelf_router/params'], equals({'id': 'u42'}));
// change() copies the context, so an inner middleware that rewrites the
// request keeps the captures the router attached.
expect(
seen.change(headers: {'x-inner': '1'}).params, equals({'id': 'u42'}));
});
test('a multi-argument handler is filled by position, never by name',
() async {
final app = buildApp();
expect(
await (await app(buildRequest('GET', '/orgs/acme/users/u42')))
.readAsString(),
equals('acme/u42'),
);
// The names in the closure are decoration. This handler calls the first
// argument `id` and the second `org`, and still receives them in the order
// the pattern captures them, so the values come out swapped.
final swapped = Router()
..get('/orgs/<org>/users/<id>',
(Request request, String id, String org) => Response.ok('$id/$org'));
expect(
await (await swapped(buildRequest('GET', '/orgs/acme/users/u42')))
.readAsString(),
equals('acme/u42'),
);
// Arity is not checked when the route is registered. Registering succeeds
// and the failure arrives on the first matching request, as a
// NoSuchMethodError from Function.apply.
final wrongArity = Router()
..get('/users/<id>',
(Request request, String id, String extra) => Response.ok(''));
await expectLater(
wrongArity(buildRequest('GET', '/users/u42')),
throwsA(isA<NoSuchMethodError>()),
);
});
test(
'the unmatched route returns the router 404, a shared re-readable object',
() async {
final app = buildApp();
final missing = await app(buildRequest('GET', '/nope'));
expect(missing.statusCode, equals(404));
expect(await missing.readAsString(), equals('Route not found'));
// It is not a response built for this request: the default notFoundHandler
// returns the one static instance every Router shares, and shelf_router had
// to override read() on it so serving it twice works. That override is why
// reading it twice below succeeds where a Response you built would throw.
expect(identical(missing, Router.routeNotFound), isTrue);
expect(await missing.readAsString(), equals('Route not found'));
// Returning that object from a handler means "not matched, keep going",
// so /search without ?q falls through to the second handler registered on
// the same pattern.
expect(await (await app(buildRequest('GET', '/search'))).readAsString(),
equals('search form'));
expect(
await (await app(buildRequest('GET', '/search?q=shelf')))
.readAsString(),
equals('results for shelf'));
// A look-alike does not fall through. Same status, same body text, but the
// router compares by identity, so this ends the request and the second
// handler is never reached.
final lookalike = Router()
..get(
'/search', (Request request) => Response.notFound('Route not found'))
..get('/search', (Request request) => Response.ok('search form'));
final ended = await lookalike(buildRequest('GET', '/search'));
expect(ended.statusCode, equals(404));
expect(identical(ended, Router.routeNotFound), isFalse);
// The 404 is a handler like any other and can be replaced.
final custom = Router(
notFoundHandler: (Request request) =>
Response.notFound('no route for ${request.url.path}'));
expect(await (await custom(buildRequest('GET', '/nope'))).readAsString(),
equals('no route for nope'));
});
test('Pipeline runs middleware in declaration order and unwinds in reverse',
() async {
final log = <String>[];
final handler = const Pipeline()
.addMiddleware(stamp('outer', log))
.addMiddleware(stamp('inner', log))
.addHandler((Request request) {
log.add('handler');
return Response.ok('body');
});
final response = await handler(buildRequest('GET', '/'));
// addMiddleware(a).addMiddleware(b) composes to a(b(handler)), so requests
// travel outward-in and responses inward-out.
expect(
log, equals(['outer >', 'inner >', 'handler', '< inner', '< outer']));
// Read off the response instead of off the log: inner appended first, so
// the outermost middleware is the one whose value ends up last.
expect(response.headers['x-stamp'], equals('inner,outer'));
// Appending hides which write lands last, so run it again with middleware
// that overwrite. The outermost one writes on the way out, after the
// handler and after everything nested inside it, so its value is the one
// that reaches the client.
final collided = const Pipeline()
.addMiddleware(setOwner('outer'))
.addMiddleware(setOwner('inner'))
.addHandler((Request request) =>
Response.ok('body', headers: {'x-owner': 'handler'}));
expect((await collided(buildRequest('GET', '/'))).headers['x-owner'],
equals('outer'));
// Middleware that answers without calling the inner handler cuts
// everything below it out of the request, including the middleware
// declared after it.
log.clear();
final guarded = const Pipeline()
.addMiddleware(stamp('outer', log))
.addMiddleware(requireToken())
.addMiddleware(stamp('inner', log))
.addHandler((Request request) {
log.add('handler');
return Response.ok('body');
});
final rejected = await guarded(buildRequest('GET', '/'));
expect(rejected.statusCode, equals(403));
expect(log, equals(['outer >', '< outer']));
expect(rejected.headers['x-stamp'], equals('outer'));
final allowed = await guarded(
buildRequest('GET', '/', headers: {'authorization': 'Bearer t0ken'}));
expect(allowed.statusCode, equals(200));
expect(log.last, equals('< outer'));
expect(allowed.headers['x-stamp'], equals('inner,outer'));
});
test('a body is a single-subscription stream whatever type you passed',
() async {
// Measured, against the expectation that a String body is held as a String
// and can be read as often as you like. Body encodes it once and keeps a
// Stream; there is no stored String to re-read, so the String case and the
// Stream case fail identically.
for (final response in [
Response.ok('hello'),
Response.ok(Stream<List<int>>.value('hello'.codeUnits)),
]) {
expect(await response.readAsString(), equals('hello'));
// The StateError is thrown out of the call, not delivered to the Future:
// read() runs before readAsString builds one. `await` still catches it,
// but .catchError on the returned Future never fires.
expect(
() => response.readAsString(),
throwsA(isA<StateError>().having(
(e) => e.message,
'message',
"The 'read' method can only be called once on a "
'shelf.Request/shelf.Response object.',
)),
);
expect(() => response.read(), throwsStateError);
}
// Requests are the same object model, so the same rule applies.
final request = buildRequest('POST', '/users', body: 'ada');
expect(await request.readAsString(), equals('ada'));
expect(() => request.readAsString(), throwsStateError);
// change() copies headers and context but shares the Body instance, which
// is what turns "I only logged it" into an empty response.
final drained = Response.ok('hello');
await drained.readAsString();
expect(() => drained.change(headers: {'x-logged': 'yes'}).readAsString(),
throwsStateError);
// Putting the string back is the whole fix, and it is a new Body.
final refilled = Response.ok('hello');
final captured = await refilled.readAsString();
final forwarded = refilled.change(body: captured);
expect(await forwarded.readAsString(), equals('hello'));
expect(forwarded.headers['content-length'], equals('5'));
});
test('reading the body in middleware breaks the handler below it', () async {
final log = <String>[];
// Response side: the middleware logs the body and the caller gets nothing.
final broken = const Pipeline()
.addMiddleware(drainingLogger(log))
.addHandler((Request request) => Response.ok('hello'));
final emptied = await broken(buildRequest('GET', '/'));
expect(log, equals(['hello']));
expect(emptied.headers['x-logged'], equals('yes'));
expect(() => emptied.readAsString(), throwsStateError);
log.clear();
final fixed = const Pipeline()
.addMiddleware(bodyLogger(log))
.addHandler((Request request) => Response.ok('hello'));
final intact = await fixed(buildRequest('GET', '/'));
expect(log, equals(['hello']));
expect(intact.headers['x-logged'], equals('yes'));
expect(await intact.readAsString(), equals('hello'));
// Request side, through the real Router: shelf_router calls
// request.change() to attach params, which carries the drained Body along,
// so the route handler's readAsString fails.
log.clear();
final brokenIn = const Pipeline()
.addMiddleware(drainingRequestLogger(log))
.addHandler(buildApp().call);
await expectLater(
brokenIn(buildRequest('POST', '/users', body: 'ada')),
throwsA(isA<StateError>()),
);
expect(log, equals(['ada']));
log.clear();
final fixedIn = const Pipeline()
.addMiddleware(requestLogger(log))
.addHandler(buildApp().call);
final ok = await fixedIn(buildRequest('POST', '/users', body: 'ada'));
expect(log, equals(['ada']));
expect(await ok.readAsString(), equals('created ada'));
});
test('header lookup ignores case; the stored key keeps the case you wrote',
() async {
final request = buildRequest(
'POST',
'/users',
headers: {'Content-Type': 'application/json', 'X-Request-Id': 'abc'},
body: '{"n":1}',
);
// Lookup is canonicalised, so every spelling finds the value and
// containsKey agrees.
for (final spelling in ['Content-Type', 'content-type', 'CONTENT-TYPE']) {
expect(request.headers[spelling], equals('application/json'));
expect(request.headers.containsKey(spelling), isTrue);
}
expect(request.mimeType, equals('application/json'));
expect(request.headers['x-request-id'], equals('abc'));
// Measured, against the expectation that shelf lowercases header names on
// the way in. CaseInsensitiveMap canonicalises for lookup only and hands
// back the key as stored, so a constructed Request keeps your capitals.
// Only iteration can tell, which is exactly what a header-forwarding proxy
// or a snapshot assertion does.
expect(request.headers.keys, contains('Content-Type'));
expect(request.headers.keys, isNot(contains('content-type')));
// shelf added this one itself, in the spelling shelf uses.
expect(request.headers['content-length'], equals('7'));
expect(request.headers.keys, contains('content-length'));
// Responses behave the same way, and headersAll is the multi-value view of
// the identical map.
final response = Response.ok('hi', headers: {
'X-Trace': ['a', 'b']
});
expect(response.headers['x-trace'], equals('a,b'));
expect(response.headersAll['x-trace'], equals(['a', 'b']));
expect(response.headers.keys, contains('X-Trace'));
// Where the lowercase belief comes from: dart:io lowercases header names
// while parsing them, so a request that arrived over a socket really does
// have lowercase keys. Served on loopback, which works with the network
// disabled, with the mixed-case name written straight onto the wire so
// nothing in a client library can be blamed for the change.
final served = Completer<List<String>>();
final server = await shelf_io.serve((Request request) {
if (!served.isCompleted) served.complete(request.headers.keys.toList());
return Response.ok('');
}, InternetAddress.loopbackIPv4, 0);
final socket =
await Socket.connect(InternetAddress.loopbackIPv4, server.port);
socket.write('GET /users/u42 HTTP/1.1\r\n'
'Host: example.com\r\n'
'X-Request-Id: abc\r\n'
'Connection: close\r\n'
'\r\n');
await socket.flush();
final servedKeys = await served.future;
socket.destroy();
await server.close(force: true);
expect(servedKeys, contains('x-request-id'));
expect(servedKeys, isNot(contains('X-Request-Id')));
});
}