diff --git a/packages/cache/.gitignore b/packages/cache/.gitignore new file mode 100644 index 00000000..e3978899 --- /dev/null +++ b/packages/cache/.gitignore @@ -0,0 +1,63 @@ +# See https://www.dartlang.org/tools/private-files.html + +# Files and directories created by pub +.packages +.pub/ +build/ +# If you're building an application, you may want to check-in your pubspec.lock +pubspec.lock + +# Directory created by dartdoc +# If you don't generate documentation locally you can remove this line. +doc/api/ +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/dictionaries + +# Sensitive or high-churn files: +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.xml +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml + +# Gradle: +.idea/**/gradle.xml +.idea/**/libraries + +# CMake +cmake-build-debug/ + +# Mongo Explorer plugin: +.idea/**/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties +.dart_tool \ No newline at end of file diff --git a/packages/cache/.idea/cache.iml b/packages/cache/.idea/cache.iml new file mode 100644 index 00000000..0fd729f3 --- /dev/null +++ b/packages/cache/.idea/cache.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/cache/.idea/modules.xml b/packages/cache/.idea/modules.xml new file mode 100644 index 00000000..c1f1e7f8 --- /dev/null +++ b/packages/cache/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/cache/.idea/runConfigurations/cache_service_dart.xml b/packages/cache/.idea/runConfigurations/cache_service_dart.xml new file mode 100644 index 00000000..0e7d0ce4 --- /dev/null +++ b/packages/cache/.idea/runConfigurations/cache_service_dart.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/packages/cache/.idea/runConfigurations/main_dart.xml b/packages/cache/.idea/runConfigurations/main_dart.xml new file mode 100644 index 00000000..750f7262 --- /dev/null +++ b/packages/cache/.idea/runConfigurations/main_dart.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/packages/cache/.idea/runConfigurations/sends_304_on_if_modified_since_in_cache_test_dart.xml b/packages/cache/.idea/runConfigurations/sends_304_on_if_modified_since_in_cache_test_dart.xml new file mode 100644 index 00000000..57b298cf --- /dev/null +++ b/packages/cache/.idea/runConfigurations/sends_304_on_if_modified_since_in_cache_test_dart.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/packages/cache/.idea/runConfigurations/tests_in_cache_test_dart.xml b/packages/cache/.idea/runConfigurations/tests_in_cache_test_dart.xml new file mode 100644 index 00000000..33520a9a --- /dev/null +++ b/packages/cache/.idea/runConfigurations/tests_in_cache_test_dart.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/packages/cache/.idea/vcs.xml b/packages/cache/.idea/vcs.xml new file mode 100644 index 00000000..35eb1ddf --- /dev/null +++ b/packages/cache/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/cache/.travis.yml b/packages/cache/.travis.yml new file mode 100644 index 00000000..2f22c5ce --- /dev/null +++ b/packages/cache/.travis.yml @@ -0,0 +1,4 @@ +language: dart +dart: + - stable + - dev \ No newline at end of file diff --git a/packages/cache/CHANGELOG.md b/packages/cache/CHANGELOG.md new file mode 100644 index 00000000..cf5dc962 --- /dev/null +++ b/packages/cache/CHANGELOG.md @@ -0,0 +1,6 @@ +# 2.0.1 +* Add `ignoreQueryAndFragment` to `ResponseCache`. +* Rename `CacheService.ignoreQuery` to `ignoreParams`. + +# 1.0.0 +* First version \ No newline at end of file diff --git a/packages/cache/LICENSE b/packages/cache/LICENSE new file mode 100644 index 00000000..89074fd3 --- /dev/null +++ b/packages/cache/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 The Angel Framework + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/cache/README.md b/packages/cache/README.md new file mode 100644 index 00000000..238be5e6 --- /dev/null +++ b/packages/cache/README.md @@ -0,0 +1,92 @@ +# cache +[![Pub](https://img.shields.io/pub/v/angel_cache.svg)](https://pub.dartlang.org/packages/angel_cache) +[![build status](https://travis-ci.org/angel-dart/cache.svg)](https://travis-ci.org/angel-dart/cache) + +Support for server-side caching in [Angel](https://angel-dart.github.io). + +## `CacheService` + +A `Service` class that caches data from one service, storing it in another. +An imaginable use case is storing results from MongoDB or another database in +MemcacheD/Redis. + +## `cacheSerializationResults` +A middleware that enables the caching of response serialization. + +This can improve the performance of sending objects that are complex to serialize. +You can pass a [shouldCache] callback to determine which values should be cached. + +```dart +main() async { + var app = new Angel()..lazyParseBodies = true; + + app.use( + '/api/todos', + new CacheService( + database: new AnonymousService( + index: ([params]) { + print('Fetched directly from the underlying service at ${new DateTime.now()}!'); + return ['foo', 'bar', 'baz']; + }, + read: (id, [params]) { + return {id: '$id at ${new DateTime.now()}'}; + } + ), + ), + ); +} +``` + +## `ResponseCache` +A flexible response cache for Angel. + +Use this to improve real and perceived response of Web applications, +as well as to memoize expensive responses. + +Supports the `If-Modified-Since` header, as well as storing the contents of +response buffers in memory. + +To initialize a simple cache: + +```dart +Future configureServer(Angel app) async { + // Simple instance. + var cache = new ResponseCache(); + + // You can also pass an invalidation timeout. + var cache = new ResponseCache(timeout: const Duration(days: 2)); + + // Close the cache when the application closes. + app.shutdownHooks.add((_) => cache.close()); + + // Use `patterns` to specify which resources should be cached. + cache.patterns.addAll([ + 'robots.txt', + new RegExp(r'\.(png|jpg|gif|txt)$'), + new Glob('public/**/*'), + ]); + + // REQUIRED: The middleware that serves cached responses + app.use(cache.handleRequest); + + // REQUIRED: The response finalizer that saves responses to the cache + app.responseFinalizers.add(cache.responseFinalizer); +} +``` + +### Purging the Cache +Call `invalidate` to remove a resource from a `ResponseCache`. + +Some servers expect a reverse proxy or caching layer to support `PURGE` requests. +If this is your case, make sure to include some sort of validation (maybe IP-based) +to ensure no arbitrary attacker can hack your cache: + +```dart +Future configureServer(Angel app) async { + app.addRoute('PURGE', '*', (req, res) { + if (req.ip != '127.0.0.1') + throw new AngelHttpException.forbidden(); + return cache.purge(req.uri.path); + }); +} +``` \ No newline at end of file diff --git a/packages/cache/analysis_options.yaml b/packages/cache/analysis_options.yaml new file mode 100644 index 00000000..eae1e42a --- /dev/null +++ b/packages/cache/analysis_options.yaml @@ -0,0 +1,3 @@ +analyzer: + strong-mode: + implicit-casts: false \ No newline at end of file diff --git a/packages/cache/example/cache_service.dart b/packages/cache/example/cache_service.dart new file mode 100644 index 00000000..2ac17a12 --- /dev/null +++ b/packages/cache/example/cache_service.dart @@ -0,0 +1,24 @@ +import 'package:angel_cache/angel_cache.dart'; +import 'package:angel_framework/angel_framework.dart'; + +main() async { + var app = new Angel(); + + app.use( + '/api/todos', + new CacheService( + cache: new MapService(), + database: new AnonymousService(index: ([params]) { + print( + 'Fetched directly from the underlying service at ${new DateTime.now()}!'); + return ['foo', 'bar', 'baz']; + }, read: (id, [params]) { + return {id: '$id at ${new DateTime.now()}'}; + }), + ), + ); + + var http = new AngelHttp(app); + var server = await http.startServer('127.0.0.1', 3000); + print('Listening at http://${server.address.address}:${server.port}'); +} diff --git a/packages/cache/example/main.dart b/packages/cache/example/main.dart new file mode 100644 index 00000000..9b08105f --- /dev/null +++ b/packages/cache/example/main.dart @@ -0,0 +1,35 @@ +import 'package:angel_cache/angel_cache.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:glob/glob.dart'; + +main() async { + var app = new Angel(); + + // Cache a glob + var cache = new ResponseCache() + ..patterns.addAll([ + new Glob('/*.txt'), + ]); + + // Handle `if-modified-since` header, and also send cached content + app.fallback(cache.handleRequest); + + // A simple handler that returns a different result every time. + app.get('/date.txt', + (req, res) => res.write(new DateTime.now().toIso8601String())); + + // Support purging the cache. + app.addRoute('PURGE', '*', (req, res) { + if (req.ip != '127.0.0.1') throw new AngelHttpException.forbidden(); + + cache.purge(req.uri.path); + print('Purged ${req.uri.path}'); + }); + + // The response finalizer that actually saves the content + app.responseFinalizers.add(cache.responseFinalizer); + + var http = new AngelHttp(app); + var server = await http.startServer('127.0.0.1', 3000); + print('Listening at http://${server.address.address}:${server.port}'); +} diff --git a/packages/cache/lib/angel_cache.dart b/packages/cache/lib/angel_cache.dart new file mode 100644 index 00000000..3b5fd187 --- /dev/null +++ b/packages/cache/lib/angel_cache.dart @@ -0,0 +1,3 @@ +export 'src/cache.dart'; +export 'src/cache_service.dart'; +export 'src/serializer.dart'; diff --git a/packages/cache/lib/src/cache.dart b/packages/cache/lib/src/cache.dart new file mode 100644 index 00000000..059e1cb5 --- /dev/null +++ b/packages/cache/lib/src/cache.dart @@ -0,0 +1,169 @@ +import 'dart:async'; +import 'dart:io' show HttpDate; +import 'package:angel_framework/angel_framework.dart'; +import 'package:pool/pool.dart'; + +/// A flexible response cache for Angel. +/// +/// Use this to improve real and perceived response of Web applications, +/// as well as to memoize expensive responses. +class ResponseCache { + /// A set of [Patterns] for which responses will be cached. + /// + /// For example, you can pass a `Glob` matching `**/*.png` files to catch all PNG images. + final List patterns = []; + + /// An optional timeout, after which a given response will be removed from the cache, and the contents refreshed. + final Duration timeout; + + final Map _cache = {}; + final Map _writeLocks = {}; + + /// If `true` (default: `false`), then caching of results will discard URI query parameters and fragments. + final bool ignoreQueryAndFragment; + + ResponseCache({this.timeout, this.ignoreQueryAndFragment: false}); + + /// Closes all internal write-locks, and closes the cache. + Future close() async { + _writeLocks.forEach((_, p) => p.close()); + } + + /// Removes an entry from the response cache. + void purge(String path) => _cache.remove(path); + + /// A middleware that handles requests with an `If-Modified-Since` header. + /// + /// This prevents the server from even having to access the cache, and plays very well with static assets. + Future ifModifiedSince(RequestContext req, ResponseContext res) async { + if (req.method != 'GET' && req.method != 'HEAD') return true; + + if (req.headers.ifModifiedSince != null) { + var modifiedSince = req.headers.ifModifiedSince; + + // Check if there is a cache entry. + for (var pattern in patterns) { + if (pattern.allMatches(_getEffectivePath(req)).isNotEmpty && + _cache.containsKey(_getEffectivePath(req))) { + var response = _cache[_getEffectivePath(req)]; + //print('timestamp ${response.timestamp} vs since ${modifiedSince}'); + + if (response.timestamp.compareTo(modifiedSince) <= 0) { + if (timeout != null) { + // If the cache timeout has been met, don't send the cached response. + if (new DateTime.now().toUtc().difference(response.timestamp) >= + timeout) return true; + } + + res.statusCode = 304; + return false; + } + } + } + } + + return true; + } + + String _getEffectivePath(RequestContext req) => + ignoreQueryAndFragment == true ? req.uri.path : req.uri.toString(); + + /// Serves content from the cache, if applicable. + Future handleRequest(RequestContext req, ResponseContext res) async { + if (!await ifModifiedSince(req, res)) return false; + if (req.method != 'GET' && req.method != 'HEAD') return true; + if (!res.isOpen) return true; + + // Check if there is a cache entry. + // + // If `if-modified-since` is present, this check has already been performed. + if (req.headers.ifModifiedSince == null) { + for (var pattern in patterns) { + if (pattern.allMatches(_getEffectivePath(req)).isNotEmpty) { + var now = new DateTime.now().toUtc(); + + if (_cache.containsKey(_getEffectivePath(req))) { + var response = _cache[_getEffectivePath(req)]; + + if (timeout != null) { + // If the cache timeout has been met, don't send the cached response. + if (now.difference(response.timestamp) >= timeout) return true; + } + + _setCachedHeaders(response.timestamp, req, res); + res + ..headers.addAll(response.headers) + ..add(response.body) + ..close(); + return false; + } else { + _setCachedHeaders(now, req, res); + } + } + } + } + + return true; + } + + /// A response finalizer that saves responses to the cache. + Future responseFinalizer( + RequestContext req, ResponseContext res) async { + if (res.statusCode == 304) return true; + if (req.method != 'GET' && req.method != 'HEAD') return true; + + // Check if there is a cache entry. + for (var pattern in patterns) { + if (pattern.allMatches(_getEffectivePath(req)).isNotEmpty) { + var now = new DateTime.now().toUtc(); + + // Invalidate the response, if need be. + if (_cache.containsKey(_getEffectivePath(req))) { + // If there is no timeout, don't invalidate. + if (timeout == null) return true; + + // Otherwise, don't invalidate unless the timeout has been exceeded. + var response = _cache[_getEffectivePath(req)]; + if (now.difference(response.timestamp) < timeout) return true; + + // If the cache entry should be invalidated, then invalidate it. + purge(_getEffectivePath(req)); + } + + // Save the response. + var writeLock = + _writeLocks.putIfAbsent(_getEffectivePath(req), () => new Pool(1)); + await writeLock.withResource(() { + _cache[_getEffectivePath(req)] = new _CachedResponse( + new Map.from(res.headers), res.buffer.toBytes(), now); + }); + + _setCachedHeaders(now, req, res); + } + } + + return true; + } + + void _setCachedHeaders( + DateTime modified, RequestContext req, ResponseContext res) { + var privacy = 'public'; + + res.headers + ..['cache-control'] = '$privacy, max-age=${timeout?.inSeconds ?? 86400}' + ..['last-modified'] = HttpDate.format(modified); + + if (timeout != null) { + var expiry = new DateTime.now().add(timeout); + res.headers['expires'] = HttpDate.format(expiry); + } + } +} + +class _CachedResponse { + final Map headers; + final List body; + final DateTime timestamp; + + _CachedResponse(this.headers, this.body, this.timestamp); +} diff --git a/packages/cache/lib/src/cache_service.dart b/packages/cache/lib/src/cache_service.dart new file mode 100644 index 00000000..d86c3ca3 --- /dev/null +++ b/packages/cache/lib/src/cache_service.dart @@ -0,0 +1,138 @@ +import 'dart:async'; +import 'package:collection/collection.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:meta/meta.dart'; + +/// An Angel [Service] that caches data from another service. +/// +/// This is useful for applications of scale, where network latency +/// can have real implications on application performance. +class CacheService extends Service { + /// The underlying [Service] that represents the original data store. + final Service database; + + /// The [Service] used to interface with a caching layer. + /// + /// If not provided, this defaults to a [MapService]. + final Service cache; + + /// If `true` (default: `false`), then result caching will discard parameters passed to service methods. + /// + /// If you want to return a cached result more-often-than-not, you may want to enable this. + final bool ignoreParams; + + final Duration timeout; + + final Map> _cache = {}; + _CachedItem> _indexed; + + CacheService( + {@required this.database, + @required this.cache, + this.ignoreParams: false, + this.timeout}) { + assert(database != null); + } + + Future _getCached( + Map params, + _CachedItem get(), + FutureOr getFresh(), + FutureOr getCached(), + FutureOr save(T data, DateTime now)) async { + var cached = get(); + var now = new DateTime.now().toUtc(); + + if (cached != null) { + // If the entry has expired, don't send from the cache + var expired = + timeout != null && now.difference(cached.timestamp) >= timeout; + + if (timeout == null || !expired) { + // Read from the cache if necessary + var queryEqual = ignoreParams == true || + (params != null && + cached.params != null && + const MapEquality().equals( + params['query'] as Map, cached.params['query'] as Map)); + if (queryEqual) { + return await getCached(); + } + } + } + + // If we haven't fetched from the cache by this point, + // let's fetch from the database. + var data = await getFresh(); + await save(data, now); + return data; + } + + @override + Future> index([Map params]) { + return _getCached( + params, + () => _indexed, + () => database.index(params), + () => _indexed?.data ?? [], + (data, now) async { + _indexed = new _CachedItem(params, now, data); + return data; + }, + ); + } + + @override + Future read(Id id, [Map params]) async { + return _getCached( + params, + () => _cache[id], + () => database.read(id, params), + () => cache.read(id), + (data, now) async { + _cache[id] = new _CachedItem(params, now, data); + return await cache.modify(id, data); + }, + ); + } + + @override + Future create(data, [Map params]) { + _indexed = null; + return database.create(data, params); + } + + @override + Future modify(Id id, Data data, [Map params]) { + _indexed = null; + _cache.remove(id); + return database.modify(id, data, params); + } + + @override + Future update(Id id, Data data, [Map params]) { + _indexed = null; + _cache.remove(id); + return database.modify(id, data, params); + } + + @override + Future remove(Id id, [Map params]) { + _indexed = null; + _cache.remove(id); + return database.remove(id, params); + } +} + +class _CachedItem { + final params; + final DateTime timestamp; + final Data data; + + _CachedItem(this.params, this.timestamp, [this.data]); + + @override + String toString() { + return '$timestamp:$params:$data'; + } +} diff --git a/packages/cache/lib/src/serializer.dart b/packages/cache/lib/src/serializer.dart new file mode 100644 index 00000000..3280eddf --- /dev/null +++ b/packages/cache/lib/src/serializer.dart @@ -0,0 +1,26 @@ +import 'dart:async'; +import 'package:angel_framework/angel_framework.dart'; + +/// A middleware that enables the caching of response serialization. +/// +/// This can improve the performance of sending objects that are complex to serialize. +/// +/// You can pass a [shouldCache] callback to determine which values should be cached. +RequestHandler cacheSerializationResults( + {Duration timeout, + FutureOr Function(RequestContext, ResponseContext, Object) + shouldCache}) { + return (RequestContext req, ResponseContext res) async { + var oldSerializer = res.serializer; + var cache = {}; + res.serializer = (value) { + if (shouldCache == null) { + return cache.putIfAbsent(value, () => oldSerializer(value)); + } + + return oldSerializer(value); + }; + + return true; + }; +} diff --git a/packages/cache/pubspec.yaml b/packages/cache/pubspec.yaml new file mode 100644 index 00000000..632a1a38 --- /dev/null +++ b/packages/cache/pubspec.yaml @@ -0,0 +1,17 @@ +name: angel_cache +version: 2.0.1 +homepage: https://github.com/angel-dart/cache +description: Support for server-side caching in Angel. +author: Tobe O +environment: + sdk: ">=2.0.0-dev <3.0.0" +dependencies: + angel_framework: ^2.0.0-alpha + collection: ^1.0.0 + meta: ^1.0.0 + pool: ^1.0.0 +dev_dependencies: + angel_test: ^2.0.0-alpha + glob: ^1.0.0 + http: any + test: ^1.0.0 \ No newline at end of file diff --git a/packages/cache/test/cache_test.dart b/packages/cache/test/cache_test.dart new file mode 100644 index 00000000..011981f0 --- /dev/null +++ b/packages/cache/test/cache_test.dart @@ -0,0 +1,107 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:angel_cache/angel_cache.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_test/angel_test.dart'; +import 'package:http/http.dart' as http; +import 'package:glob/glob.dart'; +import 'package:test/test.dart'; + +main() async { + group('no timeout', () { + TestClient client; + DateTime lastModified; + http.Response response1, response2; + + setUp(() async { + var app = new Angel(); + var cache = new ResponseCache() + ..patterns.addAll([ + new Glob('/*.txt'), + ]); + + app.fallback(cache.handleRequest); + + app.get('/date.txt', (req, res) { + res + ..useBuffer() + ..write(new DateTime.now().toIso8601String()); + }); + + app.addRoute('PURGE', '*', (req, res) { + cache.purge(req.uri.path); + print('Purged ${req.uri.path}'); + }); + + app.responseFinalizers.add(cache.responseFinalizer); + + var oldHandler = app.errorHandler; + app.errorHandler = (e, req, res) { + if (e.error == null) return oldHandler(e, req, res); + return Zone.current.handleUncaughtError(e.error, e.stackTrace); + }; + + client = await connectTo(app); + response1 = await client.get('/date.txt'); + response2 = await client.get('/date.txt'); + print(response2.headers); + lastModified = HttpDate.parse(response2.headers['last-modified']); + print('Response 1 status: ${response1.statusCode}'); + print('Response 2 status: ${response2.statusCode}'); + print('Response 1 body: ${response1.body}'); + print('Response 2 body: ${response2.body}'); + print('Response 1 headers: ${response1.headers}'); + print('Response 2 headers: ${response2.headers}'); + }); + + tearDown(() => client.close()); + + test('saves content', () async { + expect(response1.body, response2.body); + }); + + test('saves headers', () async { + response1.headers.forEach((k, v) { + expect(response2.headers, containsPair(k, v)); + }); + }); + + test('first response is normal', () { + expect(response1.statusCode, 200); + }); + + test('sends last-modified', () { + expect(response2.headers.keys, contains('last-modified')); + }); + + test('invalidate', () async { + await client.sendUnstreamed('PURGE', '/date.txt', {}); + var response = await client.get('/date.txt'); + print('Response after invalidation: ${response.body}'); + expect(response.body, isNot(response1.body)); + }); + + test('sends 304 on if-modified-since', () async { + var headers = { + 'if-modified-since': + HttpDate.format(lastModified.add(const Duration(days: 1))) + }; + var response = await client.get('/date.txt', headers: headers); + print('Sending headers: $headers'); + print('Response (${response.statusCode}): ${response.headers}'); + expect(response.statusCode, 304); + }); + + test('last-modified in the past', () async { + var response = await client.get('/date.txt', headers: { + 'if-modified-since': + HttpDate.format(lastModified.subtract(const Duration(days: 10))) + }); + print('Response: ${response.body}'); + expect(response.statusCode, 200); + expect(response.body, isNot(response1.body)); + }); + }); + + group('with timeout', () {}); +}