diff --git a/packages/file_service/.gitignore b/packages/file_service/.gitignore new file mode 100644 index 00000000..feba14ba --- /dev/null +++ b/packages/file_service/.gitignore @@ -0,0 +1,57 @@ +# 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 + +# 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 + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties +.dart_tool diff --git a/packages/file_service/.idea/file_service.iml b/packages/file_service/.idea/file_service.iml new file mode 100644 index 00000000..eae13016 --- /dev/null +++ b/packages/file_service/.idea/file_service.iml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/file_service/.idea/modules.xml b/packages/file_service/.idea/modules.xml new file mode 100644 index 00000000..7ebf9e82 --- /dev/null +++ b/packages/file_service/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/file_service/.idea/vcs.xml b/packages/file_service/.idea/vcs.xml new file mode 100644 index 00000000..94a25f7f --- /dev/null +++ b/packages/file_service/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/file_service/.travis.yml b/packages/file_service/.travis.yml new file mode 100644 index 00000000..a9e2c109 --- /dev/null +++ b/packages/file_service/.travis.yml @@ -0,0 +1,4 @@ +language: dart +dart: + - dev + - stable \ No newline at end of file diff --git a/packages/file_service/CHANGELOG.md b/packages/file_service/CHANGELOG.md new file mode 100644 index 00000000..48394f8f --- /dev/null +++ b/packages/file_service/CHANGELOG.md @@ -0,0 +1,24 @@ +# 2.0.1 +* Pass everything through `_jsonifyToSD` when returning responses. + +# 2.0.0 +* Dart/Angel 2 update. +* Remove `package:dart2_constant` +* Update `package:file` to `^5.0.0`. + +# 1.1.2 +* Added tests, because tests. + +# 1.1.1 +* Dart 2 fixes. + +# 1.1.0+2 +* `create` now uses the underlying store, instead of manually patching + +# 1.1.0+1 +* Analyzer nitpick for pana + +# 1.1.0 +* Updated to framework v1.1.x +* Use `package:file` +* Allow a custom `store` \ No newline at end of file diff --git a/packages/file_service/LICENSE b/packages/file_service/LICENSE new file mode 100644 index 00000000..89074fd3 --- /dev/null +++ b/packages/file_service/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/file_service/README.md b/packages/file_service/README.md new file mode 100644 index 00000000..ffed353b --- /dev/null +++ b/packages/file_service/README.md @@ -0,0 +1,31 @@ +# file_service +[![Pub](https://img.shields.io/pub/v/angel_file_service.svg)](https://pub.dartlang.org/packages/angel_file_service) +[![build status](https://travis-ci.org/angel-dart/file_service.svg)](https://travis-ci.org/angel-dart/file_service) + +Angel service that persists data to a file on disk, stored as a JSON list. It uses a simple +mutex to prevent race conditions, and caches contents in memory until changes +are made. + +The file will be created on read/write, if it does not already exist. + +This package is useful in development, as it prevents you from having to install +an external database to run your server. + +When running a multi-threaded server, there is no guarantee that file operations +will be mutually excluded. Thus, try to only use this one a single-threaded server +if possible, or one with very low load. + +While not necessarily *slow*, this package makes no promises about performance. + +# Usage +```dart +configureServer(Angel app) async { + // Just like a normal service + app.use( + '/api/todos', + new JsonFileService( + const LocalFileSystem().file('todos_db.json') + ), + ); +} +``` \ No newline at end of file diff --git a/packages/file_service/analysis_options.yaml b/packages/file_service/analysis_options.yaml new file mode 100644 index 00000000..eae1e42a --- /dev/null +++ b/packages/file_service/analysis_options.yaml @@ -0,0 +1,3 @@ +analyzer: + strong-mode: + implicit-casts: false \ No newline at end of file diff --git a/packages/file_service/example/main.dart b/packages/file_service/example/main.dart new file mode 100644 index 00000000..d56d3184 --- /dev/null +++ b/packages/file_service/example/main.dart @@ -0,0 +1,11 @@ +import 'package:angel_file_service/angel_file_service.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:file/local.dart'; + +configureServer(Angel app) async { + // Just like a normal service + app.use( + '/api/todos', + new JsonFileService(const LocalFileSystem().file('todos_db.json')), + ); +} diff --git a/packages/file_service/lib/angel_file_service.dart b/packages/file_service/lib/angel_file_service.dart new file mode 100644 index 00000000..8e3bcc85 --- /dev/null +++ b/packages/file_service/lib/angel_file_service.dart @@ -0,0 +1,141 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:file/file.dart'; +import 'package:pool/pool.dart'; + +/// Persists in-memory changes to a file on disk. +class JsonFileService extends Service> { + FileStat _lastStat; + final Pool _mutex = new Pool(1); + MapService _store; + final File file; + + JsonFileService(this.file, + {bool allowRemoveAll: false, bool allowQuery: true, MapService store}) { + _store = store ?? + new MapService( + allowRemoveAll: allowRemoveAll == true, + allowQuery: allowQuery != false); + } + + Map _coerceStringDynamic(Map m) { + return m.keys.fold>( + {}, (out, k) => out..[k.toString()] = m[k]); + } + + Future _load() { + return _mutex.withResource(() async { + if (!await file.exists()) await file.writeAsString(json.encode([])); + var stat = await file.stat(); + // + + if (_lastStat == null || + stat.modified.millisecondsSinceEpoch > + _lastStat.modified.millisecondsSinceEpoch) { + _lastStat = stat; + + var contents = await file.readAsString(); + + var list = json.decode(contents) as Iterable; + _store.items.clear(); // Clear exist in-memory copy + _store.items.addAll(list.map((x) => + _coerceStringDynamic(_revive(x) as Map))); // Insert all new entries + } + }); + } + + _save() { + return _mutex.withResource(() { + return file + .writeAsString(json.encode(_store.items.map(_jsonify).toList())); + }); + } + + @override + Future close() async { + _store.close(); + } + + @override + Future>> index( + [Map params]) async => + _load() + .then((_) => _store.index(params)) + .then((it) => it.map(_jsonifyToSD).toList()); + + @override + Future> read(id, [Map params]) => + _load().then((_) => _store.read(id, params)).then(_jsonifyToSD); + + @override + Future> create(data, + [Map params]) async { + await _load(); + var created = await _store.create(data, params).then(_jsonifyToSD); + await _save(); + return created; + } + + @override + Future> remove(id, [Map params]) async { + await _load(); + var r = await _store.remove(id, params).then(_jsonifyToSD); + await _save(); + return r; + } + + @override + Future> update(id, data, + [Map params]) async { + await _load(); + var r = await _store.update(id, data, params).then(_jsonifyToSD); + await _save(); + return r; + } + + @override + Future> modify(id, data, + [Map params]) async { + await _load(); + var r = await _store.update(id, data, params).then(_jsonifyToSD); + await _save(); + return r; + } +} + +_safeForJson(x) { + if (x is DateTime) + return x.toIso8601String(); + else if (x is Map) + return _jsonify(x); + else if (x is num || x is String || x is bool || x == null) + return x; + else if (x is Iterable) + return x.map(_safeForJson).toList(); + else + return x.toString(); +} + +Map _jsonify(Map map) { + return map.keys.fold({}, (out, k) => out..[k] = _safeForJson(map[k])); +} + +Map _jsonifyToSD(Map map) => + _jsonify(map).cast(); + +dynamic _revive(x) { + if (x is Map) { + return x.keys.fold>( + {}, (out, k) => out..[k.toString()] = _revive(x[k])); + } else if (x is Iterable) + return x.map(_revive).toList(); + else if (x is String) { + try { + return DateTime.parse(x); + } catch (e) { + return x; + } + } else + return x; +} diff --git a/packages/file_service/pubspec.yaml b/packages/file_service/pubspec.yaml new file mode 100644 index 00000000..264d02af --- /dev/null +++ b/packages/file_service/pubspec.yaml @@ -0,0 +1,13 @@ +name: angel_file_service +version: 2.0.1 +description: Angel service that persists data to a file on disk. +author: Tobe O +homepage: https://github.com/angel-dart/file_service +environment: + sdk: ">=2.0.0-dev <3.0.0" +dependencies: + angel_framework: ^2.0.0-alpha + file: ^5.0.0 + pool: ^1.0.0 +dev_dependencies: + test: ^1.0.0 \ No newline at end of file diff --git a/packages/file_service/test/all_test.dart b/packages/file_service/test/all_test.dart new file mode 100644 index 00000000..2936201a --- /dev/null +++ b/packages/file_service/test/all_test.dart @@ -0,0 +1,71 @@ +import 'package:angel_file_service/angel_file_service.dart'; +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:test/test.dart'; + +main() { + MemoryFileSystem fs; + File dbFile; + JsonFileService service; + + setUp(() async { + fs = new MemoryFileSystem(); + dbFile = fs.file('db.json'); + service = new JsonFileService(dbFile); + + await dbFile.writeAsString(''' + [ + {"id": "0", "foo": "bar"}, + {"id": "1", "foo": "baz"}, + {"id": "2", "foo": "quux"} + ] + '''); + }); + + tearDown(() => service.close()); + + test('index no params', () async { + expect(await service.index(), [ + {"id": "0", "foo": "bar"}, + {"id": "1", "foo": "baz"}, + {"id": "2", "foo": "quux"} + ]); + }); + + test('index with query', () async { + expect( + await service.index({ + 'query': {'foo': 'bar'} + }), + [ + {"id": "0", "foo": "bar"} + ], + ); + }); + + test('read', () async { + expect( + await service.read('2'), + {"id": "2", "foo": "quux"}, + ); + }); + + test('modify', () async { + await service.modify('2', {'baz': 'quux'}); + expect(await service.read('2'), containsPair('baz', 'quux')); + }); + + test('update', () async { + await service.update('2', {'baz': 'quux'}); + expect(await service.read('2'), containsPair('baz', 'quux')); + expect(await service.read('2'), isNot(containsPair('foo', 'quux'))); + }); + + test('delete', () async { + await service.remove('2'); + expect(await service.index(), [ + {"id": "0", "foo": "bar"}, + {"id": "1", "foo": "baz"} + ]); + }); +}