// IshedDB for .NET, written as MongoDB's C# driver is: // var patients = new IshedClient(url, key).GetDatabase("clinique").GetCollection("patients"); // var dakar = patients.Find(new Document { ["ville"] = "Dakar" }).Sort(new Document { ["age"] = -1 }).ToList(); // Only .NET's own libraries. The key never travels but over HTTPS (or to this machine). Values: DateTime <-> dates, // long beyond 2**53 <-> big integers, byte[] <-> bytes. using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; namespace Ished; /// A document, a filter, a sort or an update — as MongoDB's BsonDocument (its keys keep their order). public sealed class Document : Dictionary { public Document() { } public Document(IDictionary other) : base(other) { } } /// A refusal from IshedDB: its code (invalid-argument, not-found…) and message. public sealed class IshedException(string code, string message, int? status = null) : Exception($"{code}: {message}") { public string Code { get; } = code; public string Reason { get; } = message; public int? Status { get; } = status; } public sealed record UpdateResult(long MatchedCount, long ModifiedCount, string? UpsertedId); public sealed record DeleteResult(long DeletedCount); public sealed record BulkWriteResult(long InsertedCount, long MatchedCount, long ModifiedCount, long DeletedCount, long UpsertedCount); /// One operation of BulkWrite, as the driver's WriteModel. public abstract class WriteModel { internal abstract Document Operation(); } public sealed class InsertOneModel(Document document) : WriteModel { internal override Document Operation() => new() { ["insertOne"] = new Document { ["document"] = document } }; } public sealed class UpdateOneModel(Document filter, Document update) : WriteModel { public bool IsUpsert { get; init; } internal override Document Operation() => new() { ["updateOne"] = new Document { ["filter"] = filter, ["update"] = update, ["upsert"] = IsUpsert } }; } public sealed class UpdateManyModel(Document filter, Document update) : WriteModel { internal override Document Operation() => new() { ["updateMany"] = new Document { ["filter"] = filter, ["update"] = update } }; } public sealed class ReplaceOneModel(Document filter, Document replacement) : WriteModel { public bool IsUpsert { get; init; } internal override Document Operation() => new() { ["replaceOne"] = new Document { ["filter"] = filter, ["replacement"] = replacement, ["upsert"] = IsUpsert } }; } public sealed class DeleteOneModel(Document filter) : WriteModel { internal override Document Operation() => new() { ["deleteOne"] = new Document { ["filter"] = filter } }; } public sealed class DeleteManyModel(Document filter) : WriteModel { internal override Document Operation() => new() { ["deleteMany"] = new Document { ["filter"] = filter } }; } public sealed class UpdateOptions { public bool IsUpsert { get; set; } } internal static class Wire { private const long SafeInt = (1L << 53) - 1; public static void Write(Utf8JsonWriter w, object? v) { switch (v) { case null: w.WriteNullValue(); break; case string s: w.WriteStringValue(s); break; case bool b: w.WriteBooleanValue(b); break; case DateTime d: w.WriteStartObject(); w.WriteString("~date", d.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'")); w.WriteEndObject(); break; case DateTimeOffset o: Write(w, o.UtcDateTime); break; case byte[] bytes: w.WriteStartObject(); w.WriteString("~bytes", Convert.ToBase64String(bytes)); w.WriteEndObject(); break; case int i: w.WriteNumberValue(i); break; case long l when Math.Abs(l) <= SafeInt: w.WriteNumberValue(l); break; case long l: w.WriteStartObject(); w.WriteString("~int", l.ToString()); w.WriteEndObject(); break; case double f: w.WriteNumberValue(f); break; case decimal m: w.WriteNumberValue(m); break; case IDictionary map: w.WriteStartObject(); foreach (var (k, x) in map) { w.WritePropertyName(k); Write(w, x); } w.WriteEndObject(); break; case System.Collections.IEnumerable list: w.WriteStartArray(); foreach (var x in list) Write(w, x); w.WriteEndArray(); break; default: throw new IshedException("invalid-argument", $"a value of type {v.GetType().Name} cannot be kept"); } } public static string Json(object? v) { using var stream = new MemoryStream(); using (var w = new Utf8JsonWriter(stream)) Write(w, v); return Encoding.UTF8.GetString(stream.ToArray()); } public static object? Read(JsonElement e) => e.ValueKind switch { JsonValueKind.Object => ReadObject(e), JsonValueKind.Array => e.EnumerateArray().Select(Read).ToList(), JsonValueKind.String => e.GetString(), JsonValueKind.Number => e.TryGetInt64(out var l) ? (object)l : e.GetDouble(), JsonValueKind.True => true, JsonValueKind.False => false, _ => null, }; private static object? ReadObject(JsonElement e) { var props = e.EnumerateObject().ToList(); if (props.Count == 1) { var (k, v) = (props[0].Name, props[0].Value); if (k == "~date") return DateTime.Parse(v.GetString()!, null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal); if (k == "~int") return long.Parse(v.GetString()!); if (k == "~bytes") return Convert.FromBase64String(v.GetString()!); } var doc = new Document(); foreach (var p in props) doc[p.Name] = Read(p.Value); return doc; } } /// As the driver's IFindFluent: Sort, Limit, Skip, Project, then ToList or FirstOrDefault. Read 1000 at a time. public sealed class FindFluent { public const int MaxDocuments = 100_000; private readonly IshedCollection collection; private readonly Document filter; private Document? sort, projection; private int limit, skip; internal FindFluent(IshedCollection collection, Document filter) { this.collection = collection; this.filter = filter; } public FindFluent Sort(Document s) { sort = s; return this; } public FindFluent Limit(int n) { limit = n; return this; } public FindFluent Skip(int n) { skip = n; return this; } public FindFluent Project(Document p) { projection = p; return this; } public Document? FirstOrDefault() { limit = 1; return ToList().FirstOrDefault(); } public List ToList() { var output = new List(); string? token = null; while (true) { var want = limit > 0 ? Math.Min(1000, limit - output.Count) : 1000; var body = new Document { ["filter"] = filter, ["limit"] = want }; // The next page starts after the last one read (its token), never at an offset that moves with writes. if (token is not null) body["pageToken"] = token; else body["skip"] = skip; if (sort is not null) body["sort"] = sort; if (projection is not null) body["projection"] = projection; var page = collection.Call("find", body); output.AddRange(((List)page["documents"]!).Cast()); token = page.TryGetValue("nextPageToken", out var next) ? next as string : null; if (token is null || (limit > 0 && output.Count >= limit)) return output; if (output.Count >= MaxDocuments) throw new IshedException("resource-exhausted", $"more than {MaxDocuments} documents: narrow the filter"); } } } public sealed class IshedCollection { private readonly IshedClient client; private readonly string path; private readonly ClientSessionHandle? session; internal IshedCollection(IshedClient client, string path, ClientSessionHandle? session = null) { this.client = client; this.path = path; this.session = session; } internal Document Call(string method, Document body) => (Document)client.Send(HttpMethod.Post, $"{path}:{method}", body, session?.Transaction)!; /// As the driver's Drop(): the collection and its documents, or the view. public void Drop() => Call("drop", new Document()); // The driver's overloads, session first: the request runs in the session's transaction. private IshedCollection In(ClientSessionHandle s) => new(client, path, s); public FindFluent Find(ClientSessionHandle session, Document filter) => In(session).Find(filter); public long CountDocuments(ClientSessionHandle session, Document filter) => In(session).CountDocuments(filter); public List Aggregate(ClientSessionHandle session, IEnumerable pipeline) => In(session).Aggregate(pipeline); public BulkWriteResult BulkWrite(ClientSessionHandle session, IEnumerable requests, bool isOrdered = true) => In(session).BulkWrite(requests, isOrdered); public List Distinct(ClientSessionHandle session, string field, Document? filter = null) => In(session).Distinct(field, filter); public void InsertOne(ClientSessionHandle session, Document document) => In(session).InsertOne(document); public void InsertMany(ClientSessionHandle session, IEnumerable documents) => In(session).InsertMany(documents); public UpdateResult UpdateOne(ClientSessionHandle session, Document filter, Document update, UpdateOptions? options = null) => In(session).UpdateOne(filter, update, options); public UpdateResult UpdateMany(ClientSessionHandle session, Document filter, Document update) => In(session).UpdateMany(filter, update); public UpdateResult ReplaceOne(ClientSessionHandle session, Document filter, Document replacement, UpdateOptions? options = null) => In(session).ReplaceOne(filter, replacement, options); public DeleteResult DeleteOne(ClientSessionHandle session, Document filter) => In(session).DeleteOne(filter); public DeleteResult DeleteMany(ClientSessionHandle session, Document filter) => In(session).DeleteMany(filter); /// As the driver's Watch: the collection's changes as they happen. public ChangeStreamCursor Watch(IEnumerable? pipeline = null, ChangeStreamOptions? options = null) => new(client, $"{path}:watch", pipeline, options); /// As the driver's collection.Indexes. public IndexManager Indexes => new(this); public FindFluent Find(Document filter) => new(this, filter); public long CountDocuments(Document filter) => (long)Call("countDocuments", new Document { ["filter"] = filter })["count"]!; /// As the driver's Aggregate: the pipeline run by the server; every result, read 1000 at a time. public List Aggregate(IEnumerable pipeline) { var output = new List(); var stages = pipeline.Cast().ToList(); for (var skip = 0; ; skip += 1000) { var page = Call("aggregate", new Document { ["pipeline"] = stages, ["limit"] = 1000, ["skip"] = skip }); output.AddRange(((List)page["documents"]!).Cast()); if (!(bool)page["more"]!) return output; if (output.Count >= FindFluent.MaxDocuments) throw new IshedException("resource-exhausted", $"more than {FindFluent.MaxDocuments} documents: add $limit"); } } /// As the driver's BulkWrite: the operations in order; isOrdered false goes on past a refusal. public BulkWriteResult BulkWrite(IEnumerable requests, bool isOrdered = true) { var r = Call("bulkWrite", new Document { ["operations"] = requests.Select(m => (object?)m.Operation()).ToList(), ["ordered"] = isOrdered }); return new BulkWriteResult((long)r["insertedCount"]!, (long)r["matchedCount"]!, (long)r["modifiedCount"]!, (long)r["deletedCount"]!, (long)r["upsertedCount"]!); } /// The collection's number of documents, from the statistics (at once on a big collection). public long EstimatedDocumentCount() => (long)Call("estimatedDocumentCount", new Document())["count"]!; /// The different values of a field among the documents found (a list's items one by one). public List Distinct(string field, Document? filter = null) => (List)Call("distinct", new Document { ["key"] = field, ["filter"] = filter ?? new Document() })["values"]!; /// Adds a document; as the driver does, its "_id" is set in it when it had none. public void InsertOne(Document document) => document["_id"] = (string)Call("insertOne", new Document { ["document"] = document })["insertedId"]!; public void InsertMany(IEnumerable documents) { foreach (var chunk in documents.Chunk(1000)) { var ids = (Document)Call("insertMany", new Document { ["documents"] = chunk.ToList() })["insertedIds"]!; for (var i = 0; i < chunk.Length; i++) chunk[i]["_id"] = (string)ids[i.ToString()]!; } } private UpdateResult Change(string method, Document filter, string key, Document value, bool? upsert) { var body = new Document { ["filter"] = filter, [key] = value }; if (upsert is not null) body["upsert"] = upsert; var r = Call(method, body); return new UpdateResult((long)r["matchedCount"]!, (long)r["modifiedCount"]!, r.GetValueOrDefault("upsertedId") as string); } public UpdateResult UpdateOne(Document filter, Document update, UpdateOptions? options = null) => Change("updateOne", filter, "update", update, options?.IsUpsert ?? false); public UpdateResult UpdateMany(Document filter, Document update) => Change("updateMany", filter, "update", update, null); public UpdateResult ReplaceOne(Document filter, Document replacement, UpdateOptions? options = null) => Change("replaceOne", filter, "replacement", replacement, options?.IsUpsert ?? false); public DeleteResult DeleteOne(Document filter) => new((long)Call("deleteOne", new Document { ["filter"] = filter })["deletedCount"]!); public DeleteResult DeleteMany(Document filter) => new((long)Call("deleteMany", new Document { ["filter"] = filter })["deletedCount"]!); } /// /// A session, as the driver's: using var s = client.StartSession(); s.WithTransaction((s, ct) => coll.UpdateOne(s, f, u)); /// The server holds the transaction (60 seconds at most, 20 without a request); a conflict with another transaction /// answers "aborted", and WithTransaction runs the callback again (5 times at most). /// public sealed class ClientSessionHandle : IDisposable { private readonly IshedClient client; private readonly string v1; internal ClientSessionHandle(IshedClient client, string v1) { this.client = client; this.v1 = v1; } internal string? Transaction { get; private set; } public bool IsInTransaction => Transaction is not null; public void StartTransaction() { if (Transaction is not null) throw new IshedException("failed-precondition", "a transaction is already started in this session"); Transaction = (string)((Document)client.Send(HttpMethod.Post, $"{v1}:beginTransaction", new Document())!)["transaction"]!; } public void CommitTransaction() { var tx = Transaction ?? throw new IshedException("failed-precondition", "no transaction started in this session"); Transaction = null; client.Send(HttpMethod.Post, $"{v1}:commit", new Document { ["transaction"] = tx }); } public void AbortTransaction() { if (Transaction is not { } tx) return; Transaction = null; try { client.Send(HttpMethod.Post, $"{v1}:rollback", new Document { ["transaction"] = tx }); } catch (IshedException e) when (e.Code == "not-found") { // already ended by the server (an error, or too long): nothing kept } } public TResult WithTransaction(Func callback, CancellationToken cancellationToken = default) { for (var attempt = 1; ; attempt++) { StartTransaction(); try { var result = callback(this, cancellationToken); CommitTransaction(); return result; } catch (Exception e) { try { AbortTransaction(); } catch (IshedException) { /* the first error is the one given back */ } if (e is not IshedException { Code: "aborted" } || attempt >= 5) throw; } } } public void Dispose() => AbortTransaction(); } /// What an update change carries, as the driver's option: nothing more, or the document as it is now. public enum ChangeStreamFullDocumentOption { Default, UpdateLookup, WhenAvailable, Required } /// The document before each change, as the driver's option (kept when the collection has /// changeStreamPreAndPostImages). public enum ChangeStreamFullDocumentBeforeChangeOption { Default, Off, WhenAvailable, Required } /// The kind of a change, as the driver's ChangeStreamOperationType. public enum ChangeStreamOperationType { Insert, Update, Replace, Delete, Invalidate, Rename, Drop, DropDatabase, Other } /// The driver's options: FullDocument, ResumeAfter or StartAfter (a resume token), StartAtOperationTime, /// MaxAwaitTime (10 s by default, 20 s at most), BatchSize. public sealed class ChangeStreamOptions { public ChangeStreamFullDocumentOption FullDocument { get; set; } public ChangeStreamFullDocumentBeforeChangeOption FullDocumentBeforeChange { get; set; } public Document? ResumeAfter { get; set; } public Document? StartAfter { get; set; } public DateTime? StartAtOperationTime { get; set; } public TimeSpan? MaxAwaitTime { get; set; } public int? BatchSize { get; set; } } /// What an update changed: the fields set (dotted paths) and the fields removed. public sealed record ChangeStreamUpdateDescription(Document UpdatedFields, string[] RemovedFields); /// A change, as the driver's ChangeStreamDocument. public sealed class ChangeStreamDocument where TDocument : class { private ChangeStreamDocument(Document raw, TDocument? fullDocument) { BackingDocument = raw; FullDocument = fullDocument; } internal static ChangeStreamDocument Of(Document raw) => new(raw, raw.GetValueOrDefault("fullDocument") as Document); /// The change as the server gave it. public Document BackingDocument { get; } public TDocument? FullDocument { get; } /// The document before the change (fullDocumentBeforeChange), or null. public Document? FullDocumentBeforeChange => BackingDocument.GetValueOrDefault("fullDocumentBeforeChange") as Document; public ChangeStreamOperationType OperationType => (BackingDocument["operationType"] as string) switch { "insert" => ChangeStreamOperationType.Insert, "update" => ChangeStreamOperationType.Update, "replace" => ChangeStreamOperationType.Replace, "delete" => ChangeStreamOperationType.Delete, "invalidate" => ChangeStreamOperationType.Invalidate, "rename" => ChangeStreamOperationType.Rename, "drop" => ChangeStreamOperationType.Drop, "dropDatabase" => ChangeStreamOperationType.DropDatabase, _ => ChangeStreamOperationType.Other, }; public Document? DocumentKey => BackingDocument.GetValueOrDefault("documentKey") as Document; /// The token to resume after this change (ResumeAfter). public Document ResumeToken => (Document)BackingDocument["_id"]!; public DateTime? WallTime => BackingDocument.GetValueOrDefault("wallTime") as DateTime?; public string? DatabaseName => (BackingDocument.GetValueOrDefault("ns") as Document)?.GetValueOrDefault("db") as string; public string? CollectionName => (BackingDocument.GetValueOrDefault("ns") as Document)?.GetValueOrDefault("coll") as string; /// Where a renamed collection went. public string? RenameTo => (BackingDocument.GetValueOrDefault("to") as Document)?.GetValueOrDefault("coll") as string; public ChangeStreamUpdateDescription? UpdateDescription => BackingDocument.GetValueOrDefault("updateDescription") is Document u ? new((Document)u["updatedFields"]!, ((List)u["removedFields"]!).Cast().ToArray()) : null; } /// /// An open change stream, as the driver's cursor: foreach (var change in cursor.ToEnumerable()) { … } (waits for the /// changes), or MoveNext() and Current (one wait, its batch possibly empty); GetResumeToken(). Opened at once: the /// changes made from then on are seen. A collection dropped or renamed ends it ("invalidate"). /// public sealed class ChangeStreamCursor : IDisposable { private readonly IshedClient client; private readonly string path; private readonly Document body; private readonly long waitMs; private Document? position; private DateTime? at; private Document? token; private bool closed; private readonly Queue buffer = new(); internal ChangeStreamCursor(IshedClient client, string path, IEnumerable? pipeline, ChangeStreamOptions? options) { this.client = client; this.path = path; body = new Document { ["pipeline"] = (pipeline ?? []).Select(d => (object?)d).ToList() }; if (options?.FullDocument is { } fd && fd != ChangeStreamFullDocumentOption.Default) body["fullDocument"] = fd switch { ChangeStreamFullDocumentOption.UpdateLookup => "updateLookup", ChangeStreamFullDocumentOption.WhenAvailable => "whenAvailable", _ => "required" }; if (options?.FullDocumentBeforeChange is { } fb && fb != ChangeStreamFullDocumentBeforeChangeOption.Default) body["fullDocumentBeforeChange"] = fb switch { ChangeStreamFullDocumentBeforeChangeOption.Off => "off", ChangeStreamFullDocumentBeforeChangeOption.WhenAvailable => "whenAvailable", _ => "required" }; if (options?.BatchSize is { } n) body["batchSize"] = n; waitMs = (long)(options?.MaxAwaitTime ?? TimeSpan.FromSeconds(10)).TotalMilliseconds; position = options?.ResumeAfter ?? options?.StartAfter; at = options?.StartAtOperationTime; token = position; Current = []; if (position is null && at is null) Fetch(0); } /// The batch the last MoveNext gave. public IEnumerable> Current { get; private set; } private void Fetch(long wait) { var request = new Document(body) { ["maxAwaitTimeMS"] = wait }; if (position is not null) request["resumeAfter"] = position; else if (at is not null) request["startAtOperationTime"] = at.Value; var r = (Document)client.Send(HttpMethod.Post, path, request)!; at = null; position = (Document)r["resumeToken"]!; foreach (var c in ((List)r["changes"]!).Cast()) buffer.Enqueue(c); if (buffer.Count == 0) token = position; } // One change: the resume token follows the changes as they are given. private ChangeStreamDocument Take() { var change = buffer.Dequeue(); token = buffer.Count > 0 ? (Document)change["_id"]! : position; if (change["operationType"] as string == "invalidate") closed = true; return ChangeStreamDocument.Of(change); } /// Waits up to MaxAwaitTime for changes (Current); false once the stream is closed. public bool MoveNext() { if (closed && buffer.Count == 0) return false; if (buffer.Count == 0) Fetch(waitMs); var batch = new List>(); while (buffer.Count > 0) batch.Add(Take()); Current = batch; return true; } /// Every change as it comes, waiting for it, until the stream is closed. public IEnumerable> ToEnumerable() { for (;;) { while (buffer.Count == 0) { if (closed) yield break; Fetch(waitMs); } yield return Take(); } } /// The token to resume after the last change given (ResumeAfter). public Document? GetResumeToken() => token; public void Dispose() => closed = true; } /// As the driver's DocumentValidationLevel: which writes a collection's validator checks. public enum DocumentValidationLevel { Strict, Moderate, Off } /// As the driver's DocumentValidationAction: a document that breaks the validator refused or written. public enum DocumentValidationAction { Error, Warn } /// As the driver's CreateCollectionOptions: the validator ({ $jsonSchema: … }), its level and its action. public sealed class CreateCollectionOptions { public Document? Validator { get; set; } public DocumentValidationLevel? ValidationLevel { get; set; } public DocumentValidationAction? ValidationAction { get; set; } } /// An index's options, as the driver's CreateIndexOptions. public sealed class CreateIndexOptions { public bool? Unique { get; set; } public string? Name { get; set; } public Document? PartialFilterExpression { get; set; } public Document? Weights { get; set; } public string? DefaultLanguage { get; set; } /// A wildcard index's fields ({ attrs: 1 } only those, { secret: 0 } all but those). public Document? WildcardProjection { get; set; } } /// An index to make, as the driver's CreateIndexModel: its keys (in order) and options. public sealed class CreateIndexModel(Document keys, CreateIndexOptions? options = null) { internal Document Spec() { var spec = new Document { ["key"] = keys }; if (options?.Unique == true) spec["unique"] = true; if (options?.Name is { } n) spec["name"] = n; if (options?.PartialFilterExpression is { } f) spec["partialFilterExpression"] = f; if (options?.Weights is { } w) spec["weights"] = w; if (options?.DefaultLanguage is { } l) spec["default_language"] = l; if (options?.WildcardProjection is { } wp) spec["wildcardProjection"] = wp; return spec; } } /// The collection's indexes, as the driver's collection.Indexes: CreateOne, CreateMany, List, DropOne, DropAll. public sealed class IndexManager { private readonly IshedCollection collection; internal IndexManager(IshedCollection collection) { this.collection = collection; } public string CreateOne(CreateIndexModel model) => (string)collection.Call("createIndex", model.Spec())["name"]!; public IEnumerable CreateMany(IEnumerable models) => ((List)collection.Call("createIndexes", new Document { ["indexes"] = models.Select(m => (object?)m.Spec()).ToList() })["names"]!).Cast().ToList(); /// The indexes, as MongoDB gives them (v, key, name, unique…). public List List() => ((List)collection.Call("listIndexes", new Document())["indexes"]!).Cast().ToList(); public void DropOne(string name) => collection.Call("dropIndex", new Document { ["index"] = name }); public void DropAll() => collection.Call("dropIndexes", new Document()); } public sealed class IshedDatabase { private readonly IshedClient client; private readonly string path; internal IshedDatabase(IshedClient client, string path) { this.client = client; this.path = path; } /// As the driver's Watch: the changes of every collection of the base. public ChangeStreamCursor Watch(IEnumerable? pipeline = null, ChangeStreamOptions? options = null) => new(client, $"{path}:watch", pipeline, options); public IshedCollection GetCollection(string name) => new(client, $"{path}/collections/{Uri.EscapeDataString(name)}"); public List ListCollectionNames() => ((List)client.Send(HttpMethod.Get, $"{path}/collections", null)!).Cast().Select(c => (string)c["name"]!).ToList(); /// As the driver's CreateView(viewName, viewOn, pipeline): read only, computed when read. public void CreateView(string viewName, string viewOn, IEnumerable pipeline) => client.Send(HttpMethod.Post, $"{path}/collections/{Uri.EscapeDataString(viewName)}:create", new Document { ["viewOn"] = viewOn, ["pipeline"] = pipeline.Cast().ToList() }); /// As the driver's CreateCollection(name): an empty collection. public void CreateCollection(string name, CreateCollectionOptions? options = null) { var body = new Document(); if (options?.Validator is { } v) body["validator"] = v; if (options?.ValidationLevel is { } l) body["validationLevel"] = l.ToString().ToLowerInvariant(); if (options?.ValidationAction is { } a) body["validationAction"] = a.ToString().ToLowerInvariant(); client.Send(HttpMethod.Post, $"{path}/collections/{Uri.EscapeDataString(name)}:create", body); } /// As the driver's DropCollection(name): the collection and its documents, or the view. public void DropCollection(string name) => client.Send(HttpMethod.Post, $"{path}/collections/{Uri.EscapeDataString(name)}:drop", new Document()); /// As the driver's RunCommand: create, collMod (a view's viewOn and pipeline), drop, explain. public Document RunCommand(Document command) => (Document)client.Send(HttpMethod.Post, $"{path}:runCommand", command)!; /// As the driver's ListCollections(): name, type ("collection" or "view"), options, info. public List ListCollections() => ((List)client.Send(HttpMethod.Get, $"{path}/collections", null)!).Cast().Select(c => { var view = (string)c["type"]! == "view"; return new Document { ["name"] = c["name"], ["type"] = c["type"], ["options"] = view ? new Document { ["viewOn"] = c["viewOn"], ["pipeline"] = c["pipeline"] } : new Document(), ["info"] = new Document { ["readOnly"] = view }, }; }).ToList(); } /// A client for one project (its key), in one of its clusters (by default the first, "Cluster0"). public sealed partial class IshedClient { private readonly HttpClient http; private readonly string root, v1; public IshedClient(string url, string key, string? cluster = null, TimeSpan? timeout = null) { var uri = new Uri(url); var local = uri.Host is "127.0.0.1" or "localhost" or "[::1]" or "::1"; if (uri.Scheme != "https" && !(uri.Scheme == "http" && local)) throw new IshedException("invalid-argument", "IshedDB is reached over HTTPS (plain HTTP only to this machine)"); if (!key.StartsWith("ished_sk_", StringComparison.Ordinal)) throw new IshedException("invalid-argument", "a key ished_sk_… is needed"); if (cluster is not null && !ClusterName().IsMatch(cluster)) throw new IshedException("invalid-argument", "cluster: 1 to 64 letters, digits or \"-\""); root = url.TrimEnd('/'); v1 = cluster is null ? "/v1" : $"/v1/clusters/{cluster}"; http = new HttpClient { Timeout = timeout ?? TimeSpan.FromSeconds(30) }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", key); } [GeneratedRegex("^[A-Za-z0-9][A-Za-z0-9-]{0,63}$")] private static partial Regex ClusterName(); public IshedDatabase GetDatabase(string name) => new(this, $"{v1}/bases/{Uri.EscapeDataString(name)}"); /// As the driver's Watch: the changes of the whole cluster. public ChangeStreamCursor Watch(IEnumerable? pipeline = null, ChangeStreamOptions? options = null) => new(this, $"{v1}:watch", pipeline, options); /// A session, as the driver's: its transactions are held by the server. public ClientSessionHandle StartSession() => new(this, v1); /// As the driver's ListDatabases(): each base's name, sizeOnDisk and empty (a key limited to some: those). public List ListDatabases() => ((List)((Document)Send(HttpMethod.Get, $"{v1}/bases", null)!)["databases"]!).Cast().ToList(); /// As the driver's ListDatabaseNames(). public List ListDatabaseNames() => ListDatabases().Select(d => (string)d["name"]!).ToList(); internal object? Send(HttpMethod method, string path, Document? body, string? transaction = null) { using var request = new HttpRequestMessage(method, root + path); if (transaction is not null) request.Headers.Add("X-Ished-Transaction", transaction); if (body is not null) request.Content = new StringContent(Wire.Json(body), Encoding.UTF8, "application/json"); using var response = http.Send(request); var text = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); object? parsed = null; if (text.Length > 0) { using var json = JsonDocument.Parse(text); parsed = Wire.Read(json.RootElement); } if (!response.IsSuccessStatusCode) { var error = (parsed as Document)?.GetValueOrDefault("error") as Document; throw new IshedException(error?.GetValueOrDefault("code") as string ?? "internal", error?.GetValueOrDefault("message") as string ?? $"HTTP {(int)response.StatusCode}", (int)response.StatusCode); } return parsed; } }