Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add async implementation #137

Merged
merged 5 commits into from
Nov 24, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions NGitLab.Mock/Clients/ProjectClient.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NGitLab.Mock.Internals;
using NGitLab.Models;

namespace NGitLab.Mock.Clients
Expand Down Expand Up @@ -206,6 +210,13 @@ public Models.Project GetById(int id, SingleProjectQuery query)
}
}

[SuppressMessage("Design", "MA0042:Do not use blocking calls in an async method", Justification = "Would be an infinite recursion")]
public async Task<Models.Project> GetByIdAsync(int id, SingleProjectQuery query, CancellationToken cancellationToken = default)
{
await Task.Yield();
return GetById(id, query);
}

public IEnumerable<Models.Project> GetForks(string id, ForkedProjectQuery query)
{
using (Context.BeginOperationScope())
Expand Down Expand Up @@ -279,5 +290,10 @@ public UploadedProjectFile UploadFile(string id, FormDataContent data)
{
throw new NotImplementedException();
}

public GitLabCollectionResponse<Models.Project> GetAsync(ProjectQuery query)
{
return GitLabCollectionResponse.Create(Get(query));
}
}
}
35 changes: 35 additions & 0 deletions NGitLab.Mock/Internals/GitLabCollectionResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace NGitLab.Mock.Internals
{
internal static class GitLabCollectionResponse
{
public static GitLabCollectionResponse<T> Create<T>(IEnumerable<T> items)
{
return new GitLabCollectionResponseImpl<T>(items);
}

private sealed class GitLabCollectionResponseImpl<T> : GitLabCollectionResponse<T>
{
private readonly IEnumerable<T> _items;

public GitLabCollectionResponseImpl(IEnumerable<T> items)
{
_items = items;
}

public override async IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default)
{
await Task.Yield();
foreach (var item in _items)
{
yield return item;
}
}

public override IEnumerator<T> GetEnumerator() => _items.GetEnumerator();
}
}
}
63 changes: 63 additions & 0 deletions NGitLab.Tests/AsyncApiValidation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;

namespace NGitLab.Tests
{
public class AsyncApiValidation
{
[Test]
public void ValidateAsyncMethodSignature()
{
var interfaces = typeof(IGitLabClient).Assembly.GetTypes().Where(t => t.IsInterface && t.IsPublic && t != typeof(IHttpRequestor));
foreach (var iface in interfaces)
{
foreach (var method in iface.GetMethods())
{
if (typeof(Task).IsAssignableFrom(method.ReturnType))
{
if (!method.Name.EndsWith("Async", System.StringComparison.Ordinal))
{
Assert.Fail($"The method '{method}' must end with 'Async'");
}

// Ensure method that returns a Task takes a CancellationToken
var parameterInfo = method.GetParameters().LastOrDefault();
if (parameterInfo is null)
{
Assert.Fail($"The method '{method}' must have a parameter of type 'CancellationToken'");
}

if (parameterInfo.ParameterType != typeof(CancellationToken))
{
Assert.Fail($"The last parameter of method '{method}' must be of type 'CancellationToken' and named 'cancellationToken'");
}

if (!string.Equals(parameterInfo.Name, "cancellationToken", System.StringComparison.Ordinal))
{
Assert.Fail($"The parameter '{parameterInfo.Name}' of '{method}' must be named 'cancellationToken'");
}

// Ensure the parameter is optional
var attr = parameterInfo.GetCustomAttribute<OptionalAttribute>();
if (attr is null)
{
Assert.Fail($"The parameter '{parameterInfo.Name}' of '{method}' must be optional");
}
}

if (method.ReturnType.IsGenericType && typeof(GitLabCollectionResponse<>).IsAssignableFrom(method.ReturnType.GetGenericTypeDefinition()))
{
if (!method.Name.EndsWith("Async", System.StringComparison.Ordinal))
{
Assert.Fail($"The method '{method}' must end with 'Async'");
}
}
}
}
}
}
}
41 changes: 41 additions & 0 deletions NGitLab.Tests/Docker/GitLabTestContextRequestOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,47 @@ public override WebResponse GetResponse(HttpWebRequest request)
return response;
}

public override async Task<WebResponse> GetResponseAsync(HttpWebRequest request, CancellationToken cancellationToken)
{
lock (_allRequests)
{
_allRequests.Add(request);
}

WebResponse response = null;

// GitLab is unstable, so let's make sure we don't overload it with many concurrent requests
await s_semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
try
{
response = await base.GetResponseAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (WebException exception)
{
response = exception.Response;
if (response is HttpWebResponse webResponse)
{
response = new LoggableHttpWebResponse(webResponse);
throw new WebException(exception.Message, exception, exception.Status, response);
}

throw;
}
finally
{
response = LogRequest(request, response);
}
}
finally
{
s_semaphoreSlim.Release();
}

return response;
}

private WebResponse LogRequest(HttpWebRequest request, WebResponse response)
{
byte[] requestContent = null;
Expand Down
30 changes: 30 additions & 0 deletions NGitLab.Tests/ProjectsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NGitLab.Models;
using NGitLab.Tests.Docker;
Expand All @@ -11,6 +12,35 @@ namespace NGitLab.Tests
{
public class ProjectsTests
{
[Test]
[NGitLabRetry]
public async Task GetProjectByIdAsync()
{
using var context = await GitLabTestContext.CreateAsync();
var project = context.CreateProject();
var projectClient = context.Client.Projects;

var projectResult = await projectClient.GetByIdAsync(project.Id, new SingleProjectQuery(), CancellationToken.None);
Assert.AreEqual(project.Id, projectResult.Id);
}

[Test]
[NGitLabRetry]
public async Task GetProjectsAsync()
{
using var context = await GitLabTestContext.CreateAsync();
var project = context.CreateProject();
var projectClient = context.Client.Projects;

var projects = new List<Project>();
await foreach (var item in projectClient.GetAsync(new ProjectQuery()))
{
projects.Add(item);
}

CollectionAssert.IsNotEmpty(projects);
}

[Test]
[NGitLabRetry]
public async Task GetOwnedProjects()
Expand Down
30 changes: 30 additions & 0 deletions NGitLab/Extensions/FunctionRetryExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Threading;
using System.Threading.Tasks;

namespace NGitLab.Extensions
{
Expand Down Expand Up @@ -38,5 +39,34 @@ public static T Retry<T>(this Func<T> action, Func<Exception, int, bool> predica
}
}
}

/// <summary>
/// Do a retry a number of time on the received action if it fails
/// </summary>
public static async Task<T> RetryAsync<T>(this Func<Task<T>> action, Func<Exception, int, bool> predicate, TimeSpan waitTime, int maxRetryCount, bool useExponentialBackoff)
{
var retriesLeft = maxRetryCount;
while (true)
{
try
{
return await action().ConfigureAwait(false);
}
catch (Exception ex) when (retriesLeft > 0 && predicate(ex, retriesLeft))
{
var currentRetry = maxRetryCount - retriesLeft + 1;
Logger?.Invoke($"{ex.Message} -> Internal Retry in {waitTime.TotalMilliseconds.ToStringInvariant()} ms ({currentRetry.ToStringInvariant()} of {maxRetryCount.ToStringInvariant()})...");

await Task.Delay(waitTime).ConfigureAwait(false);

if (useExponentialBackoff)
{
waitTime = waitTime.Add(waitTime);
}

retriesLeft--;
}
}
}
}
}
10 changes: 10 additions & 0 deletions NGitLab/IHttpRequestor.cs
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

namespace NGitLab
{
public interface IHttpRequestor
{
IEnumerable<T> GetAll<T>(string tailUrl);

GitLabCollectionResponse<T> GetAllAsync<T>(string tailUrl);

void Stream(string tailAPIUrl, Action<Stream> parser);

Task StreamAsync(string tailAPIUrl, Func<Stream, Task> parser, CancellationToken cancellationToken);

T To<T>(string tailAPIUrl);

Task<T> ToAsync<T>(string tailAPIUrl, CancellationToken cancellationToken);

void Execute(string tailAPIUrl);

Task ExecuteAsync(string tailAPIUrl, CancellationToken cancellationToken);

IHttpRequestor With(object data);
}
}
6 changes: 6 additions & 0 deletions NGitLab/IProjectClient.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NGitLab.Models;

namespace NGitLab
Expand All @@ -25,6 +27,8 @@ public interface IProjectClient
/// </summary>
IEnumerable<Project> Get(ProjectQuery query);

GitLabCollectionResponse<Project> GetAsync(ProjectQuery query);

Project this[int id] { get; }

/// <summary>
Expand All @@ -50,6 +54,8 @@ public interface IProjectClient

Project GetById(int id, SingleProjectQuery query);

Task<Project> GetByIdAsync(int id, SingleProjectQuery query, CancellationToken cancellationToken = default);

Project Fork(string id, ForkProject forkProject);

IEnumerable<Project> GetForks(string id, ForkedProjectQuery query);
Expand Down
15 changes: 15 additions & 0 deletions NGitLab/Impl/GitLabCollectionResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Collections;
using System.Collections.Generic;
using System.Threading;

namespace NGitLab
{
public abstract class GitLabCollectionResponse<T> : IEnumerable<T>, IAsyncEnumerable<T>
{
public abstract IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default);

public abstract IEnumerator<T> GetEnumerator();

IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
}
Loading