diff options
Diffstat (limited to 'FireBase')
42 files changed, 3741 insertions, 0 deletions
diff --git a/FireBase/ExceptionEventArgs.cs b/FireBase/ExceptionEventArgs.cs new file mode 100644 index 0000000..f1c7fac --- /dev/null +++ b/FireBase/ExceptionEventArgs.cs @@ -0,0 +1,28 @@ +namespace Firebase.Database +{ + using System; + + /// <summary> + /// Event args holding the <see cref="Exception"/> object. + /// </summary> + public class ExceptionEventArgs<T> : EventArgs where T : Exception + { + public readonly T Exception; + + /// <summary> + /// Initializes a new instance of the <see cref="ExceptionEventArgs"/> class. + /// </summary> + /// <param name="exception"> The exception. </param> + public ExceptionEventArgs(T exception) + { + this.Exception = exception; + } + } + + public class ExceptionEventArgs : ExceptionEventArgs<Exception> + { + public ExceptionEventArgs(Exception exception) : base(exception) + { + } + } +} diff --git a/FireBase/Extensions/ObservableExtensions.cs b/FireBase/Extensions/ObservableExtensions.cs new file mode 100644 index 0000000..12cd5f3 --- /dev/null +++ b/FireBase/Extensions/ObservableExtensions.cs @@ -0,0 +1,40 @@ +namespace Firebase.Database.Extensions +{ + using System; + using System.Reactive.Linq; + + public static class ObservableExtensions + { + /// <summary> + /// Returns a cold observable which retries (re-subscribes to) the source observable on error until it successfully terminates. + /// </summary> + /// <param name="source">The source observable.</param> + /// <param name="dueTime">How long to wait between attempts.</param> + /// <param name="retryOnError">A predicate determining for which exceptions to retry. Defaults to all</param> + /// <returns> + /// A cold observable which retries (re-subscribes to) the source observable on error up to the + /// specified number of times or until it successfully terminates. + /// </returns> + public static IObservable<T> RetryAfterDelay<T, TException>( + this IObservable<T> source, + TimeSpan dueTime, + Func<TException, bool> retryOnError) + where TException: Exception + { + int attempt = 0; + + return Observable.Defer(() => + { + return ((++attempt == 1) ? source : source.DelaySubscription(dueTime)) + .Select(item => new Tuple<bool, T, Exception>(true, item, null)) + .Catch<Tuple<bool, T, Exception>, TException>(e => retryOnError(e) + ? Observable.Throw<Tuple<bool, T, Exception>>(e) + : Observable.Return(new Tuple<bool, T, Exception>(false, default(T), e))); + }) + .Retry() + .SelectMany(t => t.Item1 + ? Observable.Return(t.Item2) + : Observable.Throw<T>(t.Item3)); + } + } +} diff --git a/FireBase/Extensions/TaskExtensions.cs b/FireBase/Extensions/TaskExtensions.cs new file mode 100644 index 0000000..26bbde6 --- /dev/null +++ b/FireBase/Extensions/TaskExtensions.cs @@ -0,0 +1,23 @@ +namespace Firebase.Database.Extensions +{ + using System; + using System.Threading.Tasks; + + public static class TaskExtensions + { + /// <summary> + /// Instead of unwrapping <see cref="AggregateException"/> it throws it as it is. + /// </summary> + public static async Task WithAggregateException(this Task source) + { + try + { + await source.ConfigureAwait(false); + } + catch (Exception ex) + { + throw source.Exception ?? ex; + } + } + } +} diff --git a/FireBase/FireBase.csproj b/FireBase/FireBase.csproj new file mode 100644 index 0000000..889c32f --- /dev/null +++ b/FireBase/FireBase.csproj @@ -0,0 +1,13 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>netcoreapp2.1</TargetFramework> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="LiteDB" Version="4.1.4" /> + <PackageReference Include="Newtonsoft.Json" Version="11.0.2" /> + <PackageReference Include="System.Reactive" Version="4.1.0" /> + </ItemGroup> + +</Project> diff --git a/FireBase/FirebaseClient.cs b/FireBase/FirebaseClient.cs new file mode 100644 index 0000000..a237c8d --- /dev/null +++ b/FireBase/FirebaseClient.cs @@ -0,0 +1,57 @@ +using System.Net.Http; + +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Firebase.Database.Tests")] + +namespace Firebase.Database +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + + using Firebase.Database.Offline; + using Firebase.Database.Query; + + /// <summary> + /// Firebase client which acts as an entry point to the online database. + /// </summary> + public class FirebaseClient : IDisposable + { + internal readonly HttpClient HttpClient; + internal readonly FirebaseOptions Options; + + private readonly string baseUrl; + + /// <summary> + /// Initializes a new instance of the <see cref="FirebaseClient"/> class. + /// </summary> + /// <param name="baseUrl"> The base url. </param> + /// <param name="offlineDatabaseFactory"> Offline database. </param> + public FirebaseClient(string baseUrl, FirebaseOptions options = null) + { + this.HttpClient = new HttpClient(); + this.Options = options ?? new FirebaseOptions(); + + this.baseUrl = baseUrl; + + if (!this.baseUrl.EndsWith("/")) + { + this.baseUrl += "/"; + } + } + + /// <summary> + /// Queries for a child of the data root. + /// </summary> + /// <param name="resourceName"> Name of the child. </param> + /// <returns> <see cref="ChildQuery"/>. </returns> + public ChildQuery Child(string resourceName) + { + return new ChildQuery(this, () => this.baseUrl + resourceName); + } + + public void Dispose() + { + HttpClient?.Dispose(); + } + } +} diff --git a/FireBase/FirebaseException.cs b/FireBase/FirebaseException.cs new file mode 100644 index 0000000..e4b782b --- /dev/null +++ b/FireBase/FirebaseException.cs @@ -0,0 +1,63 @@ +namespace Firebase.Database +{ + using System; + using System.Net; + + public class FirebaseException : Exception + { + public FirebaseException(string requestUrl, string requestData, string responseData, HttpStatusCode statusCode) + : base(GenerateExceptionMessage(requestUrl, requestData, responseData)) + { + this.RequestUrl = requestUrl; + this.RequestData = requestData; + this.ResponseData = responseData; + this.StatusCode = statusCode; + } + + public FirebaseException(string requestUrl, string requestData, string responseData, HttpStatusCode statusCode, Exception innerException) + : base(GenerateExceptionMessage(requestUrl, requestData, responseData), innerException) + { + this.RequestUrl = requestUrl; + this.RequestData = requestData; + this.ResponseData = responseData; + this.StatusCode = statusCode; + } + + /// <summary> + /// Post data passed to the authentication service. + /// </summary> + public string RequestData + { + get; + } + + /// <summary> + /// Original url of the request. + /// </summary> + public string RequestUrl + { + get; + } + + /// <summary> + /// Response from the authentication service. + /// </summary> + public string ResponseData + { + get; + } + + /// <summary> + /// Status code of the response. + /// </summary> + public HttpStatusCode StatusCode + { + get; + } + + private static string GenerateExceptionMessage(string requestUrl, string requestData, string responseData) + { + return $"Exception occured while processing the request.\nUrl: {requestUrl}\nRequest Data: {requestData}\nResponse: {responseData}"; + } + } +} diff --git a/FireBase/FirebaseKeyGenerator.cs b/FireBase/FirebaseKeyGenerator.cs new file mode 100644 index 0000000..acad399 --- /dev/null +++ b/FireBase/FirebaseKeyGenerator.cs @@ -0,0 +1,92 @@ +namespace Firebase.Database +{ + using System; + using System.Text; + + /// <summary> + /// Offline key generator which mimics the official Firebase generators. + /// Credit: https://github.com/bubbafat/FirebaseSharp/blob/master/src/FirebaseSharp.Portable/FireBasePushIdGenerator.cs + /// </summary> + public class FirebaseKeyGenerator + { + // Modeled after base64 web-safe chars, but ordered by ASCII. + private const string PushCharsString = "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz"; + private static readonly char[] PushChars; + private static readonly DateTimeOffset Epoch = new DateTimeOffset(1970, 1, 1, 0, 0, 0, 0, TimeSpan.Zero); + + private static readonly Random random = new Random(); + private static readonly byte[] lastRandChars = new byte[12]; + + // Timestamp of last push, used to prevent local collisions if you push twice in one ms. + private static long lastPushTime; + + static FirebaseKeyGenerator() + { + PushChars = Encoding.UTF8.GetChars(Encoding.UTF8.GetBytes(PushCharsString)); + } + + /// <summary> + /// Returns next firebase key based on current time. + /// </summary> + /// <returns> + /// The <see cref="string"/>. </returns> + public static string Next() + { + // We generate 72-bits of randomness which get turned into 12 characters and + // appended to the timestamp to prevent collisions with other clients. We store the last + // characters we generated because in the event of a collision, we'll use those same + // characters except "incremented" by one. + var id = new StringBuilder(20); + var now = (long)(DateTimeOffset.Now - Epoch).TotalMilliseconds; + var duplicateTime = now == lastPushTime; + lastPushTime = now; + + var timeStampChars = new char[8]; + for (int i = 7; i >= 0; i--) + { + var index = (int)(now % PushChars.Length); + timeStampChars[i] = PushChars[index]; + now = (long)Math.Floor((double)now / PushChars.Length); + } + + if (now != 0) + { + throw new Exception("We should have converted the entire timestamp."); + } + + id.Append(timeStampChars); + + if (!duplicateTime) + { + for (int i = 0; i < 12; i++) + { + lastRandChars[i] = (byte)random.Next(0, PushChars.Length); + } + } + else + { + // If the timestamp hasn't changed since last push, use the same random number, + // except incremented by 1. + var lastIndex = 11; + for (; lastIndex >= 0 && lastRandChars[lastIndex] == PushChars.Length - 1; lastIndex--) + { + lastRandChars[lastIndex] = 0; + } + + lastRandChars[lastIndex]++; + } + + for (int i = 0; i < 12; i++) + { + id.Append(PushChars[lastRandChars[i]]); + } + + if (id.Length != 20) + { + throw new Exception("Length should be 20."); + } + + return id.ToString(); + } + } +} diff --git a/FireBase/FirebaseObject.cs b/FireBase/FirebaseObject.cs new file mode 100644 index 0000000..ea61893 --- /dev/null +++ b/FireBase/FirebaseObject.cs @@ -0,0 +1,31 @@ +namespace Firebase.Database +{ + /// <summary> + /// Holds the object of type <typeparam name="T" /> along with its key. + /// </summary> + /// <typeparam name="T"> Type of the underlying object. </typeparam> + public class FirebaseObject<T> + { + internal FirebaseObject(string key, T obj) + { + this.Key = key; + this.Object = obj; + } + + /// <summary> + /// Gets the key of <see cref="Object"/>. + /// </summary> + public string Key + { + get; + } + + /// <summary> + /// Gets the underlying object. + /// </summary> + public T Object + { + get; + } + } +} diff --git a/FireBase/FirebaseOptions.cs b/FireBase/FirebaseOptions.cs new file mode 100644 index 0000000..9905956 --- /dev/null +++ b/FireBase/FirebaseOptions.cs @@ -0,0 +1,76 @@ +namespace Firebase.Database +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Threading.Tasks; + + using Firebase.Database.Offline; + + using Newtonsoft.Json; + + public class FirebaseOptions + { + public FirebaseOptions() + { + this.OfflineDatabaseFactory = (t, s) => new Dictionary<string, OfflineEntry>(); + this.SubscriptionStreamReaderFactory = s => new StreamReader(s); + this.JsonSerializerSettings = new JsonSerializerSettings(); + this.SyncPeriod = TimeSpan.FromSeconds(10); + } + + /// <summary> + /// Gets or sets the factory for Firebase offline database. Default is in-memory dictionary. + /// </summary> + public Func<Type, string, IDictionary<string, OfflineEntry>> OfflineDatabaseFactory + { + get; + set; + } + + /// <summary> + /// Gets or sets the method for retrieving auth tokens. Default is null. + /// </summary> + public Func<Task<string>> AuthTokenAsyncFactory + { + get; + set; + } + + /// <summary> + /// Gets or sets the factory for <see cref="TextReader"/> used for reading online streams. Default is <see cref="StreamReader"/>. + /// </summary> + public Func<Stream, TextReader> SubscriptionStreamReaderFactory + { + get; + set; + } + + /// <summary> + /// Gets or sets the json serializer settings. + /// </summary> + public JsonSerializerSettings JsonSerializerSettings + { + get; + set; + } + + /// <summary> + /// Gets or sets the time between synchronization attempts for pulling and pushing offline entities. Default is 10 seconds. + /// </summary> + public TimeSpan SyncPeriod + { + get; + set; + } + + /// <summary> + /// Specify if token returned by factory will be used as "auth" url parameter or "access_token". + /// </summary> + public bool AsAccessToken + { + get; + set; + } + } +} diff --git a/FireBase/Http/HttpClientExtensions.cs b/FireBase/Http/HttpClientExtensions.cs new file mode 100644 index 0000000..444145b --- /dev/null +++ b/FireBase/Http/HttpClientExtensions.cs @@ -0,0 +1,90 @@ +namespace Firebase.Database.Http +{ + using System; + using System.Collections; + using System.Collections.Generic; + using System.Linq; + using System.Net.Http; + using System.Threading.Tasks; + + using Newtonsoft.Json; + using System.Net; + + /// <summary> + /// The http client extensions for object deserializations. + /// </summary> + internal static class HttpClientExtensions + { + /// <summary> + /// The get object collection async. + /// </summary> + /// <param name="client"> The client. </param> + /// <param name="requestUri"> The request uri. </param> + /// <param name="jsonSerializerSettings"> The specific JSON Serializer Settings. </param> + /// <typeparam name="T"> The type of entities the collection should contain. </typeparam> + /// <returns> The <see cref="Task"/>. </returns> + public static async Task<IReadOnlyCollection<FirebaseObject<T>>> GetObjectCollectionAsync<T>(this HttpClient client, string requestUri, + JsonSerializerSettings jsonSerializerSettings) + { + var responseData = string.Empty; + var statusCode = HttpStatusCode.OK; + + try + { + var response = await client.GetAsync(requestUri).ConfigureAwait(false); + statusCode = response.StatusCode; + responseData = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + var dictionary = JsonConvert.DeserializeObject<Dictionary<string, T>>(responseData, jsonSerializerSettings); + + if (dictionary == null) + { + return new FirebaseObject<T>[0]; + } + + return dictionary.Select(item => new FirebaseObject<T>(item.Key, item.Value)).ToList(); + } + catch (Exception ex) + { + throw new FirebaseException(requestUri, string.Empty, responseData, statusCode, ex); + } + } + + /// <summary> + /// The get object collection async. + /// </summary> + /// <param name="data"> The json data. </param> + /// <param name="elementType"> The type of entities the collection should contain. </param> + /// <returns> The <see cref="Task"/>. </returns> + public static IEnumerable<FirebaseObject<object>> GetObjectCollection(this string data, Type elementType) + { + var dictionaryType = typeof(Dictionary<,>).MakeGenericType(typeof(string), elementType); + IDictionary dictionary = null; + + if (data.StartsWith("[")) + { + var listType = typeof(List<>).MakeGenericType(elementType); + var list = JsonConvert.DeserializeObject(data, listType) as IList; + dictionary = Activator.CreateInstance(dictionaryType) as IDictionary; + var index = 0; + foreach (var item in list) dictionary.Add(index++.ToString(), item); + } + else + { + dictionary = JsonConvert.DeserializeObject(data, dictionaryType) as IDictionary; + } + + if (dictionary == null) + { + yield break; + } + + foreach (DictionaryEntry item in dictionary) + { + yield return new FirebaseObject<object>((string)item.Key, item.Value); + } + } + } +} diff --git a/FireBase/Http/PostResult.cs b/FireBase/Http/PostResult.cs new file mode 100644 index 0000000..3f010d4 --- /dev/null +++ b/FireBase/Http/PostResult.cs @@ -0,0 +1,17 @@ +namespace Firebase.Database.Http +{ + /// <summary> + /// Represents data returned after a successful POST to firebase server. + /// </summary> + public class PostResult + { + /// <summary> + /// Gets or sets the generated key after a successful post. + /// </summary> + public string Name + { + get; + set; + } + } +} diff --git a/FireBase/ObservableExtensions.cs b/FireBase/ObservableExtensions.cs new file mode 100644 index 0000000..37c3ef7 --- /dev/null +++ b/FireBase/ObservableExtensions.cs @@ -0,0 +1,44 @@ +namespace Firebase.Database +{ + using System; + using System.Collections.ObjectModel; + + using Firebase.Database.Streaming; + + /// <summary> + /// Extensions for <see cref="IObservable{T}"/>. + /// </summary> + public static class ObservableExtensions + { + /// <summary> + /// Starts observing on given firebase observable and propagates event into an <see cref="ObservableCollection{T}"/>. + /// </summary> + /// <param name="observable"> The observable. </param> + /// <typeparam name="T"> Type of entity. </typeparam> + /// <returns> The <see cref="ObservableCollection{T}"/>. </returns> + public static ObservableCollection<T> AsObservableCollection<T>(this IObservable<FirebaseEvent<T>> observable) + { + var collection = new ObservableCollection<T>(); + + observable.Subscribe(f => + { + if (f.EventType == FirebaseEventType.InsertOrUpdate) + { + var i = collection.IndexOf(f.Object); + if (i >= 0) + { + collection.RemoveAt(i); + } + + collection.Add(f.Object); + } + else + { + collection.Remove(f.Object); + } + }); + + return collection; + } + } +} diff --git a/FireBase/Offline/ConcurrentOfflineDatabase.cs b/FireBase/Offline/ConcurrentOfflineDatabase.cs new file mode 100644 index 0000000..226892d --- /dev/null +++ b/FireBase/Offline/ConcurrentOfflineDatabase.cs @@ -0,0 +1,207 @@ +namespace Firebase.Database.Offline +{ + using System; + using System.Collections; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.IO; + using System.Linq; + + using LiteDB; + + /// <summary> + /// The offline database. + /// </summary> + public class ConcurrentOfflineDatabase : IDictionary<string, OfflineEntry> + { + private readonly LiteRepository db; + private readonly ConcurrentDictionary<string, OfflineEntry> ccache; + + /// <summary> + /// Initializes a new instance of the <see cref="OfflineDatabase"/> class. + /// </summary> + /// <param name="itemType"> The item type which is used to determine the database file name. </param> + /// <param name="filenameModifier"> Custom string which will get appended to the file name. </param> + public ConcurrentOfflineDatabase(Type itemType, string filenameModifier) + { + var fullName = this.GetFileName(itemType.ToString()); + if(fullName.Length > 100) + { + fullName = fullName.Substring(0, 100); + } + + BsonMapper mapper = BsonMapper.Global; + mapper.Entity<OfflineEntry>().Id(o => o.Key); + + string root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + string filename = fullName + filenameModifier + ".db"; + var path = Path.Combine(root, filename); + this.db = new LiteRepository(new LiteDatabase(path, mapper)); + + var cache = db.Database + .GetCollection<OfflineEntry>() + .FindAll() + .ToDictionary(o => o.Key, o => o); + + this.ccache = new ConcurrentDictionary<string, OfflineEntry>(cache); + + } + + /// <summary> + /// Gets the number of elements contained in the <see cref="T:System.Collections.Generic.ICollection`1"/>. + /// </summary> + /// <returns> The number of elements contained in the <see cref="T:System.Collections.Generic.ICollection`1"/>. </returns> + public int Count => this.ccache.Count; + + /// <summary> + /// Gets a value indicating whether this is a read-only collection. + /// </summary> + public bool IsReadOnly => false; + + /// <summary> + /// Gets an <see cref="T:System.Collections.Generic.ICollection`1"/> containing the keys of the <see cref="T:System.Collections.Generic.IDictionary`2"/>. + /// </summary> + /// <returns> An <see cref="T:System.Collections.Generic.ICollection`1"/> containing the keys of the object that implements <see cref="T:System.Collections.Generic.IDictionary`2"/>. </returns> + public ICollection<string> Keys => this.ccache.Keys; + + /// <summary> + /// Gets an <see cref="T:System.Collections.Generic.ICollection`1"/> containing the values in the <see cref="T:System.Collections.Generic.IDictionary`2"/>. + /// </summary> + /// <returns> An <see cref="T:System.Collections.Generic.ICollection`1"/> containing the values in the object that implements <see cref="T:System.Collections.Generic.IDictionary`2"/>. </returns> + public ICollection<OfflineEntry> Values => this.ccache.Values; + + /// <summary> + /// Gets or sets the element with the specified key. + /// </summary> + /// <param name="key">The key of the element to get or set.</param> + /// <returns> The element with the specified key. </returns> + public OfflineEntry this[string key] + { + get + { + return this.ccache[key]; + } + + set + { + this.ccache.AddOrUpdate(key, value, (k, existing) => value); + this.db.Upsert(value); + } + } + + /// <summary> + /// Returns an enumerator that iterates through the collection. + /// </summary> + /// <returns> An enumerator that can be used to iterate through the collection. </returns> + public IEnumerator<KeyValuePair<string, OfflineEntry>> GetEnumerator() + { + return this.ccache.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + /// <summary> + /// Adds an item to the <see cref="T:System.Collections.Generic.ICollection`1"/>. + /// </summary> + /// <param name="item">The object to add to the <see cref="T:System.Collections.Generic.ICollection`1"/>.</param> + public void Add(KeyValuePair<string, OfflineEntry> item) + { + this.Add(item.Key, item.Value); + } + + /// <summary> + /// Removes all items from the <see cref="T:System.Collections.Generic.ICollection`1"/>. + /// </summary> + public void Clear() + { + this.ccache.Clear(); + this.db.Delete<OfflineEntry>(Query.All()); + } + + /// <summary> + /// Determines whether the <see cref="T:System.Collections.Generic.ICollection`1"/> contains a specific value. + /// </summary> + /// <param name="item">The object to locate in the <see cref="T:System.Collections.Generic.ICollection`1"/>.</param> + /// <returns> True if <paramref name="item"/> is found in the <see cref="T:System.Collections.Generic.ICollection`1"/>; otherwise, false. </returns> + public bool Contains(KeyValuePair<string, OfflineEntry> item) + { + return this.ContainsKey(item.Key); + } + + /// <summary> + /// Copies the elements of the <see cref="T:System.Collections.Generic.ICollection`1"/> to an <see cref="T:System.Array"/>, starting at a particular <see cref="T:System.Array"/> index. + /// </summary> + /// <param name="array">The one-dimensional <see cref="T:System.Array"/> that is the destination of the elements copied from <see cref="T:System.Collections.Generic.ICollection`1"/>. The <see cref="T:System.Array"/> must have zero-based indexing.</param> + /// <param name="arrayIndex">The zero-based index in <paramref name="array"/> at which copying begins.</param> + public void CopyTo(KeyValuePair<string, OfflineEntry>[] array, int arrayIndex) + { + this.ccache.ToList().CopyTo(array, arrayIndex); + } + + /// <summary> + /// Removes the first occurrence of a specific object from the <see cref="T:System.Collections.Generic.ICollection`1"/>. + /// </summary> + /// <param name="item">The object to remove from the <see cref="T:System.Collections.Generic.ICollection`1"/>.</param> + /// <returns> True if <paramref name="item"/> was successfully removed from the <see cref="T:System.Collections.Generic.ICollection`1"/>; otherwise, false. This method also returns false if <paramref name="item"/> is not found in the original <see cref="T:System.Collections.Generic.ICollection`1"/>. </returns> + public bool Remove(KeyValuePair<string, OfflineEntry> item) + { + return this.Remove(item.Key); + } + + /// <summary> + /// Determines whether the <see cref="T:System.Collections.Generic.IDictionary`2"/> contains an element with the specified key. + /// </summary> + /// <param name="key">The key to locate in the <see cref="T:System.Collections.Generic.IDictionary`2"/>.</param> + /// <returns> True if the <see cref="T:System.Collections.Generic.IDictionary`2"/> contains an element with the key; otherwise, false. </returns> + public bool ContainsKey(string key) + { + return this.ccache.ContainsKey(key); + } + + /// <summary> + /// Adds an element with the provided key and value to the <see cref="T:System.Collections.Generic.IDictionary`2"/>. + /// </summary> + /// <param name="key">The object to use as the key of the element to add.</param> + /// <param name="value">The object to use as the value of the element to add.</param> + public void Add(string key, OfflineEntry value) + { + this.ccache.AddOrUpdate(key, value, (k, existing) => value); + this.db.Upsert(value); + } + + /// <summary> + /// Removes the element with the specified key from the <see cref="T:System.Collections.Generic.IDictionary`2"/>. + /// </summary> + /// <param name="key">The key of the element to remove.</param> + /// <returns> True if the element is successfully removed; otherwise, false. This method also returns false if <paramref name="key"/> was not found in the original <see cref="T:System.Collections.Generic.IDictionary`2"/>. </returns> + public bool Remove(string key) + { + this.ccache.TryRemove(key, out OfflineEntry _); + return this.db.Delete<OfflineEntry>(key); + } + + /// <summary> + /// Gets the value associated with the specified key. + /// </summary> + /// <param name="key">The key whose value to get.</param><param name="value">When this method returns, the value associated with the specified key, if the key is found; otherwise, the default value for the type of the <paramref name="value"/> parameter. This parameter is passed uninitialized.</param> + /// <returns> True if the object that implements <see cref="T:System.Collections.Generic.IDictionary`2"/> contains an element with the specified key; otherwise, false. </returns> + public bool TryGetValue(string key, out OfflineEntry value) + { + return this.ccache.TryGetValue(key, out value); + } + + private string GetFileName(string fileName) + { + var invalidChars = new[] { '`', '[', ',', '=' }; + foreach(char c in invalidChars.Concat(System.IO.Path.GetInvalidFileNameChars()).Distinct()) + { + fileName = fileName.Replace(c, '_'); + } + + return fileName; + } + } +} diff --git a/FireBase/Offline/DatabaseExtensions.cs b/FireBase/Offline/DatabaseExtensions.cs new file mode 100644 index 0000000..4b04314 --- /dev/null +++ b/FireBase/Offline/DatabaseExtensions.cs @@ -0,0 +1,195 @@ +namespace Firebase.Database.Offline +{ + using System; + using System.Collections; + using System.Collections.Generic; + using System.Linq.Expressions; + using System.Reflection; + using Firebase.Database.Query; + + public static class DatabaseExtensions + { + /// <summary> + /// Create new instances of the <see cref="RealtimeDatabase{T}"/>. + /// </summary> + /// <typeparam name="T"> Type of elements. </typeparam> + /// <param name="filenameModifier"> Custom string which will get appended to the file name. </param> + /// <param name="elementRoot"> Optional custom root element of received json items. </param> + /// <param name="streamingOptions"> Realtime streaming options. </param> + /// <param name="initialPullStrategy"> Specifies what strategy should be used for initial pulling of server data. </param> + /// <param name="pushChanges"> Specifies whether changed items should actually be pushed to the server. It this is false, then Put / Post / Delete will not affect server data. </param> + /// <returns> The <see cref="RealtimeDatabase{T}"/>. </returns> + public static RealtimeDatabase<T> AsRealtimeDatabase<T>(this ChildQuery query, string filenameModifier = "", string elementRoot = "", StreamingOptions streamingOptions = StreamingOptions.LatestOnly, InitialPullStrategy initialPullStrategy = InitialPullStrategy.MissingOnly, bool pushChanges = true) + where T : class + { + return new RealtimeDatabase<T>(query, elementRoot, query.Client.Options.OfflineDatabaseFactory, filenameModifier, streamingOptions, initialPullStrategy, pushChanges); + } + + /// <summary> + /// Create new instances of the <see cref="RealtimeDatabase{T}"/>. + /// </summary> + /// <typeparam name="T"> Type of elements. </typeparam> + /// <typeparam name="TSetHandler"> Type of the custom <see cref="ISetHandler{T}"/> to use. </typeparam> + /// <param name="filenameModifier"> Custom string which will get appended to the file name. </param> + /// <param name="elementRoot"> Optional custom root element of received json items. </param> + /// <param name="streamingOptions"> Realtime streaming options. </param> + /// <param name="initialPullStrategy"> Specifies what strategy should be used for initial pulling of server data. </param> + /// <param name="pushChanges"> Specifies whether changed items should actually be pushed to the server. It this is false, then Put / Post / Delete will not affect server data. </param> + /// <returns> The <see cref="RealtimeDatabase{T}"/>. </returns> + public static RealtimeDatabase<T> AsRealtimeDatabase<T, TSetHandler>(this ChildQuery query, string filenameModifier = "", string elementRoot = "", StreamingOptions streamingOptions = StreamingOptions.LatestOnly, InitialPullStrategy initialPullStrategy = InitialPullStrategy.MissingOnly, bool pushChanges = true) + where T : class + where TSetHandler : ISetHandler<T>, new() + { + return new RealtimeDatabase<T>(query, elementRoot, query.Client.Options.OfflineDatabaseFactory, filenameModifier, streamingOptions, initialPullStrategy, pushChanges, Activator.CreateInstance<TSetHandler>()); + } + + /// <summary> + /// Overwrites existing object with given key leaving any missing properties intact in firebase. + /// </summary> + /// <param name="key"> The key. </param> + /// <param name="obj"> The object to set. </param> + /// <param name="syncOnline"> Indicates whether the item should be synced online. </param> + /// <param name="priority"> The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. </param> + public static void Patch<T>(this RealtimeDatabase<T> db, string key, T obj, bool syncOnline = true, int priority = 1) + where T: class + { + db.Set(key, obj, syncOnline ? SyncOptions.Patch : SyncOptions.None, priority); + } + + /// <summary> + /// Overwrites existing object with given key. + /// </summary> + /// <param name="key"> The key. </param> + /// <param name="obj"> The object to set. </param> + /// <param name="syncOnline"> Indicates whether the item should be synced online. </param> + /// <param name="priority"> The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. </param> + public static void Put<T>(this RealtimeDatabase<T> db, string key, T obj, bool syncOnline = true, int priority = 1) + where T: class + { + db.Set(key, obj, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + + /// <summary> + /// Adds a new entity to the Database. + /// </summary> + /// <param name="obj"> The object to add. </param> + /// <param name="syncOnline"> Indicates whether the item should be synced online. </param> + /// <param name="priority"> The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. </param> + /// <returns> The generated key for this object. </returns> + public static string Post<T>(this RealtimeDatabase<T> db, T obj, bool syncOnline = true, int priority = 1) + where T: class + { + var key = FirebaseKeyGenerator.Next(); + + db.Set(key, obj, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + + return key; + } + + /// <summary> + /// Deletes the entity with the given key. + /// </summary> + /// <param name="key"> The key. </param> + /// <param name="syncOnline"> Indicates whether the item should be synced online. </param> + /// <param name="priority"> The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. </param> + public static void Delete<T>(this RealtimeDatabase<T> db, string key, bool syncOnline = true, int priority = 1) + where T: class + { + db.Set(key, null, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + + /// <summary> + /// Do a Put for a nested property specified by <paramref name="propertyExpression"/> of an object with key <paramref name="key"/>. + /// </summary> + /// <typeparam name="T"> Type of the root elements. </typeparam> + /// <typeparam name="TProperty"> Type of the property being modified</typeparam> + /// <param name="db"> Database instance. </param> + /// <param name="key"> Key of the root element to modify. </param> + /// <param name="propertyExpression"> Expression on the root element leading to target value to modify. </param> + /// <param name="value"> Value to put. </param> + /// <param name="syncOnline"> Indicates whether the item should be synced online. </param> + /// <param name="priority"> The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. </param> + public static void Put<T, TProperty>(this RealtimeDatabase<T> db, string key, Expression<Func<T, TProperty>> propertyExpression, TProperty value, bool syncOnline = true, int priority = 1) + where T: class + { + db.Set(key, propertyExpression, value, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + + /// <summary> + /// Do a Patch for a nested property specified by <paramref name="propertyExpression"/> of an object with key <paramref name="key"/>. + /// </summary> + /// <typeparam name="T"> Type of the root elements. </typeparam> + /// <typeparam name="TProperty"> Type of the property being modified</typeparam> + /// <param name="db"> Database instance. </param> + /// <param name="key"> Key of the root element to modify. </param> + /// <param name="propertyExpression"> Expression on the root element leading to target value to modify. </param> + /// <param name="value"> Value to patch. </param> + /// <param name="syncOnline"> Indicates whether the item should be synced online. </param> + /// <param name="priority"> The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. </param> + public static void Patch<T, TProperty>(this RealtimeDatabase<T> db, string key, Expression<Func<T, TProperty>> propertyExpression, TProperty value, bool syncOnline = true, int priority = 1) + where T: class + { + db.Set(key, propertyExpression, value, syncOnline ? SyncOptions.Patch : SyncOptions.None, priority); + } + + /// <summary> + /// Delete a nested property specified by <paramref name="propertyExpression"/> of an object with key <paramref name="key"/>. This basically does a Put with null value. + /// </summary> + /// <typeparam name="T"> Type of the root elements. </typeparam> + /// <typeparam name="TProperty"> Type of the property being modified</typeparam> + /// <param name="db"> Database instance. </param> + /// <param name="key"> Key of the root element to modify. </param> + /// <param name="propertyExpression"> Expression on the root element leading to target value to modify. </param> + /// <param name="value"> Value to put. </param> + /// <param name="syncOnline"> Indicates whether the item should be synced online. </param> + /// <param name="priority"> The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. </param> + public static void Delete<T, TProperty>(this RealtimeDatabase<T> db, string key, Expression<Func<T, TProperty>> propertyExpression, bool syncOnline = true, int priority = 1) + where T: class + where TProperty: class + { + db.Set(key, propertyExpression, null, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + + /// <summary> + /// Post a new entity into the nested dictionary specified by <paramref name="propertyExpression"/> of an object with key <paramref name="key"/>. + /// The key of the new entity is automatically generated. + /// </summary> + /// <typeparam name="T"> Type of the root elements. </typeparam> + /// <typeparam name="TSelector"> Type of the dictionary being modified</typeparam> + /// <typeparam name="TProperty"> Type of the value within the dictionary being modified</typeparam> + /// <param name="db"> Database instance. </param> + /// <param name="key"> Key of the root element to modify. </param> + /// <param name="propertyExpression"> Expression on the root element leading to target value to modify. </param> + /// <param name="value"> Value to put. </param> + /// <param name="syncOnline"> Indicates whether the item should be synced online. </param> + /// <param name="priority"> The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. </param> + public static void Post<T, TSelector, TProperty>(this RealtimeDatabase<T> db, string key, Expression<Func<T, TSelector>> propertyExpression, TProperty value, bool syncOnline = true, int priority = 1) + where T: class + where TSelector: IDictionary<string, TProperty> + { + var nextKey = FirebaseKeyGenerator.Next(); + var expression = Expression.Lambda<Func<T, TProperty>>(Expression.Call(propertyExpression.Body, typeof(TSelector).GetRuntimeMethod("get_Item", new[] { typeof(string) }), Expression.Constant(nextKey)), propertyExpression.Parameters); + db.Set(key, expression, value, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + + /// <summary> + /// Delete an entity with key <paramref name="dictionaryKey"/> in the nested dictionary specified by <paramref name="propertyExpression"/> of an object with key <paramref name="key"/>. + /// The key of the new entity is automatically generated. + /// </summary> + /// <typeparam name="T"> Type of the root elements. </typeparam> + /// <typeparam name="TSelector"> Type of the dictionary being modified</typeparam> + /// <typeparam name="TProperty"> Type of the value within the dictionary being modified</typeparam> + /// <param name="db"> Database instance. </param> + /// <param name="key"> Key of the root element to modify. </param> + /// <param name="propertyExpression"> Expression on the root element leading to target value to modify. </param> + /// <param name="dictionaryKey"> Key within the nested dictionary to delete. </param> + /// <param name="syncOnline"> Indicates whether the item should be synced online. </param> + /// <param name="priority"> The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. </param> + public static void Delete<T, TProperty>(this RealtimeDatabase<T> db, string key, Expression<Func<T, IDictionary<string, TProperty>>> propertyExpression, string dictionaryKey, bool syncOnline = true, int priority = 1) + where T: class + { + var expression = Expression.Lambda<Func<T, TProperty>>(Expression.Call(propertyExpression.Body, typeof(IDictionary<string, TProperty>).GetRuntimeMethod("get_Item", new[] { typeof(string) }), Expression.Constant(dictionaryKey)), propertyExpression.Parameters); + db.Set(key, expression, null, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + } +} diff --git a/FireBase/Offline/ISetHandler.cs b/FireBase/Offline/ISetHandler.cs new file mode 100644 index 0000000..477c36b --- /dev/null +++ b/FireBase/Offline/ISetHandler.cs @@ -0,0 +1,11 @@ +namespace Firebase.Database.Offline +{ + using Firebase.Database.Query; + + using System.Threading.Tasks; + + public interface ISetHandler<in T> + { + Task SetAsync(ChildQuery query, string key, OfflineEntry entry); + } +} diff --git a/FireBase/Offline/InitialPullStrategy.cs b/FireBase/Offline/InitialPullStrategy.cs new file mode 100644 index 0000000..70f6b8c --- /dev/null +++ b/FireBase/Offline/InitialPullStrategy.cs @@ -0,0 +1,23 @@ +namespace Firebase.Database.Offline +{ + /// <summary> + /// Specifies the strategy for initial pull of server data. + /// </summary> + public enum InitialPullStrategy + { + /// <summary> + /// Don't pull anything. + /// </summary> + None, + + /// <summary> + /// Pull only what isn't already stored offline. + /// </summary> + MissingOnly, + + /// <summary> + /// Pull everything that exists on the server. + /// </summary> + Everything, + } +} diff --git a/FireBase/Offline/Internals/MemberAccessVisitor.cs b/FireBase/Offline/Internals/MemberAccessVisitor.cs new file mode 100644 index 0000000..1f7cb11 --- /dev/null +++ b/FireBase/Offline/Internals/MemberAccessVisitor.cs @@ -0,0 +1,51 @@ +namespace Firebase.Database.Offline.Internals +{ + using System.Collections.Generic; + using System.Linq.Expressions; + using System.Reflection; + + using Newtonsoft.Json; + + public class MemberAccessVisitor : ExpressionVisitor + { + private readonly IList<string> propertyNames = new List<string>(); + + private bool wasDictionaryAccess; + + public IEnumerable<string> PropertyNames => this.propertyNames; + + public MemberAccessVisitor() + { + } + + public override Expression Visit(Expression expr) + { + if (expr?.NodeType == ExpressionType.MemberAccess) + { + if (this.wasDictionaryAccess) + { + this.wasDictionaryAccess = false; + } + else + { + var memberExpr = (MemberExpression)expr; + var jsonAttr = memberExpr.Member.GetCustomAttribute<JsonPropertyAttribute>(); + + this.propertyNames.Add(jsonAttr?.PropertyName ?? memberExpr.Member.Name); + } + } + else if (expr?.NodeType == ExpressionType.Call) + { + var callExpr = (MethodCallExpression)expr; + if (callExpr.Method.Name == "get_Item" && callExpr.Arguments.Count == 1) + { + var e = Expression.Lambda(callExpr.Arguments[0]).Compile(); + this.propertyNames.Add(e.DynamicInvoke().ToString()); + this.wasDictionaryAccess = callExpr.Arguments[0].NodeType == ExpressionType.MemberAccess; + } + } + + return base.Visit(expr); + } + } +} diff --git a/FireBase/Offline/OfflineCacheAdapter.cs b/FireBase/Offline/OfflineCacheAdapter.cs new file mode 100644 index 0000000..a3761a0 --- /dev/null +++ b/FireBase/Offline/OfflineCacheAdapter.cs @@ -0,0 +1,165 @@ +namespace Firebase.Database.Offline +{ + using System; + using System.Collections; + using System.Collections.Generic; + using System.Linq; + + internal class OfflineCacheAdapter<TKey, T> : IDictionary<string, T>, IDictionary + { + private readonly IDictionary<string, OfflineEntry> database; + + public OfflineCacheAdapter(IDictionary<string, OfflineEntry> database) + { + this.database = database; + } + + public void CopyTo(Array array, int index) + { + throw new NotImplementedException(); + } + + public int Count => this.database.Count; + + public bool IsSynchronized { get; } + + public object SyncRoot { get; } + + public bool IsReadOnly => this.database.IsReadOnly; + + object IDictionary.this[object key] + { + get + { + return this.database[key.ToString()].Deserialize<T>(); + } + + set + { + var keyString = key.ToString(); + if (this.database.ContainsKey(keyString)) + { + this.database[keyString] = new OfflineEntry(keyString, value, this.database[keyString].Priority, this.database[keyString].SyncOptions); + } + else + { + this.database[keyString] = new OfflineEntry(keyString, value, 1, SyncOptions.None); + } + } + } + + public ICollection<string> Keys => this.database.Keys; + + ICollection IDictionary.Values { get; } + + ICollection IDictionary.Keys { get; } + + public ICollection<T> Values => this.database.Values.Select(o => o.Deserialize<T>()).ToList(); + + public T this[string key] + { + get + { + return this.database[key].Deserialize<T>(); + } + + set + { + if (this.database.ContainsKey(key)) + { + this.database[key] = new OfflineEntry(key, value, this.database[key].Priority, this.database[key].SyncOptions); + } + else + { + this.database[key] = new OfflineEntry(key, value, 1, SyncOptions.None); + } + } + } + + public bool Contains(object key) + { + return this.ContainsKey(key.ToString()); + } + + IDictionaryEnumerator IDictionary.GetEnumerator() + { + throw new NotImplementedException(); + } + + public void Remove(object key) + { + this.Remove(key.ToString()); + } + + public bool IsFixedSize => false; + + public IEnumerator<KeyValuePair<string, T>> GetEnumerator() + { + return this.database.Select(d => new KeyValuePair<string, T>(d.Key, d.Value.Deserialize<T>())).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + public void Add(KeyValuePair<string, T> item) + { + this.Add(item.Key, item.Value); + } + + public void Add(object key, object value) + { + this.Add(key.ToString(), (T)value); + } + + public void Clear() + { + this.database.Clear(); + } + + public bool Contains(KeyValuePair<string, T> item) + { + return this.ContainsKey(item.Key); + } + + public void CopyTo(KeyValuePair<string, T>[] array, int arrayIndex) + { + throw new NotImplementedException(); + } + + public bool Remove(KeyValuePair<string, T> item) + { + return this.database.Remove(item.Key); + } + + public void Add(string key, T value) + { + this.database.Add(key, new OfflineEntry(key, value, 1, SyncOptions.None)); + } + + public bool ContainsKey(string key) + { + return this.database.ContainsKey(key); + } + + public bool Remove(string key) + { + return this.database.Remove(key); + } + + public bool TryGetValue(string key, out T value) + { + OfflineEntry val; + + if (this.database.TryGetValue(key, out val)) + { + value = val.Deserialize<T>(); + return true; + } + + value = default(T); + return false; + } + } +} diff --git a/FireBase/Offline/OfflineDatabase.cs b/FireBase/Offline/OfflineDatabase.cs new file mode 100644 index 0000000..9cebf9c --- /dev/null +++ b/FireBase/Offline/OfflineDatabase.cs @@ -0,0 +1,201 @@ +namespace Firebase.Database.Offline +{ + using System; + using System.Collections; + using System.Collections.Generic; + using System.IO; + using System.Linq; + + using LiteDB; + + /// <summary> + /// The offline database. + /// </summary> + public class OfflineDatabase : IDictionary<string, OfflineEntry> + { + private readonly LiteRepository db; + private readonly IDictionary<string, OfflineEntry> cache; + + /// <summary> + /// Initializes a new instance of the <see cref="OfflineDatabase"/> class. + /// </summary> + /// <param name="itemType"> The item type which is used to determine the database file name. </param> + /// <param name="filenameModifier"> Custom string which will get appended to the file name. </param> + public OfflineDatabase(Type itemType, string filenameModifier) + { + var fullName = this.GetFileName(itemType.ToString()); + if(fullName.Length > 100) + { + fullName = fullName.Substring(0, 100); + } + + BsonMapper mapper = BsonMapper.Global; + mapper.Entity<OfflineEntry>().Id(o => o.Key); + + string root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + string filename = fullName + filenameModifier + ".db"; + var path = Path.Combine(root, filename); + this.db = new LiteRepository(new LiteDatabase(path, mapper)); + + this.cache = db.Database.GetCollection<OfflineEntry>().FindAll() + .ToDictionary(o => o.Key, o => o); + } + + /// <summary> + /// Gets the number of elements contained in the <see cref="T:System.Collections.Generic.ICollection`1"/>. + /// </summary> + /// <returns> The number of elements contained in the <see cref="T:System.Collections.Generic.ICollection`1"/>. </returns> + public int Count => this.cache.Count; + + /// <summary> + /// Gets a value indicating whether this is a read-only collection. + /// </summary> + public bool IsReadOnly => this.cache.IsReadOnly; + + /// <summary> + /// Gets an <see cref="T:System.Collections.Generic.ICollection`1"/> containing the keys of the <see cref="T:System.Collections.Generic.IDictionary`2"/>. + /// </summary> + /// <returns> An <see cref="T:System.Collections.Generic.ICollection`1"/> containing the keys of the object that implements <see cref="T:System.Collections.Generic.IDictionary`2"/>. </returns> + public ICollection<string> Keys => this.cache.Keys; + + /// <summary> + /// Gets an <see cref="T:System.Collections.Generic.ICollection`1"/> containing the values in the <see cref="T:System.Collections.Generic.IDictionary`2"/>. + /// </summary> + /// <returns> An <see cref="T:System.Collections.Generic.ICollection`1"/> containing the values in the object that implements <see cref="T:System.Collections.Generic.IDictionary`2"/>. </returns> + public ICollection<OfflineEntry> Values => this.cache.Values; + + /// <summary> + /// Gets or sets the element with the specified key. + /// </summary> + /// <param name="key">The key of the element to get or set.</param> + /// <returns> The element with the specified key. </returns> + public OfflineEntry this[string key] + { + get + { + return this.cache[key]; + } + + set + { + this.cache[key] = value; + this.db.Upsert(value); + } + } + + /// <summary> + /// Returns an enumerator that iterates through the collection. + /// </summary> + /// <returns> An enumerator that can be used to iterate through the collection. </returns> + public IEnumerator<KeyValuePair<string, OfflineEntry>> GetEnumerator() + { + return this.cache.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + /// <summary> + /// Adds an item to the <see cref="T:System.Collections.Generic.ICollection`1"/>. + /// </summary> + /// <param name="item">The object to add to the <see cref="T:System.Collections.Generic.ICollection`1"/>.</param> + public void Add(KeyValuePair<string, OfflineEntry> item) + { + this.Add(item.Key, item.Value); + } + + /// <summary> + /// Removes all items from the <see cref="T:System.Collections.Generic.ICollection`1"/>. + /// </summary> + public void Clear() + { + this.cache.Clear(); + this.db.Delete<OfflineEntry>(Query.All()); + } + + /// <summary> + /// Determines whether the <see cref="T:System.Collections.Generic.ICollection`1"/> contains a specific value. + /// </summary> + /// <param name="item">The object to locate in the <see cref="T:System.Collections.Generic.ICollection`1"/>.</param> + /// <returns> True if <paramref name="item"/> is found in the <see cref="T:System.Collections.Generic.ICollection`1"/>; otherwise, false. </returns> + public bool Contains(KeyValuePair<string, OfflineEntry> item) + { + return this.ContainsKey(item.Key); + } + + /// <summary> + /// Copies the elements of the <see cref="T:System.Collections.Generic.ICollection`1"/> to an <see cref="T:System.Array"/>, starting at a particular <see cref="T:System.Array"/> index. + /// </summary> + /// <param name="array">The one-dimensional <see cref="T:System.Array"/> that is the destination of the elements copied from <see cref="T:System.Collections.Generic.ICollection`1"/>. The <see cref="T:System.Array"/> must have zero-based indexing.</param> + /// <param name="arrayIndex">The zero-based index in <paramref name="array"/> at which copying begins.</param> + public void CopyTo(KeyValuePair<string, OfflineEntry>[] array, int arrayIndex) + { + this.cache.CopyTo(array, arrayIndex); + } + + /// <summary> + /// Removes the first occurrence of a specific object from the <see cref="T:System.Collections.Generic.ICollection`1"/>. + /// </summary> + /// <param name="item">The object to remove from the <see cref="T:System.Collections.Generic.ICollection`1"/>.</param> + /// <returns> True if <paramref name="item"/> was successfully removed from the <see cref="T:System.Collections.Generic.ICollection`1"/>; otherwise, false. This method also returns false if <paramref name="item"/> is not found in the original <see cref="T:System.Collections.Generic.ICollection`1"/>. </returns> + public bool Remove(KeyValuePair<string, OfflineEntry> item) + { + return this.Remove(item.Key); + } + + /// <summary> + /// Determines whether the <see cref="T:System.Collections.Generic.IDictionary`2"/> contains an element with the specified key. + /// </summary> + /// <param name="key">The key to locate in the <see cref="T:System.Collections.Generic.IDictionary`2"/>.</param> + /// <returns> True if the <see cref="T:System.Collections.Generic.IDictionary`2"/> contains an element with the key; otherwise, false. </returns> + public bool ContainsKey(string key) + { + return this.cache.ContainsKey(key); + } + + /// <summary> + /// Adds an element with the provided key and value to the <see cref="T:System.Collections.Generic.IDictionary`2"/>. + /// </summary> + /// <param name="key">The object to use as the key of the element to add.</param> + /// <param name="value">The object to use as the value of the element to add.</param> + public void Add(string key, OfflineEntry value) + { + this.cache.Add(key, value); + this.db.Insert(value); + } + + /// <summary> + /// Removes the element with the specified key from the <see cref="T:System.Collections.Generic.IDictionary`2"/>. + /// </summary> + /// <param name="key">The key of the element to remove.</param> + /// <returns> True if the element is successfully removed; otherwise, false. This method also returns false if <paramref name="key"/> was not found in the original <see cref="T:System.Collections.Generic.IDictionary`2"/>. </returns> + public bool Remove(string key) + { + this.cache.Remove(key); + return this.db.Delete<OfflineEntry>(key); + } + + /// <summary> + /// Gets the value associated with the specified key. + /// </summary> + /// <param name="key">The key whose value to get.</param><param name="value">When this method returns, the value associated with the specified key, if the key is found; otherwise, the default value for the type of the <paramref name="value"/> parameter. This parameter is passed uninitialized.</param> + /// <returns> True if the object that implements <see cref="T:System.Collections.Generic.IDictionary`2"/> contains an element with the specified key; otherwise, false. </returns> + public bool TryGetValue(string key, out OfflineEntry value) + { + return this.cache.TryGetValue(key, out value); + } + + private string GetFileName(string fileName) + { + var invalidChars = new[] { '`', '[', ',', '=' }; + foreach(char c in invalidChars.Concat(System.IO.Path.GetInvalidFileNameChars()).Distinct()) + { + fileName = fileName.Replace(c, '_'); + } + + return fileName; + } + } +} diff --git a/FireBase/Offline/OfflineEntry.cs b/FireBase/Offline/OfflineEntry.cs new file mode 100644 index 0000000..3b862cb --- /dev/null +++ b/FireBase/Offline/OfflineEntry.cs @@ -0,0 +1,116 @@ +namespace Firebase.Database.Offline +{ + using System; + + using Newtonsoft.Json; + + /// <summary> + /// Represents an object stored in offline storage. + /// </summary> + public class OfflineEntry + { + private object dataInstance; + + /// <summary> + /// Initializes a new instance of the <see cref="OfflineEntry"/> class with an already serialized object. + /// </summary> + /// <param name="key"> The key. </param> + /// <param name="obj"> The object. </param> + /// <param name="priority"> The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. </param> + /// <param name="syncOptions"> The sync options. </param> + public OfflineEntry(string key, object obj, string data, int priority, SyncOptions syncOptions, bool isPartial = false) + { + this.Key = key; + this.Priority = priority; + this.Data = data; + this.Timestamp = DateTime.UtcNow; + this.SyncOptions = syncOptions; + this.IsPartial = isPartial; + + this.dataInstance = obj; + } + + /// <summary> + /// Initializes a new instance of the <see cref="OfflineEntry"/> class. + /// </summary> + /// <param name="key"> The key. </param> + /// <param name="obj"> The object. </param> + /// <param name="priority"> The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. </param> + /// <param name="syncOptions"> The sync options. </param> + public OfflineEntry(string key, object obj, int priority, SyncOptions syncOptions, bool isPartial = false) + : this(key, obj, JsonConvert.SerializeObject(obj), priority, syncOptions, isPartial) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="OfflineEntry"/> class. + /// </summary> + public OfflineEntry() + { + } + + /// <summary> + /// Gets or sets the key of this entry. + /// </summary> + public string Key + { + get; + set; + } + + /// <summary> + /// Gets or sets the priority. Objects with higher priority will be synced first. Higher number indicates higher priority. + /// </summary> + public int Priority + { + get; + set; + } + + /// <summary> + /// Gets or sets the timestamp when this entry was last touched. + /// </summary> + public DateTime Timestamp + { + get; + set; + } + + /// <summary> + /// Gets or sets the <see cref="SyncOptions"/> which define what sync state this entry is in. + /// </summary> + public SyncOptions SyncOptions + { + get; + set; + } + + /// <summary> + /// Gets or sets serialized JSON data. + /// </summary> + public string Data + { + get; + set; + } + + /// <summary> + /// Specifies whether this is only a partial object. + /// </summary> + public bool IsPartial + { + get; + set; + } + + /// <summary> + /// Deserializes <see cref="Data"/> into <typeparamref name="T"/>. The result is cached. + /// </summary> + /// <typeparam name="T"> Type of object to deserialize into. </typeparam> + /// <returns> Instance of <typeparamref name="T"/>. </returns> + public T Deserialize<T>() + { + return (T)(this.dataInstance ?? (this.dataInstance = JsonConvert.DeserializeObject<T>(this.Data))); + } + } +} diff --git a/FireBase/Offline/RealtimeDatabase.cs b/FireBase/Offline/RealtimeDatabase.cs new file mode 100644 index 0000000..61a7010 --- /dev/null +++ b/FireBase/Offline/RealtimeDatabase.cs @@ -0,0 +1,459 @@ +namespace Firebase.Database.Offline +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Reactive.Linq; + using System.Reactive.Subjects; + using System.Threading; + using System.Threading.Tasks; + + using Firebase.Database.Extensions; + using Firebase.Database.Query; + using Firebase.Database.Streaming; + using System.Reactive.Threading.Tasks; + using System.Linq.Expressions; + using Internals; + using Newtonsoft.Json; + using System.Reflection; + using System.Reactive.Disposables; + + /// <summary> + /// The real-time Database which synchronizes online and offline data. + /// </summary> + /// <typeparam name="T"> Type of entities. </typeparam> + public partial class RealtimeDatabase<T> : IDisposable where T : class + { + private readonly ChildQuery childQuery; + private readonly string elementRoot; + private readonly StreamingOptions streamingOptions; + private readonly Subject<FirebaseEvent<T>> subject; + private readonly InitialPullStrategy initialPullStrategy; + private readonly bool pushChanges; + private readonly FirebaseCache<T> firebaseCache; + + private bool isSyncRunning; + private IObservable<FirebaseEvent<T>> observable; + private FirebaseSubscription<T> firebaseSubscription; + + /// <summary> + /// Initializes a new instance of the <see cref="RealtimeDatabase{T}"/> class. + /// </summary> + /// <param name="childQuery"> The child query. </param> + /// <param name="elementRoot"> The element Root. </param> + /// <param name="offlineDatabaseFactory"> The offline database factory. </param> + /// <param name="filenameModifier"> Custom string which will get appended to the file name. </param> + /// <param name="streamChanges"> Specifies whether changes should be streamed from the server. </param> + /// <param name="pullEverythingOnStart"> Specifies if everything should be pull from the online storage on start. It only makes sense when <see cref="streamChanges"/> is set to true. </param> + /// <param name="pushChanges"> Specifies whether changed items should actually be pushed to the server. If this is false, then Put / Post / Delete will not affect server data. </param> + public RealtimeDatabase(ChildQuery childQuery, string elementRoot, Func<Type, string, IDictionary<string, OfflineEntry>> offlineDatabaseFactory, string filenameModifier, StreamingOptions streamingOptions, InitialPullStrategy initialPullStrategy, bool pushChanges, ISetHandler<T> setHandler = null) + { + this.childQuery = childQuery; + this.elementRoot = elementRoot; + this.streamingOptions = streamingOptions; + this.initialPullStrategy = initialPullStrategy; + this.pushChanges = pushChanges; + this.Database = offlineDatabaseFactory(typeof(T), filenameModifier); + this.firebaseCache = new FirebaseCache<T>(new OfflineCacheAdapter<string, T>(this.Database)); + this.subject = new Subject<FirebaseEvent<T>>(); + + this.PutHandler = setHandler ?? new SetHandler<T>(); + + this.isSyncRunning = true; + Task.Factory.StartNew(this.SynchronizeThread, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + + /// <summary> + /// Event raised whenever an exception is thrown in the synchronization thread. Exception thrown in there are swallowed, so this event is the only way to get to them. + /// </summary> + public event EventHandler<ExceptionEventArgs> SyncExceptionThrown; + + /// <summary> + /// Gets the backing Database. + /// </summary> + public IDictionary<string, OfflineEntry> Database + { + get; + private set; + } + + public ISetHandler<T> PutHandler + { + private get; + set; + } + + /// <summary> + /// Overwrites existing object with given key. + /// </summary> + /// <param name="key"> The key. </param> + /// <param name="obj"> The object to set. </param> + /// <param name="syncOnline"> Indicates whether the item should be synced online. </param> + /// <param name="priority"> The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. </param> + public void Set(string key, T obj, SyncOptions syncOptions, int priority = 1) + { + this.SetAndRaise(key, new OfflineEntry(key, obj, priority, syncOptions)); + } + + public void Set<TProperty>(string key, Expression<Func<T, TProperty>> propertyExpression, object value, SyncOptions syncOptions, int priority = 1) + { + var fullKey = this.GenerateFullKey(key, propertyExpression, syncOptions); + var serializedObject = JsonConvert.SerializeObject(value).Trim('"', '\\'); + + if (fullKey.Item3) + { + if (typeof(TProperty) != typeof(string) || value == null) + { + // don't escape non-string primitives and null; + serializedObject = $"{{ \"{fullKey.Item2}\" : {serializedObject} }}"; + } + else + { + serializedObject = $"{{ \"{fullKey.Item2}\" : \"{serializedObject}\" }}"; + } + } + + var setObject = this.firebaseCache.PushData("/" + fullKey.Item1, serializedObject).First(); + + if (!this.Database.ContainsKey(key) || this.Database[key].SyncOptions != SyncOptions.Patch && this.Database[key].SyncOptions != SyncOptions.Put) + { + this.Database[fullKey.Item1] = new OfflineEntry(fullKey.Item1, value, serializedObject, priority, syncOptions, true); + } + + this.subject.OnNext(new FirebaseEvent<T>(key, setObject.Object, setObject == null ? FirebaseEventType.Delete : FirebaseEventType.InsertOrUpdate, FirebaseEventSource.Offline)); + } + + /// <summary> + /// Fetches an object with the given key and adds it to the Database. + /// </summary> + /// <param name="key"> The key. </param> + /// <param name="priority"> The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. </param> + public void Pull(string key, int priority = 1) + { + if (!this.Database.ContainsKey(key)) + { + this.Database[key] = new OfflineEntry(key, null, priority, SyncOptions.Pull); + } + else if (this.Database[key].SyncOptions == SyncOptions.None) + { + // pull only if push isn't pending + this.Database[key].SyncOptions = SyncOptions.Pull; + } + } + + /// <summary> + /// Fetches everything from the remote database. + /// </summary> + public async Task PullAsync() + { + var existingEntries = await this.childQuery + .OnceAsync<T>() + .ToObservable() + .RetryAfterDelay<IReadOnlyCollection<FirebaseObject<T>>, FirebaseException>( + this.childQuery.Client.Options.SyncPeriod, + ex => ex.StatusCode == System.Net.HttpStatusCode.OK) // OK implies the request couldn't complete due to network error. + .Select(e => this.ResetDatabaseFromInitial(e, false)) + .SelectMany(e => e) + .Do(e => + { + this.Database[e.Key] = new OfflineEntry(e.Key, e.Object, 1, SyncOptions.None); + this.subject.OnNext(new FirebaseEvent<T>(e.Key, e.Object, FirebaseEventType.InsertOrUpdate, FirebaseEventSource.OnlinePull)); + }) + .ToList(); + + // Remove items not stored online + foreach (var item in this.Database.Keys.Except(existingEntries.Select(f => f.Key)).ToList()) + { + this.Database.Remove(item); + this.subject.OnNext(new FirebaseEvent<T>(item, null, FirebaseEventType.Delete, FirebaseEventSource.OnlinePull)); + } + } + + /// <summary> + /// Retrieves all offline items currently stored in local database. + /// </summary> + public IEnumerable<FirebaseObject<T>> Once() + { + return this.Database + .Where(kvp => !string.IsNullOrEmpty(kvp.Value.Data) && kvp.Value.Data != "null" && !kvp.Value.IsPartial) + .Select(kvp => new FirebaseObject<T>(kvp.Key, kvp.Value.Deserialize<T>())) + .ToList(); + } + + /// <summary> + /// Starts observing the real-time Database. Events will be fired both when change is done locally and remotely. + /// </summary> + /// <returns> Stream of <see cref="FirebaseEvent{T}"/>. </returns> + public IObservable<FirebaseEvent<T>> AsObservable() + { + if (!this.isSyncRunning) + { + this.isSyncRunning = true; + Task.Factory.StartNew(this.SynchronizeThread, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + + if (this.observable == null) + { + var initialData = Observable.Return(FirebaseEvent<T>.Empty(FirebaseEventSource.Offline)); + if(this.Database.TryGetValue(this.elementRoot, out OfflineEntry oe)) + { + initialData = Observable.Return(oe) + .Where(offlineEntry => !string.IsNullOrEmpty(offlineEntry.Data) && offlineEntry.Data != "null" && !offlineEntry.IsPartial) + .Select(offlineEntry => new FirebaseEvent<T>(offlineEntry.Key, offlineEntry.Deserialize<T>(), FirebaseEventType.InsertOrUpdate, FirebaseEventSource.Offline)); + } + else if(this.Database.Count > 0) + { + initialData = this.Database + .Where(kvp => !string.IsNullOrEmpty(kvp.Value.Data) && kvp.Value.Data != "null" && !kvp.Value.IsPartial) + .Select(kvp => new FirebaseEvent<T>(kvp.Key, kvp.Value.Deserialize<T>(), FirebaseEventType.InsertOrUpdate, FirebaseEventSource.Offline)) + .ToList() + .ToObservable(); + } + + this.observable = initialData + .Merge(this.subject) + .Merge(this.GetInitialPullObservable() + .RetryAfterDelay<IReadOnlyCollection<FirebaseObject<T>>, FirebaseException>( + this.childQuery.Client.Options.SyncPeriod, + ex => ex.StatusCode == System.Net.HttpStatusCode.OK) // OK implies the request couldn't complete due to network error. + .Select(e => this.ResetDatabaseFromInitial(e)) + .SelectMany(e => e) + .Do(this.SetObjectFromInitialPull) + .Select(e => new FirebaseEvent<T>(e.Key, e.Object, e.Object == null ? FirebaseEventType.Delete : FirebaseEventType.InsertOrUpdate, FirebaseEventSource.OnlineInitial)) + .Concat(Observable.Create<FirebaseEvent<T>>(observer => this.InitializeStreamingSubscription(observer)))) + .Do(next => { }, e => this.observable = null, () => this.observable = null) + .Replay() + .RefCount(); + } + + return this.observable; + } + + public void Dispose() + { + this.subject.OnCompleted(); + this.firebaseSubscription?.Dispose(); + } + + private IReadOnlyCollection<FirebaseObject<T>> ResetDatabaseFromInitial(IReadOnlyCollection<FirebaseObject<T>> collection, bool onlyWhenInitialEverything = true) + { + if (onlyWhenInitialEverything && this.initialPullStrategy != InitialPullStrategy.Everything) + { + return collection; + } + + // items which are in local db, but not in the online collection + var extra = this.Once() + .Select(f => f.Key) + .Except(collection.Select(c => c.Key)) + .Select(k => new FirebaseObject<T>(k, null)); + + return collection.Concat(extra).ToList(); + } + + private void SetObjectFromInitialPull(FirebaseObject<T> e) + { + // set object with no sync only if it doesn't exist yet + // and the InitialPullStrategy != Everything + // this attempts to deal with scenario when you are offline, have local changes and go online + // in this case having the InitialPullStrategy set to everything would basically purge all local changes + if (!this.Database.ContainsKey(e.Key) || this.Database[e.Key].SyncOptions == SyncOptions.None || this.Database[e.Key].SyncOptions == SyncOptions.Pull || this.initialPullStrategy != InitialPullStrategy.Everything) + { + this.Database[e.Key] = new OfflineEntry(e.Key, e.Object, 1, SyncOptions.None); + } + } + + private IObservable<IReadOnlyCollection<FirebaseObject<T>>> GetInitialPullObservable() + { + FirebaseQuery query; + switch (this.initialPullStrategy) + { + case InitialPullStrategy.MissingOnly: + query = this.childQuery.OrderByKey().StartAt(() => this.GetLatestKey()); + break; + case InitialPullStrategy.Everything: + query = this.childQuery; + break; + case InitialPullStrategy.None: + default: + return Observable.Empty<IReadOnlyCollection<FirebaseEvent<T>>>(); + } + + if (string.IsNullOrWhiteSpace(this.elementRoot)) + { + return Observable.Defer(() => query.OnceAsync<T>().ToObservable()); + } + + // there is an element root, which indicates the target location is not a collection but a single element + return Observable.Defer(async () => Observable.Return(await query.OnceSingleAsync<T>()).Select(e => new[] { new FirebaseObject<T>(this.elementRoot, e) })); + } + + private IDisposable InitializeStreamingSubscription(IObserver<FirebaseEvent<T>> observer) + { + var completeDisposable = Disposable.Create(() => this.isSyncRunning = false); + + switch (this.streamingOptions) + { + case StreamingOptions.LatestOnly: + // stream since the latest key + var queryLatest = this.childQuery.OrderByKey().StartAt(() => this.GetLatestKey()); + this.firebaseSubscription = new FirebaseSubscription<T>(observer, queryLatest, this.elementRoot, this.firebaseCache); + this.firebaseSubscription.ExceptionThrown += this.StreamingExceptionThrown; + + return new CompositeDisposable(this.firebaseSubscription.Run(), completeDisposable); + case StreamingOptions.Everything: + // stream everything + var queryAll = this.childQuery; + this.firebaseSubscription = new FirebaseSubscription<T>(observer, queryAll, this.elementRoot, this.firebaseCache); + this.firebaseSubscription.ExceptionThrown += this.StreamingExceptionThrown; + + return new CompositeDisposable(this.firebaseSubscription.Run(), completeDisposable); + default: + break; + } + + return completeDisposable; + } + + private void SetAndRaise(string key, OfflineEntry obj, FirebaseEventSource eventSource = FirebaseEventSource.Offline) + { + this.Database[key] = obj; + this.subject.OnNext(new FirebaseEvent<T>(key, obj?.Deserialize<T>(), string.IsNullOrEmpty(obj?.Data) || obj?.Data == "null" ? FirebaseEventType.Delete : FirebaseEventType.InsertOrUpdate, eventSource)); + } + + private async void SynchronizeThread() + { + while (this.isSyncRunning) + { + try + { + var validEntries = this.Database.Where(e => e.Value != null); + await this.PullEntriesAsync(validEntries.Where(kvp => kvp.Value.SyncOptions == SyncOptions.Pull)); + + if (this.pushChanges) + { + await this.PushEntriesAsync(validEntries.Where(kvp => kvp.Value.SyncOptions == SyncOptions.Put || kvp.Value.SyncOptions == SyncOptions.Patch)); + } + } + catch (Exception ex) + { + this.SyncExceptionThrown?.Invoke(this, new ExceptionEventArgs(ex)); + } + + await Task.Delay(this.childQuery.Client.Options.SyncPeriod); + } + } + + private string GetLatestKey() + { + var key = this.Database.OrderBy(o => o.Key, StringComparer.Ordinal).LastOrDefault().Key ?? string.Empty; + + if (!string.IsNullOrWhiteSpace(key)) + { + key = key.Substring(0, key.Length - 1) + (char)(key[key.Length - 1] + 1); + } + + return key; + } + + private async Task PushEntriesAsync(IEnumerable<KeyValuePair<string, OfflineEntry>> pushEntries) + { + var groups = pushEntries.GroupBy(pair => pair.Value.Priority).OrderByDescending(kvp => kvp.Key).ToList(); + + foreach (var group in groups) + { + var tasks = group.OrderBy(kvp => kvp.Value.IsPartial).Select(kvp => + kvp.Value.IsPartial ? + this.ResetSyncAfterPush(this.PutHandler.SetAsync(this.childQuery, kvp.Key, kvp.Value), kvp.Key) : + this.ResetSyncAfterPush(this.PutHandler.SetAsync(this.childQuery, kvp.Key, kvp.Value), kvp.Key, kvp.Value.Deserialize<T>())); + + try + { + await Task.WhenAll(tasks).WithAggregateException(); + } + catch (Exception ex) + { + this.SyncExceptionThrown?.Invoke(this, new ExceptionEventArgs(ex)); + } + } + } + + private async Task PullEntriesAsync(IEnumerable<KeyValuePair<string, OfflineEntry>> pullEntries) + { + var taskGroups = pullEntries.GroupBy(pair => pair.Value.Priority).OrderByDescending(kvp => kvp.Key); + + foreach (var group in taskGroups) + { + var tasks = group.Select(pair => this.ResetAfterPull(this.childQuery.Child(pair.Key == this.elementRoot ? string.Empty : pair.Key).OnceSingleAsync<T>(), pair.Key, pair.Value)); + + try + { + await Task.WhenAll(tasks).WithAggregateException(); + } + catch (Exception ex) + { + this.SyncExceptionThrown?.Invoke(this, new ExceptionEventArgs(ex)); + } + } + } + + private async Task ResetAfterPull(Task<T> task, string key, OfflineEntry entry) + { + await task; + this.SetAndRaise(key, new OfflineEntry(key, task.Result, entry.Priority, SyncOptions.None), FirebaseEventSource.OnlinePull); + } + + private async Task ResetSyncAfterPush(Task task, string key, T obj) + { + await this.ResetSyncAfterPush(task, key); + + if (this.streamingOptions == StreamingOptions.None) + { + this.subject.OnNext(new FirebaseEvent<T>(key, obj, obj == null ? FirebaseEventType.Delete : FirebaseEventType.InsertOrUpdate, FirebaseEventSource.OnlinePush)); + } + } + + private async Task ResetSyncAfterPush(Task task, string key) + { + await task; + this.ResetSyncOptions(key); + } + + private void ResetSyncOptions(string key) + { + var item = this.Database[key]; + + if (item.IsPartial) + { + this.Database.Remove(key); + } + else + { + item.SyncOptions = SyncOptions.None; + this.Database[key] = item; + } + } + + private void StreamingExceptionThrown(object sender, ExceptionEventArgs<FirebaseException> e) + { + this.SyncExceptionThrown?.Invoke(this, new ExceptionEventArgs(e.Exception)); + } + + private Tuple<string, string, bool> GenerateFullKey<TProperty>(string key, Expression<Func<T, TProperty>> propertyGetter, SyncOptions syncOptions) + { + var visitor = new MemberAccessVisitor(); + visitor.Visit(propertyGetter); + var propertyType = typeof(TProperty).GetTypeInfo(); + var prefix = key == string.Empty ? string.Empty : key + "/"; + + // primitive types + if (syncOptions == SyncOptions.Patch && (propertyType.IsPrimitive || Nullable.GetUnderlyingType(typeof(TProperty)) != null || typeof(TProperty) == typeof(string))) + { + return Tuple.Create(prefix + string.Join("/", visitor.PropertyNames.Skip(1).Reverse()), visitor.PropertyNames.First(), true); + } + + return Tuple.Create(prefix + string.Join("/", visitor.PropertyNames.Reverse()), visitor.PropertyNames.First(), false); + } + + } +} diff --git a/FireBase/Offline/SetHandler.cs b/FireBase/Offline/SetHandler.cs new file mode 100644 index 0000000..1efa7b6 --- /dev/null +++ b/FireBase/Offline/SetHandler.cs @@ -0,0 +1,24 @@ +namespace Firebase.Database.Offline +{ + using Firebase.Database.Query; + + using System.Threading.Tasks; + + public class SetHandler<T> : ISetHandler<T> + { + public virtual async Task SetAsync(ChildQuery query, string key, OfflineEntry entry) + { + using (var child = query.Child(key)) + { + if (entry.SyncOptions == SyncOptions.Put) + { + await child.PutAsync(entry.Data); + } + else + { + await child.PatchAsync(entry.Data); + } + } + } + } +} diff --git a/FireBase/Offline/StreamingOptions.cs b/FireBase/Offline/StreamingOptions.cs new file mode 100644 index 0000000..9ed4e54 --- /dev/null +++ b/FireBase/Offline/StreamingOptions.cs @@ -0,0 +1,21 @@ +namespace Firebase.Database.Offline +{ + public enum StreamingOptions + { + /// <summary> + /// No realtime streaming. + /// </summary> + None, + + /// <summary> + /// Streaming of only new items - not the existing ones. + /// </summary> + LatestOnly, + + /// <summary> + /// Streaming of all items. This will also pull all existing items on start, so be mindful about the number of items in your DB. + /// When used, consider not setting the <see cref="InitialPullStrategy"/> to <see cref="InitialPullStrategy.Everything"/> because you would pointlessly pull everything twice. + /// </summary> + Everything + } +} diff --git a/FireBase/Offline/SyncOptions.cs b/FireBase/Offline/SyncOptions.cs new file mode 100644 index 0000000..b2f382a --- /dev/null +++ b/FireBase/Offline/SyncOptions.cs @@ -0,0 +1,28 @@ +namespace Firebase.Database.Offline +{ + /// <summary> + /// Specifies type of sync requested for given data. + /// </summary> + public enum SyncOptions + { + /// <summary> + /// No sync needed for given data. + /// </summary> + None, + + /// <summary> + /// Data should be pulled from firebase. + /// </summary> + Pull, + + /// <summary> + /// Data should be put to firebase. + /// </summary> + Put, + + /// <summary> + /// Data should be patched in firebase. + /// </summary> + Patch + } +} diff --git a/FireBase/Query/AuthQuery.cs b/FireBase/Query/AuthQuery.cs new file mode 100644 index 0000000..8a8d3e8 --- /dev/null +++ b/FireBase/Query/AuthQuery.cs @@ -0,0 +1,33 @@ +namespace Firebase.Database.Query +{ + using System; + + /// <summary> + /// Represents an auth parameter in firebase query, e.g. "?auth=xyz". + /// </summary> + public class AuthQuery : ParameterQuery + { + private readonly Func<string> tokenFactory; + + /// <summary> + /// Initializes a new instance of the <see cref="AuthQuery"/> class. + /// </summary> + /// <param name="parent"> The parent. </param> + /// <param name="tokenFactory"> The authentication token factory. </param> + /// <param name="client"> The owner. </param> + public AuthQuery(FirebaseQuery parent, Func<string> tokenFactory, FirebaseClient client) : base(parent, () => client.Options.AsAccessToken ? "access_token" : "auth", client) + { + this.tokenFactory = tokenFactory; + } + + /// <summary> + /// Build the url parameter value of this child. + /// </summary> + /// <param name="child"> The child of this child. </param> + /// <returns> The <see cref="string"/>. </returns> + protected override string BuildUrlParameter(FirebaseQuery child) + { + return this.tokenFactory(); + } + } +} diff --git a/FireBase/Query/ChildQuery.cs b/FireBase/Query/ChildQuery.cs new file mode 100644 index 0000000..1696ea8 --- /dev/null +++ b/FireBase/Query/ChildQuery.cs @@ -0,0 +1,56 @@ +namespace Firebase.Database.Query +{ + using System; + + /// <summary> + /// Firebase query which references the child of current node. + /// </summary> + public class ChildQuery : FirebaseQuery + { + private readonly Func<string> pathFactory; + + /// <summary> + /// Initializes a new instance of the <see cref="ChildQuery"/> class. + /// </summary> + /// <param name="parent"> The parent. </param> + /// <param name="pathFactory"> The path to the child node. </param> + /// <param name="client"> The owner. </param> + public ChildQuery(FirebaseQuery parent, Func<string> pathFactory, FirebaseClient client) + : base(parent, client) + { + this.pathFactory = pathFactory; + } + + /// <summary> + /// Initializes a new instance of the <see cref="ChildQuery"/> class. + /// </summary> + /// <param name="client"> The client. </param> + /// <param name="pathFactory"> The path to the child node. </param> + public ChildQuery(FirebaseClient client, Func<string> pathFactory) + : this(null, pathFactory, client) + { + } + + /// <summary> + /// Build the url segment of this child. + /// </summary> + /// <param name="child"> The child of this child. </param> + /// <returns> The <see cref="string"/>. </returns> + protected override string BuildUrlSegment(FirebaseQuery child) + { + var s = this.pathFactory(); + + if (s != string.Empty && !s.EndsWith("/")) + { + s += '/'; + } + + if (!(child is ChildQuery)) + { + return s + ".json"; + } + + return s; + } + } +} diff --git a/FireBase/Query/FilterQuery.cs b/FireBase/Query/FilterQuery.cs new file mode 100644 index 0000000..f9f6271 --- /dev/null +++ b/FireBase/Query/FilterQuery.cs @@ -0,0 +1,81 @@ +namespace Firebase.Database.Query +{ + using System; + using System.Globalization; + + /// <summary> + /// Represents a firebase filtering query, e.g. "?LimitToLast=10". + /// </summary> + public class FilterQuery : ParameterQuery + { + private readonly Func<string> valueFactory; + private readonly Func<double> doubleValueFactory; + private readonly Func<bool> boolValueFactory; + + /// <summary> + /// Initializes a new instance of the <see cref="FilterQuery"/> class. + /// </summary> + /// <param name="parent"> The parent. </param> + /// <param name="filterFactory"> The filter. </param> + /// <param name="valueFactory"> The value for filter. </param> + /// <param name="client"> The owning client. </param> + public FilterQuery(FirebaseQuery parent, Func<string> filterFactory, Func<string> valueFactory, FirebaseClient client) + : base(parent, filterFactory, client) + { + this.valueFactory = valueFactory; + } + + /// <summary> + /// Initializes a new instance of the <see cref="FilterQuery"/> class. + /// </summary> + /// <param name="parent"> The parent. </param> + /// <param name="filterFactory"> The filter. </param> + /// <param name="valueFactory"> The value for filter. </param> + /// <param name="client"> The owning client. </param> + public FilterQuery(FirebaseQuery parent, Func<string> filterFactory, Func<double> valueFactory, FirebaseClient client) + : base(parent, filterFactory, client) + { + this.doubleValueFactory = valueFactory; + } + + /// <summary> + /// Initializes a new instance of the <see cref="FilterQuery"/> class. + /// </summary> + /// <param name="parent"> The parent. </param> + /// <param name="filterFactory"> The filter. </param> + /// <param name="valueFactory"> The value for filter. </param> + /// <param name="client"> The owning client. </param> + public FilterQuery(FirebaseQuery parent, Func<string> filterFactory, Func<bool> valueFactory, FirebaseClient client) + : base(parent, filterFactory, client) + { + this.boolValueFactory = valueFactory; + } + + /// <summary> + /// The build url parameter. + /// </summary> + /// <param name="child"> The child. </param> + /// <returns> Url parameter part of the resulting path. </returns> + protected override string BuildUrlParameter(FirebaseQuery child) + { + if (this.valueFactory != null) + { + if(this.valueFactory() == null) + { + return $"null"; + } + return $"\"{this.valueFactory()}\""; + } + else if (this.doubleValueFactory != null) + { + return this.doubleValueFactory().ToString(CultureInfo.InvariantCulture); + } + else if (this.boolValueFactory != null) + { + return $"{this.boolValueFactory().ToString().ToLower()}"; + } + + return string.Empty; + } + } +} diff --git a/FireBase/Query/FirebaseQuery.cs b/FireBase/Query/FirebaseQuery.cs new file mode 100644 index 0000000..0e1b84a --- /dev/null +++ b/FireBase/Query/FirebaseQuery.cs @@ -0,0 +1,314 @@ +namespace Firebase.Database.Query +{ + using System; + using System.Collections.Generic; + using System.Net.Http; + using System.Reactive.Linq; + using System.Threading.Tasks; + + using Firebase.Database.Http; + using Firebase.Database.Offline; + using Firebase.Database.Streaming; + + using Newtonsoft.Json; + using System.Net; + + /// <summary> + /// Represents a firebase query. + /// </summary> + public abstract class FirebaseQuery : IFirebaseQuery, IDisposable + { + protected TimeSpan DEFAULT_HTTP_CLIENT_TIMEOUT = new TimeSpan(0, 0, 180); + + protected readonly FirebaseQuery Parent; + + private HttpClient client; + + /// <summary> + /// Initializes a new instance of the <see cref="FirebaseQuery"/> class. + /// </summary> + /// <param name="parent"> The parent of this query. </param> + /// <param name="client"> The owning client. </param> + protected FirebaseQuery(FirebaseQuery parent, FirebaseClient client) + { + this.Client = client; + this.Parent = parent; + } + + /// <summary> + /// Gets the client. + /// </summary> + public FirebaseClient Client + { + get; + } + + /// <summary> + /// Queries the firebase server once returning collection of items. + /// </summary> + /// <param name="timeout"> Optional timeout value. </param> + /// <typeparam name="T"> Type of elements. </typeparam> + /// <returns> Collection of <see cref="FirebaseObject{T}"/> holding the entities returned by server. </returns> + public async Task<IReadOnlyCollection<FirebaseObject<T>>> OnceAsync<T>(TimeSpan? timeout = null) + { + var url = string.Empty; + + try + { + url = await this.BuildUrlAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + throw new FirebaseException("Couldn't build the url", string.Empty, string.Empty, HttpStatusCode.OK, ex); + } + + return await this.GetClient(timeout).GetObjectCollectionAsync<T>(url, Client.Options.JsonSerializerSettings) + .ConfigureAwait(false); + } + + + /// <summary> + /// Assumes given query is pointing to a single object of type <typeparamref name="T"/> and retrieves it. + /// </summary> + /// <param name="timeout"> Optional timeout value. </param> + /// <typeparam name="T"> Type of elements. </typeparam> + /// <returns> Single object of type <typeparamref name="T"/>. </returns> + public async Task<T> OnceSingleAsync<T>(TimeSpan? timeout = null) + { + var responseData = string.Empty; + var statusCode = HttpStatusCode.OK; + var url = string.Empty; + + try + { + url = await this.BuildUrlAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + throw new FirebaseException("Couldn't build the url", string.Empty, responseData, statusCode, ex); + } + + try + { + var response = await this.GetClient(timeout).GetAsync(url).ConfigureAwait(false); + statusCode = response.StatusCode; + responseData = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + response.Dispose(); + + return JsonConvert.DeserializeObject<T>(responseData, Client.Options.JsonSerializerSettings); + } + catch (Exception ex) + { + throw new FirebaseException(url, string.Empty, responseData, statusCode, ex); + } + } + + /// <summary> + /// Starts observing this query watching for changes real time sent by the server. + /// </summary> + /// <typeparam name="T"> Type of elements. </typeparam> + /// <param name="elementRoot"> Optional custom root element of received json items. </param> + /// <returns> Observable stream of <see cref="FirebaseEvent{T}"/>. </returns> + public IObservable<FirebaseEvent<T>> AsObservable<T>(EventHandler<ExceptionEventArgs<FirebaseException>> exceptionHandler = null, string elementRoot = "") + { + return Observable.Create<FirebaseEvent<T>>(observer => + { + var sub = new FirebaseSubscription<T>(observer, this, elementRoot, new FirebaseCache<T>()); + sub.ExceptionThrown += exceptionHandler; + return sub.Run(); + }); + } + + /// <summary> + /// Builds the actual URL of this query. + /// </summary> + /// <returns> The <see cref="string"/>. </returns> + public async Task<string> BuildUrlAsync() + { + // if token factory is present on the parent then use it to generate auth token + if (this.Client.Options.AuthTokenAsyncFactory != null) + { + var token = await this.Client.Options.AuthTokenAsyncFactory().ConfigureAwait(false); + return this.WithAuth(token).BuildUrl(null); + } + + return this.BuildUrl(null); + } + + /// <summary> + /// Posts given object to repository. + /// </summary> + /// <param name="obj"> The object. </param> + /// <param name="generateKeyOffline"> Specifies whether the key should be generated offline instead of online. </param> + /// <param name="timeout"> Optional timeout value. </param> + /// <typeparam name="T"> Type of <see cref="obj"/> </typeparam> + /// <returns> Resulting firebase object with populated key. </returns> + public async Task<FirebaseObject<string>> PostAsync(string data, bool generateKeyOffline = true, TimeSpan? timeout = null) + { + // post generates a new key server-side, while put can be used with an already generated local key + if (generateKeyOffline) + { + var key = FirebaseKeyGenerator.Next(); + await new ChildQuery(this, () => key, this.Client).PutAsync(data).ConfigureAwait(false); + + return new FirebaseObject<string>(key, data); + } + else + { + var c = this.GetClient(timeout); + var sendData = await this.SendAsync(c, data, HttpMethod.Post).ConfigureAwait(false); + var result = JsonConvert.DeserializeObject<PostResult>(sendData, Client.Options.JsonSerializerSettings); + + return new FirebaseObject<string>(result.Name, data); + } + } + + /// <summary> + /// Patches data at given location instead of overwriting them. + /// </summary> + /// <param name="obj"> The object. </param> + /// <param name="timeout"> Optional timeout value. </param> + /// <typeparam name="T"> Type of <see cref="obj"/> </typeparam> + /// <returns> The <see cref="Task"/>. </returns> + public async Task PatchAsync(string data, TimeSpan? timeout = null) + { + var c = this.GetClient(timeout); + + await this.Silent().SendAsync(c, data, new HttpMethod("PATCH")).ConfigureAwait(false); + } + + /// <summary> + /// Sets or overwrites data at given location. + /// </summary> + /// <param name="obj"> The object. </param> + /// <param name="timeout"> Optional timeout value. </param> + /// <typeparam name="T"> Type of <see cref="obj"/> </typeparam> + /// <returns> The <see cref="Task"/>. </returns> + public async Task PutAsync(string data, TimeSpan? timeout = null) + { + var c = this.GetClient(timeout); + + await this.Silent().SendAsync(c, data, HttpMethod.Put).ConfigureAwait(false); + } + + /// <summary> + /// Deletes data from given location. + /// </summary> + /// <param name="timeout"> Optional timeout value. </param> + /// <returns> The <see cref="Task"/>. </returns> + public async Task DeleteAsync(TimeSpan? timeout = null) + { + var c = this.GetClient(timeout); + var url = string.Empty; + var responseData = string.Empty; + var statusCode = HttpStatusCode.OK; + + try + { + url = await this.BuildUrlAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + throw new FirebaseException("Couldn't build the url", string.Empty, responseData, statusCode, ex); + } + + try + { + var result = await c.DeleteAsync(url).ConfigureAwait(false); + statusCode = result.StatusCode; + responseData = await result.Content.ReadAsStringAsync().ConfigureAwait(false); + + result.EnsureSuccessStatusCode(); + } + catch (Exception ex) + { + throw new FirebaseException(url, string.Empty, responseData, statusCode, ex); + } + } + + /// <summary> + /// Disposes this instance. + /// </summary> + public void Dispose() + { + this.client?.Dispose(); + } + + /// <summary> + /// Build the url segment of this child. + /// </summary> + /// <param name="child"> The child of this query. </param> + /// <returns> The <see cref="string"/>. </returns> + protected abstract string BuildUrlSegment(FirebaseQuery child); + + private string BuildUrl(FirebaseQuery child) + { + var url = this.BuildUrlSegment(child); + + if (this.Parent != null) + { + url = this.Parent.BuildUrl(this) + url; + } + + return url; + } + + private HttpClient GetClient(TimeSpan? timeout = null) + { + if (this.client == null) + { + this.client = new HttpClient(); + } + + if (!timeout.HasValue) + { + this.client.Timeout = DEFAULT_HTTP_CLIENT_TIMEOUT; + } + else + { + this.client.Timeout = timeout.Value; + } + + return this.client; + } + + private async Task<string> SendAsync(HttpClient client, string data, HttpMethod method) + { + var responseData = string.Empty; + var statusCode = HttpStatusCode.OK; + var requestData = data; + var url = string.Empty; + + try + { + url = await this.BuildUrlAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + throw new FirebaseException("Couldn't build the url", requestData, responseData, statusCode, ex); + } + + var message = new HttpRequestMessage(method, url) + { + Content = new StringContent(requestData) + }; + + try + { + var result = await client.SendAsync(message).ConfigureAwait(false); + statusCode = result.StatusCode; + responseData = await result.Content.ReadAsStringAsync().ConfigureAwait(false); + + result.EnsureSuccessStatusCode(); + + return responseData; + } + catch (Exception ex) + { + throw new FirebaseException(url, requestData, responseData, statusCode, ex); + } + } + } +} diff --git a/FireBase/Query/IFirebaseQuery.cs b/FireBase/Query/IFirebaseQuery.cs new file mode 100644 index 0000000..2e8c671 --- /dev/null +++ b/FireBase/Query/IFirebaseQuery.cs @@ -0,0 +1,43 @@ +namespace Firebase.Database.Query +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + + using Firebase.Database.Streaming; + + /// <summary> + /// The FirebaseQuery interface. + /// </summary> + public interface IFirebaseQuery + { + /// <summary> + /// Gets the owning client of this query. + /// </summary> + FirebaseClient Client + { + get; + } + + /// <summary> + /// Retrieves items which exist on the location specified by this query instance. + /// </summary> + /// <param name="timeout"> Optional timeout value. </param> + /// <typeparam name="T"> Type of the items. </typeparam> + /// <returns> Collection of <see cref="FirebaseObject{T}"/>. </returns> + Task<IReadOnlyCollection<FirebaseObject<T>>> OnceAsync<T>(TimeSpan? timeout = null); + + /// <summary> + /// Returns current location as an observable which allows to real-time listening to events from the firebase server. + /// </summary> + /// <typeparam name="T"> Type of the items. </typeparam> + /// <returns> Cold observable of <see cref="FirebaseEvent{T}"/>. </returns> + IObservable<FirebaseEvent<T>> AsObservable<T>(EventHandler<ExceptionEventArgs<FirebaseException>> exceptionHandler, string elementRoot = ""); + + /// <summary> + /// Builds the actual url of this query. + /// </summary> + /// <returns> The <see cref="string"/>. </returns> + Task<string> BuildUrlAsync(); + } +} diff --git a/FireBase/Query/OrderQuery.cs b/FireBase/Query/OrderQuery.cs new file mode 100644 index 0000000..46ebd2c --- /dev/null +++ b/FireBase/Query/OrderQuery.cs @@ -0,0 +1,34 @@ +namespace Firebase.Database.Query +{ + using System; + + /// <summary> + /// Represents a firebase ordering query, e.g. "?OrderBy=Foo". + /// </summary> + public class OrderQuery : ParameterQuery + { + private readonly Func<string> propertyNameFactory; + + /// <summary> + /// Initializes a new instance of the <see cref="OrderQuery"/> class. + /// </summary> + /// <param name="parent"> The query parent. </param> + /// <param name="propertyNameFactory"> The property name. </param> + /// <param name="client"> The owning client. </param> + public OrderQuery(ChildQuery parent, Func<string> propertyNameFactory, FirebaseClient client) + : base(parent, () => "orderBy", client) + { + this.propertyNameFactory = propertyNameFactory; + } + + /// <summary> + /// The build url parameter. + /// </summary> + /// <param name="child"> The child. </param> + /// <returns> The <see cref="string"/>. </returns> + protected override string BuildUrlParameter(FirebaseQuery child) + { + return $"\"{this.propertyNameFactory()}\""; + } + } +} diff --git a/FireBase/Query/ParameterQuery.cs b/FireBase/Query/ParameterQuery.cs new file mode 100644 index 0000000..e3d9717 --- /dev/null +++ b/FireBase/Query/ParameterQuery.cs @@ -0,0 +1,43 @@ +namespace Firebase.Database.Query +{ + using System; + + /// <summary> + /// Represents a parameter in firebase query, e.g. "?data=foo". + /// </summary> + public abstract class ParameterQuery : FirebaseQuery + { + private readonly Func<string> parameterFactory; + private readonly string separator; + + /// <summary> + /// Initializes a new instance of the <see cref="ParameterQuery"/> class. + /// </summary> + /// <param name="parent"> The parent of this query. </param> + /// <param name="parameterFactory"> The parameter. </param> + /// <param name="client"> The owning client. </param> + protected ParameterQuery(FirebaseQuery parent, Func<string> parameterFactory, FirebaseClient client) + : base(parent, client) + { + this.parameterFactory = parameterFactory; + this.separator = (this.Parent is ChildQuery) ? "?" : "&"; + } + + /// <summary> + /// Build the url segment represented by this query. + /// </summary> + /// <param name="child"> The child. </param> + /// <returns> The <see cref="string"/>. </returns> + protected override string BuildUrlSegment(FirebaseQuery child) + { + return $"{this.separator}{this.parameterFactory()}={this.BuildUrlParameter(child)}"; + } + + /// <summary> + /// The build url parameter. + /// </summary> + /// <param name="child"> The child. </param> + /// <returns> The <see cref="string"/>. </returns> + protected abstract string BuildUrlParameter(FirebaseQuery child); + } +} diff --git a/FireBase/Query/QueryExtensions.cs b/FireBase/Query/QueryExtensions.cs new file mode 100644 index 0000000..77db644 --- /dev/null +++ b/FireBase/Query/QueryExtensions.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace Firebase.Database.Query +{ + /// <summary> + /// Query extensions providing linq like syntax for firebase server methods. + /// </summary> + public static class QueryExtensions + { + /// <summary> + /// Adds an auth parameter to the query. + /// </summary> + /// <param name="node"> The child. </param> + /// <param name="token"> The auth token. </param> + /// <returns> The <see cref="AuthQuery"/>. </returns> + internal static AuthQuery WithAuth(this FirebaseQuery node, string token) + { + return node.WithAuth(() => token); + } + + /// <summary> + /// Appends print=silent to save bandwidth. + /// </summary> + /// <param name="node"> The child. </param> + /// <returns> The <see cref="SilentQuery"/>. </returns> + internal static SilentQuery Silent(this FirebaseQuery node) + { + return new SilentQuery(node, node.Client); + } + + /// <summary> + /// References a sub child of the existing node. + /// </summary> + /// <param name="node"> The child. </param> + /// <param name="path"> The path of sub child. </param> + /// <returns> The <see cref="ChildQuery"/>. </returns> + public static ChildQuery Child(this ChildQuery node, string path) + { + return node.Child(() => path); + } + + /// <summary> + /// Order data by given <see cref="propertyName"/>. Note that this is used mainly for following filtering queries and due to firebase implementation + /// the data may actually not be ordered. + /// </summary> + /// <param name="child"> The child. </param> + /// <param name="propertyName"> The property name. </param> + /// <returns> The <see cref="OrderQuery"/>. </returns> + public static OrderQuery OrderBy(this ChildQuery child, string propertyName) + { + return child.OrderBy(() => propertyName); + } + + /// <summary> + /// Instructs firebase to send data greater or equal to the <see cref="value"/>. This must be preceded by an OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="value"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery"/>. </returns> + public static FilterQuery StartAt(this ParameterQuery child, string value) + { + return child.StartAt(() => value); + } + + /// <summary> + /// Instructs firebase to send data lower or equal to the <see cref="value"/>. This must be preceded by an OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="value"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery"/>. </returns> + public static FilterQuery EndAt(this ParameterQuery child, string value) + { + return child.EndAt(() => value); + } + + /// <summary> + /// Instructs firebase to send data equal to the <see cref="value"/>. This must be preceded by an OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="value"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery"/>. </returns> + public static FilterQuery EqualTo(this ParameterQuery child, string value) + { + return child.EqualTo(() => value); + } + + /// <summary> + /// Instructs firebase to send data greater or equal to the <see cref="value"/>. This must be preceded by an OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="value"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery"/>. </returns> + public static FilterQuery StartAt(this ParameterQuery child, double value) + { + return child.StartAt(() => value); + } + + /// <summary> + /// Instructs firebase to send data lower or equal to the <see cref="value"/>. This must be preceded by an OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="value"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery"/>. </returns> + public static FilterQuery EndAt(this ParameterQuery child, double value) + { + return child.EndAt(() => value); + } + + /// <summary> + /// Instructs firebase to send data equal to the <see cref="value"/>. This must be preceded by an OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="value"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery"/>. </returns> + public static FilterQuery EqualTo(this ParameterQuery child, double value) + { + return child.EqualTo(() => value); + } + + /// <summary> + /// Instructs firebase to send data equal to the <see cref="value"/>. This must be preceded by an OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="value"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery"/>. </returns> + public static FilterQuery EqualTo(this ParameterQuery child, bool value) + { + return child.EqualTo(() => value); + } + + /// <summary> + /// Instructs firebase to send data equal to null. This must be preceded by an OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <returns> The <see cref="FilterQuery"/>. </returns> + public static FilterQuery EqualTo(this ParameterQuery child) + { + return child.EqualTo(() => null); + } + + /// <summary> + /// Limits the result to first <see cref="count"/> items. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="count"> Number of elements. </param> + /// <returns> The <see cref="FilterQuery"/>. </returns> + public static FilterQuery LimitToFirst(this ParameterQuery child, int count) + { + return child.LimitToFirst(() => count); + } + + /// <summary> + /// Limits the result to last <see cref="count"/> items. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="count"> Number of elements. </param> + /// <returns> The <see cref="FilterQuery"/>. </returns> + public static FilterQuery LimitToLast(this ParameterQuery child, int count) + { + return child.LimitToLast(() => count); + } + + public static Task PutAsync<T>(this FirebaseQuery query, T obj) + { + return query.PutAsync(JsonConvert.SerializeObject(obj, query.Client.Options.JsonSerializerSettings)); + } + + public static Task PatchAsync<T>(this FirebaseQuery query, T obj) + { + return query.PatchAsync(JsonConvert.SerializeObject(obj, query.Client.Options.JsonSerializerSettings)); + } + + public static async Task<FirebaseObject<T>> PostAsync<T>(this FirebaseQuery query, T obj, bool generateKeyOffline = true) + { + var result = await query.PostAsync(JsonConvert.SerializeObject(obj, query.Client.Options.JsonSerializerSettings), generateKeyOffline); + + return new FirebaseObject<T>(result.Key, obj); + } + + /// <summary> + /// Fan out given item to multiple locations at once. See https://firebase.googleblog.com/2015/10/client-side-fan-out-for-data-consistency_73.html for details. + /// </summary> + /// <typeparam name="T"> Type of object to fan out. </typeparam> + /// <param name="query"> Current node. </param> + /// <param name="item"> Object to fan out. </param> + /// <param name="relativePaths"> Locations where to store the item. </param> + public static async Task FanOut<T>(this ChildQuery child, T item, params string[] relativePaths) + { + if (relativePaths == null) + { + throw new ArgumentNullException(nameof(relativePaths)); + } + + var fanoutObject = new Dictionary<string, T>(relativePaths.Length); + + foreach (var path in relativePaths) + { + fanoutObject.Add(path, item); + } + + await child.PatchAsync(fanoutObject); + } + } +} diff --git a/FireBase/Query/QueryFactoryExtensions.cs b/FireBase/Query/QueryFactoryExtensions.cs new file mode 100644 index 0000000..b36e74a --- /dev/null +++ b/FireBase/Query/QueryFactoryExtensions.cs @@ -0,0 +1,176 @@ +namespace Firebase.Database.Query +{ + using System; + + /// <summary> + /// Query extensions providing linq like syntax for firebase server methods. + /// </summary> + public static class QueryFactoryExtensions + { + /// <summary> + /// Adds an auth parameter to the query. + /// </summary> + /// <param name="node"> The child. </param> + /// <param name="tokenFactory"> The auth token. </param> + /// <returns> The <see cref="AuthQuery"/>. </returns> + internal static AuthQuery WithAuth(this FirebaseQuery node, Func<string> tokenFactory) + { + return new AuthQuery(node, tokenFactory, node.Client); + } + + /// <summary> + /// References a sub child of the existing node. + /// </summary> + /// <param name="node"> The child. </param> + /// <param name="pathFactory"> The path of sub child. </param> + /// <returns> The <see cref="ChildQuery"/>. </returns> + public static ChildQuery Child(this ChildQuery node, Func<string> pathFactory) + { + return new ChildQuery(node, pathFactory, node.Client); + } + + /// <summary> + /// Order data by given <see cref="propertyNameFactory"/>. Note that this is used mainly for following filtering queries and due to firebase implementation + /// the data may actually not be ordered. + /// </summary> + /// <param name="child"> The child. </param> + /// <param name="propertyNameFactory"> The property name. </param> + /// <returns> The <see cref="OrderQuery"/>. </returns> + public static OrderQuery OrderBy(this ChildQuery child, Func<string> propertyNameFactory) + { + return new OrderQuery(child, propertyNameFactory, child.Client); + } + + /// <summary> + /// Order data by $key. Note that this is used mainly for following filtering queries and due to firebase implementation + /// the data may actually not be ordered. + /// </summary> + /// <param name="child"> The child. </param> + /// <returns> The <see cref="OrderQuery"/>. </returns> + public static OrderQuery OrderByKey(this ChildQuery child) + { + return child.OrderBy("$key"); + } + + /// <summary> + /// Order data by $value. Note that this is used mainly for following filtering queries and due to firebase implementation + /// the data may actually not be ordered. + /// </summary> + /// <param name="child"> The child. </param> + /// <returns> The <see cref="OrderQuery"/>. </returns> + public static OrderQuery OrderByValue(this ChildQuery child) + { + return child.OrderBy("$value"); + } + + /// <summary> + /// Order data by $priority. Note that this is used mainly for following filtering queries and due to firebase implementation + /// the data may actually not be ordered. + /// </summary> + /// <param name="child"> The child. </param> + /// <returns> The <see cref="OrderQuery"/>. </returns> + public static OrderQuery OrderByPriority(this ChildQuery child) + { + return child.OrderBy("$priority"); + } + + /// <summary> + /// Instructs firebase to send data greater or equal to the <see cref="valueFactory"/>. This must be preceded by an OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="valueFactory"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery"/>. </returns> + public static FilterQuery StartAt(this ParameterQuery child, Func<string> valueFactory) + { + return new FilterQuery(child, () => "startAt", valueFactory, child.Client); + } + + /// <summary> + /// Instructs firebase to send data lower or equal to the <see cref="valueFactory"/>. This must be preceded by an OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="valueFactory"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery"/>. </returns> + public static FilterQuery EndAt(this ParameterQuery child, Func<string> valueFactory) + { + return new FilterQuery(child, () => "endAt", valueFactory, child.Client); + } + + /// <summary> + /// Instructs firebase to send data equal to the <see cref="valueFactory"/>. This must be preceded by an OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="valueFactory"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery"/>. </returns> + public static FilterQuery EqualTo(this ParameterQuery child, Func<string> valueFactory) + { + return new FilterQuery(child, () => "equalTo", valueFactory, child.Client); + } + + /// <summary> + /// Instructs firebase to send data greater or equal to the <see cref="valueFactory"/>. This must be preceded by an OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="valueFactory"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery"/>. </returns> + public static FilterQuery StartAt(this ParameterQuery child, Func<double> valueFactory) + { + return new FilterQuery(child, () => "startAt", valueFactory, child.Client); + } + + /// <summary> + /// Instructs firebase to send data lower or equal to the <see cref="valueFactory"/>. This must be preceded by an OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="valueFactory"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery"/>. </returns> + public static FilterQuery EndAt(this ParameterQuery child, Func<double> valueFactory) + { + return new FilterQuery(child, () => "endAt", valueFactory, child.Client); + } + + /// <summary> + /// Instructs firebase to send data equal to the <see cref="valueFactory"/>. This must be preceded by an OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="valueFactory"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery"/>. </returns> + public static FilterQuery EqualTo(this ParameterQuery child, Func<double> valueFactory) + { + return new FilterQuery(child, () => "equalTo", valueFactory, child.Client); + } + + /// <summary> + /// Instructs firebase to send data equal to the <see cref="valueFactory"/>. This must be preceded by an OrderBy query. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="valueFactory"> Value to start at. </param> + /// <returns> The <see cref="FilterQuery"/>. </returns> + public static FilterQuery EqualTo(this ParameterQuery child, Func<bool> valueFactory) + { + return new FilterQuery(child, () => "equalTo", valueFactory, child.Client); + } + + /// <summary> + /// Limits the result to first <see cref="countFactory"/> items. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="countFactory"> Number of elements. </param> + /// <returns> The <see cref="FilterQuery"/>. </returns> + public static FilterQuery LimitToFirst(this ParameterQuery child, Func<int> countFactory) + { + return new FilterQuery(child, () => "limitToFirst", () => countFactory(), child.Client); + } + + /// <summary> + /// Limits the result to last <see cref="countFactory"/> items. + /// </summary> + /// <param name="child"> Current node. </param> + /// <param name="countFactory"> Number of elements. </param> + /// <returns> The <see cref="FilterQuery"/>. </returns> + public static FilterQuery LimitToLast(this ParameterQuery child, Func<int> countFactory) + { + return new FilterQuery(child, () => "limitToLast", () => countFactory(), child.Client); + } + } +} diff --git a/FireBase/Query/SilentQuery.cs b/FireBase/Query/SilentQuery.cs new file mode 100644 index 0000000..15584f6 --- /dev/null +++ b/FireBase/Query/SilentQuery.cs @@ -0,0 +1,18 @@ +namespace Firebase.Database.Query +{ + /// <summary> + /// Appends print=silent to the url. + /// </summary> + public class SilentQuery : ParameterQuery + { + public SilentQuery(FirebaseQuery parent, FirebaseClient client) + : base(parent, () => "print", client) + { + } + + protected override string BuildUrlParameter(FirebaseQuery child) + { + return "silent"; + } + } +} diff --git a/FireBase/Settings.StyleCop b/FireBase/Settings.StyleCop new file mode 100644 index 0000000..833aa39 --- /dev/null +++ b/FireBase/Settings.StyleCop @@ -0,0 +1,77 @@ +<StyleCopSettings Version="105"> + <GlobalSettings> + <CollectionProperty Name="RecognizedWords"> + <Value>auth</Value> + <Value>firebase</Value> + <Value>json</Value> + <Value>linq</Value> + <Value>oauth</Value> + </CollectionProperty> + </GlobalSettings> + <Analyzers> + <Analyzer AnalyzerId="StyleCop.CSharp.DocumentationRules"> + <Rules> + <Rule Name="FileMustHaveHeader"> + <RuleSettings> + <BooleanProperty Name="Enabled">False</BooleanProperty> + </RuleSettings> + </Rule> + <Rule Name="FileHeaderMustShowCopyright"> + <RuleSettings> + <BooleanProperty Name="Enabled">False</BooleanProperty> + </RuleSettings> + </Rule> + <Rule Name="FileHeaderMustHaveCopyrightText"> + <RuleSettings> + <BooleanProperty Name="Enabled">False</BooleanProperty> + </RuleSettings> + </Rule> + <Rule Name="FileHeaderMustContainFileName"> + <RuleSettings> + <BooleanProperty Name="Enabled">False</BooleanProperty> + </RuleSettings> + </Rule> + <Rule Name="FileHeaderFileNameDocumentationMustMatchFileName"> + <RuleSettings> + <BooleanProperty Name="Enabled">False</BooleanProperty> + </RuleSettings> + </Rule> + <Rule Name="FileHeaderMustHaveValidCompanyText"> + <RuleSettings> + <BooleanProperty Name="Enabled">False</BooleanProperty> + </RuleSettings> + </Rule> + <Rule Name="FileHeaderFileNameDocumentationMustMatchTypeName"> + <RuleSettings> + <BooleanProperty Name="Enabled">False</BooleanProperty> + </RuleSettings> + </Rule> + <Rule Name="DocumentationTextMustBeginWithACapitalLetter"> + <RuleSettings> + <BooleanProperty Name="Enabled">True</BooleanProperty> + </RuleSettings> + </Rule> + <Rule Name="DocumentationTextMustEndWithAPeriod"> + <RuleSettings> + <BooleanProperty Name="Enabled">True</BooleanProperty> + </RuleSettings> + </Rule> + </Rules> + <AnalyzerSettings> + <BooleanProperty Name="IgnorePrivates">True</BooleanProperty> + <BooleanProperty Name="IgnoreInternals">True</BooleanProperty> + <BooleanProperty Name="IncludeFields">False</BooleanProperty> + </AnalyzerSettings> + </Analyzer> + <Analyzer AnalyzerId="StyleCop.CSharp.ReadabilityRules"> + <Rules> + <Rule Name="DoNotUseRegions"> + <RuleSettings> + <BooleanProperty Name="Enabled">True</BooleanProperty> + </RuleSettings> + </Rule> + </Rules> + <AnalyzerSettings /> + </Analyzer> + </Analyzers> +</StyleCopSettings>
\ No newline at end of file diff --git a/FireBase/Streaming/FirebaseCache.cs b/FireBase/Streaming/FirebaseCache.cs new file mode 100644 index 0000000..ba7990b --- /dev/null +++ b/FireBase/Streaming/FirebaseCache.cs @@ -0,0 +1,192 @@ +namespace Firebase.Database.Streaming +{ + using System; + using System.Collections; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + + using Firebase.Database.Http; + + using Newtonsoft.Json; + + /// <summary> + /// The firebase cache. + /// </summary> + /// <typeparam name="T"> Type of top-level entities in the cache. </typeparam> + public class FirebaseCache<T> : IEnumerable<FirebaseObject<T>> + { + private readonly IDictionary<string, T> dictionary; + private readonly bool isDictionaryType; + private readonly JsonSerializerSettings serializerSettings = new JsonSerializerSettings() + { + ObjectCreationHandling = ObjectCreationHandling.Replace + }; + + /// <summary> + /// Initializes a new instance of the <see cref="FirebaseCache{T}"/> class. + /// </summary> + public FirebaseCache() + : this(new Dictionary<string, T>()) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="FirebaseCache{T}"/> class and populates it with existing data. + /// </summary> + /// <param name="existingItems"> The existing items. </param> + public FirebaseCache(IDictionary<string, T> existingItems) + { + this.dictionary = existingItems; + this.isDictionaryType = typeof(IDictionary).GetTypeInfo().IsAssignableFrom(typeof(T).GetTypeInfo()); + } + + /// <summary> + /// The push data. + /// </summary> + /// <param name="path"> The path of incoming data, separated by slash. </param> + /// <param name="data"> The data in json format as returned by firebase. </param> + /// <returns> Collection of top-level entities which were affected by the push. </returns> + public IEnumerable<FirebaseObject<T>> PushData(string path, string data, bool removeEmptyEntries = true) + { + object obj = this.dictionary; + Action<object> primitiveObjSetter = null; + Action objDeleter = null; + + var pathElements = path.Split(new[] { "/" }, removeEmptyEntries ? StringSplitOptions.RemoveEmptyEntries : StringSplitOptions.None); + + // first find where we should insert the data to + foreach (var element in pathElements) + { + if (obj is IDictionary) + { + // if it's a dictionary, then it's just a matter of inserting into it / accessing existing object by key + var dictionary = obj as IDictionary; + var valueType = obj.GetType().GenericTypeArguments[1]; + + primitiveObjSetter = (d) => dictionary[element] = d; + objDeleter = () => dictionary.Remove(element); + + if (dictionary.Contains(element)) + { + obj = dictionary[element]; + } + else + { + dictionary[element] = this.CreateInstance(valueType); + obj = dictionary[element]; + } + } + else + { + // if it's not a dictionary, try to find the property of current object with the matching name + var objParent = obj; + var property = objParent + .GetType() + .GetRuntimeProperties() + .First(p => p.Name.Equals(element, StringComparison.OrdinalIgnoreCase) || element == p.GetCustomAttribute<JsonPropertyAttribute>()?.PropertyName); + + objDeleter = () => property.SetValue(objParent, null); + primitiveObjSetter = (d) => property.SetValue(objParent, d); + obj = property.GetValue(obj); + if (obj == null) + { + obj = this.CreateInstance(property.PropertyType); + property.SetValue(objParent, obj); + } + } + } + + // if data is null (=empty string) delete it + if (string.IsNullOrWhiteSpace(data) || data == "null") + { + var key = pathElements[0]; + var target = this.dictionary[key]; + + objDeleter(); + + yield return new FirebaseObject<T>(key, target); + yield break; + } + + // now insert the data + if (obj is IDictionary && !this.isDictionaryType) + { + // insert data into dictionary and return it as a collection of FirebaseObject + var dictionary = obj as IDictionary; + var valueType = obj.GetType().GenericTypeArguments[1]; + var objectCollection = data.GetObjectCollection(valueType); + + foreach (var item in objectCollection) + { + dictionary[item.Key] = item.Object; + + // top level dictionary changed + if (!pathElements.Any()) + { + yield return new FirebaseObject<T>(item.Key, (T)item.Object); + } + } + + // nested dictionary changed + if (pathElements.Any()) + { + this.dictionary[pathElements[0]] = this.dictionary[pathElements[0]]; + yield return new FirebaseObject<T>(pathElements[0], this.dictionary[pathElements[0]]); + } + } + else + { + // set the data on a property of the given object + var valueType = obj.GetType(); + + // firebase sends strings without double quotes + var targetObject = valueType == typeof(string) ? data.ToString() : JsonConvert.DeserializeObject(data, valueType); + + if ((valueType.GetTypeInfo().IsPrimitive || valueType == typeof(string)) && primitiveObjSetter != null) + { + // handle primitive (value) types separately + primitiveObjSetter(targetObject); + } + else + { + JsonConvert.PopulateObject(data, obj, this.serializerSettings); + } + + this.dictionary[pathElements[0]] = this.dictionary[pathElements[0]]; + yield return new FirebaseObject<T>(pathElements[0], this.dictionary[pathElements[0]]); + } + } + + public bool Contains(string key) + { + return this.dictionary.Keys.Contains(key); + } + + private object CreateInstance(Type type) + { + if (type == typeof(string)) + { + return string.Empty; + } + else + { + return Activator.CreateInstance(type); + } + } + + #region IEnumerable + + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + public IEnumerator<FirebaseObject<T>> GetEnumerator() + { + return this.dictionary.Select(p => new FirebaseObject<T>(p.Key, p.Value)).GetEnumerator(); + } + + #endregion + } +} diff --git a/FireBase/Streaming/FirebaseEvent.cs b/FireBase/Streaming/FirebaseEvent.cs new file mode 100644 index 0000000..c2338ca --- /dev/null +++ b/FireBase/Streaming/FirebaseEvent.cs @@ -0,0 +1,40 @@ +namespace Firebase.Database.Streaming +{ + /// <summary> + /// Firebase event which hold <see cref="EventType"/> and the object affected by the event. + /// </summary> + /// <typeparam name="T"> Type of object affected by the event. </typeparam> + public class FirebaseEvent<T> : FirebaseObject<T> + { + /// <summary> + /// Initializes a new instance of the <see cref="FirebaseEvent{T}"/> class. + /// </summary> + /// <param name="key"> The key of the object. </param> + /// <param name="obj"> The object. </param> + /// <param name="eventType"> The event type. </param> + public FirebaseEvent(string key, T obj, FirebaseEventType eventType, FirebaseEventSource eventSource) + : base(key, obj) + { + this.EventType = eventType; + this.EventSource = eventSource; + } + + /// <summary> + /// Gets the source of the event. + /// </summary> + public FirebaseEventSource EventSource + { + get; + } + + /// <summary> + /// Gets the event type. + /// </summary> + public FirebaseEventType EventType + { + get; + } + + public static FirebaseEvent<T> Empty(FirebaseEventSource source) => new FirebaseEvent<T>(string.Empty, default(T), FirebaseEventType.InsertOrUpdate, source); + } +} diff --git a/FireBase/Streaming/FirebaseEventSource.cs b/FireBase/Streaming/FirebaseEventSource.cs new file mode 100644 index 0000000..98df977 --- /dev/null +++ b/FireBase/Streaming/FirebaseEventSource.cs @@ -0,0 +1,38 @@ +namespace Firebase.Database.Streaming +{ + /// <summary> + /// Specifies the origin of given <see cref="FirebaseEvent{T}"/> + /// </summary> + public enum FirebaseEventSource + { + /// <summary> + /// Event comes from an offline source. + /// </summary> + Offline, + + /// <summary> + /// Event comes from online source fetched during initial pull (valid only for RealtimeDatabase). + /// </summary> + OnlineInitial, + + /// <summary> + /// Event comes from online source received thru active stream. + /// </summary> + OnlineStream, + + /// <summary> + /// Event comes from online source being fetched manually. + /// </summary> + OnlinePull, + + /// <summary> + /// Event raised after successful online push (valid only for RealtimeDatabase which isn't streaming). + /// </summary> + OnlinePush, + + /// <summary> + /// Event comes from an online source. + /// </summary> + Online = OnlineInitial | OnlinePull | OnlinePush | OnlineStream + } +} diff --git a/FireBase/Streaming/FirebaseEventType.cs b/FireBase/Streaming/FirebaseEventType.cs new file mode 100644 index 0000000..5fb21ef --- /dev/null +++ b/FireBase/Streaming/FirebaseEventType.cs @@ -0,0 +1,18 @@ +namespace Firebase.Database.Streaming +{ + /// <summary> + /// The type of event. + /// </summary> + public enum FirebaseEventType + { + /// <summary> + /// Item was inserted or updated. + /// </summary> + InsertOrUpdate, + + /// <summary> + /// Item was deleted. + /// </summary> + Delete + } +} diff --git a/FireBase/Streaming/FirebaseServerEventType.cs b/FireBase/Streaming/FirebaseServerEventType.cs new file mode 100644 index 0000000..1f10bc8 --- /dev/null +++ b/FireBase/Streaming/FirebaseServerEventType.cs @@ -0,0 +1,15 @@ +namespace Firebase.Database.Streaming +{ + internal enum FirebaseServerEventType + { + Put, + + Patch, + + KeepAlive, + + Cancel, + + AuthRevoked + } +} diff --git a/FireBase/Streaming/FirebaseSubscription.cs b/FireBase/Streaming/FirebaseSubscription.cs new file mode 100644 index 0000000..4b5e643 --- /dev/null +++ b/FireBase/Streaming/FirebaseSubscription.cs @@ -0,0 +1,221 @@ +namespace Firebase.Database.Streaming +{ + using System; + using System.Diagnostics; + using System.Linq; + using System.Net.Http; + using System.Net.Http.Headers; + using System.Threading; + using System.Threading.Tasks; + + using Firebase.Database.Query; + + using Newtonsoft.Json.Linq; + using System.Net; + + /// <summary> + /// The firebase subscription. + /// </summary> + /// <typeparam name="T"> Type of object to be streaming back to the called. </typeparam> + internal class FirebaseSubscription<T> : IDisposable + { + private readonly CancellationTokenSource cancel; + private readonly IObserver<FirebaseEvent<T>> observer; + private readonly IFirebaseQuery query; + private readonly FirebaseCache<T> cache; + private readonly string elementRoot; + private readonly FirebaseClient client; + + private static HttpClient http; + + static FirebaseSubscription() + { + var handler = new HttpClientHandler + { + AllowAutoRedirect = true, + MaxAutomaticRedirections = 10, + CookieContainer = new CookieContainer() + }; + + var httpClient = new HttpClient(handler, true); + + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + + http = httpClient; + } + + /// <summary> + /// Initializes a new instance of the <see cref="FirebaseSubscription{T}"/> class. + /// </summary> + /// <param name="observer"> The observer. </param> + /// <param name="query"> The query. </param> + /// <param name="cache"> The cache. </param> + public FirebaseSubscription(IObserver<FirebaseEvent<T>> observer, IFirebaseQuery query, string elementRoot, FirebaseCache<T> cache) + { + this.observer = observer; + this.query = query; + this.elementRoot = elementRoot; + this.cancel = new CancellationTokenSource(); + this.cache = cache; + this.client = query.Client; + } + + public event EventHandler<ExceptionEventArgs<FirebaseException>> ExceptionThrown; + + public void Dispose() + { + this.cancel.Cancel(); + } + + public IDisposable Run() + { + Task.Run(() => this.ReceiveThread()); + + return this; + } + + private async void ReceiveThread() + { + while (true) + { + var url = string.Empty; + var line = string.Empty; + var statusCode = HttpStatusCode.OK; + + try + { + this.cancel.Token.ThrowIfCancellationRequested(); + + // initialize network connection + url = await this.query.BuildUrlAsync().ConfigureAwait(false); + var request = new HttpRequestMessage(HttpMethod.Get, url); + var serverEvent = FirebaseServerEventType.KeepAlive; + + var client = this.GetHttpClient(); + var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, this.cancel.Token).ConfigureAwait(false); + + statusCode = response.StatusCode; + response.EnsureSuccessStatusCode(); + + using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) + using (var reader = this.client.Options.SubscriptionStreamReaderFactory(stream)) + { + while (true) + { + this.cancel.Token.ThrowIfCancellationRequested(); + + line = reader.ReadLine()?.Trim(); + + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + var tuple = line.Split(new[] { ':' }, 2, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); + + switch (tuple[0].ToLower()) + { + case "event": + serverEvent = this.ParseServerEvent(serverEvent, tuple[1]); + break; + case "data": + this.ProcessServerData(url, serverEvent, tuple[1]); + break; + } + + if (serverEvent == FirebaseServerEventType.AuthRevoked) + { + // auth token no longer valid, reconnect + break; + } + } + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) when (statusCode != HttpStatusCode.OK) + { + this.observer.OnError(new FirebaseException(url, string.Empty, line, statusCode, ex)); + this.Dispose(); + break; + } + catch (Exception ex) + { + this.ExceptionThrown?.Invoke(this, new ExceptionEventArgs<FirebaseException>(new FirebaseException(url, string.Empty, line, statusCode, ex))); + + await Task.Delay(2000).ConfigureAwait(false); + } + } + } + + private FirebaseServerEventType ParseServerEvent(FirebaseServerEventType serverEvent, string eventName) + { + switch (eventName) + { + case "put": + serverEvent = FirebaseServerEventType.Put; + break; + case "patch": + serverEvent = FirebaseServerEventType.Patch; + break; + case "keep-alive": + serverEvent = FirebaseServerEventType.KeepAlive; + break; + case "cancel": + serverEvent = FirebaseServerEventType.Cancel; + break; + case "auth_revoked": + serverEvent = FirebaseServerEventType.AuthRevoked; + break; + } + + return serverEvent; + } + + private void ProcessServerData(string url, FirebaseServerEventType serverEvent, string serverData) + { + switch (serverEvent) + { + case FirebaseServerEventType.Put: + case FirebaseServerEventType.Patch: + var result = JObject.Parse(serverData); + var path = result["path"].ToString(); + var data = result["data"].ToString(); + + // If an elementRoot parameter is provided, but it's not in the cache, it was already deleted. So we can return an empty object. + if(string.IsNullOrWhiteSpace(this.elementRoot) || !this.cache.Contains(this.elementRoot)) + { + if(path == "/" && data == string.Empty) + { + this.observer.OnNext(FirebaseEvent<T>.Empty(FirebaseEventSource.OnlineStream)); + return; + } + } + + var eventType = string.IsNullOrWhiteSpace(data) ? FirebaseEventType.Delete : FirebaseEventType.InsertOrUpdate; + + var items = this.cache.PushData(this.elementRoot + path, data); + + foreach (var i in items.ToList()) + { + this.observer.OnNext(new FirebaseEvent<T>(i.Key, i.Object, eventType, FirebaseEventSource.OnlineStream)); + } + + break; + case FirebaseServerEventType.KeepAlive: + break; + case FirebaseServerEventType.Cancel: + this.observer.OnError(new FirebaseException(url, string.Empty, serverData, HttpStatusCode.Unauthorized)); + this.Dispose(); + break; + } + } + + private HttpClient GetHttpClient() + { + return http; + } + } +} diff --git a/FireBase/Streaming/NonBlockingStreamReader.cs b/FireBase/Streaming/NonBlockingStreamReader.cs new file mode 100644 index 0000000..2ac83fd --- /dev/null +++ b/FireBase/Streaming/NonBlockingStreamReader.cs @@ -0,0 +1,60 @@ +namespace Firebase.Database.Streaming +{ + using System.IO; + using System.Text; + + /// <summary> + /// When a regular <see cref="StreamReader"/> is used in a UWP app its <see cref="StreamReader.ReadLine"/> method tends to take a long + /// time for data larger then 2 KB. This extremly simple implementation of <see cref="TextReader"/> can be used instead to boost performance + /// in your UWP app. Use <see cref="FirebaseOptions"/> to inject an instance of this class into your <see cref="FirebaseClient"/>. + /// </summary> + public class NonBlockingStreamReader : TextReader + { + private const int DefaultBufferSize = 16000; + + private readonly Stream stream; + private readonly byte[] buffer; + private readonly int bufferSize; + + private string cachedData; + + public NonBlockingStreamReader(Stream stream, int bufferSize = DefaultBufferSize) + { + this.stream = stream; + this.bufferSize = bufferSize; + this.buffer = new byte[bufferSize]; + + this.cachedData = string.Empty; + } + + public override string ReadLine() + { + var currentString = this.TryGetNewLine(); + + while (currentString == null) + { + var read = this.stream.Read(this.buffer, 0, this.bufferSize); + var str = Encoding.UTF8.GetString(buffer, 0, read); + + cachedData += str; + currentString = this.TryGetNewLine(); + } + + return currentString; + } + + private string TryGetNewLine() + { + var newLine = cachedData.IndexOf('\n'); + + if (newLine >= 0) + { + var r = cachedData.Substring(0, newLine + 1); + this.cachedData = cachedData.Remove(0, r.Length); + return r.Trim(); + } + + return null; + } + } +} |