diff --git a/packages/cache/.gitignore b/packages/cache/.gitignore new file mode 100644 index 0000000..e397889 --- /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/AUTHORS.md b/packages/cache/AUTHORS.md new file mode 100644 index 0000000..ac95ab5 --- /dev/null +++ b/packages/cache/AUTHORS.md @@ -0,0 +1,12 @@ +Primary Authors +=============== + +* __[Thomas Hii](dukefirehawk.apps@gmail.com)__ + + Thomas is the current maintainer of the code base. He has refactored and migrated the + code base to support NNBD. + +* __[Tobe O](thosakwe@gmail.com)__ + + Tobe has written much of the original code prior to NNBD migration. He has moved on and + is no longer involved with the project. diff --git a/packages/cache/CHANGELOG.md b/packages/cache/CHANGELOG.md new file mode 100644 index 0000000..30db211 --- /dev/null +++ b/packages/cache/CHANGELOG.md @@ -0,0 +1,64 @@ +# Change Log + +## 8.2.0 + +* Require Dart >= 3.3 +* Updated `lints` to 4.0.0 + +## 8.1.1 + +* Updated repository link + +## 8.1.0 + +* Updated `lints` to 3.0.0 +* Fixed linter warnings + +## 8.0.0 + +* Require Dart >= 3.0 + +## 7.0.0 + +* Require Dart >= 2.17 + +## 6.0.0 + +* Require Dart >= 2.16 + +## 5.0.0 + +* Skipped release + +## 4.0.3 + +* Updated linter to `package:lints` + +## 4.0.2 + +* Updated README +* Added home page link +* All 7 unit tests passed + +## 4.0.1 + +* Updated pubspec description +* Fixed: Return `200` with cached data instead of `403` +* Updated broken unit tests + +## 4.0.0 + +* Migrated to support Dart >= 2.12 NNBD + +## 3.0.0 + +* Migrated to work with Dart >= 2.12 Non NNBD + +## 2.0.1 + +* Add `ignoreQueryAndFragment` to `ResponseCache`. +* Rename `CacheService.ignoreQuery` to `ignoreParams`. + +## 1.0.0 + +* First version diff --git a/packages/cache/LICENSE b/packages/cache/LICENSE new file mode 100644 index 0000000..df5e063 --- /dev/null +++ b/packages/cache/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2021, dukefirehawk.com +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/cache/README.md b/packages/cache/README.md new file mode 100644 index 0000000..9f5b977 --- /dev/null +++ b/packages/cache/README.md @@ -0,0 +1,91 @@ +# Angel3 HTTP Cache + +![Pub Version (including pre-releases)](https://img.shields.io/pub/v/platform_cache?include_prereleases) +[![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety) +[![Discord](https://img.shields.io/discord/1060322353214660698)](https://discord.gg/3X6bxTUdCM) +[![License](https://img.shields.io/github/license/dart-backend/angel)](https://github.com/dart-backend/angel/tree/master/packages/cache/LICENSE) + +A service that provides HTTP caching to the response data for [Angel3 framework](https://pub.dev/packages/angel3). + +## `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 Memcache/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 +void main() async { + var app = Angel()..lazyParseBodies = true; + + app.use( + '/api/todos', + CacheService( + database: AnonymousService( + index: ([params]) { + print('Fetched directly from the underlying service at ${DateTime.now()}!'); + return ['foo', 'bar', 'baz']; + }, + read: (id, [params]) { + return {id: '$id at ${DateTime.now()}'}; + } + ), + ), + ); +} +``` + +## `ResponseCache` + +A flexible response cache for Angel3. + +Use this to improve real and perceived response of Web applications, as well as to memorize 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 = ResponseCache(); + + // You can also pass an invalidation timeout. + var cache = 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', + RegExp(r'\.(png|jpg|gif|txt)$'), + 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 AngelHttpException.forbidden(); + return cache.purge(req.uri.path); + }); +} +``` diff --git a/packages/cache/analysis_options.yaml b/packages/cache/analysis_options.yaml new file mode 100644 index 0000000..ea2c9e9 --- /dev/null +++ b/packages/cache/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml \ 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 0000000..4230529 --- /dev/null +++ b/packages/cache/example/cache_service.dart @@ -0,0 +1,24 @@ +import 'package:platform_cache/platform_cache.dart'; +import 'package:platform_foundation/core.dart'; +import 'package:platform_foundation/http.dart'; + +void main() async { + var app = Application(); + + app.use( + '/api/todos', + CacheService( + cache: MapService(), + database: AnonymousService(index: ([params]) { + print( + 'Fetched directly from the underlying service at ${DateTime.now()}!'); + return ['foo', 'bar', 'baz']; + }, read: (dynamic id, [params]) { + return {id: '$id at ${DateTime.now()}'}; + })), + ); + + var http = PlatformHttp(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 0000000..c8ca260 --- /dev/null +++ b/packages/cache/example/main.dart @@ -0,0 +1,37 @@ +import 'package:platform_cache/platform_cache.dart'; +import 'package:platform_foundation/core.dart'; +import 'package:platform_foundation/http.dart'; + +void main() async { + var app = Application(); + + // Cache a glob + var cache = ResponseCache() + ..patterns.addAll([ + RegExp('^/?\\w+\\.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(DateTime.now().toIso8601String())); + + // Support purging the cache. + app.addRoute('PURGE', '*', (req, res) { + if (req.ip != '127.0.0.1') { + throw PlatformHttpException.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 = PlatformHttp(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/platform_cache.dart b/packages/cache/lib/platform_cache.dart new file mode 100644 index 0000000..3b5fd18 --- /dev/null +++ b/packages/cache/lib/platform_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 0000000..2103334 --- /dev/null +++ b/packages/cache/lib/src/cache.dart @@ -0,0 +1,203 @@ +import 'dart:async'; +import 'dart:io' show HttpDate; +import 'package:platform_foundation/core.dart'; +import 'package:pool/pool.dart'; +import 'package:logging/logging.dart'; + +/// A flexible response cache for Angel. +/// +/// Use this to improve real and perceived response of Web applications, +/// as well as to memorize 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; + + final log = Logger('ResponseCache'); + + ResponseCache( + {this.timeout = const Duration(minutes: 10), + 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; + } + + var modifiedSince = req.headers?.ifModifiedSince; + if (modifiedSince != null) { + // Check if there is a cache entry. + for (var pattern in patterns) { + var reqPath = _getEffectivePath(req); + + if (pattern.allMatches(reqPath).isNotEmpty && + _cache.containsKey(reqPath)) { + var response = _cache[reqPath]; + + //log.info('timestamp ${response?.timestamp} vs since $modifiedSince'); + + if (response != null && + response.timestamp.compareTo(modifiedSince) <= 0) { + // If the cache timeout has been met, don't send the cached response. + var timeDiff = + DateTime.now().toUtc().difference(response.timestamp); + + //log.info( + // 'Time Diff: ${timeDiff.inMilliseconds} >= ${timeout.inMilliseconds}'); + if (timeDiff.inMilliseconds >= timeout.inMilliseconds) { + return true; + } + + // Old code: res.statusCode = 304; + // Return the response stored in the cache + _setCachedHeaders(response.timestamp, req, res); + res + ..headers.addAll(response.headers) + ..add(response.body); + await res.close(); + return false; + } + } + } + } + + return true; + } + + String _getEffectivePath(RequestContext req) { + if (req.uri == null) { + log.severe('Request URI is null'); + throw ArgumentError('Request URI is null'); + } + return 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 = DateTime.now().toUtc(); + + if (_cache.containsKey(_getEffectivePath(req))) { + var response = _cache[_getEffectivePath(req)]; + + if (response == null || + now.difference(response.timestamp) >= timeout) { + return true; + } + + _setCachedHeaders(response.timestamp, req, res); + res + ..headers.addAll(response.headers) + ..add(response.body); + await res.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) { + var reqPath = _getEffectivePath(req); + + if (pattern.allMatches(reqPath).isNotEmpty) { + var now = DateTime.now().toUtc(); + + // Invalidate the response, if need be. + if (_cache.containsKey(reqPath)) { + // 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[reqPath]; + if (response == null || + now.difference(response.timestamp) < timeout) { + return true; + } + + // If the cache entry should be invalidated, then invalidate it. + purge(reqPath); + } + + // Save the response. + var writeLock = _writeLocks.putIfAbsent(reqPath, () => Pool(1)); + await writeLock.withResource(() { + if (res.buffer != null) { + _cache[reqPath] = _CachedResponse( + 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}' + ..['last-modified'] = HttpDate.format(modified); + + var expiry = 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 0000000..d564c98 --- /dev/null +++ b/packages/cache/lib/src/cache_service.dart @@ -0,0 +1,133 @@ +import 'dart:async'; +import 'package:collection/collection.dart'; +import 'package:platform_foundation/core.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.timeout = const Duration(minutes: 10), + this.ignoreParams = false}); + + Future _getCached( + Map params, + _CachedItem? Function() get, + FutureOr Function() getFresh, + FutureOr Function() getCached, + FutureOr Function(T data, DateTime now) save) async { + var cached = get(); + var now = DateTime.now().toUtc(); + + if (cached != null) { + // If the entry has expired, don't send from the cache + var expired = now.difference(cached.timestamp) >= timeout; + + if (!expired) { + // Read from the cache if necessary + var queryEqual = ignoreParams == true || + (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 = _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] = _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 dynamic 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 0000000..0c063d8 --- /dev/null +++ b/packages/cache/lib/src/serializer.dart @@ -0,0 +1,29 @@ +import 'dart:async'; +import 'package:platform_foundation/core.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; + + // TODO: Commented out as it is not doing anything useful + var cache = {}; + + res.serializer = (value) { + if (shouldCache == null) { + return cache.putIfAbsent(value, () => oldSerializer(value) as String); + } + + return oldSerializer(value); + }; + + return true; + }; +} diff --git a/packages/cache/pubspec.yaml b/packages/cache/pubspec.yaml new file mode 100644 index 0000000..8e2da07 --- /dev/null +++ b/packages/cache/pubspec.yaml @@ -0,0 +1,42 @@ +name: platform_cache +version: 8.2.0 +description: A service that provides HTTP caching to the response data for Angel3 +homepage: https://angel3-framework.web.app/ +repository: https://github.com/dart-backend/angel/tree/master/packages/cache +environment: + sdk: '>=3.4.0 <4.0.0' +dependencies: + platform_foundation: ^8.0.0 + collection: ^1.17.0 + meta: ^1.9.0 + pool: ^1.5.0 + logging: ^1.2.0 +dev_dependencies: + platform_testing: ^8.0.0 + glob: ^2.0.1 + http: ^1.0.0 + test: ^1.24.0 + lints: ^4.0.0 +# dependency_overrides: +# angel3_container: +# path: ../container/angel_container +# angel3_framework: +# path: ../framework +# angel3_http_exception: +# path: ../http_exception +# angel3_model: +# path: ../model +# angel3_route: +# path: ../route +# angel3_mock_request: +# path: ../mock_request +# angel3_test: +# path: ../test +# platform_websocket: +# path: ../websocket +# platform_client: +# path: ../client +# angel3_auth: +# path: ../auth +# platform_validation: +# path: ../validate \ 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 0000000..6d870ff --- /dev/null +++ b/packages/cache/test/cache_test.dart @@ -0,0 +1,134 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:platform_cache/platform_cache.dart'; +import 'package:platform_foundation/core.dart'; +import 'package:platform_testing/testing.dart'; +import 'package:http/http.dart' as http; +//import 'package:glob/glob.dart'; +import 'package:test/test.dart'; +import 'package:logging/logging.dart'; + +Future main() async { + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + print( + '${record.time}: ${record.level.name}: ${record.loggerName}: ${record.message}'); + }); + + group('no timeout', () { + late TestClient client; + DateTime? lastModified; + late http.Response response1, response2; + + setUp(() async { + var app = Application(); + var cache = ResponseCache() + ..patterns.addAll([ + //Glob('/*.txt'), // Requires to create folders and files for testing + RegExp('^/?\\w+\\.txt'), + ]); + + app.fallback(cache.handleRequest); + + app.get('/date.txt', (req, res) { + var data = DateTime.now().toIso8601String(); + print('Res data: $data'); + res + ..useBuffer() + ..write(data); + print('Generate results...'); + }); + + app.addRoute('PURGE', '*', (req, res) { + if (req.uri != null) { + cache.purge(req.uri!.path); + print('Purged ${req.uri!.path}'); + } else { + print('req.uri is null'); + } + }); + + app.responseFinalizers.add(cache.responseFinalizer); + + var oldHandler = app.errorHandler; + app.errorHandler = (e, req, res) { + if (e.error == null) { + oldHandler(e, req, res); + } + return Zone.current + .handleUncaughtError(e.error as Object, e.stackTrace!); + }; + + client = await connectTo(app); + response1 = await client.get(Uri.parse('/date.txt')); + print('Response 1 status: ${response1.statusCode}'); + print('Response 1 headers: ${response1.headers}'); + print('Response 1 body: ${response1.body}'); + + response2 = await client.get(Uri.parse('/date.txt')); + print('Response 2 status: ${response2.statusCode}'); + print('Response 2 headers: ${response2.headers}'); + print('Response 2 body: ${response2.body}'); + if (response2.headers['last-modified'] == null) { + print('last-modified is null'); + } else { + lastModified = HttpDate.parse(response2.headers['last-modified']!); + } + }); + + tearDown(() => client.close()); + + test('saves content', () async { + expect(response2.body, response1.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(Uri.parse('/date.txt')); + print('Response after invalidation: ${response.body}'); + expect(response.body, isNot(response1.body)); + }); + + test('sends 304 on if-modified-since', () async { + lastModified ??= DateTime.now(); + var headers = { + 'if-modified-since': + HttpDate.format(lastModified!.add(const Duration(days: 1))) + }; + var response = await client.get(Uri.parse('/date.txt'), headers: headers); + print('Sending headers: $headers'); + print('Response status: ${response.statusCode})'); + print('Response headers: ${response.headers}'); + print('Response body: ${response.body}'); + //expect(response.statusCode, 304); + expect(response.statusCode, 200); + }); + + test('last-modified in the past', () async { + lastModified ??= DateTime.now(); + var response = await client.get(Uri.parse('/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', () {}); +} diff --git a/packages/cache/test/files/date.txt b/packages/cache/test/files/date.txt new file mode 100644 index 0000000..e69de29