Skip to content

Commit

Permalink
Cache generalization (#21)
Browse files Browse the repository at this point in the history
* Add generalized cache handling

* Update entry.dart

* Update constants.dart

* Update pubspec.yaml

* Update README.md
  • Loading branch information
z4kn4fein authored Aug 18, 2023
1 parent a7369d9 commit 03c4d57
Show file tree
Hide file tree
Showing 16 changed files with 158 additions and 163 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 3.0.0 - 2023-08-18
### Changed
- Standardized config cache key generation algorithm and cache payload format to allow shared caches to be used by SDKs of different platforms.

### Removed
- `getVariationId()` / `getAllVariationIds()` methods. Alternative: `getValueDetails()` / `getAllValueDetails()`

## 2.5.2 - 2023-06-21
### Removed
- `logger` package dependency. Switched to simple `print()` as default.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ flutter pub add configcat_client
Or put the following directly to your `pubspec.yml` and run `dart pub get` or `flutter pub get`.
```yaml
dependencies:
configcat_client: ^2.5.2
configcat_client: ^3.0.0
```
### 2. Go to the <a href="https://app.configcat.com/sdkkey" target="_blank">ConfigCat Dashboard</a> to get your *SDK Key*:
Expand Down
70 changes: 0 additions & 70 deletions lib/src/configcat_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -196,76 +196,6 @@ class ConfigCatClient {
}
}

/// Gets the Variation ID (analytics) of a feature flag or setting identified by the given [key].
///
/// [key] is the identifier of the feature flag or setting.
/// In case of any failure, [defaultVariationId] will be returned.
/// [user] is the user object to identify the caller.
@Deprecated(
"This method is obsolete and will be removed in a future major version. Please use [getValueDetails] instead.")
Future<String> getVariationId({
required String key,
required String defaultVariationId,
ConfigCatUser? user,
}) async {
try {
final result = await _getSettings();
if (result.isEmpty) {
_errorReporter.error(1000,
'Config JSON is not present when evaluating setting \'$key\'. Returning the `defaultVariationId` parameter that you specified in your application: \'$defaultVariationId\'.');
return defaultVariationId;
}
final setting = result.settings[key];
if (setting == null) {
_errorReporter.error(1001,
'Failed to evaluate setting \'$key\' (the key was not found in config JSON). Returning the `defaultVariationId` parameter that you specified in your application: \'$defaultVariationId\'. Available keys: [${result.settings.keys.map((e) => '\'$e\'').join(', ')}].');
return defaultVariationId;
}

return _evaluate(key, setting, user ?? _defaultUser, result.fetchTime)
.variationId;
} catch (e, s) {
_errorReporter.error(
1002,
'Error occurred in the `getVariationId` method while evaluating setting \'$key\'. Returning the `defaultVariationId` parameter that you specified in your application: \'$defaultVariationId\'.',
e,
s);
return defaultVariationId;
}
}

/// Gets the Variation IDs (analytics) of all feature flags or settings.
///
/// [user] is the user object to identify the caller.
@Deprecated(
"This method is obsolete and will be removed in a future major version. Please use [getAllValueDetails] instead.")
Future<List<String>> getAllVariationIds({ConfigCatUser? user}) async {
try {
final settingsResult = await _getSettings();
if (settingsResult.isEmpty) {
_errorReporter.error(
1000, 'Config JSON is not present. Returning empty list.');
return [];
}

final result = List<String>.empty(growable: true);
settingsResult.settings.forEach((key, value) {
result.add(_evaluate(
key, value, user ?? _defaultUser, settingsResult.fetchTime)
.variationId);
});

return result;
} catch (e, s) {
_errorReporter.error(
1002,
'Error occurred in the `getAllVariationIds` method. Returning empty list.',
e,
s);
return [];
}
}

/// Gets a collection of all setting keys.
Future<List<String>> getAllKeys() async {
try {
Expand Down
5 changes: 3 additions & 2 deletions lib/src/constants.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const version = '2.5.2';
const version = '3.0.0';
const configJsonCacheVersion = 'v2';
const configJsonName = 'config_v5.json';
final DateTime distantPast = DateTime.utc(1970, 01, 01);
final DateTime distantFuture =
DateTime.now().toUtc().add(const Duration(days: 1000));
DateTime.now().toUtc().add(const Duration(days: 1000 * 365));
53 changes: 53 additions & 0 deletions lib/src/entry.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import 'dart:convert';

import 'constants.dart';
import 'json/config.dart';

class Entry {
final Config config;
final String configJsonString;
final String eTag;
final DateTime fetchTime;

Entry(this.configJsonString, this.config, this.eTag, this.fetchTime);

bool get isEmpty => identical(this, empty);

Entry withTime(DateTime time) => Entry(configJsonString, config, eTag, time);

static Entry empty = Entry('', Config.empty, '', distantPast);

String serialize() {
return '${fetchTime.millisecondsSinceEpoch}\n$eTag\n$configJsonString';
}

static Entry fromConfigJson(String configJson, String eTag, DateTime time) {
final decoded = jsonDecode(configJson);
final config = Config.fromJson(decoded);
return Entry(configJson, config, eTag, time);
}

static Entry fromCached(String cached) {
final timeIndex = cached.indexOf('\n');
if (timeIndex == -1) {
throw FormatException("Number of values is fewer than expected.");
}

final eTagIndex = cached.indexOf('\n', timeIndex + 1);
if (eTagIndex == -1) {
throw FormatException("Number of values is fewer than expected.");
}

final timeString = cached.substring(0, timeIndex);
final time = int.tryParse(timeString);
if (time == null) {
throw FormatException("Invalid fetch time: $timeString");
}

final fetchTime = DateTime.fromMillisecondsSinceEpoch(time, isUtc: true);
final eTag = cached.substring(timeIndex + 1, eTagIndex);
final configJson = cached.substring(eTagIndex + 1);

return fromConfigJson(configJson, eTag, fetchTime);
}
}
10 changes: 3 additions & 7 deletions lib/src/fetch/config_fetcher.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';

import '../error_reporter.dart';
import '../json/entry.dart';
import '../entry.dart';
import '../data_governance.dart';
import '../configcat_options.dart';
import '../json/config.dart';
import '../constants.dart';
import '../log/configcat_logger.dart';

Expand Down Expand Up @@ -175,11 +173,9 @@ class ConfigFetcher implements Fetcher {
);
if (_successStatusCodes.contains(response.statusCode)) {
final eTag = response.headers.value(_eTagHeaderName) ?? '';
final decoded = jsonDecode(response.data.toString());
final config = Config.fromJson(decoded);
_logger.debug('Fetch was successful: new config fetched.');
return FetchResponse.success(
Entry(config, eTag, DateTime.now().toUtc()));
return FetchResponse.success(Entry.fromConfigJson(
response.data.toString(), eTag, DateTime.now().toUtc()));
} else if (response.statusCode == 304) {
_logger.debug('Fetch was successful: config not modified.');
return FetchResponse.notModified();
Expand Down
25 changes: 12 additions & 13 deletions lib/src/fetch/config_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import '../constants.dart';
import '../log/configcat_logger.dart';
import '../json/setting.dart';
import 'config_fetcher.dart';
import '../json/entry.dart';
import '../entry.dart';
import '../error_reporter.dart';
import 'refresh_result.dart';
import 'periodic_executor.dart';
Expand All @@ -38,7 +38,7 @@ class ConfigService with ContinuousFutureSynchronizer {
late final ConfigCatCache _cache;
late final ErrorReporter _errorReporter;
Entry _cachedEntry = Entry.empty;
String _cachedJson = '';
String _cachedEntryString = '';
bool _offline = false;
bool _initialized = false;
PeriodicExecutor? _periodicExecutor;
Expand All @@ -53,7 +53,8 @@ class ConfigService with ContinuousFutureSynchronizer {
required ErrorReporter errorReporter,
required bool offline}) {
_cacheKey = sha1
.convert(utf8.encode('dart_${configJsonName}_${sdkKey}_v2'))
.convert(
utf8.encode('${sdkKey}_${configJsonName}_$configJsonCacheVersion'))
.toString();
_mode = mode;
_hooks = hooks;
Expand Down Expand Up @@ -198,12 +199,11 @@ class ConfigService with ContinuousFutureSynchronizer {

Future<Entry> _readCache() async {
try {
final json = await _cache.read(_cacheKey);
if (json.isEmpty) return Entry.empty;
if (json == _cachedJson) return Entry.empty;
_cachedJson = json;
final decoded = jsonDecode(json);
return Entry.fromJson(decoded);
final entry = await _cache.read(_cacheKey);
if (entry.isEmpty) return Entry.empty;
if (entry == _cachedEntryString) return Entry.empty;
_cachedEntryString = entry;
return Entry.fromCached(entry);
} catch (e, s) {
_errorReporter.error(
2200, 'Error occurred while reading the cache.', e, s);
Expand All @@ -213,10 +213,9 @@ class ConfigService with ContinuousFutureSynchronizer {

Future<void> _writeCache(Entry value) async {
try {
final map = value.toJson();
final json = jsonEncode(map);
_cachedJson = json;
await _cache.write(_cacheKey, json);
final entry = value.serialize();
_cachedEntryString = entry;
await _cache.write(_cacheKey, entry);
} catch (e, s) {
_errorReporter.error(
2201, 'Error occurred while writing the cache.', e, s);
Expand Down
25 changes: 0 additions & 25 deletions lib/src/json/entry.dart

This file was deleted.

19 changes: 0 additions & 19 deletions lib/src/json/entry.g.dart

This file was deleted.

1 change: 0 additions & 1 deletion lib/src/polling_mode.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ abstract class PollingMode {
///
/// [autoPollInterval] sets at least how often this policy should fetch the latest configuration and refresh the cache.
/// [maxInitWaitTime] sets the maximum waiting time between initialization and the first config acquisition in seconds.
/// [listener] sets a configuration changed listener.
factory PollingMode.autoPoll(
{autoPollInterval = const Duration(seconds: 60),
maxInitWaitTime = const Duration(seconds: 5)}) {
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: configcat_client
description: >-
Dart (Flutter) SDK for ConfigCat. ConfigCat is a hosted feature flag service that lets you manage feature toggles across frontend, backend, mobile, desktop apps.
version: 2.5.2
version: 3.0.0
homepage: https://configcat.com/docs/sdk-reference/dart
repository: https://github.com/configcat/dart-sdk
issue_tracker: https://github.com/configcat/dart-sdk/issues
Expand Down
62 changes: 59 additions & 3 deletions test/cache_test.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import 'dart:convert';

import 'package:configcat_client/configcat_client.dart';
import 'package:configcat_client/src/entry.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
Expand Down Expand Up @@ -40,7 +39,7 @@ void main() {
// Arrange
final cache = MockConfigCatCache();
when(cache.read(any)).thenAnswer(
(_) => Future.value(jsonEncode(createTestEntry({'value': 'test'}))));
(_) => Future.value(createTestEntry({'value': 'test'}).serialize()));

final client = ConfigCatClient.get(
sdkKey: testSdkKey, options: ConfigCatOptions(cache: cache));
Expand All @@ -55,4 +54,61 @@ void main() {
// Assert
expect(value, equals('test'));
});

group('cache key generation', () {
final inputs = {
'test1': '147c5b4c2b2d7c77e1605b1a4309f0ea6684a0c6',
'test2': 'c09513b1756de9e4bc48815ec7a142b2441ed4d5',
};

inputs.forEach((sdkKey, cacheKey) {
test('$sdkKey -> $cacheKey', () async {
// Arrange
final cache = MockConfigCatCache();
when(cache.read(any)).thenAnswer((_) => Future.value(''));
when(cache.write(any, any)).thenAnswer((_) => Future.value());

final client = ConfigCatClient.get(
sdkKey: sdkKey, options: ConfigCatOptions(cache: cache));
final dioAdapter = DioAdapter(dio: client.httpClient);
dioAdapter.onGet(getPath(sdkKey: sdkKey), (server) {
server.reply(200, createTestConfig({'value': 'test2'}).toJson());
});

// Act
await client.getValue(key: 'value', defaultValue: '');

// Assert
verify(cache.read(captureThat(equals(cacheKey))));
verify(cache.write(captureThat(equals(cacheKey)), any));
});
});
});

test('cache serialization', () async {
// Arrange
final testJson =
"{\"p\":{\"u\":\"https://cdn-global.configcat.com\",\"r\":0},\"f\":{\"testKey\":{\"v\":\"testValue\",\"t\":1,\"p\":[],\"r\":[]}}}";

final time = DateTime.parse('2023-06-14T15:27:15.8440000Z');
final eTag = 'test-etag';

final expectedPayload = '1686756435844\ntest-etag\n$testJson';

final entry = Entry.fromConfigJson(testJson, eTag, time);

// Act
final cached = entry.serialize();

// Assert
expect(cached, equals(expectedPayload));

// Act
final fromCache = Entry.fromCached(expectedPayload);

// Assert
expect(fromCache.configJsonString, equals(testJson));
expect(fromCache.fetchTime, equals(time));
expect(fromCache.eTag, equals(eTag));
});
}
Loading

0 comments on commit 03c4d57

Please sign in to comment.