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', () {});
+}