From 4b33532fdca167174e0650d65c78d77e8e03fe7b Mon Sep 17 00:00:00 2001 From: Alexander Lysak Date: Tue, 1 Nov 2022 17:03:34 +0200 Subject: [PATCH 1/4] API session auto-renewal upon expiry --- src/Client/ApiSessionRefresher.cs | 286 ++++++++++++++++++ src/Client/AppWideSessionRefresher.cs | 17 ++ src/Client/IApiSessionRefresher.cs | 56 ++++ src/Client/MorphServerApiClient.cs | 41 ++- src/Client/MorphServerRestClient.cs | 66 +++- src/Model/ApiSession.cs | 12 + src/Model/ClientConfiguration.cs | 6 +- src/Model/IClientConfiguration.cs | 8 +- src/Model/InternalModels/HeadersCollection.cs | 28 +- .../OpenSessionAuthenticatorContext.cs | 15 +- src/Model/OpenSessionRequest.cs | 8 +- 11 files changed, 495 insertions(+), 48 deletions(-) create mode 100644 src/Client/ApiSessionRefresher.cs create mode 100644 src/Client/AppWideSessionRefresher.cs create mode 100644 src/Client/IApiSessionRefresher.cs diff --git a/src/Client/ApiSessionRefresher.cs b/src/Client/ApiSessionRefresher.cs new file mode 100644 index 0000000..b94640b --- /dev/null +++ b/src/Client/ApiSessionRefresher.cs @@ -0,0 +1,286 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Net.Http; +using System.Runtime.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Morph.Server.Sdk.Dto.Errors; +using Morph.Server.Sdk.Helper; +using Morph.Server.Sdk.Model; +using Morph.Server.Sdk.Model.InternalModels; + +namespace Morph.Server.Sdk.Client +{ + /// + /// Service that provides seamless API token refresh + /// + public class ApiSessionRefresher : IApiSessionRefresher + { + /// + /// DTO for '/user/me' endpoint. That endpoint returns more info, but here we only interested in one field to check whether + /// we're authenticated + /// + [DataContract] + private class UserMeDtoStub + { + [DataMember(Name = "isAuthenticated")] public bool? IsAuthenticated { get; set; } + } + + private class Container + { + public readonly T Value; + public readonly DateTime Time; + + public Container(T value) + { + Value = value; + Time = DateTime.UtcNow; + } + } + + private readonly ConcurrentDictionary> _authenticators = + new ConcurrentDictionary>(); + + private readonly ConcurrentDictionary _sessions = + new ConcurrentDictionary(); + + private readonly ConcurrentDictionary>> _refreshTasks = + new ConcurrentDictionary>>(); + + private readonly IJsonSerializer _serializer = new MorphDataContractJsonSerializer(); + + public async Task RefreshSessionAsync(HeadersCollection headers, CancellationToken token) + { + var expiredSessionToken = headers.GetValueOrDefault(ApiSession.AuthHeaderName); + + if (string.IsNullOrEmpty(expiredSessionToken)) + return false; + + var authenticator = _authenticators.TryRemove(expiredSessionToken, out var value) ? value : null; + + try + { + // Here we utilize the natural behavior of TPL/async that fits our needs perfectly. + // We want the first user that wants a replacement for session token 'A' to initiate re-auth process and receive 'B' + // (for example) as a new token, but we also want subsequent requests for 'A' replacement to just return 'B', + // without extra server-trips that would entail new tokens being generated, which in turn would result in + // session proliferation. + + // A dictionary with Tasks and a generator would do just that: + // first client to request new session token to replace 'A' would create an actual Task that would yield + // a new ApiSession instance, and subsequent clients would just get that same Task instance, which + // upon being awaited would yield the same ApiSession instance. + + var result = await _refreshTasks.GetOrAdd(expiredSessionToken, + oldToken => new Container>(RefreshSessionCore(oldToken, authenticator, token))).Value; + + result = UpdateSessionToRecent(result); + + if (string.IsNullOrWhiteSpace(result?.AuthToken)) + { + //This is not expected behavior and at least we don't want to cache 'null' session as a replacement + //for 'expiredSessionToken'. Remove cached task, re-add authenticator. + //Same logic should be applied if authenticator results in exception. + + if (authenticator != null) + _authenticators.TryAdd(expiredSessionToken, authenticator); + + _refreshTasks.TryRemove(expiredSessionToken, out _); + return false; + } + + headers.Set(ApiSession.AuthHeaderName, result.AuthToken); + + foreach (var pair in _sessions.Where(s => + string.Equals(s.Key?.AuthToken, expiredSessionToken, StringComparison.Ordinal))) + { + pair.Key.FillFrom(result); + + // Update last session access time. + _sessions.TryUpdate(pair.Key, DateTime.UtcNow, pair.Value); + } + + return true; + } + catch (Exception) + { + //Revert authenticator to original state for possible retry. + if (authenticator != null) + _authenticators.TryAdd(expiredSessionToken, authenticator); + + //Do not cache faulted/cancelled tasks + _refreshTasks.TryRemove(expiredSessionToken, out _); + throw; + } + } + + private async Task RefreshSessionCore(string oldToken, Container authenticator, CancellationToken token) + { + if (null == authenticator) + return null; + + var freshSession = await authenticator.Value(token); + + if (null == freshSession) + return null; + + // Find tasks that are completed and have previously returned 'oldToken' as their result and replace them with task + // that returns our fresh session. + // + // This way, if we for example had token 'A' exchanged for 'B' and then 'B' got invalidated and exchanged for 'C', then + // when something that was still holding token 'A' tries to refresh it, it will get good 'C' instead of already invalid 'B'. + + var toRedirect = _refreshTasks + .Where(x => x.Value.Value.Status == TaskStatus.RanToCompletion && + string.Equals(x.Value.Value.Result?.AuthToken, oldToken, StringComparison.Ordinal)) + .ToArray(); + + foreach (var kvp in toRedirect) + _refreshTasks.TryUpdate(kvp.Key, new Container>(Task.FromResult(freshSession)), kvp.Value); + + return freshSession; + } + + + /// + /// Given session instance, returns most recent replacement for it, if any. + /// + /// Session that was retrieved as re-auth result + /// Updated session or original + private ApiSession UpdateSessionToRecent(ApiSession source) + { + if (string.IsNullOrWhiteSpace(source?.AuthToken)) + return source; + + // There is (some) possibility that during the slow~ish resolution of session A->B another concurrent + // request had already exchanged B for C. In this case we would like the request for A to get the most recent + // session token 'C', not 'B'. + + // UML sequence diagram below to clarify desired behavior. + // Both Client 1 and 2 start with shared session token 'A' and then both want to refresh it ⬇ + // + // ┌───────┐ ┌────────────────┐ ┌───────┐ + // │Client1│ │SessionRefresher│ │Client2│ + // └───┬───┘ └───────┬────────┘ └───┬───┘ + // │ refresh 'A' │ │ + // │───────────────────────> │ + // │ │ │ + // │ │ refresh 'A' │ + // │ │ <───────────────────────────────────│ + // │ │ │ + // │take 'B' instead of 'A'│ │ + // │<─────────────────────── │ + // │ │ │ + // │ refresh 'B' │ │ + // │───────────────────────> │ + // │ │ │ + // │take 'C' instead of 'B'│ │ + // │<─────────────────────── │ + // │ │ │ + // │ │ should be 'C', not 'B' or (new) 'D' │ + // │ │ ───────────────────────────────────>│ + // + // + // Code below checks whether there are tasks that have already completed and were stemmed from the + // session token we're trying to refresh. If there are, we take the most recent one and return it. + + return _refreshTasks + .Where(x => x.Value.Value.Status == TaskStatus.RanToCompletion && x.Key == source?.AuthToken) + .Select(x => x.Value.Value.Result).FirstOrDefault() ?? source; + } + + public void AssociateAuthenticator(ApiSession session, Authenticator authenticator) + { + if (null == session?.AuthToken) + return; + + _authenticators.TryAdd(session.AuthToken, new Container(authenticator)); + _sessions.TryAdd(session, DateTime.UtcNow); + + PruneCache(); + } + + private void PruneCache() + { + var removeBefore = DateTime.UtcNow - TimeSpan.FromHours(5); + + // Remove everything that was last touched 5 hours ago. + + foreach (var pair in _authenticators.Where(c => c.Value.Time < removeBefore)) + _authenticators.TryRemove(pair.Key, out _); + + foreach (var pair in _refreshTasks.Where(c => c.Value.Time < removeBefore)) + _refreshTasks.TryRemove(pair.Key, out _); + + foreach (var pair in _sessions.Where(c => c.Value < removeBefore)) + _sessions.TryRemove(pair.Key, out _); + } + + public async Task IsSessionLostResponse(HeadersCollection headersCollection, string path, HttpContent httpContent, HttpResponseMessage response) + { + // Session-lost or expired errors have 403 error code, not 401, due to some EMS/http.sys error handling workarounds + if (response.StatusCode != System.Net.HttpStatusCode.Forbidden) + return false; + + // Anonymous sessions are not refreshable. + if (!headersCollection.Contains(ApiSession.AuthHeaderName)) + return false; + + // Can't refresh if something fails during refresh. + if (path.StartsWith("auth/", StringComparison.OrdinalIgnoreCase)) + return false; + + //Cannot retry if request had streaming content (can't rewind it). + if (httpContent is StreamContent) + return false; + + if (httpContent is MultipartContent multipartContent) + { + if (multipartContent.Any(part => + part is ContiniousSteamingHttpContent + || part is StreamContent + || part is ProgressStreamContent)) + return false; + } + + // Session-lost error body should be json + if(!string.Equals(response.Content?.Headers.ContentType.MediaType, "application/json", StringComparison.OrdinalIgnoreCase)) + return false; + + var content = await response.Content.ReadAsStringAsync(); + + if(string.IsNullOrWhiteSpace(content)) + return false; + + try + { + var responseModel = _serializer.Deserialize(content); + + return string.Equals(responseModel?.error.code, ReadableErrorTopCode.Unauthorized, StringComparison.Ordinal); + } + catch (Exception) + { + //Not a valid JSON or doesn't match error schema - not our case + return false; + } + } + + public async Task EnsureSessionValid(MorphServerRestClient restClient, HeadersCollection headersCollection, + CancellationToken token) + { + //This is an anonymous session -- we can't refresh it. + if (!headersCollection.Contains(ApiSession.AuthHeaderName)) + return; + + var userMe = await restClient.GetAsync(UrlHelper.JoinUrl("user", "me"), null, headersCollection, token); + + if (userMe?.Data.IsAuthenticated == true) + return; + + await RefreshSessionAsync(headersCollection, token); + } + + + } +} \ No newline at end of file diff --git a/src/Client/AppWideSessionRefresher.cs b/src/Client/AppWideSessionRefresher.cs new file mode 100644 index 0000000..c6a2400 --- /dev/null +++ b/src/Client/AppWideSessionRefresher.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading; + +namespace Morph.Server.Sdk.Client +{ + /// + /// Provides one-per-AppDomain session refresher to be used as sensible default/fallback refresher + /// + internal static class AppWideSessionRefresher + { + private static readonly Lazy Provider = new Lazy( + () => new ApiSessionRefresher(), + LazyThreadSafetyMode.ExecutionAndPublication); + + public static ApiSessionRefresher Instance => Provider.Value; + } +} \ No newline at end of file diff --git a/src/Client/IApiSessionRefresher.cs b/src/Client/IApiSessionRefresher.cs new file mode 100644 index 0000000..d66f987 --- /dev/null +++ b/src/Client/IApiSessionRefresher.cs @@ -0,0 +1,56 @@ +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Morph.Server.Sdk.Model; +using Morph.Server.Sdk.Model.InternalModels; + +namespace Morph.Server.Sdk.Client +{ + + /// + /// Delegate that upon being invoked performs server-side authentication and returns a fresh valid API Session + /// + public delegate Task Authenticator(CancellationToken token); + + /// + /// Service that provides seamless API token refresh + /// + public interface IApiSessionRefresher + { + /// + /// Re-authenticates current session (if any) and seamlessly updates existing instances that were associated + /// with the previous session via method. + /// + /// Request headers + /// Cancellation token + /// + Task RefreshSessionAsync(HeadersCollection headers, CancellationToken token); + + /// + /// Save for . T + /// his is required for object tracking and for to work. + /// + /// + /// + void AssociateAuthenticator(ApiSession session, Authenticator authenticator); + + /// + /// Returns true if indicates that the session has expired + /// + /// + /// + /// + /// + /// + Task IsSessionLostResponse(HeadersCollection headersCollection, string path, HttpContent httpContent, HttpResponseMessage response); + + /// + /// Ensures that current session is valid and refreshes it if needed + /// + /// + /// + /// + /// + Task EnsureSessionValid(MorphServerRestClient restClient, HeadersCollection headersCollection, CancellationToken token); + } +} \ No newline at end of file diff --git a/src/Client/MorphServerApiClient.cs b/src/Client/MorphServerApiClient.cs index 0b12810..1146c56 100644 --- a/src/Client/MorphServerApiClient.cs +++ b/src/Client/MorphServerApiClient.cs @@ -108,7 +108,9 @@ protected virtual IRestClient ConstructRestApiClient(HttpClient httpClient, Uri throw new ArgumentNullException(nameof(httpClient)); } - return new MorphServerRestClient(httpClient, baseAddress, clientConfiguration.HttpSecurityState); + return new MorphServerRestClient(httpClient, baseAddress, + clientConfiguration.SessionRefresher, + clientConfiguration.HttpSecurityState); } @@ -659,14 +661,10 @@ public async Task OpenSessionAsync(OpenSessionRequest openSessionReq { throw new Exception($"Unable to open session. Server has no space '{openSessionRequest.SpaceName}'"); } - var session = await MorphServerAuthenticator.OpenSessionMultiplexedAsync(desiredSpace, - new OpenSessionAuthenticatorContext( - _lowLevelApiClient, - this as ICanCloseSession, - (handler) => ConstructRestApiClient(BuildHttpClient(clientConfiguration, handler), BuildBaseAddress(clientConfiguration), clientConfiguration)), - openSessionRequest, cancellationToken); - - return session; + + var authenticator = CreateAuthenticator(openSessionRequest, desiredSpace); + + return await authenticator(cancellationToken); } catch (OperationCanceledException) when (!ct.IsCancellationRequested && linkedTokenSource.IsCancellationRequested) { @@ -676,7 +674,28 @@ public async Task OpenSessionAsync(OpenSessionRequest openSessionReq } + private Authenticator CreateAuthenticator(OpenSessionRequest openSessionRequest, SpaceEnumerationItem desiredSpace) + { + var requestClone = openSessionRequest.Clone(); + + return async ctoken => + { + var response = await MorphServerAuthenticator.OpenSessionMultiplexedAsync(desiredSpace, + new OpenSessionAuthenticatorContext(_lowLevelApiClient, + this, + (handler) => + ConstructRestApiClient( + BuildHttpClient(clientConfiguration, handler), + BuildBaseAddress(clientConfiguration), clientConfiguration)), + requestClone, + ctoken); + + if(!string.IsNullOrWhiteSpace(response?.AuthToken)) + Config.SessionRefresher.AssociateAuthenticator(response, CreateAuthenticator(requestClone, desiredSpace)); + return response; + }; + } public Task GetTasksListAsync(ApiSession apiSession, CancellationToken cancellationToken) @@ -804,6 +823,4 @@ await _lowLevelApiClient.WebFilesPutFileStreamAsync(apiSession, spaceUploadFileR } } -} - - +} \ No newline at end of file diff --git a/src/Client/MorphServerRestClient.cs b/src/Client/MorphServerRestClient.cs index b077c10..c51a1d0 100644 --- a/src/Client/MorphServerRestClient.cs +++ b/src/Client/MorphServerRestClient.cs @@ -25,6 +25,9 @@ namespace Morph.Server.Sdk.Client public class MorphServerRestClient : IRestClient { protected readonly IJsonSerializer jsonSerializer; + + private IApiSessionRefresher SessionRefresher { get; } + private static string HttpsSchemeConstant = "https"; private static string HttpStateDetectionEndpoint = "server/status"; @@ -32,13 +35,17 @@ public class MorphServerRestClient : IRestClient public Uri BaseAddress { get; protected set; } private HttpClient httpClient; + public HttpClient HttpClient { get => httpClient; set => httpClient = value; } public HttpSecurityState HttpSecurityState { get; protected set; } = HttpSecurityState.NotEvaluated; - public MorphServerRestClient(HttpClient httpClient, Uri baseAddress, IJsonSerializer jsonSerializer, HttpSecurityState httpSecurityState = HttpSecurityState.NotEvaluated) + public MorphServerRestClient(HttpClient httpClient, Uri baseAddress, IJsonSerializer jsonSerializer, + IApiSessionRefresher sessionRefresher, + HttpSecurityState httpSecurityState = HttpSecurityState.NotEvaluated) { this.jsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer)); + SessionRefresher = sessionRefresher; this.BaseAddress = baseAddress ?? throw new ArgumentNullException(nameof(baseAddress)); HttpSecurityState = httpSecurityState; HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); @@ -48,12 +55,11 @@ public MorphServerRestClient(HttpClient httpClient, Uri baseAddress, IJsonSerial { UpgradeToForcedHttps(); } - - } - public MorphServerRestClient(HttpClient httpClient, Uri baseAddress, HttpSecurityState httpSecurityState = HttpSecurityState.NotEvaluated) : - this(httpClient, baseAddress, new MorphDataContractJsonSerializer(), httpSecurityState) + public MorphServerRestClient(HttpClient httpClient, Uri baseAddress, IApiSessionRefresher sessionRefresher, + HttpSecurityState httpSecurityState = HttpSecurityState.NotEvaluated) : + this(httpClient, baseAddress, new MorphDataContractJsonSerializer(),sessionRefresher, httpSecurityState) { } @@ -194,20 +200,40 @@ CancellationToken cancellationToken httpCompletionOption, cancellationToken); } - else + + var response = await _SendAsyncAsIs(httpMethod, + path, + httpContent, + urlParameters, + headersCollection, + httpCompletionOption, + cancellationToken); + + try { - return await _SendAsyncAsIs(httpMethod, - path, - httpContent, - urlParameters, - headersCollection, - httpCompletionOption, - cancellationToken); + if (!await SessionRefresher.IsSessionLostResponse(headersCollection, path, httpContent, response)) + return response; + if (!await SessionRefresher.RefreshSessionAsync(headersCollection, cancellationToken)) + return response; + } + catch (Exception) + { + //Don't lose the original response if somebody throws an exception in the session refresher + response?.Dispose(); + throw; } + //At this point original response will not be seen by method invoker, so we should dispose it + response?.Dispose(); - + return await _SendAsyncAsIs(httpMethod, + path, + httpContent, + urlParameters, + headersCollection, + httpCompletionOption, + cancellationToken); } @@ -423,6 +449,8 @@ public virtual async Task> PushContiniousStreamin HttpContentHeaders httpResponseHeaders = null; try { + await EnsureSessionValid(headersCollection, cancellationToken); + string boundary = "MorphRestClient-Streaming--------" + Guid.NewGuid().ToString("N"); var content = new MultipartFormDataContent(boundary); @@ -498,6 +526,15 @@ ex.InnerException is WebException web && } } + private async Task EnsureSessionValid(HeadersCollection headersCollection, CancellationToken cancellationToken) + { + //This is anonymous request + if (!headersCollection.Contains(ApiSession.AuthHeaderName)) + return; + + await SessionRefresher.EnsureSessionValid(this, headersCollection, cancellationToken); + } + public virtual async Task> SendFileStreamAsync( HttpMethod httpMethod, string path, SendFileStreamData sendFileStreamData, @@ -509,6 +546,7 @@ public virtual async Task> SendFileStreamAsync( HttpContentHeaders httpResponseHeaders = null; try { + await EnsureSessionValid(headersCollection, cancellationToken); string boundary = "MorphRestClient--------" + Guid.NewGuid().ToString("N"); diff --git a/src/Model/ApiSession.cs b/src/Model/ApiSession.cs index 40c7fb5..3c62708 100644 --- a/src/Model/ApiSession.cs +++ b/src/Model/ApiSession.cs @@ -151,5 +151,17 @@ public void Dispose() } } + + /// + /// Import authentication data from other token + /// + /// Session to import from + /// is null + public void FillFrom(ApiSession freshSession) + { + if (freshSession == null) throw new ArgumentNullException(nameof(freshSession)); + + AuthToken = freshSession.AuthToken; + } } } \ No newline at end of file diff --git a/src/Model/ClientConfiguration.cs b/src/Model/ClientConfiguration.cs index c84dd16..fa4eb43 100644 --- a/src/Model/ClientConfiguration.cs +++ b/src/Model/ClientConfiguration.cs @@ -19,6 +19,8 @@ public class ClientConfiguration : IClientConfiguration public bool AutoDisposeClientOnSessionClose { get; set; } = MorphServerApiClientGlobalConfig.AutoDisposeClientOnSessionClose; + public IApiSessionRefresher SessionRefresher { get; set; } = AppWideSessionRefresher.Instance; + public Uri ApiUri { get; set; } public HttpSecurityState HttpSecurityState { get; set; } = HttpSecurityState.NotEvaluated; internal string SDKVersionString { get; set; } = MorphServerApiClientGlobalConfig.SDKVersionString; @@ -32,6 +34,4 @@ public class ClientConfiguration : IClientConfiguration } -} - - +} \ No newline at end of file diff --git a/src/Model/IClientConfiguration.cs b/src/Model/IClientConfiguration.cs index 9801035..fdb8326 100644 --- a/src/Model/IClientConfiguration.cs +++ b/src/Model/IClientConfiguration.cs @@ -16,7 +16,9 @@ public interface IClientConfiguration string ClientId { get; } string ClientType { get; } - bool AutoDisposeClientOnSessionClose { get; } + bool AutoDisposeClientOnSessionClose { get; } + + IApiSessionRefresher SessionRefresher { get; } Uri ApiUri { get; } HttpSecurityState HttpSecurityState { get; } @@ -25,6 +27,4 @@ public interface IClientConfiguration Func ServerCertificateCustomValidationCallback { get; } #endif } -} - - +} \ No newline at end of file diff --git a/src/Model/InternalModels/HeadersCollection.cs b/src/Model/InternalModels/HeadersCollection.cs index 8bdd50a..d719973 100644 --- a/src/Model/InternalModels/HeadersCollection.cs +++ b/src/Model/InternalModels/HeadersCollection.cs @@ -39,10 +39,34 @@ public void Fill(HttpRequestHeaders reqestHeaders) reqestHeaders.Add(item.Key, item.Value); } } - } + /// + /// Gets the value of the header with the specified name. + /// + /// + /// + public string GetValueOrDefault(string headerName) => _headers.TryGetValue(headerName, out var value) ? value : null; -} + /// + /// Sets the value of the header with the specified name. + /// + /// Header name. + /// Header value. + public void Set(string headerName, string headerValue) + { + _headers[headerName] = headerValue; + } + /// + /// Checks if the header with the specified name exists. + /// + /// Header name. + /// + public bool Contains(string header) + { + return _headers.ContainsKey(header); + } + } +} \ No newline at end of file diff --git a/src/Model/InternalModels/OpenSessionAuthenticatorContext.cs b/src/Model/InternalModels/OpenSessionAuthenticatorContext.cs index a4da307..54140e2 100644 --- a/src/Model/InternalModels/OpenSessionAuthenticatorContext.cs +++ b/src/Model/InternalModels/OpenSessionAuthenticatorContext.cs @@ -8,25 +8,16 @@ namespace Morph.Server.Sdk.Model.InternalModels { internal class OpenSessionAuthenticatorContext { - - public OpenSessionAuthenticatorContext - (ILowLevelApiClient lowLevelApiClient, - ICanCloseSession morphServerApiClient, - Func buildApiClient - - ) + public OpenSessionAuthenticatorContext(ILowLevelApiClient lowLevelApiClient, ICanCloseSession morphServerApiClient, + Func buildApiClient) { LowLevelApiClient = lowLevelApiClient ?? throw new ArgumentNullException(nameof(lowLevelApiClient)); MorphServerApiClient = morphServerApiClient ?? throw new ArgumentNullException(nameof(morphServerApiClient)); BuildApiClient = buildApiClient ?? throw new ArgumentNullException(nameof(buildApiClient)); - } public ILowLevelApiClient LowLevelApiClient { get; } public ICanCloseSession MorphServerApiClient { get; } public Func BuildApiClient { get; } - } -} - - +} \ No newline at end of file diff --git a/src/Model/OpenSessionRequest.cs b/src/Model/OpenSessionRequest.cs index 9fdddb7..b85462e 100644 --- a/src/Model/OpenSessionRequest.cs +++ b/src/Model/OpenSessionRequest.cs @@ -10,5 +10,11 @@ public class OpenSessionRequest { public string SpaceName{ get; set; } public string Password { get; set; } + + /// + /// Create cloned request instance + /// + /// Memberwise clone + public OpenSessionRequest Clone() => (OpenSessionRequest)MemberwiseClone(); } -} +} \ No newline at end of file From 0fc9b37c0883cd3e75dc80684d0521f4df6af885 Mon Sep 17 00:00:00 2001 From: Alexander Lysak Date: Thu, 27 Oct 2022 15:58:34 +0300 Subject: [PATCH 2/4] Folder API commands (delete/create/rename) --- src/Client/ILowLevelApiClient.cs | 18 +++++-- src/Client/IMorphServerApiClient.cs | 10 ++++ src/Client/LowLevelApiClient.cs | 61 ++++++++++++++++++++++-- src/Client/MorphServerApiClient.cs | 73 +++++++++++++++++++++++++++++ src/Dto/FolderCreateRequestDto.cs | 26 ++++++++++ src/Dto/FolderDeleteRequestDto.cs | 14 ++++++ src/Dto/FolderRenameRequestDto.cs | 29 ++++++++++++ 7 files changed, 223 insertions(+), 8 deletions(-) create mode 100644 src/Dto/FolderCreateRequestDto.cs create mode 100644 src/Dto/FolderDeleteRequestDto.cs create mode 100644 src/Dto/FolderRenameRequestDto.cs diff --git a/src/Client/ILowLevelApiClient.cs b/src/Client/ILowLevelApiClient.cs index 32ac620..96d33f3 100644 --- a/src/Client/ILowLevelApiClient.cs +++ b/src/Client/ILowLevelApiClient.cs @@ -58,6 +58,19 @@ Task> GetWorkflowResultDetailsAsync(ApiSessi Task> WebFilesBrowseSpaceAsync(ApiSession apiSession, string folderPath, CancellationToken cancellationToken); Task> WebFileExistsAsync(ApiSession apiSession, string serverFilePath, CancellationToken cancellationToken); Task> WebFilesDeleteFileAsync(ApiSession apiSession, string serverFilePath, CancellationToken cancellationToken); + + Task> WebFilesDeleteFolderAsync(ApiSession apiSession, string serverFilePath, + bool failIfNotExists, + CancellationToken cancellationToken); + + Task> WebFilesCreateFolderAsync(ApiSession apiSession, string parentFolderPath, + string folderName, + bool failIfExists, CancellationToken cancellationToken); + + Task> WebFilesRenameFolderAsync(ApiSession apiSession, string parentFolderPath, + string oldFolderName, string newFolderName, + bool failIfExists, CancellationToken cancellationToken); + Task> WebFilesDownloadFileAsync(ApiSession apiSession, string serverFilePath, Action onReceiveProgress, CancellationToken cancellationToken); Task> WebFilesPutFileStreamAsync(ApiSession apiSession, string serverFolder, SendFileStreamData sendFileStreamData, Action onSendProgress, CancellationToken cancellationToken); Task> WebFilesPostFileStreamAsync(ApiSession apiSession, string serverFolder, SendFileStreamData sendFileStreamData, Action onSendProgress, CancellationToken cancellationToken); @@ -66,7 +79,4 @@ Task> GetWorkflowResultDetailsAsync(ApiSessi Task> WebFilesOpenContiniousPutStreamAsync(ApiSession apiSession, string serverFolder, string fileName, CancellationToken cancellationToken); } -} - - - +} \ No newline at end of file diff --git a/src/Client/IMorphServerApiClient.cs b/src/Client/IMorphServerApiClient.cs index e9b968a..0e6b7e1 100644 --- a/src/Client/IMorphServerApiClient.cs +++ b/src/Client/IMorphServerApiClient.cs @@ -62,6 +62,16 @@ Task GetWorkflowResultDetailsAsync(ApiSession apiSession, Task SpaceBrowseAsync(ApiSession apiSession, string folderPath, CancellationToken cancellationToken); Task SpaceDeleteFileAsync(ApiSession apiSession, string remoteFilePath, CancellationToken cancellationToken); + + Task SpaceDeleteFolderAsync(ApiSession apiSession, string serverFolderPath, bool failIfNotExists, + CancellationToken cancellationToken); + + Task SpaceCreateFolderAsync(ApiSession apiSession, string parentFolderPath, + string folderName, bool failIfExists, CancellationToken cancellationToken); + + Task SpaceRenameFolderAsync(ApiSession apiSession, string parentFolderPath, string oldFolderName, string newFolderName, + bool failIfExists, CancellationToken cancellationToken); + Task SpaceFileExistsAsync(ApiSession apiSession, string remoteFilePath, CancellationToken cancellationToken); Task SpaceOpenStreamingDataAsync(ApiSession apiSession, string remoteFilePath, CancellationToken cancellationToken); diff --git a/src/Client/LowLevelApiClient.cs b/src/Client/LowLevelApiClient.cs index 1f34091..7b74a8b 100644 --- a/src/Client/LowLevelApiClient.cs +++ b/src/Client/LowLevelApiClient.cs @@ -200,6 +200,62 @@ public Task> WebFilesDeleteFileAsync(ApiSession apiSe return apiClient.DeleteAsync(url, null, apiSession.ToHeadersCollection(), cancellationToken); } + public Task> WebFilesDeleteFolderAsync(ApiSession apiSession, string serverFilePath, + bool failIfNotExists, CancellationToken cancellationToken) + { + var spaceName = apiSession.SpaceName; + + return apiClient.PostAsync( + url: UrlHelper.JoinUrl("space", spaceName, "foldersops", "delete"), + model: new FolderDeleteRequestDto + { + FolderPath = serverFilePath, + FailIfNotFound = failIfNotExists + }, + urlParameters: null, + apiSession.ToHeadersCollection(), + cancellationToken); + } + + public Task> WebFilesCreateFolderAsync(ApiSession apiSession, string parentFolderPath, + string folderName, + bool failIfExists, CancellationToken cancellationToken) + { + var spaceName = apiSession.SpaceName; + + return apiClient.PostAsync( + url: UrlHelper.JoinUrl("space", spaceName, "foldersops", "create"), + model: new FolderCreateRequestDto + { + FolderPath = parentFolderPath, + FolderName = folderName, + FailIfExists = failIfExists, + }, + urlParameters: null, + apiSession.ToHeadersCollection(), + cancellationToken); + } + + public Task> WebFilesRenameFolderAsync(ApiSession apiSession, string parentFolderPath, + string oldFolderName, string newFolderName, + bool failIfExists, CancellationToken cancellationToken) + { + var spaceName = apiSession.SpaceName; + + return apiClient.PostAsync( + url: UrlHelper.JoinUrl("space", spaceName, "foldersops", "rename"), + model: new FolderRenameRequestDto + { + FolderPath = parentFolderPath, + Name = oldFolderName, + NewName = newFolderName, + FailIfExists = failIfExists, + }, + urlParameters: null, + apiSession.ToHeadersCollection(), + cancellationToken); + } + public Task> AuthLoginPasswordAsync(LoginRequestDto loginRequestDto, CancellationToken cancellationToken) { var url = "auth/login"; @@ -321,7 +377,4 @@ public Task> WebFilesOpenContiniousPutStreamAsync } -} - - - +} \ No newline at end of file diff --git a/src/Client/MorphServerApiClient.cs b/src/Client/MorphServerApiClient.cs index 1146c56..3cb20c1 100644 --- a/src/Client/MorphServerApiClient.cs +++ b/src/Client/MorphServerApiClient.cs @@ -559,10 +559,83 @@ public Task SpaceDeleteFileAsync(ApiSession apiSession, string serverFilePath, C } + /// + /// Deletes folder + /// + /// api session + /// Path to server folder like /path/to/folder + /// Fails with error if folder does not exist + /// + /// + /// + public Task SpaceDeleteFolderAsync(ApiSession apiSession, string serverFolderPath, bool failIfNotExists, CancellationToken cancellationToken) + { + if (apiSession == null) + { + throw new ArgumentNullException(nameof(apiSession)); + } + return Wrapped(async (token) => + { + var apiResult = await _lowLevelApiClient.WebFilesDeleteFolderAsync(apiSession, serverFolderPath, failIfNotExists, token); + FailIfError(apiResult); + return Task.FromResult(0); + }, cancellationToken, OperationType.ShortOperation); + } + /// + /// Creates a folder + /// + /// api session + /// Path to server folder like /path/to/folder + /// + /// Fails with error if target folder exists already + /// + /// + /// + public Task SpaceCreateFolderAsync(ApiSession apiSession, string parentFolderPath, string folderName, + bool failIfExists, CancellationToken cancellationToken) + { + if (apiSession == null) + { + throw new ArgumentNullException(nameof(apiSession)); + } + + return Wrapped(async (token) => + { + var apiResult = await _lowLevelApiClient.WebFilesCreateFolderAsync(apiSession, parentFolderPath, folderName, failIfExists, token); + FailIfError(apiResult); + return Task.FromResult(0); + }, cancellationToken, OperationType.ShortOperation); + } + /// + /// Renames a folder + /// + /// api session + /// Path to containing server folder like /path/to/folder + /// Old folder name + /// New folder name + /// Fails with error if target folder exists already + /// + /// + /// + public Task SpaceRenameFolderAsync(ApiSession apiSession, string parentFolderPath, string oldFolderName, string newFolderName, + bool failIfExists, CancellationToken cancellationToken) + { + if (apiSession == null) + { + throw new ArgumentNullException(nameof(apiSession)); + } + return Wrapped(async (token) => + { + var apiResult = await _lowLevelApiClient.WebFilesRenameFolderAsync(apiSession, parentFolderPath, oldFolderName, newFolderName, + failIfExists, token); + FailIfError(apiResult); + return Task.FromResult(0); + }, cancellationToken, OperationType.ShortOperation); + } /// /// Validate tasks. Checks that there are no missing parameters in the tasks. diff --git a/src/Dto/FolderCreateRequestDto.cs b/src/Dto/FolderCreateRequestDto.cs new file mode 100644 index 0000000..ddce787 --- /dev/null +++ b/src/Dto/FolderCreateRequestDto.cs @@ -0,0 +1,26 @@ +using System.Runtime.Serialization; + +namespace Morph.Server.Sdk.Dto +{ + [DataContract] + internal sealed class FolderCreateRequestDto + { + /// + /// Containing folder path + /// + [DataMember(Name = "folderPath")] + public string FolderPath { get; set; } + + /// + /// New folder name + /// + [DataMember(Name = "folderName")] + public string FolderName { get; set; } + + /// + /// True to fail if folder already exists, 'false' to suppress the error + /// + [DataMember(Name = "failIfExists")] + public bool FailIfExists { get; set; } + } +} \ No newline at end of file diff --git a/src/Dto/FolderDeleteRequestDto.cs b/src/Dto/FolderDeleteRequestDto.cs new file mode 100644 index 0000000..b528256 --- /dev/null +++ b/src/Dto/FolderDeleteRequestDto.cs @@ -0,0 +1,14 @@ +using System.Runtime.Serialization; + +namespace Morph.Server.Sdk.Dto +{ + [DataContract] + internal sealed class FolderDeleteRequestDto + { + [DataMember(Name = "folderPath")] + public string FolderPath { get; set; } + + [DataMember(Name = "failIfNotFound")] + public bool? FailIfNotFound { get; set; } + } +} \ No newline at end of file diff --git a/src/Dto/FolderRenameRequestDto.cs b/src/Dto/FolderRenameRequestDto.cs new file mode 100644 index 0000000..1608cc8 --- /dev/null +++ b/src/Dto/FolderRenameRequestDto.cs @@ -0,0 +1,29 @@ +using System.Runtime.Serialization; + +namespace Morph.Server.Sdk.Dto +{ + [DataContract] + internal sealed class FolderRenameRequestDto + { + /// + /// Container folder path + /// + [DataMember(Name = "folderPath")] + public string FolderPath { get; set; } + + /// + /// Old folder name + /// + [DataMember(Name = "name")] + public string Name { get; set; } + + /// + /// New folder name + /// + [DataMember(Name = "newName")] + public string NewName { get; set; } + + [DataMember(Name = "failIfExists")] + public bool FailIfExists { get; set; } + } +} \ No newline at end of file From 877ab53a4856bdc78e4a88fb94beff583401b546 Mon Sep 17 00:00:00 2001 From: Alexander Lysak Date: Thu, 10 Nov 2022 14:12:05 +0200 Subject: [PATCH 3/4] Set assembly version to 5.3.2 --- src/Properties/AssemblyInfo.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Properties/AssemblyInfo.cs b/src/Properties/AssemblyInfo.cs index 52352d5..fb678cc 100644 --- a/src/Properties/AssemblyInfo.cs +++ b/src/Properties/AssemblyInfo.cs @@ -21,5 +21,5 @@ [assembly: Guid("72ecc66f-62fe-463f-afad-e1ff5cc19cd9")] -[assembly: AssemblyVersion("5.3.0.0")] -[assembly: AssemblyFileVersion("5.3.0.0")] +[assembly: AssemblyVersion("5.3.2.0")] +[assembly: AssemblyFileVersion("5.3.2.0")] \ No newline at end of file From 67a007ccfbb1ccf459b53890e1e8ca841430f772 Mon Sep 17 00:00:00 2001 From: Alexander Lysak Date: Mon, 26 Dec 2022 19:53:06 +0200 Subject: [PATCH 4/4] fix validation error details handling --- src/Dto/Errors/Error.cs | 4 ++-- src/Mappers/FieldErrorsMapper.cs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Dto/Errors/Error.cs b/src/Dto/Errors/Error.cs index c04b5b8..12dd551 100644 --- a/src/Dto/Errors/Error.cs +++ b/src/Dto/Errors/Error.cs @@ -33,9 +33,9 @@ public class Error /// An array of details about specific errors that led to this reported error. /// [DataMember] - public List detais { get; set; } + public List details { get; set; } [DataMember] public InnerError innererror { get; set; } } -} +} \ No newline at end of file diff --git a/src/Mappers/FieldErrorsMapper.cs b/src/Mappers/FieldErrorsMapper.cs index 5ac5915..c5f2042 100644 --- a/src/Mappers/FieldErrorsMapper.cs +++ b/src/Mappers/FieldErrorsMapper.cs @@ -15,13 +15,13 @@ internal static class FieldErrorsMapper { public static List MapFromDto(Error error) { - var result = error.detais.Select(x => new FieldError + var result = error.details?.Select(x => new FieldError { Field = x.target, Message = x.message, FieldErrorType = ParseFieldErrorType(x.code) }); - return result.ToList(); + return result?.ToList() ?? new List(); } private static FieldErrorType ParseFieldErrorType(string code) @@ -39,4 +39,4 @@ private static FieldErrorType ParseFieldErrorType(string code) } } -} +} \ No newline at end of file