diff --git a/NGitLab.Mock/Clients/RepositoryClient.cs b/NGitLab.Mock/Clients/RepositoryClient.cs index d0ecef6f..66faae3b 100644 --- a/NGitLab.Mock/Clients/RepositoryClient.cs +++ b/NGitLab.Mock/Clients/RepositoryClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -85,6 +85,11 @@ public void GetArchive(Action parser) throw new NotImplementedException(); } + public void GetArchive(Action parser, FileArchiveQuery fileArchiveQuery) + { + throw new NotImplementedException(); + } + public IEnumerable GetCommits(string refName, int maxResults = 0) { using (Context.BeginOperationScope()) diff --git a/NGitLab.Tests/Impl/UtilsTests.cs b/NGitLab.Tests/Impl/UtilsTests.cs index f4df615f..f7b232fa 100644 --- a/NGitLab.Tests/Impl/UtilsTests.cs +++ b/NGitLab.Tests/Impl/UtilsTests.cs @@ -15,4 +15,118 @@ public void AddParameter_ConsidersEnumMemberAttribute(EventAction value, string Assert.That(url, Is.EqualTo($"{basePath}?event_action={expectedQueryParamValue}")); } + + [TestCase] + public void AppendSegmentToUrl_ValueIsNullIncludeSegmentSeparatorFalse_ReturnsUrlWithoutAnyChange() + { + // Arrange + const string basePath = "https://gitlab.org/api/v4/stuff"; + var url = basePath; + var expected = basePath; + + // Act + var actual = NGitLab.Impl.Utils.AppendSegmentToUrl(url, value: null, includeSegmentSeparator: false); + + // Assert + Assert.That(expected, Is.EqualTo(actual)); + } + + [TestCase] + public void AppendSegmentToUrl_ValueIsNullIncludeSegmentSeparatorTrue_ReturnsUrlWithoutAnyChange() + { + // Arrange + const string basePath = "https://gitlab.org/api/v4/stuff"; + var url = basePath; + var expected = basePath; + + // Act + var actual = NGitLab.Impl.Utils.AppendSegmentToUrl(url, value: null, includeSegmentSeparator: true); + + // Assert + Assert.That(expected, Is.EqualTo(actual)); + } + + [TestCase] + public void AppendSegmentToUrl_UrlAlreadyContainsQueryString_ThrowsInvalidOperationException() + { + // Arrange + const string basePath = "https://gitlab.org/api/v4/stuff"; + var url = basePath; + + url = NGitLab.Impl.Utils.AddParameter(url, "param1", "one"); + + // Act and Assert + Assert.That(() => NGitLab.Impl.Utils.AppendSegmentToUrl(url, "segment"), Throws.InvalidOperationException); + } + + [TestCase("https://gitlab.org/api/v4/stuff/", "/segment")] + [TestCase("https://gitlab.org/api/v4/stuff/", "segment")] + [TestCase("https://gitlab.org/api/v4/stuff", "/segment")] + [TestCase("https://gitlab.org/api/v4/stuff", "segment")] + public void AppendSegmentToUrl_IncludeSegmentSeparatorIsTrue_SegmentAppendedWithSeparator(string basePath, string segment) + { + // Arrange + var url = basePath; + var expected = "https://gitlab.org/api/v4/stuff/segment"; + + // Act + var actual = NGitLab.Impl.Utils.AppendSegmentToUrl(url, value: segment, includeSegmentSeparator: true); + + // Assert + Assert.That(expected, Is.EqualTo(actual)); + } + + [TestCase("https://gitlab.org/api/v4/stuff/", "/segment")] + [TestCase("https://gitlab.org/api/v4/stuff/", "segment")] + [TestCase("https://gitlab.org/api/v4/stuff", "/segment")] + [TestCase("https://gitlab.org/api/v4/stuff", "segment")] + public void AppendSegmentToUrl_IncludeSegmentSeparatorIsFalse_SegmentAppendedWithoutSeparator(string basePath, string segment) + { + // Arrange + var url = basePath; + var expected = "https://gitlab.org/api/v4/stuffsegment"; + + // Act + var actual = NGitLab.Impl.Utils.AppendSegmentToUrl(url, value: segment, includeSegmentSeparator: false); + + // Assert + Assert.That(expected, Is.EqualTo(actual)); + } + + [TestCase("https://gitlab.org/api/v4/stuff.bz2", FileArchiveFormat.Bz2)] + [TestCase("https://gitlab.org/api/v4/stuff.gz", FileArchiveFormat.Gz)] + [TestCase("https://gitlab.org/api/v4/stuff.tar", FileArchiveFormat.Tar)] + [TestCase("https://gitlab.org/api/v4/stuff.tar.bz2", FileArchiveFormat.TarBz2)] + [TestCase("https://gitlab.org/api/v4/stuff.tar.gz", FileArchiveFormat.TarGz)] + [TestCase("https://gitlab.org/api/v4/stuff.tb2", FileArchiveFormat.Tb2)] + [TestCase("https://gitlab.org/api/v4/stuff.tbz", FileArchiveFormat.Tbz)] + [TestCase("https://gitlab.org/api/v4/stuff.tbz2", FileArchiveFormat.Tbz2)] + [TestCase("https://gitlab.org/api/v4/stuff.zip", FileArchiveFormat.Zip)] + public void AppendSegmentToUrl_ValueIsEnumWithEnumMemberAttribute_EnumMemberValueAppended(string expected, FileArchiveFormat fileArchiveFormat) + { + // Arrange + const string basePath = "https://gitlab.org/api/v4/stuff"; + var url = basePath; + + // Act + var actual = NGitLab.Impl.Utils.AppendSegmentToUrl(url, value: fileArchiveFormat, includeSegmentSeparator: false); + + // Assert + Assert.That(expected, Is.EqualTo(actual)); + } + + [TestCase("https://gitlab.org/api/v4/stuff/Group", BadgeKind.Group)] + [TestCase("https://gitlab.org/api/v4/stuff/Project", BadgeKind.Project)] + public void AppendSegmentToUrl_ValueIsEnumWithoutEnumMemberAttribute_EnumToStringValueAppended(string expected, BadgeKind badgeKind) + { + // Arrange + const string basePath = "https://gitlab.org/api/v4/stuff"; + var url = basePath; + + // Act + var actual = NGitLab.Impl.Utils.AppendSegmentToUrl(url, value: badgeKind, includeSegmentSeparator: true); + + // Assert + Assert.That(expected, Is.EqualTo(actual)); + } } diff --git a/NGitLab.Tests/RepositoryClient/RepositoryClientTests.cs b/NGitLab.Tests/RepositoryClient/RepositoryClientTests.cs index 85ee5891..bcca5d42 100644 --- a/NGitLab.Tests/RepositoryClient/RepositoryClientTests.cs +++ b/NGitLab.Tests/RepositoryClient/RepositoryClientTests.cs @@ -353,4 +353,160 @@ public async Task GetCommitRefs(CommitRefType type) Assert.That(commitRefs, Is.Not.Empty); } } + + [Test] + [NGitLabRetry] + public async Task GetArchive_NoQuerySpecified_PathConstructedWithNoParameters() + { + // Arrange + using var context = await RepositoryClientTestsContext.CreateAsync(commitCount: 2).ConfigureAwait(false); + + // Act + context.RepositoryClient.GetArchive((stream) => { }); + + // Assert + var requestPathAndQuery = context.Context.LastRequest.RequestUri.PathAndQuery; + + Assert.Multiple(() => + { + Assert.That(requestPathAndQuery, Is.Not.Null); + Assert.That(requestPathAndQuery.EndsWith("/archive", StringComparison.OrdinalIgnoreCase), Is.True); + }); + } + + [Test] + [NGitLabRetry] + public async Task GetArchive_QueryInstanceIsNull_PathConstructedWithNoParameters() + { + // Arrange + using var context = await RepositoryClientTestsContext.CreateAsync(commitCount: 2).ConfigureAwait(false); + var firstCommitId = context.Commits[0].Id.ToString(); + + // Act + context.RepositoryClient.GetArchive((stream) => { }, fileArchiveQuery: null); + + // Assert + var requestPathAndQuery = context.Context.LastRequest.RequestUri.PathAndQuery; + + Assert.Multiple(() => + { + Assert.That(requestPathAndQuery, Is.Not.Null); + Assert.That(requestPathAndQuery.EndsWith("/archive", StringComparison.OrdinalIgnoreCase), Is.True); + }); + } + + [TestCase(null, "")] + [TestCase(FileArchiveFormat.Bz2, ".bz2")] + [TestCase(FileArchiveFormat.Gz, ".gz")] + [TestCase(FileArchiveFormat.Tar, ".tar")] + [TestCase(FileArchiveFormat.TarBz2, ".tar.bz2")] + [TestCase(FileArchiveFormat.TarGz, ".tar.gz")] + [TestCase(FileArchiveFormat.Tb2, ".tb2")] + [TestCase(FileArchiveFormat.Tbz2, ".tbz2")] + [TestCase(FileArchiveFormat.Zip, ".zip")] + [NGitLabRetry] + public async Task GetArchive_QuerySpecifiesFormatValue_ArchiveExtensionPassedCorrectly( + FileArchiveFormat? archiveFormat, string expectedExtension) + { + // Arrange + using var context = await RepositoryClientTestsContext.CreateAsync(commitCount: 2); + var fileArchiveQuery = new FileArchiveQuery + { + Format = archiveFormat, + }; + + // Act + context.RepositoryClient.GetArchive((stream) => { }, fileArchiveQuery); + + // Assert + var requestPathAndQuery = context.Context.LastRequest.RequestUri.PathAndQuery; + + Assert.Multiple(() => + { + Assert.That(requestPathAndQuery, Is.Not.Null); + Assert.That(requestPathAndQuery.EndsWith($"/archive{expectedExtension}", + StringComparison.OrdinalIgnoreCase), Is.True); + }); + } + + [Test] + [NGitLabRetry] + public async Task GetArchive_QuerySpecifiesRevision_ShaValuePassedCorrectly() + { + // Arrange + using var context = await RepositoryClientTestsContext.CreateAsync(commitCount: 2); + var firstCommitId = context.Commits[0].Id.ToString(); + var fileArchiveQuery = new FileArchiveQuery + { + Ref = firstCommitId, + }; + + // Act + context.RepositoryClient.GetArchive((stream) => { }, fileArchiveQuery); + + // Assert + var requestPathAndQuery = context.Context.LastRequest.RequestUri.PathAndQuery; + + Assert.Multiple(() => + { + Assert.That(requestPathAndQuery, Is.Not.Null); + Assert.That(requestPathAndQuery.Contains($"sha={firstCommitId}", + StringComparison.OrdinalIgnoreCase), Is.True); + }); + } + + [Test] + [NGitLabRetry] + public async Task GetArchive_QuerySpecifiesPath_PathValuePassedCorrectly() + { + // Arrange + using var context = await RepositoryClientTestsContext.CreateAsync(commitCount: 2); + var path = RepositoryClientTestsContext.SubfolderName; + var fileArchiveQuery = new FileArchiveQuery + { + Path = path, + }; + + // Act + context.RepositoryClient.GetArchive((stream) => { }, fileArchiveQuery); + + // Assert + var requestPathAndQuery = context.Context.LastRequest.RequestUri.PathAndQuery; + + Assert.Multiple(() => + { + Assert.That(requestPathAndQuery, Is.Not.Null); + Assert.That(requestPathAndQuery.Contains($"path={path}", StringComparison.OrdinalIgnoreCase), Is.True); + }); + } + + [Test] + [NGitLabRetry] + public async Task GetArchive_QuerySpecifiesAllParameters_AllParametersPassedCorrectly() + { + // Arrange + using var context = await RepositoryClientTestsContext.CreateAsync(commitCount: 2); + var firstCommitId = context.Commits[0].Id.ToString(); + var path = RepositoryClientTestsContext.SubfolderName; + var fileArchiveQuery = new FileArchiveQuery + { + Format = FileArchiveFormat.Zip, + Path = path, + Ref = firstCommitId, + }; + + // Act + context.RepositoryClient.GetArchive((stream) => { }, fileArchiveQuery); + + // Assert + var requestPathAndQuery = context.Context.LastRequest.RequestUri.PathAndQuery; + + Assert.Multiple(() => + { + Assert.That(requestPathAndQuery, Is.Not.Null); + Assert.That(requestPathAndQuery.Contains($"/archive.zip", StringComparison.OrdinalIgnoreCase), Is.True); + Assert.That(requestPathAndQuery.Contains($"path={path}", StringComparison.OrdinalIgnoreCase), Is.True); + Assert.That(requestPathAndQuery.Contains($"sha={firstCommitId}", StringComparison.OrdinalIgnoreCase), Is.True); + }); + } } diff --git a/NGitLab/IRepositoryClient.cs b/NGitLab/IRepositoryClient.cs index 1734ff8a..a57a0512 100644 --- a/NGitLab/IRepositoryClient.cs +++ b/NGitLab/IRepositoryClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using NGitLab.Models; @@ -23,6 +23,8 @@ public interface IRepositoryClient void GetArchive(Action parser); + void GetArchive(Action parser, FileArchiveQuery fileArchiveQuery); + IEnumerable Commits { get; } IContributorClient Contributors { get; } diff --git a/NGitLab/Impl/RepositoryClient.cs b/NGitLab/Impl/RepositoryClient.cs index c9b00a12..3a2854f8 100644 --- a/NGitLab/Impl/RepositoryClient.cs +++ b/NGitLab/Impl/RepositoryClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel; using System.Globalization; @@ -55,9 +55,23 @@ public void GetRawBlob(string sha, Action parser) _api.Get().Stream(_repoPath + "/raw_blobs/" + sha, parser); } - public void GetArchive(Action parser) + public void GetArchive(Action parser) => GetArchive(parser, fileArchiveQuery: null); + + public void GetArchive(Action parser, FileArchiveQuery fileArchiveQuery) { - _api.Get().Stream(_repoPath + "/archive", parser); + var url = Utils.AppendSegmentToUrl(_repoPath, "/archive"); + + if (fileArchiveQuery != null) + { + // If a particular archive file format is requested, it is appended to the path directly as follows: + // /project/123/repository/archive.zip + // /project/123/repository/archive.tar + url = Utils.AppendSegmentToUrl(url, fileArchiveQuery.Format, includeSegmentSeparator: false); + url = Utils.AddParameter(url, "path", fileArchiveQuery.Path); + url = Utils.AddParameter(url, "sha", fileArchiveQuery.Ref); + } + + _api.Get().Stream(url, parser); } public IEnumerable Commits => _api.Get().GetAll(_repoPath + $"/commits?per_page={GetCommitsRequest.DefaultPerPage}"); diff --git a/NGitLab/Impl/Utils.cs b/NGitLab/Impl/Utils.cs index fd1180ed..7c217cba 100644 --- a/NGitLab/Impl/Utils.cs +++ b/NGitLab/Impl/Utils.cs @@ -8,26 +8,18 @@ namespace NGitLab.Impl; internal static class Utils { + private const char UrlSegmentSeparatorChar = '/'; + public static string AddParameter(string url, string parameterName, T value) { if (value is null) return url; var valueString = value.ToString(); - var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); - if (type.IsEnum) - { - var enumField = type.GetFields().FirstOrDefault(f => string.Equals(f.Name, valueString, StringComparison.Ordinal)); - if (enumField is not null) - { - var enumMemberValue = enumField.GetCustomAttributes(typeof(EnumMemberAttribute), inherit: true) - .Cast() - .FirstOrDefault()? - .Value; - if (enumMemberValue is not null) - return AddParameterInternal(url, parameterName, enumMemberValue); - } - } + var enumMemberValue = GetEnumMemberValue(valueString); + + if (enumMemberValue is not null) + return AddParameterInternal(url, parameterName, enumMemberValue); return AddParameterInternal(url, parameterName, valueString); } @@ -96,6 +88,31 @@ public static string AddPageParams(string url, int? page, int? perPage) return url; } + public static string AppendSegmentToUrl(string url, T value, bool includeSegmentSeparator = true) + { + if (value is null) + return url; + + // Don't allow segments to a url which already has parameters present + if (url.Contains('?')) + throw new InvalidOperationException("Cannot append segment to url which already has parameters present"); + + var valueString = value.ToString(); + var enumMemberValue = GetEnumMemberValue(valueString); + + if (enumMemberValue is not null) + valueString = enumMemberValue; + + url = url.TrimEnd(UrlSegmentSeparatorChar); + valueString = valueString.TrimStart(UrlSegmentSeparatorChar); + valueString = WebUtility.UrlEncode(valueString); + + if (includeSegmentSeparator) + return $"{url}{UrlSegmentSeparatorChar}{valueString}"; + + return $"{url}{valueString}"; + } + private static string AddParameterInternal(string url, string parameterName, string stringValue) { var @operator = !url.Contains("?") ? "?" : "&"; @@ -103,4 +120,23 @@ private static string AddParameterInternal(string url, string parameterName, str var parameter = $"{@operator}{parameterName}={formattedValue}"; return url + parameter; } + + private static string GetEnumMemberValue(string valueString) + { + string enumMemberValue = null; + var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); + if (type.IsEnum) + { + var enumField = type.GetFields().FirstOrDefault(f => string.Equals(f.Name, valueString, StringComparison.Ordinal)); + if (enumField is not null) + { + enumMemberValue = enumField.GetCustomAttributes(typeof(EnumMemberAttribute), inherit: true) + .Cast() + .FirstOrDefault()? + .Value; + } + } + + return enumMemberValue; + } } diff --git a/NGitLab/Models/FileArchiveFormat.cs b/NGitLab/Models/FileArchiveFormat.cs new file mode 100644 index 00000000..634ce24a --- /dev/null +++ b/NGitLab/Models/FileArchiveFormat.cs @@ -0,0 +1,33 @@ +using System.Runtime.Serialization; + +namespace NGitLab.Models; + +public enum FileArchiveFormat +{ + [EnumMember(Value = ".bz2")] + Bz2, + + [EnumMember(Value = ".gz")] + Gz, + + [EnumMember(Value = ".tar")] + Tar, + + [EnumMember(Value = ".tar.bz2")] + TarBz2, + + [EnumMember(Value = ".tar.gz")] + TarGz, + + [EnumMember(Value = ".tb2")] + Tb2, + + [EnumMember(Value = ".tbz")] + Tbz, + + [EnumMember(Value = ".tbz2")] + Tbz2, + + [EnumMember(Value = ".zip")] + Zip, +} diff --git a/NGitLab/Models/FileArchiveQuery.cs b/NGitLab/Models/FileArchiveQuery.cs new file mode 100644 index 00000000..c6c87ea9 --- /dev/null +++ b/NGitLab/Models/FileArchiveQuery.cs @@ -0,0 +1,12 @@ +namespace NGitLab.Models; + +public sealed class FileArchiveQuery +{ + public FileArchiveFormat? Format { get; set; } + + // This property is named Ref because even though the query string parameter key is 'sha' it accepts any ref + // i.e. branch name, sha, tag + public string Ref { get; set; } + + public string Path { get; set; } +} diff --git a/NGitLab/PublicAPI.Unshipped.txt b/NGitLab/PublicAPI.Unshipped.txt index d1f8634f..1997c980 100644 --- a/NGitLab/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI.Unshipped.txt @@ -874,6 +874,7 @@ NGitLab.Impl.RepositoryClient.Compare(NGitLab.Models.CompareQuery query) -> NGit NGitLab.Impl.RepositoryClient.Contributors.get -> NGitLab.IContributorClient NGitLab.Impl.RepositoryClient.Files.get -> NGitLab.IFilesClient NGitLab.Impl.RepositoryClient.GetArchive(System.Action parser) -> void +NGitLab.Impl.RepositoryClient.GetArchive(System.Action parser, NGitLab.Models.FileArchiveQuery fileArchiveQuery) -> void NGitLab.Impl.RepositoryClient.GetCommit(NGitLab.Sha1 sha) -> NGitLab.Models.Commit NGitLab.Impl.RepositoryClient.GetCommitDiff(NGitLab.Sha1 sha) -> System.Collections.Generic.IEnumerable NGitLab.Impl.RepositoryClient.GetCommitRefs(NGitLab.Sha1 sha, NGitLab.Models.CommitRefType type = NGitLab.Models.CommitRefType.All) -> System.Collections.Generic.IEnumerable @@ -1095,6 +1096,7 @@ NGitLab.IRepositoryClient.Compare(NGitLab.Models.CompareQuery query) -> NGitLab. NGitLab.IRepositoryClient.Contributors.get -> NGitLab.IContributorClient NGitLab.IRepositoryClient.Files.get -> NGitLab.IFilesClient NGitLab.IRepositoryClient.GetArchive(System.Action parser) -> void +NGitLab.IRepositoryClient.GetArchive(System.Action parser, NGitLab.Models.FileArchiveQuery fileArchiveQuery) -> void NGitLab.IRepositoryClient.GetCommit(NGitLab.Sha1 sha) -> NGitLab.Models.Commit NGitLab.IRepositoryClient.GetCommitDiff(NGitLab.Sha1 sha) -> System.Collections.Generic.IEnumerable NGitLab.IRepositoryClient.GetCommitRefs(NGitLab.Sha1 sha, NGitLab.Models.CommitRefType type = NGitLab.Models.CommitRefType.All) -> System.Collections.Generic.IEnumerable @@ -1776,6 +1778,24 @@ NGitLab.Models.EventTargetType.Note = 4 -> NGitLab.Models.EventTargetType NGitLab.Models.EventTargetType.Project = 5 -> NGitLab.Models.EventTargetType NGitLab.Models.EventTargetType.Snippet = 6 -> NGitLab.Models.EventTargetType NGitLab.Models.EventTargetType.User = 7 -> NGitLab.Models.EventTargetType +NGitLab.Models.FileArchiveFormat +NGitLab.Models.FileArchiveFormat.Bz2 = 0 -> NGitLab.Models.FileArchiveFormat +NGitLab.Models.FileArchiveFormat.Gz = 1 -> NGitLab.Models.FileArchiveFormat +NGitLab.Models.FileArchiveFormat.Tar = 2 -> NGitLab.Models.FileArchiveFormat +NGitLab.Models.FileArchiveFormat.TarBz2 = 3 -> NGitLab.Models.FileArchiveFormat +NGitLab.Models.FileArchiveFormat.TarGz = 4 -> NGitLab.Models.FileArchiveFormat +NGitLab.Models.FileArchiveFormat.Tb2 = 5 -> NGitLab.Models.FileArchiveFormat +NGitLab.Models.FileArchiveFormat.Tbz = 6 -> NGitLab.Models.FileArchiveFormat +NGitLab.Models.FileArchiveFormat.Tbz2 = 7 -> NGitLab.Models.FileArchiveFormat +NGitLab.Models.FileArchiveFormat.Zip = 8 -> NGitLab.Models.FileArchiveFormat +NGitLab.Models.FileArchiveQuery +NGitLab.Models.FileArchiveQuery.FileArchiveQuery() -> void +NGitLab.Models.FileArchiveQuery.Format.get -> NGitLab.Models.FileArchiveFormat? +NGitLab.Models.FileArchiveQuery.Format.set -> void +NGitLab.Models.FileArchiveQuery.Path.get -> string +NGitLab.Models.FileArchiveQuery.Path.set -> void +NGitLab.Models.FileArchiveQuery.Ref.get -> string +NGitLab.Models.FileArchiveQuery.Ref.set -> void NGitLab.Models.FileData NGitLab.Models.FileData.BlobId -> string NGitLab.Models.FileData.CommitId -> string