Skip to content
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
127 changes: 127 additions & 0 deletions ECoreNetto.Tools.Tests/Services/VersionCheckerTestFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,72 @@ public async Task Verify_that_when_cancelled_exception_is_thrown()
await Assert.ThatAsync(() => checker.ExecuteAsync(cts.Token), Throws.TypeOf<OperationCanceledException>());
}

[Test]
public void Verify_that_default_url_and_timeout_are_used_when_not_configured()
{
Assert.Multiple(() =>
{
Assert.That(this.versionChecker.ReleasesUrl, Is.EqualTo(VersionChecker.DefaultReleasesUrl));
Assert.That(this.versionChecker.Timeout, Is.EqualTo(TimeSpan.FromSeconds(2)));
});
}

[Test]
public void Verify_that_url_and_timeout_are_configurable()
{
var checker = new VersionChecker(this.httpClientFactory, this.loggerFactory,
"https://example.test/releases/latest", TimeSpan.FromSeconds(5));

Assert.Multiple(() =>
{
Assert.That(checker.ReleasesUrl, Is.EqualTo("https://example.test/releases/latest"));
Assert.That(checker.Timeout, Is.EqualTo(TimeSpan.FromSeconds(5)));
});
}

[Test]
public async Task Verify_that_QueryLatestReleaseAsync_uses_the_configured_url()
{
var recordingHandler = new RecordingHandler();
var factory = new StubHttpClientFactory(new HttpClient(recordingHandler));

var checker = new VersionChecker(factory, this.loggerFactory, "https://example.test/releases/latest");

await checker.QueryLatestReleaseAsync(new CancellationTokenSource().Token);

Assert.That(recordingHandler.LastRequestUri?.ToString(), Is.EqualTo("https://example.test/releases/latest"));
}

[Test]
public async Task Verify_that_ExecuteAsync_handles_a_malformed_tag_name_gracefully()
{
var factory = new StubHttpClientFactory(new HttpClient(new TagNameHandler("not-a-version")));

var checker = new VersionChecker(factory, this.loggerFactory);

await Assert.ThatAsync(() => checker.ExecuteAsync(new CancellationTokenSource().Token), Throws.Nothing);
}

[Test]
public async Task Verify_that_ExecuteAsync_handles_an_empty_tag_name()
{
var factory = new StubHttpClientFactory(new HttpClient(new TagNameHandler("")));

var checker = new VersionChecker(factory, this.loggerFactory);

await Assert.ThatAsync(() => checker.ExecuteAsync(new CancellationTokenSource().Token), Throws.Nothing);
}

[Test]
public async Task Verify_that_ExecuteAsync_handles_a_v_prefixed_tag_name()
{
var factory = new StubHttpClientFactory(new HttpClient(new TagNameHandler("v9.9.9")));

var checker = new VersionChecker(factory, this.loggerFactory);

await Assert.ThatAsync(() => checker.ExecuteAsync(new CancellationTokenSource().Token), Throws.Nothing);
}

/// <summary>
/// Very simple IHttpClientFactory used just for tests.
/// It always returns the HttpClient passed in the constructor.
Expand Down Expand Up @@ -153,5 +219,66 @@ protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage reques
throw new TaskCanceledException();
}
}

/// <summary>
/// An <see cref="IHttpClientFactory"/> that always returns the provided <see cref="HttpClient"/>.
/// </summary>
private sealed class StubHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient client;

public StubHttpClientFactory(HttpClient client)
{
this.client = client;
}

public HttpClient CreateClient(string name)
{
return this.client;
}
}

/// <summary>
/// A handler that records the requested <see cref="Uri"/> and returns a successful release payload.
/// </summary>
private sealed class RecordingHandler : HttpMessageHandler
{
public Uri? LastRequestUri { get; private set; }

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
this.LastRequestUri = request.RequestUri;

const string json = "{\"tag_name\":\"1.2.3\",\"body\":\"notes\",\"html_url\":\"https://example.com\"}";

return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(json)
});
}
}

/// <summary>
/// A handler that returns a release payload with a configurable tag name.
/// </summary>
private sealed class TagNameHandler : HttpMessageHandler
{
private readonly string tagName;

public TagNameHandler(string tagName)
{
this.tagName = tagName;
}

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var json = $"{{\"tag_name\":\"{this.tagName}\",\"body\":\"notes\",\"html_url\":\"https://example.com\"}}";

return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(json)
});
}
}
}
}
82 changes: 74 additions & 8 deletions ECoreNetto.Tools/Services/VersionChecker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
namespace ECoreNetto.Tools.Services
{
using System;
using System.Buffers;
using System.Net.Http;
using System.Reflection;
using System.Text.Json;
Expand All @@ -47,6 +48,17 @@ public class VersionChecker : IVersionChecker
/// </summary>
private readonly IHttpClientFactory httpClientFactory;

/// <summary>
/// The default GitHub API URL used to query the latest release
/// </summary>
public const string DefaultReleasesUrl = "https://api.github.com/repos/STARIONGROUP/EcoreNetto/releases/latest";

/// <summary>
/// The cached <see cref="SearchValues{T}"/> of the SemVer pre-release / build-metadata separators
/// used when trimming a tag name before version parsing.
/// </summary>
private static readonly SearchValues<char> SemVerSuffixSeparators = SearchValues.Create("-+");

/// <summary>
/// Initializes a new instance of the <see cref="VersionChecker"/>
/// </summary>
Expand All @@ -56,12 +68,30 @@ public class VersionChecker : IVersionChecker
/// <param name="loggerFactory">
/// The (injected) <see cref="ILoggerFactory"/> used to set up logging
/// </param>
public VersionChecker(IHttpClientFactory httpClientFactory, ILoggerFactory? loggerFactory = null)
/// <param name="releasesUrl">
/// The GitHub API URL used to query the latest release. When null, <see cref="DefaultReleasesUrl"/> is used.
/// </param>
/// <param name="timeout">
/// The timeout applied to the HTTP request. When null, a default of 2 seconds is used.
/// </param>
public VersionChecker(IHttpClientFactory httpClientFactory, ILoggerFactory? loggerFactory = null, string? releasesUrl = null, TimeSpan? timeout = null)
{
this.httpClientFactory = httpClientFactory;
this.logger = loggerFactory == null ? NullLogger<VersionChecker>.Instance : loggerFactory.CreateLogger<VersionChecker>();
this.ReleasesUrl = releasesUrl ?? DefaultReleasesUrl;
this.Timeout = timeout ?? TimeSpan.FromSeconds(2);
}

/// <summary>
/// Gets the GitHub API URL used to query the latest release
/// </summary>
public string ReleasesUrl { get; }

/// <summary>
/// Gets the timeout applied to the HTTP request
/// </summary>
public TimeSpan Timeout { get; }

/// <summary>
/// Checks for the lastest release
/// </summary>
Expand All @@ -82,7 +112,12 @@ public async Task ExecuteAsync(CancellationToken cancellationToken)
if (payload != null)
{
var currentVersion = Assembly.GetExecutingAssembly().GetName().Version;
var publishedVersion = new Version(payload.TagName);

if (!TryParseVersion(payload.TagName, out var publishedVersion))
{
this.logger.LogWarning("Unable to parse the published version '{TagName}' returned by the GitHub API", payload.TagName);
return;
}

if (currentVersion < publishedVersion)
{
Expand Down Expand Up @@ -120,15 +155,13 @@ public async Task ExecuteAsync(CancellationToken cancellationToken)
public async Task<GitHubRelease?> QueryLatestReleaseAsync(CancellationToken cancellationToken)
{
var httpClient = this.httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(2);

const string requestUrl = "https://api.github.com/repos/STARIONGROUP/EcoreNetto/releases/latest";
httpClient.Timeout = this.Timeout;

try
{
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("ECoreNetto.Tools");

var response = await httpClient.GetAsync(requestUrl, cancellationToken);
var response = await httpClient.GetAsync(this.ReleasesUrl, cancellationToken);

if (response.IsSuccessStatusCode)
{
Expand All @@ -140,14 +173,47 @@ public async Task ExecuteAsync(CancellationToken cancellationToken)
}
catch (TaskCanceledException taskCanceledException)
{
this.logger.LogWarning(taskCanceledException, "Contacting the GitGub API at {Url} timed out", requestUrl);
this.logger.LogWarning(taskCanceledException, "Contacting the GitHub API at {Url} timed out", this.ReleasesUrl);
}
catch (Exception ex)
{
this.logger.LogError(ex, "");
this.logger.LogError(ex, "An error occurred while querying the latest release from the GitHub API at {Url}", this.ReleasesUrl);
}

return null;
}

/// <summary>
/// Attempts to parse the version from a GitHub release tag name, tolerating a leading
/// 'v'/'V' prefix and any SemVer pre-release or build-metadata suffix (e.g. '-beta', '+build').
/// </summary>
/// <param name="tagName">
/// The raw tag name returned by the GitHub API
/// </param>
/// <param name="version">
/// The parsed <see cref="Version"/> when parsing succeeds; otherwise null.
/// </param>
/// <returns>
/// true when the tag name could be parsed into a <see cref="Version"/>, false otherwise.
/// </returns>
private static bool TryParseVersion(string? tagName, out Version? version)
{
version = null;

if (string.IsNullOrWhiteSpace(tagName))
{
return false;
}

var candidate = tagName!.Trim().TrimStart('v', 'V');

var suffixIndex = candidate.AsSpan().IndexOfAny(SemVerSuffixSeparators);
if (suffixIndex >= 0)
{
candidate = candidate.Substring(0, suffixIndex);
}

return Version.TryParse(candidate, out version);
}
}
}
10 changes: 7 additions & 3 deletions ECoreNetto/ModelElement/EObject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
/// <returns>
/// The meta class.
/// </returns>
public EClass EClass()

Check warning on line 88 in ECoreNetto/ModelElement/EObject.cs

View workflow job for this annotation

GitHub Actions / Build

Make 'EClass' a static method.
{
throw new NotImplementedException();
}
Expand All @@ -110,7 +110,7 @@
/// <returns>
/// The <see cref="EStructuralFeature"/> that actually contains the object.
/// </returns>
public EStructuralFeature EContainingFeature()

Check warning on line 113 in ECoreNetto/ModelElement/EObject.cs

View workflow job for this annotation

GitHub Actions / Build

Make 'EContainingFeature' a static method.
{
throw new NotImplementedException();
}
Expand All @@ -123,7 +123,7 @@
/// <returns>
/// The feature that properly contains the object.
/// </returns>
public EReference EContainmentFeature()

Check warning on line 126 in ECoreNetto/ModelElement/EObject.cs

View workflow job for this annotation

GitHub Actions / Build

Make 'EContainmentFeature' a static method.
{
throw new NotImplementedException();
}
Expand All @@ -138,7 +138,7 @@
/// <returns>
/// A list view of the content objects.
/// </returns>
public IEnumerable<EObject> EContents()

Check warning on line 141 in ECoreNetto/ModelElement/EObject.cs

View workflow job for this annotation

GitHub Actions / Build

Make 'EContents' a static method.
{
throw new NotImplementedException();
}
Expand All @@ -149,7 +149,7 @@
/// <returns>
/// A tree iterator that iterates over all contents.
/// </returns>
public IEnumerable<EObject> EAllContents()

Check warning on line 152 in ECoreNetto/ModelElement/EObject.cs

View workflow job for this annotation

GitHub Actions / Build

Make 'EAllContents' a static method.
{
throw new NotImplementedException();
}
Expand All @@ -165,7 +165,7 @@
/// <returns>
/// true if this object is a proxy or false, otherwise.
/// </returns>
public bool EIsProxy()

Check warning on line 168 in ECoreNetto/ModelElement/EObject.cs

View workflow job for this annotation

GitHub Actions / Build

Make 'EIsProxy' a static method.
{
throw new NotImplementedException();
}
Expand All @@ -181,7 +181,7 @@
/// <returns>
/// A list view of the cross referenced objects.
/// </returns>
public IEnumerable<EObject> ECrossReferences()

Check warning on line 184 in ECoreNetto/ModelElement/EObject.cs

View workflow job for this annotation

GitHub Actions / Build

Make 'ECrossReferences' a static method.
{
throw new NotImplementedException();
}
Expand Down Expand Up @@ -220,7 +220,7 @@
/// <exception cref="ArgumentException">
/// If the <see cref="EStructuralFeature"/> is not one the meta class's features and is also not affiliated with one of the meta class's features.
/// </exception>
public object EGet(EStructuralFeature feature, bool resolve)

Check warning on line 223 in ECoreNetto/ModelElement/EObject.cs

View workflow job for this annotation

GitHub Actions / Build

Make 'EGet' a static method.
{
throw new NotImplementedException();
}
Expand Down Expand Up @@ -248,7 +248,7 @@
/// <exception cref="InvalidCastException">
/// If there is a type conflict.
/// </exception>
public void ESet(EStructuralFeature feature, object newValue)

Check warning on line 251 in ECoreNetto/ModelElement/EObject.cs

View workflow job for this annotation

GitHub Actions / Build

Make 'ESet' a static method.
{
throw new NotImplementedException();
}
Expand All @@ -272,7 +272,7 @@
/// <exception cref="ArgumentException">
/// If the feature is not one the meta class's <see cref="EStructuralFeature"/>s.
/// </exception>
public bool EIsSet(EStructuralFeature feature)

Check warning on line 275 in ECoreNetto/ModelElement/EObject.cs

View workflow job for this annotation

GitHub Actions / Build

Make 'EIsSet' a static method.
{
throw new NotImplementedException();
}
Expand Down Expand Up @@ -377,6 +377,12 @@
}
}

/// <summary>
/// The names of the reference attributes whose values may contain implicit references
/// that need to be rewritten to point at the current top package.
/// </summary>
private static readonly string[] ReferenceAttributes = { "eType", "eSuperTypes", "eOpposite" };

/// <summary>
/// Process the attribute value and rewrite if required.
/// </summary>
Expand All @@ -391,9 +397,7 @@
/// </returns>
private string ProcessAttributeValue(string attributeName, string attributeValue)
{
var referenceAttributes = new[] { "eType", "eSuperTypes", "eOpposite" };

if (!referenceAttributes.Contains(attributeName))
if (!ReferenceAttributes.Contains(attributeName))
{
// nothing to do
return attributeValue;
Expand Down
Loading